Merge remote-tracking branch 'upstream/master' into colors-bytes

This commit is contained in:
Andrew Senetar 2023-01-12 00:14:17 -06:00
commit 8c5e18b980
Signed by: arsenetar
GPG Key ID: C63300DCE48AB2F1
100 changed files with 528 additions and 580 deletions

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

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

@ -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

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):
@ -555,7 +553,9 @@ class DupeGuru(Broadcaster):
# a workaround to make the damn thing work. # a workaround to make the damn thing work.
exepath, args = match.groups() exepath, args = match.groups()
path, exename = op.split(exepath) path, exename = op.split(exepath)
p = subprocess.Popen(exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) p = subprocess.Popen(
exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
output = p.stdout.read() output = p.stdout.read()
logging.info("Custom command %s %s: %s", exename, args, output) logging.info("Custom command %s %s: %s", exename, args, output)
else: else:
@ -792,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

@ -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

@ -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 bytes_to_colors, colors_to_bytes
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 bytes_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_bytes(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, bytes_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

@ -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
@ -50,13 +51,6 @@ except Exception:
def get_cache(cache_path, readonly=False): def get_cache(cache_path, readonly=False):
if cache_path.endswith("shelve"):
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) return SqliteCache(cache_path, readonly=readonly)

View File

@ -2,16 +2,14 @@
* 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"
static PyObject* static PyObject *cache_bytes_to_colors(PyObject *self, PyObject *args) {
cache_bytes_to_colors(PyObject *self, PyObject *args)
{
char *y; char *y;
Py_ssize_t char_count, i, color_count; Py_ssize_t char_count, i, color_count;
PyObject *result; PyObject *result;
@ -47,12 +45,12 @@ cache_bytes_to_colors(PyObject *self, PyObject *args)
} }
static PyMethodDef CacheMethods[] = { static PyMethodDef CacheMethods[] = {
{"bytes_to_colors", cache_bytes_to_colors, METH_VARARGS, "Transform the bytes 's' into a list of 3 sized tuples."}, {"bytes_to_colors", cache_bytes_to_colors, METH_VARARGS,
"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,
@ -60,12 +58,9 @@ static struct PyModuleDef CacheDef = {
NULL, NULL,
NULL, NULL,
NULL, NULL,
NULL NULL};
};
PyObject * PyObject *PyInit__cache(void) {
PyInit__cache(void)
{
PyObject *m = PyModule_Create(&CacheDef); PyObject *m = PyModule_Create(&CacheDef);
if (m == NULL) { if (m == NULL) {
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

@ -12,7 +12,6 @@ from hscommon.testutil import eq_
try: try:
from core.pe.cache import colors_to_bytes, bytes_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.")
@ -134,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

@ -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

@ -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

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

@ -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-pyqt5, 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

@ -192,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()

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

@ -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,11 +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 PyQt5.QtWidgets import QFormLayout
from PyQt5.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.radio_box import RadioBox
from core.scanner import ScanType from core.scanner import ScanType
from core.app import AppMode from core.app import AppMode
@ -35,11 +33,6 @@ 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.AlignLeft)
cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio)
self.widgetsVLayout.addLayout(cache_form)
self._setupBottomPart() self._setupBottomPart()
def _setupDisplayPage(self): def _setupDisplayPage(self):
@ -64,7 +57,6 @@ show scrollbars to span the view around"
def _load(self, prefs, setchecked, section): 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,6 +67,5 @@ show scrollbars to span the view around"
def _save(self, prefs, ischecked): 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

@ -161,6 +161,8 @@ class Preferences(PreferencesBase):
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)
@ -223,7 +225,6 @@ 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): def reset(self):
self.filter_hardness = 95 self.filter_hardness = 95
@ -231,6 +232,8 @@ class Preferences(PreferencesBase):
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
@ -274,7 +277,6 @@ 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): def _save_values(self, settings):
set_ = self.set_value set_ = self.set_value
@ -283,6 +285,8 @@ class Preferences(PreferencesBase):
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)
@ -326,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

@ -47,8 +47,9 @@ 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):
@ -145,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)
@ -213,6 +215,20 @@ 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 _setup_advanced_page(self):
tab_label = QLabel(
tr(
"These options are for advanced users or for very specific situations, \
most users should not have to modify these."
),
wordWrap=True,
)
self.advanced_vlayout.addWidget(tab_label)
self._setupAddCheckbox("include_exists_check_box", tr("Include existence check after scan completion"))
self.advanced_vlayout.addWidget(self.include_exists_check_box)
self._setupAddCheckbox("rehash_ignore_mtime_box", tr("Ignore difference in mtime when loading cached digests"))
self.advanced_vlayout.addWidget(self.rehash_ignore_mtime_box)
def _setupDebugPage(self): def _setupDebugPage(self):
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"))
@ -244,16 +260,20 @@ 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)
@ -265,9 +285,11 @@ use the modifier key to drag the floating window around"
self.layout().setSizeConstraint(QLayout.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): def _load(self, prefs, setchecked, section):
@ -318,6 +340,9 @@ 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)
@ -334,6 +359,8 @@ use the modifier key to drag the floating window around"
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)

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

@ -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