1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2024-11-01 06:15:58 +00:00

Compare commits

..

No commits in common. "master" and "4.0.3" have entirely different histories.

393 changed files with 15472 additions and 38563 deletions

13
.github/FUNDING.yml vendored
View File

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

View File

@ -1,31 +0,0 @@
---
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.

View File

@ -1,20 +0,0 @@
---
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.

View File

@ -1,50 +0,0 @@
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: "24 20 * * 2"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["cpp", "python"]
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- if: matrix.language == 'cpp'
name: Build Cpp
run: |
sudo apt-get update
sudo apt-get install python3-pyqt5
make modules
- if: matrix.language == 'python'
name: Autobuild
uses: github/codeql-action/autobuild@v1
# Analysis
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -1,65 +0,0 @@
# Workflow lints, and checks format in parallel then runs tests on all platforms
name: Default CI/CD
on:
push:
pull_request:
branches: [master]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: pre-commit/action@v3.0.1
test:
needs: [pre-commit]
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"]
include:
- os: windows-latest
python-version: "3.12"
- os: macos-latest
python-version: "3.12"
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools
pip install -r requirements.txt -r requirements-extra.txt
- name: Build python modules
run: |
python build.py --modules
- name: Run tests
run: |
pytest core hscommon
- name: Upload Artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: modules ${{ matrix.python-version }}
path: build/**/*.so
merge-artifacts:
needs: [test]
runs-on: ubuntu-latest
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
with:
name: modules
pattern: modules*
delete-merged: true

View File

