diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f89fcb945..9f2ec6d89 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,7 +39,7 @@ jobs: name: Build Cpp run: | sudo apt-get update - sudo apt-get install python3-pyqt5 + sudo apt-get install python3-pyqt6 make modules - if: matrix.language == 'python' name: Autobuild diff --git a/Makefile b/Makefile index c6a1d6b79..e4f5fb24a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ PYTHON ?= python3 PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)") -PYRCC5 ?= pyrcc5 +RCC ?= rcc REQ_MINOR_VERSION = 7 PREFIX ?= /usr/local @@ -35,7 +35,7 @@ endif # 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. -packages = hscommon core qt +packages = dupeguru/hscommon dupeguru/core dupeguru/qt localedirs = $(wildcard locale/*/LC_MESSAGES) pofiles = $(wildcard locale/*/LC_MESSAGES/*.po) mofiles = $(patsubst %.po,%.mo,$(pofiles)) @@ -43,7 +43,7 @@ mofiles = $(patsubst %.po,%.mo,$(pofiles)) vpath %.po $(localedirs) vpath %.mo $(localedirs) -all: | env i18n modules qt/dg_rc.py +all: | env i18n modules dupeguru/qt/dg_rc.py @echo "Build complete! You can run dupeGuru with 'make run'" run: @@ -60,8 +60,8 @@ ifndef NO_VENV @${PYTHON} -m venv -h > /dev/null || \ echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv." endif - @${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \ - { echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; } + @${PYTHON} -c 'import PyQt6' >/dev/null 2>&1 || \ + { echo "PyQt 6.3+ required. Install it and try again. Aborting"; exit 1; } env: | reqs ifndef NO_VENV @@ -76,8 +76,10 @@ endif build/help: | env $(VENV_PYTHON) build.py --doc -qt/dg_rc.py: qt/dg.qrc - $(PYRCC5) qt/dg.qrc > qt/dg_rc.py +dupeguru/qt/dg_rc.py: dupeguru/qt/dg.qrc + $(RCC) -g python dupeguru/qt/dg.qrc -o dupeguru/qt/dg_rc.py + sed -i 's/from PySide2/from PyQt6/' dupeguru/qt/dg_rc.py + sed -i 's/from PySide6/from PyQt6/' dupeguru/qt/dg_rc.py i18n: $(mofiles) @@ -118,6 +120,6 @@ uninstall: clean: -rm -rf build -rm locale/*/LC_MESSAGES/*.mo - -rm core/pe/*.$(SO) qt/pe/*.$(SO) + -rm dupeguru/core/pe/*.$(SO) dupeguru/qt/pe/*.$(SO) .PHONY: clean normpo mergepot modules i18n reqs run pyc install uninstall all diff --git a/README.md b/README.md index f574dee05..ea2ca84d7 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,15 @@ For macos instructions (qt version) see the [macOS Instructions](macos.md). ### Prerequisites * [Python 3.7+][python] -* PyQt5 +* PyQt6 ### System Setup When running in a linux based environment the following system packages or equivalents are needed to build: -* python3-pyqt5 -* pyqt5-dev-tools (on some systems, see note) +* python3-pyqt6 * 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. - To create packages the following are also needed: * python3-setuptools * debhelper diff --git a/Windows.md b/Windows.md index e54737b7e..f185f3cca 100644 --- a/Windows.md +++ b/Windows.md @@ -28,7 +28,7 @@ To build with a different python version 3.7 vs 3.8 or 32 bit vs 64 bit specify ### 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 +2. Install PyQt6 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. diff --git a/build.py b/build.py index ac650b200..5e78c715f 100644 --- a/build.py +++ b/build.py @@ -10,13 +10,13 @@ import shutil from multiprocessing import Pool -from hscommon import sphinxgen -from hscommon.build import ( +from dupeguru.hscommon import sphinxgen +from dupeguru.hscommon.build import ( add_to_pythonpath, print_and_do, fix_qt_resource_file, ) -from hscommon import loc +from dupeguru.hscommon import loc import subprocess @@ -130,7 +130,7 @@ def build_normal(): build_localizations() print("Building Qt stuff") Path("qt", "dg_rc.py").unlink(missing_ok=True) - print_and_do("pyrcc5 {} > {}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py"))) + print_and_do("rcc -g python {} -o {}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py"))) fix_qt_resource_file(Path("qt", "dg_rc.py")) build_help() diff --git a/core/me/__init__.py b/core/me/__init__.py deleted file mode 100644 index d54766262..000000000 --- a/core/me/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from core.me import fs, prioritize, result_table, scanner # noqa diff --git a/core/se/__init__.py b/core/se/__init__.py deleted file mode 100644 index 6d5f677d5..000000000 --- a/core/se/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from core.se import fs, result_table, scanner # noqa diff --git a/core/tests/conftest.py b/core/tests/conftest.py deleted file mode 100644 index 2a9cfd613..000000000 --- a/core/tests/conftest.py +++ /dev/null @@ -1 +0,0 @@ -from hscommon.testutil import app # noqa diff --git a/core/__init__.py b/dupeguru/core/__init__.py similarity index 100% rename from core/__init__.py rename to dupeguru/core/__init__.py diff --git a/core/app.py b/dupeguru/core/app.py similarity index 93% rename from core/app.py rename to dupeguru/core/app.py index 7cde6317d..e6f9107e5 100644 --- a/core/app.py +++ b/dupeguru/core/app.py @@ -9,34 +9,34 @@ import os import os.path as op import logging +import shlex import subprocess -import re import shutil from pathlib import Path from send2trash import send2trash -from hscommon.jobprogress import job -from hscommon.notify import Broadcaster -from hscommon.conflict import smart_move, smart_copy -from hscommon.gui.progress_window import ProgressWindow -from hscommon.util import delete_if_empty, first, escape, nonone, allsame -from hscommon.trans import tr -from hscommon import desktop - -from core import se, me, pe -from core.pe.photo import get_delta_dimensions -from core.util import cmp_value, fix_surrogate_encoding -from core import directories, results, export, fs, prioritize -from core.ignore import IgnoreList -from core.exclude import ExcludeDict as ExcludeList -from core.scanner import ScanType -from core.gui.deletion_options import DeletionOptions -from core.gui.details_panel import DetailsPanel -from core.gui.directory_tree import DirectoryTree -from core.gui.ignore_list_dialog import IgnoreListDialog -from core.gui.exclude_list_dialog import ExcludeListDialogCore -from core.gui.problem_dialog import ProblemDialog -from core.gui.stats_label import StatsLabel +from dupeguru.hscommon.jobprogress import job +from dupeguru.hscommon.notify import Broadcaster +from dupeguru.hscommon.conflict import smart_move, smart_copy +from dupeguru.hscommon.gui.progress_window import ProgressWindow +from dupeguru.hscommon.util import delete_if_empty, first, escape, nonone, allsame +from dupeguru.hscommon.trans import tr +from dupeguru.hscommon import desktop + +from dupeguru.core import se, me, pe +from dupeguru.core.pe.photo import get_delta_dimensions +from dupeguru.core.util import cmp_value, fix_surrogate_encoding +from dupeguru.core import directories, results, export, fs, prioritize +from dupeguru.core.ignore import IgnoreList +from dupeguru.core.exclude import ExcludeDict as ExcludeList +from dupeguru.core.scanner import ScanType +from dupeguru.core.gui.deletion_options import DeletionOptions +from dupeguru.core.gui.details_panel import DetailsPanel +from dupeguru.core.gui.directory_tree import DirectoryTree +from dupeguru.core.gui.ignore_list_dialog import IgnoreListDialog +from dupeguru.core.gui.exclude_list_dialog import ExcludeListDialogCore +from dupeguru.core.gui.problem_dialog import ProblemDialog +from dupeguru.core.gui.stats_label import StatsLabel HAD_FIRST_LAUNCH_PREFERENCE = "HadFirstLaunch" DEBUG_MODE_PREFERENCE = "DebugMode" @@ -182,7 +182,10 @@ def _recreate_result_table(self): self.view.create_results_window() def _get_picture_cache_path(self): - cache_name = "cached_pictures.db" + prescale = self.options.get("picture_prescale", 1) + # Balanced (1) = no suffix for backward compat with existing caches + suffixes = ["_accurate", "", "_turbo"] + cache_name = f"cached_pictures{suffixes[prescale]}.db" return op.join(self.appdata, cache_name) def _get_dupe_sort_key(self, dupe, get_group, key, delta): @@ -433,7 +436,7 @@ def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType): if dest_type in {DestType.RELATIVE, DestType.ABSOLUTE}: # no filename, no windows drive letter source_base = source_path.relative_to(source_path.anchor).parent - if dest_type == DestType.RELATIVE: + if dest_type == DestType.RELATIVE and location_path is not None: source_base = source_base.relative_to(location_path.relative_to(location_path.anchor)) dest_path = dest_path.joinpath(source_base) if not dest_path.exists(): @@ -544,24 +547,22 @@ def invoke_custom_command(self): dupes = self.selected_dupes refs = [self.results.get_group_of_duplicate(dupe).ref for dupe in dupes] for dupe, ref in zip(dupes, refs): - dupe_cmd = cmd.replace("%d", str(dupe.path)) - dupe_cmd = dupe_cmd.replace("%r", str(ref.path)) - match = re.match(r'"([^"]+)"(.*)', dupe_cmd) - if match is not None: - # 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 - # a workaround to make the damn thing work. - exepath, args = match.groups() - path, exename = op.split(exepath) - p = subprocess.Popen( - exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - output = p.stdout.read() - logging.info("Custom command %s %s: %s", exename, args, output) - else: - p = subprocess.Popen(dupe_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + # Tokenize the command template safely before substituting paths + try: + cmd_parts = shlex.split(cmd) + except ValueError as e: + logging.error("Invalid custom command syntax: %s", e) + self.view.show_message(tr("Invalid custom command syntax: {}").format(str(e))) + return + # Replace placeholders in each token individually to prevent injection + cmd_parts = [part.replace("%d", str(dupe.path)).replace("%r", str(ref.path)) for part in cmd_parts] + try: + p = subprocess.Popen(cmd_parts, shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output = p.stdout.read() - logging.info("Custom command %s: %s", dupe_cmd, output) + logging.info("Custom command %s: %s", cmd_parts, output) + except OSError as e: + logging.error("Failed to execute custom command %s: %s", cmd_parts, e) + self.view.show_message(tr("Failed to execute custom command: {}").format(str(e))) def load(self): """Load directory selection and ignore list from files in appdata. diff --git a/core/directories.py b/dupeguru/core/directories.py similarity index 97% rename from core/directories.py rename to dupeguru/core/directories.py index a30e263a0..274794861 100644 --- a/core/directories.py +++ b/dupeguru/core/directories.py @@ -6,14 +6,15 @@ import os from xml.etree import ElementTree as ET +from defusedxml.ElementTree import parse as safe_parse import logging from pathlib import Path -from hscommon.jobprogress import job -from hscommon.util import FileOrPath -from hscommon.trans import tr +from dupeguru.hscommon.jobprogress import job +from dupeguru.hscommon.util import FileOrPath +from dupeguru.hscommon.trans import tr -from core import fs +from dupeguru.core import fs __all__ = [ "Directories", @@ -246,7 +247,7 @@ def load_from_file(self, infile): :param file infile: path or file pointer to XML generated through :meth:`save_to_file` """ try: - root = ET.parse(infile).getroot() + root = safe_parse(infile).getroot() except Exception: return for rdn in root.iter("root_directory"): diff --git a/core/engine.py b/dupeguru/core/engine.py similarity index 99% rename from core/engine.py rename to dupeguru/core/engine.py index 934152fbb..08a0c9027 100644 --- a/core/engine.py +++ b/dupeguru/core/engine.py @@ -13,9 +13,9 @@ from collections import defaultdict, namedtuple from unicodedata import normalize -from hscommon.util import flatten, multi_replace -from hscommon.trans import tr -from hscommon.jobprogress import job +from dupeguru.hscommon.util import flatten, multi_replace +from dupeguru.hscommon.trans import tr +from dupeguru.hscommon.jobprogress import job ( WEIGHT_WORDS, diff --git a/core/exclude.py b/dupeguru/core/exclude.py similarity index 98% rename from core/exclude.py rename to dupeguru/core/exclude.py index e304ef122..d83fb0bb6 100644 --- a/core/exclude.py +++ b/dupeguru/core/exclude.py @@ -2,8 +2,9 @@ # 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 dupeguru.core.markable import Markable from xml.etree import ElementTree as ET +from defusedxml.ElementTree import parse as safe_parse # TODO: perhaps use regex module for better Unicode support? https://pypi.org/project/regex/ # also https://pypi.org/project/re2/ @@ -12,8 +13,8 @@ from os import sep import logging import functools -from hscommon.util import FileOrPath -from hscommon.plat import ISWINDOWS +from dupeguru.hscommon.util import FileOrPath +from dupeguru.hscommon.plat import ISWINDOWS import time default_regexes = [ @@ -321,7 +322,7 @@ def load_from_xml(self, infile): infile can be a file object or a filename. """ try: - root = ET.parse(infile).getroot() + root = safe_parse(infile).getroot() except Exception as e: logging.warning(f"Error while loading {infile}: {e}") self.restore_defaults() diff --git a/core/export.py b/dupeguru/core/export.py similarity index 100% rename from core/export.py rename to dupeguru/core/export.py diff --git a/core/fs.py b/dupeguru/core/fs.py similarity index 97% rename from core/fs.py rename to dupeguru/core/fs.py index a62d29ce8..b2e7d58d0 100644 --- a/core/fs.py +++ b/dupeguru/core/fs.py @@ -21,7 +21,7 @@ from typing import Any, AnyStr, Union, Callable from pathlib import Path -from hscommon.util import nonone, get_file_ext +from dupeguru.hscommon.util import nonone, get_file_ext hasher: Callable try: @@ -112,6 +112,9 @@ class FilesDB: ON CONFLICT(path) DO UPDATE SET size=:size, mtime_ns=:mtime_ns, entry_dt=datetime('now'), {key}=:value; """ + # Whitelist of valid column names to prevent SQL injection via .format(key=key) + VALID_KEYS = {"digest", "digest_partial", "digest_samples"} + ignore_mtime = False def __init__(self): @@ -150,6 +153,8 @@ def clear(self) -> None: conn.execute(self.create_table_query) def get(self, path: Path, key: str) -> Union[bytes, None]: + if key not in self.VALID_KEYS: + raise ValueError(f"Invalid cache key: {key}") stat = path.stat() size = stat.st_size mtime_ns = stat.st_mtime_ns @@ -175,6 +180,8 @@ def get(self, path: Path, key: str) -> Union[bytes, None]: return None def put(self, path: Path, key: str, value: Any) -> None: + if key not in self.VALID_KEYS: + raise ValueError(f"Invalid cache key: {key}") stat = path.stat() size = stat.st_size mtime_ns = stat.st_mtime_ns diff --git a/core/gui/__init__.py b/dupeguru/core/gui/__init__.py similarity index 100% rename from core/gui/__init__.py rename to dupeguru/core/gui/__init__.py diff --git a/core/gui/base.py b/dupeguru/core/gui/base.py similarity index 95% rename from core/gui/base.py rename to dupeguru/core/gui/base.py index 72095b81d..08f24364d 100644 --- a/core/gui/base.py +++ b/dupeguru/core/gui/base.py @@ -6,7 +6,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.notify import Listener +from dupeguru.hscommon.notify import Listener class DupeGuruGUIObject(Listener): diff --git a/core/gui/deletion_options.py b/dupeguru/core/gui/deletion_options.py similarity index 97% rename from core/gui/deletion_options.py rename to dupeguru/core/gui/deletion_options.py index ec6093b96..c2f101da6 100644 --- a/core/gui/deletion_options.py +++ b/dupeguru/core/gui/deletion_options.py @@ -7,8 +7,8 @@ import os -from hscommon.gui.base import GUIObject -from hscommon.trans import tr +from dupeguru.hscommon.gui.base import GUIObject +from dupeguru.hscommon.trans import tr class DeletionOptionsView: diff --git a/core/gui/details_panel.py b/dupeguru/core/gui/details_panel.py similarity index 93% rename from core/gui/details_panel.py rename to dupeguru/core/gui/details_panel.py index f6a4e8207..7535c16d9 100644 --- a/core/gui/details_panel.py +++ b/dupeguru/core/gui/details_panel.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.base import GUIObject -from core.gui.base import DupeGuruGUIObject +from dupeguru.hscommon.gui.base import GUIObject +from dupeguru.core.gui.base import DupeGuruGUIObject class DetailsPanel(GUIObject, DupeGuruGUIObject): diff --git a/core/gui/directory_tree.py b/dupeguru/core/gui/directory_tree.py similarity index 95% rename from core/gui/directory_tree.py rename to dupeguru/core/gui/directory_tree.py index e44da9f06..3b5e5fe73 100644 --- a/core/gui/directory_tree.py +++ b/dupeguru/core/gui/directory_tree.py @@ -6,10 +6,10 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.tree import Tree, Node +from dupeguru.hscommon.gui.tree import Tree, Node -from core.directories import DirectoryState -from core.gui.base import DupeGuruGUIObject +from dupeguru.core.directories import DirectoryState +from dupeguru.core.gui.base import DupeGuruGUIObject STATE_ORDER = [DirectoryState.NORMAL, DirectoryState.REFERENCE, DirectoryState.EXCLUDED] diff --git a/core/gui/exclude_list_dialog.py b/dupeguru/core/gui/exclude_list_dialog.py similarity index 96% rename from core/gui/exclude_list_dialog.py rename to dupeguru/core/gui/exclude_list_dialog.py index 2769bc670..0932680f1 100644 --- a/core/gui/exclude_list_dialog.py +++ b/dupeguru/core/gui/exclude_list_dialog.py @@ -5,8 +5,8 @@ # 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 dupeguru.core.gui.exclude_list_table import ExcludeListTable +from dupeguru.core.exclude import has_sep from os import sep import logging diff --git a/core/gui/exclude_list_table.py b/dupeguru/core/gui/exclude_list_table.py similarity index 93% rename from core/gui/exclude_list_table.py rename to dupeguru/core/gui/exclude_list_table.py index 30b36505d..2b24b2373 100644 --- a/core/gui/exclude_list_table.py +++ b/dupeguru/core/gui/exclude_list_table.py @@ -2,10 +2,10 @@ # 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 +from dupeguru.core.gui.base import DupeGuruGUIObject +from dupeguru.hscommon.gui.table import GUITable, Row +from dupeguru.hscommon.gui.column import Column, Columns +from dupeguru.hscommon.trans import trget tr = trget("ui") diff --git a/core/gui/ignore_list_dialog.py b/dupeguru/core/gui/ignore_list_dialog.py similarity index 91% rename from core/gui/ignore_list_dialog.py rename to dupeguru/core/gui/ignore_list_dialog.py index 9ea67ea8e..9866bc7e0 100644 --- a/core/gui/ignore_list_dialog.py +++ b/dupeguru/core/gui/ignore_list_dialog.py @@ -5,8 +5,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.trans import tr -from core.gui.ignore_list_table import IgnoreListTable +from dupeguru.hscommon.trans import tr +from dupeguru.core.gui.ignore_list_table import IgnoreListTable class IgnoreListDialog: diff --git a/core/gui/ignore_list_table.py b/dupeguru/core/gui/ignore_list_table.py similarity index 88% rename from core/gui/ignore_list_table.py rename to dupeguru/core/gui/ignore_list_table.py index 912d8e0f3..4477dbdf4 100644 --- a/core/gui/ignore_list_table.py +++ b/dupeguru/core/gui/ignore_list_table.py @@ -6,9 +6,9 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.table import GUITable, Row -from hscommon.gui.column import Column, Columns -from hscommon.trans import trget +from dupeguru.hscommon.gui.table import GUITable, Row +from dupeguru.hscommon.gui.column import Column, Columns +from dupeguru.hscommon.trans import trget coltr = trget("columns") diff --git a/core/gui/prioritize_dialog.py b/dupeguru/core/gui/prioritize_dialog.py similarity index 96% rename from core/gui/prioritize_dialog.py rename to dupeguru/core/gui/prioritize_dialog.py index adcd85156..4e5b0fc27 100644 --- a/core/gui/prioritize_dialog.py +++ b/dupeguru/core/gui/prioritize_dialog.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.base import GUIObject -from hscommon.gui.selectable_list import GUISelectableList +from dupeguru.hscommon.gui.base import GUIObject +from dupeguru.hscommon.gui.selectable_list import GUISelectableList class CriterionCategoryList(GUISelectableList): diff --git a/core/gui/problem_dialog.py b/dupeguru/core/gui/problem_dialog.py similarity index 89% rename from core/gui/problem_dialog.py rename to dupeguru/core/gui/problem_dialog.py index 35f9ad0e8..8724cdeef 100644 --- a/core/gui/problem_dialog.py +++ b/dupeguru/core/gui/problem_dialog.py @@ -6,9 +6,9 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon import desktop +from dupeguru.hscommon import desktop -from core.gui.problem_table import ProblemTable +from dupeguru.core.gui.problem_table import ProblemTable class ProblemDialog: diff --git a/core/gui/problem_table.py b/dupeguru/core/gui/problem_table.py similarity index 88% rename from core/gui/problem_table.py rename to dupeguru/core/gui/problem_table.py index 1be8b85ff..260344244 100644 --- a/core/gui/problem_table.py +++ b/dupeguru/core/gui/problem_table.py @@ -6,9 +6,9 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.table import GUITable, Row -from hscommon.gui.column import Column, Columns -from hscommon.trans import trget +from dupeguru.hscommon.gui.table import GUITable, Row +from dupeguru.hscommon.gui.column import Column, Columns +from dupeguru.hscommon.trans import trget coltr = trget("columns") diff --git a/core/gui/result_table.py b/dupeguru/core/gui/result_table.py similarity index 97% rename from core/gui/result_table.py rename to dupeguru/core/gui/result_table.py index 5582e467b..63d261d25 100644 --- a/core/gui/result_table.py +++ b/dupeguru/core/gui/result_table.py @@ -8,10 +8,10 @@ from operator import attrgetter -from hscommon.gui.table import GUITable, Row -from hscommon.gui.column import Columns +from dupeguru.hscommon.gui.table import GUITable, Row +from dupeguru.hscommon.gui.column import Columns -from core.gui.base import DupeGuruGUIObject +from dupeguru.core.gui.base import DupeGuruGUIObject class DupeRow(Row): diff --git a/core/gui/stats_label.py b/dupeguru/core/gui/stats_label.py similarity index 91% rename from core/gui/stats_label.py rename to dupeguru/core/gui/stats_label.py index fa9fe0054..6c198cf87 100644 --- a/core/gui/stats_label.py +++ b/dupeguru/core/gui/stats_label.py @@ -6,7 +6,7 @@ # 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 dupeguru.core.gui.base import DupeGuruGUIObject class StatsLabel(DupeGuruGUIObject): diff --git a/core/ignore.py b/dupeguru/core/ignore.py similarity index 96% rename from core/ignore.py rename to dupeguru/core/ignore.py index 095da4fe2..26c6b8e81 100644 --- a/core/ignore.py +++ b/dupeguru/core/ignore.py @@ -7,8 +7,9 @@ # http://www.gnu.org/licenses/gpl-3.0.html from xml.etree import ElementTree as ET +from defusedxml.ElementTree import parse as safe_parse -from hscommon.util import FileOrPath +from dupeguru.hscommon.util import FileOrPath class IgnoreList: @@ -96,7 +97,7 @@ def load_from_xml(self, infile): infile can be a file object or a filename. """ try: - root = ET.parse(infile).getroot() + root = safe_parse(infile).getroot() except Exception: return file_elems = (e for e in root if e.tag == "file") diff --git a/core/markable.py b/dupeguru/core/markable.py similarity index 100% rename from core/markable.py rename to dupeguru/core/markable.py diff --git a/dupeguru/core/me/__init__.py b/dupeguru/core/me/__init__.py new file mode 100644 index 000000000..a22192af1 --- /dev/null +++ b/dupeguru/core/me/__init__.py @@ -0,0 +1 @@ +from dupeguru.core.me import fs, prioritize, result_table, scanner # noqa diff --git a/core/me/fs.py b/dupeguru/core/me/fs.py similarity index 95% rename from core/me/fs.py rename to dupeguru/core/me/fs.py index fbfdfa09f..c45dd7a2b 100644 --- a/core/me/fs.py +++ b/dupeguru/core/me/fs.py @@ -7,10 +7,10 @@ # http://www.gnu.org/licenses/gpl-3.0.html import mutagen -from hscommon.util import get_file_ext, format_size, format_time +from dupeguru.hscommon.util import get_file_ext, format_size, format_time -from core.util import format_timestamp, format_perc, format_words, format_dupe_count -from core import fs +from dupeguru.core.util import format_timestamp, format_perc, format_words, format_dupe_count +from dupeguru.core import fs TAG_FIELDS = { "audiosize", diff --git a/core/me/prioritize.py b/dupeguru/core/me/prioritize.py similarity index 93% rename from core/me/prioritize.py rename to dupeguru/core/me/prioritize.py index 8d468f3a4..8056fc107 100644 --- a/core/me/prioritize.py +++ b/dupeguru/core/me/prioritize.py @@ -5,9 +5,9 @@ # 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 trget +from dupeguru.hscommon.trans import trget -from core.prioritize import ( +from dupeguru.core.prioritize import ( KindCategory, FolderCategory, FilenameCategory, diff --git a/core/me/result_table.py b/dupeguru/core/me/result_table.py similarity index 91% rename from core/me/result_table.py rename to dupeguru/core/me/result_table.py index ea7c45f59..2a61761be 100644 --- a/core/me/result_table.py +++ b/dupeguru/core/me/result_table.py @@ -5,10 +5,10 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.column import Column -from hscommon.trans import trget +from dupeguru.hscommon.gui.column import Column +from dupeguru.hscommon.trans import trget -from core.gui.result_table import ResultTable as ResultTableBase +from dupeguru.core.gui.result_table import ResultTable as ResultTableBase coltr = trget("columns") diff --git a/core/me/scanner.py b/dupeguru/core/me/scanner.py similarity index 84% rename from core/me/scanner.py rename to dupeguru/core/me/scanner.py index 491df7188..70a56b635 100644 --- a/core/me/scanner.py +++ b/dupeguru/core/me/scanner.py @@ -4,9 +4,9 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.trans import tr +from dupeguru.hscommon.trans import tr -from core.scanner import Scanner as ScannerBase, ScanOption, ScanType +from dupeguru.core.scanner import Scanner as ScannerBase, ScanOption, ScanType class ScannerME(ScannerBase): diff --git a/core/pe/__init__.py b/dupeguru/core/pe/__init__.py similarity index 75% rename from core/pe/__init__.py rename to dupeguru/core/pe/__init__.py index 56d45b7b5..0bb82f35f 100644 --- a/core/pe/__init__.py +++ b/dupeguru/core/pe/__init__.py @@ -1,4 +1,4 @@ -from core.pe import ( # noqa +from dupeguru.core.pe import ( # noqa block, cache, exif, diff --git a/core/pe/block.py b/dupeguru/core/pe/block.py similarity index 97% rename from core/pe/block.py rename to dupeguru/core/pe/block.py index 84084e6f5..964836d86 100644 --- a/core/pe/block.py +++ b/dupeguru/core/pe/block.py @@ -6,7 +6,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from core.pe._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 # NOQA +from dupeguru.core.pe._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 # NOQA # Converted to C # def getblock(image): diff --git a/core/pe/block.pyi b/dupeguru/core/pe/block.pyi similarity index 100% rename from core/pe/block.pyi rename to dupeguru/core/pe/block.pyi diff --git a/core/pe/cache.py b/dupeguru/core/pe/cache.py similarity index 88% rename from core/pe/cache.py rename to dupeguru/core/pe/cache.py index 738037f79..2335d231f 100644 --- a/core/pe/cache.py +++ b/dupeguru/core/pe/cache.py @@ -4,7 +4,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from core.pe._cache import bytes_to_colors # noqa +from dupeguru.core.pe._cache import bytes_to_colors # noqa def colors_to_bytes(colors): diff --git a/core/pe/cache.pyi b/dupeguru/core/pe/cache.pyi similarity index 100% rename from core/pe/cache.pyi rename to dupeguru/core/pe/cache.pyi diff --git a/core/pe/cache_sqlite.py b/dupeguru/core/pe/cache_sqlite.py similarity index 93% rename from core/pe/cache_sqlite.py rename to dupeguru/core/pe/cache_sqlite.py index f7020e466..fb0057ee1 100644 --- a/core/pe/cache_sqlite.py +++ b/dupeguru/core/pe/cache_sqlite.py @@ -9,7 +9,7 @@ import logging import sqlite3 as sqlite -from core.pe.cache import bytes_to_colors, colors_to_bytes +from dupeguru.core.pe.cache import bytes_to_colors, colors_to_bytes class SqliteCache: @@ -102,6 +102,7 @@ def __setitem__(self, path_str, blocks): def _create_con(self, second_try=False): try: self.con = sqlite.connect(self.dbname, isolation_level=None) + self.con.execute("PRAGMA journal_mode=WAL") self._check_upgrade() except sqlite.DatabaseError as e: # corrupted db if second_try: @@ -155,12 +156,12 @@ def get_id(self, path): raise ValueError(path) def get_multiple(self, rowids): - ids = ",".join(map(str, rowids)) + placeholders = ",".join("?" * len(rowids)) sql = ( "select rowid, blocks, blocks2, blocks3, blocks4, blocks5, blocks6, blocks7, blocks8 " - f"from pictures where rowid in ({ids})" + f"from pictures where rowid in ({placeholders})" ) - cur = self.con.execute(sql) + cur = self.con.execute(sql, list(rowids)) return ( ( rowid, @@ -195,5 +196,6 @@ def purge_outdated(self): continue todelete.append(rowid) if todelete: - sql = "delete from pictures where rowid in (%s)" % ",".join(map(str, todelete)) - self.con.execute(sql) + placeholders = ",".join("?" * len(todelete)) + sql = f"delete from pictures where rowid in ({placeholders})" + self.con.execute(sql, todelete) diff --git a/core/pe/exif.py b/dupeguru/core/pe/exif.py similarity index 100% rename from core/pe/exif.py rename to dupeguru/core/pe/exif.py diff --git a/core/pe/matchblock.py b/dupeguru/core/pe/matchblock.py similarity index 73% rename from core/pe/matchblock.py rename to dupeguru/core/pe/matchblock.py index 0aa0d2535..cfae04f6b 100644 --- a/core/pe/matchblock.py +++ b/dupeguru/core/pe/matchblock.py @@ -8,15 +8,17 @@ import logging import multiprocessing +import os +from concurrent.futures import ThreadPoolExecutor, as_completed from itertools import combinations -from hscommon.util import extract, iterconsume -from hscommon.trans import tr -from hscommon.jobprogress import job +from dupeguru.hscommon.util import extract, iterconsume +from dupeguru.hscommon.trans import tr +from dupeguru.hscommon.jobprogress import job -from core.engine import Match -from core.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError -from core.pe.cache_sqlite import SqliteCache +from dupeguru.core.engine import Match +from dupeguru.core.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError +from dupeguru.core.pe.cache_sqlite import SqliteCache # OPTIMIZATION NOTES: # The bottleneck of the matching phase is CPU, which is why we use multiprocessing. However, another @@ -37,6 +39,7 @@ BLOCK_COUNT_PER_SIDE = 15 DEFAULT_CHUNK_SIZE = 1000 MIN_CHUNK_SIZE = 100 +PRESCALE_MULTIPLIERS = [0, 30, 10] # 0 = no scaling, 30 = 450px, 10 = 150px # Enough so that we're sure that the main thread will not wait after a result.get() call # cpucount+1 should be enough to be sure that the spawned process will not wait after the results @@ -54,50 +57,73 @@ def get_cache(cache_path, readonly=False): return SqliteCache(cache_path, readonly=readonly) -def prepare_pictures(pictures, cache_path, with_dimensions, match_rotated, j=job.nulljob): - # The MemoryError handlers in there use logging without first caring about whether or not - # there is enough memory left to carry on the operation because it is assumed that the - # MemoryError happens when trying to read an image file, which is freed from memory by the - # time that MemoryError is raised. +def _extract_blocks(picture, with_dimensions, match_rotated, prescale_multiplier): + """Extract blocks for one picture. Returns (picture, blocks) or (picture, None) on error.""" + picture._prescale_multiplier = prescale_multiplier + if not picture.path: + logging.warning("We have a picture with a null path here") + return (picture, None) + logging.debug("Analyzing picture at %s", picture.unicode_path) + if with_dimensions: + picture.dimensions # pre-read dimensions + try: + if match_rotated: + blocks = [picture.get_blocks(BLOCK_COUNT_PER_SIDE, orientation) for orientation in range(1, 9)] + else: + blocks = [[]] * 8 + blocks[max(picture.get_orientation() - 1, 0)] = picture.get_blocks(BLOCK_COUNT_PER_SIDE) + return (picture, blocks) + except (OSError, ValueError) as e: + logging.warning(str(e)) + return (picture, None) + except MemoryError: + logging.warning( + "Ran out of memory while reading %s of size %d", + picture.unicode_path, + picture.size, + ) + if picture.size < 10 * 1024 * 1024: + raise + return (picture, None) + + +def prepare_pictures(pictures, cache_path, with_dimensions, match_rotated, picture_prescale=1, j=job.nulljob): + prescale_multiplier = PRESCALE_MULTIPLIERS[picture_prescale] cache = get_cache(cache_path) cache.purge_outdated() - prepared = [] # only pictures for which there was no error getting blocks - try: - for picture in j.iter_with_progress(pictures, tr("Analyzed %d/%d pictures")): - if not picture.path: - # XXX Find the root cause of this. I've received reports of crashes where we had - # "Analyzing picture at " (without a path) in the debug log. It was an iPhoto scan. - # For now, I'm simply working around the crash by ignoring those, but it would be - # interesting to know exactly why this happens. I'm suspecting a malformed - # entry in iPhoto library. - logging.warning("We have a picture with a null path here") - continue - logging.debug("Analyzing picture at %s", picture.unicode_path) - if with_dimensions: - picture.dimensions # pre-read dimensions - try: - if picture.unicode_path not in cache or ( - match_rotated and any(block == [] for block in cache[picture.unicode_path]) - ): - if match_rotated: - blocks = [picture.get_blocks(BLOCK_COUNT_PER_SIDE, orientation) for orientation in range(1, 9)] - else: - blocks = [[]] * 8 - blocks[max(picture.get_orientation() - 1, 0)] = picture.get_blocks(BLOCK_COUNT_PER_SIDE) - cache[picture.unicode_path] = blocks + prepared = [] + needs_extraction = [] + for picture in pictures: + if not picture.path: + continue + try: + if picture.unicode_path not in cache or ( + match_rotated and any(block == [] for block in cache[picture.unicode_path]) + ): + needs_extraction.append(picture) + else: prepared.append(picture) - except (OSError, ValueError) as e: - logging.warning(str(e)) - except MemoryError: - logging.warning( - "Ran out of memory while reading %s of size %d", - picture.unicode_path, - picture.size, - ) - if picture.size < 10 * 1024 * 1024: # We're really running out of memory - raise - except MemoryError: - logging.warning("Ran out of memory while preparing pictures") + except (OSError, KeyError): + needs_extraction.append(picture) + if needs_extraction: + try: + with ThreadPoolExecutor(max_workers=os.cpu_count()) as executor: + futures = { + executor.submit(_extract_blocks, pic, with_dimensions, match_rotated, prescale_multiplier): pic + for pic in needs_extraction + } + total = len(futures) + for i, future in enumerate(as_completed(futures)): + j.set_progress( + (i + 1) * 100 // total, + tr("Analyzed %d/%d pictures") % (i + 1, total), + ) + picture, blocks = future.result() + if blocks is not None: + cache[picture.unicode_path] = blocks + prepared.append(picture) + except MemoryError: + logging.warning("Ran out of memory while preparing pictures") cache.close() return prepared @@ -168,7 +194,7 @@ def async_compare(ref_ids, other_ids, dbname, threshold, picinfo, match_rotated= return results -def getmatches(pictures, cache_path, threshold, match_scaled=False, match_rotated=False, j=job.nulljob): +def getmatches(pictures, cache_path, threshold, match_scaled=False, match_rotated=False, picture_prescale=1, j=job.nulljob): def get_picinfo(p): if match_scaled: return ((None, None), p.is_ref) @@ -193,7 +219,7 @@ def collect_results(collect_all=False): j.set_progress(comparison_count, progress_msg) j = j.start_subjob([3, 7]) - pictures = prepare_pictures(pictures, cache_path, not match_scaled, match_rotated, j=j) + pictures = prepare_pictures(pictures, cache_path, not match_scaled, match_rotated, picture_prescale, j=j) j = j.start_subjob([9, 1], tr("Preparing for matching")) cache = get_cache(cache_path) id2picture = {} diff --git a/core/pe/matchexif.py b/dupeguru/core/pe/matchexif.py similarity index 93% rename from core/pe/matchexif.py rename to dupeguru/core/pe/matchexif.py index 29d4283ad..bb30d809b 100644 --- a/core/pe/matchexif.py +++ b/dupeguru/core/pe/matchexif.py @@ -9,9 +9,9 @@ from collections import defaultdict from itertools import combinations -from hscommon.trans import tr +from dupeguru.hscommon.trans import tr -from core.engine import Match +from dupeguru.core.engine import Match def getmatches(files, match_scaled, j): diff --git a/core/pe/modules/block.c b/dupeguru/core/pe/modules/block.c similarity index 100% rename from core/pe/modules/block.c rename to dupeguru/core/pe/modules/block.c diff --git a/core/pe/modules/block_osx.m b/dupeguru/core/pe/modules/block_osx.m similarity index 100% rename from core/pe/modules/block_osx.m rename to dupeguru/core/pe/modules/block_osx.m diff --git a/core/pe/modules/cache.c b/dupeguru/core/pe/modules/cache.c similarity index 100% rename from core/pe/modules/cache.c rename to dupeguru/core/pe/modules/cache.c diff --git a/core/pe/modules/common.c b/dupeguru/core/pe/modules/common.c similarity index 100% rename from core/pe/modules/common.c rename to dupeguru/core/pe/modules/common.c diff --git a/core/pe/modules/common.h b/dupeguru/core/pe/modules/common.h similarity index 100% rename from core/pe/modules/common.h rename to dupeguru/core/pe/modules/common.h diff --git a/core/pe/photo.py b/dupeguru/core/pe/photo.py similarity index 95% rename from core/pe/photo.py rename to dupeguru/core/pe/photo.py index 06c6c245b..c574bdc53 100644 --- a/core/pe/photo.py +++ b/dupeguru/core/pe/photo.py @@ -5,11 +5,11 @@ # http://www.gnu.org/licenses/gpl-3.0.html import logging -from hscommon.util import get_file_ext, format_size +from dupeguru.hscommon.util import get_file_ext, format_size -from core.util import format_timestamp, format_perc, format_dupe_count -from core import fs -from core.pe import exif +from dupeguru.core.util import format_timestamp, format_perc, format_dupe_count +from dupeguru.core import fs +from dupeguru.core.pe import exif # This global value is set by the platform-specific subclasser of the Photo base class PLAT_SPECIFIC_PHOTO_CLASS = None diff --git a/core/pe/prioritize.py b/dupeguru/core/pe/prioritize.py similarity index 91% rename from core/pe/prioritize.py rename to dupeguru/core/pe/prioritize.py index 407848224..6e54cafa5 100644 --- a/core/pe/prioritize.py +++ b/dupeguru/core/pe/prioritize.py @@ -5,9 +5,9 @@ # 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 trget +from dupeguru.hscommon.trans import trget -from core.prioritize import ( +from dupeguru.core.prioritize import ( KindCategory, FolderCategory, FilenameCategory, diff --git a/core/pe/result_table.py b/dupeguru/core/pe/result_table.py similarity index 86% rename from core/pe/result_table.py rename to dupeguru/core/pe/result_table.py index 3216a411f..55e13741f 100644 --- a/core/pe/result_table.py +++ b/dupeguru/core/pe/result_table.py @@ -5,10 +5,10 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.column import Column -from hscommon.trans import trget +from dupeguru.hscommon.gui.column import Column +from dupeguru.hscommon.trans import trget -from core.gui.result_table import ResultTable as ResultTableBase +from dupeguru.core.gui.result_table import ResultTable as ResultTableBase coltr = trget("columns") diff --git a/core/pe/scanner.py b/dupeguru/core/pe/scanner.py similarity index 83% rename from core/pe/scanner.py rename to dupeguru/core/pe/scanner.py index 8a1d53d26..b3653bce6 100644 --- a/core/pe/scanner.py +++ b/dupeguru/core/pe/scanner.py @@ -4,17 +4,18 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.trans import tr +from dupeguru.hscommon.trans import tr -from core.scanner import Scanner, ScanType, ScanOption +from dupeguru.core.scanner import Scanner, ScanType, ScanOption -from core.pe import matchblock, matchexif +from dupeguru.core.pe import matchblock, matchexif class ScannerPE(Scanner): cache_path = None match_scaled = False match_rotated = False + picture_prescale = 1 @staticmethod def get_scan_options(): @@ -31,6 +32,7 @@ def _getmatches(self, files, j): threshold=self.min_match_percentage, match_scaled=self.match_scaled, match_rotated=self.match_rotated, + picture_prescale=self.picture_prescale, j=j, ) elif self.scan_type == ScanType.EXIFTIMESTAMP: diff --git a/core/prioritize.py b/dupeguru/core/prioritize.py similarity index 97% rename from core/prioritize.py rename to dupeguru/core/prioritize.py index 065969a66..fd17a9d12 100644 --- a/core/prioritize.py +++ b/dupeguru/core/prioritize.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.util import dedupe, flatten, rem_file_ext -from hscommon.trans import trget, tr +from dupeguru.hscommon.util import dedupe, flatten, rem_file_ext +from dupeguru.hscommon.trans import trget, tr coltr = trget("columns") diff --git a/core/results.py b/dupeguru/core/results.py similarity index 97% rename from core/results.py rename to dupeguru/core/results.py index f9c9356e9..e6c03309f 100644 --- a/core/results.py +++ b/dupeguru/core/results.py @@ -12,14 +12,15 @@ import os.path as op from errno import EISDIR, EACCES from xml.etree import ElementTree as ET +from defusedxml.ElementTree import parse as safe_parse -from hscommon.jobprogress.job import nulljob -from hscommon.conflict import get_conflicted_name -from hscommon.util import flatten, nonone, FileOrPath, format_size -from hscommon.trans import tr +from dupeguru.hscommon.jobprogress.job import nulljob +from dupeguru.hscommon.conflict import get_conflicted_name +from dupeguru.hscommon.util import flatten, nonone, FileOrPath, format_size +from dupeguru.hscommon.trans import tr -from core import engine -from core.markable import Markable +from dupeguru.core import engine +from dupeguru.core.markable import Markable class Results(Markable): @@ -228,7 +229,7 @@ def do_match(ref_file, other_files, group): do_match(other_files[0], other_files[1:], group) self.apply_filter(None) - root = ET.parse(infile).getroot() + root = safe_parse(infile).getroot() group_elems = list(root.iter("group")) groups = [] marked = set() diff --git a/core/scanner.py b/dupeguru/core/scanner.py similarity index 97% rename from core/scanner.py rename to dupeguru/core/scanner.py index cd2c0f020..9d1aee7d6 100644 --- a/core/scanner.py +++ b/dupeguru/core/scanner.py @@ -9,11 +9,11 @@ import os.path as op from collections import namedtuple -from hscommon.jobprogress import job -from hscommon.util import dedupe, rem_file_ext, get_file_ext -from hscommon.trans import tr +from dupeguru.hscommon.jobprogress import job +from dupeguru.hscommon.util import dedupe, rem_file_ext, get_file_ext +from dupeguru.hscommon.trans import tr -from core import engine +from dupeguru.core import engine # It's quite ugly to have scan types from all editions all put in the same class, but because there's # there will be some nasty bugs popping up (ScanType is used in core when in should exclusively be diff --git a/dupeguru/core/se/__init__.py b/dupeguru/core/se/__init__.py new file mode 100644 index 000000000..15c26b9bc --- /dev/null +++ b/dupeguru/core/se/__init__.py @@ -0,0 +1 @@ +from dupeguru.core.se import fs, result_table, scanner # noqa diff --git a/core/se/fs.py b/dupeguru/core/se/fs.py similarity index 88% rename from core/se/fs.py rename to dupeguru/core/se/fs.py index aa8edbe81..f33e519ec 100644 --- a/core/se/fs.py +++ b/dupeguru/core/se/fs.py @@ -6,10 +6,10 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.util import format_size +from dupeguru.hscommon.util import format_size -from core import fs -from core.util import format_timestamp, format_perc, format_words, format_dupe_count +from dupeguru.core import fs +from dupeguru.core.util import format_timestamp, format_perc, format_words, format_dupe_count def get_display_info(dupe, group, delta): diff --git a/core/se/result_table.py b/dupeguru/core/se/result_table.py similarity index 85% rename from core/se/result_table.py rename to dupeguru/core/se/result_table.py index a97a8f504..137f7b6a3 100644 --- a/core/se/result_table.py +++ b/dupeguru/core/se/result_table.py @@ -5,10 +5,10 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.column import Column -from hscommon.trans import trget +from dupeguru.hscommon.gui.column import Column +from dupeguru.hscommon.trans import trget -from core.gui.result_table import ResultTable as ResultTableBase +from dupeguru.core.gui.result_table import ResultTable as ResultTableBase coltr = trget("columns") diff --git a/core/se/scanner.py b/dupeguru/core/se/scanner.py similarity index 82% rename from core/se/scanner.py rename to dupeguru/core/se/scanner.py index 711df5541..542beaee6 100644 --- a/core/se/scanner.py +++ b/dupeguru/core/se/scanner.py @@ -4,9 +4,9 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.trans import tr +from dupeguru.hscommon.trans import tr -from core.scanner import Scanner as ScannerBase, ScanOption, ScanType +from dupeguru.core.scanner import Scanner as ScannerBase, ScanOption, ScanType class ScannerSE(ScannerBase): diff --git a/core/tests/__init__.py b/dupeguru/core/tests/__init__.py similarity index 100% rename from core/tests/__init__.py rename to dupeguru/core/tests/__init__.py diff --git a/core/tests/app_test.py b/dupeguru/core/tests/app_test.py similarity index 90% rename from core/tests/app_test.py rename to dupeguru/core/tests/app_test.py index 6d6581aae..7fcf316ec 100644 --- a/core/tests/app_test.py +++ b/dupeguru/core/tests/app_test.py @@ -11,15 +11,15 @@ import pytest from pathlib import Path -import hscommon.conflict -import hscommon.util -from hscommon.testutil import eq_, log_calls -from hscommon.jobprogress.job import Job +import dupeguru.hscommon.conflict +import dupeguru.hscommon.util +from dupeguru.hscommon.testutil import eq_, log_calls +from dupeguru.hscommon.jobprogress.job import Job -from core.tests.base import TestApp -from core.tests.results_test import GetTestGroups -from core import app, fs, engine -from core.scanner import ScanType +from dupeguru.core.tests.base import TestApp +from dupeguru.core.tests.results_test import GetTestGroups +from dupeguru.core import app, fs, engine +from dupeguru.core.scanner import ScanType def add_fake_files_to_directories(directories, files): @@ -59,20 +59,20 @@ def test_copy_or_move(self, tmpdir, monkeypatch): p = Path(str(tmpdir)) p.joinpath("foo").touch() monkeypatch.setattr( - hscommon.conflict, + dupeguru.hscommon.conflict, "smart_copy", log_calls(lambda source_path, dest_path: None), ) # XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher. - monkeypatch.setattr(app, "smart_copy", hscommon.conflict.smart_copy) + monkeypatch.setattr(app, "smart_copy", dupeguru.hscommon.conflict.smart_copy) monkeypatch.setattr(os, "makedirs", lambda path: None) # We don't want the test to create that fake directory dgapp = TestApp().app dgapp.directories.add_path(p) [f] = dgapp.directories.get_files() with tempfile.TemporaryDirectory() as tmp_dir: dgapp.copy_or_move(f, True, tmp_dir, 0) - eq_(1, len(hscommon.conflict.smart_copy.calls)) - call = hscommon.conflict.smart_copy.calls[0] + eq_(1, len(dupeguru.hscommon.conflict.smart_copy.calls)) + call = dupeguru.hscommon.conflict.smart_copy.calls[0] eq_(call["dest_path"], Path(tmp_dir, "foo")) eq_(call["source_path"], f.path) @@ -90,6 +90,26 @@ def test_copy_or_move_clean_empty_dirs(self, tmpdir, monkeypatch): eq_(1, len(calls)) eq_(sourcepath, calls[0]["path"]) + def test_copy_or_move_no_location_path(self, tmpdir, monkeypatch): + # dupe path has no parent in self.directories (e.g. directory removed + # between scan and copy); previously raised AttributeError on None + p = Path(str(tmpdir)) + p.joinpath("foo").touch() + monkeypatch.setattr( + dupeguru.hscommon.conflict, + "smart_copy", + log_calls(lambda source_path, dest_path: None), + ) + monkeypatch.setattr(app, "smart_copy", dupeguru.hscommon.conflict.smart_copy) + monkeypatch.setattr(os, "makedirs", lambda path: None) + dgapp = TestApp().app + dgapp.directories.add_path(p) + [f] = dgapp.directories.get_files() + dgapp.directories._dirs.clear() + with tempfile.TemporaryDirectory() as tmp_dir: + dgapp.copy_or_move(f, True, tmp_dir, 1) + eq_(1, len(dupeguru.hscommon.conflict.smart_copy.calls)) + def test_scan_with_objects_evaluating_to_false(self): class FakeFile(fs.File): def __bool__(self): @@ -131,22 +151,22 @@ class TestCaseDupeGuruCleanEmptyDirs: def do_setup(self, request): monkeypatch = request.getfixturevalue("monkeypatch") monkeypatch.setattr( - hscommon.util, + dupeguru.hscommon.util, "delete_if_empty", log_calls(lambda path, files_to_delete=[]: None), ) # XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher. - monkeypatch.setattr(app, "delete_if_empty", hscommon.util.delete_if_empty) + monkeypatch.setattr(app, "delete_if_empty", dupeguru.hscommon.util.delete_if_empty) self.app = TestApp().app def test_option_off(self, do_setup): self.app.clean_empty_dirs(Path("/foo/bar")) - eq_(0, len(hscommon.util.delete_if_empty.calls)) + eq_(0, len(dupeguru.hscommon.util.delete_if_empty.calls)) def test_option_on(self, do_setup): self.app.options["clean_empty_dirs"] = True self.app.clean_empty_dirs(Path("/foo/bar")) - calls = hscommon.util.delete_if_empty.calls + calls = dupeguru.hscommon.util.delete_if_empty.calls eq_(1, len(calls)) eq_(Path("/foo/bar"), calls[0]["path"]) eq_([".DS_Store"], calls[0]["files_to_delete"]) @@ -157,12 +177,12 @@ def test_recurse_up(self, do_setup, monkeypatch): def mock_delete_if_empty(path, files_to_delete=[]): return len(path.parts) > 1 - monkeypatch.setattr(hscommon.util, "delete_if_empty", mock_delete_if_empty) + monkeypatch.setattr(dupeguru.hscommon.util, "delete_if_empty", mock_delete_if_empty) # XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher. monkeypatch.setattr(app, "delete_if_empty", mock_delete_if_empty) self.app.options["clean_empty_dirs"] = True self.app.clean_empty_dirs(Path("not-empty/empty/empty")) - calls = hscommon.util.delete_if_empty.calls + calls = dupeguru.hscommon.util.delete_if_empty.calls eq_(3, len(calls)) eq_(Path("not-empty/empty/empty"), calls[0]["path"]) eq_(Path("not-empty/empty"), calls[1]["path"]) diff --git a/core/tests/base.py b/dupeguru/core/tests/base.py similarity index 90% rename from core/tests/base.py rename to dupeguru/core/tests/base.py index eeea7d6a2..b84b87fec 100644 --- a/core/tests/base.py +++ b/dupeguru/core/tests/base.py @@ -4,17 +4,17 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.testutil import TestApp as TestAppBase, CallLogger, eq_, with_app # noqa +from dupeguru.hscommon.testutil import TestApp as TestAppBase, CallLogger, eq_, with_app # noqa from pathlib import Path -from hscommon.util import get_file_ext, format_size -from hscommon.gui.column import Column -from hscommon.jobprogress.job import nulljob, JobCancelled - -from core import engine, prioritize -from core.engine import getwords -from core.app import DupeGuru as DupeGuruBase -from core.gui.result_table import ResultTable as ResultTableBase -from core.gui.prioritize_dialog import PrioritizeDialog +from dupeguru.hscommon.util import get_file_ext, format_size +from dupeguru.hscommon.gui.column import Column +from dupeguru.hscommon.jobprogress.job import nulljob, JobCancelled + +from dupeguru.core import engine, prioritize +from dupeguru.core.engine import getwords +from dupeguru.core.app import DupeGuru as DupeGuruBase +from dupeguru.core.gui.result_table import ResultTable as ResultTableBase +from dupeguru.core.gui.prioritize_dialog import PrioritizeDialog class DupeGuruView: diff --git a/core/tests/block_test.py b/dupeguru/core/tests/block_test.py similarity index 97% rename from core/tests/block_test.py rename to dupeguru/core/tests/block_test.py index caec436c5..a0a3fdd9f 100644 --- a/core/tests/block_test.py +++ b/dupeguru/core/tests/block_test.py @@ -6,10 +6,10 @@ # The commented out tests are tests for function that have been converted to pure C for speed from pytest import raises, skip -from hscommon.testutil import eq_ +from dupeguru.hscommon.testutil import eq_ try: - from core.pe.block import avgdiff, getblocks2, NoBlocksError, DifferentBlockCountError + from dupeguru.core.pe.block import avgdiff, getblocks2, NoBlocksError, DifferentBlockCountError except ImportError: skip("Can't import the block module, probably hasn't been compiled.") diff --git a/core/tests/cache_test.py b/dupeguru/core/tests/cache_test.py similarity index 96% rename from core/tests/cache_test.py rename to dupeguru/core/tests/cache_test.py index 6e9e0e882..17822c81a 100644 --- a/core/tests/cache_test.py +++ b/dupeguru/core/tests/cache_test.py @@ -7,11 +7,11 @@ import logging from pytest import raises, skip -from hscommon.testutil import eq_ +from dupeguru.hscommon.testutil import eq_ try: - from core.pe.cache import colors_to_bytes, bytes_to_colors - from core.pe.cache_sqlite import SqliteCache + from dupeguru.core.pe.cache import colors_to_bytes, bytes_to_colors + from dupeguru.core.pe.cache_sqlite import SqliteCache except ImportError: skip("Can't import the cache module, probably hasn't been compiled.") diff --git a/dupeguru/core/tests/conftest.py b/dupeguru/core/tests/conftest.py new file mode 100644 index 000000000..1a71dd8c8 --- /dev/null +++ b/dupeguru/core/tests/conftest.py @@ -0,0 +1 @@ +from dupeguru.hscommon.testutil import app # noqa diff --git a/core/tests/directories_test.py b/dupeguru/core/tests/directories_test.py similarity index 98% rename from core/tests/directories_test.py rename to dupeguru/core/tests/directories_test.py index 8e0fe7bf4..22b7d2087 100644 --- a/core/tests/directories_test.py +++ b/dupeguru/core/tests/directories_test.py @@ -11,17 +11,17 @@ from pytest import raises from pathlib import Path -from hscommon.testutil import eq_ -from hscommon.plat import ISWINDOWS +from dupeguru.hscommon.testutil import eq_ +from dupeguru.hscommon.plat import ISWINDOWS -from core.fs import File -from core.directories import ( +from dupeguru.core.fs import File +from dupeguru.core.directories import ( Directories, DirectoryState, AlreadyThereError, InvalidPathError, ) -from core.exclude import ExcludeList, ExcludeDict +from dupeguru.core.exclude import ExcludeList, ExcludeDict def create_fake_fs(rootpath): diff --git a/core/tests/engine_test.py b/dupeguru/core/tests/engine_test.py similarity index 99% rename from core/tests/engine_test.py rename to dupeguru/core/tests/engine_test.py index 2e5170b9a..bd77a7b7a 100644 --- a/core/tests/engine_test.py +++ b/dupeguru/core/tests/engine_test.py @@ -6,13 +6,13 @@ import sys -from hscommon.jobprogress import job -from hscommon.util import first -from hscommon.testutil import eq_, log_calls +from dupeguru.hscommon.jobprogress import job +from dupeguru.hscommon.util import first +from dupeguru.hscommon.testutil import eq_, log_calls -from core.tests.base import NamedObject -from core import engine -from core.engine import ( +from dupeguru.core.tests.base import NamedObject +from dupeguru.core import engine +from dupeguru.core.engine import ( get_match, getwords, Group, diff --git a/core/tests/exclude_test.py b/dupeguru/core/tests/exclude_test.py similarity index 98% rename from core/tests/exclude_test.py rename to dupeguru/core/tests/exclude_test.py index 64314b976..676d56f1b 100644 --- a/core/tests/exclude_test.py +++ b/dupeguru/core/tests/exclude_test.py @@ -7,11 +7,11 @@ import io from xml.etree import ElementTree as ET -from hscommon.testutil import eq_ -from hscommon.plat import ISWINDOWS +from dupeguru.hscommon.testutil import eq_ +from dupeguru.hscommon.plat import ISWINDOWS -from core.tests.base import DupeGuru -from core.exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException +from dupeguru.core.tests.base import DupeGuru +from dupeguru.core.exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException from re import error diff --git a/core/tests/fs_test.py b/dupeguru/core/tests/fs_test.py similarity index 97% rename from core/tests/fs_test.py rename to dupeguru/core/tests/fs_test.py index d0f2634be..0e0cd7732 100644 --- a/core/tests/fs_test.py +++ b/dupeguru/core/tests/fs_test.py @@ -10,10 +10,10 @@ from os import urandom from pathlib import Path -from hscommon.testutil import eq_ -from core.tests.directories_test import create_fake_fs +from dupeguru.hscommon.testutil import eq_ +from dupeguru.core.tests.directories_test import create_fake_fs -from core import fs +from dupeguru.core import fs hasher: typing.Callable try: diff --git a/core/tests/ignore_test.py b/dupeguru/core/tests/ignore_test.py similarity index 98% rename from core/tests/ignore_test.py rename to dupeguru/core/tests/ignore_test.py index 7e859cebe..fcb42e760 100644 --- a/core/tests/ignore_test.py +++ b/dupeguru/core/tests/ignore_test.py @@ -8,9 +8,9 @@ from xml.etree import ElementTree as ET from pytest import raises -from hscommon.testutil import eq_ +from dupeguru.hscommon.testutil import eq_ -from core.ignore import IgnoreList +from dupeguru.core.ignore import IgnoreList def test_empty(): diff --git a/core/tests/markable_test.py b/dupeguru/core/tests/markable_test.py similarity index 97% rename from core/tests/markable_test.py rename to dupeguru/core/tests/markable_test.py index 27ca80d13..c61fa53e9 100644 --- a/core/tests/markable_test.py +++ b/dupeguru/core/tests/markable_test.py @@ -4,9 +4,9 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.testutil import eq_ +from dupeguru.hscommon.testutil import eq_ -from core.markable import MarkableList, Markable +from dupeguru.core.markable import MarkableList, Markable def gen(): diff --git a/core/tests/prioritize_test.py b/dupeguru/core/tests/prioritize_test.py similarity index 98% rename from core/tests/prioritize_test.py rename to dupeguru/core/tests/prioritize_test.py index 87909a2f0..1a0041eb0 100644 --- a/core/tests/prioritize_test.py +++ b/dupeguru/core/tests/prioritize_test.py @@ -9,8 +9,8 @@ import os.path as op from itertools import combinations -from core.tests.base import TestApp, NamedObject, with_app, eq_ -from core.engine import Group, Match +from dupeguru.core.tests.base import TestApp, NamedObject, with_app, eq_ +from dupeguru.core.engine import Group, Match no = NamedObject diff --git a/core/tests/result_table_test.py b/dupeguru/core/tests/result_table_test.py similarity index 97% rename from core/tests/result_table_test.py rename to dupeguru/core/tests/result_table_test.py index 3f5e4c656..22c533c0e 100644 --- a/core/tests/result_table_test.py +++ b/dupeguru/core/tests/result_table_test.py @@ -6,7 +6,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from core.tests.base import TestApp, GetTestGroups +from dupeguru.core.tests.base import TestApp, GetTestGroups def app_with_results(): diff --git a/core/tests/results_test.py b/dupeguru/core/tests/results_test.py similarity index 99% rename from core/tests/results_test.py rename to dupeguru/core/tests/results_test.py index c83141c77..beb2ab0a5 100644 --- a/core/tests/results_test.py +++ b/dupeguru/core/tests/results_test.py @@ -10,11 +10,11 @@ from xml.etree import ElementTree as ET from pytest import raises -from hscommon.testutil import eq_ -from hscommon.util import first -from core import engine -from core.tests.base import NamedObject, GetTestGroups, DupeGuru -from core.results import Results +from dupeguru.hscommon.testutil import eq_ +from dupeguru.hscommon.util import first +from dupeguru.core import engine +from dupeguru.core.tests.base import NamedObject, GetTestGroups, DupeGuru +from dupeguru.core.results import Results class TestCaseResultsEmpty: diff --git a/core/tests/scanner_test.py b/dupeguru/core/tests/scanner_test.py similarity index 98% rename from core/tests/scanner_test.py rename to dupeguru/core/tests/scanner_test.py index 6dc315fee..ac1147794 100644 --- a/core/tests/scanner_test.py +++ b/dupeguru/core/tests/scanner_test.py @@ -6,15 +6,15 @@ import pytest -from hscommon.jobprogress import job +from dupeguru.hscommon.jobprogress import job from pathlib import Path -from hscommon.testutil import eq_ +from dupeguru.hscommon.testutil import eq_ -from core import fs -from core.engine import getwords, Match -from core.ignore import IgnoreList -from core.scanner import Scanner, ScanType -from core.me.scanner import ScannerME +from dupeguru.core import fs +from dupeguru.core.engine import getwords, Match +from dupeguru.core.ignore import IgnoreList +from dupeguru.core.scanner import Scanner, ScanType +from dupeguru.core.me.scanner import ScannerME # TODO update this to be able to inherit from fs.File diff --git a/core/util.py b/dupeguru/core/util.py similarity index 98% rename from core/util.py rename to dupeguru/core/util.py index 4c3feef3f..1ca677c12 100644 --- a/core/util.py +++ b/dupeguru/core/util.py @@ -14,7 +14,7 @@ import logging from typing import Union -from hscommon.util import format_time_decimal +from dupeguru.hscommon.util import format_time_decimal def format_timestamp(t, delta): diff --git a/hscommon/LICENSE b/dupeguru/hscommon/LICENSE similarity index 100% rename from hscommon/LICENSE rename to dupeguru/hscommon/LICENSE diff --git a/hscommon/README b/dupeguru/hscommon/README similarity index 100% rename from hscommon/README rename to dupeguru/hscommon/README diff --git a/hscommon/__init__.py b/dupeguru/hscommon/__init__.py similarity index 100% rename from hscommon/__init__.py rename to dupeguru/hscommon/__init__.py diff --git a/hscommon/build.py b/dupeguru/hscommon/build.py similarity index 96% rename from hscommon/build.py rename to dupeguru/hscommon/build.py index bf1e46605..83352f4e9 100644 --- a/hscommon/build.py +++ b/dupeguru/hscommon/build.py @@ -23,7 +23,7 @@ import glob from typing import Any, AnyStr, Callable, Dict, List, Union -from hscommon.plat import ISWINDOWS +from dupeguru.hscommon.plat import ISWINDOWS def print_and_do(cmd: str) -> int: @@ -309,12 +309,16 @@ def iter_by_three(it): def fix_qt_resource_file(path: os.PathLike) -> None: - # pyrcc5 under Windows, if the locale is non-english, can produce a source file with a date + # rcc under Windows, if the locale is non-english, can produce a source file with a date # containing accented characters. If it does, the encoding is wrong and it prevents the file # from being correctly frozen by cx_freeze. To work around that, we open the file, strip all # comments, and save. with open(path, "rb") as fp: contents = fp.read() + # Depending on the Qt toolchain version, rcc can generate imports for + # either PySide2 or PySide6. dupeGuru uses PyQt6 at runtime. + contents = contents.replace(b"PySide2", b"PyQt6") + contents = contents.replace(b"PySide6", b"PyQt6") lines = contents.split(b"\n") lines = [line for line in lines if not line.startswith(b"#")] with open(path, "wb") as fp: diff --git a/hscommon/conflict.py b/dupeguru/hscommon/conflict.py similarity index 100% rename from hscommon/conflict.py rename to dupeguru/hscommon/conflict.py diff --git a/hscommon/desktop.py b/dupeguru/hscommon/desktop.py similarity index 90% rename from hscommon/desktop.py rename to dupeguru/hscommon/desktop.py index 09cbdc7d4..ff89cbb7d 100644 --- a/hscommon/desktop.py +++ b/dupeguru/hscommon/desktop.py @@ -44,11 +44,11 @@ def special_folder_path(special_folder: SpecialFolder, portable: bool = False) - try: - from PyQt5.QtCore import QUrl, QStandardPaths - from PyQt5.QtGui import QDesktopServices - from qt.util import get_appdata - from core.util import executable_folder - from hscommon.plat import ISWINDOWS, ISOSX + from PyQt6.QtCore import QUrl, QStandardPaths + from PyQt6.QtGui import QDesktopServices + from dupeguru.qt.util import get_appdata + from dupeguru.core.util import executable_folder + from dupeguru.hscommon.plat import ISWINDOWS, ISOSX import subprocess def _open_url(url: str) -> None: @@ -71,7 +71,7 @@ def _special_folder_path(special_folder: SpecialFolder, portable: bool = False) if ISWINDOWS and portable: folder = op.join(executable_folder(), "cache") else: - folder = QStandardPaths.standardLocations(QStandardPaths.CacheLocation)[0] + folder = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.CacheLocation)[0] else: folder = get_appdata(portable) return folder diff --git a/hscommon/gui/__init__.py b/dupeguru/hscommon/gui/__init__.py similarity index 100% rename from hscommon/gui/__init__.py rename to dupeguru/hscommon/gui/__init__.py diff --git a/hscommon/gui/base.py b/dupeguru/hscommon/gui/base.py similarity index 100% rename from hscommon/gui/base.py rename to dupeguru/hscommon/gui/base.py diff --git a/hscommon/gui/column.py b/dupeguru/hscommon/gui/column.py similarity index 99% rename from hscommon/gui/column.py rename to dupeguru/hscommon/gui/column.py index 942546ac5..23f8d010c 100644 --- a/hscommon/gui/column.py +++ b/dupeguru/hscommon/gui/column.py @@ -9,8 +9,8 @@ import copy from typing import Any, List, Tuple, Union -from hscommon.gui.base import GUIObject -from hscommon.gui.table import GUITable +from dupeguru.hscommon.gui.base import GUIObject +from dupeguru.hscommon.gui.table import GUITable class Column: diff --git a/hscommon/gui/progress_window.py b/dupeguru/hscommon/gui/progress_window.py similarity index 97% rename from hscommon/gui/progress_window.py rename to dupeguru/hscommon/gui/progress_window.py index 7adafb1d6..60fa7604d 100644 --- a/hscommon/gui/progress_window.py +++ b/dupeguru/hscommon/gui/progress_window.py @@ -5,9 +5,9 @@ # http://www.gnu.org/licenses/gpl-3.0.html from typing import Callable, Tuple, Union -from hscommon.jobprogress.performer import ThreadedJobPerformer -from hscommon.gui.base import GUIObject -from hscommon.gui.text_field import TextField +from dupeguru.hscommon.jobprogress.performer import ThreadedJobPerformer +from dupeguru.hscommon.gui.base import GUIObject +from dupeguru.hscommon.gui.text_field import TextField class ProgressWindowView: diff --git a/hscommon/gui/selectable_list.py b/dupeguru/hscommon/gui/selectable_list.py similarity index 99% rename from hscommon/gui/selectable_list.py rename to dupeguru/hscommon/gui/selectable_list.py index 352c4f5b0..33496bec9 100644 --- a/hscommon/gui/selectable_list.py +++ b/dupeguru/hscommon/gui/selectable_list.py @@ -8,7 +8,7 @@ from collections.abc import Sequence, MutableSequence -from hscommon.gui.base import GUIObject +from dupeguru.hscommon.gui.base import GUIObject class Selectable(Sequence): diff --git a/hscommon/gui/table.py b/dupeguru/hscommon/gui/table.py similarity index 99% rename from hscommon/gui/table.py rename to dupeguru/hscommon/gui/table.py index 8be9d2d77..bfdfd3321 100644 --- a/hscommon/gui/table.py +++ b/dupeguru/hscommon/gui/table.py @@ -10,8 +10,8 @@ from collections import namedtuple from typing import Any, List, Tuple, Union -from hscommon.gui.base import GUIObject -from hscommon.gui.selectable_list import Selectable +from dupeguru.hscommon.gui.base import GUIObject +from dupeguru.hscommon.gui.selectable_list import Selectable # We used to directly subclass list, but it caused problems at some point with deepcopy diff --git a/hscommon/gui/text_field.py b/dupeguru/hscommon/gui/text_field.py similarity index 97% rename from hscommon/gui/text_field.py rename to dupeguru/hscommon/gui/text_field.py index 31a87a258..266445586 100644 --- a/hscommon/gui/text_field.py +++ b/dupeguru/hscommon/gui/text_field.py @@ -5,8 +5,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.gui.base import GUIObject -from hscommon.util import nonone +from dupeguru.hscommon.gui.base import GUIObject +from dupeguru.hscommon.util import nonone class TextFieldView: diff --git a/hscommon/gui/tree.py b/dupeguru/hscommon/gui/tree.py similarity index 99% rename from hscommon/gui/tree.py rename to dupeguru/hscommon/gui/tree.py index c9fbb0480..3c7405cfb 100644 --- a/hscommon/gui/tree.py +++ b/dupeguru/hscommon/gui/tree.py @@ -6,7 +6,7 @@ from collections.abc import MutableSequence -from hscommon.gui.base import GUIObject +from dupeguru.hscommon.gui.base import GUIObject class Node(MutableSequence): diff --git a/hscommon/jobprogress/__init__.py b/dupeguru/hscommon/jobprogress/__init__.py similarity index 100% rename from hscommon/jobprogress/__init__.py rename to dupeguru/hscommon/jobprogress/__init__.py diff --git a/hscommon/jobprogress/job.py b/dupeguru/hscommon/jobprogress/job.py similarity index 100% rename from hscommon/jobprogress/job.py rename to dupeguru/hscommon/jobprogress/job.py diff --git a/hscommon/jobprogress/performer.py b/dupeguru/hscommon/jobprogress/performer.py similarity index 96% rename from hscommon/jobprogress/performer.py rename to dupeguru/hscommon/jobprogress/performer.py index 8e64e5408..19efd82dd 100644 --- a/hscommon/jobprogress/performer.py +++ b/dupeguru/hscommon/jobprogress/performer.py @@ -10,7 +10,7 @@ import sys from typing import Callable, Tuple, Union -from hscommon.jobprogress.job import Job, JobInProgressError, JobCancelled +from dupeguru.hscommon.jobprogress.job import Job, JobInProgressError, JobCancelled class ThreadedJobPerformer: diff --git a/hscommon/loc.py b/dupeguru/hscommon/loc.py similarity index 98% rename from hscommon/loc.py rename to dupeguru/hscommon/loc.py index c185aa6ea..c096d9fe8 100644 --- a/hscommon/loc.py +++ b/dupeguru/hscommon/loc.py @@ -6,7 +6,7 @@ import polib -from hscommon import pygettext +from dupeguru.hscommon import pygettext LC_MESSAGES = "LC_MESSAGES" diff --git a/hscommon/notify.py b/dupeguru/hscommon/notify.py similarity index 100% rename from hscommon/notify.py rename to dupeguru/hscommon/notify.py diff --git a/hscommon/path.py b/dupeguru/hscommon/path.py similarity index 100% rename from hscommon/path.py rename to dupeguru/hscommon/path.py diff --git a/hscommon/plat.py b/dupeguru/hscommon/plat.py similarity index 100% rename from hscommon/plat.py rename to dupeguru/hscommon/plat.py diff --git a/hscommon/pygettext.py b/dupeguru/hscommon/pygettext.py similarity index 100% rename from hscommon/pygettext.py rename to dupeguru/hscommon/pygettext.py diff --git a/hscommon/sphinxgen.py b/dupeguru/hscommon/sphinxgen.py similarity index 97% rename from hscommon/sphinxgen.py rename to dupeguru/hscommon/sphinxgen.py index c4e4958b2..2acf1a1ea 100644 --- a/hscommon/sphinxgen.py +++ b/dupeguru/hscommon/sphinxgen.py @@ -8,7 +8,7 @@ import re from typing import Callable, Dict, Union -from hscommon.build import read_changelog_file, filereplace +from dupeguru.hscommon.build import read_changelog_file, filereplace from sphinx.cmd.build import build_main as sphinx_build CHANGELOG_FORMAT = """ diff --git a/hscommon/tests/__init__.py b/dupeguru/hscommon/tests/__init__.py similarity index 100% rename from hscommon/tests/__init__.py rename to dupeguru/hscommon/tests/__init__.py diff --git a/hscommon/tests/conflict_test.py b/dupeguru/hscommon/tests/conflict_test.py similarity index 98% rename from hscommon/tests/conflict_test.py rename to dupeguru/hscommon/tests/conflict_test.py index 4f2dca851..530e6f6fb 100644 --- a/hscommon/tests/conflict_test.py +++ b/dupeguru/hscommon/tests/conflict_test.py @@ -8,7 +8,7 @@ import pytest -from hscommon.conflict import ( +from dupeguru.hscommon.conflict import ( get_conflicted_name, get_unconflicted_name, is_conflicted, @@ -16,7 +16,7 @@ smart_move, ) from pathlib import Path -from hscommon.testutil import eq_ +from dupeguru.hscommon.testutil import eq_ class TestCaseGetConflictedName: diff --git a/hscommon/tests/notify_test.py b/dupeguru/hscommon/tests/notify_test.py similarity index 97% rename from hscommon/tests/notify_test.py rename to dupeguru/hscommon/tests/notify_test.py index 062ed5a93..74f59618c 100644 --- a/hscommon/tests/notify_test.py +++ b/dupeguru/hscommon/tests/notify_test.py @@ -4,8 +4,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.testutil import eq_ -from hscommon.notify import Broadcaster, Listener, Repeater +from dupeguru.hscommon.testutil import eq_ +from dupeguru.hscommon.notify import Broadcaster, Listener, Repeater class HelloListener(Listener): diff --git a/hscommon/tests/path_test.py b/dupeguru/hscommon/tests/path_test.py similarity index 95% rename from hscommon/tests/path_test.py rename to dupeguru/hscommon/tests/path_test.py index d42b33d9b..68669a741 100644 --- a/hscommon/tests/path_test.py +++ b/dupeguru/hscommon/tests/path_test.py @@ -6,7 +6,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.path import pathify +from dupeguru.hscommon.path import pathify from pathlib import Path diff --git a/hscommon/tests/selectable_list_test.py b/dupeguru/hscommon/tests/selectable_list_test.py similarity index 94% rename from hscommon/tests/selectable_list_test.py rename to dupeguru/hscommon/tests/selectable_list_test.py index 5b4c0c958..b3820ccdd 100644 --- a/hscommon/tests/selectable_list_test.py +++ b/dupeguru/hscommon/tests/selectable_list_test.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.testutil import eq_, callcounter, CallLogger -from hscommon.gui.selectable_list import SelectableList, GUISelectableList +from dupeguru.hscommon.testutil import eq_, callcounter, CallLogger +from dupeguru.hscommon.gui.selectable_list import SelectableList, GUISelectableList def test_in(): diff --git a/hscommon/tests/table_test.py b/dupeguru/hscommon/tests/table_test.py similarity index 98% rename from hscommon/tests/table_test.py rename to dupeguru/hscommon/tests/table_test.py index 30b8d3edd..c9e0475ff 100644 --- a/hscommon/tests/table_test.py +++ b/dupeguru/hscommon/tests/table_test.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.testutil import CallLogger, eq_ -from hscommon.gui.table import Table, GUITable, Row +from dupeguru.hscommon.testutil import CallLogger, eq_ +from dupeguru.hscommon.gui.table import Table, GUITable, Row class TestRow(Row): diff --git a/hscommon/tests/tree_test.py b/dupeguru/hscommon/tests/tree_test.py similarity index 97% rename from hscommon/tests/tree_test.py rename to dupeguru/hscommon/tests/tree_test.py index d9a876bf1..22a1e01fe 100644 --- a/hscommon/tests/tree_test.py +++ b/dupeguru/hscommon/tests/tree_test.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.testutil import eq_ -from hscommon.gui.tree import Tree, Node +from dupeguru.hscommon.testutil import eq_ +from dupeguru.hscommon.gui.tree import Tree, Node def tree_with_some_nodes(): diff --git a/hscommon/tests/util_test.py b/dupeguru/hscommon/tests/util_test.py similarity index 99% rename from hscommon/tests/util_test.py rename to dupeguru/hscommon/tests/util_test.py index 601fa3bb1..66c0642a8 100644 --- a/hscommon/tests/util_test.py +++ b/dupeguru/hscommon/tests/util_test.py @@ -10,9 +10,9 @@ from pytest import raises -from hscommon.testutil import eq_ +from dupeguru.hscommon.testutil import eq_ from pathlib import Path -from hscommon.util import ( +from dupeguru.hscommon.util import ( nonone, tryint, first, diff --git a/hscommon/testutil.py b/dupeguru/hscommon/testutil.py similarity index 100% rename from hscommon/testutil.py rename to dupeguru/hscommon/testutil.py diff --git a/hscommon/trans.py b/dupeguru/hscommon/trans.py similarity index 94% rename from hscommon/trans.py rename to dupeguru/hscommon/trans.py index 694a36ba9..f6f3b1595 100644 --- a/hscommon/trans.py +++ b/dupeguru/hscommon/trans.py @@ -15,7 +15,7 @@ import os.path as op from typing import Callable, Union -from hscommon.plat import ISLINUX +from dupeguru.hscommon.plat import ISLINUX _trfunc = None _trget = None @@ -83,7 +83,7 @@ def get_locale_name(lang: str) -> Union[str, None]: # --- Qt def install_qt_trans(lang: str = None) -> None: - from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale + from PyQt6.QtCore import QCoreApplication, QTranslator, QLocale if not lang: lang = str(QLocale.system().name())[:2] @@ -140,7 +140,7 @@ def install_gettext_trans_under_qt(base_folder: os.PathLike, lang: str = None) - # So, we install the gettext locale, great, but we also should try to install qt_*.qm if # available so that strings that are inside Qt itself over which I have no control are in the # right language. - from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale, QLibraryInfo + from PyQt6.QtCore import QCoreApplication, QTranslator, QLocale, QLibraryInfo if not lang: lang = str(QLocale.system().name())[:2] @@ -156,7 +156,7 @@ def install_gettext_trans_under_qt(base_folder: os.PathLike, lang: str = None) - if ISLINUX: # Under linux, a full Qt installation is already available in the system, we didn't bundle # up the qm files in our package, so we have to load translations from the system. - qmpath = op.join(QLibraryInfo.location(QLibraryInfo.TranslationsPath), qmname) + qmpath = op.join(QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath), qmname) else: qmpath = op.join(base_folder, qmname) qtr = QTranslator(QCoreApplication.instance()) diff --git a/hscommon/util.py b/dupeguru/hscommon/util.py similarity index 99% rename from hscommon/util.py rename to dupeguru/hscommon/util.py index 39b32a7e7..364c2d2a3 100644 --- a/hscommon/util.py +++ b/dupeguru/hscommon/util.py @@ -8,7 +8,7 @@ from math import ceil from pathlib import Path -from hscommon.path import pathify, log_io_error +from dupeguru.hscommon.path import pathify, log_io_error from typing import IO, Any, Callable, Generator, Iterable, List, Tuple, Union diff --git a/qt/__init__.py b/dupeguru/qt/__init__.py similarity index 100% rename from qt/__init__.py rename to dupeguru/qt/__init__.py diff --git a/qt/about_box.py b/dupeguru/qt/about_box.py similarity index 61% rename from qt/about_box.py rename to dupeguru/qt/about_box.py index 07039cc3a..01b07812e 100644 --- a/qt/about_box.py +++ b/dupeguru/qt/about_box.py @@ -6,36 +6,46 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, QCoreApplication, QTimer -from PyQt5.QtGui import QPixmap, QFont -from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout, QVBoxLayout, QLabel +from PyQt6.QtCore import Qt, QCoreApplication, QTimer +from PyQt6.QtGui import QPixmap, QFont, QShowEvent +from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout, QVBoxLayout, QLabel, QWidget -from core.util import check_for_update -from qt.util import move_to_screen_center -from hscommon.trans import trget +from dupeguru.core.util import check_for_update +from dupeguru.qt.util import move_to_screen_center +from dupeguru.hscommon.trans import trget + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dupeguru.qt.app import DupeGuru tr = trget("ui") class AboutBox(QDialog): - def __init__(self, parent, app, **kwargs): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.MSWindowsFixedSizeDialogHint + def __init__(self, parent: QWidget, app: "DupeGuru", **kwargs) -> None: + flags = ( + Qt.WindowType.CustomizeWindowHint + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowSystemMenuHint + | Qt.WindowType.MSWindowsFixedSizeDialogHint + ) super().__init__(parent, flags, **kwargs) self.app = app self._setupUi() - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.reject) - - def _setupUi(self): + def _setupUi(self) -> None: self.setWindowTitle(tr("About {}").format(QCoreApplication.instance().applicationName())) - size_policy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + size_policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self.setSizePolicy(size_policy) main_layout = QHBoxLayout(self) + logo_label = QLabel() - logo_label.setPixmap(QPixmap(":/%s_big" % self.app.LOGO_NAME)) + logo_label.setPixmap(QPixmap(f"images:{self.app.LOGO_NAME}_128.png")) main_layout.addWidget(logo_label) + detail_layout = QVBoxLayout() + name_label = QLabel() font = QFont() font.setWeight(75) @@ -43,26 +53,35 @@ def _setupUi(self): name_label.setFont(font) name_label.setText(QCoreApplication.instance().applicationName()) detail_layout.addWidget(name_label) + version_label = QLabel() version_label.setText(tr("Version {}").format(QCoreApplication.instance().applicationVersion())) detail_layout.addWidget(version_label) + self.update_label = QLabel(tr("Checking for updates...")) - self.update_label.setTextInteractionFlags(Qt.TextBrowserInteraction) + self.update_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.update_label.setOpenExternalLinks(True) detail_layout.addWidget(self.update_label) + license_label = QLabel() license_label.setText(tr("Licensed under GPLv3")) detail_layout.addWidget(license_label) + spacer_label = QLabel() spacer_label.setFont(font) detail_layout.addWidget(spacer_label) - self.button_box = QDialogButtonBox() - self.button_box.setOrientation(Qt.Horizontal) - self.button_box.setStandardButtons(QDialogButtonBox.Ok) - detail_layout.addWidget(self.button_box) + + button_box = QDialogButtonBox() + button_box.setOrientation(Qt.Orientation.Horizontal) + button_box.setStandardButtons(QDialogButtonBox.StandardButton.Ok) + detail_layout.addWidget(button_box) + main_layout.addLayout(detail_layout) - def _check_for_update(self): + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + def _check_for_update(self) -> None: update = check_for_update(QCoreApplication.instance().applicationVersion(), include_prerelease=False) if update is None: self.update_label.setText(tr("No update available.")) @@ -71,7 +90,7 @@ def _check_for_update(self): tr('New version {} available, download here.').format(update["version"], update["url"]) ) - def showEvent(self, event): + def showEvent(self, event: QShowEvent) -> None: self.update_label.setText(tr("Checking for updates...")) # have to do this here as the frameGeometry is not correct until shown move_to_screen_center(self) diff --git a/qt/app.py b/dupeguru/qt/app.py similarity index 78% rename from qt/app.py rename to dupeguru/qt/app.py index 30312cd88..f407a585d 100644 --- a/qt/app.py +++ b/dupeguru/qt/app.py @@ -6,46 +6,49 @@ import sys import os.path as op - -from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt -from PyQt5.QtGui import QColor, QDesktopServices, QPalette -from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox, QStyleFactory, QToolTip - -from hscommon.trans import trget -from hscommon import desktop, plat - -from qt.about_box import AboutBox -from qt.recent import Recent -from qt.util import create_actions -from qt.progress_window import ProgressWindow - -from core.app import AppMode, DupeGuru as DupeGuruModel -import core.pe.photo -from qt import platform -from qt.preferences import Preferences -from qt.result_window import ResultWindow -from qt.directories_dialog import DirectoriesDialog -from qt.problem_dialog import ProblemDialog -from qt.ignore_list_dialog import IgnoreListDialog -from qt.exclude_list_dialog import ExcludeListDialog -from qt.deletion_options import DeletionOptions -from qt.se.details_dialog import DetailsDialog as DetailsDialogStandard -from qt.me.details_dialog import DetailsDialog as DetailsDialogMusic -from qt.pe.details_dialog import DetailsDialog as DetailsDialogPicture -from qt.se.preferences_dialog import PreferencesDialog as PreferencesDialogStandard -from qt.me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic -from qt.pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture -from qt.pe.photo import File as PlatSpecificPhoto -from qt.tabbed_window import TabBarWindow, TabWindow +from typing import Type + +from PyQt6.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt +from PyQt6.QtGui import QColor, QDesktopServices, QPalette +from PyQt6.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox, QStyleFactory, QToolTip + +from dupeguru.hscommon.trans import trget +from dupeguru.hscommon import desktop, plat + +from dupeguru.qt.about_box import AboutBox +from dupeguru.qt.details_dialog import DetailsDialog +from dupeguru.qt.preferences_dialog import PreferencesDialogBase +from dupeguru.qt.recent import Recent +from dupeguru.qt.util import create_actions +from dupeguru.qt.progress_window import ProgressWindow + +from dupeguru.core.app import AppMode, DupeGuru as DupeGuruModel +import dupeguru.core.pe.photo +from dupeguru.qt import platform +from dupeguru.qt.preferences import Preferences +from dupeguru.qt.result_window import ResultWindow +from dupeguru.qt.directories_dialog import DirectoriesDialog +from dupeguru.qt.problem_dialog import ProblemDialog +from dupeguru.qt.ignore_list_dialog import IgnoreListDialog +from dupeguru.qt.exclude_list_dialog import ExcludeListDialog +from dupeguru.qt.deletion_options import DeletionOptions +from dupeguru.qt.se.details_dialog import DetailsDialog as DetailsDialogStandard +from dupeguru.qt.me.details_dialog import DetailsDialog as DetailsDialogMusic +from dupeguru.qt.pe.details_dialog import DetailsDialog as DetailsDialogPicture +from dupeguru.qt.se.preferences_dialog import PreferencesDialog as PreferencesDialogStandard +from dupeguru.qt.me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic +from dupeguru.qt.pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture +from dupeguru.qt.pe.photo import File as PlatSpecificPhoto +from dupeguru.qt.tabbed_window import TabBarWindow, TabWindow tr = trget("ui") class DupeGuru(QObject): - LOGO_NAME = "logo_se" + LOGO_NAME = "dgse_logo" NAME = "dupeGuru" - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.prefs = Preferences() self.prefs.load() @@ -56,8 +59,8 @@ def __init__(self, **kwargs): self._setup() # --- Private - def _setup(self): - core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto + def _setup(self) -> None: + dupeguru.core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto self._setupActions() self.details_dialog = None self._update_options() @@ -108,7 +111,7 @@ def _setup(self): # that the application haven't launched. QTimer.singleShot(0, self.finishedLaunching) - def _setupActions(self): + def _setupActions(self) -> None: # Setup actions that are common to both the directory dialog and the results window. # (name, shortcut, icon, desc, func) ACTIONS = [ @@ -154,7 +157,7 @@ def _setupActions(self): ] create_actions(ACTIONS, self) - def _update_options(self): + def _update_options(self) -> None: self.model.options["mix_file_kind"] = self.prefs.mix_file_kind self.model.options["escape_filter_regexp"] = not self.prefs.use_regexp self.model.options["clean_empty_dirs"] = self.prefs.remove_empty_folders @@ -193,6 +196,7 @@ def _update_options(self): self.model.options["scanned_tags"] = scanned_tags self.model.options["match_scaled"] = self.prefs.match_scaled self.model.options["match_rotated"] = self.prefs.match_rotated + self.model.options["picture_prescale"] = self.prefs.picture_prescale self.model.options["include_exists_check"] = self.prefs.include_exists_check self.model.options["rehash_ignore_mtime"] = self.prefs.rehash_ignore_mtime @@ -202,7 +206,7 @@ def _update_options(self): self._set_style("dark" if self.prefs.use_dark_style else "light") # --- Private - def _get_details_dialog_class(self): + def _get_details_dialog_class(self) -> Type[DetailsDialog]: if self.model.app_mode == AppMode.PICTURE: return DetailsDialogPicture elif self.model.app_mode == AppMode.MUSIC: @@ -210,7 +214,7 @@ def _get_details_dialog_class(self): else: return DetailsDialogStandard - def _get_preferences_dialog_class(self): + def _get_preferences_dialog_class(self) -> Type[PreferencesDialogBase]: if self.model.app_mode == AppMode.PICTURE: return PreferencesDialogPicture elif self.model.app_mode == AppMode.MUSIC: @@ -218,7 +222,7 @@ def _get_preferences_dialog_class(self): else: return PreferencesDialogStandard - def _set_style(self, style="light"): + def _set_style(self, style: str = "light") -> None: # Only support this feature on windows for now if not plat.ISWINDOWS: return @@ -226,18 +230,18 @@ def _set_style(self, style="light"): QApplication.setStyle(QStyleFactory.create("Fusion")) palette = QApplication.style().standardPalette() palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) - palette.setColor(QPalette.ColorRole.WindowText, Qt.white) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(53, 53, 53)) - palette.setColor(QPalette.ColorRole.ToolTipText, Qt.white) - palette.setColor(QPalette.ColorRole.Text, Qt.white) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.ColorRole.ButtonText, Qt.white) - palette.setColor(QPalette.ColorRole.BrightText, Qt.red) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red) palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) - palette.setColor(QPalette.ColorRole.HighlightedText, Qt.black) + palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black) palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, QColor(164, 166, 168)) palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, QColor(164, 166, 168)) palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor(164, 166, 168)) @@ -252,29 +256,31 @@ def _set_style(self, style="light"): QApplication.setPalette(palette) # --- Public - def add_selected_to_ignore_list(self): + def add_selected_to_ignore_list(self) -> None: self.model.add_selected_to_ignore_list() - def remove_selected(self): - self.model.remove_selected(self) + def remove_selected(self) -> None: + self.model.remove_selected() - def confirm(self, title, msg, default_button=QMessageBox.Yes): + def confirm( + self, title: str, msg: str, default_button: QMessageBox.StandardButton = QMessageBox.StandardButton.Yes + ) -> bool: active = QApplication.activeWindow() - buttons = QMessageBox.Yes | QMessageBox.No + buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No answer = QMessageBox.question(active, title, msg, buttons, default_button) - return answer == QMessageBox.Yes + return answer == QMessageBox.StandardButton.Yes - def invokeCustomCommand(self): + def invokeCustomCommand(self) -> None: self.model.invoke_custom_command() - def show_details(self): + def show_details(self) -> None: if self.details_dialog is not None: if not self.details_dialog.isVisible(): self.details_dialog.show() else: self.details_dialog.hide() - def showResultsWindow(self): + def showResultsWindow(self) -> None: if self.resultWindow is not None: if self.use_tabs: if self.main_window.indexOfWidget(self.resultWindow) < 0: @@ -284,14 +290,14 @@ def showResultsWindow(self): else: self.resultWindow.show() - def showDirectoriesWindow(self): + def showDirectoriesWindow(self) -> None: if self.directories_dialog is not None: if self.use_tabs: self.main_window.showTab(self.directories_dialog) else: self.directories_dialog.show() - def shutdown(self): + def shutdown(self) -> None: self.willSavePrefs.emit() self.prefs.save() self.model.save() @@ -306,7 +312,7 @@ def shutdown(self): SIGTERM = pyqtSignal() # --- Events - def finishedLaunching(self): + def finishedLaunching(self) -> None: if sys.getfilesystemencoding() == "ascii": # No need to localize this, it's a debugging message. msg = ( @@ -326,28 +332,28 @@ def finishedLaunching(self): self.model.load_from(results) self.recentResults.insertItem(results) - def clearCacheTriggered(self): + def clearCacheTriggered(self) -> None: title = tr("Clear Cache") msg = tr("Do you really want to clear the cache? This will remove all cached file hashes and picture analysis.") - if self.confirm(title, msg, QMessageBox.No): + if self.confirm(title, msg, QMessageBox.StandardButton.No): self.model.clear_picture_cache() self.model.clear_hash_cache() active = QApplication.activeWindow() QMessageBox.information(active, title, tr("Cache cleared.")) - def ignoreListTriggered(self): + def ignoreListTriggered(self) -> None: if self.use_tabs: self.showTriggeredTabbedDialog(self.ignoreListDialog, tr("Ignore List")) else: # floating windows self.model.ignore_list_dialog.show() - def excludeListTriggered(self): + def excludeListTriggered(self) -> None: if self.use_tabs: self.showTriggeredTabbedDialog(self.excludeListDialog, tr("Exclusion Filters")) else: # floating windows self.model.exclude_list_dialog.show() - def showTriggeredTabbedDialog(self, dialog, desc_string): + def showTriggeredTabbedDialog(self, dialog, desc_string: str) -> None: """Add tab for dialog, name the tab with desc_string, then show it.""" index = self.main_window.indexOfWidget(dialog) # Create the tab if it doesn't exist already @@ -356,23 +362,22 @@ def showTriggeredTabbedDialog(self, dialog, desc_string): # Show the tab for that widget self.main_window.setCurrentIndex(index) - def openDebugLogTriggered(self): + def openDebugLogTriggered(self) -> None: debug_log_path = op.join(self.model.appdata, "debug.log") desktop.open_path(debug_log_path) - def preferencesTriggered(self): + def preferencesTriggered(self) -> None: preferences_dialog = self._get_preferences_dialog_class()( self.main_window if self.main_window else self.directories_dialog, self ) preferences_dialog.load() result = preferences_dialog.exec() - if result == QDialog.Accepted: + if result == QDialog.DialogCode.Accepted: preferences_dialog.save() self.prefs.save() self._update_options() - preferences_dialog.setParent(None) - def quitTriggered(self): + def quitTriggered(self) -> None: if self.details_dialog is not None: self.details_dialog.close() @@ -381,10 +386,10 @@ def quitTriggered(self): else: self.directories_dialog.close() - def showAboutBoxTriggered(self): + def showAboutBoxTriggered(self) -> None: self.about_box.show() - def showHelpTriggered(self): + def showHelpTriggered(self) -> None: base_path = platform.HELP_PATH help_path = op.abspath(op.join(base_path, "index.html")) if op.exists(help_path): @@ -393,7 +398,7 @@ def showHelpTriggered(self): url = QUrl("https://dupeguru.voltaicideas.net/help/en/") QDesktopServices.openUrl(url) - def handleSIGTERM(self): + def handleSIGTERM(self) -> None: self.shutdown() # --- model --> view @@ -403,20 +408,20 @@ def get_default(self, key): def set_default(self, key, value): self.prefs.set_value(key, value) - def show_message(self, msg): + def show_message(self, msg: str) -> None: window = QApplication.activeWindow() QMessageBox.information(window, "", msg) - def ask_yes_no(self, prompt): + def ask_yes_no(self, prompt: str) -> bool: return self.confirm("", prompt) - def create_results_window(self): + def create_results_window(self) -> None: """Creates resultWindow and details_dialog depending on the selected ``app_mode``.""" if self.details_dialog is not None: # The object is not deleted entirely, avoid saving its geometry in the future # self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs) # or simply delete it on close which is probably cleaner: - self.details_dialog.setAttribute(Qt.WA_DeleteOnClose) + self.details_dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.details_dialog.close() # if we don't do the following, Qt will crash when we recreate the Results dialog self.details_dialog.setParent(None) @@ -431,17 +436,17 @@ def create_results_window(self): self.directories_dialog._updateActionsState() self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self) - def show_results_window(self): + def show_results_window(self) -> None: self.showResultsWindow() - def show_problem_dialog(self): + def show_problem_dialog(self) -> None: self.problemDialog.show() - def select_dest_folder(self, prompt): - flags = QFileDialog.ShowDirsOnly + def select_dest_folder(self, prompt: str) -> str: + flags = QFileDialog.Option.ShowDirsOnly return QFileDialog.getExistingDirectory(self.resultWindow, prompt, "", flags) - def select_dest_file(self, prompt, extension): + def select_dest_file(self, prompt: str, extension: str) -> str: files = tr("{} file (*.{})").format(extension.upper(), extension) destination, chosen_filter = QFileDialog.getSaveFileName(self.resultWindow, prompt, "", files) if not destination.endswith(f".{extension}"): diff --git a/qt/column.py b/dupeguru/qt/column.py similarity index 94% rename from qt/column.py rename to dupeguru/qt/column.py index 44d3bb0ac..824f49ac4 100644 --- a/qt/column.py +++ b/dupeguru/qt/column.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QHeaderView +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QHeaderView class Column: @@ -16,7 +16,7 @@ def __init__( attrname, default_width, editor=None, - alignment=Qt.AlignLeft, + alignment=Qt.AlignmentFlag.AlignLeft, cant_truncate=False, painter=None, resize_to_fit=False, @@ -37,7 +37,7 @@ class Columns: def __init__(self, model, columns, header_view): self.model = model self._header_view = header_view - self._header_view.setDefaultAlignment(Qt.AlignLeft) + self._header_view.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft) def setspecs(col, modelcol): modelcol.default_width = col.default_width @@ -62,7 +62,7 @@ def setspecs(col, modelcol): # See moneyguru #14 and #15. This was added in order to allow automatic resizing of columns. for column in self.model.column_list: if column.resize_to_fit: - self._header_view.setSectionResizeMode(column.logical_index, QHeaderView.ResizeToContents) + self._header_view.setSectionResizeMode(column.logical_index, QHeaderView.ResizeMode.ResizeToContents) # --- Public def set_columns_width(self, widths): diff --git a/qt/deletion_options.py b/dupeguru/qt/deletion_options.py similarity index 89% rename from qt/deletion_options.py rename to dupeguru/qt/deletion_options.py index 2f4ef1b65..a2d274bed 100644 --- a/qt/deletion_options.py +++ b/dupeguru/qt/deletion_options.py @@ -6,18 +6,18 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QCheckBox, QDialogButtonBox +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QCheckBox, QDialogButtonBox -from hscommon.trans import trget -from qt.radio_box import RadioBox +from dupeguru.hscommon.trans import trget +from dupeguru.qt.radio_box import RadioBox tr = trget("ui") class DeletionOptions(QDialog): def __init__(self, parent, model, **kwargs): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint super().__init__(parent, flags, **kwargs) self.model = model self._setupUi() @@ -57,8 +57,8 @@ def _setupUi(self): self.directMessageLabel.setWordWrap(True) self.verticalLayout.addWidget(self.directMessageLabel) self.buttonBox = QDialogButtonBox() - self.buttonBox.addButton(tr("Proceed"), QDialogButtonBox.AcceptRole) - self.buttonBox.addButton(tr("Cancel"), QDialogButtonBox.RejectRole) + self.buttonBox.addButton(tr("Proceed"), QDialogButtonBox.ButtonRole.AcceptRole) + self.buttonBox.addButton(tr("Cancel"), QDialogButtonBox.ButtonRole.RejectRole) self.verticalLayout.addWidget(self.buttonBox) # --- Signals @@ -77,7 +77,7 @@ def show(self): self.model.link_deleted = self.linkCheckbox.isChecked() self.model.use_hardlinks = self.linkTypeRadio.selected_index == 1 self.model.direct = self.directCheckbox.isChecked() - return result == QDialog.Accepted + return result == QDialog.DialogCode.Accepted def set_hardlink_option_enabled(self, is_enabled: bool): self.linkTypeRadio.setEnabled(is_enabled) diff --git a/qt/details_dialog.py b/dupeguru/qt/details_dialog.py similarity index 72% rename from qt/details_dialog.py rename to dupeguru/qt/details_dialog.py index 63e41c621..a61ddca60 100644 --- a/qt/details_dialog.py +++ b/dupeguru/qt/details_dialog.py @@ -6,21 +6,21 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDockWidget, QWidget +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QDockWidget, QWidget -from qt.util import move_to_screen_center -from qt.details_table import DetailsModel -from hscommon.plat import ISLINUX +from dupeguru.qt.util import move_to_screen_center +from dupeguru.qt.details_table import DetailsModel +from dupeguru.hscommon.plat import ISLINUX class DetailsDialog(QDockWidget): def __init__(self, parent, app, **kwargs): - super().__init__(parent, Qt.Tool, **kwargs) + super().__init__(parent, Qt.WindowType.Tool, **kwargs) self.parent = parent self.app = app self.model = app.model.details_panel - self.setAllowedAreas(Qt.AllDockWidgetAreas) + self.setAllowedAreas(Qt.DockWidgetArea.AllDockWidgetAreas) self._setupUi() # To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog # has been shown. If it has, we know that our geometry should be saved. @@ -32,7 +32,15 @@ def __init__(self, parent, app, **kwargs): self.model.view = self self.app.willSavePrefs.connect(self.appWillSavePrefs) # self.setAttribute(Qt.WA_DeleteOnClose) - parent.addDockWidget(area if self._wasDocked else Qt.BottomDockWidgetArea, self) + dock_area = Qt.DockWidgetArea.BottomDockWidgetArea + if self._wasDocked: + # PyQt6 is strict here: addDockWidget() requires Qt.DockWidgetArea, + # while persisted prefs can deserialize as int/str. + try: + dock_area = Qt.DockWidgetArea(int(area)) + except (TypeError, ValueError): + dock_area = Qt.DockWidgetArea.BottomDockWidgetArea + parent.addDockWidget(dock_area, self) def _setupUi(self): # Virtual pass @@ -60,10 +68,11 @@ def update_options(self): self.setTitleBarWidget(QWidget()) features = self.features() + vertical_titlebar_feature = QDockWidget.DockWidgetFeature.DockWidgetVerticalTitleBar if self.app.prefs.details_dialog_vertical_titlebar: - self.setFeatures(features | QDockWidget.DockWidgetVerticalTitleBar) - elif features & QDockWidget.DockWidgetVerticalTitleBar: - self.setFeatures(features ^ QDockWidget.DockWidgetVerticalTitleBar) + self.setFeatures(features | vertical_titlebar_feature) + elif features & vertical_titlebar_feature: + self.setFeatures(features ^ vertical_titlebar_feature) # --- Events def appWillSavePrefs(self): diff --git a/qt/details_table.py b/dupeguru/qt/details_table.py similarity index 68% rename from qt/details_table.py rename to dupeguru/qt/details_table.py index 56d51bda4..f14aa13c3 100644 --- a/qt/details_table.py +++ b/dupeguru/qt/details_table.py @@ -6,11 +6,11 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, QAbstractTableModel -from PyQt5.QtWidgets import QHeaderView, QTableView -from PyQt5.QtGui import QFont, QBrush +from PyQt6.QtCore import Qt, QAbstractTableModel +from PyQt6.QtWidgets import QHeaderView, QTableView +from PyQt6.QtGui import QFont, QBrush -from hscommon.trans import trget +from dupeguru.hscommon.trans import trget tr = trget("ui") @@ -39,24 +39,28 @@ def data(self, index, role): or self.model.row(row)[1] == "---" or self.model.row(row)[2] == "---" ): - if role != Qt.DisplayRole: + if role != Qt.ItemDataRole.DisplayRole: return None return self.model.row(row)[column] - if role == Qt.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: return self.model.row(row)[column] - if role == Qt.ForegroundRole and self.model.row(row)[1] != self.model.row(row)[2]: + if role == Qt.ItemDataRole.ForegroundRole and self.model.row(row)[1] != self.model.row(row)[2]: return QBrush(self.prefs.details_table_delta_foreground_color) - if role == Qt.FontRole and self.model.row(row)[1] != self.model.row(row)[2]: + if role == Qt.ItemDataRole.FontRole and self.model.row(row)[1] != self.model.row(row)[2]: font = QFont(self.model.view.font()) # or simply QFont() font.setBold(True) return font return None # QVariant() def headerData(self, section, orientation, role): - if orientation == Qt.Horizontal and role == Qt.DisplayRole and section < len(HEADER): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole and section < len(HEADER): return HEADER[section] - elif orientation == Qt.Vertical and role == Qt.DisplayRole and section < self.model.row_count(): + elif ( + orientation == Qt.Orientation.Vertical + and role == Qt.ItemDataRole.DisplayRole + and section < self.model.row_count() + ): # Read "Attribute" cell for horizontal header return self.model.row(section)[0] return None @@ -69,8 +73,8 @@ class DetailsTable(QTableView): def __init__(self, *args): QTableView.__init__(self, *args) self.setAlternatingRowColors(True) - self.setSelectionBehavior(QTableView.SelectRows) - self.setSelectionMode(QTableView.NoSelection) + self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self.setSelectionMode(QTableView.SelectionMode.NoSelection) self.setShowGrid(False) self.setWordWrap(False) self.setCornerButtonEnabled(False) @@ -80,12 +84,12 @@ def setModel(self, model): # The model needs to be set to set header stuff hheader = self.horizontalHeader() hheader.setHighlightSections(False) - hheader.setSectionResizeMode(0, QHeaderView.Stretch) - hheader.setSectionResizeMode(1, QHeaderView.Stretch) + hheader.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + hheader.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) vheader = self.verticalHeader() vheader.setVisible(True) vheader.setDefaultSectionSize(18) # hardcoded value above is not ideal, perhaps resize to contents first? # vheader.setSectionResizeMode(QHeaderView.ResizeToContents) - vheader.setSectionResizeMode(QHeaderView.Fixed) + vheader.setSectionResizeMode(QHeaderView.ResizeMode.Fixed) vheader.setSectionsMovable(True) diff --git a/dupeguru/qt/dg.qrc b/dupeguru/qt/dg.qrc new file mode 100644 index 000000000..a2214af1d --- /dev/null +++ b/dupeguru/qt/dg.qrc @@ -0,0 +1,15 @@ + + + ../../images/dgse_logo_32.png + ../../images/dgse_logo_128.png + ../../images/plus_8.png + ../../images/minus_8.png + ../../images/search_clear_13.png + ../../images/exchange_purple_upscaled.png + ../../images/old_zoom_in.png + ../../images/old_zoom_out.png + ../../images/old_zoom_original.png + ../../images/old_zoom_best_fit.png + ../../images/dialog-error.png + + diff --git a/qt/directories_dialog.py b/dupeguru/qt/directories_dialog.py similarity index 89% rename from qt/directories_dialog.py rename to dupeguru/qt/directories_dialog.py index 272a75145..65d3e8965 100644 --- a/qt/directories_dialog.py +++ b/dupeguru/qt/directories_dialog.py @@ -4,8 +4,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import QRect, Qt -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import QRect, Qt +from PyQt6.QtWidgets import ( QListView, QWidget, QFileDialog, @@ -23,16 +23,16 @@ QLabel, QComboBox, ) -from PyQt5.QtGui import QPixmap, QIcon +from PyQt6.QtGui import QPixmap, QIcon -from hscommon.trans import trget -from core.app import AppMode -from qt.radio_box import RadioBox -from qt.recent import Recent -from qt.util import move_to_screen_center, create_actions +from dupeguru.hscommon.trans import trget +from dupeguru.core.app import AppMode +from dupeguru.qt.radio_box import RadioBox +from dupeguru.qt.recent import Recent +from dupeguru.qt.util import move_to_screen_center, create_actions -from qt import platform -from qt.directories_model import DirectoriesModel, DirectoriesDelegate +from dupeguru.qt import platform +from dupeguru.qt.directories_model import DirectoriesModel, DirectoriesDelegate tr = trget("ui") @@ -167,36 +167,38 @@ def _setupUi(self): self.verticalLayout.setSpacing(0) hl = QHBoxLayout() label = QLabel(tr("Application Mode:"), self) - label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) hl.addWidget(label) self.appModeRadioBox = RadioBox(self, items=[tr("Standard"), tr("Music"), tr("Picture")], spread=False) hl.addWidget(self.appModeRadioBox) self.verticalLayout.addLayout(hl) hl = QHBoxLayout() - hl.setAlignment(Qt.AlignLeft) + hl.setAlignment(Qt.AlignmentFlag.AlignLeft) label = QLabel(tr("Scan Type:"), self) - label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) hl.addWidget(label) self.scanTypeComboBox = QComboBox(self) - self.scanTypeComboBox.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)) + self.scanTypeComboBox.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)) self.scanTypeComboBox.setMaximumWidth(400) hl.addWidget(self.scanTypeComboBox) self.showPreferencesButton = QPushButton(tr("More Options"), self.centralwidget) - self.showPreferencesButton.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.showPreferencesButton.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) hl.addWidget(self.showPreferencesButton) self.verticalLayout.addLayout(hl) self.promptLabel = QLabel(tr('Select folders to scan and press "Scan".'), self.centralwidget) self.verticalLayout.addWidget(self.promptLabel) self.treeView = QTreeView(self.centralwidget) - self.treeView.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.treeView.setSelectionBehavior(QAbstractItemView.SelectRows) + self.treeView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.treeView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.treeView.setAcceptDrops(True) triggers = ( - QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed | QAbstractItemView.SelectedClicked + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + | QAbstractItemView.EditTrigger.SelectedClicked ) self.treeView.setEditTriggers(triggers) self.treeView.setDragDropOverwriteMode(True) - self.treeView.setDragDropMode(QAbstractItemView.DropOnly) + self.treeView.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly) self.treeView.setUniformRowHeights(True) self.verticalLayout.addWidget(self.treeView) self.horizontalLayout = QHBoxLayout() @@ -207,7 +209,7 @@ def _setupUi(self): self.addFolderButton = QPushButton(self.centralwidget) self.addFolderButton.setIcon(QIcon(QPixmap(":/plus"))) self.horizontalLayout.addWidget(self.addFolderButton) - spacer_item = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + spacer_item = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) self.horizontalLayout.addItem(spacer_item) self.loadResultsButton = QPushButton(self.centralwidget) self.loadResultsButton.setText(tr("Load Results")) @@ -230,8 +232,8 @@ def _setupUi(self): def _setupColumns(self): header = self.treeView.header() header.setStretchLastSection(False) - header.setSectionResizeMode(0, QHeaderView.Stretch) - header.setSectionResizeMode(1, QHeaderView.Fixed) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) header.resizeSection(1, 100) def _updateActionsState(self): @@ -289,15 +291,15 @@ def addFolderTriggered(self): no_native = not self.app.prefs.use_native_dialogs title = tr("Select a folder to add to the scanning list") file_dialog = QFileDialog(self, title, self.lastAddedFolder) - file_dialog.setFileMode(QFileDialog.DirectoryOnly) - file_dialog.setOption(QFileDialog.DontUseNativeDialog, no_native) + file_dialog.setFileMode(QFileDialog.FileMode.Directory) + file_dialog.setOption(QFileDialog.Option.DontUseNativeDialog, no_native) if no_native: file_view = file_dialog.findChild(QListView, "listView") if file_view: - file_view.setSelectionMode(QAbstractItemView.MultiSelection) + file_view.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) f_tree_view = file_dialog.findChild(QTreeView) if f_tree_view: - f_tree_view.setSelectionMode(QAbstractItemView.MultiSelection) + f_tree_view.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) if not file_dialog.exec(): return diff --git a/qt/directories_model.py b/dupeguru/qt/directories_model.py similarity index 76% rename from qt/directories_model.py rename to dupeguru/qt/directories_model.py index 29ca8cc78..76cfce737 100644 --- a/qt/directories_model.py +++ b/dupeguru/qt/directories_model.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import pyqtSignal, Qt, QRect, QUrl, QModelIndex, QItemSelection -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import pyqtSignal, Qt, QRect, QUrl, QModelIndex, QItemSelection +from PyQt6.QtWidgets import ( QComboBox, QStyledItemDelegate, QStyle, @@ -15,10 +15,10 @@ QStyleOptionViewItem, QApplication, ) -from PyQt5.QtGui import QBrush +from PyQt6.QtGui import QBrush -from hscommon.trans import trget -from qt.tree_model import RefNode, TreeModel +from dupeguru.hscommon.trans import trget +from dupeguru.qt.tree_model import RefNode, TreeModel tr = trget("ui") @@ -36,28 +36,28 @@ def paint(self, painter, option, index): self.initStyleOption(option, index) # No idea why, but this cast is required if we want to have access to the V4 valuess option = QStyleOptionViewItem(option) - if (index.column() == 1) and (option.state & QStyle.State_Selected): + if (index.column() == 1) and (option.state & QStyle.StateFlag.State_Selected): cboption = QStyleOptionComboBox() cboption.rect = option.rect # On OS X (with Qt4.6.0), adding State_Enabled to the flags causes the whole drawing to # fail (draw nothing), but it's an OS X only glitch. On Windows, it works alright. - cboption.state |= QStyle.State_Enabled - QApplication.style().drawComplexControl(QStyle.CC_ComboBox, cboption, painter) + cboption.state |= QStyle.StateFlag.State_Enabled + QApplication.style().drawComplexControl(QStyle.ComplexControl.CC_ComboBox, cboption, painter) painter.setBrush(option.palette.text()) rect = QRect(option.rect) rect.setLeft(rect.left() + 4) - painter.drawText(rect, Qt.AlignLeft, option.text) + painter.drawText(rect, Qt.AlignmentFlag.AlignLeft, option.text) else: super().paint(painter, option, index) def setEditorData(self, editor, index): - value = index.model().data(index, Qt.EditRole) + value = index.model().data(index, Qt.ItemDataRole.EditRole) editor.setCurrentIndex(value) editor.showPopup() def setModelData(self, editor, model, index): value = editor.currentIndex() - model.setData(index, value, Qt.EditRole) + model.setData(index, value, Qt.ItemDataRole.EditRole) def updateEditorGeometry(self, editor, option, index): editor.setGeometry(option.rect) @@ -89,19 +89,19 @@ def data(self, index, role): return None node = index.internalPointer() ref = node.ref - if role == Qt.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: if index.column() == 0: return ref.name else: return STATES[ref.state] - elif role == Qt.EditRole and index.column() == 1: + elif role == Qt.ItemDataRole.EditRole and index.column() == 1: return ref.state - elif role == Qt.ForegroundRole: + elif role == Qt.ItemDataRole.ForegroundRole: state = ref.state if state == 1: - return QBrush(Qt.blue) + return QBrush(Qt.GlobalColor.blue) elif state == 2: - return QBrush(Qt.red) + return QBrush(Qt.GlobalColor.red) return None def dropMimeData(self, mime_data, action, row, column, parent_index): @@ -119,14 +119,14 @@ def dropMimeData(self, mime_data, action, row, column, parent_index): def flags(self, index): if not index.isValid(): - return Qt.ItemIsEnabled | Qt.ItemIsDropEnabled - result = Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDropEnabled + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsDropEnabled + result = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled if index.column() == 1: - result |= Qt.ItemIsEditable + result |= Qt.ItemFlag.ItemIsEditable return result def headerData(self, section, orientation, role): - if orientation == Qt.Horizontal and role == Qt.DisplayRole and section < len(HEADERS): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole and section < len(HEADERS): return HEADERS[section] return None @@ -134,7 +134,7 @@ def mimeTypes(self): return [self.MIME_TYPE_FORMAT] def setData(self, index, value, role): - if not index.isValid() or role != Qt.EditRole or index.column() != 1: + if not index.isValid() or role != Qt.ItemDataRole.EditRole or index.column() != 1: return False node = index.internalPointer() ref = node.ref @@ -144,7 +144,7 @@ def setData(self, index, value, role): def supportedDropActions(self): # Normally, the correct action should be ActionLink, but the drop doesn't work. It doesn't # work with ActionMove either. So screw that, and accept anything. - return Qt.ActionMask + return Qt.DropAction.ActionMask # --- Events def selectionChanged(self, selected, deselected): diff --git a/qt/error_report_dialog.py b/dupeguru/qt/error_report_dialog.py similarity index 58% rename from qt/error_report_dialog.py rename to dupeguru/qt/error_report_dialog.py index acf8f15ee..83a154b92 100644 --- a/qt/error_report_dialog.py +++ b/dupeguru/qt/error_report_dialog.py @@ -11,8 +11,8 @@ import os import platform -from PyQt5.QtCore import Qt, QCoreApplication, QSize -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import Qt, QCoreApplication, QSize +from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, @@ -21,16 +21,16 @@ QPushButton, ) -from hscommon.trans import trget -from hscommon.desktop import open_url -from qt.util import horizontal_spacer +from dupeguru.hscommon.trans import trget +from dupeguru.hscommon.desktop import open_url +from dupeguru.qt.util import horizontal_spacer tr = trget("ui") class ErrorReportDialog(QDialog): def __init__(self, parent, github_url, error, **kwargs): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint super().__init__(parent, flags, **kwargs) self._setupUi() name = QCoreApplication.applicationName() @@ -40,23 +40,23 @@ def __init__(self, parent, github_url, error, **kwargs): ) # Under windows, we end up with an error report without linesep if we don't mangle it error_text = error_text.replace("\n", os.linesep) - self.errorTextEdit.setPlainText(error_text) + self.error_text_edit.setPlainText(error_text) self.github_url = github_url - self.sendButton.clicked.connect(self.goToGitHub) - self.dontSendButton.clicked.connect(self.reject) - def _setupUi(self): self.setWindowTitle(tr("Error Report")) self.resize(553, 349) - self.verticalLayout = QVBoxLayout(self) - self.label = QLabel(self) - self.label.setText(tr("Something went wrong. How about reporting the error?")) - self.label.setWordWrap(True) - self.verticalLayout.addWidget(self.label) - self.errorTextEdit = QPlainTextEdit(self) - self.errorTextEdit.setReadOnly(True) - self.verticalLayout.addWidget(self.errorTextEdit) + main_layout = QVBoxLayout(self) + + title_label = QLabel(self) + title_label.setText(tr("Something went wrong. How about reporting the error?")) + title_label.setWordWrap(True) + main_layout.addWidget(title_label) + + self.error_text_edit = QPlainTextEdit(self) + self.error_text_edit.setReadOnly(True) + main_layout.addWidget(self.error_text_edit) + msg = tr( "Error reports should be reported as GitHub issues. You can copy the error traceback " "above and paste it in a new issue.\n\nPlease make sure to run a search for any already " @@ -67,21 +67,28 @@ def _setupUi(self): "Although the application should continue to run after this error, it may be in an " "unstable state, so it is recommended that you restart the application." ) - self.label2 = QLabel(msg) - self.label2.setWordWrap(True) - self.verticalLayout.addWidget(self.label2) - self.horizontalLayout = QHBoxLayout() - self.horizontalLayout.addItem(horizontal_spacer()) - self.dontSendButton = QPushButton(self) - self.dontSendButton.setText(tr("Close")) - self.dontSendButton.setMinimumSize(QSize(110, 0)) - self.horizontalLayout.addWidget(self.dontSendButton) - self.sendButton = QPushButton(self) - self.sendButton.setText(tr("Go to GitHub")) - self.sendButton.setMinimumSize(QSize(110, 0)) - self.sendButton.setDefault(True) - self.horizontalLayout.addWidget(self.sendButton) - self.verticalLayout.addLayout(self.horizontalLayout) + instructions_label = QLabel(msg) + instructions_label.setWordWrap(True) + main_layout.addWidget(instructions_label) + + button_layout = QHBoxLayout() + button_layout.addItem(horizontal_spacer()) + + close_button = QPushButton(self) + close_button.setText(tr("Close")) + close_button.setMinimumSize(QSize(110, 0)) + button_layout.addWidget(close_button) + + report_button = QPushButton(self) + report_button.setText(tr("Go to GitHub")) + report_button.setMinimumSize(QSize(110, 0)) + report_button.setDefault(True) + button_layout.addWidget(report_button) + + main_layout.addLayout(button_layout) + + report_button.clicked.connect(self.goToGitHub) + close_button.clicked.connect(self.reject) def goToGitHub(self): open_url(self.github_url) @@ -91,6 +98,6 @@ def install_excepthook(github_url): def my_excepthook(exctype, value, tb): s = "".join(traceback.format_exception(exctype, value, tb)) dialog = ErrorReportDialog(None, github_url, s) - dialog.exec_() + dialog.exec() sys.excepthook = my_excepthook diff --git a/qt/exclude_list_dialog.py b/dupeguru/qt/exclude_list_dialog.py similarity index 84% rename from qt/exclude_list_dialog.py rename to dupeguru/qt/exclude_list_dialog.py index 43747b5d6..79e70ea83 100644 --- a/qt/exclude_list_dialog.py +++ b/dupeguru/qt/exclude_list_dialog.py @@ -3,8 +3,8 @@ # http://www.gnu.org/licenses/gpl-3.0.html import re -from PyQt5.QtCore import Qt, pyqtSlot -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtWidgets import ( QPushButton, QLineEdit, QVBoxLayout, @@ -16,17 +16,17 @@ QSizePolicy, QHeaderView, ) -from qt.exclude_list_table import ExcludeListTable +from dupeguru.qt.exclude_list_table import ExcludeListTable -from core.exclude import AlreadyThereException -from hscommon.trans import trget +from dupeguru.core.exclude import AlreadyThereException +from dupeguru.hscommon.trans import trget tr = trget("ui") class ExcludeListDialog(QDialog): def __init__(self, app, parent, model, **kwargs): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint super().__init__(parent, flags, **kwargs) self.app = app self.specific_actions = frozenset() @@ -60,29 +60,31 @@ def _setupUI(self): self.testLine = QLineEdit() self.tableView = QTableView() triggers = ( - QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed | QAbstractItemView.SelectedClicked + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + | QAbstractItemView.EditTrigger.SelectedClicked ) self.tableView.setEditTriggers(triggers) - self.tableView.setSelectionMode(QTableView.ExtendedSelection) - self.tableView.setSelectionBehavior(QTableView.SelectRows) + self.tableView.setSelectionMode(QTableView.SelectionMode.ExtendedSelection) + self.tableView.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) self.tableView.setShowGrid(False) vheader = self.tableView.verticalHeader() vheader.setSectionsMovable(True) vheader.setVisible(False) hheader = self.tableView.horizontalHeader() hheader.setSectionsMovable(False) - hheader.setSectionResizeMode(QHeaderView.Fixed) + hheader.setSectionResizeMode(QHeaderView.ResizeMode.Fixed) hheader.setStretchLastSection(True) hheader.setHighlightSections(False) hheader.setVisible(True) gridlayout.addWidget(self.inputLine, 0, 0) - gridlayout.addWidget(self.buttonAdd, 0, 1, Qt.AlignLeft) - gridlayout.addWidget(self.buttonRemove, 1, 1, Qt.AlignLeft) - gridlayout.addWidget(self.buttonRestore, 2, 1, Qt.AlignLeft) - gridlayout.addWidget(self.buttonHelp, 3, 1, Qt.AlignLeft) + gridlayout.addWidget(self.buttonAdd, 0, 1, Qt.AlignmentFlag.AlignLeft) + gridlayout.addWidget(self.buttonRemove, 1, 1, Qt.AlignmentFlag.AlignLeft) + gridlayout.addWidget(self.buttonRestore, 2, 1, Qt.AlignmentFlag.AlignLeft) + gridlayout.addWidget(self.buttonHelp, 3, 1, Qt.AlignmentFlag.AlignLeft) gridlayout.addWidget(self.buttonClose, 4, 1) gridlayout.addWidget(self.tableView, 1, 0, 6, 1) - gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), 4, 1) + gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 4, 1) gridlayout.addWidget(self.buttonTestString, 6, 1) gridlayout.addWidget(self.testLine, 6, 0) diff --git a/qt/exclude_list_table.py b/dupeguru/qt/exclude_list_table.py similarity index 57% rename from qt/exclude_list_table.py rename to dupeguru/qt/exclude_list_table.py index f07d8cf09..e327d0d6d 100644 --- a/qt/exclude_list_table.py +++ b/dupeguru/qt/exclude_list_table.py @@ -2,12 +2,12 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QFont, QFontMetrics, QIcon, QColor +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QFontMetrics, QIcon, QColor -from qt.column import Column -from qt.table import Table -from hscommon.trans import trget +from dupeguru.qt.column import Column +from dupeguru.qt.table import Table +from dupeguru.hscommon.trans import trget tr = trget("ui") @@ -28,38 +28,43 @@ def __init__(self, app, view, **kwargs): def _getData(self, row, column, role): if column.name == "marked": - if role == Qt.CheckStateRole and row.markable: - return Qt.Checked if row.marked else Qt.Unchecked - if role == Qt.ToolTipRole and not row.markable: + if role == Qt.ItemDataRole.CheckStateRole and row.markable: + return Qt.CheckState.Checked if row.marked else Qt.CheckState.Unchecked + if role == Qt.ItemDataRole.ToolTipRole and not row.markable: return tr("Compilation error: ") + row.get_cell_value("error") - if role == Qt.DecorationRole and not row.markable: + if role == Qt.ItemDataRole.DecorationRole and not row.markable: return QIcon.fromTheme("dialog-error", QIcon(":/error")) return None - if role == Qt.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: return row.data[column.name] - elif role == Qt.FontRole: + elif role == Qt.ItemDataRole.FontRole: return QFont(self.view.font()) - elif role == Qt.BackgroundRole and column.name == "regex": + elif role == Qt.ItemDataRole.BackgroundRole and column.name == "regex": if row.highlight: return QColor(10, 200, 10) # green - elif role == Qt.EditRole and column.name == "regex": + elif role == Qt.ItemDataRole.EditRole and column.name == "regex": return row.data[column.name] return None def _getFlags(self, row, column): - flags = Qt.ItemIsEnabled + flags = Qt.ItemFlag.ItemIsEnabled if column.name == "marked": if row.markable: - flags |= Qt.ItemIsUserCheckable + flags |= Qt.ItemFlag.ItemIsUserCheckable elif column.name == "regex": - flags |= Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled + flags |= ( + Qt.ItemFlag.ItemIsEditable + | Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + ) return flags def _setData(self, row, column, value, role): - if role == Qt.CheckStateRole: + if role == Qt.ItemDataRole.CheckStateRole: if column.name == "marked": row.marked = bool(value) return True - elif role == Qt.EditRole and column.name == "regex": + elif role == Qt.ItemDataRole.EditRole and column.name == "regex": return self.model.rename_selected(value) return False diff --git a/qt/ignore_list_dialog.py b/dupeguru/qt/ignore_list_dialog.py similarity index 79% rename from qt/ignore_list_dialog.py rename to dupeguru/qt/ignore_list_dialog.py index 1ccdcc47d..74ca9d65b 100644 --- a/qt/ignore_list_dialog.py +++ b/dupeguru/qt/ignore_list_dialog.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QPushButton, @@ -15,16 +15,16 @@ QAbstractItemView, ) -from hscommon.trans import trget -from qt.util import horizontal_wrap -from qt.ignore_list_table import IgnoreListTable +from dupeguru.hscommon.trans import trget +from dupeguru.qt.util import horizontal_wrap +from dupeguru.qt.ignore_list_table import IgnoreListTable tr = trget("ui") class IgnoreListDialog(QDialog): def __init__(self, parent, model, **kwargs): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint super().__init__(parent, flags, **kwargs) self.specific_actions = frozenset() self._setupUi() @@ -42,9 +42,9 @@ def _setupUi(self): self.verticalLayout = QVBoxLayout(self) self.verticalLayout.setContentsMargins(0, 0, 0, 0) self.tableView = QTableView() - self.tableView.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.tableView.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tableView.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.tableView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.tableView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.tableView.setShowGrid(False) self.tableView.horizontalHeader().setStretchLastSection(True) self.tableView.verticalHeader().setDefaultSectionSize(18) diff --git a/qt/ignore_list_table.py b/dupeguru/qt/ignore_list_table.py similarity index 86% rename from qt/ignore_list_table.py rename to dupeguru/qt/ignore_list_table.py index 908d93a6c..42291a0a8 100644 --- a/qt/ignore_list_table.py +++ b/dupeguru/qt/ignore_list_table.py @@ -5,8 +5,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from qt.column import Column -from qt.table import Table +from dupeguru.qt.column import Column +from dupeguru.qt.table import Table class IgnoreListTable(Table): diff --git a/qt/me/__init__.py b/dupeguru/qt/me/__init__.py similarity index 100% rename from qt/me/__init__.py rename to dupeguru/qt/me/__init__.py diff --git a/qt/me/details_dialog.py b/dupeguru/qt/me/details_dialog.py similarity index 69% rename from qt/me/details_dialog.py rename to dupeguru/qt/me/details_dialog.py index 43619af6a..37156e4a3 100644 --- a/qt/me/details_dialog.py +++ b/dupeguru/qt/me/details_dialog.py @@ -4,23 +4,23 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QAbstractItemView +from PyQt6.QtCore import QSize +from PyQt6.QtWidgets import QAbstractItemView -from hscommon.trans import trget -from qt.details_dialog import DetailsDialog as DetailsDialogBase -from qt.details_table import DetailsTable +from dupeguru.hscommon.trans import trget +from dupeguru.qt.details_dialog import DetailsDialog as DetailsDialogBase +from dupeguru.qt.details_table import DetailsTable tr = trget("ui") class DetailsDialog(DetailsDialogBase): - def _setupUi(self): + def _setupUi(self) -> None: self.setWindowTitle(tr("Details")) self.resize(502, 295) self.setMinimumSize(QSize(250, 250)) self.tableView = DetailsTable(self) self.tableView.setAlternatingRowColors(True) - self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tableView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.tableView.setShowGrid(False) self.setWidget(self.tableView) diff --git a/qt/me/preferences_dialog.py b/dupeguru/qt/me/preferences_dialog.py similarity index 84% rename from qt/me/preferences_dialog.py rename to dupeguru/qt/me/preferences_dialog.py index abcd7a16d..1d5cee861 100644 --- a/qt/me/preferences_dialog.py +++ b/dupeguru/qt/me/preferences_dialog.py @@ -4,27 +4,22 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import ( - QVBoxLayout, - QHBoxLayout, - QLabel, - QSizePolicy, - QSpacerItem, - QWidget, -) +from typing import Callable +from PyQt6.QtCore import QSize +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, QCheckBox -from hscommon.trans import trget -from core.app import AppMode -from core.scanner import ScanType +from dupeguru.hscommon.trans import trget +from dupeguru.core.app import AppMode +from dupeguru.core.scanner import ScanType +from dupeguru.qt.preferences import Preferences -from qt.preferences_dialog import PreferencesDialogBase +from dupeguru.qt.preferences_dialog import PreferencesDialogBase, Sections tr = trget("ui") class PreferencesDialog(PreferencesDialogBase): - def _setupPreferenceWidgets(self): + def _setupPreferenceWidgets(self) -> None: self._setupFilterHardnessBox() self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self.widget = QWidget(self) @@ -37,7 +32,7 @@ def _setupPreferenceWidgets(self): self.verticalLayout_4.addWidget(self.label_6) self.horizontalLayout_2 = QHBoxLayout() self.horizontalLayout_2.setSpacing(0) - spacer_item = QSpacerItem(15, 20, QSizePolicy.Fixed, QSizePolicy.Minimum) + spacer_item = QSpacerItem(15, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum) self.horizontalLayout_2.addItem(spacer_item) self._setupAddCheckbox("tagTrackBox", tr("Track"), self.widget) self.horizontalLayout_2.addWidget(self.tagTrackBox) @@ -70,7 +65,7 @@ def _setupPreferenceWidgets(self): self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches) self._setupBottomPart() - def _load(self, prefs, setchecked, section): + def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None: setchecked(self.tagTrackBox, prefs.scan_tag_track) setchecked(self.tagArtistBox, prefs.scan_tag_artist) setchecked(self.tagAlbumBox, prefs.scan_tag_album) @@ -89,7 +84,7 @@ def _load(self, prefs, setchecked, section): ScanType.TAG, ) tag_based = scan_type == ScanType.TAG - self.filterHardnessSlider.setEnabled(word_based) + self._setFilterHardnessEnabled(word_based) self.matchSimilarBox.setEnabled(word_based) self.wordWeightingBox.setEnabled(word_based) self.tagTrackBox.setEnabled(tag_based) @@ -99,7 +94,7 @@ def _load(self, prefs, setchecked, section): self.tagGenreBox.setEnabled(tag_based) self.tagYearBox.setEnabled(tag_based) - def _save(self, prefs, ischecked): + def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None: prefs.scan_tag_track = ischecked(self.tagTrackBox) prefs.scan_tag_artist = ischecked(self.tagArtistBox) prefs.scan_tag_album = ischecked(self.tagAlbumBox) diff --git a/qt/me/results_model.py b/dupeguru/qt/me/results_model.py similarity index 91% rename from qt/me/results_model.py rename to dupeguru/qt/me/results_model.py index e4dafc6d1..148bd7d26 100644 --- a/qt/me/results_model.py +++ b/dupeguru/qt/me/results_model.py @@ -4,8 +4,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from qt.column import Column -from qt.results_model import ResultsModel as ResultsModelBase +from dupeguru.qt.column import Column +from dupeguru.qt.results_model import ResultsModel as ResultsModelBase class ResultsModel(ResultsModelBase): diff --git a/qt/pe/__init__.py b/dupeguru/qt/pe/__init__.py similarity index 100% rename from qt/pe/__init__.py rename to dupeguru/qt/pe/__init__.py diff --git a/qt/pe/block.py b/dupeguru/qt/pe/block.py similarity index 96% rename from qt/pe/block.py rename to dupeguru/qt/pe/block.py index 5db5c61c2..1d78d5558 100644 --- a/qt/pe/block.py +++ b/dupeguru/qt/pe/block.py @@ -6,7 +6,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from qt.pe._block_qt import getblocks # NOQA +from dupeguru.qt.pe._block_qt import getblocks # NOQA # Converted to C # def getblock(image): diff --git a/qt/pe/block.pyi b/dupeguru/qt/pe/block.pyi similarity index 87% rename from qt/pe/block.pyi rename to dupeguru/qt/pe/block.pyi index 17954a929..ba5764673 100644 --- a/qt/pe/block.pyi +++ b/dupeguru/qt/pe/block.pyi @@ -1,5 +1,5 @@ from typing import Tuple, List, Union -from PyQt5.QtGui import QImage +from PyQt6.QtGui import QImage _block = Tuple[int, int, int] diff --git a/qt/pe/details_dialog.py b/dupeguru/qt/pe/details_dialog.py similarity index 87% rename from qt/pe/details_dialog.py rename to dupeguru/qt/pe/details_dialog.py index 9f6083b6f..e26fcc5d3 100644 --- a/qt/pe/details_dialog.py +++ b/dupeguru/qt/pe/details_dialog.py @@ -4,13 +4,13 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot -from PyQt5.QtWidgets import QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame -from PyQt5.QtGui import QResizeEvent -from hscommon.trans import trget -from qt.details_dialog import DetailsDialog as DetailsDialogBase -from qt.details_table import DetailsTable -from qt.pe.image_viewer import ViewerToolBar, ScrollAreaImageViewer, ScrollAreaController +from PyQt6.QtCore import Qt, QSize, pyqtSignal, pyqtSlot +from PyQt6.QtWidgets import QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame +from PyQt6.QtGui import QResizeEvent +from dupeguru.hscommon.trans import trget +from dupeguru.qt.details_dialog import DetailsDialog as DetailsDialogBase +from dupeguru.qt.details_table import DetailsTable +from dupeguru.qt.pe.image_viewer import ViewerToolBar, ScrollAreaImageViewer, ScrollAreaController tr = trget("ui") @@ -24,9 +24,9 @@ def _setupUi(self): self.setWindowTitle(tr("Details")) self.resize(502, 502) self.setMinimumSize(QSize(250, 250)) - self.splitter = QSplitter(Qt.Vertical) + self.splitter = QSplitter(Qt.Orientation.Vertical) self.topFrame = EmittingFrame() - self.topFrame.setFrameShape(QFrame.StyledPanel) + self.topFrame.setFrameShape(QFrame.Shape.StyledPanel) self.horizontalLayout = QGridLayout() # Minimum width for the toolbar in the middle: self.horizontalLayout.setColumnMinimumWidth(1, 10) @@ -47,8 +47,8 @@ def _setupUi(self): self.vController = ScrollAreaController(self) self.verticalToolBar = ViewerToolBar(self, self.vController) - self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical)) - self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) + self.verticalToolBar.setOrientation(Qt.Orientation.Vertical) + self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignmentFlag.AlignCenter) self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage") self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1) @@ -57,12 +57,12 @@ def _setupUi(self): self.splitter.setStretchFactor(0, 8) self.tableView = DetailsTable(self) - size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) + size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) size_policy.setHorizontalStretch(0) size_policy.setVerticalStretch(0) self.tableView.setSizePolicy(size_policy) self.tableView.setAlternatingRowColors(True) - self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tableView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.tableView.setShowGrid(False) self.splitter.addWidget(self.tableView) self.splitter.setStretchFactor(1, 1) diff --git a/qt/pe/image_viewer.py b/dupeguru/qt/pe/image_viewer.py similarity index 91% rename from qt/pe/image_viewer.py rename to dupeguru/qt/pe/image_viewer.py index 06d849af8..a43d66d13 100644 --- a/qt/pe/image_viewer.py +++ b/dupeguru/qt/pe/image_viewer.py @@ -2,23 +2,22 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import QObject, Qt, QSize, QRectF, QPointF, QPoint, pyqtSlot, pyqtSignal, QEvent -from PyQt5.QtGui import QPixmap, QPainter, QPalette, QCursor, QIcon, QKeySequence -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import QObject, Qt, QSize, QRectF, QPointF, QPoint, pyqtSlot, pyqtSignal, QEvent +from PyQt6.QtGui import QPixmap, QPainter, QPalette, QCursor, QIcon, QKeySequence, QAction +from PyQt6.QtWidgets import ( QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QToolBar, QToolButton, - QAction, QWidget, QScrollArea, QApplication, QAbstractScrollArea, QStyle, ) -from hscommon.trans import trget -from hscommon.plat import ISLINUX +from dupeguru.hscommon.trans import trget +from dupeguru.hscommon.plat import ISLINUX tr = trget("ui") @@ -57,7 +56,7 @@ def setupActions(self, controller): ACTIONS = [ ( "actionZoomIn", - QKeySequence.ZoomIn, + QKeySequence.StandardKey.ZoomIn, ( QIcon.fromTheme("zoom-in") if ISLINUX and not self.parent.app.prefs.details_dialog_override_theme_icons @@ -68,7 +67,7 @@ def setupActions(self, controller): ), ( "actionZoomOut", - QKeySequence.ZoomOut, + QKeySequence.StandardKey.ZoomOut, ( QIcon.fromTheme("zoom-out") if ISLINUX and not self.parent.app.prefs.details_dialog_override_theme_icons @@ -106,9 +105,9 @@ def setupActions(self, controller): def createButtons(self): self.buttonImgSwap = QToolButton(self) - self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.buttonImgSwap.setIcon( - QIcon.fromTheme("view-refresh", self.style().standardIcon(QStyle.SP_BrowserReload)) + QIcon.fromTheme("view-refresh", self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) if ISLINUX and not self.parent.app.prefs.details_dialog_override_theme_icons else QIcon(QPixmap(":/" + "exchange")) ) @@ -118,22 +117,22 @@ def createButtons(self): self.buttonImgSwap.released.connect(self.controller.swapImages) self.buttonZoomIn = QToolButton(self) - self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.buttonZoomIn.setDefaultAction(self.actionZoomIn) self.buttonZoomIn.setEnabled(False) self.buttonZoomOut = QToolButton(self) - self.buttonZoomOut.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonZoomOut.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.buttonZoomOut.setDefaultAction(self.actionZoomOut) self.buttonZoomOut.setEnabled(False) self.buttonNormalSize = QToolButton(self) - self.buttonNormalSize.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonNormalSize.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.buttonNormalSize.setDefaultAction(self.actionNormalSize) self.buttonNormalSize.setEnabled(True) self.buttonBestFit = QToolButton(self) - self.buttonBestFit.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonBestFit.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.buttonBestFit.setDefaultAction(self.actionBestFit) self.buttonBestFit.setEnabled(False) @@ -228,10 +227,14 @@ def _updateImage(self, pixmap, viewer, same_group=False): return target_size # zoomed in state, expand # only if not same_group, we need full update - scaledpixmap = pixmap.scaled(target_size, Qt.KeepAspectRatioByExpanding, Qt.FastTransformation) + scaledpixmap = pixmap.scaled( + target_size, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.FastTransformation + ) else: # best fit, keep ratio always - scaledpixmap = pixmap.scaled(target_size, Qt.KeepAspectRatio, Qt.FastTransformation) + scaledpixmap = pixmap.scaled( + target_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation + ) viewer.setImage(scaledpixmap) return target_size @@ -740,7 +743,7 @@ def resetCenter(self): self.update() def changeEvent(self, event): - if event.type() == QEvent.EnabledChange: + if event.type() == QEvent.Type.EnabledChange: if self.isEnabled(): self.connectMouseSignals() return @@ -754,7 +757,7 @@ def mousePressEvent(self, event): if self.bestFit or not self.isEnabled(): event.ignore() return - if event.button() & (Qt.LeftButton | Qt.MidButton | Qt.RightButton): + if event.button() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton | Qt.MouseButton.RightButton): self._drag = True else: self._drag = False @@ -762,7 +765,7 @@ def mousePressEvent(self, event): return self._lastMouseClickPoint = event.pos() - self._app.setOverrideCursor(Qt.ClosedHandCursor) + self._app.setOverrideCursor(Qt.CursorShape.ClosedHandCursor) self.setMouseTracking(True) event.accept() @@ -893,18 +896,18 @@ def __init__(self, parent, name=""): self.label = ScalablePixmap(self) # This is to avoid sending signals twice on scrollbar updates self.ignore_signal = False - self.setBackgroundRole(QPalette.Dark) + self.setBackgroundRole(QPalette.ColorRole.Dark) self.setWidgetResizable(False) - self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) - self.setAlignment(Qt.AlignCenter) + self.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) self._verticalScrollBar = self.verticalScrollBar() self._horizontalScrollBar = self.horizontalScrollBar() if self.prefs.details_dialog_viewers_show_scrollbars: self.toggleScrollBars() else: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setWidget(self.label) self.setVisible(True) @@ -916,14 +919,14 @@ def toggleScrollBars(self, force_on=False): if not self.prefs.details_dialog_viewers_show_scrollbars: return # Ensure that it's off on the first run - if self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded: + if self.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded: if force_on: return - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) else: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) def connectMouseSignals(self): if not self._dragConnection: @@ -942,8 +945,12 @@ def disconnectMouseSignals(self): def connectScrollBars(self): """Only call once controller is connected.""" # Cyclic connections are handled by Qt - self._verticalScrollBar.valueChanged.connect(self.controller.onVScrollBarChanged, Qt.UniqueConnection) - self._horizontalScrollBar.valueChanged.connect(self.controller.onHScrollBarChanged, Qt.UniqueConnection) + self._verticalScrollBar.valueChanged.connect( + self.controller.onVScrollBarChanged, Qt.ConnectionType.UniqueConnection + ) + self._horizontalScrollBar.valueChanged.connect( + self.controller.onHScrollBarChanged, Qt.ConnectionType.UniqueConnection + ) def contextMenuEvent(self, event): """Block parent's (main window) context menu on right click.""" @@ -956,14 +963,14 @@ def mousePressEvent(self, event): if self.bestFit: event.ignore() return - if event.button() & (Qt.LeftButton | Qt.MidButton | Qt.RightButton): + if event.button() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton | Qt.MouseButton.RightButton): self._drag = True else: self._drag = False event.ignore() return self._lastMouseClickPoint = event.pos() - self._app.setOverrideCursor(Qt.ClosedHandCursor) + self._app.setOverrideCursor(Qt.CursorShape.ClosedHandCursor) self.setMouseTracking(True) super().mousePressEvent(event) @@ -1000,7 +1007,7 @@ def wheelEvent(self, event): if old_scale == self.current_scale: return - delta_to_pos = (event.position() / old_scale) - (self.label.pos() / old_scale) + delta_to_pos = (event.position() / old_scale) - (QPointF(self.label.pos()) / old_scale) delta = (delta_to_pos * self.current_scale) - (delta_to_pos * old_scale) self.mouseWheeled.emit(self.current_scale, delta) @@ -1128,7 +1135,7 @@ def __init__(self, parent, name=""): self.other_viewer = None # specific to this class self._scene = QGraphicsScene() - self._scene.setBackgroundBrush(Qt.black) + self._scene.setBackgroundBrush(Qt.GlobalColor.black) self._item = QGraphicsPixmapItem() self.setScene(self._scene) self._scene.addItem(self._item) @@ -1140,12 +1147,12 @@ def __init__(self, parent, name=""): if self.prefs.details_dialog_viewers_show_scrollbars: self.toggleScrollBars() else: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setResizeAnchor(QGraphicsView.AnchorViewCenter) - self.setAlignment(Qt.AlignCenter) - self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) + self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate) self.setMouseTracking(True) def connectMouseSignals(self): @@ -1165,21 +1172,25 @@ def disconnectMouseSignals(self): def connectScrollBars(self): """Only call once controller is connected.""" # Cyclic connections are handled by Qt - self._verticalScrollBar.valueChanged.connect(self.controller.onVScrollBarChanged, Qt.UniqueConnection) - self._horizontalScrollBar.valueChanged.connect(self.controller.onHScrollBarChanged, Qt.UniqueConnection) + self._verticalScrollBar.valueChanged.connect( + self.controller.onVScrollBarChanged, Qt.ConnectionType.UniqueConnection + ) + self._horizontalScrollBar.valueChanged.connect( + self.controller.onHScrollBarChanged, Qt.ConnectionType.UniqueConnection + ) def toggleScrollBars(self, force_on=False): if not self.prefs.details_dialog_viewers_show_scrollbars: return # Ensure that it's off on the first run - if self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded: + if self.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded: if force_on: return - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) else: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) def contextMenuEvent(self, event): """Block parent's (main window) context menu on right click.""" @@ -1189,14 +1200,14 @@ def mousePressEvent(self, event): if self.bestFit: event.ignore() return - if event.button() & (Qt.LeftButton | Qt.MidButton | Qt.RightButton): + if event.button() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton | Qt.MouseButton.RightButton): self._drag = True else: self._drag = False event.ignore() return self._lastMouseClickPoint = event.pos() - self._app.setOverrideCursor(Qt.ClosedHandCursor) + self._app.setOverrideCursor(Qt.CursorShape.ClosedHandCursor) self.setMouseTracking(True) # We need to propagate to scrollbars, so we send back up super().mousePressEvent(event) @@ -1299,7 +1310,7 @@ def resetScale(self): def fitScale(self): self.bestFit = True - super().fitInView(self._scene.sceneRect(), Qt.KeepAspectRatio) + super().fitInView(self._scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) self.setNewCenter(self._scene.sceneRect().center()) @pyqtSlot() diff --git a/qt/pe/modules/block.c b/dupeguru/qt/pe/modules/block.c similarity index 100% rename from qt/pe/modules/block.c rename to dupeguru/qt/pe/modules/block.c diff --git a/qt/pe/photo.py b/dupeguru/qt/pe/photo.py similarity index 79% rename from qt/pe/photo.py rename to dupeguru/qt/pe/photo.py index e0d0b96e6..79b87903a 100644 --- a/qt/pe/photo.py +++ b/dupeguru/qt/pe/photo.py @@ -6,11 +6,12 @@ import logging -from PyQt5.QtGui import QImage, QImageReader, QTransform +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QImage, QImageReader, QTransform -from core.pe.photo import Photo as PhotoBase +from dupeguru.core.pe.photo import Photo as PhotoBase -from qt.pe.block import getblocks +from dupeguru.qt.pe.block import getblocks class File(PhotoBase): @@ -28,7 +29,7 @@ def _plat_get_dimensions(self): def _plat_get_blocks(self, block_count_per_side, orientation): image = QImage(str(self.path)) - image = image.convertToFormat(QImage.Format_RGB888) + image = image.convertToFormat(QImage.Format.Format_RGB888) if not isinstance(orientation, int): logging.warning( "Orientation for file '%s' was a %s '%s', not an int.", @@ -69,4 +70,9 @@ def _plat_get_blocks(self, block_count_per_side, orientation): elif orientation == 8: t.rotate(270) image = image.transformed(t) + prescale_multiplier = getattr(self, '_prescale_multiplier', 30) + if prescale_multiplier > 0: + target_size = block_count_per_side * prescale_multiplier + if image.width() > target_size or image.height() > target_size: + image = image.scaled(target_size, target_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) return getblocks(image, block_count_per_side) diff --git a/qt/pe/preferences_dialog.py b/dupeguru/qt/pe/preferences_dialog.py similarity index 72% rename from qt/pe/preferences_dialog.py rename to dupeguru/qt/pe/preferences_dialog.py index 735d4eda8..17a1f7b2e 100644 --- a/qt/pe/preferences_dialog.py +++ b/dupeguru/qt/pe/preferences_dialog.py @@ -5,24 +5,38 @@ # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.trans import trget -from hscommon.plat import ISLINUX -from core.scanner import ScanType -from core.app import AppMode +from typing import Callable -from qt.preferences_dialog import PreferencesDialogBase +from PyQt6.QtWidgets import QCheckBox + +from PyQt6.QtWidgets import QLabel, QComboBox + +from dupeguru.hscommon.trans import trget +from dupeguru.hscommon.plat import ISLINUX +from dupeguru.core.scanner import ScanType +from dupeguru.core.app import AppMode + +from dupeguru.qt.preferences import Preferences +from dupeguru.qt.preferences_dialog import PreferencesDialogBase, Sections tr = trget("ui") class PreferencesDialog(PreferencesDialogBase): - def _setupPreferenceWidgets(self): + def _setupPreferenceWidgets(self) -> None: self._setupFilterHardnessBox() self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self._setupAddCheckbox("matchScaledBox", tr("Match pictures of different dimensions")) self.widgetsVLayout.addWidget(self.matchScaledBox) self._setupAddCheckbox("matchRotatedBox", tr("Match pictures of different rotations")) self.widgetsVLayout.addWidget(self.matchRotatedBox) + self.prescaleLabel = QLabel(tr("Scan speed:")) + self.widgetsVLayout.addWidget(self.prescaleLabel) + self.prescaleComboBox = QComboBox(self) + self.prescaleComboBox.addItem(tr("Accurate (slow)")) + self.prescaleComboBox.addItem(tr("Balanced (recommended)")) + self.prescaleComboBox.addItem(tr("Turbo")) + self.widgetsVLayout.addWidget(self.prescaleComboBox) self._setupAddCheckbox("mixFileKindBox", tr("Can mix file kind")) self.widgetsVLayout.addWidget(self.mixFileKindBox) self._setupAddCheckbox("useRegexpBox", tr("Use regular expressions when filtering")) @@ -37,7 +51,7 @@ def _setupPreferenceWidgets(self): self._setupBottomPart() - def _setupDisplayPage(self): + def _setupDisplayPage(self) -> None: super()._setupDisplayPage() self._setupAddCheckbox("details_dialog_override_theme_icons", tr("Override theme icons in viewer toolbar")) self.details_dialog_override_theme_icons.setToolTip( @@ -57,19 +71,21 @@ def _setupDisplayPage(self): ) self.details_groupbox_layout.insertWidget(index + 2, self.details_dialog_viewers_show_scrollbars) - def _load(self, prefs, setchecked, section): + def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None: setchecked(self.matchScaledBox, prefs.match_scaled) setchecked(self.matchRotatedBox, prefs.match_rotated) + self.prescaleComboBox.setCurrentIndex(prefs.picture_prescale) # Update UI state based on selected scan type scan_type = prefs.get_scan_type(AppMode.PICTURE) fuzzy_scan = scan_type == ScanType.FUZZYBLOCK - self.filterHardnessSlider.setEnabled(fuzzy_scan) + self._setFilterHardnessEnabled(fuzzy_scan) setchecked(self.details_dialog_override_theme_icons, prefs.details_dialog_override_theme_icons) setchecked(self.details_dialog_viewers_show_scrollbars, prefs.details_dialog_viewers_show_scrollbars) - def _save(self, prefs, ischecked): + def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None: prefs.match_scaled = ischecked(self.matchScaledBox) prefs.match_rotated = ischecked(self.matchRotatedBox) + prefs.picture_prescale = self.prescaleComboBox.currentIndex() prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons) prefs.details_dialog_viewers_show_scrollbars = ischecked(self.details_dialog_viewers_show_scrollbars) diff --git a/qt/pe/results_model.py b/dupeguru/qt/pe/results_model.py similarity index 88% rename from qt/pe/results_model.py rename to dupeguru/qt/pe/results_model.py index 5ebfa8c4b..485fe9f75 100644 --- a/qt/pe/results_model.py +++ b/dupeguru/qt/pe/results_model.py @@ -4,8 +4,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from qt.column import Column -from qt.results_model import ResultsModel as ResultsModelBase +from dupeguru.qt.column import Column +from dupeguru.qt.results_model import ResultsModel as ResultsModelBase class ResultsModel(ResultsModelBase): diff --git a/qt/platform.py b/dupeguru/qt/platform.py similarity index 94% rename from qt/platform.py rename to dupeguru/qt/platform.py index 02c52e11f..e1d1758d5 100644 --- a/qt/platform.py +++ b/dupeguru/qt/platform.py @@ -5,7 +5,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html import os.path as op -from hscommon.plat import ISWINDOWS, ISOSX, ISLINUX +from dupeguru.hscommon.plat import ISWINDOWS, ISOSX, ISLINUX if op.exists(__file__): # We want to get the absolute path or our root folder. We know that in that folder we're diff --git a/qt/preferences.py b/dupeguru/qt/preferences.py similarity index 93% rename from qt/preferences.py rename to dupeguru/qt/preferences.py index 1f88cc314..f37e265e8 100644 --- a/qt/preferences.py +++ b/dupeguru/qt/preferences.py @@ -4,16 +4,17 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtWidgets import QApplication, QDockWidget -from PyQt5.QtCore import Qt, QRect, QObject, pyqtSignal -from PyQt5.QtGui import QColor +from typing import Any, Tuple +from PyQt6.QtWidgets import QApplication, QDockWidget +from PyQt6.QtCore import Qt, QRect, QObject, pyqtSignal +from PyQt6.QtGui import QColor -from hscommon import trans -from hscommon.plat import ISLINUX -from core.app import AppMode -from core.scanner import ScanType -from hscommon.util import tryint -from qt.util import create_qsettings +from dupeguru.hscommon import trans +from dupeguru.hscommon.plat import ISLINUX +from dupeguru.core.app import AppMode +from dupeguru.core.scanner import ScanType +from dupeguru.hscommon.util import tryint +from dupeguru.qt.util import create_qsettings def get_langnames(): @@ -126,7 +127,7 @@ def set_rect(self, name, r): def set_value(self, name, value): self._settings.setValue(name, _normalize_for_serialization(value)) - def saveGeometry(self, name, widget): + def saveGeometry(self, name, widget) -> None: # We save geometry under a 7-sized int array: first item is a flag # for whether the widget is maximized, second item is a flag for whether # the widget is docked, third item is a Qt::DockWidgetArea enum value, @@ -138,12 +139,12 @@ def saveGeometry(self, name, widget): rect_as_list = [r.x(), r.y(), r.width(), r.height()] self.set_value(name, [m, d, area] + rect_as_list) - def restoreGeometry(self, name, widget): + def restoreGeometry(self, name, widget) -> Tuple[bool, Any]: geometry = self.get_value(name) if geometry and len(geometry) == 7: m, d, area, x, y, w, h = geometry if m: - widget.setWindowState(Qt.WindowMaximized) + widget.setWindowState(Qt.WindowState.WindowMaximized) else: r = QRect(x, y, w, h) widget.setGeometry(r) @@ -154,7 +155,7 @@ def restoreGeometry(self, name, widget): class Preferences(PreferencesBase): - def _load_values(self, settings): + def _load_values(self, settings) -> None: get = self.get_value self.filter_hardness = get("FilterHardness", self.filter_hardness) self.mix_file_kind = get("MixFileKind", self.mix_file_kind) @@ -226,8 +227,9 @@ def _load_values(self, settings): self.scan_tag_year = get("ScanTagYear", self.scan_tag_year) self.match_scaled = get("MatchScaled", self.match_scaled) self.match_rotated = get("MatchRotated", self.match_rotated) + self.picture_prescale = get("PicturePrescale", self.picture_prescale) - def reset(self): + def reset(self) -> None: self.filter_hardness = 95 self.mix_file_kind = True self.use_regexp = False @@ -251,8 +253,8 @@ def reset(self): # By default use internal icons on platforms other than Linux for now self.details_dialog_override_theme_icons = False if not ISLINUX else True self.details_dialog_viewers_show_scrollbars = True - self.result_table_ref_foreground_color = QColor(Qt.blue) - self.result_table_ref_background_color = QColor(Qt.lightGray) + self.result_table_ref_foreground_color = QColor(Qt.GlobalColor.blue) + self.result_table_ref_background_color = QColor(Qt.GlobalColor.lightGray) self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange self.resultWindowIsMaximized = False self.resultWindowRect = None @@ -279,8 +281,9 @@ def reset(self): self.scan_tag_year = False self.match_scaled = False self.match_rotated = False + self.picture_prescale = 1 # 0=accurate, 1=balanced, 2=turbo - def _save_values(self, settings): + def _save_values(self, settings) -> None: set_ = self.set_value set_("FilterHardness", self.filter_hardness) set_("MixFileKind", self.mix_file_kind) @@ -333,6 +336,7 @@ def _save_values(self, settings): set_("ScanTagYear", self.scan_tag_year) set_("MatchScaled", self.match_scaled) set_("MatchRotated", self.match_rotated) + set_("PicturePrescale", self.picture_prescale) # scan_type is special because we save it immediately when we set it. def get_scan_type(self, app_mode): diff --git a/qt/preferences_dialog.py b/dupeguru/qt/preferences_dialog.py similarity index 83% rename from qt/preferences_dialog.py rename to dupeguru/qt/preferences_dialog.py index 216d3f7e5..ad1d4f657 100644 --- a/qt/preferences_dialog.py +++ b/dupeguru/qt/preferences_dialog.py @@ -4,8 +4,9 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, QSize, pyqtSlot -from PyQt5.QtWidgets import ( +from typing import Union +from PyQt6.QtCore import Qt, QSize, pyqtSlot +from PyQt6.QtWidgets import ( QDialog, QDialogButtonBox, QVBoxLayout, @@ -28,16 +29,21 @@ QGroupBox, QFormLayout, ) -from PyQt5.QtGui import QPixmap, QIcon -from hscommon import desktop, plat +from PyQt6.QtGui import QPixmap, QIcon, QShowEvent +from dupeguru.hscommon import desktop, plat -from hscommon.trans import trget -from hscommon.plat import ISLINUX -from qt.util import horizontal_wrap, move_to_screen_center -from qt.preferences import get_langnames +from dupeguru.hscommon.trans import trget +from dupeguru.hscommon.plat import ISLINUX +from dupeguru.qt.util import horizontal_wrap, move_to_screen_center +from dupeguru.qt.preferences import get_langnames from enum import Flag, auto -from qt.preferences import Preferences +from dupeguru.qt.preferences import Preferences + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dupeguru.qt.app import DupeGuru tr = trget("ui") @@ -53,8 +59,8 @@ class Sections(Flag): class PreferencesDialogBase(QDialog): - def __init__(self, parent, app, **kwargs): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + def __init__(self, parent: QWidget, app: "DupeGuru", **kwargs): + flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint super().__init__(parent, flags, **kwargs) self.app = app self.supportedLanguages = dict(sorted(get_langnames().items(), key=lambda item: item[1])) @@ -66,18 +72,18 @@ def __init__(self, parent, app, **kwargs): self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) - def _setupFilterHardnessBox(self): + def _setupFilterHardnessBox(self) -> None: self.filterHardnessHLayout = QHBoxLayout() - self.filterHardnessLabel = QLabel(self) - self.filterHardnessLabel.setText(tr("Filter Hardness:")) - self.filterHardnessLabel.setMinimumSize(QSize(0, 0)) - self.filterHardnessHLayout.addWidget(self.filterHardnessLabel) + self.filterHardnessTitleLabel = QLabel(self) + self.filterHardnessTitleLabel.setText(tr("Filter Hardness:")) + self.filterHardnessTitleLabel.setMinimumSize(QSize(0, 0)) + self.filterHardnessHLayout.addWidget(self.filterHardnessTitleLabel) self.filterHardnessVLayout = QVBoxLayout() self.filterHardnessVLayout.setSpacing(0) self.filterHardnessHLayoutSub1 = QHBoxLayout() self.filterHardnessHLayoutSub1.setSpacing(12) self.filterHardnessSlider = QSlider(self) - size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) size_policy.setHorizontalStretch(0) size_policy.setVerticalStretch(0) size_policy.setHeightForWidth(self.filterHardnessSlider.sizePolicy().hasHeightForWidth()) @@ -85,7 +91,7 @@ def _setupFilterHardnessBox(self): self.filterHardnessSlider.setMinimum(1) self.filterHardnessSlider.setMaximum(100) self.filterHardnessSlider.setTracking(True) - self.filterHardnessSlider.setOrientation(Qt.Horizontal) + self.filterHardnessSlider.setOrientation(Qt.Orientation.Horizontal) self.filterHardnessHLayoutSub1.addWidget(self.filterHardnessSlider) self.filterHardnessLabel = QLabel(self) self.filterHardnessLabel.setText("100") @@ -97,7 +103,7 @@ def _setupFilterHardnessBox(self): self.moreResultsLabel = QLabel(self) self.moreResultsLabel.setText(tr("More Results")) self.filterHardnessHLayoutSub2.addWidget(self.moreResultsLabel) - spacer_item = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + spacer_item = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) self.filterHardnessHLayoutSub2.addItem(spacer_item) self.fewerResultsLabel = QLabel(self) self.fewerResultsLabel.setText(tr("Fewer Results")) @@ -105,7 +111,14 @@ def _setupFilterHardnessBox(self): self.filterHardnessVLayout.addLayout(self.filterHardnessHLayoutSub2) self.filterHardnessHLayout.addLayout(self.filterHardnessVLayout) - def _setupBottomPart(self): + def _setFilterHardnessEnabled(self, enabled): + self.filterHardnessSlider.setEnabled(enabled) + self.filterHardnessLabel.setEnabled(enabled) + self.filterHardnessTitleLabel.setEnabled(enabled) + self.moreResultsLabel.setEnabled(enabled) + self.fewerResultsLabel.setEnabled(enabled) + + def _setupBottomPart(self) -> None: # The bottom part of the pref panel is always the same in all editions. self.copyMoveLabel = QLabel(self) self.copyMoveLabel.setText(tr("Copy and Move:")) @@ -121,7 +134,7 @@ def _setupBottomPart(self): self.customCommandEdit = QLineEdit(self) self.widgetsVLayout.addWidget(self.customCommandEdit) - def _setupDisplayPage(self): + def _setupDisplayPage(self) -> None: self.ui_groupbox = QGroupBox("&" + tr("General Interface")) layout = QVBoxLayout() self.languageLabel = QLabel(tr("Language:"), self) @@ -173,7 +186,7 @@ def _setupDisplayPage(self): formlayout.addRow(tr("Reference background color:"), self.result_table_ref_background_color) self.result_table_delta_foreground_color = ColorPickerButton(self) formlayout.addRow(tr("Delta foreground color:"), self.result_table_delta_foreground_color) - formlayout.setLabelAlignment(Qt.AlignLeft) + formlayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) # Keep same vertical spacing as parent layout for consistency formlayout.setVerticalSpacing(self.displayVLayout.spacing()) @@ -215,21 +228,7 @@ def _setupDisplayPage(self): details_groupbox.setLayout(self.details_groupbox_layout) self.displayVLayout.addWidget(details_groupbox) - def _setup_advanced_page(self): - tab_label = QLabel( - tr( - "These options are for advanced users or for very specific situations, \ -most users should not have to modify these." - ), - wordWrap=True, - ) - self.advanced_vlayout.addWidget(tab_label) - self._setupAddCheckbox("include_exists_check_box", tr("Include existence check after scan completion")) - self.advanced_vlayout.addWidget(self.include_exists_check_box) - self._setupAddCheckbox("rehash_ignore_mtime_box", tr("Ignore difference in mtime when loading cached digests")) - self.advanced_vlayout.addWidget(self.rehash_ignore_mtime_box) - - def _setupDebugPage(self): + def _setupDebugPage(self) -> None: self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)")) self._setupAddCheckbox("profile_scan_box", tr("Profile scan operation")) self.profile_scan_box.setToolTip(tr("Profile the scan operation and save logs for optimization.")) @@ -241,7 +240,7 @@ def _setupDebugPage(self): ) self.debugVLayout.addWidget(self.debug_location_label) - def _setupAddCheckbox(self, name, label, parent=None): + def _setupAddCheckbox(self, name: str, label: str, parent: Union[QWidget, None] = None) -> None: if parent is None: parent = self cb = QCheckBox(parent) @@ -252,7 +251,27 @@ def _setupPreferenceWidgets(self): # Edition-specific pass - def _setupUi(self): + def _setup_advanced_page(self) -> None: + tab_label = QLabel( + tr( + "These options are for advanced users or for very specific situations, \ +most users should not have to modify these." + ), + wordWrap=True, + ) + self.advanced_vlayout.addWidget(tab_label) + self._setupAddCheckbox( + "rehash_ignore_mtime_box", + tr("Ignore difference in mtime when loading cached digests"), + ) + self.advanced_vlayout.addWidget(self.rehash_ignore_mtime_box) + self._setupAddCheckbox( + "include_exists_check_box", + tr("Include existence check after scan completion"), + ) + self.advanced_vlayout.addWidget(self.include_exists_check_box) + + def _setupUi(self) -> None: self.setWindowTitle(tr("Options")) self.setSizeGripEnabled(False) self.setModal(True) @@ -278,11 +297,13 @@ def _setupUi(self): # self.mainVLayout.addLayout(self.widgetsVLayout) self.buttonBox = QDialogButtonBox(self) self.buttonBox.setStandardButtons( - QDialogButtonBox.Cancel | QDialogButtonBox.Ok | QDialogButtonBox.RestoreDefaults + QDialogButtonBox.StandardButton.Cancel + | QDialogButtonBox.StandardButton.Ok + | QDialogButtonBox.StandardButton.RestoreDefaults ) self.mainVLayout.addWidget(self.tabwidget) self.mainVLayout.addWidget(self.buttonBox) - self.layout().setSizeConstraint(QLayout.SetFixedSize) + self.layout().setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) self.tabwidget.addTab(self.page_general, tr("General")) self.tabwidget.addTab(self.page_display, tr("Display")) self.tabwidget.addTab(self.page_advanced, tr("Advanced")) @@ -292,20 +313,20 @@ def _setupUi(self): self.advanced_vlayout.addStretch(0) self.debugVLayout.addStretch(0) - def _load(self, prefs, setchecked, section): + def _load(self, prefs, setchecked, section) -> None: # Edition-specific pass - def _save(self, prefs, ischecked): + def _save(self, prefs, ischecked) -> None: # Edition-specific pass - def load(self, prefs=None, section=Sections.ALL): + def load(self, prefs: Preferences = None, section: Sections = Sections.ALL) -> None: if prefs is None: prefs = self.app.prefs - def setchecked(cb, b): - cb.setCheckState(Qt.Checked if b else Qt.Unchecked) + def setchecked(cb: QCheckBox, b: bool) -> None: + cb.setCheckState(Qt.CheckState.Checked if b else Qt.CheckState.Unchecked) if section & Sections.GENERAL: self.filterHardnessSlider.setValue(prefs.filter_hardness) @@ -348,12 +369,12 @@ def setchecked(cb, b): setchecked(self.profile_scan_box, prefs.profile_scan) self._load(prefs, setchecked, section) - def save(self): + def save(self) -> None: prefs = self.app.prefs prefs.filter_hardness = self.filterHardnessSlider.value() - def ischecked(cb): - return cb.checkState() == Qt.Checked + def ischecked(cb: QCheckBox) -> bool: + return cb.checkState() == Qt.CheckState.Checked prefs.mix_file_kind = ischecked(self.mixFileKindBox) prefs.use_regexp = ischecked(self.useRegexpBox) @@ -390,13 +411,13 @@ def ischecked(cb): self.app.prefs.language = lang_code self._save(prefs, ischecked) - def resetToDefaults(self, section_to_update): + def resetToDefaults(self, section_to_update: Sections) -> None: self.load(Preferences(), section_to_update) # --- Events - def buttonClicked(self, button): + def buttonClicked(self, button: QPushButton) -> None: role = self.buttonBox.buttonRole(button) - if role == QDialogButtonBox.ResetRole: + if role == QDialogButtonBox.ButtonRole.ResetRole: current_tab = self.tabwidget.currentWidget() section_to_update = Sections.ALL if current_tab is self.page_general: @@ -407,30 +428,31 @@ def buttonClicked(self, button): section_to_update = Sections.DEBUG self.resetToDefaults(section_to_update) - def showEvent(self, event): + def showEvent(self, event: QShowEvent) -> None: # have to do this here as the frameGeometry is not correct until shown move_to_screen_center(self) super().showEvent(event) class ColorPickerButton(QPushButton): - def __init__(self, parent): + def __init__(self, parent: QWidget) -> None: super().__init__(parent) - self.parent = parent self.color = None self.clicked.connect(self.onClicked) @pyqtSlot() - def onClicked(self): - color = QColorDialog.getColor(self.color if self.color is not None else Qt.white, self.parent) + def onClicked(self) -> None: + color = QColorDialog.getColor( + self.color if self.color is not None else Qt.GlobalColor.white, self.parentWidget() + ) self.setColor(color) - def setColor(self, color): + def setColor(self, color) -> None: size = QSize(16, 16) px = QPixmap(size) if color is None: - size.width = 0 - size.height = 0 + size.setWidth(0) + size.setHeight(0) elif not color.isValid(): return else: diff --git a/qt/prioritize_dialog.py b/dupeguru/qt/prioritize_dialog.py similarity index 83% rename from qt/prioritize_dialog.py rename to dupeguru/qt/prioritize_dialog.py index d63778fb8..cc59b22e2 100644 --- a/qt/prioritize_dialog.py +++ b/dupeguru/qt/prioritize_dialog.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, QMimeData, QByteArray -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import Qt, QMimeData, QByteArray +from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, @@ -23,10 +23,10 @@ QSizePolicy, ) -from hscommon.trans import trget -from qt.selectable_list import ComboboxModel, ListviewModel -from qt.util import vertical_spacer -from core.gui.prioritize_dialog import PrioritizeDialog as PrioritizeDialogModel +from dupeguru.hscommon.trans import trget +from dupeguru.qt.selectable_list import ComboboxModel, ListviewModel +from dupeguru.qt.util import vertical_spacer +from dupeguru.core.gui.prioritize_dialog import PrioritizeDialog as PrioritizeDialogModel tr = trget("ui") @@ -36,8 +36,8 @@ class PrioritizationList(ListviewModel): def flags(self, index): if not index.isValid(): - return Qt.ItemIsEnabled | Qt.ItemIsDropEnabled - return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsDropEnabled + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled # --- Drag & Drop def dropMimeData(self, mime_data, action, row, column, parent_index): @@ -70,12 +70,12 @@ def mimeTypes(self): return [MIME_INDEXES] def supportedDropActions(self): - return Qt.MoveAction + return Qt.DropAction.MoveAction class PrioritizeDialog(QDialog): def __init__(self, parent, app, **kwargs): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint super().__init__(parent, flags, **kwargs) self._setupUi() self.model = PrioritizeDialogModel(app=app.model) @@ -107,24 +107,24 @@ def _setupUi(self): self.promptLabel.setWordWrap(True) self.categoryCombobox = QComboBox() self.criteriaListView = QListView() - self.criteriaListView.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.addCriteriaButton = QPushButton(self.style().standardIcon(QStyle.SP_ArrowRight), "") - self.removeCriteriaButton = QPushButton(self.style().standardIcon(QStyle.SP_ArrowLeft), "") + self.criteriaListView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.addCriteriaButton = QPushButton(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight), "") + self.removeCriteriaButton = QPushButton(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowLeft), "") self.prioritizationListView = QListView() self.prioritizationListView.setAcceptDrops(True) self.prioritizationListView.setDragEnabled(True) - self.prioritizationListView.setDragDropMode(QAbstractItemView.InternalMove) - self.prioritizationListView.setSelectionBehavior(QAbstractItemView.SelectRows) - self.prioritizationListView.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.prioritizationListView.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.prioritizationListView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.prioritizationListView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.buttonBox = QDialogButtonBox() - self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) + self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok) # layout self.mainLayout = QVBoxLayout(self) self.mainLayout.addWidget(self.promptLabel) self.splitter = QSplitter() sp = self.splitter.sizePolicy() - sp.setVerticalPolicy(QSizePolicy.Expanding) + sp.setVerticalPolicy(QSizePolicy.Policy.Expanding) self.splitter.setSizePolicy(sp) self.leftSide = QWidget() self.leftWidgetsLayout = QVBoxLayout() diff --git a/dupeguru/qt/problem_dialog.py b/dupeguru/qt/problem_dialog.py new file mode 100644 index 000000000..a81ca670e --- /dev/null +++ b/dupeguru/qt/problem_dialog.py @@ -0,0 +1,85 @@ +# Created By: Virgil Dupras +# Created On: 2010-04-12 +# 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 PyQt6.QtCore import Qt +from PyQt6.QtGui import QShowEvent +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QSpacerItem, + QSizePolicy, + QLabel, + QTableView, + QAbstractItemView, + QWidget, +) + +from dupeguru.core.gui.problem_dialog import ProblemDialog as ProblemDiaglogModel +from dupeguru.qt.util import move_to_screen_center +from dupeguru.hscommon.trans import trget +from dupeguru.qt.problem_table import ProblemTable + +tr = trget("ui") + + +class ProblemDialog(QDialog): + def __init__(self, parent: QWidget, model: ProblemDiaglogModel, **kwargs) -> None: + flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint + super().__init__(parent, flags, **kwargs) + self.model = model + self.table_view = QTableView(self) + self.table = ProblemTable(self.model.problem_table, view=self.table_view) + self._setupUi() + + def _setupUi(self) -> None: + self.setWindowTitle(tr("Problems!")) + self.resize(413, 323) + main_layout = QVBoxLayout(self) + notice_label = QLabel(self) + msg = tr( + "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." + ) + notice_label.setText(msg) + notice_label.setWordWrap(True) + main_layout.addWidget(notice_label) + + self.table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table_view.setShowGrid(False) + self.table_view.horizontalHeader().setStretchLastSection(True) + self.table_view.verticalHeader().setDefaultSectionSize(18) + self.table_view.verticalHeader().setHighlightSections(False) + main_layout.addWidget(self.table_view) + + button_layout = QHBoxLayout() + reveal_button = QPushButton(self) + reveal_button.setText(tr("Reveal Selected")) + button_layout.addWidget(reveal_button) + + spacer_item = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + button_layout.addItem(spacer_item) + + close_button = QPushButton(self) + close_button.setText(tr("Close")) + close_button.setDefault(True) + button_layout.addWidget(close_button) + + main_layout.addLayout(button_layout) + + reveal_button.clicked.connect(self.model.reveal_selected_dupe) + close_button.clicked.connect(self.accept) + + def showEvent(self, event: QShowEvent) -> None: + # have to do this here as the frameGeometry is not correct until shown + move_to_screen_center(self) + super().showEvent(event) diff --git a/qt/problem_table.py b/dupeguru/qt/problem_table.py similarity index 90% rename from qt/problem_table.py rename to dupeguru/qt/problem_table.py index 9e8a35158..ac2fb51ca 100644 --- a/qt/problem_table.py +++ b/dupeguru/qt/problem_table.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from qt.column import Column -from qt.table import Table +from dupeguru.qt.column import Column +from dupeguru.qt.table import Table class ProblemTable(Table): diff --git a/qt/progress_window.py b/dupeguru/qt/progress_window.py similarity index 91% rename from qt/progress_window.py rename to dupeguru/qt/progress_window.py index ad67dd03c..0f366eebf 100644 --- a/qt/progress_window.py +++ b/dupeguru/qt/progress_window.py @@ -4,10 +4,10 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtWidgets import QDialog, QMessageBox, QVBoxLayout, QLabel, QProgressBar, QPushButton +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtWidgets import QDialog, QMessageBox, QVBoxLayout, QLabel, QProgressBar, QPushButton -from hscommon.trans import tr +from dupeguru.hscommon.trans import tr class ProgressWindow: @@ -36,7 +36,7 @@ def set_progress(self, last_progress): self._progress_bar.setValue(last_progress) def show(self): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint self._window = QDialog(self.parent, flags) self._setup_ui() self._window.setModal(True) @@ -67,7 +67,7 @@ def cancel(self): self._window, ) confirm_dialog.setDefaultButton(QMessageBox.StandardButton.No) - result = confirm_dialog.exec_() + result = confirm_dialog.exec() if result != QMessageBox.StandardButton.Yes: return self.close() diff --git a/qt/radio_box.py b/dupeguru/qt/radio_box.py similarity index 94% rename from qt/radio_box.py rename to dupeguru/qt/radio_box.py index f59ba7e98..3d75ea937 100644 --- a/qt/radio_box.py +++ b/dupeguru/qt/radio_box.py @@ -5,10 +5,10 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QWidget, QHBoxLayout, QRadioButton +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QRadioButton -from qt.util import horizontal_spacer +from dupeguru.qt.util import horizontal_spacer class RadioBox(QWidget): diff --git a/qt/recent.py b/dupeguru/qt/recent.py similarity index 94% rename from qt/recent.py rename to dupeguru/qt/recent.py index 3fb5d1361..c48885d12 100644 --- a/qt/recent.py +++ b/dupeguru/qt/recent.py @@ -8,11 +8,11 @@ from collections import namedtuple -from PyQt5.QtCore import pyqtSignal, QObject -from PyQt5.QtWidgets import QAction +from PyQt6.QtCore import pyqtSignal, QObject +from PyQt6.QtGui import QAction -from hscommon.trans import trget -from hscommon.util import dedupe +from dupeguru.hscommon.trans import trget +from dupeguru.hscommon.util import dedupe tr = trget("ui") diff --git a/qt/result_window.py b/dupeguru/qt/result_window.py similarity index 93% rename from qt/result_window.py rename to dupeguru/qt/result_window.py index 284391e3d..c49ff4352 100644 --- a/qt/result_window.py +++ b/dupeguru/qt/result_window.py @@ -6,8 +6,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, QRect -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import Qt, QRect +from PyQt6.QtWidgets import ( QMainWindow, QMenu, QLabel, @@ -20,20 +20,20 @@ QDialog, QPushButton, QCheckBox, - QDesktopWidget, + QApplication, ) -from hscommon.trans import trget -from qt.util import move_to_screen_center, horizontal_wrap, create_actions -from qt.search_edit import SearchEdit +from dupeguru.hscommon.trans import trget +from dupeguru.qt.util import move_to_screen_center, horizontal_wrap, create_actions +from dupeguru.qt.search_edit import SearchEdit -from core.app import AppMode -from qt.results_model import ResultsView -from qt.stats_label import StatsLabel -from qt.prioritize_dialog import PrioritizeDialog -from qt.se.results_model import ResultsModel as ResultsModelStandard -from qt.me.results_model import ResultsModel as ResultsModelMusic -from qt.pe.results_model import ResultsModel as ResultsModelPicture +from dupeguru.core.app import AppMode +from dupeguru.qt.results_model import ResultsView +from dupeguru.qt.stats_label import StatsLabel +from dupeguru.qt.prioritize_dialog import PrioritizeDialog +from dupeguru.qt.se.results_model import ResultsModel as ResultsModelStandard +from dupeguru.qt.me.results_model import ResultsModel as ResultsModelMusic +from dupeguru.qt.pe.results_model import ResultsModel as ResultsModelPicture tr = trget("ui") @@ -170,7 +170,7 @@ def _setupActions(self): ), ( "actionMarkSelected", - Qt.Key_Space, + Qt.Key.Key_Space, "", tr("Mark Selected"), self.markSelectedTriggered, @@ -350,8 +350,8 @@ def _setupUi(self): self.horizontalLayout.setSpacing(8) self.verticalLayout.addLayout(self.horizontalLayout) self.resultsView = ResultsView(self.centralwidget) - self.resultsView.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.resultsView.setSelectionBehavior(QAbstractItemView.SelectRows) + self.resultsView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.resultsView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.resultsView.setSortingEnabled(True) self.resultsView.setWordWrap(False) self.resultsView.verticalHeader().setVisible(False) @@ -359,7 +359,7 @@ def _setupUi(self): h.setHighlightSections(False) h.setSectionsMovable(True) h.setStretchLastSection(False) - h.setDefaultAlignment(Qt.AlignLeft) + h.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft) self.verticalLayout.addWidget(self.resultsView) self.setCentralWidget(self.centralwidget) self._setupActions() @@ -371,17 +371,18 @@ def _setupUi(self): self.statusbar.addPermanentWidget(self.statusLabel, 1) if self.app.prefs.resultWindowIsMaximized: - self.setWindowState(self.windowState() | Qt.WindowMaximized) + self.setWindowState(self.windowState() | Qt.WindowState.WindowMaximized) else: if self.app.prefs.resultWindowRect is not None: self.setGeometry(self.app.prefs.resultWindowRect) # if not on any screen move to center of default screen # moves to center of closest screen if partially off screen frame = self.frameGeometry() - if QDesktopWidget().screenNumber(self) == -1: + screen = QApplication.screenAt(self.geometry().center()) + if screen is None: move_to_screen_center(self) - elif QDesktopWidget().availableGeometry(self).contains(frame) is False: - frame.moveCenter(QDesktopWidget().availableGeometry(self).center()) + elif screen.availableGeometry().contains(frame) is False: + frame.moveCenter(screen.availableGeometry().center()) self.move(frame.topLeft()) else: move_to_screen_center(self) @@ -458,7 +459,7 @@ def renameTriggered(self): def reprioritizeTriggered(self): dlg = PrioritizeDialog(self, self.app) result = dlg.exec() - if result == QDialog.Accepted: + if result == QDialog.DialogCode.Accepted: dlg.model.perform_reprioritization() def revealTriggered(self): @@ -490,7 +491,7 @@ def columnToggled(self, action): action.setChecked(visible) def contextMenuEvent(self, event): - self.actionActions.menu().exec_(event.globalPos()) + self.actionActions.menu().exec(event.globalPos()) def resultsDoubleClicked(self, model_index): self.app.model.open_selected() diff --git a/qt/results_model.py b/dupeguru/qt/results_model.py similarity index 74% rename from qt/results_model.py rename to dupeguru/qt/results_model.py index 54fb9c1f9..a449713a4 100644 --- a/qt/results_model.py +++ b/dupeguru/qt/results_model.py @@ -6,18 +6,18 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex -from PyQt5.QtGui import QBrush, QFont, QFontMetrics -from PyQt5.QtWidgets import QTableView +from PyQt6.QtCore import Qt, pyqtSignal, QModelIndex +from PyQt6.QtGui import QBrush, QFont, QFontMetrics +from PyQt6.QtWidgets import QTableView -from qt.table import Table +from dupeguru.qt.table import Table class ResultsModel(Table): def __init__(self, app, view, **kwargs): model = app.model.result_table super().__init__(model, view, **kwargs) - view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder) + view.horizontalHeader().setSortIndicator(1, Qt.SortOrder.AscendingOrder) font = view.font() font.setPointSize(app.prefs.tableFontSize) view.setFont(font) @@ -29,52 +29,52 @@ def __init__(self, app, view, **kwargs): def _getData(self, row, column, role): if column.name == "marked": - if role == Qt.BackgroundRole and row.isref: + if role == Qt.ItemDataRole.BackgroundRole and row.isref: return QBrush(self.prefs.result_table_ref_background_color) - if role == Qt.CheckStateRole and row.markable: - return Qt.Checked if row.marked else Qt.Unchecked + if role == Qt.ItemDataRole.CheckStateRole and row.markable: + return Qt.CheckState.Checked if row.marked else Qt.CheckState.Unchecked return None - if role == Qt.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: data = row.data_delta if self.model.delta_values else row.data return data[column.name] - elif role == Qt.ForegroundRole: + elif role == Qt.ItemDataRole.ForegroundRole: if row.isref: return QBrush(self.prefs.result_table_ref_foreground_color) elif row.is_cell_delta(column.name): return QBrush(self.prefs.result_table_delta_foreground_color) - elif role == Qt.BackgroundRole: + elif role == Qt.ItemDataRole.BackgroundRole: if row.isref: return QBrush(self.prefs.result_table_ref_background_color) - elif role == Qt.FontRole: + elif role == Qt.ItemDataRole.FontRole: font = QFont(self.view.font()) if self.prefs.reference_bold_font: font.setBold(row.isref) return font - elif role == Qt.EditRole and column.name == "name": + elif role == Qt.ItemDataRole.EditRole and column.name == "name": return row.data[column.name] return None def _getFlags(self, row, column): - flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable if column.name == "marked": if row.markable: - flags |= Qt.ItemIsUserCheckable + flags |= Qt.ItemFlag.ItemIsUserCheckable elif column.name == "name": - flags |= Qt.ItemIsEditable + flags |= Qt.ItemFlag.ItemIsEditable return flags def _setData(self, row, column, value, role): - if role == Qt.CheckStateRole: + if role == Qt.ItemDataRole.CheckStateRole: if column.name == "marked": row.marked = bool(value) return True - elif role == Qt.EditRole and column.name == "name": + elif role == Qt.ItemDataRole.EditRole and column.name == "name": return self.model.rename_selected(value) return False def sort(self, column, order): column = self.model.COLUMNS[column] - self.model.sort(column.name, order == Qt.AscendingOrder) + self.model.sort(column.name, order == Qt.SortOrder.AscendingOrder) # --- Properties @property diff --git a/qt/se/__init__.py b/dupeguru/qt/se/__init__.py similarity index 100% rename from qt/se/__init__.py rename to dupeguru/qt/se/__init__.py diff --git a/qt/se/details_dialog.py b/dupeguru/qt/se/details_dialog.py similarity index 72% rename from qt/se/details_dialog.py rename to dupeguru/qt/se/details_dialog.py index ea59c7c2a..f5a749cc5 100644 --- a/qt/se/details_dialog.py +++ b/dupeguru/qt/se/details_dialog.py @@ -4,12 +4,12 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QAbstractItemView +from PyQt6.QtCore import QSize +from PyQt6.QtWidgets import QAbstractItemView -from hscommon.trans import trget -from qt.details_dialog import DetailsDialog as DetailsDialogBase -from qt.details_table import DetailsTable +from dupeguru.hscommon.trans import trget +from dupeguru.qt.details_dialog import DetailsDialog as DetailsDialogBase +from dupeguru.qt.details_table import DetailsTable tr = trget("ui") @@ -21,6 +21,6 @@ def _setupUi(self): self.setMinimumSize(QSize(200, 0)) self.tableView = DetailsTable(self) self.tableView.setAlternatingRowColors(True) - self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tableView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.tableView.setShowGrid(False) self.setWidget(self.tableView) diff --git a/qt/se/preferences_dialog.py b/dupeguru/qt/se/preferences_dialog.py similarity index 82% rename from qt/se/preferences_dialog.py rename to dupeguru/qt/se/preferences_dialog.py index d3a69fe53..3365099ce 100644 --- a/qt/se/preferences_dialog.py +++ b/dupeguru/qt/se/preferences_dialog.py @@ -4,29 +4,23 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import ( - QSpinBox, - QVBoxLayout, - QHBoxLayout, - QLabel, - QSizePolicy, - QSpacerItem, - QWidget, -) +from typing import Callable +from PyQt6.QtCore import QSize +from PyQt6.QtWidgets import QSpinBox, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, QCheckBox -from hscommon.trans import trget +from dupeguru.hscommon.trans import trget -from core.app import AppMode -from core.scanner import ScanType +from dupeguru.core.app import AppMode +from dupeguru.core.scanner import ScanType +from dupeguru.qt.preferences import Preferences -from qt.preferences_dialog import PreferencesDialogBase +from dupeguru.qt.preferences_dialog import PreferencesDialogBase, Sections tr = trget("ui") class PreferencesDialog(PreferencesDialogBase): - def _setupPreferenceWidgets(self): + def _setupPreferenceWidgets(self) -> None: self._setupFilterHardnessBox() self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self.widget = QWidget(self) @@ -50,7 +44,7 @@ def _setupPreferenceWidgets(self): self._setupAddCheckbox("ignoreSmallFilesBox", tr("Ignore files smaller than"), self.widget) self.horizontalLayout_2.addWidget(self.ignoreSmallFilesBox) self.sizeThresholdSpinBox = QSpinBox(self.widget) - size_policy = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + size_policy = QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) size_policy.setHorizontalStretch(0) size_policy.setVerticalStretch(0) size_policy.setHeightForWidth(self.sizeThresholdSpinBox.sizePolicy().hasHeightForWidth()) @@ -61,14 +55,14 @@ def _setupPreferenceWidgets(self): self.label_6 = QLabel(self.widget) self.label_6.setText(tr("KB")) self.horizontalLayout_2.addWidget(self.label_6) - spacer_item1 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + spacer_item1 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) self.horizontalLayout_2.addItem(spacer_item1) self.verticalLayout_4.addLayout(self.horizontalLayout_2) self.horizontalLayout_2a = QHBoxLayout() self._setupAddCheckbox("ignoreLargeFilesBox", tr("Ignore files larger than"), self.widget) self.horizontalLayout_2a.addWidget(self.ignoreLargeFilesBox) self.sizeSaturationSpinBox = QSpinBox(self.widget) - size_policy = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + size_policy = QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) self.sizeSaturationSpinBox.setSizePolicy(size_policy) self.sizeSaturationSpinBox.setMaximumSize(QSize(300, 16777215)) self.sizeSaturationSpinBox.setRange(0, 1000000) @@ -76,7 +70,7 @@ def _setupPreferenceWidgets(self): self.label_6a = QLabel(self.widget) self.label_6a.setText(tr("MB")) self.horizontalLayout_2a.addWidget(self.label_6a) - spacer_item3 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + spacer_item3 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) self.horizontalLayout_2a.addItem(spacer_item3) self.verticalLayout_4.addLayout(self.horizontalLayout_2a) self.horizontalLayout_2b = QHBoxLayout() @@ -94,7 +88,7 @@ def _setupPreferenceWidgets(self): self.label_6b = QLabel(self.widget) self.label_6b.setText(tr("MB")) self.horizontalLayout_2b.addWidget(self.label_6b) - spacer_item2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + spacer_item2 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) self.horizontalLayout_2b.addItem(spacer_item2) self.verticalLayout_4.addLayout(self.horizontalLayout_2b) self._setupAddCheckbox( @@ -106,7 +100,7 @@ def _setupPreferenceWidgets(self): self.widgetsVLayout.addWidget(self.widget) self._setupBottomPart() - def _load(self, prefs, setchecked, section): + def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None: setchecked(self.matchSimilarBox, prefs.match_similar) setchecked(self.wordWeightingBox, prefs.word_weighting) setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files) @@ -119,11 +113,11 @@ def _load(self, prefs, setchecked, section): # Update UI state based on selected scan type scan_type = prefs.get_scan_type(AppMode.STANDARD) word_based = scan_type == ScanType.FILENAME - self.filterHardnessSlider.setEnabled(word_based) + self._setFilterHardnessEnabled(word_based) self.matchSimilarBox.setEnabled(word_based) self.wordWeightingBox.setEnabled(word_based) - def _save(self, prefs, ischecked): + def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None: prefs.match_similar = ischecked(self.matchSimilarBox) prefs.word_weighting = ischecked(self.wordWeightingBox) prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox) diff --git a/qt/se/results_model.py b/dupeguru/qt/se/results_model.py similarity index 87% rename from qt/se/results_model.py rename to dupeguru/qt/se/results_model.py index 23f872fad..9c1229902 100644 --- a/qt/se/results_model.py +++ b/dupeguru/qt/se/results_model.py @@ -4,8 +4,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from qt.column import Column -from qt.results_model import ResultsModel as ResultsModelBase +from dupeguru.qt.column import Column +from dupeguru.qt.results_model import ResultsModel as ResultsModelBase class ResultsModel(ResultsModelBase): diff --git a/qt/search_edit.py b/dupeguru/qt/search_edit.py similarity index 80% rename from qt/search_edit.py rename to dupeguru/qt/search_edit.py index 79c610833..91556cab9 100644 --- a/qt/search_edit.py +++ b/dupeguru/qt/search_edit.py @@ -6,11 +6,11 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import pyqtSignal, Qt -from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPalette -from PyQt5.QtWidgets import QToolButton, QLineEdit, QStyle, QStyleOptionFrame +from PyQt6.QtCore import pyqtSignal, Qt +from PyQt6.QtGui import QIcon, QPixmap, QPainter, QPalette +from PyQt6.QtWidgets import QToolButton, QLineEdit, QStyle, QStyleOptionFrame -from hscommon.trans import trget +from dupeguru.hscommon.trans import trget tr = trget("ui") @@ -24,8 +24,8 @@ def __init__(self, parent, **kwargs): pixmap = QPixmap(":/search_clear_13") self.setIcon(QIcon(pixmap)) self.setIconSize(pixmap.size()) - self.setCursor(Qt.ArrowCursor) - self.setPopupMode(QToolButton.InstantPopup) + self.setCursor(Qt.CursorShape.ArrowCursor) + self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) stylesheet = "QToolButton { border: none; padding: 0px; }" self.setStyleSheet(stylesheet) @@ -36,7 +36,7 @@ def __init__(self, parent=None, is_clearable=True, **kwargs): self._is_clearable = is_clearable if is_clearable: self._clearButton = LineEditButton(self) - frame_width = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth) + frame_width = self.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth) padding_right = self._clearButton.sizeHint().width() + frame_width + 1 stylesheet = f"QLineEdit {{ padding-right:{padding_right}px; }}" self.setStyleSheet(stylesheet) @@ -58,7 +58,7 @@ def _hasClearableContent(self): # --- QLineEdit overrides def resizeEvent(self, event): if self._is_clearable: - frame_width = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth) + frame_width = self.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth) rect = self.rect() right_hint = self._clearButton.sizeHint() right_x = rect.right() - frame_width - right_hint.width() @@ -92,7 +92,7 @@ def _textChanged(self, text): def keyPressEvent(self, event): key = event.key() - if key == Qt.Key_Escape: + if key == Qt.Key.Key_Escape: self._clearSearch() else: ClearableEdit.keyPressEvent(self, event) @@ -102,14 +102,14 @@ def paintEvent(self, event): if not bool(self.text()) and self.inactiveText and not self.hasFocus(): panel = QStyleOptionFrame() self.initStyleOption(panel) - text_rect = self.style().subElementRect(QStyle.SE_LineEditContents, panel, self) + text_rect = self.style().subElementRect(QStyle.SubElement.SE_LineEditContents, panel, self) left_margin = 2 right_margin = self._clearButton.iconSize().width() text_rect.adjust(left_margin, 0, -right_margin, 0) painter = QPainter(self) - disabled_color = self.palette().brush(QPalette.Disabled, QPalette.Text).color() + disabled_color = self.palette().brush(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text).color() painter.setPen(disabled_color) - painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignVCenter, self.inactiveText) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self.inactiveText) # --- Event Handlers def _returnPressed(self): diff --git a/qt/selectable_list.py b/dupeguru/qt/selectable_list.py similarity index 93% rename from qt/selectable_list.py rename to dupeguru/qt/selectable_list.py index f1bbc8173..e97a879c7 100644 --- a/qt/selectable_list.py +++ b/dupeguru/qt/selectable_list.py @@ -6,7 +6,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import Qt, QAbstractListModel, QItemSelection, QItemSelectionModel +from PyQt6.QtCore import Qt, QAbstractListModel, QItemSelection, QItemSelectionModel class SelectableList(QAbstractListModel): @@ -23,7 +23,7 @@ def data(self, index, role): if not index.isValid(): return None # We need EditRole for QComboBoxes with setEditable(True) - if role in {Qt.DisplayRole, Qt.EditRole}: + if role in {Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole}: return self.model[index.row()] return None @@ -88,10 +88,10 @@ def _restoreSelection(self): new_selection = QItemSelection() for index in self.model.selected_indexes: new_selection.select(self.createIndex(index, 0), self.createIndex(index, 0)) - self.view.selectionModel().select(new_selection, QItemSelectionModel.ClearAndSelect) + self.view.selectionModel().select(new_selection, QItemSelectionModel.SelectionFlag.ClearAndSelect) if len(new_selection.indexes()): current_index = new_selection.indexes()[0] - self.view.selectionModel().setCurrentIndex(current_index, QItemSelectionModel.Current) + self.view.selectionModel().setCurrentIndex(current_index, QItemSelectionModel.SelectionFlag.Current) self.view.scrollTo(current_index) # --- Events diff --git a/qt/stats_label.py b/dupeguru/qt/stats_label.py similarity index 100% rename from qt/stats_label.py rename to dupeguru/qt/stats_label.py diff --git a/qt/tabbed_window.py b/dupeguru/qt/tabbed_window.py similarity index 95% rename from qt/tabbed_window.py rename to dupeguru/qt/tabbed_window.py index 58ac996d9..381b666a1 100644 --- a/qt/tabbed_window.py +++ b/dupeguru/qt/tabbed_window.py @@ -2,8 +2,8 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtCore import QRect, pyqtSlot, Qt, QEvent -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import QRect, pyqtSlot, Qt, QEvent +from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, @@ -13,12 +13,12 @@ QTabBar, QStackedWidget, ) -from hscommon.trans import trget -from qt.util import move_to_screen_center, create_actions -from qt.directories_dialog import DirectoriesDialog -from qt.result_window import ResultWindow -from qt.ignore_list_dialog import IgnoreListDialog -from qt.exclude_list_dialog import ExcludeListDialog +from dupeguru.hscommon.trans import trget +from dupeguru.qt.util import move_to_screen_center, create_actions +from dupeguru.qt.directories_dialog import DirectoriesDialog +from dupeguru.qt.result_window import ResultWindow +from dupeguru.qt.ignore_list_dialog import IgnoreListDialog +from dupeguru.qt.exclude_list_dialog import ExcludeListDialog tr = trget("ui") @@ -175,7 +175,7 @@ def addTab(self, page, title, switch=False): # Warning: this supposedly takes ownership of the page index = self.tabWidget.addTab(page, title) if isinstance(page, DirectoriesDialog): - self.tabWidget.tabBar().setTabButton(index, QTabBar.RightSide, None) + self.tabWidget.tabBar().setTabButton(index, QTabBar.ButtonPosition.RightSide, None) if switch: self.setCurrentIndex(index) return index @@ -221,7 +221,7 @@ def showEvent(self, event): super().showEvent(event) def changeEvent(self, event): - if event.type() == QEvent.WindowStateChange and not self.isMaximized(): + if event.type() == QEvent.Type.WindowStateChange and not self.isMaximized(): move_to_screen_center(self) super().changeEvent(event) @@ -278,8 +278,8 @@ def _setupUi(self): self.stackedWidget = QStackedWidget() self.centralWidget.setLayout(self.verticalLayout) self.horizontalLayout = QHBoxLayout() - self.horizontalLayout.addWidget(self.menubar, 0, Qt.AlignTop) - self.horizontalLayout.addWidget(self.tabBar, 0, Qt.AlignTop) + self.horizontalLayout.addWidget(self.menubar, 0, Qt.AlignmentFlag.AlignTop) + self.horizontalLayout.addWidget(self.tabBar, 0, Qt.AlignmentFlag.AlignTop) self.verticalLayout.addLayout(self.horizontalLayout) self.verticalLayout.addWidget(self.stackedWidget) @@ -297,7 +297,7 @@ def addTab(self, page, title, switch=True): self.tabBar.insertTab(stack_index, title) if isinstance(page, DirectoriesDialog): - self.tabBar.setTabButton(stack_index, QTabBar.RightSide, None) + self.tabBar.setTabButton(stack_index, QTabBar.ButtonPosition.RightSide, None) if switch: # switch to the added tab immediately upon creation self.setTabIndex(stack_index) return stack_index diff --git a/qt/table.py b/dupeguru/qt/table.py similarity index 87% rename from qt/table.py rename to dupeguru/qt/table.py index 17aeb1a39..e6e16ad72 100644 --- a/qt/table.py +++ b/dupeguru/qt/table.py @@ -7,7 +7,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html import typing -from PyQt5.QtCore import ( +from PyQt6.QtCore import ( Qt, QAbstractTableModel, QModelIndex, @@ -15,7 +15,7 @@ QItemSelection, ) -from qt.column import Columns, Column +from dupeguru.qt.column import Columns, Column class Table(QAbstractTableModel): @@ -49,32 +49,32 @@ def _updateViewSelection(self): column_count = self.columnCount(QModelIndex()) for index in self.model.selected_indexes: new_selection.select(self.createIndex(index, 0), self.createIndex(index, column_count - 1)) - self.view.selectionModel().select(new_selection, QItemSelectionModel.ClearAndSelect) + self.view.selectionModel().select(new_selection, QItemSelectionModel.SelectionFlag.ClearAndSelect) if len(new_selection.indexes()): current_index = new_selection.indexes()[0] - self.view.selectionModel().setCurrentIndex(current_index, QItemSelectionModel.Current) + self.view.selectionModel().setCurrentIndex(current_index, QItemSelectionModel.SelectionFlag.Current) self.view.scrollTo(current_index) # --- Data Model methods # Virtual def _getData(self, row, column, role): - if role in (Qt.DisplayRole, Qt.EditRole): + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): attrname = column.name return row.get_cell_value(attrname) - elif role == Qt.TextAlignmentRole: + elif role == Qt.ItemDataRole.TextAlignmentRole: return column.alignment return None # Virtual def _getFlags(self, row, column): - flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable if row.can_edit_cell(column.name): - flags |= Qt.ItemIsEditable + flags |= Qt.ItemFlag.ItemIsEditable return flags # Virtual def _setData(self, row, column, value, role): - if role == Qt.EditRole: + if role == Qt.ItemDataRole.EditRole: attrname = column.name if attrname == "from": attrname = "from_" @@ -100,14 +100,14 @@ def flags(self, index): return self._getFlags(row, column) def headerData(self, section, orientation, role): - if orientation != Qt.Horizontal: + if orientation != Qt.Orientation.Horizontal: return None if section >= self.model._columns.columns_count(): return None column = self.model._columns.column_by_index(section) - if role == Qt.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: return column.display - elif role == Qt.TextAlignmentRole: + elif role == Qt.ItemDataRole.TextAlignmentRole: return column.alignment else: return None @@ -130,7 +130,7 @@ def setData(self, index, value, role): def sort(self, section, order): column = self.model._columns.column_by_index(section) attrname = column.name - self.model.sort_by(attrname, desc=order == Qt.DescendingOrder) + self.model.sort_by(attrname, desc=order == Qt.SortOrder.DescendingOrder) def submit(self): self.model.save_edits() diff --git a/qt/tree_model.py b/dupeguru/qt/tree_model.py similarity index 99% rename from qt/tree_model.py rename to dupeguru/qt/tree_model.py index 07f66d0a6..42fad8bde 100644 --- a/qt/tree_model.py +++ b/dupeguru/qt/tree_model.py @@ -8,7 +8,7 @@ import logging -from PyQt5.QtCore import QAbstractItemModel, QModelIndex +from PyQt6.QtCore import QAbstractItemModel, QModelIndex class NodeContainer: diff --git a/qt/util.py b/dupeguru/qt/util.py similarity index 76% rename from qt/util.py rename to dupeguru/qt/util.py index ba6e0eb6f..cbecc2cb7 100644 --- a/qt/util.py +++ b/dupeguru/qt/util.py @@ -11,22 +11,18 @@ import os.path as op import os import logging +from typing import List, Union -from core.util import executable_folder -from hscommon.util import first -from hscommon.plat import ISWINDOWS +from dupeguru.core.util import executable_folder +from dupeguru.hscommon.util import first +from dupeguru.hscommon.plat import ISWINDOWS -from PyQt5.QtCore import QStandardPaths, QSettings -from PyQt5.QtGui import QPixmap, QIcon, QGuiApplication -from PyQt5.QtWidgets import ( - QSpacerItem, - QSizePolicy, - QAction, - QHBoxLayout, -) +from PyQt6.QtCore import QStandardPaths, QSettings +from PyQt6.QtGui import QPixmap, QIcon, QGuiApplication, QAction +from PyQt6.QtWidgets import QSpacerItem, QSizePolicy, QHBoxLayout, QWidget -def move_to_screen_center(widget): +def move_to_screen_center(widget: QWidget) -> None: frame = widget.frameGeometry() if QGuiApplication.screenAt(frame.center()) is None: # if center not on any screen use default screen @@ -43,21 +39,21 @@ def move_to_screen_center(widget): widget.move(frame.topLeft()) -def vertical_spacer(size=None): +def vertical_spacer(size: Union[int, None] = None) -> QSpacerItem: if size: - return QSpacerItem(1, size, QSizePolicy.Fixed, QSizePolicy.Fixed) + return QSpacerItem(1, size, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) else: - return QSpacerItem(1, 1, QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) + return QSpacerItem(1, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding) -def horizontal_spacer(size=None): +def horizontal_spacer(size: Union[int, None] = None) -> QSpacerItem: if size: - return QSpacerItem(size, 1, QSizePolicy.Fixed, QSizePolicy.Fixed) + return QSpacerItem(size, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) else: - return QSpacerItem(1, 1, QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + return QSpacerItem(1, 1, QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) -def horizontal_wrap(widgets): +def horizontal_wrap(widgets: List[Union[QWidget, int, None]]) -> QHBoxLayout: """Wrap all widgets in `widgets` in a horizontal layout. If, instead of placing a widget in your list, you place an int or None, an horizontal spacer @@ -77,7 +73,7 @@ def create_actions(actions, target): for name, shortcut, icon, desc, func in actions: action = QAction(target) if icon: - action.setIcon(QIcon(QPixmap(":/" + icon))) + action.setIcon(QIcon(QPixmap(":/" + icon))) # TODO stop using qrc file path if shortcut: action.setShortcut(shortcut) action.setText(desc) @@ -100,11 +96,11 @@ def set_accel_keys(menu): action.setText(newtext) -def get_appdata(portable=False): +def get_appdata(portable: bool = False) -> str: if portable: return op.join(executable_folder(), "data") else: - return QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)[0] + return QStandardPaths.standardLocations(QStandardPaths.StandardLocation.AppDataLocation)[0] class SysWrapper(io.IOBase): @@ -140,18 +136,18 @@ def escape_amp(s): return s.replace("&", "&&") -def create_qsettings(): +def create_qsettings() -> QSettings: # Create a QSettings instance with the correct arguments. config_location = op.join(executable_folder(), "settings.ini") if op.isfile(config_location): - settings = QSettings(config_location, QSettings.IniFormat) + settings = QSettings(config_location, QSettings.Format.IniFormat) settings.setValue("Portable", True) elif ISWINDOWS: # On windows use an ini file in the AppDataLocation instead of registry if possible as it # makes it easier for a user to clear it out when there are issues. - locations = QStandardPaths.standardLocations(QStandardPaths.AppDataLocation) + locations = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.AppDataLocation) if locations: - settings = QSettings(op.join(locations[0], "settings.ini"), QSettings.IniFormat) + settings = QSettings(op.join(locations[0], "settings.ini"), QSettings.Format.IniFormat) else: settings = QSettings() settings.setValue("Portable", False) diff --git a/help/changelog b/help/changelog index 80d588005..8817d638e 100644 --- a/help/changelog +++ b/help/changelog @@ -1,3 +1,17 @@ +=== 4.3.1^20260802git (2026-08-02) +* guard copy_or_move when no location path matches dupe parent +* the application name in the dock is "dupeguru" +* use Qt6 rather than Qt5 +* fix to show dupeguru icon in the launcher +* configurable scan speed for picture mode (up to 16x faster) +* fix SQL injection in cache modules +* fix XXE vulnerabilities in XML parsing +* fix command injection in Custom Command +* updated Polish, French, Russian translations +* force Qt6 to use the X11 interfaces since Fedora now runs wayland + and under wayland, Qt6 application window does not use the proper + styling. + === 4.3.1 (2022-07-08) * Fix issue where cache db exceptions could prevent files being hashed (#1015) * Add extra guard for non-zero length files without digests to prevent false duplicates diff --git a/help/conf.tmpl b/help/conf.tmpl index 5878cbdcf..b4c0e8078 100644 --- a/help/conf.tmpl +++ b/help/conf.tmpl @@ -32,6 +32,7 @@ def setup(app): app.connect('autodoc-process-signature', fix_nulljob_in_sig) autodoc_member_order = 'groupwise' +autodoc_mock_imports = ["dupeguru.core.pe._block", "dupeguru.core.pe._cache", "dupeguru.qt.pe._block"] # -- General configuration ----------------------------------------------------- diff --git a/help/en/developer/core/app.rst b/help/en/developer/core/app.rst index b183bcca2..5c2ab72b4 100644 --- a/help/en/developer/core/app.rst +++ b/help/en/developer/core/app.rst @@ -1,5 +1,5 @@ core.app ======== -.. automodule:: core.app +.. automodule:: dupeguru.core.app :members: diff --git a/help/en/developer/core/directories.rst b/help/en/developer/core/directories.rst index 0854f0919..fe7b23ba2 100644 --- a/help/en/developer/core/directories.rst +++ b/help/en/developer/core/directories.rst @@ -1,5 +1,5 @@ core.directories ================ -.. automodule:: core.directories +.. automodule:: dupeguru.core.directories :members: diff --git a/help/en/developer/core/engine.rst b/help/en/developer/core/engine.rst index 658111c3b..4e8bc873a 100644 --- a/help/en/developer/core/engine.rst +++ b/help/en/developer/core/engine.rst @@ -1,7 +1,7 @@ core.engine =========== -.. automodule:: core.engine +.. automodule:: dupeguru.core.engine .. autoclass:: Match diff --git a/help/en/developer/core/fs.rst b/help/en/developer/core/fs.rst index c6275428b..4564f120a 100644 --- a/help/en/developer/core/fs.rst +++ b/help/en/developer/core/fs.rst @@ -1,5 +1,5 @@ core.fs ======= -.. automodule:: core.fs +.. automodule:: dupeguru.core.fs :members: diff --git a/help/en/developer/core/gui/deletion_options.rst b/help/en/developer/core/gui/deletion_options.rst index 2fbc7f8a9..1ba48a199 100644 --- a/help/en/developer/core/gui/deletion_options.rst +++ b/help/en/developer/core/gui/deletion_options.rst @@ -1,5 +1,5 @@ core.gui.deletion_options ========================= -.. automodule:: core.gui.deletion_options +.. automodule:: dupeguru.core.gui.deletion_options :members: diff --git a/help/en/developer/core/gui/index.rst b/help/en/developer/core/gui/index.rst index 74da03050..a87a2b384 100644 --- a/help/en/developer/core/gui/index.rst +++ b/help/en/developer/core/gui/index.rst @@ -1,7 +1,7 @@ core.gui ======== -.. automodule:: core.gui +.. automodule:: dupeguru.core.gui :members: .. toctree:: diff --git a/help/en/developer/core/results.rst b/help/en/developer/core/results.rst index 58e025606..75976da0f 100644 --- a/help/en/developer/core/results.rst +++ b/help/en/developer/core/results.rst @@ -1,5 +1,5 @@ core.results ============ -.. automodule:: core.results +.. automodule:: dupeguru.core.results :members: diff --git a/help/en/developer/hscommon/build.rst b/help/en/developer/hscommon/build.rst index b3b106bd9..700d58c4c 100644 --- a/help/en/developer/hscommon/build.rst +++ b/help/en/developer/hscommon/build.rst @@ -1,5 +1,5 @@ hscommon.build ============== -.. automodule:: hscommon.build +.. automodule:: dupeguru.hscommon.build :members: diff --git a/help/en/developer/hscommon/conflict.rst b/help/en/developer/hscommon/conflict.rst index 34724d10b..37e542a45 100644 --- a/help/en/developer/hscommon/conflict.rst +++ b/help/en/developer/hscommon/conflict.rst @@ -1,5 +1,5 @@ hscommon.conflict ================= -.. automodule:: hscommon.conflict +.. automodule:: dupeguru.hscommon.conflict :members: diff --git a/help/en/developer/hscommon/desktop.rst b/help/en/developer/hscommon/desktop.rst index e30ccafd8..89379f007 100644 --- a/help/en/developer/hscommon/desktop.rst +++ b/help/en/developer/hscommon/desktop.rst @@ -1,5 +1,5 @@ hscommon.desktop ================ -.. automodule:: hscommon.desktop +.. automodule:: dupeguru.hscommon.desktop :members: diff --git a/help/en/developer/hscommon/gui/base.rst b/help/en/developer/hscommon/gui/base.rst index 739cf3679..426fdbfd7 100644 --- a/help/en/developer/hscommon/gui/base.rst +++ b/help/en/developer/hscommon/gui/base.rst @@ -1,7 +1,7 @@ hscommon.gui.base ================= -.. automodule:: hscommon.gui.base +.. automodule:: dupeguru.hscommon.gui.base .. autosummary:: diff --git a/help/en/developer/hscommon/gui/column.rst b/help/en/developer/hscommon/gui/column.rst index 3b202b1f0..cb87c462d 100644 --- a/help/en/developer/hscommon/gui/column.rst +++ b/help/en/developer/hscommon/gui/column.rst @@ -1,7 +1,7 @@ hscommon.gui.column ============================ -.. automodule:: hscommon.gui.column +.. automodule:: dupeguru.hscommon.gui.column .. autosummary:: diff --git a/help/en/developer/hscommon/gui/progress_window.rst b/help/en/developer/hscommon/gui/progress_window.rst index f62a16d8e..1479bc083 100644 --- a/help/en/developer/hscommon/gui/progress_window.rst +++ b/help/en/developer/hscommon/gui/progress_window.rst @@ -1,7 +1,7 @@ hscommon.gui.progress_window ============================ -.. automodule:: hscommon.gui.progress_window +.. automodule:: dupeguru.hscommon.gui.progress_window .. autosummary:: diff --git a/help/en/developer/hscommon/gui/selectable_list.rst b/help/en/developer/hscommon/gui/selectable_list.rst index b02003865..384385bbe 100644 --- a/help/en/developer/hscommon/gui/selectable_list.rst +++ b/help/en/developer/hscommon/gui/selectable_list.rst @@ -1,7 +1,7 @@ hscommon.gui.selectable_list ============================ -.. automodule:: hscommon.gui.selectable_list +.. automodule:: dupeguru.hscommon.gui.selectable_list .. autosummary:: diff --git a/help/en/developer/hscommon/gui/table.rst b/help/en/developer/hscommon/gui/table.rst index d1ed1dd82..06bd85a2a 100644 --- a/help/en/developer/hscommon/gui/table.rst +++ b/help/en/developer/hscommon/gui/table.rst @@ -1,7 +1,7 @@ hscommon.gui.table ================== -.. automodule:: hscommon.gui.table +.. automodule:: dupeguru.hscommon.gui.table .. autosummary:: diff --git a/help/en/developer/hscommon/gui/text_field.rst b/help/en/developer/hscommon/gui/text_field.rst index 1799af02a..7924cb5b2 100644 --- a/help/en/developer/hscommon/gui/text_field.rst +++ b/help/en/developer/hscommon/gui/text_field.rst @@ -1,7 +1,7 @@ hscommon.gui.text_field ======================= -.. automodule:: hscommon.gui.text_field +.. automodule:: dupeguru.hscommon.gui.text_field .. autosummary:: diff --git a/help/en/developer/hscommon/gui/tree.rst b/help/en/developer/hscommon/gui/tree.rst index fb2dd253e..91782f08c 100644 --- a/help/en/developer/hscommon/gui/tree.rst +++ b/help/en/developer/hscommon/gui/tree.rst @@ -1,7 +1,7 @@ hscommon.gui.tree ================= -.. automodule:: hscommon.gui.tree +.. automodule:: dupeguru.hscommon.gui.tree .. autosummary:: diff --git a/help/en/developer/hscommon/jobprogress/job.rst b/help/en/developer/hscommon/jobprogress/job.rst index c60a9da4e..74b15db62 100644 --- a/help/en/developer/hscommon/jobprogress/job.rst +++ b/help/en/developer/hscommon/jobprogress/job.rst @@ -1,7 +1,7 @@ hscommon.jobprogress.job ======================== -.. automodule:: hscommon.jobprogress.job +.. automodule:: dupeguru.hscommon.jobprogress.job .. autosummary:: diff --git a/help/en/developer/hscommon/jobprogress/performer.rst b/help/en/developer/hscommon/jobprogress/performer.rst index e714c7a09..3f6e828f2 100644 --- a/help/en/developer/hscommon/jobprogress/performer.rst +++ b/help/en/developer/hscommon/jobprogress/performer.rst @@ -1,7 +1,7 @@ hscommon.jobprogress.performer ============================== -.. automodule:: hscommon.jobprogress.performer +.. automodule:: dupeguru.hscommon.jobprogress.performer .. autosummary:: diff --git a/help/en/developer/hscommon/notify.rst b/help/en/developer/hscommon/notify.rst index 4d61d5846..8bfc8f413 100644 --- a/help/en/developer/hscommon/notify.rst +++ b/help/en/developer/hscommon/notify.rst @@ -1,5 +1,5 @@ hscommon.notify =============== -.. automodule:: hscommon.notify +.. automodule:: dupeguru.hscommon.notify :members: diff --git a/help/en/developer/hscommon/path.rst b/help/en/developer/hscommon/path.rst index e2bcab3d9..8bfe02504 100644 --- a/help/en/developer/hscommon/path.rst +++ b/help/en/developer/hscommon/path.rst @@ -1,5 +1,5 @@ hscommon.path ============= -.. automodule:: hscommon.path +.. automodule:: dupeguru.hscommon.path :members: diff --git a/help/en/developer/hscommon/util.rst b/help/en/developer/hscommon/util.rst index 3dc3f5397..d93329a27 100644 --- a/help/en/developer/hscommon/util.rst +++ b/help/en/developer/hscommon/util.rst @@ -1,5 +1,5 @@ hscommon.util ============= -.. automodule:: hscommon.util +.. automodule:: dupeguru.hscommon.util :members: diff --git a/locale/fr/LC_MESSAGES/columns.po b/locale/fr/LC_MESSAGES/columns.po index 58a4b7d9f..d88e256d1 100644 --- a/locale/fr/LC_MESSAGES/columns.po +++ b/locale/fr/LC_MESSAGES/columns.po @@ -44,7 +44,7 @@ msgstr "Dossier" #: core\me\result_table.py:21 msgid "Size (MB)" -msgstr "Taille (MB)" +msgstr "Taille (Mo)" #: core\me\result_table.py:22 msgid "Time" @@ -52,7 +52,7 @@ msgstr "Temps" #: core\me\result_table.py:24 msgid "Sample Rate" -msgstr "Sample Rate" +msgstr "Échantillonnage" #: core\me\result_table.py:25 core\pe\result_table.py:22 core\prioritize.py:65 #: core\se\result_table.py:22 @@ -86,7 +86,7 @@ msgstr "Année" #: core\me\result_table.py:32 msgid "Track Number" -msgstr "Track" +msgstr "Numéro de piste" #: core\me\result_table.py:33 msgid "Comment" @@ -95,16 +95,16 @@ msgstr "Commentaire" #: core\me\result_table.py:34 core\pe\result_table.py:26 #: core\se\result_table.py:24 msgid "Match %" -msgstr "Match %" +msgstr "Correspond à %" #: core\me\result_table.py:35 core\se\result_table.py:25 msgid "Words Used" -msgstr "Mots" +msgstr "Mots utilisés" #: core\me\result_table.py:36 core\pe\result_table.py:27 #: core\se\result_table.py:26 msgid "Dupe Count" -msgstr "Nombre de Doublons" +msgstr "Nombre de doublons" #: core\pe\prioritize.py:23 core\pe\result_table.py:23 msgid "Dimensions" @@ -112,7 +112,7 @@ msgstr "Dimensions" #: core\pe\result_table.py:21 core\se\result_table.py:21 msgid "Size (KB)" -msgstr "Taille (KB)" +msgstr "Taille (Ko)" #: core\pe\result_table.py:24 msgid "EXIF Timestamp" diff --git a/locale/fr/LC_MESSAGES/core.po b/locale/fr/LC_MESSAGES/core.po index 1ff37ae05..7e59101b0 100644 --- a/locale/fr/LC_MESSAGES/core.po +++ b/locale/fr/LC_MESSAGES/core.po @@ -52,8 +52,8 @@ msgid "" "A previous action is still hanging in there. You can't start a new one yet. " "Wait a few seconds, then try again." msgstr "" -"Une action précédente est encore en cours. Attendez quelques secondes avant " -"d'en repartir une nouvelle." +"Une action précédente est encore en cours. Vous ne pouvez pas encore en démarrer une nouvelle. " +"Attendez quelques secondes et essayer à nouveau." #: core\app.py:300 msgid "No duplicates found." @@ -69,7 +69,7 @@ msgstr "Tous les fichiers marqués ont été déplacés correctement." #: core\app.py:319 msgid "All marked files were deleted successfully." -msgstr "" +msgstr "Tous les fichiers marqués ont bien été supprimés." #: core\app.py:321 msgid "All marked files were successfully sent to Trash." @@ -137,11 +137,11 @@ msgstr "%s (%d hors-groupe)" #: core\directories.py:191 msgid "Collected {} files to scan" -msgstr "" +msgstr "{} fichiers collectés pour scanner" #: core\directories.py:207 msgid "Collected {} folders to scan" -msgstr "" +msgstr "{} dossiers collectés pour scanner" #: core\engine.py:27 msgid "%d matches found from %d groups" @@ -183,7 +183,7 @@ msgstr "Contenu" #: core\pe\matchblock.py:66 msgid "Analyzed %d/%d pictures" -msgstr "Analyzé %d/%d images" +msgstr "Analysé %d/%d images" #: core\pe\matchblock.py:183 msgid "Performed %d/%d chunk matches" @@ -215,7 +215,7 @@ msgstr "Chiffres à la fin" #: core\prioritize.py:103 msgid "Doesn't end with number" -msgstr "Pas de chiffres à la finr" +msgstr "Pas de chiffres à la fin" #: core\prioritize.py:104 msgid "Longest" diff --git a/locale/fr/LC_MESSAGES/ui.po b/locale/fr/LC_MESSAGES/ui.po index 3b8c231cf..30a32ec61 100644 --- a/locale/fr/LC_MESSAGES/ui.po +++ b/locale/fr/LC_MESSAGES/ui.po @@ -43,7 +43,7 @@ msgstr "Ouvrir logs de déboguage" #: qt/app.py:180 cocoa/en.lproj/Localizable.strings:0 msgid "Do you really want to remove all your cached picture analysis?" -msgstr "Voulez-vous vraiment vider la cache de vos analyses précédentes?" +msgstr "Voulez-vous vraiment vider la cache de vos analyses précédentes ?" #: qt/app.py:184 msgid "Picture cache cleared." @@ -66,16 +66,16 @@ msgid "" "After having deleted a duplicate, place a link targeting the reference file " "to replace the deleted file." msgstr "" -"Après avoir effacé un fichier, remplacer celui-ci par un lien vers le " +"Après avoir effacé un fichier dupliqué, remplacer celui-ci par un lien vers le " "fichier référence." #: qt/deletion_options.py:44 msgid "Hardlink" -msgstr "Hardlink" +msgstr "Lien en dur" #: qt/deletion_options.py:44 msgid "Symlink" -msgstr "Symlink" +msgstr "Lien symbolique" #: qt/deletion_options.py:48 msgid " (unsupported)" @@ -91,7 +91,7 @@ msgid "" "usually used as a workaround when the normal deletion method doesn't work." msgstr "" "Au lieu de passer par la corbeille, supprimer directement. Cette option " -"n'est généralement utilisée qu'en cas de problème." +"n'est généralement utilisée qu'en cas d'échec avec la métode de suppression normale." #: qt/deletion_options.py:59 cocoa/en.lproj/Localizable.strings:0 msgid "Proceed" @@ -162,7 +162,7 @@ msgstr "Standard" #: qt/directories_dialog.py:128 cocoa/en.lproj/Localizable.strings:0 msgid "Scan Type:" -msgstr "Type de scan:" +msgstr "Type de scan :" #: qt/directories_dialog.py:135 msgid "More Options" @@ -186,7 +186,7 @@ msgstr "Résultats non sauvegardés" #: qt/directories_dialog.py:231 cocoa/en.lproj/Localizable.strings:0 msgid "You have unsaved results, do you really want to quit?" -msgstr "Vos résultats ne sont pas sauvegardés. Voulez-vous vraiment quitter?" +msgstr "Vos résultats ne sont pas sauvegardés. Voulez-vous vraiment quitter ?" #: qt/directories_dialog.py:239 cocoa/en.lproj/Localizable.strings:0 msgid "Select a folder to add to the scanning list" @@ -211,7 +211,7 @@ msgstr "Commencer un nouveau scan" #: qt/directories_dialog.py:279 cocoa/en.lproj/Localizable.strings:0 msgid "You have unsaved results, do you really want to continue?" msgstr "" -"Vos résultats ne sont pas sauvegardés. Voulez-vous vraiment continuer?" +"Vos résultats ne sont pas sauvegardés. Voulez-vous vraiment continuer ?" #: qt/directories_model.py:23 cocoa/en.lproj/Localizable.strings:0 msgid "Name" @@ -254,7 +254,7 @@ msgstr "Tags à scanner:" #: qt/me/preferences_dialog.py:36 cocoa/en.lproj/Localizable.strings:0 msgid "Track" -msgstr "Track" +msgstr "Piste" #: qt/me/preferences_dialog.py:38 cocoa/en.lproj/Localizable.strings:0 msgid "Artist" @@ -299,12 +299,12 @@ msgstr "Utiliser les expressions régulières pour les filtres" #: qt/me/preferences_dialog.py:58 qt/pe/preferences_dialog.py:25 #: qt/se/preferences_dialog.py:38 cocoa/en.lproj/Localizable.strings:0 msgid "Remove empty folders on delete or move" -msgstr "Effacer les dossiers vides après un déplacement" +msgstr "Effacer les dossiers vides après une suppression ou un déplacement" #: qt/me/preferences_dialog.py:60 qt/pe/preferences_dialog.py:27 #: qt/se/preferences_dialog.py:59 cocoa/en.lproj/Localizable.strings:0 msgid "Ignore duplicates hardlinking to the same file" -msgstr "Ignorer doublons avec hardlink vers le même fichier" +msgstr "Ignorer doublons avec un lien en dur vers le même fichier" #: qt/me/preferences_dialog.py:62 qt/pe/preferences_dialog.py:29 #: qt/se/preferences_dialog.py:62 cocoa/en.lproj/Localizable.strings:0 @@ -317,7 +317,7 @@ msgstr "Comparer les images de tailles différentes" #: qt/preferences_dialog.py:43 msgid "Filter Hardness:" -msgstr "Seuil du filtre:" +msgstr "Seuil du filtre :" #: qt/preferences_dialog.py:69 msgid "More Results" @@ -329,15 +329,15 @@ msgstr "Moins de doublons" #: qt/preferences_dialog.py:81 msgid "Font size:" -msgstr "Taille de police:" +msgstr "Taille de police :" #: qt/preferences_dialog.py:85 msgid "Language:" -msgstr "Langue:" +msgstr "Langue :" #: qt/preferences_dialog.py:91 cocoa/en.lproj/Localizable.strings:0 msgid "Copy and Move:" -msgstr "Déplacements de fichiers:" +msgstr "Copie et déplacements de fichiers :" #: qt/preferences_dialog.py:94 cocoa/en.lproj/Localizable.strings:0 msgid "Right in destination" @@ -353,7 +353,7 @@ msgstr "Re-créer chemins absolus" #: qt/preferences_dialog.py:99 msgid "Custom Command (arguments: %d for dupe, %r for ref):" -msgstr "Commande perso. (arguments: %d pour doublon, %r pour réf):" +msgstr "Commande perso. (arguments : %d pour doublon, %r pour réf) :" #: qt/preferences_dialog.py:174 msgid "dupeGuru has to restart for language changes to take effect." @@ -369,13 +369,13 @@ msgid "" " the best to these criteria to their respective group's reference position. " "Read the help file for more information." msgstr "" -"Ajoutez des critères dans la liste de droite pour envoyer les doublons qui " -"correspondent le plus à ces critère à la position de référence. Une lecture " +"Ajoutez des critères dans la liste de droite et cliquez sur OK pour envoyer les doublons qui " +"correspondent le plus à ces critères à la position de référence. Une lecture " "préalable du fichier d'aide est conseillée." #: qt/problem_dialog.py:33 cocoa/en.lproj/Localizable.strings:0 msgid "Problems!" -msgstr "Problèmes!" +msgstr "Problèmes !" #: qt/problem_dialog.py:37 cocoa/en.lproj/Localizable.strings:0 msgid "" @@ -383,13 +383,13 @@ msgid "" "these problems are described in the table below. Those files were not " "removed from your results." msgstr "" -"Des problèmes ont été rencontrés lors du traitement de certains fichiers. La" +"Des problèmes ont été rencontrés lors du traitement de certains fichiers (ou tous). La" " nature de ces problèmes est décrite dans la liste ci-dessous. Ces fichiers " "n'ont pas été retirés des résultats." #: qt/problem_dialog.py:56 msgid "Reveal Selected" -msgstr "Révéler Fichier" +msgstr "Révéler la selection" #: qt/result_window.py:57 qt/result_window.py:104 qt/result_window.py:167 #: qt/result_window.py:191 cocoa/en.lproj/Localizable.strings:0 @@ -406,19 +406,19 @@ msgstr "Montrer les valeurs en tant que delta" #: qt/result_window.py:60 msgid "Send Marked to Recycle Bin..." -msgstr "Envoyer marqués à la corbeille..." +msgstr "Envoyer les fichiers marqués à la corbeille..." #: qt/result_window.py:61 cocoa/en.lproj/Localizable.strings:0 msgid "Move Marked to..." -msgstr "Déplacer marqués vers..." +msgstr "Déplacer les fichiers marqués vers..." #: qt/result_window.py:62 cocoa/en.lproj/Localizable.strings:0 msgid "Copy Marked to..." -msgstr "Copier marqués vers..." +msgstr "Copier les fichiers marqués vers..." #: qt/result_window.py:63 cocoa/en.lproj/Localizable.strings:0 msgid "Remove Marked from Results" -msgstr "Retirer marqués des résultats" +msgstr "Retirer les fichiers marqués des résultats" #: qt/result_window.py:64 cocoa/en.lproj/Localizable.strings:0 msgid "Re-Prioritize Results..." @@ -426,19 +426,19 @@ msgstr "Re-prioriser les résultats" #: qt/result_window.py:67 cocoa/en.lproj/Localizable.strings:0 msgid "Remove Selected from Results" -msgstr "Retirer sélectionnés des résultats" +msgstr "Retirer les fichiers sélectionnés des résultats" #: qt/result_window.py:71 cocoa/en.lproj/Localizable.strings:0 msgid "Add Selected to Ignore List" -msgstr "Ajouter sélectionnés à la liste de fichiers ignorés" +msgstr "Ajouter les fichiers sélectionnés à la liste de fichiers ignorés" #: qt/result_window.py:75 cocoa/en.lproj/Localizable.strings:0 msgid "Make Selected into Reference" -msgstr "Transformer sélectionnés en références" +msgstr "Transformer les fichiers sélectionnés en références" #: qt/result_window.py:77 cocoa/en.lproj/Localizable.strings:0 msgid "Open Selected with Default Application" -msgstr "Ouvrir sélectionné avec l'application par défaut" +msgstr "Ouvrir les fichiers sélectionnés avec l'application par défaut" #: qt/result_window.py:80 msgid "Open Containing Folder of Selected" @@ -466,19 +466,19 @@ msgstr "Marquer sélectionnés" #: qt/result_window.py:87 msgid "Export To HTML" -msgstr "Exporter vers HTML" +msgstr "Exporter en HTML" #: qt/result_window.py:88 msgid "Export To CSV" -msgstr "Exporter vers CSV" +msgstr "Exporter en CSV" #: qt/result_window.py:89 cocoa/en.lproj/Localizable.strings:0 msgid "Save Results..." -msgstr "Sauvegarder résultats..." +msgstr "Sauvegarder les résultats..." #: qt/result_window.py:90 cocoa/en.lproj/Localizable.strings:0 msgid "Invoke Custom Command" -msgstr "Invoquer commande personnalisée" +msgstr "Invoquer une commande personnalisée" #: qt/result_window.py:102 msgid "Mark" @@ -498,7 +498,7 @@ msgstr "{} résultats" #: qt/result_window.py:193 cocoa/en.lproj/Localizable.strings:0 msgid "Dupes Only" -msgstr "Sans réf." +msgstr "Dupliqués seulement" #: qt/result_window.py:194 msgid "Delta Values" @@ -514,7 +514,7 @@ msgstr "Ignorer les fichiers plus petits que" #: qt/se/preferences_dialog.py:52 cocoa/en.lproj/Localizable.strings:0 msgid "KB" -msgstr "KB" +msgstr "Ko" #: cocoa/en.lproj/Localizable.strings:0 msgid "%@ Results" @@ -526,7 +526,7 @@ msgstr "Action" #: cocoa/en.lproj/Localizable.strings:0 msgid "Add New Folder..." -msgstr "Ajouter dossier..." +msgstr "Ajouter un nouveau dossier..." #: cocoa/en.lproj/Localizable.strings:0 msgid "Advanced" @@ -546,7 +546,7 @@ msgstr "Tout ramener au premier plan" #: cocoa/en.lproj/Localizable.strings:0 msgid "Check for update..." -msgstr "Mise à jour..." +msgstr "Recherche de mise à jour..." #: cocoa/en.lproj/Localizable.strings:0 msgid "Close Window" @@ -558,7 +558,7 @@ msgstr "Copier" #: cocoa/en.lproj/Localizable.strings:0 msgid "Custom command (arguments: %d for dupe, %r for ref):" -msgstr "Commande perso. (arguments: %d pour doublon, %r pour réf):" +msgstr "Commande perso. (arguments : %d pour doublon, %r pour réf) :" #: cocoa/en.lproj/Localizable.strings:0 msgid "Cut" @@ -602,11 +602,11 @@ msgstr "Édition" #: cocoa/en.lproj/Localizable.strings:0 msgid "Export Results to CSV" -msgstr "Exporter les résultats vers CSV" +msgstr "Exporter les résultats en CSV" #: cocoa/en.lproj/Localizable.strings:0 msgid "Export Results to XHTML" -msgstr "Exporter les résultats vers HTML" +msgstr "Exporter les résultats en XHTML" #: cocoa/en.lproj/Localizable.strings:0 msgid "Fewer results" @@ -618,7 +618,7 @@ msgstr "Filtre" #: cocoa/en.lproj/Localizable.strings:0 msgid "Filter hardness:" -msgstr "Seuil du filtre:" +msgstr "Seuil du filtre :" #: cocoa/en.lproj/Localizable.strings:0 msgid "Filter Results..." @@ -630,7 +630,7 @@ msgstr "Fenêtre de sélection de dossiers" #: cocoa/en.lproj/Localizable.strings:0 msgid "Font Size:" -msgstr "Taille de police:" +msgstr "Taille de police :" #: cocoa/en.lproj/Localizable.strings:0 msgid "Hide dupeGuru" @@ -642,7 +642,7 @@ msgstr "Masquer les autres" #: cocoa/en.lproj/Localizable.strings:0 msgid "Ignore files smaller than:" -msgstr "Ignorer les fichiers plus petits que:" +msgstr "Ignorer les fichiers plus petits que :" #: cocoa/en.lproj/Localizable.strings:0 msgid "Load from file..." @@ -658,7 +658,7 @@ msgstr "Mode" #: cocoa/en.lproj/Localizable.strings:0 msgid "More results" -msgstr "plus de doublons" +msgstr "Plus de doublons" #: cocoa/en.lproj/Localizable.strings:0 msgid "Ok" @@ -674,7 +674,7 @@ msgstr "Préférences..." #: cocoa/en.lproj/Localizable.strings:0 msgid "Quick Look" -msgstr "Quick Look" +msgstr "Regard rapide" #: cocoa/en.lproj/Localizable.strings:0 msgid "Quit dupeGuru" @@ -798,11 +798,11 @@ msgstr "Erreur de compilation:" #: qt\pe\image_viewer.py:56 msgid "Increase zoom" -msgstr "Accroître zoom" +msgstr "Zoomer" #: qt\pe\image_viewer.py:66 msgid "Decrease zoom" -msgstr "Décroître zoom" +msgstr "Dézoomer" #: qt\pe\image_viewer.py:71 msgid "Ctrl+/" @@ -822,7 +822,7 @@ msgstr "Meilleur ajustement" #: qt\pe\preferences_dialog.py:49 msgid "Picture cache mode:" -msgstr "Mode de cache d'images:" +msgstr "Mode de cache d'images :" #: qt\pe\preferences_dialog.py:56 msgid "Override theme icons in viewer toolbar" @@ -945,59 +945,61 @@ msgstr "Affichage" #: qt\se\preferences_dialog.py:70 msgid "Partially hash files bigger than" -msgstr "" +msgstr "Hacher partiellement les fichiers plus gros que" #: qt\se\preferences_dialog.py:80 msgid "MB" -msgstr "" +msgstr "Mo" #: qt\preferences_dialog.py:163 msgid "Use native OS dialogs" -msgstr "" +msgstr "Utiliser les dialogues natifs de l'OS" #: qt\preferences_dialog.py:166 msgid "" "For actions such as file/folder selection use the OS native dialogs.\n" "Some native dialogs have limited functionality." -msgstr "" +msgstr "Pour les actions telles que la sélection de fichiers/dossiers, utilisez les boîtes de dialogue natives du système d'exploitation.\n" +"Des boites de dialogues ont des fonctionnalités limitées." #: qt\se\preferences_dialog.py:68 msgid "Ignore files larger than" -msgstr "" +msgstr "Ignorer les fichiers plus gros que" #: qt\app.py:135 qt\app.py:293 msgid "Clear Cache" -msgstr "" +msgstr "Vider le cache" #: qt\app.py:294 msgid "" "Do you really want to clear the cache? This will remove all cached file " "hashes and picture analysis." -msgstr "" +msgstr "Voulez-vous réellement vider le cacher ? Cela supprimera tous les hash de fichiers dans le cache" +" et les analyses d'image." #: qt\app.py:299 msgid "Cache cleared." -msgstr "" +msgstr "Cache vidé." #: qt\preferences_dialog.py:173 msgid "Use dark style" -msgstr "" +msgstr "Utliliser le style sombre" #: qt\preferences_dialog.py:241 msgid "Profile scan operation" -msgstr "" +msgstr "Opération de balayage de profil" #: qt\preferences_dialog.py:242 msgid "Profile the scan operation and save logs for optimization." -msgstr "" +msgstr "Profilage de l'opération de balayage et enregistrement des journaux pour optimisation" #: qt\preferences_dialog.py:246 msgid "Logs located in: {}" -msgstr "" +msgstr "Les logs se trouvent dans : {}" #: qt\preferences_dialog.py:291 msgid "Debug" -msgstr "" +msgstr "Débogage" #: qt\about_box.py:31 msgid "About {}" @@ -1009,7 +1011,7 @@ msgstr "Version {}" #: qt\about_box.py:49 qt\about_box.py:75 msgid "Checking for updates..." -msgstr "" +msgstr "Recherche des mises à jour..." #: qt\about_box.py:54 msgid "Licensed under GPLv3" @@ -1017,11 +1019,11 @@ msgstr "Sous licence GPLv3" #: qt\about_box.py:68 msgid "No update available." -msgstr "" +msgstr "Pas de mise à jour disponible." #: qt\about_box.py:71 msgid "New version {} available, download here." -msgstr "" +msgstr "Une nouvelle version ({}) est disponible, téléchagez-là ici." #: qt\error_report_dialog.py:50 msgid "Error Report" @@ -1029,7 +1031,7 @@ msgstr "Rapport d'erreur" #: qt\error_report_dialog.py:54 msgid "Something went wrong. How about reporting the error?" -msgstr "Un problème est survenu. Rapporter l'erreur?" +msgstr "Un problème est survenu. Rapporter l'erreur ?" #: qt\error_report_dialog.py:60 msgid "" @@ -1141,31 +1143,36 @@ msgstr "Recherche..." msgid "" "These options are for advanced users or for very specific situations, most " "users should not have to modify these." -msgstr "" +msgstr "Ces options sont destinées aux utilisateurs avancés ou pour des situation bien spécifiques, " +"la plupart des utilisateurs ne devraient pas avoir à les modifier." #: qt\preferences_dialog.py:225 msgid "Include existence check after scan completion" -msgstr "" +msgstr "Inclure un contrôle d'existence à la fin de l'analyse" #: qt\preferences_dialog.py:227 msgid "Ignore difference in mtime when loading cached digests" -msgstr "" +msgstr "Ignorer la différence dans le mtime en chargent les résumés en cache" #: qt\progress_window.py:64 msgid "Cancel?" -msgstr "" +msgstr "Annuler ?" #: qt\progress_window.py:65 msgid "Are you sure you want to cancel? All progress will be lost." -msgstr "" +msgstr "Êtes-vous sûr de vouloir annuler ? Tout ce qui a déjà été fait va être perdu." #: qt\exclude_list_dialog.py:161 msgid "" "These (case sensitive) python regular expressions will filter out files during scans.
Directores will also have their default state set to Excluded in the Directories tab if their name happens to match one of the selected regular expressions.
For each file collected, two tests are performed to determine whether or not to completely ignore it:
  • 1. Regular expressions with no path separator in them will be compared to the file name only.
  • \n" "
  • 2. Regular expressions with at least one path separator in them will be compared to the full path to the file.

  • Example: if you want to filter out .PNG files from the \"My Pictures\" directory only:
    .*My\\sPictures\\\\.*\\.png

    You can test the regular expression with the \"test string\" button after pasting a fake path in the test field:
    C:\\\\User\\My Pictures\\test.png

    \n" "Matching regular expressions will be highlighted.
    If there is at least one highlight, the path or filename tested will be ignored during scans.

    Directories and files starting with a period '.' are filtered out by default.

    " -msgstr "" +msgstr "Cette expression régulière python (sensible à la casse) va filtrer les fichiers pendant les scans.
    " +"Ces expressions régulières python (sensible aux majuscules) peuvent ignorer les fichiers pendant les scans. Les dossiers auront également leur état par défaut mis sur Exclus dans l'onglet Dossiers si leur nom correspond à une des expressions régulières sélectionnées
    Pour chaque fichier collecté, deux tests sont faits pour déterminer s'il doit être totalement ignoré:
  • 1. Les expressions régulières sans séparateur de chemin sont comparées au nom de fichier seul.
  • \n" +"
  • 2. Les expressions régulières avec au moins un séparateur de chemin sont comparées au chemin complet vers le fichier.

  • \n" +"Exemple: si vous voulez uniquement ignorer les fichiers .PNG du dossier \"Mes Images\":
    .*Mes\\sImages\\\\.*\\.png

    Vous pouvez tester l'expression régulière via le bouton \"Tester la chaîne de caractères\" après avoir tapé un faux chemin de fichier dans le champs correspondant:
    C:\\\\Utilisateur\\Mes Images\\test.png

    \n" +"Les expressions régulières qui fonctionnent seront surlignées.
    S'il y a au moins un surlignage, le chemin ou nom de fichier testé sera ignoré durant les scans.

    Les dossiers et fichiers commençant par un point '.' sont ignorés par défaut.

    " #: qt\pe\preferences_dialog.py:24 msgid "Match pictures of different rotations" -msgstr "" +msgstr "Associer des images de différentes rotations" diff --git a/locale/pl_PL/LC_MESSAGES/columns.po b/locale/pl_PL/LC_MESSAGES/columns.po index 3d6e42348..84a0a46dc 100644 --- a/locale/pl_PL/LC_MESSAGES/columns.po +++ b/locale/pl_PL/LC_MESSAGES/columns.po @@ -94,7 +94,7 @@ msgstr "Komentarz" #: core\me\result_table.py:34 core\pe\result_table.py:26 #: core\se\result_table.py:24 msgid "Match %" -msgstr "Mecz %" +msgstr "Dopasowanie %" #: core\me\result_table.py:35 core\se\result_table.py:25 msgid "Words Used" diff --git a/locale/pl_PL/LC_MESSAGES/core.po b/locale/pl_PL/LC_MESSAGES/core.po index e1b36765a..3a159c5c0 100644 --- a/locale/pl_PL/LC_MESSAGES/core.po +++ b/locale/pl_PL/LC_MESSAGES/core.po @@ -196,7 +196,7 @@ msgstr "Przygotowanie do dopasowania" #: core\pe\matchblock.py:240 msgid "Verified %d/%d matches" -msgstr "Zweryfikowane %d/%d meczów" +msgstr "Zweryfikowane %d/%d dopasowań" #: core\pe\matchexif.py:19 msgid "Read EXIF of %d/%d pictures" diff --git a/locale/ru/LC_MESSAGES/columns.po b/locale/ru/LC_MESSAGES/columns.po index 0040ff6b3..4223790d5 100644 --- a/locale/ru/LC_MESSAGES/columns.po +++ b/locale/ru/LC_MESSAGES/columns.po @@ -112,7 +112,7 @@ msgstr "Размеры" #: core\pe\result_table.py:21 core\se\result_table.py:21 msgid "Size (KB)" -msgstr "Размер (КБ)" +msgstr "Размер (кБ)" #: core\pe\result_table.py:24 msgid "EXIF Timestamp" diff --git a/locale/ru/LC_MESSAGES/core.po b/locale/ru/LC_MESSAGES/core.po index ea3b9415b..b00592a86 100644 --- a/locale/ru/LC_MESSAGES/core.po +++ b/locale/ru/LC_MESSAGES/core.po @@ -100,15 +100,15 @@ msgstr "" #: core\app.py:469 msgid "Select a directory to copy marked files to" -msgstr "Выберите каталог, в который вы хотите скопировать отмеченные файлы" +msgstr "Выберите каталог, в который Вы хотите скопировать отмеченные файлы" #: core\app.py:471 msgid "Select a directory to move marked files to" -msgstr "Выберите каталог, в который вы хотите переместить отмеченные файлы" +msgstr "Выберите каталог для перемещения отмеченных файлов" #: core\app.py:510 msgid "Select a destination for your exported CSV" -msgstr "Выберите назначение для экспортируемого " +msgstr "Выберите назначение для Вашего экспортируемого CSV" #: core\app.py:516 core\app.py:777 core\app.py:787 msgid "Couldn't write to file: {}" @@ -124,7 +124,7 @@ msgstr "Вы собираетесь удалить %d файлов из резу #: core\app.py:749 msgid "{} duplicate groups were changed by the re-prioritization." -msgstr "{} групп дубликатов было изменено при реприоритезации." +msgstr "{} групп дубликатов было изменено при обновлении приоритета." #: core\app.py:797 msgid "The selected directories contain no scannable file." @@ -136,7 +136,7 @@ msgstr "Сбор файлов для сканирования" #: core\app.py:863 msgid "%s (%d discarded)" -msgstr "%s. (%d отменено)" +msgstr "%s (%d отменено)" #: core\directories.py:191 msgid "Collected {} files to scan" @@ -148,7 +148,7 @@ msgstr "Собрано {} каталогов для сканирования" #: core\engine.py:27 msgid "%d matches found from %d groups" -msgstr "Найдено %d совпадений из %d групп" +msgstr "%d совпадений найдено из %d групп" #: core\gui\deletion_options.py:71 msgid "You are sending {} file(s) to the Trash." @@ -249,7 +249,7 @@ msgstr "%d / %d (%s / %s) дубликатов отмечено." #: core\results.py:142 msgid " filter: %s" -msgstr "фильтр: %s" +msgstr " фильтр: %s" #: core\scanner.py:114 msgid "Read metadata of %d/%d files" diff --git a/locale/ru/LC_MESSAGES/ui.po b/locale/ru/LC_MESSAGES/ui.po index c61b25ea4..062bf782b 100644 --- a/locale/ru/LC_MESSAGES/ui.po +++ b/locale/ru/LC_MESSAGES/ui.po @@ -93,8 +93,8 @@ msgid "" "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." msgstr "" -"Удалить файлы с диска вместо отправки в Корзину. Используйте если нормальный" -" метод удаления не работает." +"Удалить файлы с диска вместо отправки в Корзину. Используйте, если нормальный " +"метод удаления не работает." #: qt/deletion_options.py:59 cocoa/en.lproj/Localizable.strings:0 msgid "Proceed" @@ -189,7 +189,7 @@ msgstr "Несохранённые результаты" #: qt/directories_dialog.py:231 cocoa/en.lproj/Localizable.strings:0 msgid "You have unsaved results, do you really want to quit?" -msgstr "Имеются несохранённые результаты, вы действительно хотите выйти?" +msgstr "Имеются несохранённые результаты, Вы действительно хотите выйти?" #: qt/directories_dialog.py:239 cocoa/en.lproj/Localizable.strings:0 msgid "Select a folder to add to the scanning list" @@ -205,7 +205,7 @@ msgstr "Все файлы (*.*)" #: qt/directories_dialog.py:267 qt/result_window.py:311 msgid "dupeGuru Results (*.dupeguru)" -msgstr "Результаты dupeGuru (*. dupeguru)" +msgstr "Результаты dupeGuru (*.dupeguru)" #: qt/directories_dialog.py:278 msgid "Start a new scan" @@ -213,7 +213,7 @@ msgstr "Начать новую проверку" #: qt/directories_dialog.py:279 cocoa/en.lproj/Localizable.strings:0 msgid "You have unsaved results, do you really want to continue?" -msgstr "Имеются несохранённые результаты, вы действительно хотите продолжить?" +msgstr "Имеются несохранённые результаты, Вы действительно хотите продолжить?" #: qt/directories_model.py:23 cocoa/en.lproj/Localizable.strings:0 msgid "Name" @@ -256,7 +256,7 @@ msgstr "Теги для проверки:" #: qt/me/preferences_dialog.py:36 cocoa/en.lproj/Localizable.strings:0 msgid "Track" -msgstr "Трек" +msgstr "Дорожка" #: qt/me/preferences_dialog.py:38 cocoa/en.lproj/Localizable.strings:0 msgid "Artist" @@ -323,7 +323,7 @@ msgstr "Уровень фильтрации:" #: qt/preferences_dialog.py:69 msgid "More Results" -msgstr "Дополнительные результаты" +msgstr "Больше результатов" #: qt/preferences_dialog.py:74 msgid "Fewer Results" @@ -379,7 +379,7 @@ msgstr "" #: qt/problem_dialog.py:33 cocoa/en.lproj/Localizable.strings:0 msgid "Problems!" -msgstr "Проблемка!" +msgstr "Проблема!" #: qt/problem_dialog.py:37 cocoa/en.lproj/Localizable.strings:0 msgid "" @@ -409,15 +409,15 @@ msgstr "Показать значения разницы" #: qt/result_window.py:60 msgid "Send Marked to Recycle Bin..." -msgstr "Переместить отмеченные в Корзину…" +msgstr "Переместить отмеченные в Корзину..." #: qt/result_window.py:61 cocoa/en.lproj/Localizable.strings:0 msgid "Move Marked to..." -msgstr "Переместить отмеченные в…" +msgstr "Переместить отмеченные в..." #: qt/result_window.py:62 cocoa/en.lproj/Localizable.strings:0 msgid "Copy Marked to..." -msgstr "Скопировать отмеченные в…" +msgstr "Скопировать отмеченные в..." #: qt/result_window.py:63 cocoa/en.lproj/Localizable.strings:0 msgid "Remove Marked from Results" @@ -477,7 +477,7 @@ msgstr "Экспорт в CSV" #: qt/result_window.py:89 cocoa/en.lproj/Localizable.strings:0 msgid "Save Results..." -msgstr "Сохранить результаты…" +msgstr "Сохранить результаты..." #: qt/result_window.py:90 cocoa/en.lproj/Localizable.strings:0 msgid "Invoke Custom Command" @@ -509,15 +509,15 @@ msgstr "Значения разницы" #: qt/result_window.py:310 cocoa/en.lproj/Localizable.strings:0 msgid "Select a file to save your results to" -msgstr "Выберите файл, чтобы сохранить ваши результаты" +msgstr "Выберите файл для сохранения Ваших результатов" #: qt/se/preferences_dialog.py:41 msgid "Ignore files smaller than" -msgstr "Игнорировать файлы меньше чем" +msgstr "Игнорировать файлы меньше, чем" #: qt/se/preferences_dialog.py:52 cocoa/en.lproj/Localizable.strings:0 msgid "KB" -msgstr "КБ" +msgstr "кБ" #: cocoa/en.lproj/Localizable.strings:0 msgid "%@ Results" @@ -549,7 +549,7 @@ msgstr "Все на передний план" #: cocoa/en.lproj/Localizable.strings:0 msgid "Check for update..." -msgstr "Проверка обновлений…" +msgstr "Проверка обновлений..." #: cocoa/en.lproj/Localizable.strings:0 msgid "Close Window" @@ -594,7 +594,7 @@ msgstr "Настройки dupeGuru" #: cocoa/en.lproj/Localizable.strings:0 msgid "dupeGuru Results" -msgstr "Результаты dupeGuru " +msgstr "Результаты dupeGuru" #: cocoa/en.lproj/Localizable.strings:0 msgid "dupeGuru Website" @@ -626,7 +626,7 @@ msgstr "Уровень фильтрации:" #: cocoa/en.lproj/Localizable.strings:0 msgid "Filter Results..." -msgstr "Отфильтровать результаты…" +msgstr "Отфильтровать результаты..." #: cocoa/en.lproj/Localizable.strings:0 msgid "Folder Selection Window" @@ -646,11 +646,11 @@ msgstr "Скрыть остальные" #: cocoa/en.lproj/Localizable.strings:0 msgid "Ignore files smaller than:" -msgstr "Пропускать файлы меньше чем:" +msgstr "Пропускать файлы меньше, чем:" #: cocoa/en.lproj/Localizable.strings:0 msgid "Load from file..." -msgstr "Загрузить из файла…" +msgstr "Загрузить из файла..." #: cocoa/en.lproj/Localizable.strings:0 msgid "Minimize" @@ -674,7 +674,7 @@ msgstr "Вставить" #: cocoa/en.lproj/Localizable.strings:0 msgid "Preferences..." -msgstr "Настройки…" +msgstr "Настройки..." #: cocoa/en.lproj/Localizable.strings:0 msgid "Quick Look" @@ -686,7 +686,7 @@ msgstr "Выйти из dupeGuru" #: cocoa/en.lproj/Localizable.strings:0 msgid "Reset to Default" -msgstr "Восстановить значения по умолчанию" +msgstr "Восстановить значение по умолчанию" #: cocoa/en.lproj/Localizable.strings:0 msgid "Reset To Defaults" @@ -706,7 +706,7 @@ msgstr "Выбрать все" #: cocoa/en.lproj/Localizable.strings:0 msgid "Send Marked to Trash..." -msgstr "Переместить отмеченные в Корзину…" +msgstr "Переместить отмеченные в Корзину..." #: cocoa/en.lproj/Localizable.strings:0 msgid "Services" @@ -754,11 +754,11 @@ msgstr "Выберите файл каталогов для загрузки" #: qt\directories_dialog.py:338 msgid "dupeGuru Results (*.dupegurudirs)" -msgstr "Каталоги dupeGuru (*.dupegurudirs)" +msgstr "Результаты dupeGuru (*.dupegurudirs)" #: qt\directories_dialog.py:347 msgid "Select a file to save your directories to" -msgstr "Выберите файл для сохранения каталогов" +msgstr "Выберите файл для сохранения Ваших каталогов" #: qt\directories_dialog.py:348 msgid "dupeGuru Directories (*.dupegurudirs)" @@ -798,7 +798,7 @@ msgstr "" #: qt\exclude_list_table.py:36 msgid "Compilation error: " -msgstr "Ошибка компиляции:" +msgstr "Ошибка компиляции: " #: qt\pe\image_viewer.py:56 msgid "Increase zoom" @@ -835,7 +835,7 @@ msgstr "Переопределить значки темы на панели и #: qt\pe\preferences_dialog.py:58 msgid "" "Use our own internal icons instead of those provided by the theme engine" -msgstr "Используйте внутренние значки вместо значков, встроенных в тему" +msgstr "Использовать наши внутренние значки вместо значков, встроенных в тему" #: qt\pe\preferences_dialog.py:66 msgid "Show scrollbars in image viewers" @@ -965,7 +965,7 @@ msgstr "" #: qt\se\preferences_dialog.py:68 msgid "Ignore files larger than" -msgstr "Игнорировать файлы больше чем" +msgstr "Игнорировать файлы больше, чем" #: qt\app.py:135 qt\app.py:293 msgid "Clear Cache" @@ -981,7 +981,7 @@ msgstr "" #: qt\app.py:299 msgid "Cache cleared." -msgstr "Кэш очищен " +msgstr "Кэш очищен." #: qt\preferences_dialog.py:173 msgid "Use dark style" @@ -994,8 +994,7 @@ msgstr "Сохранить профиль сканирования" #: qt\preferences_dialog.py:242 msgid "Profile the scan operation and save logs for optimization." msgstr "" -"В папке установленной или портативной программы, есть папка Data в которую " -"сохраняется логи и файл с раширением *.profile для оптимизации." +"Настройте операцию сканирования и сохраните журналы для оптимизации." #: qt\preferences_dialog.py:246 msgid "Logs located in: {}" @@ -1023,11 +1022,11 @@ msgstr "Под лицензией GPLv3" #: qt\about_box.py:68 msgid "No update available." -msgstr "У вас самая свежая версия" +msgstr "Обновления недоступны." #: qt\about_box.py:71 msgid "New version {} available, download here." -msgstr "Обнаружена новая {} версия, загружать тут." +msgstr "Обнаружена новая {} версия, загрузить тут." #: qt\error_report_dialog.py:50 msgid "Error Report" diff --git a/macos.md b/macos.md index 1270666dd..bf54be51b 100644 --- a/macos.md +++ b/macos.md @@ -7,19 +7,19 @@ These instructions are for the Qt version of the UI on macOS. - [Python 3.7+][python] - [Xcode 12.3][xcode] or just Xcode command line tools (older versions can be used if not interested in arm macs) - [Homebrew][homebrew] -- [qt5](https://www.qt.io/) +- [qt6](https://www.qt.io/) #### Prerequisite setup 1. Install Xcode if desired 2. Install [Homebrew][homebrew], if not on the path after install (arm based Macs) create `~/.zshrc` with `export PATH="/opt/homebrew/bin:$PATH"`. Will need to reload terminal or source the file to take effect. -3. Install qt5 with `brew`. If you are using a version of macos without system python 3.7+ then you will +3. Install qt6 with `brew`. If you are using a version of macos without system python 3.7+ then you will also need to install that via brew or with pyenv. - $ brew install qt5 + $ brew install qt6 - NOTE: Using `brew` to install qt5 is to allow pyqt5 to build without a native wheel + NOTE: Using `brew` to install qt6 is to allow pyqt6 to build without a native wheel available. If you are using an intel based mac you can probably skip this step. 4. May need to launch a new terminal to have everything working. @@ -27,7 +27,7 @@ also need to install that via brew or with pyenv. ### With build.py OSX comes with a version of python 3 by default in newer versions of OSX. To produce universal builds either the 3.8 version shipped in macos or 3.9.1 or newer needs to be used. If needing to -build pyqt5 from source then the first line below is needed, else it may be omitted. (Path shown is +build pyqt6 from source then the first line below is needed, else it may be omitted. (Path shown is for an arm mac.) $ export PATH="/opt/homebrew/opt/qt/bin:$PATH" diff --git a/package.py b/package.py index 90140c584..a022de697 100644 --- a/package.py +++ b/package.py @@ -15,7 +15,7 @@ import distro import re -from hscommon.build import ( +from dupeguru.hscommon.build import ( print_and_do, copy_packages, build_debian_changelog, diff --git a/pkg/arch/dupeguru.desktop b/pkg/arch/dupeguru.desktop index 50f5f607d..210b3ba8c 100644 --- a/pkg/arch/dupeguru.desktop +++ b/pkg/arch/dupeguru.desktop @@ -6,3 +6,4 @@ Icon={iconpath} Terminal=false Type=Application Categories=Utility; +StartupWMClass=dupeguru diff --git a/pkg/debian/control b/pkg/debian/control index 1f84eac44..e6ec1e2d0 100644 --- a/pkg/debian/control +++ b/pkg/debian/control @@ -10,7 +10,7 @@ Vcs-Git: https://github.com/arsenetar/dupeguru.git Package: {pkgname} Architecture: {arch} -Depends: ${shlibs:Depends}, python3 (>=3.7), python3 (<<3.12), python3-pyqt5, python3-mutagen, python3-semantic-version +Depends: ${shlibs:Depends}, python3 (>=3.7), python3 (<<3.12), python3-pyqt6, python3-mutagen, python3-semantic-version Provides: dupeguru-se, dupeguru-me, dupeguru-pe Replaces: dupeguru-se, dupeguru-me, dupeguru-pe Conflicts: dupeguru-se, dupeguru-me, dupeguru-pe diff --git a/pkg/debian/dupeguru.desktop b/pkg/debian/dupeguru.desktop index 50f5f607d..210b3ba8c 100644 --- a/pkg/debian/dupeguru.desktop +++ b/pkg/debian/dupeguru.desktop @@ -6,3 +6,4 @@ Icon={iconpath} Terminal=false Type=Application Categories=Utility; +StartupWMClass=dupeguru diff --git a/pkg/dupeguru.desktop b/pkg/dupeguru.desktop index 91e8bf6e4..a627644b8 100644 --- a/pkg/dupeguru.desktop +++ b/pkg/dupeguru.desktop @@ -7,3 +7,4 @@ Terminal=false Type=Application Categories=Utility; Keywords=file manager;gui; +StartupWMClass=dupeguru diff --git a/qt/dg.qrc b/qt/dg.qrc deleted file mode 100644 index d432eb447..000000000 --- a/qt/dg.qrc +++ /dev/null @@ -1,15 +0,0 @@ - - - ../images/dgse_logo_32.png - ../images/dgse_logo_128.png - ../images/plus_8.png - ../images/minus_8.png - ../images/search_clear_13.png - ../images/exchange_purple_upscaled.png - ../images/old_zoom_in.png - ../images/old_zoom_out.png - ../images/old_zoom_original.png - ../images/old_zoom_best_fit.png - ../images/dialog-error.png - - diff --git a/qt/problem_dialog.py b/qt/problem_dialog.py deleted file mode 100644 index 38c0515f6..000000000 --- a/qt/problem_dialog.py +++ /dev/null @@ -1,78 +0,0 @@ -# Created By: Virgil Dupras -# Created On: 2010-04-12 -# 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 PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QPushButton, - QSpacerItem, - QSizePolicy, - QLabel, - QTableView, - QAbstractItemView, -) - -from qt.util import move_to_screen_center -from hscommon.trans import trget -from qt.problem_table import ProblemTable - -tr = trget("ui") - - -class ProblemDialog(QDialog): - def __init__(self, parent, model, **kwargs): - flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint - super().__init__(parent, flags, **kwargs) - self._setupUi() - self.model = model - self.model.view = self - self.table = ProblemTable(self.model.problem_table, view=self.tableView) - - self.revealButton.clicked.connect(self.model.reveal_selected_dupe) - self.closeButton.clicked.connect(self.accept) - - def _setupUi(self): - self.setWindowTitle(tr("Problems!")) - self.resize(413, 323) - self.verticalLayout = QVBoxLayout(self) - self.label = QLabel(self) - msg = tr( - "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." - ) - self.label.setText(msg) - self.label.setWordWrap(True) - self.verticalLayout.addWidget(self.label) - self.tableView = QTableView(self) - self.tableView.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.tableView.setSelectionMode(QAbstractItemView.SingleSelection) - self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) - self.tableView.setShowGrid(False) - self.tableView.horizontalHeader().setStretchLastSection(True) - self.tableView.verticalHeader().setDefaultSectionSize(18) - self.tableView.verticalHeader().setHighlightSections(False) - self.verticalLayout.addWidget(self.tableView) - self.horizontalLayout = QHBoxLayout() - self.revealButton = QPushButton(self) - self.revealButton.setText(tr("Reveal Selected")) - self.horizontalLayout.addWidget(self.revealButton) - spacer_item = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacer_item) - self.closeButton = QPushButton(self) - self.closeButton.setText(tr("Close")) - self.closeButton.setDefault(True) - self.horizontalLayout.addWidget(self.closeButton) - self.verticalLayout.addLayout(self.horizontalLayout) - - def showEvent(self, event): - # have to do this here as the frameGeometry is not correct until shown - move_to_screen_center(self) - super().showEvent(event) diff --git a/requirements.txt b/requirements.txt index 9618b5b15..291f68b1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ +defusedxml>=0.7.1,<1.0.0 distro>=1.8.0,<2.0.0 mutagen>=1.46.0,<2.0.0 polib>=1.1.0,<2.0.0 -PyQt5 >=5.15.0,<6.0; sys_platform != 'linux' +PyQt6 >=6.3,<7.0 pywin32>=304; sys_platform == 'win32' semantic-version>=2.0.0,<3.0.0 -Send2Trash>=1.8.2,<2.0.0 +Send2Trash>=1.8.2,<3.0.0 sphinx>=5.3.0,<8.0.0 xxhash>=3.0.0,<4.0.0 diff --git a/run.py b/run.py index d6b5d236f..2124c499d 100644 --- a/run.py +++ b/run.py @@ -7,18 +7,19 @@ import sys import os.path as op +import os import gc -from PyQt5.QtCore import QCoreApplication -from PyQt5.QtGui import QIcon, QPixmap -from PyQt5.QtWidgets import QApplication +from PyQt6.QtCore import QDir +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QApplication -from hscommon.trans import install_gettext_trans_under_qt -from qt.error_report_dialog import install_excepthook -from qt.util import setup_qt_logging, create_qsettings -from qt import dg_rc # noqa: F401 -from qt.platform import BASE_PATH -from core import __version__, __appname__ +from dupeguru.hscommon.trans import install_gettext_trans_under_qt +from dupeguru.qt.error_report_dialog import install_excepthook +from dupeguru.qt.util import setup_qt_logging, create_qsettings +from dupeguru.qt import dg_rc # noqa: F401 +from dupeguru.qt.platform import BASE_PATH +from dupeguru.core import __version__, __appname__ # SIGQUIT is not defined on Windows if sys.platform == "win32": @@ -31,6 +32,10 @@ global dgapp dgapp = None +# force Qt6 to use the X11 interfaces since Fedora now runs wayland +# and under wayland, Qt6 application window does not use the proper +# styling. +os.environ["QT_QPA_PLATFORM"] = "xcb" def signal_handler(sig, frame): global dgapp @@ -48,9 +53,11 @@ def setup_signals(): def main(): app = QApplication(sys.argv) - QCoreApplication.setOrganizationName("Hardcoded Software") - QCoreApplication.setApplicationName(__appname__) - QCoreApplication.setApplicationVersion(__version__) + app.setDesktopFileName("dupeguru") + QApplication.setOrganizationName("Hardcoded Software") + QApplication.setApplicationName(__appname__) + QApplication.setApplicationVersion(__version__) + QDir.addSearchPath("images", op.join(BASE_PATH, "images")) setup_qt_logging() settings = create_qsettings() lang = settings.value("Language") @@ -61,16 +68,16 @@ def main(): # Let the Python interpreter runs every 500ms to handle signals. This is # required because Python cannot handle signals while the Qt event loop is # running. - from PyQt5.QtCore import QTimer + from PyQt6.QtCore import QTimer timer = QTimer() timer.start(500) timer.timeout.connect(lambda: None) # Many strings are translated at import time, so this is why we only import after the translator # has been installed - from qt.app import DupeGuru + from dupeguru.qt.app import DupeGuru - app.setWindowIcon(QIcon(QPixmap(f":/{DupeGuru.LOGO_NAME}"))) + app.setWindowIcon(QIcon(f"images:{DupeGuru.LOGO_NAME}_32.png")) global dgapp dgapp = DupeGuru() install_excepthook("https://github.com/arsenetar/dupeguru/issues") diff --git a/setup.cfg b/setup.cfg index bc12410b2..2bef299e4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,20 +1,19 @@ [metadata] name = dupeGuru -version = attr: core.__version__ +version = attr: dupeguru.core.__version__ url = https://github.com/arsenetar/dupeguru project_urls = Bug Reports = https://github.com/arsenetar/dupeguru/issues author = Andrew Senetar author_email = arsenetar@voltaicideas.net -license = GPLv3 -license_files = license +license = GPL-3.0-only +license_files = LICENSE description = dupeGuru is a tool to find duplicate files on your computer. long_description = file:README.md long_description_content_type = text/markdown classifiers = Development Status :: 5 - Production/Stable Intended Audience :: End Users/Desktop - License :: OSI Approved :: GNU General Public License v3 (GPLv3) Operating System :: MacOS :: MacOS X Operating System :: Microsoft :: Windows Operating System :: POSIX @@ -26,23 +25,35 @@ classifiers = Topic :: Desktop Environment :: File Managers [options] -packages = find: +packages = + dupeguru.core + dupeguru.core.gui + dupeguru.core.me + dupeguru.core.pe + dupeguru.core.pe.modules + dupeguru.core.se + dupeguru.core.tests + dupeguru.hscommon + dupeguru.hscommon.gui + dupeguru.hscommon.jobprogress + dupeguru.hscommon.tests + dupeguru.qt + dupeguru.qt.me + dupeguru.qt.pe + dupeguru.qt.pe.modules + dupeguru.qt.se python_requires = >=3.7 install_requires = - Send2Trash>=1.8.2,<2.0.0 + Send2Trash>=1.8.2,<3.0.0 mutagen>=1.46.0,<2.0.0 distro>=1.8.0,<2.0.0 - PyQt5 >=5.15.0,<6.0; sys_platform != 'linux' + PyQt6 >=6.3.0,<7.0; sys_platform != 'linux' pywin32>=228; sys_platform == 'win32' semantic-version>=2.0.0,<3.0.0 xxhash>=3.0.0,<4.0.0 setup_requires = - sphinx>=3.0.0 + sphinx>=5.0.0 polib>=1.1.0 tests_require = pytest >=6,<7 include_package_data = true - -[options.entry_points] -console_scripts = - dupeguru = run.py diff --git a/setup.nsi b/setup.nsi index a6809cf7a..3ec789bbf 100644 --- a/setup.nsi +++ b/setup.nsi @@ -245,7 +245,7 @@ Section "Uninstall" ; Remove Files & Folders in Install Folder RMDir /r "$INSTDIR\core" RMDir /r "$INSTDIR\help" - RMDir /r "$INSTDIR\PyQt5" + RMDir /r "$INSTDIR\PyQt6" RMDir /r "$INSTDIR\qt" RMDir /r "$INSTDIR\locale" Delete "$INSTDIR\*.exe" diff --git a/setup.py b/setup.py index 97cb61e38..e34dd7469 100644 --- a/setup.py +++ b/setup.py @@ -3,24 +3,24 @@ exts = [ Extension( - "core.pe._block", + "dupeguru.core.pe._block", [ - str(Path("core", "pe", "modules", "block.c")), - str(Path("core", "pe", "modules", "common.c")), + str(Path("dupeguru", "core", "pe", "modules", "block.c")), + str(Path("dupeguru", "core", "pe", "modules", "common.c")), ], - include_dirs=[str(Path("core", "pe", "modules"))], + include_dirs=[str(Path("dupeguru", "core", "pe", "modules"))], ), Extension( - "core.pe._cache", + "dupeguru.core.pe._cache", [ - str(Path("core", "pe", "modules", "cache.c")), - str(Path("core", "pe", "modules", "common.c")), + str(Path("dupeguru", "core", "pe", "modules", "cache.c")), + str(Path("dupeguru", "core", "pe", "modules", "common.c")), ], - include_dirs=[str(Path("core", "pe", "modules"))], + include_dirs=[str(Path("dupeguru", "core", "pe", "modules"))], ), - Extension("qt.pe._block_qt", [str(Path("qt", "pe", "modules", "block.c"))]), + Extension("dupeguru.qt.pe._block_qt", [str(Path("dupeguru", "qt", "pe", "modules", "block.c"))]), ] -headers = [str(Path("core", "pe", "modules", "common.h"))] +headers = [str(Path("dupeguru", "core", "pe", "modules", "common.h"))] setup(ext_modules=exts, headers=headers)