1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2026-03-12 03:31:37 +00:00

Compare commits

..

30 Commits

Author SHA1 Message Date
6d8b86b7eb fix(core): Remove old directory state logic
- Remove code forcing the exclusion of `.` directories by default, the
  new default exclusion filters do this by default
- Change default state code to always return a value
2023-02-27 17:58:15 -06:00
e41c91623c Merge pull request #1049 from Dobatymo/colors-bytes
serialize/deserialize colors to/from bytes instead of strings
2023-01-26 21:24:20 -06:00
46521c8af1 feat: Add migration for picture cache db
- Add migration (just delete db and change to new schema) for picture
  cache following the same sort of strategy as the file digest cache
- Rename mtime column to mtime_ns to match file cache for consistency
2023-01-13 00:05:47 -06:00
549eb7f153 chore: Add vscode launch.json 2023-01-12 23:51:05 -06:00
8125e3ec97 chore: Add rulers to vscode settings, format 2023-01-12 23:30:35 -06:00
8c5e18b980 Merge remote-tracking branch 'upstream/master' into colors-bytes 2023-01-12 00:14:17 -06:00
d81759f77f fix: Specify maximum python version for deb
Specify maximum supported python version so attempts to install are met
with better errors.
2023-01-11 23:53:02 -06:00
c57042fdd2 fix: Resolve issue with mock object for core test
Last change introduced a new method on the fs.File object that the test
object did not have.  Add similar method to test object.
2023-01-11 23:20:40 -06:00
057be0294a fix: Prevent exception during existence check
- Add "safe" existence check to files which catches OSErrors that may
  occur when trying to stat files
- Use "safe" existence check during final existence check
2023-01-11 23:07:06 -06:00
81daddd072 refactor: Improve digest cache db method performance
- Remove lock on read operations, only needed for write operations
- Change to use context manager for sqlite connection
- Remove long lived cursor object and use short lived cursors instead

Fixes #1080
2023-01-11 00:58:29 -06:00
1e651a1603 Merge pull request #1089 from arsenetar/as/pre-commit
feat: Add pre-commit, include python 3.11 in tests
2023-01-09 23:18:13 -06:00
78f4145910 chore: Remove unused qtlib.pot file 2023-01-09 23:02:19 -06:00
46d1afb566 chore: Apply whitespace fixes from hooks
- Remove trailing whitespace
- Correct single newline at end of files (skip for json)
- Update to formatting in a few places due to black
2023-01-09 22:58:08 -06:00
a5e31f15f0 Merge pull request #1088 from arsenetar/as/remove-shelve
feat: Remove shelve picture cache
2023-01-09 22:48:37 -06:00
0cf6c9a1a2 ci: Update to include python 3.11 & pre-commit 2023-01-09 22:44:10 -06:00
6db2fa2be6 fix: Correct flake8 config
- Add exclude pattern for flake8 when running with pre-commit as it does
  not fully honor the exclude paths.
- Cleanup exclude paths for flake8 in tox.ini
- Re-enable line length check and correct three affected files
2023-01-09 22:35:12 -06:00
2dd2a801cc feat: Add pre-commit and commitlint 2023-01-09 21:53:22 -06:00
83f5e80427 feat: Remove shelve picture cache
- Remove shelve picture cache as it has had a fair number of historical
  issues.  Original issue for which it was added should be long
  resolved.  Additionally this allows additional consolidation of the
  various cache code and potentially dbs in the future.
- Remove all related preferences and related code for changing cache
  backend between sqlite and shelve.
2023-01-06 00:35:23 -06:00
091cae0cc6 feat: Add confirmation dialog when canceling job
- Implement a confirmation dialog for cancellation of jobs, required
  changing from QProgressDialog to QDialog to keep cleaner.
- Update ui translation source file

Close #1033, #515
2023-01-06 00:06:55 -06:00
e30a135451 feat: Add additional scan time options
- Add option to include file existence check at end of scan, speeds up
  end of scan operation time considerably, however if user has removed
  or moved files since starting a scan there could be later errors when
  interacting with results.  Defaults to existing behavior of including
  the check, until it can be verified later dialogs and actions handle
  non-existent items better.
- Add option to ignore differences in mtime when checking hash cache.
  Option is present in advanced tab of preferences.  Closes #1022.
- Regenerate pot files for translations
2023-01-05 23:01:16 -06:00
1db93fd142 Merge pull request #1069 from eugenesan/master
Add webp image format support
2022-12-06 05:50:36 -06:00
48862b6414 Merge pull request #1036 from dktrkranz/desktopfile
Add Keywords tag to desktop file
2022-12-06 05:48:50 -06:00
Eugene San (eugenesan)
c920412856 Add webp image format support 2022-11-24 13:53:27 -07:00
4448b999ab fix: Add W503 to flake8 extend-ignore
For some reason flake8 is now throwing W503, which should be disabled by
default, adding to extend-ignore fixes it, so doing that for now.
2022-09-28 07:05:46 -05:00
af1ae33598 Merge pull request #1042 from fascox/patch-1
Update core.po for `it`
2022-09-28 06:52:52 -05:00
265d10b261 Merge pull request #1026 from muath-ye/patch-1
Update columns.po for `ar`
2022-09-28 06:46:50 -05:00
Dobatymo
f1153c85c0 serialize/deserialize colors to/from bytes instead of strings
it's a tiny bit faster and saves a bit of memory
2022-09-27 17:47:38 +08:00
Fabio Scognamiglio
1eee3fd7e4 Update core.po
fix mispelled translation
2022-09-10 13:29:04 +02:00
Luca Falavigna
1827827fdf Add Keywords tag to desktop file 2022-08-31 14:57:16 +00:00
Muath Alsowadi
db174d4e63 Update columns.po 2022-08-07 09:32:33 +03:00
120 changed files with 903 additions and 981 deletions

View File

@@ -40,7 +40,7 @@ jobs:
name: Build Cpp name: Build Cpp
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install python3-pyqt6 sudo apt-get install python3-pyqt5
make modules make modules
- if: matrix.language == 'python' - if: matrix.language == 'python'
name: Autobuild name: Autobuild

View File

@@ -9,43 +9,22 @@ on:
branches: [master] branches: [master]
jobs: jobs:
lint: pre-commit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python 3.10 - name: Set up Python 3.11
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.11"
- name: Install dependencies - uses: pre-commit/action@v3.0.0
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-extra.txt
- name: Lint with flake8
run: |
flake8 .
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.10
uses: actions/setup-python@v2
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-extra.txt
- name: Check format with black
run: |
black .
test: test:
needs: [lint, format] needs: [pre-commit]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-latest, windows-latest]
python-version: [3.7, 3.8, 3.9, "3.10"] python-version: [3.7, 3.8, 3.9, "3.10", "3.11"]
exclude: exclude:
- os: macos-latest - os: macos-latest
python-version: 3.7 python-version: 3.7
@@ -53,17 +32,21 @@ jobs:
python-version: 3.8 python-version: 3.8
- os: macos-latest - os: macos-latest
python-version: 3.9 python-version: 3.9
- os: macos-latest
python-version: "3.10"
- os: windows-latest - os: windows-latest
python-version: 3.7 python-version: 3.7
- os: windows-latest - os: windows-latest
python-version: 3.8 python-version: 3.8
- os: windows-latest - os: windows-latest
python-version: 3.9 python-version: 3.9
- os: windows-latest
python-version: "3.10"
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies

4
.gitignore vendored
View File