@ -1,26 +0,0 @@
# Push translation source to Transifex
name: Transifex Sync
on:
push:
branches:
- master
paths:
- locale/*.pot
env:
TX_VERSION: "v1.6.10"
jobs:
push-source:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get Transifex Client
run: |
curl -o- https://raw.githubusercontent.com/transifex/cli/master/install.sh | bash -s -- $TX_VERSION
- name: Update & Push Translation Sources
env:
TX_TOKEN: ${{ secrets.TX_TOKEN }}
run: |
./tx push -s --use-git-timestamps

120
.gitignore vendored
View File

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

9
.gitmodules vendored Normal file
View File

@ -0,0 +1,9 @@
[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

View File

@ -1,24 +0,0 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-yaml
- id: check-toml
- id: end-of-file-fixer
exclude: ".*.json"
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
exclude: ^(.tox|env|build|dist|help|qt/dg_rc.py|pkg).*
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.11.0
hooks:
- id: commitlint
stages: [commit-msg]
additional_dependencies: ["@commitlint/config-conventional"]

View File

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

View File

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

View File

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

17
.vscode/launch.json vendored
View File

@ -1,17 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "DupuGuru",
"type": "debugpy",
"request": "launch",
"program": "run.py",
"console": "integratedTerminal",
"subProcess": true,
"justMyCode": false
},
]
}

17
.vscode/settings.json vendored
View File

@ -1,17 +0,0 @@
{
"cSpell.words": [
"Dupras",
"hscommon"
],
"editor.rulers": [
88,
120
],
"python.languageServer": "Pylance",
"yaml.schemaStore.enable": true,
"[python]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "ms-python.black-formatter"
},
"python.testing.pytestEnabled": true
}

View File

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

View File

@ -1,8 +1,6 @@
To know who contributed to dupeGuru, you can look at the commit log, but not all contributions To know who contributed to dupeGuru, you can look at the commit log, but not all contributions
result in a commit. This file lists contributors who don't necessarily appear in the commit log. result in a commit. This file lists contributors who don't necessarily appear in the commit log.
* Jason Cho, Exchange icon
* schollidesign (https://findicons.com/pack/1035/human_o2), Zoom-in, Zoom-out, Zoom-best-fit, Zoom-original icons
* Jérôme Cantin, Main icon * Jérôme Cantin, Main icon
* Gregor Tätzner, German localization * Gregor Tätzner, German localization
* Frank Weber, German localization * Frank Weber, German localization

View File

@ -619,3 +619,4 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee. copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS

View File

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

124
Makefile
View File

@ -1,41 +1,15 @@
PYTHON ?= python3 PYTHON ?= python3
PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)") REQ_MINOR_VERSION = 4
PYRCC5 ?= pyrcc5
REQ_MINOR_VERSION = 7
PREFIX ?= /usr/local 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 # 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. # 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 # Our build scripts are not very "make like" yet and perform their task in a bundle. For now, we
# use one of each file to act as a representative, a target, of these groups. # use one of each file to act as a representative, a target, of these groups.
submodules_target = hscommon/__init__.py
packages = hscommon core qt packages = hscommon qtlib core qt
localedirs = $(wildcard locale/*/LC_MESSAGES) localedirs = $(wildcard locale/*/LC_MESSAGES)
pofiles = $(wildcard locale/*/LC_MESSAGES/*.po) pofiles = $(wildcard locale/*/LC_MESSAGES/*.po)
mofiles = $(patsubst %.po,%.mo,$(pofiles)) mofiles = $(patsubst %.po,%.mo,$(pofiles))
@ -43,59 +17,76 @@ mofiles = $(patsubst %.po,%.mo,$(pofiles))
vpath %.po $(localedirs) vpath %.po $(localedirs)
vpath %.mo $(localedirs) vpath %.mo $(localedirs)
all: | env i18n modules qt/dg_rc.py all : | run.py
@echo "Build complete! You can run dupeGuru with 'make run'" @echo "Build complete! You can run dupeGuru with 'make run'"
run: run.py : | env i18n modules qt/dg_rc.py
$(VENV_PYTHON) run.py cp qt/run_template.py run.py
pyc: | env run: | run.py
${VENV_PYTHON} -m compileall ${packages} ./env/bin/python run.py
reqs: pyc:
ifneq ($(shell test $(PYTHON_VERSION_MINOR) -ge $(REQ_MINOR_VERSION); echo $$?),0) ${PYTHON} -m compileall ${packages}
$(error "Python 3.${REQ_MINOR_VERSION}+ required. Aborting.")
endif reqs :
ifndef NO_VENV @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
@${PYTHON} -m venv -h > /dev/null || \ @${PYTHON} -m venv -h > /dev/null || \
echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv." 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 || \ @${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \
{ echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; } { echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
env: | reqs # Ensure that submodules are initialized
ifndef NO_VENV $(submodules_target) :
git submodule init
git submodule update
env : | $(submodules_target) reqs
@echo "Creating our virtualenv" @echo "Creating our virtualenv"
${PYTHON} -m venv env ${PYTHON} -m venv env --system-site-packages
$(VENV_PYTHON) -m pip install -r requirements.txt ./env/bin/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 build/help : | env
$(VENV_PYTHON) build.py --doc ./env/bin/python build.py --doc
qt/dg_rc.py: qt/dg.qrc qt/dg_rc.py : qt/dg.qrc
$(PYRCC5) qt/dg.qrc > qt/dg_rc.py pyrcc5 qt/dg.qrc > qt/dg_rc.py
i18n: $(mofiles) i18n: $(mofiles)
%.mo: %.po %.mo : %.po
msgfmt -o $@ $< msgfmt -o $@ $<
modules: | env core/pe/_block.*.so : core/pe/modules/block.c core/pe/modules/common.c | env
$(VENV_PYTHON) build.py --modules ./env/bin/python hscommon/build_ext.py $^ _block
mv _block.*.so core/pe
mergepot: | env core/pe/_cache.*.so : core/pe/modules/cache.c core/pe/modules/common.c | env
$(VENV_PYTHON) build.py --mergepot ./env/bin/python hscommon/build_ext.py $^ _cache
mv _cache.*.so core/pe
normpo: | env qt/pe/_block_qt.*.so : qt/pe/modules/block.c | env
$(VENV_PYTHON) build.py --normpo ./env/bin/python hscommon/build_ext.py $^ _block_qt
mv _block_qt.*.so qt/pe
install: all pyc 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
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
cp -rf ${packages} locale ${DESTDIR}${PREFIX}/share/dupeguru cp -rf ${packages} locale build/help ${DESTDIR}${PREFIX}/share/dupeguru
cp -f run.py ${DESTDIR}${PREFIX}/share/dupeguru/run.py cp -f run.py ${DESTDIR}${PREFIX}/share/dupeguru/run.py
chmod 755 ${DESTDIR}${PREFIX}/share/dupeguru/run.py chmod 755 ${DESTDIR}${PREFIX}/share/dupeguru/run.py
mkdir -p ${DESTDIR}${PREFIX}/bin mkdir -p ${DESTDIR}${PREFIX}/bin
@ -105,19 +96,16 @@ install: all pyc
mkdir -p ${DESTDIR}${PREFIX}/share/pixmaps mkdir -p ${DESTDIR}${PREFIX}/share/pixmaps
cp -f images/dgse_logo_128.png ${DESTDIR}${PREFIX}/share/pixmaps/dupeguru.png cp -f images/dgse_logo_128.png ${DESTDIR}${PREFIX}/share/pixmaps/dupeguru.png
installdocs: build/help uninstall :
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
cp -rf build/help ${DESTDIR}${PREFIX}/share/dupeguru
uninstall:
rm -rf "${DESTDIR}${PREFIX}/share/dupeguru" rm -rf "${DESTDIR}${PREFIX}/share/dupeguru"
rm -f "${DESTDIR}${PREFIX}/bin/dupeguru" rm -f "${DESTDIR}${PREFIX}/bin/dupeguru"
rm -f "${DESTDIR}${PREFIX}/share/applications/dupeguru.desktop" rm -f "${DESTDIR}${PREFIX}/share/applications/dupeguru.desktop"
rm -f "${DESTDIR}${PREFIX}/share/pixmaps/dupeguru.png" rm -f "${DESTDIR}${PREFIX}/share/pixmaps/dupeguru.png"
clean: clean:
-rm run.py
-rm -rf build -rm -rf build
-rm locale/*/LC_MESSAGES/*.mo -rm locale/*/LC_MESSAGES/*.mo
-rm core/pe/*.$(SO) qt/pe/*.$(SO) -rm core/pe/*.so qt/pe/*.so
.PHONY: clean normpo mergepot modules i18n reqs run pyc install uninstall all .PHONY : clean srcpkg normpo mergepot modules i18n reqs run pyc install uninstall all

169
README.md
View File

@ -1,85 +1,154 @@
# dupeGuru # dupeGuru
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in [dupeGuru][dupeguru] is a cross-platform (Linux and OS X) GUI tool to find duplicate files in
a system. It is written mostly in Python 3 and uses [qt](https://www.qt.io/) for the UI. a system. It's 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.
## Current status ## Current status: People wanted
Still looking for additional help especially with regards to:
* OSX maintenance: reproducing bugs, packaging verification. dupeGuru has currently only one maintainer, me. This is a dangerous situation that needs to be
* Linux maintenance: reproducing bugs, maintaining PPA repository, Debian package, rpm package. corrected.
* Translations: updating missing strings, transifex project at https://www.transifex.com/voltaicideas/dupeguru-1
* Documentation: keeping it up-to-date. 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].
## Contents of this folder ## Contents of this folder
This folder contains the source for dupeGuru. Its documentation is in `help`, but is also 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 organized: [available online][documentation] in its built form. Here's how this source tree is organised:
* core: Contains the core logic code for dupeGuru. It's Python code. * 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. * qt: UI code for the Qt toolkit. It's written in Python and uses PyQt.
* images: Images used by the different UI codebases. * images: Images used by the different UI codebases.
* pkg: Skeleton files required to create different packages * pkg: Skeleton files required to create different packages
* help: Help document, written for Sphinx. * help: Help document, written for Sphinx.
* locale: .po files for localization. * 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:
* hscommon: A collection of helpers used across HS applications. * 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 ## How to build dupeGuru from source
### Windows & macOS specific additional instructions ### make
For windows instructions see the [Windows Instructions](Windows.md).
For macos instructions (qt version) see the [macOS Instructions](macos.md). If you're on linux, you can build the ap for local development with `make`:
### Prerequisites $ make
* [Python 3.7+][python] $ make run
* PyQt5
### System Setup The `Makefile` is a recent addition, however. You might have to fallback to the legacy build
When running in a linux based environment the following system packages or equivalents are needed to build: scripts.
* python3-pyqt5
* pyqt5-dev-tools (on some systems, see note)
* python3-venv (only if using a virtual environment)
* python3-dev
* build-essential
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. ### Legacy build
To create packages the following are also needed: If you're on OS X or that if the `make` method didn't work, you can build dupeGuru with the
* python3-setuptools legacy scripts.
* debhelper
### Building with Make There's a bootstrap script that will make building very easy. There might be some things that you
dupeGuru comes with a makefile that can be used to build and run: 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:
$ make && make run $ ./bootstrap.sh
### Building without Make and follow instructions from the script.
$ cd <dupeGuru directory> ### Prerequisites installation
$ python3 -m venv --system-site-packages ./env
$ source ./env/bin/activate 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
$ pip install -r requirements.txt $ 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 build.py
$ python run.py $ python run.py
### Generating Debian/Ubuntu package You can also package dupeGuru into an installable package with:
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 $ python package.py
This can be made a one-liner (once in the directory) as:
$ bash -c "python3 -m venv --system-site-packages env && source env/bin/activate && pip install -r requirements.txt -r requirements-extra.txt && python build.py --clean && python package.py" ### Generate Ubuntu packages
## Running tests $ bash -c "pyvenv --system-site-packages env && source env/bin/activate && pip install -r requirements.txt && python3 build.py --clean && python3 package.py"
The complete test suite is run with [Tox 1.7+][tox]. If you have it installed system-wide, you ### Running tests
The complete test suite is ran with [Tox 1.7+][tox]. If you have it installed system-wide, you
don't even need to set up a virtualenv. Just `cd` into the root project folder and run `tox`. don't even need to set up a virtualenv. Just `cd` into the root project folder and run `tox`.
If you don't have Tox system-wide, install it in your virtualenv with `pip install tox` and then If you don't have Tox system-wide, install it in your virtualenv with `pip install tox` and then
@ -89,9 +158,13 @@ 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 `requirements-extra.txt`. So, you can do `pip install -r requirements-extra.txt` inside your
virtualenv and then `py.test core hscommon` virtualenv and then `py.test core hscommon`
[dupeguru]: https://dupeguru.voltaicideas.net/ [dupeguru]: http://www.hardcoded.net/dupeguru/
[cross-toolkit]: http://www.hardcoded.net/articles/cross-toolkit-software [cross-toolkit]: http://www.hardcoded.net/articles/cross-toolkit-software
[documentation]: http://dupeguru.voltaicideas.net/help/en/ [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/
[python]: http://www.python.org/ [python]: http://www.python.org/
[pyqt]: http://www.riverbankcomputing.com [pyqt]: http://www.riverbankcomputing.com
[pyenv]: https://github.com/yyuu/pyenv
[tox]: https://tox.readthedocs.org/en/latest/ [tox]: https://tox.readthedocs.org/en/latest/

View File

@ -1,55 +0,0 @@
## How to build dupeGuru for Windows
### Prerequisites
- [Python 3.7+][python]
- [Visual Studio 2019][vs] or [Visual Studio Build Tools 2019][vsBuildTools] with the Windows 10 SDK
- [nsis][nsis] (for installer creation)
- [msys2][msys2] (for using makefile method)
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.7 vs 3.8 or 32 bit vs 64 bit specify that version instead of -3.8 to the `py` command below. If you want to build additional versions while keeping all virtual environments setup use a different location for each virtual environment.
$ cd <dupeGuru directory>
$ py -3.8 -m venv .\env
$ .\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 Executable file
View File

@ -0,0 +1,41 @@
#!/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"

409
build.py
View File

@ -1,165 +1,376 @@
# Copyright 2017 Virgil Dupras # Copyright 2016 Hardcoded Software (http://www.hardcoded.net)
# #
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from pathlib import Path
import sys import sys
import os
import os.path as op
from optparse import OptionParser from optparse import OptionParser
import shutil import shutil
from multiprocessing import Pool import compileall
from setuptools import setup, Extension
from setuptools import sandbox
from hscommon import sphinxgen from hscommon import sphinxgen
from hscommon.build import ( from hscommon.build import (
add_to_pythonpath, add_to_pythonpath, print_and_do, copy_packages, filereplace,
print_and_do, get_module_version, move_all, copy_all, OSXAppStructure,
fix_qt_resource_file, build_cocoalib_xibless, fix_qt_resource_file, build_cocoa_ext, copy_embeddable_python_dylib,
collect_stdlib_dependencies
) )
from hscommon import loc from hscommon import loc
from hscommon.plat import ISOSX
from hscommon.util import ensure_folder, delete_files_with_pattern
def parse_args(): def parse_args():
usage = "usage: %prog [options]" usage = "usage: %prog [options]"
parser = OptionParser(usage=usage) parser = OptionParser(usage=usage)
parser.add_option( parser.add_option(
"--clean", '--clean', action='store_true', dest='clean',
action="store_true", help="Clean build folder before building"
dest="clean",
help="Clean build folder before building",
)
parser.add_option("--doc", action="store_true", dest="doc", help="Build only the help file (en)")
parser.add_option("--alldoc", action="store_true", dest="all_doc", help="Build only the help file in all languages")
parser.add_option("--loc", action="store_true", dest="loc", help="Build only localization")
parser.add_option(
"--updatepot",
action="store_true",
dest="updatepot",
help="Generate .pot files from source code.",
) )
parser.add_option( parser.add_option(
"--mergepot", '--doc', action='store_true', dest='doc',
action="store_true", help="Build only the help file"
dest="mergepot",
help="Update all .po files based on .pot files.",
) )
parser.add_option( parser.add_option(
"--normpo", '--ui', dest='ui',
action="store_true", help="Type of UI to build. 'qt' or 'cocoa'. Default is determined by your system."
dest="normpo",
help="Normalize all PO files (do this before commit).",
) )
parser.add_option( parser.add_option(
"--modules", '--dev', action='store_true', dest='dev', default=False,
action="store_true", help="If this flag is set, will configure for dev builds."
dest="modules", )
help="Build the python modules.", parser.add_option(
'--loc', action='store_true', dest='loc',
help="Build only localization"
)
parser.add_option(
'--cocoa-ext', action='store_true', dest='cocoa_ext',
help="Build only Cocoa extensions"
)
parser.add_option(
'--cocoa-compile', action='store_true', dest='cocoa_compile',
help="Build only Cocoa executable"
)
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)."
) )
(options, args) = parser.parse_args() (options, args) = parser.parse_args()
return options return options
def cocoa_app():
app_path = 'build/dupeGuru.app'
return OSXAppStructure(app_path)
def build_one_help(language): def build_xibless(dest='cocoa/autogen'):
print(f"Generating Help in {language}") import xibless
current_path = Path(".").absolute() ensure_folder(dest)
changelog_path = current_path.joinpath("help", "changelog") FNPAIRS = [
tixurl = "https://github.com/arsenetar/dupeguru/issues/{}" ('ignore_list_dialog.py', 'IgnoreListDialog_UI'),
changelogtmpl = current_path.joinpath("help", "changelog.tmpl") ('deletion_options.py', 'DeletionOptions_UI'),
conftmpl = current_path.joinpath("help", "conf.tmpl") ('problem_dialog.py', 'ProblemDialog_UI'),
help_basepath = current_path.joinpath("help", language) ('directory_panel.py', 'DirectoryPanel_UI'),
help_destpath = current_path.joinpath("build", "help", language) ('prioritize_dialog.py', 'PrioritizeDialog_UI'),
confrepl = {"language": language} ('result_window.py', 'ResultWindow_UI'),
sphinxgen.gen( ('main_menu.py', 'MainMenu_UI'),
help_basepath, ('details_panel.py', 'DetailsPanel_UI'),
help_destpath, ('details_panel_picture.py', 'DetailsPanelPicture_UI'),
changelog_path, ]
tixurl, for srcname, dstname in FNPAIRS:
confrepl, xibless.generate(
conftmpl, op.join('cocoa', 'ui', srcname), op.join(dest, dstname),
changelogtmpl, 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):
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')
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(): def build_help():
languages = ["en", "de", "fr", "hy", "ru", "uk"] print("Generating Help")
# Running with Pools as for some reason sphinx seems to cross contaminate the output otherwise current_path = op.abspath('.')
with Pool(len(languages)) as p: help_basepath = op.join(current_path, 'help', 'en')
p.map(build_one_help, languages) 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)
def build_qt_localizations():
loc.compile_all_po(op.join('qtlib', 'locale'))
loc.merge_locale_dir(op.join('qtlib', 'locale'), 'locale')
def build_localizations(): def build_localizations(ui):
loc.compile_all_po("locale") loc.compile_all_po('locale')
locale_dest = Path("build", "locale") if ui == 'cocoa':
if locale_dest.exists(): 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':
build_qt_localizations()
locale_dest = op.join('build', 'locale')
if op.exists(locale_dest):
shutil.rmtree(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(): 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 .pot files from source files")
print("Building core.pot") print("Building core.pot")
loc.generate_pot(["core"], Path("locale", "core.pot"), ["tr"]) loc.generate_pot(['core'], op.join('locale', 'core.pot'), ['tr'])
print("Building columns.pot") print("Building columns.pot")
loc.generate_pot(["core"], Path("locale", "columns.pot"), ["coltr"]) loc.generate_pot(['core'], op.join('locale', 'columns.pot'), ['coltr'])
print("Building ui.pot") print("Building ui.pot")
loc.generate_pot(["qt"], Path("locale", "ui.pot"), ["tr"], merge=True) # 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))
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')
)
def build_mergepot(): def build_mergepot():
print("Updating .po files using .pot files") print("Updating .po files using .pot files")
loc.merge_pots_into_pos("locale") loc.merge_pots_into_pos('locale')
loc.merge_pots_into_pos(op.join('qtlib', 'locale'))
loc.merge_pots_into_pos(op.join('cocoalib', 'locale'))
def build_normpo(): def build_normpo():
loc.normalize_all_pos("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_pe_modules(): 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_pe_modules(ui):
print("Building PE Modules") print("Building PE Modules")
# Leverage setup.py to build modules exts = [
sandbox.run_setup("setup.py", ["build_ext", "--inplace"]) Extension(
"_block",
[op.join('core', 'pe', 'modules', 'block.c'), op.join('core', 'pe', 'modules', 'common.c')]
),
Extension(
"_cache",
[op.join('core', 'pe', 'modules', 'cache.c'), op.join('core', 'pe', 'modules', 'common.c')]
),
]
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",
]
))
setup(
script_args=['build_ext', '--inplace'],
ext_modules=exts,
)
move_all('_block_qt*', op.join('qt', 'pe'))
move_all('_block*', op.join('core', 'pe'))
move_all('_cache*', op.join('core', 'pe'))
def build_normal(ui, dev):
def build_normal(): print("Building dupeGuru with UI {}".format(ui))
print("Building dupeGuru with UI qt") add_to_pythonpath('.')
add_to_pythonpath(".")
print("Building dupeGuru") print("Building dupeGuru")
build_pe_modules() build_pe_modules(ui)
print("Building localizations") if ui == 'cocoa':
build_localizations() build_cocoa(dev)
print("Building Qt stuff") elif ui == 'qt':
Path("qt", "dg_rc.py").unlink(missing_ok=True) build_qt(dev)
print_and_do("pyrcc5 {} > {}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py")))
fix_qt_resource_file(Path("qt", "dg_rc.py"))
build_help()
def main(): def main():
if sys.version_info < (3, 7):
sys.exit("Python < 3.7 is unsupported.")
options = parse_args() options = parse_args()
if options.clean and Path("build").exists(): ui = options.ui
shutil.rmtree("build") if ui not in ('cocoa', 'qt'):
if not Path("build").exists(): ui = 'cocoa' if ISOSX else 'qt'
Path("build").mkdir() 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 options.doc: if options.doc:
build_one_help("en")
elif options.all_doc:
build_help() build_help()
elif options.loc: elif options.loc:
build_localizations() build_localizations(ui)
elif options.updatepot: elif options.updatepot:
build_updatepot() build_updatepot()
elif options.mergepot: elif options.mergepot:
build_mergepot() build_mergepot()
elif options.normpo: elif options.normpo:
build_normpo() build_normpo()
elif options.modules: elif options.cocoa_ext:
build_pe_modules() 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()
else: else:
build_normal() build_normal(ui, options.dev)
if __name__ == '__main__':
if __name__ == "__main__":
main() main()

79
cocoa/AppDelegate.h Normal file
View File

@ -0,0 +1,79 @@
/*
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 "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;
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;
/* 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

394
cocoa/AppDelegate.m Normal file
View File

@ -0,0 +1,394 @@
/*
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;
+ (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)];
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];
_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

24
cocoa/Consts.h Normal file
View File

@ -0,0 +1,24 @@
/*
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

33
cocoa/DeletionOptions.h Normal file
View File

@ -0,0 +1,33 @@
/*
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

72
cocoa/DeletionOptions.m Normal file
View File

@ -0,0 +1,72 @@
/*
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

31
cocoa/DetailsPanel.h Normal file
View File

@ -0,0 +1,31 @@
/*
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

81
cocoa/DetailsPanel.m Normal file
View File

@ -0,0 +1,81 @@
/*
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

View File

@ -0,0 +1,32 @@
/*
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

View File

@ -0,0 +1,96 @@
/*
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

21
cocoa/DirectoryOutline.h Normal file
View File

@ -0,0 +1,21 @@
/*
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;

87
cocoa/DirectoryOutline.m Normal file
View File

@ -0,0 +1,87 @@
/*
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

57
cocoa/DirectoryPanel.h Normal file
View File

@ -0,0 +1,57 @@
/*
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

256
cocoa/DirectoryPanel.m Normal file
View File

@ -0,0 +1,256 @@
/*
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

25
cocoa/IgnoreListDialog.h Normal file
View File

@ -0,0 +1,25 @@
/*
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

51
cocoa/IgnoreListDialog.m Normal file
View File

@ -0,0 +1,51 @@
/*
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

38
cocoa/InfoTemplate.plist Normal file
View File

@ -0,0 +1,38 @@
<?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>

37
cocoa/PrioritizeDialog.h Normal file
View File

@ -0,0 +1,37 @@
/*
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;

56
cocoa/PrioritizeDialog.m Normal file
View File

@ -0,0 +1,56 @@
/*
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

16
cocoa/PrioritizeList.h Normal file
View File

@ -0,0 +1,16 @@
/*
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

58
cocoa/PrioritizeList.m Normal file
View File

@ -0,0 +1,58 @@
/*
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

26
cocoa/ProblemDialog.h Normal file
View File

@ -0,0 +1,26 @@
/*
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

44
cocoa/ProblemDialog.m Normal file
View File

@ -0,0 +1,44 @@
/*
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

23
cocoa/ResultTable.h Normal file
View File

@ -0,0 +1,23 @@
/*
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;

180
cocoa/ResultTable.m Normal file
View File

@ -0,0 +1,180 @@
/*
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

76
cocoa/ResultWindow.h Normal file
View File

@ -0,0 +1,76 @@
/*
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

406
cocoa/ResultWindow.m Normal file
View File

@ -0,0 +1,406 @@
/*
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

17
cocoa/StatsLabel.h Normal file
View File

@ -0,0 +1,17 @@
/*
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

34
cocoa/StatsLabel.m Normal file
View File

@ -0,0 +1,34 @@
/*
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

17
cocoa/dg_cocoa.py Normal file
View File

@ -0,0 +1,17 @@
# 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

20
cocoa/dsa_pub.pem Normal file
View File

@ -0,0 +1,20 @@
-----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-----

View File

@ -0,0 +1,140 @@
"%@ 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";

10
cocoa/inter/all.py Normal file
View File

@ -0,0 +1,10 @@
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

252
cocoa/inter/app.py Normal file
View File

@ -0,0 +1,252 @@
import logging
from objp.util import pyref, dontwrap
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):
PICTURE_CACHE_TYPE = 'shelve'
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)

View File

@ -0,0 +1,37 @@
# 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)

View File

@ -0,0 +1,11 @@
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)]

View File

@ -0,0 +1,53 @@
# 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))]

View File

@ -0,0 +1,21 @@
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()

View File

@ -0,0 +1,21 @@
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()

35
cocoa/inter/photo.py Normal file
View File

@ -0,0 +1,35 @@
# 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 ''

View File

@ -0,0 +1,29 @@
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()

View File

@ -0,0 +1,8 @@
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)

View File

@ -0,0 +1,9 @@
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()

View File

@ -0,0 +1,50 @@
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()

View File

@ -0,0 +1,9 @@
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 Normal file
View File

@ -0,0 +1,49 @@
/*
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;
}

10
cocoa/run_template.py Normal file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env python3
import sys
import os
def main():
return os.system('open "{{app_path}}"')
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,49 @@
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

32
cocoa/ui/details_panel.py Normal file
View File

@ -0,0 +1,32 @@
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)

View File

@ -0,0 +1,70 @@
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)

View File

@ -0,0 +1,76 @@
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)

View File

@ -0,0 +1,30 @@
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)

77
cocoa/ui/main_menu.py Normal file
View File

@ -0,0 +1,77 @@
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.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'))

View File

@ -0,0 +1,173 @@
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)

View File

@ -0,0 +1,65 @@
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)

View File

@ -0,0 +1,35 @@
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)

97
cocoa/ui/result_window.py Normal file
View File

@ -0,0 +1,97 @@
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)

169
cocoa/waf vendored Executable file

File diff suppressed because one or more lines are too long

71
cocoa/wscript Normal file
View File

@ -0,0 +1,71 @@
#!/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'
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 = ['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 Submodule

@ -0,0 +1 @@
Subproject commit d059aa9b7910f76174090ccd449fe6ab92bb43f0

View File

@ -1,17 +0,0 @@
const Configuration = {
/*
* Resolve and load @commitlint/config-conventional from node_modules.
* Referenced packages must be installed
*/
extends: ['@commitlint/config-conventional'],
/*
* Any rules defined here will override rules from @commitlint/config-conventional
*/
rules: {
'header-max-length': [2, 'always', 72],
'subject-case': [2, 'always', 'sentence-case'],
'scope-enum': [2, 'always'],
},
};
module.exports = Configuration;

View File

@ -1,2 +1,3 @@
__version__ = "4.3.1" __version__ = '4.0.3'
__appname__ = "dupeGuru" __appname__ = 'dupeGuru'

View File

