mirror of
https://github.com/arsenetar/dupeguru.git
synced 2026-01-25 08:01:39 +00:00
Compare commits
310 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
4a40b346a4
|
|||
|
035cdc23b1
|
|||
|
fbdb333457
|
|||
|
e36aab177c
|
|||
|
77116ba94b
|
|||
|
d7f79aefd2
|
|||
|
4c939f379c
|
|||
|
d098fe2281
|
|||
| 09cfbad38d | |||
|
|
528dedd813 | ||
| b30d67b834 | |||
|
|
3e6e74e2a9 | ||
|
|
b919b3ddc8 | ||
|
|
be3862fa8f | ||
|
|
da09920553 | ||
|
|
2baba3bfa0 | ||
|
a659a70dbe
|
|||
|
c9e48a5e3b
|
|||
|
68711162d1
|
|||
|
0b0fd36629
|
|||
|
bf5d151799
|
|||
|
e29a427caf
|
|||
|
95ccbad92b
|
|||
| 421a58a61c | |||
|
|
b5a3313f80 | ||
|
|
116ac18e13 | ||
|
|
32dcd90b50 | ||
|
|
c2fef8d624 | ||
|
fd0adc77b3
|
|||
|
6a03e1e399
|
|||
|
ae51842007
|
|||
| ab6acd9e88 | |||
|
6a2c1eb293
|
|||
| 7b4c31d262 | |||
|
|
5553414205 | ||
|
|
b138dfad33 | ||
| 701e6d4bb2 | |||
|
b44d1652b6
|
|||
|
|
990eaaa797 | ||
|
|
348ce95f83 | ||
|
|
3255bdf0a2 | ||
|
|
1058247b44 | ||
|
|
7414f82e28 | ||
|
|
8105bb709f | ||
|
ec628751af
|
|||
|
|
288023d03e | ||
|
|
7740dfca0e | ||
|
1e12ad8d4c
|
|||
|
|
c1d94d6771 | ||
| 7f691d3c31 | |||
|
|
a93bd3aeee | ||
|
|
39d353d073 | ||
|
|
b76e86686a | ||
|
|
b5f59d27c9 | ||
|
|
f0d3dec517 | ||
|
|
90c7c067b7 | ||
|
c8cfa954d5
|
|||
|
|
e533a396fb | ||
|
|
4b4cc04e87 | ||
|
e822a67b38
|
|||
|
c30c3400d4
|
|||
|
d539517525
|
|||
|
|
07eba09ec2 | ||
|
|
7f19647e4b | ||
| bf7d720126 | |||
|
|
6bc619055e | ||
|
|
452d1604bd | ||
|
|
680cb581c1 | ||
| 1d05f8910d | |||
|
|
bd09b30468 | ||
| 8d9933d035 | |||
|
|
cf5ba038d7 | ||
|
|
59ce740369 | ||
|
|
92feba5f08 | ||
|
|
a265b71d36 | ||
| 8d26c921a0 | |||
|
|
32d66cd19b | ||
|
|
735ba2fd0e | ||
|
|
b16b6ecf4d | ||
|
|
2875448c71 | ||
|
|
51b76385c0 | ||
|
|
b9f8dd6ea0 | ||
|
|
6623b04403 | ||
|
|
424d34a7ed | ||
|
|
2a032d24bc | ||
|
|
b8af2a4eb5 | ||
|
|
a55e02b36d | ||
|
|
18c933b4bf | ||
|
|
ea11a566af | ||
|
|
584e9c92d9 | ||
|
|
4a1641e39d | ||
|
|
26d18945b1 | ||
|
|
3382bd5e5b | ||
|
|
9f223f3964 | ||
|
|
2eaf7e7893 | ||
|
|
a26de27c47 | ||
|
|
21e62b7374 | ||
| 9e6b117327 | |||
|
|
3333d26557 | ||
|
|
6e81042989 | ||
|
|
470307aa3c | ||
|
|
089f00adb8 | ||
|
|
76fbfc2822 | ||
|
|
866bf996cf | ||
|
|
0104d8922c | ||
|
|
fbd7c4fe5f | ||
|
|
de5e61293b | ||
|
|
a3e402a3af | ||
|
|
056fa819cc | ||
|
|
3be1ee87c6 | ||
|
|
628d772766 | ||
|
|
acdeb01206 | ||
| ab402d4024 | |||
|
|
d2cdcc989b | ||
|
|
2620d0080c | ||
|
|
63a9f00552 | ||
|
|
87f9317805 | ||
|
|
a542168a0d | ||
|
|
86e1b55b02 | ||
|
|
1b3b40543b | ||
|
|
dd6ffe08d7 | ||
|
|
11254381a8 | ||
|
|
23642815f6 | ||
|
|
7e4f371841 | ||
|
|
9b8637ffc8 | ||
|
|
79613f9b1e | ||
|
|
fa54e93236 | ||
|
|
8fb82ae3d8 | ||
|
|
eab5003e61 | ||
|
|
da8c493c9f | ||
|
|
9795f14176 | ||
|
|
1937120ad7 | ||
|
|
1823575af4 | ||
|
|
7dc9f25b06 | ||
| 5502b48089 | |||
| f02b66fd54 | |||
| d2235f9bc9 | |||
|
|
5f5f9232c1 | ||
| c36fd84512 | |||
|
|
63b2f95cfa | ||
|
|
d193e1fd12 | ||
|
|
f0adf35db4 | ||
|
|
49a1beb225 | ||
|
|
f19b5d6ea6 | ||
|
|
730fadf63f | ||
|
|
9ae0d7e5cf | ||
| 1167519730 | |||
|
|
cf64565012 | ||
|
|
298f659f6e | ||
|
|
3539263437 | ||
|
|
6213d50670 | ||
|
|
ac941037ff | ||
|
|
733b3b0ed4 | ||
|
|
9168d72f38 | ||
|
|
75621cc816 | ||
|
|
3c816b2f11 | ||
|
|
85d6e05cd4 | ||
|
|
66127d025e | ||
|
|
58c675d1fa | ||
|
|
95b8406c7b | ||
|
|
3eddeb6aeb | ||
|
|
56912a7108 | ||
|
|
7ab299874d | ||
|
|
a4265e7fff | ||
|
|
db228ec8a3 | ||
|
|
61fc4f07ae | ||
|
|
b0a256f0d4 | ||
|
|
4ee9479a5f | ||
|
|
e7b3252534 | ||
|
|
36ab84423a | ||
|
|
370b582c9b | ||
|
|
9f15139d5f | ||
|
|
011939f5ee | ||
|
|
977c20f7c4 | ||
|
|
aa79b31aae | ||
|
|
970bb5e19d | ||
|
|
a706d0ebe5 | ||
|
|
b7abcf2989 | ||
|
|
8103cb3664 | ||
|
|
c3797918d2 | ||
|
|
60ddb9b596 | ||
|
|
a29f3fb407 | ||
|
|
c6162914ed | ||
|
|
02bd822ca0 | ||
|
|
ea6197626b | ||
|
|
468a736bfb | ||
|
|
f42df12a29 | ||
|
|
9b48e1851d | ||
|
|
c973224fa4 | ||
|
092cf1471b
|
|||
|
|
5cbe342d5b | ||
|
4f252480d3
|
|||
|
5cc439d846
|
|||
|
|
c6f5031dd8 | ||
|
|
eb6946343b | ||
|
|
e41a6b878c | ||
|
ee2671a5f3
|
|||
|
e05c72ad8c
|
|||
| 7658cdafbc | |||
|
ecf005fad0
|
|||
| de0542d2a8 | |||
|
|
bcb26507fe | ||
| c35db7f698 | |||
|
d2193328a7
|
|||
|
|
ed64428c80 | ||
|
|
e89156e55c | ||
|
4c9309ea9c
|
|||
| 1c00331bc2 | |||
| 427e32f406 | |||
|
|
b048fa5968 | ||
| d5a6ca7488 | |||
|
|
d15dea7aa0 | ||
|
|
ccb1c75f22 | ||
|
|
dffbed8e22 | ||
|
|
50ce010212 | ||
|
|
0e8cd32a6e | ||
|
|
ea191a8924 | ||
| 6abcedddda | |||
| debf309a9a | |||
|
|
4b1c925ab1 | ||
|
|
1c0990f610 | ||
|
|
89f2dc3b15 | ||
|
|
ffae58040d | ||
| 0cc1cb4cb8 | |||
|
|
dab762f05e | ||
| c4a6958ef0 | |||
| 98c6f12b39 | |||
| 5d21454789 | |||
| 3e4fe5b765 | |||
|
|
bd0f53bcbe | ||
|
|
d820fcc7b1 | ||
|
de8a0a21b2
|
|||
|
7ba8aa3514
|
|||
|
359d6498f7
|
|||
|
2ea02bd7b5
|
|||
| 8506d482af | |||
|
411d0d6e4a
|
|||
|
95ff6b6b76
|
|||
|
334f6fe989
|
|||
|
|
080bb8935c | ||
| ad2a07a289 | |||
|
|
c61a7f1aaf | ||
|
|
f536f32b19 | ||
|
|
8cdff7b48c | ||
|
|
718e99e880 | ||
|
|
3c2ef97ee2 | ||
|
|
2f439d0fc7 | ||
|
|
4f234f272f | ||
|
|
18acaae888 | ||
|
|
be7d558dfe | ||
|
|
0b12236537 | ||
|
|
ed2a0bcd4d | ||
|
11e57b0316
|
|||
|
c661905350
|
|||
|
d819719eca
|
|||
| 08722a30f2 | |||
|
a1cc0fe946
|
|||
| 2a2c0061f1 | |||
|
15bfe059c7
|
|||
| a1cacbe72b | |||
| 0a5db4c5c1 | |||
|
1b879259a4
|
|||
|
136342f7d0
|
|||
| 9eb15509c1 | |||
|
dde2c9bf8f
|
|||
| f7e20c8aa6 | |||
|
1a04f6ee86
|
|||
|
bd3d47bf19
|
|||
|
f953bc4af4
|
|||
| 10ac536c3b | |||
|
ab9703b86e
|
|||
|
79b97311e9
|
|||
| 48936b53a8 | |||
|
|
9bf1887109 | ||
| 30b6e5c68d | |||
| 20202d8dfa | |||
| 6c6271bc69 | |||
| f349f6a9b9 | |||
|
|
afe1d4ed2e | ||
|
|
c37037ca4a | ||
| 445f51d595 | |||
| 6132d7c211 | |||
|
|
79adbfd4f2 | ||
|
|
45b907a529 | ||
|
|
d5fef949e9 | ||
|
|
899a42f6a9 | ||
|
|
93a3978747 | ||
|
|
5d15cd4c97 | ||
|
|
7936339909 | ||
|
|
2f31dc7aab | ||
|
|
a6b1e6e9ab | ||
| 8cd0ef4c2b | |||
|
|
50e26928f4 | ||
|
|
84011fb46d | ||
|
|
8861f6296e | ||
|
|
35ea499857 | ||
|
|
a82a19e074 | ||
|
|
e72cf917f1 | ||
|
|
245ed0ddec | ||
|
|
f51f94e03d | ||
|
|
6a28017c49 | ||
|
|
dc6933c90c | ||
|
|
e0281dd740 | ||
|
|
79e99db1d3 | ||
|
|
76cc2000ab | ||
|
|
e4b6e12d4c | ||
|
|
c58a4817ca | ||
|
|
f7adb5f11e | ||
|
|
c43044ea4c | ||
|
|
cc01e8eb09 |
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. Windows 10 / OSX 10.15 / Ubuntu 20.04 / Arch Linux]
|
||||
- Version [e.g. 4.1.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. You may include the debug log although it is normally best to attach it as a file.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -9,7 +9,7 @@ __pycache__
|
||||
|
||||
build
|
||||
dist
|
||||
env
|
||||
env*
|
||||
/deps
|
||||
cocoa/autogen
|
||||
|
||||
@@ -19,3 +19,10 @@ cocoa/autogen
|
||||
/qt/*_rc.py
|
||||
/help/*/conf.py
|
||||
/help/*/changelog.rst
|
||||
/transifex
|
||||
|
||||
*.pyd
|
||||
*.exe
|
||||
*.spec
|
||||
|
||||
.vscode
|
||||
|
||||
12
.gitmodules
vendored
12
.gitmodules
vendored
@@ -1,12 +0,0 @@
|
||||
[submodule "qtlib"]
|
||||
path = qtlib
|
||||
url = https://github.com/hsoft/qtlib.git
|
||||
[submodule "hscommon"]
|
||||
path = hscommon
|
||||
url = https://github.com/hsoft/hscommon.git
|
||||
[submodule "cocoalib"]
|
||||
path = cocoalib
|
||||
url = https://github.com/hsoft/cocoalib.git
|
||||
[submodule "cocoa/Sparkle"]
|
||||
path = cocoa/Sparkle
|
||||
url = https://github.com/sparkle-project/Sparkle.git
|
||||
27
.travis.yml
Normal file
27
.travis.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
sudo: false
|
||||
language: python
|
||||
install:
|
||||
- pip3 install -r requirements.txt -r requirements-extra.txt
|
||||
script: tox
|
||||
matrix:
|
||||
include:
|
||||
- os: "linux"
|
||||
dist: "xenial"
|
||||
python: "3.6"
|
||||
- os: "linux"
|
||||
dist: "xenial"
|
||||
python: "3.7"
|
||||
- os: "linux"
|
||||
dist: "focal"
|
||||
python: "3.8"
|
||||
- os: "linux"
|
||||
dist: "focal"
|
||||
python: "3.9"
|
||||
- os: "windows"
|
||||
language: shell
|
||||
python: "3.8"
|
||||
env: "PATH=/c/python38:/c/python38/Scripts:$PATH"
|
||||
before_install:
|
||||
- choco install python --version=3.8.6
|
||||
- cp /c/python38/python.exe /c/python38/python3.exe
|
||||
script: tox -e py38
|
||||
2
CREDITS
2
CREDITS
@@ -1,6 +1,8 @@
|
||||
To know who contributed to dupeGuru, you can look at the commit log, but not all contributions
|
||||
result in a commit. This file lists contributors who don't necessarily appear in the commit log.
|
||||
|
||||
* Jason Cho, Exchange icon
|
||||
* schollidesign (https://findicons.com/pack/1035/human_o2), Zoom-in, Zoom-out, Zoom-best-fit, Zoom-original icons
|
||||
* Jérôme Cantin, Main icon
|
||||
* Gregor Tätzner, German localization
|
||||
* Frank Weber, German localization
|
||||
|
||||
122
Makefile
122
Makefile
@@ -1,13 +1,39 @@
|
||||
PYTHON ?= python3
|
||||
REQ_MINOR_VERSION = 4
|
||||
PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)")
|
||||
PYRCC5 ?= pyrcc5
|
||||
REQ_MINOR_VERSION = 6
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
# Window compatability via Msys2
|
||||
# - venv creates Scripts instead of bin
|
||||
# - compile generates .pyd instead of .so
|
||||
# - venv with --sytem-site-packages has issues on windows as well...
|
||||
|
||||
ifeq ($(shell ${PYTHON} -c "import platform; print(platform.system())"), Windows)
|
||||
BIN = Scripts
|
||||
SO = *.pyd
|
||||
VENV_OPTIONS =
|
||||
else
|
||||
BIN = bin
|
||||
SO = *.so
|
||||
VENV_OPTIONS = --system-site-packages
|
||||
endif
|
||||
|
||||
# Set this variable if all dependencies are already met on the system. We will then avoid the
|
||||
# whole vitualenv creation and pip install dance.
|
||||
NO_VENV ?=
|
||||
|
||||
ifdef NO_VENV
|
||||
VENV_PYTHON = $(PYTHON)
|
||||
else
|
||||
VENV_PYTHON = ./env/$(BIN)/python
|
||||
endif
|
||||
|
||||
# If you're installing into a path that is not going to be the final path prefix (such as a
|
||||
# sandbox), set DESTDIR to that path.
|
||||
|
||||
# Our build scripts are not very "make like" yet and perform their task in a bundle. For now, we
|
||||
# use one of each file to act as a representative, a target, of these groups.
|
||||
submodules_target = hscommon/__init__.py
|
||||
|
||||
packages = hscommon qtlib core qt
|
||||
localedirs = $(wildcard locale/*/LC_MESSAGES)
|
||||
@@ -17,76 +43,59 @@ mofiles = $(patsubst %.po,%.mo,$(pofiles))
|
||||
vpath %.po $(localedirs)
|
||||
vpath %.mo $(localedirs)
|
||||
|
||||
all : | run.py
|
||||
all: | env i18n modules qt/dg_rc.py
|
||||
@echo "Build complete! You can run dupeGuru with 'make run'"
|
||||
|
||||
run.py : | env i18n modules qt/dg_rc.py
|
||||
cp qt/run_template.py run.py
|
||||
run:
|
||||
$(VENV_PYTHON) run.py
|
||||
|
||||
run: | run.py
|
||||
./env/bin/python run.py
|
||||
pyc: | env
|
||||
${VENV_PYTHON} -m compileall ${packages}
|
||||
|
||||
pyc:
|
||||
${PYTHON} -m compileall ${packages}
|
||||
|
||||
reqs :
|
||||
@ret=`${PYTHON} -c "import sys; print(int(sys.version_info[:2] >= (3, ${REQ_MINOR_VERSION})))"`; \
|
||||
if [ $${ret} -ne 1 ]; then \
|
||||
echo "Python 3.${REQ_MINOR_VERSION}+ required. Aborting."; \
|
||||
exit 1; \
|
||||
fi
|
||||
reqs:
|
||||
ifneq ($(shell test $(PYTHON_VERSION_MINOR) -gt $(REQ_MINOR_VERSION); echo $$?),0)
|
||||
$(error "Python 3.${REQ_MINOR_VERSION}+ required. Aborting.")
|
||||
endif
|
||||
ifndef NO_VENV
|
||||
@${PYTHON} -m venv -h > /dev/null || \
|
||||
echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv."
|
||||
endif
|
||||
@${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \
|
||||
{ echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
|
||||
|
||||
# Ensure that submodules are initialized
|
||||
$(submodules_target) :
|
||||
git submodule init
|
||||
git submodule update
|
||||
|
||||
env : | $(submodules_target) reqs
|
||||
env: | reqs
|
||||
ifndef NO_VENV
|
||||
@echo "Creating our virtualenv"
|
||||
${PYTHON} -m venv env --system-site-packages
|
||||
./env/bin/python -m pip install -r requirements.txt
|
||||
${PYTHON} -m venv env
|
||||
$(VENV_PYTHON) -m pip install -r requirements.txt
|
||||
# We can't use the "--system-site-packages" flag on creation because otherwise we end up with
|
||||
# the system's pip and that messes up things in some cases (notably in Gentoo).
|
||||
${PYTHON} -m venv --upgrade ${VENV_OPTIONS} env
|
||||
endif
|
||||
|
||||
build/help : | env
|
||||
./env/bin/python build.py --doc
|
||||
build/help: | env
|
||||
$(VENV_PYTHON) build.py --doc
|
||||
|
||||
qt/dg_rc.py : qt/dg.qrc
|
||||
pyrcc5 qt/dg.qrc > qt/dg_rc.py
|
||||
qt/dg_rc.py: qt/dg.qrc
|
||||
$(PYRCC5) qt/dg.qrc > qt/dg_rc.py
|
||||
|
||||
i18n: $(mofiles)
|
||||
|
||||
%.mo : %.po
|
||||
%.mo: %.po
|
||||
msgfmt -o $@ $<
|
||||
|
||||
core/pe/_block.*.so : core/pe/modules/block.c core/pe/modules/common.c | env
|
||||
./env/bin/python hscommon/build_ext.py $^ _block
|
||||
mv _block.*.so core/pe
|
||||
modules: | env
|
||||
$(VENV_PYTHON) build.py --modules
|
||||
|
||||
core/pe/_cache.*.so : core/pe/modules/cache.c core/pe/modules/common.c | env
|
||||
./env/bin/python hscommon/build_ext.py $^ _cache
|
||||
mv _cache.*.so core/pe
|
||||
mergepot: | env
|
||||
$(VENV_PYTHON) build.py --mergepot
|
||||
|
||||
qt/pe/_block_qt.*.so : qt/pe/modules/block.c | env
|
||||
./env/bin/python hscommon/build_ext.py $^ _block_qt
|
||||
mv _block_qt.*.so qt/pe
|
||||
normpo: | env
|
||||
$(VENV_PYTHON) build.py --normpo
|
||||
|
||||
modules : core/pe/_block.*.so core/pe/_cache.*.so qt/pe/_block_qt.*.so
|
||||
|
||||
mergepot :
|
||||
./env/bin/python build.py --mergepot
|
||||
|
||||
normpo :
|
||||
./env/bin/python build.py --normpo
|
||||
|
||||
srcpkg :
|
||||
./scripts/srcpkg.sh
|
||||
|
||||
install: build/help | all pyc
|
||||
install: all pyc
|
||||
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
|
||||
cp -rf ${packages} locale build/help ${DESTDIR}${PREFIX}/share/dupeguru
|
||||
cp -rf ${packages} locale ${DESTDIR}${PREFIX}/share/dupeguru
|
||||
cp -f run.py ${DESTDIR}${PREFIX}/share/dupeguru/run.py
|
||||
chmod 755 ${DESTDIR}${PREFIX}/share/dupeguru/run.py
|
||||
mkdir -p ${DESTDIR}${PREFIX}/bin
|
||||
@@ -96,16 +105,19 @@ install: build/help | all pyc
|
||||
mkdir -p ${DESTDIR}${PREFIX}/share/pixmaps
|
||||
cp -f images/dgse_logo_128.png ${DESTDIR}${PREFIX}/share/pixmaps/dupeguru.png
|
||||
|
||||
uninstall :
|
||||
installdocs: build/help
|
||||
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
|
||||
cp -rf build/help ${DESTDIR}${PREFIX}/share/dupeguru
|
||||
|
||||
uninstall:
|
||||
rm -rf "${DESTDIR}${PREFIX}/share/dupeguru"
|
||||
rm -f "${DESTDIR}${PREFIX}/bin/dupeguru"
|
||||
rm -f "${DESTDIR}${PREFIX}/share/applications/dupeguru.desktop"
|
||||
rm -f "${DESTDIR}${PREFIX}/share/pixmaps/dupeguru.png"
|
||||
|
||||
clean:
|
||||
-rm run.py
|
||||
-rm -rf build
|
||||
-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
|
||||
|
||||
169
README.md
169
README.md
@@ -1,155 +1,92 @@
|
||||
# dupeGuru
|
||||
|
||||
[dupeGuru][dupeguru] is a cross-platform (Linux and OS X) GUI tool to find duplicate files in
|
||||
a system. It's written mostly in Python 3 and has the peculiarity of using
|
||||
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
|
||||
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
|
||||
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.
|
||||
|
||||
## Current status: People wanted
|
||||
The Cocoa UI of dupeGuru is hosted in a separate repo: https://github.com/arsenetar/dupeguru-cocoa
|
||||
|
||||
dupeGuru has currently only one maintainer, me. This is a dangerous situation that needs to be
|
||||
corrected.
|
||||
|
||||
The goal is to eventually have another active maintainer, but before we can get there, the project
|
||||
needs more contributors. It is very much lacking on that side right now.
|
||||
|
||||
Whatever your skills, if you are remotely interestested in being a contributor, I'm interested in
|
||||
mentoring you. If that's the case, please refer to [the open ticket on the subject][contrib-issue]
|
||||
and let's get started.
|
||||
|
||||
### Slowed development
|
||||
|
||||
Until I manage to find contributors, I'm slowing the development pace of dupeGuru. I'm not much
|
||||
interested in maintaining it alone, I personally have no use for this app (it's been a *loooong*,
|
||||
time since I had dupe problems :) )
|
||||
|
||||
I don't want to let it die, however, so I will still do normal maintainership, that is, issue
|
||||
triaging, code review, critical bugfixes, releases management.
|
||||
|
||||
But anything non-critical, I'm not going to implement it myself because I see every issue as a
|
||||
contribution opportunity.
|
||||
|
||||
### Windows maintainer wanted
|
||||
|
||||
As [described on my website][nowindows], dupeGuru v4.0 dropped Windows support
|
||||
because there isn't anyone to bear the burden of Windows maintenance. If
|
||||
you're a Windows developer and are interested in taking this task, [don't
|
||||
hesitate to let me know][contrib-issue].
|
||||
|
||||
### OS X maintainer wanted
|
||||
|
||||
My Mac Mini is already a couple of years old and is likely to be my last Apple purchase. When it
|
||||
dies, I will be unable maintain the OS X version of moneyGuru. I've already stopped paying for the
|
||||
Mac Developer membership so I can't sign the apps anymore (in the "official way" I mean. The
|
||||
download is still PGP signed) If you're a Mac developer and are interested in taking this task,
|
||||
[don't hesitate to let me know][contrib-issue].
|
||||
## Current status
|
||||
Still looking for additional help especially with regards to:
|
||||
* OSX maintenance: reproducing bugs & cocoa version, building package with Cocoa UI.
|
||||
* Linux maintenance: reproducing bugs, maintaining PPA repository, Debian package.
|
||||
* Translations: updating missing strings, transifex project at https://www.transifex.com/voltaicideas/dupeguru-1
|
||||
* Documentation: keeping it up-to-date.
|
||||
|
||||
## Contents of this folder
|
||||
|
||||
This folder contains the source for dupeGuru. Its documentation is in `help`, but is also
|
||||
[available online][documentation] in its built form. Here's how this source tree is organised:
|
||||
[available online][documentation] in its built form. Here's how this source tree is organized:
|
||||
|
||||
* core: Contains the core logic code for dupeGuru. It's Python code.
|
||||
* cocoa: UI code for the Cocoa toolkit. It's Objective-C code.
|
||||
* qt: UI code for the Qt toolkit. It's written in Python and uses PyQt.
|
||||
* images: Images used by the different UI codebases.
|
||||
* pkg: Skeleton files required to create different packages
|
||||
* help: Help document, written for Sphinx.
|
||||
* locale: .po files for localisation.
|
||||
|
||||
There are also other sub-folder that comes from external repositories and are part of this repo as
|
||||
git submodules:
|
||||
|
||||
* Sparkle: An auto-update library for the OS X version.
|
||||
* locale: .po files for localization.
|
||||
* hscommon: A collection of helpers used across HS applications.
|
||||
* cocoalib: A collection of helpers used across Cocoa UI codebases of HS applications.
|
||||
* qtlib: A collection of helpers used across Qt UI codebases of HS applications.
|
||||
|
||||
## How to build dupeGuru from source
|
||||
|
||||
### make
|
||||
### Windows & macOS specific additional instructions
|
||||
For windows instructions see the [Windows Instructions](Windows.md).
|
||||
|
||||
If you're on linux, you can build the ap for local development with `make`:
|
||||
For macos instructions (qt version) see the [macOS Instructions](macos.md).
|
||||
|
||||
$ make
|
||||
$ make run
|
||||
### Prerequisites
|
||||
* [Python 3.6+][python]
|
||||
* PyQt5
|
||||
|
||||
The `Makefile` is a recent addition, however. You might have to fallback to the legacy build
|
||||
scripts.
|
||||
### System Setup
|
||||
When running in a linux based environment the following system packages or equivalents are needed to build:
|
||||
* python3-pyqt5
|
||||
* pyqt5-dev-tools (on some systems, see note)
|
||||
* python3-wheel (for hsaudiotag3k)
|
||||
* python3-venv (only if using a virtual environment)
|
||||
* python3-dev
|
||||
* build-essential
|
||||
|
||||
### Legacy build
|
||||
|
||||
If you're on OS X or that if the `make` method didn't work, you can build dupeGuru with the
|
||||
legacy scripts.
|
||||
Note: On some linux systems pyrcc5 is not put on the path when installing python3-pyqt5, this will cause some issues with the resource files (and icons). These systems should have a respective pyqt5-dev-tools package, which should also be installed. The presence of pyrcc5 can be checked with `which pyrcc5`. Debian based systems need the extra package, and Arch does not.
|
||||
|
||||
There's a bootstrap script that will make building very easy. There might be some things that you
|
||||
need to install manually on your system, but the bootstrap script will tell you when what you need
|
||||
to install. You can run the bootstrap with:
|
||||
To create packages the following are also needed:
|
||||
* python3-setuptools
|
||||
* debhelper
|
||||
|
||||
$ ./bootstrap.sh
|
||||
### Building with Make
|
||||
dupeGuru comes with a makefile that can be used to build and run:
|
||||
|
||||
and follow instructions from the script.
|
||||
$ make && make run
|
||||
|
||||
### Prerequisites installation
|
||||
### Building without Make
|
||||
|
||||
Prerequisites are installed through `pip`. However, some of them are not "pip installable" and have
|
||||
to be installed manually.
|
||||
|
||||
* All systems: [Python 3.4+][python]
|
||||
* Mac OS X: OS X 10.10+ with XCode command line tools.
|
||||
* Linux: PyQt5
|
||||
|
||||
On Ubuntu (14.04+), the apt-get command to install all pre-requisites is:
|
||||
|
||||
$ apt-get install python3-dev python3-pyqt5 pyqt5-dev-tools python3-venv
|
||||
|
||||
### OS X and pyenv
|
||||
|
||||
[pyenv][pyenv] is a popular way to manage multiple python versions. However, be aware that dupeGuru
|
||||
will not compile with a pyenv's python unless it's been built with `--enable-framework`. You can do
|
||||
this with:
|
||||
|
||||
$ env PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install 3.4.3
|
||||
|
||||
### Setting up the virtual environment
|
||||
|
||||
*This is done automatically by the bootstrap script. This is a reference in case you need to do it
|
||||
manually.*
|
||||
|
||||
Use Python's built-in `pyvenv` to create a virtual environment in which we're going to install our.
|
||||
Python-related dependencies. In that environment, we then install our requirements with pip.
|
||||
|
||||
For Linux (`--system-site-packages` is to be able to import PyQt):
|
||||
|
||||
$ pyvenv --system-site-packages env
|
||||
$ source env/bin/activate
|
||||
$ cd <dupeGuru directory>
|
||||
$ python3 -m venv --system-site-packages ./env
|
||||
$ source ./env/bin/activate
|
||||
$ pip install -r requirements.txt
|
||||
|
||||
For OS X:
|
||||
|
||||
$ pyvenv env
|
||||
$ source env/bin/activate
|
||||
$ pip install -r requirements-osx.txt
|
||||
|
||||
### Actual building and running
|
||||
|
||||
With your virtualenv activated, you can build and run dupeGuru with these commands:
|
||||
|
||||
$ python build.py
|
||||
$ python run.py
|
||||
|
||||
You can also package dupeGuru into an installable package with:
|
||||
### 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:
|
||||
|
||||
### Generate Ubuntu packages
|
||||
$ 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"
|
||||
|
||||
$ bash -c "pyvenv --system-site-packages env && source env/bin/activate && pip install -r requirements.txt && python3 build.py --clean && python3 package.py"
|
||||
## Running tests
|
||||
|
||||
### Running tests
|
||||
|
||||
The complete test suite is ran 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`.
|
||||
|
||||
If you don't have Tox system-wide, install it in your virtualenv with `pip install tox` and then
|
||||
@@ -159,13 +96,9 @@ You can also run automated tests without Tox. Extra requirements for running tes
|
||||
`requirements-extra.txt`. So, you can do `pip install -r requirements-extra.txt` inside your
|
||||
virtualenv and then `py.test core hscommon`
|
||||
|
||||
[dupeguru]: http://www.hardcoded.net/dupeguru/
|
||||
[dupeguru]: https://dupeguru.voltaicideas.net/
|
||||
[cross-toolkit]: http://www.hardcoded.net/articles/cross-toolkit-software
|
||||
[contrib-issue]: https://github.com/hsoft/dupeguru/issues/300
|
||||
[nowindows]: https://www.hardcoded.net/archive2015#2015-11-01
|
||||
[documentation]: http://www.hardcoded.net/dupeguru/help/en/
|
||||
[documentation]: http://dupeguru.voltaicideas.net/help/en/
|
||||
[python]: http://www.python.org/
|
||||
[pyqt]: http://www.riverbankcomputing.com
|
||||
[pyenv]: https://github.com/yyuu/pyenv
|
||||
[tox]: https://tox.readthedocs.org/en/latest/
|
||||
|
||||
|
||||
55
Windows.md
Normal file
55
Windows.md
Normal file
@@ -0,0 +1,55 @@
|
||||
## How to build dupeGuru for Windows
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Python 3.6+][python]
|
||||
- [Visual Studio 2019][vs] or [Visual Studio Build Tools 2019][vsBuildTools] with the Windows 10 SDK
|
||||
- [nsis][nsis] (for installer creation)
|
||||
- [msys2][msys2] (for using makefile method)
|
||||
|
||||
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.8):
|
||||
|
||||
$ 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] Take note of the required vc++ versions.
|
||||
|
||||
### With build.py (preferred)
|
||||
To build with a different python version 3.6 vs 3.8 or 32 bit vs 64 bit specify that version instead of -3.8 to the `py` command below. If you want to build additional versions while keeping all virtual environments setup use a different location for each virtual environment.
|
||||
|
||||
$ cd <dupeGuru directory>
|
||||
$ py -3.8 -m venv .\env
|
||||
$ .\env\Scripts\activate
|
||||
$ pip install -r requirements.txt
|
||||
$ python build.py
|
||||
$ python run.py
|
||||
|
||||
### With makefile
|
||||
It is possible to build dupeGuru with the makefile on windows using a compatable POSIX environment. The following steps have been tested using [msys2][msys2]. Before running make:
|
||||
1. Install msys2 or other POSIX environment
|
||||
2. Install PyQt5 globally via pip
|
||||
3. Use the respective console for msys2 it is `msys2 msys`
|
||||
|
||||
Then the following execution of the makefile should work. Pass the correct value for PYTHON to the makefile if not on the path as python3.
|
||||
|
||||
$ cd <dupeGuru directory>
|
||||
$ make PYTHON='py -3.8'
|
||||
$ make run
|
||||
|
||||
### 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. 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
|
||||
|
||||
### 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/
|
||||
[nsis]: http://nsis.sourceforge.net/Main_Page
|
||||
[vs]: https://www.visualstudio.com/downloads/#visual-studio-community-2019
|
||||
[vsBuildTools]: https://www.visualstudio.com/downloads/#build-tools-for-visual-studio-2019
|
||||
[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
|
||||
[pythonWindowsCompilers]: https://wiki.python.org/moin/WindowsCompilers
|
||||
[msys2]: http://www.msys2.org/
|
||||
41
bootstrap.sh
41
bootstrap.sh
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
PYTHON=python3
|
||||
ret=`$PYTHON -c "import sys; print(int(sys.version_info[:2] >= (3, 4)))"`
|
||||
if [ $ret -ne 1 ]; then
|
||||
echo "Python 3.4+ required. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if [ -d ".git" ]; then
|
||||
git submodule init
|
||||
git submodule update
|
||||
fi
|
||||
|
||||
if [ ! -d "env" ]; then
|
||||
echo "No virtualenv. Creating one"
|
||||
# We need a "system-site-packages" env to have PyQt, but we also need to ensure a local pip
|
||||
# install. To achieve our latter goal, we start with a normal venv, which we later upgrade to
|
||||
# a system-site-packages once pip is installed.
|
||||
if ! $PYTHON -m venv env ; then
|
||||
echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
$PYTHON -m venv env --upgrade --system-site-packages
|
||||
fi
|
||||
fi
|
||||
|
||||
source env/bin/activate
|
||||
|
||||
echo "Installing pip requirements"
|
||||
if [ "$(uname)" == "Darwin" ]; then
|
||||
./env/bin/pip install -r requirements-osx.txt
|
||||
else
|
||||
./env/bin/python -c "import PyQt5" >/dev/null 2>&1 || { echo >&2 "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
|
||||
./env/bin/pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
echo "Bootstrapping complete! You can now configure, build and run dupeGuru with:"
|
||||
echo ". env/bin/activate && python build.py && python run.py"
|
||||
427
build.py
427
build.py
@@ -1,382 +1,235 @@
|
||||
# Copyright 2016 Hardcoded Software (http://www.hardcoded.net)
|
||||
# Copyright 2017 Virgil Dupras
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import sys
|
||||
import os
|
||||
import os.path as op
|
||||
from optparse import OptionParser
|
||||
import shutil
|
||||
import compileall
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools import setup, Extension
|
||||
|
||||
from hscommon import sphinxgen
|
||||
from hscommon.build import (
|
||||
add_to_pythonpath, print_and_do, copy_packages, filereplace,
|
||||
get_module_version, move_all, copy_all, OSXAppStructure,
|
||||
build_cocoalib_xibless, fix_qt_resource_file, build_cocoa_ext, copy_embeddable_python_dylib,
|
||||
collect_stdlib_dependencies
|
||||
add_to_pythonpath,
|
||||
print_and_do,
|
||||
move_all,
|
||||
fix_qt_resource_file,
|
||||
)
|
||||
from hscommon import loc
|
||||
from hscommon.plat import ISOSX
|
||||
from hscommon.util import ensure_folder, delete_files_with_pattern
|
||||
|
||||
|
||||
def parse_args():
|
||||
usage = "usage: %prog [options]"
|
||||
parser = OptionParser(usage=usage)
|
||||
parser.add_option(
|
||||
'--clean', action='store_true', dest='clean',
|
||||
help="Clean build folder before building"
|
||||
"--clean",
|
||||
action="store_true",
|
||||
dest="clean",
|
||||
help="Clean build folder before building",
|
||||
)
|
||||
parser.add_option(
|
||||
'--doc', action='store_true', dest='doc',
|
||||
help="Build only the help file"
|
||||
"--doc", action="store_true", dest="doc", help="Build only the help file"
|
||||
)
|
||||
parser.add_option(
|
||||
'--ui', dest='ui',
|
||||
help="Type of UI to build. 'qt' or 'cocoa'. Default is determined by your system."
|
||||
"--loc", action="store_true", dest="loc", help="Build only localization"
|
||||
)
|
||||
parser.add_option(
|
||||
'--dev', action='store_true', dest='dev', default=False,
|
||||
help="If this flag is set, will configure for dev builds."
|
||||
"--updatepot",
|
||||
action="store_true",
|
||||
dest="updatepot",
|
||||
help="Generate .pot files from source code.",
|
||||
)
|
||||
parser.add_option(
|
||||
'--loc', action='store_true', dest='loc',
|
||||
help="Build only localization"
|
||||
"--mergepot",
|
||||
action="store_true",
|
||||
dest="mergepot",
|
||||
help="Update all .po files based on .pot files.",
|
||||
)
|
||||
parser.add_option(
|
||||
'--cocoa-ext', action='store_true', dest='cocoa_ext',
|
||||
help="Build only Cocoa extensions"
|
||||
"--normpo",
|
||||
action="store_true",
|
||||
dest="normpo",
|
||||
help="Normalize all PO files (do this before commit).",
|
||||
)
|
||||
parser.add_option(
|
||||
'--cocoa-compile', action='store_true', dest='cocoa_compile',
|
||||
help="Build only Cocoa executable"
|
||||
"--modules",
|
||||
action="store_true",
|
||||
dest="modules",
|
||||
help="Build the python modules.",
|
||||
)
|
||||
parser.add_option(
|
||||
'--xibless', action='store_true', dest='xibless',
|
||||
help="Build only xibless UIs"
|
||||
)
|
||||
parser.add_option(
|
||||
'--updatepot', action='store_true', dest='updatepot',
|
||||
help="Generate .pot files from source code."
|
||||
)
|
||||
parser.add_option(
|
||||
'--mergepot', action='store_true', dest='mergepot',
|
||||
help="Update all .po files based on .pot files."
|
||||
)
|
||||
parser.add_option(
|
||||
'--normpo', action='store_true', dest='normpo',
|
||||
help="Normalize all PO files (do this before commit)."
|
||||
"--importpo",
|
||||
action="store_true",
|
||||
dest="importpo",
|
||||
help="Import all PO files downloaded from transifex.",
|
||||
)
|
||||
(options, args) = parser.parse_args()
|
||||
return options
|
||||
|
||||
def cocoa_app():
|
||||
app_path = 'build/dupeGuru.app'
|
||||
return OSXAppStructure(app_path)
|
||||
|
||||
def build_xibless(dest='cocoa/autogen'):
|
||||
import xibless
|
||||
ensure_folder(dest)
|
||||
FNPAIRS = [
|
||||
('ignore_list_dialog.py', 'IgnoreListDialog_UI'),
|
||||
('deletion_options.py', 'DeletionOptions_UI'),
|
||||
('problem_dialog.py', 'ProblemDialog_UI'),
|
||||
('directory_panel.py', 'DirectoryPanel_UI'),
|
||||
('prioritize_dialog.py', 'PrioritizeDialog_UI'),
|
||||
('result_window.py', 'ResultWindow_UI'),
|
||||
('main_menu.py', 'MainMenu_UI'),
|
||||
('details_panel.py', 'DetailsPanel_UI'),
|
||||
('details_panel_picture.py', 'DetailsPanelPicture_UI'),
|
||||
]
|
||||
for srcname, dstname in FNPAIRS:
|
||||
xibless.generate(
|
||||
op.join('cocoa', 'ui', srcname), op.join(dest, dstname),
|
||||
localizationTable='Localizable'
|
||||
)
|
||||
for appmode in ('standard', 'music', 'picture'):
|
||||
xibless.generate(
|
||||
op.join('cocoa', 'ui', 'preferences_panel.py'),
|
||||
op.join(dest, 'PreferencesPanel%s_UI' % appmode.capitalize()),
|
||||
localizationTable='Localizable',
|
||||
args={'appmode': appmode},
|
||||
)
|
||||
|
||||
def build_cocoa(dev):
|
||||
sparkle_framework_path = op.join('cocoa', 'Sparkle', 'build', 'Release', 'Sparkle.framework')
|
||||
if not op.exists(sparkle_framework_path):
|
||||
print("Building Sparkle")
|
||||
os.chdir(op.join('cocoa', 'Sparkle'))
|
||||
print_and_do('make build')
|
||||
os.chdir(op.join('..', '..'))
|
||||
print("Creating OS X app structure")
|
||||
app = cocoa_app()
|
||||
app_version = get_module_version('core')
|
||||
cocoa_project_path = 'cocoa'
|
||||
filereplace(op.join(cocoa_project_path, 'InfoTemplate.plist'), op.join('build', 'Info.plist'), version=app_version)
|
||||
app.create(op.join('build', 'Info.plist'))
|
||||
print("Building localizations")
|
||||
build_localizations('cocoa')
|
||||
print("Building xibless UIs")
|
||||
build_cocoalib_xibless()
|
||||
build_xibless()
|
||||
print("Building Python extensions")
|
||||
build_cocoa_proxy_module()
|
||||
build_cocoa_bridging_interfaces()
|
||||
print("Building the cocoa layer")
|
||||
copy_embeddable_python_dylib('build')
|
||||
pydep_folder = op.join(app.resources, 'py')
|
||||
if not op.exists(pydep_folder):
|
||||
os.mkdir(pydep_folder)
|
||||
shutil.copy(op.join(cocoa_project_path, 'dg_cocoa.py'), 'build')
|
||||
tocopy = [
|
||||
'core', 'hscommon', 'cocoa/inter', 'cocoalib/cocoa', 'objp', 'send2trash', 'hsaudiotag',
|
||||
]
|
||||
copy_packages(tocopy, pydep_folder, create_links=dev)
|
||||
sys.path.insert(0, 'build')
|
||||
# ModuleFinder can't seem to correctly detect the multiprocessing dependency, so we have
|
||||
# to manually specify it.
|
||||
extra_deps = ['multiprocessing']
|
||||
collect_stdlib_dependencies('build/dg_cocoa.py', pydep_folder, extra_deps=extra_deps)
|
||||
del sys.path[0]
|
||||
# Views are not referenced by python code, so they're not found by the collector.
|
||||
copy_all('build/inter/*.so', op.join(pydep_folder, 'inter'))
|
||||
if not dev:
|
||||
# Important: Don't ever run delete_files_with_pattern('*.py') on dev builds because you'll
|
||||
# be deleting all py files in symlinked folders.
|
||||
compileall.compile_dir(pydep_folder, force=True, legacy=True)
|
||||
delete_files_with_pattern(pydep_folder, '*.py')
|
||||
delete_files_with_pattern(pydep_folder, '__pycache__')
|
||||
print("Compiling with WAF")
|
||||
os.chdir('cocoa')
|
||||
print_and_do('{0} waf configure && {0} waf'.format(sys.executable))
|
||||
os.chdir('..')
|
||||
app.copy_executable('cocoa/build/dupeGuru')
|
||||
build_help()
|
||||
print("Copying resources and frameworks")
|
||||
image_path = 'cocoa/dupeguru.icns'
|
||||
resources = [image_path, 'cocoa/dsa_pub.pem', 'build/dg_cocoa.py', 'build/help']
|
||||
app.copy_resources(*resources, use_symlinks=dev)
|
||||
app.copy_frameworks('build/Python', sparkle_framework_path)
|
||||
print("Creating the run.py file")
|
||||
tmpl = open('cocoa/run_template.py', 'rt').read()
|
||||
run_contents = tmpl.replace('{{app_path}}', app.dest)
|
||||
open('run.py', 'wt').write(run_contents)
|
||||
|
||||
def build_qt(dev):
|
||||
print("Building localizations")
|
||||
build_localizations('qt')
|
||||
print("Building Qt stuff")
|
||||
print_and_do("pyrcc5 {0} > {1}".format(op.join('qt', 'dg.qrc'), op.join('qt', 'dg_rc.py')))
|
||||
fix_qt_resource_file(op.join('qt', 'dg_rc.py'))
|
||||
build_help()
|
||||
print("Creating the run.py file")
|
||||
shutil.copy(op.join('qt', 'run_template.py'), 'run.py')
|
||||
|
||||
def build_help():
|
||||
print("Generating Help")
|
||||
current_path = op.abspath('.')
|
||||
help_basepath = op.join(current_path, 'help', 'en')
|
||||
help_destpath = op.join(current_path, 'build', 'help')
|
||||
changelog_path = op.join(current_path, 'help', 'changelog')
|
||||
tixurl = "https://github.com/hsoft/dupeguru/issues/{}"
|
||||
confrepl = {'language': 'en'}
|
||||
changelogtmpl = op.join(current_path, 'help', 'changelog.tmpl')
|
||||
conftmpl = op.join(current_path, 'help', 'conf.tmpl')
|
||||
sphinxgen.gen(help_basepath, help_destpath, changelog_path, tixurl, confrepl, conftmpl, changelogtmpl)
|
||||
current_path = op.abspath(".")
|
||||
help_basepath = op.join(current_path, "help", "en")
|
||||
help_destpath = op.join(current_path, "build", "help")
|
||||
changelog_path = op.join(current_path, "help", "changelog")
|
||||
tixurl = "https://github.com/arsenetar/dupeguru/issues/{}"
|
||||
confrepl = {"language": "en"}
|
||||
changelogtmpl = op.join(current_path, "help", "changelog.tmpl")
|
||||
conftmpl = op.join(current_path, "help", "conf.tmpl")
|
||||
sphinxgen.gen(
|
||||
help_basepath,
|
||||
help_destpath,
|
||||
changelog_path,
|
||||
tixurl,
|
||||
confrepl,
|
||||
conftmpl,
|
||||
changelogtmpl,
|
||||
)
|
||||
|
||||
|
||||
def build_qt_localizations():
|
||||
loc.compile_all_po(op.join('qtlib', 'locale'))
|
||||
loc.merge_locale_dir(op.join('qtlib', 'locale'), 'locale')
|
||||
loc.compile_all_po(op.join("qtlib", "locale"))
|
||||
loc.merge_locale_dir(op.join("qtlib", "locale"), "locale")
|
||||
|
||||
def build_localizations(ui):
|
||||
loc.compile_all_po('locale')
|
||||
if ui == 'cocoa':
|
||||
app = cocoa_app()
|
||||
loc.build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'en.lproj', 'Localizable.strings'))
|
||||
locale_dest = op.join(app.resources, 'locale')
|
||||
elif ui == 'qt':
|
||||
|
||||
def build_localizations():
|
||||
loc.compile_all_po("locale")
|
||||
build_qt_localizations()
|
||||
locale_dest = op.join('build', 'locale')
|
||||
locale_dest = op.join("build", "locale")
|
||||
if op.exists(locale_dest):
|
||||
shutil.rmtree(locale_dest)
|
||||
shutil.copytree('locale', locale_dest, ignore=shutil.ignore_patterns('*.po', '*.pot'))
|
||||
shutil.copytree(
|
||||
"locale", locale_dest, ignore=shutil.ignore_patterns("*.po", "*.pot")
|
||||
)
|
||||
|
||||
|
||||
def build_updatepot():
|
||||
if ISOSX:
|
||||
print("Updating Cocoa strings file.")
|
||||
build_cocoalib_xibless('cocoalib/autogen')
|
||||
loc.generate_cocoa_strings_from_code('cocoalib', 'cocoalib/en.lproj')
|
||||
build_xibless()
|
||||
loc.generate_cocoa_strings_from_code('cocoa', 'cocoa/en.lproj')
|
||||
print("Building .pot files from source files")
|
||||
print("Building core.pot")
|
||||
loc.generate_pot(['core'], op.join('locale', 'core.pot'), ['tr'])
|
||||
loc.generate_pot(["core"], op.join("locale", "core.pot"), ["tr"])
|
||||
print("Building columns.pot")
|
||||
loc.generate_pot(['core'], op.join('locale', 'columns.pot'), ['coltr'])
|
||||
loc.generate_pot(["core"], op.join("locale", "columns.pot"), ["coltr"])
|
||||
print("Building ui.pot")
|
||||
# When we're not under OS X, we don't want to overwrite ui.pot because it contains Cocoa locs
|
||||
# We want to merge the generated pot with the old pot in the most preserving way possible.
|
||||
ui_packages = ['qt', op.join('cocoa', 'inter')]
|
||||
loc.generate_pot(ui_packages, op.join('locale', 'ui.pot'), ['tr'], merge=(not ISOSX))
|
||||
ui_packages = ["qt", op.join("cocoa", "inter")]
|
||||
loc.generate_pot(ui_packages, op.join("locale", "ui.pot"), ["tr"], merge=True)
|
||||
print("Building qtlib.pot")
|
||||
loc.generate_pot(['qtlib'], op.join('qtlib', 'locale', 'qtlib.pot'), ['tr'])
|
||||
if ISOSX:
|
||||
print("Building cocoalib.pot")
|
||||
cocoalib_pot = op.join('cocoalib', 'locale', 'cocoalib.pot')
|
||||
os.remove(cocoalib_pot)
|
||||
loc.strings2pot(op.join('cocoalib', 'en.lproj', 'cocoalib.strings'), cocoalib_pot)
|
||||
print("Enhancing ui.pot with Cocoa's strings files")
|
||||
loc.strings2pot(
|
||||
op.join('cocoa', 'en.lproj', 'Localizable.strings'),
|
||||
op.join('locale', 'ui.pot')
|
||||
)
|
||||
loc.generate_pot(["qtlib"], op.join("qtlib", "locale", "qtlib.pot"), ["tr"])
|
||||
|
||||
|
||||
def build_mergepot():
|
||||
print("Updating .po files using .pot files")
|
||||
loc.merge_pots_into_pos('locale')
|
||||
loc.merge_pots_into_pos(op.join('qtlib', 'locale'))
|
||||
loc.merge_pots_into_pos(op.join('cocoalib', 'locale'))
|
||||
loc.merge_pots_into_pos("locale")
|
||||
loc.merge_pots_into_pos(op.join("qtlib", "locale"))
|
||||
# loc.merge_pots_into_pos(op.join("cocoalib", "locale"))
|
||||
|
||||
|
||||
def build_normpo():
|
||||
loc.normalize_all_pos('locale')
|
||||
loc.normalize_all_pos(op.join('qtlib', 'locale'))
|
||||
loc.normalize_all_pos(op.join('cocoalib', 'locale'))
|
||||
loc.normalize_all_pos("locale")
|
||||
loc.normalize_all_pos(op.join("qtlib", "locale"))
|
||||
# loc.normalize_all_pos(op.join("cocoalib", "locale"))
|
||||
|
||||
def build_cocoa_proxy_module():
|
||||
print("Building Cocoa Proxy")
|
||||
import objp.p2o
|
||||
objp.p2o.generate_python_proxy_code('cocoalib/cocoa/CocoaProxy.h', 'build/CocoaProxy.m')
|
||||
build_cocoa_ext(
|
||||
"CocoaProxy", 'cocoalib/cocoa',
|
||||
[
|
||||
'cocoalib/cocoa/CocoaProxy.m', 'build/CocoaProxy.m', 'build/ObjP.m',
|
||||
'cocoalib/HSErrorReportWindow.m', 'cocoa/autogen/HSErrorReportWindow_UI.m'
|
||||
],
|
||||
['AppKit', 'CoreServices'],
|
||||
['cocoalib', 'cocoa/autogen']
|
||||
)
|
||||
|
||||
def build_cocoa_bridging_interfaces():
|
||||
print("Building Cocoa Bridging Interfaces")
|
||||
import objp.o2p
|
||||
import objp.p2o
|
||||
add_to_pythonpath('cocoa')
|
||||
add_to_pythonpath('cocoalib')
|
||||
from cocoa.inter import (
|
||||
PyGUIObject, GUIObjectView, PyColumns, ColumnsView, PyOutline,
|
||||
OutlineView, PySelectableList, SelectableListView, PyTable, TableView, PyBaseApp,
|
||||
PyTextField, ProgressWindowView, PyProgressWindow
|
||||
)
|
||||
from inter.deletion_options import PyDeletionOptions, DeletionOptionsView
|
||||
from inter.details_panel import PyDetailsPanel, DetailsPanelView
|
||||
from inter.directory_outline import PyDirectoryOutline, DirectoryOutlineView
|
||||
from inter.prioritize_dialog import PyPrioritizeDialog, PrioritizeDialogView
|
||||
from inter.prioritize_list import PyPrioritizeList, PrioritizeListView
|
||||
from inter.problem_dialog import PyProblemDialog
|
||||
from inter.ignore_list_dialog import PyIgnoreListDialog, IgnoreListDialogView
|
||||
from inter.result_table import PyResultTable, ResultTableView
|
||||
from inter.stats_label import PyStatsLabel, StatsLabelView
|
||||
from inter.app import PyDupeGuru, DupeGuruView
|
||||
allclasses = [
|
||||
PyGUIObject, PyColumns, PyOutline, PySelectableList, PyTable, PyBaseApp,
|
||||
PyDetailsPanel, PyDirectoryOutline, PyPrioritizeDialog, PyPrioritizeList, PyProblemDialog,
|
||||
PyIgnoreListDialog, PyDeletionOptions, PyResultTable, PyStatsLabel, PyDupeGuru,
|
||||
PyTextField, PyProgressWindow
|
||||
]
|
||||
for class_ in allclasses:
|
||||
objp.o2p.generate_objc_code(class_, 'cocoa/autogen', inherit=True)
|
||||
allclasses = [
|
||||
GUIObjectView, ColumnsView, OutlineView, SelectableListView, TableView,
|
||||
DetailsPanelView, DirectoryOutlineView, PrioritizeDialogView, PrioritizeListView,
|
||||
IgnoreListDialogView, DeletionOptionsView, ResultTableView, StatsLabelView,
|
||||
ProgressWindowView, DupeGuruView
|
||||
]
|
||||
clsspecs = [objp.o2p.spec_from_python_class(class_) for class_ in allclasses]
|
||||
objp.p2o.generate_python_proxy_code_from_clsspec(clsspecs, 'build/CocoaViews.m')
|
||||
build_cocoa_ext('CocoaViews', 'cocoa/inter', ['build/CocoaViews.m', 'build/ObjP.m'])
|
||||
def build_importpo():
|
||||
basePath = Path.cwd()
|
||||
# expect a folder named transifex with all the .po files from the exports
|
||||
translationsPath = basePath.joinpath("transifex")
|
||||
# locations where the translation files go
|
||||
qtlibPath = basePath.joinpath("qtlib", "locale")
|
||||
localePath = basePath.joinpath("locale")
|
||||
for translation in translationsPath.iterdir():
|
||||
# transifex files are named resource_lang.po so split on first '_'
|
||||
parts = translation.stem.split("_", 1)
|
||||
resource = parts[0]
|
||||
language = parts[1]
|
||||
# make sure qtlib resources go to dedicated folder
|
||||
if resource == "qtlib":
|
||||
outputPath = qtlibPath
|
||||
else:
|
||||
outputPath = localePath
|
||||
outputFolder = outputPath.joinpath(language, "LC_MESSAGES")
|
||||
# create the language folder if it is new
|
||||
if not outputFolder.exists():
|
||||
outputFolder.mkdir(parents=True)
|
||||
# copy the po file over
|
||||
shutil.copy(translation, outputFolder.joinpath(resource + ".po"))
|
||||
# normalize files after complete
|
||||
build_normpo()
|
||||
|
||||
def build_pe_modules(ui):
|
||||
|
||||
def build_pe_modules():
|
||||
print("Building PE Modules")
|
||||
exts = [
|
||||
Extension(
|
||||
"_block",
|
||||
[op.join('core', 'pe', 'modules', 'block.c'), op.join('core', 'pe', 'modules', 'common.c')]
|
||||
[
|
||||
op.join("core", "pe", "modules", "block.c"),
|
||||
op.join("core", "pe", "modules", "common.c"),
|
||||
],
|
||||
),
|
||||
Extension(
|
||||
"_cache",
|
||||
[op.join('core', 'pe', 'modules', 'cache.c'), op.join('core', 'pe', 'modules', 'common.c')]
|
||||
[
|
||||
op.join("core", "pe", "modules", "cache.c"),
|
||||
op.join("core", "pe", "modules", "common.c"),
|
||||
],
|
||||
),
|
||||
]
|
||||
if ui == 'qt':
|
||||
exts.append(Extension("_block_qt", [op.join('qt', 'pe', 'modules', 'block.c')]))
|
||||
elif ui == 'cocoa':
|
||||
exts.append(Extension(
|
||||
"_block_osx",
|
||||
[op.join('core', 'pe', 'modules', 'block_osx.m'), op.join('core', 'pe', 'modules', 'common.c')],
|
||||
extra_link_args=[
|
||||
"-framework", "CoreFoundation",
|
||||
"-framework", "Foundation",
|
||||
"-framework", "ApplicationServices",
|
||||
]
|
||||
))
|
||||
exts.append(Extension("_block_qt", [op.join("qt", "pe", "modules", "block.c")]))
|
||||
setup(
|
||||
script_args=['build_ext', '--inplace'],
|
||||
script_args=["build_ext", "--inplace"],
|
||||
ext_modules=exts,
|
||||
)
|
||||
move_all('_block_qt*', op.join('qt', 'pe'))
|
||||
move_all('_block*', op.join('core', 'pe'))
|
||||
move_all('_cache*', op.join('core', 'pe'))
|
||||
move_all("_block_qt*", op.join("qt", "pe"))
|
||||
move_all("_block*", op.join("core", "pe"))
|
||||
move_all("_cache*", op.join("core", "pe"))
|
||||
|
||||
def build_normal(ui, dev):
|
||||
print("Building dupeGuru with UI {}".format(ui))
|
||||
add_to_pythonpath('.')
|
||||
|
||||
def build_normal():
|
||||
print("Building dupeGuru with UI qt")
|
||||
add_to_pythonpath(".")
|
||||
print("Building dupeGuru")
|
||||
build_pe_modules(ui)
|
||||
if ui == 'cocoa':
|
||||
build_cocoa(dev)
|
||||
elif ui == 'qt':
|
||||
build_qt(dev)
|
||||
build_pe_modules()
|
||||
print("Building localizations")
|
||||
build_localizations()
|
||||
print("Building Qt stuff")
|
||||
print_and_do(
|
||||
"pyrcc5 {0} > {1}".format(op.join("qt", "dg.qrc"), op.join("qt", "dg_rc.py"))
|
||||
)
|
||||
fix_qt_resource_file(op.join("qt", "dg_rc.py"))
|
||||
build_help()
|
||||
|
||||
|
||||
def main():
|
||||
options = parse_args()
|
||||
ui = options.ui
|
||||
if ui not in ('cocoa', 'qt'):
|
||||
ui = 'cocoa' if ISOSX else 'qt'
|
||||
if options.dev:
|
||||
print("Building in Dev mode")
|
||||
if options.clean:
|
||||
for path in ['build', op.join('cocoa', 'build'), op.join('cocoa', 'autogen')]:
|
||||
if op.exists(path):
|
||||
shutil.rmtree(path)
|
||||
if not op.exists('build'):
|
||||
os.mkdir('build')
|
||||
if op.exists("build"):
|
||||
shutil.rmtree("build")
|
||||
if not op.exists("build"):
|
||||
os.mkdir("build")
|
||||
if options.doc:
|
||||
build_help()
|
||||
elif options.loc:
|
||||
build_localizations(ui)
|
||||
build_localizations()
|
||||
elif options.updatepot:
|
||||
build_updatepot()
|
||||
elif options.mergepot:
|
||||
build_mergepot()
|
||||
elif options.normpo:
|
||||
build_normpo()
|
||||
elif options.cocoa_ext:
|
||||
build_cocoa_proxy_module()
|
||||
build_cocoa_bridging_interfaces()
|
||||
elif options.cocoa_compile:
|
||||
os.chdir('cocoa')
|
||||
print_and_do('{0} waf configure && {0} waf'.format(sys.executable))
|
||||
os.chdir('..')
|
||||
cocoa_app().copy_executable('cocoa/build/dupeGuru')
|
||||
elif options.xibless:
|
||||
build_cocoalib_xibless()
|
||||
build_xibless()
|
||||
elif options.modules:
|
||||
build_pe_modules()
|
||||
elif options.importpo:
|
||||
build_importpo()
|
||||
else:
|
||||
build_normal(ui, options.dev)
|
||||
build_normal()
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Sparkle/SUUpdater.h>
|
||||
#import "PyDupeGuru.h"
|
||||
#import "ResultWindow.h"
|
||||
#import "ResultTable.h"
|
||||
#import "DetailsPanel.h"
|
||||
#import "DirectoryPanel.h"
|
||||
#import "IgnoreListDialog.h"
|
||||
#import "ProblemDialog.h"
|
||||
#import "DeletionOptions.h"
|
||||
#import "HSAboutBox.h"
|
||||
#import "HSRecentFiles.h"
|
||||
#import "HSProgressWindow.h"
|
||||
|
||||
@interface AppDelegate : NSObject <NSFileManagerDelegate>
|
||||
{
|
||||
NSMenu *recentResultsMenu;
|
||||
NSMenu *columnsMenu;
|
||||
SUUpdater *updater;
|
||||
|
||||
PyDupeGuru *model;
|
||||
ResultWindow *_resultWindow;
|
||||
DirectoryPanel *_directoryPanel;
|
||||
DetailsPanel *_detailsPanel;
|
||||
IgnoreListDialog *_ignoreListDialog;
|
||||
ProblemDialog *_problemDialog;
|
||||
DeletionOptions *_deletionOptions;
|
||||
HSProgressWindow *_progressWindow;
|
||||
NSWindowController *_preferencesPanel;
|
||||
HSAboutBox *_aboutBox;
|
||||
HSRecentFiles *_recentResults;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) NSMenu *recentResultsMenu;
|
||||
@property (readwrite, retain) NSMenu *columnsMenu;
|
||||
@property (readwrite, retain) SUUpdater *updater;
|
||||
|
||||
/* Virtual */
|
||||
+ (NSDictionary *)defaultPreferences;
|
||||
- (PyDupeGuru *)model;
|
||||
- (DetailsPanel *)createDetailsPanel;
|
||||
- (void)setScanOptions;
|
||||
|
||||
/* Public */
|
||||
- (void)finalizeInit;
|
||||
- (ResultWindow *)resultWindow;
|
||||
- (DirectoryPanel *)directoryPanel;
|
||||
- (DetailsPanel *)detailsPanel;
|
||||
- (HSRecentFiles *)recentResults;
|
||||
- (NSInteger)getAppMode;
|
||||
- (void)setAppMode:(NSInteger)appMode;
|
||||
|
||||
/* Delegate */
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
|
||||
- (void)applicationWillBecomeActive:(NSNotification *)aNotification;
|
||||
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender;
|
||||
- (void)applicationWillTerminate:(NSNotification *)aNotification;
|
||||
- (void)recentFileClicked:(NSString *)path;
|
||||
|
||||
/* Actions */
|
||||
- (void)clearPictureCache;
|
||||
- (void)loadResults;
|
||||
- (void)openWebsite;
|
||||
- (void)openHelp;
|
||||
- (void)showAboutBox;
|
||||
- (void)showDirectoryWindow;
|
||||
- (void)showPreferencesPanel;
|
||||
- (void)showResultWindow;
|
||||
- (void)showIgnoreList;
|
||||
- (void)startScanning;
|
||||
|
||||
/* model --> view */
|
||||
- (void)showMessage:(NSString *)msg;
|
||||
@end
|
||||
@@ -1,402 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "AppDelegate.h"
|
||||
#import "ProgressController.h"
|
||||
#import "HSPyUtil.h"
|
||||
#import "Consts.h"
|
||||
#import "Dialogs.h"
|
||||
#import "Utils.h"
|
||||
#import "ValueTransformers.h"
|
||||
#import "DetailsPanelPicture.h"
|
||||
#import "PreferencesPanelStandard_UI.h"
|
||||
#import "PreferencesPanelMusic_UI.h"
|
||||
#import "PreferencesPanelPicture_UI.h"
|
||||
|
||||
@implementation AppDelegate
|
||||
|
||||
@synthesize recentResultsMenu;
|
||||
@synthesize columnsMenu;
|
||||
@synthesize updater;
|
||||
|
||||
+ (NSDictionary *)defaultPreferences
|
||||
{
|
||||
NSMutableDictionary *d = [NSMutableDictionary dictionary];
|
||||
[d setObject:i2n(1) forKey:@"scanTypeStandard"];
|
||||
[d setObject:i2n(3) forKey:@"scanTypeMusic"];
|
||||
[d setObject:i2n(0) forKey:@"scanTypePicture"];
|
||||
[d setObject:i2n(95) forKey:@"minMatchPercentage"];
|
||||
[d setObject:i2n(30) forKey:@"smallFileThreshold"];
|
||||
[d setObject:b2n(YES) forKey:@"wordWeighting"];
|
||||
[d setObject:b2n(NO) forKey:@"matchSimilarWords"];
|
||||
[d setObject:b2n(YES) forKey:@"ignoreSmallFiles"];
|
||||
[d setObject:b2n(NO) forKey:@"scanTagTrack"];
|
||||
[d setObject:b2n(YES) forKey:@"scanTagArtist"];
|
||||
[d setObject:b2n(YES) forKey:@"scanTagAlbum"];
|
||||
[d setObject:b2n(YES) forKey:@"scanTagTitle"];
|
||||
[d setObject:b2n(NO) forKey:@"scanTagGenre"];
|
||||
[d setObject:b2n(NO) forKey:@"scanTagYear"];
|
||||
[d setObject:b2n(NO) forKey:@"matchScaled"];
|
||||
[d setObject:i2n(1) forKey:@"recreatePathType"];
|
||||
[d setObject:i2n(11) forKey:TableFontSize];
|
||||
[d setObject:b2n(YES) forKey:@"mixFileKind"];
|
||||
[d setObject:b2n(NO) forKey:@"useRegexpFilter"];
|
||||
[d setObject:b2n(NO) forKey:@"ignoreHardlinkMatches"];
|
||||
[d setObject:b2n(NO) forKey:@"removeEmptyFolders"];
|
||||
[d setObject:b2n(NO) forKey:@"DebugMode"];
|
||||
[d setObject:@"" forKey:@"CustomCommand"];
|
||||
[d setObject:[NSArray array] forKey:@"recentDirectories"];
|
||||
[d setObject:[NSArray array] forKey:@"columnsOrder"];
|
||||
[d setObject:[NSDictionary dictionary] forKey:@"columnsWidth"];
|
||||
return d;
|
||||
}
|
||||
|
||||
+ (void)initialize
|
||||
{
|
||||
HSVTAdd *vt = [[[HSVTAdd alloc] initWithValue:4] autorelease];
|
||||
[NSValueTransformer setValueTransformer:vt forName:@"vtRowHeightOffset"];
|
||||
NSDictionary *d = [self defaultPreferences];
|
||||
[[NSUserDefaultsController sharedUserDefaultsController] setInitialValues:d];
|
||||
[[NSUserDefaults standardUserDefaults] registerDefaults:d];
|
||||
}
|
||||
|
||||
- (id)init
|
||||
{
|
||||
self = [super init];
|
||||
model = [[PyDupeGuru alloc] init];
|
||||
[model bindCallback:createCallback(@"DupeGuruView", self)];
|
||||
[self setUpdater:[SUUpdater sharedUpdater]];
|
||||
NSMutableIndexSet *contentsIndexes = [NSMutableIndexSet indexSet];
|
||||
[contentsIndexes addIndex:1];
|
||||
[contentsIndexes addIndex:2];
|
||||
VTIsIntIn *vt = [[[VTIsIntIn alloc] initWithValues:contentsIndexes reverse:YES] autorelease];
|
||||
[NSValueTransformer setValueTransformer:vt forName:@"vtScanTypeIsNotContent"];
|
||||
NSMutableIndexSet *i = [NSMutableIndexSet indexSetWithIndex:0];
|
||||
VTIsIntIn *vtScanTypeIsFuzzy = [[[VTIsIntIn alloc] initWithValues:i reverse:NO] autorelease];
|
||||
[NSValueTransformer setValueTransformer:vtScanTypeIsFuzzy forName:@"vtScanTypeIsFuzzy"];
|
||||
i = [NSMutableIndexSet indexSetWithIndex:4];
|
||||
VTIsIntIn *vtScanTypeIsNotContent = [[[VTIsIntIn alloc] initWithValues:i reverse:YES] autorelease];
|
||||
[NSValueTransformer setValueTransformer:vtScanTypeIsNotContent forName:@"vtScanTypeMusicIsNotContent"];
|
||||
VTIsIntIn *vtScanTypeIsTag = [[[VTIsIntIn alloc] initWithValues:[NSIndexSet indexSetWithIndex:3] reverse:NO] autorelease];
|
||||
[NSValueTransformer setValueTransformer:vtScanTypeIsTag forName:@"vtScanTypeIsTag"];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)finalizeInit
|
||||
{
|
||||
// We can only finalize initialization once the main menu has been created, which cannot happen
|
||||
// before AppDelegate is created.
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
/* Because the pref pane is lazily loaded, we have to manually do the update check if the
|
||||
preference is set.
|
||||
*/
|
||||
if ([ud boolForKey:@"SUEnableAutomaticChecks"]) {
|
||||
[[SUUpdater sharedUpdater] checkForUpdatesInBackground];
|
||||
}
|
||||
_recentResults = [[HSRecentFiles alloc] initWithName:@"recentResults" menu:recentResultsMenu];
|
||||
[_recentResults setDelegate:self];
|
||||
_directoryPanel = [[DirectoryPanel alloc] initWithParentApp:self];
|
||||
_ignoreListDialog = [[IgnoreListDialog alloc] initWithPyRef:[model ignoreListDialog]];
|
||||
_problemDialog = [[ProblemDialog alloc] initWithPyRef:[model problemDialog]];
|
||||
_deletionOptions = [[DeletionOptions alloc] initWithPyRef:[model deletionOptions]];
|
||||
_progressWindow = [[HSProgressWindow alloc] initWithPyRef:[[self model] progressWindow] view:nil];
|
||||
[_progressWindow setParentWindow:[_directoryPanel window]];
|
||||
// Lazily loaded
|
||||
_aboutBox = nil;
|
||||
_preferencesPanel = nil;
|
||||
_resultWindow = nil;
|
||||
_detailsPanel = nil;
|
||||
[[[self directoryPanel] window] makeKeyAndOrderFront:self];
|
||||
}
|
||||
|
||||
/* Virtual */
|
||||
|
||||
- (PyDupeGuru *)model
|
||||
{
|
||||
return model;
|
||||
}
|
||||
|
||||
- (DetailsPanel *)createDetailsPanel
|
||||
{
|
||||
NSInteger appMode = [self getAppMode];
|
||||
if (appMode == AppModePicture) {
|
||||
return [[DetailsPanelPicture alloc] initWithApp:model];
|
||||
}
|
||||
else {
|
||||
return [[DetailsPanel alloc] initWithPyRef:[model detailsPanel]];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setScanOptions
|
||||
{
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
NSString *scanTypeOptionName;
|
||||
NSInteger appMode = [self getAppMode];
|
||||
if (appMode == AppModePicture) {
|
||||
scanTypeOptionName = @"scanTypePicture";
|
||||
}
|
||||
else if (appMode == AppModeMusic) {
|
||||
scanTypeOptionName = @"scanTypeMusic";
|
||||
}
|
||||
else {
|
||||
scanTypeOptionName = @"scanTypeStandard";
|
||||
}
|
||||
[model setScanType:n2i([ud objectForKey:scanTypeOptionName])];
|
||||
[model setMinMatchPercentage:n2i([ud objectForKey:@"minMatchPercentage"])];
|
||||
[model setWordWeighting:n2b([ud objectForKey:@"wordWeighting"])];
|
||||
[model setMixFileKind:n2b([ud objectForKey:@"mixFileKind"])];
|
||||
[model setIgnoreHardlinkMatches:n2b([ud objectForKey:@"ignoreHardlinkMatches"])];
|
||||
[model setMatchSimilarWords:n2b([ud objectForKey:@"matchSimilarWords"])];
|
||||
int smallFileThreshold = [ud integerForKey:@"smallFileThreshold"]; // In KB
|
||||
int sizeThreshold = [ud boolForKey:@"ignoreSmallFiles"] ? smallFileThreshold * 1024 : 0; // The py side wants bytes
|
||||
[model setSizeThreshold:sizeThreshold];
|
||||
[model enable:n2b([ud objectForKey:@"scanTagTrack"]) scanForTag:@"track"];
|
||||
[model enable:n2b([ud objectForKey:@"scanTagArtist"]) scanForTag:@"artist"];
|
||||
[model enable:n2b([ud objectForKey:@"scanTagAlbum"]) scanForTag:@"album"];
|
||||
[model enable:n2b([ud objectForKey:@"scanTagTitle"]) scanForTag:@"title"];
|
||||
[model enable:n2b([ud objectForKey:@"scanTagGenre"]) scanForTag:@"genre"];
|
||||
[model enable:n2b([ud objectForKey:@"scanTagYear"]) scanForTag:@"year"];
|
||||
[model setMatchScaled:n2b([ud objectForKey:@"matchScaled"])];
|
||||
}
|
||||
|
||||
/* Public */
|
||||
- (ResultWindow *)resultWindow
|
||||
{
|
||||
return _resultWindow;
|
||||
}
|
||||
|
||||
- (DirectoryPanel *)directoryPanel
|
||||
{
|
||||
return _directoryPanel;
|
||||
}
|
||||
|
||||
- (DetailsPanel *)detailsPanel
|
||||
{
|
||||
return _detailsPanel;
|
||||
}
|
||||
|
||||
- (HSRecentFiles *)recentResults
|
||||
{
|
||||
return _recentResults;
|
||||
}
|
||||
|
||||
- (NSInteger)getAppMode
|
||||
{
|
||||
return [model getAppMode];
|
||||
}
|
||||
|
||||
- (void)setAppMode:(NSInteger)appMode
|
||||
{
|
||||
[model setAppMode:appMode];
|
||||
if (_preferencesPanel != nil) {
|
||||
[_preferencesPanel release];
|
||||
_preferencesPanel = nil;
|
||||
}
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
- (void)clearPictureCache
|
||||
{
|
||||
NSString *msg = NSLocalizedString(@"Do you really want to remove all your cached picture analysis?", @"");
|
||||
if ([Dialogs askYesNo:msg] == NSAlertSecondButtonReturn) // NO
|
||||
return;
|
||||
[model clearPictureCache];
|
||||
}
|
||||
|
||||
- (void)loadResults
|
||||
{
|
||||
NSOpenPanel *op = [NSOpenPanel openPanel];
|
||||
[op setCanChooseFiles:YES];
|
||||
[op setCanChooseDirectories:NO];
|
||||
[op setCanCreateDirectories:NO];
|
||||
[op setAllowsMultipleSelection:NO];
|
||||
[op setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]];
|
||||
[op setTitle:NSLocalizedString(@"Select a results file to load", @"")];
|
||||
if ([op runModal] == NSOKButton) {
|
||||
NSString *filename = [[[op URLs] objectAtIndex:0] path];
|
||||
[model loadResultsFrom:filename];
|
||||
[[self recentResults] addFile:filename];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)openWebsite
|
||||
{
|
||||
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://www.hardcoded.net/dupeguru/"]];
|
||||
}
|
||||
|
||||
- (void)openHelp
|
||||
{
|
||||
NSBundle *b = [NSBundle mainBundle];
|
||||
NSString *p = [b pathForResource:@"index" ofType:@"html" inDirectory:@"help"];
|
||||
NSURL *u = [NSURL fileURLWithPath:p];
|
||||
[[NSWorkspace sharedWorkspace] openURL:u];
|
||||
}
|
||||
|
||||
- (void)showAboutBox
|
||||
{
|
||||
if (_aboutBox == nil) {
|
||||
_aboutBox = [[HSAboutBox alloc] initWithApp:model];
|
||||
}
|
||||
[[_aboutBox window] makeKeyAndOrderFront:nil];
|
||||
}
|
||||
|
||||
- (void)showDirectoryWindow
|
||||
{
|
||||
[[[self directoryPanel] window] makeKeyAndOrderFront:nil];
|
||||
}
|
||||
|
||||
- (void)showPreferencesPanel
|
||||
{
|
||||
if (_preferencesPanel == nil) {
|
||||
NSWindow *window;
|
||||
NSInteger appMode = [model getAppMode];
|
||||
if (appMode == AppModePicture) {
|
||||
window = createPreferencesPanelPicture_UI(nil);
|
||||
}
|
||||
else if (appMode == AppModeMusic) {
|
||||
window = createPreferencesPanelMusic_UI(nil);
|
||||
}
|
||||
else {
|
||||
window = createPreferencesPanelStandard_UI(nil);
|
||||
}
|
||||
_preferencesPanel = [[NSWindowController alloc] initWithWindow:window];
|
||||
}
|
||||
[_preferencesPanel showWindow:nil];
|
||||
}
|
||||
|
||||
- (void)showResultWindow
|
||||
{
|
||||
[[[self resultWindow] window] makeKeyAndOrderFront:nil];
|
||||
}
|
||||
|
||||
- (void)showIgnoreList
|
||||
{
|
||||
[model showIgnoreList];
|
||||
}
|
||||
|
||||
- (void)startScanning
|
||||
{
|
||||
[[self directoryPanel] startDuplicateScan];
|
||||
}
|
||||
|
||||
|
||||
/* Delegate */
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
|
||||
{
|
||||
[model loadSession];
|
||||
}
|
||||
|
||||
- (void)applicationWillBecomeActive:(NSNotification *)aNotification
|
||||
{
|
||||
if (![[[self directoryPanel] window] isVisible]) {
|
||||
[[self directoryPanel] showWindow:NSApp];
|
||||
}
|
||||
}
|
||||
|
||||
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender
|
||||
{
|
||||
if ([model resultsAreModified]) {
|
||||
NSString *msg = NSLocalizedString(@"You have unsaved results, do you really want to quit?", @"");
|
||||
if ([Dialogs askYesNo:msg] == NSAlertSecondButtonReturn) { // NO
|
||||
return NSTerminateCancel;
|
||||
}
|
||||
}
|
||||
return NSTerminateNow;
|
||||
}
|
||||
|
||||
- (void)applicationWillTerminate:(NSNotification *)aNotification
|
||||
{
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
NSInteger sc = [ud integerForKey:@"sessionCountSinceLastIgnorePurge"];
|
||||
if (sc >= 10) {
|
||||
sc = -1;
|
||||
[model purgeIgnoreList];
|
||||
}
|
||||
sc++;
|
||||
[model saveSession];
|
||||
[ud setInteger:sc forKey:@"sessionCountSinceLastIgnorePurge"];
|
||||
// NSApplication does not release nib instances objects, we must do it manually
|
||||
// Well, it isn't needed because the memory is freed anyway (we are quitting the application
|
||||
// But I need to release HSRecentFiles so it saves the user defaults
|
||||
[_directoryPanel release];
|
||||
[_recentResults release];
|
||||
}
|
||||
|
||||
- (void)recentFileClicked:(NSString *)path
|
||||
{
|
||||
[model loadResultsFrom:path];
|
||||
}
|
||||
|
||||
|
||||
/* model --> view */
|
||||
- (void)showMessage:(NSString *)msg
|
||||
{
|
||||
[Dialogs showMessage:msg];
|
||||
}
|
||||
|
||||
- (BOOL)askYesNoWithPrompt:(NSString *)prompt
|
||||
{
|
||||
return [Dialogs askYesNo:prompt] == NSAlertFirstButtonReturn;
|
||||
}
|
||||
|
||||
- (void)createResultsWindow
|
||||
{
|
||||
if (_resultWindow != nil) {
|
||||
[_resultWindow release];
|
||||
}
|
||||
if (_detailsPanel != nil) {
|
||||
[_detailsPanel release];
|
||||
}
|
||||
// Warning: creation order is important
|
||||
// If the details panel is not created first and that there are some results in the model
|
||||
// (happens if we load results), a dupe selection event triggers a details refresh in the
|
||||
// core before we have the chance to initialize it, and then we crash.
|
||||
_detailsPanel = [self createDetailsPanel];
|
||||
_resultWindow = [[ResultWindow alloc] initWithParentApp:self];
|
||||
}
|
||||
- (void)showResultsWindow
|
||||
{
|
||||
[[[self resultWindow] window] makeKeyAndOrderFront:nil];
|
||||
}
|
||||
|
||||
- (void)showProblemDialog
|
||||
{
|
||||
[_problemDialog showWindow:self];
|
||||
}
|
||||
|
||||
- (NSString *)selectDestFolderWithPrompt:(NSString *)prompt
|
||||
{
|
||||
NSOpenPanel *op = [NSOpenPanel openPanel];
|
||||
[op setCanChooseFiles:NO];
|
||||
[op setCanChooseDirectories:YES];
|
||||
[op setCanCreateDirectories:YES];
|
||||
[op setAllowsMultipleSelection:NO];
|
||||
[op setTitle:prompt];
|
||||
if ([op runModal] == NSOKButton) {
|
||||
return [[[op URLs] objectAtIndex:0] path];
|
||||
}
|
||||
else {
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (NSString *)selectDestFileWithPrompt:(NSString *)prompt extension:(NSString *)extension
|
||||
{
|
||||
NSSavePanel *sp = [NSSavePanel savePanel];
|
||||
[sp setCanCreateDirectories:YES];
|
||||
[sp setAllowedFileTypes:[NSArray arrayWithObject:extension]];
|
||||
[sp setTitle:prompt];
|
||||
if ([sp runModal] == NSOKButton) {
|
||||
return [[sp URL] path];
|
||||
}
|
||||
else {
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
#define JobStarted @"JobStarted"
|
||||
#define JobInProgress @"JobInProgress"
|
||||
#define TableFontSize @"TableFontSize"
|
||||
|
||||
#define jobLoad @"job_load"
|
||||
#define jobScan @"job_scan"
|
||||
#define jobCopy @"job_copy"
|
||||
#define jobMove @"job_move"
|
||||
#define jobDelete @"job_delete"
|
||||
|
||||
#define DGPrioritizeIndexPasteboardType @"DGPrioritizeIndexPasteboardType"
|
||||
#define ImageLoadedNotification @"ImageLoadedNotification"
|
||||
|
||||
#define AppModeStandard 0
|
||||
#define AppModeMusic 1
|
||||
#define AppModePicture 2
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "PyDeletionOptions.h"
|
||||
|
||||
@interface DeletionOptions : NSWindowController
|
||||
{
|
||||
|
||||
PyDeletionOptions *model;
|
||||
|
||||
NSTextField *messageTextField;
|
||||
NSButton *linkButton;
|
||||
NSMatrix *linkTypeRadio;
|
||||
NSButton *directButton;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) NSTextField *messageTextField;
|
||||
@property (readwrite, retain) NSButton *linkButton;
|
||||
@property (readwrite, retain) NSMatrix *linkTypeRadio;
|
||||
@property (readwrite, retain) NSButton *directButton;
|
||||
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef;
|
||||
|
||||
- (void)updateOptions;
|
||||
- (void)proceed;
|
||||
- (void)cancel;
|
||||
@end
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "DeletionOptions.h"
|
||||
#import "DeletionOptions_UI.h"
|
||||
#import "HSPyUtil.h"
|
||||
|
||||
@implementation DeletionOptions
|
||||
|
||||
@synthesize messageTextField;
|
||||
@synthesize linkButton;
|
||||
@synthesize linkTypeRadio;
|
||||
@synthesize directButton;
|
||||
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef
|
||||
{
|
||||
self = [super initWithWindow:nil];
|
||||
model = [[PyDeletionOptions alloc] initWithModel:aPyRef];
|
||||
[self setWindow:createDeletionOptions_UI(self)];
|
||||
[model bindCallback:createCallback(@"DeletionOptionsView", self)];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[model release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
- (void)updateOptions
|
||||
{
|
||||
[model setLinkDeleted:[linkButton state] == NSOnState];
|
||||
[model setUseHardlinks:[linkTypeRadio selectedColumn] == 1];
|
||||
[model setDirect:[directButton state] == NSOnState];
|
||||
}
|
||||
|
||||
- (void)proceed
|
||||
{
|
||||
[NSApp stopModalWithCode:NSOKButton];
|
||||
}
|
||||
|
||||
- (void)cancel
|
||||
{
|
||||
[NSApp stopModalWithCode:NSCancelButton];
|
||||
}
|
||||
|
||||
/* model --> view */
|
||||
- (void)updateMsg:(NSString *)msg
|
||||
{
|
||||
[messageTextField setStringValue:msg];
|
||||
}
|
||||
|
||||
- (BOOL)show
|
||||
{
|
||||
[linkButton setState:NSOffState];
|
||||
[directButton setState:NSOffState];
|
||||
[linkTypeRadio selectCellAtRow:0 column:0];
|
||||
NSInteger r = [NSApp runModalForWindow:[self window]];
|
||||
[[self window] close];
|
||||
return r == NSOKButton;
|
||||
}
|
||||
|
||||
- (void)setHardlinkOptionEnabled:(BOOL)enabled
|
||||
{
|
||||
[linkTypeRadio setEnabled:enabled];
|
||||
}
|
||||
@end
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Python.h>
|
||||
#import "PyDetailsPanel.h"
|
||||
|
||||
@interface DetailsPanel : NSWindowController <NSTableViewDataSource>
|
||||
{
|
||||
NSTableView *detailsTable;
|
||||
|
||||
PyDetailsPanel *model;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) NSTableView *detailsTable;
|
||||
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef;
|
||||
- (PyDetailsPanel *)model;
|
||||
|
||||
- (NSWindow *)createWindow;
|
||||
- (BOOL)isVisible;
|
||||
- (void)toggleVisibility;
|
||||
|
||||
/* Python --> Cocoa */
|
||||
- (void)refresh;
|
||||
@end
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "DetailsPanel.h"
|
||||
#import "HSPyUtil.h"
|
||||
#import "DetailsPanel_UI.h"
|
||||
|
||||
@implementation DetailsPanel
|
||||
|
||||
@synthesize detailsTable;
|
||||
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef
|
||||
{
|
||||
self = [super initWithWindow:nil];
|
||||
[self setWindow:[self createWindow]];
|
||||
model = [[PyDetailsPanel alloc] initWithModel:aPyRef];
|
||||
[model bindCallback:createCallback(@"DetailsPanelView", self)];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[model release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
- (PyDetailsPanel *)model
|
||||
{
|
||||
return (PyDetailsPanel *)model;
|
||||
}
|
||||
|
||||
- (NSWindow *)createWindow
|
||||
{
|
||||
return createDetailsPanel_UI(self);
|
||||
}
|
||||
|
||||
- (void)refreshDetails
|
||||
{
|
||||
[detailsTable reloadData];
|
||||
}
|
||||
|
||||
- (BOOL)isVisible
|
||||
{
|
||||
return [[self window] isVisible];
|
||||
}
|
||||
|
||||
- (void)toggleVisibility
|
||||
{
|
||||
if ([self isVisible]) {
|
||||
[[self window] close];
|
||||
}
|
||||
else {
|
||||
[self refreshDetails]; // selection might have changed since last time
|
||||
[[self window] orderFront:nil];
|
||||
}
|
||||
}
|
||||
|
||||
/* NSTableView Delegate */
|
||||
- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView
|
||||
{
|
||||
return [[self model] numberOfRows];
|
||||
}
|
||||
|
||||
- (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)row
|
||||
{
|
||||
return [[self model] valueForColumn:[column identifier] row:row];
|
||||
}
|
||||
|
||||
/* Python --> Cocoa */
|
||||
- (void)refresh
|
||||
{
|
||||
if ([[self window] isVisible]) {
|
||||
[self refreshDetails];
|
||||
}
|
||||
}
|
||||
@end
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "DetailsPanel.h"
|
||||
#import "PyDupeGuru.h"
|
||||
|
||||
@interface DetailsPanelPicture : DetailsPanel
|
||||
{
|
||||
NSImageView *dupeImage;
|
||||
NSProgressIndicator *dupeProgressIndicator;
|
||||
NSImageView *refImage;
|
||||
NSProgressIndicator *refProgressIndicator;
|
||||
|
||||
PyDupeGuru *pyApp;
|
||||
BOOL _needsRefresh;
|
||||
NSString *_dupePath;
|
||||
NSString *_refPath;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) NSImageView *dupeImage;
|
||||
@property (readwrite, retain) NSProgressIndicator *dupeProgressIndicator;
|
||||
@property (readwrite, retain) NSImageView *refImage;
|
||||
@property (readwrite, retain) NSProgressIndicator *refProgressIndicator;
|
||||
|
||||
- (id)initWithApp:(PyDupeGuru *)aApp;
|
||||
@end
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "Utils.h"
|
||||
#import "NSNotificationAdditions.h"
|
||||
#import "NSImageAdditions.h"
|
||||
#import "PyDupeGuru.h"
|
||||
#import "DetailsPanelPicture.h"
|
||||
#import "Consts.h"
|
||||
#import "DetailsPanelPicture_UI.h"
|
||||
|
||||
@implementation DetailsPanelPicture
|
||||
|
||||
@synthesize dupeImage;
|
||||
@synthesize dupeProgressIndicator;
|
||||
@synthesize refImage;
|
||||
@synthesize refProgressIndicator;
|
||||
|
||||
- (id)initWithApp:(PyDupeGuru *)aApp
|
||||
{
|
||||
self = [super initWithPyRef:[aApp detailsPanel]];
|
||||
pyApp = aApp;
|
||||
_needsRefresh = YES;
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(imageLoaded:) name:ImageLoadedNotification object:self];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSWindow *)createWindow
|
||||
{
|
||||
return createDetailsPanelPicture_UI(self);
|
||||
}
|
||||
|
||||
- (void)loadImageAsync:(NSString *)imagePath
|
||||
{
|
||||
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
|
||||
NSImage *image = [[NSImage alloc] initByReferencingFile:imagePath];
|
||||
NSImage *thumbnail = [image imageByScalingProportionallyToSize:NSMakeSize(512,512)];
|
||||
[image release];
|
||||
NSMutableDictionary *params = [NSMutableDictionary dictionary];
|
||||
[params setValue:imagePath forKey:@"imagePath"];
|
||||
[params setValue:thumbnail forKey:@"image"];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationOnMainThreadWithName:ImageLoadedNotification object:self userInfo:params waitUntilDone:YES];
|
||||
[pool release];
|
||||
}
|
||||
|
||||
- (void)refreshDetails
|
||||
{
|
||||
if (!_needsRefresh)
|
||||
return;
|
||||
[detailsTable reloadData];
|
||||
|
||||
NSString *refPath = [pyApp getSelectedDupeRefPath];
|
||||
if (_refPath != nil)
|
||||
[_refPath autorelease];
|
||||
_refPath = [refPath retain];
|
||||
[NSThread detachNewThreadSelector:@selector(loadImageAsync:) toTarget:self withObject:refPath];
|
||||
NSString *dupePath = [pyApp getSelectedDupePath];
|
||||
if (_dupePath != nil)
|
||||
[_dupePath autorelease];
|
||||
_dupePath = [dupePath retain];
|
||||
if (![dupePath isEqual: refPath])
|
||||
[NSThread detachNewThreadSelector:@selector(loadImageAsync:) toTarget:self withObject:dupePath];
|
||||
[refProgressIndicator startAnimation:nil];
|
||||
[dupeProgressIndicator startAnimation:nil];
|
||||
_needsRefresh = NO;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
- (void)imageLoaded:(NSNotification *)aNotification
|
||||
{
|
||||
NSString *imagePath = [[aNotification userInfo] valueForKey:@"imagePath"];
|
||||
NSImage *image = [[aNotification userInfo] valueForKey:@"image"];
|
||||
if ([imagePath isEqual: _refPath])
|
||||
{
|
||||
[refImage setImage:image];
|
||||
[refProgressIndicator stopAnimation:nil];
|
||||
}
|
||||
if ([imagePath isEqual: _dupePath])
|
||||
{
|
||||
[dupeImage setImage:image];
|
||||
[dupeProgressIndicator stopAnimation:nil];
|
||||
}
|
||||
}
|
||||
|
||||
/* Python --> Cocoa */
|
||||
- (void)refresh
|
||||
{
|
||||
_needsRefresh = YES;
|
||||
[super refresh];
|
||||
}
|
||||
@end
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Python.h>
|
||||
#import "HSOutline.h"
|
||||
#import "PyDirectoryOutline.h"
|
||||
|
||||
#define DGAddedFoldersNotification @"DGAddedFoldersNotification"
|
||||
|
||||
@interface DirectoryOutline : HSOutline {}
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView;
|
||||
- (PyDirectoryOutline *)model;
|
||||
|
||||
- (void)selectAll;
|
||||
@end;
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "DirectoryOutline.h"
|
||||
|
||||
@implementation DirectoryOutline
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView
|
||||
{
|
||||
self = [super initWithPyRef:aPyRef wrapperClass:[PyDirectoryOutline class]
|
||||
callbackClassName:@"DirectoryOutlineView" view:aOutlineView];
|
||||
[[self view] registerForDraggedTypes:[NSArray arrayWithObject:NSFilenamesPboardType]];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (PyDirectoryOutline *)model
|
||||
{
|
||||
return (PyDirectoryOutline *)model;
|
||||
}
|
||||
|
||||
/* Public */
|
||||
- (void)selectAll
|
||||
{
|
||||
[[self model] selectAll];
|
||||
}
|
||||
|
||||
/* Delegate */
|
||||
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id < NSDraggingInfo >)info proposedItem:(id)item proposedChildIndex:(NSInteger)index
|
||||
{
|
||||
NSPasteboard *pboard;
|
||||
NSDragOperation sourceDragMask;
|
||||
sourceDragMask = [info draggingSourceOperationMask];
|
||||
pboard = [info draggingPasteboard];
|
||||
if ([[pboard types] containsObject:NSFilenamesPboardType]) {
|
||||
if (sourceDragMask & NSDragOperationLink)
|
||||
return NSDragOperationLink;
|
||||
}
|
||||
return NSDragOperationNone;
|
||||
}
|
||||
|
||||
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id < NSDraggingInfo >)info item:(id)item childIndex:(NSInteger)index
|
||||
{
|
||||
NSPasteboard *pboard;
|
||||
NSDragOperation sourceDragMask;
|
||||
sourceDragMask = [info draggingSourceOperationMask];
|
||||
pboard = [info draggingPasteboard];
|
||||
if ([[pboard types] containsObject:NSFilenamesPboardType]) {
|
||||
NSArray *foldernames = [pboard propertyListForType:NSFilenamesPboardType];
|
||||
if (!(sourceDragMask & NSDragOperationLink))
|
||||
return NO;
|
||||
for (NSString *foldername in foldernames) {
|
||||
[[self model] addDirectory:foldername];
|
||||
}
|
||||
NSDictionary *userInfo = [NSDictionary dictionaryWithObject:foldernames forKey:@"foldernames"];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:DGAddedFoldersNotification
|
||||
object:self userInfo:userInfo];
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)outlineView:(NSOutlineView *)aOutlineView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)tableColumn item:(id)item
|
||||
{
|
||||
if ([cell isKindOfClass:[NSTextFieldCell class]]) {
|
||||
NSTextFieldCell *textCell = cell;
|
||||
NSIndexPath *path = item;
|
||||
BOOL selected = [path isEqualTo:[[self view] selectedPath]];
|
||||
if (selected) {
|
||||
[textCell setTextColor:[NSColor blackColor]];
|
||||
return;
|
||||
}
|
||||
NSInteger state = [self intProperty:@"state" valueAtPath:path];
|
||||
if (state == 1) {
|
||||
[textCell setTextColor:[NSColor blueColor]];
|
||||
}
|
||||
else if (state == 2) {
|
||||
[textCell setTextColor:[NSColor redColor]];
|
||||
}
|
||||
else {
|
||||
[textCell setTextColor:[NSColor blackColor]];
|
||||
}
|
||||
}
|
||||
}
|
||||
@end
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "HSOutlineView.h"
|
||||
#import "HSRecentFiles.h"
|
||||
#import "DirectoryOutline.h"
|
||||
#import "PyDupeGuru.h"
|
||||
|
||||
@class AppDelegate;
|
||||
|
||||
@interface DirectoryPanel : NSWindowController <NSOpenSavePanelDelegate>
|
||||
{
|
||||
AppDelegate *_app;
|
||||
PyDupeGuru *model;
|
||||
HSRecentFiles *_recentDirectories;
|
||||
DirectoryOutline *outline;
|
||||
BOOL _alwaysShowPopUp;
|
||||
NSSegmentedControl *appModeSelector;
|
||||
NSPopUpButton *scanTypePopup;
|
||||
NSPopUpButton *addButtonPopUp;
|
||||
NSPopUpButton *loadRecentButtonPopUp;
|
||||
HSOutlineView *outlineView;
|
||||
NSButton *removeButton;
|
||||
NSButton *loadResultsButton;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) NSSegmentedControl *appModeSelector;
|
||||
@property (readwrite, retain) NSPopUpButton *scanTypePopup;
|
||||
@property (readwrite, retain) NSPopUpButton *addButtonPopUp;
|
||||
@property (readwrite, retain) NSPopUpButton *loadRecentButtonPopUp;
|
||||
@property (readwrite, retain) HSOutlineView *outlineView;
|
||||
@property (readwrite, retain) NSButton *removeButton;
|
||||
@property (readwrite, retain) NSButton *loadResultsButton;
|
||||
|
||||
- (id)initWithParentApp:(AppDelegate *)aParentApp;
|
||||
|
||||
- (void)fillPopUpMenu;
|
||||
- (void)fillScanTypeMenu;
|
||||
- (void)adjustUIToLocalization;
|
||||
|
||||
- (void)askForDirectory;
|
||||
- (void)popupAddDirectoryMenu:(id)sender;
|
||||
- (void)popupLoadRecentMenu:(id)sender;
|
||||
- (void)removeSelectedDirectory;
|
||||
- (void)startDuplicateScan;
|
||||
|
||||
- (void)addDirectory:(NSString *)directory;
|
||||
- (void)refreshRemoveButtonText;
|
||||
- (void)markAll;
|
||||
|
||||
@end
|
||||
@@ -1,256 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "DirectoryPanel.h"
|
||||
#import "DirectoryPanel_UI.h"
|
||||
#import "Dialogs.h"
|
||||
#import "Utils.h"
|
||||
#import "AppDelegate.h"
|
||||
#import "Consts.h"
|
||||
|
||||
@implementation DirectoryPanel
|
||||
|
||||
@synthesize appModeSelector;
|
||||
@synthesize scanTypePopup;
|
||||
@synthesize addButtonPopUp;
|
||||
@synthesize loadRecentButtonPopUp;
|
||||
@synthesize outlineView;
|
||||
@synthesize removeButton;
|
||||
@synthesize loadResultsButton;
|
||||
|
||||
- (id)initWithParentApp:(AppDelegate *)aParentApp
|
||||
{
|
||||
self = [super initWithWindow:nil];
|
||||
[self setWindow:createDirectoryPanel_UI(self)];
|
||||
_app = aParentApp;
|
||||
model = [_app model];
|
||||
[[self window] setTitle:[model appName]];
|
||||
self.appModeSelector.selectedSegment = 0;
|
||||
[self fillScanTypeMenu];
|
||||
_alwaysShowPopUp = NO;
|
||||
[self fillPopUpMenu];
|
||||
_recentDirectories = [[HSRecentFiles alloc] initWithName:@"recentDirectories" menu:[addButtonPopUp menu]];
|
||||
[_recentDirectories setDelegate:self];
|
||||
outline = [[DirectoryOutline alloc] initWithPyRef:[model directoryTree] outlineView:outlineView];
|
||||
[self refreshRemoveButtonText];
|
||||
[self adjustUIToLocalization];
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(directorySelectionChanged:)
|
||||
name:NSOutlineViewSelectionDidChangeNotification object:outlineView];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(outlineAddedFolders:)
|
||||
name:DGAddedFoldersNotification object:outline];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[outline release];
|
||||
[_recentDirectories release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
/* Private */
|
||||
|
||||
- (void)fillPopUpMenu
|
||||
{
|
||||
NSMenu *m = [addButtonPopUp menu];
|
||||
NSMenuItem *mi = [m addItemWithTitle:NSLocalizedString(@"Add New Folder...", @"") action:@selector(askForDirectory) keyEquivalent:@""];
|
||||
[mi setTarget:self];
|
||||
[m addItem:[NSMenuItem separatorItem]];
|
||||
}
|
||||
|
||||
- (void)fillScanTypeMenu
|
||||
{
|
||||
[[self scanTypePopup] unbind:@"selectedIndex"];
|
||||
[[self scanTypePopup] removeAllItems];
|
||||
[[self scanTypePopup] addItemsWithTitles:[[_app model] getScanOptions]];
|
||||
NSString *keypath;
|
||||
NSInteger appMode = [_app getAppMode];
|
||||
if (appMode == AppModePicture) {
|
||||
keypath = @"values.scanTypePicture";
|
||||
}
|
||||
else if (appMode == AppModeMusic) {
|
||||
keypath = @"values.scanTypeMusic";
|
||||
}
|
||||
else {
|
||||
keypath = @"values.scanTypeStandard";
|
||||
}
|
||||
[[self scanTypePopup] bind:@"selectedIndex" toObject:[NSUserDefaultsController sharedUserDefaultsController] withKeyPath:keypath options:nil];
|
||||
}
|
||||
|
||||
- (void)adjustUIToLocalization
|
||||
{
|
||||
NSString *lang = [[NSBundle preferredLocalizationsFromArray:[[NSBundle mainBundle] localizations]] objectAtIndex:0];
|
||||
NSInteger loadResultsWidthDelta = 0;
|
||||
if ([lang isEqual:@"ru"]) {
|
||||
loadResultsWidthDelta = 50;
|
||||
}
|
||||
else if ([lang isEqual:@"uk"]) {
|
||||
loadResultsWidthDelta = 70;
|
||||
}
|
||||
else if ([lang isEqual:@"hy"]) {
|
||||
loadResultsWidthDelta = 30;
|
||||
}
|
||||
if (loadResultsWidthDelta) {
|
||||
NSRect r = [loadResultsButton frame];
|
||||
r.size.width += loadResultsWidthDelta;
|
||||
r.origin.x -= loadResultsWidthDelta;
|
||||
[loadResultsButton setFrame:r];
|
||||
}
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
- (void)askForDirectory
|
||||
{
|
||||
NSOpenPanel *op = [NSOpenPanel openPanel];
|
||||
[op setCanChooseFiles:YES];
|
||||
[op setCanChooseDirectories:YES];
|
||||
[op setAllowsMultipleSelection:YES];
|
||||
[op setTitle:NSLocalizedString(@"Select a folder to add to the scanning list", @"")];
|
||||
[op setDelegate:self];
|
||||
if ([op runModal] == NSOKButton) {
|
||||
for (NSURL *directoryURL in [op URLs]) {
|
||||
[self addDirectory:[directoryURL path]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)changeAppMode:(id)sender
|
||||
{
|
||||
NSInteger appMode;
|
||||
NSUInteger selectedSegment = self.appModeSelector.selectedSegment;
|
||||
if (selectedSegment == 2) {
|
||||
appMode = AppModePicture;
|
||||
}
|
||||
else if (selectedSegment == 1) {
|
||||
appMode = AppModeMusic;
|
||||
}
|
||||
else {
|
||||
appMode = AppModeStandard;
|
||||
}
|
||||
[_app setAppMode:appMode];
|
||||
[self fillScanTypeMenu];
|
||||
}
|
||||
|
||||
- (void)popupAddDirectoryMenu:(id)sender
|
||||
{
|
||||
if ((!_alwaysShowPopUp) && ([[_recentDirectories filepaths] count] == 0)) {
|
||||
[self askForDirectory];
|
||||
}
|
||||
else {
|
||||
[addButtonPopUp selectItem:nil];
|
||||
[[addButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)popupLoadRecentMenu:(id)sender
|
||||
{
|
||||
if ([[[_app recentResults] filepaths] count] > 0) {
|
||||
NSMenu *m = [loadRecentButtonPopUp menu];
|
||||
while ([m numberOfItems] > 0) {
|
||||
[m removeItemAtIndex:0];
|
||||
}
|
||||
NSMenuItem *mi = [m addItemWithTitle:NSLocalizedString(@"Load from file...", @"") action:@selector(loadResults) keyEquivalent:@""];
|
||||
[mi setTarget:_app];
|
||||
[m addItem:[NSMenuItem separatorItem]];
|
||||
[[_app recentResults] fillMenu:m];
|
||||
[loadRecentButtonPopUp selectItem:nil];
|
||||
[[loadRecentButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]];
|
||||
}
|
||||
else {
|
||||
[_app loadResults];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)removeSelectedDirectory
|
||||
{
|
||||
[[self window] makeKeyAndOrderFront:nil];
|
||||
[[outline model] removeSelectedDirectory];
|
||||
[self refreshRemoveButtonText];
|
||||
}
|
||||
|
||||
- (void)startDuplicateScan
|
||||
{
|
||||
if ([model resultsAreModified]) {
|
||||
if ([Dialogs askYesNo:NSLocalizedString(@"You have unsaved results, do you really want to continue?", @"")] == NSAlertSecondButtonReturn) // NO
|
||||
return;
|
||||
}
|
||||
[_app setScanOptions];
|
||||
[model doScan];
|
||||
}
|
||||
|
||||
/* Public */
|
||||
- (void)addDirectory:(NSString *)directory
|
||||
{
|
||||
[model addDirectory:directory];
|
||||
[_recentDirectories addFile:directory];
|
||||
[[self window] makeKeyAndOrderFront:nil];
|
||||
}
|
||||
|
||||
- (void)refreshRemoveButtonText
|
||||
{
|
||||
if ([outlineView selectedRow] < 0) {
|
||||
[removeButton setEnabled:NO];
|
||||
return;
|
||||
}
|
||||
[removeButton setEnabled:YES];
|
||||
NSIndexPath *path = [outline selectedIndexPath];
|
||||
if (path != nil) {
|
||||
NSInteger state = [outline intProperty:@"state" valueAtPath:path];
|
||||
BOOL shouldDisplayArrow = ([path length] > 1) && (state == 2);
|
||||
NSString *imgName = shouldDisplayArrow ? @"NSGoLeftTemplate" : @"NSRemoveTemplate";
|
||||
[removeButton setImage:[NSImage imageNamed:imgName]];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)markAll
|
||||
{
|
||||
/* markAll isn't very descriptive of what we do, but since we re-use the Mark All button from
|
||||
the result window, we don't have much choice.
|
||||
*/
|
||||
[outline selectAll];
|
||||
}
|
||||
|
||||
/* Delegate */
|
||||
- (BOOL)panel:(id)sender shouldShowFilename:(NSString *)path
|
||||
{
|
||||
BOOL isdir;
|
||||
[[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isdir];
|
||||
return isdir;
|
||||
}
|
||||
|
||||
- (void)recentFileClicked:(NSString *)path
|
||||
{
|
||||
[self addDirectory:path];
|
||||
}
|
||||
|
||||
- (BOOL)validateMenuItem:(NSMenuItem *)item
|
||||
{
|
||||
if ([item action] == @selector(markAll)) {
|
||||
[item setTitle:NSLocalizedString(@"Select All", @"")];
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
|
||||
- (void)directorySelectionChanged:(NSNotification *)aNotification
|
||||
{
|
||||
[self refreshRemoveButtonText];
|
||||
}
|
||||
|
||||
- (void)outlineAddedFolders:(NSNotification *)aNotification
|
||||
{
|
||||
NSArray *foldernames = [[aNotification userInfo] objectForKey:@"foldernames"];
|
||||
for (NSString *foldername in foldernames) {
|
||||
[_recentDirectories addFile:foldername];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "PyIgnoreListDialog.h"
|
||||
#import "HSTable.h"
|
||||
|
||||
@interface IgnoreListDialog : NSWindowController
|
||||
{
|
||||
PyIgnoreListDialog *model;
|
||||
HSTable *ignoreListTable;
|
||||
NSTableView *ignoreListTableView;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) PyIgnoreListDialog *model;
|
||||
@property (readwrite, retain) NSTableView *ignoreListTableView;
|
||||
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef;
|
||||
- (void)initializeColumns;
|
||||
@end
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "IgnoreListDialog.h"
|
||||
#import "IgnoreListDialog_UI.h"
|
||||
#import "HSPyUtil.h"
|
||||
|
||||
@implementation IgnoreListDialog
|
||||
|
||||
@synthesize model;
|
||||
@synthesize ignoreListTableView;
|
||||
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef
|
||||
{
|
||||
self = [super initWithWindow:nil];
|
||||
self.model = [[[PyIgnoreListDialog alloc] initWithModel:aPyRef] autorelease];
|
||||
[self.model bindCallback:createCallback(@"IgnoreListDialogView", self)];
|
||||
[self setWindow:createIgnoreListDialog_UI(self)];
|
||||
ignoreListTable = [[HSTable alloc] initWithPyRef:[model ignoreListTable] tableView:ignoreListTableView];
|
||||
[self initializeColumns];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[ignoreListTable release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
- (void)initializeColumns
|
||||
{
|
||||
HSColumnDef defs[] = {
|
||||
{@"path1", 240, 40, 0, NO, nil},
|
||||
{@"path2", 240, 40, 0, NO, nil},
|
||||
nil
|
||||
};
|
||||
[[ignoreListTable columns] initializeColumns:defs];
|
||||
[[ignoreListTable columns] setColumnsAsReadOnly];
|
||||
}
|
||||
|
||||
/* model --> view */
|
||||
- (void)show
|
||||
{
|
||||
[self showWindow:self];
|
||||
}
|
||||
@end
|
||||
@@ -1,38 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>dupeGuru</string>
|
||||
<key>CFBundleHelpBookFolder</key>
|
||||
<string>dupeguru_help</string>
|
||||
<key>CFBundleHelpBookName</key>
|
||||
<string>dupeGuru Help</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>dupeguru</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.hardcoded-software.dupeguru</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>dupeGuru</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>hsft</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{version}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{version}</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© Hardcoded Software, 2016</string>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://www.hardcoded.net/updates/dupeguru.appcast</string>
|
||||
<key>SUPublicDSAKeyFile</key>
|
||||
<string>dsa_pub.pem</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "PyPrioritizeDialog.h"
|
||||
#import "HSPopUpList.h"
|
||||
#import "HSSelectableList.h"
|
||||
#import "PrioritizeList.h"
|
||||
#import "PyDupeGuru.h"
|
||||
|
||||
@interface PrioritizeDialog : NSWindowController
|
||||
{
|
||||
NSPopUpButton *categoryPopUpView;
|
||||
NSTableView *criteriaTableView;
|
||||
NSTableView *prioritizationTableView;
|
||||
|
||||
PyPrioritizeDialog *model;
|
||||
HSPopUpList *categoryPopUp;
|
||||
HSSelectableList *criteriaList;
|
||||
PrioritizeList *prioritizationList;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) NSPopUpButton *categoryPopUpView;
|
||||
@property (readwrite, retain) NSTableView *criteriaTableView;
|
||||
@property (readwrite, retain) NSTableView *prioritizationTableView;
|
||||
|
||||
- (id)initWithApp:(PyDupeGuru *)aApp;
|
||||
- (PyPrioritizeDialog *)model;
|
||||
|
||||
- (void)ok;
|
||||
- (void)cancel;
|
||||
@end;
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "PrioritizeDialog.h"
|
||||
#import "PrioritizeDialog_UI.h"
|
||||
#import "HSPyUtil.h"
|
||||
|
||||
@implementation PrioritizeDialog
|
||||
|
||||
@synthesize categoryPopUpView;
|
||||
@synthesize criteriaTableView;
|
||||
@synthesize prioritizationTableView;
|
||||
|
||||
- (id)initWithApp:(PyDupeGuru *)aApp
|
||||
{
|
||||
self = [super initWithWindowNibName:@"PrioritizeDialog"];
|
||||
model = [[PyPrioritizeDialog alloc] initWithApp:[aApp pyRef]];
|
||||
[self setWindow:createPrioritizeDialog_UI(self)];
|
||||
categoryPopUp = [[HSPopUpList alloc] initWithPyRef:[[self model] categoryList] popupView:categoryPopUpView];
|
||||
criteriaList = [[HSSelectableList alloc] initWithPyRef:[[self model] criteriaList] tableView:criteriaTableView];
|
||||
prioritizationList = [[PrioritizeList alloc] initWithPyRef:[[self model] prioritizationList] tableView:prioritizationTableView];
|
||||
[model bindCallback:createCallback(@"PrioritizeDialogView", self)];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[categoryPopUp release];
|
||||
[criteriaList release];
|
||||
[prioritizationList release];
|
||||
[model release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
- (PyPrioritizeDialog *)model
|
||||
{
|
||||
return (PyPrioritizeDialog *)model;
|
||||
}
|
||||
|
||||
- (void)ok
|
||||
{
|
||||
[NSApp stopModal];
|
||||
[self close];
|
||||
}
|
||||
|
||||
- (void)cancel
|
||||
{
|
||||
[NSApp abortModal];
|
||||
[self close];
|
||||
}
|
||||
@end
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "HSSelectableList.h"
|
||||
#import "PyPrioritizeList.h"
|
||||
|
||||
@interface PrioritizeList : HSSelectableList {}
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef tableView:(NSTableView *)aTableView;
|
||||
- (PyPrioritizeList *)model;
|
||||
@end
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "PrioritizeList.h"
|
||||
#import "Utils.h"
|
||||
#import "Consts.h"
|
||||
|
||||
@implementation PrioritizeList
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef tableView:(NSTableView *)aTableView
|
||||
{
|
||||
self = [super initWithPyRef:aPyRef wrapperClass:[PyPrioritizeList class]
|
||||
callbackClassName:@"PrioritizeListView" view:aTableView];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (PyPrioritizeList *)model
|
||||
{
|
||||
return (PyPrioritizeList *)model;
|
||||
}
|
||||
|
||||
- (void)setView:(NSTableView *)aTableView
|
||||
{
|
||||
[super setView:aTableView];
|
||||
[[self view] registerForDraggedTypes:[NSArray arrayWithObject:DGPrioritizeIndexPasteboardType]];
|
||||
}
|
||||
|
||||
- (BOOL)tableView:(NSTableView *)tv writeRowsWithIndexes:(NSIndexSet *)rowIndexes toPasteboard:(NSPasteboard*)pboard
|
||||
{
|
||||
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:rowIndexes];
|
||||
[pboard declareTypes:[NSArray arrayWithObject:DGPrioritizeIndexPasteboardType] owner:self];
|
||||
[pboard setData:data forType:DGPrioritizeIndexPasteboardType];
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (NSDragOperation)tableView:(NSTableView*)tv validateDrop:(id <NSDraggingInfo>)info proposedRow:(NSInteger)row
|
||||
proposedDropOperation:(NSTableViewDropOperation)op
|
||||
{
|
||||
if (op == NSTableViewDropAbove) {
|
||||
return NSDragOperationMove;
|
||||
}
|
||||
return NSDragOperationNone;
|
||||
}
|
||||
|
||||
- (BOOL)tableView:(NSTableView *)aTableView acceptDrop:(id <NSDraggingInfo>)info
|
||||
row:(NSInteger)row dropOperation:(NSTableViewDropOperation)operation
|
||||
{
|
||||
NSPasteboard* pboard = [info draggingPasteboard];
|
||||
NSData* rowData = [pboard dataForType:DGPrioritizeIndexPasteboardType];
|
||||
NSIndexSet* rowIndexes = [NSKeyedUnarchiver unarchiveObjectWithData:rowData];
|
||||
[[self model] moveIndexes:[Utils indexSet2Array:rowIndexes] toIndex:row];
|
||||
return YES;
|
||||
}
|
||||
@end
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "PyProblemDialog.h"
|
||||
#import "HSTable.h"
|
||||
|
||||
@interface ProblemDialog : NSWindowController
|
||||
{
|
||||
PyProblemDialog *model;
|
||||
HSTable *problemTable;
|
||||
NSTableView *problemTableView;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) PyProblemDialog *model;
|
||||
@property (readwrite, retain) NSTableView *problemTableView;
|
||||
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef;
|
||||
|
||||
- (void)initializeColumns;
|
||||
@end
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "ProblemDialog.h"
|
||||
#import "ProblemDialog_UI.h"
|
||||
#import "Utils.h"
|
||||
|
||||
@implementation ProblemDialog
|
||||
|
||||
@synthesize model;
|
||||
@synthesize problemTableView;
|
||||
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef
|
||||
{
|
||||
self = [super initWithWindow:nil];
|
||||
self.model = [[PyProblemDialog alloc] initWithModel:aPyRef];
|
||||
[self setWindow:createProblemDialog_UI(self)];
|
||||
problemTable = [[HSTable alloc] initWithPyRef:[self.model problemTable] tableView:problemTableView];
|
||||
[self initializeColumns];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[problemTable release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
- (void)initializeColumns
|
||||
{
|
||||
HSColumnDef defs[] = {
|
||||
{@"path", 202, 40, 0, NO, nil},
|
||||
{@"msg", 228, 40, 0, NO, nil},
|
||||
nil
|
||||
};
|
||||
[[problemTable columns] initializeColumns:defs];
|
||||
[[problemTable columns] setColumnsAsReadOnly];
|
||||
}
|
||||
@end
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Quartz/Quartz.h>
|
||||
#import "HSTable.h"
|
||||
#import "PyResultTable.h"
|
||||
|
||||
@interface ResultTable : HSTable <QLPreviewPanelDataSource, QLPreviewPanelDelegate>
|
||||
{
|
||||
}
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTableView *)aTableView;
|
||||
- (PyResultTable *)model;
|
||||
- (BOOL)powerMarkerMode;
|
||||
- (void)setPowerMarkerMode:(BOOL)aPowerMarkerMode;
|
||||
- (BOOL)deltaValuesMode;
|
||||
- (void)setDeltaValuesMode:(BOOL)aDeltaValuesMode;
|
||||
@end;
|
||||
@@ -1,180 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "ResultTable.h"
|
||||
#import "Dialogs.h"
|
||||
#import "Utils.h"
|
||||
#import "HSQuicklook.h"
|
||||
|
||||
@interface HSTable (private)
|
||||
- (void)setPySelection;
|
||||
- (void)setViewSelection;
|
||||
@end
|
||||
|
||||
@implementation ResultTable
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTableView *)aTableView
|
||||
{
|
||||
self = [super initWithPyRef:aPyRef wrapperClass:[PyResultTable class] callbackClassName:@"ResultTableView" view:aTableView];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (PyResultTable *)model
|
||||
{
|
||||
return (PyResultTable *)model;
|
||||
}
|
||||
|
||||
/* Private */
|
||||
- (void)updateQuicklookIfNeeded
|
||||
{
|
||||
if ([[QLPreviewPanel sharedPreviewPanel] dataSource] == self) {
|
||||
[[QLPreviewPanel sharedPreviewPanel] reloadData];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setPySelection
|
||||
{
|
||||
[super setPySelection];
|
||||
[self updateQuicklookIfNeeded];
|
||||
}
|
||||
|
||||
- (void)setViewSelection
|
||||
{
|
||||
[super setViewSelection];
|
||||
[self updateQuicklookIfNeeded];
|
||||
}
|
||||
|
||||
/* Public */
|
||||
- (BOOL)powerMarkerMode
|
||||
{
|
||||
return [[self model] powerMarkerMode];
|
||||
}
|
||||
|
||||
- (void)setPowerMarkerMode:(BOOL)aPowerMarkerMode
|
||||
{
|
||||
[[self model] setPowerMarkerMode:aPowerMarkerMode];
|
||||
}
|
||||
|
||||
- (BOOL)deltaValuesMode
|
||||
{
|
||||
return [[self model] deltaValuesMode];
|
||||
}
|
||||
|
||||
- (void)setDeltaValuesMode:(BOOL)aDeltaValuesMode
|
||||
{
|
||||
[[self model] setDeltaValuesMode:aDeltaValuesMode];
|
||||
}
|
||||
|
||||
/* Datasource */
|
||||
- (id)tableView:(NSTableView *)aTableView objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)row
|
||||
{
|
||||
NSString *identifier = [column identifier];
|
||||
if ([identifier isEqual:@"marked"]) {
|
||||
return [[self model] valueForColumn:@"marked" row:row];
|
||||
}
|
||||
return [[self model] valueForRow:row column:identifier];
|
||||
}
|
||||
|
||||
- (void)tableView:(NSTableView *)aTableView setObjectValue:(id)object forTableColumn:(NSTableColumn *)column row:(NSInteger)row
|
||||
{
|
||||
NSString *identifier = [column identifier];
|
||||
if ([identifier isEqual:@"marked"]) {
|
||||
[[self model] setValue:object forColumn:identifier row:row];
|
||||
}
|
||||
else if ([identifier isEqual:@"name"]) {
|
||||
NSString *oldName = [[self model] valueForRow:row column:identifier];
|
||||
NSString *newName = object;
|
||||
if (![newName isEqual:oldName]) {
|
||||
BOOL renamed = [[self model] renameSelected:newName];
|
||||
if (!renamed) {
|
||||
[Dialogs showMessage:[NSString stringWithFormat:NSLocalizedString(@"The name '%@' already exists.", @""), newName]];
|
||||
}
|
||||
else {
|
||||
[[self view] setNeedsDisplay:YES];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Delegate */
|
||||
- (void)tableView:(NSTableView *)aTableView didClickTableColumn:(NSTableColumn *)tableColumn
|
||||
{
|
||||
if ([[[self view] sortDescriptors] count] < 1)
|
||||
return;
|
||||
NSSortDescriptor *sd = [[[self view] sortDescriptors] objectAtIndex:0];
|
||||
[[self model] sortBy:[sd key] ascending:[sd ascending]];
|
||||
}
|
||||
|
||||
- (void)tableView:(NSTableView *)aTableView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)column row:(NSInteger)row
|
||||
{
|
||||
BOOL isSelected = [[self view] isRowSelected:row];
|
||||
BOOL isMarkable = n2b([[self model] valueForColumn:@"markable" row:row]);
|
||||
if ([[column identifier] isEqual:@"marked"]) {
|
||||
[cell setEnabled:isMarkable];
|
||||
// Low-tech solution, for indentation, but it works...
|
||||
NSCellImagePosition pos = isMarkable ? NSImageRight : NSImageLeft;
|
||||
[cell setImagePosition:pos];
|
||||
}
|
||||
if ([cell isKindOfClass:[NSTextFieldCell class]]) {
|
||||
NSColor *color = [NSColor textColor];
|
||||
if (isSelected) {
|
||||
color = [NSColor selectedTextColor];
|
||||
}
|
||||
else if (isMarkable) {
|
||||
if ([[self model] isDeltaAtRow:row column:[column identifier]]) {
|
||||
color = [NSColor orangeColor];
|
||||
}
|
||||
}
|
||||
else {
|
||||
color = [NSColor blueColor];
|
||||
}
|
||||
[(NSTextFieldCell *)cell setTextColor:color];
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)tableViewHadDeletePressed:(NSTableView *)tableView
|
||||
{
|
||||
[[self model] removeSelected];
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)tableViewHadSpacePressed:(NSTableView *)tableView
|
||||
{
|
||||
[[self model] markSelected];
|
||||
return YES;
|
||||
}
|
||||
|
||||
/* Quicklook */
|
||||
- (NSInteger)numberOfPreviewItemsInPreviewPanel:(QLPreviewPanel *)panel
|
||||
{
|
||||
return [[[self model] selectedRows] count];
|
||||
}
|
||||
|
||||
- (id <QLPreviewItem>)previewPanel:(QLPreviewPanel *)panel previewItemAtIndex:(NSInteger)index
|
||||
{
|
||||
NSArray *selectedRows = [[self model] selectedRows];
|
||||
NSInteger absIndex = n2i([selectedRows objectAtIndex:index]);
|
||||
NSString *path = [[self model] pathAtIndex:absIndex];
|
||||
return [[HSQLPreviewItem alloc] initWithUrl:[NSURL fileURLWithPath:path] title:path];
|
||||
}
|
||||
|
||||
- (BOOL)previewPanel:(QLPreviewPanel *)panel handleEvent:(NSEvent *)event
|
||||
{
|
||||
// redirect all key down events to the table view
|
||||
if ([event type] == NSKeyDown) {
|
||||
[[self view] keyDown:event];
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
/* Python --> Cocoa */
|
||||
- (void)invalidateMarkings
|
||||
{
|
||||
[[self view] setNeedsDisplay:YES];
|
||||
}
|
||||
@end
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Quartz/Quartz.h>
|
||||
#import "StatsLabel.h"
|
||||
#import "ResultTable.h"
|
||||
#import "HSTableView.h"
|
||||
#import "PyDupeGuru.h"
|
||||
|
||||
@class AppDelegate;
|
||||
|
||||
@interface ResultWindow : NSWindowController
|
||||
{
|
||||
@protected
|
||||
NSSegmentedControl *optionsSwitch;
|
||||
NSToolbarItem *optionsToolbarItem;
|
||||
HSTableView *matches;
|
||||
NSTextField *stats;
|
||||
NSSearchField *filterField;
|
||||
|
||||
AppDelegate *app;
|
||||
PyDupeGuru *model;
|
||||
ResultTable *table;
|
||||
StatsLabel *statsLabel;
|
||||
QLPreviewPanel* previewPanel;
|
||||
}
|
||||
|
||||
@property (readwrite, retain) NSSegmentedControl *optionsSwitch;
|
||||
@property (readwrite, retain) NSToolbarItem *optionsToolbarItem;
|
||||
@property (readwrite, retain) HSTableView *matches;
|
||||
@property (readwrite, retain) NSTextField *stats;
|
||||
@property (readwrite, retain) NSSearchField *filterField;
|
||||
|
||||
- (id)initWithParentApp:(AppDelegate *)app;
|
||||
|
||||
/* Helpers */
|
||||
- (void)fillColumnsMenu;
|
||||
- (void)updateOptionSegments;
|
||||
- (void)adjustUIToLocalization;
|
||||
- (void)initResultColumns:(ResultTable *)aTable;
|
||||
|
||||
/* Actions */
|
||||
- (void)changeOptions;
|
||||
- (void)copyMarked;
|
||||
- (void)trashMarked;
|
||||
- (void)filter;
|
||||
- (void)focusOnFilterField;
|
||||
- (void)ignoreSelected;
|
||||
- (void)invokeCustomCommand;
|
||||
- (void)markAll;
|
||||
- (void)markInvert;
|
||||
- (void)markNone;
|
||||
- (void)markSelected;
|
||||
- (void)moveMarked;
|
||||
- (void)openClicked;
|
||||
- (void)openSelected;
|
||||
- (void)removeMarked;
|
||||
- (void)removeSelected;
|
||||
- (void)renameSelected;
|
||||
- (void)reprioritizeResults;
|
||||
- (void)resetColumnsToDefault;
|
||||
- (void)revealSelected;
|
||||
- (void)saveResults;
|
||||
- (void)switchSelected;
|
||||
- (void)toggleColumn:(id)sender;
|
||||
- (void)toggleDelta;
|
||||
- (void)toggleDetailsPanel;
|
||||
- (void)togglePowerMarker;
|
||||
- (void)toggleQuicklookPanel;
|
||||
@end
|
||||
@@ -1,406 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "ResultWindow.h"
|
||||
#import "ResultWindow_UI.h"
|
||||
#import "Dialogs.h"
|
||||
#import "ProgressController.h"
|
||||
#import "Utils.h"
|
||||
#import "AppDelegate.h"
|
||||
#import "Consts.h"
|
||||
#import "PrioritizeDialog.h"
|
||||
|
||||
@implementation ResultWindow
|
||||
|
||||
@synthesize optionsSwitch;
|
||||
@synthesize optionsToolbarItem;
|
||||
@synthesize matches;
|
||||
@synthesize stats;
|
||||
@synthesize filterField;
|
||||
|
||||
- (id)initWithParentApp:(AppDelegate *)aApp;
|
||||
{
|
||||
self = [super initWithWindow:nil];
|
||||
app = aApp;
|
||||
model = [app model];
|
||||
[self setWindow:createResultWindow_UI(self)];
|
||||
[[self window] setTitle:fmt(NSLocalizedString(@"%@ Results", @""), [model appName])];
|
||||
/* Put a cute iTunes-like bottom bar */
|
||||
[[self window] setContentBorderThickness:28 forEdge:NSMinYEdge];
|
||||
table = [[ResultTable alloc] initWithPyRef:[model resultTable] view:matches];
|
||||
statsLabel = [[StatsLabel alloc] initWithPyRef:[model statsLabel] view:stats];
|
||||
[self initResultColumns:table];
|
||||
[[table columns] setColumnsAsReadOnly];
|
||||
[self fillColumnsMenu];
|
||||
[matches setTarget:self];
|
||||
[matches setDoubleAction:@selector(openClicked)];
|
||||
[self adjustUIToLocalization];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[table release];
|
||||
[statsLabel release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
/* Helpers */
|
||||
- (void)fillColumnsMenu
|
||||
{
|
||||
[[app columnsMenu] removeAllItems];
|
||||
NSArray *menuItems = [[[table columns] model] menuItems];
|
||||
for (NSInteger i=0; i < [menuItems count]; i++) {
|
||||
NSArray *pair = [menuItems objectAtIndex:i];
|
||||
NSString *display = [pair objectAtIndex:0];
|
||||
BOOL marked = n2b([pair objectAtIndex:1]);
|
||||
NSMenuItem *mi = [[app columnsMenu] addItemWithTitle:display action:@selector(toggleColumn:) keyEquivalent:@""];
|
||||
[mi setTarget:self];
|
||||
[mi setState:marked ? NSOnState : NSOffState];
|
||||
[mi setTag:i];
|
||||
}
|
||||
[[app columnsMenu] addItem:[NSMenuItem separatorItem]];
|
||||
NSMenuItem *mi = [[app columnsMenu] addItemWithTitle:NSLocalizedString(@"Reset to Default", @"")
|
||||
action:@selector(resetColumnsToDefault) keyEquivalent:@""];
|
||||
[mi setTarget:self];
|
||||
}
|
||||
|
||||
- (void)updateOptionSegments
|
||||
{
|
||||
[optionsSwitch setSelected:[[app detailsPanel] isVisible] forSegment:0];
|
||||
[optionsSwitch setSelected:[table powerMarkerMode] forSegment:1];
|
||||
[optionsSwitch setSelected:[table deltaValuesMode] forSegment:2];
|
||||
}
|
||||
|
||||
- (void)adjustUIToLocalization
|
||||
{
|
||||
NSString *lang = [[NSBundle preferredLocalizationsFromArray:[[NSBundle mainBundle] localizations]] objectAtIndex:0];
|
||||
NSInteger seg1delta = 0;
|
||||
NSInteger seg2delta = 0;
|
||||
if ([lang isEqual:@"ru"]) {
|
||||
seg2delta = 20;
|
||||
}
|
||||
else if ([lang isEqual:@"uk"]) {
|
||||
seg2delta = 20;
|
||||
}
|
||||
else if ([lang isEqual:@"hy"]) {
|
||||
seg1delta = 20;
|
||||
}
|
||||
if (seg1delta || seg2delta) {
|
||||
[optionsSwitch setWidth:[optionsSwitch widthForSegment:0]+seg1delta forSegment:0];
|
||||
[optionsSwitch setWidth:[optionsSwitch widthForSegment:1]+seg2delta forSegment:1];
|
||||
NSSize s = [optionsToolbarItem maxSize];
|
||||
s.width += seg1delta + seg2delta;
|
||||
[optionsToolbarItem setMaxSize:s];
|
||||
[optionsToolbarItem setMinSize:s];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)initResultColumns:(ResultTable *)aTable
|
||||
{
|
||||
NSInteger appMode = [app getAppMode];
|
||||
if (appMode == AppModePicture) {
|
||||
HSColumnDef defs[] = {
|
||||
{@"marked", 26, 26, 26, YES, [NSButtonCell class]},
|
||||
{@"name", 162, 16, 0, YES, nil},
|
||||
{@"folder_path", 142, 16, 0, YES, nil},
|
||||
{@"size", 63, 16, 0, YES, nil},
|
||||
{@"extension", 40, 16, 0, YES, nil},
|
||||
{@"dimensions", 73, 16, 0, YES, nil},
|
||||
{@"exif_timestamp", 120, 16, 0, YES, nil},
|
||||
{@"mtime", 120, 16, 0, YES, nil},
|
||||
{@"percentage", 58, 16, 0, YES, nil},
|
||||
{@"dupe_count", 80, 16, 0, YES, nil},
|
||||
nil
|
||||
};
|
||||
[[aTable columns] initializeColumns:defs];
|
||||
NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"];
|
||||
[[c dataCell] setButtonType:NSSwitchButton];
|
||||
[[c dataCell] setControlSize:NSSmallControlSize];
|
||||
c = [[aTable view] tableColumnWithIdentifier:@"size"];
|
||||
[[c dataCell] setAlignment:NSRightTextAlignment];
|
||||
}
|
||||
else if (appMode == AppModeMusic) {
|
||||
HSColumnDef defs[] = {
|
||||
{@"marked", 26, 26, 26, YES, [NSButtonCell class]},
|
||||
{@"name", 235, 16, 0, YES, nil},
|
||||
{@"folder_path", 120, 16, 0, YES, nil},
|
||||
{@"size", 63, 16, 0, YES, nil},
|
||||
{@"duration", 50, 16, 0, YES, nil},
|
||||
{@"bitrate", 50, 16, 0, YES, nil},
|
||||
{@"samplerate", 60, 16, 0, YES, nil},
|
||||
{@"extension", 40, 16, 0, YES, nil},
|
||||
{@"mtime", 120, 16, 0, YES, nil},
|
||||
{@"title", 120, 16, 0, YES, nil},
|
||||
{@"artist", 120, 16, 0, YES, nil},
|
||||
{@"album", 120, 16, 0, YES, nil},
|
||||
{@"genre", 80, 16, 0, YES, nil},
|
||||
{@"year", 40, 16, 0, YES, nil},
|
||||
{@"track", 40, 16, 0, YES, nil},
|
||||
{@"comment", 120, 16, 0, YES, nil},
|
||||
{@"percentage", 57, 16, 0, YES, nil},
|
||||
{@"words", 120, 16, 0, YES, nil},
|
||||
{@"dupe_count", 80, 16, 0, YES, nil},
|
||||
nil
|
||||
};
|
||||
[[aTable columns] initializeColumns:defs];
|
||||
NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"];
|
||||
[[c dataCell] setButtonType:NSSwitchButton];
|
||||
[[c dataCell] setControlSize:NSSmallControlSize];
|
||||
c = [[aTable view] tableColumnWithIdentifier:@"size"];
|
||||
[[c dataCell] setAlignment:NSRightTextAlignment];
|
||||
c = [[aTable view] tableColumnWithIdentifier:@"duration"];
|
||||
[[c dataCell] setAlignment:NSRightTextAlignment];
|
||||
c = [[aTable view] tableColumnWithIdentifier:@"bitrate"];
|
||||
[[c dataCell] setAlignment:NSRightTextAlignment];
|
||||
}
|
||||
else {
|
||||
HSColumnDef defs[] = {
|
||||
{@"marked", 26, 26, 26, YES, [NSButtonCell class]},
|
||||
{@"name", 195, 16, 0, YES, nil},
|
||||
{@"folder_path", 183, 16, 0, YES, nil},
|
||||
{@"size", 63, 16, 0, YES, nil},
|
||||
{@"extension", 40, 16, 0, YES, nil},
|
||||
{@"mtime", 120, 16, 0, YES, nil},
|
||||
{@"percentage", 60, 16, 0, YES, nil},
|
||||
{@"words", 120, 16, 0, YES, nil},
|
||||
{@"dupe_count", 80, 16, 0, YES, nil},
|
||||
nil
|
||||
};
|
||||
[[aTable columns] initializeColumns:defs];
|
||||
NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"];
|
||||
[[c dataCell] setButtonType:NSSwitchButton];
|
||||
[[c dataCell] setControlSize:NSSmallControlSize];
|
||||
c = [[aTable view] tableColumnWithIdentifier:@"size"];
|
||||
[[c dataCell] setAlignment:NSRightTextAlignment];
|
||||
}
|
||||
[[aTable columns] restoreColumns];
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
- (void)changeOptions
|
||||
{
|
||||
NSInteger seg = [optionsSwitch selectedSegment];
|
||||
if (seg == 0) {
|
||||
[self toggleDetailsPanel];
|
||||
}
|
||||
else if (seg == 1) {
|
||||
[self togglePowerMarker];
|
||||
}
|
||||
else if (seg == 2) {
|
||||
[self toggleDelta];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)copyMarked
|
||||
{
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
[model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])];
|
||||
[model setCopyMoveDestType:n2i([ud objectForKey:@"recreatePathType"])];
|
||||
[model copyMarked];
|
||||
}
|
||||
|
||||
- (void)trashMarked
|
||||
{
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
[model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])];
|
||||
[model deleteMarked];
|
||||
}
|
||||
|
||||
- (void)filter
|
||||
{
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
[model setEscapeFilterRegexp:!n2b([ud objectForKey:@"useRegexpFilter"])];
|
||||
[model applyFilter:[filterField stringValue]];
|
||||
}
|
||||
|
||||
- (void)focusOnFilterField
|
||||
{
|
||||
[[self window] makeFirstResponder:filterField];
|
||||
}
|
||||
|
||||
- (void)ignoreSelected
|
||||
{
|
||||
[model addSelectedToIgnoreList];
|
||||
}
|
||||
|
||||
- (void)invokeCustomCommand
|
||||
{
|
||||
[model invokeCustomCommand];
|
||||
}
|
||||
|
||||
- (void)markAll
|
||||
{
|
||||
[model markAll];
|
||||
}
|
||||
|
||||
- (void)markInvert
|
||||
{
|
||||
[model markInvert];
|
||||
}
|
||||
|
||||
- (void)markNone
|
||||
{
|
||||
[model markNone];
|
||||
}
|
||||
|
||||
- (void)markSelected
|
||||
{
|
||||
[model toggleSelectedMark];
|
||||
}
|
||||
|
||||
- (void)moveMarked
|
||||
{
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
[model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])];
|
||||
[model setCopyMoveDestType:n2i([ud objectForKey:@"recreatePathType"])];
|
||||
[model moveMarked];
|
||||
}
|
||||
|
||||
- (void)openClicked
|
||||
{
|
||||
if ([matches clickedRow] < 0) {
|
||||
return;
|
||||
}
|
||||
[matches selectRowIndexes:[NSIndexSet indexSetWithIndex:[matches clickedRow]] byExtendingSelection:NO];
|
||||
[model openSelected];
|
||||
}
|
||||
|
||||
- (void)openSelected
|
||||
{
|
||||
[model openSelected];
|
||||
}
|
||||
|
||||
- (void)removeMarked
|
||||
{
|
||||
[model removeMarked];
|
||||
}
|
||||
|
||||
- (void)removeSelected
|
||||
{
|
||||
[model removeSelected];
|
||||
}
|
||||
|
||||
- (void)renameSelected
|
||||
{
|
||||
NSInteger col = [matches columnWithIdentifier:@"name"];
|
||||
NSInteger row = [matches selectedRow];
|
||||
[matches editColumn:col row:row withEvent:[NSApp currentEvent] select:YES];
|
||||
}
|
||||
|
||||
- (void)reprioritizeResults
|
||||
{
|
||||
PrioritizeDialog *dlg = [[PrioritizeDialog alloc] initWithApp:model];
|
||||
NSInteger result = [NSApp runModalForWindow:[dlg window]];
|
||||
if (result == NSRunStoppedResponse) {
|
||||
[[dlg model] performReprioritization];
|
||||
}
|
||||
[dlg release];
|
||||
[[self window] makeKeyAndOrderFront:nil];
|
||||
}
|
||||
|
||||
- (void)resetColumnsToDefault
|
||||
{
|
||||
[[[table columns] model] resetToDefaults];
|
||||
[self fillColumnsMenu];
|
||||
}
|
||||
|
||||
- (void)revealSelected
|
||||
{
|
||||
[model revealSelected];
|
||||
}
|
||||
|
||||
- (void)saveResults
|
||||
{
|
||||
NSSavePanel *sp = [NSSavePanel savePanel];
|
||||
[sp setCanCreateDirectories:YES];
|
||||
[sp setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]];
|
||||
[sp setTitle:NSLocalizedString(@"Select a file to save your results to", @"")];
|
||||
if ([sp runModal] == NSOKButton) {
|
||||
[model saveResultsAs:[[sp URL] path]];
|
||||
[[app recentResults] addFile:[[sp URL] path]];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)switchSelected
|
||||
{
|
||||
[model makeSelectedReference];
|
||||
}
|
||||
|
||||
- (void)toggleColumn:(id)sender
|
||||
{
|
||||
NSMenuItem *mi = sender;
|
||||
BOOL checked = [[[table columns] model] toggleMenuItem:[mi tag]];
|
||||
[mi setState:checked ? NSOnState : NSOffState];
|
||||
}
|
||||
|
||||
- (void)toggleDetailsPanel
|
||||
{
|
||||
[[app detailsPanel] toggleVisibility];
|
||||
[self updateOptionSegments];
|
||||
}
|
||||
|
||||
- (void)toggleDelta
|
||||
{
|
||||
[table setDeltaValuesMode:![table deltaValuesMode]];
|
||||
[self updateOptionSegments];
|
||||
}
|
||||
|
||||
- (void)togglePowerMarker
|
||||
{
|
||||
[table setPowerMarkerMode:![table powerMarkerMode]];
|
||||
[self updateOptionSegments];
|
||||
}
|
||||
|
||||
- (void)toggleQuicklookPanel
|
||||
{
|
||||
if ([QLPreviewPanel sharedPreviewPanelExists] && [[QLPreviewPanel sharedPreviewPanel] isVisible]) {
|
||||
[[QLPreviewPanel sharedPreviewPanel] orderOut:nil];
|
||||
}
|
||||
else {
|
||||
[[QLPreviewPanel sharedPreviewPanel] makeKeyAndOrderFront:nil];
|
||||
}
|
||||
}
|
||||
|
||||
/* Quicklook */
|
||||
- (BOOL)acceptsPreviewPanelControl:(QLPreviewPanel *)panel;
|
||||
{
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)beginPreviewPanelControl:(QLPreviewPanel *)panel
|
||||
{
|
||||
// This document is now responsible of the preview panel
|
||||
// It is allowed to set the delegate, data source and refresh panel.
|
||||
previewPanel = [panel retain];
|
||||
panel.delegate = table;
|
||||
panel.dataSource = table;
|
||||
}
|
||||
|
||||
- (void)endPreviewPanelControl:(QLPreviewPanel *)panel
|
||||
{
|
||||
// This document loses its responsisibility on the preview panel
|
||||
// Until the next call to -beginPreviewPanelControl: it must not
|
||||
// change the panel's delegate, data source or refresh it.
|
||||
[previewPanel release];
|
||||
previewPanel = nil;
|
||||
}
|
||||
|
||||
- (BOOL)validateToolbarItem:(NSToolbarItem *)theItem
|
||||
{
|
||||
return ![[ProgressController mainProgressController] isShown];
|
||||
}
|
||||
|
||||
- (BOOL)validateMenuItem:(NSMenuItem *)item
|
||||
{
|
||||
if ([item action] == @selector(markAll)) {
|
||||
[item setTitle:NSLocalizedString(@"Mark All", @"")];
|
||||
}
|
||||
return ![[ProgressController mainProgressController] isShown];
|
||||
}
|
||||
@end
|
||||
Submodule cocoa/Sparkle deleted from 1c8d54166b
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import "HSGUIController.h"
|
||||
#import "PyStatsLabel.h"
|
||||
|
||||
@interface StatsLabel : HSGUIController {}
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTextField *)aLabelView;
|
||||
- (PyStatsLabel *)model;
|
||||
- (NSTextField *)labelView;
|
||||
@end
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import "StatsLabel.h"
|
||||
#import "Utils.h"
|
||||
|
||||
@implementation StatsLabel
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTextField *)aLabelView
|
||||
{
|
||||
return [super initWithPyRef:aPyRef wrapperClass:[PyStatsLabel class]
|
||||
callbackClassName:@"StatsLabelView" view:aLabelView];
|
||||
}
|
||||
|
||||
- (PyStatsLabel *)model
|
||||
{
|
||||
return (PyStatsLabel *)model;
|
||||
}
|
||||
|
||||
- (NSTextField *)labelView
|
||||
{
|
||||
return (NSTextField *)view;
|
||||
}
|
||||
|
||||
/* Python --> Cocoa */
|
||||
- (void)refresh
|
||||
{
|
||||
[[self labelView] setStringValue:[[self model] display]];
|
||||
}
|
||||
@end
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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 install_gettext_trans_under_cocoa
|
||||
install_gettext_trans_under_cocoa()
|
||||
|
||||
from cocoa.inter import PySelectableList, PyColumns, PyTable
|
||||
|
||||
from inter.all import *
|
||||
from inter.app import PyDupeGuru
|
||||
|
||||
# When built under virtualenv, the dependency collector misses this module, so we have to force it
|
||||
# to see the module.
|
||||
import distutils.sysconfig
|
||||
@@ -1,20 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIDOjCCAi0GByqGSM44BAEwggIgAoIBAQDSurIL+HKbw+jsppG6tp3+WOcA4W71
|
||||
nhwR/DD2Se076AtCXJcssAhuCDUm+AVkQ3l34D++aYWtLR575rCrwU4lZXfQe+b9
|
||||
plHK02oOuqAY8lO5y02xoHEh7XeGunZ0u8wOVZw8MI999vIJ8rtCdvIF3r26wkjx
|
||||
9sieSxVpzJHDV5JHVdK3ObkXp/ts99dOD5B3CWGS8UiroMgS0FmRl7uPuADRRn2G
|
||||
srHTBYMwJvq8HFzQmDxcLldGQMAKvRKchtH+nH6ci1unSnpDUyrsCd+7qv1cSTse
|
||||
qc4OgXBDQ94MfVEh6Bs0S9stYfJf8cp6iV18J0sqMb9rbP4qC56iBsXfAhUAj6tx
|
||||
gwima7VaNI4YiC69jpLod3MCggEAYx+/mbU8P/xGooV9MgA3nI2v2vVNkwZVFcPa
|
||||
ROLQHg+R7bAftF3+1M9AnSP2O+PnXL65DwyTOab/Z/zM/vof3LLCGLYCmzPL+xvB
|
||||
6PxlqO374kFsKHEaaw66nnFWzPSdks/il0rauAiEbO8Gn/a8F2HFdA/OCCzq83l6
|
||||
cOhya7kGXZxdjeIfpfiNjDqZXi+8VRNDcDXx5u/T4vpkliQ+4O8ZXjwE4z2dPHfu
|
||||
Bw/N7DUalkzhZygYqcgx3tUxu3x/Pso+inmIBbk/As0uZv2nEll2CkEI6CSJIpfn
|
||||
pLKNQb4E4G7h+u+8kfHcwQ59RU1uGh0PU5uM+DOPg6HsC41RwgOCAQUAAoIBABLY
|
||||
T8gN8KdxWheESorvgksdG+Fizhkafpac08MCwJFF24v5a8AvZbhcCMLhChrloKcQ
|
||||
19qHshRIuWbSma/OqCmQKH752PTOKxRKsmqAfO0Rej2aDJrd0s7YBMY72DqeSYPP
|
||||
peLlwv0gkgRW7/EbDvBI18iTbrQLZtdqs9Xajc3dyIG5wrMtAf/Gta2oWChHlBLZ
|
||||
S45++Y9ou+LtW7dMc7c+aTxbzeLG36S57kAenRzjfP8zOi3P+Cc+5b9+SZgqfFrz
|
||||
/ch/HjB2zYAKq9AZSmgp9qIlOIuXnctJUD9hHivuEXFDr6xi1cxj7Q8WnX4+C58/
|
||||
QyGS4lebbLQ35x6fTQ8=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,140 +0,0 @@
|
||||
|
||||
"%@ Results" = "%@ Results";
|
||||
"About dupeGuru" = "About dupeGuru";
|
||||
"Action" = "Action";
|
||||
"Actions" = "Actions";
|
||||
"Add criteria to the right box and click OK to send the dupes that correspond the best to these criteria to their respective group's reference position. Read the help file for more information." = "Add criteria to the right box and click OK to send the dupes that correspond the best to these criteria to their respective group's reference position. Read the help file for more information.";
|
||||
"Add New Folder..." = "Add New Folder...";
|
||||
"Add Selected to Ignore List" = "Add Selected to Ignore List";
|
||||
"Advanced" = "Advanced";
|
||||
"After having deleted a duplicate, place a link targeting the reference file to replace the deleted file." = "After having deleted a duplicate, place a link targeting the reference file to replace the deleted file.";
|
||||
"Album" = "Album";
|
||||
"Application Mode:" = "Application Mode:";
|
||||
"Artist" = "Artist";
|
||||
"Attribute" = "Attribute";
|
||||
"Automatically check for updates" = "Automatically check for updates";
|
||||
"Basic" = "Basic";
|
||||
"Bring All to Front" = "Bring All to Front";
|
||||
"Can mix file kind" = "Can mix file kind";
|
||||
"Cancel" = "Cancel";
|
||||
"Check for update..." = "Check for update...";
|
||||
"Clear" = "Clear";
|
||||
"Clear Picture Cache" = "Clear Picture Cache";
|
||||
"Close" = "Close";
|
||||
"Close Window" = "Close Window";
|
||||
"Columns" = "Columns";
|
||||
"Copy" = "Copy";
|
||||
"Copy and Move:" = "Copy and Move:";
|
||||
"Copy Marked to..." = "Copy Marked to...";
|
||||
"Custom command (arguments: %d for dupe, %r for ref):" = "Custom command (arguments: %d for dupe, %r for ref):";
|
||||
"Cut" = "Cut";
|
||||
"Debug mode (restart required)" = "Debug mode (restart required)";
|
||||
"Deletion Options" = "Deletion Options";
|
||||
"Delta" = "Delta";
|
||||
"Details" = "Details";
|
||||
"Details of Selected File" = "Details of Selected File";
|
||||
"Details Panel" = "Details Panel";
|
||||
"Directly delete files" = "Directly delete files";
|
||||
"Directories" = "Directories";
|
||||
"Do you really want to remove all your cached picture analysis?" = "Do you really want to remove all your cached picture analysis?";
|
||||
"dupeGuru" = "dupeGuru";
|
||||
"dupeGuru Help" = "dupeGuru Help";
|
||||
"dupeGuru Preferences" = "dupeGuru Preferences";
|
||||
"dupeGuru Results" = "dupeGuru Results";
|
||||
"dupeGuru Website" = "dupeGuru Website";
|
||||
"Dupes Only" = "Dupes Only";
|
||||
"Edit" = "Edit";
|
||||
"Excluded" = "Excluded";
|
||||
"Export Results to CSV" = "Export Results to CSV";
|
||||
"Export Results to XHTML" = "Export Results to XHTML";
|
||||
"Fewer results" = "Fewer results";
|
||||
"File" = "File";
|
||||
"Filter" = "Filter";
|
||||
"Filter hardness:" = "Filter hardness:";
|
||||
"Filter Results..." = "Filter Results...";
|
||||
"Folder Selection Window" = "Folder Selection Window";
|
||||
"Font Size:" = "Font Size:";
|
||||
"Genre" = "Genre";
|
||||
"Help" = "Help";
|
||||
"Hide dupeGuru" = "Hide dupeGuru";
|
||||
"Hide Others" = "Hide Others";
|
||||
"Ignore duplicates hardlinking to the same file" = "Ignore duplicates hardlinking to the same file";
|
||||
"Ignore files smaller than:" = "Ignore files smaller than:";
|
||||
"Ignore List" = "Ignore List";
|
||||
"Instead of sending files to trash, delete them directly. This option is usually used as a workaround when the normal deletion method doesn't work." = "Instead of sending files to trash, delete them directly. This option is usually used as a workaround when the normal deletion method doesn't work.";
|
||||
"Invert Marking" = "Invert Marking";
|
||||
"Invoke Custom Command" = "Invoke Custom Command";
|
||||
"KB" = "KB";
|
||||
"Link deleted files" = "Link deleted files";
|
||||
"Load from file..." = "Load from file...";
|
||||
"Load Recent Results" = "Load Recent Results";
|
||||
"Load Results" = "Load Results";
|
||||
"Load Results..." = "Load Results...";
|
||||
"Make Selected into Reference" = "Make Selected into Reference";
|
||||
"Mark All" = "Mark All";
|
||||
"Mark None" = "Mark None";
|
||||
"Mark Selected" = "Mark Selected";
|
||||
"Match pictures of different dimensions" = "Match pictures of different dimensions";
|
||||
"Match similar words" = "Match similar words";
|
||||
"Minimize" = "Minimize";
|
||||
"Mode" = "Mode";
|
||||
"More results" = "More results";
|
||||
"Move Marked to..." = "Move Marked to...";
|
||||
"Music" = "Music";
|
||||
"Name" = "Name";
|
||||
"Normal" = "Normal";
|
||||
"Ok" = "Ok";
|
||||
"Open Selected with Default Application" = "Open Selected with Default Application";
|
||||
"Options" = "Options";
|
||||
"Paste" = "Paste";
|
||||
"Picture" = "Picture";
|
||||
"Preferences..." = "Preferences...";
|
||||
"Problems!" = "Problems!";
|
||||
"Proceed" = "Proceed";
|
||||
"Quick Look" = "Quick Look";
|
||||
"Quit dupeGuru" = "Quit dupeGuru";
|
||||
"Re-Prioritize duplicates" = "Re-Prioritize duplicates";
|
||||
"Re-Prioritize Results..." = "Re-Prioritize Results...";
|
||||
"Recreate absolute path" = "Recreate absolute path";
|
||||
"Recreate relative path" = "Recreate relative path";
|
||||
"Reference" = "Reference";
|
||||
"Remove empty folders on delete or move" = "Remove empty folders on delete or move";
|
||||
"Remove Marked from Results" = "Remove Marked from Results";
|
||||
"Remove Selected" = "Remove Selected";
|
||||
"Remove Selected from Results" = "Remove Selected from Results";
|
||||
"Rename Selected" = "Rename Selected";
|
||||
"Reset to Default" = "Reset to Default";
|
||||
"Reset To Defaults" = "Reset To Defaults";
|
||||
"Results Window" = "Results Window";
|
||||
"Reveal" = "Reveal";
|
||||
"Reveal Selected in Finder" = "Reveal Selected in Finder";
|
||||
"Right in destination" = "Right in destination";
|
||||
"Save Results..." = "Save Results...";
|
||||
"Scan" = "Scan";
|
||||
"Scan Type:" = "Scan Type:";
|
||||
"Select a file to save your results to" = "Select a file to save your results to";
|
||||
"Select a folder to add to the scanning list" = "Select a folder to add to the scanning list";
|
||||
"Select a results file to load" = "Select a results file to load";
|
||||
"Select All" = "Select All";
|
||||
"Select folders to scan and press \"Scan\"." = "Select folders to scan and press \"Scan\".";
|
||||
"Selected" = "Selected";
|
||||
"Send Marked to Trash..." = "Send Marked to Trash...";
|
||||
"Services" = "Services";
|
||||
"Show All" = "Show All";
|
||||
"Show Delta Values" = "Show Delta Values";
|
||||
"Show Dupes Only" = "Show Dupes Only";
|
||||
"Standard" = "Standard";
|
||||
"Start Duplicate Scan" = "Start Duplicate Scan";
|
||||
"State" = "State";
|
||||
"Tags to scan:" = "Tags to scan:";
|
||||
"The name '%@' already exists." = "The name '%@' already exists.";
|
||||
"There were problems processing some (or all) of the files. The cause of these problems are described in the table below. Those files were not removed from your results." = "There were problems processing some (or all) of the files. The cause of these problems are described in the table below. Those files were not removed from your results.";
|
||||
"Title" = "Title";
|
||||
"Track" = "Track";
|
||||
"Use regular expressions when filtering" = "Use regular expressions when filtering";
|
||||
"Window" = "Window";
|
||||
"Word weighting" = "Word weighting";
|
||||
"Year" = "Year";
|
||||
"You have unsaved results, do you really want to continue?" = "You have unsaved results, do you really want to continue?";
|
||||
"You have unsaved results, do you really want to quit?" = "You have unsaved results, do you really want to quit?";
|
||||
"Zoom" = "Zoom";
|
||||
@@ -1,10 +0,0 @@
|
||||
from cocoa.inter import PyTextField, PyProgressWindow
|
||||
from .deletion_options import PyDeletionOptions
|
||||
from .details_panel import PyDetailsPanel
|
||||
from .directory_outline import PyDirectoryOutline
|
||||
from .prioritize_dialog import PyPrioritizeDialog
|
||||
from .prioritize_list import PyPrioritizeList
|
||||
from .problem_dialog import PyProblemDialog
|
||||
from .ignore_list_dialog import PyIgnoreListDialog
|
||||
from .result_table import PyResultTable
|
||||
from .stats_label import PyStatsLabel
|
||||
@@ -1,251 +0,0 @@
|
||||
import logging
|
||||
|
||||
from objp.util import pyref, dontwrap
|
||||
from hscommon.path import Path, pathify
|
||||
from cocoa import install_exception_hook, install_cocoa_logger, patch_threaded_job_performer
|
||||
from cocoa.inter import PyBaseApp, BaseAppView
|
||||
|
||||
import core.pe.photo
|
||||
from core.app import DupeGuru as DupeGuruBase, AppMode
|
||||
from .directories import Directories, Bundle
|
||||
from .photo import Photo
|
||||
|
||||
class DupeGuru(DupeGuruBase):
|
||||
def __init__(self, view):
|
||||
DupeGuruBase.__init__(self, view)
|
||||
self.directories = Directories()
|
||||
|
||||
def selected_dupe_path(self):
|
||||
if not self.selected_dupes:
|
||||
return None
|
||||
return self.selected_dupes[0].path
|
||||
|
||||
def selected_dupe_ref_path(self):
|
||||
if not self.selected_dupes:
|
||||
return None
|
||||
ref = self.results.get_group_of_duplicate(self.selected_dupes[0]).ref
|
||||
if ref is self.selected_dupes[0]: # we don't want the same pic to be displayed on both sides
|
||||
return None
|
||||
return ref.path
|
||||
|
||||
def _get_fileclasses(self):
|
||||
result = DupeGuruBase._get_fileclasses(self)
|
||||
if self.app_mode == AppMode.Standard:
|
||||
result = [Bundle] + result
|
||||
return result
|
||||
|
||||
class DupeGuruView(BaseAppView):
|
||||
def askYesNoWithPrompt_(self, prompt: str) -> bool: pass
|
||||
def createResultsWindow(self): pass
|
||||
def showResultsWindow(self): pass
|
||||
def showProblemDialog(self): pass
|
||||
def selectDestFolderWithPrompt_(self, prompt: str) -> str: pass
|
||||
def selectDestFileWithPrompt_extension_(self, prompt: str, extension: str) -> str: pass
|
||||
|
||||
class PyDupeGuru(PyBaseApp):
|
||||
@dontwrap
|
||||
def __init__(self):
|
||||
core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = Photo
|
||||
logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(message)s')
|
||||
install_exception_hook('https://github.com/hsoft/dupeguru/issues')
|
||||
install_cocoa_logger()
|
||||
patch_threaded_job_performer()
|
||||
self.model = DupeGuru(self)
|
||||
|
||||
#---Sub-proxies
|
||||
def detailsPanel(self) -> pyref:
|
||||
return self.model.details_panel
|
||||
|
||||
def directoryTree(self) -> pyref:
|
||||
return self.model.directory_tree
|
||||
|
||||
def problemDialog(self) -> pyref:
|
||||
return self.model.problem_dialog
|
||||
|
||||
def statsLabel(self) -> pyref:
|
||||
return self.model.stats_label
|
||||
|
||||
def resultTable(self) -> pyref:
|
||||
return self.model.result_table
|
||||
|
||||
def ignoreListDialog(self) -> pyref:
|
||||
return self.model.ignore_list_dialog
|
||||
|
||||
def progressWindow(self) -> pyref:
|
||||
return self.model.progress_window
|
||||
|
||||
def deletionOptions(self) -> pyref:
|
||||
return self.model.deletion_options
|
||||
|
||||
#---Directories
|
||||
def addDirectory_(self, directory: str):
|
||||
self.model.add_directory(directory)
|
||||
|
||||
#---Results
|
||||
def doScan(self):
|
||||
self.model.start_scanning()
|
||||
|
||||
def exportToXHTML(self):
|
||||
self.model.export_to_xhtml()
|
||||
|
||||
def exportToCSV(self):
|
||||
self.model.export_to_csv()
|
||||
|
||||
def loadSession(self):
|
||||
self.model.load()
|
||||
|
||||
def loadResultsFrom_(self, filename: str):
|
||||
self.model.load_from(filename)
|
||||
|
||||
def markAll(self):
|
||||
self.model.mark_all()
|
||||
|
||||
def markNone(self):
|
||||
self.model.mark_none()
|
||||
|
||||
def markInvert(self):
|
||||
self.model.mark_invert()
|
||||
|
||||
def purgeIgnoreList(self):
|
||||
self.model.purge_ignore_list()
|
||||
|
||||
def toggleSelectedMark(self):
|
||||
self.model.toggle_selected_mark_state()
|
||||
|
||||
def saveSession(self):
|
||||
self.model.save()
|
||||
|
||||
def saveResultsAs_(self, filename: str):
|
||||
self.model.save_as(filename)
|
||||
|
||||
#---Actions
|
||||
def addSelectedToIgnoreList(self):
|
||||
self.model.add_selected_to_ignore_list()
|
||||
|
||||
def deleteMarked(self):
|
||||
self.model.delete_marked()
|
||||
|
||||
def applyFilter_(self, filter: str):
|
||||
self.model.apply_filter(filter)
|
||||
|
||||
def makeSelectedReference(self):
|
||||
self.model.make_selected_reference()
|
||||
|
||||
def copyMarked(self):
|
||||
self.model.copy_or_move_marked(copy=True)
|
||||
|
||||
def moveMarked(self):
|
||||
self.model.copy_or_move_marked(copy=False)
|
||||
|
||||
def openSelected(self):
|
||||
self.model.open_selected()
|
||||
|
||||
def removeMarked(self):
|
||||
self.model.remove_marked()
|
||||
|
||||
def removeSelected(self):
|
||||
self.model.remove_selected()
|
||||
|
||||
def revealSelected(self):
|
||||
self.model.reveal_selected()
|
||||
|
||||
def invokeCustomCommand(self):
|
||||
self.model.invoke_custom_command()
|
||||
|
||||
def showIgnoreList(self):
|
||||
self.model.ignore_list_dialog.show()
|
||||
|
||||
def clearPictureCache(self):
|
||||
self.model.clear_picture_cache()
|
||||
|
||||
#---Information
|
||||
def getScanOptions(self) -> list:
|
||||
return [o.label for o in self.model.SCANNER_CLASS.get_scan_options()]
|
||||
|
||||
def resultsAreModified(self) -> bool:
|
||||
return self.model.results.is_modified
|
||||
|
||||
def getSelectedDupePath(self) -> str:
|
||||
return str(self.model.selected_dupe_path())
|
||||
|
||||
def getSelectedDupeRefPath(self) -> str:
|
||||
return str(self.model.selected_dupe_ref_path())
|
||||
|
||||
#---Properties
|
||||
def getAppMode(self) -> int:
|
||||
return self.model.app_mode
|
||||
|
||||
def setAppMode_(self, app_mode: int):
|
||||
self.model.app_mode = app_mode
|
||||
|
||||
def setScanType_(self, scan_type_index: int):
|
||||
scan_options = self.model.SCANNER_CLASS.get_scan_options()
|
||||
try:
|
||||
so = scan_options[scan_type_index]
|
||||
self.model.options['scan_type'] = so.scan_type
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
def setMinMatchPercentage_(self, percentage: int):
|
||||
self.model.options['min_match_percentage'] = int(percentage)
|
||||
|
||||
def setWordWeighting_(self, words_are_weighted: bool):
|
||||
self.model.options['word_weighting'] = words_are_weighted
|
||||
|
||||
def setMatchSimilarWords_(self, match_similar_words: bool):
|
||||
self.model.options['match_similar_words'] = match_similar_words
|
||||
|
||||
def setSizeThreshold_(self, size_threshold: int):
|
||||
self.model.options['size_threshold'] = size_threshold
|
||||
|
||||
def enable_scanForTag_(self, enable: bool, scan_tag: str):
|
||||
if 'scanned_tags' not in self.model.options:
|
||||
self.model.options['scanned_tags'] = set()
|
||||
if enable:
|
||||
self.model.options['scanned_tags'].add(scan_tag)
|
||||
else:
|
||||
self.model.options['scanned_tags'].discard(scan_tag)
|
||||
|
||||
def setMatchScaled_(self, match_scaled: bool):
|
||||
self.model.options['match_scaled'] = match_scaled
|
||||
|
||||
def setMixFileKind_(self, mix_file_kind: bool):
|
||||
self.model.options['mix_file_kind'] = mix_file_kind
|
||||
|
||||
def setEscapeFilterRegexp_(self, escape_filter_regexp: bool):
|
||||
self.model.options['escape_filter_regexp'] = escape_filter_regexp
|
||||
|
||||
def setRemoveEmptyFolders_(self, remove_empty_folders: bool):
|
||||
self.model.options['clean_empty_dirs'] = remove_empty_folders
|
||||
|
||||
def setIgnoreHardlinkMatches_(self, ignore_hardlink_matches: bool):
|
||||
self.model.options['ignore_hardlink_matches'] = ignore_hardlink_matches
|
||||
|
||||
def setCopyMoveDestType_(self, copymove_dest_type: int):
|
||||
self.model.options['copymove_dest_type'] = copymove_dest_type
|
||||
|
||||
#--- model --> view
|
||||
@dontwrap
|
||||
def ask_yes_no(self, prompt):
|
||||
return self.callback.askYesNoWithPrompt_(prompt)
|
||||
|
||||
@dontwrap
|
||||
def create_results_window(self):
|
||||
self.callback.createResultsWindow()
|
||||
|
||||
@dontwrap
|
||||
def show_results_window(self):
|
||||
self.callback.showResultsWindow()
|
||||
|
||||
@dontwrap
|
||||
def show_problem_dialog(self):
|
||||
self.callback.showProblemDialog()
|
||||
|
||||
@dontwrap
|
||||
def select_dest_folder(self, prompt):
|
||||
return self.callback.selectDestFolderWithPrompt_(prompt)
|
||||
|
||||
@dontwrap
|
||||
def select_dest_file(self, prompt, extension):
|
||||
return self.callback.selectDestFileWithPrompt_extension_(prompt, extension)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Created On: 2012-05-30
|
||||
# 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 objp.util import dontwrap
|
||||
from cocoa.inter import PyGUIObject, GUIObjectView
|
||||
|
||||
class DeletionOptionsView(GUIObjectView):
|
||||
def updateMsg_(self, msg: str): pass
|
||||
def show(self) -> bool: pass
|
||||
def setHardlinkOptionEnabled_(self, enabled: bool): pass
|
||||
|
||||
class PyDeletionOptions(PyGUIObject):
|
||||
def setLinkDeleted_(self, link_deleted: bool):
|
||||
self.model.link_deleted = link_deleted
|
||||
|
||||
def setUseHardlinks_(self, use_hardlinks: bool):
|
||||
self.model.use_hardlinks = use_hardlinks
|
||||
|
||||
def setDirect_(self, direct: bool):
|
||||
self.model.direct = direct
|
||||
|
||||
#--- model --> view
|
||||
@dontwrap
|
||||
def update_msg(self, msg):
|
||||
self.callback.updateMsg_(msg)
|
||||
|
||||
@dontwrap
|
||||
def show(self):
|
||||
return self.callback.show()
|
||||
|
||||
@dontwrap
|
||||
def set_hardlink_option_enabled(self, enabled):
|
||||
self.callback.setHardlinkOptionEnabled_(enabled)
|
||||
@@ -1,11 +0,0 @@
|
||||
from cocoa.inter import PyGUIObject, GUIObjectView
|
||||
|
||||
class DetailsPanelView(GUIObjectView):
|
||||
pass
|
||||
|
||||
class PyDetailsPanel(PyGUIObject):
|
||||
def numberOfRows(self) -> int:
|
||||
return self.model.row_count()
|
||||
|
||||
def valueForColumn_row_(self, column: str, row: int) -> object:
|
||||
return self.model.row(row)[int(column)]
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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
|
||||
|
||||
from cocoa import proxy
|
||||
from hscommon.path import Path, pathify
|
||||
from core.se import fs
|
||||
from core.directories import Directories as DirectoriesBase, DirectoryState
|
||||
|
||||
def is_bundle(str_path):
|
||||
uti = proxy.getUTI_(str_path)
|
||||
if uti is None:
|
||||
logging.warning('There was an error trying to detect the UTI of %s', str_path)
|
||||
return proxy.type_conformsToType_(uti, 'com.apple.bundle') or proxy.type_conformsToType_(uti, 'com.apple.package')
|
||||
|
||||
class Bundle(fs.Folder):
|
||||
@classmethod
|
||||
@pathify
|
||||
def can_handle(cls, path: Path):
|
||||
return not path.islink() and path.isdir() and is_bundle(str(path))
|
||||
|
||||
class Directories(DirectoriesBase):
|
||||
ROOT_PATH_TO_EXCLUDE = list(map(Path, ['/Library', '/Volumes', '/System', '/bin', '/sbin', '/opt', '/private', '/dev']))
|
||||
HOME_PATH_TO_EXCLUDE = [Path('Library')]
|
||||
|
||||
def _default_state_for_path(self, path):
|
||||
result = DirectoriesBase._default_state_for_path(self, path)
|
||||
if result is not None:
|
||||
return result
|
||||
if path in self.ROOT_PATH_TO_EXCLUDE:
|
||||
return DirectoryState.Excluded
|
||||
if path[:2] == Path('/Users') and path[3:] in self.HOME_PATH_TO_EXCLUDE:
|
||||
return DirectoryState.Excluded
|
||||
|
||||
def _get_folders(self, from_folder, j):
|
||||
# We don't want to scan bundle's subfolder even in Folders mode. Bundle's integrity has to
|
||||
# stay intact.
|
||||
if is_bundle(str(from_folder.path)):
|
||||
# just yield the current folder and bail
|
||||
state = self.get_state(from_folder.path)
|
||||
if state != DirectoryState.Excluded:
|
||||
from_folder.is_ref = state == DirectoryState.Reference
|
||||
yield from_folder
|
||||
return
|
||||
else:
|
||||
yield from DirectoriesBase._get_folders(self, from_folder, j)
|
||||
|
||||
@staticmethod
|
||||
def get_subfolders(path):
|
||||
result = DirectoriesBase.get_subfolders(path)
|
||||
return [p for p in result if not is_bundle(str(p))]
|
||||
@@ -1,21 +0,0 @@
|
||||
from objp.util import dontwrap
|
||||
from cocoa.inter import PyOutline, GUIObjectView
|
||||
|
||||
class DirectoryOutlineView(GUIObjectView):
|
||||
pass
|
||||
|
||||
class PyDirectoryOutline(PyOutline):
|
||||
def addDirectory_(self, path: str):
|
||||
self.model.add_directory(path)
|
||||
|
||||
def removeSelectedDirectory(self):
|
||||
self.model.remove_selected()
|
||||
|
||||
def selectAll(self):
|
||||
self.model.select_all()
|
||||
|
||||
# python --> cocoa
|
||||
@dontwrap
|
||||
def refresh_states(self):
|
||||
# Under cocoa, both refresh() and refresh_states() do the same thing.
|
||||
self.callback.refresh()
|
||||
@@ -1,21 +0,0 @@
|
||||
from objp.util import pyref, dontwrap
|
||||
from cocoa.inter import PyGUIObject, GUIObjectView
|
||||
|
||||
class IgnoreListDialogView(GUIObjectView):
|
||||
def show(self): pass
|
||||
|
||||
class PyIgnoreListDialog(PyGUIObject):
|
||||
def ignoreListTable(self) -> pyref:
|
||||
return self.model.ignore_list_table
|
||||
|
||||
def removeSelected(self):
|
||||
self.model.remove_selected()
|
||||
|
||||
def clear(self):
|
||||
self.model.clear()
|
||||
|
||||
#--- model --> view
|
||||
@dontwrap
|
||||
def show(self):
|
||||
self.callback.show()
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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
|
||||
|
||||
from cocoa import proxy
|
||||
from core.pe import _block_osx
|
||||
from core.pe.photo import Photo as PhotoBase
|
||||
|
||||
class Photo(PhotoBase):
|
||||
HANDLED_EXTS = PhotoBase.HANDLED_EXTS.copy()
|
||||
HANDLED_EXTS.update({'psd', 'nef', 'cr2', 'orf'})
|
||||
|
||||
def _plat_get_dimensions(self):
|
||||
return _block_osx.get_image_size(str(self.path))
|
||||
|
||||
def _plat_get_blocks(self, block_count_per_side, orientation):
|
||||
try:
|
||||
blocks = _block_osx.getblocks(str(self.path), block_count_per_side, orientation)
|
||||
except Exception as e:
|
||||
raise IOError('The reading of "%s" failed with "%s"' % (str(self.path), str(e)))
|
||||
if not blocks:
|
||||
raise IOError('The picture %s could not be read' % str(self.path))
|
||||
return blocks
|
||||
|
||||
def _get_exif_timestamp(self):
|
||||
exifdata = proxy.readExifData_(str(self.path))
|
||||
if exifdata:
|
||||
try:
|
||||
return exifdata['{Exif}']['DateTimeOriginal']
|
||||
except KeyError:
|
||||
return ''
|
||||
else:
|
||||
return ''
|
||||
@@ -1,29 +0,0 @@
|
||||
from objp.util import pyref
|
||||
from cocoa.inter import PyGUIObject, GUIObjectView
|
||||
from core.gui.prioritize_dialog import PrioritizeDialog
|
||||
|
||||
class PrioritizeDialogView(GUIObjectView):
|
||||
pass
|
||||
|
||||
class PyPrioritizeDialog(PyGUIObject):
|
||||
def __init__(self, app: pyref):
|
||||
model = PrioritizeDialog(app.model)
|
||||
PyGUIObject.__init__(self, model)
|
||||
|
||||
def categoryList(self) -> pyref:
|
||||
return self.model.category_list
|
||||
|
||||
def criteriaList(self) -> pyref:
|
||||
return self.model.criteria_list
|
||||
|
||||
def prioritizationList(self) -> pyref:
|
||||
return self.model.prioritization_list
|
||||
|
||||
def addSelected(self):
|
||||
self.model.add_selected()
|
||||
|
||||
def removeSelected(self):
|
||||
self.model.remove_selected()
|
||||
|
||||
def performReprioritization(self):
|
||||
self.model.perform_reprioritization()
|
||||
@@ -1,8 +0,0 @@
|
||||
from cocoa.inter import PySelectableList, SelectableListView
|
||||
|
||||
class PrioritizeListView(SelectableListView):
|
||||
pass
|
||||
|
||||
class PyPrioritizeList(PySelectableList):
|
||||
def moveIndexes_toIndex_(self, indexes: list, dest_index: int):
|
||||
self.model.move_indexes(indexes, dest_index)
|
||||
@@ -1,9 +0,0 @@
|
||||
from objp.util import pyref
|
||||
from cocoa.inter import PyGUIObject
|
||||
|
||||
class PyProblemDialog(PyGUIObject):
|
||||
def problemTable(self) -> pyref:
|
||||
return self.model.problem_table
|
||||
|
||||
def revealSelected(self):
|
||||
self.model.reveal_selected_dupe()
|
||||
@@ -1,50 +0,0 @@
|
||||
from objp.util import dontwrap
|
||||
from cocoa.inter import PyTable, TableView
|
||||
|
||||
class ResultTableView(TableView):
|
||||
def invalidateMarkings(self): pass
|
||||
|
||||
class PyResultTable(PyTable):
|
||||
def powerMarkerMode(self) -> bool:
|
||||
return self.model.power_marker
|
||||
|
||||
def setPowerMarkerMode_(self, value: bool):
|
||||
self.model.power_marker = value
|
||||
|
||||
def deltaValuesMode(self) -> bool:
|
||||
return self.model.delta_values
|
||||
|
||||
def setDeltaValuesMode_(self, value: bool):
|
||||
self.model.delta_values = value
|
||||
|
||||
def valueForRow_column_(self, row_index: int, column: str) -> object:
|
||||
return self.model.get_row_value(row_index, column)
|
||||
|
||||
def isDeltaAtRow_column_(self, row_index: int, column: str) -> bool:
|
||||
row = self.model[row_index]
|
||||
return row.is_cell_delta(column)
|
||||
|
||||
def renameSelected_(self, newname: str) -> bool:
|
||||
return self.model.rename_selected(newname)
|
||||
|
||||
def sortBy_ascending_(self, key: str, asc: bool):
|
||||
self.model.sort(key, asc)
|
||||
|
||||
def markSelected(self):
|
||||
self.model.app.toggle_selected_mark_state()
|
||||
|
||||
def removeSelected(self):
|
||||
self.model.app.remove_selected()
|
||||
|
||||
def selectedDupeCount(self) -> int:
|
||||
return self.model.selected_dupe_count
|
||||
|
||||
def pathAtIndex_(self, index: int) -> str:
|
||||
row = self.model[index]
|
||||
return str(row._dupe.path)
|
||||
|
||||
# python --> cocoa
|
||||
@dontwrap
|
||||
def invalidate_markings(self):
|
||||
self.callback.invalidateMarkings()
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from cocoa.inter import PyGUIObject, GUIObjectView
|
||||
|
||||
class StatsLabelView(GUIObjectView):
|
||||
pass
|
||||
|
||||
class PyStatsLabel(PyGUIObject):
|
||||
|
||||
def display(self) -> str:
|
||||
return self.model.display
|
||||
49
cocoa/main.m
49
cocoa/main.m
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
which should be included with this package. The terms are also available at
|
||||
http://www.gnu.org/licenses/gpl-3.0.html
|
||||
*/
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Python.h>
|
||||
#import <wchar.h>
|
||||
#import <locale.h>
|
||||
#import "AppDelegate.h"
|
||||
#import "MainMenu_UI.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
|
||||
/* We have to set the locate to UTF8 for mbstowcs() to correctly convert non-ascii chars in paths */
|
||||
setlocale(LC_ALL, "en_US.UTF-8");
|
||||
NSString *respath = [[NSBundle mainBundle] resourcePath];
|
||||
NSString *mainpy = [respath stringByAppendingPathComponent:@"dg_cocoa.py"];
|
||||
wchar_t wPythonPath[PATH_MAX+1];
|
||||
NSString *pypath = [respath stringByAppendingPathComponent:@"py"];
|
||||
mbstowcs(wPythonPath, [pypath fileSystemRepresentation], PATH_MAX+1);
|
||||
Py_SetPath(wPythonPath);
|
||||
Py_SetPythonHome(wPythonPath);
|
||||
Py_Initialize();
|
||||
PyEval_InitThreads();
|
||||
PyGILState_STATE gilState = PyGILState_Ensure();
|
||||
FILE* fp = fopen([mainpy UTF8String], "r");
|
||||
PyRun_SimpleFile(fp, [mainpy UTF8String]);
|
||||
fclose(fp);
|
||||
PyGILState_Release(gilState);
|
||||
if (gilState == PyGILState_LOCKED) {
|
||||
PyThreadState_Swap(NULL);
|
||||
PyEval_ReleaseLock();
|
||||
}
|
||||
|
||||
[NSApplication sharedApplication];
|
||||
AppDelegate *appDelegate = [[AppDelegate alloc] init];
|
||||
[NSApp setDelegate:appDelegate];
|
||||
[NSApp setMainMenu:createMainMenu_UI(appDelegate)];
|
||||
[appDelegate finalizeInit];
|
||||
[pool release];
|
||||
[NSApp run];
|
||||
Py_Finalize();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
def main():
|
||||
return os.system('open "{{app_path}}"')
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,49 +0,0 @@
|
||||
ownerclass = 'DeletionOptions'
|
||||
ownerimport = 'DeletionOptions.h'
|
||||
|
||||
result = Window(450, 240, "Deletion Options")
|
||||
messageLabel = Label(result, "")
|
||||
linkCheckbox = Checkbox(result, "Link deleted files")
|
||||
linkLabel = Label(result, "After having deleted a duplicate, place a link targeting the "
|
||||
"reference file to replace the deleted file.")
|
||||
linkTypeChoice = RadioButtons(result, ["Symlink", "Hardlink"], columns=2)
|
||||
directCheckbox = Checkbox(result, "Directly delete files")
|
||||
directLabel = Label(result, "Instead of sending files to trash, delete them directly. This option "
|
||||
"is usually used as a workaround when the normal deletion method doesn't work.")
|
||||
proceedButton = Button(result, "Proceed")
|
||||
cancelButton = Button(result, "Cancel")
|
||||
|
||||
owner.linkButton = linkCheckbox
|
||||
owner.linkTypeRadio = linkTypeChoice
|
||||
owner.directButton = directCheckbox
|
||||
owner.messageTextField = messageLabel
|
||||
|
||||
result.canMinimize = False
|
||||
result.canResize = False
|
||||
linkLabel.controlSize = ControlSize.Small
|
||||
directLabel.controlSize = ControlSize.Small
|
||||
linkTypeChoice.controlSize = ControlSize.Small
|
||||
proceedButton.keyEquivalent = '\\r'
|
||||
cancelButton.keyEquivalent = '\\e'
|
||||
linkCheckbox.action = directCheckbox.action = linkTypeChoice.action = Action(owner, 'updateOptions')
|
||||
proceedButton.action = Action(owner, 'proceed')
|
||||
cancelButton.action = Action(owner, 'cancel')
|
||||
|
||||
linkLabel.height *= 2 # 2 lines
|
||||
directLabel.height *= 3 # 3 lines
|
||||
proceedButton.width = 92
|
||||
cancelButton.width = 92
|
||||
|
||||
mainLayout = VLayout([messageLabel, linkCheckbox, linkLabel, linkTypeChoice, directCheckbox,
|
||||
directLabel])
|
||||
mainLayout.packToCorner(Pack.UpperLeft)
|
||||
mainLayout.fill(Pack.Right)
|
||||
buttonLayout = HLayout([cancelButton, proceedButton])
|
||||
buttonLayout.packToCorner(Pack.LowerRight)
|
||||
|
||||
# indent the labels under checkboxes a little bit to the right
|
||||
for indentedView in (linkLabel, directLabel, linkTypeChoice):
|
||||
indentedView.x += 20
|
||||
indentedView.width -= 20
|
||||
# We actually don't want the link choice radio buttons to take all the width, it looks weird.
|
||||
linkTypeChoice.width = 170
|
||||
@@ -1,32 +0,0 @@
|
||||
ownerclass = 'DetailsPanel'
|
||||
ownerimport = 'DetailsPanel.h'
|
||||
|
||||
result = Panel(451, 146, "Details of Selected File")
|
||||
table = TableView(result)
|
||||
|
||||
owner.detailsTable = table
|
||||
|
||||
result.style = PanelStyle.Utility
|
||||
result.xProportion = 0.2
|
||||
result.yProportion = 0.4
|
||||
result.canMinimize = False
|
||||
result.autosaveName = 'DetailsPanel'
|
||||
result.minSize = Size(result.width, result.height)
|
||||
|
||||
table.dataSource = owner
|
||||
table.allowsColumnReordering = False
|
||||
table.allowsColumnSelection = False
|
||||
table.allowsMultipleSelection = False
|
||||
table.font = Font(FontFamily.System, FontSize.SmallSystem)
|
||||
table.rowHeight = 14
|
||||
table.editable = False
|
||||
col = table.addColumn('0', "Attribute", 70)
|
||||
col.autoResizable = True
|
||||
col = table.addColumn('1', "Selected", 198)
|
||||
col.autoResizable = True
|
||||
col = table.addColumn('2', "Reference", 172)
|
||||
col.autoResizable = True
|
||||
|
||||
table.packToCorner(Pack.UpperLeft, margin=0)
|
||||
table.fill(Pack.LowerRight, margin=0)
|
||||
table.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
@@ -1,70 +0,0 @@
|
||||
ownerclass = 'DetailsPanelPicture'
|
||||
ownerimport = 'DetailsPanelPicture.h'
|
||||
|
||||
result = Panel(593, 398, "Details of Selected File")
|
||||
table = TableView(result)
|
||||
split = SplitView(result, 2, vertical=True)
|
||||
leftSplit, rightSplit = split.subviews
|
||||
selectedLabel = Label(leftSplit, "Selected")
|
||||
selectedImage = ImageView(leftSplit, 'NSApplicationIcon')
|
||||
leftSpinner = ProgressIndicator(leftSplit)
|
||||
referenceLabel = Label(rightSplit, "Reference")
|
||||
referenceImage = ImageView(rightSplit, 'NSApplicationIcon')
|
||||
rightSpinner = ProgressIndicator(rightSplit)
|
||||
|
||||
owner.detailsTable = table
|
||||
owner.dupeImage = selectedImage
|
||||
owner.dupeProgressIndicator = leftSpinner
|
||||
owner.refImage = referenceImage
|
||||
owner.refProgressIndicator = rightSpinner
|
||||
table.dataSource = owner
|
||||
|
||||
result.style = PanelStyle.Utility
|
||||
result.xProportion = 0.6
|
||||
result.yProportion = 0.6
|
||||
result.canMinimize = False
|
||||
result.autosaveName = 'DetailsPanel'
|
||||
result.minSize = Size(451, 240)
|
||||
|
||||
table.allowsColumnReordering = False
|
||||
table.allowsColumnSelection = False
|
||||
table.allowsMultipleSelection = False
|
||||
table.font = Font(FontFamily.System, FontSize.SmallSystem)
|
||||
table.rowHeight = 14
|
||||
table.editable = False
|
||||
col = table.addColumn('0', "Attribute", 70)
|
||||
col.autoResizable = True
|
||||
col = table.addColumn('1', "Selected", 198)
|
||||
col.autoResizable = True
|
||||
col = table.addColumn('2', "Reference", 172)
|
||||
col.autoResizable = True
|
||||
table.height = 165
|
||||
|
||||
sides = [
|
||||
(leftSplit, selectedLabel, selectedImage, leftSpinner),
|
||||
(rightSplit, referenceLabel, referenceImage, rightSpinner),
|
||||
]
|
||||
for subSplit, label, image, spinner in sides:
|
||||
label.alignment = TextAlignment.Center
|
||||
spinner.style = const.NSProgressIndicatorSpinningStyle
|
||||
spinner.controlSize = const.NSSmallControlSize
|
||||
spinner.displayedWhenStopped = False
|
||||
|
||||
label.packToCorner(Pack.UpperLeft, margin=0)
|
||||
label.fill(Pack.Right, margin=0)
|
||||
label.setAnchor(Pack.UpperLeft, growX=True)
|
||||
image.packRelativeTo(label, Pack.Below)
|
||||
image.fill(Pack.LowerRight, margin=0)
|
||||
image.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
spinner.y = label.y
|
||||
spinner.x = subSplit.width - 30
|
||||
spinner.setAnchor(Pack.UpperRight)
|
||||
|
||||
table.packToCorner(Pack.UpperLeft, margin=0)
|
||||
table.fill(Pack.Right, margin=0)
|
||||
table.setAnchor(Pack.UpperLeft, growX=True)
|
||||
|
||||
split.packRelativeTo(table, Pack.Below)
|
||||
split.fill(Pack.LowerRight, margin=0)
|
||||
split.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
ownerclass = 'DirectoryPanel'
|
||||
ownerimport = 'DirectoryPanel.h'
|
||||
|
||||
result = Window(425, 300, "dupeGuru")
|
||||
promptLabel = Label(result, "Select folders to scan and press \"Scan\".")
|
||||
directoryOutline = OutlineView(result)
|
||||
directoryOutline.OBJC_CLASS = 'HSOutlineView'
|
||||
appModeSelector = SegmentedControl(result)
|
||||
appModeLabel = Label(result, "Application Mode:")
|
||||
scanTypePopup = Popup(result)
|
||||
scanTypeLabel = Label(result, "Scan Type:")
|
||||
addButton = Button(result, "")
|
||||
removeButton = Button(result, "")
|
||||
loadResultsButton = Button(result, "Load Results")
|
||||
scanButton = Button(result, "Scan")
|
||||
addPopup = Popup(None)
|
||||
loadRecentPopup = Popup(None)
|
||||
|
||||
owner.outlineView = directoryOutline
|
||||
owner.appModeSelector = appModeSelector
|
||||
owner.scanTypePopup = scanTypePopup
|
||||
owner.removeButton = removeButton
|
||||
owner.loadResultsButton = loadResultsButton
|
||||
owner.addButtonPopUp = addPopup
|
||||
owner.loadRecentButtonPopUp = loadRecentPopup
|
||||
|
||||
result.autosaveName = 'DirectoryPanel'
|
||||
result.canMinimize = False
|
||||
result.minSize = Size(400, 270)
|
||||
for label in ["Standard", "Music", "Picture"]:
|
||||
appModeSelector.addSegment(label, 80)
|
||||
addButton.bezelStyle = removeButton.bezelStyle = const.NSTexturedRoundedBezelStyle
|
||||
addButton.image = 'NSAddTemplate'
|
||||
removeButton.image = 'NSRemoveTemplate'
|
||||
for button in (addButton, removeButton):
|
||||
button.style = const.NSTexturedRoundedBezelStyle
|
||||
button.imagePosition = const.NSImageOnly
|
||||
scanButton.keyEquivalent = '\\r'
|
||||
appModeSelector.action = Action(owner, 'changeAppMode:')
|
||||
addButton.action = Action(owner, 'popupAddDirectoryMenu:')
|
||||
removeButton.action = Action(owner, 'removeSelectedDirectory')
|
||||
loadResultsButton.action = Action(owner, 'popupLoadRecentMenu:')
|
||||
scanButton.action = Action(None, 'startScanning')
|
||||
|
||||
directoryOutline.font = Font(FontFamily.System, FontSize.SmallSystem)
|
||||
col = directoryOutline.addColumn('name', "Name", 100)
|
||||
col.editable = False
|
||||
col.autoResizable = True
|
||||
col = directoryOutline.addColumn('state', "State", 85)
|
||||
col.editable = True
|
||||
col.autoResizable = False
|
||||
col.dataCell = Popup(None, ["Normal", "Reference", "Excluded"])
|
||||
col.dataCell.controlSize = const.NSSmallControlSize
|
||||
directoryOutline.allowsColumnReordering = False
|
||||
directoryOutline.allowsColumnSelection = False
|
||||
directoryOutline.allowsMultipleSelection = True
|
||||
|
||||
appModeLabel.width = scanTypeLabel.width = 110
|
||||
scanTypePopup.width = 248
|
||||
appModeLayout = HLayout([appModeLabel, appModeSelector])
|
||||
scanTypeLayout = HLayout([scanTypeLabel, scanTypePopup])
|
||||
|
||||
for button in (addButton, removeButton):
|
||||
button.width = 28
|
||||
for button in (loadResultsButton, scanButton):
|
||||
button.width = 118
|
||||
|
||||
buttonLayout = HLayout([addButton, removeButton, None, loadResultsButton, scanButton])
|
||||
mainLayout = VLayout([appModeLayout, scanTypeLayout, promptLabel, directoryOutline, buttonLayout], filler=directoryOutline)
|
||||
mainLayout.packToCorner(Pack.UpperLeft)
|
||||
mainLayout.fill(Pack.LowerRight)
|
||||
directoryOutline.packRelativeTo(promptLabel, Pack.Below)
|
||||
|
||||
promptLabel.setAnchor(Pack.UpperLeft, growX=True)
|
||||
directoryOutline.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
buttonLayout.setAnchor(Pack.Below)
|
||||
@@ -1,30 +0,0 @@
|
||||
ownerclass = 'IgnoreListDialog'
|
||||
ownerimport = 'IgnoreListDialog.h'
|
||||
|
||||
result = Window(550, 350, "Ignore List")
|
||||
table = TableView(result)
|
||||
removeSelectedButton = Button(result, "Remove Selected")
|
||||
clearButton = Button(result, "Clear")
|
||||
closeButton = Button(result, "Close")
|
||||
|
||||
owner.ignoreListTableView = table
|
||||
|
||||
result.canMinimize = False
|
||||
removeSelectedButton.action = Action(owner.model, 'removeSelected')
|
||||
clearButton.action = Action(owner.model, 'clear')
|
||||
closeButton.action = Action(result, 'performClose:')
|
||||
closeButton.keyEquivalent = '\\r'
|
||||
table.allowsColumnReordering = False
|
||||
table.allowsColumnSelection = False
|
||||
table.allowsMultipleSelection = True
|
||||
|
||||
removeSelectedButton.width = 142
|
||||
clearButton.width = 142
|
||||
closeButton.width = 84
|
||||
buttonLayout = HLayout([removeSelectedButton, clearButton, None, closeButton])
|
||||
buttonLayout.packToCorner(Pack.LowerLeft)
|
||||
buttonLayout.fill(Pack.Right)
|
||||
buttonLayout.setAnchor(Pack.Below)
|
||||
table.packRelativeTo(buttonLayout, Pack.Above)
|
||||
table.fill(Pack.UpperRight)
|
||||
table.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
@@ -1,78 +0,0 @@
|
||||
ownerclass = 'AppDelegate'
|
||||
ownerimport = 'AppDelegate.h'
|
||||
|
||||
result = Menu("")
|
||||
appMenu = result.addMenu("dupeGuru")
|
||||
fileMenu = result.addMenu("File")
|
||||
editMenu = result.addMenu("Edit")
|
||||
actionMenu = result.addMenu("Actions")
|
||||
owner.columnsMenu = result.addMenu("Columns")
|
||||
modeMenu = result.addMenu("Mode")
|
||||
windowMenu = result.addMenu("Window")
|
||||
helpMenu = result.addMenu("Help")
|
||||
|
||||
appMenu.addItem("About dupeGuru", Action(owner, 'showAboutBox'))
|
||||
appMenu.addItem("Check for update...", Action(owner.updater, 'checkForUpdates:'))
|
||||
appMenu.addSeparator()
|
||||
appMenu.addItem("Preferences...", Action(owner, 'showPreferencesPanel'), 'cmd+,')
|
||||
appMenu.addSeparator()
|
||||
NSApp.servicesMenu = appMenu.addMenu("Services")
|
||||
appMenu.addSeparator()
|
||||
appMenu.addItem("Hide dupeGuru", Action(NSApp, 'hide:'), 'cmd+h')
|
||||
appMenu.addItem("Hide Others", Action(NSApp, 'hideOtherApplications:'), 'cmd+alt+h')
|
||||
appMenu.addItem("Show All", Action(NSApp, 'unhideAllApplications:'))
|
||||
appMenu.addSeparator()
|
||||
appMenu.addItem("Quit dupeGuru", Action(NSApp, 'terminate:'), 'cmd+q')
|
||||
|
||||
fileMenu.addItem("Load Results...", Action(None, 'loadResults'), 'cmd+o')
|
||||
owner.recentResultsMenu = fileMenu.addMenu("Load Recent Results")
|
||||
fileMenu.addItem("Save Results...", Action(None, 'saveResults'), 'cmd+s')
|
||||
fileMenu.addItem("Export Results to XHTML", Action(owner.model, 'exportToXHTML'), 'cmd+shift+e')
|
||||
fileMenu.addItem("Export Results to CSV", Action(owner.model, 'exportToCSV'))
|
||||
fileMenu.addItem("Clear Picture Cache", Action(owner, 'clearPictureCache'), 'cmd+shift+p')
|
||||
|
||||
editMenu.addItem("Mark All", Action(None, 'markAll'), 'cmd+a')
|
||||
editMenu.addItem("Mark None", Action(None, 'markNone'), 'cmd+shift+a')
|
||||
editMenu.addItem("Invert Marking", Action(None, 'markInvert'), 'cmd+alt+a')
|
||||
editMenu.addItem("Mark Selected", Action(None, 'markSelected'), 'ctrl+cmd+a')
|
||||
editMenu.addSeparator()
|
||||
editMenu.addItem("Cut", Action(None, 'cut:'), 'cmd+x')
|
||||
editMenu.addItem("Copy", Action(None, 'copy:'), 'cmd+c')
|
||||
editMenu.addItem("Paste", Action(None, 'paste:'), 'cmd+v')
|
||||
editMenu.addSeparator()
|
||||
editMenu.addItem("Filter Results...", Action(None, 'focusOnFilterField'), 'cmd+alt+f')
|
||||
|
||||
actionMenu.addItem("Start Duplicate Scan", Action(owner, 'startScanning'), 'cmd+d')
|
||||
actionMenu.addSeparator()
|
||||
actionMenu.addItem("Send Marked to Trash...", Action(None, 'trashMarked'), 'cmd+t')
|
||||
actionMenu.addItem("Move Marked to...", Action(None, 'moveMarked'), 'cmd+m')
|
||||
actionMenu.addItem("Copy Marked to...", Action(None, 'copyMarked'), 'cmd+alt+m')
|
||||
actionMenu.addItem("Remove Marked from Results", Action(None, 'removeMarked'), 'cmd+r')
|
||||
actionMenu.addItem("Re-Prioritize Results...", Action(None, 'reprioritizeResults'))
|
||||
actionMenu.addSeparator()
|
||||
actionMenu.addItem("Remove Selected from Results", Action(None, 'removeSelected'), 'cmd+backspace')
|
||||
actionMenu.addItem("Add Selected to Ignore List", Action(None, 'ignoreSelected'), 'cmd+g')
|
||||
actionMenu.addItem("Make Selected into Reference", Action(None, 'switchSelected'), 'cmd+arrowup')
|
||||
actionMenu.addSeparator()
|
||||
actionMenu.addItem("Open Selected with Default Application", Action(None, 'openSelected'), 'cmd+return')
|
||||
actionMenu.addItem("Reveal Selected in Finder", Action(None, 'revealSelected'), 'cmd+alt+return')
|
||||
actionMenu.addItem("Invoke Custom Command", Action(None, 'invokeCustomCommand'), 'cmd+shift+c')
|
||||
actionMenu.addItem("Rename Selected", Action(None, 'renameSelected'), 'enter')
|
||||
|
||||
modeMenu.addItem("Show Dupes Only", Action(None, 'togglePowerMarker'), 'cmd+1')
|
||||
modeMenu.addItem("Show Delta Values", Action(None, 'toggleDelta'), 'cmd+2')
|
||||
|
||||
windowMenu.addItem("Results Window", Action(owner, 'showResultWindow'))
|
||||
windowMenu.addItem("Folder Selection Window", Action(owner, 'showDirectoryWindow'))
|
||||
windowMenu.addItem("Ignore List", Action(owner, 'showIgnoreList'))
|
||||
windowMenu.addItem("Details Panel", Action(None, 'toggleDetailsPanel'), 'cmd+i')
|
||||
windowMenu.addItem("Quick Look", Action(None, 'toggleQuicklookPanel'), 'cmd+l')
|
||||
windowMenu.addSeparator()
|
||||
windowMenu.addItem("Minimize", Action(None, 'performMinimize:'))
|
||||
windowMenu.addItem("Zoom", Action(None, 'performZoom:'))
|
||||
windowMenu.addItem("Close Window", Action(None, 'performClose:'), 'cmd+w')
|
||||
windowMenu.addSeparator()
|
||||
windowMenu.addItem("Bring All to Front", Action(None, 'arrangeInFront:'))
|
||||
|
||||
helpMenu.addItem("dupeGuru Help", Action(owner, 'openHelp'), 'cmd+?')
|
||||
helpMenu.addItem("dupeGuru Website", Action(owner, 'openWebsite'))
|
||||
@@ -1,173 +0,0 @@
|
||||
appmode = args.get('appmode', 'standard')
|
||||
dialogHeights = {
|
||||
'standard': 325,
|
||||
'music': 345,
|
||||
'picture': 255,
|
||||
}
|
||||
|
||||
result = Window(410, dialogHeights[appmode], "dupeGuru Preferences")
|
||||
tabView = TabView(result)
|
||||
basicTab = tabView.addTab("Basic")
|
||||
advancedTab = tabView.addTab("Advanced")
|
||||
thresholdSlider = Slider(basicTab.view, 1, 100, 80)
|
||||
thresholdLabel = Label(basicTab.view, "Filter hardness:")
|
||||
moreResultsLabel = Label(basicTab.view, "More results")
|
||||
fewerResultsLabel = Label(basicTab.view, "Fewer results")
|
||||
thresholdValueLabel = Label(basicTab.view, "")
|
||||
fontSizeCombo = Combobox(basicTab.view, ["11", "12", "13", "14", "18", "24"])
|
||||
fontSizeLabel = Label(basicTab.view, "Font Size:")
|
||||
if appmode in ('standard', 'music'):
|
||||
wordWeightingBox = Checkbox(basicTab.view, "Word weighting")
|
||||
matchSimilarWordsBox = Checkbox(basicTab.view, "Match similar words")
|
||||
elif appmode == 'picture':
|
||||
matchDifferentDimensionsBox = Checkbox(basicTab.view, "Match pictures of different dimensions")
|
||||
mixKindBox = Checkbox(basicTab.view, "Can mix file kind")
|
||||
removeEmptyFoldersBox = Checkbox(basicTab.view, "Remove empty folders on delete or move")
|
||||
checkForUpdatesBox = Checkbox(basicTab.view, "Automatically check for updates")
|
||||
if appmode == 'standard':
|
||||
ignoreSmallFilesBox = Checkbox(basicTab.view, "Ignore files smaller than:")
|
||||
smallFilesThresholdText = TextField(basicTab.view, "")
|
||||
smallFilesThresholdSuffixLabel = Label(basicTab.view, "KB")
|
||||
elif appmode == 'music':
|
||||
tagsToScanLabel = Label(basicTab.view, "Tags to scan:")
|
||||
trackBox = Checkbox(basicTab.view, "Track")
|
||||
artistBox = Checkbox(basicTab.view, "Artist")
|
||||
albumBox = Checkbox(basicTab.view, "Album")
|
||||
titleBox = Checkbox(basicTab.view, "Title")
|
||||
genreBox = Checkbox(basicTab.view, "Genre")
|
||||
yearBox = Checkbox(basicTab.view, "Year")
|
||||
tagBoxes = [trackBox, artistBox, albumBox, titleBox, genreBox, yearBox]
|
||||
|
||||
regexpCheckbox = Checkbox(advancedTab.view, "Use regular expressions when filtering")
|
||||
ignoreHardlinksBox = Checkbox(advancedTab.view, "Ignore duplicates hardlinking to the same file")
|
||||
debugModeCheckbox = Checkbox(advancedTab.view, "Debug mode (restart required)")
|
||||
customCommandLabel = Label(advancedTab.view, "Custom command (arguments: %d for dupe, %r for ref):")
|
||||
customCommandText = TextField(advancedTab.view, "")
|
||||
copyMoveLabel = Label(advancedTab.view, "Copy and Move:")
|
||||
copyMovePopup = Popup(advancedTab.view, ["Right in destination", "Recreate relative path", "Recreate absolute path"])
|
||||
|
||||
resetToDefaultsButton = Button(result, "Reset To Defaults")
|
||||
thresholdSlider.bind('value', defaults, 'values.minMatchPercentage')
|
||||
thresholdValueLabel.bind('value', defaults, 'values.minMatchPercentage')
|
||||
fontSizeCombo.bind('value', defaults, 'values.TableFontSize')
|
||||
mixKindBox.bind('value', defaults, 'values.mixFileKind')
|
||||
removeEmptyFoldersBox.bind('value', defaults, 'values.removeEmptyFolders')
|
||||
checkForUpdatesBox.bind('value', defaults, 'values.SUEnableAutomaticChecks')
|
||||
regexpCheckbox.bind('value', defaults, 'values.useRegexpFilter')
|
||||
ignoreHardlinksBox.bind('value', defaults, 'values.ignoreHardlinkMatches')
|
||||
debugModeCheckbox.bind('value', defaults, 'values.DebugMode')
|
||||
customCommandText.bind('value', defaults, 'values.CustomCommand')
|
||||
copyMovePopup.bind('selectedIndex', defaults, 'values.recreatePathType')
|
||||
if appmode in ('standard', 'music'):
|
||||
wordWeightingBox.bind('value', defaults, 'values.wordWeighting')
|
||||
matchSimilarWordsBox.bind('value', defaults, 'values.matchSimilarWords')
|
||||
disableWhenContentScan = [thresholdSlider, wordWeightingBox, matchSimilarWordsBox]
|
||||
for control in disableWhenContentScan:
|
||||
vtname = 'vtScanTypeMusicIsNotContent' if appmode == 'music' else 'vtScanTypeIsNotContent'
|
||||
prefname = 'values.scanTypeMusic' if appmode == 'music' else 'values.scanTypeStandard'
|
||||
control.bind('enabled', defaults, prefname, valueTransformer=vtname)
|
||||
if appmode == 'standard':
|
||||
ignoreSmallFilesBox.bind('value', defaults, 'values.ignoreSmallFiles')
|
||||
smallFilesThresholdText.bind('value', defaults, 'values.smallFileThreshold')
|
||||
elif appmode == 'music':
|
||||
for box in tagBoxes:
|
||||
box.bind('enabled', defaults, 'values.scanTypeMusic', valueTransformer='vtScanTypeIsTag')
|
||||
trackBox.bind('value', defaults, 'values.scanTagTrack')
|
||||
artistBox.bind('value', defaults, 'values.scanTagArtist')
|
||||
albumBox.bind('value', defaults, 'values.scanTagAlbum')
|
||||
titleBox.bind('value', defaults, 'values.scanTagTitle')
|
||||
genreBox.bind('value', defaults, 'values.scanTagGenre')
|
||||
yearBox.bind('value', defaults, 'values.scanTagYear')
|
||||
elif appmode == 'picture':
|
||||
matchDifferentDimensionsBox.bind('value', defaults, 'values.matchScaled')
|
||||
thresholdSlider.bind('enabled', defaults, 'values.scanTypePicture', valueTransformer='vtScanTypeIsFuzzy')
|
||||
|
||||
result.canResize = False
|
||||
result.canMinimize = False
|
||||
thresholdValueLabel.formatter = NumberFormatter(NumberStyle.Decimal)
|
||||
thresholdValueLabel.formatter.maximumFractionDigits = 0
|
||||
allLabels = [thresholdValueLabel, moreResultsLabel, fewerResultsLabel,
|
||||
thresholdLabel, fontSizeLabel, customCommandLabel, copyMoveLabel]
|
||||
allCheckboxes = [mixKindBox, removeEmptyFoldersBox, checkForUpdatesBox, regexpCheckbox,
|
||||
ignoreHardlinksBox, debugModeCheckbox]
|
||||
if appmode == 'standard':
|
||||
allLabels += [smallFilesThresholdSuffixLabel]
|
||||
allCheckboxes += [ignoreSmallFilesBox, wordWeightingBox, matchSimilarWordsBox]
|
||||
elif appmode == 'music':
|
||||
allLabels += [tagsToScanLabel]
|
||||
allCheckboxes += tagBoxes + [wordWeightingBox, matchSimilarWordsBox]
|
||||
elif appmode == 'picture':
|
||||
allCheckboxes += [matchDifferentDimensionsBox]
|
||||
for label in allLabels:
|
||||
label.controlSize = ControlSize.Small
|
||||
fewerResultsLabel.alignment = TextAlignment.Right
|
||||
for checkbox in allCheckboxes:
|
||||
checkbox.font = thresholdValueLabel.font
|
||||
resetToDefaultsButton.action = Action(defaults, 'revertToInitialValues:')
|
||||
|
||||
thresholdLabel.width = fontSizeLabel.width = 94
|
||||
fontSizeCombo.width = 66
|
||||
thresholdValueLabel.width = 25
|
||||
resetToDefaultsButton.width = 136
|
||||
if appmode == 'standard':
|
||||
smallFilesThresholdText.width = 60
|
||||
smallFilesThresholdSuffixLabel.width = 40
|
||||
elif appmode == 'music':
|
||||
for box in tagBoxes:
|
||||
box.width = 70
|
||||
|
||||
tabView.packToCorner(Pack.UpperLeft)
|
||||
tabView.fill(Pack.Right)
|
||||
resetToDefaultsButton.packRelativeTo(tabView, Pack.Below, align=Pack.Right)
|
||||
tabView.fill(Pack.Below, margin=14)
|
||||
tabView.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
thresholdLayout = HLayout([thresholdLabel, thresholdSlider, thresholdValueLabel], filler=thresholdSlider)
|
||||
thresholdLayout.packToCorner(Pack.UpperLeft)
|
||||
thresholdLayout.fill(Pack.Right)
|
||||
# We want to give the labels as much space as possible, and we only "know" how much is available
|
||||
# after the slider's fill operation.
|
||||
moreResultsLabel.width = fewerResultsLabel.width = thresholdSlider.width // 2
|
||||
moreResultsLabel.packRelativeTo(thresholdSlider, Pack.Below, align=Pack.Left, margin=6)
|
||||
fewerResultsLabel.packRelativeTo(thresholdSlider, Pack.Below, align=Pack.Right, margin=6)
|
||||
fontSizeCombo.packRelativeTo(moreResultsLabel, Pack.Below)
|
||||
fontSizeLabel.packRelativeTo(fontSizeCombo, Pack.Left)
|
||||
|
||||
if appmode == 'music':
|
||||
tagsToScanLabel.packRelativeTo(fontSizeCombo, Pack.Below)
|
||||
tagsToScanLabel.fill(Pack.Left)
|
||||
tagsToScanLabel.fill(Pack.Right)
|
||||
trackBox.packRelativeTo(tagsToScanLabel, Pack.Below)
|
||||
trackBox.x += 10
|
||||
artistBox.packRelativeTo(trackBox, Pack.Right)
|
||||
albumBox.packRelativeTo(artistBox, Pack.Right)
|
||||
titleBox.packRelativeTo(trackBox, Pack.Below)
|
||||
genreBox.packRelativeTo(titleBox, Pack.Right)
|
||||
yearBox.packRelativeTo(genreBox, Pack.Right)
|
||||
viewToPackCheckboxesUnder = titleBox
|
||||
else:
|
||||
viewToPackCheckboxesUnder = fontSizeCombo
|
||||
|
||||
if appmode == 'standard':
|
||||
checkboxesToLayout = [wordWeightingBox, matchSimilarWordsBox, mixKindBox, removeEmptyFoldersBox,
|
||||
ignoreSmallFilesBox]
|
||||
elif appmode == 'music':
|
||||
checkboxesToLayout = [wordWeightingBox, matchSimilarWordsBox, mixKindBox, removeEmptyFoldersBox,
|
||||
checkForUpdatesBox]
|
||||
elif appmode == 'picture':
|
||||
checkboxesToLayout = [matchDifferentDimensionsBox, mixKindBox, removeEmptyFoldersBox,
|
||||
checkForUpdatesBox]
|
||||
checkboxLayout = VLayout(checkboxesToLayout)
|
||||
checkboxLayout.packRelativeTo(viewToPackCheckboxesUnder, Pack.Below)
|
||||
checkboxLayout.fill(Pack.Left)
|
||||
checkboxLayout.fill(Pack.Right)
|
||||
|
||||
if appmode == 'standard':
|
||||
smallFilesThresholdText.packRelativeTo(ignoreSmallFilesBox, Pack.Below, margin=4)
|
||||
checkForUpdatesBox.packRelativeTo(smallFilesThresholdText, Pack.Below, margin=4)
|
||||
checkForUpdatesBox.fill(Pack.Right)
|
||||
smallFilesThresholdText.x += 20
|
||||
smallFilesThresholdSuffixLabel.packRelativeTo(smallFilesThresholdText, Pack.Right)
|
||||
|
||||
advancedLayout = VLayout(advancedTab.view.subviews[:])
|
||||
advancedLayout.packToCorner(Pack.UpperLeft)
|
||||
advancedLayout.fill(Pack.Right)
|
||||
@@ -1,65 +0,0 @@
|
||||
ownerclass = 'PrioritizeDialog'
|
||||
ownerimport = 'PrioritizeDialog.h'
|
||||
|
||||
result = Window(610, 400, "Re-Prioritize duplicates")
|
||||
promptLabel = Label(result, "Add criteria to the right box and click OK to send the dupes that "
|
||||
"correspond the best to these criteria to their respective group's reference position. Read "
|
||||
"the help file for more information.")
|
||||
split = SplitView(result, 2, vertical=True)
|
||||
categoryPopup = Popup(split.subviews[0])
|
||||
criteriaTable = ListView(split.subviews[0])
|
||||
prioritizationTable = ListView(split.subviews[1])
|
||||
addButton = Button(split.subviews[1], NLSTR("-->"))
|
||||
removeButton = Button(split.subviews[1], NLSTR("<--"))
|
||||
okButton = Button(result, "Ok")
|
||||
cancelButton = Button(result, "Cancel")
|
||||
|
||||
owner.categoryPopUpView = categoryPopup
|
||||
owner.criteriaTableView = criteriaTable
|
||||
owner.prioritizationTableView = prioritizationTable
|
||||
|
||||
result.canMinimize = False
|
||||
result.canClose = False
|
||||
result.minSize = Size(result.width, result.height)
|
||||
addButton.action = Action(owner.model, 'addSelected')
|
||||
removeButton.action = Action(owner.model, 'removeSelected')
|
||||
okButton.action = Action(owner, 'ok')
|
||||
cancelButton.action = Action(owner, 'cancel')
|
||||
okButton.keyEquivalent = '\\r'
|
||||
cancelButton.keyEquivalent = '\\e'
|
||||
|
||||
# For layouts to correctly work, subviews need to have the dimensions they'll approximately have
|
||||
# at runtime.
|
||||
split.subviews[0].width = 260
|
||||
split.subviews[0].height = 260
|
||||
split.subviews[1].width = 340
|
||||
split.subviews[1].height = 260
|
||||
promptLabel.height *= 3 # 3 lines
|
||||
|
||||
leftLayout = VLayout([categoryPopup, criteriaTable], filler=criteriaTable)
|
||||
middleLayout = VLayout([addButton, removeButton], width=41)
|
||||
buttonLayout = HLayout([None, cancelButton, okButton])
|
||||
|
||||
#pack split subview 0
|
||||
leftLayout.fillAll()
|
||||
|
||||
#pack split subview 1
|
||||
prioritizationTable.fillAll()
|
||||
prioritizationTable.width -= 48
|
||||
prioritizationTable.moveTo(Pack.Right)
|
||||
middleLayout.moveNextTo(prioritizationTable, Pack.Left, align=Pack.Middle)
|
||||
|
||||
# Main layout
|
||||
promptLabel.packToCorner(Pack.UpperLeft)
|
||||
promptLabel.fill(Pack.Right)
|
||||
split.moveNextTo(promptLabel, Pack.Below)
|
||||
buttonLayout.moveNextTo(split, Pack.Below)
|
||||
buttonLayout.fill(Pack.Right)
|
||||
split.fill(Pack.LowerRight)
|
||||
|
||||
promptLabel.setAnchor(Pack.UpperLeft, growX=True)
|
||||
prioritizationTable.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
categoryPopup.setAnchor(Pack.UpperLeft, growX=True)
|
||||
criteriaTable.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
split.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
buttonLayout.setAnchor(Pack.Below)
|
||||
@@ -1,35 +0,0 @@
|
||||
ownerclass = 'ProblemDialog'
|
||||
ownerimport = 'ProblemDialog.h'
|
||||
|
||||
result = Window(480, 310, "Problems!")
|
||||
messageLabel = Label(result, "There were problems processing some (or all) of the files. The cause "
|
||||
"of these problems are described in the table below. Those files were not removed from your "
|
||||
"results.")
|
||||
problemTable = TableView(result)
|
||||
revealButton = Button(result, "Reveal")
|
||||
closeButton = Button(result, "Close")
|
||||
|
||||
owner.problemTableView = problemTable
|
||||
|
||||
result.canMinimize = False
|
||||
result.minSize = Size(300, 300)
|
||||
closeButton.keyEquivalent = '\\r'
|
||||
revealButton.action = Action(owner.model, 'revealSelected')
|
||||
closeButton.action = Action(result, 'performClose:')
|
||||
|
||||
messageLabel.height *= 3 # 3 lines
|
||||
revealButton.width = 150
|
||||
closeButton.width = 98
|
||||
|
||||
messageLabel.packToCorner(Pack.UpperLeft)
|
||||
messageLabel.fill(Pack.Right)
|
||||
problemTable.packRelativeTo(messageLabel, Pack.Below)
|
||||
problemTable.fill(Pack.Right)
|
||||
revealButton.packRelativeTo(problemTable, Pack.Below)
|
||||
closeButton.packRelativeTo(problemTable, Pack.Below, align=Pack.Right)
|
||||
problemTable.fill(Pack.Below)
|
||||
|
||||
messageLabel.setAnchor(Pack.UpperLeft, growX=True)
|
||||
problemTable.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
revealButton.setAnchor(Pack.LowerLeft)
|
||||
closeButton.setAnchor(Pack.LowerRight)
|
||||
@@ -1,97 +0,0 @@
|
||||
ownerclass = 'ResultWindow'
|
||||
ownerimport = 'ResultWindow.h'
|
||||
|
||||
result = Window(557, 400, "dupeGuru Results")
|
||||
toolbar = result.createToolbar('ResultsToolbar')
|
||||
table = TableView(result)
|
||||
table.OBJC_CLASS = 'HSTableView'
|
||||
statsLabel = Label(result, "")
|
||||
contextMenu = Menu("")
|
||||
|
||||
#Setup toolbar items
|
||||
toolbar.displayMode = const.NSToolbarDisplayModeIconOnly
|
||||
directoriesToolItem = toolbar.addItem('Directories', "Directories", image='folder32')
|
||||
actionToolItem = toolbar.addItem('Action', "Action")
|
||||
filterToolItem = toolbar.addItem('Filter', "Filter")
|
||||
optionsToolItem = toolbar.addItem('Options', "Options")
|
||||
quicklookToolItem = toolbar.addItem('QuickLook', "Quick Look")
|
||||
toolbar.defaultItems = [actionToolItem, optionsToolItem, quicklookToolItem, directoriesToolItem,
|
||||
toolbar.flexibleSpace(), filterToolItem]
|
||||
actionPopup = Popup(None)
|
||||
actionPopup.pullsdown = True
|
||||
actionPopup.bezelStyle = const.NSTexturedRoundedBezelStyle
|
||||
actionPopup.arrowPosition = const.NSPopUpArrowAtBottom
|
||||
item = actionPopup.menu.addItem("") # First item is invisible
|
||||
item.hidden = True
|
||||
item.image = 'NSActionTemplate'
|
||||
actionPopup.width = 44
|
||||
actionToolItem.view = actionPopup
|
||||
filterField = SearchField(None, "Filter")
|
||||
filterField.action = Action(owner, 'filter')
|
||||
filterField.sendsWholeSearchString = True
|
||||
filterToolItem.view = filterField
|
||||
filterToolItem.minSize = Size(80, 22)
|
||||
filterToolItem.maxSize = Size(300, 22)
|
||||
quickLookButton = Button(None, "")
|
||||
quickLookButton.bezelStyle = const.NSTexturedRoundedBezelStyle
|
||||
quickLookButton.image = 'NSQuickLookTemplate'
|
||||
quickLookButton.width = 44
|
||||
quickLookButton.action = Action(owner, 'toggleQuicklookPanel')
|
||||
quicklookToolItem.view = quickLookButton
|
||||
optionsSegments = SegmentedControl(None)
|
||||
optionsSegments.segmentStyle = const.NSSegmentStyleCapsule
|
||||
optionsSegments.trackingMode = const.NSSegmentSwitchTrackingSelectAny
|
||||
optionsSegments.font = Font(FontFamily.System, 11)
|
||||
optionsSegments.addSegment("Details", 57)
|
||||
optionsSegments.addSegment("Dupes Only", 82)
|
||||
optionsSegments.addSegment("Delta", 48)
|
||||
optionsSegments.action = Action(owner, 'changeOptions')
|
||||
optionsToolItem.view = optionsSegments
|
||||
|
||||
# Popuplate menus
|
||||
actionPopup.menu.addItem("Send Marked to Trash...", action=Action(owner, 'trashMarked'))
|
||||
actionPopup.menu.addItem("Move Marked to...", action=Action(owner, 'moveMarked'))
|
||||
actionPopup.menu.addItem("Copy Marked to...", action=Action(owner, 'copyMarked'))
|
||||
actionPopup.menu.addItem("Remove Marked from Results", action=Action(owner, 'removeMarked'))
|
||||
actionPopup.menu.addSeparator()
|
||||
for menu in (actionPopup.menu, contextMenu):
|
||||
menu.addItem("Remove Selected from Results", action=Action(owner, 'removeSelected'))
|
||||
menu.addItem("Add Selected to Ignore List", action=Action(owner, 'ignoreSelected'))
|
||||
menu.addItem("Make Selected into Reference", action=Action(owner, 'switchSelected'))
|
||||
menu.addSeparator()
|
||||
menu.addItem("Open Selected with Default Application", action=Action(owner, 'openSelected'))
|
||||
menu.addItem("Reveal Selected in Finder", action=Action(owner, 'revealSelected'))
|
||||
menu.addItem("Rename Selected", action=Action(owner, 'renameSelected'))
|
||||
|
||||
# Doing connections
|
||||
owner.filterField = filterField
|
||||
owner.matches = table
|
||||
owner.optionsSwitch = optionsSegments
|
||||
owner.optionsToolbarItem = optionsToolItem
|
||||
owner.stats = statsLabel
|
||||
table.bind('rowHeight', defaults, 'values.TableFontSize', valueTransformer='vtRowHeightOffset')
|
||||
|
||||
# Rest of the setup
|
||||
result.minSize = Size(340, 340)
|
||||
result.autosaveName = 'MainWindow'
|
||||
statsLabel.alignment = TextAlignment.Center
|
||||
table.alternatingRows = True
|
||||
table.menu = contextMenu
|
||||
table.allowsColumnReordering = True
|
||||
table.allowsColumnResizing = True
|
||||
table.allowsColumnSelection = False
|
||||
table.allowsEmptySelection = False
|
||||
table.allowsMultipleSelection = True
|
||||
table.allowsTypeSelect = True
|
||||
table.gridStyleMask = const.NSTableViewSolidHorizontalGridLineMask
|
||||
table.setAnchor(Pack.UpperLeft, growX=True, growY=True)
|
||||
statsLabel.setAnchor(Pack.LowerLeft, growX=True)
|
||||
|
||||
# Layout
|
||||
# It's a little weird to pack with a margin of -1, but if I don't do that, I get too thick of a
|
||||
# border on the upper side of the table.
|
||||
table.packToCorner(Pack.UpperLeft, margin=-1)
|
||||
table.fill(Pack.Right, margin=0)
|
||||
statsLabel.packRelativeTo(table, Pack.Below, margin=6)
|
||||
statsLabel.fill(Pack.Right, margin=0)
|
||||
table.fill(Pack.Below, margin=5)
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import sys
|
||||
import os
|
||||
import os.path as op
|
||||
|
||||
top = '.'
|
||||
out = 'build'
|
||||
|
||||
def options(opt):
|
||||
opt.load('compiler_c python')
|
||||
|
||||
def configure(conf):
|
||||
# We use clang to compile our app
|
||||
conf.env.CC = 'clang'
|
||||
# WAF has a "pyembed" feature allowing us to automatically find Python and compile by linking
|
||||
# to it. The problem is that because we made a copy of the Python library to mangle with its
|
||||
# "install name", we don't actually want to link to our installed python, but to our mangled
|
||||
# Python. The line below tells the "pyembed" WAF feature to look in ../build for Python.
|
||||
conf.env.LIBPATH_PYEMBED = op.abspath('../build')
|
||||
# I did a lot of fiddling-around, but I didn't find how to tell WAF the Python library name
|
||||
# to look for without making the whole compilation process fail, so I just create a symlink
|
||||
# with the name WAF is looking for.
|
||||
versioned_dylib_path = '../build/libpython{}m.dylib'.format(sys.version[:3])
|
||||
if not op.exists(versioned_dylib_path):
|
||||
os.symlink('../build/Python', versioned_dylib_path)
|
||||
# The rest is standard WAF code that you can find the the python and macapp demos.
|
||||
conf.load('compiler_c python')
|
||||
conf.check_python_version((3,4,0))
|
||||
conf.check_python_headers()
|
||||
conf.env.FRAMEWORK_COCOA = 'Cocoa'
|
||||
conf.env.ARCH_COCOA = ['x86_64']
|
||||
conf.env.MACOSX_DEPLOYMENT_TARGET = '10.8'
|
||||
conf.env.CFLAGS = ['-F'+op.abspath('Sparkle/build/Release')]
|
||||
conf.env.LINKFLAGS = ['-F'+op.abspath('Sparkle/build/Release')]
|
||||
|
||||
def build(ctx):
|
||||
# What do we compile?
|
||||
cocoalib_node = ctx.srcnode.find_dir('..').find_dir('cocoalib')
|
||||
cocoalib_folders = ['controllers', 'views']
|
||||
cocoalib_includes = [cocoalib_node] + [cocoalib_node.find_dir(folder) for folder in cocoalib_folders]
|
||||
cocoalib_uses = ['NSEventAdditions', 'Dialogs', 'HSAboutBox', 'Utils',
|
||||
'HSPyUtil', 'ProgressController', 'HSRecentFiles', 'HSQuicklook', 'ValueTransformers',
|
||||
'NSImageAdditions', 'NSNotificationAdditions',
|
||||
'views/HSTableView', 'views/HSOutlineView', 'views/NSIndexPathAdditions',
|
||||
'views/NSTableViewAdditions',
|
||||
'controllers/HSColumns', 'controllers/HSGUIController', 'controllers/HSTable',
|
||||
'controllers/HSOutline', 'controllers/HSPopUpList', 'controllers/HSSelectableList',
|
||||
'controllers/HSTextField', 'controllers/HSProgressWindow']
|
||||
cocoalib_src = [cocoalib_node.find_node(usename + '.m') for usename in cocoalib_uses] + cocoalib_node.ant_glob('autogen/*.m')
|
||||
project_folders = [ctx.srcnode, ctx.srcnode.find_dir('autogen')]
|
||||
project_src = ctx.srcnode.ant_glob('autogen/*.m') + ctx.srcnode.ant_glob('*.m')
|
||||
|
||||
# Compile
|
||||
ctx.program(
|
||||
# "pyembed" takes care of the include and linking stuff to compile an app that embed Python.
|
||||
features = 'c cprogram pyembed',
|
||||
target = ctx.bldnode.make_node("dupeGuru"),
|
||||
source = cocoalib_src + project_src,
|
||||
includes = project_folders + cocoalib_includes,
|
||||
use = 'COCOA',
|
||||
# Because our python lib's install name is "@rpath/Python", we need to set the executable's
|
||||
# rpath. Fortunately, WAF supports it and we just need to supply the "rpath" argument.
|
||||
rpath = '@executable_path/../Frameworks',
|
||||
framework = ['Sparkle', 'Quartz'],
|
||||
)
|
||||
|
||||
from waflib import TaskGen
|
||||
@TaskGen.extension('.m')
|
||||
def m_hook(self, node):
|
||||
"""Alias .m files to be compiled the same as .c files, gcc will do the right thing."""
|
||||
return self.create_compiled_task('c', node)
|
||||
|
||||
1
cocoalib
1
cocoalib
Submodule cocoalib deleted from d059aa9b79
@@ -1,3 +1,2 @@
|
||||
__version__ = '4.0.2'
|
||||
__appname__ = 'dupeGuru'
|
||||
|
||||
__version__ = "4.1.1"
|
||||
__appname__ = "dupeGuru"
|
||||
|
||||
268
core/app.py
268
core/app.py
@@ -26,16 +26,18 @@ from .pe.photo import get_delta_dimensions
|
||||
from .util import cmp_value, fix_surrogate_encoding
|
||||
from . import directories, results, export, fs, prioritize
|
||||
from .ignore import IgnoreList
|
||||
from .exclude import ExcludeDict as ExcludeList
|
||||
from .scanner import ScanType
|
||||
from .gui.deletion_options import DeletionOptions
|
||||
from .gui.details_panel import DetailsPanel
|
||||
from .gui.directory_tree import DirectoryTree
|
||||
from .gui.ignore_list_dialog import IgnoreListDialog
|
||||
from .gui.exclude_list_dialog import ExcludeListDialogCore
|
||||
from .gui.problem_dialog import ProblemDialog
|
||||
from .gui.stats_label import StatsLabel
|
||||
|
||||
HAD_FIRST_LAUNCH_PREFERENCE = 'HadFirstLaunch'
|
||||
DEBUG_MODE_PREFERENCE = 'DebugMode'
|
||||
HAD_FIRST_LAUNCH_PREFERENCE = "HadFirstLaunch"
|
||||
DEBUG_MODE_PREFERENCE = "DebugMode"
|
||||
|
||||
MSG_NO_MARKED_DUPES = tr("There are no marked duplicates. Nothing has been done.")
|
||||
MSG_NO_SELECTED_DUPES = tr("There are no selected duplicates. Nothing has been done.")
|
||||
@@ -44,23 +46,27 @@ MSG_MANY_FILES_TO_OPEN = tr(
|
||||
"files are opened with, doing so can create quite a mess. Continue?"
|
||||
)
|
||||
|
||||
|
||||
class DestType:
|
||||
Direct = 0
|
||||
Relative = 1
|
||||
Absolute = 2
|
||||
|
||||
|
||||
class JobType:
|
||||
Scan = 'job_scan'
|
||||
Load = 'job_load'
|
||||
Move = 'job_move'
|
||||
Copy = 'job_copy'
|
||||
Delete = 'job_delete'
|
||||
Scan = "job_scan"
|
||||
Load = "job_load"
|
||||
Move = "job_move"
|
||||
Copy = "job_copy"
|
||||
Delete = "job_delete"
|
||||
|
||||
|
||||
class AppMode:
|
||||
Standard = 0
|
||||
Music = 1
|
||||
Picture = 2
|
||||
|
||||
|
||||
JOBID2TITLE = {
|
||||
JobType.Scan: tr("Scanning for duplicates"),
|
||||
JobType.Load: tr("Loading"),
|
||||
@@ -69,6 +75,7 @@ JOBID2TITLE = {
|
||||
JobType.Delete: tr("Sending to Trash"),
|
||||
}
|
||||
|
||||
|
||||
class DupeGuru(Broadcaster):
|
||||
"""Holds everything together.
|
||||
|
||||
@@ -100,7 +107,8 @@ class DupeGuru(Broadcaster):
|
||||
|
||||
Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results`
|
||||
"""
|
||||
#--- View interface
|
||||
|
||||
# --- View interface
|
||||
# get_default(key_name)
|
||||
# set_default(key_name, value)
|
||||
# show_message(msg)
|
||||
@@ -116,35 +124,41 @@ class DupeGuru(Broadcaster):
|
||||
|
||||
NAME = PROMPT_NAME = "dupeGuru"
|
||||
|
||||
PICTURE_CACHE_TYPE = "sqlite" # set to 'shelve' for a ShelveCache
|
||||
|
||||
def __init__(self, view):
|
||||
if view.get_default(DEBUG_MODE_PREFERENCE):
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logging.debug("Debug mode enabled")
|
||||
Broadcaster.__init__(self)
|
||||
self.view = view
|
||||
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.AppData, appname=self.NAME)
|
||||
self.appdata = desktop.special_folder_path(
|
||||
desktop.SpecialFolder.AppData, appname=self.NAME
|
||||
)
|
||||
if not op.exists(self.appdata):
|
||||
os.makedirs(self.appdata)
|
||||
self.app_mode = AppMode.Standard
|
||||
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.ignore_list = IgnoreList()
|
||||
# In addition to "app-level" options, this dictionary also holds options that will be
|
||||
# sent to the scanner. They don't have default values because those defaults values are
|
||||
# defined in the scanner class.
|
||||
self.options = {
|
||||
'escape_filter_regexp': True,
|
||||
'clean_empty_dirs': False,
|
||||
'ignore_hardlink_matches': False,
|
||||
'copymove_dest_type': DestType.Relative,
|
||||
'cache_path': op.join(self.appdata, 'cached_pictures.db'),
|
||||
"escape_filter_regexp": True,
|
||||
"clean_empty_dirs": False,
|
||||
"ignore_hardlink_matches": False,
|
||||
"copymove_dest_type": DestType.Relative,
|
||||
"picture_cache_type": self.PICTURE_CACHE_TYPE,
|
||||
}
|
||||
self.selected_dupes = []
|
||||
self.details_panel = DetailsPanel(self)
|
||||
self.directory_tree = DirectoryTree(self)
|
||||
self.problem_dialog = ProblemDialog(self)
|
||||
self.ignore_list_dialog = IgnoreListDialog(self)
|
||||
self.exclude_list_dialog = ExcludeListDialogCore(self)
|
||||
self.stats_label = StatsLabel(self)
|
||||
self.result_table = None
|
||||
self.deletion_options = DeletionOptions()
|
||||
@@ -153,7 +167,7 @@ class DupeGuru(Broadcaster):
|
||||
for child in children:
|
||||
child.connect()
|
||||
|
||||
#--- Private
|
||||
# --- Private
|
||||
def _recreate_result_table(self):
|
||||
if self.result_table is not None:
|
||||
self.result_table.disconnect()
|
||||
@@ -166,22 +180,31 @@ class DupeGuru(Broadcaster):
|
||||
self.result_table.connect()
|
||||
self.view.create_results_window()
|
||||
|
||||
def _get_picture_cache_path(self):
|
||||
cache_type = self.options["picture_cache_type"]
|
||||
cache_name = (
|
||||
"cached_pictures.shelve" if cache_type == "shelve" else "cached_pictures.db"
|
||||
)
|
||||
return op.join(self.appdata, cache_name)
|
||||
|
||||
def _get_dupe_sort_key(self, dupe, get_group, key, delta):
|
||||
if self.app_mode in (AppMode.Music, AppMode.Picture):
|
||||
if key == 'folder_path':
|
||||
dupe_folder_path = getattr(dupe, 'display_folder_path', dupe.folder_path)
|
||||
if key == "folder_path":
|
||||
dupe_folder_path = getattr(
|
||||
dupe, "display_folder_path", dupe.folder_path
|
||||
)
|
||||
return str(dupe_folder_path).lower()
|
||||
if self.app_mode == AppMode.Picture:
|
||||
if delta and key == 'dimensions':
|
||||
if delta and key == "dimensions":
|
||||
r = cmp_value(dupe, key)
|
||||
ref_value = cmp_value(get_group().ref, key)
|
||||
return get_delta_dimensions(r, ref_value)
|
||||
if key == 'marked':
|
||||
if key == "marked":
|
||||
return self.results.is_marked(dupe)
|
||||
if key == 'percentage':
|
||||
if key == "percentage":
|
||||
m = get_group().get_match_of(dupe)
|
||||
return m.percentage
|
||||
elif key == 'dupe_count':
|
||||
elif key == "dupe_count":
|
||||
return 0
|
||||
else:
|
||||
result = cmp_value(dupe, key)
|
||||
@@ -196,21 +219,25 @@ class DupeGuru(Broadcaster):
|
||||
|
||||
def _get_group_sort_key(self, group, key):
|
||||
if self.app_mode in (AppMode.Music, AppMode.Picture):
|
||||
if key == 'folder_path':
|
||||
dupe_folder_path = getattr(group.ref, 'display_folder_path', group.ref.folder_path)
|
||||
if key == "folder_path":
|
||||
dupe_folder_path = getattr(
|
||||
group.ref, "display_folder_path", group.ref.folder_path
|
||||
)
|
||||
return str(dupe_folder_path).lower()
|
||||
if key == 'percentage':
|
||||
if key == "percentage":
|
||||
return group.percentage
|
||||
if key == 'dupe_count':
|
||||
if key == "dupe_count":
|
||||
return len(group)
|
||||
if key == 'marked':
|
||||
if key == "marked":
|
||||
return len([dupe for dupe in group.dupes if self.results.is_marked(dupe)])
|
||||
return cmp_value(group.ref, key)
|
||||
|
||||
def _do_delete(self, j, link_deleted, use_hardlinks, direct_deletion):
|
||||
def op(dupe):
|
||||
j.add_progress()
|
||||
return self._do_delete_dupe(dupe, link_deleted, use_hardlinks, direct_deletion)
|
||||
return self._do_delete_dupe(
|
||||
dupe, link_deleted, use_hardlinks, direct_deletion
|
||||
)
|
||||
|
||||
j.start_job(self.results.mark_count)
|
||||
self.results.perform_on_marked(op, True)
|
||||
@@ -236,7 +263,7 @@ class DupeGuru(Broadcaster):
|
||||
|
||||
def _create_file(self, path):
|
||||
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
|
||||
return fs.get_file(path, self.fileclasses + [fs.Folder])
|
||||
return fs.get_file(path, self.fileclasses + [se.fs.Folder])
|
||||
|
||||
def _get_file(self, str_path):
|
||||
path = Path(str_path)
|
||||
@@ -251,8 +278,9 @@ class DupeGuru(Broadcaster):
|
||||
|
||||
def _get_export_data(self):
|
||||
columns = [
|
||||
col for col in self.result_table.columns.ordered_columns
|
||||
if col.visible and col.name != 'marked'
|
||||
col
|
||||
for col in self.result_table.columns.ordered_columns
|
||||
if col.visible and col.name != "marked"
|
||||
]
|
||||
colnames = [col.display for col in columns]
|
||||
rows = []
|
||||
@@ -266,10 +294,11 @@ class DupeGuru(Broadcaster):
|
||||
|
||||
def _results_changed(self):
|
||||
self.selected_dupes = [
|
||||
d for d in self.selected_dupes
|
||||
d
|
||||
for d in self.selected_dupes
|
||||
if self.results.get_group_of_duplicate(d) is not None
|
||||
]
|
||||
self.notify('results_changed')
|
||||
self.notify("results_changed")
|
||||
|
||||
def _start_job(self, jobid, func, args=()):
|
||||
title = JOBID2TITLE[jobid]
|
||||
@@ -303,7 +332,9 @@ class DupeGuru(Broadcaster):
|
||||
msg = {
|
||||
JobType.Copy: tr("All marked files were copied successfully."),
|
||||
JobType.Move: tr("All marked files were moved successfully."),
|
||||
JobType.Delete: tr("All marked files were successfully sent to Trash."),
|
||||
JobType.Delete: tr(
|
||||
"All marked files were successfully sent to Trash."
|
||||
),
|
||||
}[jobid]
|
||||
self.view.show_message(msg)
|
||||
|
||||
@@ -334,9 +365,9 @@ class DupeGuru(Broadcaster):
|
||||
if dupes == self.selected_dupes:
|
||||
return
|
||||
self.selected_dupes = dupes
|
||||
self.notify('dupes_selected')
|
||||
self.notify("dupes_selected")
|
||||
|
||||
#--- Protected
|
||||
# --- Protected
|
||||
def _get_fileclasses(self):
|
||||
if self.app_mode == AppMode.Picture:
|
||||
return [pe.photo.PLAT_SPECIFIC_PHOTO_CLASS]
|
||||
@@ -353,7 +384,7 @@ class DupeGuru(Broadcaster):
|
||||
else:
|
||||
return prioritize.all_categories()
|
||||
|
||||
#--- Public
|
||||
# --- Public
|
||||
def add_directory(self, d):
|
||||
"""Adds folder ``d`` to :attr:`directories`.
|
||||
|
||||
@@ -363,7 +394,7 @@ class DupeGuru(Broadcaster):
|
||||
"""
|
||||
try:
|
||||
self.directories.add_path(Path(d))
|
||||
self.notify('directories_changed')
|
||||
self.notify("directories_changed")
|
||||
except directories.AlreadyThereError:
|
||||
self.view.show_message(tr("'{}' already is in the list.").format(d))
|
||||
except directories.InvalidPathError:
|
||||
@@ -376,7 +407,9 @@ class DupeGuru(Broadcaster):
|
||||
if not dupes:
|
||||
self.view.show_message(MSG_NO_SELECTED_DUPES)
|
||||
return
|
||||
msg = tr("All selected %d matches are going to be ignored in all subsequent scans. Continue?")
|
||||
msg = tr(
|
||||
"All selected %d matches are going to be ignored in all subsequent scans. Continue?"
|
||||
)
|
||||
if not self.view.ask_yes_no(msg % len(dupes)):
|
||||
return
|
||||
for dupe in dupes:
|
||||
@@ -393,21 +426,22 @@ class DupeGuru(Broadcaster):
|
||||
:param str filter: filter to apply
|
||||
"""
|
||||
self.results.apply_filter(None)
|
||||
if self.options['escape_filter_regexp']:
|
||||
filter = escape(filter, set('()[]\\.|+?^'))
|
||||
filter = escape(filter, '*', '.')
|
||||
if self.options["escape_filter_regexp"]:
|
||||
filter = escape(filter, set("()[]\\.|+?^"))
|
||||
filter = escape(filter, "*", ".")
|
||||
self.results.apply_filter(filter)
|
||||
self._results_changed()
|
||||
|
||||
def clean_empty_dirs(self, path):
|
||||
if self.options['clean_empty_dirs']:
|
||||
while delete_if_empty(path, ['.DS_Store']):
|
||||
if self.options["clean_empty_dirs"]:
|
||||
while delete_if_empty(path, [".DS_Store"]):
|
||||
path = path.parent()
|
||||
|
||||
def clear_picture_cache(self):
|
||||
cache = pe.cache.Cache(self.options['cache_path'])
|
||||
cache.clear()
|
||||
cache.close()
|
||||
try:
|
||||
os.remove(self._get_picture_cache_path())
|
||||
except FileNotFoundError:
|
||||
pass # we don't care
|
||||
|
||||
def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType):
|
||||
source_path = dupe.path
|
||||
@@ -436,6 +470,7 @@ class DupeGuru(Broadcaster):
|
||||
|
||||
:param bool copy: If True, duplicates will be copied instead of moved
|
||||
"""
|
||||
|
||||
def do(j):
|
||||
def op(dupe):
|
||||
j.add_progress()
|
||||
@@ -447,11 +482,11 @@ class DupeGuru(Broadcaster):
|
||||
if not self.results.mark_count:
|
||||
self.view.show_message(MSG_NO_MARKED_DUPES)
|
||||
return
|
||||
opname = tr("copy") if copy else tr("move")
|
||||
prompt = tr("Select a directory to {} marked files to").format(opname)
|
||||
destination = self.view.select_dest_folder(prompt)
|
||||
destination = self.view.select_dest_folder(
|
||||
tr("Select a directory to copy marked files to") if copy
|
||||
else tr("Select a directory to move marked files to"))
|
||||
if destination:
|
||||
desttype = self.options['copymove_dest_type']
|
||||
desttype = self.options["copymove_dest_type"]
|
||||
jobid = JobType.Copy if copy else JobType.Move
|
||||
self._start_job(jobid, do)
|
||||
|
||||
@@ -464,8 +499,9 @@ class DupeGuru(Broadcaster):
|
||||
if not self.deletion_options.show(self.results.mark_count):
|
||||
return
|
||||
args = [
|
||||
self.deletion_options.link_deleted, self.deletion_options.use_hardlinks,
|
||||
self.deletion_options.direct
|
||||
self.deletion_options.link_deleted,
|
||||
self.deletion_options.use_hardlinks,
|
||||
self.deletion_options.direct,
|
||||
]
|
||||
logging.debug("Starting deletion job with args %r", args)
|
||||
self._start_job(JobType.Delete, self._do_delete, args=args)
|
||||
@@ -487,7 +523,9 @@ class DupeGuru(Broadcaster):
|
||||
The columns and their order in the resulting CSV file is determined in the same way as in
|
||||
:meth:`export_to_xhtml`.
|
||||
"""
|
||||
dest_file = self.view.select_dest_file(tr("Select a destination for your exported CSV"), 'csv')
|
||||
dest_file = self.view.select_dest_file(
|
||||
tr("Select a destination for your exported CSV"), "csv"
|
||||
)
|
||||
if dest_file:
|
||||
colnames, rows = self._get_export_data()
|
||||
try:
|
||||
@@ -497,13 +535,16 @@ class DupeGuru(Broadcaster):
|
||||
|
||||
def get_display_info(self, dupe, group, delta=False):
|
||||
def empty_data():
|
||||
return {c.name: '---' for c in self.result_table.COLUMNS[1:]}
|
||||
return {c.name: "---" for c in self.result_table.COLUMNS[1:]}
|
||||
|
||||
if (dupe is None) or (group is None):
|
||||
return empty_data()
|
||||
try:
|
||||
return dupe.get_display_info(group, delta)
|
||||
except Exception as e:
|
||||
logging.warning("Exception on GetDisplayInfo for %s: %s", str(dupe.path), str(e))
|
||||
logging.warning(
|
||||
"Exception (type: %s) on GetDisplayInfo for %s: %s",
|
||||
type(e), str(dupe.path), str(e))
|
||||
return empty_data()
|
||||
|
||||
def invoke_custom_command(self):
|
||||
@@ -513,9 +554,11 @@ class DupeGuru(Broadcaster):
|
||||
is replaced with that dupe's ref file. If there's no selection, the command is not invoked.
|
||||
If the dupe is a ref, ``%d`` and ``%r`` will be the same.
|
||||
"""
|
||||
cmd = self.view.get_default('CustomCommand')
|
||||
cmd = self.view.get_default("CustomCommand")
|
||||
if not cmd:
|
||||
msg = tr("You have no custom command set up. Set it up in your preferences.")
|
||||
msg = tr(
|
||||
"You have no custom command set up. Set it up in your preferences."
|
||||
)
|
||||
self.view.show_message(msg)
|
||||
return
|
||||
if not self.selected_dupes:
|
||||
@@ -523,8 +566,8 @@ class DupeGuru(Broadcaster):
|
||||
dupe = self.selected_dupes[0]
|
||||
group = self.results.get_group_of_duplicate(dupe)
|
||||
ref = group.ref
|
||||
cmd = cmd.replace('%d', str(dupe.path))
|
||||
cmd = cmd.replace('%r', str(ref.path))
|
||||
cmd = cmd.replace("%d", str(dupe.path))
|
||||
cmd = cmd.replace("%r", str(ref.path))
|
||||
match = re.match(r'"([^"]+)"(.*)', cmd)
|
||||
if match is not None:
|
||||
# This code here is because subprocess. Popen doesn't seem to accept, under Windows,
|
||||
@@ -543,19 +586,30 @@ class DupeGuru(Broadcaster):
|
||||
is persistent data, is the same as when the last session was closed (when :meth:`save` was
|
||||
called).
|
||||
"""
|
||||
self.directories.load_from_file(op.join(self.appdata, 'last_directories.xml'))
|
||||
self.notify('directories_changed')
|
||||
p = op.join(self.appdata, 'ignore_list.xml')
|
||||
self.directories.load_from_file(op.join(self.appdata, "last_directories.xml"))
|
||||
self.notify("directories_changed")
|
||||
p = op.join(self.appdata, "ignore_list.xml")
|
||||
self.ignore_list.load_from_xml(p)
|
||||
self.ignore_list_dialog.refresh()
|
||||
p = op.join(self.appdata, "exclude_list.xml")
|
||||
self.exclude_list.load_from_xml(p)
|
||||
self.exclude_list_dialog.refresh()
|
||||
|
||||
def load_directories(self, filepath):
|
||||
# Clear out previous entries
|
||||
self.directories.__init__()
|
||||
self.directories.load_from_file(filepath)
|
||||
self.notify("directories_changed")
|
||||
|
||||
def load_from(self, filename):
|
||||
"""Start an async job to load results from ``filename``.
|
||||
|
||||
:param str filename: path of the XML file (created with :meth:`save_as`) to load
|
||||
"""
|
||||
|
||||
def do(j):
|
||||
self.results.load_from_xml(filename, self._get_file, j)
|
||||
|
||||
self._start_job(JobType.Load, do)
|
||||
|
||||
def make_selected_reference(self):
|
||||
@@ -580,35 +634,36 @@ class DupeGuru(Broadcaster):
|
||||
if not self.result_table.power_marker:
|
||||
if changed_groups:
|
||||
self.selected_dupes = [
|
||||
d for d in self.selected_dupes
|
||||
d
|
||||
for d in self.selected_dupes
|
||||
if self.results.get_group_of_duplicate(d).ref is d
|
||||
]
|
||||
self.notify('results_changed')
|
||||
self.notify("results_changed")
|
||||
else:
|
||||
# If we're in "Dupes Only" mode (previously called Power Marker), things are a bit
|
||||
# different. The refs are not shown in the table, and if our operation is successful,
|
||||
# this means that there's no way to follow our dupe selection. Then, the best thing to
|
||||
# do is to keep our selection index-wise (different dupe selection, but same index
|
||||
# selection).
|
||||
self.notify('results_changed_but_keep_selection')
|
||||
self.notify("results_changed_but_keep_selection")
|
||||
|
||||
def mark_all(self):
|
||||
"""Set all dupes in the results as marked.
|
||||
"""
|
||||
self.results.mark_all()
|
||||
self.notify('marking_changed')
|
||||
self.notify("marking_changed")
|
||||
|
||||
def mark_none(self):
|
||||
"""Set all dupes in the results as unmarked.
|
||||
"""
|
||||
self.results.mark_none()
|
||||
self.notify('marking_changed')
|
||||
self.notify("marking_changed")
|
||||
|
||||
def mark_invert(self):
|
||||
"""Invert the marked state of all dupes in the results.
|
||||
"""
|
||||
self.results.mark_invert()
|
||||
self.notify('marking_changed')
|
||||
self.notify("marking_changed")
|
||||
|
||||
def mark_dupe(self, dupe, marked):
|
||||
"""Change marked status of ``dupe``.
|
||||
@@ -621,7 +676,7 @@ class DupeGuru(Broadcaster):
|
||||
self.results.mark(dupe)
|
||||
else:
|
||||
self.results.unmark(dupe)
|
||||
self.notify('marking_changed')
|
||||
self.notify("marking_changed")
|
||||
|
||||
def open_selected(self):
|
||||
"""Open :attr:`selected_dupes` with their associated application.
|
||||
@@ -648,7 +703,7 @@ class DupeGuru(Broadcaster):
|
||||
indexes = sorted(indexes, reverse=True)
|
||||
for index in indexes:
|
||||
del self.directories[index]
|
||||
self.notify('directories_changed')
|
||||
self.notify("directories_changed")
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
@@ -661,7 +716,7 @@ class DupeGuru(Broadcaster):
|
||||
:type duplicates: list of :class:`~core.fs.File`
|
||||
"""
|
||||
self.results.remove_duplicates(self.without_ref(duplicates))
|
||||
self.notify('results_changed_but_keep_selection')
|
||||
self.notify("results_changed_but_keep_selection")
|
||||
|
||||
def remove_marked(self):
|
||||
"""Removed marked duplicates from the results (without touching the files themselves).
|
||||
@@ -716,7 +771,9 @@ class DupeGuru(Broadcaster):
|
||||
if group.prioritize(key_func=sort_key):
|
||||
count += 1
|
||||
self._results_changed()
|
||||
msg = tr("{} duplicate groups were changed by the re-prioritization.").format(count)
|
||||
msg = tr("{} duplicate groups were changed by the re-prioritization.").format(
|
||||
count
|
||||
)
|
||||
self.view.show_message(msg)
|
||||
|
||||
def reveal_selected(self):
|
||||
@@ -726,10 +783,12 @@ class DupeGuru(Broadcaster):
|
||||
def save(self):
|
||||
if not op.exists(self.appdata):
|
||||
os.makedirs(self.appdata)
|
||||
self.directories.save_to_file(op.join(self.appdata, 'last_directories.xml'))
|
||||
p = op.join(self.appdata, 'ignore_list.xml')
|
||||
self.directories.save_to_file(op.join(self.appdata, "last_directories.xml"))
|
||||
p = op.join(self.appdata, "ignore_list.xml")
|
||||
self.ignore_list.save_to_xml(p)
|
||||
self.notify('save_session')
|
||||
p = op.join(self.appdata, "exclude_list.xml")
|
||||
self.exclude_list.save_to_xml(p)
|
||||
self.notify("save_session")
|
||||
|
||||
def save_as(self, filename):
|
||||
"""Save results in ``filename``.
|
||||
@@ -741,6 +800,16 @@ class DupeGuru(Broadcaster):
|
||||
except OSError as e:
|
||||
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
|
||||
|
||||
def save_directories_as(self, filename):
|
||||
"""Save directories in ``filename``.
|
||||
|
||||
:param str filename: path of the file to save directories (as XML) to.
|
||||
"""
|
||||
try:
|
||||
self.directories.save_to_file(filename)
|
||||
except OSError as e:
|
||||
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
|
||||
|
||||
def start_scanning(self):
|
||||
"""Starts an async job to scan for duplicates.
|
||||
|
||||
@@ -748,12 +817,16 @@ class DupeGuru(Broadcaster):
|
||||
"""
|
||||
scanner = self.SCANNER_CLASS()
|
||||
if not self.directories.has_any_file():
|
||||
self.view.show_message(tr("The selected directories contain no scannable file."))
|
||||
self.view.show_message(
|
||||
tr("The selected directories contain no scannable file.")
|
||||
)
|
||||
return
|
||||
# Send relevant options down to the scanner instance
|
||||
for k, v in self.options.items():
|
||||
if hasattr(scanner, k):
|
||||
setattr(scanner, k, v)
|
||||
if self.app_mode == AppMode.Picture:
|
||||
scanner.cache_path = self._get_picture_cache_path()
|
||||
self.results.groups = []
|
||||
self._recreate_result_table()
|
||||
self._results_changed()
|
||||
@@ -761,12 +834,16 @@ class DupeGuru(Broadcaster):
|
||||
def do(j):
|
||||
j.set_progress(0, tr("Collecting files to scan"))
|
||||
if scanner.scan_type == ScanType.Folders:
|
||||
files = list(self.directories.get_folders(folderclass=se.fs.Folder, j=j))
|
||||
files = list(
|
||||
self.directories.get_folders(folderclass=se.fs.Folder, j=j)
|
||||
)
|
||||
else:
|
||||
files = list(self.directories.get_files(fileclasses=self.fileclasses, j=j))
|
||||
if self.options['ignore_hardlink_matches']:
|
||||
files = list(
|
||||
self.directories.get_files(fileclasses=self.fileclasses, j=j)
|
||||
)
|
||||
if self.options["ignore_hardlink_matches"]:
|
||||
files = self._remove_hardlink_dupes(files)
|
||||
logging.info('Scanning %d files' % len(files))
|
||||
logging.info("Scanning %d files" % len(files))
|
||||
self.results.groups = scanner.get_dupe_groups(files, self.ignore_list, j)
|
||||
self.discarded_file_count = scanner.discarded_file_count
|
||||
|
||||
@@ -782,12 +859,16 @@ class DupeGuru(Broadcaster):
|
||||
markfunc = self.results.mark
|
||||
for dupe in selected:
|
||||
markfunc(dupe)
|
||||
self.notify('marking_changed')
|
||||
self.notify("marking_changed")
|
||||
|
||||
def without_ref(self, dupes):
|
||||
"""Returns ``dupes`` with all reference elements removed.
|
||||
"""
|
||||
return [dupe for dupe in dupes if self.results.get_group_of_duplicate(dupe).ref is not dupe]
|
||||
return [
|
||||
dupe
|
||||
for dupe in dupes
|
||||
if self.results.get_group_of_duplicate(dupe).ref is not dupe
|
||||
]
|
||||
|
||||
def get_default(self, key, fallback_value=None):
|
||||
result = nonone(self.view.get_default(key), fallback_value)
|
||||
@@ -802,7 +883,7 @@ class DupeGuru(Broadcaster):
|
||||
def set_default(self, key, value):
|
||||
self.view.set_default(key, value)
|
||||
|
||||
#--- Properties
|
||||
# --- Properties
|
||||
@property
|
||||
def stat_line(self):
|
||||
result = self.results.stat_line
|
||||
@@ -826,12 +907,21 @@ class DupeGuru(Broadcaster):
|
||||
@property
|
||||
def METADATA_TO_READ(self):
|
||||
if self.app_mode == AppMode.Picture:
|
||||
return ['size', 'mtime', 'dimensions', 'exif_timestamp']
|
||||
return ["size", "mtime", "dimensions", "exif_timestamp"]
|
||||
elif self.app_mode == AppMode.Music:
|
||||
return [
|
||||
'size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist',
|
||||
'album', 'genre', 'year', 'track', 'comment'
|
||||
"size",
|
||||
"mtime",
|
||||
"duration",
|
||||
"bitrate",
|
||||
"samplerate",
|
||||
"title",
|
||||
"artist",
|
||||
"album",
|
||||
"genre",
|
||||
"year",
|
||||
"track",
|
||||
"comment",
|
||||
]
|
||||
else:
|
||||
return ['size', 'mtime']
|
||||
|
||||
return ["size", "mtime"]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2006/02/27
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
# Copyright 2017 Virgil Dupras
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import os
|
||||
from xml.etree import ElementTree as ET
|
||||
import logging
|
||||
|
||||
@@ -16,12 +15,13 @@ from hscommon.util import FileOrPath
|
||||
from . import fs
|
||||
|
||||
__all__ = [
|
||||
'Directories',
|
||||
'DirectoryState',
|
||||
'AlreadyThereError',
|
||||
'InvalidPathError',
|
||||
"Directories",
|
||||
"DirectoryState",
|
||||
"AlreadyThereError",
|
||||
"InvalidPathError",
|
||||
]
|
||||
|
||||
|
||||
class DirectoryState:
|
||||
"""Enum describing how a folder should be considered.
|
||||
|
||||
@@ -29,16 +29,20 @@ class DirectoryState:
|
||||
* DirectoryState.Reference: Scan files, but make sure never to delete any of them
|
||||
* DirectoryState.Excluded: Don't scan this folder
|
||||
"""
|
||||
|
||||
Normal = 0
|
||||
Reference = 1
|
||||
Excluded = 2
|
||||
|
||||
|
||||
class AlreadyThereError(Exception):
|
||||
"""The path being added is already in the directory list"""
|
||||
|
||||
|
||||
class InvalidPathError(Exception):
|
||||
"""The path being added is invalid"""
|
||||
|
||||
|
||||
class Directories:
|
||||
"""Holds user folder selection.
|
||||
|
||||
@@ -48,11 +52,13 @@ class Directories:
|
||||
Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped
|
||||
in :mod:`core.fs`) that have to be scanned according to the chosen folders/states.
|
||||
"""
|
||||
#---Override
|
||||
def __init__(self):
|
||||
|
||||
# ---Override
|
||||
def __init__(self, exclude_list=None):
|
||||
self._dirs = []
|
||||
# {path: state}
|
||||
self.states = {}
|
||||
self._exclude_list = exclude_list
|
||||
|
||||
def __contains__(self, path):
|
||||
for p in self._dirs:
|
||||
@@ -69,35 +75,67 @@ class Directories:
|
||||
def __len__(self):
|
||||
return len(self._dirs)
|
||||
|
||||
#---Private
|
||||
# ---Private
|
||||
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.
|
||||
if path.name.startswith('.'): # hidden
|
||||
if path.name.startswith("."):
|
||||
return DirectoryState.Excluded
|
||||
|
||||
def _get_files(self, from_path, fileclasses, j):
|
||||
for root, dirs, files in os.walk(str(from_path)):
|
||||
j.check_if_cancelled()
|
||||
state = self.get_state(from_path)
|
||||
rootPath = Path(root)
|
||||
state = self.get_state(rootPath)
|
||||
if state == DirectoryState.Excluded:
|
||||
# Recursively get files from folders with lots of subfolder is expensive. However, there
|
||||
# might be a subfolder in this path that is not excluded. What we want to do is to skim
|
||||
# through self.states and see if we must continue, or we can stop right here to save time
|
||||
if not any(p[:len(from_path)] == from_path for p in self.states):
|
||||
return
|
||||
if not any(p[: len(rootPath)] == rootPath for p in self.states):
|
||||
del dirs[:]
|
||||
try:
|
||||
filepaths = set()
|
||||
if state != DirectoryState.Excluded:
|
||||
found_files = fs.get_files(from_path, fileclasses=fileclasses)
|
||||
logging.debug("Collected %d files in folder %s", len(found_files), str(from_path))
|
||||
# Old logic
|
||||
if self._exclude_list is None or not self._exclude_list.mark_count:
|
||||
found_files = [fs.get_file(rootPath + f, fileclasses=fileclasses) for f in files]
|
||||
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]
|
||||
# In some cases, directories can be considered as files by dupeGuru, which is
|
||||
# why we have this line below. In fact, there only one case: Bundle files under
|
||||
# OS X... In other situations, this forloop will do nothing.
|
||||
for d in dirs[:]:
|
||||
f = fs.get_file(rootPath + d, fileclasses=fileclasses)
|
||||
if f is not None:
|
||||
found_files.append(f)
|
||||
dirs.remove(d)
|
||||
logging.debug(
|
||||
"Collected %d files in folder %s",
|
||||
len(found_files),
|
||||
str(rootPath),
|
||||
)
|
||||
for file in found_files:
|
||||
file.is_ref = state == DirectoryState.Reference
|
||||
filepaths.add(file.path)
|
||||
yield file
|
||||
# it's possible that a folder (bundle) gets into the file list. in that case, we don't
|
||||
# want to recurse into it
|
||||
subfolders = [p for p in from_path.listdir() if not p.islink() and p.isdir() and p not in filepaths]
|
||||
for subfolder in subfolders:
|
||||
for file in self._get_files(subfolder, fileclasses=fileclasses, j=j):
|
||||
yield file
|
||||
except (EnvironmentError, fs.InvalidPath):
|
||||
pass
|
||||
@@ -116,7 +154,7 @@ class Directories:
|
||||
except (EnvironmentError, fs.InvalidPath):
|
||||
pass
|
||||
|
||||
#---Public
|
||||
# ---Public
|
||||
def add_path(self, path):
|
||||
"""Adds ``path`` to self, if not already there.
|
||||
|
||||
@@ -176,16 +214,23 @@ class Directories:
|
||||
|
||||
:rtype: :class:`DirectoryState`
|
||||
"""
|
||||
# direct match? easy result.
|
||||
if path in self.states:
|
||||
return self.states[path]
|
||||
default_state = self._default_state_for_path(path)
|
||||
if default_state is not None:
|
||||
return default_state
|
||||
parent = path.parent()
|
||||
if parent in self:
|
||||
return self.get_state(parent)
|
||||
else:
|
||||
return 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
|
||||
# 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():
|
||||
if p.is_parent_of(path) and len(p) > prevlen:
|
||||
prevlen = len(p)
|
||||
state = s
|
||||
return state
|
||||
|
||||
def has_any_file(self):
|
||||
"""Returns whether selected folders contain any file.
|
||||
@@ -209,21 +254,21 @@ class Directories:
|
||||
root = ET.parse(infile).getroot()
|
||||
except Exception:
|
||||
return
|
||||
for rdn in root.getiterator('root_directory'):
|
||||
for rdn in root.iter("root_directory"):
|
||||
attrib = rdn.attrib
|
||||
if 'path' not in attrib:
|
||||
if "path" not in attrib:
|
||||
continue
|
||||
path = attrib['path']
|
||||
path = attrib["path"]
|
||||
try:
|
||||
self.add_path(Path(path))
|
||||
except (AlreadyThereError, InvalidPathError):
|
||||
pass
|
||||
for sn in root.getiterator('state'):
|
||||
for sn in root.iter("state"):
|
||||
attrib = sn.attrib
|
||||
if not ('path' in attrib and 'value' in attrib):
|
||||
if not ("path" in attrib and "value" in attrib):
|
||||
continue
|
||||
path = attrib['path']
|
||||
state = attrib['value']
|
||||
path = attrib["path"]
|
||||
state = attrib["value"]
|
||||
self.states[Path(path)] = int(state)
|
||||
|
||||
def save_to_file(self, outfile):
|
||||
@@ -231,17 +276,17 @@ class Directories:
|
||||
|
||||
:param file outfile: path or file pointer to XML file to save to.
|
||||
"""
|
||||
with FileOrPath(outfile, 'wb') as fp:
|
||||
root = ET.Element('directories')
|
||||
with FileOrPath(outfile, "wb") as fp:
|
||||
root = ET.Element("directories")
|
||||
for root_path in self:
|
||||
root_path_node = ET.SubElement(root, 'root_directory')
|
||||
root_path_node.set('path', str(root_path))
|
||||
root_path_node = ET.SubElement(root, "root_directory")
|
||||
root_path_node.set("path", str(root_path))
|
||||
for path, state in self.states.items():
|
||||
state_node = ET.SubElement(root, 'state')
|
||||
state_node.set('path', str(path))
|
||||
state_node.set('value', str(state))
|
||||
state_node = ET.SubElement(root, "state")
|
||||
state_node.set("path", str(path))
|
||||
state_node.set("value", str(state))
|
||||
tree = ET.ElementTree(root)
|
||||
tree.write(fp, encoding='utf-8')
|
||||
tree.write(fp, encoding="utf-8")
|
||||
|
||||
def set_state(self, path, state):
|
||||
"""Set the state of folder at ``path``.
|
||||
@@ -256,4 +301,3 @@ class Directories:
|
||||
if path.is_parent_of(iter_path):
|
||||
del self.states[iter_path]
|
||||
self.states[path] = state
|
||||
|
||||
|
||||
106
core/engine.py
106
core/engine.py
@@ -17,25 +17,26 @@ from hscommon.util import flatten, multi_replace
|
||||
from hscommon.trans import tr
|
||||
from hscommon.jobprogress import job
|
||||
|
||||
(
|
||||
WEIGHT_WORDS,
|
||||
MATCH_SIMILAR_WORDS,
|
||||
NO_FIELD_ORDER,
|
||||
) = range(3)
|
||||
(WEIGHT_WORDS, MATCH_SIMILAR_WORDS, NO_FIELD_ORDER,) = range(3)
|
||||
|
||||
JOB_REFRESH_RATE = 100
|
||||
|
||||
|
||||
def getwords(s):
|
||||
# We decompose the string so that ascii letters with accents can be part of the word.
|
||||
s = normalize('NFD', s)
|
||||
s = multi_replace(s, "-_&+():;\\[]{}.,<>/?~!@#$*", ' ').lower()
|
||||
s = ''.join(c for c in s if c in string.ascii_letters + string.digits + string.whitespace)
|
||||
return [_f for _f in s.split(' ') if _f] # remove empty elements
|
||||
s = normalize("NFD", s)
|
||||
s = multi_replace(s, "-_&+():;\\[]{}.,<>/?~!@#$*", " ").lower()
|
||||
s = "".join(
|
||||
c for c in s if c in string.ascii_letters + string.digits + string.whitespace
|
||||
)
|
||||
return [_f for _f in s.split(" ") if _f] # remove empty elements
|
||||
|
||||
|
||||
def getfields(s):
|
||||
fields = [getwords(field) for field in s.split(' - ')]
|
||||
fields = [getwords(field) for field in s.split(" - ")]
|
||||
return [_f for _f in fields if _f]
|
||||
|
||||
|
||||
def unpack_fields(fields):
|
||||
result = []
|
||||
for field in fields:
|
||||
@@ -45,6 +46,7 @@ def unpack_fields(fields):
|
||||
result.append(field)
|
||||
return result
|
||||
|
||||
|
||||
def compare(first, second, flags=()):
|
||||
"""Returns the % of words that match between ``first`` and ``second``
|
||||
|
||||
@@ -55,11 +57,11 @@ def compare(first, second, flags=()):
|
||||
return 0
|
||||
if any(isinstance(element, list) for element in first):
|
||||
return compare_fields(first, second, flags)
|
||||
second = second[:] #We must use a copy of second because we remove items from it
|
||||
second = second[:] # We must use a copy of second because we remove items from it
|
||||
match_similar = MATCH_SIMILAR_WORDS in flags
|
||||
weight_words = WEIGHT_WORDS in flags
|
||||
joined = first + second
|
||||
total_count = (sum(len(word) for word in joined) if weight_words else len(joined))
|
||||
total_count = sum(len(word) for word in joined) if weight_words else len(joined)
|
||||
match_count = 0
|
||||
in_order = True
|
||||
for word in first:
|
||||
@@ -71,12 +73,13 @@ def compare(first, second, flags=()):
|
||||
if second[0] != word:
|
||||
in_order = False
|
||||
second.remove(word)
|
||||
match_count += (len(word) if weight_words else 1)
|
||||
match_count += len(word) if weight_words else 1
|
||||
result = round(((match_count * 2) / total_count) * 100)
|
||||
if (result == 100) and (not in_order):
|
||||
result = 99 # We cannot consider a match exact unless the ordering is the same
|
||||
return result
|
||||
|
||||
|
||||
def compare_fields(first, second, flags=()):
|
||||
"""Returns the score for the lowest matching :ref:`fields`.
|
||||
|
||||
@@ -87,7 +90,7 @@ def compare_fields(first, second, flags=()):
|
||||
return 0
|
||||
if NO_FIELD_ORDER in flags:
|
||||
results = []
|
||||
#We don't want to remove field directly in the list. We must work on a copy.
|
||||
# We don't want to remove field directly in the list. We must work on a copy.
|
||||
second = second[:]
|
||||
for field1 in first:
|
||||
max = 0
|
||||
@@ -101,9 +104,12 @@ def compare_fields(first, second, flags=()):
|
||||
if matched_field:
|
||||
second.remove(matched_field)
|
||||
else:
|
||||
results = [compare(field1, field2, flags) for field1, field2 in zip(first, second)]
|
||||
results = [
|
||||
compare(field1, field2, flags) for field1, field2 in zip(first, second)
|
||||
]
|
||||
return min(results) if results else 0
|
||||
|
||||
|
||||
def build_word_dict(objects, j=job.nulljob):
|
||||
"""Returns a dict of objects mapped by their words.
|
||||
|
||||
@@ -113,11 +119,14 @@ def build_word_dict(objects, j=job.nulljob):
|
||||
The result will be a dict with words as keys, lists of objects as values.
|
||||
"""
|
||||
result = defaultdict(set)
|
||||
for object in j.iter_with_progress(objects, 'Prepared %d/%d files', JOB_REFRESH_RATE):
|
||||
for object in j.iter_with_progress(
|
||||
objects, "Prepared %d/%d files", JOB_REFRESH_RATE
|
||||
):
|
||||
for word in unpack_fields(object.words):
|
||||
result[word].add(object)
|
||||
return result
|
||||
|
||||
|
||||
def merge_similar_words(word_dict):
|
||||
"""Take all keys in ``word_dict`` that are similar, and merge them together.
|
||||
|
||||
@@ -126,7 +135,7 @@ def merge_similar_words(word_dict):
|
||||
a word equal to the other.
|
||||
"""
|
||||
keys = list(word_dict.keys())
|
||||
keys.sort(key=len)# we want the shortest word to stay
|
||||
keys.sort(key=len) # we want the shortest word to stay
|
||||
while keys:
|
||||
key = keys.pop(0)
|
||||
similars = difflib.get_close_matches(key, keys, 100, 0.8)
|
||||
@@ -138,6 +147,7 @@ def merge_similar_words(word_dict):
|
||||
del word_dict[similar]
|
||||
keys.remove(similar)
|
||||
|
||||
|
||||
def reduce_common_words(word_dict, threshold):
|
||||
"""Remove all objects from ``word_dict`` values where the object count >= ``threshold``
|
||||
|
||||
@@ -146,7 +156,9 @@ def reduce_common_words(word_dict, threshold):
|
||||
The exception to this removal are the objects where all the words of the object are common.
|
||||
Because if we remove them, we will miss some duplicates!
|
||||
"""
|
||||
uncommon_words = set(word for word, objects in word_dict.items() if len(objects) < threshold)
|
||||
uncommon_words = set(
|
||||
word for word, objects in word_dict.items() if len(objects) < threshold
|
||||
)
|
||||
for word, objects in list(word_dict.items()):
|
||||
if len(objects) < threshold:
|
||||
continue
|
||||
@@ -159,11 +171,13 @@ def reduce_common_words(word_dict, threshold):
|
||||
else:
|
||||
del word_dict[word]
|
||||
|
||||
|
||||
# Writing docstrings in a namedtuple is tricky. From Python 3.3, it's possible to set __doc__, but
|
||||
# some research allowed me to find a more elegant solution, which is what is done here. See
|
||||
# http://stackoverflow.com/questions/1606436/adding-docstrings-to-namedtuples-in-python
|
||||
|
||||
class Match(namedtuple('Match', 'first second percentage')):
|
||||
|
||||
class Match(namedtuple("Match", "first second percentage")):
|
||||
"""Represents a match between two :class:`~core.fs.File`.
|
||||
|
||||
Regarless of the matching method, when two files are determined to match, a Match pair is created,
|
||||
@@ -182,16 +196,24 @@ class Match(namedtuple('Match', 'first second percentage')):
|
||||
their match level according to the scan method which found the match. int from 1 to 100. For
|
||||
exact scan methods, such as Contents scans, this will always be 100.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
def get_match(first, second, flags=()):
|
||||
#it is assumed here that first and second both have a "words" attribute
|
||||
# it is assumed here that first and second both have a "words" attribute
|
||||
percentage = compare(first.words, second.words, flags)
|
||||
return Match(first, second, percentage)
|
||||
|
||||
|
||||
def getmatches(
|
||||
objects, min_match_percentage=0, match_similar_words=False, weight_words=False,
|
||||
no_field_order=False, j=job.nulljob):
|
||||
objects,
|
||||
min_match_percentage=0,
|
||||
match_similar_words=False,
|
||||
weight_words=False,
|
||||
no_field_order=False,
|
||||
j=job.nulljob,
|
||||
):
|
||||
"""Returns a list of :class:`Match` within ``objects`` after fuzzily matching their words.
|
||||
|
||||
:param objects: List of :class:`~core.fs.File` to match.
|
||||
@@ -206,7 +228,7 @@ def getmatches(
|
||||
j = j.start_subjob(2)
|
||||
sj = j.start_subjob(2)
|
||||
for o in objects:
|
||||
if not hasattr(o, 'words'):
|
||||
if not hasattr(o, "words"):
|
||||
o.words = getwords(o.name)
|
||||
word_dict = build_word_dict(objects, sj)
|
||||
reduce_common_words(word_dict, COMMON_WORD_THRESHOLD)
|
||||
@@ -242,10 +264,14 @@ def getmatches(
|
||||
# This is the place where the memory usage is at its peak during the scan.
|
||||
# Just continue the process with an incomplete list of matches.
|
||||
del compared # This should give us enough room to call logging.
|
||||
logging.warning('Memory Overflow. Matches: %d. Word dict: %d' % (len(result), len(word_dict)))
|
||||
logging.warning(
|
||||
"Memory Overflow. Matches: %d. Word dict: %d"
|
||||
% (len(result), len(word_dict))
|
||||
)
|
||||
return result
|
||||
return result
|
||||
|
||||
|
||||
def getmatches_by_contents(files, j=job.nulljob):
|
||||
"""Returns a list of :class:`Match` within ``files`` if their contents is the same.
|
||||
|
||||
@@ -270,6 +296,7 @@ def getmatches_by_contents(files, j=job.nulljob):
|
||||
j.add_progress(desc=tr("%d matches found") % len(result))
|
||||
return result
|
||||
|
||||
|
||||
class Group:
|
||||
"""A group of :class:`~core.fs.File` that match together.
|
||||
|
||||
@@ -297,7 +324,8 @@ class Group:
|
||||
|
||||
Average match percentage of match pairs containing :attr:`ref`.
|
||||
"""
|
||||
#---Override
|
||||
|
||||
# ---Override
|
||||
def __init__(self):
|
||||
self._clear()
|
||||
|
||||
@@ -313,7 +341,7 @@ class Group:
|
||||
def __len__(self):
|
||||
return len(self.ordered)
|
||||
|
||||
#---Private
|
||||
# ---Private
|
||||
def _clear(self):
|
||||
self._percentage = None
|
||||
self._matches_for_ref = None
|
||||
@@ -328,7 +356,7 @@ class Group:
|
||||
self._matches_for_ref = [match for match in self.matches if ref in match]
|
||||
return self._matches_for_ref
|
||||
|
||||
#---Public
|
||||
# ---Public
|
||||
def add_match(self, match):
|
||||
"""Adds ``match`` to internal match list and possibly add duplicates to the group.
|
||||
|
||||
@@ -339,6 +367,7 @@ class Group:
|
||||
|
||||
:param tuple match: pair of :class:`~core.fs.File` to add
|
||||
"""
|
||||
|
||||
def add_candidate(item, match):
|
||||
matches = self.candidates[item]
|
||||
matches.add(match)
|
||||
@@ -362,7 +391,11 @@ class Group:
|
||||
|
||||
You can call this after the duplicate scanning process to free a bit of memory.
|
||||
"""
|
||||
discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second]))
|
||||
discarded = set(
|
||||
m
|
||||
for m in self.matches
|
||||
if not all(obj in self.unordered for obj in [m.first, m.second])
|
||||
)
|
||||
self.matches -= discarded
|
||||
self.candidates = defaultdict(set)
|
||||
return discarded
|
||||
@@ -409,7 +442,9 @@ class Group:
|
||||
self.unordered.remove(item)
|
||||
self._percentage = None
|
||||
self._matches_for_ref = None
|
||||
if (len(self) > 1) and any(not getattr(item, 'is_ref', False) for item in self):
|
||||
if (len(self) > 1) and any(
|
||||
not getattr(item, "is_ref", False) for item in self
|
||||
):
|
||||
if discard_matches:
|
||||
self.matches = set(m for m in self.matches if item not in m)
|
||||
else:
|
||||
@@ -438,7 +473,9 @@ class Group:
|
||||
if self._percentage is None:
|
||||
if self.dupes:
|
||||
matches = self._get_matches_for_ref()
|
||||
self._percentage = sum(match.percentage for match in matches) // len(matches)
|
||||
self._percentage = sum(match.percentage for match in matches) // len(
|
||||
matches
|
||||
)
|
||||
else:
|
||||
self._percentage = 0
|
||||
return self._percentage
|
||||
@@ -485,7 +522,7 @@ def get_groups(matches):
|
||||
del dupe2group
|
||||
del matches
|
||||
# should free enough memory to continue
|
||||
logging.warning('Memory Overflow. Groups: {0}'.format(len(groups)))
|
||||
logging.warning("Memory Overflow. Groups: {0}".format(len(groups)))
|
||||
# Now that we have a group, we have to discard groups' matches and see if there're any "orphan"
|
||||
# matches, that is, matches that were candidate in a group but that none of their 2 files were
|
||||
# accepted in the group. With these orphan groups, it's safe to build additional groups
|
||||
@@ -493,9 +530,12 @@ def get_groups(matches):
|
||||
orphan_matches = []
|
||||
for group in groups:
|
||||
orphan_matches += {
|
||||
m for m in group.discard_matches()
|
||||
m
|
||||
for m in group.discard_matches()
|
||||
if not any(obj in matched_files for obj in [m.first, m.second])
|
||||
}
|
||||
if groups and orphan_matches:
|
||||
groups += get_groups(orphan_matches) # no job, as it isn't supposed to take a long time
|
||||
groups += get_groups(
|
||||
orphan_matches
|
||||
) # no job, as it isn't supposed to take a long time
|
||||
return groups
|
||||
|
||||
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
|
||||
@@ -114,36 +114,42 @@ ROW_TEMPLATE = """
|
||||
|
||||
CELL_TEMPLATE = """<td>{value}</td>"""
|
||||
|
||||
|
||||
def export_to_xhtml(colnames, rows):
|
||||
# a row is a list of values with the first value being a flag indicating if the row should be indented
|
||||
if rows:
|
||||
assert len(rows[0]) == len(colnames) + 1 # + 1 is for the "indented" flag
|
||||
colheaders = ''.join(COLHEADERS_TEMPLATE.format(name=name) for name in colnames)
|
||||
colheaders = "".join(COLHEADERS_TEMPLATE.format(name=name) for name in colnames)
|
||||
rendered_rows = []
|
||||
previous_group_id = None
|
||||
for row in rows:
|
||||
# [2:] is to remove the indented flag + filename
|
||||
if row[0] != previous_group_id:
|
||||
# We've just changed dupe group, which means that this dupe is a ref. We don't indent it.
|
||||
indented = ''
|
||||
indented = ""
|
||||
else:
|
||||
indented = 'indented'
|
||||
indented = "indented"
|
||||
filename = row[1]
|
||||
cells = ''.join(CELL_TEMPLATE.format(value=value) for value in row[2:])
|
||||
rendered_rows.append(ROW_TEMPLATE.format(indented=indented, filename=filename, cells=cells))
|
||||
cells = "".join(CELL_TEMPLATE.format(value=value) for value in row[2:])
|
||||
rendered_rows.append(
|
||||
ROW_TEMPLATE.format(indented=indented, filename=filename, cells=cells)
|
||||
)
|
||||
previous_group_id = row[0]
|
||||
rendered_rows = ''.join(rendered_rows)
|
||||
rendered_rows = "".join(rendered_rows)
|
||||
# The main template can't use format because the css code uses {}
|
||||
content = MAIN_TEMPLATE.replace('$colheaders', colheaders).replace('$rows', rendered_rows)
|
||||
content = MAIN_TEMPLATE.replace("$colheaders", colheaders).replace(
|
||||
"$rows", rendered_rows
|
||||
)
|
||||
folder = mkdtemp()
|
||||
destpath = op.join(folder, 'export.htm')
|
||||
fp = open(destpath, 'wt', encoding='utf-8')
|
||||
destpath = op.join(folder, "export.htm")
|
||||
fp = open(destpath, "wt", encoding="utf-8")
|
||||
fp.write(content)
|
||||
fp.close()
|
||||
return destpath
|
||||
|
||||
|
||||
def export_to_csv(dest, colnames, rows):
|
||||
writer = csv.writer(open(dest, 'wt', encoding='utf-8'))
|
||||
writer = csv.writer(open(dest, "wt", encoding="utf-8"))
|
||||
writer.writerow(["Group ID"] + colnames)
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
78
core/fs.py
78
core/fs.py
@@ -17,19 +17,20 @@ import logging
|
||||
from hscommon.util import nonone, get_file_ext
|
||||
|
||||
__all__ = [
|
||||
'File',
|
||||
'Folder',
|
||||
'get_file',
|
||||
'get_files',
|
||||
'FSError',
|
||||
'AlreadyExistsError',
|
||||
'InvalidPath',
|
||||
'InvalidDestinationError',
|
||||
'OperationError',
|
||||
"File",
|
||||
"Folder",
|
||||
"get_file",
|
||||
"get_files",
|
||||
"FSError",
|
||||
"AlreadyExistsError",
|
||||
"InvalidPath",
|
||||
"InvalidDestinationError",
|
||||
"OperationError",
|
||||
]
|
||||
|
||||
NOT_SET = object()
|
||||
|
||||
|
||||
class FSError(Exception):
|
||||
cls_message = "An error has occured on '{name}' in '{parent}'"
|
||||
|
||||
@@ -40,8 +41,8 @@ class FSError(Exception):
|
||||
elif isinstance(fsobject, File):
|
||||
name = fsobject.name
|
||||
else:
|
||||
name = ''
|
||||
parentname = str(parent) if parent is not None else ''
|
||||
name = ""
|
||||
parentname = str(parent) if parent is not None else ""
|
||||
Exception.__init__(self, message.format(name=name, parent=parentname))
|
||||
|
||||
|
||||
@@ -49,32 +50,39 @@ class AlreadyExistsError(FSError):
|
||||
"The directory or file name we're trying to add already exists"
|
||||
cls_message = "'{name}' already exists in '{parent}'"
|
||||
|
||||
|
||||
class InvalidPath(FSError):
|
||||
"The path of self is invalid, and cannot be worked with."
|
||||
cls_message = "'{name}' is invalid."
|
||||
|
||||
|
||||
class InvalidDestinationError(FSError):
|
||||
"""A copy/move operation has been called, but the destination is invalid."""
|
||||
|
||||
cls_message = "'{name}' is an invalid destination for this operation."
|
||||
|
||||
|
||||
class OperationError(FSError):
|
||||
"""A copy/move/delete operation has been called, but the checkup after the
|
||||
operation shows that it didn't work."""
|
||||
|
||||
cls_message = "Operation on '{name}' failed."
|
||||
|
||||
|
||||
class File:
|
||||
"""Represents a file and holds metadata to be used for scanning.
|
||||
"""
|
||||
|
||||
INITIAL_INFO = {
|
||||
'size': 0,
|
||||
'mtime': 0,
|
||||
'md5': '',
|
||||
'md5partial': '',
|
||||
"size": 0,
|
||||
"mtime": 0,
|
||||
"md5": "",
|
||||
"md5partial": "",
|
||||
}
|
||||
# Slots for File make us save quite a bit of memory. In a memory test I've made with a lot of
|
||||
# files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become
|
||||
# even greater when we take into account read attributes (70%!). Yeah, it's worth it.
|
||||
__slots__ = ('path', 'is_ref', 'words') + tuple(INITIAL_INFO.keys())
|
||||
__slots__ = ("path", "is_ref", "words") + tuple(INITIAL_INFO.keys())
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
@@ -90,25 +98,27 @@ class File:
|
||||
try:
|
||||
self._read_info(attrname)
|
||||
except Exception as e:
|
||||
logging.warning("An error '%s' was raised while decoding '%s'", e, repr(self.path))
|
||||
logging.warning(
|
||||
"An error '%s' was raised while decoding '%s'", e, repr(self.path)
|
||||
)
|
||||
result = object.__getattribute__(self, attrname)
|
||||
if result is NOT_SET:
|
||||
result = self.INITIAL_INFO[attrname]
|
||||
return result
|
||||
|
||||
#This offset is where we should start reading the file to get a partial md5
|
||||
#For audio file, it should be where audio data starts
|
||||
# This offset is where we should start reading the file to get a partial md5
|
||||
# For audio file, it should be where audio data starts
|
||||
def _get_md5partial_offset_and_size(self):
|
||||
return (0x4000, 0x4000) #16Kb
|
||||
return (0x4000, 0x4000) # 16Kb
|
||||
|
||||
def _read_info(self, field):
|
||||
if field in ('size', 'mtime'):
|
||||
if field in ("size", "mtime"):
|
||||
stats = self.path.stat()
|
||||
self.size = nonone(stats.st_size, 0)
|
||||
self.mtime = nonone(stats.st_mtime, 0)
|
||||
elif field == 'md5partial':
|
||||
elif field == "md5partial":
|
||||
try:
|
||||
fp = self.path.open('rb')
|
||||
fp = self.path.open("rb")
|
||||
offset, size = self._get_md5partial_offset_and_size()
|
||||
fp.seek(offset)
|
||||
partialdata = fp.read(size)
|
||||
@@ -117,9 +127,9 @@ class File:
|
||||
fp.close()
|
||||
except Exception:
|
||||
pass
|
||||
elif field == 'md5':
|
||||
elif field == "md5":
|
||||
try:
|
||||
fp = self.path.open('rb')
|
||||
fp = self.path.open("rb")
|
||||
md5 = hashlib.md5()
|
||||
# The goal here is to not run out of memory on really big files. However, the chunk
|
||||
# size has to be large enough so that the python loop isn't too costly in terms of
|
||||
@@ -144,7 +154,7 @@ class File:
|
||||
for attrname in attrnames:
|
||||
getattr(self, attrname)
|
||||
|
||||
#--- Public
|
||||
# --- Public
|
||||
@classmethod
|
||||
def can_handle(cls, path):
|
||||
"""Returns whether this file wrapper class can handle ``path``.
|
||||
@@ -170,7 +180,7 @@ class File:
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
#--- Properties
|
||||
# --- Properties
|
||||
@property
|
||||
def extension(self):
|
||||
return get_file_ext(self.name)
|
||||
@@ -189,7 +199,8 @@ class Folder(File):
|
||||
|
||||
It has the size/md5 info of a File, but it's value are the sum of its subitems.
|
||||
"""
|
||||
__slots__ = File.__slots__ + ('_subfolders', )
|
||||
|
||||
__slots__ = File.__slots__ + ("_subfolders",)
|
||||
|
||||
def __init__(self, path):
|
||||
File.__init__(self, path)
|
||||
@@ -201,12 +212,12 @@ class Folder(File):
|
||||
return folders + files
|
||||
|
||||
def _read_info(self, field):
|
||||
if field in {'size', 'mtime'}:
|
||||
if field in {"size", "mtime"}:
|
||||
size = sum((f.size for f in self._all_items()), 0)
|
||||
self.size = size
|
||||
stats = self.path.stat()
|
||||
self.mtime = nonone(stats.st_mtime, 0)
|
||||
elif field in {'md5', 'md5partial'}:
|
||||
elif field in {"md5", "md5partial"}:
|
||||
# What's sensitive here is that we must make sure that subfiles'
|
||||
# md5 are always added up in the same order, but we also want a
|
||||
# different md5 if a file gets moved in a different subdirectory.
|
||||
@@ -214,7 +225,7 @@ class Folder(File):
|
||||
items = self._all_items()
|
||||
items.sort(key=lambda f: f.path)
|
||||
md5s = [getattr(f, field) for f in items]
|
||||
return b''.join(md5s)
|
||||
return b"".join(md5s)
|
||||
|
||||
md5 = hashlib.md5(get_dir_md5_concat())
|
||||
digest = md5.digest()
|
||||
@@ -223,7 +234,9 @@ class Folder(File):
|
||||
@property
|
||||
def subfolders(self):
|
||||
if self._subfolders is None:
|
||||
subfolders = [p for p in self.path.listdir() if not p.islink() and p.isdir()]
|
||||
subfolders = [
|
||||
p for p in self.path.listdir() if not p.islink() and p.isdir()
|
||||
]
|
||||
self._subfolders = [self.__class__(p) for p in subfolders]
|
||||
return self._subfolders
|
||||
|
||||
@@ -244,6 +257,7 @@ def get_file(path, fileclasses=[File]):
|
||||
if fileclass.can_handle(path):
|
||||
return fileclass(path)
|
||||
|
||||
|
||||
def get_files(path, fileclasses=[File]):
|
||||
"""Returns a list of :class:`File` for each file contained in ``path``.
|
||||
|
||||
|
||||
@@ -13,4 +13,3 @@ blue, which is supposed to be orange, does the sorting logic, holds selection, e
|
||||
|
||||
.. _cross-toolkit: http://www.hardcoded.net/articles/cross-toolkit-software
|
||||
"""
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
from hscommon.notify import Listener
|
||||
|
||||
|
||||
class DupeGuruGUIObject(Listener):
|
||||
def __init__(self, app):
|
||||
Listener.__init__(self, app)
|
||||
@@ -27,4 +28,3 @@ class DupeGuruGUIObject(Listener):
|
||||
|
||||
def results_changed_but_keep_selection(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
from hscommon.gui.base import GUIObject
|
||||
from hscommon.trans import tr
|
||||
|
||||
|
||||
class DeletionOptionsView:
|
||||
"""Expected interface for :class:`DeletionOptions`'s view.
|
||||
|
||||
@@ -26,6 +27,7 @@ class DeletionOptionsView:
|
||||
Other than the flags, there's also a prompt message which has a dynamic content, defined by
|
||||
:meth:`update_msg`.
|
||||
"""
|
||||
|
||||
def update_msg(self, msg: str):
|
||||
"""Update the dialog's prompt with ``str``.
|
||||
"""
|
||||
@@ -40,12 +42,14 @@ class DeletionOptionsView:
|
||||
"""Enable or disable the widget controlling :attr:`DeletionOptions.use_hardlinks`.
|
||||
"""
|
||||
|
||||
|
||||
class DeletionOptions(GUIObject):
|
||||
"""Present the user with deletion options before proceeding.
|
||||
|
||||
When the user activates "Send to trash", we present him with a couple of options that changes
|
||||
the behavior of that deletion operation.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
GUIObject.__init__(self)
|
||||
#: Whether symlinks or hardlinks are used when doing :attr:`link_deleted`.
|
||||
@@ -103,5 +107,3 @@ class DeletionOptions(GUIObject):
|
||||
self._link_deleted = value
|
||||
hardlinks_enabled = value and self.supports_links()
|
||||
self.view.set_hardlink_option_enabled(hardlinks_enabled)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
from hscommon.gui.base import GUIObject
|
||||
from .base import DupeGuruGUIObject
|
||||
|
||||
|
||||
class DetailsPanel(GUIObject, DupeGuruGUIObject):
|
||||
def __init__(self, app):
|
||||
GUIObject.__init__(self, multibind=True)
|
||||
@@ -19,7 +20,7 @@ class DetailsPanel(GUIObject, DupeGuruGUIObject):
|
||||
self._refresh()
|
||||
self.view.refresh()
|
||||
|
||||
#--- Private
|
||||
# --- Private
|
||||
def _refresh(self):
|
||||
if self.app.selected_dupes:
|
||||
dupe = self.app.selected_dupes[0]
|
||||
@@ -31,18 +32,19 @@ class DetailsPanel(GUIObject, DupeGuruGUIObject):
|
||||
# we don't want the two sides of the table to display the stats for the same file
|
||||
ref = group.ref if group is not None and group.ref is not dupe else None
|
||||
data2 = self.app.get_display_info(ref, group, False)
|
||||
columns = self.app.result_table.COLUMNS[1:] # first column is the 'marked' column
|
||||
columns = self.app.result_table.COLUMNS[
|
||||
1:
|
||||
] # first column is the 'marked' column
|
||||
self._table = [(c.display, data1[c.name], data2[c.name]) for c in columns]
|
||||
|
||||
#--- Public
|
||||
# --- Public
|
||||
def row_count(self):
|
||||
return len(self._table)
|
||||
|
||||
def row(self, row_index):
|
||||
return self._table[row_index]
|
||||
|
||||
#--- Event Handlers
|
||||
# --- Event Handlers
|
||||
def dupes_selected(self):
|
||||
self._refresh()
|
||||
self.view.refresh()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from .base import DupeGuruGUIObject
|
||||
|
||||
STATE_ORDER = [DirectoryState.Normal, DirectoryState.Reference, DirectoryState.Excluded]
|
||||
|
||||
|
||||
# Lazily loads children
|
||||
class DirectoryNode(Node):
|
||||
def __init__(self, tree, path, name):
|
||||
@@ -35,7 +36,9 @@ class DirectoryNode(Node):
|
||||
self._loaded = True
|
||||
|
||||
def update_all_states(self):
|
||||
self._state = STATE_ORDER.index(self._tree.app.directories.get_state(self._directory_path))
|
||||
self._state = STATE_ORDER.index(
|
||||
self._tree.app.directories.get_state(self._directory_path)
|
||||
)
|
||||
for node in self:
|
||||
node.update_all_states()
|
||||
|
||||
@@ -55,7 +58,7 @@ class DirectoryNode(Node):
|
||||
|
||||
|
||||
class DirectoryTree(Tree, DupeGuruGUIObject):
|
||||
#--- model -> view calls:
|
||||
# --- model -> view calls:
|
||||
# refresh()
|
||||
# refresh_states() # when only states label need to be refreshed
|
||||
#
|
||||
@@ -100,8 +103,7 @@ class DirectoryTree(Tree, DupeGuruGUIObject):
|
||||
node.update_all_states()
|
||||
self.view.refresh_states()
|
||||
|
||||
#--- Event Handlers
|
||||
# --- Event Handlers
|
||||
def directories_changed(self):
|
||||
self._refresh()
|
||||
self.view.refresh()
|
||||
|
||||
|
||||
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
|
||||
@@ -8,20 +8,23 @@
|
||||
from hscommon.trans import tr
|
||||
from .ignore_list_table import IgnoreListTable
|
||||
|
||||
|
||||
class IgnoreListDialog:
|
||||
#--- View interface
|
||||
# --- View interface
|
||||
# show()
|
||||
#
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.ignore_list = self.app.ignore_list
|
||||
self.ignore_list_table = IgnoreListTable(self)
|
||||
self.ignore_list_table = IgnoreListTable(self) # GUITable
|
||||
|
||||
def clear(self):
|
||||
if not self.ignore_list:
|
||||
return
|
||||
msg = tr("Do you really want to remove all %d items from the ignore list?") % len(self.ignore_list)
|
||||
msg = tr(
|
||||
"Do you really want to remove all %d items from the ignore list?"
|
||||
) % len(self.ignore_list)
|
||||
if self.app.view.ask_yes_no(msg):
|
||||
self.ignore_list.Clear()
|
||||
self.refresh()
|
||||
@@ -36,4 +39,3 @@ class IgnoreListDialog:
|
||||
|
||||
def show(self):
|
||||
self.view.show()
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ from hscommon.gui.table import GUITable, Row
|
||||
from hscommon.gui.column import Column, Columns
|
||||
from hscommon.trans import trget
|
||||
|
||||
coltr = trget('columns')
|
||||
coltr = trget("columns")
|
||||
|
||||
|
||||
class IgnoreListTable(GUITable):
|
||||
COLUMNS = [
|
||||
# the str concat below saves us needless localization.
|
||||
Column('path1', coltr("File Path") + " 1"),
|
||||
Column('path2', coltr("File Path") + " 2"),
|
||||
Column("path1", coltr("File Path") + " 1"),
|
||||
Column("path2", coltr("File Path") + " 2"),
|
||||
]
|
||||
|
||||
def __init__(self, ignore_list_dialog):
|
||||
@@ -25,7 +26,7 @@ class IgnoreListTable(GUITable):
|
||||
self.view = None
|
||||
self.dialog = ignore_list_dialog
|
||||
|
||||
#--- Override
|
||||
# --- Override
|
||||
def _fill(self):
|
||||
for path1, path2 in self.dialog.ignore_list:
|
||||
self.append(IgnoreListRow(self, path1, path2))
|
||||
@@ -38,4 +39,3 @@ class IgnoreListRow(Row):
|
||||
self.path2_original = path2
|
||||
self.path1 = str(path1)
|
||||
self.path2 = str(path2)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
from hscommon.gui.base import GUIObject
|
||||
from hscommon.gui.selectable_list import GUISelectableList
|
||||
|
||||
|
||||
class CriterionCategoryList(GUISelectableList):
|
||||
def __init__(self, dialog):
|
||||
self.dialog = dialog
|
||||
@@ -18,6 +19,7 @@ class CriterionCategoryList(GUISelectableList):
|
||||
self.dialog.select_category(self.dialog.categories[self.selected_index])
|
||||
GUISelectableList._update_selection(self)
|
||||
|
||||
|
||||
class PrioritizationList(GUISelectableList):
|
||||
def __init__(self, dialog):
|
||||
self.dialog = dialog
|
||||
@@ -41,6 +43,7 @@ class PrioritizationList(GUISelectableList):
|
||||
del prilist[i]
|
||||
self._refresh_contents()
|
||||
|
||||
|
||||
class PrioritizeDialog(GUIObject):
|
||||
def __init__(self, app):
|
||||
GUIObject.__init__(self)
|
||||
@@ -52,15 +55,15 @@ class PrioritizeDialog(GUIObject):
|
||||
self.prioritizations = []
|
||||
self.prioritization_list = PrioritizationList(self)
|
||||
|
||||
#--- Override
|
||||
# --- Override
|
||||
def _view_updated(self):
|
||||
self.category_list.select(0)
|
||||
|
||||
#--- Private
|
||||
# --- Private
|
||||
def _sort_key(self, dupe):
|
||||
return tuple(crit.sort_key(dupe) for crit in self.prioritizations)
|
||||
|
||||
#--- Public
|
||||
# --- Public
|
||||
def select_category(self, category):
|
||||
self.criteria = category.criteria_list()
|
||||
self.criteria_list[:] = [c.display_value for c in self.criteria]
|
||||
@@ -69,13 +72,15 @@ class PrioritizeDialog(GUIObject):
|
||||
# Add selected criteria in criteria_list to prioritization_list.
|
||||
if self.criteria_list.selected_index is None:
|
||||
return
|
||||
crit = self.criteria[self.criteria_list.selected_index]
|
||||
for i in self.criteria_list.selected_indexes:
|
||||
crit = self.criteria[i]
|
||||
self.prioritizations.append(crit)
|
||||
del crit
|
||||
self.prioritization_list[:] = [crit.display for crit in self.prioritizations]
|
||||
|
||||
def remove_selected(self):
|
||||
self.prioritization_list.remove_selected()
|
||||
self.prioritization_list.select([])
|
||||
|
||||
def perform_reprioritization(self):
|
||||
self.app.reprioritize_groups(self._sort_key)
|
||||
|
||||
@@ -10,6 +10,7 @@ from hscommon import desktop
|
||||
|
||||
from .problem_table import ProblemTable
|
||||
|
||||
|
||||
class ProblemDialog:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
@@ -26,4 +27,3 @@ class ProblemDialog:
|
||||
|
||||
def select_dupe(self, dupe):
|
||||
self._selected_dupe = dupe
|
||||
|
||||
|
||||
@@ -10,12 +10,13 @@ from hscommon.gui.table import GUITable, Row
|
||||
from hscommon.gui.column import Column, Columns
|
||||
from hscommon.trans import trget
|
||||
|
||||
coltr = trget('columns')
|
||||
coltr = trget("columns")
|
||||
|
||||
|
||||
class ProblemTable(GUITable):
|
||||
COLUMNS = [
|
||||
Column('path', coltr("File Path")),
|
||||
Column('msg', coltr("Error Message")),
|
||||
Column("path", coltr("File Path")),
|
||||
Column("msg", coltr("Error Message")),
|
||||
]
|
||||
|
||||
def __init__(self, problem_dialog):
|
||||
@@ -23,7 +24,7 @@ class ProblemTable(GUITable):
|
||||
self.columns = Columns(self)
|
||||
self.dialog = problem_dialog
|
||||
|
||||
#--- Override
|
||||
# --- Override
|
||||
def _update_selection(self):
|
||||
row = self.selected_row
|
||||
dupe = row.dupe if row is not None else None
|
||||
@@ -41,4 +42,3 @@ class ProblemRow(Row):
|
||||
self.dupe = dupe
|
||||
self.msg = msg
|
||||
self.path = str(dupe.path)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from hscommon.gui.column import Columns
|
||||
|
||||
from .base import DupeGuruGUIObject
|
||||
|
||||
|
||||
class DupeRow(Row):
|
||||
def __init__(self, table, group, dupe):
|
||||
Row.__init__(self, table)
|
||||
@@ -42,7 +43,9 @@ class DupeRow(Row):
|
||||
dupe_info = self.data
|
||||
ref_info = self._group.ref.get_display_info(group=self._group, delta=False)
|
||||
for key, value in dupe_info.items():
|
||||
if (key not in self._delta_columns) and (ref_info[key].lower() != value.lower()):
|
||||
if (key not in self._delta_columns) and (
|
||||
ref_info[key].lower() != value.lower()
|
||||
):
|
||||
self._delta_columns.add(key)
|
||||
return column_name in self._delta_columns
|
||||
|
||||
@@ -79,12 +82,12 @@ class ResultTable(GUITable, DupeGuruGUIObject):
|
||||
def __init__(self, app):
|
||||
GUITable.__init__(self)
|
||||
DupeGuruGUIObject.__init__(self, app)
|
||||
self.columns = Columns(self, prefaccess=app, savename='ResultTable')
|
||||
self.columns = Columns(self, prefaccess=app, savename="ResultTable")
|
||||
self._power_marker = False
|
||||
self._delta_values = False
|
||||
self._sort_descriptors = ('name', True)
|
||||
self._sort_descriptors = ("name", True)
|
||||
|
||||
#--- Override
|
||||
# --- Override
|
||||
def _view_updated(self):
|
||||
self._refresh_with_view()
|
||||
|
||||
@@ -96,7 +99,7 @@ class ResultTable(GUITable, DupeGuruGUIObject):
|
||||
|
||||
def _update_selection(self):
|
||||
rows = self.selected_rows
|
||||
self.app._select_dupes(list(map(attrgetter('_dupe'), rows)))
|
||||
self.app._select_dupes(list(map(attrgetter("_dupe"), rows)))
|
||||
|
||||
def _fill(self):
|
||||
if not self.power_marker:
|
||||
@@ -113,12 +116,12 @@ class ResultTable(GUITable, DupeGuruGUIObject):
|
||||
self.refresh()
|
||||
self.view.show_selected_row()
|
||||
|
||||
#--- Public
|
||||
# --- Public
|
||||
def get_row_value(self, index, column):
|
||||
try:
|
||||
row = self[index]
|
||||
except IndexError:
|
||||
return '---'
|
||||
return "---"
|
||||
if self.delta_values:
|
||||
return row.data_delta[column]
|
||||
else:
|
||||
@@ -142,7 +145,7 @@ class ResultTable(GUITable, DupeGuruGUIObject):
|
||||
self._sort_descriptors = (key, asc)
|
||||
self._refresh_with_view()
|
||||
|
||||
#--- Properties
|
||||
# --- Properties
|
||||
@property
|
||||
def power_marker(self):
|
||||
return self._power_marker
|
||||
@@ -171,7 +174,7 @@ class ResultTable(GUITable, DupeGuruGUIObject):
|
||||
def selected_dupe_count(self):
|
||||
return sum(1 for row in self.selected_rows if not row.isref)
|
||||
|
||||
#--- Event Handlers
|
||||
# --- Event Handlers
|
||||
def marking_changed(self):
|
||||
self.view.invalidate_markings()
|
||||
|
||||
@@ -188,4 +191,3 @@ class ResultTable(GUITable, DupeGuruGUIObject):
|
||||
|
||||
def save_session(self):
|
||||
self.columns.save_columns()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
from .base import DupeGuruGUIObject
|
||||
|
||||
|
||||
class StatsLabel(DupeGuruGUIObject):
|
||||
def _view_updated(self):
|
||||
self.view.refresh()
|
||||
@@ -18,4 +19,5 @@ class StatsLabel(DupeGuruGUIObject):
|
||||
|
||||
def results_changed(self):
|
||||
self.view.refresh()
|
||||
|
||||
marking_changed = results_changed
|
||||
|
||||
@@ -10,13 +10,15 @@ from xml.etree import ElementTree as ET
|
||||
|
||||
from hscommon.util import FileOrPath
|
||||
|
||||
|
||||
class IgnoreList:
|
||||
"""An ignore list implementation that is iterable, filterable and exportable to XML.
|
||||
|
||||
Call Ignore to add an ignore list entry, and AreIgnore to check if 2 items are in the list.
|
||||
When iterated, 2 sized tuples will be returned, the tuples containing 2 items ignored together.
|
||||
"""
|
||||
#---Override
|
||||
|
||||
# ---Override
|
||||
def __init__(self):
|
||||
self._ignored = {}
|
||||
self._count = 0
|
||||
@@ -29,7 +31,7 @@ class IgnoreList:
|
||||
def __len__(self):
|
||||
return self._count
|
||||
|
||||
#---Public
|
||||
# ---Public
|
||||
def AreIgnored(self, first, second):
|
||||
def do_check(first, second):
|
||||
try:
|
||||
@@ -99,14 +101,14 @@ class IgnoreList:
|
||||
root = ET.parse(infile).getroot()
|
||||
except Exception:
|
||||
return
|
||||
file_elems = (e for e in root if e.tag == 'file')
|
||||
file_elems = (e for e in root if e.tag == "file")
|
||||
for fn in file_elems:
|
||||
file_path = fn.get('path')
|
||||
file_path = fn.get("path")
|
||||
if not file_path:
|
||||
continue
|
||||
subfile_elems = (e for e in fn if e.tag == 'file')
|
||||
subfile_elems = (e for e in fn if e.tag == "file")
|
||||
for sfn in subfile_elems:
|
||||
subfile_path = sfn.get('path')
|
||||
subfile_path = sfn.get("path")
|
||||
if subfile_path:
|
||||
self.Ignore(file_path, subfile_path)
|
||||
|
||||
@@ -115,15 +117,13 @@ class IgnoreList:
|
||||
|
||||
outfile can be a file object or a filename.
|
||||
"""
|
||||
root = ET.Element('ignore_list')
|
||||
root = ET.Element("ignore_list")
|
||||
for filename, subfiles in self._ignored.items():
|
||||
file_node = ET.SubElement(root, 'file')
|
||||
file_node.set('path', filename)
|
||||
file_node = ET.SubElement(root, "file")
|
||||
file_node.set("path", filename)
|
||||
for subfilename in subfiles:
|
||||
subfile_node = ET.SubElement(file_node, 'file')
|
||||
subfile_node.set('path', subfilename)
|
||||
subfile_node = ET.SubElement(file_node, "file")
|
||||
subfile_node.set("path", subfilename)
|
||||
tree = ET.ElementTree(root)
|
||||
with FileOrPath(outfile, 'wb') as fp:
|
||||
tree.write(fp, encoding='utf-8')
|
||||
|
||||
|
||||
with FileOrPath(outfile, "wb") as fp:
|
||||
tree.write(fp, encoding="utf-8")
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
|
||||
class Markable:
|
||||
def __init__(self):
|
||||
self.__marked = set()
|
||||
self.__inverted = False
|
||||
|
||||
#---Virtual
|
||||
#About did_mark and did_unmark: They only happen what an object is actually added/removed
|
||||
# ---Virtual
|
||||
# About did_mark and did_unmark: They only happen what an object is actually added/removed
|
||||
# in self.__marked, and is not affected by __inverted. Thus, self.mark while __inverted
|
||||
#is True will launch _DidUnmark.
|
||||
# is True will launch _DidUnmark.
|
||||
def _did_mark(self, o):
|
||||
pass
|
||||
|
||||
@@ -27,7 +28,7 @@ class Markable:
|
||||
def _is_markable(self, o):
|
||||
return True
|
||||
|
||||
#---Protected
|
||||
# ---Protected
|
||||
def _remove_mark_flag(self, o):
|
||||
try:
|
||||
self.__marked.remove(o)
|
||||
@@ -35,7 +36,7 @@ class Markable:
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
#---Public
|
||||
# ---Public
|
||||
def is_marked(self, o):
|
||||
if not self._is_markable(o):
|
||||
return False
|
||||
@@ -92,7 +93,7 @@ class Markable:
|
||||
for o in objects:
|
||||
self.unmark(o)
|
||||
|
||||
#--- Properties
|
||||
# --- Properties
|
||||
@property
|
||||
def mark_count(self):
|
||||
if self.__inverted:
|
||||
@@ -104,6 +105,7 @@ class Markable:
|
||||
def mark_inverted(self):
|
||||
return self.__inverted
|
||||
|
||||
|
||||
class MarkableList(list, Markable):
|
||||
def __init__(self):
|
||||
list.__init__(self)
|
||||
|
||||
@@ -13,25 +13,37 @@ from core.util import format_timestamp, format_perc, format_words, format_dupe_c
|
||||
from core import fs
|
||||
|
||||
TAG_FIELDS = {
|
||||
'audiosize', 'duration', 'bitrate', 'samplerate', 'title', 'artist',
|
||||
'album', 'genre', 'year', 'track', 'comment'
|
||||
"audiosize",
|
||||
"duration",
|
||||
"bitrate",
|
||||
"samplerate",
|
||||
"title",
|
||||
"artist",
|
||||
"album",
|
||||
"genre",
|
||||
"year",
|
||||
"track",
|
||||
"comment",
|
||||
}
|
||||
|
||||
|
||||
class MusicFile(fs.File):
|
||||
INITIAL_INFO = fs.File.INITIAL_INFO.copy()
|
||||
INITIAL_INFO.update({
|
||||
'audiosize': 0,
|
||||
'bitrate': 0,
|
||||
'duration': 0,
|
||||
'samplerate': 0,
|
||||
'artist': '',
|
||||
'album': '',
|
||||
'title': '',
|
||||
'genre': '',
|
||||
'comment': '',
|
||||
'year': '',
|
||||
'track': 0,
|
||||
})
|
||||
INITIAL_INFO.update(
|
||||
{
|
||||
"audiosize": 0,
|
||||
"bitrate": 0,
|
||||
"duration": 0,
|
||||
"samplerate": 0,
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"title": "",
|
||||
"genre": "",
|
||||
"comment": "",
|
||||
"year": "",
|
||||
"track": 0,
|
||||
}
|
||||
)
|
||||
__slots__ = fs.File.__slots__ + tuple(INITIAL_INFO.keys())
|
||||
|
||||
@classmethod
|
||||
@@ -60,26 +72,26 @@ class MusicFile(fs.File):
|
||||
else:
|
||||
percentage = group.percentage
|
||||
dupe_count = len(group.dupes)
|
||||
dupe_folder_path = getattr(self, 'display_folder_path', self.folder_path)
|
||||
dupe_folder_path = getattr(self, "display_folder_path", self.folder_path)
|
||||
return {
|
||||
'name': self.name,
|
||||
'folder_path': str(dupe_folder_path),
|
||||
'size': format_size(size, 2, 2, False),
|
||||
'duration': format_time(duration, with_hours=False),
|
||||
'bitrate': str(bitrate),
|
||||
'samplerate': str(samplerate),
|
||||
'extension': self.extension,
|
||||
'mtime': format_timestamp(mtime, delta and m),
|
||||
'title': self.title,
|
||||
'artist': self.artist,
|
||||
'album': self.album,
|
||||
'genre': self.genre,
|
||||
'year': self.year,
|
||||
'track': str(self.track),
|
||||
'comment': self.comment,
|
||||
'percentage': format_perc(percentage),
|
||||
'words': format_words(self.words) if hasattr(self, 'words') else '',
|
||||
'dupe_count': format_dupe_count(dupe_count),
|
||||
"name": self.name,
|
||||
"folder_path": str(dupe_folder_path),
|
||||
"size": format_size(size, 2, 2, False),
|
||||
"duration": format_time(duration, with_hours=False),
|
||||
"bitrate": str(bitrate),
|
||||
"samplerate": str(samplerate),
|
||||
"extension": self.extension,
|
||||
"mtime": format_timestamp(mtime, delta and m),
|
||||
"title": self.title,
|
||||
"artist": self.artist,
|
||||
"album": self.album,
|
||||
"genre": self.genre,
|
||||
"year": self.year,
|
||||
"track": str(self.track),
|
||||
"comment": self.comment,
|
||||
"percentage": format_perc(percentage),
|
||||
"words": format_words(self.words) if hasattr(self, "words") else "",
|
||||
"dupe_count": format_dupe_count(dupe_count),
|
||||
}
|
||||
|
||||
def _get_md5partial_offset_and_size(self):
|
||||
@@ -101,4 +113,3 @@ class MusicFile(fs.File):
|
||||
self.comment = f.comment
|
||||
self.year = f.year
|
||||
self.track = f.track
|
||||
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
from hscommon.trans import trget
|
||||
|
||||
from core.prioritize import (
|
||||
KindCategory, FolderCategory, FilenameCategory, NumericalCategory,
|
||||
SizeCategory, MtimeCategory
|
||||
KindCategory,
|
||||
FolderCategory,
|
||||
FilenameCategory,
|
||||
NumericalCategory,
|
||||
SizeCategory,
|
||||
MtimeCategory,
|
||||
)
|
||||
|
||||
coltr = trget('columns')
|
||||
coltr = trget("columns")
|
||||
|
||||
|
||||
class DurationCategory(NumericalCategory):
|
||||
NAME = coltr("Duration")
|
||||
@@ -20,21 +25,29 @@ class DurationCategory(NumericalCategory):
|
||||
def extract_value(self, dupe):
|
||||
return dupe.duration
|
||||
|
||||
|
||||
class BitrateCategory(NumericalCategory):
|
||||
NAME = coltr("Bitrate")
|
||||
|
||||
def extract_value(self, dupe):
|
||||
return dupe.bitrate
|
||||
|
||||
|
||||
class SamplerateCategory(NumericalCategory):
|
||||
NAME = coltr("Samplerate")
|
||||
|
||||
def extract_value(self, dupe):
|
||||
return dupe.samplerate
|
||||
|
||||
|
||||
def all_categories():
|
||||
return [
|
||||
KindCategory, FolderCategory, FilenameCategory, SizeCategory, DurationCategory,
|
||||
BitrateCategory, SamplerateCategory, MtimeCategory
|
||||
KindCategory,
|
||||
FolderCategory,
|
||||
FilenameCategory,
|
||||
SizeCategory,
|
||||
DurationCategory,
|
||||
BitrateCategory,
|
||||
SamplerateCategory,
|
||||
MtimeCategory,
|
||||
]
|
||||
|
||||
|
||||
@@ -10,28 +10,29 @@ from hscommon.trans import trget
|
||||
|
||||
from core.gui.result_table import ResultTable as ResultTableBase
|
||||
|
||||
coltr = trget('columns')
|
||||
coltr = trget("columns")
|
||||
|
||||
|
||||
class ResultTable(ResultTableBase):
|
||||
COLUMNS = [
|
||||
Column('marked', ''),
|
||||
Column('name', coltr("Filename")),
|
||||
Column('folder_path', coltr("Folder"), visible=False, optional=True),
|
||||
Column('size', coltr("Size (MB)"), optional=True),
|
||||
Column('duration', coltr("Time"), optional=True),
|
||||
Column('bitrate', coltr("Bitrate"), optional=True),
|
||||
Column('samplerate', coltr("Sample Rate"), visible=False, optional=True),
|
||||
Column('extension', coltr("Kind"), optional=True),
|
||||
Column('mtime', coltr("Modification"), visible=False, optional=True),
|
||||
Column('title', coltr("Title"), visible=False, optional=True),
|
||||
Column('artist', coltr("Artist"), visible=False, optional=True),
|
||||
Column('album', coltr("Album"), visible=False, optional=True),
|
||||
Column('genre', coltr("Genre"), visible=False, optional=True),
|
||||
Column('year', coltr("Year"), visible=False, optional=True),
|
||||
Column('track', coltr("Track Number"), visible=False, optional=True),
|
||||
Column('comment', coltr("Comment"), visible=False, optional=True),
|
||||
Column('percentage', coltr("Match %"), optional=True),
|
||||
Column('words', coltr("Words Used"), visible=False, optional=True),
|
||||
Column('dupe_count', coltr("Dupe Count"), visible=False, optional=True),
|
||||
Column("marked", ""),
|
||||
Column("name", coltr("Filename")),
|
||||
Column("folder_path", coltr("Folder"), visible=False, optional=True),
|
||||
Column("size", coltr("Size (MB)"), optional=True),
|
||||
Column("duration", coltr("Time"), optional=True),
|
||||
Column("bitrate", coltr("Bitrate"), optional=True),
|
||||
Column("samplerate", coltr("Sample Rate"), visible=False, optional=True),
|
||||
Column("extension", coltr("Kind"), optional=True),
|
||||
Column("mtime", coltr("Modification"), visible=False, optional=True),
|
||||
Column("title", coltr("Title"), visible=False, optional=True),
|
||||
Column("artist", coltr("Artist"), visible=False, optional=True),
|
||||
Column("album", coltr("Album"), visible=False, optional=True),
|
||||
Column("genre", coltr("Genre"), visible=False, optional=True),
|
||||
Column("year", coltr("Year"), visible=False, optional=True),
|
||||
Column("track", coltr("Track Number"), visible=False, optional=True),
|
||||
Column("comment", coltr("Comment"), visible=False, optional=True),
|
||||
Column("percentage", coltr("Match %"), optional=True),
|
||||
Column("words", coltr("Words Used"), visible=False, optional=True),
|
||||
Column("dupe_count", coltr("Dupe Count"), visible=False, optional=True),
|
||||
]
|
||||
DELTA_COLUMNS = {'size', 'duration', 'bitrate', 'samplerate', 'mtime'}
|
||||
DELTA_COLUMNS = {"size", "duration", "bitrate", "samplerate", "mtime"}
|
||||
|
||||
@@ -8,6 +8,7 @@ from hscommon.trans import tr
|
||||
|
||||
from core.scanner import Scanner as ScannerBase, ScanOption, ScanType
|
||||
|
||||
|
||||
class ScannerME(ScannerBase):
|
||||
@staticmethod
|
||||
def _key_func(dupe):
|
||||
@@ -22,5 +23,3 @@ class ScannerME(ScannerBase):
|
||||
ScanOption(ScanType.Tag, tr("Tags")),
|
||||
ScanOption(ScanType.Contents, tr("Contents")),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
from . import block, cache, exif, iphoto_plist, matchblock, matchexif, photo, prioritize, result_table, scanner # noqa
|
||||
from . import ( # noqa
|
||||
block,
|
||||
cache,
|
||||
exif,
|
||||
iphoto_plist,
|
||||
matchblock,
|
||||
matchexif,
|
||||
photo,
|
||||
prioritize,
|
||||
result_table,
|
||||
scanner,
|
||||
)
|
||||
|
||||
143
core/pe/cache.py
143
core/pe/cache.py
@@ -1,17 +1,11 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2006/09/14
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
# Copyright 2016 Virgil Dupras
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import os
|
||||
import os.path as op
|
||||
import logging
|
||||
import sqlite3 as sqlite
|
||||
from ._cache import string_to_colors # noqa
|
||||
|
||||
from ._cache import string_to_colors
|
||||
|
||||
def colors_to_string(colors):
|
||||
"""Transform the 3 sized tuples 'colors' into a hex string.
|
||||
@@ -19,7 +13,8 @@ def colors_to_string(colors):
|
||||
[(0,100,255)] --> 0064ff
|
||||
[(1,2,3),(4,5,6)] --> 010203040506
|
||||
"""
|
||||
return ''.join(['%02x%02x%02x' % (r, g, b) for r, g, b in colors])
|
||||
return "".join("%02x%02x%02x" % (r, g, b) for r, g, b in colors)
|
||||
|
||||
|
||||
# This function is an important bottleneck of dupeGuru PE. It has been converted to C.
|
||||
# def string_to_colors(s):
|
||||
@@ -30,133 +25,3 @@ def colors_to_string(colors):
|
||||
# number = int(s[i:i+6], 16)
|
||||
# result.append((number >> 16, (number >> 8) & 0xff, number & 0xff))
|
||||
# return result
|
||||
|
||||
class Cache:
|
||||
"""A class to cache picture blocks.
|
||||
"""
|
||||
def __init__(self, db=':memory:'):
|
||||
self.dbname = db
|
||||
self.con = None
|
||||
self._create_con()
|
||||
|
||||
def __contains__(self, key):
|
||||
sql = "select count(*) from pictures where path = ?"
|
||||
result = self.con.execute(sql, [key]).fetchall()
|
||||
return result[0][0] > 0
|
||||
|
||||
def __delitem__(self, key):
|
||||
if key not in self:
|
||||
raise KeyError(key)
|
||||
sql = "delete from pictures where path = ?"
|
||||
self.con.execute(sql, [key])
|
||||
|
||||
# Optimized
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, int):
|
||||
sql = "select blocks from pictures where rowid = ?"
|
||||
else:
|
||||
sql = "select blocks from pictures where path = ?"
|
||||
result = self.con.execute(sql, [key]).fetchone()
|
||||
if result:
|
||||
result = string_to_colors(result[0])
|
||||
return result
|
||||
else:
|
||||
raise KeyError(key)
|
||||
|
||||
def __iter__(self):
|
||||
sql = "select path from pictures"
|
||||
result = self.con.execute(sql)
|
||||
return (row[0] for row in result)
|
||||
|
||||
def __len__(self):
|
||||
sql = "select count(*) from pictures"
|
||||
result = self.con.execute(sql).fetchall()
|
||||
return result[0][0]
|
||||
|
||||
def __setitem__(self, path_str, blocks):
|
||||
blocks = colors_to_string(blocks)
|
||||
if op.exists(path_str):
|
||||
mtime = int(os.stat(path_str).st_mtime)
|
||||
else:
|
||||
mtime = 0
|
||||
if path_str in self:
|
||||
sql = "update pictures set blocks = ?, mtime = ? where path = ?"
|
||||
else:
|
||||
sql = "insert into pictures(blocks,mtime,path) values(?,?,?)"
|
||||
try:
|
||||
self.con.execute(sql, [blocks, mtime, path_str])
|
||||
except sqlite.OperationalError:
|
||||
logging.warning('Picture cache could not set value for key %r', path_str)
|
||||
except sqlite.DatabaseError as e:
|
||||
logging.warning('DatabaseError while setting value for key %r: %s', path_str, str(e))
|
||||
|
||||
def _create_con(self, second_try=False):
|
||||
def create_tables():
|
||||
logging.debug("Creating picture cache tables.")
|
||||
self.con.execute("drop table if exists pictures")
|
||||
self.con.execute("drop index if exists idx_path")
|
||||
self.con.execute("create table pictures(path TEXT, mtime INTEGER, blocks TEXT)")
|
||||
self.con.execute("create index idx_path on pictures (path)")
|
||||
|
||||
self.con = sqlite.connect(self.dbname, isolation_level=None)
|
||||
try:
|
||||
self.con.execute("select path, mtime, blocks from pictures where 1=2")
|
||||
except sqlite.OperationalError: # new db
|
||||
create_tables()
|
||||
except sqlite.DatabaseError as e: # corrupted db
|
||||
if second_try:
|
||||
raise # Something really strange is happening
|
||||
logging.warning('Could not create picture cache because of an error: %s', str(e))
|
||||
self.con.close()
|
||||
os.remove(self.dbname)
|
||||
self._create_con(second_try=True)
|
||||
|
||||
def clear(self):
|
||||
self.close()
|
||||
if self.dbname != ':memory:':
|
||||
os.remove(self.dbname)
|
||||
self._create_con()
|
||||
|
||||
def close(self):
|
||||
if self.con is not None:
|
||||
self.con.close()
|
||||
self.con = None
|
||||
|
||||
def filter(self, func):
|
||||
to_delete = [key for key in self if not func(key)]
|
||||
for key in to_delete:
|
||||
del self[key]
|
||||
|
||||
def get_id(self, path):
|
||||
sql = "select rowid from pictures where path = ?"
|
||||
result = self.con.execute(sql, [path]).fetchone()
|
||||
if result:
|
||||
return result[0]
|
||||
else:
|
||||
raise ValueError(path)
|
||||
|
||||
def get_multiple(self, rowids):
|
||||
sql = "select rowid, blocks from pictures where rowid in (%s)" % ','.join(map(str, rowids))
|
||||
cur = self.con.execute(sql)
|
||||
return ((rowid, string_to_colors(blocks)) for rowid, blocks in cur)
|
||||
|
||||
def purge_outdated(self):
|
||||
"""Go through the cache and purge outdated records.
|
||||
|
||||
A record is outdated if the picture doesn't exist or if its mtime is greater than the one in
|
||||
the db.
|
||||
"""
|
||||
todelete = []
|
||||
sql = "select rowid, path, mtime from pictures"
|
||||
cur = self.con.execute(sql)
|
||||
for rowid, path_str, mtime in cur:
|
||||
if mtime and op.exists(path_str):
|
||||
picture_mtime = os.stat(path_str).st_mtime
|
||||
if int(picture_mtime) <= mtime:
|
||||
# not outdated
|
||||
continue
|
||||
todelete.append(rowid)
|
||||
if todelete:
|
||||
sql = "delete from pictures where rowid in (%s)" % ','.join(map(str, todelete))
|
||||
self.con.execute(sql)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user