@@ -87,8 +87,8 @@ cython_debug/
# Visual Studio Code # Visual Studio Code
.vscode/* .vscode/*
!.vscode/settings.json !.vscode/settings.json
#!.vscode/tasks.json !.vscode/tasks.json
#!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/*.code-snippets !.vscode/*.code-snippets

24
.pre-commit-config.yaml Normal file
View File

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

View File

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

View File

@@ -18,4 +18,3 @@ file_filter = locale/<lang>/LC_MESSAGES/ui.po
source_file = locale/ui.pot source_file = locale/ui.pot
source_lang = en source_lang = en
type = PO type = PO

View File

@@ -5,6 +5,7 @@
"ms-python.vscode-pylance", "ms-python.vscode-pylance",
"ms-python.python" "ms-python.python"
], ],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace. // List of extensions recommended by VS Code that should not be recommended for
// users of this workspace.
"unwantedRecommendations": [] "unwantedRecommendations": []
} }

17
.vscode/launch.json vendored Normal file
View File

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

View File

@@ -4,9 +4,10 @@
"Dupras", "Dupras",
"hscommon" "hscommon"
], ],
"editor.rulers": [
88,
120
],
"python.languageServer": "Pylance", "python.languageServer": "Pylance",
"yaml.schemaStore.enable": true, "yaml.schemaStore.enable": true,
"yaml.schemas": {
"https://json.schemastore.org/github-workflow.json": ".github/workflows/*.yml"
}
} }

View File

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

View File

@@ -60,8 +60,8 @@ ifndef NO_VENV
@${PYTHON} -m venv -h > /dev/null || \ @${PYTHON} -m venv -h > /dev/null || \
echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv." echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv."
endif endif
@${PYTHON} -c 'import PyQt6' >/dev/null 2>&1 || \ @${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \
{ echo "PyQt 6.3+ required. Install it and try again. Aborting"; exit 1; } { echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
env: | reqs env: | reqs
ifndef NO_VENV ifndef NO_VENV

View File

@@ -32,15 +32,18 @@ For macos instructions (qt version) see the [macOS Instructions](macos.md).
### Prerequisites ### Prerequisites
* [Python 3.7+][python] * [Python 3.7+][python]
* PyQt6 * PyQt5
### System Setup ### System Setup
When running in a linux based environment the following system packages or equivalents are needed to build: When running in a linux based environment the following system packages or equivalents are needed to build:
* python3-pyqt6 * python3-pyqt5
* pyqt5-dev-tools (on some systems, see note)
* python3-venv (only if using a virtual environment) * python3-venv (only if using a virtual environment)
* python3-dev * python3-dev
* build-essential * 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: To create packages the following are also needed:
* python3-setuptools * python3-setuptools
* debhelper * debhelper

View File

@@ -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 ### 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: 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 1. Install msys2 or other POSIX environment
2. Install PyQt6 globally via pip 2. Install PyQt5 globally via pip
3. Use the respective console for msys2 it is `msys2 msys` 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. 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.

17
commitlint.config.js Normal file
View File

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

View File

@@ -126,8 +126,6 @@ class DupeGuru(Broadcaster):
NAME = PROMPT_NAME = "dupeGuru" NAME = PROMPT_NAME = "dupeGuru"
PICTURE_CACHE_TYPE = "sqlite" # set to 'shelve' for a ShelveCache
def __init__(self, view, portable=False): def __init__(self, view, portable=False):
if view.get_default(DEBUG_MODE_PREFERENCE): if view.get_default(DEBUG_MODE_PREFERENCE):
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
@@ -153,7 +151,8 @@ class DupeGuru(Broadcaster):
"clean_empty_dirs": False, "clean_empty_dirs": False,
"ignore_hardlink_matches": False, "ignore_hardlink_matches": False,
"copymove_dest_type": DestType.RELATIVE, "copymove_dest_type": DestType.RELATIVE,
"picture_cache_type": self.PICTURE_CACHE_TYPE, "include_exists_check": True,
"rehash_ignore_mtime": False,
} }
self.selected_dupes = [] self.selected_dupes = []
self.details_panel = DetailsPanel(self) self.details_panel = DetailsPanel(self)
@@ -183,8 +182,7 @@ class DupeGuru(Broadcaster):
self.view.create_results_window() self.view.create_results_window()
def _get_picture_cache_path(self): def _get_picture_cache_path(self):
cache_type = self.options["picture_cache_type"] cache_name = "cached_pictures.db"
cache_name = "cached_pictures.shelve" if cache_type == "shelve" else "cached_pictures.db"
return op.join(self.appdata, cache_name) return op.join(self.appdata, cache_name)
def _get_dupe_sort_key(self, dupe, get_group, key, delta): def _get_dupe_sort_key(self, dupe, get_group, key, delta):
@@ -794,6 +792,7 @@ class DupeGuru(Broadcaster):
Scans folders selected in :attr:`directories` and put the results in :attr:`results` Scans folders selected in :attr:`directories` and put the results in :attr:`results`
""" """
scanner = self.SCANNER_CLASS() scanner = self.SCANNER_CLASS()
fs.filesdb.ignore_mtime = self.options["rehash_ignore_mtime"] is True
if not self.directories.has_any_file(): if not self.directories.has_any_file():
self.view.show_message(tr("The selected directories contain no scannable file.")) self.view.show_message(tr("The selected directories contain no scannable file."))
return return

View File

@@ -84,10 +84,11 @@ class Directories:
for denied_path_re in self._exclude_list.compiled: for denied_path_re in self._exclude_list.compiled:
if denied_path_re.match(str(path.name)): if denied_path_re.match(str(path.name)):
return DirectoryState.EXCLUDED return DirectoryState.EXCLUDED
# return # We still use the old logic to force state on hidden dirs return DirectoryState.NORMAL
# Override this in subclasses to specify the state of some special folders. # Override this in subclasses to specify the state of some special folders.
if path.name.startswith("."): if path.name.startswith("."):
return DirectoryState.EXCLUDED return DirectoryState.EXCLUDED
return DirectoryState.NORMAL
def _get_files(self, from_path, fileclasses, j): def _get_files(self, from_path, fileclasses, j):
try: try:
@@ -214,7 +215,7 @@ class Directories:
# direct match? easy result. # direct match? easy result.
if path in self.states: if path in self.states:
return self.states[path] return self.states[path]
state = self._default_state_for_path(path) or DirectoryState.NORMAL state = self._default_state_for_path(path)
# Save non-default states in cache, necessary for _get_files() # Save non-default states in cache, necessary for _get_files()
if state != DirectoryState.NORMAL: if state != DirectoryState.NORMAL:
self.states[path] = state self.states[path] = state

View File

@@ -97,59 +97,68 @@ class FilesDB:
schema_version = 1 schema_version = 1
schema_version_description = "Changed from md5 to xxhash if available." schema_version_description = "Changed from md5 to xxhash if available."
create_table_query = "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER, entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)" create_table_query = """CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER,
entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)"""
drop_table_query = "DROP TABLE IF EXISTS files;" drop_table_query = "DROP TABLE IF EXISTS files;"
select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns" select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns"
select_query_ignore_mtime = "SELECT {key} FROM files WHERE path=:path AND size=:size"
insert_query = """ insert_query = """
INSERT INTO files (path, size, mtime_ns, entry_dt, {key}) VALUES (:path, :size, :mtime_ns, datetime('now'), :value) INSERT INTO files (path, size, mtime_ns, entry_dt, {key})
VALUES (:path, :size, :mtime_ns, datetime('now'), :value)
ON CONFLICT(path) DO UPDATE SET size=:size, mtime_ns=:mtime_ns, entry_dt=datetime('now'), {key}=:value; ON CONFLICT(path) DO UPDATE SET size=:size, mtime_ns=:mtime_ns, entry_dt=datetime('now'), {key}=:value;
""" """
ignore_mtime = False
def __init__(self): def __init__(self):
self.conn = None self.conn = None
self.cur = None
self.lock = None self.lock = None
def connect(self, path: Union[AnyStr, os.PathLike]) -> None: def connect(self, path: Union[AnyStr, os.PathLike]) -> None:
self.conn = sqlite3.connect(path, check_same_thread=False) self.conn = sqlite3.connect(path, check_same_thread=False)
self.cur = self.conn.cursor()
self.lock = Lock() self.lock = Lock()
self._check_upgrade() self._check_upgrade()
def _check_upgrade(self) -> None: def _check_upgrade(self) -> None:
with self.lock: with self.lock, self.conn as conn:
has_schema = self.cur.execute( has_schema = conn.execute(
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'" "SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchall() ).fetchall()
version = None version = None
if has_schema: if has_schema:
version = self.cur.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0] version = conn.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0]
else: else:
self.cur.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)") conn.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)")
if version != self.schema_version: if version != self.schema_version:
self.cur.execute(self.drop_table_query) conn.execute(self.drop_table_query)
self.cur.execute( conn.execute(
"INSERT OR REPLACE INTO schema_version VALUES (:version, :description)", "INSERT OR REPLACE INTO schema_version VALUES (:version, :description)",
{"version": self.schema_version, "description": self.schema_version_description}, {"version": self.schema_version, "description": self.schema_version_description},
) )
self.cur.execute(self.create_table_query) conn.execute(self.create_table_query)
self.conn.commit()
def clear(self) -> None: def clear(self) -> None:
with self.lock: with self.lock, self.conn as conn:
self.cur.execute(self.drop_table_query) conn.execute(self.drop_table_query)
self.cur.execute(self.create_table_query) conn.execute(self.create_table_query)
def get(self, path: Path, key: str) -> Union[bytes, None]: def get(self, path: Path, key: str) -> Union[bytes, None]:
stat = path.stat() stat = path.stat()
size = stat.st_size size = stat.st_size
mtime_ns = stat.st_mtime_ns mtime_ns = stat.st_mtime_ns
try: try:
with self.lock: with self.conn as conn:
self.cur.execute( if self.ignore_mtime:
self.select_query.format(key=key), {"path": str(path), "size": size, "mtime_ns": mtime_ns} cursor = conn.execute(
) self.select_query_ignore_mtime.format(key=key), {"path": str(path), "size": size}
result = self.cur.fetchone() )
else:
cursor = conn.execute(
self.select_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns},
)
result = cursor.fetchone()
cursor.close()
if result: if result:
return result[0] return result[0]
@@ -163,8 +172,8 @@ class FilesDB:
size = stat.st_size size = stat.st_size
mtime_ns = stat.st_mtime_ns mtime_ns = stat.st_mtime_ns
try: try:
with self.lock: with self.lock, self.conn as conn:
self.cur.execute( conn.execute(
self.insert_query.format(key=key), self.insert_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value}, {"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
) )
@@ -177,7 +186,6 @@ class FilesDB:
def close(self) -> None: def close(self) -> None:
with self.lock: with self.lock:
self.cur.close()
self.conn.close() self.conn.close()
@@ -307,6 +315,14 @@ class File:
"""Returns whether this file wrapper class can handle ``path``.""" """Returns whether this file wrapper class can handle ``path``."""
return not path.is_symlink() and path.is_file() return not path.is_symlink() and path.is_file()
def exists(self) -> bool:
"""Safely check if the underlying file exists, treat error as non-existent"""
try:
return self.path.exists()
except OSError as ex:
logging.warning(f"Checking {self.path} raised: {ex}")
return False
def rename(self, newname): def rename(self, newname):
if newname == self.name: if newname == self.name:
return return

View File

@@ -4,24 +4,13 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from core.pe._cache import string_to_colors # noqa from core.pe._cache import bytes_to_colors # noqa
def colors_to_string(colors): def colors_to_bytes(colors):
"""Transform the 3 sized tuples 'colors' into a hex string. """Transform the 3 sized tuples 'colors' into a bytes string.
[(0,100,255)] --> 0064ff [(0,100,255)] --> b'\x00d\xff'
[(1,2,3),(4,5,6)] --> 010203040506 [(1,2,3),(4,5,6)] --> b'\x01\x02\x03\x04\x05\x06'
""" """
return "".join("{:02x}{:02x}{:02x}".format(r, g, b) for r, g, b in colors) return b"".join(map(bytes, colors))
# This function is an important bottleneck of dupeGuru PE. It has been converted to C.
# def string_to_colors(s):
# """Transform the string 's' in a list of 3 sized tuples.
# """
# result = []
# for i in xrange(0, len(s), 6):
# number = int(s[i:i+6], 16)
# result.append((number >> 16, (number >> 8) & 0xff, number & 0xff))
# return result

View File

@@ -2,5 +2,5 @@ from typing import Union, Tuple, List
_block = Tuple[int, int, int] _block = Tuple[int, int, int]
def colors_to_string(colors: List[_block]) -> str: ... # noqa: E302 def colors_to_bytes(colors: List[_block]) -> bytes: ... # noqa: E302
def string_to_colors(s: str) -> Union[List[_block], None]: ... def bytes_to_colors(s: bytes) -> Union[List[_block], None]: ...

View File

@@ -1,141 +0,0 @@
# Copyright 2016 Virgil Dupras
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import os
import os.path as op
import shelve
import tempfile
from collections import namedtuple
from core.pe.cache import string_to_colors, colors_to_string
def wrap_path(path):
return f"path:{path}"
def unwrap_path(key):
return key[5:]
def wrap_id(path):
return f"id:{path}"
def unwrap_id(key):
return int(key[3:])
CacheRow = namedtuple("CacheRow", "id path blocks mtime")
class ShelveCache:
"""A class to cache picture blocks in a shelve backend."""
def __init__(self, db=None, readonly=False):
self.istmp = db is None
if self.istmp:
self.dtmp = tempfile.mkdtemp()
self.ftmp = db = op.join(self.dtmp, "tmpdb")
flag = "r" if readonly else "c"
self.shelve = shelve.open(db, flag)
self.maxid = self._compute_maxid()
def __contains__(self, key):
return wrap_path(key) in self.shelve
def __delitem__(self, key):
row = self.shelve[wrap_path(key)]
del self.shelve[wrap_path(key)]
del self.shelve[wrap_id(row.id)]
def __getitem__(self, key):
if isinstance(key, int):
skey = self.shelve[wrap_id(key)]
else:
skey = wrap_path(key)
return string_to_colors(self.shelve[skey].blocks)
def __iter__(self):
return (unwrap_path(k) for k in self.shelve if k.startswith("path:"))
def __len__(self):
return sum(1 for k in self.shelve if k.startswith("path:"))
def __setitem__(self, path_str, blocks):
blocks = colors_to_string(blocks)
if op.exists(path_str):
mtime = int(os.stat(path_str).st_mtime)
else:
mtime = 0
if path_str in self:
rowid = self.shelve[wrap_path(path_str)].id
else:
rowid = self._get_new_id()
row = CacheRow(rowid, path_str, blocks, mtime)
self.shelve[wrap_path(path_str)] = row
self.shelve[wrap_id(rowid)] = wrap_path(path_str)
def _compute_maxid(self):
return max((unwrap_id(k) for k in self.shelve if k.startswith("id:")), default=1)
def _get_new_id(self):
self.maxid += 1
return self.maxid
def clear(self):
self.shelve.clear()
def close(self):
if self.shelve is not None:
self.shelve.close()
if self.istmp:
os.remove(self.ftmp)
os.rmdir(self.dtmp)
self.shelve = None
def filter(self, func):
to_delete = [key for key in self if not func(key)]
for key in to_delete:
del self[key]
def get_id(self, path):
if path in self:
return self.shelve[wrap_path(path)].id
else:
raise ValueError(path)
def get_multiple(self, rowids):
for rowid in rowids:
try:
skey = self.shelve[wrap_id(rowid)]
except KeyError:
continue
yield (rowid, string_to_colors(self.shelve[skey].blocks))
def purge_outdated(self):
"""Go through the cache and purge outdated records.
A record is outdated if the picture doesn't exist or if its mtime is greater than the one in
the db.
"""
todelete = []
for path in self:
row = self.shelve[wrap_path(path)]
if row.mtime and op.exists(path):
picture_mtime = os.stat(path).st_mtime
if int(picture_mtime) <= row.mtime:
# not outdated
continue
todelete.append(path)
for path in todelete:
try:
del self[path]
except KeyError:
# I have no idea why a KeyError sometimes happen, but it does, as we can see in
# #402 and #439. I don't think it hurts to silently ignore the error, so that's
# what we do
pass

View File

@@ -9,12 +9,20 @@ import os.path as op
import logging import logging
import sqlite3 as sqlite import sqlite3 as sqlite
from core.pe.cache import string_to_colors, colors_to_string from core.pe.cache import bytes_to_colors, colors_to_bytes
class SqliteCache: class SqliteCache:
"""A class to cache picture blocks in a sqlite backend.""" """A class to cache picture blocks in a sqlite backend."""
schema_version = 1
schema_version_description = "Changed from string to bytes for blocks."
create_table_query = "CREATE TABLE IF NOT EXISTS pictures(path TEXT, mtime_ns INTEGER, blocks BLOB)"
create_index_query = "CREATE INDEX IF NOT EXISTS idx_path on pictures (path)"
drop_table_query = "DROP TABLE IF EXISTS pictures"
drop_index_query = "DROP INDEX IF EXISTS idx_path"
def __init__(self, db=":memory:", readonly=False): def __init__(self, db=":memory:", readonly=False):
# readonly is not used in the sqlite version of the cache # readonly is not used in the sqlite version of the cache
self.dbname = db self.dbname = db
@@ -40,7 +48,7 @@ class SqliteCache:
sql = "select blocks from pictures where path = ?" sql = "select blocks from pictures where path = ?"
result = self.con.execute(sql, [key]).fetchone() result = self.con.execute(sql, [key]).fetchone()
if result: if result:
result = string_to_colors(result[0]) result = bytes_to_colors(result[0])
return result return result
else: else:
raise KeyError(key) raise KeyError(key)
@@ -56,15 +64,15 @@ class SqliteCache:
return result[0][0] return result[0][0]
def __setitem__(self, path_str, blocks): def __setitem__(self, path_str, blocks):
blocks = colors_to_string(blocks) blocks = colors_to_bytes(blocks)
if op.exists(path_str): if op.exists(path_str):
mtime = int(os.stat(path_str).st_mtime) mtime = int(os.stat(path_str).st_mtime)
else: else:
mtime = 0 mtime = 0
if path_str in self: if path_str in self:
sql = "update pictures set blocks = ?, mtime = ? where path = ?" sql = "update pictures set blocks = ?, mtime_ns = ? where path = ?"
else: else:
sql = "insert into pictures(blocks,mtime,path) values(?,?,?)" sql = "insert into pictures(blocks,mtime_ns,path) values(?,?,?)"
try: try:
self.con.execute(sql, [blocks, mtime, path_str]) self.con.execute(sql, [blocks, mtime, path_str])
except sqlite.OperationalError: except sqlite.OperationalError:
@@ -73,18 +81,9 @@ class SqliteCache:
logging.warning("DatabaseError while setting value for key %r: %s", path_str, str(e)) logging.warning("DatabaseError while setting value for key %r: %s", path_str, str(e))
def _create_con(self, second_try=False): def _create_con(self, second_try=False):
def create_tables():
logging.debug("Creating picture cache tables.")
self.con.execute("drop table if exists pictures")
self.con.execute("drop index if exists idx_path")
self.con.execute("create table pictures(path TEXT, mtime INTEGER, blocks TEXT)")
self.con.execute("create index idx_path on pictures (path)")
self.con = sqlite.connect(self.dbname, isolation_level=None)
try: try:
self.con.execute("select path, mtime, blocks from pictures where 1=2") self.con = sqlite.connect(self.dbname, isolation_level=None)
except sqlite.OperationalError: # new db self._check_upgrade()
create_tables()
except sqlite.DatabaseError as e: # corrupted db except sqlite.DatabaseError as e: # corrupted db
if second_try: if second_try:
raise # Something really strange is happening raise # Something really strange is happening
@@ -93,6 +92,25 @@ class SqliteCache:
os.remove(self.dbname) os.remove(self.dbname)
self._create_con(second_try=True) self._create_con(second_try=True)
def _check_upgrade(self) -> None:
with self.con as conn:
has_schema = conn.execute(
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchall()
version = None
if has_schema:
version = conn.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0]
else:
conn.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)")
if version != self.schema_version:
conn.execute(self.drop_table_query)
conn.execute(
"INSERT OR REPLACE INTO schema_version VALUES (:version, :description)",
{"version": self.schema_version, "description": self.schema_version_description},
)
conn.execute(self.create_table_query)
conn.execute(self.create_index_query)
def clear(self): def clear(self):
self.close() self.close()
if self.dbname != ":memory:": if self.dbname != ":memory:":
@@ -120,7 +138,7 @@ class SqliteCache:
def get_multiple(self, rowids): def get_multiple(self, rowids):
sql = "select rowid, blocks from pictures where rowid in (%s)" % ",".join(map(str, rowids)) sql = "select rowid, blocks from pictures where rowid in (%s)" % ",".join(map(str, rowids))
cur = self.con.execute(sql) cur = self.con.execute(sql)
return ((rowid, string_to_colors(blocks)) for rowid, blocks in cur) return ((rowid, bytes_to_colors(blocks)) for rowid, blocks in cur)
def purge_outdated(self): def purge_outdated(self):
"""Go through the cache and purge outdated records. """Go through the cache and purge outdated records.
@@ -129,12 +147,12 @@ class SqliteCache:
the db. the db.
""" """
todelete = [] todelete = []
sql = "select rowid, path, mtime from pictures" sql = "select rowid, path, mtime_ns from pictures"
cur = self.con.execute(sql) cur = self.con.execute(sql)
for rowid, path_str, mtime in cur: for rowid, path_str, mtime_ns in cur:
if mtime and op.exists(path_str): if mtime_ns and op.exists(path_str):
picture_mtime = os.stat(path_str).st_mtime picture_mtime = os.stat(path_str).st_mtime
if int(picture_mtime) <= mtime: if int(picture_mtime) <= mtime_ns:
# not outdated # not outdated
continue continue
todelete.append(rowid) todelete.append(rowid)

View File

@@ -16,6 +16,7 @@ from hscommon.jobprogress import job
from core.engine import Match from core.engine import Match
from core.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError from core.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError
from core.pe.cache_sqlite import SqliteCache
# OPTIMIZATION NOTES: # OPTIMIZATION NOTES:
# The bottleneck of the matching phase is CPU, which is why we use multiprocessing. However, another # The bottleneck of the matching phase is CPU, which is why we use multiprocessing. However, another
@@ -27,7 +28,7 @@ from core.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError
# to files in other chunks. So chunkifying doesn't save us any actual comparison, but the advantage # to files in other chunks. So chunkifying doesn't save us any actual comparison, but the advantage
# is that instead of reading blocks from disk number_of_files**2 times, we read it # is that instead of reading blocks from disk number_of_files**2 times, we read it
# number_of_files*number_of_chunks times. # number_of_files*number_of_chunks times.
# Determining the right chunk size is tricky, bceause if it's too big, too many blocks will be in # Determining the right chunk size is tricky, because if it's too big, too many blocks will be in
# memory at the same time and we might end up with memory trashing, which is awfully slow. So, # memory at the same time and we might end up with memory trashing, which is awfully slow. So,
# because our *real* bottleneck is CPU, the chunk size must simply be enough so that the CPU isn't # because our *real* bottleneck is CPU, the chunk size must simply be enough so that the CPU isn't
# starved by Disk IOs. # starved by Disk IOs.
@@ -50,14 +51,7 @@ except Exception:
def get_cache(cache_path, readonly=False): def get_cache(cache_path, readonly=False):
if cache_path.endswith("shelve"): return SqliteCache(cache_path, readonly=readonly)
from core.pe.cache_shelve import ShelveCache
return ShelveCache(cache_path, readonly=readonly)
else:
from core.pe.cache_sqlite import SqliteCache
return SqliteCache(cache_path, readonly=readonly)
def prepare_pictures(pictures, cache_path, with_dimensions, j=job.nulljob): def prepare_pictures(pictures, cache_path, with_dimensions, j=job.nulljob):

View File

@@ -2,94 +2,68 @@
* Created On: 2010-01-30 * Created On: 2010-01-30
* Copyright 2014 Hardcoded Software (http://www.hardcoded.net) * Copyright 2014 Hardcoded Software (http://www.hardcoded.net)
* *
* This software is licensed under the "BSD" License as described in the "LICENSE" file, * This software is licensed under the "BSD" License as described in the
* which should be included with this package. The terms are also available at * "LICENSE" file, which should be included with this package. The terms are
* http://www.hardcoded.net/licenses/bsd_license * also available at http://www.hardcoded.net/licenses/bsd_license
*/ */
#include "common.h" #include "common.h"
/* I know that there strtol out there, but it requires a pointer to static PyObject *cache_bytes_to_colors(PyObject *self, PyObject *args) {
* a char, which would in turn require me to buffer my chars around, char *y;
* making the whole process slower. Py_ssize_t char_count, i, color_count;
*/ PyObject *result;
static long unsigned long r, g, b;
xchar_to_long(char c) Py_ssize_t ci;
{ PyObject *color_tuple;
if ((c >= 48) && (c <= 57)) { /* 0-9 */
return c - 48; if (!PyArg_ParseTuple(args, "y#", &y, &char_count)) {
return NULL;
}
color_count = char_count / 3;
result = PyList_New(color_count);
if (result == NULL) {
return NULL;
}
for (i = 0; i < color_count; i++) {
ci = i * 3;
r = (unsigned char)y[ci];
g = (unsigned char)y[ci + 1];
b = (unsigned char)y[ci + 2];
color_tuple = inttuple(3, r, g, b);
if (color_tuple == NULL) {
Py_DECREF(result);
return NULL;
} }
else if ((c >= 65) && (c <= 70)) { /* A-F */ PyList_SET_ITEM(result, i, color_tuple);
return c - 55; }
}
else if ((c >= 97) && (c <= 102)) { /* a-f */
return c - 87;
}
return 0;
}
static PyObject* return result;
cache_string_to_colors(PyObject *self, PyObject *args)
{
char *s;
Py_ssize_t char_count, color_count, i;
PyObject *result;
if (!PyArg_ParseTuple(args, "s#", &s, &char_count)) {
return NULL;
}
color_count = (char_count / 6);
result = PyList_New(color_count);
if (result == NULL) {
return NULL;
}
for (i=0; i<color_count; i++) {
long r, g, b;
Py_ssize_t ci;
PyObject *color_tuple;
ci = i * 6;
r = (xchar_to_long(s[ci]) << 4) + xchar_to_long(s[ci+1]);
g = (xchar_to_long(s[ci+2]) << 4) + xchar_to_long(s[ci+3]);
b = (xchar_to_long(s[ci+4]) << 4) + xchar_to_long(s[ci+5]);
color_tuple = inttuple(3, r, g, b);
if (color_tuple == NULL) {
Py_DECREF(result);
return NULL;
}
PyList_SET_ITEM(result, i, color_tuple);
}
return result;
} }
static PyMethodDef CacheMethods[] = { static PyMethodDef CacheMethods[] = {
{"string_to_colors", cache_string_to_colors, METH_VARARGS, {"bytes_to_colors", cache_bytes_to_colors, METH_VARARGS,
"Transform the string 's' in a list of 3 sized tuples."}, "Transform the bytes 's' into a list of 3 sized tuples."},
{NULL, NULL, 0, NULL} /* Sentinel */ {NULL, NULL, 0, NULL} /* Sentinel */
}; };
static struct PyModuleDef CacheDef = { static struct PyModuleDef CacheDef = {PyModuleDef_HEAD_INIT,
PyModuleDef_HEAD_INIT, "_cache",
"_cache", NULL,
NULL, -1,
-1, CacheMethods,
CacheMethods, NULL,
NULL, NULL,
NULL, NULL,
NULL, NULL};
NULL
};
PyObject * PyObject *PyInit__cache(void) {
PyInit__cache(void) PyObject *m = PyModule_Create(&CacheDef);
{ if (m == NULL) {
PyObject *m = PyModule_Create(&CacheDef); return NULL;
if (m == NULL) { }
return NULL; return m;
}
return m;
} }

View File