@ -4,42 +4,38 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
import cProfile
import datetime
import os import os
import os.path as op import os.path as op
import logging import logging
import subprocess import subprocess
import re import re
import shutil import shutil
from pathlib import Path
from send2trash import send2trash from send2trash import send2trash
from hscommon.jobprogress import job from hscommon.jobprogress import job
from hscommon.notify import Broadcaster from hscommon.notify import Broadcaster
from hscommon.path import Path
from hscommon.conflict import smart_move, smart_copy from hscommon.conflict import smart_move, smart_copy
from hscommon.gui.progress_window import ProgressWindow from hscommon.gui.progress_window import ProgressWindow
from hscommon.util import delete_if_empty, first, escape, nonone, allsame from hscommon.util import delete_if_empty, first, escape, nonone, allsame
from hscommon.trans import tr from hscommon.trans import tr
from hscommon import desktop from hscommon import desktop
from core import se, me, pe from . import se, me, pe
from core.pe.photo import get_delta_dimensions from .pe.photo import get_delta_dimensions
from core.util import cmp_value, fix_surrogate_encoding from .util import cmp_value, fix_surrogate_encoding
from core import directories, results, export, fs, prioritize from . import directories, results, export, fs, prioritize
from core.ignore import IgnoreList from .ignore import IgnoreList
from core.exclude import ExcludeDict as ExcludeList from .scanner import ScanType
from core.scanner import ScanType from .gui.deletion_options import DeletionOptions
from core.gui.deletion_options import DeletionOptions from .gui.details_panel import DetailsPanel
from core.gui.details_panel import DetailsPanel from .gui.directory_tree import DirectoryTree
from core.gui.directory_tree import DirectoryTree from .gui.ignore_list_dialog import IgnoreListDialog
from core.gui.ignore_list_dialog import IgnoreListDialog from .gui.problem_dialog import ProblemDialog
from core.gui.exclude_list_dialog import ExcludeListDialogCore from .gui.stats_label import StatsLabel
from core.gui.problem_dialog import ProblemDialog
from core.gui.stats_label import StatsLabel
HAD_FIRST_LAUNCH_PREFERENCE = "HadFirstLaunch" HAD_FIRST_LAUNCH_PREFERENCE = 'HadFirstLaunch'
DEBUG_MODE_PREFERENCE = "DebugMode" DEBUG_MODE_PREFERENCE = 'DebugMode'
MSG_NO_MARKED_DUPES = tr("There are no marked duplicates. Nothing has been done.") 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.") MSG_NO_SELECTED_DUPES = tr("There are no selected duplicates. Nothing has been done.")
@ -48,36 +44,31 @@ MSG_MANY_FILES_TO_OPEN = tr(
"files are opened with, doing so can create quite a mess. Continue?" "files are opened with, doing so can create quite a mess. Continue?"
) )
class DestType: class DestType:
DIRECT = 0 Direct = 0
RELATIVE = 1 Relative = 1
ABSOLUTE = 2 Absolute = 2
class JobType: class JobType:
SCAN = "job_scan" Scan = 'job_scan'
LOAD = "job_load" Load = 'job_load'
MOVE = "job_move" Move = 'job_move'
COPY = "job_copy" Copy = 'job_copy'
DELETE = "job_delete" Delete = 'job_delete'
class AppMode: class AppMode:
STANDARD = 0 Standard = 0
MUSIC = 1 Music = 1
PICTURE = 2 Picture = 2
JOBID2TITLE = { JOBID2TITLE = {
JobType.SCAN: tr("Scanning for duplicates"), JobType.Scan: tr("Scanning for duplicates"),
JobType.LOAD: tr("Loading"), JobType.Load: tr("Loading"),
JobType.MOVE: tr("Moving"), JobType.Move: tr("Moving"),
JobType.COPY: tr("Copying"), JobType.Copy: tr("Copying"),
JobType.DELETE: tr("Sending to Trash"), JobType.Delete: tr("Sending to Trash"),
} }
class DupeGuru(Broadcaster): class DupeGuru(Broadcaster):
"""Holds everything together. """Holds everything together.
@ -109,8 +100,7 @@ class DupeGuru(Broadcaster):
Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results` Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results`
""" """
#--- View interface
# --- View interface
# get_default(key_name) # get_default(key_name)
# set_default(key_name, value) # set_default(key_name, value)
# show_message(msg) # show_message(msg)
@ -126,40 +116,37 @@ class DupeGuru(Broadcaster):
NAME = PROMPT_NAME = "dupeGuru" NAME = PROMPT_NAME = "dupeGuru"
def __init__(self, view, portable=False): PICTURE_CACHE_TYPE = 'sqlite' # set to 'shelve' for a ShelveCache
def __init__(self, view):
if view.get_default(DEBUG_MODE_PREFERENCE): if view.get_default(DEBUG_MODE_PREFERENCE):
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
logging.debug("Debug mode enabled") logging.debug("Debug mode enabled")
Broadcaster.__init__(self) Broadcaster.__init__(self)
self.view = view self.view = view
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.APPDATA, portable=portable) self.appdata = desktop.special_folder_path(desktop.SpecialFolder.AppData, appname=self.NAME)
if not op.exists(self.appdata): if not op.exists(self.appdata):
os.makedirs(self.appdata) os.makedirs(self.appdata)
self.app_mode = AppMode.STANDARD self.app_mode = AppMode.Standard
self.discarded_file_count = 0 self.discarded_file_count = 0
self.exclude_list = ExcludeList() self.directories = directories.Directories()
hash_cache_file = op.join(self.appdata, "hash_cache.db")
fs.filesdb.connect(hash_cache_file)
self.directories = directories.Directories(self.exclude_list)
self.results = results.Results(self) self.results = results.Results(self)
self.ignore_list = IgnoreList() self.ignore_list = IgnoreList()
# In addition to "app-level" options, this dictionary also holds options that will be # In addition to "app-level" options, this dictionary also holds options that will be
# sent to the scanner. They don't have default values because those defaults values are # sent to the scanner. They don't have default values because those defaults values are
# defined in the scanner class. # defined in the scanner class.
self.options = { self.options = {
"escape_filter_regexp": True, 'escape_filter_regexp': True,
"clean_empty_dirs": False, 'clean_empty_dirs': False,
"ignore_hardlink_matches": False, 'ignore_hardlink_matches': False,
"copymove_dest_type": DestType.RELATIVE, 'copymove_dest_type': DestType.Relative,
"include_exists_check": True, 'picture_cache_type': self.PICTURE_CACHE_TYPE
"rehash_ignore_mtime": False,
} }
self.selected_dupes = [] self.selected_dupes = []
self.details_panel = DetailsPanel(self) self.details_panel = DetailsPanel(self)
self.directory_tree = DirectoryTree(self) self.directory_tree = DirectoryTree(self)
self.problem_dialog = ProblemDialog(self) self.problem_dialog = ProblemDialog(self)
self.ignore_list_dialog = IgnoreListDialog(self) self.ignore_list_dialog = IgnoreListDialog(self)
self.exclude_list_dialog = ExcludeListDialogCore(self)
self.stats_label = StatsLabel(self) self.stats_label = StatsLabel(self)
self.result_table = None self.result_table = None
self.deletion_options = DeletionOptions() self.deletion_options = DeletionOptions()
@ -168,13 +155,13 @@ class DupeGuru(Broadcaster):
for child in children: for child in children:
child.connect() child.connect()
# --- Private #--- Private
def _recreate_result_table(self): def _recreate_result_table(self):
if self.result_table is not None: if self.result_table is not None:
self.result_table.disconnect() self.result_table.disconnect()
if self.app_mode == AppMode.PICTURE: if self.app_mode == AppMode.Picture:
self.result_table = pe.result_table.ResultTable(self) self.result_table = pe.result_table.ResultTable(self)
elif self.app_mode == AppMode.MUSIC: elif self.app_mode == AppMode.Music:
self.result_table = me.result_table.ResultTable(self) self.result_table = me.result_table.ResultTable(self)
else: else:
self.result_table = se.result_table.ResultTable(self) self.result_table = se.result_table.ResultTable(self)
@ -182,23 +169,26 @@ class DupeGuru(Broadcaster):
self.view.create_results_window() self.view.create_results_window()
def _get_picture_cache_path(self): def _get_picture_cache_path(self):
cache_name = "cached_pictures.db" 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) return op.join(self.appdata, cache_name)
def _get_dupe_sort_key(self, dupe, get_group, key, delta): def _get_dupe_sort_key(self, dupe, get_group, key, delta):
if self.app_mode in (AppMode.MUSIC, AppMode.PICTURE) and key == "folder_path": if self.app_mode in (AppMode.Music, AppMode.Picture):
dupe_folder_path = getattr(dupe, "display_folder_path", dupe.folder_path) if key == 'folder_path':
return str(dupe_folder_path).lower() dupe_folder_path = getattr(dupe, 'display_folder_path', dupe.folder_path)
if self.app_mode == AppMode.PICTURE and delta and key == "dimensions": return str(dupe_folder_path).lower()
r = cmp_value(dupe, key) if self.app_mode == AppMode.Picture:
ref_value = cmp_value(get_group().ref, key) if delta and key == 'dimensions':
return get_delta_dimensions(r, ref_value) r = cmp_value(dupe, key)
if key == "marked": ref_value = cmp_value(get_group().ref, key)
return get_delta_dimensions(r, ref_value)
if key == 'marked':
return self.results.is_marked(dupe) return self.results.is_marked(dupe)
if key == "percentage": if key == 'percentage':
m = get_group().get_match_of(dupe) m = get_group().get_match_of(dupe)
return m.percentage return m.percentage
elif key == "dupe_count": elif key == 'dupe_count':
return 0 return 0
else: else:
result = cmp_value(dupe, key) result = cmp_value(dupe, key)
@ -212,14 +202,15 @@ class DupeGuru(Broadcaster):
return result return result
def _get_group_sort_key(self, group, key): def _get_group_sort_key(self, group, key):
if self.app_mode in (AppMode.MUSIC, AppMode.PICTURE) and key == "folder_path": if self.app_mode in (AppMode.Music, AppMode.Picture):
dupe_folder_path = getattr(group.ref, "display_folder_path", group.ref.folder_path) if key == 'folder_path':
return str(dupe_folder_path).lower() dupe_folder_path = getattr(group.ref, 'display_folder_path', group.ref.folder_path)
if key == "percentage": return str(dupe_folder_path).lower()
if key == 'percentage':
return group.percentage return group.percentage
if key == "dupe_count": if key == 'dupe_count':
return len(group) return len(group)
if key == "marked": if key == 'marked':
return len([dupe for dupe in group.dupes if self.results.is_marked(dupe)]) return len([dupe for dupe in group.dupes if self.results.is_marked(dupe)])
return cmp_value(group.ref, key) return cmp_value(group.ref, key)
@ -242,17 +233,17 @@ class DupeGuru(Broadcaster):
else: else:
os.remove(str_path) os.remove(str_path)
else: else:
send2trash(str_path) # Raises OSError when there's a problem send2trash(str_path) # Raises OSError when there's a problem
if link_deleted: if link_deleted:
group = self.results.get_group_of_duplicate(dupe) group = self.results.get_group_of_duplicate(dupe)
ref = group.ref ref = group.ref
linkfunc = os.link if use_hardlinks else os.symlink linkfunc = os.link if use_hardlinks else os.symlink
linkfunc(str(ref.path), str_path) linkfunc(str(ref.path), str_path)
self.clean_empty_dirs(dupe.path.parent) self.clean_empty_dirs(dupe.path.parent())
def _create_file(self, path): def _create_file(self, path):
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths. # We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
return fs.get_file(path, self.fileclasses + [se.fs.Folder]) return fs.get_file(path, self.fileclasses + [fs.Folder])
def _get_file(self, str_path): def _get_file(self, str_path):
path = Path(str_path) path = Path(str_path)
@ -262,11 +253,14 @@ class DupeGuru(Broadcaster):
try: try:
f._read_all_info(attrnames=self.METADATA_TO_READ) f._read_all_info(attrnames=self.METADATA_TO_READ)
return f return f
except OSError: except EnvironmentError:
return None return None
def _get_export_data(self): def _get_export_data(self):
columns = [col for col in self.result_table._columns.ordered_columns if col.visible and col.name != "marked"] columns = [
col for col in self.result_table.columns.ordered_columns
if col.visible and col.name != 'marked'
]
colnames = [col.display for col in columns] colnames = [col.display for col in columns]
rows = [] rows = []
for group_id, group in enumerate(self.results.groups): for group_id, group in enumerate(self.results.groups):
@ -278,8 +272,11 @@ class DupeGuru(Broadcaster):
return colnames, rows return colnames, rows
def _results_changed(self): def _results_changed(self):
self.selected_dupes = [d for d in self.selected_dupes if self.results.get_group_of_duplicate(d) is not None] self.selected_dupes = [
self.notify("results_changed") d for d in self.selected_dupes
if self.results.get_group_of_duplicate(d) is not None
]
self.notify('results_changed')
def _start_job(self, jobid, func, args=()): def _start_job(self, jobid, func, args=()):
title = JOBID2TITLE[jobid] title = JOBID2TITLE[jobid]
@ -293,36 +290,32 @@ class DupeGuru(Broadcaster):
self.view.show_message(msg) self.view.show_message(msg)
def _job_completed(self, jobid): def _job_completed(self, jobid):
if jobid == JobType.SCAN: if jobid == JobType.Scan:
self._results_changed() self._results_changed()
fs.filesdb.commit()
if not self.results.groups: if not self.results.groups:
self.view.show_message(tr("No duplicates found.")) self.view.show_message(tr("No duplicates found."))
else: else:
self.view.show_results_window() self.view.show_results_window()
if jobid in {JobType.MOVE, JobType.DELETE}: if jobid in {JobType.Move, JobType.Delete}:
self._results_changed() self._results_changed()
if jobid == JobType.LOAD: if jobid == JobType.Load:
self._recreate_result_table() self._recreate_result_table()
self._results_changed() self._results_changed()
self.view.show_results_window() self.view.show_results_window()
if jobid in {JobType.COPY, JobType.MOVE, JobType.DELETE}: if jobid in {JobType.Copy, JobType.Move, JobType.Delete}:
if self.results.problems: if self.results.problems:
self.problem_dialog.refresh() self.problem_dialog.refresh()
self.view.show_problem_dialog() self.view.show_problem_dialog()
else: else:
if jobid == JobType.COPY: msg = {
msg = tr("All marked files were copied successfully.") JobType.Copy: tr("All marked files were copied successfully."),
elif jobid == JobType.MOVE: JobType.Move: tr("All marked files were moved successfully."),
msg = tr("All marked files were moved successfully.") JobType.Delete: tr("All marked files were successfully sent to Trash."),
elif jobid == JobType.DELETE and self.deletion_options.direct: }[jobid]
msg = tr("All marked files were deleted successfully.")
else:
msg = tr("All marked files were successfully sent to Trash.")
self.view.show_message(msg) self.view.show_message(msg)
def _job_error(self, jobid, err): def _job_error(self, jobid, err):
if jobid == JobType.LOAD: if jobid == JobType.Load:
msg = tr("Could not load file: {}").format(err) msg = tr("Could not load file: {}").format(err)
self.view.show_message(msg) self.view.show_message(msg)
return False return False
@ -348,26 +341,26 @@ class DupeGuru(Broadcaster):
if dupes == self.selected_dupes: if dupes == self.selected_dupes:
return return
self.selected_dupes = dupes self.selected_dupes = dupes
self.notify("dupes_selected") self.notify('dupes_selected')
# --- Protected #--- Protected
def _get_fileclasses(self): def _get_fileclasses(self):
if self.app_mode == AppMode.PICTURE: if self.app_mode == AppMode.Picture:
return [pe.photo.PLAT_SPECIFIC_PHOTO_CLASS] return [pe.photo.PLAT_SPECIFIC_PHOTO_CLASS]
elif self.app_mode == AppMode.MUSIC: elif self.app_mode == AppMode.Music:
return [me.fs.MusicFile] return [me.fs.MusicFile]
else: else:
return [se.fs.File] return [se.fs.File]
def _prioritization_categories(self): def _prioritization_categories(self):
if self.app_mode == AppMode.PICTURE: if self.app_mode == AppMode.Picture:
return pe.prioritize.all_categories() return pe.prioritize.all_categories()
elif self.app_mode == AppMode.MUSIC: elif self.app_mode == AppMode.Music:
return me.prioritize.all_categories() return me.prioritize.all_categories()
else: else:
return prioritize.all_categories() return prioritize.all_categories()
# --- Public #--- Public
def add_directory(self, d): def add_directory(self, d):
"""Adds folder ``d`` to :attr:`directories`. """Adds folder ``d`` to :attr:`directories`.
@ -377,14 +370,15 @@ class DupeGuru(Broadcaster):
""" """
try: try:
self.directories.add_path(Path(d)) self.directories.add_path(Path(d))
self.notify("directories_changed") self.notify('directories_changed')
except directories.AlreadyThereError: except directories.AlreadyThereError:
self.view.show_message(tr("'{}' already is in the list.").format(d)) self.view.show_message(tr("'{}' already is in the list.").format(d))
except directories.InvalidPathError: except directories.InvalidPathError:
self.view.show_message(tr("'{}' does not exist.").format(d)) self.view.show_message(tr("'{}' does not exist.").format(d))
def add_selected_to_ignore_list(self): def add_selected_to_ignore_list(self):
"""Adds :attr:`selected_dupes` to :attr:`ignore_list`.""" """Adds :attr:`selected_dupes` to :attr:`ignore_list`.
"""
dupes = self.without_ref(self.selected_dupes) dupes = self.without_ref(self.selected_dupes)
if not dupes: if not dupes:
self.view.show_message(MSG_NO_SELECTED_DUPES) self.view.show_message(MSG_NO_SELECTED_DUPES)
@ -396,64 +390,60 @@ class DupeGuru(Broadcaster):
g = self.results.get_group_of_duplicate(dupe) g = self.results.get_group_of_duplicate(dupe)
for other in g: for other in g:
if other is not dupe: if other is not dupe:
self.ignore_list.ignore(str(other.path), str(dupe.path)) self.ignore_list.Ignore(str(other.path), str(dupe.path))
self.remove_duplicates(dupes) self.remove_duplicates(dupes)
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
def apply_filter(self, result_filter): def apply_filter(self, filter):
"""Apply a filter ``filter`` to the results so that it shows only dupe groups that match it. """Apply a filter ``filter`` to the results so that it shows only dupe groups that match it.
:param str filter: filter to apply :param str filter: filter to apply
""" """
self.results.apply_filter(None) self.results.apply_filter(None)
if self.options["escape_filter_regexp"]: if self.options['escape_filter_regexp']:
result_filter = escape(result_filter, set("()[]\\.|+?^")) filter = escape(filter, set('()[]\\.|+?^'))
result_filter = escape(result_filter, "*", ".") filter = escape(filter, '*', '.')
self.results.apply_filter(result_filter) self.results.apply_filter(filter)
self._results_changed() self._results_changed()
def clean_empty_dirs(self, path): def clean_empty_dirs(self, path):
if self.options["clean_empty_dirs"]: if self.options['clean_empty_dirs']:
while delete_if_empty(path, [".DS_Store"]): while delete_if_empty(path, ['.DS_Store']):
path = path.parent path = path.parent()
def clear_picture_cache(self): def clear_picture_cache(self):
try: try:
os.remove(self._get_picture_cache_path()) os.remove(self._get_picture_cache_path())
except FileNotFoundError: except FileNotFoundError:
pass # we don't care pass # we don't care
def clear_hash_cache(self):
fs.filesdb.clear()
def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType): def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType):
source_path = dupe.path source_path = dupe.path
location_path = first(p for p in self.directories if p in dupe.path.parents) location_path = first(p for p in self.directories if dupe.path in p)
dest_path = Path(destination) dest_path = Path(destination)
if dest_type in {DestType.RELATIVE, DestType.ABSOLUTE}: if dest_type in {DestType.Relative, DestType.Absolute}:
# no filename, no windows drive letter # no filename, no windows drive letter
source_base = source_path.relative_to(source_path.anchor).parent source_base = source_path.remove_drive_letter().parent()
if dest_type == DestType.RELATIVE: if dest_type == DestType.Relative:
source_base = source_base.relative_to(location_path.relative_to(location_path.anchor)) source_base = source_base[location_path:]
dest_path = dest_path.joinpath(source_base) dest_path = dest_path[source_base]
if not dest_path.exists(): if not dest_path.exists():
dest_path.mkdir(parents=True) dest_path.makedirs()
# Add filename to dest_path. For file move/copy, it's not required, but for folders, yes. # Add filename to dest_path. For file move/copy, it's not required, but for folders, yes.
dest_path = dest_path.joinpath(source_path.name) dest_path = dest_path[source_path.name]
logging.debug("Copy/Move operation from '%s' to '%s'", source_path, dest_path) logging.debug("Copy/Move operation from '%s' to '%s'", source_path, dest_path)
# Raises an EnvironmentError if there's a problem # Raises an EnvironmentError if there's a problem
if copy: if copy:
smart_copy(source_path, dest_path) smart_copy(source_path, dest_path)
else: else:
smart_move(source_path, dest_path) smart_move(source_path, dest_path)
self.clean_empty_dirs(source_path.parent) self.clean_empty_dirs(source_path.parent())
def copy_or_move_marked(self, copy): def copy_or_move_marked(self, copy):
"""Start an async move (or copy) job on marked duplicates. """Start an async move (or copy) job on marked duplicates.
:param bool copy: If True, duplicates will be copied instead of moved :param bool copy: If True, duplicates will be copied instead of moved
""" """
def do(j): def do(j):
def op(dupe): def op(dupe):
j.add_progress() j.add_progress()
@ -465,30 +455,28 @@ class DupeGuru(Broadcaster):
if not self.results.mark_count: if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES) self.view.show_message(MSG_NO_MARKED_DUPES)
return return
destination = self.view.select_dest_folder( opname = tr("copy") if copy else tr("move")
tr("Select a directory to copy marked files to") prompt = tr("Select a directory to {} marked files to").format(opname)
if copy destination = self.view.select_dest_folder(prompt)
else tr("Select a directory to move marked files to")
)
if destination: if destination:
desttype = self.options["copymove_dest_type"] desttype = self.options['copymove_dest_type']
jobid = JobType.COPY if copy else JobType.MOVE jobid = JobType.Copy if copy else JobType.Move
self._start_job(jobid, do) self._start_job(jobid, do)
def delete_marked(self): def delete_marked(self):
"""Start an async job to send marked duplicates to the trash.""" """Start an async job to send marked duplicates to the trash.
"""
if not self.results.mark_count: if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES) self.view.show_message(MSG_NO_MARKED_DUPES)
return return
if not self.deletion_options.show(self.results.mark_count): if not self.deletion_options.show(self.results.mark_count):
return return
args = [ args = [
self.deletion_options.link_deleted, self.deletion_options.link_deleted, self.deletion_options.use_hardlinks,
self.deletion_options.use_hardlinks, self.deletion_options.direct
self.deletion_options.direct,
] ]
logging.debug("Starting deletion job with args %r", args) logging.debug("Starting deletion job with args %r", args)
self._start_job(JobType.DELETE, self._do_delete, args=args) self._start_job(JobType.Delete, self._do_delete, args=args)
def export_to_xhtml(self): def export_to_xhtml(self):
"""Export current results to XHTML. """Export current results to XHTML.
@ -507,7 +495,7 @@ class DupeGuru(Broadcaster):
The columns and their order in the resulting CSV file is determined in the same way as in The columns and their order in the resulting CSV file is determined in the same way as in
:meth:`export_to_xhtml`. :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: if dest_file:
colnames, rows = self._get_export_data() colnames, rows = self._get_export_data()
try: try:
@ -517,14 +505,13 @@ class DupeGuru(Broadcaster):
def get_display_info(self, dupe, group, delta=False): def get_display_info(self, dupe, group, delta=False):
def empty_data(): 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): if (dupe is None) or (group is None):
return empty_data() return empty_data()
try: try:
return dupe.get_display_info(group, delta) return dupe.get_display_info(group, delta)
except Exception as e: except Exception as e:
logging.warning("Exception (type: %s) on GetDisplayInfo for %s: %s", type(e), str(dupe.path), str(e)) logging.warning("Exception on GetDisplayInfo for %s: %s", str(dupe.path), str(e))
return empty_data() return empty_data()
def invoke_custom_command(self): def invoke_custom_command(self):
@ -534,34 +521,28 @@ class DupeGuru(Broadcaster):
is replaced with that dupe's ref file. If there's no selection, the command is not invoked. 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. 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: 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) self.view.show_message(msg)
return return
if not self.selected_dupes: if not self.selected_dupes:
return return
dupes = self.selected_dupes dupe = self.selected_dupes[0]
refs = [self.results.get_group_of_duplicate(dupe).ref for dupe in dupes] group = self.results.get_group_of_duplicate(dupe)
for dupe, ref in zip(dupes, refs): ref = group.ref
dupe_cmd = cmd.replace("%d", str(dupe.path)) cmd = cmd.replace('%d', str(dupe.path))
dupe_cmd = dupe_cmd.replace("%r", str(ref.path)) cmd = cmd.replace('%r', str(ref.path))
match = re.match(r'"([^"]+)"(.*)', dupe_cmd) match = re.match(r'"([^"]+)"(.*)', cmd)
if match is not None: if match is not None:
# This code here is because subprocess. Popen doesn't seem to accept, under Windows, # This code here is because subprocess. Popen doesn't seem to accept, under Windows,
# executable paths with spaces in it, *even* when they're enclosed in "". So this is # executable paths with spaces in it, *even* when they're enclosed in "". So this is
# a workaround to make the damn thing work. # a workaround to make the damn thing work.
exepath, args = match.groups() exepath, args = match.groups()
path, exename = op.split(exepath) path, exename = op.split(exepath)
p = subprocess.Popen( subprocess.Popen(exename + args, shell=True, cwd=path)
exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT else:
) subprocess.Popen(cmd, shell=True)
output = p.stdout.read()
logging.info("Custom command %s %s: %s", exename, args, output)
else:
p = subprocess.Popen(dupe_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = p.stdout.read()
logging.info("Custom command %s: %s", dupe_cmd, output)
def load(self): def load(self):
"""Load directory selection and ignore list from files in appdata. """Load directory selection and ignore list from files in appdata.
@ -570,31 +551,20 @@ class DupeGuru(Broadcaster):
is persistent data, is the same as when the last session was closed (when :meth:`save` was is persistent data, is the same as when the last session was closed (when :meth:`save` was
called). called).
""" """
self.directories.load_from_file(op.join(self.appdata, "last_directories.xml")) self.directories.load_from_file(op.join(self.appdata, 'last_directories.xml'))
self.notify("directories_changed") self.notify('directories_changed')
p = op.join(self.appdata, "ignore_list.xml") p = op.join(self.appdata, 'ignore_list.xml')
self.ignore_list.load_from_xml(p) self.ignore_list.load_from_xml(p)
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
p = op.join(self.appdata, "exclude_list.xml")
self.exclude_list.load_from_xml(p)
self.exclude_list_dialog.refresh()
def load_directories(self, filepath):
# Clear out previous entries
self.directories.__init__()
self.directories.load_from_file(filepath)
self.notify("directories_changed")
def load_from(self, filename): def load_from(self, filename):
"""Start an async job to load results from ``filename``. """Start an async job to load results from ``filename``.
:param str filename: path of the XML file (created with :meth:`save_as`) to load :param str filename: path of the XML file (created with :meth:`save_as`) to load
""" """
def do(j): def do(j):
self.results.load_from_xml(filename, self._get_file, j) self.results.load_from_xml(filename, self._get_file, j)
self._start_job(JobType.Load, do)
self._start_job(JobType.LOAD, do)
def make_selected_reference(self): def make_selected_reference(self):
"""Promote :attr:`selected_dupes` to reference position within their respective groups. """Promote :attr:`selected_dupes` to reference position within their respective groups.
@ -607,8 +577,9 @@ class DupeGuru(Broadcaster):
changed_groups = set() changed_groups = set()
for dupe in dupes: for dupe in dupes:
g = self.results.get_group_of_duplicate(dupe) g = self.results.get_group_of_duplicate(dupe)
if g not in changed_groups and self.results.make_ref(dupe): if g not in changed_groups:
changed_groups.add(g) if self.results.make_ref(dupe):
changed_groups.add(g)
# It's not always obvious to users what this action does, so to make it a bit clearer, # It's not always obvious to users what this action does, so to make it a bit clearer,
# we change our selection to the ref of all changed groups. However, we also want to keep # we change our selection to the ref of all changed groups. However, we also want to keep
# the files that were ref before and weren't changed by the action. In effect, what this # the files that were ref before and weren't changed by the action. In effect, what this
@ -617,31 +588,35 @@ class DupeGuru(Broadcaster):
if not self.result_table.power_marker: if not self.result_table.power_marker:
if changed_groups: if changed_groups:
self.selected_dupes = [ self.selected_dupes = [
d for d in self.selected_dupes if self.results.get_group_of_duplicate(d).ref is d 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: else:
# If we're in "Dupes Only" mode (previously called Power Marker), things are a bit # 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, # 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 # 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 # do is to keep our selection index-wise (different dupe selection, but same index
# selection). # selection).
self.notify("results_changed_but_keep_selection") self.notify('results_changed_but_keep_selection')
def mark_all(self): def mark_all(self):
"""Set all dupes in the results as marked.""" """Set all dupes in the results as marked.
"""
self.results.mark_all() self.results.mark_all()
self.notify("marking_changed") self.notify('marking_changed')
def mark_none(self): def mark_none(self):
"""Set all dupes in the results as unmarked.""" """Set all dupes in the results as unmarked.
"""
self.results.mark_none() self.results.mark_none()
self.notify("marking_changed") self.notify('marking_changed')
def mark_invert(self): def mark_invert(self):
"""Invert the marked state of all dupes in the results.""" """Invert the marked state of all dupes in the results.
"""
self.results.mark_invert() self.results.mark_invert()
self.notify("marking_changed") self.notify('marking_changed')
def mark_dupe(self, dupe, marked): def mark_dupe(self, dupe, marked):
"""Change marked status of ``dupe``. """Change marked status of ``dupe``.
@ -654,18 +629,21 @@ class DupeGuru(Broadcaster):
self.results.mark(dupe) self.results.mark(dupe)
else: else:
self.results.unmark(dupe) self.results.unmark(dupe)
self.notify("marking_changed") self.notify('marking_changed')
def open_selected(self): def open_selected(self):
"""Open :attr:`selected_dupes` with their associated application.""" """Open :attr:`selected_dupes` with their associated application.
if len(self.selected_dupes) > 10 and not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN): """
return if len(self.selected_dupes) > 10:
if not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN):
return
for dupe in self.selected_dupes: for dupe in self.selected_dupes:
desktop.open_path(dupe.path) desktop.open_path(dupe.path)
def purge_ignore_list(self): def purge_ignore_list(self):
"""Remove files that don't exist from :attr:`ignore_list`.""" """Remove files that don't exist from :attr:`ignore_list`.
self.ignore_list.filter(lambda f, s: op.exists(f) and op.exists(s)) """
self.ignore_list.Filter(lambda f, s: op.exists(f) and op.exists(s))
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
def remove_directories(self, indexes): def remove_directories(self, indexes):
@ -678,7 +656,7 @@ class DupeGuru(Broadcaster):
indexes = sorted(indexes, reverse=True) indexes = sorted(indexes, reverse=True)
for index in indexes: for index in indexes:
del self.directories[index] del self.directories[index]
self.notify("directories_changed") self.notify('directories_changed')
except IndexError: except IndexError:
pass pass
@ -691,10 +669,11 @@ class DupeGuru(Broadcaster):
:type duplicates: list of :class:`~core.fs.File` :type duplicates: list of :class:`~core.fs.File`
""" """
self.results.remove_duplicates(self.without_ref(duplicates)) 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): def remove_marked(self):
"""Removed marked duplicates from the results (without touching the files themselves).""" """Removed marked duplicates from the results (without touching the files themselves).
"""
if not self.results.mark_count: if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES) self.view.show_message(MSG_NO_MARKED_DUPES)
return return
@ -705,7 +684,8 @@ class DupeGuru(Broadcaster):
self._results_changed() self._results_changed()
def remove_selected(self): def remove_selected(self):
"""Removed :attr:`selected_dupes` from the results (without touching the files themselves).""" """Removed :attr:`selected_dupes` from the results (without touching the files themselves).
"""
dupes = self.without_ref(self.selected_dupes) dupes = self.without_ref(self.selected_dupes)
if not dupes: if not dupes:
self.view.show_message(MSG_NO_SELECTED_DUPES) self.view.show_message(MSG_NO_SELECTED_DUPES)
@ -743,8 +723,6 @@ class DupeGuru(Broadcaster):
for group in self.results.groups: for group in self.results.groups:
if group.prioritize(key_func=sort_key): if group.prioritize(key_func=sort_key):
count += 1 count += 1
if count:
self.results.refresh_required = True
self._results_changed() 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) self.view.show_message(msg)
@ -756,15 +734,10 @@ class DupeGuru(Broadcaster):
def save(self): def save(self):
if not op.exists(self.appdata): if not op.exists(self.appdata):
os.makedirs(self.appdata) os.makedirs(self.appdata)
self.directories.save_to_file(op.join(self.appdata, "last_directories.xml")) self.directories.save_to_file(op.join(self.appdata, 'last_directories.xml'))
p = op.join(self.appdata, "ignore_list.xml") p = op.join(self.appdata, 'ignore_list.xml')
self.ignore_list.save_to_xml(p) self.ignore_list.save_to_xml(p)
p = op.join(self.appdata, "exclude_list.xml") self.notify('save_session')
self.exclude_list.save_to_xml(p)
self.notify("save_session")
def close(self):
fs.filesdb.close()
def save_as(self, filename): def save_as(self, filename):
"""Save results in ``filename``. """Save results in ``filename``.
@ -776,23 +749,12 @@ class DupeGuru(Broadcaster):
except OSError as e: except OSError as e:
self.view.show_message(tr("Couldn't write to file: {}").format(str(e))) self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
def save_directories_as(self, filename): def start_scanning(self):
"""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, profile_scan=False):
"""Starts an async job to scan for duplicates. """Starts an async job to scan for duplicates.
Scans folders selected in :attr:`directories` and put the results in :attr:`results` Scans folders selected in :attr:`directories` and put the results in :attr:`results`
""" """
scanner = self.SCANNER_CLASS() scanner = self.SCANNER_CLASS()
fs.filesdb.ignore_mtime = self.options["rehash_ignore_mtime"] is True
if not self.directories.has_any_file(): if not self.directories.has_any_file():
self.view.show_message(tr("The selected directories contain no scannable file.")) self.view.show_message(tr("The selected directories contain no scannable file."))
return return
@ -800,31 +762,25 @@ class DupeGuru(Broadcaster):
for k, v in self.options.items(): for k, v in self.options.items():
if hasattr(scanner, k): if hasattr(scanner, k):
setattr(scanner, k, v) setattr(scanner, k, v)
if self.app_mode == AppMode.PICTURE: if self.app_mode == AppMode.Picture:
scanner.cache_path = self._get_picture_cache_path() scanner.cache_path = self._get_picture_cache_path()
self.results.groups = [] self.results.groups = []
self._recreate_result_table() self._recreate_result_table()
self._results_changed() self._results_changed()
def do(j): def do(j):
if profile_scan:
pr = cProfile.Profile()
pr.enable()
j.set_progress(0, tr("Collecting files to scan")) j.set_progress(0, tr("Collecting files to scan"))
if scanner.scan_type == ScanType.FOLDERS: if scanner.scan_type == ScanType.Folders:
files = list(self.directories.get_folders(folderclass=se.fs.Folder, j=j)) files = list(self.directories.get_folders(folderclass=se.fs.Folder, j=j))
else: else:
files = list(self.directories.get_files(fileclasses=self.fileclasses, j=j)) files = list(self.directories.get_files(fileclasses=self.fileclasses, j=j))
if self.options["ignore_hardlink_matches"]: if self.options['ignore_hardlink_matches']:
files = self._remove_hardlink_dupes(files) 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.results.groups = scanner.get_dupe_groups(files, self.ignore_list, j)
self.discarded_file_count = scanner.discarded_file_count self.discarded_file_count = scanner.discarded_file_count
if profile_scan:
pr.disable()
pr.dump_stats(op.join(self.appdata, f"{datetime.datetime.now():%Y-%m-%d_%H-%M-%S}.profile"))
self._start_job(JobType.SCAN, do) self._start_job(JobType.Scan, do)
def toggle_selected_mark_state(self): def toggle_selected_mark_state(self):
selected = self.without_ref(self.selected_dupes) selected = self.without_ref(self.selected_dupes)
@ -836,10 +792,11 @@ class DupeGuru(Broadcaster):
markfunc = self.results.mark markfunc = self.results.mark
for dupe in selected: for dupe in selected:
markfunc(dupe) markfunc(dupe)
self.notify("marking_changed") self.notify('marking_changed')
def without_ref(self, dupes): def without_ref(self, dupes):
"""Returns ``dupes`` with all reference elements removed.""" """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): def get_default(self, key, fallback_value=None):
@ -855,7 +812,7 @@ class DupeGuru(Broadcaster):
def set_default(self, key, value): def set_default(self, key, value):
self.view.set_default(key, value) self.view.set_default(key, value)
# --- Properties #--- Properties
@property @property
def stat_line(self): def stat_line(self):
result = self.results.stat_line result = self.results.stat_line
@ -869,31 +826,22 @@ class DupeGuru(Broadcaster):
@property @property
def SCANNER_CLASS(self): def SCANNER_CLASS(self):
if self.app_mode == AppMode.PICTURE: if self.app_mode == AppMode.Picture:
return pe.scanner.ScannerPE return pe.scanner.ScannerPE
elif self.app_mode == AppMode.MUSIC: elif self.app_mode == AppMode.Music:
return me.scanner.ScannerME return me.scanner.ScannerME
else: else:
return se.scanner.ScannerSE return se.scanner.ScannerSE
@property @property
def METADATA_TO_READ(self): def METADATA_TO_READ(self):
if self.app_mode == AppMode.PICTURE: if self.app_mode == AppMode.Picture:
return ["size", "mtime", "dimensions", "exif_timestamp"] return ['size', 'mtime', 'dimensions', 'exif_timestamp']
elif self.app_mode == AppMode.MUSIC: elif self.app_mode == AppMode.Music:
return [ return [
"size", 'size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist',
"mtime", 'album', 'genre', 'year', 'track', 'comment'
"duration",
"bitrate",
"samplerate",
"title",
"artist",
"album",
"genre",
"year",
"track",
"comment",
] ]
else: else:
return ["size", "mtime"] return ['size', 'mtime']

