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]
jobs:
lint:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.10
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
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: 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 .
python-version: "3.11"
- uses: pre-commit/action@v3.0.0
test:
needs: [lint, format]
needs: [pre-commit]
runs-on: ${{ matrix.os }}
strategy:
matrix:
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:
- os: macos-latest
python-version: 3.7
@ -53,17 +32,21 @@ jobs:
python-version: 3.8
- os: macos-latest
python-version: 3.9
- os: macos-latest
python-version: "3.10"
- os: windows-latest
python-version: 3.7
- os: windows-latest
python-version: 3.8
- os: windows-latest
python-version: 3.9
- os: windows-latest
python-version: "3.10"
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- 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_lang = en
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.
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"
PICTURE_CACHE_TYPE = "sqlite" # set to 'shelve' for a ShelveCache
def __init__(self, view, portable=False):
if view.get_default(DEBUG_MODE_PREFERENCE):
logging.getLogger().setLevel(logging.DEBUG)
@ -153,7 +151,8 @@ class DupeGuru(Broadcaster):
"clean_empty_dirs": False,
"ignore_hardlink_matches": False,
"copymove_dest_type": DestType.RELATIVE,
"picture_cache_type": self.PICTURE_CACHE_TYPE,
"include_exists_check": True,
"rehash_ignore_mtime": False,
}
self.selected_dupes = []
self.details_panel = DetailsPanel(self)
@ -183,8 +182,7 @@ class DupeGuru(Broadcaster):
self.view.create_results_window()
def _get_picture_cache_path(self):
cache_type = self.options["picture_cache_type"]
cache_name = "cached_pictures.shelve" if cache_type == "shelve" else "cached_pictures.db"
cache_name = "cached_pictures.db"
return op.join(self.appdata, cache_name)
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.
exepath, args = match.groups()
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()
logging.info("Custom command %s %s: %s", exename, args, output)
else:
@ -792,6 +792,7 @@ class DupeGuru(Broadcaster):
Scans folders selected in :attr:`directories` and put the results in :attr:`results`
"""
scanner = self.SCANNER_CLASS()
fs.filesdb.ignore_mtime = self.options["rehash_ignore_mtime"] is True
if not self.directories.has_any_file():
self.view.show_message(tr("The selected directories contain no scannable file."))
return

View File

@ -97,59 +97,68 @@ class FilesDB:
schema_version = 1
schema_version_description = "Changed from md5 to xxhash if available."
create_table_query = "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER, entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)"
create_table_query = """CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER,
entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)"""
drop_table_query = "DROP TABLE IF EXISTS files;"
select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns"
select_query_ignore_mtime = "SELECT {key} FROM files WHERE path=:path AND size=:size"
insert_query = """
INSERT INTO files (path, size, mtime_ns, entry_dt, {key}) VALUES (:path, :size, :mtime_ns, datetime('now'), :value)
INSERT INTO files (path, size, mtime_ns, entry_dt, {key})
VALUES (:path, :size, :mtime_ns, datetime('now'), :value)
ON CONFLICT(path) DO UPDATE SET size=:size, mtime_ns=:mtime_ns, entry_dt=datetime('now'), {key}=:value;
"""
ignore_mtime = False
def __init__(self):
self.conn = None
self.cur = None
self.lock = None
def connect(self, path: Union[AnyStr, os.PathLike]) -> None:
self.conn = sqlite3.connect(path, check_same_thread=False)
self.cur = self.conn.cursor()
self.lock = Lock()
self._check_upgrade()
def _check_upgrade(self) -> None:
with self.lock:
has_schema = self.cur.execute(
with self.lock, self.conn as conn:
has_schema = conn.execute(
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchall()
version = None
if has_schema:
version = 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:
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:
self.cur.execute(self.drop_table_query)
self.cur.execute(
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},
)
self.cur.execute(self.create_table_query)
self.conn.commit()
conn.execute(self.create_table_query)
def clear(self) -> None:
with self.lock:
self.cur.execute(self.drop_table_query)
self.cur.execute(self.create_table_query)
with self.lock, self.conn as conn:
conn.execute(self.drop_table_query)
conn.execute(self.create_table_query)
def get(self, path: Path, key: str) -> Union[bytes, None]:
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
try:
with self.lock:
self.cur.execute(
self.select_query.format(key=key), {"path": str(path), "size": size, "mtime_ns": mtime_ns}
with self.conn as conn:
if self.ignore_mtime:
cursor = conn.execute(
self.select_query_ignore_mtime.format(key=key), {"path": str(path), "size": size}
)
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:
return result[0]
@ -163,8 +172,8 @@ class FilesDB:
size = stat.st_size
mtime_ns = stat.st_mtime_ns
try:
with self.lock:
self.cur.execute(
with self.lock, self.conn as conn:
conn.execute(
self.insert_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
)
@ -177,7 +186,6 @@ class FilesDB:
def close(self) -> None:
with self.lock:
self.cur.close()
self.conn.close()
@ -307,6 +315,14 @@ class File:
"""Returns whether this file wrapper class can handle ``path``."""
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):
if newname == self.name:
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.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError
from core.pe.cache_sqlite import SqliteCache
# OPTIMIZATION NOTES:
# 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):
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)

View File

@ -2,16 +2,14 @@
* Created On: 2010-01-30
* Copyright 2014 Hardcoded Software (http://www.hardcoded.net)
*
* This software is licensed under the "BSD" License as described in the "LICENSE" file,
* which should be included with this package. The terms are also available at
* http://www.hardcoded.net/licenses/bsd_license
* This software is licensed under the "BSD" License as described in the
* "LICENSE" file, which should be included with this package. The terms are
* also available at http://www.hardcoded.net/licenses/bsd_license
*/
#include "common.h"
static PyObject*
cache_bytes_to_colors(PyObject *self, PyObject *args)
{
static PyObject *cache_bytes_to_colors(PyObject *self, PyObject *args) {
char *y;
Py_ssize_t char_count, i, color_count;
PyObject *result;
@ -47,12 +45,12 @@ cache_bytes_to_colors(PyObject *self, PyObject *args)
}
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 */
};
static struct PyModuleDef CacheDef = {
PyModuleDef_HEAD_INIT,
static struct PyModuleDef CacheDef = {PyModuleDef_HEAD_INIT,
"_cache",
NULL,
-1,
@ -60,12 +58,9 @@ static struct PyModuleDef CacheDef = {
NULL,
NULL,
NULL,
NULL
};
NULL};
PyObject *
PyInit__cache(void)
{
PyObject *PyInit__cache(void) {
PyObject *m = PyModule_Create(&CacheDef);
if (m == NULL) {
return NULL;

View File

@ -29,7 +29,7 @@ class Photo(fs.File):
__slots__ = fs.File.__slots__ + tuple(INITIAL_INFO.keys())
# 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):
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]
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 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)]
if ignore_list:
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
big_file_size_threshold = 0
word_weighting = False
include_exists_check = True

View File

@ -12,7 +12,6 @@ from hscommon.testutil import eq_
try:
from core.pe.cache import colors_to_bytes, bytes_to_colors
from core.pe.cache_sqlite import SqliteCache
from core.pe.cache_shelve import ShelveCache
except ImportError:
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)])
class TestCaseShelveCache(BaseTestCaseCache):
def get_cache(self, dbname=None):
return ShelveCache(dbname)
class TestCaseCacheSQLEscape:
def get_cache(self):
return SqliteCache()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -178,4 +178,3 @@ Preferences are stored elsewhere:
.. _Github: https://github.com/arsenetar/dupeguru
.. _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>`_, ми зрозуміти це.
.. 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(
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:
global _trfunc, _trget
_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\problem_table.py:18
msgid "File Path"
msgstr ""
msgstr "مسار الملف"
#: core\gui\problem_table.py:19
msgid "Error Message"
msgstr ""
msgstr "رسالة خطأ"
#: core\me\prioritize.py:23
msgid "Duration"
msgstr ""
msgstr "مدة"
#: core\me\prioritize.py:30 core\me\result_table.py:23
msgid "Bitrate"
msgstr ""
msgstr "معدل البت"
#: core\me\prioritize.py:37
msgid "Samplerate"
msgstr ""
msgstr "معدل العينة"
#: core\me\result_table.py:19 core\pe\result_table.py:19 core\prioritize.py:92
#: core\se\result_table.py:19
msgid "Filename"
msgstr ""
msgstr "اسم الملف"
#: core\me\result_table.py:20 core\pe\result_table.py:20 core\prioritize.py:75
#: core\se\result_table.py:20
msgid "Folder"
msgstr ""
msgstr "مجلد"
#: core\me\result_table.py:21
msgid "Size (MB)"
msgstr ""
msgstr "الحجم (ميغا بايت)"
#: core\me\result_table.py:22
msgid "Time"
msgstr ""
msgstr "زمن"
#: core\me\result_table.py:24
msgid "Sample Rate"
msgstr ""
msgstr "معدل العينة"
#: core\me\result_table.py:25 core\pe\result_table.py:22 core\prioritize.py:65
#: core\se\result_table.py:22
msgid "Kind"
msgstr ""
msgstr "طيب القلب"
#: core\me\result_table.py:26 core\pe\result_table.py:25
#: core\prioritize.py:163 core\se\result_table.py:23
msgid "Modification"
msgstr ""
msgstr "تعديل"
#: core\me\result_table.py:27
msgid "Title"
msgstr ""
msgstr "عنوان"
#: core\me\result_table.py:28
msgid "Artist"
msgstr ""
msgstr "فنان"
#: core\me\result_table.py:29
msgid "Album"
msgstr ""
msgstr "البوم"
#: core\me\result_table.py:30
msgid "Genre"
msgstr ""
msgstr "النوع"
#: core\me\result_table.py:31
msgid "Year"
msgstr ""
msgstr "سنة"
#: core\me\result_table.py:32
msgid "Track Number"
msgstr ""
msgstr "رقم الشاحنة"
#: core\me\result_table.py:33
msgid "Comment"
msgstr ""
msgstr "تعليق"
#: core\me\result_table.py:34 core\pe\result_table.py:26
#: core\se\result_table.py:24
msgid "Match %"
msgstr ""
msgstr "مباراة ٪"
#: core\me\result_table.py:35 core\se\result_table.py:25
msgid "Words Used"
msgstr ""
msgstr "الكلمات المستخدمة"
#: core\me\result_table.py:36 core\pe\result_table.py:27
#: core\se\result_table.py:26
msgid "Dupe Count"
msgstr ""
msgstr "عدد المخادعين"
#: core\pe\prioritize.py:23 core\pe\result_table.py:23
msgid "Dimensions"
msgstr ""
msgstr "أبعاد"
#: core\pe\result_table.py:21 core\se\result_table.py:21
msgid "Size (KB)"
msgstr ""
msgstr "الحجم (كيلو بايت)"
#: core\pe\result_table.py:24
msgid "EXIF Timestamp"
msgstr ""
msgstr "الطابع الزمني EXIF"
#: core\prioritize.py:156
msgid "Size"
msgstr ""
msgstr "بحجم"

View File

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

View File

@ -36,83 +36,83 @@ msgstr ""
msgid "Sending to Trash"
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."
msgstr ""
#: core\app.py:302
#: core\app.py:304
msgid "No duplicates found."
msgstr ""
#: core\app.py:317
#: core\app.py:319
msgid "All marked files were copied successfully."
msgstr ""
#: core\app.py:319
#: core\app.py:321
msgid "All marked files were moved successfully."
msgstr ""
#: core\app.py:321
#: core\app.py:323
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:323
#: core\app.py:325
msgid "All marked files were successfully sent to Trash."
msgstr ""
#: core\app.py:328
#: core\app.py:330
msgid "Could not load file: {}"
msgstr ""
#: core\app.py:384
#: core\app.py:386
msgid "'{}' already is in the list."
msgstr ""
#: core\app.py:386
#: core\app.py:388
msgid "'{}' does not exist."
msgstr ""
#: core\app.py:394
#: core\app.py:396
msgid "All selected %d matches are going to be ignored in all subsequent scans. Continue?"
msgstr ""
#: core\app.py:471
#: core\app.py:473
msgid "Select a directory to copy marked files to"
msgstr ""
#: core\app.py:473
#: core\app.py:475
msgid "Select a directory to move marked files to"
msgstr ""
#: core\app.py:512
#: core\app.py:514
msgid "Select a destination for your exported CSV"
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: {}"
msgstr ""
#: core\app.py:541
#: core\app.py:543
msgid "You have no custom command set up. Set it up in your preferences."
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?"
msgstr ""
#: core\app.py:745
#: core\app.py:753
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr ""
#: core\app.py:792
#: core\app.py:801
msgid "The selected directories contain no scannable file."
msgstr ""
#: core\app.py:808
#: core\app.py:817
msgid "Collecting files to scan"
msgstr ""
#: core\app.py:858
#: core\app.py:867
msgid "%s (%d discarded)"
msgstr ""
@ -243,4 +243,3 @@ msgstr ""
#: core\se\scanner.py:18
msgid "Folders"
msgstr ""

View File

@ -150,7 +150,7 @@ msgstr "Raccolte {} cartelle da scansionare"
#: core\engine.py:27
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
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
msgid "Search..."
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)
-- 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}
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
Replaces: dupeguru-se, dupeguru-me, dupeguru-pe
Conflicts: dupeguru-se, dupeguru-me, dupeguru-pe

View File

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

View File

@ -192,7 +192,8 @@ class DupeGuru(QObject):
scanned_tags.add("year")
self.model.options["scanned_tags"] = scanned_tags
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:
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>\
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>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>\
<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>

View File

@ -31,7 +31,10 @@ class File(PhotoBase):
image = image.convertToFormat(QImage.Format_RGB888)
if type(orientation) != int:
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:
orientation = int(orientation)

View File

@ -4,11 +4,9 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtWidgets import QFormLayout
from PyQt5.QtCore import Qt
from hscommon.trans import trget
from hscommon.plat import ISLINUX
from qt.radio_box import RadioBox
from core.scanner import ScanType
from core.app import AppMode
@ -35,11 +33,6 @@ class PreferencesDialog(PreferencesDialogBase):
)
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()
def _setupDisplayPage(self):
@ -64,7 +57,6 @@ show scrollbars to span the view around"
def _load(self, prefs, setchecked, section):
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
scan_type = prefs.get_scan_type(AppMode.PICTURE)
@ -75,6 +67,5 @@ show scrollbars to span the view around"
def _save(self, prefs, ischecked):
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_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.use_regexp = get("UseRegexp", self.use_regexp)
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.profile_scan = get("ProfileScan", self.profile_scan)
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_year = get("ScanTagYear", self.scan_tag_year)
self.match_scaled = get("MatchScaled", self.match_scaled)
self.picture_cache_type = get("PictureCacheType", self.picture_cache_type)
def reset(self):
self.filter_hardness = 95
@ -231,6 +232,8 @@ class Preferences(PreferencesBase):
self.use_regexp = False
self.ignore_hardlink_matches = False
self.remove_empty_folders = False
self.rehash_ignore_mtime = False
self.include_exists_check = True
self.debug_mode = False
self.profile_scan = False
self.destination_type = 1
@ -274,7 +277,6 @@ class Preferences(PreferencesBase):
self.scan_tag_genre = False
self.scan_tag_year = False
self.match_scaled = False
self.picture_cache_type = "sqlite"
def _save_values(self, settings):
set_ = self.set_value
@ -283,6 +285,8 @@ class Preferences(PreferencesBase):
set_("IgnoreHardlinkMatches", self.ignore_hardlink_matches)
set_("UseRegexp", self.use_regexp)
set_("RemoveEmptyFolders", self.remove_empty_folders)
set_("RehashIgnoreMTime", self.rehash_ignore_mtime)
set_("IncludeExistsCheck", self.include_exists_check)
set_("DebugMode", self.debug_mode)
set_("ProfileScan", self.profile_scan)
set_("DestinationType", self.destination_type)
@ -326,7 +330,6 @@ class Preferences(PreferencesBase):
set_("ScanTagGenre", self.scan_tag_genre)
set_("ScanTagYear", self.scan_tag_year)
set_("MatchScaled", self.match_scaled)
set_("PictureCacheType", self.picture_cache_type)
# scan_type is special because we save it immediately when we set it.
def get_scan_type(self, app_mode):

View File

@ -47,8 +47,9 @@ class Sections(Flag):
GENERAL = auto()
DISPLAY = auto()
ADVANCED = auto()
DEBUG = auto()
ALL = GENERAL | DISPLAY | DEBUG
ALL = GENERAL | DISPLAY | ADVANCED | DEBUG
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(
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)
@ -213,6 +215,20 @@ use the modifier key to drag the floating window around"
details_groupbox.setLayout(self.details_groupbox_layout)
self.displayVLayout.addWidget(details_groupbox)
def _setup_advanced_page(self):
tab_label = QLabel(
tr(
"These options are for advanced users or for very specific situations, \
most users should not have to modify these."
),
wordWrap=True,
)
self.advanced_vlayout.addWidget(tab_label)
self._setupAddCheckbox("include_exists_check_box", tr("Include existence check after scan completion"))
self.advanced_vlayout.addWidget(self.include_exists_check_box)
self._setupAddCheckbox("rehash_ignore_mtime_box", tr("Ignore difference in mtime when loading cached digests"))
self.advanced_vlayout.addWidget(self.rehash_ignore_mtime_box)
def _setupDebugPage(self):
self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)"))
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.page_general = QWidget()
self.page_display = QWidget()
self.page_advanced = QWidget()
self.page_debug = QWidget()
self.widgetsVLayout = QVBoxLayout()
self.page_general.setLayout(self.widgetsVLayout)
self.displayVLayout = QVBoxLayout()
self.displayVLayout.setSpacing(5) # arbitrary value, might conflict with style
self.page_display.setLayout(self.displayVLayout)
self.advanced_vlayout = QVBoxLayout()
self.page_advanced.setLayout(self.advanced_vlayout)
self.debugVLayout = QVBoxLayout()
self.page_debug.setLayout(self.debugVLayout)
self._setupPreferenceWidgets()
self._setupDisplayPage()
self._setup_advanced_page()
self._setupDebugPage()
# self.mainVLayout.addLayout(self.widgetsVLayout)
self.buttonBox = QDialogButtonBox(self)
@ -265,9 +285,11 @@ use the modifier key to drag the floating window around"
self.layout().setSizeConstraint(QLayout.SetFixedSize)
self.tabwidget.addTab(self.page_general, tr("General"))
self.tabwidget.addTab(self.page_display, tr("Display"))
self.tabwidget.addTab(self.page_advanced, tr("Advanced"))
self.tabwidget.addTab(self.page_debug, tr("Debug"))
self.displayVLayout.addStretch(0)
self.widgetsVLayout.addStretch(0)
self.advanced_vlayout.addStretch(0)
self.debugVLayout.addStretch(0)
def _load(self, prefs, setchecked, section):
@ -318,6 +340,9 @@ use the modifier key to drag the floating window around"
except KeyError:
selected_lang = self.supportedLanguages["en"]
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:
setchecked(self.debugModeBox, prefs.debug_mode)
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.remove_empty_folders = ischecked(self.removeEmptyFoldersBox)
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.profile_scan = ischecked(self.profile_scan_box)
prefs.reference_bold_font = ischecked(self.reference_bold_font)

View File

@ -5,7 +5,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html
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
@ -25,37 +25,60 @@ class ProgressWindow:
def refresh(self): # Labels
if self._window is not None:
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):
if self._window is not None:
if last_progress < 0:
self._window.setRange(0, 0)
self._progress_bar.setRange(0, 0)
else:
self._window.setRange(0, 100)
self._window.setValue(last_progress)
self._progress_bar.setRange(0, 100)
self._progress_bar.setValue(last_progress)
def show(self):
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.setAutoReset(False)
self._window.setAutoClose(False)
self._timer = QTimer(self._window)
self._timer.timeout.connect(self.model.pulse)
self._window.show()
self._window.canceled.connect(self.model.cancel)
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):
# 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
if self._window is not None:
self._timer.stop()
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.setParent(None)
self._window = None
self.model.cancel()

View File

@ -16,7 +16,7 @@ deps =
-r{toxinidir}/requirements-extra.txt
[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
select = C,E,F,W,B,B950
extend-ignore = E203, E501
extend-ignore = E203,W503