@@ -32,7 +32,7 @@ PyObject* inttuple(int n, ...)
result = PyTuple_New(n); result = PyTuple_New(n);
for (i=0; i<n; i++) { for (i=0; i<n; i++) {
pnumber = PyLong_FromLong(va_arg(numbers, long)); pnumber = PyLong_FromUnsignedLong(va_arg(numbers, long));
if (pnumber == NULL) { if (pnumber == NULL) {
Py_DECREF(result); Py_DECREF(result);
return NULL; return NULL;

View File

@@ -29,7 +29,7 @@ class Photo(fs.File):
__slots__ = fs.File.__slots__ + tuple(INITIAL_INFO.keys()) __slots__ = fs.File.__slots__ + tuple(INITIAL_INFO.keys())
# These extensions are supported on all platforms # These extensions are supported on all platforms
HANDLED_EXTS = {"png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif"} HANDLED_EXTS = {"png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp"}
def _plat_get_dimensions(self): def _plat_get_dimensions(self):
raise NotImplementedError() raise NotImplementedError()

View File

@@ -171,7 +171,8 @@ class Scanner:
matches = [m for m in matches if m.first.path not in toremove or m.second.path not in toremove] matches = [m for m in matches if m.first.path not in toremove or m.second.path not in toremove]
if not self.mix_file_kind: if not self.mix_file_kind:
matches = [m for m in matches if get_file_ext(m.first.name) == get_file_ext(m.second.name)] matches = [m for m in matches if get_file_ext(m.first.name) == get_file_ext(m.second.name)]
matches = [m for m in matches if m.first.path.exists() and m.second.path.exists()] if self.include_exists_check:
matches = [m for m in matches if m.first.exists() and m.second.exists()]
matches = [m for m in matches if not (m.first.is_ref and m.second.is_ref)] matches = [m for m in matches if not (m.first.is_ref and m.second.is_ref)]
if ignore_list: if ignore_list:
matches = [m for m in matches if not ignore_list.are_ignored(str(m.first.path), str(m.second.path))] matches = [m for m in matches if not ignore_list.are_ignored(str(m.first.path), str(m.second.path))]
@@ -212,3 +213,4 @@ class Scanner:
large_size_threshold = 0 large_size_threshold = 0
big_file_size_threshold = 0 big_file_size_threshold = 0
word_weighting = False word_weighting = False
include_exists_check = True

View File

@@ -10,41 +10,41 @@ from pytest import raises, skip
from hscommon.testutil import eq_ from hscommon.testutil import eq_
try: try:
from core.pe.cache import colors_to_string, string_to_colors from core.pe.cache import colors_to_bytes, bytes_to_colors
from core.pe.cache_sqlite import SqliteCache from core.pe.cache_sqlite import SqliteCache
from core.pe.cache_shelve import ShelveCache
except ImportError: except ImportError:
skip("Can't import the cache module, probably hasn't been compiled.") skip("Can't import the cache module, probably hasn't been compiled.")
class TestCaseColorsToString: class TestCaseColorsToString:
def test_no_color(self): def test_no_color(self):
eq_("", colors_to_string([])) eq_(b"", colors_to_bytes([]))
def test_single_color(self): def test_single_color(self):
eq_("000000", colors_to_string([(0, 0, 0)])) eq_(b"\x00\x00\x00", colors_to_bytes([(0, 0, 0)]))
eq_("010101", colors_to_string([(1, 1, 1)])) eq_(b"\x01\x01\x01", colors_to_bytes([(1, 1, 1)]))
eq_("0a141e", colors_to_string([(10, 20, 30)])) eq_(b"\x0a\x14\x1e", colors_to_bytes([(10, 20, 30)]))
def test_two_colors(self): def test_two_colors(self):
eq_("000102030405", colors_to_string([(0, 1, 2), (3, 4, 5)])) eq_(b"\x00\x01\x02\x03\x04\x05", colors_to_bytes([(0, 1, 2), (3, 4, 5)]))
class TestCaseStringToColors: class TestCaseStringToColors:
def test_empty(self): def test_empty(self):
eq_([], string_to_colors("")) eq_([], bytes_to_colors(b""))
def test_single_color(self): def test_single_color(self):
eq_([(0, 0, 0)], string_to_colors("000000")) eq_([(0, 0, 0)], bytes_to_colors(b"\x00\x00\x00"))
eq_([(2, 3, 4)], string_to_colors("020304")) eq_([(2, 3, 4)], bytes_to_colors(b"\x02\x03\x04"))
eq_([(10, 20, 30)], string_to_colors("0a141e")) eq_([(10, 20, 30)], bytes_to_colors(b"\x0a\x14\x1e"))
def test_two_colors(self): def test_two_colors(self):
eq_([(10, 20, 30), (40, 50, 60)], string_to_colors("0a141e28323c")) eq_([(10, 20, 30), (40, 50, 60)], bytes_to_colors(b"\x0a\x14\x1e\x28\x32\x3c"))
def test_incomplete_color(self): def test_incomplete_color(self):
# don't return anything if it's not a complete color # don't return anything if it's not a complete color
eq_([], string_to_colors("102")) eq_([], bytes_to_colors(b"\x01"))
eq_([(1, 2, 3)], bytes_to_colors(b"\x01\x02\x03\x04"))
class BaseTestCaseCache: class BaseTestCaseCache:
@@ -133,11 +133,6 @@ class TestCaseSqliteCache(BaseTestCaseCache):
eq_(c["foo"], [(1, 2, 3)]) eq_(c["foo"], [(1, 2, 3)])
class TestCaseShelveCache(BaseTestCaseCache):
def get_cache(self, dbname=None):
return ShelveCache(dbname)
class TestCaseCacheSQLEscape: class TestCaseCacheSQLEscape:
def get_cache(self): def get_cache(self):
return SqliteCache() return SqliteCache()

View File

@@ -326,6 +326,7 @@ def test_default_path_state_override(tmpdir):
def _default_state_for_path(self, path): def _default_state_for_path(self, path):
if "foobar" in path.parts: if "foobar" in path.parts:
return DirectoryState.EXCLUDED return DirectoryState.EXCLUDED
return DirectoryState.NORMAL
d = MyDirectories() d = MyDirectories()
p1 = Path(str(tmpdir)) p1 = Path(str(tmpdir))

View File

@@ -17,6 +17,7 @@ from core.scanner import Scanner, ScanType
from core.me.scanner import ScannerME from core.me.scanner import ScannerME
# TODO update this to be able to inherit from fs.File
class NamedObject: class NamedObject:
def __init__(self, name="foobar", size=1, path=None): def __init__(self, name="foobar", size=1, path=None):
if path is None: if path is None:
@@ -31,6 +32,9 @@ class NamedObject:
def __repr__(self): def __repr__(self):
return "<NamedObject {!r} {!r}>".format(self.name, self.path) return "<NamedObject {!r} {!r}>".format(self.name, self.path)
def exists(self):
return self.path.exists()
no = NamedObject no = NamedObject

View File

@@ -15,4 +15,3 @@ hscommon.gui.progress_window
.. autoclass:: ProgressWindowView .. autoclass:: ProgressWindowView
:members: :members:
:private-members: :private-members:

View File

@@ -15,4 +15,3 @@ hscommon.gui.tree
.. autoclass:: Node .. autoclass:: Node
:members: :members:
:private-members: :private-members:

View File

@@ -13,4 +13,3 @@ hscommon
util util
jobprogress/* jobprogress/*
gui/* gui/*

View File

@@ -14,4 +14,3 @@ hscommon.jobprogress.job
.. autoclass:: NullJob .. autoclass:: NullJob
:members: :members:

View File

@@ -9,4 +9,3 @@ hscommon.jobprogress.performer
.. autoclass:: ThreadedJobPerformer .. autoclass:: ThreadedJobPerformer
:members: :members:

View File

@@ -178,4 +178,3 @@ Preferences are stored elsewhere:
.. _Github: https://github.com/arsenetar/dupeguru .. _Github: https://github.com/arsenetar/dupeguru
.. _open an issue: https://github.com/arsenetar/dupeguru/wiki/issue-labels .. _open an issue: https://github.com/arsenetar/dupeguru/wiki/issue-labels

View File

@@ -12,4 +12,3 @@
* Եթե համոզված եք, որ կրկնօրինակը արդյունքներում կա, ապա սեղմեք **Խմբագրել-->Նշել բոլորը**, և ապա **Գործողություններ-->Ուղարկել Նշվածը Աղբարկղ**: * Եթե համոզված եք, որ կրկնօրինակը արդյունքներում կա, ապա սեղմեք **Խմբագրել-->Նշել բոլորը**, և ապա **Գործողություններ-->Ուղարկել Նշվածը Աղբարկղ**:
Սա միայն բազային ստուգում է: Կան բազմաթիվ կարգավորումներ, որոնք հնարավորություն են տալիս նշելու տարբեր արդյունքներ և մի քանի եղանակներ արդյունքների փոփոխման: Մանրամասների համար կարդացեք Օգնության ֆայլը: Սա միայն բազային ստուգում է: Կան բազմաթիվ կարգավորումներ, որոնք հնարավորություն են տալիս նշելու տարբեր արդյունքներ և մի քանի եղանակներ արդյունքների փոփոխման: Մանրամասների համար կարդացեք Օգնության ֆայլը:

View File

@@ -23,4 +23,3 @@ dupeGuru-ը փորձում է որոշել, թե որ կրկնօրինակներ
մեծագույն ֆայլը և եթե երկու կամ ավելի ֆայլեր ունեն նույն չափը, ապա մեկը ունի ֆայլի անուն, որը մեծագույն ֆայլը և եթե երկու կամ ավելի ֆայլեր ունեն նույն չափը, ապա մեկը ունի ֆայլի անուն, որը
չի ավարտվում թվով, կօգտագործվի: Երբ փաստարկի արդյունքը կապված է, կարգը, որի սխալները չի ավարտվում թվով, կօգտագործվի: Երբ փաստարկի արդյունքը կապված է, կարգը, որի սխալները
նախկինում էին, խումբը պետք է օգտագործվի: նախկինում էին, խումբը պետք է օգտագործվի:

View File

@@ -114,4 +114,3 @@
Якщо все це не так, `контакт УГ підтримки <http://www.hardcoded.net/support>`_, ми зрозуміти це. Якщо все це не так, `контакт УГ підтримки <http://www.hardcoded.net/support>`_, ми зрозуміти це.
.. todo:: This FAQ qestion is outdated, see english version. .. todo:: This FAQ qestion is outdated, see english version.

View File

@@ -44,8 +44,8 @@ def special_folder_path(special_folder: SpecialFolder, portable: bool = False) -
try: try:
from PyQt6.QtCore import QUrl, QStandardPaths from PyQt5.QtCore import QUrl, QStandardPaths
from PyQt6.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from qt.util import get_appdata from qt.util import get_appdata
from core.util import executable_folder from core.util import executable_folder
from hscommon.plat import ISWINDOWS, ISOSX from hscommon.plat import ISWINDOWS, ISOSX
@@ -71,7 +71,7 @@ try:
if ISWINDOWS and portable: if ISWINDOWS and portable:
folder = op.join(executable_folder(), "cache") folder = op.join(executable_folder(), "cache")
else: else:
folder = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.CacheLocation)[0] folder = QStandardPaths.standardLocations(QStandardPaths.CacheLocation)[0]
else: else:
folder = get_appdata(portable) folder = get_appdata(portable)
return folder return folder

View File

@@ -41,7 +41,8 @@ def trget(domain: str) -> Callable[[str], str]:
def set_tr( def set_tr(
new_tr: Callable[[str, Union[str, None]], str], new_trget: Union[Callable[[str], Callable[[str], str]], None] = None new_tr: Callable[[str, Union[str, None]], str],
new_trget: Union[Callable[[str], Callable[[str], str]], None] = None,
) -> None: ) -> None:
global _trfunc, _trget global _trfunc, _trget
_trfunc = new_tr _trfunc = new_tr
@@ -82,7 +83,7 @@ def get_locale_name(lang: str) -> Union[str, None]:
# --- Qt # --- Qt
def install_qt_trans(lang: str = None) -> None: def install_qt_trans(lang: str = None) -> None:
from PyQt6.QtCore import QCoreApplication, QTranslator, QLocale from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale
if not lang: if not lang:
lang = str(QLocale.system().name())[:2] lang = str(QLocale.system().name())[:2]
@@ -139,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 # 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 # available so that strings that are inside Qt itself over which I have no control are in the
# right language. # right language.
from PyQt6.QtCore import QCoreApplication, QTranslator, QLocale, QLibraryInfo from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale, QLibraryInfo
if not lang: if not lang:
lang = str(QLocale.system().name())[:2] lang = str(QLocale.system().name())[:2]
@@ -155,7 +156,7 @@ def install_gettext_trans_under_qt(base_folder: os.PathLike, lang: str = None) -
if ISLINUX: if ISLINUX:
# Under linux, a full Qt installation is already available in the system, we didn't bundle # 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. # up the qm files in our package, so we have to load translations from the system.
qmpath = op.join(QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath), qmname) qmpath = op.join(QLibraryInfo.location(QLibraryInfo.TranslationsPath), qmname)
else: else:
qmpath = op.join(base_folder, qmname) qmpath = op.join(base_folder, qmname)
qtr = QTranslator(QCoreApplication.instance()) qtr = QTranslator(QCoreApplication.instance())

View File

@@ -10,110 +10,110 @@ msgstr ""
#: core\gui\ignore_list_table.py:19 core\gui\ignore_list_table.py:20 #: core\gui\ignore_list_table.py:19 core\gui\ignore_list_table.py:20
#: core\gui\problem_table.py:18 #: core\gui\problem_table.py:18
msgid "File Path" msgid "File Path"
msgstr "" msgstr "مسار الملف"
#: core\gui\problem_table.py:19 #: core\gui\problem_table.py:19
msgid "Error Message" msgid "Error Message"
msgstr "" msgstr "رسالة خطأ"
#: core\me\prioritize.py:23 #: core\me\prioritize.py:23
msgid "Duration" msgid "Duration"
msgstr "" msgstr "مدة"
#: core\me\prioritize.py:30 core\me\result_table.py:23 #: core\me\prioritize.py:30 core\me\result_table.py:23
msgid "Bitrate" msgid "Bitrate"
msgstr "" msgstr "معدل البت"
#: core\me\prioritize.py:37 #: core\me\prioritize.py:37
msgid "Samplerate" msgid "Samplerate"
msgstr "" msgstr "معدل العينة"
#: core\me\result_table.py:19 core\pe\result_table.py:19 core\prioritize.py:92 #: core\me\result_table.py:19 core\pe\result_table.py:19 core\prioritize.py:92
#: core\se\result_table.py:19 #: core\se\result_table.py:19
msgid "Filename" msgid "Filename"
msgstr "" msgstr "اسم الملف"
#: core\me\result_table.py:20 core\pe\result_table.py:20 core\prioritize.py:75 #: core\me\result_table.py:20 core\pe\result_table.py:20 core\prioritize.py:75
#: core\se\result_table.py:20 #: core\se\result_table.py:20
msgid "Folder" msgid "Folder"
msgstr "" msgstr "مجلد"
#: core\me\result_table.py:21 #: core\me\result_table.py:21
msgid "Size (MB)" msgid "Size (MB)"
msgstr "" msgstr "الحجم (ميغا بايت)"
#: core\me\result_table.py:22 #: core\me\result_table.py:22
msgid "Time" msgid "Time"
msgstr "" msgstr "زمن"
#: core\me\result_table.py:24 #: core\me\result_table.py:24
msgid "Sample Rate" msgid "Sample Rate"
msgstr "" msgstr "معدل العينة"
#: core\me\result_table.py:25 core\pe\result_table.py:22 core\prioritize.py:65 #: core\me\result_table.py:25 core\pe\result_table.py:22 core\prioritize.py:65
#: core\se\result_table.py:22 #: core\se\result_table.py:22
msgid "Kind" msgid "Kind"
msgstr "" msgstr "طيب القلب"
#: core\me\result_table.py:26 core\pe\result_table.py:25 #: core\me\result_table.py:26 core\pe\result_table.py:25
#: core\prioritize.py:163 core\se\result_table.py:23 #: core\prioritize.py:163 core\se\result_table.py:23
msgid "Modification" msgid "Modification"
msgstr "" msgstr "تعديل"
#: core\me\result_table.py:27 #: core\me\result_table.py:27
msgid "Title" msgid "Title"
msgstr "" msgstr "عنوان"
#: core\me\result_table.py:28 #: core\me\result_table.py:28
msgid "Artist" msgid "Artist"
msgstr "" msgstr "فنان"
#: core\me\result_table.py:29 #: core\me\result_table.py:29
msgid "Album" msgid "Album"
msgstr "" msgstr "البوم"
#: core\me\result_table.py:30 #: core\me\result_table.py:30
msgid "Genre" msgid "Genre"
msgstr "" msgstr "النوع"
#: core\me\result_table.py:31 #: core\me\result_table.py:31
msgid "Year" msgid "Year"
msgstr "" msgstr "سنة"
#: core\me\result_table.py:32 #: core\me\result_table.py:32
msgid "Track Number" msgid "Track Number"
msgstr "" msgstr "رقم الشاحنة"
#: core\me\result_table.py:33 #: core\me\result_table.py:33
msgid "Comment" msgid "Comment"
msgstr "" msgstr "تعليق"
#: core\me\result_table.py:34 core\pe\result_table.py:26 #: core\me\result_table.py:34 core\pe\result_table.py:26
#: core\se\result_table.py:24 #: core\se\result_table.py:24
msgid "Match %" msgid "Match %"
msgstr "" msgstr "مباراة ٪"
#: core\me\result_table.py:35 core\se\result_table.py:25 #: core\me\result_table.py:35 core\se\result_table.py:25
msgid "Words Used" msgid "Words Used"
msgstr "" msgstr "الكلمات المستخدمة"
#: core\me\result_table.py:36 core\pe\result_table.py:27 #: core\me\result_table.py:36 core\pe\result_table.py:27
#: core\se\result_table.py:26 #: core\se\result_table.py:26
msgid "Dupe Count" msgid "Dupe Count"
msgstr "" msgstr "عدد المخادعين"
#: core\pe\prioritize.py:23 core\pe\result_table.py:23 #: core\pe\prioritize.py:23 core\pe\result_table.py:23
msgid "Dimensions" msgid "Dimensions"
msgstr "" msgstr "أبعاد"
#: core\pe\result_table.py:21 core\se\result_table.py:21 #: core\pe\result_table.py:21 core\se\result_table.py:21
msgid "Size (KB)" msgid "Size (KB)"
msgstr "" msgstr "الحجم (كيلو بايت)"
#: core\pe\result_table.py:24 #: core\pe\result_table.py:24
msgid "EXIF Timestamp" msgid "EXIF Timestamp"
msgstr "" msgstr "الطابع الزمني EXIF"
#: core\prioritize.py:156 #: core\prioritize.py:156
msgid "Size" msgid "Size"
msgstr "" msgstr "بحجم"

View File

@@ -114,4 +114,3 @@ msgstr ""
#: core\prioritize.py:158 #: core\prioritize.py:158
msgid "Size" msgid "Size"
msgstr "" msgstr ""

View File

@@ -36,83 +36,83 @@ msgstr ""
msgid "Sending to Trash" msgid "Sending to Trash"
msgstr "" msgstr ""
#: core\app.py:291 #: core\app.py:293
msgid "A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again." 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 "" msgstr ""
#: core\app.py:302 #: core\app.py:304
msgid "No duplicates found." msgid "No duplicates found."
msgstr "" msgstr ""
#: core\app.py:317 #: core\app.py:319
msgid "All marked files were copied successfully." msgid "All marked files were copied successfully."
msgstr "" msgstr ""
#: core\app.py:319 #: core\app.py:321
msgid "All marked files were moved successfully." msgid "All marked files were moved successfully."
msgstr "" msgstr ""
#: core\app.py:321 #: core\app.py:323
msgid "All marked files were deleted successfully." msgid "All marked files were deleted successfully."
msgstr "" msgstr ""
#: core\app.py:323 #: core\app.py:325
msgid "All marked files were successfully sent to Trash." msgid "All marked files were successfully sent to Trash."
msgstr "" msgstr ""
#: core\app.py:328 #: core\app.py:330
msgid "Could not load file: {}" msgid "Could not load file: {}"
msgstr "" msgstr ""
#: core\app.py:384 #: core\app.py:386
msgid "'{}' already is in the list." msgid "'{}' already is in the list."
msgstr "" msgstr ""
#: core\app.py:386 #: core\app.py:388
msgid "'{}' does not exist." msgid "'{}' does not exist."
msgstr "" msgstr ""
#: core\app.py:394 #: core\app.py:396
msgid "All selected %d matches are going to be ignored in all subsequent scans. Continue?" msgid "All selected %d matches are going to be ignored in all subsequent scans. Continue?"
msgstr "" msgstr ""
#: core\app.py:471 #: core\app.py:473
msgid "Select a directory to copy marked files to" msgid "Select a directory to copy marked files to"
msgstr "" msgstr ""
#: core\app.py:473 #: core\app.py:475
msgid "Select a directory to move marked files to" msgid "Select a directory to move marked files to"
msgstr "" msgstr ""
#: core\app.py:512 #: core\app.py:514
msgid "Select a destination for your exported CSV" msgid "Select a destination for your exported CSV"
msgstr "" msgstr ""
#: core\app.py:518 core\app.py:773 core\app.py:783 #: core\app.py:520 core\app.py:781 core\app.py:791
msgid "Couldn't write to file: {}" msgid "Couldn't write to file: {}"
msgstr "" msgstr ""
#: core\app.py:541 #: core\app.py:543
msgid "You have no custom command set up. Set it up in your preferences." msgid "You have no custom command set up. Set it up in your preferences."
msgstr "" msgstr ""
#: core\app.py:697 core\app.py:709 #: core\app.py:705 core\app.py:717
msgid "You are about to remove %d files from results. Continue?" msgid "You are about to remove %d files from results. Continue?"
msgstr "" msgstr ""
#: core\app.py:745 #: core\app.py:753
msgid "{} duplicate groups were changed by the re-prioritization." msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "" msgstr ""
#: core\app.py:792 #: core\app.py:801
msgid "The selected directories contain no scannable file." msgid "The selected directories contain no scannable file."
msgstr "" msgstr ""
#: core\app.py:808 #: core\app.py:817
msgid "Collecting files to scan" msgid "Collecting files to scan"
msgstr "" msgstr ""
#: core\app.py:858 #: core\app.py:867
msgid "%s (%d discarded)" msgid "%s (%d discarded)"
msgstr "" msgstr ""
@@ -243,4 +243,3 @@ msgstr ""
#: core\se\scanner.py:18 #: core\se\scanner.py:18
msgid "Folders" msgid "Folders"
msgstr "" msgstr ""

View File

@@ -150,7 +150,7 @@ msgstr "Raccolte {} cartelle da scansionare"
#: core\engine.py:27 #: core\engine.py:27
msgid "%d matches found from %d groups" msgid "%d matches found from %d groups"
msgstr "%d corrispondeze trovate da %d gruppi" msgstr "%d corrispondenze trovate da %d gruppi"
#: core\gui\deletion_options.py:71 #: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash." msgid "You are sending {} file(s) to the Trash."

View File

@@ -1,6 +0,0 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: utf-8\n"

View File

@@ -1092,3 +1092,32 @@ msgstr ""
#: qt\search_edit.py:78 #: qt\search_edit.py:78
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
#: qt\preferences_dialog.py:219
msgid ""
"These options are for advanced users or for very specific situations, most "
"users should not have to modify these."
msgstr ""
#: qt\preferences_dialog.py:225
msgid "Include existence check after scan completion"
msgstr ""
#: qt\preferences_dialog.py:227
msgid "Ignore difference in mtime when loading cached digests"
msgstr ""
#: qt\progress_window.py:64
msgid "Cancel?"
msgstr ""
#: qt\progress_window.py:65
msgid "Are you sure you want to cancel? All progress will be lost."
msgstr ""
#: qt\exclude_list_dialog.py:161
msgid ""
"These (case sensitive) python regular expressions will filter out files during scans.<br>Directores will also have their <strong>default state</strong> set to Excluded in the Directories tab if their name happens to match one of the selected regular expressions.<br>For each file collected, two tests are performed to determine whether or not to completely ignore it:<br><li>1. Regular expressions with no path separator in them will be compared to the file name only.</li>\n"
"<li>2. Regular expressions with at least one path separator in them will be compared to the full path to the file.</li><br>Example: if you want to filter out .PNG files from the \"My Pictures\" directory only:<br><code>.*My\\sPictures\\\\.*\\.png</code><br><br>You can test the regular expression with the \"test string\" button after pasting a fake path in the test field:<br><code>C:\\\\User\\My Pictures\\test.png</code><br><br>\n"
"Matching regular expressions will be highlighted.<br>If there is at least one highlight, the path or filename tested will be ignored during scans.<br><br>Directories and files starting with a period '.' are filtered out by default.<br><br>"
msgstr ""

View File

@@ -7,19 +7,19 @@ These instructions are for the Qt version of the UI on macOS.
- [Python 3.7+][python] - [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) - [Xcode 12.3][xcode] or just Xcode command line tools (older versions can be used if not interested in arm macs)
- [Homebrew][homebrew] - [Homebrew][homebrew]
- [qt6](https://www.qt.io/) - [qt5](https://www.qt.io/)
#### Prerequisite setup #### Prerequisite setup
1. Install Xcode if desired 1. Install Xcode if desired
2. Install [Homebrew][homebrew], if not on the path after install (arm based Macs) create `~/.zshrc` 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 with `export PATH="/opt/homebrew/bin:$PATH"`. Will need to reload terminal or source the file to take
effect. effect.
3. Install qt6 with `brew`. If you are using a version of macos without system python 3.7+ then you will 3. Install qt5 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. also need to install that via brew or with pyenv.
$ brew install qt6 $ brew install qt5
NOTE: Using `brew` to install qt6 is to allow pyqt6 to build without a native wheel NOTE: Using `brew` to install qt5 is to allow pyqt5 to build without a native wheel
available. If you are using an intel based mac you can probably skip this step. 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. 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 ### With build.py
OSX comes with a version of python 3 by default in newer versions of OSX. To produce universal 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 builds either the 3.8 version shipped in macos or 3.9.1 or newer needs to be used. If needing to
build pyqt6 from source then the first line below is needed, else it may be omitted. (Path shown is build pyqt5 from source then the first line below is needed, else it may be omitted. (Path shown is
for an arm mac.) for an arm mac.)
$ export PATH="/opt/homebrew/opt/qt/bin:$PATH" $ export PATH="/opt/homebrew/opt/qt/bin:$PATH"

View File

@@ -348,4 +348,3 @@ dupeguru (2.9.2-1) unstable; urgency=low
* Fixed selection glitches, especially while renaming. (#93) * Fixed selection glitches, especially while renaming. (#93)
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 10 Feb 2010 00:00:00 +0000 -- Virgil Dupras <hsoft@hardcoded.net> Wed, 10 Feb 2010 00:00:00 +0000

View File

@@ -10,7 +10,7 @@ Vcs-Git: https://github.com/arsenetar/dupeguru.git
Package: {pkgname} Package: {pkgname}
Architecture: {arch} Architecture: {arch}
Depends: ${shlibs:Depends}, python3 (>=3.7), python3-pyqt6, python3-mutagen, python3-semantic-version Depends: ${shlibs:Depends}, python3 (>=3.7), python3 (<<3.12), python3-pyqt5, python3-mutagen, python3-semantic-version
Provides: dupeguru-se, dupeguru-me, dupeguru-pe Provides: dupeguru-se, dupeguru-me, dupeguru-pe
Replaces: dupeguru-se, dupeguru-me, dupeguru-pe Replaces: dupeguru-se, dupeguru-me, dupeguru-pe
Conflicts: dupeguru-se, dupeguru-me, dupeguru-pe Conflicts: dupeguru-se, dupeguru-me, dupeguru-pe

View File

@@ -6,3 +6,4 @@ Icon=dupeguru
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Utility; Categories=Utility;
Keywords=file manager;gui;

View File

@@ -6,46 +6,36 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt6.QtCore import Qt, QCoreApplication, QTimer from PyQt5.QtCore import Qt, QCoreApplication, QTimer
from PyQt6.QtGui import QPixmap, QFont, QShowEvent from PyQt5.QtGui import QPixmap, QFont
from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout, QVBoxLayout, QLabel, QWidget from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout, QVBoxLayout, QLabel
from core.util import check_for_update from core.util import check_for_update
from qt.util import move_to_screen_center from qt.util import move_to_screen_center
from hscommon.trans import trget from hscommon.trans import trget
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from qt.app import DupeGuru
tr = trget("ui") tr = trget("ui")
class AboutBox(QDialog): class AboutBox(QDialog):
def __init__(self, parent: QWidget, app: "DupeGuru", **kwargs) -> None: def __init__(self, parent, app, **kwargs):
flags = ( flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.MSWindowsFixedSizeDialogHint
Qt.WindowType.CustomizeWindowHint
| Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowSystemMenuHint
| Qt.WindowType.MSWindowsFixedSizeDialogHint
)
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self.app = app self.app = app
self._setupUi() self._setupUi()
def _setupUi(self) -> None: self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
def _setupUi(self):
self.setWindowTitle(tr("About {}").format(QCoreApplication.instance().applicationName())) self.setWindowTitle(tr("About {}").format(QCoreApplication.instance().applicationName()))
size_policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) size_policy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.setSizePolicy(size_policy) self.setSizePolicy(size_policy)
main_layout = QHBoxLayout(self) main_layout = QHBoxLayout(self)
logo_label = QLabel() logo_label = QLabel()
logo_label.setPixmap(QPixmap(f"images:{self.app.LOGO_NAME}_128.png")) logo_label.setPixmap(QPixmap(":/%s_big" % self.app.LOGO_NAME))
main_layout.addWidget(logo_label) main_layout.addWidget(logo_label)
detail_layout = QVBoxLayout() detail_layout = QVBoxLayout()
name_label = QLabel() name_label = QLabel()
font = QFont() font = QFont()
font.setWeight(75) font.setWeight(75)
@@ -53,35 +43,26 @@ class AboutBox(QDialog):
name_label.setFont(font) name_label.setFont(font)
name_label.setText(QCoreApplication.instance().applicationName()) name_label.setText(QCoreApplication.instance().applicationName())
detail_layout.addWidget(name_label) detail_layout.addWidget(name_label)
version_label = QLabel() version_label = QLabel()
version_label.setText(tr("Version {}").format(QCoreApplication.instance().applicationVersion())) version_label.setText(tr("Version {}").format(QCoreApplication.instance().applicationVersion()))
detail_layout.addWidget(version_label) detail_layout.addWidget(version_label)
self.update_label = QLabel(tr("Checking for updates...")) self.update_label = QLabel(tr("Checking for updates..."))
self.update_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.update_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
self.update_label.setOpenExternalLinks(True) self.update_label.setOpenExternalLinks(True)
detail_layout.addWidget(self.update_label) detail_layout.addWidget(self.update_label)
license_label = QLabel() license_label = QLabel()
license_label.setText(tr("Licensed under GPLv3")) license_label.setText(tr("Licensed under GPLv3"))
detail_layout.addWidget(license_label) detail_layout.addWidget(license_label)
spacer_label = QLabel() spacer_label = QLabel()
spacer_label.setFont(font) spacer_label.setFont(font)
detail_layout.addWidget(spacer_label) detail_layout.addWidget(spacer_label)
self.button_box = QDialogButtonBox()
button_box = QDialogButtonBox() self.button_box.setOrientation(Qt.Horizontal)
button_box.setOrientation(Qt.Orientation.Horizontal) self.button_box.setStandardButtons(QDialogButtonBox.Ok)
button_box.setStandardButtons(QDialogButtonBox.StandardButton.Ok) detail_layout.addWidget(self.button_box)
detail_layout.addWidget(button_box)
main_layout.addLayout(detail_layout) main_layout.addLayout(detail_layout)
button_box.accepted.connect(self.accept) def _check_for_update(self):
button_box.rejected.connect(self.reject)
def _check_for_update(self) -> None:
update = check_for_update(QCoreApplication.instance().applicationVersion(), include_prerelease=False) update = check_for_update(QCoreApplication.instance().applicationVersion(), include_prerelease=False)
if update is None: if update is None:
self.update_label.setText(tr("No update available.")) self.update_label.setText(tr("No update available."))
@@ -90,7 +71,7 @@ class AboutBox(QDialog):
tr('New version {} available, download <a href="{}">here</a>.').format(update["version"], update["url"]) tr('New version {} available, download <a href="{}">here</a>.').format(update["version"], update["url"])
) )
def showEvent(self, event: QShowEvent) -> None: def showEvent(self, event):
self.update_label.setText(tr("Checking for updates...")) self.update_label.setText(tr("Checking for updates..."))
# have to do this here as the frameGeometry is not correct until shown # have to do this here as the frameGeometry is not correct until shown
move_to_screen_center(self) move_to_screen_center(self)

109
qt/app.py
View File

@@ -6,18 +6,15 @@
import sys import sys
import os.path as op import os.path as op
from typing import Type
from PyQt6.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt
from PyQt6.QtGui import QColor, QDesktopServices, QPalette from PyQt5.QtGui import QColor, QDesktopServices, QPalette
from PyQt6.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox, QStyleFactory, QToolTip from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox, QStyleFactory, QToolTip
from hscommon.trans import trget from hscommon.trans import trget
from hscommon import desktop, plat from hscommon import desktop, plat
from qt.about_box import AboutBox from qt.about_box import AboutBox
from qt.details_dialog import DetailsDialog
from qt.preferences_dialog import PreferencesDialogBase
from qt.recent import Recent from qt.recent import Recent
from qt.util import create_actions from qt.util import create_actions
from qt.progress_window import ProgressWindow from qt.progress_window import ProgressWindow
@@ -45,10 +42,10 @@ tr = trget("ui")
class DupeGuru(QObject): class DupeGuru(QObject):
LOGO_NAME = "dgse_logo" LOGO_NAME = "logo_se"
NAME = "dupeGuru" NAME = "dupeGuru"
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.prefs = Preferences() self.prefs = Preferences()
self.prefs.load() self.prefs.load()
@@ -59,7 +56,7 @@ class DupeGuru(QObject):
self._setup() self._setup()
# --- Private # --- Private
def _setup(self) -> None: def _setup(self):
core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto
self._setupActions() self._setupActions()
self.details_dialog = None self.details_dialog = None
@@ -111,7 +108,7 @@ class DupeGuru(QObject):
# that the application haven't launched. # that the application haven't launched.
QTimer.singleShot(0, self.finishedLaunching) QTimer.singleShot(0, self.finishedLaunching)
def _setupActions(self) -> None: def _setupActions(self):
# Setup actions that are common to both the directory dialog and the results window. # Setup actions that are common to both the directory dialog and the results window.
# (name, shortcut, icon, desc, func) # (name, shortcut, icon, desc, func)
ACTIONS = [ ACTIONS = [
@@ -157,7 +154,7 @@ class DupeGuru(QObject):
] ]
create_actions(ACTIONS, self) create_actions(ACTIONS, self)
def _update_options(self) -> None: def _update_options(self):
self.model.options["mix_file_kind"] = self.prefs.mix_file_kind 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["escape_filter_regexp"] = not self.prefs.use_regexp
self.model.options["clean_empty_dirs"] = self.prefs.remove_empty_folders self.model.options["clean_empty_dirs"] = self.prefs.remove_empty_folders
@@ -195,7 +192,8 @@ class DupeGuru(QObject):
scanned_tags.add("year") scanned_tags.add("year")
self.model.options["scanned_tags"] = scanned_tags self.model.options["scanned_tags"] = scanned_tags
self.model.options["match_scaled"] = self.prefs.match_scaled self.model.options["match_scaled"] = self.prefs.match_scaled
self.model.options["picture_cache_type"] = self.prefs.picture_cache_type self.model.options["include_exists_check"] = self.prefs.include_exists_check
self.model.options["rehash_ignore_mtime"] = self.prefs.rehash_ignore_mtime
if self.details_dialog: if self.details_dialog:
self.details_dialog.update_options() self.details_dialog.update_options()
@@ -203,7 +201,7 @@ class DupeGuru(QObject):
self._set_style("dark" if self.prefs.use_dark_style else "light") self._set_style("dark" if self.prefs.use_dark_style else "light")
# --- Private # --- Private
def _get_details_dialog_class(self) -> Type[DetailsDialog]: def _get_details_dialog_class(self):
if self.model.app_mode == AppMode.PICTURE: if self.model.app_mode == AppMode.PICTURE:
return DetailsDialogPicture return DetailsDialogPicture
elif self.model.app_mode == AppMode.MUSIC: elif self.model.app_mode == AppMode.MUSIC:
@@ -211,7 +209,7 @@ class DupeGuru(QObject):
else: else:
return DetailsDialogStandard return DetailsDialogStandard
def _get_preferences_dialog_class(self) -> Type[PreferencesDialogBase]: def _get_preferences_dialog_class(self):
if self.model.app_mode == AppMode.PICTURE: if self.model.app_mode == AppMode.PICTURE:
return PreferencesDialogPicture return PreferencesDialogPicture
elif self.model.app_mode == AppMode.MUSIC: elif self.model.app_mode == AppMode.MUSIC:
@@ -219,7 +217,7 @@ class DupeGuru(QObject):
else: else:
return PreferencesDialogStandard return PreferencesDialogStandard
def _set_style(self, style: str = "light") -> None: def _set_style(self, style="light"):
# Only support this feature on windows for now # Only support this feature on windows for now
if not plat.ISWINDOWS: if not plat.ISWINDOWS:
return return
@@ -227,18 +225,18 @@ class DupeGuru(QObject):
QApplication.setStyle(QStyleFactory.create("Fusion")) QApplication.setStyle(QStyleFactory.create("Fusion"))
palette = QApplication.style().standardPalette() palette = QApplication.style().standardPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) palette.setColor(QPalette.ColorRole.WindowText, Qt.white)
palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) palette.setColor(QPalette.ColorRole.ToolTipText, Qt.white)
palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) palette.setColor(QPalette.ColorRole.Text, Qt.white)
palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) palette.setColor(QPalette.ColorRole.ButtonText, Qt.white)
palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red) palette.setColor(QPalette.ColorRole.BrightText, Qt.red)
palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black) palette.setColor(QPalette.ColorRole.HighlightedText, Qt.black)
palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, QColor(164, 166, 168)) 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.WindowText, QColor(164, 166, 168))
palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor(164, 166, 168)) palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor(164, 166, 168))
@@ -253,31 +251,29 @@ class DupeGuru(QObject):
QApplication.setPalette(palette) QApplication.setPalette(palette)
# --- Public # --- Public
def add_selected_to_ignore_list(self) -> None: def add_selected_to_ignore_list(self):
self.model.add_selected_to_ignore_list() self.model.add_selected_to_ignore_list()
def remove_selected(self) -> None: def remove_selected(self):
self.model.remove_selected() self.model.remove_selected(self)
def confirm( def confirm(self, title, msg, default_button=QMessageBox.Yes):
self, title: str, msg: str, default_button: QMessageBox.StandardButton = QMessageBox.StandardButton.Yes
) -> bool:
active = QApplication.activeWindow() active = QApplication.activeWindow()
buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No buttons = QMessageBox.Yes | QMessageBox.No
answer = QMessageBox.question(active, title, msg, buttons, default_button) answer = QMessageBox.question(active, title, msg, buttons, default_button)
return answer == QMessageBox.StandardButton.Yes return answer == QMessageBox.Yes
def invokeCustomCommand(self) -> None: def invokeCustomCommand(self):
self.model.invoke_custom_command() self.model.invoke_custom_command()
def show_details(self) -> None: def show_details(self):
if self.details_dialog is not None: if self.details_dialog is not None:
if not self.details_dialog.isVisible(): if not self.details_dialog.isVisible():
self.details_dialog.show() self.details_dialog.show()
else: else:
self.details_dialog.hide() self.details_dialog.hide()
def showResultsWindow(self) -> None: def showResultsWindow(self):
if self.resultWindow is not None: if self.resultWindow is not None:
if self.use_tabs: if self.use_tabs:
if self.main_window.indexOfWidget(self.resultWindow) < 0: if self.main_window.indexOfWidget(self.resultWindow) < 0:
@@ -287,14 +283,14 @@ class DupeGuru(QObject):
else: else:
self.resultWindow.show() self.resultWindow.show()
def showDirectoriesWindow(self) -> None: def showDirectoriesWindow(self):
if self.directories_dialog is not None: if self.directories_dialog is not None:
if self.use_tabs: if self.use_tabs:
self.main_window.showTab(self.directories_dialog) self.main_window.showTab(self.directories_dialog)
else: else:
self.directories_dialog.show() self.directories_dialog.show()
def shutdown(self) -> None: def shutdown(self):
self.willSavePrefs.emit() self.willSavePrefs.emit()
self.prefs.save() self.prefs.save()
self.model.save() self.model.save()
@@ -309,7 +305,7 @@ class DupeGuru(QObject):
SIGTERM = pyqtSignal() SIGTERM = pyqtSignal()
# --- Events # --- Events
def finishedLaunching(self) -> None: def finishedLaunching(self):
if sys.getfilesystemencoding() == "ascii": if sys.getfilesystemencoding() == "ascii":
# No need to localize this, it's a debugging message. # No need to localize this, it's a debugging message.
msg = ( msg = (
@@ -329,28 +325,28 @@ class DupeGuru(QObject):
self.model.load_from(results) self.model.load_from(results)
self.recentResults.insertItem(results) self.recentResults.insertItem(results)
def clearCacheTriggered(self) -> None: def clearCacheTriggered(self):
title = tr("Clear Cache") title = tr("Clear Cache")
msg = tr("Do you really want to clear the cache? This will remove all cached file hashes and picture analysis.") 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.StandardButton.No): if self.confirm(title, msg, QMessageBox.No):
self.model.clear_picture_cache() self.model.clear_picture_cache()
self.model.clear_hash_cache() self.model.clear_hash_cache()
active = QApplication.activeWindow() active = QApplication.activeWindow()
QMessageBox.information(active, title, tr("Cache cleared.")) QMessageBox.information(active, title, tr("Cache cleared."))
def ignoreListTriggered(self) -> None: def ignoreListTriggered(self):
if self.use_tabs: if self.use_tabs:
self.showTriggeredTabbedDialog(self.ignoreListDialog, tr("Ignore List")) self.showTriggeredTabbedDialog(self.ignoreListDialog, tr("Ignore List"))
else: # floating windows else: # floating windows
self.model.ignore_list_dialog.show() self.model.ignore_list_dialog.show()
def excludeListTriggered(self) -> None: def excludeListTriggered(self):
if self.use_tabs: if self.use_tabs:
self.showTriggeredTabbedDialog(self.excludeListDialog, tr("Exclusion Filters")) self.showTriggeredTabbedDialog(self.excludeListDialog, tr("Exclusion Filters"))
else: # floating windows else: # floating windows
self.model.exclude_list_dialog.show() self.model.exclude_list_dialog.show()
def showTriggeredTabbedDialog(self, dialog, desc_string: str) -> None: def showTriggeredTabbedDialog(self, dialog, desc_string):
"""Add tab for dialog, name the tab with desc_string, then show it.""" """Add tab for dialog, name the tab with desc_string, then show it."""
index = self.main_window.indexOfWidget(dialog) index = self.main_window.indexOfWidget(dialog)
# Create the tab if it doesn't exist already # Create the tab if it doesn't exist already
@@ -359,22 +355,23 @@ class DupeGuru(QObject):
# Show the tab for that widget # Show the tab for that widget
self.main_window.setCurrentIndex(index) self.main_window.setCurrentIndex(index)
def openDebugLogTriggered(self) -> None: def openDebugLogTriggered(self):
debug_log_path = op.join(self.model.appdata, "debug.log") debug_log_path = op.join(self.model.appdata, "debug.log")
desktop.open_path(debug_log_path) desktop.open_path(debug_log_path)
def preferencesTriggered(self) -> None: def preferencesTriggered(self):
preferences_dialog = self._get_preferences_dialog_class()( preferences_dialog = self._get_preferences_dialog_class()(
self.main_window if self.main_window else self.directories_dialog, self self.main_window if self.main_window else self.directories_dialog, self
) )
preferences_dialog.load() preferences_dialog.load()
result = preferences_dialog.exec() result = preferences_dialog.exec()
if result == QDialog.DialogCode.Accepted: if result == QDialog.Accepted:
preferences_dialog.save() preferences_dialog.save()
self.prefs.save() self.prefs.save()
self._update_options() self._update_options()
preferences_dialog.setParent(None)
def quitTriggered(self) -> None: def quitTriggered(self):
if self.details_dialog is not None: if self.details_dialog is not None:
self.details_dialog.close() self.details_dialog.close()
@@ -383,10 +380,10 @@ class DupeGuru(QObject):
else: else:
self.directories_dialog.close() self.directories_dialog.close()
def showAboutBoxTriggered(self) -> None: def showAboutBoxTriggered(self):
self.about_box.show() self.about_box.show()
def showHelpTriggered(self) -> None: def showHelpTriggered(self):
base_path = platform.HELP_PATH base_path = platform.HELP_PATH
help_path = op.abspath(op.join(base_path, "index.html")) help_path = op.abspath(op.join(base_path, "index.html"))
if op.exists(help_path): if op.exists(help_path):
@@ -395,7 +392,7 @@ class DupeGuru(QObject):
url = QUrl("https://dupeguru.voltaicideas.net/help/en/") url = QUrl("https://dupeguru.voltaicideas.net/help/en/")
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)
def handleSIGTERM(self) -> None: def handleSIGTERM(self):
self.shutdown() self.shutdown()
# --- model --> view # --- model --> view
@@ -405,20 +402,20 @@ class DupeGuru(QObject):
def set_default(self, key, value): def set_default(self, key, value):
self.prefs.set_value(key, value) self.prefs.set_value(key, value)
def show_message(self, msg: str) -> None: def show_message(self, msg):
window = QApplication.activeWindow() window = QApplication.activeWindow()
QMessageBox.information(window, "", msg) QMessageBox.information(window, "", msg)
def ask_yes_no(self, prompt: str) -> bool: def ask_yes_no(self, prompt):
return self.confirm("", prompt) return self.confirm("", prompt)
def create_results_window(self) -> None: def create_results_window(self):
"""Creates resultWindow and details_dialog depending on the selected ``app_mode``.""" """Creates resultWindow and details_dialog depending on the selected ``app_mode``."""
if self.details_dialog is not None: if self.details_dialog is not None:
# The object is not deleted entirely, avoid saving its geometry in the future # The object is not deleted entirely, avoid saving its geometry in the future
# self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs) # self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs)
# or simply delete it on close which is probably cleaner: # or simply delete it on close which is probably cleaner:
self.details_dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.details_dialog.setAttribute(Qt.WA_DeleteOnClose)
self.details_dialog.close() self.details_dialog.close()
# if we don't do the following, Qt will crash when we recreate the Results dialog # if we don't do the following, Qt will crash when we recreate the Results dialog
self.details_dialog.setParent(None) self.details_dialog.setParent(None)
@@ -433,17 +430,17 @@ class DupeGuru(QObject):
self.directories_dialog._updateActionsState() self.directories_dialog._updateActionsState()
self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self) self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self)
def show_results_window(self) -> None: def show_results_window(self):
self.showResultsWindow() self.showResultsWindow()
def show_problem_dialog(self) -> None: def show_problem_dialog(self):
self.problemDialog.show() self.problemDialog.show()
def select_dest_folder(self, prompt: str) -> str: def select_dest_folder(self, prompt):
flags = QFileDialog.Option.ShowDirsOnly flags = QFileDialog.ShowDirsOnly
return QFileDialog.getExistingDirectory(self.resultWindow, prompt, "", flags) return QFileDialog.getExistingDirectory(self.resultWindow, prompt, "", flags)
def select_dest_file(self, prompt: str, extension: str) -> str: def select_dest_file(self, prompt, extension):
files = tr("{} file (*.{})").format(extension.upper(), extension) files = tr("{} file (*.{})").format(extension.upper(), extension)
destination, chosen_filter = QFileDialog.getSaveFileName(self.resultWindow, prompt, "", files) destination, chosen_filter = QFileDialog.getSaveFileName(self.resultWindow, prompt, "", files)
if not destination.endswith(f".{extension}"): if not destination.endswith(f".{extension}"):

View File

@@ -11,8 +11,8 @@ import sys
import os import os
import platform import platform
from PyQt6.QtCore import Qt, QCoreApplication, QSize from PyQt5.QtCore import Qt, QCoreApplication, QSize
from PyQt6.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
@@ -30,7 +30,7 @@ tr = trget("ui")
class ErrorReportDialog(QDialog): class ErrorReportDialog(QDialog):
def __init__(self, parent, github_url, error, **kwargs): def __init__(self, parent, github_url, error, **kwargs):
flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self._setupUi() self._setupUi()
name = QCoreApplication.applicationName() name = QCoreApplication.applicationName()
@@ -40,23 +40,23 @@ class ErrorReportDialog(QDialog):
) )
# Under windows, we end up with an error report without linesep if we don't mangle it # 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) error_text = error_text.replace("\n", os.linesep)
self.error_text_edit.setPlainText(error_text) self.errorTextEdit.setPlainText(error_text)
self.github_url = github_url self.github_url = github_url
self.sendButton.clicked.connect(self.goToGithub)
self.dontSendButton.clicked.connect(self.reject)
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("Error Report")) self.setWindowTitle(tr("Error Report"))
self.resize(553, 349) self.resize(553, 349)
main_layout = QVBoxLayout(self) self.verticalLayout = QVBoxLayout(self)
self.label = QLabel(self)
title_label = QLabel(self) self.label.setText(tr("Something went wrong. How about reporting the error?"))
title_label.setText(tr("Something went wrong. How about reporting the error?")) self.label.setWordWrap(True)
title_label.setWordWrap(True) self.verticalLayout.addWidget(self.label)
main_layout.addWidget(title_label) self.errorTextEdit = QPlainTextEdit(self)
self.errorTextEdit.setReadOnly(True)
self.error_text_edit = QPlainTextEdit(self) self.verticalLayout.addWidget(self.errorTextEdit)
self.error_text_edit.setReadOnly(True)
main_layout.addWidget(self.error_text_edit)
msg = tr( msg = tr(
"Error reports should be reported as Github issues. You can copy the error traceback " "Error reports should be reported as Github issues. You can copy the error traceback "
"above and paste it in a new issue.\n\nPlease make sure to run a search for any already " "above and paste it in a new issue.\n\nPlease make sure to run a search for any already "
@@ -67,28 +67,21 @@ class ErrorReportDialog(QDialog):
"Although the application should continue to run after this error, it may be in an " "Although the application should continue to run after this error, it may be in an "
"unstable state, so it is recommended that you restart the application." "unstable state, so it is recommended that you restart the application."
) )
instructions_label = QLabel(msg) self.label2 = QLabel(msg)
instructions_label.setWordWrap(True) self.label2.setWordWrap(True)
main_layout.addWidget(instructions_label) self.verticalLayout.addWidget(self.label2)
self.horizontalLayout = QHBoxLayout()
button_layout = QHBoxLayout() self.horizontalLayout.addItem(horizontal_spacer())
button_layout.addItem(horizontal_spacer()) self.dontSendButton = QPushButton(self)
self.dontSendButton.setText(tr("Close"))
close_button = QPushButton(self) self.dontSendButton.setMinimumSize(QSize(110, 0))
close_button.setText(tr("Close")) self.horizontalLayout.addWidget(self.dontSendButton)
close_button.setMinimumSize(QSize(110, 0)) self.sendButton = QPushButton(self)
button_layout.addWidget(close_button) self.sendButton.setText(tr("Go to Github"))
self.sendButton.setMinimumSize(QSize(110, 0))
report_button = QPushButton(self) self.sendButton.setDefault(True)
report_button.setText(tr("Go to Github")) self.horizontalLayout.addWidget(self.sendButton)
report_button.setMinimumSize(QSize(110, 0)) self.verticalLayout.addLayout(self.horizontalLayout)
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): def goToGithub(self):
open_url(self.github_url) open_url(self.github_url)
@@ -98,6 +91,6 @@ def install_excepthook(github_url):
def my_excepthook(exctype, value, tb): def my_excepthook(exctype, value, tb):
s = "".join(traceback.format_exception(exctype, value, tb)) s = "".join(traceback.format_exception(exctype, value, tb))
dialog = ErrorReportDialog(None, github_url, s) dialog = ErrorReportDialog(None, github_url, s)
dialog.exec() dialog.exec_()
sys.excepthook = my_excepthook sys.excepthook = my_excepthook

View File

@@ -165,8 +165,8 @@ Directores will also have their <strong>default state</strong> set to Excluded \
in the Directories tab if their name happens to match one of the selected regular expressions.<br>\ in the Directories tab if their name happens to match one of the selected regular expressions.<br>\
For each file collected, two tests are performed to determine whether or not to completely ignore it:<br>\ For each file collected, two tests are performed to determine whether or not to completely ignore it:<br>\
<li>1. Regular expressions with no path separator in them will be compared to the file name only.</li> <li>1. Regular expressions with no path separator in them will be compared to the file name only.</li>
<li>2. Regular expressions with at least one path separator in them will be compared to the full path to the file.</li><br> <li>2. Regular expressions with at least one path separator in them will be compared to the full path to the file.</li>\
Example: if you want to filter out .PNG files from the "My Pictures" directory only:<br>\ <br>Example: if you want to filter out .PNG files from the "My Pictures" directory only:<br>\
<code>.*My\\sPictures\\\\.*\\.png</code><br><br>\ <code>.*My\\sPictures\\\\.*\\.png</code><br><br>\
You can test the regular expression with the "test string" button after pasting a fake path in the test field:<br>\ You can test the regular expression with the "test string" button after pasting a fake path in the test field:<br>\
<code>C:\\\\User\\My Pictures\\test.png</code><br><br> <code>C:\\\\User\\My Pictures\\test.png</code><br><br>

View File

@@ -4,8 +4,8 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt6.QtCore import QSize from PyQt5.QtCore import QSize
from PyQt6.QtWidgets import QAbstractItemView from PyQt5.QtWidgets import QAbstractItemView
from hscommon.trans import trget from hscommon.trans import trget
from qt.details_dialog import DetailsDialog as DetailsDialogBase from qt.details_dialog import DetailsDialog as DetailsDialogBase
@@ -15,12 +15,12 @@ tr = trget("ui")
class DetailsDialog(DetailsDialogBase): class DetailsDialog(DetailsDialogBase):
def _setupUi(self) -> None: def _setupUi(self):
self.setWindowTitle(tr("Details")) self.setWindowTitle(tr("Details"))
self.resize(502, 295) self.resize(502, 295)
self.setMinimumSize(QSize(250, 250)) self.setMinimumSize(QSize(250, 250))
self.tableView = DetailsTable(self) self.tableView = DetailsTable(self)
self.tableView.setAlternatingRowColors(True) self.tableView.setAlternatingRowColors(True)
self.tableView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tableView.setShowGrid(False) self.tableView.setShowGrid(False)
self.setWidget(self.tableView) self.setWidget(self.tableView)

View File

@@ -4,22 +4,27 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from typing import Callable from PyQt5.QtCore import QSize
from PyQt6.QtCore import QSize from PyQt5.QtWidgets import (
from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, QCheckBox QVBoxLayout,
QHBoxLayout,
QLabel,
QSizePolicy,
QSpacerItem,
QWidget,
)
from hscommon.trans import trget from hscommon.trans import trget
from core.app import AppMode from core.app import AppMode
from core.scanner import ScanType from core.scanner import ScanType
from qt.preferences import Preferences
from qt.preferences_dialog import PreferencesDialogBase, Sections from qt.preferences_dialog import PreferencesDialogBase
tr = trget("ui") tr = trget("ui")
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def _setupPreferenceWidgets(self) -> None: def _setupPreferenceWidgets(self):
self._setupFilterHardnessBox() self._setupFilterHardnessBox()
self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self.widgetsVLayout.addLayout(self.filterHardnessHLayout)
self.widget = QWidget(self) self.widget = QWidget(self)
@@ -32,7 +37,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.verticalLayout_4.addWidget(self.label_6) self.verticalLayout_4.addWidget(self.label_6)
self.horizontalLayout_2 = QHBoxLayout() self.horizontalLayout_2 = QHBoxLayout()
self.horizontalLayout_2.setSpacing(0) self.horizontalLayout_2.setSpacing(0)
spacer_item = QSpacerItem(15, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum) spacer_item = QSpacerItem(15, 20, QSizePolicy.Fixed, QSizePolicy.Minimum)
self.horizontalLayout_2.addItem(spacer_item) self.horizontalLayout_2.addItem(spacer_item)
self._setupAddCheckbox("tagTrackBox", tr("Track"), self.widget) self._setupAddCheckbox("tagTrackBox", tr("Track"), self.widget)
self.horizontalLayout_2.addWidget(self.tagTrackBox) self.horizontalLayout_2.addWidget(self.tagTrackBox)
@@ -65,7 +70,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches) self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None: def _load(self, prefs, setchecked, section):
setchecked(self.tagTrackBox, prefs.scan_tag_track) setchecked(self.tagTrackBox, prefs.scan_tag_track)
setchecked(self.tagArtistBox, prefs.scan_tag_artist) setchecked(self.tagArtistBox, prefs.scan_tag_artist)
setchecked(self.tagAlbumBox, prefs.scan_tag_album) setchecked(self.tagAlbumBox, prefs.scan_tag_album)
@@ -94,7 +99,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.tagGenreBox.setEnabled(tag_based) self.tagGenreBox.setEnabled(tag_based)
self.tagYearBox.setEnabled(tag_based) self.tagYearBox.setEnabled(tag_based)
def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None: def _save(self, prefs, ischecked):
prefs.scan_tag_track = ischecked(self.tagTrackBox) prefs.scan_tag_track = ischecked(self.tagTrackBox)
prefs.scan_tag_artist = ischecked(self.tagArtistBox) prefs.scan_tag_artist = ischecked(self.tagArtistBox)
prefs.scan_tag_album = ischecked(self.tagAlbumBox) prefs.scan_tag_album = ischecked(self.tagAlbumBox)

View File

@@ -1,5 +1,5 @@
from typing import Tuple, List, Union from typing import Tuple, List, Union
from PyQt6.QtGui import QImage from PyQt5.QtGui import QImage
_block = Tuple[int, int, int] _block = Tuple[int, int, int]

View File

@@ -31,7 +31,10 @@ class File(PhotoBase):
image = image.convertToFormat(QImage.Format_RGB888) image = image.convertToFormat(QImage.Format_RGB888)
if type(orientation) != int: if type(orientation) != int:
logging.warning( logging.warning(
"Orientation for file '%s' was a %s '%s', not an int.", str(self.path), type(orientation), orientation "Orientation for file '%s' was a %s '%s', not an int.",
str(self.path),
type(orientation),
orientation,
) )
try: try:
orientation = int(orientation) orientation = int(orientation)

View File

@@ -4,23 +4,19 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from typing import Callable
from PyQt6.QtWidgets import QFormLayout, QCheckBox
from PyQt6.QtCore import Qt
from hscommon.trans import trget from hscommon.trans import trget
from hscommon.plat import ISLINUX from hscommon.plat import ISLINUX
from qt.preferences import Preferences
from qt.radio_box import RadioBox
from core.scanner import ScanType from core.scanner import ScanType
from core.app import AppMode from core.app import AppMode
from qt.preferences_dialog import PreferencesDialogBase, Sections from qt.preferences_dialog import PreferencesDialogBase
tr = trget("ui") tr = trget("ui")
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def _setupPreferenceWidgets(self) -> None: def _setupPreferenceWidgets(self):
self._setupFilterHardnessBox() self._setupFilterHardnessBox()
self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self.widgetsVLayout.addLayout(self.filterHardnessHLayout)
self._setupAddCheckbox("matchScaledBox", tr("Match pictures of different dimensions")) self._setupAddCheckbox("matchScaledBox", tr("Match pictures of different dimensions"))
@@ -37,14 +33,9 @@ class PreferencesDialog(PreferencesDialogBase):
) )
self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches) self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches)
self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False)
cache_form = QFormLayout()
cache_form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio)
self.widgetsVLayout.addLayout(cache_form)
self._setupBottomPart() self._setupBottomPart()
def _setupDisplayPage(self) -> None: def _setupDisplayPage(self):
super()._setupDisplayPage() super()._setupDisplayPage()
self._setupAddCheckbox("details_dialog_override_theme_icons", tr("Override theme icons in viewer toolbar")) self._setupAddCheckbox("details_dialog_override_theme_icons", tr("Override theme icons in viewer toolbar"))
self.details_dialog_override_theme_icons.setToolTip( self.details_dialog_override_theme_icons.setToolTip(
@@ -64,9 +55,8 @@ show scrollbars to span the view around"
) )
self.details_groupbox_layout.insertWidget(index + 2, self.details_dialog_viewers_show_scrollbars) self.details_groupbox_layout.insertWidget(index + 2, self.details_dialog_viewers_show_scrollbars)
def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None: def _load(self, prefs, setchecked, section):
setchecked(self.matchScaledBox, prefs.match_scaled) setchecked(self.matchScaledBox, prefs.match_scaled)
self.cacheTypeRadio.selected_index = 1 if prefs.picture_cache_type == "shelve" else 0
# Update UI state based on selected scan type # Update UI state based on selected scan type
scan_type = prefs.get_scan_type(AppMode.PICTURE) scan_type = prefs.get_scan_type(AppMode.PICTURE)
@@ -75,8 +65,7 @@ show scrollbars to span the view around"
setchecked(self.details_dialog_override_theme_icons, prefs.details_dialog_override_theme_icons) 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) setchecked(self.details_dialog_viewers_show_scrollbars, prefs.details_dialog_viewers_show_scrollbars)
def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None: def _save(self, prefs, ischecked):
prefs.match_scaled = ischecked(self.matchScaledBox) prefs.match_scaled = ischecked(self.matchScaledBox)
prefs.picture_cache_type = "shelve" if self.cacheTypeRadio.selected_index == 1 else "sqlite"
prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons) prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons)
prefs.details_dialog_viewers_show_scrollbars = ischecked(self.details_dialog_viewers_show_scrollbars) prefs.details_dialog_viewers_show_scrollbars = ischecked(self.details_dialog_viewers_show_scrollbars)

View File

@@ -4,10 +4,9 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from typing import Any, Tuple from PyQt5.QtWidgets import QApplication, QDockWidget
from PyQt6.QtWidgets import QApplication, QDockWidget from PyQt5.QtCore import Qt, QRect, QObject, pyqtSignal
from PyQt6.QtCore import Qt, QRect, QObject, pyqtSignal from PyQt5.QtGui import QColor
from PyQt6.QtGui import QColor
from hscommon import trans from hscommon import trans
from hscommon.plat import ISLINUX from hscommon.plat import ISLINUX
@@ -127,7 +126,7 @@ class PreferencesBase(QObject):
def set_value(self, name, value): def set_value(self, name, value):
self._settings.setValue(name, _normalize_for_serialization(value)) self._settings.setValue(name, _normalize_for_serialization(value))
def saveGeometry(self, name, widget) -> None: def saveGeometry(self, name, widget):
# We save geometry under a 7-sized int array: first item is a flag # 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 # 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, # the widget is docked, third item is a Qt::DockWidgetArea enum value,
@@ -139,12 +138,12 @@ class PreferencesBase(QObject):
rect_as_list = [r.x(), r.y(), r.width(), r.height()] rect_as_list = [r.x(), r.y(), r.width(), r.height()]
self.set_value(name, [m, d, area] + rect_as_list) self.set_value(name, [m, d, area] + rect_as_list)
def restoreGeometry(self, name, widget) -> Tuple[bool, Any]: def restoreGeometry(self, name, widget):
geometry = self.get_value(name) geometry = self.get_value(name)
if geometry and len(geometry) == 7: if geometry and len(geometry) == 7:
m, d, area, x, y, w, h = geometry m, d, area, x, y, w, h = geometry
if m: if m:
widget.setWindowState(Qt.WindowState.WindowMaximized) widget.setWindowState(Qt.WindowMaximized)
else: else:
r = QRect(x, y, w, h) r = QRect(x, y, w, h)
widget.setGeometry(r) widget.setGeometry(r)
@@ -155,13 +154,15 @@ class PreferencesBase(QObject):
class Preferences(PreferencesBase): class Preferences(PreferencesBase):
def _load_values(self, settings) -> None: def _load_values(self, settings):
get = self.get_value get = self.get_value
self.filter_hardness = get("FilterHardness", self.filter_hardness) self.filter_hardness = get("FilterHardness", self.filter_hardness)
self.mix_file_kind = get("MixFileKind", self.mix_file_kind) self.mix_file_kind = get("MixFileKind", self.mix_file_kind)
self.ignore_hardlink_matches = get("IgnoreHardlinkMatches", self.ignore_hardlink_matches) self.ignore_hardlink_matches = get("IgnoreHardlinkMatches", self.ignore_hardlink_matches)
self.use_regexp = get("UseRegexp", self.use_regexp) self.use_regexp = get("UseRegexp", self.use_regexp)
self.remove_empty_folders = get("RemoveEmptyFolders", self.remove_empty_folders) self.remove_empty_folders = get("RemoveEmptyFolders", self.remove_empty_folders)
self.rehash_ignore_mtime = get("RehashIgnoreMTime", self.rehash_ignore_mtime)
self.include_exists_check = get("IncludeExistsCheck", self.include_exists_check)
self.debug_mode = get("DebugMode", self.debug_mode) self.debug_mode = get("DebugMode", self.debug_mode)
self.profile_scan = get("ProfileScan", self.profile_scan) self.profile_scan = get("ProfileScan", self.profile_scan)
self.destination_type = get("DestinationType", self.destination_type) self.destination_type = get("DestinationType", self.destination_type)
@@ -224,14 +225,15 @@ class Preferences(PreferencesBase):
self.scan_tag_genre = get("ScanTagGenre", self.scan_tag_genre) self.scan_tag_genre = get("ScanTagGenre", self.scan_tag_genre)
self.scan_tag_year = get("ScanTagYear", self.scan_tag_year) self.scan_tag_year = get("ScanTagYear", self.scan_tag_year)
self.match_scaled = get("MatchScaled", self.match_scaled) self.match_scaled = get("MatchScaled", self.match_scaled)
self.picture_cache_type = get("PictureCacheType", self.picture_cache_type)
def reset(self) -> None: def reset(self):
self.filter_hardness = 95 self.filter_hardness = 95
self.mix_file_kind = True self.mix_file_kind = True
self.use_regexp = False self.use_regexp = False
self.ignore_hardlink_matches = False self.ignore_hardlink_matches = False
self.remove_empty_folders = False self.remove_empty_folders = False
self.rehash_ignore_mtime = False
self.include_exists_check = True
self.debug_mode = False self.debug_mode = False
self.profile_scan = False self.profile_scan = False
self.destination_type = 1 self.destination_type = 1
@@ -248,8 +250,8 @@ class Preferences(PreferencesBase):
# By default use internal icons on platforms other than Linux for now # 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_override_theme_icons = False if not ISLINUX else True
self.details_dialog_viewers_show_scrollbars = True self.details_dialog_viewers_show_scrollbars = True
self.result_table_ref_foreground_color = QColor(Qt.GlobalColor.blue) self.result_table_ref_foreground_color = QColor(Qt.blue)
self.result_table_ref_background_color = QColor(Qt.GlobalColor.lightGray) self.result_table_ref_background_color = QColor(Qt.lightGray)
self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange
self.resultWindowIsMaximized = False self.resultWindowIsMaximized = False
self.resultWindowRect = None self.resultWindowRect = None
@@ -275,15 +277,16 @@ class Preferences(PreferencesBase):
self.scan_tag_genre = False self.scan_tag_genre = False
self.scan_tag_year = False self.scan_tag_year = False
self.match_scaled = False self.match_scaled = False
self.picture_cache_type = "sqlite"
def _save_values(self, settings) -> None: def _save_values(self, settings):
set_ = self.set_value set_ = self.set_value
set_("FilterHardness", self.filter_hardness) set_("FilterHardness", self.filter_hardness)
set_("MixFileKind", self.mix_file_kind) set_("MixFileKind", self.mix_file_kind)
set_("IgnoreHardlinkMatches", self.ignore_hardlink_matches) set_("IgnoreHardlinkMatches", self.ignore_hardlink_matches)
set_("UseRegexp", self.use_regexp) set_("UseRegexp", self.use_regexp)
set_("RemoveEmptyFolders", self.remove_empty_folders) set_("RemoveEmptyFolders", self.remove_empty_folders)
set_("RehashIgnoreMTime", self.rehash_ignore_mtime)
set_("IncludeExistsCheck", self.include_exists_check)
set_("DebugMode", self.debug_mode) set_("DebugMode", self.debug_mode)
set_("ProfileScan", self.profile_scan) set_("ProfileScan", self.profile_scan)
set_("DestinationType", self.destination_type) set_("DestinationType", self.destination_type)
@@ -327,7 +330,6 @@ class Preferences(PreferencesBase):
set_("ScanTagGenre", self.scan_tag_genre) set_("ScanTagGenre", self.scan_tag_genre)
set_("ScanTagYear", self.scan_tag_year) set_("ScanTagYear", self.scan_tag_year)
set_("MatchScaled", self.match_scaled) set_("MatchScaled", self.match_scaled)
set_("PictureCacheType", self.picture_cache_type)
# scan_type is special because we save it immediately when we set it. # scan_type is special because we save it immediately when we set it.
def get_scan_type(self, app_mode): def get_scan_type(self, app_mode):

View File

@@ -4,9 +4,8 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from typing import Union from PyQt5.QtCore import Qt, QSize, pyqtSlot
from PyQt6.QtCore import Qt, QSize, pyqtSlot from PyQt5.QtWidgets import (
from PyQt6.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QVBoxLayout, QVBoxLayout,
@@ -29,7 +28,7 @@ from PyQt6.QtWidgets import (
QGroupBox, QGroupBox,
QFormLayout, QFormLayout,
) )
from PyQt6.QtGui import QPixmap, QIcon, QShowEvent from PyQt5.QtGui import QPixmap, QIcon
from hscommon import desktop, plat from hscommon import desktop, plat
from hscommon.trans import trget from hscommon.trans import trget
@@ -40,11 +39,6 @@ from enum import Flag, auto
from qt.preferences import Preferences from qt.preferences import Preferences
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from qt.app import DupeGuru
tr = trget("ui") tr = trget("ui")
@@ -53,13 +47,14 @@ class Sections(Flag):
GENERAL = auto() GENERAL = auto()
DISPLAY = auto() DISPLAY = auto()
ADVANCED = auto()
DEBUG = auto() DEBUG = auto()
ALL = GENERAL | DISPLAY | DEBUG ALL = GENERAL | DISPLAY | ADVANCED | DEBUG
class PreferencesDialogBase(QDialog): class PreferencesDialogBase(QDialog):
def __init__(self, parent: QWidget, app: "DupeGuru", **kwargs): def __init__(self, parent, app, **kwargs):
flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self.app = app self.app = app
self.supportedLanguages = dict(sorted(get_langnames().items(), key=lambda item: item[1])) self.supportedLanguages = dict(sorted(get_langnames().items(), key=lambda item: item[1]))
@@ -71,7 +66,7 @@ class PreferencesDialogBase(QDialog):
self.buttonBox.accepted.connect(self.accept) self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject) self.buttonBox.rejected.connect(self.reject)
def _setupFilterHardnessBox(self) -> None: def _setupFilterHardnessBox(self):
self.filterHardnessHLayout = QHBoxLayout() self.filterHardnessHLayout = QHBoxLayout()
self.filterHardnessLabel = QLabel(self) self.filterHardnessLabel = QLabel(self)
self.filterHardnessLabel.setText(tr("Filter Hardness:")) self.filterHardnessLabel.setText(tr("Filter Hardness:"))
@@ -82,7 +77,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessHLayoutSub1 = QHBoxLayout() self.filterHardnessHLayoutSub1 = QHBoxLayout()
self.filterHardnessHLayoutSub1.setSpacing(12) self.filterHardnessHLayoutSub1.setSpacing(12)
self.filterHardnessSlider = QSlider(self) self.filterHardnessSlider = QSlider(self)
size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
size_policy.setHorizontalStretch(0) size_policy.setHorizontalStretch(0)
size_policy.setVerticalStretch(0) size_policy.setVerticalStretch(0)
size_policy.setHeightForWidth(self.filterHardnessSlider.sizePolicy().hasHeightForWidth()) size_policy.setHeightForWidth(self.filterHardnessSlider.sizePolicy().hasHeightForWidth())
@@ -90,7 +85,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessSlider.setMinimum(1) self.filterHardnessSlider.setMinimum(1)
self.filterHardnessSlider.setMaximum(100) self.filterHardnessSlider.setMaximum(100)
self.filterHardnessSlider.setTracking(True) self.filterHardnessSlider.setTracking(True)
self.filterHardnessSlider.setOrientation(Qt.Orientation.Horizontal) self.filterHardnessSlider.setOrientation(Qt.Horizontal)
self.filterHardnessHLayoutSub1.addWidget(self.filterHardnessSlider) self.filterHardnessHLayoutSub1.addWidget(self.filterHardnessSlider)
self.filterHardnessLabel = QLabel(self) self.filterHardnessLabel = QLabel(self)
self.filterHardnessLabel.setText("100") self.filterHardnessLabel.setText("100")
@@ -102,7 +97,7 @@ class PreferencesDialogBase(QDialog):
self.moreResultsLabel = QLabel(self) self.moreResultsLabel = QLabel(self)
self.moreResultsLabel.setText(tr("More Results")) self.moreResultsLabel.setText(tr("More Results"))
self.filterHardnessHLayoutSub2.addWidget(self.moreResultsLabel) self.filterHardnessHLayoutSub2.addWidget(self.moreResultsLabel)
spacer_item = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) spacer_item = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.filterHardnessHLayoutSub2.addItem(spacer_item) self.filterHardnessHLayoutSub2.addItem(spacer_item)
self.fewerResultsLabel = QLabel(self) self.fewerResultsLabel = QLabel(self)
self.fewerResultsLabel.setText(tr("Fewer Results")) self.fewerResultsLabel.setText(tr("Fewer Results"))
@@ -110,7 +105,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessVLayout.addLayout(self.filterHardnessHLayoutSub2) self.filterHardnessVLayout.addLayout(self.filterHardnessHLayoutSub2)
self.filterHardnessHLayout.addLayout(self.filterHardnessVLayout) self.filterHardnessHLayout.addLayout(self.filterHardnessVLayout)
def _setupBottomPart(self) -> None: def _setupBottomPart(self):
# The bottom part of the pref panel is always the same in all editions. # The bottom part of the pref panel is always the same in all editions.
self.copyMoveLabel = QLabel(self) self.copyMoveLabel = QLabel(self)
self.copyMoveLabel.setText(tr("Copy and Move:")) self.copyMoveLabel.setText(tr("Copy and Move:"))
@@ -126,7 +121,7 @@ class PreferencesDialogBase(QDialog):
self.customCommandEdit = QLineEdit(self) self.customCommandEdit = QLineEdit(self)
self.widgetsVLayout.addWidget(self.customCommandEdit) self.widgetsVLayout.addWidget(self.customCommandEdit)
def _setupDisplayPage(self) -> None: def _setupDisplayPage(self):
self.ui_groupbox = QGroupBox("&" + tr("General Interface")) self.ui_groupbox = QGroupBox("&" + tr("General Interface"))
layout = QVBoxLayout() layout = QVBoxLayout()
self.languageLabel = QLabel(tr("Language:"), self) self.languageLabel = QLabel(tr("Language:"), self)
@@ -151,7 +146,8 @@ On MacOS, the tab bar will fill up the window's width instead."
) )
self.use_native_dialogs.setToolTip( self.use_native_dialogs.setToolTip(
tr( tr(
"For actions such as file/folder selection use the OS native dialogs.\nSome native dialogs have limited functionality." "For actions such as file/folder selection use the OS native dialogs.\n\
Some native dialogs have limited functionality."
) )
) )
layout.addWidget(self.use_native_dialogs) layout.addWidget(self.use_native_dialogs)
@@ -177,7 +173,7 @@ On MacOS, the tab bar will fill up the window's width instead."
formlayout.addRow(tr("Reference background color:"), self.result_table_ref_background_color) formlayout.addRow(tr("Reference background color:"), self.result_table_ref_background_color)
self.result_table_delta_foreground_color = ColorPickerButton(self) self.result_table_delta_foreground_color = ColorPickerButton(self)
formlayout.addRow(tr("Delta foreground color:"), self.result_table_delta_foreground_color) formlayout.addRow(tr("Delta foreground color:"), self.result_table_delta_foreground_color)
formlayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) formlayout.setLabelAlignment(Qt.AlignLeft)
# Keep same vertical spacing as parent layout for consistency # Keep same vertical spacing as parent layout for consistency
formlayout.setVerticalSpacing(self.displayVLayout.spacing()) formlayout.setVerticalSpacing(self.displayVLayout.spacing())
@@ -219,7 +215,21 @@ use the modifier key to drag the floating window around"
details_groupbox.setLayout(self.details_groupbox_layout) details_groupbox.setLayout(self.details_groupbox_layout)
self.displayVLayout.addWidget(details_groupbox) self.displayVLayout.addWidget(details_groupbox)
def _setupDebugPage(self) -> None: 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):
self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)")) self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)"))
self._setupAddCheckbox("profile_scan_box", tr("Profile scan operation")) self._setupAddCheckbox("profile_scan_box", tr("Profile scan operation"))
self.profile_scan_box.setToolTip(tr("Profile the scan operation and save logs for optimization.")) self.profile_scan_box.setToolTip(tr("Profile the scan operation and save logs for optimization."))
@@ -231,7 +241,7 @@ use the modifier key to drag the floating window around"
) )
self.debugVLayout.addWidget(self.debug_location_label) self.debugVLayout.addWidget(self.debug_location_label)
def _setupAddCheckbox(self, name: str, label: str, parent: Union[QWidget, None] = None) -> None: def _setupAddCheckbox(self, name, label, parent=None):
if parent is None: if parent is None:
parent = self parent = self
cb = QCheckBox(parent) cb = QCheckBox(parent)
@@ -242,7 +252,7 @@ use the modifier key to drag the floating window around"
# Edition-specific # Edition-specific
pass pass
def _setupUi(self) -> None: def _setupUi(self):
self.setWindowTitle(tr("Options")) self.setWindowTitle(tr("Options"))
self.setSizeGripEnabled(False) self.setSizeGripEnabled(False)
self.setModal(True) self.setModal(True)
@@ -250,48 +260,52 @@ use the modifier key to drag the floating window around"
self.tabwidget = QTabWidget() self.tabwidget = QTabWidget()
self.page_general = QWidget() self.page_general = QWidget()
self.page_display = QWidget() self.page_display = QWidget()
self.page_advanced = QWidget()
self.page_debug = QWidget() self.page_debug = QWidget()
self.widgetsVLayout = QVBoxLayout() self.widgetsVLayout = QVBoxLayout()
self.page_general.setLayout(self.widgetsVLayout) self.page_general.setLayout(self.widgetsVLayout)
self.displayVLayout = QVBoxLayout() self.displayVLayout = QVBoxLayout()
self.displayVLayout.setSpacing(5) # arbitrary value, might conflict with style self.displayVLayout.setSpacing(5) # arbitrary value, might conflict with style
self.page_display.setLayout(self.displayVLayout) self.page_display.setLayout(self.displayVLayout)
self.advanced_vlayout = QVBoxLayout()
self.page_advanced.setLayout(self.advanced_vlayout)
self.debugVLayout = QVBoxLayout() self.debugVLayout = QVBoxLayout()
self.page_debug.setLayout(self.debugVLayout) self.page_debug.setLayout(self.debugVLayout)
self._setupPreferenceWidgets() self._setupPreferenceWidgets()
self._setupDisplayPage() self._setupDisplayPage()
self._setup_advanced_page()
self._setupDebugPage() self._setupDebugPage()
# self.mainVLayout.addLayout(self.widgetsVLayout) # self.mainVLayout.addLayout(self.widgetsVLayout)
self.buttonBox = QDialogButtonBox(self) self.buttonBox = QDialogButtonBox(self)
self.buttonBox.setStandardButtons( self.buttonBox.setStandardButtons(
QDialogButtonBox.StandardButton.Cancel QDialogButtonBox.Cancel | QDialogButtonBox.Ok | QDialogButtonBox.RestoreDefaults
| QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.RestoreDefaults
) )
self.mainVLayout.addWidget(self.tabwidget) self.mainVLayout.addWidget(self.tabwidget)
self.mainVLayout.addWidget(self.buttonBox) self.mainVLayout.addWidget(self.buttonBox)
self.layout().setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) self.layout().setSizeConstraint(QLayout.SetFixedSize)
self.tabwidget.addTab(self.page_general, tr("General")) self.tabwidget.addTab(self.page_general, tr("General"))
self.tabwidget.addTab(self.page_display, tr("Display")) self.tabwidget.addTab(self.page_display, tr("Display"))
self.tabwidget.addTab(self.page_advanced, tr("Advanced"))
self.tabwidget.addTab(self.page_debug, tr("Debug")) self.tabwidget.addTab(self.page_debug, tr("Debug"))
self.displayVLayout.addStretch(0) self.displayVLayout.addStretch(0)
self.widgetsVLayout.addStretch(0) self.widgetsVLayout.addStretch(0)
self.advanced_vlayout.addStretch(0)
self.debugVLayout.addStretch(0) self.debugVLayout.addStretch(0)
def _load(self, prefs, setchecked, section) -> None: def _load(self, prefs, setchecked, section):
# Edition-specific # Edition-specific
pass pass
def _save(self, prefs, ischecked) -> None: def _save(self, prefs, ischecked):
# Edition-specific # Edition-specific
pass pass
def load(self, prefs: Preferences = None, section: Sections = Sections.ALL) -> None: def load(self, prefs=None, section=Sections.ALL):
if prefs is None: if prefs is None:
prefs = self.app.prefs prefs = self.app.prefs
def setchecked(cb: QCheckBox, b: bool) -> None: def setchecked(cb, b):
cb.setCheckState(Qt.CheckState.Checked if b else Qt.CheckState.Unchecked) cb.setCheckState(Qt.Checked if b else Qt.Unchecked)
if section & Sections.GENERAL: if section & Sections.GENERAL:
self.filterHardnessSlider.setValue(prefs.filter_hardness) self.filterHardnessSlider.setValue(prefs.filter_hardness)
@@ -326,22 +340,27 @@ use the modifier key to drag the floating window around"
except KeyError: except KeyError:
selected_lang = self.supportedLanguages["en"] selected_lang = self.supportedLanguages["en"]
self.languageComboBox.setCurrentText(selected_lang) self.languageComboBox.setCurrentText(selected_lang)
if section & Sections.ADVANCED:
setchecked(self.rehash_ignore_mtime_box, prefs.rehash_ignore_mtime)
setchecked(self.include_exists_check_box, prefs.include_exists_check)
if section & Sections.DEBUG: if section & Sections.DEBUG:
setchecked(self.debugModeBox, prefs.debug_mode) setchecked(self.debugModeBox, prefs.debug_mode)
setchecked(self.profile_scan_box, prefs.profile_scan) setchecked(self.profile_scan_box, prefs.profile_scan)
self._load(prefs, setchecked, section) self._load(prefs, setchecked, section)
def save(self) -> None: def save(self):
prefs = self.app.prefs prefs = self.app.prefs
prefs.filter_hardness = self.filterHardnessSlider.value() prefs.filter_hardness = self.filterHardnessSlider.value()
def ischecked(cb: QCheckBox) -> bool: def ischecked(cb):
return cb.checkState() == Qt.CheckState.Checked return cb.checkState() == Qt.Checked
prefs.mix_file_kind = ischecked(self.mixFileKindBox) prefs.mix_file_kind = ischecked(self.mixFileKindBox)
prefs.use_regexp = ischecked(self.useRegexpBox) prefs.use_regexp = ischecked(self.useRegexpBox)
prefs.remove_empty_folders = ischecked(self.removeEmptyFoldersBox) prefs.remove_empty_folders = ischecked(self.removeEmptyFoldersBox)
prefs.ignore_hardlink_matches = ischecked(self.ignoreHardlinkMatches) prefs.ignore_hardlink_matches = ischecked(self.ignoreHardlinkMatches)
prefs.rehash_ignore_mtime = ischecked(self.rehash_ignore_mtime_box)
prefs.include_exists_check = ischecked(self.include_exists_check_box)
prefs.debug_mode = ischecked(self.debugModeBox) prefs.debug_mode = ischecked(self.debugModeBox)
prefs.profile_scan = ischecked(self.profile_scan_box) prefs.profile_scan = ischecked(self.profile_scan_box)
prefs.reference_bold_font = ischecked(self.reference_bold_font) prefs.reference_bold_font = ischecked(self.reference_bold_font)
@@ -371,13 +390,13 @@ use the modifier key to drag the floating window around"
self.app.prefs.language = lang_code self.app.prefs.language = lang_code
self._save(prefs, ischecked) self._save(prefs, ischecked)
def resetToDefaults(self, section_to_update: Sections) -> None: def resetToDefaults(self, section_to_update):
self.load(Preferences(), section_to_update) self.load(Preferences(), section_to_update)
# --- Events # --- Events
def buttonClicked(self, button: QPushButton) -> None: def buttonClicked(self, button):
role = self.buttonBox.buttonRole(button) role = self.buttonBox.buttonRole(button)
if role == QDialogButtonBox.ButtonRole.ResetRole: if role == QDialogButtonBox.ResetRole:
current_tab = self.tabwidget.currentWidget() current_tab = self.tabwidget.currentWidget()
section_to_update = Sections.ALL section_to_update = Sections.ALL
if current_tab is self.page_general: if current_tab is self.page_general:
@@ -388,31 +407,30 @@ use the modifier key to drag the floating window around"
section_to_update = Sections.DEBUG section_to_update = Sections.DEBUG
self.resetToDefaults(section_to_update) self.resetToDefaults(section_to_update)
def showEvent(self, event: QShowEvent) -> None: def showEvent(self, event):
# have to do this here as the frameGeometry is not correct until shown # have to do this here as the frameGeometry is not correct until shown
move_to_screen_center(self) move_to_screen_center(self)
super().showEvent(event) super().showEvent(event)
class ColorPickerButton(QPushButton): class ColorPickerButton(QPushButton):
def __init__(self, parent: QWidget) -> None: def __init__(self, parent):
super().__init__(parent) super().__init__(parent)
self.parent = parent
self.color = None self.color = None
self.clicked.connect(self.onClicked) self.clicked.connect(self.onClicked)
@pyqtSlot() @pyqtSlot()
def onClicked(self) -> None: def onClicked(self):
color = QColorDialog.getColor( color = QColorDialog.getColor(self.color if self.color is not None else Qt.white, self.parent)
self.color if self.color is not None else Qt.GlobalColor.white, self.parentWidget()
)
self.setColor(color) self.setColor(color)
def setColor(self, color) -> None: def setColor(self, color):
size = QSize(16, 16) size = QSize(16, 16)
px = QPixmap(size) px = QPixmap(size)
if color is None: if color is None:
size.setWidth(0) size.width = 0
size.setHeight(0) size.height = 0
elif not color.isValid(): elif not color.isValid():
return return
else: else:

View File

@@ -6,9 +6,8 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt6.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt6.QtGui import QShowEvent from PyQt5.QtWidgets import (
from PyQt6.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
@@ -18,10 +17,8 @@ from PyQt6.QtWidgets import (
QLabel, QLabel,
QTableView, QTableView,
QAbstractItemView, QAbstractItemView,
QWidget,
) )
from core.gui.problem_dialog import ProblemDialog as ProblemDiaglogModel
from qt.util import move_to_screen_center from qt.util import move_to_screen_center
from hscommon.trans import trget from hscommon.trans import trget
from qt.problem_table import ProblemTable from qt.problem_table import ProblemTable
@@ -30,56 +27,52 @@ tr = trget("ui")
class ProblemDialog(QDialog): class ProblemDialog(QDialog):
def __init__(self, parent: QWidget, model: ProblemDiaglogModel, **kwargs) -> None: def __init__(self, parent, model, **kwargs):
flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs) 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() self._setupUi()
self.model = model
self.model.view = self
self.table = ProblemTable(self.model.problem_table, view=self.tableView)
def _setupUi(self) -> None: self.revealButton.clicked.connect(self.model.reveal_selected_dupe)
self.closeButton.clicked.connect(self.accept)
def _setupUi(self):
self.setWindowTitle(tr("Problems!")) self.setWindowTitle(tr("Problems!"))
self.resize(413, 323) self.resize(413, 323)
main_layout = QVBoxLayout(self) self.verticalLayout = QVBoxLayout(self)
notice_label = QLabel(self) self.label = QLabel(self)
msg = tr( msg = tr(
"There were problems processing some (or all) of the files. The cause of " "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 " "these problems are described in the table below. Those files were not "
"removed from your results." "removed from your results."
) )
notice_label.setText(msg) self.label.setText(msg)
notice_label.setWordWrap(True) self.label.setWordWrap(True)
main_layout.addWidget(notice_label) 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)
self.table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) def showEvent(self, event):
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 # have to do this here as the frameGeometry is not correct until shown
move_to_screen_center(self) move_to_screen_center(self)
super().showEvent(event) super().showEvent(event)

View File

@@ -5,7 +5,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtWidgets import QProgressDialog from PyQt5.QtWidgets import QDialog, QMessageBox, QVBoxLayout, QLabel, QProgressBar, QPushButton
from hscommon.trans import tr from hscommon.trans import tr
@@ -25,37 +25,60 @@ class ProgressWindow:
def refresh(self): # Labels def refresh(self): # Labels
if self._window is not None: if self._window is not None:
self._window.setWindowTitle(self.model.jobdesc_textfield.text) self._window.setWindowTitle(self.model.jobdesc_textfield.text)
self._window.setLabelText(self.model.progressdesc_textfield.text) self._label.setText(self.model.progressdesc_textfield.text)
def set_progress(self, last_progress): def set_progress(self, last_progress):
if self._window is not None: if self._window is not None:
if last_progress < 0: if last_progress < 0:
self._window.setRange(0, 0) self._progress_bar.setRange(0, 0)
else: else:
self._window.setRange(0, 100) self._progress_bar.setRange(0, 100)
self._window.setValue(last_progress) self._progress_bar.setValue(last_progress)
def show(self): def show(self):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
self._window = QProgressDialog("", tr("Cancel"), 0, 100, self.parent, flags) self._window = QDialog(self.parent, flags)
self._setup_ui()
self._window.setModal(True) self._window.setModal(True)
self._window.setAutoReset(False)
self._window.setAutoClose(False)
self._timer = QTimer(self._window) self._timer = QTimer(self._window)
self._timer.timeout.connect(self.model.pulse) self._timer.timeout.connect(self.model.pulse)
self._window.show() self._window.show()
self._window.canceled.connect(self.model.cancel)
self._timer.start(500) self._timer.start(500)
def _setup_ui(self):
self._window.setWindowTitle(tr("Cancel"))
vertical_layout = QVBoxLayout(self._window)
self._label = QLabel("", self._window)
vertical_layout.addWidget(self._label)
self._progress_bar = QProgressBar(self._window)
self._progress_bar.setRange(0, 100)
vertical_layout.addWidget(self._progress_bar)
self._cancel_button = QPushButton(tr("Cancel"), self._window)
self._cancel_button.clicked.connect(self.cancel)
vertical_layout.addWidget(self._cancel_button)
def cancel(self):
if self._window is not None:
confirm_dialog = QMessageBox(
QMessageBox.Icon.Question,
tr("Cancel?"),
tr("Are you sure you want to cancel? All progress will be lost."),
QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes,
self._window,
)
confirm_dialog.setDefaultButton(QMessageBox.StandardButton.No)
result = confirm_dialog.exec_()
if result != QMessageBox.StandardButton.Yes:
return
self.close()
def close(self): def close(self):
# it seems it is possible for close to be called without a corresponding # it seems it is possible for close to be called without a corresponding
# show, only perform a close if there is a window to close # show, only perform a close if there is a window to close
if self._window is not None: if self._window is not None:
self._timer.stop() self._timer.stop()
del self._timer del self._timer
# For some weird reason, canceled() signal is sent upon close, whether the user canceled
# or not. If we don't want a false cancellation, we have to disconnect it.
self._window.canceled.disconnect()
self._window.close() self._window.close()
self._window.setParent(None) self._window.setParent(None)
self._window = None self._window = None
self.model.cancel()

View File

@@ -4,23 +4,29 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from typing import Callable from PyQt5.QtCore import QSize
from PyQt6.QtCore import QSize from PyQt5.QtWidgets import (
from PyQt6.QtWidgets import QSpinBox, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, QCheckBox QSpinBox,
QVBoxLayout,
QHBoxLayout,
QLabel,
QSizePolicy,
QSpacerItem,
QWidget,
)
from hscommon.trans import trget from hscommon.trans import trget
from core.app import AppMode from core.app import AppMode
from core.scanner import ScanType from core.scanner import ScanType
from qt.preferences import Preferences
from qt.preferences_dialog import PreferencesDialogBase, Sections from qt.preferences_dialog import PreferencesDialogBase
tr = trget("ui") tr = trget("ui")
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def _setupPreferenceWidgets(self) -> None: def _setupPreferenceWidgets(self):
self._setupFilterHardnessBox() self._setupFilterHardnessBox()
self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self.widgetsVLayout.addLayout(self.filterHardnessHLayout)
self.widget = QWidget(self) self.widget = QWidget(self)
@@ -44,7 +50,7 @@ class PreferencesDialog(PreferencesDialogBase):
self._setupAddCheckbox("ignoreSmallFilesBox", tr("Ignore files smaller than"), self.widget) self._setupAddCheckbox("ignoreSmallFilesBox", tr("Ignore files smaller than"), self.widget)
self.horizontalLayout_2.addWidget(self.ignoreSmallFilesBox) self.horizontalLayout_2.addWidget(self.ignoreSmallFilesBox)
self.sizeThresholdSpinBox = QSpinBox(self.widget) self.sizeThresholdSpinBox = QSpinBox(self.widget)
size_policy = QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) size_policy = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
size_policy.setHorizontalStretch(0) size_policy.setHorizontalStretch(0)
size_policy.setVerticalStretch(0) size_policy.setVerticalStretch(0)
size_policy.setHeightForWidth(self.sizeThresholdSpinBox.sizePolicy().hasHeightForWidth()) size_policy.setHeightForWidth(self.sizeThresholdSpinBox.sizePolicy().hasHeightForWidth())
@@ -55,14 +61,14 @@ class PreferencesDialog(PreferencesDialogBase):
self.label_6 = QLabel(self.widget) self.label_6 = QLabel(self.widget)
self.label_6.setText(tr("KB")) self.label_6.setText(tr("KB"))
self.horizontalLayout_2.addWidget(self.label_6) self.horizontalLayout_2.addWidget(self.label_6)
spacer_item1 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) spacer_item1 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout_2.addItem(spacer_item1) self.horizontalLayout_2.addItem(spacer_item1)
self.verticalLayout_4.addLayout(self.horizontalLayout_2) self.verticalLayout_4.addLayout(self.horizontalLayout_2)
self.horizontalLayout_2a = QHBoxLayout() self.horizontalLayout_2a = QHBoxLayout()
self._setupAddCheckbox("ignoreLargeFilesBox", tr("Ignore files larger than"), self.widget) self._setupAddCheckbox("ignoreLargeFilesBox", tr("Ignore files larger than"), self.widget)
self.horizontalLayout_2a.addWidget(self.ignoreLargeFilesBox) self.horizontalLayout_2a.addWidget(self.ignoreLargeFilesBox)
self.sizeSaturationSpinBox = QSpinBox(self.widget) self.sizeSaturationSpinBox = QSpinBox(self.widget)
size_policy = QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) size_policy = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
self.sizeSaturationSpinBox.setSizePolicy(size_policy) self.sizeSaturationSpinBox.setSizePolicy(size_policy)
self.sizeSaturationSpinBox.setMaximumSize(QSize(300, 16777215)) self.sizeSaturationSpinBox.setMaximumSize(QSize(300, 16777215))
self.sizeSaturationSpinBox.setRange(0, 1000000) self.sizeSaturationSpinBox.setRange(0, 1000000)
@@ -70,7 +76,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.label_6a = QLabel(self.widget) self.label_6a = QLabel(self.widget)
self.label_6a.setText(tr("MB")) self.label_6a.setText(tr("MB"))
self.horizontalLayout_2a.addWidget(self.label_6a) self.horizontalLayout_2a.addWidget(self.label_6a)
spacer_item3 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) spacer_item3 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout_2a.addItem(spacer_item3) self.horizontalLayout_2a.addItem(spacer_item3)
self.verticalLayout_4.addLayout(self.horizontalLayout_2a) self.verticalLayout_4.addLayout(self.horizontalLayout_2a)
self.horizontalLayout_2b = QHBoxLayout() self.horizontalLayout_2b = QHBoxLayout()
@@ -88,7 +94,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.label_6b = QLabel(self.widget) self.label_6b = QLabel(self.widget)
self.label_6b.setText(tr("MB")) self.label_6b.setText(tr("MB"))
self.horizontalLayout_2b.addWidget(self.label_6b) self.horizontalLayout_2b.addWidget(self.label_6b)
spacer_item2 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) spacer_item2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout_2b.addItem(spacer_item2) self.horizontalLayout_2b.addItem(spacer_item2)
self.verticalLayout_4.addLayout(self.horizontalLayout_2b) self.verticalLayout_4.addLayout(self.horizontalLayout_2b)
self._setupAddCheckbox( self._setupAddCheckbox(
@@ -100,7 +106,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.widgetsVLayout.addWidget(self.widget) self.widgetsVLayout.addWidget(self.widget)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None: def _load(self, prefs, setchecked, section):
setchecked(self.matchSimilarBox, prefs.match_similar) setchecked(self.matchSimilarBox, prefs.match_similar)
setchecked(self.wordWeightingBox, prefs.word_weighting) setchecked(self.wordWeightingBox, prefs.word_weighting)
setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files) setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files)
@@ -117,7 +123,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.matchSimilarBox.setEnabled(word_based) self.matchSimilarBox.setEnabled(word_based)
self.wordWeightingBox.setEnabled(word_based) self.wordWeightingBox.setEnabled(word_based)
def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None: def _save(self, prefs, ischecked):
prefs.match_similar = ischecked(self.matchSimilarBox) prefs.match_similar = ischecked(self.matchSimilarBox)
prefs.word_weighting = ischecked(self.wordWeightingBox) prefs.word_weighting = ischecked(self.wordWeightingBox)
prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox) prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox)

View File

@@ -11,18 +11,22 @@ import io
import os.path as op import os.path as op
import os import os
import logging import logging
from typing import List, Union
from core.util import executable_folder from core.util import executable_folder
from hscommon.util import first from hscommon.util import first
from hscommon.plat import ISWINDOWS from hscommon.plat import ISWINDOWS
from PyQt6.QtCore import QStandardPaths, QSettings from PyQt5.QtCore import QStandardPaths, QSettings
from PyQt6.QtGui import QPixmap, QIcon, QGuiApplication, QAction from PyQt5.QtGui import QPixmap, QIcon, QGuiApplication
from PyQt6.QtWidgets import QSpacerItem, QSizePolicy, QHBoxLayout, QWidget from PyQt5.QtWidgets import (
QSpacerItem,
QSizePolicy,
QAction,
QHBoxLayout,
)
def move_to_screen_center(widget: QWidget) -> None: def move_to_screen_center(widget):
frame = widget.frameGeometry() frame = widget.frameGeometry()
if QGuiApplication.screenAt(frame.center()) is None: if QGuiApplication.screenAt(frame.center()) is None:
# if center not on any screen use default screen # if center not on any screen use default screen
@@ -39,21 +43,21 @@ def move_to_screen_center(widget: QWidget) -> None:
widget.move(frame.topLeft()) widget.move(frame.topLeft())
def vertical_spacer(size: Union[int, None] = None) -> QSpacerItem: def vertical_spacer(size=None):
if size: if size:
return QSpacerItem(1, size, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) return QSpacerItem(1, size, QSizePolicy.Fixed, QSizePolicy.Fixed)
else: else:
return QSpacerItem(1, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding) return QSpacerItem(1, 1, QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
def horizontal_spacer(size: Union[int, None] = None) -> QSpacerItem: def horizontal_spacer(size=None):
if size: if size:
return QSpacerItem(size, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) return QSpacerItem(size, 1, QSizePolicy.Fixed, QSizePolicy.Fixed)
else: else:
return QSpacerItem(1, 1, QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) return QSpacerItem(1, 1, QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
def horizontal_wrap(widgets: List[Union[QWidget, int, None]]) -> QHBoxLayout: def horizontal_wrap(widgets):
"""Wrap all widgets in `widgets` in a horizontal layout. """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 If, instead of placing a widget in your list, you place an int or None, an horizontal spacer
@@ -73,7 +77,7 @@ def create_actions(actions, target):
for name, shortcut, icon, desc, func in actions: for name, shortcut, icon, desc, func in actions:
action = QAction(target) action = QAction(target)
if icon: if icon:
action.setIcon(QIcon(QPixmap(":/" + icon))) # TODO stop using qrc file path action.setIcon(QIcon(QPixmap(":/" + icon)))
if shortcut: if shortcut:
action.setShortcut(shortcut) action.setShortcut(shortcut)
action.setText(desc) action.setText(desc)
@@ -96,11 +100,11 @@ def set_accel_keys(menu):
action.setText(newtext) action.setText(newtext)
def get_appdata(portable: bool = False) -> str: def get_appdata(portable=False):
if portable: if portable:
return op.join(executable_folder(), "data") return op.join(executable_folder(), "data")
else: else:
return QStandardPaths.standardLocations(QStandardPaths.StandardLocation.AppDataLocation)[0] return QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)[0]
class SysWrapper(io.IOBase): class SysWrapper(io.IOBase):
@@ -136,18 +140,18 @@ def escape_amp(s):
return s.replace("&", "&&") return s.replace("&", "&&")
def create_qsettings() -> QSettings: def create_qsettings():
# Create a QSettings instance with the correct arguments. # Create a QSettings instance with the correct arguments.
config_location = op.join(executable_folder(), "settings.ini") config_location = op.join(executable_folder(), "settings.ini")
if op.isfile(config_location): if op.isfile(config_location):
settings = QSettings(config_location, QSettings.Format.IniFormat) settings = QSettings(config_location, QSettings.IniFormat)
settings.setValue("Portable", True) settings.setValue("Portable", True)
elif ISWINDOWS: elif ISWINDOWS:
# On windows use an ini file in the AppDataLocation instead of registry if possible as it # 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. # makes it easier for a user to clear it out when there are issues.
locations = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.AppDataLocation) locations = QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)
if locations: if locations:
settings = QSettings(op.join(locations[0], "settings.ini"), QSettings.Format.IniFormat) settings = QSettings(op.join(locations[0], "settings.ini"), QSettings.IniFormat)
else: else:
settings = QSettings() settings = QSettings()
settings.setValue("Portable", False) settings.setValue("Portable", False)

View File

@@ -1,4 +1,4 @@
pytest>=6,<7.2 pytest>=6,<7
flake8 flake8
black black
pyinstaller>=4.5,<5.0; sys_platform != 'linux' pyinstaller>=4.5,<5.0; sys_platform != 'linux'

View File

@@ -1,9 +1,9 @@
distro>=1.5.0, <2.0 distro>=1.5.0
mutagen>=1.44.0, <2.0 mutagen>=1.44.0
polib>=1.1.0, <2.0 polib>=1.1.0
PyQt6 >=6.3,<7.0; sys_platform != 'linux' PyQt5 >=5.14.1,<6.0; sys_platform != 'linux'
pywin32>=228; sys_platform == 'win32' pywin32>=228; sys_platform == 'win32'
semantic-version>=2.0.0,<3.0.0 semantic-version>=2.0.0,<3.0.0
Send2Trash>=1.3.0 Send2Trash>=1.3.0
sphinx>=5.0.0, <6.0 sphinx>=3.0.0
xxhash>=3.0.0,<4.0.0 xxhash>=3.0.0,<4.0.0

17
run.py
View File

@@ -9,9 +9,9 @@ import sys
import os.path as op import os.path as op
import gc import gc
from PyQt6.QtCore import QDir from PyQt5.QtCore import QCoreApplication
from PyQt6.QtGui import QIcon from PyQt5.QtGui import QIcon, QPixmap
from PyQt6.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from hscommon.trans import install_gettext_trans_under_qt from hscommon.trans import install_gettext_trans_under_qt
from qt.error_report_dialog import install_excepthook from qt.error_report_dialog import install_excepthook
@@ -48,10 +48,9 @@ def setup_signals():
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
QApplication.setOrganizationName("Hardcoded Software") QCoreApplication.setOrganizationName("Hardcoded Software")
QApplication.setApplicationName(__appname__) QCoreApplication.setApplicationName(__appname__)
QApplication.setApplicationVersion(__version__) QCoreApplication.setApplicationVersion(__version__)
QDir.addSearchPath("images", op.join(BASE_PATH, "images"))
setup_qt_logging() setup_qt_logging()
settings = create_qsettings() settings = create_qsettings()
lang = settings.value("Language") lang = settings.value("Language")
@@ -62,7 +61,7 @@ def main():
# Let the Python interpreter runs every 500ms to handle signals. This is # Let the Python interpreter runs every 500ms to handle signals. This is
# required because Python cannot handle signals while the Qt event loop is # required because Python cannot handle signals while the Qt event loop is
# running. # running.
from PyQt6.QtCore import QTimer from PyQt5.QtCore import QTimer
timer = QTimer() timer = QTimer()
timer.start(500) timer.start(500)
@@ -71,7 +70,7 @@ def main():
# has been installed # has been installed
from qt.app import DupeGuru from qt.app import DupeGuru
app.setWindowIcon(QIcon(f"images:{DupeGuru.LOGO_NAME}_32.png")) app.setWindowIcon(QIcon(QPixmap(f":/{DupeGuru.LOGO_NAME}")))
global dgapp global dgapp
dgapp = DupeGuru() dgapp = DupeGuru()
install_excepthook("https://github.com/arsenetar/dupeguru/issues") install_excepthook("https://github.com/arsenetar/dupeguru/issues")

View File

@@ -32,15 +32,15 @@ install_requires =
Send2Trash>=1.3.0 Send2Trash>=1.3.0
mutagen>=1.45.1 mutagen>=1.45.1
distro>=1.5.0 distro>=1.5.0
PyQt6 >=6.3.0,<7.0; sys_platform != 'linux' PyQt5 >=5.14.1,<6.0; sys_platform != 'linux'
pywin32>=228; sys_platform == 'win32' pywin32>=228; sys_platform == 'win32'
semantic-version>=2.0.0,<3.0.0 semantic-version>=2.0.0,<3.0.0
xxhash>=3.0.0,<4.0.0 xxhash>=3.0.0,<4.0.0
setup_requires = setup_requires =
sphinx>=5.0.0 sphinx>=3.0.0
polib>=1.1.0 polib>=1.1.0
tests_require = tests_require =
pytest >=7,<8 pytest >=6,<7
include_package_data = true include_package_data = true
[options.entry_points] [options.entry_points]

View File

@@ -245,7 +245,7 @@ Section "Uninstall"
; Remove Files & Folders in Install Folder ; Remove Files & Folders in Install Folder
RMDir /r "$INSTDIR\core" RMDir /r "$INSTDIR\core"
RMDir /r "$INSTDIR\help" RMDir /r "$INSTDIR\help"
RMDir /r "$INSTDIR\PyQt6" RMDir /r "$INSTDIR\PyQt5"
RMDir /r "$INSTDIR\qt" RMDir /r "$INSTDIR\qt"
RMDir /r "$INSTDIR\locale" RMDir /r "$INSTDIR\locale"
Delete "$INSTDIR\*.exe" Delete "$INSTDIR\*.exe"

View File

@@ -16,7 +16,7 @@ deps =
-r{toxinidir}/requirements-extra.txt -r{toxinidir}/requirements-extra.txt
[flake8] [flake8]
exclude = .tox,env,build,cocoalib,cocoa,help,./qt/dg_rc.py,cocoa/run_template.py,./pkg exclude = .tox,env*,build,help,qt/dg_rc.py,pkg
max-line-length = 120 max-line-length = 120
select = C,E,F,W,B,B950 select = C,E,F,W,B,B950
extend-ignore = E203, E501 extend-ignore = E203,W503