View File

@ -1,28 +1,27 @@
# Copyright 2017 Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/02/27
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
# #
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
import os
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import logging import logging
from pathlib import Path
from hscommon.jobprogress import job from hscommon.jobprogress import job
from hscommon.path import Path
from hscommon.util import FileOrPath from hscommon.util import FileOrPath
from hscommon.trans import tr
from core import fs from . import fs
__all__ = [ __all__ = [
"Directories", 'Directories',
"DirectoryState", 'DirectoryState',
"AlreadyThereError", 'AlreadyThereError',
"InvalidPathError", 'InvalidPathError',
] ]
class DirectoryState: class DirectoryState:
"""Enum describing how a folder should be considered. """Enum describing how a folder should be considered.
@ -30,20 +29,16 @@ class DirectoryState:
* DirectoryState.Reference: Scan files, but make sure never to delete any of them * DirectoryState.Reference: Scan files, but make sure never to delete any of them
* DirectoryState.Excluded: Don't scan this folder * DirectoryState.Excluded: Don't scan this folder
""" """
Normal = 0
NORMAL = 0 Reference = 1
REFERENCE = 1 Excluded = 2
EXCLUDED = 2
class AlreadyThereError(Exception): class AlreadyThereError(Exception):
"""The path being added is already in the directory list""" """The path being added is already in the directory list"""
class InvalidPathError(Exception): class InvalidPathError(Exception):
"""The path being added is invalid""" """The path being added is invalid"""
class Directories: class Directories:
"""Holds user folder selection. """Holds user folder selection.
@ -53,17 +48,15 @@ class Directories:
Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped 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. in :mod:`core.fs`) that have to be scanned according to the chosen folders/states.
""" """
#---Override
# ---Override def __init__(self):
def __init__(self, exclude_list=None):
self._dirs = [] self._dirs = []
# {path: state} # {path: state}
self.states = {} self.states = {}
self._exclude_list = exclude_list
def __contains__(self, path): def __contains__(self, path):
for p in self._dirs: for p in self._dirs:
if path == p or p in path.parents: if path in p:
return True return True
return False return False
@ -76,75 +69,54 @@ class Directories:
def __len__(self): def __len__(self):
return len(self._dirs) return len(self._dirs)
# ---Private #---Private
def _default_state_for_path(self, path): def _default_state_for_path(self, path):
# New logic with regex filters
if self._exclude_list is not None and self._exclude_list.mark_count > 0:
# We iterate even if we only have one item here
for denied_path_re in self._exclude_list.compiled:
if denied_path_re.match(str(path.name)):
return DirectoryState.EXCLUDED
return DirectoryState.NORMAL
# Override this in subclasses to specify the state of some special folders. # Override this in subclasses to specify the state of some special folders.
if path.name.startswith("."): if path.name.startswith('.'): # hidden
return DirectoryState.EXCLUDED return DirectoryState.Excluded
return DirectoryState.NORMAL
def _get_files(self, from_path, fileclasses, j): def _get_files(self, from_path, fileclasses, j):
j.check_if_cancelled()
state = self.get_state(from_path)
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
try: try:
with os.scandir(from_path) as iter: filepaths = set()
root_path = Path(from_path) if state != DirectoryState.Excluded:
state = self.get_state(root_path) found_files = fs.get_files(from_path, fileclasses=fileclasses)
# if we have no un-excluded dirs under this directory skip going deeper logging.debug("Collected %d files in folder %s", len(found_files), str(from_path))
skip_dirs = state == DirectoryState.EXCLUDED and not any( for file in found_files:
p.parts[: len(root_path.parts)] == root_path.parts for p in self.states file.is_ref = state == DirectoryState.Reference
) filepaths.add(file.path)
count = 0 yield file
for item in iter: # it's possible that a folder (bundle) gets into the file list. in that case, we don't
j.check_if_cancelled() # want to recurse into it
try: subfolders = [p for p in from_path.listdir() if not p.islink() and p.isdir() and p not in filepaths]
if item.is_dir(): for subfolder in subfolders:
if skip_dirs: for file in self._get_files(subfolder, fileclasses=fileclasses, j=j):
continue yield file
yield from self._get_files(item.path, fileclasses, j) except (EnvironmentError, fs.InvalidPath):
continue
elif state == DirectoryState.EXCLUDED:
continue
# File excluding or not
if (
self._exclude_list is None
or not self._exclude_list.mark_count
or not self._exclude_list.is_excluded(str(from_path), item.name)
):
file = fs.get_file(item, fileclasses=fileclasses)
if file:
file.is_ref = state == DirectoryState.REFERENCE
count += 1
yield file
except (OSError, fs.InvalidPath):
pass
logging.debug(
"Collected %d files in folder %s",
count,
str(root_path),
)
except OSError:
pass pass
def _get_folders(self, from_folder, j): def _get_folders(self, from_folder, j):
j.check_if_cancelled() j.check_if_cancelled()
try: try:
for subfolder in from_folder.subfolders: for subfolder in from_folder.subfolders:
yield from self._get_folders(subfolder, j) for folder in self._get_folders(subfolder, j):
yield folder
state = self.get_state(from_folder.path) state = self.get_state(from_folder.path)
if state != DirectoryState.EXCLUDED: if state != DirectoryState.Excluded:
from_folder.is_ref = state == DirectoryState.REFERENCE from_folder.is_ref = state == DirectoryState.Reference
logging.debug("Yielding Folder %r state: %d", from_folder, state) logging.debug("Yielding Folder %r state: %d", from_folder, state)
yield from_folder yield from_folder
except (OSError, fs.InvalidPath): except (EnvironmentError, fs.InvalidPath):
pass pass
# ---Public #---Public
def add_path(self, path): def add_path(self, path):
"""Adds ``path`` to self, if not already there. """Adds ``path`` to self, if not already there.
@ -159,7 +131,7 @@ class Directories:
raise AlreadyThereError() raise AlreadyThereError()
if not path.exists(): if not path.exists():
raise InvalidPathError() raise InvalidPathError()
self._dirs = [p for p in self._dirs if path not in p.parents] self._dirs = [p for p in self._dirs if p not in path]
self._dirs.append(path) self._dirs.append(path)
@staticmethod @staticmethod
@ -170,10 +142,10 @@ class Directories:
:rtype: list of Path :rtype: list of Path
""" """
try: try:
subpaths = [p for p in path.glob("*") if p.is_dir()] subpaths = [p for p in path.listdir() if p.isdir()]
subpaths.sort(key=lambda x: x.name.lower()) subpaths.sort(key=lambda x: x.name.lower())
return subpaths return subpaths
except OSError: except EnvironmentError:
return [] return []
def get_files(self, fileclasses=None, j=job.nulljob): def get_files(self, fileclasses=None, j=job.nulljob):
@ -183,12 +155,8 @@ class Directories:
""" """
if fileclasses is None: if fileclasses is None:
fileclasses = [fs.File] fileclasses = [fs.File]
file_count = 0
for path in self._dirs: for path in self._dirs:
for file in self._get_files(path, fileclasses=fileclasses, j=j): for file in self._get_files(path, fileclasses=fileclasses, j=j):
file_count += 1
if not isinstance(j, job.NullJob):
j.set_progress(-1, tr("Collected {} files to scan").format(file_count))
yield file yield file
def get_folders(self, folderclass=None, j=job.nulljob): def get_folders(self, folderclass=None, j=job.nulljob):
@ -198,13 +166,9 @@ class Directories:
""" """
if folderclass is None: if folderclass is None:
folderclass = fs.Folder folderclass = fs.Folder
folder_count = 0
for path in self._dirs: for path in self._dirs:
from_folder = folderclass(path) from_folder = folderclass(path)
for folder in self._get_folders(from_folder, j): for folder in self._get_folders(from_folder, j):
folder_count += 1
if not isinstance(j, job.NullJob):
j.set_progress(-1, tr("Collected {} folders to scan").format(folder_count))
yield folder yield folder
def get_state(self, path): def get_state(self, path):
@ -212,20 +176,16 @@ class Directories:
:rtype: :class:`DirectoryState` :rtype: :class:`DirectoryState`
""" """
# direct match? easy result.
if path in self.states: if path in self.states:
return self.states[path] return self.states[path]
state = self._default_state_for_path(path) default_state = self._default_state_for_path(path)
# Save non-default states in cache, necessary for _get_files() if default_state is not None:
if state != DirectoryState.NORMAL: return default_state
self.states[path] = state parent = path.parent()
return state if parent in self:
# find the longest parent path that is in states and return that state if found return self.get_state(parent)
# NOTE: path.parents is ordered longest to shortest else:
for parent_path in path.parents: return DirectoryState.Normal
if parent_path in self.states:
return self.states[parent_path]
return state
def has_any_file(self): def has_any_file(self):
"""Returns whether selected folders contain any file. """Returns whether selected folders contain any file.
@ -249,21 +209,21 @@ class Directories:
root = ET.parse(infile).getroot() root = ET.parse(infile).getroot()
except Exception: except Exception:
return return
for rdn in root.iter("root_directory"): for rdn in root.getiterator('root_directory'):
attrib = rdn.attrib attrib = rdn.attrib
if "path" not in attrib: if 'path' not in attrib:
continue continue
path = attrib["path"] path = attrib['path']
try: try:
self.add_path(Path(path)) self.add_path(Path(path))
except (AlreadyThereError, InvalidPathError): except (AlreadyThereError, InvalidPathError):
pass pass
for sn in root.iter("state"): for sn in root.getiterator('state'):
attrib = sn.attrib attrib = sn.attrib
if not ("path" in attrib and "value" in attrib): if not ('path' in attrib and 'value' in attrib):
continue continue
path = attrib["path"] path = attrib['path']
state = attrib["value"] state = attrib['value']
self.states[Path(path)] = int(state) self.states[Path(path)] = int(state)
def save_to_file(self, outfile): def save_to_file(self, outfile):
@ -271,17 +231,17 @@ class Directories:
:param file outfile: path or file pointer to XML file to save to. :param file outfile: path or file pointer to XML file to save to.
""" """
with FileOrPath(outfile, "wb") as fp: with FileOrPath(outfile, 'wb') as fp:
root = ET.Element("directories") root = ET.Element('directories')
for root_path in self: for root_path in self:
root_path_node = ET.SubElement(root, "root_directory") root_path_node = ET.SubElement(root, 'root_directory')
root_path_node.set("path", str(root_path)) root_path_node.set('path', str(root_path))
for path, state in self.states.items(): for path, state in self.states.items():
state_node = ET.SubElement(root, "state") state_node = ET.SubElement(root, 'state')
state_node.set("path", str(path)) state_node.set('path', str(path))
state_node.set("value", str(state)) state_node.set('value', str(state))
tree = ET.ElementTree(root) tree = ET.ElementTree(root)
tree.write(fp, encoding="utf-8") tree.write(fp, encoding='utf-8')
def set_state(self, path, state): def set_state(self, path, state):
"""Set the state of folder at ``path``. """Set the state of folder at ``path``.
@ -293,6 +253,7 @@ class Directories:
if self.get_state(path) == state: if self.get_state(path) == state:
return return
for iter_path in list(self.states.keys()): for iter_path in list(self.states.keys()):
if path in iter_path.parents: if path.is_parent_of(iter_path):
del self.states[iter_path] del self.states[iter_path]
self.states[path] = state self.states[path] = state

View File

@ -24,33 +24,18 @@ from hscommon.jobprogress import job
) = range(3) ) = range(3)
JOB_REFRESH_RATE = 100 JOB_REFRESH_RATE = 100
PROGRESS_MESSAGE = tr("%d matches found from %d groups")
def getwords(s): def getwords(s):
# We decompose the string so that ascii letters with accents can be part of the word. # We decompose the string so that ascii letters with accents can be part of the word.
s = normalize("NFD", s) s = normalize('NFD', s)
s = multi_replace(s, "-_&+():;\\[]{}.,<>/?~!@#$*", " ").lower() s = multi_replace(s, "-_&+():;\\[]{}.,<>/?~!@#$*", ' ').lower()
# logging.debug(f"DEBUG chars for: {s}\n" s = ''.join(c for c in s if c in string.ascii_letters + string.digits + string.whitespace)
# f"{[c for c in s if ord(c) != 32]}\n" return [_f for _f in s.split(' ') if _f] # remove empty elements
# f"{[ord(c) for c in s if ord(c) != 32]}")
# HACK We shouldn't ignore non-ascii characters altogether. Any Unicode char
# above common european characters that cannot be "sanitized" (ie. stripped
# of their accents, etc.) are preserved as is. The arbitrary limit is
# obtained from this one: ord("\u037e") GREEK QUESTION MARK
s = "".join(
c
for c in s
if (ord(c) <= 894 and c in string.ascii_letters + string.digits + string.whitespace) or ord(c) > 894
)
return [_f for _f in s.split(" ") if _f] # remove empty elements
def getfields(s): 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] return [_f for _f in fields if _f]
def unpack_fields(fields): def unpack_fields(fields):
result = [] result = []
for field in fields: for field in fields:
@ -60,7 +45,6 @@ def unpack_fields(fields):
result.append(field) result.append(field)
return result return result
def compare(first, second, flags=()): def compare(first, second, flags=()):
"""Returns the % of words that match between ``first`` and ``second`` """Returns the % of words that match between ``first`` and ``second``
@ -71,11 +55,11 @@ def compare(first, second, flags=()):
return 0 return 0
if any(isinstance(element, list) for element in first): if any(isinstance(element, list) for element in first):
return compare_fields(first, second, flags) 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 match_similar = MATCH_SIMILAR_WORDS in flags
weight_words = WEIGHT_WORDS in flags weight_words = WEIGHT_WORDS in flags
joined = first + second 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 match_count = 0
in_order = True in_order = True
for word in first: for word in first:
@ -87,13 +71,12 @@ def compare(first, second, flags=()):
if second[0] != word: if second[0] != word:
in_order = False in_order = False
second.remove(word) 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) result = round(((match_count * 2) / total_count) * 100)
if (result == 100) and (not in_order): if (result == 100) and (not in_order):
result = 99 # We cannot consider a match exact unless the ordering is the same result = 99 # We cannot consider a match exact unless the ordering is the same
return result return result
def compare_fields(first, second, flags=()): def compare_fields(first, second, flags=()):
"""Returns the score for the lowest matching :ref:`fields`. """Returns the score for the lowest matching :ref:`fields`.
@ -104,24 +87,23 @@ def compare_fields(first, second, flags=()):
return 0 return 0
if NO_FIELD_ORDER in flags: if NO_FIELD_ORDER in flags:
results = [] 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[:] second = second[:]
for field1 in first: for field1 in first:
max_score = 0 max = 0
matched_field = None matched_field = None
for field2 in second: for field2 in second:
r = compare(field1, field2, flags) r = compare(field1, field2, flags)
if r > max_score: if r > max:
max_score = r max = r
matched_field = field2 matched_field = field2
results.append(max_score) results.append(max)
if matched_field: if matched_field:
second.remove(matched_field) second.remove(matched_field)
else: 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 return min(results) if results else 0
def build_word_dict(objects, j=job.nulljob): def build_word_dict(objects, j=job.nulljob):
"""Returns a dict of objects mapped by their words. """Returns a dict of objects mapped by their words.
@ -131,12 +113,11 @@ def build_word_dict(objects, j=job.nulljob):
The result will be a dict with words as keys, lists of objects as values. The result will be a dict with words as keys, lists of objects as values.
""" """
result = defaultdict(set) 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): for word in unpack_fields(object.words):
result[word].add(object) result[word].add(object)
return result return result
def merge_similar_words(word_dict): def merge_similar_words(word_dict):
"""Take all keys in ``word_dict`` that are similar, and merge them together. """Take all keys in ``word_dict`` that are similar, and merge them together.
@ -145,7 +126,7 @@ def merge_similar_words(word_dict):
a word equal to the other. a word equal to the other.
""" """
keys = list(word_dict.keys()) 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: while keys:
key = keys.pop(0) key = keys.pop(0)
similars = difflib.get_close_matches(key, keys, 100, 0.8) similars = difflib.get_close_matches(key, keys, 100, 0.8)
@ -157,7 +138,6 @@ def merge_similar_words(word_dict):
del word_dict[similar] del word_dict[similar]
keys.remove(similar) keys.remove(similar)
def reduce_common_words(word_dict, threshold): def reduce_common_words(word_dict, threshold):
"""Remove all objects from ``word_dict`` values where the object count >= ``threshold`` """Remove all objects from ``word_dict`` values where the object count >= ``threshold``
@ -166,7 +146,7 @@ def reduce_common_words(word_dict, threshold):
The exception to this removal are the objects where all the words of the object are common. The exception to this removal are the objects where all the words of the object are common.
Because if we remove them, we will miss some duplicates! Because if we remove them, we will miss some duplicates!
""" """
uncommon_words = {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()): for word, objects in list(word_dict.items()):
if len(objects) < threshold: if len(objects) < threshold:
continue continue
@ -179,13 +159,11 @@ def reduce_common_words(word_dict, threshold):
else: else:
del word_dict[word] del word_dict[word]
# Writing docstrings in a namedtuple is tricky. From Python 3.3, it's possible to set __doc__, but # 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 # 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 # 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`. """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, Regarless of the matching method, when two files are determined to match, a Match pair is created,
@ -204,24 +182,16 @@ 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 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. exact scan methods, such as Contents scans, this will always be 100.
""" """
__slots__ = () __slots__ = ()
def get_match(first, second, flags=()): 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) percentage = compare(first.words, second.words, flags)
return Match(first, second, percentage) return Match(first, second, percentage)
def getmatches( def getmatches(
objects, objects, min_match_percentage=0, match_similar_words=False, weight_words=False,
min_match_percentage=0, no_field_order=False, j=job.nulljob):
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. """Returns a list of :class:`Match` within ``objects`` after fuzzily matching their words.
:param objects: List of :class:`~core.fs.File` to match. :param objects: List of :class:`~core.fs.File` to match.
@ -236,7 +206,7 @@ def getmatches(
j = j.start_subjob(2) j = j.start_subjob(2)
sj = j.start_subjob(2) sj = j.start_subjob(2)
for o in objects: for o in objects:
if not hasattr(o, "words"): if not hasattr(o, 'words'):
o.words = getwords(o.name) o.words = getwords(o.name)
word_dict = build_word_dict(objects, sj) word_dict = build_word_dict(objects, sj)
reduce_common_words(word_dict, COMMON_WORD_THRESHOLD) reduce_common_words(word_dict, COMMON_WORD_THRESHOLD)
@ -249,11 +219,10 @@ def getmatches(
match_flags.append(MATCH_SIMILAR_WORDS) match_flags.append(MATCH_SIMILAR_WORDS)
if no_field_order: if no_field_order:
match_flags.append(NO_FIELD_ORDER) match_flags.append(NO_FIELD_ORDER)
j.start_job(len(word_dict), PROGRESS_MESSAGE % (0, 0)) j.start_job(len(word_dict), tr("0 matches found"))
compared = defaultdict(set) compared = defaultdict(set)
result = [] result = []
try: try:
word_count = 0
# This whole 'popping' thing is there to avoid taking too much memory at the same time. # This whole 'popping' thing is there to avoid taking too much memory at the same time.
while word_dict: while word_dict:
items = word_dict.popitem()[1] items = word_dict.popitem()[1]
@ -268,54 +237,39 @@ def getmatches(
result.append(m) result.append(m)
if len(result) >= LIMIT: if len(result) >= LIMIT:
return result return result
word_count += 1 j.add_progress(desc=tr("%d matches found") % len(result))
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), word_count))
except MemoryError: except MemoryError:
# This is the place where the memory usage is at its peak during the scan. # 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. # Just continue the process with an incomplete list of matches.
del compared # This should give us enough room to call logging. 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
return result return result
def getmatches_by_contents(files, j=job.nulljob):
def getmatches_by_contents(files, bigsize=0, j=job.nulljob):
"""Returns a list of :class:`Match` within ``files`` if their contents is the same. """Returns a list of :class:`Match` within ``files`` if their contents is the same.
:param bigsize: The size in bytes over which we consider files big enough to
justify taking samples of the file for hashing. If 0, compute digest as usual.
:param j: A :ref:`job progress instance <jobs>`. :param j: A :ref:`job progress instance <jobs>`.
""" """
size2files = defaultdict(set) size2files = defaultdict(set)
for f in files: for f in files:
size2files[f.size].add(f) if f.size:
size2files[f.size].add(f)
del files del files
possible_matches = [files for files in size2files.values() if len(files) > 1] possible_matches = [files for files in size2files.values() if len(files) > 1]
del size2files del size2files
result = [] result = []
j.start_job(len(possible_matches), PROGRESS_MESSAGE % (0, 0)) j.start_job(len(possible_matches), tr("0 matches found"))
group_count = 0
for group in possible_matches: for group in possible_matches:
for first, second in itertools.combinations(group, 2): for first, second in itertools.combinations(group, 2):
if first.is_ref and second.is_ref: if first.is_ref and second.is_ref:
continue # Don't spend time comparing two ref pics together. continue # Don't spend time comparing two ref pics together.
if first.size == 0 and second.size == 0: if first.md5partial == second.md5partial:
# skip hashing for zero length files if first.md5 == second.md5:
result.append(Match(first, second, 100)) result.append(Match(first, second, 100))
continue j.add_progress(desc=tr("%d matches found") % len(result))
# if digests are the same (and not None) then files match
if first.digest_partial is not None and first.digest_partial == second.digest_partial:
if bigsize > 0 and first.size > bigsize:
if first.digest_samples is not None and first.digest_samples == second.digest_samples:
result.append(Match(first, second, 100))
else:
if first.digest is not None and first.digest == second.digest:
result.append(Match(first, second, 100))
group_count += 1
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), group_count))
return result return result
class Group: class Group:
"""A group of :class:`~core.fs.File` that match together. """A group of :class:`~core.fs.File` that match together.
@ -343,8 +297,7 @@ class Group:
Average match percentage of match pairs containing :attr:`ref`. Average match percentage of match pairs containing :attr:`ref`.
""" """
#---Override
# ---Override
def __init__(self): def __init__(self):
self._clear() self._clear()
@ -360,7 +313,7 @@ class Group:
def __len__(self): def __len__(self):
return len(self.ordered) return len(self.ordered)
# ---Private #---Private
def _clear(self): def _clear(self):
self._percentage = None self._percentage = None
self._matches_for_ref = None self._matches_for_ref = None
@ -375,7 +328,7 @@ class Group:
self._matches_for_ref = [match for match in self.matches if ref in match] self._matches_for_ref = [match for match in self.matches if ref in match]
return self._matches_for_ref return self._matches_for_ref
# ---Public #---Public
def add_match(self, match): def add_match(self, match):
"""Adds ``match`` to internal match list and possibly add duplicates to the group. """Adds ``match`` to internal match list and possibly add duplicates to the group.
@ -386,7 +339,6 @@ class Group:
:param tuple match: pair of :class:`~core.fs.File` to add :param tuple match: pair of :class:`~core.fs.File` to add
""" """
def add_candidate(item, match): def add_candidate(item, match):
matches = self.candidates[item] matches = self.candidates[item]
matches.add(match) matches.add(match)
@ -410,13 +362,14 @@ class Group:
You can call this after the duplicate scanning process to free a bit of memory. You can call this after the duplicate scanning process to free a bit of memory.
""" """
discarded = {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.matches -= discarded
self.candidates = defaultdict(set) self.candidates = defaultdict(set)
return discarded return discarded
def get_match_of(self, item): def get_match_of(self, item):
"""Returns the match pair between ``item`` and :attr:`ref`.""" """Returns the match pair between ``item`` and :attr:`ref`.
"""
if item is self.ref: if item is self.ref:
return return
for m in self._get_matches_for_ref(): for m in self._get_matches_for_ref():
@ -432,7 +385,8 @@ class Group:
""" """
# tie_breaker(ref, dupe) --> True if dupe should be ref # tie_breaker(ref, dupe) --> True if dupe should be ref
# Returns True if anything changed during prioritization. # Returns True if anything changed during prioritization.
new_order = sorted(self.ordered, key=lambda x: (-x.is_ref, key_func(x))) master_key_func = lambda x: (-x.is_ref, key_func(x))
new_order = sorted(self.ordered, key=master_key_func)
changed = new_order != self.ordered changed = new_order != self.ordered
self.ordered = new_order self.ordered = new_order
if tie_breaker is None: if tie_breaker is None:
@ -455,16 +409,17 @@ class Group:
self.unordered.remove(item) self.unordered.remove(item)
self._percentage = None self._percentage = None
self._matches_for_ref = None self._matches_for_ref = None
if (len(self) > 1) and any(not getattr(item, "is_ref", False) for item in self): if (len(self) > 1) and any(not getattr(item, 'is_ref', False) for item in self):
if discard_matches: if discard_matches:
self.matches = {m for m in self.matches if item not in m} self.matches = set(m for m in self.matches if item not in m)
else: else:
self._clear() self._clear()
except ValueError: except ValueError:
pass pass
def switch_ref(self, with_dupe): def switch_ref(self, with_dupe):
"""Make the :attr:`ref` dupe of the group switch position with ``with_dupe``.""" """Make the :attr:`ref` dupe of the group switch position with ``with_dupe``.
"""
if self.ref.is_ref: if self.ref.is_ref:
return False return False
try: try:
@ -530,7 +485,7 @@ def get_groups(matches):
del dupe2group del dupe2group
del matches del matches
# should free enough memory to continue # should free enough memory to continue
logging.warning(f"Memory Overflow. Groups: {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" # Now that we have a group, we have to discard groups' matches and see if there're any "orphan"
# matches, that is, matches that were candidate in a group but that none of their 2 files were # matches, that is, matches that were candidate in a group but that none of their 2 files were
# accepted in the group. With these orphan groups, it's safe to build additional groups # accepted in the group. With these orphan groups, it's safe to build additional groups
@ -538,8 +493,9 @@ def get_groups(matches):
orphan_matches = [] orphan_matches = []
for group in groups: for group in groups:
orphan_matches += { orphan_matches += {
m for m in group.discard_matches() if not any(obj in matched_files for obj in [m.first, m.second]) 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: 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 return groups

View File

@ -1,513 +0,0 @@
# 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 core.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.has_entry(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):
return re.compile(expr)
# @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)]
self._dirty = False
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)),)
self._dirty = False
@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(self._use_union)
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(self._use_union)
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(self._use_union)
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.has_entry(regex):
# This exception should never be ignored
raise AlreadyThereException()
if regex in forbidden_regexes:
raise ValueError("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 has_entry(self, regex):
for item in self._excluded:
if regex == item[0]:
return True
return False
def is_excluded(self, dirname, filename):
"""Return True if the file or the absolute path to file is supposed to be
filtered out, False otherwise."""
matched = False
for expr in self.compiled_files:
if expr.fullmatch(filename):
matched = True
break
if not matched:
for expr in self.compiled_paths:
if expr.fullmatch(dirname + sep + filename):
matched = True
break
return matched
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:
self._add_compiled(newregex)
if 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.has_entry(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.get(regex).get("compiled"))
except Exception as e:
logging.error(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 has_entry(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.get("index"),
"compilable": iscompilable,
"error": error,
"compiled": compiled,
}
self._remove_compiled(regex)
if iscompilable:
self._add_compiled(newregex)
if was_marked:
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(regexp):
return "\\" + sep in regexp
else:
def has_sep(regexp):
return sep in regexp

View File

@ -114,38 +114,36 @@ ROW_TEMPLATE = """
CELL_TEMPLATE = """<td>{value}</td>""" CELL_TEMPLATE = """<td>{value}</td>"""
def export_to_xhtml(colnames, rows): 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 # a row is a list of values with the first value being a flag indicating if the row should be indented
if rows: if rows:
assert len(rows[0]) == len(colnames) + 1 # + 1 is for the "indented" flag 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 = [] rendered_rows = []
previous_group_id = None previous_group_id = None
for row in rows: for row in rows:
# [2:] is to remove the indented flag + filename # [2:] is to remove the indented flag + filename
if row[0] != previous_group_id: 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. # We've just changed dupe group, which means that this dupe is a ref. We don't indent it.
indented = "" indented = ''
else: else:
indented = "indented" indented = 'indented'
filename = row[1] filename = row[1]
cells = "".join(CELL_TEMPLATE.format(value=value) for value in row[2:]) cells = ''.join(CELL_TEMPLATE.format(value=value) for value in row[2:])
rendered_rows.append(ROW_TEMPLATE.format(indented=indented, filename=filename, cells=cells)) rendered_rows.append(ROW_TEMPLATE.format(indented=indented, filename=filename, cells=cells))
previous_group_id = row[0] 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 {} # 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() folder = mkdtemp()
destpath = op.join(folder, "export.htm") destpath = op.join(folder, 'export.htm')
fp = open(destpath, "wt", encoding="utf-8") fp = open(destpath, 'wt', encoding='utf-8')
fp.write(content) fp.write(content)
fp.close() fp.close()
return destpath return destpath
def export_to_csv(dest, colnames, rows): 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) writer.writerow(["Group ID"] + colnames)
for row in rows: for row in rows:
writer.writerow(row) writer.writerow(row)

View File

@ -11,54 +11,25 @@
# resulting needless complexity and memory usage. It's been a while since I wanted to do that fork, # resulting needless complexity and memory usage. It's been a while since I wanted to do that fork,
# and I'm doing it now. # and I'm doing it now.
import os import hashlib
from math import floor
import logging import logging
import sqlite3
from sys import platform
from threading import Lock
from typing import Any, AnyStr, Union, Callable
from pathlib import Path
from hscommon.util import nonone, get_file_ext from hscommon.util import nonone, get_file_ext
hasher: Callable
try:
import xxhash
hasher = xxhash.xxh128
except ImportError:
import hashlib
hasher = hashlib.md5
__all__ = [ __all__ = [
"File", 'File',
"Folder", 'Folder',
"get_file", 'get_file',
"get_files", 'get_files',
"FSError", 'FSError',
"AlreadyExistsError", 'AlreadyExistsError',
"InvalidPath", 'InvalidPath',
"InvalidDestinationError", 'InvalidDestinationError',
"OperationError", 'OperationError',
] ]
NOT_SET = object() NOT_SET = object()
# The goal here is to not run out of memory on really big files. However, the chunk
# size has to be large enough so that the python loop isn't too costly in terms of
# CPU.
CHUNK_SIZE = 1024 * 1024 # 1 MiB
# Minimum size below which partial hashing is not used
MIN_FILE_SIZE = 3 * CHUNK_SIZE # 3MiB, because we take 3 samples
# Partial hashing offset and size
PARTIAL_OFFSET_SIZE = (0x4000, 0x4000)
class FSError(Exception): class FSError(Exception):
cls_message = "An error has occured on '{name}' in '{parent}'" cls_message = "An error has occured on '{name}' in '{parent}'"
@ -69,8 +40,8 @@ class FSError(Exception):
elif isinstance(fsobject, File): elif isinstance(fsobject, File):
name = fsobject.name name = fsobject.name
else: else:
name = "" name = ''
parentname = str(parent) if parent is not None else "" parentname = str(parent) if parent is not None else ''
Exception.__init__(self, message.format(name=name, parent=parentname)) Exception.__init__(self, message.format(name=name, parent=parentname))
@ -78,150 +49,40 @@ class AlreadyExistsError(FSError):
"The directory or file name we're trying to add already exists" "The directory or file name we're trying to add already exists"
cls_message = "'{name}' already exists in '{parent}'" cls_message = "'{name}' already exists in '{parent}'"
class InvalidPath(FSError): class InvalidPath(FSError):
"The path of self is invalid, and cannot be worked with." "The path of self is invalid, and cannot be worked with."
cls_message = "'{name}' is invalid." cls_message = "'{name}' is invalid."
class InvalidDestinationError(FSError): class InvalidDestinationError(FSError):
"""A copy/move operation has been called, but the destination is invalid.""" """A copy/move operation has been called, but the destination is invalid."""
cls_message = "'{name}' is an invalid destination for this operation." cls_message = "'{name}' is an invalid destination for this operation."
class OperationError(FSError): class OperationError(FSError):
"""A copy/move/delete operation has been called, but the checkup after the """A copy/move/delete operation has been called, but the checkup after the
operation shows that it didn't work.""" operation shows that it didn't work."""
cls_message = "Operation on '{name}' failed." cls_message = "Operation on '{name}' failed."
class FilesDB:
schema_version = 1
schema_version_description = "Changed from md5 to xxhash if available."
create_table_query = """CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER,
entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)"""
drop_table_query = "DROP TABLE IF EXISTS files;"
select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns"
select_query_ignore_mtime = "SELECT {key} FROM files WHERE path=:path AND size=:size"
insert_query = """
INSERT INTO files (path, size, mtime_ns, entry_dt, {key})
VALUES (:path, :size, :mtime_ns, datetime('now'), :value)
ON CONFLICT(path) DO UPDATE SET size=:size, mtime_ns=:mtime_ns, entry_dt=datetime('now'), {key}=:value;
"""
ignore_mtime = False
def __init__(self):
self.conn = None
self.lock = None
def connect(self, path: Union[AnyStr, os.PathLike]) -> None:
if platform.startswith("gnu0"):
self.conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
else:
self.conn = sqlite3.connect(path, check_same_thread=False)
self.lock = Lock()
self._check_upgrade()
def _check_upgrade(self) -> None:
with self.lock, self.conn as conn:
has_schema = conn.execute(
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchall()
version = None
if has_schema:
version = conn.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0]
else:
conn.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)")
if version != self.schema_version:
conn.execute(self.drop_table_query)
conn.execute(
"INSERT OR REPLACE INTO schema_version VALUES (:version, :description)",
{"version": self.schema_version, "description": self.schema_version_description},
)
conn.execute(self.create_table_query)
def clear(self) -> None:
with self.lock, self.conn as conn:
conn.execute(self.drop_table_query)
conn.execute(self.create_table_query)
def get(self, path: Path, key: str) -> Union[bytes, None]:
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
try:
with self.conn as conn:
if self.ignore_mtime:
cursor = conn.execute(
self.select_query_ignore_mtime.format(key=key), {"path": str(path), "size": size}
)
else:
cursor = conn.execute(
self.select_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns},
)
result = cursor.fetchone()
cursor.close()
if result:
return result[0]
except Exception as ex:
logging.warning(f"Couldn't get {key} for {path} w/{size}, {mtime_ns}: {ex}")
return None
def put(self, path: Path, key: str, value: Any) -> None:
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
try:
with self.lock, self.conn as conn:
conn.execute(
self.insert_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
)
except Exception as ex:
logging.warning(f"Couldn't put {key} for {path} w/{size}, {mtime_ns}: {ex}")
def commit(self) -> None:
with self.lock:
self.conn.commit()
def close(self) -> None:
with self.lock:
self.conn.close()
filesdb = FilesDB() # Singleton
class File: class File:
"""Represents a file and holds metadata to be used for scanning.""" """Represents a file and holds metadata to be used for scanning.
"""
INITIAL_INFO = {"size": 0, "mtime": 0, "digest": b"", "digest_partial": b"", "digest_samples": b""} INITIAL_INFO = {
'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 # Slots for File make us save quite a bit of memory. In a memory test I've made with a lot of
# files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become # files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become
# even greater when we take into account read attributes (70%!). Yeah, it's worth it. # even greater when we take into account read attributes (70%!). Yeah, it's worth it.
__slots__ = ("path", "unicode_path", "is_ref", "words") + tuple(INITIAL_INFO.keys()) __slots__ = ('path', 'is_ref', 'words') + tuple(INITIAL_INFO.keys())
def __init__(self, path): def __init__(self, path):
self.path = path
for attrname in self.INITIAL_INFO: for attrname in self.INITIAL_INFO:
setattr(self, attrname, NOT_SET) setattr(self, attrname, NOT_SET)
if type(path) is os.DirEntry:
self.path = Path(path.path)
self.size = nonone(path.stat().st_size, 0)
self.mtime = nonone(path.stat().st_mtime, 0)
else:
self.path = path
if self.path:
self.unicode_path = str(self.path)
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__} {str(self.path)}>" return "<{} {}>".format(self.__class__.__name__, str(self.path))
def __getattribute__(self, attrname): def __getattribute__(self, attrname):
result = object.__getattribute__(self, attrname) result = object.__getattribute__(self, attrname)
@ -235,77 +96,43 @@ class File:
result = self.INITIAL_INFO[attrname] result = self.INITIAL_INFO[attrname]
return result return result
def _calc_digest(self): #This offset is where we should start reading the file to get a partial md5
# type: () -> bytes #For audio file, it should be where audio data starts
def _get_md5partial_offset_and_size(self):
with self.path.open("rb") as fp: return (0x4000, 0x4000) #16Kb
file_hash = hasher()
# The goal here is to not run out of memory on really big files. However, the chunk
# size has to be large enough so that the python loop isn't too costly in terms of
# CPU.
CHUNK_SIZE = 1024 * 1024 # 1 mb
filedata = fp.read(CHUNK_SIZE)
while filedata:
file_hash.update(filedata)
filedata = fp.read(CHUNK_SIZE)
return file_hash.digest()
def _calc_digest_partial(self):
# type: () -> bytes
with self.path.open("rb") as fp:
fp.seek(PARTIAL_OFFSET_SIZE[0])
partial_data = fp.read(PARTIAL_OFFSET_SIZE[1])
return hasher(partial_data).digest()
def _calc_digest_samples(self) -> bytes:
size = self.size
with self.path.open("rb") as fp:
# Chunk at 25% of the file
fp.seek(floor(size * 25 / 100), 0)
file_data = fp.read(CHUNK_SIZE)
file_hash = hasher(file_data)
# Chunk at 60% of the file
fp.seek(floor(size * 60 / 100), 0)
file_data = fp.read(CHUNK_SIZE)
file_hash.update(file_data)
# Last chunk of the file
fp.seek(-CHUNK_SIZE, 2)
file_data = fp.read(CHUNK_SIZE)
file_hash.update(file_data)
return file_hash.digest()
def _read_info(self, field): def _read_info(self, field):
# print(f"_read_info({field}) for {self}") if field in ('size', 'mtime'):
if field in ("size", "mtime"):
stats = self.path.stat() stats = self.path.stat()
self.size = nonone(stats.st_size, 0) self.size = nonone(stats.st_size, 0)
self.mtime = nonone(stats.st_mtime, 0) self.mtime = nonone(stats.st_mtime, 0)
elif field == "digest_partial": elif field == 'md5partial':
self.digest_partial = filesdb.get(self.path, "digest_partial") try:
if self.digest_partial is None: fp = self.path.open('rb')
# If file is smaller than partial requirements just use the full digest offset, size = self._get_md5partial_offset_and_size()
if self.size < PARTIAL_OFFSET_SIZE[0] + PARTIAL_OFFSET_SIZE[1]: fp.seek(offset)
self.digest_partial = self.digest partialdata = fp.read(size)
else: md5 = hashlib.md5(partialdata)
self.digest_partial = self._calc_digest_partial() self.md5partial = md5.digest()
filesdb.put(self.path, "digest_partial", self.digest_partial) fp.close()
elif field == "digest": except Exception:
self.digest = filesdb.get(self.path, "digest") pass
if self.digest is None: elif field == 'md5':
self.digest = self._calc_digest() try:
filesdb.put(self.path, "digest", self.digest) fp = self.path.open('rb')
elif field == "digest_samples": md5 = hashlib.md5()
size = self.size # The goal here is to not run out of memory on really big files. However, the chunk
# Might as well hash such small files entirely. # size has to be large enough so that the python loop isn't too costly in terms of
if size <= MIN_FILE_SIZE: # CPU.
self.digest_samples = self.digest CHUNK_SIZE = 1024 * 1024 # 1 mb
return filedata = fp.read(CHUNK_SIZE)
self.digest_samples = filesdb.get(self.path, "digest_samples") while filedata:
if self.digest_samples is None: md5.update(filedata)
self.digest_samples = self._calc_digest_samples() filedata = fp.read(CHUNK_SIZE)
filesdb.put(self.path, "digest_samples", self.digest_samples) self.md5 = md5.digest()
fp.close()
except Exception:
pass
def _read_all_info(self, attrnames=None): def _read_all_info(self, attrnames=None):
"""Cache all possible info. """Cache all possible info.
@ -317,39 +144,33 @@ class File:
for attrname in attrnames: for attrname in attrnames:
getattr(self, attrname) getattr(self, attrname)
# --- Public #--- Public
@classmethod @classmethod
def can_handle(cls, path): def can_handle(cls, path):
"""Returns whether this file wrapper class can handle ``path``.""" """Returns whether this file wrapper class can handle ``path``.
return not path.is_symlink() and path.is_file() """
return not path.islink() and path.isfile()
def exists(self) -> bool:
"""Safely check if the underlying file exists, treat error as non-existent"""
try:
return self.path.exists()
except OSError as ex:
logging.warning(f"Checking {self.path} raised: {ex}")
return False
def rename(self, newname): def rename(self, newname):
if newname == self.name: if newname == self.name:
return return
destpath = self.path.parent.joinpath(newname) destpath = self.path.parent()[newname]
if destpath.exists(): if destpath.exists():
raise AlreadyExistsError(newname, self.path.parent) raise AlreadyExistsError(newname, self.path.parent())
try: try:
self.path.rename(destpath) self.path.rename(destpath)
except OSError: except EnvironmentError:
raise OperationError(self) raise OperationError(self)
if not destpath.exists(): if not destpath.exists():
raise OperationError(self) raise OperationError(self)
self.path = destpath self.path = destpath
def get_display_info(self, group, delta): def get_display_info(self, group, delta):
"""Returns a display-ready dict of dupe's data.""" """Returns a display-ready dict of dupe's data.
"""
raise NotImplementedError() raise NotImplementedError()
# --- Properties #--- Properties
@property @property
def extension(self): def extension(self):
return get_file_ext(self.name) return get_file_ext(self.name)
@ -360,20 +181,18 @@ class File:
@property @property
def folder_path(self): def folder_path(self):
return self.path.parent return self.path.parent()
class Folder(File): class Folder(File):
"""A wrapper around a folder path. """A wrapper around a folder path.
It has the size/digest info of a File, but its value is the sum of its subitems. 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): def __init__(self, path):
File.__init__(self, path) File.__init__(self, path)
self.size = NOT_SET
self._subfolders = None self._subfolders = None
def _all_items(self): def _all_items(self):
@ -382,37 +201,35 @@ class Folder(File):
return folders + files return folders + files
def _read_info(self, field): def _read_info(self, field):
# print(f"_read_info({field}) for Folder {self}") if field in {'size', 'mtime'}:
if field in {"size", "mtime"}:
size = sum((f.size for f in self._all_items()), 0) size = sum((f.size for f in self._all_items()), 0)
self.size = size self.size = size
stats = self.path.stat() stats = self.path.stat()
self.mtime = nonone(stats.st_mtime, 0) self.mtime = nonone(stats.st_mtime, 0)
elif field in {"digest", "digest_partial", "digest_samples"}: elif field in {'md5', 'md5partial'}:
# What's sensitive here is that we must make sure that subfiles' # What's sensitive here is that we must make sure that subfiles'
# digest are always added up in the same order, but we also want a # md5 are always added up in the same order, but we also want a
# different digest if a file gets moved in a different subdirectory. # different md5 if a file gets moved in a different subdirectory.
def get_dir_md5_concat():
def get_dir_digest_concat():
items = self._all_items() items = self._all_items()
items.sort(key=lambda f: f.path) items.sort(key=lambda f: f.path)
digests = [getattr(f, field) for f in items] md5s = [getattr(f, field) for f in items]
return b"".join(digests) return b''.join(md5s)
digest = hasher(get_dir_digest_concat()).digest() md5 = hashlib.md5(get_dir_md5_concat())
digest = md5.digest()
setattr(self, field, digest) setattr(self, field, digest)
@property @property
def subfolders(self): def subfolders(self):
if self._subfolders is None: if self._subfolders is None:
with os.scandir(self.path) as iter: subfolders = [p for p in self.path.listdir() if not p.islink() and p.isdir()]
subfolders = [p for p in iter if not p.is_symlink() and p.is_dir()]
self._subfolders = [self.__class__(p) for p in subfolders] self._subfolders = [self.__class__(p) for p in subfolders]
return self._subfolders return self._subfolders
@classmethod @classmethod
def can_handle(cls, path): def can_handle(cls, path):
return not path.is_symlink() and path.is_dir() return not path.islink() and path.isdir()
def get_file(path, fileclasses=[File]): def get_file(path, fileclasses=[File]):
@ -427,7 +244,6 @@ def get_file(path, fileclasses=[File]):
if fileclass.can_handle(path): if fileclass.can_handle(path):
return fileclass(path) return fileclass(path)
def get_files(path, fileclasses=[File]): def get_files(path, fileclasses=[File]):
"""Returns a list of :class:`File` for each file contained in ``path``. """Returns a list of :class:`File` for each file contained in ``path``.
@ -437,11 +253,10 @@ def get_files(path, fileclasses=[File]):
assert all(issubclass(fileclass, File) for fileclass in fileclasses) assert all(issubclass(fileclass, File) for fileclass in fileclasses)
try: try:
result = [] result = []
with os.scandir(path) as iter: for path in path.listdir():
for item in iter: file = get_file(path, fileclasses=fileclasses)
file = get_file(item, fileclasses=fileclasses) if file is not None:
if file is not None: result.append(file)
result.append(file)
return result return result
except OSError: except EnvironmentError:
raise InvalidPath(path) raise InvalidPath(path)

View File

@ -13,3 +13,4 @@ blue, which is supposed to be orange, does the sorting logic, holds selection, e
.. _cross-toolkit: http://www.hardcoded.net/articles/cross-toolkit-software .. _cross-toolkit: http://www.hardcoded.net/articles/cross-toolkit-software
""" """

View File

@ -8,28 +8,23 @@
from hscommon.notify import Listener from hscommon.notify import Listener
class DupeGuruGUIObject(Listener): class DupeGuruGUIObject(Listener):
def __init__(self, app): def __init__(self, app):
Listener.__init__(self, app) Listener.__init__(self, app)
self.app = app self.app = app
def directories_changed(self): def directories_changed(self):
# Implemented in child classes
pass pass
def dupes_selected(self): def dupes_selected(self):
# Implemented in child classes
pass pass
def marking_changed(self): def marking_changed(self):
# Implemented in child classes
pass pass
def results_changed(self): def results_changed(self):
# Implemented in child classes
pass pass
def results_changed_but_keep_selection(self): def results_changed_but_keep_selection(self):
# Implemented in child classes
pass pass

View File

@ -10,7 +10,6 @@ import os
from hscommon.gui.base import GUIObject from hscommon.gui.base import GUIObject
from hscommon.trans import tr from hscommon.trans import tr
class DeletionOptionsView: class DeletionOptionsView:
"""Expected interface for :class:`DeletionOptions`'s view. """Expected interface for :class:`DeletionOptions`'s view.
@ -27,9 +26,9 @@ class DeletionOptionsView:
Other than the flags, there's also a prompt message which has a dynamic content, defined by Other than the flags, there's also a prompt message which has a dynamic content, defined by
:meth:`update_msg`. :meth:`update_msg`.
""" """
def update_msg(self, msg: str): def update_msg(self, msg: str):
"""Update the dialog's prompt with ``str``.""" """Update the dialog's prompt with ``str``.
"""
def show(self): def show(self):
"""Show the dialog in a modal fashion. """Show the dialog in a modal fashion.
@ -38,8 +37,8 @@ class DeletionOptionsView:
""" """
def set_hardlink_option_enabled(self, is_enabled: bool): def set_hardlink_option_enabled(self, is_enabled: bool):
"""Enable or disable the widget controlling :attr:`DeletionOptions.use_hardlinks`.""" """Enable or disable the widget controlling :attr:`DeletionOptions.use_hardlinks`.
"""
class DeletionOptions(GUIObject): class DeletionOptions(GUIObject):
"""Present the user with deletion options before proceeding. """Present the user with deletion options before proceeding.
@ -47,7 +46,6 @@ class DeletionOptions(GUIObject):
When the user activates "Send to trash", we present him with a couple of options that changes When the user activates "Send to trash", we present him with a couple of options that changes
the behavior of that deletion operation. the behavior of that deletion operation.
""" """
def __init__(self): def __init__(self):
GUIObject.__init__(self) GUIObject.__init__(self)
#: Whether symlinks or hardlinks are used when doing :attr:`link_deleted`. #: Whether symlinks or hardlinks are used when doing :attr:`link_deleted`.
@ -73,7 +71,8 @@ class DeletionOptions(GUIObject):
return self.view.show() return self.view.show()
def supports_links(self): def supports_links(self):
"""Returns whether our platform supports symlinks.""" """Returns whether our platform supports symlinks.
"""
# When on a platform that doesn't implement it, calling os.symlink() (with the wrong number # When on a platform that doesn't implement it, calling os.symlink() (with the wrong number
# of arguments) raises NotImplementedError, which allows us to gracefully check for the # of arguments) raises NotImplementedError, which allows us to gracefully check for the
# feature. # feature.
@ -104,3 +103,5 @@ class DeletionOptions(GUIObject):
self._link_deleted = value self._link_deleted = value
hardlinks_enabled = value and self.supports_links() hardlinks_enabled = value and self.supports_links()
self.view.set_hardlink_option_enabled(hardlinks_enabled) self.view.set_hardlink_option_enabled(hardlinks_enabled)

View File

@ -7,8 +7,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.gui.base import GUIObject from hscommon.gui.base import GUIObject
from core.gui.base import DupeGuruGUIObject from .base import DupeGuruGUIObject
class DetailsPanel(GUIObject, DupeGuruGUIObject): class DetailsPanel(GUIObject, DupeGuruGUIObject):
def __init__(self, app): def __init__(self, app):
@ -20,7 +19,7 @@ class DetailsPanel(GUIObject, DupeGuruGUIObject):
self._refresh() self._refresh()
self.view.refresh() self.view.refresh()
# --- Private #--- Private
def _refresh(self): def _refresh(self):
if self.app.selected_dupes: if self.app.selected_dupes:
dupe = self.app.selected_dupes[0] dupe = self.app.selected_dupes[0]
@ -32,16 +31,18 @@ class DetailsPanel(GUIObject, DupeGuruGUIObject):
# we don't want the two sides of the table to display the stats for the same file # 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 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) 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] self._table = [(c.display, data1[c.name], data2[c.name]) for c in columns]
# --- Public #--- Public
def row_count(self): def row_count(self):
return len(self._table) return len(self._table)
def row(self, row_index): def row(self, row_index):
return self._table[row_index] return self._table[row_index]
# --- Event Handlers #--- Event Handlers
def dupes_selected(self): def dupes_selected(self):
self._view_updated() self._refresh()
self.view.refresh()

View File

@ -8,11 +8,10 @@
from hscommon.gui.tree import Tree, Node from hscommon.gui.tree import Tree, Node
from core.directories import DirectoryState from ..directories import DirectoryState
from core.gui.base import DupeGuruGUIObject from .base import DupeGuruGUIObject
STATE_ORDER = [DirectoryState.NORMAL, DirectoryState.REFERENCE, DirectoryState.EXCLUDED]
STATE_ORDER = [DirectoryState.Normal, DirectoryState.Reference, DirectoryState.Excluded]
# Lazily loads children # Lazily loads children
class DirectoryNode(Node): class DirectoryNode(Node):
@ -56,7 +55,7 @@ class DirectoryNode(Node):
class DirectoryTree(Tree, DupeGuruGUIObject): class DirectoryTree(Tree, DupeGuruGUIObject):
# --- model -> view calls: #--- model -> view calls:
# refresh() # refresh()
# refresh_states() # when only states label need to be refreshed # refresh_states() # when only states label need to be refreshed
# #
@ -86,9 +85,9 @@ class DirectoryTree(Tree, DupeGuruGUIObject):
else: else:
# All selected nodes or on second-or-more level, exclude them. # All selected nodes or on second-or-more level, exclude them.
nodes = self.selected_nodes nodes = self.selected_nodes
newstate = DirectoryState.EXCLUDED newstate = DirectoryState.Excluded
if all(node.state == DirectoryState.EXCLUDED for node in nodes): if all(node.state == DirectoryState.Excluded for node in nodes):
newstate = DirectoryState.NORMAL newstate = DirectoryState.Normal
for node in nodes: for node in nodes:
node.state = newstate node.state = newstate
@ -101,6 +100,8 @@ class DirectoryTree(Tree, DupeGuruGUIObject):
node.update_all_states() node.update_all_states()
self.view.refresh_states() self.view.refresh_states()
# --- Event Handlers #--- Event Handlers
def directories_changed(self): def directories_changed(self):
self._view_updated() self._refresh()
self.view.refresh()

View File

@ -1,90 +0,0 @@
# 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 core.gui.exclude_list_table import ExcludeListTable
from core.exclude import has_sep
from os import sep
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):
"""Rename the selected regex to ``newregex``.
If there is more than one selected row, the first one is used.
:param str newregex: The regex to rename the row's regex to.
:return bool: true if success, false if error.
"""
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):
self.exclude_list.add(regex)
self.exclude_list.mark(regex)
self.exclude_list_table.add(regex)
def test_string(self, test_string):
"""Set the highlight property on each row when its regex matches the
test_string supplied. Return True if any row matched."""
matched = False
for row in self.exclude_list_table.rows:
compiled_regex = self.exclude_list.get_compiled(row.regex)
if self.is_match(test_string, compiled_regex):
row.highlight = True
matched = True
else:
row.highlight = False
return matched
def is_match(self, test_string, compiled_regex):
# This method is like an inverted version of ExcludeList.is_excluded()
if not compiled_regex:
return False
matched = False
# Test only the filename portion of the path
if not has_sep(compiled_regex.pattern) and sep in test_string:
filename = test_string.rsplit(sep, 1)[1]
if compiled_regex.fullmatch(filename):
matched = True
return matched
# Test the entire path + filename
if compiled_regex.fullmatch(test_string):
matched = True
return matched
def reset_rows_highlight(self):
for row in self.exclude_list_table.rows:
row.highlight = False
def show(self):
self.view.show()

View File

@ -1,96 +0,0 @@
# 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 core.gui.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.dialog.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

View File

@ -6,25 +6,24 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.trans import tr from hscommon.trans import tr
from core.gui.ignore_list_table import IgnoreListTable from .ignore_list_table import IgnoreListTable
class IgnoreListDialog: class IgnoreListDialog:
# --- View interface #--- View interface
# show() # show()
# #
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.ignore_list = self.app.ignore_list self.ignore_list = self.app.ignore_list
self.ignore_list_table = IgnoreListTable(self) # GUITable self.ignore_list_table = IgnoreListTable(self)
def clear(self): def clear(self):
if not self.ignore_list: if not self.ignore_list:
return 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): if self.app.view.ask_yes_no(msg):
self.ignore_list.clear() self.ignore_list.Clear()
self.refresh() self.refresh()
def refresh(self): def refresh(self):
@ -37,3 +36,4 @@ class IgnoreListDialog:
def show(self): def show(self):
self.view.show() self.view.show()

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