1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2026-01-25 08:01:39 +00:00

Compare commits

..

33 Commits

Author SHA1 Message Date
Virgil Dupras
943a6570d8 Added Utopic Unicorn to the list of supported Ubuntu dists 2014-10-26 12:18:49 -04:00
Virgil Dupras
854a253d9f me v6.8.1 2014-10-26 12:00:54 -04:00
Virgil Dupras
4e477104a6 Use --deep flag when code signing under OS X
It is now required in new versions of OS X that the embedded Python framework is signed separately.
2014-10-18 11:09:18 -04:00
Virgil Dupras
79800bc6ed Added --arch-pkg option to package.py
Otherwise, AUR packages don't work with Arch lookalikes like Manjaro.
2014-10-17 15:58:45 -04:00
Virgil Dupras
6e7b95b2cf se v3.9.1 2014-10-17 15:51:48 -04:00
Virgil Dupras
bf09c4ce8a Nicely wrap PermissionDenied errors on save
In fact, all `OSError`.

ref #266
2014-10-17 15:46:43 -04:00
Virgil Dupras
b4a73771c2 Fix iCCP: known incorrect sRGB profile warnings in stderr
I processed all images through `convert -strip`.

It's still possible, however, to get these error if PE tries to open an
image with an invalid profile.
2014-10-17 15:45:07 -04:00
Virgil Dupras
2166a0996c Added tox configuration
... and fixed pep8 warnings. There's a lot of them that are still
ignored, but that's because it's too much of a step to take at once.
2014-10-13 15:08:59 -04:00
Virgil Dupras
24643a9b5d Updated copyright year to 2014 in Cocoa about boxes
Better late than never.
2014-10-12 13:19:55 -04:00
Virgil Dupras
045051ce06 Fixed formatting in changelog_pe 2014-10-12 10:52:41 -04:00
Virgil Dupras
7c3728ca47 Converted hscommon.jobprogress.qt to Qt5 2014-10-12 10:52:21 -04:00
Virgil Dupras
91be1c7336 pe v2.10.1 2014-10-12 10:47:18 -04:00
Virgil Dupras
162378bb0a Updated hscommon 2014-10-12 10:39:21 -04:00
Virgil Dupras
4e3cad5702 Fixed minor typo 2014-10-12 10:15:07 -04:00
Virgil Dupras
321f8ab406 Catch MemoryError better in PE's block matching algo
fixes #264 (for good this time, hopefully)
2014-10-05 22:22:59 -04:00
Virgil Dupras
5b3d5f5d1c Tweaked the main dev help page to have actual reflinks 2014-10-05 20:12:38 -04:00
Virgil Dupras
372a682610 Catch MemoryError in PE's block matching algo
fixes #264 (hopefully)
2014-10-05 17:13:36 -04:00
Virgil Dupras
44266273bf Included hscommon.jobprogress in the devdocs 2014-10-05 17:12:10 -04:00
Virgil Dupras
ac32305532 Integrated the jobprogress library into hscommon
I have a fix to make in it and it's really silly to pretend that this
lib is of any use to anybody outside HS apps. Bringing it back here will
make things more simple.
2014-10-05 16:31:16 -04:00
Virgil Dupras
87c2fa2573 Updated README which was a bit outdated 2014-10-04 17:01:22 -04:00
Virgil Dupras
db63b63cfd Fix crash in PE when reading some EXIF tags
The crash was caused by ObjP, which crashed when converting `NSDictionary` containing unsupported types.

Updating ObjP to v1.3.1 does the trick.

fixes #263
fixes #265
2014-10-04 16:35:26 -04:00
Virgil Dupras
6725b2bf0f Updated German localisation, by Frank Weber 2014-09-28 13:40:09 -04:00
Virgil Dupras
990e73c383 Catch Spinx SystemExit when building help
In a recent Sphinx release, it started calling `sys.exit()` and that
caused our whole build process to exit prematurely.
2014-09-13 16:05:40 -04:00
Virgil Dupras
9e9e73aa6b qtlib: Fix broken SelectableList
It was still using `.reset()`, which disappeared in Qt5.

Fixes #254.
2014-07-01 08:30:56 -04:00
Virgil Dupras
8434befe1f me v6.8.0 2014-05-11 09:26:55 -04:00
Virgil Dupras
1114ac5613 Fixed debian packaging 2014-05-11 09:11:38 -04:00
Virgil Dupras
f5f29d775c Adapt IPhotoPlistParser to Python 3.4
This also means that Python 3.3 isn't supported anymore for that part.
Updated README accordingly.
2014-05-03 15:12:13 -04:00
Virgil Dupras
ebd7f1b4ce pe v2.10.0 2014-05-03 13:57:00 -04:00
Virgil Dupras
279b7ad10c Fix typo in README 2014-05-03 13:53:16 -04:00
Virgil Dupras
878205fc49 Fix empty ignore List dialog bug in PE
Re-instantiating a new scanner for PE  made the ignore list dialog
target the wrong ignore list. We now only instantiate a scanner once.

Fixes #253
2014-05-03 13:44:38 -04:00
Virgil Dupras
b16df32150 I'm giving PyCharm a try 2014-05-03 13:39:39 -04:00
Virgil Dupras
04b06f7704 Removed the setNativeMenuBar() call under Qt
I put it there to make the menu usable under Ubuntu 13.10, but since
14.04, this line actually brakes it.
2014-05-03 09:34:41 -04:00
Virgil Dupras
c6ea1c62d4 Fixed Windows packaging 2014-04-21 10:00:53 -04:00
103 changed files with 2100 additions and 1623 deletions

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@
*.pyd *.pyd
*.waf* *.waf*
.lock-waf* .lock-waf*
.idea
.tox
build build
dist dist

View File

@@ -3,7 +3,7 @@
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in [dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
a system. It's written mostly in Python 3 and has the peculiarity of using a system. It's written mostly in Python 3 and has the peculiarity of using
[multiple GUI toolkits][cross-toolkit], all using the same core Python code. On OS X, the UI layer [multiple GUI toolkits][cross-toolkit], all using the same core Python code. On OS X, the UI layer
is written in Objective-C and uses Cocoa. On Linux and Windows, it's written in Python and uses Qt4. is written in Objective-C and uses Cocoa. On Linux and Windows, it's written in Python and uses Qt5.
dupeGuru comes in 3 editions (standard, music and picture) which are all buildable from this same dupeGuru comes in 3 editions (standard, music and picture) which are all buildable from this same
source tree. You choose the edition you want to build in a ``configure.py`` flag. source tree. You choose the edition you want to build in a ``configure.py`` flag.
@@ -47,7 +47,7 @@ Prerequisites are installed through `pip`. However, some of them are not "pip in
to be installed manually. to be installed manually.
* All systems: [Python 3.3+][python] and [setuptools][setuptools] * All systems: [Python 3.3+][python] and [setuptools][setuptools]
* Mac OS X: The last XCode to have the 10.6 SDK included. * Mac OS X: The last XCode to have the 10.7 SDK included. Python 3.4+.
* Windows: Visual Studio 2010, [PyQt 5.0+][pyqt], [cx_Freeze][cxfreeze] and * Windows: Visual Studio 2010, [PyQt 5.0+][pyqt], [cx_Freeze][cxfreeze] and
[Advanced Installer][advinst] (you only need the last two if you want to create an installer) [Advanced Installer][advinst] (you only need the last two if you want to create an installer)
@@ -63,12 +63,12 @@ On Arch, it's:
Use Python's built-in `pyvenv` to create a virtual environment in which we're going to install our. Use Python's built-in `pyvenv` to create a virtual environment in which we're going to install our.
Python-related dependencies. `pyvenv` is built-in Python but, unlike its `virtualenv` predecessor, Python-related dependencies. `pyvenv` is built-in Python but, unlike its `virtualenv` predecessor,
it doesn't install setuptools and pip, so it has to be installed manually: it doesn't install setuptools and pip (unless you use Python 3.4+), so it has to be installed
manually:
$ pyvenv --system-site-packages env $ pyvenv --system-site-packages env
$ source env/bin/activate $ source env/bin/activate
$ wget https://bitbucket.org/pypa/setuptools/raw/bootstrap/ez_setup.py -O - | python $ python get-pip.py
$ easy_install pip
Then, you can install pip requirements in your virtualenv: Then, you can install pip requirements in your virtualenv:
@@ -96,3 +96,4 @@ You can also package dupeGuru into an installable package with:
[pyqt]: http://www.riverbankcomputing.com [pyqt]: http://www.riverbankcomputing.com
[cxfreeze]: http://cx-freeze.sourceforge.net/ [cxfreeze]: http://cx-freeze.sourceforge.net/
[advinst]: http://www.advancedinstaller.com [advinst]: http://www.advancedinstaller.com

132
build.py
View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-12-30 # Created On: 2009-12-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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys import sys
@@ -18,10 +18,12 @@ import compileall
from setuptools import setup, Extension from setuptools import setup, Extension
from hscommon import sphinxgen from hscommon import sphinxgen
from hscommon.build import (add_to_pythonpath, print_and_do, copy_packages, filereplace, from hscommon.build import (
add_to_pythonpath, print_and_do, copy_packages, filereplace,
get_module_version, move_all, copy_all, OSXAppStructure, get_module_version, move_all, copy_all, OSXAppStructure,
build_cocoalib_xibless, fix_qt_resource_file, build_cocoa_ext, copy_embeddable_python_dylib, build_cocoalib_xibless, fix_qt_resource_file, build_cocoa_ext, copy_embeddable_python_dylib,
collect_stdlib_dependencies, copy) collect_stdlib_dependencies, copy
)
from hscommon import loc from hscommon import loc
from hscommon.plat import ISOSX, ISLINUX from hscommon.plat import ISOSX, ISLINUX
from hscommon.util import ensure_folder, delete_files_with_pattern from hscommon.util import ensure_folder, delete_files_with_pattern
@@ -29,24 +31,42 @@ from hscommon.util import ensure_folder, delete_files_with_pattern
def parse_args(): def parse_args():
usage = "usage: %prog [options]" usage = "usage: %prog [options]"
parser = OptionParser(usage=usage) parser = OptionParser(usage=usage)
parser.add_option('--clean', action='store_true', dest='clean', parser.add_option(
help="Clean build folder before building") '--clean', action='store_true', dest='clean',
parser.add_option('--doc', action='store_true', dest='doc', help="Clean build folder before building"
help="Build only the help file") )
parser.add_option('--loc', action='store_true', dest='loc', parser.add_option(
help="Build only localization") '--doc', action='store_true', dest='doc',
parser.add_option('--cocoa-ext', action='store_true', dest='cocoa_ext', help="Build only the help file"
help="Build only Cocoa extensions") )
parser.add_option('--cocoa-compile', action='store_true', dest='cocoa_compile', parser.add_option(
help="Build only Cocoa executable") '--loc', action='store_true', dest='loc',
parser.add_option('--xibless', action='store_true', dest='xibless', help="Build only localization"
help="Build only xibless UIs") )
parser.add_option('--updatepot', action='store_true', dest='updatepot', parser.add_option(
help="Generate .pot files from source code.") '--cocoa-ext', action='store_true', dest='cocoa_ext',
parser.add_option('--mergepot', action='store_true', dest='mergepot', help="Build only Cocoa extensions"
help="Update all .po files based on .pot files.") )
parser.add_option('--normpo', action='store_true', dest='normpo', parser.add_option(
help="Normalize all PO files (do this before commit).") '--cocoa-compile', action='store_true', dest='cocoa_compile',
help="Build only Cocoa executable"
)
parser.add_option(
'--xibless', action='store_true', dest='xibless',
help="Build only xibless UIs"
)
parser.add_option(
'--updatepot', action='store_true', dest='updatepot',
help="Generate .pot files from source code."
)
parser.add_option(
'--mergepot', action='store_true', dest='mergepot',
help="Update all .po files based on .pot files."
)
parser.add_option(
'--normpo', action='store_true', dest='normpo',
help="Normalize all PO files (do this before commit)."
)
(options, args) = parser.parse_args() (options, args) = parser.parse_args()
return options return options
@@ -75,12 +95,20 @@ def build_xibless(edition, dest='cocoa/autogen'):
('preferences_panel.py', 'PreferencesPanel_UI'), ('preferences_panel.py', 'PreferencesPanel_UI'),
] ]
for srcname, dstname in FNPAIRS: for srcname, dstname in FNPAIRS:
xibless.generate(op.join('cocoa', 'base', 'ui', srcname), op.join(dest, dstname), xibless.generate(
localizationTable='Localizable', args={'edition': edition}) op.join('cocoa', 'base', 'ui', srcname), op.join(dest, dstname),
localizationTable='Localizable', args={'edition': edition}
)
if edition == 'pe': if edition == 'pe':
xibless.generate('cocoa/pe/ui/details_panel.py', op.join(dest, 'DetailsPanel_UI'), localizationTable='Localizable') xibless.generate(
'cocoa/pe/ui/details_panel.py', op.join(dest, 'DetailsPanel_UI'),
localizationTable='Localizable'
)
else: else:
xibless.generate('cocoa/base/ui/details_panel.py', op.join(dest, 'DetailsPanel_UI'), localizationTable='Localizable') xibless.generate(
'cocoa/base/ui/details_panel.py', op.join(dest, 'DetailsPanel_UI'),
localizationTable='Localizable'
)
def build_cocoa(edition, dev): def build_cocoa(edition, dev):
print("Creating OS X app structure") print("Creating OS X app structure")
@@ -110,15 +138,16 @@ def build_cocoa(edition, dev):
'me': ['core_me'] + appscript_pkgs + ['hsaudiotag'], 'me': ['core_me'] + appscript_pkgs + ['hsaudiotag'],
'pe': ['core_pe'] + appscript_pkgs, 'pe': ['core_pe'] + appscript_pkgs,
}[edition] }[edition]
tocopy = ['core', 'hscommon', 'cocoa/inter', 'cocoalib/cocoa', 'jobprogress', 'objp', tocopy = [
'send2trash'] + specific_packages 'core', 'hscommon', 'cocoa/inter', 'cocoalib/cocoa', 'objp', 'send2trash'
] + specific_packages
copy_packages(tocopy, pydep_folder, create_links=dev) copy_packages(tocopy, pydep_folder, create_links=dev)
sys.path.insert(0, 'build') sys.path.insert(0, 'build')
extra_deps = None extra_deps = None
if edition == 'pe': if edition == 'pe':
# ModuleFinder can't seem to correctly detect the multiprocessing dependency, so we have # ModuleFinder can't seem to correctly detect the multiprocessing dependency, so we have
# to manually specify it. # to manually specify it.
extra_deps=['multiprocessing'] extra_deps = ['multiprocessing']
collect_stdlib_dependencies('build/dg_cocoa.py', pydep_folder, extra_deps=extra_deps) collect_stdlib_dependencies('build/dg_cocoa.py', pydep_folder, extra_deps=extra_deps)
del sys.path[0] del sys.path[0]
# Views are not referenced by python code, so they're not found by the collector. # Views are not referenced by python code, so they're not found by the collector.
@@ -224,8 +253,10 @@ def build_updatepot():
os.remove(cocoalib_pot) os.remove(cocoalib_pot)
loc.strings2pot(op.join('cocoalib', 'en.lproj', 'cocoalib.strings'), cocoalib_pot) loc.strings2pot(op.join('cocoalib', 'en.lproj', 'cocoalib.strings'), cocoalib_pot)
print("Enhancing ui.pot with Cocoa's strings files") print("Enhancing ui.pot with Cocoa's strings files")
loc.strings2pot(op.join('cocoa', 'base', 'en.lproj', 'Localizable.strings'), loc.strings2pot(
op.join('locale', 'ui.pot')) op.join('cocoa', 'base', 'en.lproj', 'Localizable.strings'),
op.join('locale', 'ui.pot')
)
def build_mergepot(): def build_mergepot():
print("Updating .po files using .pot files") print("Updating .po files using .pot files")
@@ -242,11 +273,15 @@ def build_cocoa_proxy_module():
print("Building Cocoa Proxy") print("Building Cocoa Proxy")
import objp.p2o import objp.p2o
objp.p2o.generate_python_proxy_code('cocoalib/cocoa/CocoaProxy.h', 'build/CocoaProxy.m') objp.p2o.generate_python_proxy_code('cocoalib/cocoa/CocoaProxy.h', 'build/CocoaProxy.m')
build_cocoa_ext("CocoaProxy", 'cocoalib/cocoa', build_cocoa_ext(
['cocoalib/cocoa/CocoaProxy.m', 'build/CocoaProxy.m', 'build/ObjP.m', "CocoaProxy", 'cocoalib/cocoa',
'cocoalib/HSErrorReportWindow.m', 'cocoa/autogen/HSErrorReportWindow_UI.m'], [
'cocoalib/cocoa/CocoaProxy.m', 'build/CocoaProxy.m', 'build/ObjP.m',
'cocoalib/HSErrorReportWindow.m', 'cocoa/autogen/HSErrorReportWindow_UI.m'
],
['AppKit', 'CoreServices'], ['AppKit', 'CoreServices'],
['cocoalib', 'cocoa/autogen']) ['cocoalib', 'cocoa/autogen']
)
def build_cocoa_bridging_interfaces(edition): def build_cocoa_bridging_interfaces(edition):
print("Building Cocoa Bridging Interfaces") print("Building Cocoa Bridging Interfaces")
@@ -254,9 +289,11 @@ def build_cocoa_bridging_interfaces(edition):
import objp.p2o import objp.p2o
add_to_pythonpath('cocoa') add_to_pythonpath('cocoa')
add_to_pythonpath('cocoalib') add_to_pythonpath('cocoalib')
from cocoa.inter import (PyGUIObject, GUIObjectView, PyColumns, ColumnsView, PyOutline, from cocoa.inter import (
PyGUIObject, GUIObjectView, PyColumns, ColumnsView, PyOutline,
OutlineView, PySelectableList, SelectableListView, PyTable, TableView, PyBaseApp, OutlineView, PySelectableList, SelectableListView, PyTable, TableView, PyBaseApp,
PyTextField, ProgressWindowView, PyProgressWindow) PyTextField, ProgressWindowView, PyProgressWindow
)
from inter.deletion_options import PyDeletionOptions, DeletionOptionsView from inter.deletion_options import PyDeletionOptions, DeletionOptionsView
from inter.details_panel import PyDetailsPanel, DetailsPanelView from inter.details_panel import PyDetailsPanel, DetailsPanelView
from inter.directory_outline import PyDirectoryOutline, DirectoryOutlineView from inter.directory_outline import PyDirectoryOutline, DirectoryOutlineView
@@ -268,16 +305,20 @@ def build_cocoa_bridging_interfaces(edition):
from inter.stats_label import PyStatsLabel, StatsLabelView from inter.stats_label import PyStatsLabel, StatsLabelView
from inter.app import PyDupeGuruBase, DupeGuruView from inter.app import PyDupeGuruBase, DupeGuruView
appmod = importlib.import_module('inter.app_{}'.format(edition)) appmod = importlib.import_module('inter.app_{}'.format(edition))
allclasses = [PyGUIObject, PyColumns, PyOutline, PySelectableList, PyTable, PyBaseApp, allclasses = [
PyGUIObject, PyColumns, PyOutline, PySelectableList, PyTable, PyBaseApp,
PyDetailsPanel, PyDirectoryOutline, PyPrioritizeDialog, PyPrioritizeList, PyProblemDialog, PyDetailsPanel, PyDirectoryOutline, PyPrioritizeDialog, PyPrioritizeList, PyProblemDialog,
PyIgnoreListDialog, PyDeletionOptions, PyResultTable, PyStatsLabel, PyDupeGuruBase, PyIgnoreListDialog, PyDeletionOptions, PyResultTable, PyStatsLabel, PyDupeGuruBase,
PyTextField, PyProgressWindow, appmod.PyDupeGuru] PyTextField, PyProgressWindow, appmod.PyDupeGuru
]
for class_ in allclasses: for class_ in allclasses:
objp.o2p.generate_objc_code(class_, 'cocoa/autogen', inherit=True) objp.o2p.generate_objc_code(class_, 'cocoa/autogen', inherit=True)
allclasses = [GUIObjectView, ColumnsView, OutlineView, SelectableListView, TableView, allclasses = [
GUIObjectView, ColumnsView, OutlineView, SelectableListView, TableView,
DetailsPanelView, DirectoryOutlineView, PrioritizeDialogView, PrioritizeListView, DetailsPanelView, DirectoryOutlineView, PrioritizeDialogView, PrioritizeListView,
IgnoreListDialogView, DeletionOptionsView, ResultTableView, StatsLabelView, IgnoreListDialogView, DeletionOptionsView, ResultTableView, StatsLabelView,
ProgressWindowView, DupeGuruView] ProgressWindowView, DupeGuruView
]
clsspecs = [objp.o2p.spec_from_python_class(class_) for class_ in allclasses] clsspecs = [objp.o2p.spec_from_python_class(class_) for class_ in allclasses]
objp.p2o.generate_python_proxy_code_from_clsspec(clsspecs, 'build/CocoaViews.m') objp.p2o.generate_python_proxy_code_from_clsspec(clsspecs, 'build/CocoaViews.m')
build_cocoa_ext('CocoaViews', 'cocoa/inter', ['build/CocoaViews.m', 'build/ObjP.m']) build_cocoa_ext('CocoaViews', 'cocoa/inter', ['build/CocoaViews.m', 'build/ObjP.m'])
@@ -296,11 +337,12 @@ def build_pe_modules(ui):
extra_link_args=[ extra_link_args=[
"-framework", "CoreFoundation", "-framework", "CoreFoundation",
"-framework", "Foundation", "-framework", "Foundation",
"-framework", "ApplicationServices",] "-framework", "ApplicationServices",
]
)) ))
setup( setup(
script_args = ['build_ext', '--inplace'], script_args=['build_ext', '--inplace'],
ext_modules = exts, ext_modules=exts,
) )
move_all('_block_qt*', op.join('qt', 'pe')) move_all('_block_qt*', op.join('qt', 'pe'))
move_all('_block*', 'core_pe') move_all('_block*', 'core_pe')

View File

@@ -31,7 +31,7 @@
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>© Hardcoded Software, 2013</string> <string>© Hardcoded Software, 2014</string>
<key>SUFeedURL</key> <key>SUFeedURL</key>
<string>http://www.hardcoded.net/updates/dupeguru_me.appcast</string> <string>http://www.hardcoded.net/updates/dupeguru_me.appcast</string>
<key>SUPublicDSAKeyFile</key> <key>SUPublicDSAKeyFile</key>

View File

@@ -31,7 +31,7 @@
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>© Hardcoded Software, 2013</string> <string>© Hardcoded Software, 2014</string>
<key>SUFeedURL</key> <key>SUFeedURL</key>
<string>http://www.hardcoded.net/updates/dupeguru_pe.appcast</string> <string>http://www.hardcoded.net/updates/dupeguru_pe.appcast</string>
<key>SUPublicDSAKeyFile</key> <key>SUPublicDSAKeyFile</key>

View File

@@ -29,7 +29,7 @@
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>© Hardcoded Software, 2013</string> <string>© Hardcoded Software, 2014</string>
<key>SUFeedURL</key> <key>SUFeedURL</key>
<string>http://www.hardcoded.net/updates/dupeguru.appcast</string> <string>http://www.hardcoded.net/updates/dupeguru.appcast</string>
<key>SUPublicDSAKeyFile</key> <key>SUPublicDSAKeyFile</key>

View File

@@ -2,8 +2,8 @@
# Created On: 2007-10-06 # Created On: 2007-10-06
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import logging import logging
@@ -26,7 +26,7 @@ def autoreleasepool(func):
def as_fetch(as_list, as_type, step_size=1000): def as_fetch(as_list, as_type, step_size=1000):
"""When fetching items from a very big list through applescript, the connection with the app """When fetching items from a very big list through applescript, the connection with the app
will timeout. This function is to circumvent that. 'as_type' is the type of the items in the will timeout. This function is to circumvent that. 'as_type' is the type of the items in the
list (found in appscript.k). If we don't pass it to the 'each' arg of 'count()', it doesn't work. list (found in appscript.k). If we don't pass it to the 'each' arg of 'count()', it doesn't work.
applescript is rather stupid...""" applescript is rather stupid..."""
result = [] result = []
@@ -66,7 +66,7 @@ def extract_tb_noline(tb):
def safe_format_exception(type, value, tb): def safe_format_exception(type, value, tb):
"""Format exception from type, value and tb and fallback if there's a problem. """Format exception from type, value and tb and fallback if there's a problem.
In some cases in threaded exceptions under Cocoa, I get tracebacks targeting pyc files instead In some cases in threaded exceptions under Cocoa, I get tracebacks targeting pyc files instead
of py files, which results in traceback.format_exception() trying to print lines from pyc files of py files, which results in traceback.format_exception() trying to print lines from pyc files
and then crashing when trying to interpret that binary data as utf-8. We want a fallback in and then crashing when trying to interpret that binary data as utf-8. We want a fallback in
@@ -113,5 +113,6 @@ def patch_threaded_job_performer():
# _async_run, under cocoa, has to be run within an autorelease pool to prevent leaks. # _async_run, under cocoa, has to be run within an autorelease pool to prevent leaks.
# You only need this patch is you use one of CocoaProxy's function (which allocate objc # You only need this patch is you use one of CocoaProxy's function (which allocate objc
# structures) inside a threaded job. # structures) inside a threaded job.
from jobprogress.performer import ThreadedJobPerformer from hscommon.jobprogress.performer import ThreadedJobPerformer
ThreadedJobPerformer._async_run = autoreleasepool(ThreadedJobPerformer._async_run) ThreadedJobPerformer._async_run = autoreleasepool(ThreadedJobPerformer._async_run)

View File

@@ -1,12 +1,11 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-12-30 # Created On: 2009-12-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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys
from optparse import OptionParser from optparse import OptionParser
import json import json
@@ -29,11 +28,18 @@ def main(options):
if __name__ == '__main__': if __name__ == '__main__':
usage = "usage: %prog [options]" usage = "usage: %prog [options]"
parser = OptionParser(usage=usage) parser = OptionParser(usage=usage)
parser.add_option('--edition', dest='edition', parser.add_option(
help="dupeGuru edition to build (se, me or pe). Default is se.") '--edition', dest='edition',
parser.add_option('--ui', dest='ui', help="dupeGuru edition to build (se, me or pe). Default is se."
help="Type of UI to build. 'qt' or 'cocoa'. Default is determined by your system.") )
parser.add_option('--dev', action='store_true', dest='dev', default=False, parser.add_option(
help="If this flag is set, will configure for dev builds.") '--ui', dest='ui',
help="Type of UI to build. 'qt' or 'cocoa'. Default is determined by your system."
)
parser.add_option(
'--dev', action='store_true', dest='dev', default=False,
help="If this flag is set, will configure for dev builds."
)
(options, args) = parser.parse_args() (options, args) = parser.parse_args()
main(options) main(options)

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/11/11 # Created On: 2006/11/11
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import os import os
@@ -15,7 +15,7 @@ import time
import shutil import shutil
from send2trash import send2trash from send2trash import send2trash
from jobprogress import job from hscommon.jobprogress import job
from hscommon.notify import Broadcaster from hscommon.notify import Broadcaster
from hscommon.path import Path from hscommon.path import Path
from hscommon.conflict import smart_move, smart_copy from hscommon.conflict import smart_move, smart_copy
@@ -38,8 +38,10 @@ DEBUG_MODE_PREFERENCE = 'DebugMode'
MSG_NO_MARKED_DUPES = tr("There are no marked duplicates. Nothing has been done.") MSG_NO_MARKED_DUPES = tr("There are no marked duplicates. Nothing has been done.")
MSG_NO_SELECTED_DUPES = tr("There are no selected duplicates. Nothing has been done.") MSG_NO_SELECTED_DUPES = tr("There are no selected duplicates. Nothing has been done.")
MSG_MANY_FILES_TO_OPEN = tr("You're about to open many files at once. Depending on what those " MSG_MANY_FILES_TO_OPEN = tr(
"files are opened with, doing so can create quite a mess. Continue?") "You're about to open many files at once. Depending on what those "
"files are opened with, doing so can create quite a mess. Continue?"
)
class DestType: class DestType:
Direct = 0 Direct = 0
@@ -78,7 +80,7 @@ def format_words(w):
return '(%s)' % ', '.join(do_format(item) for item in w) return '(%s)' % ', '.join(do_format(item) for item in w)
else: else:
return w.replace('\n', ' ') return w.replace('\n', ' ')
return ', '.join(do_format(item) for item in w) return ', '.join(do_format(item) for item in w)
def format_perc(p): def format_perc(p):
@@ -110,33 +112,33 @@ def fix_surrogate_encoding(s, encoding='utf-8'):
class DupeGuru(Broadcaster): class DupeGuru(Broadcaster):
"""Holds everything together. """Holds everything together.
Instantiated once per running application, it holds a reference to every high-level object Instantiated once per running application, it holds a reference to every high-level object
whose reference needs to be held: :class:`~core.results.Results`, :class:`Scanner`, whose reference needs to be held: :class:`~core.results.Results`, :class:`Scanner`,
:class:`~core.directories.Directories`, :mod:`core.gui` instances, etc.. :class:`~core.directories.Directories`, :mod:`core.gui` instances, etc..
It also hosts high level methods and acts as a coordinator for all those elements. This is why It also hosts high level methods and acts as a coordinator for all those elements. This is why
some of its methods seem a bit shallow, like for example :meth:`mark_all` and some of its methods seem a bit shallow, like for example :meth:`mark_all` and
:meth:`remove_duplicates`. These methos are just proxies for a method in :attr:`results`, but :meth:`remove_duplicates`. These methos are just proxies for a method in :attr:`results`, but
they are also followed by a notification call which is very important if we want GUI elements they are also followed by a notification call which is very important if we want GUI elements
to be correctly notified of a change in the data they're presenting. to be correctly notified of a change in the data they're presenting.
.. attribute:: directories .. attribute:: directories
Instance of :class:`~core.directories.Directories`. It holds the current folder selection. Instance of :class:`~core.directories.Directories`. It holds the current folder selection.
.. attribute:: results .. attribute:: results
Instance of :class:`core.results.Results`. Holds the results of the latest scan. Instance of :class:`core.results.Results`. Holds the results of the latest scan.
.. attribute:: selected_dupes .. attribute:: selected_dupes
List of currently selected dupes from our :attr:`results`. Whenever the user changes its List of currently selected dupes from our :attr:`results`. Whenever the user changes its
selection at the UI level, :attr:`result_table` takes care of updating this attribute, so selection at the UI level, :attr:`result_table` takes care of updating this attribute, so
you can trust that it's always up-to-date. you can trust that it's always up-to-date.
.. attribute:: result_table .. attribute:: result_table
Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results` Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results`
""" """
#--- View interface #--- View interface
@@ -151,10 +153,10 @@ class DupeGuru(Broadcaster):
# show_problem_dialog() # show_problem_dialog()
# select_dest_folder(prompt: str) --> str # select_dest_folder(prompt: str) --> str
# select_dest_file(prompt: str, ext: str) --> str # select_dest_file(prompt: str, ext: str) --> str
# in fairware prompts, we don't mention the edition, it's too long.
PROMPT_NAME = "dupeGuru" PROMPT_NAME = "dupeGuru"
SCANNER_CLASS = scanner.Scanner
def __init__(self, view): def __init__(self, view):
if view.get_default(DEBUG_MODE_PREFERENCE): if view.get_default(DEBUG_MODE_PREFERENCE):
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
@@ -166,7 +168,7 @@ class DupeGuru(Broadcaster):
os.makedirs(self.appdata) os.makedirs(self.appdata)
self.directories = directories.Directories() self.directories = directories.Directories()
self.results = results.Results(self) self.results = results.Results(self)
self.scanner = scanner.Scanner() self.scanner = self.SCANNER_CLASS()
self.options = { self.options = {
'escape_filter_regexp': True, 'escape_filter_regexp': True,
'clean_empty_dirs': False, 'clean_empty_dirs': False,
@@ -185,14 +187,14 @@ class DupeGuru(Broadcaster):
children = [self.result_table, self.directory_tree, self.stats_label, self.details_panel] children = [self.result_table, self.directory_tree, self.stats_label, self.details_panel]
for child in children: for child in children:
child.connect() child.connect()
#--- Virtual #--- Virtual
def _prioritization_categories(self): def _prioritization_categories(self):
raise NotImplementedError() raise NotImplementedError()
def _create_result_table(self): def _create_result_table(self):
raise NotImplementedError() raise NotImplementedError()
#--- Private #--- Private
def _get_dupe_sort_key(self, dupe, get_group, key, delta): def _get_dupe_sort_key(self, dupe, get_group, key, delta):
if key == 'marked': if key == 'marked':
@@ -212,7 +214,7 @@ class DupeGuru(Broadcaster):
same = cmp_value(dupe, key) == refval same = cmp_value(dupe, key) == refval
result = (same, result) result = (same, result)
return result return result
def _get_group_sort_key(self, group, key): def _get_group_sort_key(self, group, key):
if key == 'percentage': if key == 'percentage':
return group.percentage return group.percentage
@@ -221,15 +223,15 @@ class DupeGuru(Broadcaster):
if key == 'marked': if key == 'marked':
return len([dupe for dupe in group.dupes if self.results.is_marked(dupe)]) return len([dupe for dupe in group.dupes if self.results.is_marked(dupe)])
return cmp_value(group.ref, key) return cmp_value(group.ref, key)
def _do_delete(self, j, link_deleted, use_hardlinks, direct_deletion): def _do_delete(self, j, link_deleted, use_hardlinks, direct_deletion):
def op(dupe): def op(dupe):
j.add_progress() j.add_progress()
return self._do_delete_dupe(dupe, link_deleted, use_hardlinks, direct_deletion) return self._do_delete_dupe(dupe, link_deleted, use_hardlinks, direct_deletion)
j.start_job(self.results.mark_count) j.start_job(self.results.mark_count)
self.results.perform_on_marked(op, True) self.results.perform_on_marked(op, True)
def _do_delete_dupe(self, dupe, link_deleted, use_hardlinks, direct_deletion): def _do_delete_dupe(self, dupe, link_deleted, use_hardlinks, direct_deletion):
if not dupe.path.exists(): if not dupe.path.exists():
return return
@@ -248,11 +250,11 @@ class DupeGuru(Broadcaster):
linkfunc = os.link if use_hardlinks else os.symlink linkfunc = os.link if use_hardlinks else os.symlink
linkfunc(str(ref.path), str_path) linkfunc(str(ref.path), str_path)
self.clean_empty_dirs(dupe.path.parent()) self.clean_empty_dirs(dupe.path.parent())
def _create_file(self, path): def _create_file(self, path):
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths. # We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
return fs.get_file(path, self.directories.fileclasses + [fs.Folder]) return fs.get_file(path, self.directories.fileclasses + [fs.Folder])
def _get_file(self, str_path): def _get_file(self, str_path):
path = Path(str_path) path = Path(str_path)
f = self._create_file(path) f = self._create_file(path)
@@ -263,10 +265,12 @@ class DupeGuru(Broadcaster):
return f return f
except EnvironmentError: except EnvironmentError:
return None return None
def _get_export_data(self): def _get_export_data(self):
columns = [col for col in self.result_table.columns.ordered_columns columns = [
if col.visible and col.name != 'marked'] col for col in self.result_table.columns.ordered_columns
if col.visible and col.name != 'marked'
]
colnames = [col.display for col in columns] colnames = [col.display for col in columns]
rows = [] rows = []
for group_id, group in enumerate(self.results.groups): for group_id, group in enumerate(self.results.groups):
@@ -276,20 +280,25 @@ class DupeGuru(Broadcaster):
row.insert(0, group_id) row.insert(0, group_id)
rows.append(row) rows.append(row)
return colnames, rows return colnames, rows
def _results_changed(self): def _results_changed(self):
self.selected_dupes = [d for d in self.selected_dupes self.selected_dupes = [
if self.results.get_group_of_duplicate(d) is not None] d for d in self.selected_dupes
if self.results.get_group_of_duplicate(d) is not None
]
self.notify('results_changed') self.notify('results_changed')
def _start_job(self, jobid, func, args=()): def _start_job(self, jobid, func, args=()):
title = JOBID2TITLE[jobid] title = JOBID2TITLE[jobid]
try: try:
self.progress_window.run(jobid, title, func, args=args) self.progress_window.run(jobid, title, func, args=args)
except job.JobInProgressError: except job.JobInProgressError:
msg = tr("A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again.") msg = tr(
"A previous action is still hanging in there. You can't start a new one yet. Wait "
"a few seconds, then try again."
)
self.view.show_message(msg) self.view.show_message(msg)
def _job_completed(self, jobid): def _job_completed(self, jobid):
if jobid == JobType.Scan: if jobid == JobType.Scan:
self._results_changed() self._results_changed()
@@ -312,7 +321,7 @@ class DupeGuru(Broadcaster):
JobType.Delete: tr("All marked files were successfully sent to Trash."), JobType.Delete: tr("All marked files were successfully sent to Trash."),
}[jobid] }[jobid]
self.view.show_message(msg) self.view.show_message(msg)
@staticmethod @staticmethod
def _remove_hardlink_dupes(files): def _remove_hardlink_dupes(files):
seen_inodes = set() seen_inodes = set()
@@ -327,19 +336,19 @@ class DupeGuru(Broadcaster):
seen_inodes.add(inode) seen_inodes.add(inode)
result.append(file) result.append(file)
return result return result
def _select_dupes(self, dupes): def _select_dupes(self, dupes):
if dupes == self.selected_dupes: if dupes == self.selected_dupes:
return return
self.selected_dupes = dupes self.selected_dupes = dupes
self.notify('dupes_selected') self.notify('dupes_selected')
#--- Public #--- Public
def add_directory(self, d): def add_directory(self, d):
"""Adds folder ``d`` to :attr:`directories`. """Adds folder ``d`` to :attr:`directories`.
Shows an error message dialog if something bad happens. Shows an error message dialog if something bad happens.
:param str d: path of folder to add :param str d: path of folder to add
""" """
try: try:
@@ -349,7 +358,7 @@ class DupeGuru(Broadcaster):
self.view.show_message(tr("'{}' already is in the list.").format(d)) self.view.show_message(tr("'{}' already is in the list.").format(d))
except directories.InvalidPathError: except directories.InvalidPathError:
self.view.show_message(tr("'{}' does not exist.").format(d)) self.view.show_message(tr("'{}' does not exist.").format(d))
def add_selected_to_ignore_list(self): def add_selected_to_ignore_list(self):
"""Adds :attr:`selected_dupes` to :attr:`scanner`'s ignore list. """Adds :attr:`selected_dupes` to :attr:`scanner`'s ignore list.
""" """
@@ -367,10 +376,10 @@ class DupeGuru(Broadcaster):
self.scanner.ignore_list.Ignore(str(other.path), str(dupe.path)) self.scanner.ignore_list.Ignore(str(other.path), str(dupe.path))
self.remove_duplicates(dupes) self.remove_duplicates(dupes)
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
def apply_filter(self, filter): def apply_filter(self, filter):
"""Apply a filter ``filter`` to the results so that it shows only dupe groups that match it. """Apply a filter ``filter`` to the results so that it shows only dupe groups that match it.
:param str filter: filter to apply :param str filter: filter to apply
""" """
self.results.apply_filter(None) self.results.apply_filter(None)
@@ -379,12 +388,12 @@ class DupeGuru(Broadcaster):
filter = escape(filter, '*', '.') filter = escape(filter, '*', '.')
self.results.apply_filter(filter) self.results.apply_filter(filter)
self._results_changed() self._results_changed()
def clean_empty_dirs(self, path): def clean_empty_dirs(self, path):
if self.options['clean_empty_dirs']: if self.options['clean_empty_dirs']:
while delete_if_empty(path, ['.DS_Store']): while delete_if_empty(path, ['.DS_Store']):
path = path.parent() path = path.parent()
def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType): def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType):
source_path = dupe.path source_path = dupe.path
location_path = first(p for p in self.directories if dupe.path in p) location_path = first(p for p in self.directories if dupe.path in p)
@@ -406,20 +415,20 @@ class DupeGuru(Broadcaster):
else: else:
smart_move(source_path, dest_path) smart_move(source_path, dest_path)
self.clean_empty_dirs(source_path.parent()) self.clean_empty_dirs(source_path.parent())
def copy_or_move_marked(self, copy): def copy_or_move_marked(self, copy):
"""Start an async move (or copy) job on marked duplicates. """Start an async move (or copy) job on marked duplicates.
:param bool copy: If True, duplicates will be copied instead of moved :param bool copy: If True, duplicates will be copied instead of moved
""" """
def do(j): def do(j):
def op(dupe): def op(dupe):
j.add_progress() j.add_progress()
self.copy_or_move(dupe, copy, destination, desttype) self.copy_or_move(dupe, copy, destination, desttype)
j.start_job(self.results.mark_count) j.start_job(self.results.mark_count)
self.results.perform_on_marked(op, not copy) self.results.perform_on_marked(op, not copy)
if not self.results.mark_count: if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES) self.view.show_message(MSG_NO_MARKED_DUPES)
return return
@@ -430,7 +439,7 @@ class DupeGuru(Broadcaster):
desttype = self.options['copymove_dest_type'] desttype = self.options['copymove_dest_type']
jobid = JobType.Copy if copy else JobType.Move jobid = JobType.Copy if copy else JobType.Move
self._start_job(jobid, do) self._start_job(jobid, do)
def delete_marked(self): def delete_marked(self):
"""Start an async job to send marked duplicates to the trash. """Start an async job to send marked duplicates to the trash.
""" """
@@ -439,14 +448,16 @@ class DupeGuru(Broadcaster):
return return
if not self.deletion_options.show(self.results.mark_count): if not self.deletion_options.show(self.results.mark_count):
return return
args = [self.deletion_options.link_deleted, self.deletion_options.use_hardlinks, args = [
self.deletion_options.direct] self.deletion_options.link_deleted, self.deletion_options.use_hardlinks,
self.deletion_options.direct
]
logging.debug("Starting deletion job with args %r", args) logging.debug("Starting deletion job with args %r", args)
self._start_job(JobType.Delete, self._do_delete, args=args) self._start_job(JobType.Delete, self._do_delete, args=args)
def export_to_xhtml(self): def export_to_xhtml(self):
"""Export current results to XHTML. """Export current results to XHTML.
The configuration of the :attr:`result_table` (columns order and visibility) is used to The configuration of the :attr:`result_table` (columns order and visibility) is used to
determine how the data is presented in the export. In other words, the exported table in determine how the data is presented in the export. In other words, the exported table in
the resulting XHTML will look just like the results table. the resulting XHTML will look just like the results table.
@@ -454,18 +465,21 @@ class DupeGuru(Broadcaster):
colnames, rows = self._get_export_data() colnames, rows = self._get_export_data()
export_path = export.export_to_xhtml(colnames, rows) export_path = export.export_to_xhtml(colnames, rows)
desktop.open_path(export_path) desktop.open_path(export_path)
def export_to_csv(self): def export_to_csv(self):
"""Export current results to CSV. """Export current results to CSV.
The columns and their order in the resulting CSV file is determined in the same way as in The columns and their order in the resulting CSV file is determined in the same way as in
:meth:`export_to_xhtml`. :meth:`export_to_xhtml`.
""" """
dest_file = self.view.select_dest_file(tr("Select a destination for your exported CSV"), 'csv') dest_file = self.view.select_dest_file(tr("Select a destination for your exported CSV"), 'csv')
if dest_file: if dest_file:
colnames, rows = self._get_export_data() colnames, rows = self._get_export_data()
export.export_to_csv(dest_file, colnames, rows) try:
export.export_to_csv(dest_file, colnames, rows)
except OSError as e:
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
def get_display_info(self, dupe, group, delta=False): def get_display_info(self, dupe, group, delta=False):
def empty_data(): def empty_data():
return {c.name: '---' for c in self.result_table.COLUMNS[1:]} return {c.name: '---' for c in self.result_table.COLUMNS[1:]}
@@ -476,10 +490,10 @@ class DupeGuru(Broadcaster):
except Exception as e: except Exception as e:
logging.warning("Exception on GetDisplayInfo for %s: %s", str(dupe.path), str(e)) logging.warning("Exception on GetDisplayInfo for %s: %s", str(dupe.path), str(e))
return empty_data() return empty_data()
def invoke_custom_command(self): def invoke_custom_command(self):
"""Calls command in ``CustomCommand`` pref with ``%d`` and ``%r`` placeholders replaced. """Calls command in ``CustomCommand`` pref with ``%d`` and ``%r`` placeholders replaced.
Using the current selection, ``%d`` is replaced with the currently selected dupe and ``%r`` Using the current selection, ``%d`` is replaced with the currently selected dupe and ``%r``
is replaced with that dupe's ref file. If there's no selection, the command is not invoked. is replaced with that dupe's ref file. If there's no selection, the command is not invoked.
If the dupe is a ref, ``%d`` and ``%r`` will be the same. If the dupe is a ref, ``%d`` and ``%r`` will be the same.
@@ -506,10 +520,10 @@ class DupeGuru(Broadcaster):
subprocess.Popen(exename + args, shell=True, cwd=path) subprocess.Popen(exename + args, shell=True, cwd=path)
else: else:
subprocess.Popen(cmd, shell=True) subprocess.Popen(cmd, shell=True)
def load(self): def load(self):
"""Load directory selection and ignore list from files in appdata. """Load directory selection and ignore list from files in appdata.
This method is called during startup so that directory selection and ignore list, which This method is called during startup so that directory selection and ignore list, which
is persistent data, is the same as when the last session was closed (when :meth:`save` was is persistent data, is the same as when the last session was closed (when :meth:`save` was
called). called).
@@ -519,19 +533,19 @@ class DupeGuru(Broadcaster):
p = op.join(self.appdata, 'ignore_list.xml') p = op.join(self.appdata, 'ignore_list.xml')
self.scanner.ignore_list.load_from_xml(p) self.scanner.ignore_list.load_from_xml(p)
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
def load_from(self, filename): def load_from(self, filename):
"""Start an async job to load results from ``filename``. """Start an async job to load results from ``filename``.
:param str filename: path of the XML file (created with :meth:`save_as`) to load :param str filename: path of the XML file (created with :meth:`save_as`) to load
""" """
def do(j): def do(j):
self.results.load_from_xml(filename, self._get_file, j) self.results.load_from_xml(filename, self._get_file, j)
self._start_job(JobType.Load, do) self._start_job(JobType.Load, do)
def make_selected_reference(self): def make_selected_reference(self):
"""Promote :attr:`selected_dupes` to reference position within their respective groups. """Promote :attr:`selected_dupes` to reference position within their respective groups.
Each selected dupe will become the :attr:`~core.engine.Group.ref` of its group. If there's Each selected dupe will become the :attr:`~core.engine.Group.ref` of its group. If there's
more than one dupe selected for the same group, only the first (in the order currently shown more than one dupe selected for the same group, only the first (in the order currently shown
in :attr:`result_table`) dupe will be promoted. in :attr:`result_table`) dupe will be promoted.
@@ -550,8 +564,10 @@ class DupeGuru(Broadcaster):
# If no group was changed, however, we don't touch the selection. # If no group was changed, however, we don't touch the selection.
if not self.result_table.power_marker: if not self.result_table.power_marker:
if changed_groups: if changed_groups:
self.selected_dupes = [d for d in self.selected_dupes self.selected_dupes = [
if self.results.get_group_of_duplicate(d).ref is d] d for d in self.selected_dupes
if self.results.get_group_of_duplicate(d).ref is d
]
self.notify('results_changed') self.notify('results_changed')
else: else:
# If we're in "Dupes Only" mode (previously called Power Marker), things are a bit # If we're in "Dupes Only" mode (previously called Power Marker), things are a bit
@@ -560,28 +576,28 @@ class DupeGuru(Broadcaster):
# do is to keep our selection index-wise (different dupe selection, but same index # do is to keep our selection index-wise (different dupe selection, but same index
# selection). # selection).
self.notify('results_changed_but_keep_selection') self.notify('results_changed_but_keep_selection')
def mark_all(self): def mark_all(self):
"""Set all dupes in the results as marked. """Set all dupes in the results as marked.
""" """
self.results.mark_all() self.results.mark_all()
self.notify('marking_changed') self.notify('marking_changed')
def mark_none(self): def mark_none(self):
"""Set all dupes in the results as unmarked. """Set all dupes in the results as unmarked.
""" """
self.results.mark_none() self.results.mark_none()
self.notify('marking_changed') self.notify('marking_changed')
def mark_invert(self): def mark_invert(self):
"""Invert the marked state of all dupes in the results. """Invert the marked state of all dupes in the results.
""" """
self.results.mark_invert() self.results.mark_invert()
self.notify('marking_changed') self.notify('marking_changed')
def mark_dupe(self, dupe, marked): def mark_dupe(self, dupe, marked):
"""Change marked status of ``dupe``. """Change marked status of ``dupe``.
:param dupe: dupe to mark/unmark :param dupe: dupe to mark/unmark
:type dupe: :class:`~core.fs.File` :type dupe: :class:`~core.fs.File`
:param bool marked: True = mark, False = unmark :param bool marked: True = mark, False = unmark
@@ -591,7 +607,7 @@ class DupeGuru(Broadcaster):
else: else:
self.results.unmark(dupe) self.results.unmark(dupe)
self.notify('marking_changed') self.notify('marking_changed')
def open_selected(self): def open_selected(self):
"""Open :attr:`selected_dupes` with their associated application. """Open :attr:`selected_dupes` with their associated application.
""" """
@@ -600,16 +616,16 @@ class DupeGuru(Broadcaster):
return return
for dupe in self.selected_dupes: for dupe in self.selected_dupes:
desktop.open_path(dupe.path) desktop.open_path(dupe.path)
def purge_ignore_list(self): def purge_ignore_list(self):
"""Remove files that don't exist from :attr:`ignore_list`. """Remove files that don't exist from :attr:`ignore_list`.
""" """
self.scanner.ignore_list.Filter(lambda f,s:op.exists(f) and op.exists(s)) self.scanner.ignore_list.Filter(lambda f, s: op.exists(f) and op.exists(s))
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
def remove_directories(self, indexes): def remove_directories(self, indexes):
"""Remove root directories at ``indexes`` from :attr:`directories`. """Remove root directories at ``indexes`` from :attr:`directories`.
:param indexes: Indexes of the directories to remove. :param indexes: Indexes of the directories to remove.
:type indexes: list of int :type indexes: list of int
""" """
@@ -620,30 +636,30 @@ class DupeGuru(Broadcaster):
self.notify('directories_changed') self.notify('directories_changed')
except IndexError: except IndexError:
pass pass
def remove_duplicates(self, duplicates): def remove_duplicates(self, duplicates):
"""Remove ``duplicates`` from :attr:`results`. """Remove ``duplicates`` from :attr:`results`.
Calls :meth:`~core.results.Results.remove_duplicates` and send appropriate notifications. Calls :meth:`~core.results.Results.remove_duplicates` and send appropriate notifications.
:param duplicates: duplicates to remove. :param duplicates: duplicates to remove.
:type duplicates: list of :class:`~core.fs.File` :type duplicates: list of :class:`~core.fs.File`
""" """
self.results.remove_duplicates(self.without_ref(duplicates)) self.results.remove_duplicates(self.without_ref(duplicates))
self.notify('results_changed_but_keep_selection') self.notify('results_changed_but_keep_selection')
def remove_marked(self): def remove_marked(self):
"""Removed marked duplicates from the results (without touching the files themselves). """Removed marked duplicates from the results (without touching the files themselves).
""" """
if not self.results.mark_count: if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES) self.view.show_message(MSG_NO_MARKED_DUPES)
return return
msg = tr("You are about to remove %d files from results. Continue?") msg = tr("You are about to remove %d files from results. Continue?")
if not self.view.ask_yes_no(msg % self.results.mark_count): if not self.view.ask_yes_no(msg % self.results.mark_count):
return return
self.results.perform_on_marked(lambda x:None, True) self.results.perform_on_marked(lambda x: None, True)
self._results_changed() self._results_changed()
def remove_selected(self): def remove_selected(self):
"""Removed :attr:`selected_dupes` from the results (without touching the files themselves). """Removed :attr:`selected_dupes` from the results (without touching the files themselves).
""" """
@@ -651,16 +667,16 @@ class DupeGuru(Broadcaster):
if not dupes: if not dupes:
self.view.show_message(MSG_NO_SELECTED_DUPES) self.view.show_message(MSG_NO_SELECTED_DUPES)
return return
msg = tr("You are about to remove %d files from results. Continue?") msg = tr("You are about to remove %d files from results. Continue?")
if not self.view.ask_yes_no(msg % len(dupes)): if not self.view.ask_yes_no(msg % len(dupes)):
return return
self.remove_duplicates(dupes) self.remove_duplicates(dupes)
def rename_selected(self, newname): def rename_selected(self, newname):
"""Renames the selected dupes's file to ``newname``. """Renames the selected dupes's file to ``newname``.
If there's more than one selected dupes, the first one is used. If there's more than one selected dupes, the first one is used.
:param str newname: The filename to rename the dupe's file to. :param str newname: The filename to rename the dupe's file to.
""" """
try: try:
@@ -670,13 +686,13 @@ class DupeGuru(Broadcaster):
except (IndexError, fs.FSError) as e: except (IndexError, fs.FSError) as e:
logging.warning("dupeGuru Warning: %s" % str(e)) logging.warning("dupeGuru Warning: %s" % str(e))
return False return False
def reprioritize_groups(self, sort_key): def reprioritize_groups(self, sort_key):
"""Sort dupes in each group (in :attr:`results`) according to ``sort_key``. """Sort dupes in each group (in :attr:`results`) according to ``sort_key``.
Called by the re-prioritize dialog. Calls :meth:`~core.engine.Group.prioritize` and, once Called by the re-prioritize dialog. Calls :meth:`~core.engine.Group.prioritize` and, once
the sorting is done, show a message that confirms the action. the sorting is done, show a message that confirms the action.
:param sort_key: The key being sent to :meth:`~core.engine.Group.prioritize` :param sort_key: The key being sent to :meth:`~core.engine.Group.prioritize`
:type sort_key: f(dupe) :type sort_key: f(dupe)
""" """
@@ -687,11 +703,11 @@ class DupeGuru(Broadcaster):
self._results_changed() self._results_changed()
msg = tr("{} duplicate groups were changed by the re-prioritization.").format(count) msg = tr("{} duplicate groups were changed by the re-prioritization.").format(count)
self.view.show_message(msg) self.view.show_message(msg)
def reveal_selected(self): def reveal_selected(self):
if self.selected_dupes: if self.selected_dupes:
desktop.reveal_path(self.selected_dupes[0].path) desktop.reveal_path(self.selected_dupes[0].path)
def save(self): def save(self):
if not op.exists(self.appdata): if not op.exists(self.appdata):
os.makedirs(self.appdata) os.makedirs(self.appdata)
@@ -699,17 +715,20 @@ class DupeGuru(Broadcaster):
p = op.join(self.appdata, 'ignore_list.xml') p = op.join(self.appdata, 'ignore_list.xml')
self.scanner.ignore_list.save_to_xml(p) self.scanner.ignore_list.save_to_xml(p)
self.notify('save_session') self.notify('save_session')
def save_as(self, filename): def save_as(self, filename):
"""Save results in ``filename``. """Save results in ``filename``.
:param str filename: path of the file to save results (as XML) to. :param str filename: path of the file to save results (as XML) to.
""" """
self.results.save_to_xml(filename) try:
self.results.save_to_xml(filename)
except OSError as e:
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
def start_scanning(self): def start_scanning(self):
"""Starts an async job to scan for duplicates. """Starts an async job to scan for duplicates.
Scans folders selected in :attr:`directories` and put the results in :attr:`results` Scans folders selected in :attr:`directories` and put the results in :attr:`results`
""" """
def do(j): def do(j):
@@ -722,14 +741,14 @@ class DupeGuru(Broadcaster):
files = self._remove_hardlink_dupes(files) files = self._remove_hardlink_dupes(files)
logging.info('Scanning %d files' % len(files)) logging.info('Scanning %d files' % len(files))
self.results.groups = self.scanner.get_dupe_groups(files, j) self.results.groups = self.scanner.get_dupe_groups(files, j)
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
self.results.groups = [] self.results.groups = []
self._results_changed() self._results_changed()
self._start_job(JobType.Scan, do) self._start_job(JobType.Scan, do)
def toggle_selected_mark_state(self): def toggle_selected_mark_state(self):
selected = self.without_ref(self.selected_dupes) selected = self.without_ref(self.selected_dupes)
if not selected: if not selected:
@@ -741,12 +760,12 @@ class DupeGuru(Broadcaster):
for dupe in selected: for dupe in selected:
markfunc(dupe) markfunc(dupe)
self.notify('marking_changed') self.notify('marking_changed')
def without_ref(self, dupes): def without_ref(self, dupes):
"""Returns ``dupes`` with all reference elements removed. """Returns ``dupes`` with all reference elements removed.
""" """
return [dupe for dupe in dupes if self.results.get_group_of_duplicate(dupe).ref is not dupe] return [dupe for dupe in dupes if self.results.get_group_of_duplicate(dupe).ref is not dupe]
def get_default(self, key, fallback_value=None): def get_default(self, key, fallback_value=None):
result = nonone(self.view.get_default(key), fallback_value) result = nonone(self.view.get_default(key), fallback_value)
if fallback_value is not None and not isinstance(result, type(fallback_value)): if fallback_value is not None and not isinstance(result, type(fallback_value)):
@@ -756,10 +775,10 @@ class DupeGuru(Broadcaster):
except Exception: except Exception:
result = fallback_value result = fallback_value
return result return result
def set_default(self, key, value): def set_default(self, key, value):
self.view.set_default(key, value) self.view.set_default(key, value)
#--- Properties #--- Properties
@property @property
def stat_line(self): def stat_line(self):
@@ -767,4 +786,4 @@ class DupeGuru(Broadcaster):
if self.scanner.discarded_file_count: if self.scanner.discarded_file_count:
result = tr("%s (%d discarded)") % (result, self.scanner.discarded_file_count) result = tr("%s (%d discarded)") % (result, self.scanner.discarded_file_count)
return result return result

View File

@@ -1,15 +1,15 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/02/27 # Created On: 2006/02/27
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import logging import logging
from jobprogress import job from hscommon.jobprogress import job
from hscommon.path import Path from hscommon.path import Path
from hscommon.util import FileOrPath from hscommon.util import FileOrPath
@@ -24,7 +24,7 @@ __all__ = [
class DirectoryState: class DirectoryState:
"""Enum describing how a folder should be considered. """Enum describing how a folder should be considered.
* DirectoryState.Normal: Scan all files normally * DirectoryState.Normal: Scan all files normally
* DirectoryState.Reference: Scan files, but make sure never to delete any of them * DirectoryState.Reference: Scan files, but make sure never to delete any of them
* DirectoryState.Excluded: Don't scan this folder * DirectoryState.Excluded: Don't scan this folder
@@ -41,10 +41,10 @@ class InvalidPathError(Exception):
class Directories: class Directories:
"""Holds user folder selection. """Holds user folder selection.
Manages the selection that the user make through the folder selection dialog. It also manages Manages the selection that the user make through the folder selection dialog. It also manages
folder states, and how recursion applies to them. folder states, and how recursion applies to them.
Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped
in :mod:`core.fs`) that have to be scanned according to the chosen folders/states. in :mod:`core.fs`) that have to be scanned according to the chosen folders/states.
""" """
@@ -55,28 +55,28 @@ class Directories:
self.states = {} self.states = {}
self.fileclasses = fileclasses self.fileclasses = fileclasses
self.folderclass = fs.Folder self.folderclass = fs.Folder
def __contains__(self, path): def __contains__(self, path):
for p in self._dirs: for p in self._dirs:
if path in p: if path in p:
return True return True
return False return False
def __delitem__(self,key): def __delitem__(self, key):
self._dirs.__delitem__(key) self._dirs.__delitem__(key)
def __getitem__(self,key): def __getitem__(self, key):
return self._dirs.__getitem__(key) return self._dirs.__getitem__(key)
def __len__(self): def __len__(self):
return len(self._dirs) return len(self._dirs)
#---Private #---Private
def _default_state_for_path(self, path): def _default_state_for_path(self, path):
# Override this in subclasses to specify the state of some special folders. # Override this in subclasses to specify the state of some special folders.
if path.name.startswith('.'): # hidden if path.name.startswith('.'): # hidden
return DirectoryState.Excluded return DirectoryState.Excluded
def _get_files(self, from_path, j): def _get_files(self, from_path, j):
j.check_if_cancelled() j.check_if_cancelled()
state = self.get_state(from_path) state = self.get_state(from_path)
@@ -95,14 +95,15 @@ class Directories:
file.is_ref = state == DirectoryState.Reference file.is_ref = state == DirectoryState.Reference
filepaths.add(file.path) filepaths.add(file.path)
yield file yield file
# it's possible that a folder (bundle) gets into the file list. in that case, we don't want to recurse into it # it's possible that a folder (bundle) gets into the file list. in that case, we don't
# want to recurse into it
subfolders = [p for p in from_path.listdir() if not p.islink() and p.isdir() and p not in filepaths] subfolders = [p for p in from_path.listdir() if not p.islink() and p.isdir() and p not in filepaths]
for subfolder in subfolders: for subfolder in subfolders:
for file in self._get_files(subfolder, j): for file in self._get_files(subfolder, j):
yield file yield file
except (EnvironmentError, fs.InvalidPath): except (EnvironmentError, fs.InvalidPath):
pass pass
def _get_folders(self, from_folder, j): def _get_folders(self, from_folder, j):
j.check_if_cancelled() j.check_if_cancelled()
try: try:
@@ -116,16 +117,16 @@ class Directories:
yield from_folder yield from_folder
except (EnvironmentError, fs.InvalidPath): except (EnvironmentError, fs.InvalidPath):
pass pass
#---Public #---Public
def add_path(self, path): def add_path(self, path):
"""Adds ``path`` to self, if not already there. """Adds ``path`` to self, if not already there.
Raises :exc:`AlreadyThereError` if ``path`` is already in self. If path is a directory Raises :exc:`AlreadyThereError` if ``path`` is already in self. If path is a directory
containing some of the directories already present in self, ``path`` will be added, but all containing some of the directories already present in self, ``path`` will be added, but all
directories under it will be removed. Can also raise :exc:`InvalidPathError` if ``path`` directories under it will be removed. Can also raise :exc:`InvalidPathError` if ``path``
does not exist. does not exist.
:param Path path: path to add :param Path path: path to add
""" """
if path in self: if path in self:
@@ -134,43 +135,43 @@ class Directories:
raise InvalidPathError() raise InvalidPathError()
self._dirs = [p for p in self._dirs if p not in path] self._dirs = [p for p in self._dirs if p not in path]
self._dirs.append(path) self._dirs.append(path)
@staticmethod @staticmethod
def get_subfolders(path): def get_subfolders(path):
"""Returns a sorted list of paths corresponding to subfolders in ``path``. """Returns a sorted list of paths corresponding to subfolders in ``path``.
:param Path path: get subfolders from there :param Path path: get subfolders from there
:rtype: list of Path :rtype: list of Path
""" """
try: try:
subpaths = [p for p in path.listdir() if p.isdir()] subpaths = [p for p in path.listdir() if p.isdir()]
subpaths.sort(key=lambda x:x.name.lower()) subpaths.sort(key=lambda x: x.name.lower())
return subpaths return subpaths
except EnvironmentError: except EnvironmentError:
return [] return []
def get_files(self, j=job.nulljob): def get_files(self, j=job.nulljob):
"""Returns a list of all files that are not excluded. """Returns a list of all files that are not excluded.
Returned files also have their ``is_ref`` attr set if applicable. Returned files also have their ``is_ref`` attr set if applicable.
""" """
for path in self._dirs: for path in self._dirs:
for file in self._get_files(path, j): for file in self._get_files(path, j):
yield file yield file
def get_folders(self, j=job.nulljob): def get_folders(self, j=job.nulljob):
"""Returns a list of all folders that are not excluded. """Returns a list of all folders that are not excluded.
Returned folders also have their ``is_ref`` attr set if applicable. Returned folders also have their ``is_ref`` attr set if applicable.
""" """
for path in self._dirs: for path in self._dirs:
from_folder = self.folderclass(path) from_folder = self.folderclass(path)
for folder in self._get_folders(from_folder, j): for folder in self._get_folders(from_folder, j):
yield folder yield folder
def get_state(self, path): def get_state(self, path):
"""Returns the state of ``path``. """Returns the state of ``path``.
:rtype: :class:`DirectoryState` :rtype: :class:`DirectoryState`
""" """
if path in self.states: if path in self.states:
@@ -183,12 +184,12 @@ class Directories:
return self.get_state(parent) return self.get_state(parent)
else: else:
return DirectoryState.Normal return DirectoryState.Normal
def has_any_file(self): def has_any_file(self):
"""Returns whether selected folders contain any file. """Returns whether selected folders contain any file.
Because it stops at the first file it finds, it's much faster than get_files(). Because it stops at the first file it finds, it's much faster than get_files().
:rtype: bool :rtype: bool
""" """
try: try:
@@ -196,10 +197,10 @@ class Directories:
return True return True
except StopIteration: except StopIteration:
return False return False
def load_from_file(self, infile): def load_from_file(self, infile):
"""Load folder selection from ``infile``. """Load folder selection from ``infile``.
:param file infile: path or file pointer to XML generated through :meth:`save_to_file` :param file infile: path or file pointer to XML generated through :meth:`save_to_file`
""" """
try: try:
@@ -222,10 +223,10 @@ class Directories:
path = attrib['path'] path = attrib['path']
state = attrib['value'] state = attrib['value']
self.states[Path(path)] = int(state) self.states[Path(path)] = int(state)
def save_to_file(self, outfile): def save_to_file(self, outfile):
"""Save folder selection as XML to ``outfile``. """Save folder selection as XML to ``outfile``.
:param file outfile: path or file pointer to XML file to save to. :param file outfile: path or file pointer to XML file to save to.
""" """
with FileOrPath(outfile, 'wb') as fp: with FileOrPath(outfile, 'wb') as fp:
@@ -239,10 +240,10 @@ class Directories:
state_node.set('value', str(state)) state_node.set('value', str(state))
tree = ET.ElementTree(root) tree = ET.ElementTree(root)
tree.write(fp, encoding='utf-8') tree.write(fp, encoding='utf-8')
def set_state(self, path, state): def set_state(self, path, state):
"""Set the state of folder at ``path``. """Set the state of folder at ``path``.
:param Path path: path of the target folder :param Path path: path of the target folder
:param state: state to set folder to :param state: state to set folder to
:type state: :class:`DirectoryState` :type state: :class:`DirectoryState`
@@ -253,4 +254,4 @@ class Directories:
if path.is_parent_of(iter_path): if path.is_parent_of(iter_path):
del self.states[iter_path] del self.states[iter_path]
self.states[path] = state self.states[path] = state

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/01/29 # Created On: 2006/01/29
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import difflib import difflib
@@ -15,11 +15,13 @@ from unicodedata import normalize
from hscommon.util import flatten, multi_replace from hscommon.util import flatten, multi_replace
from hscommon.trans import tr from hscommon.trans import tr
from jobprogress import job from hscommon.jobprogress import job
(WEIGHT_WORDS, (
MATCH_SIMILAR_WORDS, WEIGHT_WORDS,
NO_FIELD_ORDER) = range(3) MATCH_SIMILAR_WORDS,
NO_FIELD_ORDER,
) = range(3)
JOB_REFRESH_RATE = 100 JOB_REFRESH_RATE = 100
@@ -45,7 +47,7 @@ def unpack_fields(fields):
def compare(first, second, flags=()): def compare(first, second, flags=()):
"""Returns the % of words that match between ``first`` and ``second`` """Returns the % of words that match between ``first`` and ``second``
The result is a ``int`` in the range 0..100. The result is a ``int`` in the range 0..100.
``first`` and ``second`` can be either a string or a list (of words). ``first`` and ``second`` can be either a string or a list (of words).
""" """
@@ -53,7 +55,7 @@ def compare(first, second, flags=()):
return 0 return 0
if any(isinstance(element, list) for element in first): if any(isinstance(element, list) for element in first):
return compare_fields(first, second, flags) return compare_fields(first, second, flags)
second = second[:] #We must use a copy of second because we remove items from it second = second[:] #We must use a copy of second because we remove items from it
match_similar = MATCH_SIMILAR_WORDS in flags match_similar = MATCH_SIMILAR_WORDS in flags
weight_words = WEIGHT_WORDS in flags weight_words = WEIGHT_WORDS in flags
joined = first + second joined = first + second
@@ -77,9 +79,9 @@ def compare(first, second, flags=()):
def compare_fields(first, second, flags=()): def compare_fields(first, second, flags=()):
"""Returns the score for the lowest matching :ref:`fields`. """Returns the score for the lowest matching :ref:`fields`.
``first`` and ``second`` must be lists of lists of string. Each sub-list is then compared with ``first`` and ``second`` must be lists of lists of string. Each sub-list is then compared with
:func:`compare`. :func:`compare`.
""" """
if len(first) != len(second): if len(first) != len(second):
return 0 return 0
@@ -104,10 +106,10 @@ def compare_fields(first, second, flags=()):
def build_word_dict(objects, j=job.nulljob): def build_word_dict(objects, j=job.nulljob):
"""Returns a dict of objects mapped by their words. """Returns a dict of objects mapped by their words.
objects must have a ``words`` attribute being a list of strings or a list of lists of strings objects must have a ``words`` attribute being a list of strings or a list of lists of strings
(:ref:`fields`). (:ref:`fields`).
The result will be a dict with words as keys, lists of objects as values. The result will be a dict with words as keys, lists of objects as values.
""" """
result = defaultdict(set) result = defaultdict(set)
@@ -118,7 +120,7 @@ def build_word_dict(objects, j=job.nulljob):
def merge_similar_words(word_dict): def merge_similar_words(word_dict):
"""Take all keys in ``word_dict`` that are similar, and merge them together. """Take all keys in ``word_dict`` that are similar, and merge them together.
``word_dict`` has been built with :func:`build_word_dict`. Similarity is computed with Python's ``word_dict`` has been built with :func:`build_word_dict`. Similarity is computed with Python's
``difflib.get_close_matches()``, which computes the number of edits that are necessary to make ``difflib.get_close_matches()``, which computes the number of edits that are necessary to make
a word equal to the other. a word equal to the other.
@@ -138,9 +140,9 @@ def merge_similar_words(word_dict):
def reduce_common_words(word_dict, threshold): def reduce_common_words(word_dict, threshold):
"""Remove all objects from ``word_dict`` values where the object count >= ``threshold`` """Remove all objects from ``word_dict`` values where the object count >= ``threshold``
``word_dict`` has been built with :func:`build_word_dict`. ``word_dict`` has been built with :func:`build_word_dict`.
The exception to this removal are the objects where all the words of the object are common. The exception to this removal are the objects where all the words of the object are common.
Because if we remove them, we will miss some duplicates! Because if we remove them, we will miss some duplicates!
""" """
@@ -181,17 +183,17 @@ class Match(namedtuple('Match', 'first second percentage')):
exact scan methods, such as Contents scans, this will always be 100. exact scan methods, such as Contents scans, this will always be 100.
""" """
__slots__ = () __slots__ = ()
def get_match(first, second, flags=()): def get_match(first, second, flags=()):
#it is assumed here that first and second both have a "words" attribute #it is assumed here that first and second both have a "words" attribute
percentage = compare(first.words, second.words, flags) percentage = compare(first.words, second.words, flags)
return Match(first, second, percentage) return Match(first, second, percentage)
def getmatches( def getmatches(
objects, min_match_percentage=0, match_similar_words=False, weight_words=False, objects, min_match_percentage=0, match_similar_words=False, weight_words=False,
no_field_order=False, j=job.nulljob): no_field_order=False, j=job.nulljob):
"""Returns a list of :class:`Match` within ``objects`` after fuzzily matching their words. """Returns a list of :class:`Match` within ``objects`` after fuzzily matching their words.
:param objects: List of :class:`~core.fs.File` to match. :param objects: List of :class:`~core.fs.File` to match.
:param int min_match_percentage: minimum % of words that have to match. :param int min_match_percentage: minimum % of words that have to match.
:param bool match_similar_words: make similar words (see :func:`merge_similar_words`) match. :param bool match_similar_words: make similar words (see :func:`merge_similar_words`) match.
@@ -246,7 +248,7 @@ def getmatches(
def getmatches_by_contents(files, sizeattr='size', partial=False, j=job.nulljob): def getmatches_by_contents(files, sizeattr='size', partial=False, j=job.nulljob):
"""Returns a list of :class:`Match` within ``files`` if their contents is the same. """Returns a list of :class:`Match` within ``files`` if their contents is the same.
:param str sizeattr: attibute name of the :class:`~core.fs.file` that returns the size of the :param str sizeattr: attibute name of the :class:`~core.fs.file` that returns the size of the
file to use for comparison. file to use for comparison.
:param bool partial: if true, will use the "md5partial" attribute instead of "md5" to compute :param bool partial: if true, will use the "md5partial" attribute instead of "md5" to compute
@@ -259,6 +261,7 @@ def getmatches_by_contents(files, sizeattr='size', partial=False, j=job.nulljob)
filesize = getattr(file, sizeattr) filesize = getattr(file, sizeattr)
if filesize: if filesize:
size2files[filesize].add(file) size2files[filesize].add(file)
del files
possible_matches = [files for files in size2files.values() if len(files) > 1] possible_matches = [files for files in size2files.values() if len(files) > 1]
del size2files del size2files
result = [] result = []
@@ -278,44 +281,44 @@ class Group:
This manages match pairs into groups and ensures that all files in the group match to each This manages match pairs into groups and ensures that all files in the group match to each
other. other.
.. attribute:: ref .. attribute:: ref
The "reference" file, which is the file among the group that isn't going to be deleted. The "reference" file, which is the file among the group that isn't going to be deleted.
.. attribute:: ordered .. attribute:: ordered
Ordered list of duplicates in the group (including the :attr:`ref`). Ordered list of duplicates in the group (including the :attr:`ref`).
.. attribute:: unordered .. attribute:: unordered
Set duplicates in the group (including the :attr:`ref`). Set duplicates in the group (including the :attr:`ref`).
.. attribute:: dupes .. attribute:: dupes
An ordered list of the group's duplicate, without :attr:`ref`. Equivalent to An ordered list of the group's duplicate, without :attr:`ref`. Equivalent to
``ordered[1:]`` ``ordered[1:]``
.. attribute:: percentage .. attribute:: percentage
Average match percentage of match pairs containing :attr:`ref`. Average match percentage of match pairs containing :attr:`ref`.
""" """
#---Override #---Override
def __init__(self): def __init__(self):
self._clear() self._clear()
def __contains__(self, item): def __contains__(self, item):
return item in self.unordered return item in self.unordered
def __getitem__(self, key): def __getitem__(self, key):
return self.ordered.__getitem__(key) return self.ordered.__getitem__(key)
def __iter__(self): def __iter__(self):
return iter(self.ordered) return iter(self.ordered)
def __len__(self): def __len__(self):
return len(self.ordered) return len(self.ordered)
#---Private #---Private
def _clear(self): def _clear(self):
self._percentage = None self._percentage = None
@@ -324,22 +327,22 @@ class Group:
self.candidates = defaultdict(set) self.candidates = defaultdict(set)
self.ordered = [] self.ordered = []
self.unordered = set() self.unordered = set()
def _get_matches_for_ref(self): def _get_matches_for_ref(self):
if self._matches_for_ref is None: if self._matches_for_ref is None:
ref = self.ref ref = self.ref
self._matches_for_ref = [match for match in self.matches if ref in match] self._matches_for_ref = [match for match in self.matches if ref in match]
return self._matches_for_ref return self._matches_for_ref
#---Public #---Public
def add_match(self, match): def add_match(self, match):
"""Adds ``match`` to internal match list and possibly add duplicates to the group. """Adds ``match`` to internal match list and possibly add duplicates to the group.
A duplicate can only be considered as such if it matches all other duplicates in the group. A duplicate can only be considered as such if it matches all other duplicates in the group.
This method registers that pair (A, B) represented in ``match`` as possible candidates and, This method registers that pair (A, B) represented in ``match`` as possible candidates and,
if A and/or B end up matching every other duplicates in the group, add these duplicates to if A and/or B end up matching every other duplicates in the group, add these duplicates to
the group. the group.
:param tuple match: pair of :class:`~core.fs.File` to add :param tuple match: pair of :class:`~core.fs.File` to add
""" """
def add_candidate(item, match): def add_candidate(item, match):
@@ -348,7 +351,7 @@ class Group:
if self.unordered <= matches: if self.unordered <= matches:
self.ordered.append(item) self.ordered.append(item)
self.unordered.add(item) self.unordered.add(item)
if match in self.matches: if match in self.matches:
return return
self.matches.add(match) self.matches.add(match)
@@ -359,17 +362,17 @@ class Group:
add_candidate(second, first) add_candidate(second, first)
self._percentage = None self._percentage = None
self._matches_for_ref = None self._matches_for_ref = None
def discard_matches(self): def discard_matches(self):
"""Remove all recorded matches that didn't result in a duplicate being added to the group. """Remove all recorded matches that didn't result in a duplicate being added to the group.
You can call this after the duplicate scanning process to free a bit of memory. You can call this after the duplicate scanning process to free a bit of memory.
""" """
discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second])) discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second]))
self.matches -= discarded self.matches -= discarded
self.candidates = defaultdict(set) self.candidates = defaultdict(set)
return discarded return discarded
def get_match_of(self, item): def get_match_of(self, item):
"""Returns the match pair between ``item`` and :attr:`ref`. """Returns the match pair between ``item`` and :attr:`ref`.
""" """
@@ -378,10 +381,10 @@ class Group:
for m in self._get_matches_for_ref(): for m in self._get_matches_for_ref():
if item in m: if item in m:
return m return m
def prioritize(self, key_func, tie_breaker=None): def prioritize(self, key_func, tie_breaker=None):
"""Reorders :attr:`ordered` according to ``key_func``. """Reorders :attr:`ordered` according to ``key_func``.
:param key_func: Key (f(x)) to be used for sorting :param key_func: Key (f(x)) to be used for sorting
:param tie_breaker: function to be used to select the reference position in case the top :param tie_breaker: function to be used to select the reference position in case the top
duplicates have the same key_func() result. duplicates have the same key_func() result.
@@ -405,7 +408,7 @@ class Group:
self.switch_ref(ref) self.switch_ref(ref)
return True return True
return changed return changed
def remove_dupe(self, item, discard_matches=True): def remove_dupe(self, item, discard_matches=True):
try: try:
self.ordered.remove(item) self.ordered.remove(item)
@@ -419,7 +422,7 @@ class Group:
self._clear() self._clear()
except ValueError: except ValueError:
pass pass
def switch_ref(self, with_dupe): def switch_ref(self, with_dupe):
"""Make the :attr:`ref` dupe of the group switch position with ``with_dupe``. """Make the :attr:`ref` dupe of the group switch position with ``with_dupe``.
""" """
@@ -433,9 +436,9 @@ class Group:
return True return True
except ValueError: except ValueError:
return False return False
dupes = property(lambda self: self[1:]) dupes = property(lambda self: self[1:])
@property @property
def percentage(self): def percentage(self):
if self._percentage is None: if self._percentage is None:
@@ -445,16 +448,16 @@ class Group:
else: else:
self._percentage = 0 self._percentage = 0
return self._percentage return self._percentage
@property @property
def ref(self): def ref(self):
if self: if self:
return self[0] return self[0]
def get_groups(matches, j=job.nulljob): def get_groups(matches, j=job.nulljob):
"""Returns a list of :class:`Group` from ``matches``. """Returns a list of :class:`Group` from ``matches``.
Create groups out of match pairs in the smartest way possible. Create groups out of match pairs in the smartest way possible.
""" """
matches.sort(key=lambda match: -match.percentage) matches.sort(key=lambda match: -match.percentage)
@@ -495,7 +498,10 @@ def get_groups(matches, j=job.nulljob):
matched_files = set(flatten(groups)) matched_files = set(flatten(groups))
orphan_matches = [] orphan_matches = []
for group in groups: for group in groups:
orphan_matches += set(m for m in group.discard_matches() if not any(obj in matched_files for obj in [m.first, m.second])) orphan_matches += {
m for m in group.discard_matches()
if not any(obj in matched_files for obj in [m.first, m.second])
}
if groups and orphan_matches: if groups and orphan_matches:
groups += get_groups(orphan_matches) # no job, as it isn't supposed to take a long time groups += get_groups(orphan_matches) # no job, as it isn't supposed to take a long time
return groups return groups

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/09/16 # Created On: 2006/09/16
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import os.path as op import os.path as op
@@ -19,56 +19,56 @@ MAIN_TEMPLATE = """
<html xmlns="http://www.w3.org/1999/xhtml"> <html xmlns="http://www.w3.org/1999/xhtml">
<head> <head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"/> <meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
<title>dupeGuru Results</title> <title>dupeGuru Results</title>
<style type="text/css"> <style type="text/css">
BODY BODY
{ {
background-color:white; background-color:white;
} }
BODY,A,P,UL,TABLE,TR,TD BODY,A,P,UL,TABLE,TR,TD
{ {
font-family:Tahoma,Arial,sans-serif; font-family:Tahoma,Arial,sans-serif;
font-size:10pt; font-size:10pt;
color: #4477AA; color: #4477AA;
} }
TABLE TABLE
{ {
background-color: #225588; background-color: #225588;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
width: 90%; width: 90%;
} }
TR TR
{ {
background-color: white; background-color: white;
} }
TH TH
{ {
font-weight: bold; font-weight: bold;
color: black; color: black;
background-color: #C8D6E5; background-color: #C8D6E5;
} }
TH TD TH TD
{ {
color:black; color:black;
} }
TD TD
{ {
padding-left: 2pt; padding-left: 2pt;
} }
TD.rightelem TD.rightelem
{ {
text-align:right; text-align:right;
/*padding-left:0pt;*/ /*padding-left:0pt;*/
padding-right: 2pt; padding-right: 2pt;
width: 17%; width: 17%;
} }
TD.indented TD.indented
@@ -78,19 +78,19 @@ TD.indented
H1 H1
{ {
font-family:&quot;Courier New&quot;,monospace; font-family:&quot;Courier New&quot;,monospace;
color:#6699CC; color:#6699CC;
font-size:18pt; font-size:18pt;
color:#6da500; color:#6da500;
border-color: #70A0CF; border-color: #70A0CF;
border-width: 1pt; border-width: 1pt;
border-style: solid; border-style: solid;
margin-top: 16pt; margin-top: 16pt;
margin-left: 5%; margin-left: 5%;
margin-right: 5%; margin-right: 5%;
padding-top: 2pt; padding-top: 2pt;
padding-bottom:2pt; padding-bottom:2pt;
text-align: center; text-align: center;
} }
</style> </style>
</head> </head>

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-10-22 # Created On: 2009-10-22
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
# This is a fork from hsfs. The reason for this fork is that hsfs has been designed for musicGuru # This is a fork from hsfs. The reason for this fork is that hsfs has been designed for musicGuru
@@ -32,6 +32,7 @@ NOT_SET = object()
class FSError(Exception): class FSError(Exception):
cls_message = "An error has occured on '{name}' in '{parent}'" cls_message = "An error has occured on '{name}' in '{parent}'"
def __init__(self, fsobject, parent=None): def __init__(self, fsobject, parent=None):
message = self.cls_message message = self.cls_message
if isinstance(fsobject, str): if isinstance(fsobject, str):
@@ -42,7 +43,7 @@ class FSError(Exception):
name = '' name = ''
parentname = str(parent) if parent is not None else '' parentname = str(parent) if parent is not None else ''
Exception.__init__(self, message.format(name=name, parent=parentname)) Exception.__init__(self, message.format(name=name, parent=parentname))
class AlreadyExistsError(FSError): class AlreadyExistsError(FSError):
"The directory or file name we're trying to add already exists" "The directory or file name we're trying to add already exists"
@@ -57,7 +58,7 @@ class InvalidDestinationError(FSError):
cls_message = "'{name}' is an invalid destination for this operation." cls_message = "'{name}' is an invalid destination for this operation."
class OperationError(FSError): class OperationError(FSError):
"""A copy/move/delete operation has been called, but the checkup after the """A copy/move/delete operation has been called, but the checkup after the
operation shows that it didn't work.""" operation shows that it didn't work."""
cls_message = "Operation on '{name}' failed." cls_message = "Operation on '{name}' failed."
@@ -74,15 +75,15 @@ class File:
# files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become # files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become
# even greater when we take into account read attributes (70%!). Yeah, it's worth it. # even greater when we take into account read attributes (70%!). Yeah, it's worth it.
__slots__ = ('path', 'is_ref', 'words') + tuple(INITIAL_INFO.keys()) __slots__ = ('path', 'is_ref', 'words') + tuple(INITIAL_INFO.keys())
def __init__(self, path): def __init__(self, path):
self.path = path self.path = path
for attrname in self.INITIAL_INFO: for attrname in self.INITIAL_INFO:
setattr(self, attrname, NOT_SET) setattr(self, attrname, NOT_SET)
def __repr__(self): def __repr__(self):
return "<{} {}>".format(self.__class__.__name__, str(self.path)) return "<{} {}>".format(self.__class__.__name__, str(self.path))
def __getattribute__(self, attrname): def __getattribute__(self, attrname):
result = object.__getattribute__(self, attrname) result = object.__getattribute__(self, attrname)
if result is NOT_SET: if result is NOT_SET:
@@ -94,12 +95,12 @@ class File:
if result is NOT_SET: if result is NOT_SET:
result = self.INITIAL_INFO[attrname] result = self.INITIAL_INFO[attrname]
return result return result
#This offset is where we should start reading the file to get a partial md5 #This offset is where we should start reading the file to get a partial md5
#For audio file, it should be where audio data starts #For audio file, it should be where audio data starts
def _get_md5partial_offset_and_size(self): def _get_md5partial_offset_and_size(self):
return (0x4000, 0x4000) #16Kb return (0x4000, 0x4000) #16Kb
def _read_info(self, field): def _read_info(self, field):
if field in ('size', 'mtime'): if field in ('size', 'mtime'):
stats = self.path.stat() stats = self.path.stat()
@@ -129,24 +130,24 @@ class File:
fp.close() fp.close()
except Exception: except Exception:
pass pass
def _read_all_info(self, attrnames=None): def _read_all_info(self, attrnames=None):
"""Cache all possible info. """Cache all possible info.
If `attrnames` is not None, caches only attrnames. If `attrnames` is not None, caches only attrnames.
""" """
if attrnames is None: if attrnames is None:
attrnames = self.INITIAL_INFO.keys() attrnames = self.INITIAL_INFO.keys()
for attrname in attrnames: for attrname in attrnames:
getattr(self, attrname) getattr(self, attrname)
#--- Public #--- Public
@classmethod @classmethod
def can_handle(cls, path): def can_handle(cls, path):
"""Returns whether this file wrapper class can handle ``path``. """Returns whether this file wrapper class can handle ``path``.
""" """
return not path.islink() and path.isfile() return not path.islink() and path.isfile()
def rename(self, newname): def rename(self, newname):
if newname == self.name: if newname == self.name:
return return
@@ -160,42 +161,42 @@ class File:
if not destpath.exists(): if not destpath.exists():
raise OperationError(self) raise OperationError(self)
self.path = destpath self.path = destpath
def get_display_info(self, group, delta): def get_display_info(self, group, delta):
"""Returns a display-ready dict of dupe's data. """Returns a display-ready dict of dupe's data.
""" """
raise NotImplementedError() raise NotImplementedError()
#--- Properties #--- Properties
@property @property
def extension(self): def extension(self):
return get_file_ext(self.name) return get_file_ext(self.name)
@property @property
def name(self): def name(self):
return self.path.name return self.path.name
@property @property
def folder_path(self): def folder_path(self):
return self.path.parent() return self.path.parent()
class Folder(File): class Folder(File):
"""A wrapper around a folder path. """A wrapper around a folder path.
It has the size/md5 info of a File, but it's value are the sum of its subitems. It has the size/md5 info of a File, but it's value are the sum of its subitems.
""" """
__slots__ = File.__slots__ + ('_subfolders', ) __slots__ = File.__slots__ + ('_subfolders', )
def __init__(self, path): def __init__(self, path):
File.__init__(self, path) File.__init__(self, path)
self._subfolders = None self._subfolders = None
def _all_items(self): def _all_items(self):
folders = self.subfolders folders = self.subfolders
files = get_files(self.path) files = get_files(self.path)
return folders + files return folders + files
def _read_info(self, field): def _read_info(self, field):
if field in {'size', 'mtime'}: if field in {'size', 'mtime'}:
size = sum((f.size for f in self._all_items()), 0) size = sum((f.size for f in self._all_items()), 0)
@@ -208,31 +209,31 @@ class Folder(File):
# different md5 if a file gets moved in a different subdirectory. # different md5 if a file gets moved in a different subdirectory.
def get_dir_md5_concat(): def get_dir_md5_concat():
items = self._all_items() items = self._all_items()
items.sort(key=lambda f:f.path) items.sort(key=lambda f: f.path)
md5s = [getattr(f, field) for f in items] md5s = [getattr(f, field) for f in items]
return b''.join(md5s) return b''.join(md5s)
md5 = hashlib.md5(get_dir_md5_concat()) md5 = hashlib.md5(get_dir_md5_concat())
digest = md5.digest() digest = md5.digest()
setattr(self, field, digest) setattr(self, field, digest)
@property @property
def subfolders(self): def subfolders(self):
if self._subfolders is None: if self._subfolders is None:
subfolders = [p for p in self.path.listdir() if not p.islink() and p.isdir()] subfolders = [p for p in self.path.listdir() if not p.islink() and p.isdir()]
self._subfolders = [self.__class__(p) for p in subfolders] self._subfolders = [self.__class__(p) for p in subfolders]
return self._subfolders return self._subfolders
@classmethod @classmethod
def can_handle(cls, path): def can_handle(cls, path):
return not path.islink() and path.isdir() return not path.islink() and path.isdir()
def get_file(path, fileclasses=[File]): def get_file(path, fileclasses=[File]):
"""Wraps ``path`` around its appropriate :class:`File` class. """Wraps ``path`` around its appropriate :class:`File` class.
Whether a class is "appropriate" is decided by :meth:`File.can_handle` Whether a class is "appropriate" is decided by :meth:`File.can_handle`
:param Path path: path to wrap :param Path path: path to wrap
:param fileclasses: List of candidate :class:`File` classes :param fileclasses: List of candidate :class:`File` classes
""" """
@@ -242,7 +243,7 @@ def get_file(path, fileclasses=[File]):
def get_files(path, fileclasses=[File]): def get_files(path, fileclasses=[File]):
"""Returns a list of :class:`File` for each file contained in ``path``. """Returns a list of :class:`File` for each file contained in ``path``.
:param Path path: path to scan :param Path path: path to scan
:param fileclasses: List of candidate :class:`File` classes :param fileclasses: List of candidate :class:`File` classes
""" """

View File

@@ -12,4 +12,5 @@ either Cocoa's ``NSTableView`` or Qt's ``QTableView``. It tells them which cell
blue, which is supposed to be orange, does the sorting logic, holds selection, etc.. blue, which is supposed to be orange, does the sorting logic, holds selection, etc..
.. _cross-toolkit: http://www.hardcoded.net/articles/cross-toolkit-software .. _cross-toolkit: http://www.hardcoded.net/articles/cross-toolkit-software
""" """

View File

@@ -1,31 +1,30 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2010-02-06 # Created On: 2010-02-06
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from hscommon.notify import Listener from hscommon.notify import Listener
from hscommon.gui.base import NoopGUI
class DupeGuruGUIObject(Listener): class DupeGuruGUIObject(Listener):
def __init__(self, app): def __init__(self, app):
Listener.__init__(self, app) Listener.__init__(self, app)
self.app = app self.app = app
def directories_changed(self): def directories_changed(self):
pass pass
def dupes_selected(self): def dupes_selected(self):
pass pass
def marking_changed(self): def marking_changed(self):
pass pass
def results_changed(self): def results_changed(self):
pass pass
def results_changed_but_keep_selection(self): def results_changed_but_keep_selection(self):
pass pass

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-09-06 # Created On: 2011-09-06
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from hscommon.gui.base import GUIObject from hscommon.gui.base import GUIObject
@@ -13,7 +13,7 @@ class CriterionCategoryList(GUISelectableList):
def __init__(self, dialog): def __init__(self, dialog):
self.dialog = dialog self.dialog = dialog
GUISelectableList.__init__(self, [c.NAME for c in dialog.categories]) GUISelectableList.__init__(self, [c.NAME for c in dialog.categories])
def _update_selection(self): def _update_selection(self):
self.dialog.select_category(self.dialog.categories[self.selected_index]) self.dialog.select_category(self.dialog.categories[self.selected_index])
GUISelectableList._update_selection(self) GUISelectableList._update_selection(self)
@@ -22,10 +22,10 @@ class PrioritizationList(GUISelectableList):
def __init__(self, dialog): def __init__(self, dialog):
self.dialog = dialog self.dialog = dialog
GUISelectableList.__init__(self) GUISelectableList.__init__(self)
def _refresh_contents(self): def _refresh_contents(self):
self[:] = [crit.display for crit in self.dialog.prioritizations] self[:] = [crit.display for crit in self.dialog.prioritizations]
def move_indexes(self, indexes, dest_index): def move_indexes(self, indexes, dest_index):
indexes.sort() indexes.sort()
prilist = self.dialog.prioritizations prilist = self.dialog.prioritizations
@@ -34,7 +34,7 @@ class PrioritizationList(GUISelectableList):
del prilist[i] del prilist[i]
prilist[dest_index:dest_index] = selected prilist[dest_index:dest_index] = selected
self._refresh_contents() self._refresh_contents()
def remove_selected(self): def remove_selected(self):
prilist = self.dialog.prioritizations prilist = self.dialog.prioritizations
for i in sorted(self.selected_indexes, reverse=True): for i in sorted(self.selected_indexes, reverse=True):
@@ -51,15 +51,15 @@ class PrioritizeDialog(GUIObject):
self.criteria_list = GUISelectableList() self.criteria_list = GUISelectableList()
self.prioritizations = [] self.prioritizations = []
self.prioritization_list = PrioritizationList(self) self.prioritization_list = PrioritizationList(self)
#--- Override #--- Override
def _view_updated(self): def _view_updated(self):
self.category_list.select(0) self.category_list.select(0)
#--- Private #--- Private
def _sort_key(self, dupe): def _sort_key(self, dupe):
return tuple(crit.sort_key(dupe) for crit in self.prioritizations) return tuple(crit.sort_key(dupe) for crit in self.prioritizations)
#--- Public #--- Public
def select_category(self, category): def select_category(self, category):
self.criteria = category.criteria_list() self.criteria = category.criteria_list()
@@ -71,10 +71,11 @@ class PrioritizeDialog(GUIObject):
return return
crit = self.criteria[self.criteria_list.selected_index] crit = self.criteria[self.criteria_list.selected_index]
self.prioritizations.append(crit) self.prioritizations.append(crit)
del crit
self.prioritization_list[:] = [crit.display for crit in self.prioritizations] self.prioritization_list[:] = [crit.display for crit in self.prioritizations]
def remove_selected(self): def remove_selected(self):
self.prioritization_list.remove_selected() self.prioritization_list.remove_selected()
def perform_reprioritization(self): def perform_reprioritization(self):
self.app.reprioritize_groups(self._sort_key) self.app.reprioritize_groups(self._sort_key)

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/05/02 # Created On: 2006/05/02
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
@@ -12,7 +12,7 @@ from hscommon.util import FileOrPath
class IgnoreList: class IgnoreList:
"""An ignore list implementation that is iterable, filterable and exportable to XML. """An ignore list implementation that is iterable, filterable and exportable to XML.
Call Ignore to add an ignore list entry, and AreIgnore to check if 2 items are in the list. Call Ignore to add an ignore list entry, and AreIgnore to check if 2 items are in the list.
When iterated, 2 sized tuples will be returned, the tuples containing 2 items ignored together. When iterated, 2 sized tuples will be returned, the tuples containing 2 items ignored together.
""" """
@@ -20,43 +20,43 @@ class IgnoreList:
def __init__(self): def __init__(self):
self._ignored = {} self._ignored = {}
self._count = 0 self._count = 0
def __iter__(self): def __iter__(self):
for first,seconds in self._ignored.items(): for first, seconds in self._ignored.items():
for second in seconds: for second in seconds:
yield (first,second) yield (first, second)
def __len__(self): def __len__(self):
return self._count return self._count
#---Public #---Public
def AreIgnored(self,first,second): def AreIgnored(self, first, second):
def do_check(first,second): def do_check(first, second):
try: try:
matches = self._ignored[first] matches = self._ignored[first]
return second in matches return second in matches
except KeyError: except KeyError:
return False return False
return do_check(first,second) or do_check(second,first) return do_check(first, second) or do_check(second, first)
def Clear(self): def Clear(self):
self._ignored = {} self._ignored = {}
self._count = 0 self._count = 0
def Filter(self,func): def Filter(self, func):
"""Applies a filter on all ignored items, and remove all matches where func(first,second) """Applies a filter on all ignored items, and remove all matches where func(first,second)
doesn't return True. doesn't return True.
""" """
filtered = IgnoreList() filtered = IgnoreList()
for first,second in self: for first, second in self:
if func(first,second): if func(first, second):
filtered.Ignore(first,second) filtered.Ignore(first, second)
self._ignored = filtered._ignored self._ignored = filtered._ignored
self._count = filtered._count self._count = filtered._count
def Ignore(self,first,second): def Ignore(self, first, second):
if self.AreIgnored(first,second): if self.AreIgnored(first, second):
return return
try: try:
matches = self._ignored[first] matches = self._ignored[first]
@@ -70,7 +70,7 @@ class IgnoreList:
matches.add(second) matches.add(second)
self._ignored[first] = matches self._ignored[first] = matches
self._count += 1 self._count += 1
def remove(self, first, second): def remove(self, first, second):
def inner(first, second): def inner(first, second):
try: try:
@@ -85,14 +85,14 @@ class IgnoreList:
return False return False
except KeyError: except KeyError:
return False return False
if not inner(first, second): if not inner(first, second):
if not inner(second, first): if not inner(second, first):
raise ValueError() raise ValueError()
def load_from_xml(self, infile): def load_from_xml(self, infile):
"""Loads the ignore list from a XML created with save_to_xml. """Loads the ignore list from a XML created with save_to_xml.
infile can be a file object or a filename. infile can be a file object or a filename.
""" """
try: try:
@@ -109,10 +109,10 @@ class IgnoreList:
subfile_path = sfn.get('path') subfile_path = sfn.get('path')
if subfile_path: if subfile_path:
self.Ignore(file_path, subfile_path) self.Ignore(file_path, subfile_path)
def save_to_xml(self, outfile): def save_to_xml(self, outfile):
"""Create a XML file that can be used by load_from_xml. """Create a XML file that can be used by load_from_xml.
outfile can be a file object or a filename. outfile can be a file object or a filename.
""" """
root = ET.Element('ignore_list') root = ET.Element('ignore_list')
@@ -125,5 +125,5 @@ class IgnoreList:
tree = ET.ElementTree(root) tree = ET.ElementTree(root)
with FileOrPath(outfile, 'wb') as fp: with FileOrPath(outfile, 'wb') as fp:
tree.write(fp, encoding='utf-8') tree.write(fp, encoding='utf-8')

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/02/23 # Created On: 2006/02/23
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import logging import logging
@@ -12,7 +12,7 @@ import os
import os.path as op import os.path as op
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from jobprogress.job import nulljob from hscommon.jobprogress.job import nulljob
from hscommon.conflict import get_conflicted_name from hscommon.conflict import get_conflicted_name
from hscommon.util import flatten, nonone, FileOrPath, format_size from hscommon.util import flatten, nonone, FileOrPath, format_size
from hscommon.trans import tr from hscommon.trans import tr
@@ -22,15 +22,15 @@ from .markable import Markable
class Results(Markable): class Results(Markable):
"""Manages a collection of duplicate :class:`~core.engine.Group`. """Manages a collection of duplicate :class:`~core.engine.Group`.
This class takes care or marking, sorting and filtering duplicate groups. This class takes care or marking, sorting and filtering duplicate groups.
.. attribute:: groups .. attribute:: groups
The list of :class:`~core.engine.Group` contained managed by this instance. The list of :class:`~core.engine.Group` contained managed by this instance.
.. attribute:: dupes .. attribute:: dupes
A list of all duplicates (:class:`~core.fs.File` instances), without ref, contained in the A list of all duplicates (:class:`~core.fs.File` instances), without ref, contained in the
currently managed :attr:`groups`. currently managed :attr:`groups`.
""" """
@@ -50,16 +50,16 @@ class Results(Markable):
self.app = app self.app = app
self.problems = [] # (dupe, error_msg) self.problems = [] # (dupe, error_msg)
self.is_modified = False self.is_modified = False
def _did_mark(self, dupe): def _did_mark(self, dupe):
self.__marked_size += dupe.size self.__marked_size += dupe.size
def _did_unmark(self, dupe): def _did_unmark(self, dupe):
self.__marked_size -= dupe.size self.__marked_size -= dupe.size
def _get_markable_count(self): def _get_markable_count(self):
return self.__total_count return self.__total_count
def _is_markable(self, dupe): def _is_markable(self, dupe):
if dupe.is_ref: if dupe.is_ref:
return False return False
@@ -71,45 +71,48 @@ class Results(Markable):
if self.__filtered_dupes and dupe not in self.__filtered_dupes: if self.__filtered_dupes and dupe not in self.__filtered_dupes:
return False return False
return True return True
def mark_all(self): def mark_all(self):
if self.__filters: if self.__filters:
self.mark_multiple(self.__filtered_dupes) self.mark_multiple(self.__filtered_dupes)
else: else:
Markable.mark_all(self) Markable.mark_all(self)
def mark_invert(self): def mark_invert(self):
if self.__filters: if self.__filters:
self.mark_toggle_multiple(self.__filtered_dupes) self.mark_toggle_multiple(self.__filtered_dupes)
else: else:
Markable.mark_invert(self) Markable.mark_invert(self)
def mark_none(self): def mark_none(self):
if self.__filters: if self.__filters:
self.unmark_multiple(self.__filtered_dupes) self.unmark_multiple(self.__filtered_dupes)
else: else:
Markable.mark_none(self) Markable.mark_none(self)
#---Private #---Private
def __get_dupe_list(self): def __get_dupe_list(self):
if self.__dupes is None: if self.__dupes is None:
self.__dupes = flatten(group.dupes for group in self.groups) self.__dupes = flatten(group.dupes for group in self.groups)
if None in self.__dupes: if None in self.__dupes:
# This is debug logging to try to figure out #44 # This is debug logging to try to figure out #44
logging.warning("There is a None value in the Results' dupe list. dupes: %r groups: %r", self.__dupes, self.groups) logging.warning(
"There is a None value in the Results' dupe list. dupes: %r groups: %r",
self.__dupes, self.groups
)
if self.__filtered_dupes: if self.__filtered_dupes:
self.__dupes = [dupe for dupe in self.__dupes if dupe in self.__filtered_dupes] self.__dupes = [dupe for dupe in self.__dupes if dupe in self.__filtered_dupes]
sd = self.__dupes_sort_descriptor sd = self.__dupes_sort_descriptor
if sd: if sd:
self.sort_dupes(sd[0], sd[1], sd[2]) self.sort_dupes(sd[0], sd[1], sd[2])
return self.__dupes return self.__dupes
def __get_groups(self): def __get_groups(self):
if self.__filtered_groups is None: if self.__filtered_groups is None:
return self.__groups return self.__groups
else: else:
return self.__filtered_groups return self.__filtered_groups
def __get_stat_line(self): def __get_stat_line(self):
if self.__filtered_dupes is None: if self.__filtered_dupes is None:
mark_count = self.mark_count mark_count = self.mark_count
@@ -132,7 +135,7 @@ class Results(Markable):
if self.__filters: if self.__filters:
result += tr(" filter: %s") % ' --> '.join(self.__filters) result += tr(" filter: %s") % ' --> '.join(self.__filters)
return result return result
def __recalculate_stats(self): def __recalculate_stats(self):
self.__total_size = 0 self.__total_size = 0
self.__total_count = 0 self.__total_count = 0
@@ -140,7 +143,7 @@ class Results(Markable):
markable = [dupe for dupe in group.dupes if self._is_markable(dupe)] markable = [dupe for dupe in group.dupes if self._is_markable(dupe)]
self.__total_count += len(markable) self.__total_count += len(markable)
self.__total_size += sum(dupe.size for dupe in markable) self.__total_size += sum(dupe.size for dupe in markable)
def __set_groups(self, new_groups): def __set_groups(self, new_groups):
self.mark_none() self.mark_none()
self.__groups = new_groups self.__groups = new_groups
@@ -155,18 +158,18 @@ class Results(Markable):
self.apply_filter(None) self.apply_filter(None)
for filter_str in old_filters: for filter_str in old_filters:
self.apply_filter(filter_str) self.apply_filter(filter_str)
#---Public #---Public
def apply_filter(self, filter_str): def apply_filter(self, filter_str):
"""Applies a filter ``filter_str`` to :attr:`groups` """Applies a filter ``filter_str`` to :attr:`groups`
When you apply the filter, only dupes with the filename matching ``filter_str`` will be in When you apply the filter, only dupes with the filename matching ``filter_str`` will be in
in the results. To cancel the filter, just call apply_filter with ``filter_str`` to None, in the results. To cancel the filter, just call apply_filter with ``filter_str`` to None,
and the results will go back to normal. and the results will go back to normal.
If call apply_filter on a filtered results, the filter will be applied If call apply_filter on a filtered results, the filter will be applied
*on the filtered results*. *on the filtered results*.
:param str filter_str: a string containing a regexp to filter dupes with. :param str filter_str: a string containing a regexp to filter dupes with.
""" """
if not filter_str: if not filter_str:
@@ -193,7 +196,7 @@ class Results(Markable):
if sd: if sd:
self.sort_groups(sd[0], sd[1]) self.sort_groups(sd[0], sd[1])
self.__dupes = None self.__dupes = None
def get_group_of_duplicate(self, dupe): def get_group_of_duplicate(self, dupe):
"""Returns :class:`~core.engine.Group` in which ``dupe`` belongs. """Returns :class:`~core.engine.Group` in which ``dupe`` belongs.
""" """
@@ -201,12 +204,12 @@ class Results(Markable):
return self.__group_of_duplicate[dupe] return self.__group_of_duplicate[dupe]
except (TypeError, KeyError): except (TypeError, KeyError):
return None return None
is_markable = _is_markable is_markable = _is_markable
def load_from_xml(self, infile, get_file, j=nulljob): def load_from_xml(self, infile, get_file, j=nulljob):
"""Load results from ``infile``. """Load results from ``infile``.
:param infile: a file or path pointing to an XML file created with :meth:`save_to_xml`. :param infile: a file or path pointing to an XML file created with :meth:`save_to_xml`.
:param get_file: a function f(path) returning a :class:`~core.fs.File` wrapping the path. :param get_file: a function f(path) returning a :class:`~core.fs.File` wrapping the path.
:param j: A :ref:`job progress instance <jobs>`. :param j: A :ref:`job progress instance <jobs>`.
@@ -217,7 +220,7 @@ class Results(Markable):
for other_file in other_files: for other_file in other_files:
group.add_match(engine.get_match(ref_file, other_file)) group.add_match(engine.get_match(ref_file, other_file))
do_match(other_files[0], other_files[1:], group) do_match(other_files[0], other_files[1:], group)
self.apply_filter(None) self.apply_filter(None)
try: try:
root = ET.parse(infile).getroot() root = ET.parse(infile).getroot()
@@ -249,19 +252,20 @@ class Results(Markable):
second_file = dupes[int(attrs['second'])] second_file = dupes[int(attrs['second'])]
percentage = int(attrs['percentage']) percentage = int(attrs['percentage'])
group.add_match(engine.Match(first_file, second_file, percentage)) group.add_match(engine.Match(first_file, second_file, percentage))
except (IndexError, KeyError, ValueError): # Covers missing attr, non-int values and indexes out of bounds except (IndexError, KeyError, ValueError):
# Covers missing attr, non-int values and indexes out of bounds
pass pass
if (not group.matches) and (len(dupes) >= 2): if (not group.matches) and (len(dupes) >= 2):
do_match(dupes[0], dupes[1:], group) do_match(dupes[0], dupes[1:], group)
group.prioritize(lambda x: dupes.index(x)) group.prioritize(lambda x: dupes.index(x))
if len(group): if len(group):
groups.append(group) groups.append(group)
j.add_progress() j.add_progress()
self.groups = groups self.groups = groups
for dupe_file in marked: for dupe_file in marked:
self.mark(dupe_file) self.mark(dupe_file)
self.is_modified = False self.is_modified = False
def make_ref(self, dupe): def make_ref(self, dupe):
"""Make ``dupe`` take the :attr:`~core.engine.Group.ref` position of its group. """Make ``dupe`` take the :attr:`~core.engine.Group.ref` position of its group.
""" """
@@ -279,13 +283,13 @@ class Results(Markable):
self.__dupes = None self.__dupes = None
self.is_modified = True self.is_modified = True
return True return True
def perform_on_marked(self, func, remove_from_results): def perform_on_marked(self, func, remove_from_results):
"""Performs ``func`` on all marked dupes. """Performs ``func`` on all marked dupes.
If an ``EnvironmentError`` is raised during the call, the problematic dupe is added to If an ``EnvironmentError`` is raised during the call, the problematic dupe is added to
self.problems. self.problems.
:param bool remove_from_results: If true, dupes which had ``func`` applied and didn't cause :param bool remove_from_results: If true, dupes which had ``func`` applied and didn't cause
any problem. any problem.
""" """
@@ -303,10 +307,10 @@ class Results(Markable):
self.mark_none() self.mark_none()
for dupe, _ in self.problems: for dupe, _ in self.problems:
self.mark(dupe) self.mark(dupe)
def remove_duplicates(self, dupes): def remove_duplicates(self, dupes):
"""Remove ``dupes`` from their respective :class:`~core.engine.Group`. """Remove ``dupes`` from their respective :class:`~core.engine.Group`.
Also, remove the group from :attr:`groups` if it ends up empty. Also, remove the group from :attr:`groups` if it ends up empty.
""" """
affected_groups = set() affected_groups = set()
@@ -331,10 +335,10 @@ class Results(Markable):
group.discard_matches() group.discard_matches()
self.__dupes = None self.__dupes = None
self.is_modified = bool(self.__groups) self.is_modified = bool(self.__groups)
def save_to_xml(self, outfile): def save_to_xml(self, outfile):
"""Save results to ``outfile`` in XML. """Save results to ``outfile`` in XML.
:param outfile: file object or path. :param outfile: file object or path.
""" """
self.apply_filter(None) self.apply_filter(None)
@@ -362,11 +366,11 @@ class Results(Markable):
match_elem.set('second', str(dupe2index[match.second])) match_elem.set('second', str(dupe2index[match.second]))
match_elem.set('percentage', str(int(match.percentage))) match_elem.set('percentage', str(int(match.percentage)))
tree = ET.ElementTree(root) tree = ET.ElementTree(root)
def do_write(outfile): def do_write(outfile):
with FileOrPath(outfile, 'wb') as fp: with FileOrPath(outfile, 'wb') as fp:
tree.write(fp, encoding='utf-8') tree.write(fp, encoding='utf-8')
try: try:
do_write(outfile) do_write(outfile)
except IOError as e: except IOError as e:
@@ -381,10 +385,10 @@ class Results(Markable):
else: else:
raise raise
self.is_modified = False self.is_modified = False
def sort_dupes(self, key, asc=True, delta=False): def sort_dupes(self, key, asc=True, delta=False):
"""Sort :attr:`dupes` according to ``key``. """Sort :attr:`dupes` according to ``key``.
:param str key: key attribute name to sort with. :param str key: key attribute name to sort with.
:param bool asc: If false, sorting is reversed. :param bool asc: If false, sorting is reversed.
:param bool delta: If true, sorting occurs using :ref:`delta values <deltavalues>`. :param bool delta: If true, sorting occurs using :ref:`delta values <deltavalues>`.
@@ -393,21 +397,22 @@ class Results(Markable):
self.__get_dupe_list() self.__get_dupe_list()
keyfunc = lambda d: self.app._get_dupe_sort_key(d, lambda: self.get_group_of_duplicate(d), key, delta) keyfunc = lambda d: self.app._get_dupe_sort_key(d, lambda: self.get_group_of_duplicate(d), key, delta)
self.__dupes.sort(key=keyfunc, reverse=not asc) self.__dupes.sort(key=keyfunc, reverse=not asc)
self.__dupes_sort_descriptor = (key,asc,delta) self.__dupes_sort_descriptor = (key, asc, delta)
def sort_groups(self, key, asc=True): def sort_groups(self, key, asc=True):
"""Sort :attr:`groups` according to ``key``. """Sort :attr:`groups` according to ``key``.
The :attr:`~core.engine.Group.ref` of each group is used to extract values for sorting. The :attr:`~core.engine.Group.ref` of each group is used to extract values for sorting.
:param str key: key attribute name to sort with. :param str key: key attribute name to sort with.
:param bool asc: If false, sorting is reversed. :param bool asc: If false, sorting is reversed.
""" """
keyfunc = lambda g: self.app._get_group_sort_key(g, key) keyfunc = lambda g: self.app._get_group_sort_key(g, key)
self.groups.sort(key=keyfunc, reverse=not asc) self.groups.sort(key=keyfunc, reverse=not asc)
self.__groups_sort_descriptor = (key,asc) self.__groups_sort_descriptor = (key, asc)
#---Properties #---Properties
dupes = property(__get_dupe_list) dupes = property(__get_dupe_list)
groups = property(__get_groups, __set_groups) groups = property(__get_groups, __set_groups)
stat_line = property(__get_stat_line) stat_line = property(__get_stat_line)

View File

@@ -1,16 +1,16 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/03/03 # Created On: 2006/03/03
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import logging import logging
import re import re
import os.path as op import os.path as op
from jobprogress import job from hscommon.jobprogress import job
from hscommon.util import dedupe, rem_file_ext, get_file_ext from hscommon.util import dedupe, rem_file_ext, get_file_ext
from hscommon.trans import tr from hscommon.trans import tr
@@ -29,7 +29,7 @@ class ScanType:
Folders = 4 Folders = 4
Contents = 5 Contents = 5
ContentsAudio = 6 ContentsAudio = 6
#PE #PE
FuzzyBlock = 10 FuzzyBlock = 10
ExifTimestamp = 11 ExifTimestamp = 11
@@ -72,7 +72,7 @@ class Scanner:
def __init__(self): def __init__(self):
self.ignore_list = IgnoreList() self.ignore_list = IgnoreList()
self.discarded_file_count = 0 self.discarded_file_count = 0
def _getmatches(self, files, j): def _getmatches(self, files, j):
if self.size_threshold: if self.size_threshold:
j = j.start_subjob([2, 8]) j = j.start_subjob([2, 8])
@@ -81,7 +81,9 @@ class Scanner:
files = [f for f in files if f.size >= self.size_threshold] files = [f for f in files if f.size >= self.size_threshold]
if self.scan_type in {ScanType.Contents, ScanType.ContentsAudio, ScanType.Folders}: if self.scan_type in {ScanType.Contents, ScanType.ContentsAudio, ScanType.Folders}:
sizeattr = 'audiosize' if self.scan_type == ScanType.ContentsAudio else 'size' sizeattr = 'audiosize' if self.scan_type == ScanType.ContentsAudio else 'size'
return engine.getmatches_by_contents(files, sizeattr, partial=self.scan_type==ScanType.ContentsAudio, j=j) return engine.getmatches_by_contents(
files, sizeattr, partial=self.scan_type == ScanType.ContentsAudio, j=j
)
else: else:
j = j.start_subjob([2, 8]) j = j.start_subjob([2, 8])
kw = {} kw = {}
@@ -94,17 +96,21 @@ class Scanner:
func = { func = {
ScanType.Filename: lambda f: engine.getwords(rem_file_ext(f.name)), ScanType.Filename: lambda f: engine.getwords(rem_file_ext(f.name)),
ScanType.Fields: lambda f: engine.getfields(rem_file_ext(f.name)), ScanType.Fields: lambda f: engine.getfields(rem_file_ext(f.name)),
ScanType.Tag: lambda f: [engine.getwords(str(getattr(f, attrname))) for attrname in SCANNABLE_TAGS if attrname in self.scanned_tags], ScanType.Tag: lambda f: [
engine.getwords(str(getattr(f, attrname)))
for attrname in SCANNABLE_TAGS
if attrname in self.scanned_tags
],
}[self.scan_type] }[self.scan_type]
for f in j.iter_with_progress(files, tr("Read metadata of %d/%d files")): for f in j.iter_with_progress(files, tr("Read metadata of %d/%d files")):
logging.debug("Reading metadata of {}".format(str(f.path))) logging.debug("Reading metadata of {}".format(str(f.path)))
f.words = func(f) f.words = func(f)
return engine.getmatches(files, j=j, **kw) return engine.getmatches(files, j=j, **kw)
@staticmethod @staticmethod
def _key_func(dupe): def _key_func(dupe):
return -dupe.size return -dupe.size
@staticmethod @staticmethod
def _tie_breaker(ref, dupe): def _tie_breaker(ref, dupe):
refname = rem_file_ext(ref.name).lower() refname = rem_file_ext(ref.name).lower()
@@ -118,7 +124,7 @@ class Scanner:
if is_same_with_digit(refname, dupename): if is_same_with_digit(refname, dupename):
return True return True
return len(dupe.path) > len(ref.path) return len(dupe.path) > len(ref.path)
def get_dupe_groups(self, files, j=job.nulljob): def get_dupe_groups(self, files, j=job.nulljob):
j = j.start_subjob([8, 2]) j = j.start_subjob([8, 2])
for f in (f for f in files if not hasattr(f, 'is_ref')): for f in (f for f in files if not hasattr(f, 'is_ref')):
@@ -152,8 +158,10 @@ class Scanner:
if self.ignore_list: if self.ignore_list:
j = j.start_subjob(2) j = j.start_subjob(2)
iter_matches = j.iter_with_progress(matches, tr("Processed %d/%d matches against the ignore list")) iter_matches = j.iter_with_progress(matches, tr("Processed %d/%d matches against the ignore list"))
matches = [m for m in iter_matches matches = [
if not self.ignore_list.AreIgnored(str(m.first.path), str(m.second.path))] m for m in iter_matches
if not self.ignore_list.AreIgnored(str(m.first.path), str(m.second.path))
]
logging.info('Grouping matches') logging.info('Grouping matches')
groups = engine.get_groups(matches, j) groups = engine.get_groups(matches, j)
matched_files = dedupe([m.first for m in matches] + [m.second for m in matches]) matched_files = dedupe([m.first for m in matches] + [m.second for m in matches])
@@ -177,11 +185,12 @@ class Scanner:
for g in groups: for g in groups:
g.prioritize(self._key_func, self._tie_breaker) g.prioritize(self._key_func, self._tie_breaker)
return groups return groups
match_similar_words = False match_similar_words = False
min_match_percentage = 80 min_match_percentage = 80
mix_file_kind = True mix_file_kind = True
scan_type = ScanType.Filename scan_type = ScanType.Filename
scanned_tags = {'artist', 'title'} scanned_tags = {'artist', 'title'}
size_threshold = 0 size_threshold = 0
word_weighting = False word_weighting = False

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2007-06-23 # Created On: 2007-06-23
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import os import os
@@ -15,7 +15,7 @@ from hscommon.path import Path
import hscommon.conflict import hscommon.conflict
import hscommon.util import hscommon.util
from hscommon.testutil import CallLogger, eq_, log_calls from hscommon.testutil import CallLogger, eq_, log_calls
from jobprogress.job import Job from hscommon.jobprogress.job import Job
from .base import DupeGuru, TestApp from .base import DupeGuru, TestApp
from .results_test import GetTestGroups from .results_test import GetTestGroups
@@ -36,7 +36,7 @@ class TestCaseDupeGuru:
assert call['filter_str'] is None assert call['filter_str'] is None
call = dgapp.results.apply_filter.calls[1] call = dgapp.results.apply_filter.calls[1]
eq_('foo', call['filter_str']) eq_('foo', call['filter_str'])
def test_apply_filter_escapes_regexp(self, monkeypatch): def test_apply_filter_escapes_regexp(self, monkeypatch):
dgapp = TestApp().app dgapp = TestApp().app
monkeypatch.setattr(dgapp.results, 'apply_filter', log_calls(dgapp.results.apply_filter)) monkeypatch.setattr(dgapp.results, 'apply_filter', log_calls(dgapp.results.apply_filter))
@@ -50,7 +50,7 @@ class TestCaseDupeGuru:
dgapp.apply_filter('(abc)') dgapp.apply_filter('(abc)')
call = dgapp.results.apply_filter.calls[5] call = dgapp.results.apply_filter.calls[5]
eq_('(abc)', call['filter_str']) eq_('(abc)', call['filter_str'])
def test_copy_or_move(self, tmpdir, monkeypatch): def test_copy_or_move(self, tmpdir, monkeypatch):
# The goal here is just to have a test for a previous blowup I had. I know my test coverage # The goal here is just to have a test for a previous blowup I had. I know my test coverage
# for this unit is pathetic. What's done is done. My approach now is to add tests for # for this unit is pathetic. What's done is done. My approach now is to add tests for
@@ -69,7 +69,7 @@ class TestCaseDupeGuru:
call = hscommon.conflict.smart_copy.calls[0] call = hscommon.conflict.smart_copy.calls[0]
eq_(call['dest_path'], op.join('some_destination', 'foo')) eq_(call['dest_path'], op.join('some_destination', 'foo'))
eq_(call['source_path'], f.path) eq_(call['source_path'], f.path)
def test_copy_or_move_clean_empty_dirs(self, tmpdir, monkeypatch): def test_copy_or_move_clean_empty_dirs(self, tmpdir, monkeypatch):
tmppath = Path(str(tmpdir)) tmppath = Path(str(tmpdir))
sourcepath = tmppath['source'] sourcepath = tmppath['source']
@@ -83,13 +83,13 @@ class TestCaseDupeGuru:
calls = app.clean_empty_dirs.calls calls = app.clean_empty_dirs.calls
eq_(1, len(calls)) eq_(1, len(calls))
eq_(sourcepath, calls[0]['path']) eq_(sourcepath, calls[0]['path'])
def test_Scan_with_objects_evaluating_to_false(self): def test_Scan_with_objects_evaluating_to_false(self):
class FakeFile(fs.File): class FakeFile(fs.File):
def __bool__(self): def __bool__(self):
return False return False
# At some point, any() was used in a wrong way that made Scan() wrongly return 1 # At some point, any() was used in a wrong way that made Scan() wrongly return 1
app = TestApp().app app = TestApp().app
f1, f2 = [FakeFile('foo') for i in range(2)] f1, f2 = [FakeFile('foo') for i in range(2)]
@@ -97,7 +97,7 @@ class TestCaseDupeGuru:
assert not (bool(f1) and bool(f2)) assert not (bool(f1) and bool(f2))
add_fake_files_to_directories(app.directories, [f1, f2]) add_fake_files_to_directories(app.directories, [f1, f2])
app.start_scanning() # no exception app.start_scanning() # no exception
@mark.skipif("not hasattr(os, 'link')") @mark.skipif("not hasattr(os, 'link')")
def test_ignore_hardlink_matches(self, tmpdir): def test_ignore_hardlink_matches(self, tmpdir):
# If the ignore_hardlink_matches option is set, don't match files hardlinking to the same # If the ignore_hardlink_matches option is set, don't match files hardlinking to the same
@@ -111,7 +111,7 @@ class TestCaseDupeGuru:
app.options['ignore_hardlink_matches'] = True app.options['ignore_hardlink_matches'] = True
app.start_scanning() app.start_scanning()
eq_(len(app.results.groups), 0) eq_(len(app.results.groups), 0)
def test_rename_when_nothing_is_selected(self): def test_rename_when_nothing_is_selected(self):
# Issue #140 # Issue #140
# It's possible that rename operation has its selected row swept off from under it, thus # It's possible that rename operation has its selected row swept off from under it, thus
@@ -127,11 +127,11 @@ class TestCaseDupeGuru_clean_empty_dirs:
# XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher. # XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher.
monkeypatch.setattr(app, 'delete_if_empty', hscommon.util.delete_if_empty) monkeypatch.setattr(app, 'delete_if_empty', hscommon.util.delete_if_empty)
self.app = TestApp().app self.app = TestApp().app
def test_option_off(self, do_setup): def test_option_off(self, do_setup):
self.app.clean_empty_dirs(Path('/foo/bar')) self.app.clean_empty_dirs(Path('/foo/bar'))
eq_(0, len(hscommon.util.delete_if_empty.calls)) eq_(0, len(hscommon.util.delete_if_empty.calls))
def test_option_on(self, do_setup): def test_option_on(self, do_setup):
self.app.options['clean_empty_dirs'] = True self.app.options['clean_empty_dirs'] = True
self.app.clean_empty_dirs(Path('/foo/bar')) self.app.clean_empty_dirs(Path('/foo/bar'))
@@ -139,13 +139,13 @@ class TestCaseDupeGuru_clean_empty_dirs:
eq_(1, len(calls)) eq_(1, len(calls))
eq_(Path('/foo/bar'), calls[0]['path']) eq_(Path('/foo/bar'), calls[0]['path'])
eq_(['.DS_Store'], calls[0]['files_to_delete']) eq_(['.DS_Store'], calls[0]['files_to_delete'])
def test_recurse_up(self, do_setup, monkeypatch): def test_recurse_up(self, do_setup, monkeypatch):
# delete_if_empty must be recursively called up in the path until it returns False # delete_if_empty must be recursively called up in the path until it returns False
@log_calls @log_calls
def mock_delete_if_empty(path, files_to_delete=[]): def mock_delete_if_empty(path, files_to_delete=[]):
return len(path) > 1 return len(path) > 1
monkeypatch.setattr(hscommon.util, 'delete_if_empty', mock_delete_if_empty) monkeypatch.setattr(hscommon.util, 'delete_if_empty', mock_delete_if_empty)
# XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher. # XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher.
monkeypatch.setattr(app, 'delete_if_empty', mock_delete_if_empty) monkeypatch.setattr(app, 'delete_if_empty', mock_delete_if_empty)
@@ -156,7 +156,7 @@ class TestCaseDupeGuru_clean_empty_dirs:
eq_(Path('not-empty/empty/empty'), calls[0]['path']) eq_(Path('not-empty/empty/empty'), calls[0]['path'])
eq_(Path('not-empty/empty'), calls[1]['path']) eq_(Path('not-empty/empty'), calls[1]['path'])
eq_(Path('not-empty'), calls[2]['path']) eq_(Path('not-empty'), calls[2]['path'])
class TestCaseDupeGuruWithResults: class TestCaseDupeGuruWithResults:
def pytest_funcarg__do_setup(self, request): def pytest_funcarg__do_setup(self, request):
@@ -173,7 +173,7 @@ class TestCaseDupeGuruWithResults:
tmppath['foo'].mkdir() tmppath['foo'].mkdir()
tmppath['bar'].mkdir() tmppath['bar'].mkdir()
self.app.directories.add_path(tmppath) self.app.directories.add_path(tmppath)
def test_GetObjects(self, do_setup): def test_GetObjects(self, do_setup):
objects = self.objects objects = self.objects
groups = self.groups groups = self.groups
@@ -186,7 +186,7 @@ class TestCaseDupeGuruWithResults:
r = self.rtable[4] r = self.rtable[4]
assert r._group is groups[1] assert r._group is groups[1]
assert r._dupe is objects[4] assert r._dupe is objects[4]
def test_GetObjects_after_sort(self, do_setup): def test_GetObjects_after_sort(self, do_setup):
objects = self.objects objects = self.objects
groups = self.groups[:] # we need an un-sorted reference groups = self.groups[:] # we need an un-sorted reference
@@ -194,14 +194,14 @@ class TestCaseDupeGuruWithResults:
r = self.rtable[1] r = self.rtable[1]
assert r._group is groups[1] assert r._group is groups[1]
assert r._dupe is objects[4] assert r._dupe is objects[4]
def test_selected_result_node_paths_after_deletion(self, do_setup): def test_selected_result_node_paths_after_deletion(self, do_setup):
# cases where the selected dupes aren't there are correctly handled # cases where the selected dupes aren't there are correctly handled
self.rtable.select([1, 2, 3]) self.rtable.select([1, 2, 3])
self.app.remove_selected() self.app.remove_selected()
# The first 2 dupes have been removed. The 3rd one is a ref. it stays there, in first pos. # The first 2 dupes have been removed. The 3rd one is a ref. it stays there, in first pos.
eq_(self.rtable.selected_indexes, [1]) # no exception eq_(self.rtable.selected_indexes, [1]) # no exception
def test_selectResultNodePaths(self, do_setup): def test_selectResultNodePaths(self, do_setup):
app = self.app app = self.app
objects = self.objects objects = self.objects
@@ -209,7 +209,7 @@ class TestCaseDupeGuruWithResults:
eq_(len(app.selected_dupes), 2) eq_(len(app.selected_dupes), 2)
assert app.selected_dupes[0] is objects[1] assert app.selected_dupes[0] is objects[1]
assert app.selected_dupes[1] is objects[2] assert app.selected_dupes[1] is objects[2]
def test_selectResultNodePaths_with_ref(self, do_setup): def test_selectResultNodePaths_with_ref(self, do_setup):
app = self.app app = self.app
objects = self.objects objects = self.objects
@@ -218,26 +218,26 @@ class TestCaseDupeGuruWithResults:
assert app.selected_dupes[0] is objects[1] assert app.selected_dupes[0] is objects[1]
assert app.selected_dupes[1] is objects[2] assert app.selected_dupes[1] is objects[2]
assert app.selected_dupes[2] is self.groups[1].ref assert app.selected_dupes[2] is self.groups[1].ref
def test_selectResultNodePaths_after_sort(self, do_setup): def test_selectResultNodePaths_after_sort(self, do_setup):
app = self.app app = self.app
objects = self.objects objects = self.objects
groups = self.groups[:] #To keep the old order in memory groups = self.groups[:] #To keep the old order in memory
self.rtable.sort('name', False) #0 self.rtable.sort('name', False) #0
#Now, the group order is supposed to be reversed #Now, the group order is supposed to be reversed
self.rtable.select([1, 2, 3]) self.rtable.select([1, 2, 3])
eq_(len(app.selected_dupes), 3) eq_(len(app.selected_dupes), 3)
assert app.selected_dupes[0] is objects[4] assert app.selected_dupes[0] is objects[4]
assert app.selected_dupes[1] is groups[0].ref assert app.selected_dupes[1] is groups[0].ref
assert app.selected_dupes[2] is objects[1] assert app.selected_dupes[2] is objects[1]
def test_selected_powermarker_node_paths(self, do_setup): def test_selected_powermarker_node_paths(self, do_setup):
# app.selected_dupes is correctly converted into paths # app.selected_dupes is correctly converted into paths
self.rtable.power_marker = True self.rtable.power_marker = True
self.rtable.select([0, 1, 2]) self.rtable.select([0, 1, 2])
self.rtable.power_marker = False self.rtable.power_marker = False
eq_(self.rtable.selected_indexes, [1, 2, 4]) eq_(self.rtable.selected_indexes, [1, 2, 4])
def test_selected_powermarker_node_paths_after_deletion(self, do_setup): def test_selected_powermarker_node_paths_after_deletion(self, do_setup):
# cases where the selected dupes aren't there are correctly handled # cases where the selected dupes aren't there are correctly handled
app = self.app app = self.app
@@ -245,7 +245,7 @@ class TestCaseDupeGuruWithResults:
self.rtable.select([0, 1, 2]) self.rtable.select([0, 1, 2])
app.remove_selected() app.remove_selected()
eq_(self.rtable.selected_indexes, []) # no exception eq_(self.rtable.selected_indexes, []) # no exception
def test_selectPowerMarkerRows_after_sort(self, do_setup): def test_selectPowerMarkerRows_after_sort(self, do_setup):
app = self.app app = self.app
objects = self.objects objects = self.objects
@@ -256,7 +256,7 @@ class TestCaseDupeGuruWithResults:
assert app.selected_dupes[0] is objects[4] assert app.selected_dupes[0] is objects[4]
assert app.selected_dupes[1] is objects[2] assert app.selected_dupes[1] is objects[2]
assert app.selected_dupes[2] is objects[1] assert app.selected_dupes[2] is objects[1]
def test_toggle_selected_mark_state(self, do_setup): def test_toggle_selected_mark_state(self, do_setup):
app = self.app app = self.app
objects = self.objects objects = self.objects
@@ -270,7 +270,7 @@ class TestCaseDupeGuruWithResults:
assert not app.results.is_marked(objects[2]) assert not app.results.is_marked(objects[2])
assert not app.results.is_marked(objects[3]) assert not app.results.is_marked(objects[3])
assert app.results.is_marked(objects[4]) assert app.results.is_marked(objects[4])
def test_toggle_selected_mark_state_with_different_selected_state(self, do_setup): def test_toggle_selected_mark_state_with_different_selected_state(self, do_setup):
# When marking selected dupes with a heterogenous selection, mark all selected dupes. When # When marking selected dupes with a heterogenous selection, mark all selected dupes. When
# it's homogenous, simply toggle. # it's homogenous, simply toggle.
@@ -285,7 +285,7 @@ class TestCaseDupeGuruWithResults:
eq_(app.results.mark_count, 2) eq_(app.results.mark_count, 2)
app.toggle_selected_mark_state() app.toggle_selected_mark_state()
eq_(app.results.mark_count, 0) eq_(app.results.mark_count, 0)
def test_refreshDetailsWithSelected(self, do_setup): def test_refreshDetailsWithSelected(self, do_setup):
self.rtable.select([1, 4]) self.rtable.select([1, 4])
eq_(self.dpanel.row(0), ('Filename', 'bar bleh', 'foo bar')) eq_(self.dpanel.row(0), ('Filename', 'bar bleh', 'foo bar'))
@@ -293,7 +293,7 @@ class TestCaseDupeGuruWithResults:
self.rtable.select([]) self.rtable.select([])
eq_(self.dpanel.row(0), ('Filename', '---', '---')) eq_(self.dpanel.row(0), ('Filename', '---', '---'))
self.dpanel.view.check_gui_calls(['refresh']) self.dpanel.view.check_gui_calls(['refresh'])
def test_makeSelectedReference(self, do_setup): def test_makeSelectedReference(self, do_setup):
app = self.app app = self.app
objects = self.objects objects = self.objects
@@ -302,7 +302,7 @@ class TestCaseDupeGuruWithResults:
app.make_selected_reference() app.make_selected_reference()
assert groups[0].ref is objects[1] assert groups[0].ref is objects[1]
assert groups[1].ref is objects[4] assert groups[1].ref is objects[4]
def test_makeSelectedReference_by_selecting_two_dupes_in_the_same_group(self, do_setup): def test_makeSelectedReference_by_selecting_two_dupes_in_the_same_group(self, do_setup):
app = self.app app = self.app
objects = self.objects objects = self.objects
@@ -312,7 +312,7 @@ class TestCaseDupeGuruWithResults:
app.make_selected_reference() app.make_selected_reference()
assert groups[0].ref is objects[1] assert groups[0].ref is objects[1]
assert groups[1].ref is objects[4] assert groups[1].ref is objects[4]
def test_removeSelected(self, do_setup): def test_removeSelected(self, do_setup):
app = self.app app = self.app
self.rtable.select([1, 4]) self.rtable.select([1, 4])
@@ -320,7 +320,7 @@ class TestCaseDupeGuruWithResults:
eq_(len(app.results.dupes), 1) # the first path is now selected eq_(len(app.results.dupes), 1) # the first path is now selected
app.remove_selected() app.remove_selected()
eq_(len(app.results.dupes), 0) eq_(len(app.results.dupes), 0)
def test_addDirectory_simple(self, do_setup): def test_addDirectory_simple(self, do_setup):
# There's already a directory in self.app, so adding another once makes 2 of em # There's already a directory in self.app, so adding another once makes 2 of em
app = self.app app = self.app
@@ -328,7 +328,7 @@ class TestCaseDupeGuruWithResults:
otherpath = Path(op.dirname(__file__)) otherpath = Path(op.dirname(__file__))
app.add_directory(otherpath) app.add_directory(otherpath)
eq_(len(app.directories), 2) eq_(len(app.directories), 2)
def test_addDirectory_already_there(self, do_setup): def test_addDirectory_already_there(self, do_setup):
app = self.app app = self.app
otherpath = Path(op.dirname(__file__)) otherpath = Path(op.dirname(__file__))
@@ -336,13 +336,13 @@ class TestCaseDupeGuruWithResults:
app.add_directory(otherpath) app.add_directory(otherpath)
eq_(len(app.view.messages), 1) eq_(len(app.view.messages), 1)
assert "already" in app.view.messages[0] assert "already" in app.view.messages[0]
def test_addDirectory_does_not_exist(self, do_setup): def test_addDirectory_does_not_exist(self, do_setup):
app = self.app app = self.app
app.add_directory('/does_not_exist') app.add_directory('/does_not_exist')
eq_(len(app.view.messages), 1) eq_(len(app.view.messages), 1)
assert "exist" in app.view.messages[0] assert "exist" in app.view.messages[0]
def test_ignore(self, do_setup): def test_ignore(self, do_setup):
app = self.app app = self.app
self.rtable.select([4]) #The dupe of the second, 2 sized group self.rtable.select([4]) #The dupe of the second, 2 sized group
@@ -352,7 +352,7 @@ class TestCaseDupeGuruWithResults:
app.add_selected_to_ignore_list() app.add_selected_to_ignore_list()
#BOTH the ref and the other dupe should have been added #BOTH the ref and the other dupe should have been added
eq_(len(app.scanner.ignore_list), 3) eq_(len(app.scanner.ignore_list), 3)
def test_purgeIgnoreList(self, do_setup, tmpdir): def test_purgeIgnoreList(self, do_setup, tmpdir):
app = self.app app = self.app
p1 = str(tmpdir.join('file1')) p1 = str(tmpdir.join('file1'))
@@ -367,19 +367,19 @@ class TestCaseDupeGuruWithResults:
eq_(1,len(app.scanner.ignore_list)) eq_(1,len(app.scanner.ignore_list))
assert app.scanner.ignore_list.AreIgnored(p1,p2) assert app.scanner.ignore_list.AreIgnored(p1,p2)
assert not app.scanner.ignore_list.AreIgnored(dne,p1) assert not app.scanner.ignore_list.AreIgnored(dne,p1)
def test_only_unicode_is_added_to_ignore_list(self, do_setup): def test_only_unicode_is_added_to_ignore_list(self, do_setup):
def FakeIgnore(first,second): def FakeIgnore(first,second):
if not isinstance(first,str): if not isinstance(first,str):
self.fail() self.fail()
if not isinstance(second,str): if not isinstance(second,str):
self.fail() self.fail()
app = self.app app = self.app
app.scanner.ignore_list.Ignore = FakeIgnore app.scanner.ignore_list.Ignore = FakeIgnore
self.rtable.select([4]) self.rtable.select([4])
app.add_selected_to_ignore_list() app.add_selected_to_ignore_list()
def test_cancel_scan_with_previous_results(self, do_setup): def test_cancel_scan_with_previous_results(self, do_setup):
# When doing a scan with results being present prior to the scan, correctly invalidate the # When doing a scan with results being present prior to the scan, correctly invalidate the
# results table. # results table.
@@ -388,7 +388,7 @@ class TestCaseDupeGuruWithResults:
add_fake_files_to_directories(app.directories, self.objects) # We want the scan to at least start add_fake_files_to_directories(app.directories, self.objects) # We want the scan to at least start
app.start_scanning() # will be cancelled immediately app.start_scanning() # will be cancelled immediately
eq_(len(self.rtable), 0) eq_(len(self.rtable), 0)
def test_selected_dupes_after_removal(self, do_setup): def test_selected_dupes_after_removal(self, do_setup):
# Purge the app's `selected_dupes` attribute when removing dupes, or else it might cause a # Purge the app's `selected_dupes` attribute when removing dupes, or else it might cause a
# crash later with None refs. # crash later with None refs.
@@ -398,7 +398,7 @@ class TestCaseDupeGuruWithResults:
app.remove_marked() app.remove_marked()
eq_(len(self.rtable), 0) eq_(len(self.rtable), 0)
eq_(app.selected_dupes, []) eq_(app.selected_dupes, [])
def test_dont_crash_on_delta_powermarker_dupecount_sort(self, do_setup): def test_dont_crash_on_delta_powermarker_dupecount_sort(self, do_setup):
# Don't crash when sorting by dupe count or percentage while delta+powermarker are enabled. # Don't crash when sorting by dupe count or percentage while delta+powermarker are enabled.
# Ref #238 # Ref #238
@@ -410,7 +410,7 @@ class TestCaseDupeGuruWithResults:
# don't crash # don't crash
self.rtable.sort('percentage', False) self.rtable.sort('percentage', False)
# don't crash # don't crash
class TestCaseDupeGuru_renameSelected: class TestCaseDupeGuru_renameSelected:
def pytest_funcarg__do_setup(self, request): def pytest_funcarg__do_setup(self, request):
@@ -437,7 +437,7 @@ class TestCaseDupeGuru_renameSelected:
self.groups = groups self.groups = groups
self.p = p self.p = p
self.files = files self.files = files
def test_simple(self, do_setup): def test_simple(self, do_setup):
app = self.app app = self.app
g = self.groups[0] g = self.groups[0]
@@ -447,7 +447,7 @@ class TestCaseDupeGuru_renameSelected:
assert 'renamed' in names assert 'renamed' in names
assert 'foo bar 2' not in names assert 'foo bar 2' not in names
eq_(g.dupes[0].name, 'renamed') eq_(g.dupes[0].name, 'renamed')
def test_none_selected(self, do_setup, monkeypatch): def test_none_selected(self, do_setup, monkeypatch):
app = self.app app = self.app
g = self.groups[0] g = self.groups[0]
@@ -460,7 +460,7 @@ class TestCaseDupeGuru_renameSelected:
assert 'renamed' not in names assert 'renamed' not in names
assert 'foo bar 2' in names assert 'foo bar 2' in names
eq_(g.dupes[0].name, 'foo bar 2') eq_(g.dupes[0].name, 'foo bar 2')
def test_name_already_exists(self, do_setup, monkeypatch): def test_name_already_exists(self, do_setup, monkeypatch):
app = self.app app = self.app
g = self.groups[0] g = self.groups[0]
@@ -473,7 +473,7 @@ class TestCaseDupeGuru_renameSelected:
assert 'foo bar 1' in names assert 'foo bar 1' in names
assert 'foo bar 2' in names assert 'foo bar 2' in names
eq_(g.dupes[0].name, 'foo bar 2') eq_(g.dupes[0].name, 'foo bar 2')
class TestAppWithDirectoriesInTree: class TestAppWithDirectoriesInTree:
def pytest_funcarg__do_setup(self, request): def pytest_funcarg__do_setup(self, request):
@@ -487,7 +487,7 @@ class TestAppWithDirectoriesInTree:
self.dtree = app.dtree self.dtree = app.dtree
self.dtree.add_directory(p) self.dtree.add_directory(p)
self.dtree.view.clear_calls() self.dtree.view.clear_calls()
def test_set_root_as_ref_makes_subfolders_ref_as_well(self, do_setup): def test_set_root_as_ref_makes_subfolders_ref_as_well(self, do_setup):
# Setting a node state to something also affect subnodes. These subnodes must be correctly # Setting a node state to something also affect subnodes. These subnodes must be correctly
# refreshed. # refreshed.
@@ -500,4 +500,4 @@ class TestAppWithDirectoriesInTree:
subnode = node[0] subnode = node[0]
eq_(subnode.state, 1) eq_(subnode.state, 1)
self.dtree.view.check_gui_calls(['refresh_states']) self.dtree.view.check_gui_calls(['refresh_states'])

View File

@@ -1,16 +1,16 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011/09/07 # Created On: 2011/09/07
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from hscommon.testutil import TestApp as TestAppBase, eq_, with_app from hscommon.testutil import TestApp as TestAppBase, eq_, with_app
from hscommon.path import Path from hscommon.path import Path
from hscommon.util import get_file_ext, format_size from hscommon.util import get_file_ext, format_size
from hscommon.gui.column import Column from hscommon.gui.column import Column
from jobprogress.job import nulljob, JobCancelled from hscommon.jobprogress.job import nulljob, JobCancelled
from .. import engine from .. import engine
from .. import prioritize from .. import prioritize
@@ -23,28 +23,28 @@ from ..gui.prioritize_dialog import PrioritizeDialog
class DupeGuruView: class DupeGuruView:
JOB = nulljob JOB = nulljob
def __init__(self): def __init__(self):
self.messages = [] self.messages = []
def start_job(self, jobid, func, args=()): def start_job(self, jobid, func, args=()):
try: try:
func(self.JOB, *args) func(self.JOB, *args)
except JobCancelled: except JobCancelled:
return return
def get_default(self, key_name): def get_default(self, key_name):
return None return None
def set_default(self, key_name, value): def set_default(self, key_name, value):
pass pass
def show_message(self, msg): def show_message(self, msg):
self.messages.append(msg) self.messages.append(msg)
def ask_yes_no(self, prompt): def ask_yes_no(self, prompt):
return True # always answer yes return True # always answer yes
class ResultTable(ResultTableBase): class ResultTable(ResultTableBase):
COLUMNS = [ COLUMNS = [
@@ -55,21 +55,21 @@ class ResultTable(ResultTableBase):
Column('extension', 'Kind'), Column('extension', 'Kind'),
] ]
DELTA_COLUMNS = {'size', } DELTA_COLUMNS = {'size', }
class DupeGuru(DupeGuruBase): class DupeGuru(DupeGuruBase):
NAME = 'dupeGuru' NAME = 'dupeGuru'
METADATA_TO_READ = ['size'] METADATA_TO_READ = ['size']
def __init__(self): def __init__(self):
DupeGuruBase.__init__(self, DupeGuruView()) DupeGuruBase.__init__(self, DupeGuruView())
self.appdata = '/tmp' self.appdata = '/tmp'
def _prioritization_categories(self): def _prioritization_categories(self):
return prioritize.all_categories() return prioritize.all_categories()
def _create_result_table(self): def _create_result_table(self):
return ResultTable(self) return ResultTable(self)
class NamedObject: class NamedObject:
def __init__(self, name="foobar", with_words=False, size=1, folder=None): def __init__(self, name="foobar", with_words=False, size=1, folder=None):
@@ -83,10 +83,10 @@ class NamedObject:
if with_words: if with_words:
self.words = getwords(name) self.words = getwords(name)
self.is_ref = False self.is_ref = False
def __bool__(self): def __bool__(self):
return False #Make sure that operations are made correctly when the bool value of files is false. return False #Make sure that operations are made correctly when the bool value of files is false.
def get_display_info(self, group, delta): def get_display_info(self, group, delta):
size = self.size size = self.size
m = group.get_match_of(self) m = group.get_match_of(self)
@@ -99,19 +99,19 @@ class NamedObject:
'size': format_size(size, 0, 1, False), 'size': format_size(size, 0, 1, False),
'extension': self.extension if hasattr(self, 'extension') else '---', 'extension': self.extension if hasattr(self, 'extension') else '---',
} }
@property @property
def path(self): def path(self):
return self._folder[self.name] return self._folder[self.name]
@property @property
def folder_path(self): def folder_path(self):
return self.path.parent() return self.path.parent()
@property @property
def extension(self): def extension(self):
return get_file_ext(self.name) return get_file_ext(self.name)
# Returns a group set that looks like that: # Returns a group set that looks like that:
# "foo bar" (1) # "foo bar" (1)
# "bar bleh" (1024) # "bar bleh" (1024)
@@ -135,7 +135,7 @@ class TestApp(TestAppBase):
if hasattr(gui, 'columns'): # tables if hasattr(gui, 'columns'): # tables
gui.columns.view = self.make_logger() gui.columns.view = self.make_logger()
return gui return gui
TestAppBase.__init__(self) TestAppBase.__init__(self)
make_gui = self.make_gui make_gui = self.make_gui
self.app = DupeGuru() self.app = DupeGuru()
@@ -153,14 +153,14 @@ class TestApp(TestAppBase):
link_gui(self.app.progress_window) link_gui(self.app.progress_window)
link_gui(self.app.progress_window.jobdesc_textfield) link_gui(self.app.progress_window.jobdesc_textfield)
link_gui(self.app.progress_window.progressdesc_textfield) link_gui(self.app.progress_window.progressdesc_textfield)
#--- Helpers #--- Helpers
def select_pri_criterion(self, name): def select_pri_criterion(self, name):
# Select a main prioritize criterion by name instead of by index. Makes tests more # Select a main prioritize criterion by name instead of by index. Makes tests more
# maintainable. # maintainable.
index = self.pdialog.category_list.index(name) index = self.pdialog.category_list.index(name)
self.pdialog.category_list.select(index) self.pdialog.category_list.select(index)
def add_pri_criterion(self, name, index): def add_pri_criterion(self, name, index):
self.select_pri_criterion(name) self.select_pri_criterion(name)
self.pdialog.criteria_list.select([index]) self.pdialog.criteria_list.select([index])

View File

@@ -1,14 +1,14 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/01/29 # Created On: 2006/01/29
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys import sys
from jobprogress import job from hscommon.jobprogress import job
from hscommon.util import first from hscommon.util import first
from hscommon.testutil import eq_, log_calls from hscommon.testutil import eq_, log_calls
@@ -48,119 +48,119 @@ class TestCasegetwords:
def test_spaces(self): def test_spaces(self):
eq_(['a', 'b', 'c', 'd'], getwords("a b c d")) eq_(['a', 'b', 'c', 'd'], getwords("a b c d"))
eq_(['a', 'b', 'c', 'd'], getwords(" a b c d ")) eq_(['a', 'b', 'c', 'd'], getwords(" a b c d "))
def test_splitter_chars(self): def test_splitter_chars(self):
eq_( eq_(
[chr(i) for i in range(ord('a'),ord('z')+1)], [chr(i) for i in range(ord('a'),ord('z')+1)],
getwords("a-b_c&d+e(f)g;h\\i[j]k{l}m:n.o,p<q>r/s?t~u!v@w#x$y*z") getwords("a-b_c&d+e(f)g;h\\i[j]k{l}m:n.o,p<q>r/s?t~u!v@w#x$y*z")
) )
def test_joiner_chars(self): def test_joiner_chars(self):
eq_(["aec"], getwords("a'e\u0301c")) eq_(["aec"], getwords("a'e\u0301c"))
def test_empty(self): def test_empty(self):
eq_([], getwords('')) eq_([], getwords(''))
def test_returns_lowercase(self): def test_returns_lowercase(self):
eq_(['foo', 'bar'], getwords('FOO BAR')) eq_(['foo', 'bar'], getwords('FOO BAR'))
def test_decompose_unicode(self): def test_decompose_unicode(self):
eq_(getwords('foo\xe9bar'), ['fooebar']) eq_(getwords('foo\xe9bar'), ['fooebar'])
class TestCasegetfields: class TestCasegetfields:
def test_simple(self): def test_simple(self):
eq_([['a', 'b'], ['c', 'd', 'e']], getfields('a b - c d e')) eq_([['a', 'b'], ['c', 'd', 'e']], getfields('a b - c d e'))
def test_empty(self): def test_empty(self):
eq_([], getfields('')) eq_([], getfields(''))
def test_cleans_empty_fields(self): def test_cleans_empty_fields(self):
expected = [['a', 'bc', 'def']] expected = [['a', 'bc', 'def']]
actual = getfields(' - a bc def') actual = getfields(' - a bc def')
eq_(expected, actual) eq_(expected, actual)
expected = [['bc', 'def']] expected = [['bc', 'def']]
class TestCaseunpack_fields: class TestCaseunpack_fields:
def test_with_fields(self): def test_with_fields(self):
expected = ['a', 'b', 'c', 'd', 'e', 'f'] expected = ['a', 'b', 'c', 'd', 'e', 'f']
actual = unpack_fields([['a'], ['b', 'c'], ['d', 'e', 'f']]) actual = unpack_fields([['a'], ['b', 'c'], ['d', 'e', 'f']])
eq_(expected, actual) eq_(expected, actual)
def test_without_fields(self): def test_without_fields(self):
expected = ['a', 'b', 'c', 'd', 'e', 'f'] expected = ['a', 'b', 'c', 'd', 'e', 'f']
actual = unpack_fields(['a', 'b', 'c', 'd', 'e', 'f']) actual = unpack_fields(['a', 'b', 'c', 'd', 'e', 'f'])
eq_(expected, actual) eq_(expected, actual)
def test_empty(self): def test_empty(self):
eq_([], unpack_fields([])) eq_([], unpack_fields([]))
class TestCaseWordCompare: class TestCaseWordCompare:
def test_list(self): def test_list(self):
eq_(100, compare(['a', 'b', 'c', 'd'],['a', 'b', 'c', 'd'])) eq_(100, compare(['a', 'b', 'c', 'd'],['a', 'b', 'c', 'd']))
eq_(86, compare(['a', 'b', 'c', 'd'],['a', 'b', 'c'])) eq_(86, compare(['a', 'b', 'c', 'd'],['a', 'b', 'c']))
def test_unordered(self): def test_unordered(self):
#Sometimes, users don't want fuzzy matching too much When they set the slider #Sometimes, users don't want fuzzy matching too much When they set the slider
#to 100, they don't expect a filename with the same words, but not the same order, to match. #to 100, they don't expect a filename with the same words, but not the same order, to match.
#Thus, we want to return 99 in that case. #Thus, we want to return 99 in that case.
eq_(99, compare(['a', 'b', 'c', 'd'], ['d', 'b', 'c', 'a'])) eq_(99, compare(['a', 'b', 'c', 'd'], ['d', 'b', 'c', 'a']))
def test_word_occurs_twice(self): def test_word_occurs_twice(self):
#if a word occurs twice in first, but once in second, we want the word to be only counted once #if a word occurs twice in first, but once in second, we want the word to be only counted once
eq_(89, compare(['a', 'b', 'c', 'd', 'a'], ['d', 'b', 'c', 'a'])) eq_(89, compare(['a', 'b', 'c', 'd', 'a'], ['d', 'b', 'c', 'a']))
def test_uses_copy_of_lists(self): def test_uses_copy_of_lists(self):
first = ['foo', 'bar'] first = ['foo', 'bar']
second = ['bar', 'bleh'] second = ['bar', 'bleh']
compare(first, second) compare(first, second)
eq_(['foo', 'bar'], first) eq_(['foo', 'bar'], first)
eq_(['bar', 'bleh'], second) eq_(['bar', 'bleh'], second)
def test_word_weight(self): def test_word_weight(self):
eq_(int((6.0 / 13.0) * 100), compare(['foo', 'bar'], ['bar', 'bleh'], (WEIGHT_WORDS, ))) eq_(int((6.0 / 13.0) * 100), compare(['foo', 'bar'], ['bar', 'bleh'], (WEIGHT_WORDS, )))
def test_similar_words(self): def test_similar_words(self):
eq_(100, compare(['the', 'white', 'stripes'],['the', 'whites', 'stripe'], (MATCH_SIMILAR_WORDS, ))) eq_(100, compare(['the', 'white', 'stripes'],['the', 'whites', 'stripe'], (MATCH_SIMILAR_WORDS, )))
def test_empty(self): def test_empty(self):
eq_(0, compare([], [])) eq_(0, compare([], []))
def test_with_fields(self): def test_with_fields(self):
eq_(67, compare([['a', 'b'], ['c', 'd', 'e']], [['a', 'b'], ['c', 'd', 'f']])) eq_(67, compare([['a', 'b'], ['c', 'd', 'e']], [['a', 'b'], ['c', 'd', 'f']]))
def test_propagate_flags_with_fields(self, monkeypatch): def test_propagate_flags_with_fields(self, monkeypatch):
def mock_compare(first, second, flags): def mock_compare(first, second, flags):
eq_((0, 1, 2, 3, 5), flags) eq_((0, 1, 2, 3, 5), flags)
monkeypatch.setattr(engine, 'compare_fields', mock_compare) monkeypatch.setattr(engine, 'compare_fields', mock_compare)
compare([['a']], [['a']], (0, 1, 2, 3, 5)) compare([['a']], [['a']], (0, 1, 2, 3, 5))
class TestCaseWordCompareWithFields: class TestCaseWordCompareWithFields:
def test_simple(self): def test_simple(self):
eq_(67, compare_fields([['a', 'b'], ['c', 'd', 'e']], [['a', 'b'], ['c', 'd', 'f']])) eq_(67, compare_fields([['a', 'b'], ['c', 'd', 'e']], [['a', 'b'], ['c', 'd', 'f']]))
def test_empty(self): def test_empty(self):
eq_(0, compare_fields([], [])) eq_(0, compare_fields([], []))
def test_different_length(self): def test_different_length(self):
eq_(0, compare_fields([['a'], ['b']], [['a'], ['b'], ['c']])) eq_(0, compare_fields([['a'], ['b']], [['a'], ['b'], ['c']]))
def test_propagates_flags(self, monkeypatch): def test_propagates_flags(self, monkeypatch):
def mock_compare(first, second, flags): def mock_compare(first, second, flags):
eq_((0, 1, 2, 3, 5), flags) eq_((0, 1, 2, 3, 5), flags)
monkeypatch.setattr(engine, 'compare_fields', mock_compare) monkeypatch.setattr(engine, 'compare_fields', mock_compare)
compare_fields([['a']], [['a']],(0, 1, 2, 3, 5)) compare_fields([['a']], [['a']],(0, 1, 2, 3, 5))
def test_order(self): def test_order(self):
first = [['a', 'b'], ['c', 'd', 'e']] first = [['a', 'b'], ['c', 'd', 'e']]
second = [['c', 'd', 'f'], ['a', 'b']] second = [['c', 'd', 'f'], ['a', 'b']]
eq_(0, compare_fields(first, second)) eq_(0, compare_fields(first, second))
def test_no_order(self): def test_no_order(self):
first = [['a','b'],['c','d','e']] first = [['a','b'],['c','d','e']]
second = [['c','d','f'],['a','b']] second = [['c','d','f'],['a','b']]
@@ -168,10 +168,10 @@ class TestCaseWordCompareWithFields:
first = [['a','b'],['a','b']] #a field can only be matched once. first = [['a','b'],['a','b']] #a field can only be matched once.
second = [['c','d','f'],['a','b']] second = [['c','d','f'],['a','b']]
eq_(0, compare_fields(first, second, (NO_FIELD_ORDER, ))) eq_(0, compare_fields(first, second, (NO_FIELD_ORDER, )))
first = [['a','b'],['a','b','c']] first = [['a','b'],['a','b','c']]
second = [['c','d','f'],['a','b']] second = [['c','d','f'],['a','b']]
eq_(33, compare_fields(first, second, (NO_FIELD_ORDER, ))) eq_(33, compare_fields(first, second, (NO_FIELD_ORDER, )))
def test_compare_fields_without_order_doesnt_alter_fields(self): def test_compare_fields_without_order_doesnt_alter_fields(self):
#The NO_ORDER comp type altered the fields! #The NO_ORDER comp type altered the fields!
first = [['a','b'],['c','d','e']] first = [['a','b'],['c','d','e']]
@@ -179,7 +179,7 @@ class TestCaseWordCompareWithFields:
eq_(67, compare_fields(first, second, (NO_FIELD_ORDER, ))) eq_(67, compare_fields(first, second, (NO_FIELD_ORDER, )))
eq_([['a','b'],['c','d','e']],first) eq_([['a','b'],['c','d','e']],first)
eq_([['c','d','f'],['a','b']],second) eq_([['c','d','f'],['a','b']],second)
class TestCasebuild_word_dict: class TestCasebuild_word_dict:
def test_with_standard_words(self): def test_with_standard_words(self):
@@ -199,30 +199,30 @@ class TestCasebuild_word_dict:
assert l[2] in d['baz'] assert l[2] in d['baz']
eq_(1,len(d['bleh'])) eq_(1,len(d['bleh']))
assert l[2] in d['bleh'] assert l[2] in d['bleh']
def test_unpack_fields(self): def test_unpack_fields(self):
o = NamedObject('') o = NamedObject('')
o.words = [['foo','bar'],['baz']] o.words = [['foo','bar'],['baz']]
d = build_word_dict([o]) d = build_word_dict([o])
eq_(3,len(d)) eq_(3,len(d))
eq_(1,len(d['foo'])) eq_(1,len(d['foo']))
def test_words_are_unaltered(self): def test_words_are_unaltered(self):
o = NamedObject('') o = NamedObject('')
o.words = [['foo','bar'],['baz']] o.words = [['foo','bar'],['baz']]
build_word_dict([o]) build_word_dict([o])
eq_([['foo','bar'],['baz']],o.words) eq_([['foo','bar'],['baz']],o.words)
def test_object_instances_can_only_be_once_in_words_object_list(self): def test_object_instances_can_only_be_once_in_words_object_list(self):
o = NamedObject('foo foo',True) o = NamedObject('foo foo',True)
d = build_word_dict([o]) d = build_word_dict([o])
eq_(1,len(d['foo'])) eq_(1,len(d['foo']))
def test_job(self): def test_job(self):
def do_progress(p,d=''): def do_progress(p,d=''):
self.log.append(p) self.log.append(p)
return True return True
j = job.Job(1,do_progress) j = job.Job(1,do_progress)
self.log = [] self.log = []
s = "foo bar" s = "foo bar"
@@ -230,7 +230,7 @@ class TestCasebuild_word_dict:
# We don't have intermediate log because iter_with_progress is called with every > 1 # We don't have intermediate log because iter_with_progress is called with every > 1
eq_(0,self.log[0]) eq_(0,self.log[0])
eq_(100,self.log[1]) eq_(100,self.log[1])
class TestCasemerge_similar_words: class TestCasemerge_similar_words:
def test_some_similar_words(self): def test_some_similar_words(self):
@@ -242,8 +242,8 @@ class TestCasemerge_similar_words:
merge_similar_words(d) merge_similar_words(d)
eq_(1,len(d)) eq_(1,len(d))
eq_(3,len(d['foobar'])) eq_(3,len(d['foobar']))
class TestCasereduce_common_words: class TestCasereduce_common_words:
def test_typical(self): def test_typical(self):
@@ -254,7 +254,7 @@ class TestCasereduce_common_words:
reduce_common_words(d, 50) reduce_common_words(d, 50)
assert 'foo' not in d assert 'foo' not in d
eq_(49,len(d['bar'])) eq_(49,len(d['bar']))
def test_dont_remove_objects_with_only_common_words(self): def test_dont_remove_objects_with_only_common_words(self):
d = { d = {
'common': set([NamedObject("common uncommon",True) for i in range(50)] + [NamedObject("common",True)]), 'common': set([NamedObject("common uncommon",True) for i in range(50)] + [NamedObject("common",True)]),
@@ -263,7 +263,7 @@ class TestCasereduce_common_words:
reduce_common_words(d, 50) reduce_common_words(d, 50)
eq_(1,len(d['common'])) eq_(1,len(d['common']))
eq_(1,len(d['uncommon'])) eq_(1,len(d['uncommon']))
def test_values_still_are_set_instances(self): def test_values_still_are_set_instances(self):
d = { d = {
'common': set([NamedObject("common uncommon",True) for i in range(50)] + [NamedObject("common",True)]), 'common': set([NamedObject("common uncommon",True) for i in range(50)] + [NamedObject("common",True)]),
@@ -272,7 +272,7 @@ class TestCasereduce_common_words:
reduce_common_words(d, 50) reduce_common_words(d, 50)
assert isinstance(d['common'],set) assert isinstance(d['common'],set)
assert isinstance(d['uncommon'],set) assert isinstance(d['uncommon'],set)
def test_dont_raise_KeyError_when_a_word_has_been_removed(self): def test_dont_raise_KeyError_when_a_word_has_been_removed(self):
#If a word has been removed by the reduce, an object in a subsequent common word that #If a word has been removed by the reduce, an object in a subsequent common word that
#contains the word that has been removed would cause a KeyError. #contains the word that has been removed would cause a KeyError.
@@ -285,14 +285,14 @@ class TestCasereduce_common_words:
reduce_common_words(d, 50) reduce_common_words(d, 50)
except KeyError: except KeyError:
self.fail() self.fail()
def test_unpack_fields(self): def test_unpack_fields(self):
#object.words may be fields. #object.words may be fields.
def create_it(): def create_it():
o = NamedObject('') o = NamedObject('')
o.words = [['foo','bar'],['baz']] o.words = [['foo','bar'],['baz']]
return o return o
d = { d = {
'foo': set([create_it() for i in range(50)]) 'foo': set([create_it() for i in range(50)])
} }
@@ -300,7 +300,7 @@ class TestCasereduce_common_words:
reduce_common_words(d, 50) reduce_common_words(d, 50)
except TypeError: except TypeError:
self.fail("must support fields.") self.fail("must support fields.")
def test_consider_a_reduced_common_word_common_even_after_reduction(self): def test_consider_a_reduced_common_word_common_even_after_reduction(self):
#There was a bug in the code that causeda word that has already been reduced not to #There was a bug in the code that causeda word that has already been reduced not to
#be counted as a common word for subsequent words. For example, if 'foo' is processed #be counted as a common word for subsequent words. For example, if 'foo' is processed
@@ -316,7 +316,7 @@ class TestCasereduce_common_words:
eq_(1,len(d['foo'])) eq_(1,len(d['foo']))
eq_(1,len(d['bar'])) eq_(1,len(d['bar']))
eq_(49,len(d['baz'])) eq_(49,len(d['baz']))
class TestCaseget_match: class TestCaseget_match:
def test_simple(self): def test_simple(self):
@@ -328,7 +328,7 @@ class TestCaseget_match:
eq_(['bar','bleh'],m.second.words) eq_(['bar','bleh'],m.second.words)
assert m.first is o1 assert m.first is o1
assert m.second is o2 assert m.second is o2
def test_in(self): def test_in(self):
o1 = NamedObject("foo",True) o1 = NamedObject("foo",True)
o2 = NamedObject("bar",True) o2 = NamedObject("bar",True)
@@ -336,15 +336,15 @@ class TestCaseget_match:
assert o1 in m assert o1 in m
assert o2 in m assert o2 in m
assert object() not in m assert object() not in m
def test_word_weight(self): def test_word_weight(self):
eq_(int((6.0 / 13.0) * 100),get_match(NamedObject("foo bar",True),NamedObject("bar bleh",True),(WEIGHT_WORDS,)).percentage) eq_(int((6.0 / 13.0) * 100),get_match(NamedObject("foo bar",True),NamedObject("bar bleh",True),(WEIGHT_WORDS,)).percentage)
class TestCaseGetMatches: class TestCaseGetMatches:
def test_empty(self): def test_empty(self):
eq_(getmatches([]), []) eq_(getmatches([]), [])
def test_simple(self): def test_simple(self):
l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("a b c foo")] l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("a b c foo")]
r = getmatches(l) r = getmatches(l)
@@ -353,7 +353,7 @@ class TestCaseGetMatches:
assert_match(m, 'foo bar', 'bar bleh') assert_match(m, 'foo bar', 'bar bleh')
m = first(m for m in r if m.percentage == 33) #"foo bar" and "a b c foo" m = first(m for m in r if m.percentage == 33) #"foo bar" and "a b c foo"
assert_match(m, 'foo bar', 'a b c foo') assert_match(m, 'foo bar', 'a b c foo')
def test_null_and_unrelated_objects(self): def test_null_and_unrelated_objects(self):
l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject(""),NamedObject("unrelated object")] l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject(""),NamedObject("unrelated object")]
r = getmatches(l) r = getmatches(l)
@@ -361,22 +361,22 @@ class TestCaseGetMatches:
m = r[0] m = r[0]
eq_(m.percentage, 50) eq_(m.percentage, 50)
assert_match(m, 'foo bar', 'bar bleh') assert_match(m, 'foo bar', 'bar bleh')
def test_twice_the_same_word(self): def test_twice_the_same_word(self):
l = [NamedObject("foo foo bar"),NamedObject("bar bleh")] l = [NamedObject("foo foo bar"),NamedObject("bar bleh")]
r = getmatches(l) r = getmatches(l)
eq_(1,len(r)) eq_(1,len(r))
def test_twice_the_same_word_when_preworded(self): def test_twice_the_same_word_when_preworded(self):
l = [NamedObject("foo foo bar",True),NamedObject("bar bleh",True)] l = [NamedObject("foo foo bar",True),NamedObject("bar bleh",True)]
r = getmatches(l) r = getmatches(l)
eq_(1,len(r)) eq_(1,len(r))
def test_two_words_match(self): def test_two_words_match(self):
l = [NamedObject("foo bar"),NamedObject("foo bar bleh")] l = [NamedObject("foo bar"),NamedObject("foo bar bleh")]
r = getmatches(l) r = getmatches(l)
eq_(1,len(r)) eq_(1,len(r))
def test_match_files_with_only_common_words(self): def test_match_files_with_only_common_words(self):
#If a word occurs more than 50 times, it is excluded from the matching process #If a word occurs more than 50 times, it is excluded from the matching process
#The problem with the common_word_threshold is that the files containing only common #The problem with the common_word_threshold is that the files containing only common
@@ -385,18 +385,18 @@ class TestCaseGetMatches:
l = [NamedObject("foo") for i in range(50)] l = [NamedObject("foo") for i in range(50)]
r = getmatches(l) r = getmatches(l)
eq_(1225,len(r)) eq_(1225,len(r))
def test_use_words_already_there_if_there(self): def test_use_words_already_there_if_there(self):
o1 = NamedObject('foo') o1 = NamedObject('foo')
o2 = NamedObject('bar') o2 = NamedObject('bar')
o2.words = ['foo'] o2.words = ['foo']
eq_(1, len(getmatches([o1,o2]))) eq_(1, len(getmatches([o1,o2])))
def test_job(self): def test_job(self):
def do_progress(p,d=''): def do_progress(p,d=''):
self.log.append(p) self.log.append(p)
return True return True
j = job.Job(1,do_progress) j = job.Job(1,do_progress)
self.log = [] self.log = []
s = "foo bar" s = "foo bar"
@@ -404,12 +404,12 @@ class TestCaseGetMatches:
assert len(self.log) > 2 assert len(self.log) > 2
eq_(0,self.log[0]) eq_(0,self.log[0])
eq_(100,self.log[-1]) eq_(100,self.log[-1])
def test_weight_words(self): def test_weight_words(self):
l = [NamedObject("foo bar"),NamedObject("bar bleh")] l = [NamedObject("foo bar"),NamedObject("bar bleh")]
m = getmatches(l, weight_words=True)[0] m = getmatches(l, weight_words=True)[0]
eq_(int((6.0 / 13.0) * 100),m.percentage) eq_(int((6.0 / 13.0) * 100),m.percentage)
def test_similar_word(self): def test_similar_word(self):
l = [NamedObject("foobar"),NamedObject("foobars")] l = [NamedObject("foobar"),NamedObject("foobars")]
eq_(len(getmatches(l, match_similar_words=True)), 1) eq_(len(getmatches(l, match_similar_words=True)), 1)
@@ -420,16 +420,16 @@ class TestCaseGetMatches:
eq_(len(getmatches(l, match_similar_words=True)), 1) eq_(len(getmatches(l, match_similar_words=True)), 1)
l = [NamedObject("foobar"),NamedObject("foosbar")] l = [NamedObject("foobar"),NamedObject("foosbar")]
eq_(len(getmatches(l, match_similar_words=True)), 1) eq_(len(getmatches(l, match_similar_words=True)), 1)
def test_single_object_with_similar_words(self): def test_single_object_with_similar_words(self):
l = [NamedObject("foo foos")] l = [NamedObject("foo foos")]
eq_(len(getmatches(l, match_similar_words=True)), 0) eq_(len(getmatches(l, match_similar_words=True)), 0)
def test_double_words_get_counted_only_once(self): def test_double_words_get_counted_only_once(self):
l = [NamedObject("foo bar foo bleh"),NamedObject("foo bar bleh bar")] l = [NamedObject("foo bar foo bleh"),NamedObject("foo bar bleh bar")]
m = getmatches(l)[0] m = getmatches(l)[0]
eq_(75,m.percentage) eq_(75,m.percentage)
def test_with_fields(self): def test_with_fields(self):
o1 = NamedObject("foo bar - foo bleh") o1 = NamedObject("foo bar - foo bleh")
o2 = NamedObject("foo bar - bleh bar") o2 = NamedObject("foo bar - bleh bar")
@@ -437,7 +437,7 @@ class TestCaseGetMatches:
o2.words = getfields(o2.name) o2.words = getfields(o2.name)
m = getmatches([o1, o2])[0] m = getmatches([o1, o2])[0]
eq_(50, m.percentage) eq_(50, m.percentage)
def test_with_fields_no_order(self): def test_with_fields_no_order(self):
o1 = NamedObject("foo bar - foo bleh") o1 = NamedObject("foo bar - foo bleh")
o2 = NamedObject("bleh bang - foo bar") o2 = NamedObject("bleh bang - foo bar")
@@ -445,11 +445,11 @@ class TestCaseGetMatches:
o2.words = getfields(o2.name) o2.words = getfields(o2.name)
m = getmatches([o1, o2], no_field_order=True)[0] m = getmatches([o1, o2], no_field_order=True)[0]
eq_(m.percentage, 50) eq_(m.percentage, 50)
def test_only_match_similar_when_the_option_is_set(self): def test_only_match_similar_when_the_option_is_set(self):
l = [NamedObject("foobar"),NamedObject("foobars")] l = [NamedObject("foobar"),NamedObject("foobars")]
eq_(len(getmatches(l, match_similar_words=False)), 0) eq_(len(getmatches(l, match_similar_words=False)), 0)
def test_dont_recurse_do_match(self): def test_dont_recurse_do_match(self):
# with nosetests, the stack is increased. The number has to be high enough not to be failing falsely # with nosetests, the stack is increased. The number has to be high enough not to be failing falsely
sys.setrecursionlimit(100) sys.setrecursionlimit(100)
@@ -460,19 +460,19 @@ class TestCaseGetMatches:
self.fail() self.fail()
finally: finally:
sys.setrecursionlimit(1000) sys.setrecursionlimit(1000)
def test_min_match_percentage(self): def test_min_match_percentage(self):
l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("a b c foo")] l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("a b c foo")]
r = getmatches(l, min_match_percentage=50) r = getmatches(l, min_match_percentage=50)
eq_(1,len(r)) #Only "foo bar" / "bar bleh" should match eq_(1,len(r)) #Only "foo bar" / "bar bleh" should match
def test_MemoryError(self, monkeypatch): def test_MemoryError(self, monkeypatch):
@log_calls @log_calls
def mocked_match(first, second, flags): def mocked_match(first, second, flags):
if len(mocked_match.calls) > 42: if len(mocked_match.calls) > 42:
raise MemoryError() raise MemoryError()
return Match(first, second, 0) return Match(first, second, 0)
objects = [NamedObject() for i in range(10)] # results in 45 matches objects = [NamedObject() for i in range(10)] # results in 45 matches
monkeypatch.setattr(engine, 'get_match', mocked_match) monkeypatch.setattr(engine, 'get_match', mocked_match)
try: try:
@@ -480,13 +480,13 @@ class TestCaseGetMatches:
except MemoryError: except MemoryError:
self.fail('MemorryError must be handled') self.fail('MemorryError must be handled')
eq_(42, len(r)) eq_(42, len(r))
class TestCaseGetMatchesByContents: class TestCaseGetMatchesByContents:
def test_dont_compare_empty_files(self): def test_dont_compare_empty_files(self):
o1, o2 = no(size=0), no(size=0) o1, o2 = no(size=0), no(size=0)
assert not getmatches_by_contents([o1, o2]) assert not getmatches_by_contents([o1, o2])
class TestCaseGroup: class TestCaseGroup:
def test_empy(self): def test_empy(self):
@@ -494,7 +494,7 @@ class TestCaseGroup:
eq_(None,g.ref) eq_(None,g.ref)
eq_([],g.dupes) eq_([],g.dupes)
eq_(0,len(g.matches)) eq_(0,len(g.matches))
def test_add_match(self): def test_add_match(self):
g = Group() g = Group()
m = get_match(NamedObject("foo",True),NamedObject("bar",True)) m = get_match(NamedObject("foo",True),NamedObject("bar",True))
@@ -503,7 +503,7 @@ class TestCaseGroup:
eq_([m.second],g.dupes) eq_([m.second],g.dupes)
eq_(1,len(g.matches)) eq_(1,len(g.matches))
assert m in g.matches assert m in g.matches
def test_multiple_add_match(self): def test_multiple_add_match(self):
g = Group() g = Group()
o1 = NamedObject("a",True) o1 = NamedObject("a",True)
@@ -529,13 +529,13 @@ class TestCaseGroup:
g.add_match(get_match(o3,o4)) g.add_match(get_match(o3,o4))
eq_([o2,o3,o4],g.dupes) eq_([o2,o3,o4],g.dupes)
eq_(6,len(g.matches)) eq_(6,len(g.matches))
def test_len(self): def test_len(self):
g = Group() g = Group()
eq_(0,len(g)) eq_(0,len(g))
g.add_match(get_match(NamedObject("foo",True),NamedObject("bar",True))) g.add_match(get_match(NamedObject("foo",True),NamedObject("bar",True)))
eq_(2,len(g)) eq_(2,len(g))
def test_add_same_match_twice(self): def test_add_same_match_twice(self):
g = Group() g = Group()
m = get_match(NamedObject("foo",True),NamedObject("foo",True)) m = get_match(NamedObject("foo",True),NamedObject("foo",True))
@@ -545,7 +545,7 @@ class TestCaseGroup:
g.add_match(m) g.add_match(m)
eq_(2,len(g)) eq_(2,len(g))
eq_(1,len(g.matches)) eq_(1,len(g.matches))
def test_in(self): def test_in(self):
g = Group() g = Group()
o1 = NamedObject("foo",True) o1 = NamedObject("foo",True)
@@ -554,7 +554,7 @@ class TestCaseGroup:
g.add_match(get_match(o1,o2)) g.add_match(get_match(o1,o2))
assert o1 in g assert o1 in g
assert o2 in g assert o2 in g
def test_remove(self): def test_remove(self):
g = Group() g = Group()
o1 = NamedObject("foo",True) o1 = NamedObject("foo",True)
@@ -571,7 +571,7 @@ class TestCaseGroup:
g.remove_dupe(o1) g.remove_dupe(o1)
eq_(0,len(g.matches)) eq_(0,len(g.matches))
eq_(0,len(g)) eq_(0,len(g))
def test_remove_with_ref_dupes(self): def test_remove_with_ref_dupes(self):
g = Group() g = Group()
o1 = NamedObject("foo",True) o1 = NamedObject("foo",True)
@@ -584,7 +584,7 @@ class TestCaseGroup:
o2.is_ref = True o2.is_ref = True
g.remove_dupe(o3) g.remove_dupe(o3)
eq_(0,len(g)) eq_(0,len(g))
def test_switch_ref(self): def test_switch_ref(self):
o1 = NamedObject(with_words=True) o1 = NamedObject(with_words=True)
o2 = NamedObject(with_words=True) o2 = NamedObject(with_words=True)
@@ -598,7 +598,7 @@ class TestCaseGroup:
assert o2 is g.ref assert o2 is g.ref
g.switch_ref(NamedObject('',True)) g.switch_ref(NamedObject('',True))
assert o2 is g.ref assert o2 is g.ref
def test_switch_ref_from_ref_dir(self): def test_switch_ref_from_ref_dir(self):
# When the ref dupe is from a ref dir, switch_ref() does nothing # When the ref dupe is from a ref dir, switch_ref() does nothing
o1 = no(with_words=True) o1 = no(with_words=True)
@@ -608,7 +608,7 @@ class TestCaseGroup:
g.add_match(get_match(o1, o2)) g.add_match(get_match(o1, o2))
g.switch_ref(o2) g.switch_ref(o2)
assert o1 is g.ref assert o1 is g.ref
def test_get_match_of(self): def test_get_match_of(self):
g = Group() g = Group()
for m in get_match_triangle(): for m in get_match_triangle():
@@ -619,7 +619,7 @@ class TestCaseGroup:
assert o in m assert o in m
assert g.get_match_of(NamedObject('',True)) is None assert g.get_match_of(NamedObject('',True)) is None
assert g.get_match_of(g.ref) is None assert g.get_match_of(g.ref) is None
def test_percentage(self): def test_percentage(self):
#percentage should return the avg percentage in relation to the ref #percentage should return the avg percentage in relation to the ref
m1,m2,m3 = get_match_triangle() m1,m2,m3 = get_match_triangle()
@@ -638,11 +638,11 @@ class TestCaseGroup:
g.add_match(m1) g.add_match(m1)
g.add_match(m2) g.add_match(m2)
eq_(66,g.percentage) eq_(66,g.percentage)
def test_percentage_on_empty_group(self): def test_percentage_on_empty_group(self):
g = Group() g = Group()
eq_(0,g.percentage) eq_(0,g.percentage)
def test_prioritize(self): def test_prioritize(self):
m1,m2,m3 = get_match_triangle() m1,m2,m3 = get_match_triangle()
o1 = m1.first o1 = m1.first
@@ -658,7 +658,7 @@ class TestCaseGroup:
assert o1 is g.ref assert o1 is g.ref
assert g.prioritize(lambda x:x.name) assert g.prioritize(lambda x:x.name)
assert o3 is g.ref assert o3 is g.ref
def test_prioritize_with_tie_breaker(self): def test_prioritize_with_tie_breaker(self):
# if the ref has the same key as one or more of the dupe, run the tie_breaker func among them # if the ref has the same key as one or more of the dupe, run the tie_breaker func among them
g = get_test_group() g = get_test_group()
@@ -666,9 +666,9 @@ class TestCaseGroup:
tie_breaker = lambda ref, dupe: dupe is o3 tie_breaker = lambda ref, dupe: dupe is o3
g.prioritize(lambda x:0, tie_breaker) g.prioritize(lambda x:0, tie_breaker)
assert g.ref is o3 assert g.ref is o3
def test_prioritize_with_tie_breaker_runs_on_all_dupes(self): def test_prioritize_with_tie_breaker_runs_on_all_dupes(self):
# Even if a dupe is chosen to switch with ref with a tie breaker, we still run the tie breaker # Even if a dupe is chosen to switch with ref with a tie breaker, we still run the tie breaker
# with other dupes and the newly chosen ref # with other dupes and the newly chosen ref
g = get_test_group() g = get_test_group()
o1, o2, o3 = g.ordered o1, o2, o3 = g.ordered
@@ -678,7 +678,7 @@ class TestCaseGroup:
tie_breaker = lambda ref, dupe: dupe.foo > ref.foo tie_breaker = lambda ref, dupe: dupe.foo > ref.foo
g.prioritize(lambda x:0, tie_breaker) g.prioritize(lambda x:0, tie_breaker)
assert g.ref is o3 assert g.ref is o3
def test_prioritize_with_tie_breaker_runs_only_on_tie_dupes(self): def test_prioritize_with_tie_breaker_runs_only_on_tie_dupes(self):
# The tie breaker only runs on dupes that had the same value for the key_func # The tie breaker only runs on dupes that had the same value for the key_func
g = get_test_group() g = get_test_group()
@@ -693,7 +693,7 @@ class TestCaseGroup:
tie_breaker = lambda ref, dupe: dupe.bar > ref.bar tie_breaker = lambda ref, dupe: dupe.bar > ref.bar
g.prioritize(key_func, tie_breaker) g.prioritize(key_func, tie_breaker)
assert g.ref is o2 assert g.ref is o2
def test_prioritize_with_ref_dupe(self): def test_prioritize_with_ref_dupe(self):
# when the ref dupe of a group is from a ref dir, make it stay on top. # when the ref dupe of a group is from a ref dir, make it stay on top.
g = get_test_group() g = get_test_group()
@@ -702,7 +702,7 @@ class TestCaseGroup:
o2.size = 2 o2.size = 2
g.prioritize(lambda x: -x.size) g.prioritize(lambda x: -x.size)
assert g.ref is o1 assert g.ref is o1
def test_prioritize_nothing_changes(self): def test_prioritize_nothing_changes(self):
# prioritize() returns False when nothing changes in the group. # prioritize() returns False when nothing changes in the group.
g = get_test_group() g = get_test_group()
@@ -710,14 +710,14 @@ class TestCaseGroup:
g[1].name = 'b' g[1].name = 'b'
g[2].name = 'c' g[2].name = 'c'
assert not g.prioritize(lambda x:x.name) assert not g.prioritize(lambda x:x.name)
def test_list_like(self): def test_list_like(self):
g = Group() g = Group()
o1,o2 = (NamedObject("foo",True),NamedObject("bar",True)) o1,o2 = (NamedObject("foo",True),NamedObject("bar",True))
g.add_match(get_match(o1,o2)) g.add_match(get_match(o1,o2))
assert g[0] is o1 assert g[0] is o1
assert g[1] is o2 assert g[1] is o2
def test_discard_matches(self): def test_discard_matches(self):
g = Group() g = Group()
o1,o2,o3 = (NamedObject("foo",True),NamedObject("bar",True),NamedObject("baz",True)) o1,o2,o3 = (NamedObject("foo",True),NamedObject("bar",True),NamedObject("baz",True))
@@ -726,13 +726,13 @@ class TestCaseGroup:
g.discard_matches() g.discard_matches()
eq_(1,len(g.matches)) eq_(1,len(g.matches))
eq_(0,len(g.candidates)) eq_(0,len(g.candidates))
class TestCaseget_groups: class TestCaseget_groups:
def test_empty(self): def test_empty(self):
r = get_groups([]) r = get_groups([])
eq_([],r) eq_([],r)
def test_simple(self): def test_simple(self):
l = [NamedObject("foo bar"),NamedObject("bar bleh")] l = [NamedObject("foo bar"),NamedObject("bar bleh")]
matches = getmatches(l) matches = getmatches(l)
@@ -742,7 +742,7 @@ class TestCaseget_groups:
g = r[0] g = r[0]
assert g.ref is m.first assert g.ref is m.first
eq_([m.second],g.dupes) eq_([m.second],g.dupes)
def test_group_with_multiple_matches(self): def test_group_with_multiple_matches(self):
#This results in 3 matches #This results in 3 matches
l = [NamedObject("foo"),NamedObject("foo"),NamedObject("foo")] l = [NamedObject("foo"),NamedObject("foo"),NamedObject("foo")]
@@ -751,7 +751,7 @@ class TestCaseget_groups:
eq_(1,len(r)) eq_(1,len(r))
g = r[0] g = r[0]
eq_(3,len(g)) eq_(3,len(g))
def test_must_choose_a_group(self): def test_must_choose_a_group(self):
l = [NamedObject("a b"),NamedObject("a b"),NamedObject("b c"),NamedObject("c d"),NamedObject("c d")] l = [NamedObject("a b"),NamedObject("a b"),NamedObject("b c"),NamedObject("c d"),NamedObject("c d")]
#There will be 2 groups here: group "a b" and group "c d" #There will be 2 groups here: group "a b" and group "c d"
@@ -760,7 +760,7 @@ class TestCaseget_groups:
r = get_groups(matches) r = get_groups(matches)
eq_(2,len(r)) eq_(2,len(r))
eq_(5,len(r[0])+len(r[1])) eq_(5,len(r[0])+len(r[1]))
def test_should_all_go_in_the_same_group(self): def test_should_all_go_in_the_same_group(self):
l = [NamedObject("a b"),NamedObject("a b"),NamedObject("a b"),NamedObject("a b")] l = [NamedObject("a b"),NamedObject("a b"),NamedObject("a b"),NamedObject("a b")]
#There will be 2 groups here: group "a b" and group "c d" #There will be 2 groups here: group "a b" and group "c d"
@@ -768,7 +768,7 @@ class TestCaseget_groups:
matches = getmatches(l) matches = getmatches(l)
r = get_groups(matches) r = get_groups(matches)
eq_(1,len(r)) eq_(1,len(r))
def test_give_priority_to_matches_with_higher_percentage(self): def test_give_priority_to_matches_with_higher_percentage(self):
o1 = NamedObject(with_words=True) o1 = NamedObject(with_words=True)
o2 = NamedObject(with_words=True) o2 = NamedObject(with_words=True)
@@ -782,14 +782,14 @@ class TestCaseget_groups:
assert o1 not in g assert o1 not in g
assert o2 in g assert o2 in g
assert o3 in g assert o3 in g
def test_four_sized_group(self): def test_four_sized_group(self):
l = [NamedObject("foobar") for i in range(4)] l = [NamedObject("foobar") for i in range(4)]
m = getmatches(l) m = getmatches(l)
r = get_groups(m) r = get_groups(m)
eq_(1,len(r)) eq_(1,len(r))
eq_(4,len(r[0])) eq_(4,len(r[0]))
def test_referenced_by_ref2(self): def test_referenced_by_ref2(self):
o1 = NamedObject(with_words=True) o1 = NamedObject(with_words=True)
o2 = NamedObject(with_words=True) o2 = NamedObject(with_words=True)
@@ -799,12 +799,12 @@ class TestCaseget_groups:
m3 = get_match(o3,o2) m3 = get_match(o3,o2)
r = get_groups([m1,m2,m3]) r = get_groups([m1,m2,m3])
eq_(3,len(r[0])) eq_(3,len(r[0]))
def test_job(self): def test_job(self):
def do_progress(p,d=''): def do_progress(p,d=''):
self.log.append(p) self.log.append(p)
return True return True
self.log = [] self.log = []
j = job.Job(1,do_progress) j = job.Job(1,do_progress)
m1,m2,m3 = get_match_triangle() m1,m2,m3 = get_match_triangle()
@@ -813,7 +813,7 @@ class TestCaseget_groups:
get_groups([m1,m2,m3,m4],j) get_groups([m1,m2,m3,m4],j)
eq_(0,self.log[0]) eq_(0,self.log[0])
eq_(100,self.log[-1]) eq_(100,self.log[-1])
def test_group_admissible_discarded_dupes(self): def test_group_admissible_discarded_dupes(self):
# If, with a (A, B, C, D) set, all match with A, but C and D don't match with B and that the # If, with a (A, B, C, D) set, all match with A, but C and D don't match with B and that the
# (A, B) match is the highest (thus resulting in an (A, B) group), still match C and D # (A, B) match is the highest (thus resulting in an (A, B) group), still match C and D
@@ -830,4 +830,4 @@ class TestCaseget_groups:
assert B in g1 assert B in g1
assert C in g2 assert C in g2
assert D in g2 assert D in g2

View File

@@ -1,12 +1,12 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/03/03 # Created On: 2006/03/03
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from jobprogress import job from hscommon.jobprogress import job
from hscommon.path import Path from hscommon.path import Path
from hscommon.testutil import eq_ from hscommon.testutil import eq_
@@ -25,10 +25,10 @@ class NamedObject:
self.size = size self.size = size
self.path = path self.path = path
self.words = getwords(name) self.words = getwords(name)
def __repr__(self): def __repr__(self):
return '<NamedObject %r %r>' % (self.name, self.path) return '<NamedObject %r %r>' % (self.name, self.path)
no = NamedObject no = NamedObject
@@ -384,7 +384,7 @@ def test_file_evaluates_to_false(fake_fileexists):
class FalseNamedObject(NamedObject): class FalseNamedObject(NamedObject):
def __bool__(self): def __bool__(self):
return False return False
s = Scanner() s = Scanner()
f1 = FalseNamedObject('foobar', path='p1') f1 = FalseNamedObject('foobar', path='p1')
@@ -445,7 +445,7 @@ def test_tie_breaker_same_name_plus_digit(fake_fileexists):
assert group.ref is o5 assert group.ref is o5
def test_partial_group_match(fake_fileexists): def test_partial_group_match(fake_fileexists):
# Count the number of discarded matches (when a file doesn't match all other dupes of the # Count the number of discarded matches (when a file doesn't match all other dupes of the
# group) in Scanner.discarded_file_count # group) in Scanner.discarded_file_count
s = Scanner() s = Scanner()
o1, o2, o3 = no('a b'), no('a'), no('b') o1, o2, o3 = no('a b'), no('a'), no('b')
@@ -476,7 +476,7 @@ def test_dont_group_files_that_dont_exist(tmpdir):
file2.path.remove() file2.path.remove()
return [Match(file1, file2, 100)] return [Match(file1, file2, 100)]
s._getmatches = getmatches s._getmatches = getmatches
assert not s.get_dupe_groups([file1, file2]) assert not s.get_dupe_groups([file1, file2])
def test_folder_scan_exclude_subfolder_matches(fake_fileexists): def test_folder_scan_exclude_subfolder_matches(fake_fileexists):

View File

@@ -1,2 +1,3 @@
__version__ = '6.7.0' __version__ = '6.8.1'
__appname__ = 'dupeGuru Music Edition' __appname__ = 'dupeGuru Music Edition'

View File

@@ -1,8 +1,8 @@
# Created On: 2011/09/20 # Created On: 2011/09/20
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from core.app import DupeGuru as DupeGuruBase from core.app import DupeGuru as DupeGuruBase
@@ -13,28 +13,30 @@ from .result_table import ResultTable
class DupeGuru(DupeGuruBase): class DupeGuru(DupeGuruBase):
NAME = __appname__ NAME = __appname__
METADATA_TO_READ = ['size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist', METADATA_TO_READ = [
'album', 'genre', 'year', 'track', 'comment'] 'size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist',
'album', 'genre', 'year', 'track', 'comment'
]
def __init__(self, view): def __init__(self, view):
DupeGuruBase.__init__(self, view) DupeGuruBase.__init__(self, view)
self.scanner = scanner.ScannerME() self.scanner = scanner.ScannerME()
self.directories.fileclasses = [fs.MusicFile] self.directories.fileclasses = [fs.MusicFile]
def _get_dupe_sort_key(self, dupe, get_group, key, delta): def _get_dupe_sort_key(self, dupe, get_group, key, delta):
if key == 'folder_path': if key == 'folder_path':
dupe_folder_path = getattr(dupe, 'display_folder_path', dupe.folder_path) dupe_folder_path = getattr(dupe, 'display_folder_path', dupe.folder_path)
return str(dupe_folder_path).lower() return str(dupe_folder_path).lower()
return DupeGuruBase._get_dupe_sort_key(self, dupe, get_group, key, delta) return DupeGuruBase._get_dupe_sort_key(self, dupe, get_group, key, delta)
def _get_group_sort_key(self, group, key): def _get_group_sort_key(self, group, key):
if key == 'folder_path': if key == 'folder_path':
dupe_folder_path = getattr(group.ref, 'display_folder_path', group.ref.folder_path) dupe_folder_path = getattr(group.ref, 'display_folder_path', group.ref.folder_path)
return str(dupe_folder_path).lower() return str(dupe_folder_path).lower()
return DupeGuruBase._get_group_sort_key(self, group, key) return DupeGuruBase._get_group_sort_key(self, group, key)
def _prioritization_categories(self): def _prioritization_categories(self):
return prioritize.all_categories() return prioritize.all_categories()
def _create_result_table(self): def _create_result_table(self):
return ResultTable(self) return ResultTable(self)

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-10-23 # Created On: 2009-10-23
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from hsaudiotag import auto from hsaudiotag import auto
@@ -12,32 +12,34 @@ from hscommon.util import get_file_ext, format_size, format_time
from core.app import format_timestamp, format_perc, format_words, format_dupe_count from core.app import format_timestamp, format_perc, format_words, format_dupe_count
from core import fs from core import fs
TAG_FIELDS = {'audiosize', 'duration', 'bitrate', 'samplerate', 'title', 'artist', TAG_FIELDS = {
'album', 'genre', 'year', 'track', 'comment'} 'audiosize', 'duration', 'bitrate', 'samplerate', 'title', 'artist',
'album', 'genre', 'year', 'track', 'comment'
}
class MusicFile(fs.File): class MusicFile(fs.File):
INITIAL_INFO = fs.File.INITIAL_INFO.copy() INITIAL_INFO = fs.File.INITIAL_INFO.copy()
INITIAL_INFO.update({ INITIAL_INFO.update({
'audiosize': 0, 'audiosize': 0,
'bitrate' : 0, 'bitrate': 0,
'duration' : 0, 'duration': 0,
'samplerate':0, 'samplerate': 0,
'artist' : '', 'artist': '',
'album' : '', 'album': '',
'title' : '', 'title': '',
'genre' : '', 'genre': '',
'comment' : '', 'comment': '',
'year' : '', 'year': '',
'track' : 0, 'track': 0,
}) })
__slots__ = fs.File.__slots__ + tuple(INITIAL_INFO.keys()) __slots__ = fs.File.__slots__ + tuple(INITIAL_INFO.keys())
@classmethod @classmethod
def can_handle(cls, path): def can_handle(cls, path):
if not fs.File.can_handle(path): if not fs.File.can_handle(path):
return False return False
return get_file_ext(path.name) in auto.EXT2CLASS return get_file_ext(path.name) in auto.EXT2CLASS
def get_display_info(self, group, delta): def get_display_info(self, group, delta):
size = self.size size = self.size
duration = self.duration duration = self.duration
@@ -67,7 +69,7 @@ class MusicFile(fs.File):
'bitrate': str(bitrate), 'bitrate': str(bitrate),
'samplerate': str(samplerate), 'samplerate': str(samplerate),
'extension': self.extension, 'extension': self.extension,
'mtime': format_timestamp(mtime,delta and m), 'mtime': format_timestamp(mtime, delta and m),
'title': self.title, 'title': self.title,
'artist': self.artist, 'artist': self.artist,
'album': self.album, 'album': self.album,
@@ -79,11 +81,11 @@ class MusicFile(fs.File):
'words': format_words(self.words) if hasattr(self, 'words') else '', 'words': format_words(self.words) if hasattr(self, 'words') else '',
'dupe_count': format_dupe_count(dupe_count), 'dupe_count': format_dupe_count(dupe_count),
} }
def _get_md5partial_offset_and_size(self): def _get_md5partial_offset_and_size(self):
f = auto.File(str(self.path)) f = auto.File(str(self.path))
return (f.audio_offset, f.audio_size) return (f.audio_offset, f.audio_size)
def _read_info(self, field): def _read_info(self, field):
fs.File._read_info(self, field) fs.File._read_info(self, field)
if field in TAG_FIELDS: if field in TAG_FIELDS:
@@ -99,4 +101,4 @@ class MusicFile(fs.File):
self.comment = f.comment self.comment = f.comment
self.year = f.year self.year = f.year
self.track = f.track self.track = f.track

View File

@@ -1,35 +1,40 @@
# Created On: 2011/09/16 # Created On: 2011/09/16
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from hscommon.trans import trget from hscommon.trans import trget
from core.prioritize import (KindCategory, FolderCategory, FilenameCategory, NumericalCategory, from core.prioritize import (
SizeCategory, MtimeCategory) KindCategory, FolderCategory, FilenameCategory, NumericalCategory,
SizeCategory, MtimeCategory
)
coltr = trget('columns') coltr = trget('columns')
class DurationCategory(NumericalCategory): class DurationCategory(NumericalCategory):
NAME = coltr("Duration") NAME = coltr("Duration")
def extract_value(self, dupe): def extract_value(self, dupe):
return dupe.duration return dupe.duration
class BitrateCategory(NumericalCategory): class BitrateCategory(NumericalCategory):
NAME = coltr("Bitrate") NAME = coltr("Bitrate")
def extract_value(self, dupe): def extract_value(self, dupe):
return dupe.bitrate return dupe.bitrate
class SamplerateCategory(NumericalCategory): class SamplerateCategory(NumericalCategory):
NAME = coltr("Samplerate") NAME = coltr("Samplerate")
def extract_value(self, dupe): def extract_value(self, dupe):
return dupe.samplerate return dupe.samplerate
def all_categories(): def all_categories():
return [KindCategory, FolderCategory, FilenameCategory, SizeCategory, DurationCategory, return [
BitrateCategory, SamplerateCategory, MtimeCategory] KindCategory, FolderCategory, FilenameCategory, SizeCategory, DurationCategory,
BitrateCategory, SamplerateCategory, MtimeCategory
]

View File

@@ -1,2 +1,2 @@
__version__ = '2.9.0' __version__ = '2.10.1'
__appname__ = 'dupeGuru Picture Edition' __appname__ = 'dupeGuru Picture Edition'

View File

@@ -17,10 +17,10 @@ from .result_table import ResultTable
class DupeGuru(DupeGuruBase): class DupeGuru(DupeGuruBase):
NAME = __appname__ NAME = __appname__
METADATA_TO_READ = ['size', 'mtime', 'dimensions', 'exif_timestamp'] METADATA_TO_READ = ['size', 'mtime', 'dimensions', 'exif_timestamp']
SCANNER_CLASS = ScannerPE
def __init__(self, view): def __init__(self, view):
DupeGuruBase.__init__(self, view) DupeGuruBase.__init__(self, view)
self.scanner = ScannerPE()
self.scanner.cache_path = op.join(self.appdata, 'cached_pictures.db') self.scanner.cache_path = op.join(self.appdata, 'cached_pictures.db')
def _get_dupe_sort_key(self, dupe, get_group, key, delta): def _get_dupe_sort_key(self, dupe, get_group, key, delta):

View File

@@ -1,17 +1,17 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/09/01 # Created On: 2006/09/01
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 # NOQA
# Converted to C # Converted to C
# def getblock(image): # def getblock(image):
# """Returns a 3 sized tuple containing the mean color of 'image'. # """Returns a 3 sized tuple containing the mean color of 'image'.
# #
# image: a PIL image or crop. # image: a PIL image or crop.
# """ # """
# if image.size[0]: # if image.size[0]:
@@ -28,7 +28,7 @@ from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2
# This is not used anymore # This is not used anymore
# def getblocks(image,blocksize): # def getblocks(image,blocksize):
# """Returns a list of blocks (3 sized tuples). # """Returns a list of blocks (3 sized tuples).
# #
# image: A PIL image to base the blocks on. # image: A PIL image to base the blocks on.
# blocksize: The size of the blocks to be create. This is a single integer, defining # blocksize: The size of the blocks to be create. This is a single integer, defining
# both width and height (blocks are square). # both width and height (blocks are square).
@@ -46,7 +46,7 @@ from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2
# Converted to C # Converted to C
# def getblocks2(image,block_count_per_side): # def getblocks2(image,block_count_per_side):
# """Returns a list of blocks (3 sized tuples). # """Returns a list of blocks (3 sized tuples).
# #
# image: A PIL image to base the blocks on. # image: A PIL image to base the blocks on.
# block_count_per_side: This integer determine the number of blocks the function will return. # block_count_per_side: This integer determine the number of blocks the function will return.
# If it is 10, for example, 100 blocks will be returns (10 width, 10 height). The blocks will not # If it is 10, for example, 100 blocks will be returns (10 width, 10 height). The blocks will not
@@ -73,7 +73,7 @@ from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2
# Converted to C # Converted to C
# def diff(first, second): # def diff(first, second):
# """Returns the difference between the first block and the second. # """Returns the difference between the first block and the second.
# #
# It returns an absolute sum of the 3 differences (RGB). # It returns an absolute sum of the 3 differences (RGB).
# """ # """
# r1, g1, b1 = first # r1, g1, b1 = first
@@ -83,7 +83,7 @@ from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2
# Converted to C # Converted to C
# def avgdiff(first, second, limit=768, min_iterations=1): # def avgdiff(first, second, limit=768, min_iterations=1):
# """Returns the average diff between first blocks and seconds. # """Returns the average diff between first blocks and seconds.
# #
# If the result surpasses limit, limit + 1 is returned, except if less than min_iterations # If the result surpasses limit, limit + 1 is returned, except if less than min_iterations
# iterations have been made in the blocks. # iterations have been made in the blocks.
# """ # """
@@ -106,7 +106,7 @@ from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2
# This is not used anymore # This is not used anymore
# def maxdiff(first,second,limit=768): # def maxdiff(first,second,limit=768):
# """Returns the max diff between first blocks and seconds. # """Returns the max diff between first blocks and seconds.
# #
# If the result surpasses limit, the first max being over limit is returned. # If the result surpasses limit, the first max being over limit is returned.
# """ # """
# if len(first) != len(second): # if len(first) != len(second):

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2006/09/14 # Created On: 2006/09/14
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import os import os
@@ -15,11 +15,11 @@ from ._cache import string_to_colors
def colors_to_string(colors): def colors_to_string(colors):
"""Transform the 3 sized tuples 'colors' into a hex string. """Transform the 3 sized tuples 'colors' into a hex string.
[(0,100,255)] --> 0064ff [(0,100,255)] --> 0064ff
[(1,2,3),(4,5,6)] --> 010203040506 [(1,2,3),(4,5,6)] --> 010203040506
""" """
return ''.join(['%02x%02x%02x' % (r,g,b) for r,g,b in colors]) return ''.join(['%02x%02x%02x' % (r, g, b) for r, g, b in colors])
# This function is an important bottleneck of dupeGuru PE. It has been converted to C. # This function is an important bottleneck of dupeGuru PE. It has been converted to C.
# def string_to_colors(s): # def string_to_colors(s):
@@ -38,18 +38,18 @@ class Cache:
self.dbname = db self.dbname = db
self.con = None self.con = None
self._create_con() self._create_con()
def __contains__(self, key): def __contains__(self, key):
sql = "select count(*) from pictures where path = ?" sql = "select count(*) from pictures where path = ?"
result = self.con.execute(sql, [key]).fetchall() result = self.con.execute(sql, [key]).fetchall()
return result[0][0] > 0 return result[0][0] > 0
def __delitem__(self, key): def __delitem__(self, key):
if key not in self: if key not in self:
raise KeyError(key) raise KeyError(key)
sql = "delete from pictures where path = ?" sql = "delete from pictures where path = ?"
self.con.execute(sql, [key]) self.con.execute(sql, [key])
# Optimized # Optimized
def __getitem__(self, key): def __getitem__(self, key):
if isinstance(key, int): if isinstance(key, int):
@@ -62,17 +62,17 @@ class Cache:
return result return result
else: else:
raise KeyError(key) raise KeyError(key)
def __iter__(self): def __iter__(self):
sql = "select path from pictures" sql = "select path from pictures"
result = self.con.execute(sql) result = self.con.execute(sql)
return (row[0] for row in result) return (row[0] for row in result)
def __len__(self): def __len__(self):
sql = "select count(*) from pictures" sql = "select count(*) from pictures"
result = self.con.execute(sql).fetchall() result = self.con.execute(sql).fetchall()
return result[0][0] return result[0][0]
def __setitem__(self, path_str, blocks): def __setitem__(self, path_str, blocks):
blocks = colors_to_string(blocks) blocks = colors_to_string(blocks)
if op.exists(path_str): if op.exists(path_str):
@@ -89,15 +89,15 @@ class Cache:
logging.warning('Picture cache could not set value for key %r', path_str) logging.warning('Picture cache could not set value for key %r', path_str)
except sqlite.DatabaseError as e: except sqlite.DatabaseError as e:
logging.warning('DatabaseError while setting value for key %r: %s', path_str, str(e)) logging.warning('DatabaseError while setting value for key %r: %s', path_str, str(e))
def _create_con(self, second_try=False): def _create_con(self, second_try=False):
def create_tables(): def create_tables():
logging.debug("Creating picture cache tables.") logging.debug("Creating picture cache tables.")
self.con.execute("drop table if exists pictures"); self.con.execute("drop table if exists pictures")
self.con.execute("drop index if exists idx_path"); self.con.execute("drop index if exists idx_path")
self.con.execute("create table pictures(path TEXT, mtime INTEGER, blocks TEXT)"); self.con.execute("create table pictures(path TEXT, mtime INTEGER, blocks TEXT)")
self.con.execute("create index idx_path on pictures (path)") self.con.execute("create index idx_path on pictures (path)")
self.con = sqlite.connect(self.dbname, isolation_level=None) self.con = sqlite.connect(self.dbname, isolation_level=None)
try: try:
self.con.execute("select path, mtime, blocks from pictures where 1=2") self.con.execute("select path, mtime, blocks from pictures where 1=2")
@@ -110,23 +110,23 @@ class Cache:
self.con.close() self.con.close()
os.remove(self.dbname) os.remove(self.dbname)
self._create_con(second_try=True) self._create_con(second_try=True)
def clear(self): def clear(self):
self.close() self.close()
if self.dbname != ':memory:': if self.dbname != ':memory:':
os.remove(self.dbname) os.remove(self.dbname)
self._create_con() self._create_con()
def close(self): def close(self):
if self.con is not None: if self.con is not None:
self.con.close() self.con.close()
self.con = None self.con = None
def filter(self, func): def filter(self, func):
to_delete = [key for key in self if not func(key)] to_delete = [key for key in self if not func(key)]
for key in to_delete: for key in to_delete:
del self[key] del self[key]
def get_id(self, path): def get_id(self, path):
sql = "select rowid from pictures where path = ?" sql = "select rowid from pictures where path = ?"
result = self.con.execute(sql, [path]).fetchone() result = self.con.execute(sql, [path]).fetchone()
@@ -134,15 +134,15 @@ class Cache:
return result[0] return result[0]
else: else:
raise ValueError(path) raise ValueError(path)
def get_multiple(self, rowids): def get_multiple(self, rowids):
sql = "select rowid, blocks from pictures where rowid in (%s)" % ','.join(map(str, rowids)) sql = "select rowid, blocks from pictures where rowid in (%s)" % ','.join(map(str, rowids))
cur = self.con.execute(sql) cur = self.con.execute(sql)
return ((rowid, string_to_colors(blocks)) for rowid, blocks in cur) return ((rowid, string_to_colors(blocks)) for rowid, blocks in cur)
def purge_outdated(self): def purge_outdated(self):
"""Go through the cache and purge outdated records. """Go through the cache and purge outdated records.
A record is outdated if the picture doesn't exist or if its mtime is greater than the one in A record is outdated if the picture doesn't exist or if its mtime is greater than the one in
the db. the db.
""" """
@@ -159,4 +159,4 @@ class Cache:
if todelete: if todelete:
sql = "delete from pictures where rowid in (%s)" % ','.join(map(str, todelete)) sql = "delete from pictures where rowid in (%s)" % ','.join(map(str, todelete))
self.con.execute(sql) self.con.execute(sql)

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-04-20 # Created On: 2011-04-20
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
# Heavily based on http://topo.math.u-psud.fr/~bousch/exifdump.py by Thierry Bousch (Public Domain) # Heavily based on http://topo.math.u-psud.fr/~bousch/exifdump.py by Thierry Bousch (Public Domain)
@@ -181,7 +181,7 @@ class Fraction:
def __repr__(self): def __repr__(self):
return '%d/%d' % (self.num, self.den) return '%d/%d' % (self.num, self.den)
class TIFF_file: class TIFF_file:
def __init__(self, data): def __init__(self, data):
@@ -201,14 +201,14 @@ class TIFF_file:
logging.debug(self.endian) logging.debug(self.endian)
logging.debug("Slice for offset %d length %d: %r and value: %d", offset, length, slice, val) logging.debug("Slice for offset %d length %d: %r and value: %d", offset, length, slice, val)
return val return val
def first_IFD(self): def first_IFD(self):
return self.s2n(4, 4) return self.s2n(4, 4)
def next_IFD(self, ifd): def next_IFD(self, ifd):
entries = self.s2n(ifd, 2) entries = self.s2n(ifd, 2)
return self.s2n(ifd + 2 + 12 * entries, 4) return self.s2n(ifd + 2 + 12 * entries, 4)
def list_IFDs(self): def list_IFDs(self):
i = self.first_IFD() i = self.first_IFD()
a = [] a = []
@@ -216,7 +216,7 @@ class TIFF_file:
a.append(i) a.append(i)
i = self.next_IFD(i) i = self.next_IFD(i)
return a return a
def dump_IFD(self, ifd): def dump_IFD(self, ifd):
entries = self.s2n(ifd, 2) entries = self.s2n(ifd, 2)
logging.debug("Entries for IFD %d: %d", ifd, entries) logging.debug("Entries for IFD %d: %d", ifd, entries)
@@ -230,7 +230,7 @@ class TIFF_file:
type = self.s2n(entry+2, 2) type = self.s2n(entry+2, 2)
if not 1 <= type <= 10: if not 1 <= type <= 10:
continue # not handled continue # not handled
typelen = [ 1, 1, 2, 4, 8, 1, 1, 2, 4, 8 ] [type-1] typelen = [1, 1, 2, 4, 8, 1, 1, 2, 4, 8][type-1]
count = self.s2n(entry+4, 4) count = self.s2n(entry+4, 4)
if count > MAX_COUNT: if count > MAX_COUNT:
logging.debug("Probably corrupt. Aborting.") logging.debug("Probably corrupt. Aborting.")
@@ -247,7 +247,7 @@ class TIFF_file:
for j in range(count): for j in range(count):
if type in {5, 10}: if type in {5, 10}:
# The type is either 5 or 10 # The type is either 5 or 10
value_j = Fraction(self.s2n(offset, 4, signed), value_j = Fraction(self.s2n(offset, 4, signed),
self.s2n(offset+4, 4, signed)) self.s2n(offset+4, 4, signed))
else: else:
# Not a fraction # Not a fraction
@@ -255,7 +255,7 @@ class TIFF_file:
values.append(value_j) values.append(value_j)
offset = offset + typelen offset = offset + typelen
# Now "values" is either a string or an array # Now "values" is either a string or an array
a.append((tag,type,values)) a.append((tag, type, values))
return a return a
def read_exif_header(fp): def read_exif_header(fp):
@@ -283,13 +283,13 @@ def get_fields(fp):
logging.debug("Exif header length: %d bytes", length) logging.debug("Exif header length: %d bytes", length)
data = fp.read(length-8) data = fp.read(length-8)
data_format = data[0] data_format = data[0]
logging.debug("%s format", {INTEL_ENDIAN:'Intel', MOTOROLA_ENDIAN:'Motorola'}[data_format]) logging.debug("%s format", {INTEL_ENDIAN: 'Intel', MOTOROLA_ENDIAN: 'Motorola'}[data_format])
T = TIFF_file(data) T = TIFF_file(data)
# There may be more than one IFD per file, but we only read the first one because others are # There may be more than one IFD per file, but we only read the first one because others are
# most likely thumbnails. # most likely thumbnails.
main_IFD_offset = T.first_IFD() main_IFD_offset = T.first_IFD()
result = {} result = {}
def add_tag_to_result(tag, values): def add_tag_to_result(tag, values):
try: try:
stag = EXIF_TAGS[tag] stag = EXIF_TAGS[tag]
@@ -298,7 +298,7 @@ def get_fields(fp):
if stag in result: if stag in result:
return # don't overwrite data return # don't overwrite data
result[stag] = values result[stag] = values
logging.debug("IFD at offset %d", main_IFD_offset) logging.debug("IFD at offset %d", main_IFD_offset)
IFD = T.dump_IFD(main_IFD_offset) IFD = T.dump_IFD(main_IFD_offset)
exif_off = gps_off = 0 exif_off = gps_off = 0

View File

@@ -8,24 +8,24 @@
import plistlib import plistlib
class IPhotoPlistParser(plistlib.PlistParser): class IPhotoPlistParser(plistlib._PlistParser):
"""A parser for iPhoto plists. """A parser for iPhoto plists.
iPhoto plists tend to be malformed, so we have to subclass the built-in parser to be a bit more iPhoto plists tend to be malformed, so we have to subclass the built-in parser to be a bit more
lenient. lenient.
""" """
def __init__(self): def __init__(self):
plistlib.PlistParser.__init__(self) plistlib._PlistParser.__init__(self, use_builtin_types=True, dict_type=dict)
# For debugging purposes, we remember the last bit of data to be analyzed so that we can # For debugging purposes, we remember the last bit of data to be analyzed so that we can
# log it in case of an exception # log it in case of an exception
self.lastdata = '' self.lastdata = ''
def getData(self): def get_data(self):
self.lastdata = plistlib.PlistParser.getData(self) self.lastdata = plistlib._PlistParser.get_data(self)
return self.lastdata return self.lastdata
def end_integer(self): def end_integer(self):
try: try:
self.addObject(int(self.getData())) self.add_object(int(self.get_data()))
except ValueError: except ValueError:
self.addObject(0) self.add_object(0)

View File

@@ -1,18 +1,18 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2007/02/25 # Created On: 2007/02/25
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import logging import logging
import multiprocessing import multiprocessing
from itertools import combinations from itertools import combinations
from hscommon.util import extract from hscommon.util import extract, iterconsume
from hscommon.trans import tr from hscommon.trans import tr
from jobprogress import job from hscommon.jobprogress import job
from core.engine import Match from core.engine import Match
from .block import avgdiff, DifferentBlockCountError, NoBlocksError from .block import avgdiff, DifferentBlockCountError, NoBlocksError
@@ -93,8 +93,10 @@ def get_chunks(pictures):
chunk_count = max(min_chunk_count, chunk_count) chunk_count = max(min_chunk_count, chunk_count)
chunk_size = (len(pictures) // chunk_count) + 1 chunk_size = (len(pictures) // chunk_count) + 1
chunk_size = max(MIN_CHUNK_SIZE, chunk_size) chunk_size = max(MIN_CHUNK_SIZE, chunk_size)
logging.info("Creating %d chunks with a chunk size of %d for %d pictures", chunk_count, logging.info(
chunk_size, len(pictures)) "Creating %d chunks with a chunk size of %d for %d pictures", chunk_count,
chunk_size, len(pictures)
)
chunks = [pictures[i:i+chunk_size] for i in range(0, len(pictures), chunk_size)] chunks = [pictures[i:i+chunk_size] for i in range(0, len(pictures), chunk_size)]
return chunks return chunks
@@ -132,17 +134,17 @@ def async_compare(ref_ids, other_ids, dbname, threshold, picinfo):
results.append((ref_id, other_id, percentage)) results.append((ref_id, other_id, percentage))
cache.close() cache.close()
return results return results
def getmatches(pictures, cache_path, threshold=75, match_scaled=False, j=job.nulljob): def getmatches(pictures, cache_path, threshold=75, match_scaled=False, j=job.nulljob):
def get_picinfo(p): def get_picinfo(p):
if match_scaled: if match_scaled:
return (None, p.is_ref) return (None, p.is_ref)
else: else:
return (p.dimensions, p.is_ref) return (p.dimensions, p.is_ref)
def collect_results(collect_all=False): def collect_results(collect_all=False):
# collect results and wait until the queue is small enough to accomodate a new results. # collect results and wait until the queue is small enough to accomodate a new results.
nonlocal async_results, matches, comparison_count nonlocal async_results, matches, comparison_count, comparisons_to_do
limit = 0 if collect_all else RESULTS_QUEUE_LIMIT limit = 0 if collect_all else RESULTS_QUEUE_LIMIT
while len(async_results) > limit: while len(async_results) > limit:
ready, working = extract(lambda r: r.ready(), async_results) ready, working = extract(lambda r: r.ready(), async_results)
@@ -150,9 +152,10 @@ def getmatches(pictures, cache_path, threshold=75, match_scaled=False, j=job.nul
matches += result.get() matches += result.get()
async_results.remove(result) async_results.remove(result)
comparison_count += 1 comparison_count += 1
progress_msg = tr("Performed %d/%d chunk matches") % (comparison_count, len(comparisons_to_do)) # About the NOQA below: I think there's a bug in pyflakes. To investigate...
progress_msg = tr("Performed %d/%d chunk matches") % (comparison_count, len(comparisons_to_do)) # NOQA
j.set_progress(comparison_count, progress_msg) j.set_progress(comparison_count, progress_msg)
j = j.start_subjob([3, 7]) j = j.start_subjob([3, 7])
pictures = prepare_pictures(pictures, cache_path, with_dimensions=not match_scaled, j=j) pictures = prepare_pictures(pictures, cache_path, with_dimensions=not match_scaled, j=j)
j = j.start_subjob([9, 1], tr("Preparing for matching")) j = j.start_subjob([9, 1], tr("Preparing for matching"))
@@ -175,22 +178,36 @@ def getmatches(pictures, cache_path, threshold=75, match_scaled=False, j=job.nul
comparisons_to_do = list(combinations(chunks + [None], 2)) comparisons_to_do = list(combinations(chunks + [None], 2))
comparison_count = 0 comparison_count = 0
j.start_job(len(comparisons_to_do)) j.start_job(len(comparisons_to_do))
for ref_chunk, other_chunk in comparisons_to_do: try:
picinfo = {p.cache_id: get_picinfo(p) for p in ref_chunk} for ref_chunk, other_chunk in comparisons_to_do:
ref_ids = [p.cache_id for p in ref_chunk] picinfo = {p.cache_id: get_picinfo(p) for p in ref_chunk}
if other_chunk is not None: ref_ids = [p.cache_id for p in ref_chunk]
other_ids = [p.cache_id for p in other_chunk] if other_chunk is not None:
picinfo.update({p.cache_id: get_picinfo(p) for p in other_chunk}) other_ids = [p.cache_id for p in other_chunk]
else: picinfo.update({p.cache_id: get_picinfo(p) for p in other_chunk})
other_ids = None else:
args = (ref_ids, other_ids, cache_path, threshold, picinfo) other_ids = None
async_results.append(pool.apply_async(async_compare, args)) args = (ref_ids, other_ids, cache_path, threshold, picinfo)
collect_results() async_results.append(pool.apply_async(async_compare, args))
collect_results(collect_all=True) collect_results()
collect_results(collect_all=True)
except MemoryError:
# Rare, but possible, even in 64bit situations (ref #264). What do we do now? We free us
# some wiggle room, log about the incident, and stop matching right here. We then process
# the matches we have. The rest of the process doesn't allocate much and we should be
# alright.
del comparisons_to_do, chunks, pictures # some wiggle room for the next statements
logging.warning("Ran out of memory when scanning! We had %d matches.", len(matches))
del matches[-len(matches)//3:] # some wiggle room to ensure we don't run out of memory again.
pool.close() pool.close()
result = [] result = []
for ref_id, other_id, percentage in j.iter_with_progress(matches, tr("Verified %d/%d matches"), every=10): myiter = j.iter_with_progress(
iterconsume(matches, reverse=False),
tr("Verified %d/%d matches"),
every=10,
count=len(matches),
)
for ref_id, other_id, percentage in myiter:
ref = id2picture[ref_id] ref = id2picture[ref_id]
other = id2picture[other_id] other = id2picture[other_id]
if percentage == 100 and ref.md5 != other.md5: if percentage == 100 and ref.md5 != other.md5:
@@ -201,4 +218,5 @@ def getmatches(pictures, cache_path, threshold=75, match_scaled=False, j=job.nul
result.append(get_match(ref, other, percentage)) result.append(get_match(ref, other, percentage))
return result return result
multiprocessing.freeze_support() multiprocessing.freeze_support()

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-04-20 # Created On: 2011-04-20
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from collections import defaultdict from collections import defaultdict
@@ -27,4 +27,5 @@ def getmatches(files, match_scaled, j):
if (not match_scaled) and (p1.dimensions != p2.dimensions): if (not match_scaled) and (p1.dimensions != p2.dimensions):
continue continue
matches.append(Match(p1, p2, 100)) matches.append(Match(p1, p2, 100))
return matches return matches

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-05-29 # Created On: 2011-05-29
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import logging import logging
@@ -23,20 +23,20 @@ def get_delta_dimensions(value, ref_value):
class Photo(fs.File): class Photo(fs.File):
INITIAL_INFO = fs.File.INITIAL_INFO.copy() INITIAL_INFO = fs.File.INITIAL_INFO.copy()
INITIAL_INFO.update({ INITIAL_INFO.update({
'dimensions': (0,0), 'dimensions': (0, 0),
'exif_timestamp': '', 'exif_timestamp': '',
}) })
__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'}
def _plat_get_dimensions(self): def _plat_get_dimensions(self):
raise NotImplementedError() raise NotImplementedError()
def _plat_get_blocks(self, block_count_per_side, orientation): def _plat_get_blocks(self, block_count_per_side, orientation):
raise NotImplementedError() raise NotImplementedError()
def _get_orientation(self): def _get_orientation(self):
if not hasattr(self, '_cached_orientation'): if not hasattr(self, '_cached_orientation'):
try: try:
@@ -48,7 +48,7 @@ class Photo(fs.File):
except Exception: # Couldn't read EXIF data, no transforms except Exception: # Couldn't read EXIF data, no transforms
self._cached_orientation = 0 self._cached_orientation = 0
return self._cached_orientation return self._cached_orientation
def _get_exif_timestamp(self): def _get_exif_timestamp(self):
try: try:
with self.path.open('rb') as fp: with self.path.open('rb') as fp:
@@ -57,11 +57,11 @@ class Photo(fs.File):
except Exception: except Exception:
logging.info("Couldn't read EXIF of picture: %s", self.path) logging.info("Couldn't read EXIF of picture: %s", self.path)
return '' return ''
@classmethod @classmethod
def can_handle(cls, path): def can_handle(cls, path):
return fs.File.can_handle(path) and get_file_ext(path.name) in cls.HANDLED_EXTS return fs.File.can_handle(path) and get_file_ext(path.name) in cls.HANDLED_EXTS
def get_display_info(self, group, delta): def get_display_info(self, group, delta):
size = self.size size = self.size
mtime = self.mtime mtime = self.mtime
@@ -90,7 +90,7 @@ class Photo(fs.File):
'percentage': format_perc(percentage), 'percentage': format_perc(percentage),
'dupe_count': format_dupe_count(dupe_count), 'dupe_count': format_dupe_count(dupe_count),
} }
def _read_info(self, field): def _read_info(self, field):
fs.File._read_info(self, field) fs.File._read_info(self, field)
if field == 'dimensions': if field == 'dimensions':
@@ -99,7 +99,7 @@ class Photo(fs.File):
self.dimensions = (self.dimensions[1], self.dimensions[0]) self.dimensions = (self.dimensions[1], self.dimensions[0])
elif field == 'exif_timestamp': elif field == 'exif_timestamp':
self.exif_timestamp = self._get_exif_timestamp() self.exif_timestamp = self._get_exif_timestamp()
def get_blocks(self, block_count_per_side): def get_blocks(self, block_count_per_side):
return self._plat_get_blocks(block_count_per_side, self._get_orientation()) return self._plat_get_blocks(block_count_per_side, self._get_orientation())

View File

@@ -1,27 +1,31 @@
# Created On: 2011/09/16 # Created On: 2011/09/16
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from hscommon.trans import trget from hscommon.trans import trget
from core.prioritize import (KindCategory, FolderCategory, FilenameCategory, NumericalCategory, from core.prioritize import (
SizeCategory, MtimeCategory) KindCategory, FolderCategory, FilenameCategory, NumericalCategory,
SizeCategory, MtimeCategory
)
coltr = trget('columns') coltr = trget('columns')
class DimensionsCategory(NumericalCategory): class DimensionsCategory(NumericalCategory):
NAME = coltr("Dimensions") NAME = coltr("Dimensions")
def extract_value(self, dupe): def extract_value(self, dupe):
return dupe.dimensions return dupe.dimensions
def invert_numerical_value(self, value): def invert_numerical_value(self, value):
width, height = value width, height = value
return (-width, -height) return (-width, -height)
def all_categories(): def all_categories():
return [KindCategory, FolderCategory, FilenameCategory, SizeCategory, DimensionsCategory, return [
MtimeCategory] KindCategory, FolderCategory, FilenameCategory, SizeCategory, DimensionsCategory,
MtimeCategory
]

View File

@@ -1,2 +1,3 @@
__version__ = '3.9.0' __version__ = '3.9.1'
__appname__ = 'dupeGuru' __appname__ = 'dupeGuru'

View File

@@ -1,3 +1,23 @@
=== 6.8.1 (2014-10-26)
* Fixed ``AttributeError: 'ComboboxModel' object has no attribute 'reset'``. [Linux, Windows] (#254)
* Fixed ``PermissionError`` on saving results. (#266)
* Fixed a build problem introduced by Sphinx 1.2.3.
* Updated German localisation, by Frank Weber.
=== 6.8.0 (2014-05-11)
* This is mostly a dependencies upgrade.
* Upgraded to Python 3.3.
* Upgraded to Qt 5.
* Minimum Windows version is now Windows 7 64bit.
* Minimum Ubuntu version is now 14.04.
* Minimum OS X version is now 10.7 (Lion).
* ... But with a couple of little improvements.
* Improved documentation.
* Overwrite subfolders' state when setting states in folder dialog (#248)
* The error report dialog now brings the user to Github issues.
=== 6.7.0 (2013-12-08) === 6.7.0 (2013-12-08)
* Disable symlink/hardlink deletion option when not relevant. (#247) * Disable symlink/hardlink deletion option when not relevant. (#247)

View File

@@ -1,3 +1,25 @@
=== 2.10.1 (2014-10-12)
* Catch MemoryError better in block matching algo. (#264)
* Fix crash when reading some EXIF tags. [Mac] (#263, #265)
* Fixed ``AttributeError: 'ComboboxModel' object has no attribute 'reset'``. [Linux, Windows] (#254)
* Fixed a build problem introduced by Sphinx 1.2.3.
* Updated German localisation, by Frank Weber.
=== 2.10.0 (2014-05-03)
* This is mostly a dependencies upgrade.
* Upgraded to Python 3.3.
* Upgraded to Qt 5.
* Minimum Windows version is now Windows 7 64bit.
* Minimum Ubuntu version is now 14.04.
* Minimum OS X version is now 10.7 (Lion).
* ... But with a couple of little improvements.
* Improved documentation.
* Overwrite subfolders' state when setting states in folder dialog (#248)
* Fix empty Ignore List dialog bug. (#253)
* The error report dialog now brings the user to Github issues.
=== 2.9.0 (2013-12-22) === 2.9.0 (2013-12-22)
* Read RAW pictures EXIF tags. [Mac] (#234) * Read RAW pictures EXIF tags. [Mac] (#234)

View File

@@ -1,3 +1,10 @@
=== 3.9.1 (2014-10-17)
* Fixed ``AttributeError: 'ComboboxModel' object has no attribute 'reset'``. [Linux, Windows] (#254)
* Fixed ``PermissionError`` on saving results. (#266)
* Fixed a build problem introduced by Sphinx 1.2.3.
* Updated German localisation, by Frank Weber.
=== 3.9.0 (2014-04-19) === 3.9.0 (2014-04-19)
* This is mostly a dependencies upgrade. * This is mostly a dependencies upgrade.

View File

@@ -25,7 +25,7 @@ sys.path.insert(0, os.path.abspath(os.path.join('..', '..')))
def fix_nulljob_in_sig(app, what, name, obj, options, signature, return_annotation): def fix_nulljob_in_sig(app, what, name, obj, options, signature, return_annotation):
if signature: if signature:
signature = re.sub(r"<jobprogress.job.NullJob object at 0x[\da-f]+>", "nulljob", signature) signature = re.sub(r"<hscommon.jobprogress.job.NullJob object at 0x[\da-f]+>", "nulljob", signature)
return signature, return_annotation return signature, return_annotation
def setup(app): def setup(app):

View File

@@ -10,6 +10,8 @@ Unten befindet sich die Liste aller Menschen, die direkt oder indirekt zu dupeGu
| **Gregor Tätzner, deutsche Übersetzung** | **Gregor Tätzner, deutsche Übersetzung**
| **Frank Weber, deutsche Übersetzung**
| **Eric Dee, chinesische Übersetzung** | **Eric Dee, chinesische Übersetzung**
| **Aleš Nehyba, Czech localization** | **Aleš Nehyba, Czech localization**

View File

@@ -10,6 +10,8 @@ Below is the list of people who contributed, directly or indirectly to dupeGuru.
| **Gregor Tätzner, German localization** | **Gregor Tätzner, German localization**
| **Frank Weber, German localization**
| **Eric Dee, Chinese localization** | **Eric Dee, Chinese localization**
| **Aleš Nehyba, Czech localization** | **Aleš Nehyba, Czech localization**

View File

@@ -3,6 +3,7 @@ hscommon
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
:glob:
build build
conflict conflict
@@ -10,10 +11,6 @@ hscommon
notify notify
path path
util util
gui/base jobprogress/*
gui/text_field gui/*
gui/selectable_list
gui/table
gui/tree
gui/column
gui/progress_window

View File

@@ -0,0 +1,17 @@
hscommon.jobprogress.job
========================
.. automodule:: hscommon.jobprogress.job
.. autosummary::
Job
NullJob
.. autoclass:: Job
:members:
:private-members:
.. autoclass:: NullJob
:members:

View File

@@ -0,0 +1,12 @@
hscommon.jobprogress.performer
==============================
.. automodule:: hscommon.jobprogress.performer
.. autosummary::
ThreadedJobPerformer
.. autoclass:: ThreadedJobPerformer
:members:

View File

@@ -0,0 +1,12 @@
hscommon.jobprogress.qt
=======================
.. automodule:: hscommon.jobprogress.qt
.. autosummary::
Progress
.. autoclass:: Progress
:members:

View File

@@ -12,16 +12,16 @@ dupeGuru's codebase has quite a few design flaws. The Model, View and Controller
different classes, scattered around. If you're aware of that, it might help you to understand what different classes, scattered around. If you're aware of that, it might help you to understand what
the heck is going on. the heck is going on.
The central piece of dupeGuru is ``dupeguru.app.DupeGuru`` (in the ``core`` code). It's the only The central piece of dupeGuru is :class:`core.app.DupeGuru`. It's the only
interface to the python's code for the GUI code. A duplicate scan is started with interface to the python's code for the GUI code. A duplicate scan is started with
``start_scanning()``, directories are added through ``add_directory()``, etc.. :meth:`core.app.DupeGuru.start_scanning()`, directories are added through
:meth:`core.app.DupeGuru.add_directory()`, etc..
A lot of functionalities of the App are implemented in the platform-specific subclasses of A lot of functionalities of the App are implemented in the platform-specific subclasses of
``app.DupeGuru``, like ``app_cocoa.DupeGuru``, or the ``base.app.DupeGuru`` class in the PyQt :class:`core.app.DupeGuru`, like ``DupeGuru`` in ``cocoa/inter/app.py``, or the ``DupeGuru`` class
codebase. For example, when performing "Remove Selected From Results", in ``qt/base/app.py``. For example, when performing "Remove Selected From Results",
``app_cocoa.Dupeguru.RemoveSelected()`` on the Obj-C side, and ``RemoveSelected()`` on the cocoa side, and ``remove_duplicates()`` on the PyQt side, are
``base.app.DupeGuru.remove_duplicates()`` on the PyQt side, are respectively called to perform the respectively called to perform the thing.
thing. All of this is quite ugly, I know (see the "Refactoring" section below).
.. _jobs: .. _jobs:
@@ -29,23 +29,26 @@ Jobs
---- ----
A lot of operations in dupeGuru take a significant amount of time. This is why there's a generalized A lot of operations in dupeGuru take a significant amount of time. This is why there's a generalized
threaded job mechanism built-in ``app.DupeGuru``. First, ``app.DupeGuru`` has a ``progress`` member threaded job mechanism built-in :class:`~core.app.DupeGuru`. First, :class:`~core.app.DupeGuru` has
which is an instance of ``jobprogress.job.ThreadedJobPerformer``. It lets the GUI code know of the a ``progress`` member which is an instance of
progress of the current threaded job. When ``app.DupeGuru`` needs to start a job, it calls :class:`~hscommon.jobprogress.performer.ThreadedJobPerformer`. It lets the GUI code know of the progress
of the current threaded job. When :class:`~core.app.DupeGuru` needs to start a job, it calls
``_start_job()`` and the platform specific subclass deals with the details of starting the job. ``_start_job()`` and the platform specific subclass deals with the details of starting the job.
Core principles Core principles
--------------- ---------------
The core of the duplicate matching takes place (for SE and ME, not PE) in ``dupeguru.engine``. The core of the duplicate matching takes place (for SE and ME, not PE) in :mod:`core.engine`.
There's ``MatchFactory.getmatches()`` which take a list of ``fs.File`` instances and return a list There's :func:`core.engine.getmatches` which take a list of :class:`core.fs.File` instances and
of ``(firstfile, secondfile, match_percentage)`` matches. Then, there's ``get_groups()`` which takes return a list of ``(firstfile, secondfile, match_percentage)`` matches. Then, there's
a list of matches and returns a list of ``Group`` instances (a ``Group`` is basically a list of :func:`core.engine.get_groups` which takes a list of matches and returns a list of
``fs.File`` matching together). :class:`.Group` instances (a :class:`.Group` is basically a list of :class:`.File` matching
together).
When a scan is over, the final result (the list of groups from ``get_groups()``) is placed into When a scan is over, the final result (the list of groups from :func:`.get_groups`) is placed into
``app.DupeGuru.results``, which is a ``results.Results`` instance. The ``Results`` instance is where :attr:`core.app.DupeGuru.results`, which is a :class:`core.results.Results` instance. The
all the dupe marking, sorting, removing, power marking, etc. takes place. :class:`~.Results` instance is where all the dupe marking, sorting, removing, power marking, etc.
takes place.
API API
--- ---

View File

@@ -9,6 +9,8 @@ Voici la liste des contributeurs de dupeGuru. Merci!
| **Gregor Tätzner, localisation allemande** | **Gregor Tätzner, localisation allemande**
| **Frank Weber, localisation allemande**
| **Eric Dee, localisation choinoise** | **Eric Dee, localisation choinoise**
| **Aleš Nehyba, localisation tchèque** | **Aleš Nehyba, localisation tchèque**

View File

@@ -10,6 +10,8 @@
| **Gregor Tätzner, Գերմաներեն թարգմանիչը** | **Gregor Tätzner, Գերմաներեն թարգմանիչը**
| **Frank Weber, Գերմաներեն թարգմանիչը**
| **Eric Dee, Չինարեն թարգմանիչը** | **Eric Dee, Չինարեն թարգմանիչը**
| **Aleš Nehyba, Չեխերեն թարգմանիչը** | **Aleš Nehyba, Չեխերեն թարգմանիչը**

View File

@@ -10,6 +10,8 @@
| **Gregor Tätzner, Немецкая локализация** | **Gregor Tätzner, Немецкая локализация**
| **Frank Weber, Немецкая локализация**
| **Eric Dee, Китайская локализация** | **Eric Dee, Китайская локализация**
| **Aleš Nehyba, Чешский локализации** | **Aleš Nehyba, Чешский локализации**

View File

@@ -10,6 +10,8 @@
| **Gregor Tätzner, Німецька локалізація** | **Gregor Tätzner, Німецька локалізація**
| **Frank Weber, Німецька локалізація**
| **Eric Dee, Китайська локалізація** | **Eric Dee, Китайська локалізація**
| **Aleš Nehyba, Чеський локалізації** | **Aleš Nehyba, Чеський локалізації**

View File

@@ -2,8 +2,8 @@
# Created On: 2009-03-03 # Created On: 2009-03-03
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
"""This module is a collection of function to help in HS apps build process. """This module is a collection of function to help in HS apps build process.
@@ -96,7 +96,7 @@ def filereplace(filename, outfilename=None, **kwargs):
fp.close() fp.close()
# We can't use str.format() because in some files, there might be {} characters that mess with it. # We can't use str.format() because in some files, there might be {} characters that mess with it.
for key, item in kwargs.items(): for key, item in kwargs.items():
contents = contents.replace('{{{}}}'.format(key), item) contents = contents.replace('{{{}}}'.format(key), item)
fp = open(outfilename, 'wt', encoding='utf-8') fp = open(outfilename, 'wt', encoding='utf-8')
fp.write(contents) fp.write(contents)
fp.close() fp.close()
@@ -106,12 +106,22 @@ def get_module_version(modulename):
return mod.__version__ return mod.__version__
def setup_package_argparser(parser): def setup_package_argparser(parser):
parser.add_argument('--sign', dest='sign_identity', parser.add_argument(
help="Sign app under specified identity before packaging (OS X only)") '--sign', dest='sign_identity',
parser.add_argument('--nosign', action='store_true', dest='nosign', help="Sign app under specified identity before packaging (OS X only)"
help="Don't sign the packaged app (OS X only)") )
parser.add_argument('--src-pkg', action='store_true', dest='src_pkg', parser.add_argument(
help="Build a tar.gz of the current source.") '--nosign', action='store_true', dest='nosign',
help="Don't sign the packaged app (OS X only)"
)
parser.add_argument(
'--src-pkg', action='store_true', dest='src_pkg',
help="Build a tar.gz of the current source."
)
parser.add_argument(
'--arch-pkg', action='store_true', dest='arch_pkg',
help="Force Arch Linux packaging type, regardless of distro name."
)
# `args` come from an ArgumentParser updated with setup_package_argparser() # `args` come from an ArgumentParser updated with setup_package_argparser()
def package_cocoa_app_in_dmg(app_path, destfolder, args): def package_cocoa_app_in_dmg(app_path, destfolder, args):
@@ -120,7 +130,7 @@ def package_cocoa_app_in_dmg(app_path, destfolder, args):
# a valid signature. # a valid signature.
if args.sign_identity: if args.sign_identity:
sign_identity = "Developer ID Application: {}".format(args.sign_identity) sign_identity = "Developer ID Application: {}".format(args.sign_identity)
result = print_and_do('codesign --force --sign "{}" "{}"'.format(sign_identity, app_path)) result = print_and_do('codesign --force --deep --sign "{}" "{}"'.format(sign_identity, app_path))
if result != 0: if result != 0:
print("ERROR: Signing failed. Aborting packaging.") print("ERROR: Signing failed. Aborting packaging.")
return return
@@ -131,7 +141,7 @@ def package_cocoa_app_in_dmg(app_path, destfolder, args):
def build_dmg(app_path, destfolder): def build_dmg(app_path, destfolder):
"""Builds a DMG volume with application at ``app_path`` and puts it in ``dest_path``. """Builds a DMG volume with application at ``app_path`` and puts it in ``dest_path``.
The name of the resulting DMG volume is determined by the app's name and version. The name of the resulting DMG volume is determined by the app's name and version.
""" """
print(repr(op.join(app_path, 'Contents', 'Info.plist'))) print(repr(op.join(app_path, 'Contents', 'Info.plist')))
@@ -176,7 +186,7 @@ def add_to_pythonpath(path):
# from there. # from there.
def copy_packages(packages_names, dest, create_links=False, extra_ignores=None): def copy_packages(packages_names, dest, create_links=False, extra_ignores=None):
"""Copy python packages ``packages_names`` to ``dest``, spurious data. """Copy python packages ``packages_names`` to ``dest``, spurious data.
Copy will happen without tests, testdata, mercurial data or C extension module source with it. Copy will happen without tests, testdata, mercurial data or C extension module source with it.
``py2app`` include and exclude rules are **quite** funky, and doing this is the only reliable ``py2app`` include and exclude rules are **quite** funky, and doing this is the only reliable
way to make sure we don't end up with useless stuff in our app. way to make sure we don't end up with useless stuff in our app.
@@ -223,7 +233,7 @@ def copy_qt_plugins(folder_names, dest): # This is only for Windows
def build_debian_changelog(changelogpath, destfile, pkgname, from_version=None, def build_debian_changelog(changelogpath, destfile, pkgname, from_version=None,
distribution='precise', fix_version=None): distribution='precise', fix_version=None):
"""Builds a debian changelog out of a YAML changelog. """Builds a debian changelog out of a YAML changelog.
Use fix_version to patch the top changelog to that version (if, for example, there was a Use fix_version to patch the top changelog to that version (if, for example, there was a
packaging error and you need to quickly fix it) packaging error and you need to quickly fix it)
""" """
@@ -233,7 +243,7 @@ def build_debian_changelog(changelogpath, destfile, pkgname, from_version=None,
desc = desc.replace(' ', ' ') desc = desc.replace(' ', ' ')
result = desc.split('*') result = desc.split('*')
return [s.strip() for s in result if s.strip()] return [s.strip() for s in result if s.strip()]
ENTRY_MODEL = "{pkg} ({version}~{distribution}) {distribution}; urgency=low\n\n{changes}\n -- Virgil Dupras <hsoft@hardcoded.net> {date}\n\n" ENTRY_MODEL = "{pkg} ({version}~{distribution}) {distribution}; urgency=low\n\n{changes}\n -- Virgil Dupras <hsoft@hardcoded.net> {date}\n\n"
CHANGE_MODEL = " * {description}\n" CHANGE_MODEL = " * {description}\n"
changelogs = read_changelog_file(changelogpath) changelogs = read_changelog_file(changelogpath)
@@ -269,7 +279,7 @@ def read_changelog_file(filename):
date = next(it) date = next(it)
description = next(it) description = next(it)
yield version, date, description yield version, date, description
with open(filename, 'rt', encoding='utf-8') as fp: with open(filename, 'rt', encoding='utf-8') as fp:
contents = fp.read() contents = fp.read()
splitted = re_changelog_header.split(contents)[1:] # the first item is empty splitted = re_changelog_header.split(contents)[1:] # the first item is empty
@@ -289,7 +299,7 @@ class OSXAppStructure:
self.resources = op.join(self.contents, 'Resources') self.resources = op.join(self.contents, 'Resources')
self.frameworks = op.join(self.contents, 'Frameworks') self.frameworks = op.join(self.contents, 'Frameworks')
self.infoplist = op.join(self.contents, 'Info.plist') self.infoplist = op.join(self.contents, 'Info.plist')
def create(self, infoplist): def create(self, infoplist):
ensure_empty_folder(self.dest) ensure_empty_folder(self.dest)
os.makedirs(self.macos) os.makedirs(self.macos)
@@ -297,24 +307,24 @@ class OSXAppStructure:
os.mkdir(self.frameworks) os.mkdir(self.frameworks)
copy(infoplist, self.infoplist) copy(infoplist, self.infoplist)
open(op.join(self.contents, 'PkgInfo'), 'wt').write("APPLxxxx") open(op.join(self.contents, 'PkgInfo'), 'wt').write("APPLxxxx")
def copy_executable(self, executable): def copy_executable(self, executable):
info = plistlib.readPlist(self.infoplist) info = plistlib.readPlist(self.infoplist)
self.executablename = info['CFBundleExecutable'] self.executablename = info['CFBundleExecutable']
self.executablepath = op.join(self.macos, self.executablename) self.executablepath = op.join(self.macos, self.executablename)
copy(executable, self.executablepath) copy(executable, self.executablepath)
def copy_resources(self, *resources, use_symlinks=False): def copy_resources(self, *resources, use_symlinks=False):
for path in resources: for path in resources:
resource_dest = op.join(self.resources, op.basename(path)) resource_dest = op.join(self.resources, op.basename(path))
action = symlink if use_symlinks else copy action = symlink if use_symlinks else copy
action(op.abspath(path), resource_dest) action(op.abspath(path), resource_dest)
def copy_frameworks(self, *frameworks): def copy_frameworks(self, *frameworks):
for path in frameworks: for path in frameworks:
framework_dest = op.join(self.frameworks, op.basename(path)) framework_dest = op.join(self.frameworks, op.basename(path))
copy(path, framework_dest) copy(path, framework_dest)
def create_osx_app_structure(dest, executable, infoplist, resources=None, frameworks=None, def create_osx_app_structure(dest, executable, infoplist, resources=None, frameworks=None,
symlink_resources=False): symlink_resources=False):
@@ -338,7 +348,7 @@ class OSXFrameworkStructure:
self.headers = op.join(self.contents, 'Headers') self.headers = op.join(self.contents, 'Headers')
self.infoplist = op.join(self.resources, 'Info.plist') self.infoplist = op.join(self.resources, 'Info.plist')
self._update_executable_path() self._update_executable_path()
def _update_executable_path(self): def _update_executable_path(self):
if not op.exists(self.infoplist): if not op.exists(self.infoplist):
self.executablename = self.executablepath = None self.executablename = self.executablepath = None
@@ -346,7 +356,7 @@ class OSXFrameworkStructure:
info = plistlib.readPlist(self.infoplist) info = plistlib.readPlist(self.infoplist)
self.executablename = info['CFBundleExecutable'] self.executablename = info['CFBundleExecutable']
self.executablepath = op.join(self.contents, self.executablename) self.executablepath = op.join(self.contents, self.executablename)
def create(self, infoplist): def create(self, infoplist):
ensure_empty_folder(self.dest) ensure_empty_folder(self.dest)
os.makedirs(self.contents) os.makedirs(self.contents)
@@ -354,7 +364,7 @@ class OSXFrameworkStructure:
os.mkdir(self.headers) os.mkdir(self.headers)
copy(infoplist, self.infoplist) copy(infoplist, self.infoplist)
self._update_executable_path() self._update_executable_path()
def create_symlinks(self): def create_symlinks(self):
# Only call this after create() and copy_executable() # Only call this after create() and copy_executable()
rel = lambda path: op.relpath(path, self.dest) rel = lambda path: op.relpath(path, self.dest)
@@ -362,22 +372,22 @@ class OSXFrameworkStructure:
os.symlink(rel(self.executablepath), op.join(self.dest, self.executablename)) os.symlink(rel(self.executablepath), op.join(self.dest, self.executablename))
os.symlink(rel(self.headers), op.join(self.dest, 'Headers')) os.symlink(rel(self.headers), op.join(self.dest, 'Headers'))
os.symlink(rel(self.resources), op.join(self.dest, 'Resources')) os.symlink(rel(self.resources), op.join(self.dest, 'Resources'))
def copy_executable(self, executable): def copy_executable(self, executable):
copy(executable, self.executablepath) copy(executable, self.executablepath)
def copy_resources(self, *resources, use_symlinks=False): def copy_resources(self, *resources, use_symlinks=False):
for path in resources: for path in resources:
resource_dest = op.join(self.resources, op.basename(path)) resource_dest = op.join(self.resources, op.basename(path))
action = symlink if use_symlinks else copy action = symlink if use_symlinks else copy
action(op.abspath(path), resource_dest) action(op.abspath(path), resource_dest)
def copy_headers(self, *headers, use_symlinks=False): def copy_headers(self, *headers, use_symlinks=False):
for path in headers: for path in headers:
header_dest = op.join(self.headers, op.basename(path)) header_dest = op.join(self.headers, op.basename(path))
action = symlink if use_symlinks else copy action = symlink if use_symlinks else copy
action(op.abspath(path), header_dest) action(op.abspath(path), header_dest)
def build_cocoalib_xibless(dest='cocoa/autogen'): def build_cocoalib_xibless(dest='cocoa/autogen'):
import xibless import xibless
@@ -415,7 +425,7 @@ def collect_stdlib_dependencies(script, dest_folder, extra_deps=None):
if not (path.startswith(sysprefix) or path.startswith(real_lib_prefix)): if not (path.startswith(sysprefix) or path.startswith(real_lib_prefix)):
return False return False
return True return True
ensure_folder(dest_folder) ensure_folder(dest_folder)
mf = modulefinder.ModuleFinder() mf = modulefinder.ModuleFinder()
mf.run_script(script) mf.run_script(script)

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2013-10-12 # Created On: 2013-10-12
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import os.path as op import os.path as op
@@ -30,10 +30,10 @@ def reveal_path(path):
def special_folder_path(special_folder, appname=None): def special_folder_path(special_folder, appname=None):
"""Returns the path of ``special_folder``. """Returns the path of ``special_folder``.
``special_folder`` is a SpecialFolder.* const. The result is the special folder for the current ``special_folder`` is a SpecialFolder.* const. The result is the special folder for the current
application. The running process' application info is used to determine relevant information. application. The running process' application info is used to determine relevant information.
You can override the application name with ``appname``. This argument is ingored under Qt. You can override the application name with ``appname``. This argument is ingored under Qt.
""" """
return _special_folder_path(special_folder, appname) return _special_folder_path(special_folder, appname)
@@ -49,7 +49,7 @@ try:
_open_url = proxy.openURL_ _open_url = proxy.openURL_
_open_path = proxy.openPath_ _open_path = proxy.openPath_
_reveal_path = proxy.revealPath_ _reveal_path = proxy.revealPath_
def _special_folder_path(special_folder, appname=None): def _special_folder_path(special_folder, appname=None):
if special_folder == SpecialFolder.Cache: if special_folder == SpecialFolder.Cache:
base = proxy.getCachePath() base = proxy.getCachePath()
@@ -58,7 +58,7 @@ try:
if not appname: if not appname:
appname = proxy.bundleInfo_('CFBundleName') appname = proxy.bundleInfo_('CFBundleName')
return op.join(base, appname) return op.join(base, appname)
except ImportError: except ImportError:
try: try:
from PyQt5.QtCore import QUrl, QStandardPaths from PyQt5.QtCore import QUrl, QStandardPaths
@@ -69,26 +69,25 @@ except ImportError:
def _open_path(path): def _open_path(path):
url = QUrl.fromLocalFile(str(path)) url = QUrl.fromLocalFile(str(path))
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)
def _reveal_path(path): def _reveal_path(path):
_open_path(op.dirname(str(path))) _open_path(op.dirname(str(path)))
def _special_folder_path(special_folder, appname=None): def _special_folder_path(special_folder, appname=None):
if special_folder == SpecialFolder.Cache: if special_folder == SpecialFolder.Cache:
qtfolder = QStandardPaths.CacheLocation qtfolder = QStandardPaths.CacheLocation
else: else:
qtfolder = QStandardPaths.DataLocation qtfolder = QStandardPaths.DataLocation
return QStandardPaths.standardLocations(qtfolder)[0] return QStandardPaths.standardLocations(qtfolder)[0]
except ImportError: except ImportError:
# We're either running tests, and these functions don't matter much or we're in a really # We're either running tests, and these functions don't matter much or we're in a really
# weird situation. Let's just have dummy fallbacks. # weird situation. Let's just have dummy fallbacks.
logging.warning("Can't setup desktop functions!") logging.warning("Can't setup desktop functions!")
def _open_path(path): def _open_path(path):
pass pass
def _reveal_path(path): def _reveal_path(path):
pass pass
def _special_folder_path(special_folder, appname=None): def _special_folder_path(special_folder, appname=None):
return '/tmp' return '/tmp'

View File

@@ -1,61 +1,58 @@
# Created On: 2013/07/01 # Created On: 2013/07/01
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from jobprogress.performer import ThreadedJobPerformer from ..jobprogress.performer import ThreadedJobPerformer
from .base import GUIObject from .base import GUIObject
from .text_field import TextField from .text_field import TextField
class ProgressWindowView: class ProgressWindowView:
"""Expected interface for :class:`ProgressWindow`'s view. """Expected interface for :class:`ProgressWindow`'s view.
*Not actually used in the code. For documentation purposes only.* *Not actually used in the code. For documentation purposes only.*
Our view, some kind window with a progress bar, two labels and a cancel button, is expected Our view, some kind window with a progress bar, two labels and a cancel button, is expected
to properly respond to its callbacks. to properly respond to its callbacks.
It's also expected to call :meth:`ProgressWindow.cancel` when the cancel button is clicked. It's also expected to call :meth:`ProgressWindow.cancel` when the cancel button is clicked.
""" """
def show(self): def show(self):
"""Show the dialog. """Show the dialog.
""" """
def close(self): def close(self):
"""Close the dialog. """Close the dialog.
""" """
def set_progress(self, progress): def set_progress(self, progress):
"""Set the progress of the progress bar to ``progress``. """Set the progress of the progress bar to ``progress``.
Not all jobs are equally responsive on their job progress report and it is recommended that Not all jobs are equally responsive on their job progress report and it is recommended that
you put your progressbar in "indeterminate" mode as long as you haven't received the first you put your progressbar in "indeterminate" mode as long as you haven't received the first
``set_progress()`` call to avoid letting the user think that the app is frozen. ``set_progress()`` call to avoid letting the user think that the app is frozen.
:param int progress: a value between ``0`` and ``100``. :param int progress: a value between ``0`` and ``100``.
""" """
class ProgressWindow(GUIObject, ThreadedJobPerformer): class ProgressWindow(GUIObject, ThreadedJobPerformer):
"""Cross-toolkit GUI-enabled progress window. """Cross-toolkit GUI-enabled progress window.
This class allows you to run a long running, `job enabled`_ function in a separate thread and This class allows you to run a long running, job enabled function in a separate thread and
allow the user to follow its progress with a progress dialog. allow the user to follow its progress with a progress dialog.
To use it, you start your long-running job with :meth:`run` and then have your UI layer To use it, you start your long-running job with :meth:`run` and then have your UI layer
regularly call :meth:`pulse` to refresh the job status in the UI. It is advised that you call regularly call :meth:`pulse` to refresh the job status in the UI. It is advised that you call
:meth:`pulse` in the main thread because GUI toolkit usually only support calling UI-related :meth:`pulse` in the main thread because GUI toolkit usually only support calling UI-related
functions from the main thread. functions from the main thread.
We subclass :class:`.GUIObject` and ``ThreadedJobPerformer`` (from the ``jobprogress`` library). We subclass :class:`.GUIObject` and :class:`.ThreadedJobPerformer`.
Expected view: :class:`ProgressWindowView`. Expected view: :class:`ProgressWindowView`.
:param finishfunc: A function ``f(jobid)`` that is called when a job is completed. ``jobid`` is :param finishfunc: A function ``f(jobid)`` that is called when a job is completed. ``jobid`` is
an arbitrary id passed to :meth:`run`. an arbitrary id passed to :meth:`run`.
.. _job enabled: https://pypi.python.org/pypi/jobprogress
""" """
def __init__(self, finish_func): def __init__(self, finish_func):
# finish_func(jobid) is the function that is called when a job is completed. # finish_func(jobid) is the function that is called when a job is completed.
@@ -68,7 +65,7 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
#: during its course. #: during its course.
self.progressdesc_textfield = TextField() self.progressdesc_textfield = TextField()
self.jobid = None self.jobid = None
def cancel(self): def cancel(self):
"""Call for a user-initiated job cancellation. """Call for a user-initiated job cancellation.
""" """
@@ -77,13 +74,13 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
# we verify that the job is still running. # we verify that the job is still running.
if self._job_running: if self._job_running:
self.job_cancelled = True self.job_cancelled = True
def pulse(self): def pulse(self):
"""Update progress reports in the GUI. """Update progress reports in the GUI.
Call this regularly from the GUI main run loop. The values might change before Call this regularly from the GUI main run loop. The values might change before
:meth:`ProgressWindowView.set_progress` happens. :meth:`ProgressWindowView.set_progress` happens.
If the job is finished, ``pulse()`` will take care of closing the window and re-raising any If the job is finished, ``pulse()`` will take care of closing the window and re-raising any
exception that might have been raised during the job (in the main thread this time). If exception that might have been raised during the job (in the main thread this time). If
there was no exception, ``finish_func(jobid)`` is called to let you take appropriate action. there was no exception, ``finish_func(jobid)`` is called to let you take appropriate action.
@@ -101,13 +98,13 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
if last_desc: if last_desc:
self.progressdesc_textfield.text = last_desc self.progressdesc_textfield.text = last_desc
self.view.set_progress(last_progress) self.view.set_progress(last_progress)
def run(self, jobid, title, target, args=()): def run(self, jobid, title, target, args=()):
"""Starts a threaded job. """Starts a threaded job.
The ``target`` function will be sent, as its first argument, a ``Job`` instance (from the The ``target`` function will be sent, as its first argument, a :class:`.Job` instance which
``jobprogress`` library) which it can use to report on its progress. it can use to report on its progress.
:param jobid: Arbitrary identifier which will be passed to ``finish_func()`` at the end. :param jobid: Arbitrary identifier which will be passed to ``finish_func()`` at the end.
:param title: A title for the task you're starting. :param title: A title for the task you're starting.
:param target: The function that does your famous long running job. :param target: The function that does your famous long running job.
@@ -122,4 +119,4 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
self.run_threaded(target, args) self.run_threaded(target, args)
self.jobdesc_textfield.text = title self.jobdesc_textfield.text = title
self.view.show() self.view.show()

View File

166
hscommon/jobprogress/job.py Normal file
View File

@@ -0,0 +1,166 @@
# Created By: Virgil Dupras
# Created On: 2004/12/20
# Copyright 2011 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
class JobCancelled(Exception):
"The user has cancelled the job"
class JobInProgressError(Exception):
"A job is already being performed, you can't perform more than one at the same time."
class JobCountError(Exception):
"The number of jobs started have exceeded the number of jobs allowed"
class Job:
"""Manages a job's progression and return it's progression through a callback.
Note that this class is not foolproof. For example, you could call
start_subjob, and then call add_progress from the parent job, and nothing
would stop you from doing it. However, it would mess your progression
because it is the sub job that is supposed to drive the progression.
Another example would be to start a subjob, then start another, and call
add_progress from the old subjob. Once again, it would mess your progression.
There are no stops because it would remove the lightweight aspect of the
class (A Job would need to have a Parent instead of just a callback,
and the parent could be None. A lot of checks for nothing.).
Another one is that nothing stops you from calling add_progress right after
SkipJob.
"""
#---Magic functions
def __init__(self, job_proportions, callback):
"""Initialize the Job with 'jobcount' jobs. Start every job with
start_job(). Every time the job progress is updated, 'callback' is called
'callback' takes a 'progress' int param, and a optional 'desc'
parameter. Callback must return false if the job must be cancelled.
"""
if not hasattr(callback, '__call__'):
raise TypeError("'callback' MUST be set when creating a Job")
if isinstance(job_proportions, int):
job_proportions = [1] * job_proportions
self._job_proportions = list(job_proportions)
self._jobcount = sum(job_proportions)
self._callback = callback
self._current_job = 0
self._passed_jobs = 0
self._progress = 0
self._currmax = 1
#---Private
def _subjob_callback(self, progress, desc=''):
"""This is the callback passed to children jobs.
"""
self.set_progress(progress, desc)
return True #if JobCancelled has to be raised, it will be at the highest level
def _do_update(self, desc):
"""Calls the callback function with a % progress as a parameter.
The parameter is a int in the 0-100 range.
"""
if self._current_job:
passed_progress = self._passed_jobs * self._currmax
current_progress = self._current_job * self._progress
total_progress = self._jobcount * self._currmax
progress = ((passed_progress + current_progress) * 100) // total_progress
else:
progress = -1 # indeterminate
# It's possible that callback doesn't support a desc arg
result = self._callback(progress, desc) if desc else self._callback(progress)
if not result:
raise JobCancelled()
#---Public
def add_progress(self, progress=1, desc=''):
self.set_progress(self._progress + progress, desc)
def check_if_cancelled(self):
self._do_update('')
def iter_with_progress(self, iterable, desc_format=None, every=1, count=None):
"""Iterate through ``iterable`` while automatically adding progress.
WARNING: We need our iterable's length. If ``iterable`` is not a sequence (that is,
something we can call ``len()`` on), you *have* to specify a count through the ``count``
argument. If ``count`` is ``None``, ``len(iterable)`` is used.
"""
if count is None:
count = len(iterable)
desc = ''
if desc_format:
desc = desc_format % (0, count)
self.start_job(count, desc)
for i, element in enumerate(iterable, start=1):
yield element
if i % every == 0:
if desc_format:
desc = desc_format % (i, count)
self.add_progress(progress=every, desc=desc)
if desc_format:
desc = desc_format % (count, count)
self.set_progress(100, desc)
def start_job(self, max_progress=100, desc=''):
"""Begin work on the next job. You must not call start_job more than
'jobcount' (in __init__) times.
'max' is the job units you are to perform.
'desc' is the description of the job.
"""
self._passed_jobs += self._current_job
try:
self._current_job = self._job_proportions.pop(0)
except IndexError:
raise JobCountError()
self._progress = 0
self._currmax = max(1, max_progress)
self._do_update(desc)
def start_subjob(self, job_proportions, desc=''):
"""Starts a sub job. Use this when you want to split a job into
multiple smaller jobs. Pretty handy when starting a process where you
know how many subjobs you will have, but don't know the work unit count
for every of them.
returns the Job object
"""
self.start_job(100, desc)
return Job(job_proportions, self._subjob_callback)
def set_progress(self, progress, desc=''):
"""Sets the progress of the current job to 'progress', and call the
callback
"""
self._progress = progress
if self._progress > self._currmax:
self._progress = self._currmax
if self._progress < 0:
self._progress = 0
self._do_update(desc)
class NullJob:
def __init__(self, *args, **kwargs):
pass
def add_progress(self, *args, **kwargs):
pass
def check_if_cancelled(self):
pass
def iter_with_progress(self, sequence, *args, **kwargs):
return iter(sequence)
def start_job(self, *args, **kwargs):
pass
def start_subjob(self, *args, **kwargs):
return NullJob()
def set_progress(self, *args, **kwargs):
pass
nulljob = NullJob()

View File

@@ -0,0 +1,72 @@
# Created By: Virgil Dupras
# Created On: 2010-11-19
# Copyright 2011 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
from threading import Thread
import sys
from .job import Job, JobInProgressError, JobCancelled
class ThreadedJobPerformer:
"""Run threaded jobs and track progress.
To run a threaded job, first create a job with _create_job(), then call _run_threaded(), with
your work function as a parameter.
Example:
j = self._create_job()
self._run_threaded(self.some_work_func, (arg1, arg2, j))
"""
_job_running = False
last_error = None
#--- Protected
def create_job(self):
if self._job_running:
raise JobInProgressError()
self.last_progress = -1
self.last_desc = ''
self.job_cancelled = False
return Job(1, self._update_progress)
def _async_run(self, *args):
target = args[0]
args = tuple(args[1:])
self._job_running = True
self.last_error = None
try:
target(*args)
except JobCancelled:
pass
except Exception as e:
self.last_error = e
self.last_traceback = sys.exc_info()[2]
finally:
self._job_running = False
self.last_progress = None
def reraise_if_error(self):
"""Reraises the error that happened in the thread if any.
Call this after the caller of run_threaded detected that self._job_running returned to False
"""
if self.last_error is not None:
raise self.last_error.with_traceback(self.last_traceback)
def _update_progress(self, newprogress, newdesc=''):
self.last_progress = newprogress
if newdesc:
self.last_desc = newdesc
return not self.job_cancelled
def run_threaded(self, target, args=()):
if self._job_running:
raise JobInProgressError()
args = (target, ) + args
Thread(target=self._async_run, args=args).start()

View File

@@ -0,0 +1,52 @@
# Created By: Virgil Dupras
# Created On: 2009-09-14
# Copyright 2011 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
from PyQt5.QtCore import pyqtSignal, Qt, QTimer
from PyQt5.QtWidgets import QProgressDialog
from . import performer
class Progress(QProgressDialog, performer.ThreadedJobPerformer):
finished = pyqtSignal(['QString'])
def __init__(self, parent):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
QProgressDialog.__init__(self, '', "Cancel", 0, 100, parent, flags)
self.setModal(True)
self.setAutoReset(False)
self.setAutoClose(False)
self._timer = QTimer()
self._jobid = ''
self._timer.timeout.connect(self.updateProgress)
def updateProgress(self):
# the values might change before setValue happens
last_progress = self.last_progress
last_desc = self.last_desc
if not self._job_running or last_progress is None:
self._timer.stop()
self.close()
if not self.job_cancelled:
self.finished.emit(self._jobid)
return
if self.wasCanceled():
self.job_cancelled = True
return
if last_desc:
self.setLabelText(last_desc)
self.setValue(last_progress)
def run(self, jobid, title, target, args=()):
self._jobid = jobid
self.reset()
self.setLabelText('')
self.run_threaded(target, args)
self.setWindowTitle(title)
self.show()
self._timer.start(500)

0
hscommon/path.py Executable file → Normal file
View File

View File

@@ -1,179 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2009-05-16
# 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
import re
from hashlib import md5
from . import desktop
from .trans import trget
tr = trget('hscommon')
ALL_APPS = [
(1, 'dupeGuru'),
(2, 'moneyGuru'),
(3, 'musicGuru'),
(6, 'PdfMasher'),
]
OLDAPPIDS = {
1: {1, 4, 5},
2: {6, },
3: {2, },
}
class InvalidCodeError(Exception):
"""The supplied code is invalid."""
DEMO_PROMPT = tr("{name} is fairware, which means \"open source software developed with expectation "
"of fair contributions from users\". It's a very interesting concept, but one year of fairware has "
"shown that most people just want to know how much it costs and not be bothered with theories "
"about intellectual property."
"\n\n"
"So I won't bother you and will be very straightforward: You can try {name} for free but you have "
"to buy it in order to use it without limitations. In demo mode, {name} {limitation}."
"\n\n"
"So it's as simple as this. If you're curious about fairware, however, I encourage you to read "
"more about it by clicking on the \"Fairware?\" button.")
class RegistrableApplication:
#--- View interface
# get_default(key_name)
# set_default(key_name, value)
# setup_as_registered()
# show_message(msg)
# show_demo_nag(prompt)
PROMPT_NAME = "<undefined>"
DEMO_LIMITATION = "<undefined>"
def __init__(self, view, appid):
self.view = view
self.appid = appid
self.registered = False
self.fairware_mode = False
self.registration_code = ''
self.registration_email = ''
self._unpaid_hours = None
@staticmethod
def _is_code_valid(appid, code, email):
if len(code) != 32:
return False
appid = str(appid)
for i in range(100):
blob = appid + email + str(i) + 'aybabtu'
digest = md5(blob.encode('utf-8')).hexdigest()
if digest == code:
return True
return False
def _set_registration(self, code, email):
self.validate_code(code, email)
self.registration_code = code
self.registration_email = email
self.registered = True
self.view.setup_as_registered()
def initial_registration_setup(self):
# Should be called only after the app is finished launching
if self.registered:
# We've already set registration in a hardcoded way (for example, for the Ubuntu Store)
# Just ignore registration, but not before having set as registered.
self.view.setup_as_registered()
return
code = self.view.get_default('RegistrationCode')
email = self.view.get_default('RegistrationEmail')
if code and email:
try:
self._set_registration(code, email)
except InvalidCodeError:
pass
if not self.registered:
if self.view.get_default('FairwareMode'):
self.fairware_mode = True
if not self.fairware_mode:
prompt = DEMO_PROMPT.format(name=self.PROMPT_NAME, limitation=self.DEMO_LIMITATION)
self.view.show_demo_nag(prompt)
def validate_code(self, code, email):
code = code.strip().lower()
email = email.strip().lower()
if self._is_code_valid(self.appid, code, email):
return
# Check if it's not an old reg code
for oldappid in OLDAPPIDS.get(self.appid, []):
if self._is_code_valid(oldappid, code, email):
return
# let's see if the user didn't mix the fields up
if self._is_code_valid(self.appid, email, code):
msg = "Invalid Code. It seems like you inverted the 'Registration Code' and"\
"'Registration E-mail' field."
raise InvalidCodeError(msg)
# Is the code a paypal transaction id?
if re.match(r'^[a-z\d]{17}$', code) is not None:
msg = "The code you submitted looks like a Paypal transaction ID. Registration codes are "\
"32 digits codes which you should have received in a separate e-mail. If you haven't "\
"received it yet, please visit http://www.hardcoded.net/support/"
raise InvalidCodeError(msg)
# Invalid, let's see if it's a code for another app.
for appid, appname in ALL_APPS:
if self._is_code_valid(appid, code, email):
msg = "This code is a {0} code. You're running the wrong application. You can "\
"download the correct application at http://www.hardcoded.net".format(appname)
raise InvalidCodeError(msg)
DEFAULT_MSG = "Your code is invalid. Make sure that you wrote the good code. Also make sure "\
"that the e-mail you gave is the same as the e-mail you used for your purchase."
raise InvalidCodeError(DEFAULT_MSG)
def set_registration(self, code, email, register_os):
if not self.fairware_mode and 'fairware' in {code.strip().lower(), email.strip().lower()}:
self.fairware_mode = True
self.view.set_default('FairwareMode', True)
self.view.show_message("Fairware mode enabled.")
return True
try:
self._set_registration(code, email)
self.view.show_message("Your code is valid. Thanks!")
if register_os:
self.register_os()
self.view.set_default('RegistrationCode', self.registration_code)
self.view.set_default('RegistrationEmail', self.registration_email)
return True
except InvalidCodeError as e:
self.view.show_message(str(e))
return False
def register_os(self):
# We don't do that anymore.
pass
def contribute(self):
desktop.open_url("http://open.hardcoded.net/contribute/")
def buy(self):
desktop.open_url("http://www.hardcoded.net/purchase.htm")
def about_fairware(self):
desktop.open_url("http://open.hardcoded.net/about/")
@property
def should_show_fairware_reminder(self):
return (not self.registered) and (self.fairware_mode) and (self.unpaid_hours >= 1)
@property
def should_apply_demo_limitation(self):
return (not self.registered) and (not self.fairware_mode)
@property
def unpaid_hours(self):
# We don't bother verifying unpaid hours anymore, the only app that still has fairware
# dialogs is dupeGuru and it has a huge surplus. Now, "fairware mode" really means
# "free mode".
return 0

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-01-12 # Created On: 2011-01-12
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import os.path as op import os.path as op
@@ -31,7 +31,7 @@ def tixgen(tixurl):
def gen(basepath, destpath, changelogpath, tixurl, confrepl=None, confpath=None, changelogtmpl=None): def gen(basepath, destpath, changelogpath, tixurl, confrepl=None, confpath=None, changelogtmpl=None):
"""Generate sphinx docs with all bells and whistles. """Generate sphinx docs with all bells and whistles.
basepath: The base sphinx source path. basepath: The base sphinx source path.
destpath: The final path of html files destpath: The final path of html files
changelogpath: The path to the changelog file to insert in changelog.rst. changelogpath: The path to the changelog file to insert in changelog.rst.
@@ -66,4 +66,8 @@ def gen(basepath, destpath, changelogpath, tixurl, confrepl=None, confpath=None,
# missing dependencies which are in the virtualenv). Here, we do exactly what is done when # missing dependencies which are in the virtualenv). Here, we do exactly what is done when
# calling the command from bash. # calling the command from bash.
cmd = load_entry_point('Sphinx', 'console_scripts', 'sphinx-build') cmd = load_entry_point('Sphinx', 'console_scripts', 'sphinx-build')
cmd(['sphinx-build', basepath, destpath]) try:
cmd(['sphinx-build', basepath, destpath])
except SystemExit:
print("Sphinx called sys.exit(), but we're cancelling it because we don't actually want to exit")

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-01-11 # Created On: 2011-01-11
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from io import StringIO from io import StringIO
@@ -65,6 +65,12 @@ def test_trailiter():
eq_(list(trailiter(['foo', 'bar'], skipfirst=True)), [('foo', 'bar')]) eq_(list(trailiter(['foo', 'bar'], skipfirst=True)), [('foo', 'bar')])
eq_(list(trailiter([], skipfirst=True)), []) # no crash eq_(list(trailiter([], skipfirst=True)), []) # no crash
def test_iterconsume():
# We just want to make sure that we return *all* items and that we're not mistakenly skipping
# one.
eq_(list(range(2500)), list(iterconsume(list(range(2500)))))
eq_(list(reversed(range(2500))), list(iterconsume(list(range(2500)), reverse=False)))
#--- String #--- String
def test_escape(): def test_escape():
@@ -188,63 +194,63 @@ class TestCase_modified_after:
monkeyplus.patch_osstat('first', st_mtime=42) monkeyplus.patch_osstat('first', st_mtime=42)
monkeyplus.patch_osstat('second', st_mtime=41) monkeyplus.patch_osstat('second', st_mtime=41)
assert modified_after('first', 'second') assert modified_after('first', 'second')
def test_second_is_modified_after(self, monkeyplus): def test_second_is_modified_after(self, monkeyplus):
monkeyplus.patch_osstat('first', st_mtime=42) monkeyplus.patch_osstat('first', st_mtime=42)
monkeyplus.patch_osstat('second', st_mtime=43) monkeyplus.patch_osstat('second', st_mtime=43)
assert not modified_after('first', 'second') assert not modified_after('first', 'second')
def test_same_mtime(self, monkeyplus): def test_same_mtime(self, monkeyplus):
monkeyplus.patch_osstat('first', st_mtime=42) monkeyplus.patch_osstat('first', st_mtime=42)
monkeyplus.patch_osstat('second', st_mtime=42) monkeyplus.patch_osstat('second', st_mtime=42)
assert not modified_after('first', 'second') assert not modified_after('first', 'second')
def test_first_file_does_not_exist(self, monkeyplus): def test_first_file_does_not_exist(self, monkeyplus):
# when the first file doesn't exist, we return False # when the first file doesn't exist, we return False
monkeyplus.patch_osstat('second', st_mtime=42) monkeyplus.patch_osstat('second', st_mtime=42)
assert not modified_after('does_not_exist', 'second') # no crash assert not modified_after('does_not_exist', 'second') # no crash
def test_second_file_does_not_exist(self, monkeyplus): def test_second_file_does_not_exist(self, monkeyplus):
# when the second file doesn't exist, we return True # when the second file doesn't exist, we return True
monkeyplus.patch_osstat('first', st_mtime=42) monkeyplus.patch_osstat('first', st_mtime=42)
assert modified_after('first', 'does_not_exist') # no crash assert modified_after('first', 'does_not_exist') # no crash
def test_first_file_is_none(self, monkeyplus): def test_first_file_is_none(self, monkeyplus):
# when the first file is None, we return False # when the first file is None, we return False
monkeyplus.patch_osstat('second', st_mtime=42) monkeyplus.patch_osstat('second', st_mtime=42)
assert not modified_after(None, 'second') # no crash assert not modified_after(None, 'second') # no crash
def test_second_file_is_none(self, monkeyplus): def test_second_file_is_none(self, monkeyplus):
# when the second file is None, we return True # when the second file is None, we return True
monkeyplus.patch_osstat('first', st_mtime=42) monkeyplus.patch_osstat('first', st_mtime=42)
assert modified_after('first', None) # no crash assert modified_after('first', None) # no crash
class TestCase_delete_if_empty: class TestCase_delete_if_empty:
def test_is_empty(self, tmpdir): def test_is_empty(self, tmpdir):
testpath = Path(str(tmpdir)) testpath = Path(str(tmpdir))
assert delete_if_empty(testpath) assert delete_if_empty(testpath)
assert not testpath.exists() assert not testpath.exists()
def test_not_empty(self, tmpdir): def test_not_empty(self, tmpdir):
testpath = Path(str(tmpdir)) testpath = Path(str(tmpdir))
testpath['foo'].mkdir() testpath['foo'].mkdir()
assert not delete_if_empty(testpath) assert not delete_if_empty(testpath)
assert testpath.exists() assert testpath.exists()
def test_with_files_to_delete(self, tmpdir): def test_with_files_to_delete(self, tmpdir):
testpath = Path(str(tmpdir)) testpath = Path(str(tmpdir))
testpath['foo'].open('w') testpath['foo'].open('w')
testpath['bar'].open('w') testpath['bar'].open('w')
assert delete_if_empty(testpath, ['foo', 'bar']) assert delete_if_empty(testpath, ['foo', 'bar'])
assert not testpath.exists() assert not testpath.exists()
def test_directory_in_files_to_delete(self, tmpdir): def test_directory_in_files_to_delete(self, tmpdir):
testpath = Path(str(tmpdir)) testpath = Path(str(tmpdir))
testpath['foo'].mkdir() testpath['foo'].mkdir()
assert not delete_if_empty(testpath, ['foo']) assert not delete_if_empty(testpath, ['foo'])
assert testpath.exists() assert testpath.exists()
def test_delete_files_to_delete_only_if_dir_is_empty(self, tmpdir): def test_delete_files_to_delete_only_if_dir_is_empty(self, tmpdir):
testpath = Path(str(tmpdir)) testpath = Path(str(tmpdir))
testpath['foo'].open('w') testpath['foo'].open('w')
@@ -252,25 +258,25 @@ class TestCase_delete_if_empty:
assert not delete_if_empty(testpath, ['foo']) assert not delete_if_empty(testpath, ['foo'])
assert testpath.exists() assert testpath.exists()
assert testpath['foo'].exists() assert testpath['foo'].exists()
def test_doesnt_exist(self): def test_doesnt_exist(self):
# When the 'path' doesn't exist, just do nothing. # When the 'path' doesn't exist, just do nothing.
delete_if_empty(Path('does_not_exist')) # no crash delete_if_empty(Path('does_not_exist')) # no crash
def test_is_file(self, tmpdir): def test_is_file(self, tmpdir):
# When 'path' is a file, do nothing. # When 'path' is a file, do nothing.
p = Path(str(tmpdir)) + 'filename' p = Path(str(tmpdir)) + 'filename'
p.open('w').close() p.open('w').close()
delete_if_empty(p) # no crash delete_if_empty(p) # no crash
def test_ioerror(self, tmpdir, monkeypatch): def test_ioerror(self, tmpdir, monkeypatch):
# if an IO error happens during the operation, ignore it. # if an IO error happens during the operation, ignore it.
def do_raise(*args, **kw): def do_raise(*args, **kw):
raise OSError() raise OSError()
monkeypatch.setattr(Path, 'rmdir', do_raise) monkeypatch.setattr(Path, 'rmdir', do_raise)
delete_if_empty(Path(str(tmpdir))) # no crash delete_if_empty(Path(str(tmpdir))) # no crash
class TestCase_open_if_filename: class TestCase_open_if_filename:
def test_file_name(self, tmpdir): def test_file_name(self, tmpdir):
@@ -280,7 +286,7 @@ class TestCase_open_if_filename:
assert close assert close
eq_(b'test_data', file.read()) eq_(b'test_data', file.read())
file.close() file.close()
def test_opened_file(self): def test_opened_file(self):
sio = StringIO() sio = StringIO()
sio.write('test_data') sio.write('test_data')
@@ -288,14 +294,14 @@ class TestCase_open_if_filename:
file, close = open_if_filename(sio) file, close = open_if_filename(sio)
assert not close assert not close
eq_('test_data', file.read()) eq_('test_data', file.read())
def test_mode_is_passed_to_open(self, tmpdir): def test_mode_is_passed_to_open(self, tmpdir):
filepath = str(tmpdir.join('test.txt')) filepath = str(tmpdir.join('test.txt'))
open(filepath, 'w').close() open(filepath, 'w').close()
file, close = open_if_filename(filepath, 'a') file, close = open_if_filename(filepath, 'a')
eq_('a', file.mode) eq_('a', file.mode)
file.close() file.close()
class TestCase_FileOrPath: class TestCase_FileOrPath:
def test_path(self, tmpdir): def test_path(self, tmpdir):
@@ -303,17 +309,17 @@ class TestCase_FileOrPath:
open(filepath, 'wb').write(b'test_data') open(filepath, 'wb').write(b'test_data')
with FileOrPath(filepath) as fp: with FileOrPath(filepath) as fp:
eq_(b'test_data', fp.read()) eq_(b'test_data', fp.read())
def test_opened_file(self): def test_opened_file(self):
sio = StringIO() sio = StringIO()
sio.write('test_data') sio.write('test_data')
sio.seek(0) sio.seek(0)
with FileOrPath(sio) as fp: with FileOrPath(sio) as fp:
eq_('test_data', fp.read()) eq_('test_data', fp.read())
def test_mode_is_passed_to_open(self, tmpdir): def test_mode_is_passed_to_open(self, tmpdir):
filepath = str(tmpdir.join('test.txt')) filepath = str(tmpdir.join('test.txt'))
open(filepath, 'w').close() open(filepath, 'w').close()
with FileOrPath(filepath, 'a') as fp: with FileOrPath(filepath, 'a') as fp:
eq_('a', fp.mode) eq_('a', fp.mode)

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-01-11 # Created On: 2011-01-11
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys import sys
@@ -42,7 +42,7 @@ def minmax(value, min_value, max_value):
def dedupe(iterable): def dedupe(iterable):
"""Returns a list of elements in ``iterable`` with all dupes removed. """Returns a list of elements in ``iterable`` with all dupes removed.
The order of the elements is preserved. The order of the elements is preserved.
""" """
result = [] result = []
@@ -56,7 +56,7 @@ def dedupe(iterable):
def flatten(iterables, start_with=None): def flatten(iterables, start_with=None):
"""Takes a list of lists ``iterables`` and returns a list containing elements of every list. """Takes a list of lists ``iterables`` and returns a list containing elements of every list.
If ``start_with`` is not ``None``, the result will start with ``start_with`` items, exactly as If ``start_with`` is not ``None``, the result will start with ``start_with`` items, exactly as
if ``start_with`` would be the first item of lists. if ``start_with`` would be the first item of lists.
""" """
@@ -104,7 +104,7 @@ def allsame(iterable):
def trailiter(iterable, skipfirst=False): def trailiter(iterable, skipfirst=False):
"""Yields (prev_element, element), starting with (None, first_element). """Yields (prev_element, element), starting with (None, first_element).
If skipfirst is True, there will be no (None, item1) element and we'll start If skipfirst is True, there will be no (None, item1) element and we'll start
directly with (item1, item2). directly with (item1, item2).
""" """
@@ -117,6 +117,21 @@ def trailiter(iterable, skipfirst=False):
yield prev, item yield prev, item
prev = item prev = item
def iterconsume(seq, reverse=True):
"""Iterate over ``seq`` and pops yielded objects.
Because we use the ``pop()`` method, we reverse ``seq`` before proceeding. If you don't need
to do that, set ``reverse`` to ``False``.
This is useful in tight memory situation where you are looping over a sequence of objects that
are going to be discarded afterwards. If you're creating other objects during that iteration
you might want to use this to avoid ``MemoryError``.
"""
if reverse:
seq.reverse()
while seq:
yield seq.pop()
#--- String related #--- String related
def escape(s, to_escape, escape_with='\\'): def escape(s, to_escape, escape_with='\\'):
@@ -144,7 +159,7 @@ def rem_file_ext(filename):
def pluralize(number, word, decimals=0, plural_word=None): def pluralize(number, word, decimals=0, plural_word=None):
"""Returns a pluralized string with ``number`` in front of ``word``. """Returns a pluralized string with ``number`` in front of ``word``.
Adds a 's' to s if ``number`` > 1. Adds a 's' to s if ``number`` > 1.
``number``: The number to go in front of s ``number``: The number to go in front of s
``word``: The word to go after number ``word``: The word to go after number
@@ -162,7 +177,7 @@ def pluralize(number, word, decimals=0, plural_word=None):
def format_time(seconds, with_hours=True): def format_time(seconds, with_hours=True):
"""Transforms seconds in a hh:mm:ss string. """Transforms seconds in a hh:mm:ss string.
If ``with_hours`` if false, the format is mm:ss. If ``with_hours`` if false, the format is mm:ss.
""" """
minus = seconds < 0 minus = seconds < 0
@@ -202,14 +217,14 @@ SIZE_DESC = ('B','KB','MB','GB','TB','PB','EB','ZB','YB')
SIZE_VALS = tuple(1024 ** i for i in range(1,9)) SIZE_VALS = tuple(1024 ** i for i in range(1,9))
def format_size(size, decimal=0, forcepower=-1, showdesc=True): def format_size(size, decimal=0, forcepower=-1, showdesc=True):
"""Transform a byte count in a formatted string (KB, MB etc..). """Transform a byte count in a formatted string (KB, MB etc..).
``size`` is the number of bytes to format. ``size`` is the number of bytes to format.
``decimal`` is the number digits after the dot. ``decimal`` is the number digits after the dot.
``forcepower`` is the desired suffix. 0 is B, 1 is KB, 2 is MB etc.. if kept at -1, the suffix ``forcepower`` is the desired suffix. 0 is B, 1 is KB, 2 is MB etc.. if kept at -1, the suffix
will be automatically chosen (so the resulting number is always below 1024). will be automatically chosen (so the resulting number is always below 1024).
if ``showdesc`` is ``True``, the suffix will be shown after the number. if ``showdesc`` is ``True``, the suffix will be shown after the number.
Usage example:: Usage example::
>>> format_size(1234, decimal=2, showdesc=True) >>> format_size(1234, decimal=2, showdesc=True)
'1.21 KB' '1.21 KB'
""" """
@@ -283,7 +298,7 @@ def iterdaterange(start, end):
@pathify @pathify
def modified_after(first_path: Path, second_path: Path): def modified_after(first_path: Path, second_path: Path):
"""Returns ``True`` if first_path's mtime is higher than second_path's mtime. """Returns ``True`` if first_path's mtime is higher than second_path's mtime.
If one of the files doesn't exist or is ``None``, it is considered "never modified". If one of the files doesn't exist or is ``None``, it is considered "never modified".
""" """
try: try:
@@ -326,11 +341,11 @@ def delete_if_empty(path: Path, files_to_delete=[]):
def open_if_filename(infile, mode='rb'): def open_if_filename(infile, mode='rb'):
"""If ``infile`` is a string, it opens and returns it. If it's already a file object, it simply returns it. """If ``infile`` is a string, it opens and returns it. If it's already a file object, it simply returns it.
This function returns ``(file, should_close_flag)``. The should_close_flag is True is a file has This function returns ``(file, should_close_flag)``. The should_close_flag is True is a file has
effectively been opened (if we already pass a file object, we assume that the responsibility for effectively been opened (if we already pass a file object, we assume that the responsibility for
closing the file has already been taken). Example usage:: closing the file has already been taken). Example usage::
fp, shouldclose = open_if_filename(infile) fp, shouldclose = open_if_filename(infile)
dostuff() dostuff()
if shouldclose: if shouldclose:
@@ -370,9 +385,9 @@ def delete_files_with_pattern(folder_path, pattern, recursive=True):
class FileOrPath: class FileOrPath:
"""Does the same as :func:`open_if_filename`, but it can be used with a ``with`` statement. """Does the same as :func:`open_if_filename`, but it can be used with a ``with`` statement.
Example:: Example::
with FileOrPath(infile): with FileOrPath(infile):
dostuff() dostuff()
""" """
@@ -381,12 +396,12 @@ class FileOrPath:
self.mode = mode self.mode = mode
self.mustclose = False self.mustclose = False
self.fp = None self.fp = None
def __enter__(self): def __enter__(self):
self.fp, self.mustclose = open_if_filename(self.file_or_path, self.mode) self.fp, self.mustclose = open_if_filename(self.file_or_path, self.mode)
return self.fp return self.fp
def __exit__(self, exc_type, exc_value, traceback): def __exit__(self, exc_type, exc_value, traceback):
if self.fp and self.mustclose: if self.fp and self.mustclose:
self.fp.close() self.fp.close()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 B

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -1,9 +1,10 @@
# Translators: # Translators:
# Harakiri1337, 2014
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: dupeGuru\n" "Project-Id-Version: dupeGuru\n"
"PO-Revision-Date: 2013-11-20 11:53+0000\n" "PO-Revision-Date: 2014-06-03 21:56+0000\n"
"Last-Translator: hsoft <hsoft@hardcoded.net>\n" "Last-Translator: Harakiri1337\n"
"Language-Team: German (http://www.transifex.com/projects/p/dupeguru/language/de/)\n" "Language-Team: German (http://www.transifex.com/projects/p/dupeguru/language/de/)\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n" "Content-Transfer-Encoding: utf-8\n"
@@ -32,7 +33,7 @@ msgstr "Ordner"
#: core/prioritize.py:88 core_me/result_table.py:18 core_pe/result_table.py:18 #: core/prioritize.py:88 core_me/result_table.py:18 core_pe/result_table.py:18
#: core_se/result_table.py:18 #: core_se/result_table.py:18
msgid "Filename" msgid "Filename"
msgstr "Filename" msgstr "Dateiname"
#: core/prioritize.py:147 #: core/prioritize.py:147
msgid "Size" msgid "Size"
@@ -41,11 +42,11 @@ msgstr "Größe"
#: core/prioritize.py:153 core_me/result_table.py:25 #: core/prioritize.py:153 core_me/result_table.py:25
#: core_pe/result_table.py:24 core_se/result_table.py:22 #: core_pe/result_table.py:24 core_se/result_table.py:22
msgid "Modification" msgid "Modification"
msgstr "Modifikation" msgstr "Geändert"
#: core_me/prioritize.py:16 #: core_me/prioritize.py:16
msgid "Duration" msgid "Duration"
msgstr "" msgstr "Dauer"
#: core_me/prioritize.py:22 core_me/result_table.py:22 #: core_me/prioritize.py:22 core_me/result_table.py:22
msgid "Bitrate" msgid "Bitrate"
@@ -53,7 +54,7 @@ msgstr "Bitrate"
#: core_me/prioritize.py:28 #: core_me/prioritize.py:28
msgid "Samplerate" msgid "Samplerate"
msgstr "" msgstr "Abtastrate"
#: core_me/result_table.py:20 #: core_me/result_table.py:20
msgid "Size (MB)" msgid "Size (MB)"
@@ -89,7 +90,7 @@ msgstr "Jahr"
#: core_me/result_table.py:31 #: core_me/result_table.py:31
msgid "Track Number" msgid "Track Number"
msgstr "Stück Nummer" msgstr "Titel Nummer"
#: core_me/result_table.py:32 #: core_me/result_table.py:32
msgid "Comment" msgid "Comment"
@@ -102,16 +103,16 @@ msgstr "Übereinstimmung %"
#: core_me/result_table.py:34 core_se/result_table.py:24 #: core_me/result_table.py:34 core_se/result_table.py:24
msgid "Words Used" msgid "Words Used"
msgstr "Wörter genutzt" msgstr "genutzte Wörter"
#: core_me/result_table.py:35 core_pe/result_table.py:26 #: core_me/result_table.py:35 core_pe/result_table.py:26
#: core_se/result_table.py:25 #: core_se/result_table.py:25
msgid "Dupe Count" msgid "Dupe Count"
msgstr "Anzahl Duplikate" msgstr "Anzahl der Duplikate"
#: core_pe/prioritize.py:16 core_pe/result_table.py:22 #: core_pe/prioritize.py:16 core_pe/result_table.py:22
msgid "Dimensions" msgid "Dimensions"
msgstr "Dimensionen" msgstr "Auflösung"
#: core_pe/result_table.py:20 core_se/result_table.py:20 #: core_pe/result_table.py:20 core_se/result_table.py:20
msgid "Size (KB)" msgid "Size (KB)"
@@ -119,4 +120,4 @@ msgstr "Größe (KB)"
#: core_pe/result_table.py:23 #: core_pe/result_table.py:23
msgid "EXIF Timestamp" msgid "EXIF Timestamp"
msgstr "" msgstr "EXIF Zeitstempel"

View File

@@ -1,9 +1,11 @@
# Translators: # Translators:
# Harakiri1337, 2014
# Frank Weber <frank.weber@gmail.com>, 2014
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: dupeGuru\n" "Project-Id-Version: dupeGuru\n"
"PO-Revision-Date: 2013-12-07 15:22+0000\n" "PO-Revision-Date: 2014-09-26 21:24+0000\n"
"Last-Translator: hsoft <hsoft@hardcoded.net>\n" "Last-Translator: Frank Weber <frank.weber@gmail.com>\n"
"Language-Team: German (http://www.transifex.com/projects/p/dupeguru/language/de/)\n" "Language-Team: German (http://www.transifex.com/projects/p/dupeguru/language/de/)\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n" "Content-Transfer-Encoding: utf-8\n"
@@ -12,17 +14,19 @@ msgstr ""
#: core/app.py:39 #: core/app.py:39
msgid "There are no marked duplicates. Nothing has been done." msgid "There are no marked duplicates. Nothing has been done."
msgstr "" msgstr "Keine markierten Duplikate, daher wurde nichts getan."
#: core/app.py:40 #: core/app.py:40
msgid "There are no selected duplicates. Nothing has been done." msgid "There are no selected duplicates. Nothing has been done."
msgstr "" msgstr "Keine ausgewählten Duplikate, daher wurde nichts getan."
#: core/app.py:41 #: core/app.py:41
msgid "" msgid ""
"You're about to open many files at once. Depending on what those files are " "You're about to open many files at once. Depending on what those files are "
"opened with, doing so can create quite a mess. Continue?" "opened with, doing so can create quite a mess. Continue?"
msgstr "" msgstr ""
"Sie sind dabei, sehr viele Dateien gleichzeitig zu öffnen. Das kann zu "
"ziemlichem Durcheinander führen! Trotzdem fortfahren?"
#: core/app.py:57 #: core/app.py:57
msgid "Scanning for duplicates" msgid "Scanning for duplicates"
@@ -30,23 +34,23 @@ msgstr "Suche nach Duplikaten"
#: core/app.py:58 #: core/app.py:58
msgid "Loading" msgid "Loading"
msgstr "Laden" msgstr "Lade"
#: core/app.py:59 #: core/app.py:59
msgid "Moving" msgid "Moving"
msgstr "Verschieben" msgstr "Verschiebe"
#: core/app.py:60 #: core/app.py:60
msgid "Copying" msgid "Copying"
msgstr "Kopieren" msgstr "Kopiere"
#: core/app.py:61 #: core/app.py:61
msgid "Sending to Trash" msgid "Sending to Trash"
msgstr "Verschiebe in den Mülleimer" msgstr "Verschiebe in den Papierkorb"
#: core/app.py:64 #: core/app.py:64
msgid "Sending files to the recycle bin" msgid "Sending files to the recycle bin"
msgstr "Sende Dateien in den Mülleimer" msgstr "Verschiebe Dateien in den Papierkorb"
#: core/app.py:290 #: core/app.py:290
msgid "" msgid ""
@@ -62,29 +66,32 @@ msgstr "Keine Duplikate gefunden."
#: core/app.py:310 #: core/app.py:310
msgid "All marked files were copied successfully." msgid "All marked files were copied successfully."
msgstr "" msgstr "Alle markierten Dateien wurden erfolgreich kopiert."
#: core/app.py:311 #: core/app.py:311
msgid "All marked files were moved successfully." msgid "All marked files were moved successfully."
msgstr "" msgstr "Alle markierten Dateien wurden erfolgreich verschoben."
#: core/app.py:312 #: core/app.py:312
msgid "All marked files were successfully sent to Trash." msgid "All marked files were successfully sent to Trash."
msgstr "" msgstr ""
"Alle markierten Dateien wurden erfolgreich in den Papierkorb verschoben."
#: core/app.py:349 #: core/app.py:349
msgid "'{}' already is in the list." msgid "'{}' already is in the list."
msgstr "" msgstr "'{}' ist bereits in der Liste."
#: core/app.py:351 #: core/app.py:351
msgid "'{}' does not exist." msgid "'{}' does not exist."
msgstr "" msgstr "'{}' existiert nicht."
#: core/app.py:360 #: core/app.py:360
msgid "" msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. " "All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?" "Continue?"
msgstr "%d Dateien werden in zukünftigen Scans ignoriert werden. Fortfahren?" msgstr ""
"Alle %d ausgewählten Dateien werden in zukünftigen Scans ignoriert. "
"Fortfahren?"
#: core/app.py:426 #: core/app.py:426
msgid "copy" msgid "copy"
@@ -96,17 +103,17 @@ msgstr "verschieben"
#: core/app.py:427 #: core/app.py:427
msgid "Select a directory to {} marked files to" msgid "Select a directory to {} marked files to"
msgstr "Wählen sie einen Ordner zum {} der ausgewählten Dateien" msgstr "Wählen Sie einen Ordner zum {} der ausgewählten Dateien."
#: core/app.py:464 #: core/app.py:464
msgid "Select a destination for your exported CSV" msgid "Select a destination for your exported CSV"
msgstr "" msgstr "Zielverzeichnis für den CSV Export angeben"
#: core/app.py:489 #: core/app.py:489
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 ""
"Sie haben keinen eigenen Befehl erstellt. Bitte in den Einstellungen " "Sie haben noch keinen Befehl erstellt. Bitte dies in den Einstellungen vornehmen.\n"
"konfigurieren." "Bsp.: \"C:\\Program Files\\Diff\\Diff.exe\" \"%d\" \"%r\""
#: core/app.py:641 core/app.py:654 #: core/app.py:641 core/app.py:654
msgid "You are about to remove %d files from results. Continue?" msgid "You are about to remove %d files from results. Continue?"
@@ -114,15 +121,15 @@ msgstr "%d Dateien werden aus der Ergebnisliste entfernt. Fortfahren?"
#: core/app.py:688 #: core/app.py:688
msgid "{} duplicate groups were changed by the re-prioritization." msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "" msgstr "{} Duplikat-Gruppen wurden durch die Neu-Priorisierung geändert."
#: core/app.py:716 #: core/app.py:716
msgid "Collecting files to scan" msgid "Collecting files to scan"
msgstr "Sammle Dateien zum Scannen" msgstr "Sammle zu scannende Dateien..."
#: core/app.py:727 #: core/app.py:727
msgid "The selected directories contain no scannable file." msgid "The selected directories contain no scannable file."
msgstr "Der ausgewählte Ordner enthält keine scannbare Dateien." msgstr "Ausgewählte Ordner enthalten keine scannbaren Dateien."
#: core/app.py:768 #: core/app.py:768
msgid "%s (%d discarded)" msgid "%s (%d discarded)"
@@ -130,11 +137,11 @@ msgstr "%s (%d verworfen)"
#: core/engine.py:220 core/engine.py:265 #: core/engine.py:220 core/engine.py:265
msgid "0 matches found" msgid "0 matches found"
msgstr "0 Paare gefunden" msgstr "0 Übereinstimmungen gefunden"
#: core/engine.py:238 core/engine.py:273 #: core/engine.py:238 core/engine.py:273
msgid "%d matches found" msgid "%d matches found"
msgstr "%d Paare gefunden" msgstr "%d Übereinstimmungen gefunden"
#: core/engine.py:258 core/scanner.py:79 #: core/engine.py:258 core/scanner.py:79
msgid "Read size of %d/%d files" msgid "Read size of %d/%d files"
@@ -142,51 +149,51 @@ msgstr "Lese Größe von %d/%d Dateien"
#: core/engine.py:464 #: core/engine.py:464
msgid "Grouped %d/%d matches" msgid "Grouped %d/%d matches"
msgstr "%d/%d Paare gruppiert" msgstr "%d/%d Übereinstimmungen gruppiert"
#: core/gui/deletion_options.py:69 #: core/gui/deletion_options.py:69
msgid "You are sending {} file(s) to the Trash." msgid "You are sending {} file(s) to the Trash."
msgstr "" msgstr "Verschiebe {} Datei(en) in den Papierkorb."
#: core/gui/ignore_list_dialog.py:24 #: core/gui/ignore_list_dialog.py:24
msgid "Do you really want to remove all %d items from the ignore list?" msgid "Do you really want to remove all %d items from the ignore list?"
msgstr "Möchten Sie wirklich alle %d Einträge aus der Ignorier-Liste löschen?" msgstr "Möchten Sie wirklich alle %d Einträge aus der Ausnahmeliste löschen?"
#: core/prioritize.py:68 #: core/prioritize.py:68
msgid "None" msgid "None"
msgstr "" msgstr "Nichts"
#: core/prioritize.py:96 #: core/prioritize.py:96
msgid "Ends with number" msgid "Ends with number"
msgstr "Ends with number" msgstr "Endet mit Zahl"
#: core/prioritize.py:97 #: core/prioritize.py:97
msgid "Doesn't end with number" msgid "Doesn't end with number"
msgstr "Doesn't end with number" msgstr "Endet nicht mit Zahl"
#: core/prioritize.py:98 #: core/prioritize.py:98
msgid "Longest" msgid "Longest"
msgstr "" msgstr "Längste"
#: core/prioritize.py:99 #: core/prioritize.py:99
msgid "Shortest" msgid "Shortest"
msgstr "" msgstr "Kürzeste"
#: core/prioritize.py:132 #: core/prioritize.py:132
msgid "Highest" msgid "Highest"
msgstr "Highest" msgstr "Höchste"
#: core/prioritize.py:132 #: core/prioritize.py:132
msgid "Lowest" msgid "Lowest"
msgstr "Lowest" msgstr "Niedrigste"
#: core/prioritize.py:159 #: core/prioritize.py:159
msgid "Newest" msgid "Newest"
msgstr "Newest" msgstr "Neuste"
#: core/prioritize.py:159 #: core/prioritize.py:159
msgid "Oldest" msgid "Oldest"
msgstr "Oldest" msgstr "Älterste"
#: core/results.py:126 #: core/results.py:126
msgid "%d / %d (%s / %s) duplicates marked." msgid "%d / %d (%s / %s) duplicates marked."
@@ -202,11 +209,11 @@ msgstr "Lese Metadaten von %d/%d Dateien"
#: core/scanner.py:130 #: core/scanner.py:130
msgid "Removing false matches" msgid "Removing false matches"
msgstr "Entferne Falschpositive." msgstr "Entferne falsche Übereinstimmungen"
#: core/scanner.py:154 #: core/scanner.py:154
msgid "Processed %d/%d matches against the ignore list" msgid "Processed %d/%d matches against the ignore list"
msgstr "Verarbeitung von %d/%d Paaren gegen die Ignorier-Liste" msgstr "%d/%d Treffer mit der Ausnahmeliste abgeglichen"
#: core/scanner.py:176 #: core/scanner.py:176
msgid "Doing group prioritization" msgid "Doing group prioritization"
@@ -214,20 +221,20 @@ msgstr "Gruppenpriorisierung"
#: core_pe/matchblock.py:61 #: core_pe/matchblock.py:61
msgid "Analyzed %d/%d pictures" msgid "Analyzed %d/%d pictures"
msgstr "Analysiere %d/%d Bilder" msgstr "Analysiere Bild %d/%d"
#: core_pe/matchblock.py:153 #: core_pe/matchblock.py:153
msgid "Performed %d/%d chunk matches" msgid "Performed %d/%d chunk matches"
msgstr "Performed %d/%d chunk matches" msgstr "%d/%d Chunk-Matches ausgeführt"
#: core_pe/matchblock.py:158 #: core_pe/matchblock.py:158
msgid "Preparing for matching" msgid "Preparing for matching"
msgstr "Vorbereitung auf den Vergleich" msgstr "Bereite Matching vor"
#: core_pe/matchblock.py:193 #: core_pe/matchblock.py:193
msgid "Verified %d/%d matches" msgid "Verified %d/%d matches"
msgstr "%d/%d verifizierte Paare" msgstr "%d/%d verifizierte Übereinstimmungen"
#: core_pe/matchexif.py:18 #: core_pe/matchexif.py:18
msgid "Read EXIF of %d/%d pictures" msgid "Read EXIF of %d/%d pictures"
msgstr "" msgstr "Lese EXIF von Bild %d/%d"

View File

@@ -1,9 +1,11 @@
# Translators: # Translators:
# Harakiri1337, 2014
# Frank Weber <frank.weber@gmail.com>, 2014
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: dupeGuru\n" "Project-Id-Version: dupeGuru\n"
"PO-Revision-Date: 2013-12-07 15:22+0000\n" "PO-Revision-Date: 2014-09-26 21:15+0000\n"
"Last-Translator: hsoft <hsoft@hardcoded.net>\n" "Last-Translator: Frank Weber <frank.weber@gmail.com>\n"
"Language-Team: German (http://www.transifex.com/projects/p/dupeguru/language/de/)\n" "Language-Team: German (http://www.transifex.com/projects/p/dupeguru/language/de/)\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n" "Content-Transfer-Encoding: utf-8\n"
@@ -12,49 +14,50 @@ msgstr ""
#: cocoa/inter/app_me.py:34 #: cocoa/inter/app_me.py:34
msgid "Removing dead tracks from your iTunes Library" msgid "Removing dead tracks from your iTunes Library"
msgstr "Entferne tote Stücke aus Ihrer iTunes Bibliothek." msgstr "Entferne tote Tracks aus Ihrer iTunes Bibliothek"
#: cocoa/inter/app_me.py:35 #: cocoa/inter/app_me.py:35
msgid "Scanning the iTunes Library" msgid "Scanning the iTunes Library"
msgstr "Scanne die iTunes Bibiliothek" msgstr "Durchsuche die iTunes-Bibliothek"
#: cocoa/inter/app_me.py:158 cocoa/inter/app_pe.py:200 #: cocoa/inter/app_me.py:158 cocoa/inter/app_pe.py:200
msgid "Sending dupes to the Trash" msgid "Sending dupes to the Trash"
msgstr "Verschiebe Duplikate in den Mülleimer" msgstr "Schicke Duplikate in den Papierkorb"
#: cocoa/inter/app_me.py:160 #: cocoa/inter/app_me.py:160
msgid "Talking to iTunes. Don't touch it!" msgid "Talking to iTunes. Don't touch it!"
msgstr "" msgstr "Kommuniziere mit iTunes. Bitte warten!"
#: cocoa/inter/app_me.py:195 #: cocoa/inter/app_me.py:195
msgid "" msgid ""
"Your iTunes Library contains %d dead tracks ready to be removed. Continue?" "Your iTunes Library contains %d dead tracks ready to be removed. Continue?"
msgstr "" msgstr ""
"Your iTunes Library contains %d dead tracks ready to be removed. Continue?" "Ihre iTunes-Bibliothek enthält %d tote Tracks zum Entfernen. Fortsetzen?"
#: cocoa/inter/app_me.py:199 #: cocoa/inter/app_me.py:199
msgid "You have no dead tracks in your iTunes Library" msgid "You have no dead tracks in your iTunes Library"
msgstr "You have no dead tracks in your iTunes Library" msgstr "Sie haben keine toten Tracks in Ihrer iTunes-Bibliothek"
#: cocoa/inter/app_me.py:217 #: cocoa/inter/app_me.py:217
msgid "The iTunes application couldn't be found." msgid "The iTunes application couldn't be found."
msgstr "" msgstr "Das iTunes-Programm konnte nicht gefunden werden."
#: cocoa/inter/app_pe.py:202 #: cocoa/inter/app_pe.py:202
msgid "Talking to iPhoto. Don't touch it!" msgid "Talking to iPhoto. Don't touch it!"
msgstr "" msgstr "Kommuniziere mit iPhoto. Bitte warten!"
#: cocoa/inter/app_pe.py:211 #: cocoa/inter/app_pe.py:211
msgid "Talking to Aperture. Don't touch it!" msgid "Talking to Aperture. Don't touch it!"
msgstr "" msgstr "Kommuniziere mit Aperture. Bitte warten!"
#: cocoa/inter/app_pe.py:284 #: cocoa/inter/app_pe.py:284
msgid "Deleted Aperture photos were sent to a project called \"dupeGuru Trash\"." msgid "Deleted Aperture photos were sent to a project called \"dupeGuru Trash\"."
msgstr "" msgstr ""
"Gelöschte Aperture-Fotos wurden dem Projekt \"dupeGuru Trash\" hinzugefügt."
#: cocoa/inter/app_pe.py:310 #: cocoa/inter/app_pe.py:310
msgid "The iPhoto application couldn't be found." msgid "The iPhoto application couldn't be found."
msgstr "The iPhoto application couldn't be found." msgstr "Das iPhoto-Programm konnte nicht gefunden werden."
#: qt/base/app.py:83 #: qt/base/app.py:83
msgid "Quit" msgid "Quit"
@@ -67,7 +70,7 @@ msgstr "Einstellungen"
#: qt/base/app.py:85 qt/base/ignore_list_dialog.py:32 #: qt/base/app.py:85 qt/base/ignore_list_dialog.py:32
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Ignore List" msgid "Ignore List"
msgstr "" msgstr "Ausnahme-Liste"
#: qt/base/app.py:86 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/app.py:86 cocoa/base/en.lproj/Localizable.strings:0
msgid "dupeGuru Help" msgid "dupeGuru Help"
@@ -87,51 +90,56 @@ msgstr "Debug Log öffnen"
#: qt/base/app.py:198 #: qt/base/app.py:198
msgid "{} file (*.{})" msgid "{} file (*.{})"
msgstr "" msgstr "{} Datei (*.{})"
#: qt/base/deletion_options.py:30 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/deletion_options.py:30 cocoa/base/en.lproj/Localizable.strings:0
msgid "Deletion Options" msgid "Deletion Options"
msgstr "" msgstr "Lösch-Optionen"
#: qt/base/deletion_options.py:35 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/deletion_options.py:35 cocoa/base/en.lproj/Localizable.strings:0
msgid "Link deleted files" msgid "Link deleted files"
msgstr "" msgstr "Verlinke gelöschte Dateien"
#: qt/base/deletion_options.py:37 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/deletion_options.py:37 cocoa/base/en.lproj/Localizable.strings:0
msgid "" msgid ""
"After having deleted a duplicate, place a link targeting the reference file " "After having deleted a duplicate, place a link targeting the reference file "
"to replace the deleted file." "to replace the deleted file."
msgstr "" msgstr ""
"Doppelte Dateien werden gelöscht, an deren Stelle wird eine Verknüpfung auf "
"die Referenz-Datei erstellt."
#: qt/base/deletion_options.py:42 #: qt/base/deletion_options.py:42
msgid "Hardlink" msgid "Hardlink"
msgstr "" msgstr "Hardlink"
#: qt/base/deletion_options.py:42 #: qt/base/deletion_options.py:42
msgid "Symlink" msgid "Symlink"
msgstr "" msgstr "Symlink"
#: qt/base/deletion_options.py:46 #: qt/base/deletion_options.py:46
msgid " (unsupported)" msgid " (unsupported)"
msgstr "" msgstr "(nicht unterstützt)"
#: qt/base/deletion_options.py:47 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/deletion_options.py:47 cocoa/base/en.lproj/Localizable.strings:0
msgid "Directly delete files" msgid "Directly delete files"
msgstr "" msgstr "Ohne Papierkorb löschen"
#: qt/base/deletion_options.py:49 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/deletion_options.py:49 cocoa/base/en.lproj/Localizable.strings:0
msgid "" msgid ""
"Instead of sending files to trash, delete them directly. This option is " "Instead of sending files to trash, delete them directly. This option is "
"usually used as a workaround when the normal deletion method doesn't work." "usually used as a workaround when the normal deletion method doesn't work."
msgstr "" msgstr ""
"Anstatt Dateien in den Papierkorb zu verschieben, können Sie diese direkt "
"löschen. Diese Option wird in der Regel genutzt, falls die normale "
"Löschmethode nicht funktioniert."
#: qt/base/deletion_options.py:55 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/deletion_options.py:55 cocoa/base/en.lproj/Localizable.strings:0
msgid "Proceed" msgid "Proceed"
msgstr "" msgstr "Fortfahren"
#: qt/base/deletion_options.py:56 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/deletion_options.py:56 cocoa/base/en.lproj/Localizable.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Abbrechen"
#: qt/base/details_table.py:16 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/details_table.py:16 cocoa/base/en.lproj/Localizable.strings:0
msgid "Attribute" msgid "Attribute"
@@ -148,7 +156,7 @@ msgstr "Referenz"
#: qt/base/directories_dialog.py:58 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:58 cocoa/base/en.lproj/Localizable.strings:0
msgid "Load Results..." msgid "Load Results..."
msgstr "Lade Ergebnisse..." msgstr "Ergebnis laden..."
#: qt/base/directories_dialog.py:59 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:59 cocoa/base/en.lproj/Localizable.strings:0
msgid "Results Window" msgid "Results Window"
@@ -161,7 +169,7 @@ msgstr "Ordner hinzufügen..."
#: qt/base/directories_dialog.py:68 qt/base/result_window.py:77 #: qt/base/directories_dialog.py:68 qt/base/result_window.py:77
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "File" msgid "File"
msgstr "Ablage" msgstr "Datei"
#: qt/base/directories_dialog.py:70 qt/base/result_window.py:85 #: qt/base/directories_dialog.py:70 qt/base/result_window.py:85
msgid "View" msgid "View"
@@ -174,11 +182,11 @@ msgstr "Hilfe"
#: qt/base/directories_dialog.py:74 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:74 cocoa/base/en.lproj/Localizable.strings:0
msgid "Load Recent Results" msgid "Load Recent Results"
msgstr "Lade letzte Ergebnisse" msgstr "Lade letztes Suchergebnis"
#: qt/base/directories_dialog.py:108 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:108 cocoa/base/en.lproj/Localizable.strings:0
msgid "Select folders to scan and press \"Scan\"." msgid "Select folders to scan and press \"Scan\"."
msgstr "Zu scannende Ordner auswählen und \"Scan\" drücken." msgstr "Zu durchsuchende Ordner auswählen und \"Suche starten\" drücken."
#: qt/base/directories_dialog.py:132 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:132 cocoa/base/en.lproj/Localizable.strings:0
msgid "Load Results" msgid "Load Results"
@@ -186,7 +194,7 @@ msgstr "Lade Ergebnisse"
#: qt/base/directories_dialog.py:135 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:135 cocoa/base/en.lproj/Localizable.strings:0
msgid "Scan" msgid "Scan"
msgstr "Scan" msgstr "Suche starten"
#: qt/base/directories_dialog.py:179 #: qt/base/directories_dialog.py:179
msgid "Unsaved results" msgid "Unsaved results"
@@ -194,15 +202,16 @@ msgstr "Ungespeicherte Ergebnisse"
#: qt/base/directories_dialog.py:180 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:180 cocoa/base/en.lproj/Localizable.strings:0
msgid "You have unsaved results, do you really want to quit?" msgid "You have unsaved results, do you really want to quit?"
msgstr "Sie haben ungespeicherte Ergebnisse. Wollen Sie wirklich beenden?" msgstr ""
"Sie haben ungespeicherte Ergebnisse. Wollen Sie wirklich dupeGuru beenden?"
#: qt/base/directories_dialog.py:188 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:188 cocoa/base/en.lproj/Localizable.strings:0
msgid "Select a folder to add to the scanning list" msgid "Select a folder to add to the scanning list"
msgstr "Wählen Sie einen Ordner aus, um ihn der Scanliste hinzuzufügen." msgstr "Wählen Sie einen Ordner aus, um ihn der Scanliste hinzuzufügen"
#: qt/base/directories_dialog.py:205 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:205 cocoa/base/en.lproj/Localizable.strings:0
msgid "Select a results file to load" msgid "Select a results file to load"
msgstr "Wählen Sie eine Ergebnisliste zum Laden aus." msgstr "Wählen Sie eine Ergebnisdatei zum Laden aus"
#: qt/base/directories_dialog.py:206 #: qt/base/directories_dialog.py:206
msgid "All Files (*.*)" msgid "All Files (*.*)"
@@ -210,11 +219,11 @@ msgstr "Alle Dateien (*.*)"
#: qt/base/directories_dialog.py:206 qt/base/result_window.py:287 #: qt/base/directories_dialog.py:206 qt/base/result_window.py:287
msgid "dupeGuru Results (*.dupeguru)" msgid "dupeGuru Results (*.dupeguru)"
msgstr "dupeGuru Ergebnisse (*.dupeguru)" msgstr "dupeGuru Suchergebnisse (*.dupeguru)"
#: qt/base/directories_dialog.py:217 #: qt/base/directories_dialog.py:217
msgid "Start a new scan" msgid "Start a new scan"
msgstr "Starte einen neuen Scan" msgstr "Starte einen neuen Suchlauf"
#: qt/base/directories_dialog.py:218 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/directories_dialog.py:218 cocoa/base/en.lproj/Localizable.strings:0
msgid "You have unsaved results, do you really want to continue?" msgid "You have unsaved results, do you really want to continue?"
@@ -238,11 +247,11 @@ msgstr "Normal"
#: qt/base/ignore_list_dialog.py:45 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/ignore_list_dialog.py:45 cocoa/base/en.lproj/Localizable.strings:0
msgid "Remove Selected" msgid "Remove Selected"
msgstr "" msgstr "Auswahl löschen"
#: qt/base/ignore_list_dialog.py:46 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/ignore_list_dialog.py:46 cocoa/base/en.lproj/Localizable.strings:0
msgid "Clear" msgid "Clear"
msgstr "" msgstr "Liste leeren"
#: qt/base/ignore_list_dialog.py:47 qt/base/problem_dialog.py:57 #: qt/base/ignore_list_dialog.py:47 qt/base/problem_dialog.py:57
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
@@ -259,15 +268,15 @@ msgstr "Filter Empfindlichkeit:"
#: qt/base/preferences_dialog.py:76 #: qt/base/preferences_dialog.py:76
msgid "More Results" msgid "More Results"
msgstr "mehr Ergebnisse" msgstr "Mehr Ergebnisse"
#: qt/base/preferences_dialog.py:81 #: qt/base/preferences_dialog.py:81
msgid "Fewer Results" msgid "Fewer Results"
msgstr "weniger Ergebnisse" msgstr "Weniger Ergebnisse"
#: qt/base/preferences_dialog.py:88 #: qt/base/preferences_dialog.py:88
msgid "Font size:" msgid "Font size:"
msgstr "Font size:" msgstr "Schriftgröße:"
#: qt/base/preferences_dialog.py:92 #: qt/base/preferences_dialog.py:92
msgid "Language:" msgid "Language:"
@@ -279,7 +288,7 @@ msgstr "Kopieren und Verschieben:"
#: qt/base/preferences_dialog.py:101 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/preferences_dialog.py:101 cocoa/base/en.lproj/Localizable.strings:0
msgid "Right in destination" msgid "Right in destination"
msgstr "Direkt im Ziel" msgstr "Direkt ins Ziel"
#: qt/base/preferences_dialog.py:102 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/preferences_dialog.py:102 cocoa/base/en.lproj/Localizable.strings:0
msgid "Recreate relative path" msgid "Recreate relative path"
@@ -291,7 +300,7 @@ msgstr "Absoluten Pfad neu erstellen"
#: qt/base/preferences_dialog.py:106 #: qt/base/preferences_dialog.py:106
msgid "Custom Command (arguments: %d for dupe, %r for ref):" msgid "Custom Command (arguments: %d for dupe, %r for ref):"
msgstr "Eigener Befehl (Argumente: %d für Duplikat, %r für Referenz):" msgstr "Eigener Befehl (Variablen: %d für Duplikat, %r für Referenz):"
#: qt/base/preferences_dialog.py:184 #: qt/base/preferences_dialog.py:184
msgid "dupeGuru has to restart for language changes to take effect." msgid "dupeGuru has to restart for language changes to take effect."
@@ -299,7 +308,7 @@ msgstr "dupeGuru muss neustarten, um die Sprachänderung durchzuführen."
#: qt/base/prioritize_dialog.py:71 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/prioritize_dialog.py:71 cocoa/base/en.lproj/Localizable.strings:0
msgid "Re-Prioritize duplicates" msgid "Re-Prioritize duplicates"
msgstr "Re-Prioritize duplicates" msgstr "Re-priorisiere Duplikate"
#: qt/base/prioritize_dialog.py:75 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/prioritize_dialog.py:75 cocoa/base/en.lproj/Localizable.strings:0
msgid "" msgid ""
@@ -307,9 +316,9 @@ msgid ""
" the best to these criteria to their respective group's reference position. " " the best to these criteria to their respective group's reference position. "
"Read the help file for more information." "Read the help file for more information."
msgstr "" msgstr ""
"Add criteria to the right box and click OK to send the dupes that correspond" "Fügen Sie Kriterien zur rechten Box hinzu. Klicken Sie OK, um die Duplikate,"
" the best to these criteria to their respective group's reference position. " " die diesen Kriterien am besten entsprechen, zur Referenzposition der "
"Read the help file for more information." "entsprechenden Gruppe zu senden. Lesen Sie die Hilfe für mehr Informationen."
#: qt/base/problem_dialog.py:31 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/problem_dialog.py:31 cocoa/base/en.lproj/Localizable.strings:0
msgid "Problems!" msgid "Problems!"
@@ -321,8 +330,9 @@ msgid ""
"these problems are described in the table below. Those files were not " "these problems are described in the table below. Those files were not "
"removed from your results." "removed from your results."
msgstr "" msgstr ""
"Es gab Probleme bei der Verarbeitung einiger (aller) Dateien. Der Grund der " "Es gab Probleme bei der Verarbeitung einiger (aller) Dateien. Der Ursache "
"Probleme ist unten in der Tabelle beschrieben." "dieser Probleme ist unten genauer beschrieben. Diese Dateien wurden "
"\"nicht\" aus Ihren Suchergebnissen entfernt."
#: qt/base/problem_dialog.py:52 #: qt/base/problem_dialog.py:52
msgid "Reveal Selected" msgid "Reveal Selected"
@@ -346,11 +356,11 @@ msgstr "Nur Duplikate anzeigen"
#: qt/base/result_window.py:47 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:47 cocoa/base/en.lproj/Localizable.strings:0
msgid "Show Delta Values" msgid "Show Delta Values"
msgstr "Zeige Deltawerte" msgstr "Zeige Delta-Werte"
#: qt/base/result_window.py:48 #: qt/base/result_window.py:48
msgid "Send Marked to Recycle Bin..." msgid "Send Marked to Recycle Bin..."
msgstr "Verschiebe Markierte in den Mülleimer..." msgstr "Verschiebe Markierte in den Papierkorb..."
#: qt/base/result_window.py:49 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:49 cocoa/base/en.lproj/Localizable.strings:0
msgid "Move Marked to..." msgid "Move Marked to..."
@@ -366,31 +376,31 @@ msgstr "Entferne Markierte aus den Ergebnissen"
#: qt/base/result_window.py:52 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:52 cocoa/base/en.lproj/Localizable.strings:0
msgid "Re-Prioritize Results..." msgid "Re-Prioritize Results..."
msgstr "Entferne Ausgewählte aus den Ergebnissen..." msgstr "Re-priorisiere Ergebnisse..."
#: qt/base/result_window.py:53 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:53 cocoa/base/en.lproj/Localizable.strings:0
msgid "Remove Selected from Results" msgid "Remove Selected from Results"
msgstr "Entferne Ausgewählte aus den Ergebnissen" msgstr "Entferne Auswahl aus den Ergebnissen"
#: qt/base/result_window.py:54 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:54 cocoa/base/en.lproj/Localizable.strings:0
msgid "Add Selected to Ignore List" msgid "Add Selected to Ignore List"
msgstr "Füge Ausgewählte der Ignorier-Liste hinzu" msgstr "Füge Auswahl der Ausnahmeliste hinzu"
#: qt/base/result_window.py:55 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:55 cocoa/base/en.lproj/Localizable.strings:0
msgid "Make Selected into Reference" msgid "Make Selected into Reference"
msgstr "" msgstr "Mache Auswahl zur Referenz"
#: qt/base/result_window.py:56 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:56 cocoa/base/en.lproj/Localizable.strings:0
msgid "Open Selected with Default Application" msgid "Open Selected with Default Application"
msgstr "Öffne Ausgewählte mit Standardanwendung" msgstr "Öffne Auswahl mit Standard-Anwendung"
#: qt/base/result_window.py:57 #: qt/base/result_window.py:57
msgid "Open Containing Folder of Selected" msgid "Open Containing Folder of Selected"
msgstr "Öffne beeinhaltenden Ordner der Ausgewählten" msgstr "Öffne den Über-Ordner der Auswahl"
#: qt/base/result_window.py:58 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:58 cocoa/base/en.lproj/Localizable.strings:0
msgid "Rename Selected" msgid "Rename Selected"
msgstr "Ausgewählte umbenennen" msgstr "Auswahl umbenennen"
#: qt/base/result_window.py:59 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:59 cocoa/base/en.lproj/Localizable.strings:0
msgid "Mark All" msgid "Mark All"
@@ -402,19 +412,19 @@ msgstr "Nichts markieren"
#: qt/base/result_window.py:61 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:61 cocoa/base/en.lproj/Localizable.strings:0
msgid "Invert Marking" msgid "Invert Marking"
msgstr "Markierung invertieren" msgstr "Auswahl umkehren"
#: qt/base/result_window.py:62 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:62 cocoa/base/en.lproj/Localizable.strings:0
msgid "Mark Selected" msgid "Mark Selected"
msgstr "Ausgewählte markieren" msgstr "Auswahl markieren"
#: qt/base/result_window.py:63 #: qt/base/result_window.py:63
msgid "Export To HTML" msgid "Export To HTML"
msgstr "Exportiere als HTML" msgstr "Exportiere als HTML..."
#: qt/base/result_window.py:64 #: qt/base/result_window.py:64
msgid "Export To CSV" msgid "Export To CSV"
msgstr "" msgstr "Exportiere als CSV..."
#: qt/base/result_window.py:65 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:65 cocoa/base/en.lproj/Localizable.strings:0
msgid "Save Results..." msgid "Save Results..."
@@ -434,7 +444,7 @@ msgstr "Spalten"
#: qt/base/result_window.py:141 #: qt/base/result_window.py:141
msgid "Reset to Defaults" msgid "Reset to Defaults"
msgstr "Voreinstellungen" msgstr "Auf Voreinstellung zurücksetzen"
#: qt/base/result_window.py:163 #: qt/base/result_window.py:163
msgid "{} Results" msgid "{} Results"
@@ -442,15 +452,15 @@ msgstr "{} (Ergebnisse)"
#: qt/base/result_window.py:171 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:171 cocoa/base/en.lproj/Localizable.strings:0
msgid "Dupes Only" msgid "Dupes Only"
msgstr "Dupes Only" msgstr "Nur Duplikate anzeigen"
#: qt/base/result_window.py:172 #: qt/base/result_window.py:172
msgid "Delta Values" msgid "Delta Values"
msgstr "" msgstr "Zeige Delta-Werte"
#: qt/base/result_window.py:286 cocoa/base/en.lproj/Localizable.strings:0 #: qt/base/result_window.py:286 cocoa/base/en.lproj/Localizable.strings:0
msgid "Select a file to save your results to" msgid "Select a file to save your results to"
msgstr "Datei zum Speichern der Ergebnisliste auswählen." msgstr "Datei zum Speichern der Suchergebnisse auswählen"
#: qt/me/preferences_dialog.py:39 qt/se/preferences_dialog.py:39 #: qt/me/preferences_dialog.py:39 qt/se/preferences_dialog.py:39
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
@@ -472,7 +482,7 @@ msgstr "Tags"
#: qt/me/preferences_dialog.py:43 qt/pe/preferences_dialog.py:33 #: qt/me/preferences_dialog.py:43 qt/pe/preferences_dialog.py:33
#: qt/se/preferences_dialog.py:40 cocoa/base/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:40 cocoa/base/en.lproj/Localizable.strings:0
msgid "Contents" msgid "Contents"
msgstr "Inhalt" msgstr "Inhalte"
#: qt/me/preferences_dialog.py:44 #: qt/me/preferences_dialog.py:44
msgid "Audio Contents" msgid "Audio Contents"
@@ -480,11 +490,11 @@ msgstr "Audio Inhalte"
#: qt/me/preferences_dialog.py:55 cocoa/base/en.lproj/Localizable.strings:0 #: qt/me/preferences_dialog.py:55 cocoa/base/en.lproj/Localizable.strings:0
msgid "Tags to scan:" msgid "Tags to scan:"
msgstr "folgende Tags scannen:" msgstr "Folgende Tags scannen:"
#: qt/me/preferences_dialog.py:61 cocoa/base/en.lproj/Localizable.strings:0 #: qt/me/preferences_dialog.py:61 cocoa/base/en.lproj/Localizable.strings:0
msgid "Track" msgid "Track"
msgstr "Stück" msgstr "Track"
#: qt/me/preferences_dialog.py:63 cocoa/base/en.lproj/Localizable.strings:0 #: qt/me/preferences_dialog.py:63 cocoa/base/en.lproj/Localizable.strings:0
msgid "Artist" msgid "Artist"
@@ -514,7 +524,7 @@ msgstr "Wortgewichtung"
#: qt/me/preferences_dialog.py:77 qt/se/preferences_dialog.py:51 #: qt/me/preferences_dialog.py:77 qt/se/preferences_dialog.py:51
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Match similar words" msgid "Match similar words"
msgstr "Vergleiche ähnliche Wörter" msgstr "Gleiche ähnliche Wörter ab"
#: qt/me/preferences_dialog.py:79 qt/pe/preferences_dialog.py:41 #: qt/me/preferences_dialog.py:79 qt/pe/preferences_dialog.py:41
#: qt/se/preferences_dialog.py:53 cocoa/base/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:53 cocoa/base/en.lproj/Localizable.strings:0
@@ -534,7 +544,7 @@ msgstr "Entferne leere Ordner beim Löschen oder Verschieben"
#: qt/me/preferences_dialog.py:85 qt/pe/preferences_dialog.py:47 #: qt/me/preferences_dialog.py:85 qt/pe/preferences_dialog.py:47
#: qt/se/preferences_dialog.py:76 cocoa/base/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:76 cocoa/base/en.lproj/Localizable.strings:0
msgid "Ignore duplicates hardlinking to the same file" msgid "Ignore duplicates hardlinking to the same file"
msgstr "Ignoriere Duplikate die mit derselben Datei verknüpft sind" msgstr "Ignoriere Duplikate mit Hardlinks auf dieselbe Datei"
#: qt/me/preferences_dialog.py:87 qt/pe/preferences_dialog.py:49 #: qt/me/preferences_dialog.py:87 qt/pe/preferences_dialog.py:49
#: qt/se/preferences_dialog.py:78 cocoa/base/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:78 cocoa/base/en.lproj/Localizable.strings:0
@@ -543,16 +553,16 @@ msgstr "Debug Modus (Neustart nötig)"
#: qt/pe/preferences_dialog.py:34 cocoa/base/en.lproj/Localizable.strings:0 #: qt/pe/preferences_dialog.py:34 cocoa/base/en.lproj/Localizable.strings:0
msgid "EXIF Timestamp" msgid "EXIF Timestamp"
msgstr "EXIF Timestamp" msgstr "EXIF Zeitstempel"
#: qt/pe/preferences_dialog.py:39 cocoa/base/en.lproj/Localizable.strings:0 #: qt/pe/preferences_dialog.py:39 cocoa/base/en.lproj/Localizable.strings:0
msgid "Match pictures of different dimensions" msgid "Match pictures of different dimensions"
msgstr "Vergleiche Bilder mit unterschiedlicher Auflösung" msgstr "Gleiche Bilder mit unterschiedlicher Auflösung ab"
#: qt/pe/result_window.py:19 qt/pe/result_window.py:25 #: qt/pe/result_window.py:19 qt/pe/result_window.py:25
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Clear Picture Cache" msgid "Clear Picture Cache"
msgstr "Bildzwischenspeicher leeren" msgstr "Bilder-Cache leeren"
#: qt/pe/result_window.py:26 cocoa/base/en.lproj/Localizable.strings:0 #: qt/pe/result_window.py:26 cocoa/base/en.lproj/Localizable.strings:0
msgid "Do you really want to remove all your cached picture analysis?" msgid "Do you really want to remove all your cached picture analysis?"
@@ -561,7 +571,7 @@ msgstr ""
#: qt/pe/result_window.py:29 #: qt/pe/result_window.py:29
msgid "Picture cache cleared." msgid "Picture cache cleared."
msgstr "Bildzwischenspeicher geleert." msgstr "Bilder-Cache geleert."
#: qt/se/preferences_dialog.py:41 cocoa/base/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:41 cocoa/base/en.lproj/Localizable.strings:0
msgid "Folders" msgid "Folders"
@@ -577,35 +587,35 @@ msgstr "KB"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "%@ Results" msgid "%@ Results"
msgstr "" msgstr "%@ Ergebnisse"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Action" msgid "Action"
msgstr "Action" msgstr "Aktion"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Add Aperture Library" msgid "Add Aperture Library"
msgstr "" msgstr "Füge Aperture-Bibliothek hinzu"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Add iPhoto Library" msgid "Add iPhoto Library"
msgstr "Add iPhoto Library" msgstr "Füge iPhoto-Bibliothek hinzu"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Add iTunes Library" msgid "Add iTunes Library"
msgstr "" msgstr "Füge iTunes-Bibliothek hinzu"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Add New Folder..." msgid "Add New Folder..."
msgstr "Add New Folder..." msgstr "Neuer Ordner..."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Advanced" msgid "Advanced"
msgstr "Advanced" msgstr "Fortgeschritten"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Audio Content" msgid "Audio Content"
msgstr "Audio Content" msgstr "Audio Inhalt"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Automatically check for updates" msgid "Automatically check for updates"
@@ -613,7 +623,7 @@ msgstr "Automatisch nach Updates suchen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Basic" msgid "Basic"
msgstr "Basic" msgstr "Einfach"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Bring All to Front" msgid "Bring All to Front"
@@ -621,15 +631,15 @@ msgstr "Alle nach vorne bringen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Check for update..." msgid "Check for update..."
msgstr "Check for update..." msgstr "Auf Updates prüfen..."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Close Window" msgid "Close Window"
msgstr "Fenster Schließen" msgstr "Fenster schließen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Content" msgid "Content"
msgstr "Content" msgstr "Inhalt"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Copy" msgid "Copy"
@@ -637,7 +647,7 @@ msgstr "Kopieren"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Custom command (arguments: %d for dupe, %r for ref):" msgid "Custom command (arguments: %d for dupe, %r for ref):"
msgstr "Custom command (arguments: %d for dupe, %r for ref):" msgstr "Eigener Befehl (Variablen: %d für Duplikat, %r für Referenz):"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Cut" msgid "Cut"
@@ -649,7 +659,7 @@ msgstr "Delta"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Details of Selected File" msgid "Details of Selected File"
msgstr "Details of Selected File" msgstr "Details der ausgewählten Datei"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Details Panel" msgid "Details Panel"
@@ -657,27 +667,27 @@ msgstr "Details Panel"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Directories" msgid "Directories"
msgstr "Directories" msgstr "Verzeichnisse"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "dupeGuru" msgid "dupeGuru"
msgstr "" msgstr "dupeGuru"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "dupeGuru ME Preferences" msgid "dupeGuru ME Preferences"
msgstr "dupeGuru ME Preferences" msgstr "dupeGuru ME Einstellungen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "dupeGuru PE Preferences" msgid "dupeGuru PE Preferences"
msgstr "dupeGuru PE Preferences" msgstr "dupeGuru PE Einstellungen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "dupeGuru Preferences" msgid "dupeGuru Preferences"
msgstr "dupeGuru Preferences" msgstr "dupeGuru Einstellungen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "dupeGuru Results" msgid "dupeGuru Results"
msgstr "dupeGuru Results" msgstr "dupeGuru Ergebnisse"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "dupeGuru Website" msgid "dupeGuru Website"
@@ -689,15 +699,15 @@ msgstr "Bearbeiten"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Export Results to CSV" msgid "Export Results to CSV"
msgstr "" msgstr "Exportiere als CSV..."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Export Results to XHTML" msgid "Export Results to XHTML"
msgstr "Export Results to XHTML" msgstr "Exportiere als XHTML..."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Fewer results" msgid "Fewer results"
msgstr "Fewer results" msgstr "Weniger Suchergebnisse"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Filter" msgid "Filter"
@@ -705,19 +715,19 @@ msgstr "Filter"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Filter hardness:" msgid "Filter hardness:"
msgstr "Filter hardness:" msgstr "Filter Empfindlichkeit:"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Filter Results..." msgid "Filter Results..."
msgstr "" msgstr "Filter Suchergebnisse..."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Folder Selection Window" msgid "Folder Selection Window"
msgstr "Folder Selection Window" msgstr "Ordner-Auswahlfenster"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Font Size:" msgid "Font Size:"
msgstr "" msgstr "Schriftgröße:"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Hide dupeGuru" msgid "Hide dupeGuru"
@@ -733,19 +743,19 @@ msgstr "Ignoriere Dateien kleiner als:"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Load from file..." msgid "Load from file..."
msgstr "Load from file..." msgstr "Lade von Datei..."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Minimize" msgid "Minimize"
msgstr "Im Dock ablegen" msgstr "Minimieren"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Mode" msgid "Mode"
msgstr "Mode" msgstr "Modus"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "More results" msgid "More results"
msgstr "More results" msgstr "Mehr Suchergebnisse"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Ok" msgid "Ok"
@@ -753,19 +763,19 @@ msgstr "Ok"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Options" msgid "Options"
msgstr "Options" msgstr "Optionen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Paste" msgid "Paste"
msgstr "Einsetzen" msgstr "Einfügen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Preferences..." msgid "Preferences..."
msgstr "Preferences..." msgstr "Einstellungen..."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Quick Look" msgid "Quick Look"
msgstr "" msgstr "Quick Look"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Quit dupeGuru" msgid "Quit dupeGuru"
@@ -773,35 +783,35 @@ msgstr "dupeGuru beenden"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Remove Dead Tracks in iTunes" msgid "Remove Dead Tracks in iTunes"
msgstr "Remove Dead Tracks in iTunes" msgstr "Entferne tote Tracks in iTunes"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Reset to Default" msgid "Reset to Default"
msgstr "Reset to Default" msgstr "Auf Voreinstellung zurücksetzen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Reset To Defaults" msgid "Reset To Defaults"
msgstr "" msgstr "Auf Voreinstellungen zurücksetzen"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Reveal" msgid "Reveal"
msgstr "" msgstr "Zeige"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Reveal Selected in Finder" msgid "Reveal Selected in Finder"
msgstr "Reveal Selected in Finder" msgstr "Zeige Auswahl im Finder"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Select All" msgid "Select All"
msgstr "" msgstr "Alles markieren"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Send Marked to Trash..." msgid "Send Marked to Trash..."
msgstr "" msgstr "Verschiebe Markierte in den Papierkorb..."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Services" msgid "Services"
msgstr "" msgstr "Services"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Show All" msgid "Show All"
@@ -809,11 +819,11 @@ msgstr "Alle einblenden"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Start Duplicate Scan" msgid "Start Duplicate Scan"
msgstr "Start Duplicate Scan" msgstr "Starte Duplikat-Scan"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "The name '%@' already exists." msgid "The name '%@' already exists."
msgstr "The name '%@' already exists." msgstr "Der Name '%@' existiert bereits."
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Window" msgid "Window"
@@ -821,4 +831,4 @@ msgstr "Fenster"
#: cocoa/base/en.lproj/Localizable.strings:0 #: cocoa/base/en.lproj/Localizable.strings:0
msgid "Zoom" msgid "Zoom"
msgstr "Zoomen" msgstr "Zoom"

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-12-30 # Created On: 2009-12-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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import os import os
@@ -16,9 +16,11 @@ import platform
import glob import glob
from hscommon.plat import ISWINDOWS, ISLINUX from hscommon.plat import ISWINDOWS, ISLINUX
from hscommon.build import (add_to_pythonpath, print_and_do, copy_packages, build_debian_changelog, from hscommon.build import (
add_to_pythonpath, print_and_do, copy_packages, build_debian_changelog,
copy_qt_plugins, get_module_version, filereplace, copy, setup_package_argparser, copy_qt_plugins, get_module_version, filereplace, copy, setup_package_argparser,
package_cocoa_app_in_dmg, copy_all) package_cocoa_app_in_dmg, copy_all, find_in_path
)
def parse_args(): def parse_args():
parser = ArgumentParser() parser = ArgumentParser()
@@ -38,20 +40,22 @@ def package_windows(edition, dev):
print("Qt packaging only works under Windows.") print("Qt packaging only works under Windows.")
return return
from cx_Freeze import setup, Executable from cx_Freeze import setup, Executable
from PyQt5.QtCore import QLibraryInfo
add_to_pythonpath('.') add_to_pythonpath('.')
app_version = get_module_version('core_{}'.format(edition)) app_version = get_module_version('core_{}'.format(edition))
distdir = 'dist' distdir = 'dist'
if op.exists(distdir): if op.exists(distdir):
shutil.rmtree(distdir) shutil.rmtree(distdir)
if not dev: if not dev:
# Copy qt plugins # Copy qt plugins
plugin_dest = distdir plugin_dest = distdir
plugin_names = ['accessible', 'codecs', 'iconengines', 'imageformats'] plugin_names = ['accessible', 'codecs', 'iconengines', 'imageformats']
copy_qt_plugins(plugin_names, plugin_dest) copy_qt_plugins(plugin_names, plugin_dest)
# Since v4.2.3, cx_freeze started to falsely include tkinter in the package. We exclude it explicitly because of that. # Since v4.2.3, cx_freeze started to falsely include tkinter in the package. We exclude it
# explicitly because of that.
options = { options = {
'build_exe': { 'build_exe': {
'includes': 'atexit', 'includes': 'atexit',
@@ -80,13 +84,15 @@ def package_windows(edition, dev):
executables=executables executables=executables
) )
print("Removing useless DLLs") print("Removing useless files")
# Huge useless dll that appeared with Qt5
for fn in glob.glob(op.join(distdir, 'icu*.dll')):
os.remove(fn)
# Debug info that cx_freeze brings in. # Debug info that cx_freeze brings in.
for fn in glob.glob(op.join(distdir, '*', '*.pdb')): for fn in glob.glob(op.join(distdir, '*', '*.pdb')):
os.remove(fn) os.remove(fn)
print("Copying forgotten DLLs")
qtlibpath = QLibraryInfo.location(QLibraryInfo.LibrariesPath)
shutil.copy(op.join(qtlibpath, 'libEGL.dll'), distdir)
shutil.copy(find_in_path('msvcp110.dll'), distdir)
print("Copying the rest")
help_path = op.join('build', 'help') help_path = op.join('build', 'help')
print("Copying {} to dist\\help".format(help_path)) print("Copying {} to dist\\help".format(help_path))
shutil.copytree(help_path, op.join(distdir, 'help')) shutil.copytree(help_path, op.join(distdir, 'help'))
@@ -126,7 +132,7 @@ def package_debian_distribution(edition, distribution):
ed = lambda s: s.format(edition) ed = lambda s: s.format(edition)
destpath = op.join('build', 'dupeguru-{0}-{1}'.format(edition, version)) destpath = op.join('build', 'dupeguru-{0}-{1}'.format(edition, version))
srcpath = op.join(destpath, 'src') srcpath = op.join(destpath, 'src')
packages = ['hscommon', 'core', ed('core_{0}'), 'qtlib', 'qt', 'send2trash', 'jobprogress'] packages = ['hscommon', 'core', ed('core_{0}'), 'qtlib', 'qt', 'send2trash']
if edition == 'me': if edition == 'me':
packages.append('hsaudiotag') packages.append('hsaudiotag')
copy_files_to_package(srcpath, packages, with_so=False) copy_files_to_package(srcpath, packages, with_so=False)
@@ -148,8 +154,10 @@ def package_debian_distribution(edition, distribution):
changelog_dest = op.join(debdest, 'changelog') changelog_dest = op.join(debdest, 'changelog')
project_name = debopts['pkgname'] project_name = debopts['pkgname']
from_version = {'se': '2.9.2', 'me': '5.7.2', 'pe': '1.8.5'}[edition] from_version = {'se': '2.9.2', 'me': '5.7.2', 'pe': '1.8.5'}[edition]
build_debian_changelog(changelogpath, changelog_dest, project_name, from_version=from_version, build_debian_changelog(
distribution=distribution) changelogpath, changelog_dest, project_name, from_version=from_version,
distribution=distribution
)
shutil.copy(op.join('images', ed('dg{0}_logo_128.png')), srcpath) shutil.copy(op.join('images', ed('dg{0}_logo_128.png')), srcpath)
os.chdir(destpath) os.chdir(destpath)
cmd = "dpkg-buildpackage -S" cmd = "dpkg-buildpackage -S"
@@ -158,7 +166,7 @@ def package_debian_distribution(edition, distribution):
def package_debian(edition): def package_debian(edition):
print("Packaging for Ubuntu") print("Packaging for Ubuntu")
for distribution in ['trusty']: for distribution in ['trusty', 'utopic']:
package_debian_distribution(edition, distribution) package_debian_distribution(edition, distribution)
def package_arch(edition): def package_arch(edition):
@@ -168,7 +176,7 @@ def package_arch(edition):
print("Packaging for Arch") print("Packaging for Arch")
ed = lambda s: s.format(edition) ed = lambda s: s.format(edition)
srcpath = op.join('build', ed('dupeguru-{}-arch')) srcpath = op.join('build', ed('dupeguru-{}-arch'))
packages = ['hscommon', 'core', ed('core_{0}'), 'qtlib', 'qt', 'send2trash', 'jobprogress'] packages = ['hscommon', 'core', ed('core_{0}'), 'qtlib', 'qt', 'send2trash']
if edition == 'me': if edition == 'me':
packages.append('hsaudiotag') packages.append('hsaudiotag')
copy_files_to_package(srcpath, packages, with_so=True) copy_files_to_package(srcpath, packages, with_so=True)
@@ -206,7 +214,10 @@ def main():
if ISWINDOWS: if ISWINDOWS:
package_windows(edition, dev) package_windows(edition, dev)
elif ISLINUX: elif ISLINUX:
distname, _, _ = platform.dist() if not args.arch_pkg:
distname, _, _ = platform.dist()
else:
distname = 'arch'
if distname == 'arch': if distname == 'arch':
package_arch(edition) package_arch(edition)
else: else:
@@ -216,3 +227,4 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@@ -2,7 +2,7 @@ Source: {pkgname}
Section: devel Section: devel
Priority: extra Priority: extra
Maintainer: Virgil Dupras <hsoft@hardcoded.net> Maintainer: Virgil Dupras <hsoft@hardcoded.net>
Build-Depends: debhelper (>= 7), python3-dev Build-Depends: debhelper (>= 7), python3-dev, python3-setuptools
Standards-Version: 3.8.1 Standards-Version: 3.8.1
Homepage: http://www.hardcoded.net Homepage: http://www.hardcoded.net

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-04-25 # Created On: 2009-04-25
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys import sys
@@ -14,13 +14,12 @@ from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox
from hscommon.trans import trget from hscommon.trans import trget
from hscommon.plat import ISLINUX
from hscommon import desktop from hscommon import desktop
from qtlib.about_box import AboutBox from qtlib.about_box import AboutBox
from qtlib.recent import Recent from qtlib.recent import Recent
from qtlib.util import createActions from qtlib.util import createActions
from qtlib.progress_window import ProgressWindow from qtlib.progress_window import ProgressWindow
from . import platform from . import platform
from .result_window import ResultWindow from .result_window import ResultWindow
@@ -35,13 +34,13 @@ class DupeGuru(QObject):
MODELCLASS = None MODELCLASS = None
LOGO_NAME = '<replace this>' LOGO_NAME = '<replace this>'
NAME = '<replace this>' NAME = '<replace this>'
DETAILS_DIALOG_CLASS = None DETAILS_DIALOG_CLASS = None
RESULT_WINDOW_CLASS = ResultWindow RESULT_WINDOW_CLASS = ResultWindow
RESULT_MODEL_CLASS = None RESULT_MODEL_CLASS = None
PREFERENCES_CLASS = None PREFERENCES_CLASS = None
PREFERENCES_DIALOG_CLASS = None PREFERENCES_DIALOG_CLASS = None
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.prefs = self.PREFERENCES_CLASS() self.prefs = self.PREFERENCES_CLASS()
@@ -49,7 +48,7 @@ class DupeGuru(QObject):
self.model = self.MODELCLASS(view=self) self.model = self.MODELCLASS(view=self)
self._setup() self._setup()
self.prefsChanged.emit(self.prefs) self.prefsChanged.emit(self.prefs)
#--- Private #--- Private
def _setup(self): def _setup(self):
self._setupActions() self._setupActions()
@@ -58,24 +57,24 @@ class DupeGuru(QObject):
self.recentResults.mustOpenItem.connect(self.model.load_from) self.recentResults.mustOpenItem.connect(self.model.load_from)
self.directories_dialog = DirectoriesDialog(self) self.directories_dialog = DirectoriesDialog(self)
self.resultWindow = self.RESULT_WINDOW_CLASS(self.directories_dialog, self) self.resultWindow = self.RESULT_WINDOW_CLASS(self.directories_dialog, self)
self.progress_window = ProgressWindow(self.resultWindow, self.model.progress_window) self.progress_window = ProgressWindow(self.resultWindow, self.model.progress_window)
self.details_dialog = self.DETAILS_DIALOG_CLASS(self.resultWindow, self) self.details_dialog = self.DETAILS_DIALOG_CLASS(self.resultWindow, self)
self.problemDialog = ProblemDialog(parent=self.resultWindow, model=self.model.problem_dialog) self.problemDialog = ProblemDialog(parent=self.resultWindow, model=self.model.problem_dialog)
self.ignoreListDialog = IgnoreListDialog(parent=self.resultWindow, model=self.model.ignore_list_dialog) self.ignoreListDialog = IgnoreListDialog(parent=self.resultWindow, model=self.model.ignore_list_dialog)
self.deletionOptions = DeletionOptions(parent=self.resultWindow, model=self.model.deletion_options) self.deletionOptions = DeletionOptions(parent=self.resultWindow, model=self.model.deletion_options)
self.preferences_dialog = self.PREFERENCES_DIALOG_CLASS(self.resultWindow, self) self.preferences_dialog = self.PREFERENCES_DIALOG_CLASS(self.resultWindow, self)
self.about_box = AboutBox(self.resultWindow, self) self.about_box = AboutBox(self.resultWindow, self)
self.directories_dialog.show() self.directories_dialog.show()
self.model.load() self.model.load()
# The timer scheme is because if the nag is not shown before the application is # The timer scheme is because if the nag is not shown before the application is
# completely initialized, the nag will be shown before the app shows up in the task bar # completely initialized, the nag will be shown before the app shows up in the task bar
# In some circumstances, the nag is hidden by other window, which may make the user think # In some circumstances, the nag is hidden by other window, which may make the user think
# that the application haven't launched. # that the application haven't launched.
QTimer.singleShot(0, self.finishedLaunching) QTimer.singleShot(0, self.finishedLaunching)
QCoreApplication.instance().aboutToQuit.connect(self.application_will_terminate) QCoreApplication.instance().aboutToQuit.connect(self.application_will_terminate)
def _setupActions(self): def _setupActions(self):
# Setup actions that are common to both the directory dialog and the results window. # Setup actions that are common to both the directory dialog and the results window.
# (name, shortcut, icon, desc, func) # (name, shortcut, icon, desc, func)
@@ -88,61 +87,61 @@ class DupeGuru(QObject):
('actionOpenDebugLog', '', '', tr("Open Debug Log"), self.openDebugLogTriggered), ('actionOpenDebugLog', '', '', tr("Open Debug Log"), self.openDebugLogTriggered),
] ]
createActions(ACTIONS, self) createActions(ACTIONS, self)
def _update_options(self): def _update_options(self):
self.model.scanner.mix_file_kind = self.prefs.mix_file_kind self.model.scanner.mix_file_kind = self.prefs.mix_file_kind
self.model.options['escape_filter_regexp'] = self.prefs.use_regexp self.model.options['escape_filter_regexp'] = self.prefs.use_regexp
self.model.options['clean_empty_dirs'] = self.prefs.remove_empty_folders self.model.options['clean_empty_dirs'] = self.prefs.remove_empty_folders
self.model.options['ignore_hardlink_matches'] = self.prefs.ignore_hardlink_matches self.model.options['ignore_hardlink_matches'] = self.prefs.ignore_hardlink_matches
self.model.options['copymove_dest_type'] = self.prefs.destination_type self.model.options['copymove_dest_type'] = self.prefs.destination_type
#--- Public #--- Public
def add_selected_to_ignore_list(self): def add_selected_to_ignore_list(self):
self.model.add_selected_to_ignore_list() self.model.add_selected_to_ignore_list()
def remove_selected(self): def remove_selected(self):
self.model.remove_selected(self) self.model.remove_selected(self)
def confirm(self, title, msg, default_button=QMessageBox.Yes): def confirm(self, title, msg, default_button=QMessageBox.Yes):
active = QApplication.activeWindow() active = QApplication.activeWindow()
buttons = QMessageBox.Yes | QMessageBox.No buttons = QMessageBox.Yes | QMessageBox.No
answer = QMessageBox.question(active, title, msg, buttons, default_button) answer = QMessageBox.question(active, title, msg, buttons, default_button)
return answer == QMessageBox.Yes return answer == QMessageBox.Yes
def invokeCustomCommand(self): def invokeCustomCommand(self):
self.model.invoke_custom_command() self.model.invoke_custom_command()
def show_details(self): def show_details(self):
self.details_dialog.show() self.details_dialog.show()
def showResultsWindow(self): def showResultsWindow(self):
self.resultWindow.show() self.resultWindow.show()
#--- Signals #--- Signals
willSavePrefs = pyqtSignal() willSavePrefs = pyqtSignal()
prefsChanged = pyqtSignal(object) prefsChanged = pyqtSignal(object)
#--- Events #--- Events
def finishedLaunching(self): def finishedLaunching(self):
if sys.getfilesystemencoding() == 'ascii': if sys.getfilesystemencoding() == 'ascii':
# No need to localize this, it's a debugging message. # No need to localize this, it's a debugging message.
msg = "Something is wrong with the way your system locale is set. If the files you're "\ msg = "Something is wrong with the way your system locale is set. If the files you're "\
"scanning have accented letters, you'll probably get a crash. It is advised that "\ "scanning have accented letters, you'll probably get a crash. It is advised that "\
"you set your system locale properly." "you set your system locale properly."
QMessageBox.warning(self.directories_dialog, "Wrong Locale", msg) QMessageBox.warning(self.directories_dialog, "Wrong Locale", msg)
def application_will_terminate(self): def application_will_terminate(self):
self.willSavePrefs.emit() self.willSavePrefs.emit()
self.prefs.save() self.prefs.save()
self.model.save() self.model.save()
def ignoreListTriggered(self): def ignoreListTriggered(self):
self.model.ignore_list_dialog.show() self.model.ignore_list_dialog.show()
def openDebugLogTriggered(self): def openDebugLogTriggered(self):
debugLogPath = op.join(self.model.appdata, 'debug.log') debugLogPath = op.join(self.model.appdata, 'debug.log')
desktop.open_path(debugLogPath) desktop.open_path(debugLogPath)
def preferencesTriggered(self): def preferencesTriggered(self):
self.preferences_dialog.load() self.preferences_dialog.load()
result = self.preferences_dialog.exec() result = self.preferences_dialog.exec()
@@ -151,42 +150,42 @@ class DupeGuru(QObject):
self.prefs.save() self.prefs.save()
self._update_options() self._update_options()
self.prefsChanged.emit(self.prefs) self.prefsChanged.emit(self.prefs)
def quitTriggered(self): def quitTriggered(self):
self.directories_dialog.close() self.directories_dialog.close()
def showAboutBoxTriggered(self): def showAboutBoxTriggered(self):
self.about_box.show() self.about_box.show()
def showHelpTriggered(self): def showHelpTriggered(self):
base_path = platform.HELP_PATH base_path = platform.HELP_PATH
url = QUrl.fromLocalFile(op.abspath(op.join(base_path, 'index.html'))) url = QUrl.fromLocalFile(op.abspath(op.join(base_path, 'index.html')))
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)
#--- model --> view #--- model --> view
def get_default(self, key): def get_default(self, key):
return self.prefs.get_value(key) return self.prefs.get_value(key)
def set_default(self, key, value): def set_default(self, key, value):
self.prefs.set_value(key, value) self.prefs.set_value(key, value)
def show_message(self, msg): def show_message(self, msg):
window = QApplication.activeWindow() window = QApplication.activeWindow()
QMessageBox.information(window, '', msg) QMessageBox.information(window, '', msg)
def ask_yes_no(self, prompt): def ask_yes_no(self, prompt):
return self.confirm('', prompt) return self.confirm('', prompt)
def show_results_window(self): def show_results_window(self):
self.showResultsWindow() self.showResultsWindow()
def show_problem_dialog(self): def show_problem_dialog(self):
self.problemDialog.show() self.problemDialog.show()
def select_dest_folder(self, prompt): def select_dest_folder(self, prompt):
flags = QFileDialog.ShowDirsOnly flags = QFileDialog.ShowDirsOnly
return QFileDialog.getExistingDirectory(self.resultWindow, prompt, '', flags) return QFileDialog.getExistingDirectory(self.resultWindow, prompt, '', flags)
def select_dest_file(self, prompt, extension): def select_dest_file(self, prompt, extension):
files = tr("{} file (*.{})").format(extension.upper(), extension) files = tr("{} file (*.{})").format(extension.upper(), extension)
destination, chosen_filter = QFileDialog.getSaveFileName(self.resultWindow, prompt, '', files) destination, chosen_filter = QFileDialog.getSaveFileName(self.resultWindow, prompt, '', files)

View File

@@ -1,5 +1,6 @@
# cxfreeze has some problems detecting all dependencies. # cxfreeze has some problems detecting all dependencies.
# This modules explicitly import those problematic modules. # This modules explicitly import those problematic modules.
# flake8: noqa
import xml.etree.ElementPath import xml.etree.ElementPath
import gzip import gzip

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2012-05-30 # Created On: 2012-05-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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
@@ -21,11 +21,11 @@ class DeletionOptions(QDialog):
self.model = model self.model = model
self._setupUi() self._setupUi()
self.model.view = self self.model.view = self
self.linkCheckbox.stateChanged.connect(self.linkCheckboxChanged) self.linkCheckbox.stateChanged.connect(self.linkCheckboxChanged)
self.buttonBox.accepted.connect(self.accept) self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject) self.buttonBox.rejected.connect(self.reject)
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("Deletion Options")) self.setWindowTitle(tr("Deletion Options"))
self.resize(400, 270) self.resize(400, 270)
@@ -34,8 +34,10 @@ class DeletionOptions(QDialog):
self.verticalLayout.addWidget(self.msgLabel) self.verticalLayout.addWidget(self.msgLabel)
self.linkCheckbox = QCheckBox(tr("Link deleted files")) self.linkCheckbox = QCheckBox(tr("Link deleted files"))
self.verticalLayout.addWidget(self.linkCheckbox) self.verticalLayout.addWidget(self.linkCheckbox)
text = tr("After having deleted a duplicate, place a link targeting the reference file " text = tr(
"to replace the deleted file.") "After having deleted a duplicate, place a link targeting the reference file "
"to replace the deleted file."
)
self.linkMessageLabel = QLabel(text) self.linkMessageLabel = QLabel(text)
self.linkMessageLabel.setWordWrap(True) self.linkMessageLabel.setWordWrap(True)
self.verticalLayout.addWidget(self.linkMessageLabel) self.verticalLayout.addWidget(self.linkMessageLabel)
@@ -46,8 +48,10 @@ class DeletionOptions(QDialog):
self.linkCheckbox.setText(self.linkCheckbox.text() + tr(" (unsupported)")) self.linkCheckbox.setText(self.linkCheckbox.text() + tr(" (unsupported)"))
self.directCheckbox = QCheckBox(tr("Directly delete files")) self.directCheckbox = QCheckBox(tr("Directly delete files"))
self.verticalLayout.addWidget(self.directCheckbox) self.verticalLayout.addWidget(self.directCheckbox)
text = tr("Instead of sending files to trash, delete them directly. This option is usually " text = tr(
"used as a workaround when the normal deletion method doesn't work.") "Instead of sending files to trash, delete them directly. This option is usually "
"used as a workaround when the normal deletion method doesn't work."
)
self.directMessageLabel = QLabel(text) self.directMessageLabel = QLabel(text)
self.directMessageLabel.setWordWrap(True) self.directMessageLabel.setWordWrap(True)
self.verticalLayout.addWidget(self.directMessageLabel) self.verticalLayout.addWidget(self.directMessageLabel)
@@ -55,15 +59,15 @@ class DeletionOptions(QDialog):
self.buttonBox.addButton(tr("Proceed"), QDialogButtonBox.AcceptRole) self.buttonBox.addButton(tr("Proceed"), QDialogButtonBox.AcceptRole)
self.buttonBox.addButton(tr("Cancel"), QDialogButtonBox.RejectRole) self.buttonBox.addButton(tr("Cancel"), QDialogButtonBox.RejectRole)
self.verticalLayout.addWidget(self.buttonBox) self.verticalLayout.addWidget(self.buttonBox)
#--- Signals #--- Signals
def linkCheckboxChanged(self, changed: int): def linkCheckboxChanged(self, changed: int):
self.model.link_deleted = bool(changed) self.model.link_deleted = bool(changed)
#--- model --> view #--- model --> view
def update_msg(self, msg: str): def update_msg(self, msg: str):
self.msgLabel.setText(msg) self.msgLabel.setText(msg)
def show(self): def show(self):
self.linkCheckbox.setChecked(self.model.link_deleted) self.linkCheckbox.setChecked(self.model.link_deleted)
self.linkTypeRadio.selected_index = 1 if self.model.use_hardlinks else 0 self.linkTypeRadio.selected_index = 1 if self.model.use_hardlinks else 0
@@ -73,7 +77,7 @@ class DeletionOptions(QDialog):
self.model.use_hardlinks = self.linkTypeRadio.selected_index == 1 self.model.use_hardlinks = self.linkTypeRadio.selected_index == 1
self.model.direct = self.directCheckbox.isChecked() self.model.direct = self.directCheckbox.isChecked()
return result == QDialog.Accepted return result == QDialog.Accepted
def set_hardlink_option_enabled(self, is_enabled: bool): def set_hardlink_option_enabled(self, is_enabled: bool):
self.linkTypeRadio.setEnabled(is_enabled) self.linkTypeRadio.setEnabled(is_enabled)

View File

@@ -1,15 +1,17 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-04-25 # Created On: 2009-04-25
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from PyQt5.QtCore import QRect from PyQt5.QtCore import QRect
from PyQt5.QtWidgets import (QWidget, QFileDialog, QHeaderView, QVBoxLayout, QHBoxLayout, QTreeView, from PyQt5.QtWidgets import (
QWidget, QFileDialog, QHeaderView, QVBoxLayout, QHBoxLayout, QTreeView,
QAbstractItemView, QSpacerItem, QSizePolicy, QPushButton, QMainWindow, QMenuBar, QMenu, QLabel, QAbstractItemView, QSpacerItem, QSizePolicy, QPushButton, QMainWindow, QMenuBar, QMenu, QLabel,
QApplication) QApplication
)
from PyQt5.QtGui import QPixmap, QIcon from PyQt5.QtGui import QPixmap, QIcon
from hscommon.trans import trget from hscommon.trans import trget
@@ -39,7 +41,7 @@ class DirectoriesDialog(QMainWindow):
self._updateRemoveButton() self._updateRemoveButton()
self._updateLoadResultsButton() self._updateLoadResultsButton()
self._setupBindings() self._setupBindings()
def _setupBindings(self): def _setupBindings(self):
self.scanButton.clicked.connect(self.scanButtonClicked) self.scanButton.clicked.connect(self.scanButtonClicked)
self.loadResultsButton.clicked.connect(self.actionLoadResults.trigger) self.loadResultsButton.clicked.connect(self.actionLoadResults.trigger)
@@ -51,7 +53,7 @@ class DirectoriesDialog(QMainWindow):
self.recentFolders.mustOpenItem.connect(self.app.model.add_directory) self.recentFolders.mustOpenItem.connect(self.app.model.add_directory)
self.directoriesModel.foldersAdded.connect(self.directoriesModelAddedFolders) self.directoriesModel.foldersAdded.connect(self.directoriesModelAddedFolders)
self.app.willSavePrefs.connect(self.appWillSavePrefs) self.app.willSavePrefs.connect(self.appWillSavePrefs)
def _setupActions(self): def _setupActions(self):
# (name, shortcut, icon, desc, func) # (name, shortcut, icon, desc, func)
ACTIONS = [ ACTIONS = [
@@ -60,7 +62,7 @@ class DirectoriesDialog(QMainWindow):
('actionAddFolder', '', '', tr("Add Folder..."), self.addFolderTriggered), ('actionAddFolder', '', '', tr("Add Folder..."), self.addFolderTriggered),
] ]
createActions(ACTIONS, self) createActions(ACTIONS, self)
def _setupMenu(self): def _setupMenu(self):
self.menubar = QMenuBar(self) self.menubar = QMenuBar(self)
self.menubar.setGeometry(QRect(0, 0, 42, 22)) self.menubar.setGeometry(QRect(0, 0, 42, 22))
@@ -73,7 +75,7 @@ class DirectoriesDialog(QMainWindow):
self.menuLoadRecent = QMenu(self.menuFile) self.menuLoadRecent = QMenu(self.menuFile)
self.menuLoadRecent.setTitle(tr("Load Recent Results")) self.menuLoadRecent.setTitle(tr("Load Recent Results"))
self.setMenuBar(self.menubar) self.setMenuBar(self.menubar)
self.menuFile.addAction(self.actionLoadResults) self.menuFile.addAction(self.actionLoadResults)
self.menuFile.addAction(self.menuLoadRecent.menuAction()) self.menuFile.addAction(self.menuLoadRecent.menuAction())
self.menuFile.addSeparator() self.menuFile.addSeparator()
@@ -84,21 +86,21 @@ class DirectoriesDialog(QMainWindow):
self.menuHelp.addAction(self.app.actionShowHelp) self.menuHelp.addAction(self.app.actionShowHelp)
self.menuHelp.addAction(self.app.actionOpenDebugLog) self.menuHelp.addAction(self.app.actionOpenDebugLog)
self.menuHelp.addAction(self.app.actionAbout) self.menuHelp.addAction(self.app.actionAbout)
self.menubar.addAction(self.menuFile.menuAction()) self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuView.menuAction()) self.menubar.addAction(self.menuView.menuAction())
self.menubar.addAction(self.menuHelp.menuAction()) self.menubar.addAction(self.menuHelp.menuAction())
# Recent folders menu # Recent folders menu
self.menuRecentFolders = QMenu() self.menuRecentFolders = QMenu()
self.menuRecentFolders.addAction(self.actionAddFolder) self.menuRecentFolders.addAction(self.actionAddFolder)
self.menuRecentFolders.addSeparator() self.menuRecentFolders.addSeparator()
# Recent results menu # Recent results menu
self.menuRecentResults = QMenu() self.menuRecentResults = QMenu()
self.menuRecentResults.addAction(self.actionLoadResults) self.menuRecentResults.addAction(self.actionLoadResults)
self.menuRecentResults.addSeparator() self.menuRecentResults.addSeparator()
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(self.app.NAME) self.setWindowTitle(self.app.NAME)
self.resize(420, 338) self.resize(420, 338)
@@ -110,8 +112,8 @@ class DirectoriesDialog(QMainWindow):
self.treeView.setSelectionMode(QAbstractItemView.ExtendedSelection) self.treeView.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.treeView.setSelectionBehavior(QAbstractItemView.SelectRows) self.treeView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.treeView.setAcceptDrops(True) self.treeView.setAcceptDrops(True)
triggers = QAbstractItemView.DoubleClicked|QAbstractItemView.EditKeyPressed\ triggers = QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed\
|QAbstractItemView.SelectedClicked | QAbstractItemView.SelectedClicked
self.treeView.setEditTriggers(triggers) self.treeView.setEditTriggers(triggers)
self.treeView.setDragDropOverwriteMode(True) self.treeView.setDragDropOverwriteMode(True)
self.treeView.setDragDropMode(QAbstractItemView.DropOnly) self.treeView.setDragDropMode(QAbstractItemView.DropOnly)
@@ -136,41 +138,41 @@ class DirectoriesDialog(QMainWindow):
self.horizontalLayout.addWidget(self.scanButton) self.horizontalLayout.addWidget(self.scanButton)
self.verticalLayout.addLayout(self.horizontalLayout) self.verticalLayout.addLayout(self.horizontalLayout)
self.setCentralWidget(self.centralwidget) self.setCentralWidget(self.centralwidget)
self._setupActions() self._setupActions()
self._setupMenu() self._setupMenu()
if self.app.prefs.directoriesWindowRect is not None: if self.app.prefs.directoriesWindowRect is not None:
self.setGeometry(self.app.prefs.directoriesWindowRect) self.setGeometry(self.app.prefs.directoriesWindowRect)
else: else:
moveToScreenCenter(self) moveToScreenCenter(self)
def _setupColumns(self): def _setupColumns(self):
header = self.treeView.header() header = self.treeView.header()
header.setStretchLastSection(False) header.setStretchLastSection(False)
header.setSectionResizeMode(0, QHeaderView.Stretch) header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.Fixed) header.setSectionResizeMode(1, QHeaderView.Fixed)
header.resizeSection(1, 100) header.resizeSection(1, 100)
def _updateAddButton(self): def _updateAddButton(self):
if self.recentFolders.isEmpty(): if self.recentFolders.isEmpty():
self.addFolderButton.setMenu(None) self.addFolderButton.setMenu(None)
else: else:
self.addFolderButton.setMenu(self.menuRecentFolders) self.addFolderButton.setMenu(self.menuRecentFolders)
def _updateRemoveButton(self): def _updateRemoveButton(self):
indexes = self.treeView.selectedIndexes() indexes = self.treeView.selectedIndexes()
if not indexes: if not indexes:
self.removeFolderButton.setEnabled(False) self.removeFolderButton.setEnabled(False)
return return
self.removeFolderButton.setEnabled(True) self.removeFolderButton.setEnabled(True)
def _updateLoadResultsButton(self): def _updateLoadResultsButton(self):
if self.app.recentResults.isEmpty(): if self.app.recentResults.isEmpty():
self.loadResultsButton.setMenu(None) self.loadResultsButton.setMenu(None)
else: else:
self.loadResultsButton.setMenu(self.menuRecentResults) self.loadResultsButton.setMenu(self.menuRecentResults)
#--- QWidget overrides #--- QWidget overrides
def closeEvent(self, event): def closeEvent(self, event):
event.accept() event.accept()
@@ -181,7 +183,7 @@ class DirectoriesDialog(QMainWindow):
event.ignore() event.ignore()
if event.isAccepted(): if event.isAccepted():
QApplication.quit() QApplication.quit()
#--- Events #--- Events
def addFolderTriggered(self): def addFolderTriggered(self):
title = tr("Select a folder to add to the scanning list") title = tr("Select a folder to add to the scanning list")
@@ -192,14 +194,14 @@ class DirectoriesDialog(QMainWindow):
self.lastAddedFolder = dirpath self.lastAddedFolder = dirpath
self.app.model.add_directory(dirpath) self.app.model.add_directory(dirpath)
self.recentFolders.insertItem(dirpath) self.recentFolders.insertItem(dirpath)
def appWillSavePrefs(self): def appWillSavePrefs(self):
self.app.prefs.directoriesWindowRect = self.geometry() self.app.prefs.directoriesWindowRect = self.geometry()
def directoriesModelAddedFolders(self, folders): def directoriesModelAddedFolders(self, folders):
for folder in folders: for folder in folders:
self.recentFolders.insertItem(folder) self.recentFolders.insertItem(folder)
def loadResultsTriggered(self): def loadResultsTriggered(self):
title = tr("Select a results file to load") title = tr("Select a results file to load")
files = ';;'.join([tr("dupeGuru Results (*.dupeguru)"), tr("All Files (*.*)")]) files = ';;'.join([tr("dupeGuru Results (*.dupeguru)"), tr("All Files (*.*)")])
@@ -207,10 +209,10 @@ class DirectoriesDialog(QMainWindow):
if destination: if destination:
self.app.model.load_from(destination) self.app.model.load_from(destination)
self.app.recentResults.insertItem(destination) self.app.recentResults.insertItem(destination)
def removeFolderButtonClicked(self): def removeFolderButtonClicked(self):
self.directoriesModel.model.remove_selected() self.directoriesModel.model.remove_selected()
def scanButtonClicked(self): def scanButtonClicked(self):
if self.app.model.results.is_modified: if self.app.model.results.is_modified:
title = tr("Start a new scan") title = tr("Start a new scan")
@@ -218,17 +220,17 @@ class DirectoriesDialog(QMainWindow):
if not self.app.confirm(title, msg): if not self.app.confirm(title, msg):
return return
self.app.model.start_scanning() self.app.model.start_scanning()
def selectionChanged(self, selected, deselected): def selectionChanged(self, selected, deselected):
self._updateRemoveButton() self._updateRemoveButton()
if __name__ == '__main__': if __name__ == '__main__':
import sys import sys
from . import dg_rc from . import dg_rc # NOQA
from ..testapp import TestApp from ..testapp import TestApp
app = QApplication([]) app = QApplication([])
dgapp = TestApp() dgapp = TestApp()
dialog = DirectoriesDialog(None, dgapp) dialog = DirectoriesDialog(None, dgapp)
dialog.show() dialog.show()
sys.exit(app.exec_()) sys.exit(app.exec_())

View File

@@ -1,16 +1,18 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-04-25 # Created On: 2009-04-25
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import urllib.parse import urllib.parse
from PyQt5.QtCore import pyqtSignal, Qt, QRect, QUrl, QModelIndex, QItemSelection from PyQt5.QtCore import pyqtSignal, Qt, QRect, QUrl, QModelIndex, QItemSelection
from PyQt5.QtWidgets import (QComboBox, QStyledItemDelegate, QStyle, QStyleOptionComboBox, from PyQt5.QtWidgets import (
QStyleOptionViewItem, QApplication) QComboBox, QStyledItemDelegate, QStyle, QStyleOptionComboBox,
QStyleOptionViewItem, QApplication
)
from PyQt5.QtGui import QBrush from PyQt5.QtGui import QBrush
from hscommon.trans import trget from hscommon.trans import trget
@@ -23,10 +25,10 @@ STATES = [tr("Normal"), tr("Reference"), tr("Excluded")]
class DirectoriesDelegate(QStyledItemDelegate): class DirectoriesDelegate(QStyledItemDelegate):
def createEditor(self, parent, option, index): def createEditor(self, parent, option, index):
editor = QComboBox(parent); editor = QComboBox(parent)
editor.addItems(STATES) editor.addItems(STATES)
return editor return editor
def paint(self, painter, option, index): def paint(self, painter, option, index):
self.initStyleOption(option, index) self.initStyleOption(option, index)
# No idea why, but this cast is required if we want to have access to the V4 valuess # No idea why, but this cast is required if we want to have access to the V4 valuess
@@ -44,19 +46,19 @@ class DirectoriesDelegate(QStyledItemDelegate):
painter.drawText(rect, Qt.AlignLeft, option.text) painter.drawText(rect, Qt.AlignLeft, option.text)
else: else:
super().paint(painter, option, index) super().paint(painter, option, index)
def setEditorData(self, editor, index): def setEditorData(self, editor, index):
value = index.model().data(index, Qt.EditRole) value = index.model().data(index, Qt.EditRole)
editor.setCurrentIndex(value); editor.setCurrentIndex(value)
editor.showPopup() editor.showPopup()
def setModelData(self, editor, model, index): def setModelData(self, editor, model, index):
value = editor.currentIndex() value = editor.currentIndex()
model.setData(index, value, Qt.EditRole) model.setData(index, value, Qt.EditRole)
def updateEditorGeometry(self, editor, option, index): def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect) editor.setGeometry(option.rect)
class DirectoriesModel(TreeModel): class DirectoriesModel(TreeModel):
def __init__(self, model, view, **kwargs): def __init__(self, model, view, **kwargs):
@@ -65,18 +67,18 @@ class DirectoriesModel(TreeModel):
self.model.view = self self.model.view = self
self.view = view self.view = view
self.view.setModel(self) self.view.setModel(self)
self.view.selectionModel().selectionChanged[(QItemSelection, QItemSelection)].connect(self.selectionChanged) self.view.selectionModel().selectionChanged[(QItemSelection, QItemSelection)].connect(self.selectionChanged)
def _createNode(self, ref, row): def _createNode(self, ref, row):
return RefNode(self, None, ref, row) return RefNode(self, None, ref, row)
def _getChildren(self): def _getChildren(self):
return list(self.model) return list(self.model)
def columnCount(self, parent=QModelIndex()): def columnCount(self, parent=QModelIndex()):
return 2 return 2
def data(self, index, role): def data(self, index, role):
if not index.isValid(): if not index.isValid():
return None return None
@@ -96,7 +98,7 @@ class DirectoriesModel(TreeModel):
elif state == 2: elif state == 2:
return QBrush(Qt.red) return QBrush(Qt.red)
return None return None
def dropMimeData(self, mimeData, action, row, column, parentIndex): def dropMimeData(self, mimeData, action, row, column, parentIndex):
# the data in mimeData is urlencoded **in utf-8**!!! What we do is to decode, the mime data # the data in mimeData is urlencoded **in utf-8**!!! What we do is to decode, the mime data
# with 'ascii', which works since it's urlencoded. Then, we pass that to urllib. # with 'ascii', which works since it's urlencoded. Then, we pass that to urllib.
@@ -111,7 +113,7 @@ class DirectoriesModel(TreeModel):
self.foldersAdded.emit(paths) self.foldersAdded.emit(paths)
self.reset() self.reset()
return True return True
def flags(self, index): def flags(self, index):
if not index.isValid(): if not index.isValid():
return Qt.ItemIsEnabled | Qt.ItemIsDropEnabled return Qt.ItemIsEnabled | Qt.ItemIsDropEnabled
@@ -119,16 +121,16 @@ class DirectoriesModel(TreeModel):
if index.column() == 1: if index.column() == 1:
result |= Qt.ItemIsEditable result |= Qt.ItemIsEditable
return result return result
def headerData(self, section, orientation, role): def headerData(self, section, orientation, role):
if orientation == Qt.Horizontal: if orientation == Qt.Horizontal:
if role == Qt.DisplayRole and section < len(HEADERS): if role == Qt.DisplayRole and section < len(HEADERS):
return HEADERS[section] return HEADERS[section]
return None return None
def mimeTypes(self): def mimeTypes(self):
return ['text/uri-list'] return ['text/uri-list']
def setData(self, index, value, role): def setData(self, index, value, role):
if not index.isValid() or role != Qt.EditRole or index.column() != 1: if not index.isValid() or role != Qt.EditRole or index.column() != 1:
return False return False
@@ -136,24 +138,24 @@ class DirectoriesModel(TreeModel):
ref = node.ref ref = node.ref
ref.state = value ref.state = value
return True return True
def supportedDropActions(self): def supportedDropActions(self):
# Normally, the correct action should be ActionLink, but the drop doesn't work. It doesn't # Normally, the correct action should be ActionLink, but the drop doesn't work. It doesn't
# work with ActionMove either. So screw that, and accept anything. # work with ActionMove either. So screw that, and accept anything.
return Qt.ActionMask return Qt.ActionMask
#--- Events #--- Events
def selectionChanged(self, selected, deselected): def selectionChanged(self, selected, deselected):
newNodes = [modelIndex.internalPointer().ref for modelIndex in self.view.selectionModel().selectedRows()] newNodes = [modelIndex.internalPointer().ref for modelIndex in self.view.selectionModel().selectedRows()]
self.model.selected_nodes = newNodes self.model.selected_nodes = newNodes
#--- Signals #--- Signals
foldersAdded = pyqtSignal(list) foldersAdded = pyqtSignal(list)
#--- model --> view #--- model --> view
def refresh(self): def refresh(self):
self.reset() self.reset()
def refresh_states(self): def refresh_states(self):
self.refreshData() self.refreshData()

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2012-03-13 # Created On: 2012-03-13
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
@@ -23,11 +23,11 @@ class IgnoreListDialog(QDialog):
self.model = model self.model = model
self.model.view = self self.model.view = self
self.table = IgnoreListTable(self.model.ignore_list_table, view=self.tableView) self.table = IgnoreListTable(self.model.ignore_list_table, view=self.tableView)
self.removeSelectedButton.clicked.connect(self.model.remove_selected) self.removeSelectedButton.clicked.connect(self.model.remove_selected)
self.clearButton.clicked.connect(self.model.clear) self.clearButton.clicked.connect(self.model.clear)
self.closeButton.clicked.connect(self.accept) self.closeButton.clicked.connect(self.accept)
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("Ignore List")) self.setWindowTitle(tr("Ignore List"))
self.resize(540, 330) self.resize(540, 330)
@@ -45,10 +45,14 @@ class IgnoreListDialog(QDialog):
self.removeSelectedButton = QPushButton(tr("Remove Selected")) self.removeSelectedButton = QPushButton(tr("Remove Selected"))
self.clearButton = QPushButton(tr("Clear")) self.clearButton = QPushButton(tr("Clear"))
self.closeButton = QPushButton(tr("Close")) self.closeButton = QPushButton(tr("Close"))
self.verticalLayout.addLayout(horizontalWrap([self.removeSelectedButton, self.clearButton, self.verticalLayout.addLayout(
None, self.closeButton])) horizontalWrap([
self.removeSelectedButton, self.clearButton,
None, self.closeButton
])
)
#--- model --> view #--- model --> view
def show(self): def show(self):
super().show() super().show()

View File

@@ -1,14 +1,16 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-01-21 # Created On: 2011-01-21
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from PyQt5.QtCore import Qt, QSize from PyQt5.QtCore import Qt, QSize
from PyQt5.QtWidgets import (QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, from PyQt5.QtWidgets import (
QSlider, QSizePolicy, QSpacerItem, QCheckBox, QLineEdit, QMessageBox, QSpinBox) QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
QSlider, QSizePolicy, QSpacerItem, QCheckBox, QLineEdit, QMessageBox, QSpinBox
)
from hscommon.plat import ISOSX, ISLINUX from hscommon.plat import ISOSX, ISLINUX
from hscommon.trans import trget from hscommon.trans import trget
@@ -25,12 +27,12 @@ class PreferencesDialogBase(QDialog):
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self.app = app self.app = app
self._setupUi() self._setupUi()
self.filterHardnessSlider.valueChanged['int'].connect(self.filterHardnessLabel.setNum) self.filterHardnessSlider.valueChanged['int'].connect(self.filterHardnessLabel.setNum)
self.buttonBox.clicked.connect(self.buttonClicked) self.buttonBox.clicked.connect(self.buttonClicked)
self.buttonBox.accepted.connect(self.accept) self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject) self.buttonBox.rejected.connect(self.reject)
def _setupScanTypeBox(self, labels): def _setupScanTypeBox(self, labels):
self.scanTypeHLayout = QHBoxLayout() self.scanTypeHLayout = QHBoxLayout()
self.scanTypeLabel = QLabel(self) self.scanTypeLabel = QLabel(self)
@@ -43,7 +45,7 @@ class PreferencesDialogBase(QDialog):
self.scanTypeComboBox.addItem(label) self.scanTypeComboBox.addItem(label)
self.scanTypeHLayout.addWidget(self.scanTypeComboBox) self.scanTypeHLayout.addWidget(self.scanTypeComboBox)
self.widgetsVLayout.addLayout(self.scanTypeHLayout) self.widgetsVLayout.addLayout(self.scanTypeHLayout)
def _setupFilterHardnessBox(self): def _setupFilterHardnessBox(self):
self.filterHardnessHLayout = QHBoxLayout() self.filterHardnessHLayout = QHBoxLayout()
self.filterHardnessLabel = QLabel(self) self.filterHardnessLabel = QLabel(self)
@@ -82,7 +84,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessHLayoutSub2.addWidget(self.fewerResultsLabel) self.filterHardnessHLayoutSub2.addWidget(self.fewerResultsLabel)
self.filterHardnessVLayout.addLayout(self.filterHardnessHLayoutSub2) self.filterHardnessVLayout.addLayout(self.filterHardnessHLayoutSub2)
self.filterHardnessHLayout.addLayout(self.filterHardnessVLayout) self.filterHardnessHLayout.addLayout(self.filterHardnessVLayout)
def _setupBottomPart(self): def _setupBottomPart(self):
# The bottom part of the pref panel is always the same in all editions. # The bottom part of the pref panel is always the same in all editions.
self.fontSizeLabel = QLabel(tr("Font size:")) self.fontSizeLabel = QLabel(tr("Font size:"))
@@ -107,18 +109,18 @@ class PreferencesDialogBase(QDialog):
self.widgetsVLayout.addWidget(self.customCommandLabel) self.widgetsVLayout.addWidget(self.customCommandLabel)
self.customCommandEdit = QLineEdit(self) self.customCommandEdit = QLineEdit(self)
self.widgetsVLayout.addWidget(self.customCommandEdit) self.widgetsVLayout.addWidget(self.customCommandEdit)
def _setupAddCheckbox(self, name, label, parent=None): def _setupAddCheckbox(self, name, label, parent=None):
if parent is None: if parent is None:
parent = self parent = self
cb = QCheckBox(parent) cb = QCheckBox(parent)
cb.setText(label) cb.setText(label)
setattr(self, name, cb) setattr(self, name, cb)
def _setupPreferenceWidgets(self): def _setupPreferenceWidgets(self):
# Edition-specific # Edition-specific
pass pass
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("Preferences")) self.setWindowTitle(tr("Preferences"))
self.resize(304, 263) self.resize(304, 263)
@@ -134,15 +136,15 @@ class PreferencesDialogBase(QDialog):
if (not ISOSX) and (not ISLINUX): if (not ISOSX) and (not ISLINUX):
self.mainVLayout.removeWidget(self.ignoreHardlinkMatches) self.mainVLayout.removeWidget(self.ignoreHardlinkMatches)
self.ignoreHardlinkMatches.setHidden(True) self.ignoreHardlinkMatches.setHidden(True)
def _load(self, prefs, setchecked): def _load(self, prefs, setchecked):
# Edition-specific # Edition-specific
pass pass
def _save(self, prefs, ischecked): def _save(self, prefs, ischecked):
# Edition-specific # Edition-specific
pass pass
def load(self, prefs=None): def load(self, prefs=None):
if prefs is None: if prefs is None:
prefs = self.app.prefs prefs = self.app.prefs
@@ -163,7 +165,7 @@ class PreferencesDialogBase(QDialog):
langindex = 0 langindex = 0
self.languageComboBox.setCurrentIndex(langindex) self.languageComboBox.setCurrentIndex(langindex)
self._load(prefs, setchecked) self._load(prefs, setchecked)
def save(self): def save(self):
prefs = self.app.prefs prefs = self.app.prefs
prefs.filter_hardness = self.filterHardnessSlider.value() prefs.filter_hardness = self.filterHardnessSlider.value()
@@ -184,9 +186,10 @@ class PreferencesDialogBase(QDialog):
QMessageBox.information(self, "", tr("dupeGuru has to restart for language changes to take effect.")) QMessageBox.information(self, "", tr("dupeGuru has to restart for language changes to take effect."))
self.app.prefs.language = lang self.app.prefs.language = lang
self._save(prefs, ischecked) self._save(prefs, ischecked)
#--- Events #--- Events
def buttonClicked(self, button): def buttonClicked(self, button):
role = self.buttonBox.buttonRole(button) role = self.buttonBox.buttonRole(button)
if role == QDialogButtonBox.ResetRole: if role == QDialogButtonBox.ResetRole:
self.resetToDefaults() self.resetToDefaults()

View File

@@ -1,14 +1,16 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-09-06 # Created On: 2011-09-06
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from PyQt5.QtCore import Qt, QMimeData, QByteArray from PyQt5.QtCore import Qt, QMimeData, QByteArray
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QComboBox, QListView, from PyQt5.QtWidgets import (
QDialogButtonBox, QAbstractItemView, QLabel, QStyle, QSplitter, QWidget, QSizePolicy) QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QComboBox, QListView,
QDialogButtonBox, QAbstractItemView, QLabel, QStyle, QSplitter, QWidget, QSizePolicy
)
from hscommon.trans import trget from hscommon.trans import trget
from qtlib.selectable_list import ComboboxModel, ListviewModel from qtlib.selectable_list import ComboboxModel, ListviewModel
@@ -24,7 +26,7 @@ class PrioritizationList(ListviewModel):
if not index.isValid(): if not index.isValid():
return Qt.ItemIsEnabled | Qt.ItemIsDropEnabled return Qt.ItemIsEnabled | Qt.ItemIsDropEnabled
return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled
#--- Drag & Drop #--- Drag & Drop
def dropMimeData(self, mimeData, action, row, column, parentIndex): def dropMimeData(self, mimeData, action, row, column, parentIndex):
if not mimeData.hasFormat(MIME_INDEXES): if not mimeData.hasFormat(MIME_INDEXES):
@@ -37,17 +39,17 @@ class PrioritizationList(ListviewModel):
indexes = list(map(int, strMimeData.split(','))) indexes = list(map(int, strMimeData.split(',')))
self.model.move_indexes(indexes, row) self.model.move_indexes(indexes, row)
return True return True
def mimeData(self, indexes): def mimeData(self, indexes):
rows = {str(index.row()) for index in indexes} rows = {str(index.row()) for index in indexes}
data = ','.join(rows) data = ','.join(rows)
mimeData = QMimeData() mimeData = QMimeData()
mimeData.setData(MIME_INDEXES, QByteArray(data.encode())) mimeData.setData(MIME_INDEXES, QByteArray(data.encode()))
return mimeData return mimeData
def mimeTypes(self): def mimeTypes(self):
return [MIME_INDEXES] return [MIME_INDEXES]
def supportedDropActions(self): def supportedDropActions(self):
return Qt.MoveAction return Qt.MoveAction
@@ -59,9 +61,11 @@ class PrioritizeDialog(QDialog):
self.model = PrioritizeDialogModel(app=app.model) self.model = PrioritizeDialogModel(app=app.model)
self.categoryList = ComboboxModel(model=self.model.category_list, view=self.categoryCombobox) self.categoryList = ComboboxModel(model=self.model.category_list, view=self.categoryCombobox)
self.criteriaList = ListviewModel(model=self.model.criteria_list, view=self.criteriaListView) self.criteriaList = ListviewModel(model=self.model.criteria_list, view=self.criteriaListView)
self.prioritizationList = PrioritizationList(model=self.model.prioritization_list, view=self.prioritizationListView) self.prioritizationList = PrioritizationList(
model=self.model.prioritization_list, view=self.prioritizationListView
)
self.model.view = self self.model.view = self
self.addCriteriaButton.clicked.connect(self.model.add_selected) self.addCriteriaButton.clicked.connect(self.model.add_selected)
self.removeCriteriaButton.clicked.connect(self.model.remove_selected) self.removeCriteriaButton.clicked.connect(self.model.remove_selected)
self.buttonBox.accepted.connect(self.accept) self.buttonBox.accepted.connect(self.accept)
@@ -70,11 +74,13 @@ class PrioritizeDialog(QDialog):
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("Re-Prioritize duplicates")) self.setWindowTitle(tr("Re-Prioritize duplicates"))
self.resize(700, 400) self.resize(700, 400)
#widgets #widgets
msg = tr("Add criteria to the right box and click OK to send the dupes that correspond the " msg = tr(
"Add criteria to the right box and click OK to send the dupes that correspond the "
"best to these criteria to their respective group's " "best to these criteria to their respective group's "
"reference position. Read the help file for more information.") "reference position. Read the help file for more information."
)
self.promptLabel = QLabel(msg) self.promptLabel = QLabel(msg)
self.promptLabel.setWordWrap(True) self.promptLabel.setWordWrap(True)
self.categoryCombobox = QComboBox() self.categoryCombobox = QComboBox()
@@ -88,7 +94,7 @@ class PrioritizeDialog(QDialog):
self.prioritizationListView.setSelectionBehavior(QAbstractItemView.SelectRows) self.prioritizationListView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.buttonBox = QDialogButtonBox() self.buttonBox = QDialogButtonBox()
self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok) self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok)
# layout # layout
self.mainLayout = QVBoxLayout(self) self.mainLayout = QVBoxLayout(self)
self.mainLayout.addWidget(self.promptLabel) self.mainLayout.addWidget(self.promptLabel)

View File

@@ -1,14 +1,16 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2010-04-12 # Created On: 2010-04-12
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QSpacerItem, QSizePolicy, from PyQt5.QtWidgets import (
QLabel, QTableView, QAbstractItemView, QApplication) QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QSpacerItem, QSizePolicy,
QLabel, QTableView, QAbstractItemView, QApplication
)
from hscommon.trans import trget from hscommon.trans import trget
from .problem_table import ProblemTable from .problem_table import ProblemTable
@@ -23,18 +25,20 @@ class ProblemDialog(QDialog):
self.model = model self.model = model
self.model.view = self self.model.view = self
self.table = ProblemTable(self.model.problem_table, view=self.tableView) self.table = ProblemTable(self.model.problem_table, view=self.tableView)
self.revealButton.clicked.connect(self.model.reveal_selected_dupe) self.revealButton.clicked.connect(self.model.reveal_selected_dupe)
self.closeButton.clicked.connect(self.accept) self.closeButton.clicked.connect(self.accept)
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("Problems!")) self.setWindowTitle(tr("Problems!"))
self.resize(413, 323) self.resize(413, 323)
self.verticalLayout = QVBoxLayout(self) self.verticalLayout = QVBoxLayout(self)
self.label = QLabel(self) self.label = QLabel(self)
msg = tr("There were problems processing some (or all) of the files. The cause of " msg = tr(
"There were problems processing some (or all) of the files. The cause of "
"these problems are described in the table below. Those files were not " "these problems are described in the table below. Those files were not "
"removed from your results.") "removed from your results."
)
self.label.setText(msg) self.label.setText(msg)
self.label.setWordWrap(True) self.label.setWordWrap(True)
self.verticalLayout.addWidget(self.label) self.verticalLayout.addWidget(self.label)
@@ -58,7 +62,7 @@ class ProblemDialog(QDialog):
self.closeButton.setDefault(True) self.closeButton.setDefault(True)
self.horizontalLayout.addWidget(self.closeButton) self.horizontalLayout.addWidget(self.closeButton)
self.verticalLayout.addLayout(self.horizontalLayout) self.verticalLayout.addLayout(self.horizontalLayout)
if __name__ == '__main__': if __name__ == '__main__':
import sys import sys
@@ -67,4 +71,4 @@ if __name__ == '__main__':
dgapp = TestApp() dgapp = TestApp()
dialog = ProblemDialog(None, dgapp) dialog = ProblemDialog(None, dgapp)
dialog.show() dialog.show()
sys.exit(app.exec_()) sys.exit(app.exec_())

View File

@@ -1,14 +1,16 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-04-25 # Created On: 2009-04-25
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from PyQt5.QtCore import Qt, QRect from PyQt5.QtCore import Qt, QRect
from PyQt5.QtWidgets import (QMainWindow, QMenu, QLabel, QFileDialog, QMenuBar, QWidget, from PyQt5.QtWidgets import (
QVBoxLayout, QAbstractItemView, QStatusBar, QDialog, QPushButton, QCheckBox) QMainWindow, QMenu, QLabel, QFileDialog, QMenuBar, QWidget,
QVBoxLayout, QAbstractItemView, QStatusBar, QDialog, QPushButton, QCheckBox
)
from hscommon.trans import trget from hscommon.trans import trget
from qtlib.util import moveToScreenCenter, horizontalWrap, createActions from qtlib.util import moveToScreenCenter, horizontalWrap, createActions
@@ -28,7 +30,7 @@ class ResultWindow(QMainWindow):
self.resultsModel = app.RESULT_MODEL_CLASS(self.app, self.resultsView) self.resultsModel = app.RESULT_MODEL_CLASS(self.app, self.resultsView)
self.stats = StatsLabel(app.model.stats_label, self.statusLabel) self.stats = StatsLabel(app.model.stats_label, self.statusLabel)
self._update_column_actions_status() self._update_column_actions_status()
self.menuColumns.triggered.connect(self.columnToggled) self.menuColumns.triggered.connect(self.columnToggled)
self.resultsView.doubleClicked.connect(self.resultsDoubleClicked) self.resultsView.doubleClicked.connect(self.resultsDoubleClicked)
self.resultsView.spacePressed.connect(self.resultsSpacePressed) self.resultsView.spacePressed.connect(self.resultsSpacePressed)
@@ -37,7 +39,7 @@ class ResultWindow(QMainWindow):
self.deltaValuesCheckBox.stateChanged.connect(self.deltaTriggered) self.deltaValuesCheckBox.stateChanged.connect(self.deltaTriggered)
self.searchEdit.searchChanged.connect(self.searchChanged) self.searchEdit.searchChanged.connect(self.searchChanged)
self.app.willSavePrefs.connect(self.appWillSavePrefs) self.app.willSavePrefs.connect(self.appWillSavePrefs)
def _setupActions(self): def _setupActions(self):
# (name, shortcut, icon, desc, func) # (name, shortcut, icon, desc, func)
ACTIONS = [ ACTIONS = [
@@ -50,11 +52,23 @@ class ResultWindow(QMainWindow):
('actionCopyMarked', 'Ctrl+Shift+M', '', tr("Copy Marked to..."), self.copyTriggered), ('actionCopyMarked', 'Ctrl+Shift+M', '', tr("Copy Marked to..."), self.copyTriggered),
('actionRemoveMarked', 'Ctrl+R', '', tr("Remove Marked from Results"), self.removeMarkedTriggered), ('actionRemoveMarked', 'Ctrl+R', '', tr("Remove Marked from Results"), self.removeMarkedTriggered),
('actionReprioritize', '', '', tr("Re-Prioritize Results..."), self.reprioritizeTriggered), ('actionReprioritize', '', '', tr("Re-Prioritize Results..."), self.reprioritizeTriggered),
('actionRemoveSelected', 'Ctrl+Del', '', tr("Remove Selected from Results"), self.removeSelectedTriggered), (
('actionIgnoreSelected', 'Ctrl+Shift+Del', '', tr("Add Selected to Ignore List"), self.addToIgnoreListTriggered), 'actionRemoveSelected', 'Ctrl+Del', '',
('actionMakeSelectedReference', 'Ctrl+Space', '', tr("Make Selected into Reference"), self.app.model.make_selected_reference), tr("Remove Selected from Results"), self.removeSelectedTriggered
),
(
'actionIgnoreSelected', 'Ctrl+Shift+Del', '',
tr("Add Selected to Ignore List"), self.addToIgnoreListTriggered
),
(
'actionMakeSelectedReference', 'Ctrl+Space', '',
tr("Make Selected into Reference"), self.app.model.make_selected_reference
),
('actionOpenSelected', 'Ctrl+O', '', tr("Open Selected with Default Application"), self.openTriggered), ('actionOpenSelected', 'Ctrl+O', '', tr("Open Selected with Default Application"), self.openTriggered),
('actionRevealSelected', 'Ctrl+Shift+O', '', tr("Open Containing Folder of Selected"), self.revealTriggered), (
'actionRevealSelected', 'Ctrl+Shift+O', '',
tr("Open Containing Folder of Selected"), self.revealTriggered
),
('actionRenameSelected', 'F2', '', tr("Rename Selected"), self.renameTriggered), ('actionRenameSelected', 'F2', '', tr("Rename Selected"), self.renameTriggered),
('actionMarkAll', 'Ctrl+A', '', tr("Mark All"), self.markAllTriggered), ('actionMarkAll', 'Ctrl+A', '', tr("Mark All"), self.markAllTriggered),
('actionMarkNone', 'Ctrl+Shift+A', '', tr("Mark None"), self.markNoneTriggered), ('actionMarkNone', 'Ctrl+Shift+A', '', tr("Mark None"), self.markNoneTriggered),
@@ -68,10 +82,9 @@ class ResultWindow(QMainWindow):
createActions(ACTIONS, self) createActions(ACTIONS, self)
self.actionDelta.setCheckable(True) self.actionDelta.setCheckable(True)
self.actionPowerMarker.setCheckable(True) self.actionPowerMarker.setCheckable(True)
def _setupMenu(self): def _setupMenu(self):
self.menubar = QMenuBar() self.menubar = QMenuBar()
self.menubar.setNativeMenuBar(False)
self.menubar.setGeometry(QRect(0, 0, 630, 22)) self.menubar.setGeometry(QRect(0, 0, 630, 22))
self.menuFile = QMenu(self.menubar) self.menuFile = QMenu(self.menubar)
self.menuFile.setTitle(tr("File")) self.menuFile.setTitle(tr("File"))
@@ -86,7 +99,7 @@ class ResultWindow(QMainWindow):
self.menuHelp = QMenu(self.menubar) self.menuHelp = QMenu(self.menubar)
self.menuHelp.setTitle(tr("Help")) self.menuHelp.setTitle(tr("Help"))
self.setMenuBar(self.menubar) self.setMenuBar(self.menubar)
self.menuActions.addAction(self.actionDeleteMarked) self.menuActions.addAction(self.actionDeleteMarked)
self.menuActions.addAction(self.actionMoveMarked) self.menuActions.addAction(self.actionMoveMarked)
self.menuActions.addAction(self.actionCopyMarked) self.menuActions.addAction(self.actionCopyMarked)
@@ -119,14 +132,14 @@ class ResultWindow(QMainWindow):
self.menuFile.addAction(self.actionExportToCSV) self.menuFile.addAction(self.actionExportToCSV)
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.app.actionQuit) self.menuFile.addAction(self.app.actionQuit)
self.menubar.addAction(self.menuFile.menuAction()) self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuMark.menuAction()) self.menubar.addAction(self.menuMark.menuAction())
self.menubar.addAction(self.menuActions.menuAction()) self.menubar.addAction(self.menuActions.menuAction())
self.menubar.addAction(self.menuColumns.menuAction()) self.menubar.addAction(self.menuColumns.menuAction())
self.menubar.addAction(self.menuView.menuAction()) self.menubar.addAction(self.menuView.menuAction())
self.menubar.addAction(self.menuHelp.menuAction()) self.menubar.addAction(self.menuHelp.menuAction())
# Columns menu # Columns menu
menu = self.menuColumns menu = self.menuColumns
self._column_actions = [] self._column_actions = []
@@ -139,7 +152,7 @@ class ResultWindow(QMainWindow):
menu.addSeparator() menu.addSeparator()
action = menu.addAction(tr("Reset to Defaults")) action = menu.addAction(tr("Reset to Defaults"))
action.item_index = -1 action.item_index = -1
# Action menu # Action menu
actionMenu = QMenu(tr("Actions"), self.menubar) actionMenu = QMenu(tr("Actions"), self.menubar)
actionMenu.addAction(self.actionDeleteMarked) actionMenu.addAction(self.actionDeleteMarked)
@@ -157,7 +170,7 @@ class ResultWindow(QMainWindow):
actionMenu.addAction(self.actionRenameSelected) actionMenu.addAction(self.actionRenameSelected)
self.actionActions.setMenu(actionMenu) self.actionActions.setMenu(actionMenu)
self.actionsButton.setMenu(self.actionActions.menu()) self.actionsButton.setMenu(self.actionActions.menu())
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("{} Results").format(self.app.NAME)) self.setWindowTitle(tr("{} Results").format(self.app.NAME))
self.resize(630, 514) self.resize(630, 514)
@@ -171,8 +184,10 @@ class ResultWindow(QMainWindow):
self.deltaValuesCheckBox = QCheckBox(tr("Delta Values")) self.deltaValuesCheckBox = QCheckBox(tr("Delta Values"))
self.searchEdit = SearchEdit() self.searchEdit = SearchEdit()
self.searchEdit.setMaximumWidth(300) self.searchEdit.setMaximumWidth(300)
self.horizontalLayout = horizontalWrap([self.actionsButton, self.detailsButton, self.horizontalLayout = horizontalWrap([
self.dupesOnlyCheckBox, self.deltaValuesCheckBox, None, self.searchEdit, 8]) self.actionsButton, self.detailsButton,
self.dupesOnlyCheckBox, self.deltaValuesCheckBox, None, self.searchEdit, 8
])
self.horizontalLayout.setSpacing(8) self.horizontalLayout.setSpacing(8)
self.verticalLayout.addLayout(self.horizontalLayout) self.verticalLayout.addLayout(self.horizontalLayout)
self.resultsView = ResultsView(self.centralwidget) self.resultsView = ResultsView(self.centralwidget)
@@ -194,7 +209,7 @@ class ResultWindow(QMainWindow):
self.setStatusBar(self.statusbar) self.setStatusBar(self.statusbar)
self.statusLabel = QLabel(self) self.statusLabel = QLabel(self)
self.statusbar.addPermanentWidget(self.statusLabel, 1) self.statusbar.addPermanentWidget(self.statusLabel, 1)
if self.app.prefs.resultWindowIsMaximized: if self.app.prefs.resultWindowIsMaximized:
self.setWindowState(self.windowState() | Qt.WindowMaximized) self.setWindowState(self.windowState() | Qt.WindowMaximized)
else: else:
@@ -202,85 +217,85 @@ class ResultWindow(QMainWindow):
self.setGeometry(self.app.prefs.resultWindowRect) self.setGeometry(self.app.prefs.resultWindowRect)
else: else:
moveToScreenCenter(self) moveToScreenCenter(self)
#--- Private #--- Private
def _update_column_actions_status(self): def _update_column_actions_status(self):
# Update menu checked state # Update menu checked state
menu_items = self.app.model.result_table.columns.menu_items() menu_items = self.app.model.result_table.columns.menu_items()
for action, (display, visible) in zip(self._column_actions, menu_items): for action, (display, visible) in zip(self._column_actions, menu_items):
action.setChecked(visible) action.setChecked(visible)
#--- Actions #--- Actions
def actionsTriggered(self): def actionsTriggered(self):
self.actionsButton.showMenu() self.actionsButton.showMenu()
def addToIgnoreListTriggered(self): def addToIgnoreListTriggered(self):
self.app.model.add_selected_to_ignore_list() self.app.model.add_selected_to_ignore_list()
def copyTriggered(self): def copyTriggered(self):
self.app.model.copy_or_move_marked(True) self.app.model.copy_or_move_marked(True)
def deleteTriggered(self): def deleteTriggered(self):
self.app.model.delete_marked() self.app.model.delete_marked()
def deltaTriggered(self, state=None): def deltaTriggered(self, state=None):
# The sender can be either the action or the checkbox, but both have a isChecked() method. # The sender can be either the action or the checkbox, but both have a isChecked() method.
self.resultsModel.delta_values = self.sender().isChecked() self.resultsModel.delta_values = self.sender().isChecked()
self.actionDelta.setChecked(self.resultsModel.delta_values) self.actionDelta.setChecked(self.resultsModel.delta_values)
self.deltaValuesCheckBox.setChecked(self.resultsModel.delta_values) self.deltaValuesCheckBox.setChecked(self.resultsModel.delta_values)
def detailsTriggered(self): def detailsTriggered(self):
self.app.show_details() self.app.show_details()
def markAllTriggered(self): def markAllTriggered(self):
self.app.model.mark_all() self.app.model.mark_all()
def markInvertTriggered(self): def markInvertTriggered(self):
self.app.model.mark_invert() self.app.model.mark_invert()
def markNoneTriggered(self): def markNoneTriggered(self):
self.app.model.mark_none() self.app.model.mark_none()
def markSelectedTriggered(self): def markSelectedTriggered(self):
self.app.model.toggle_selected_mark_state() self.app.model.toggle_selected_mark_state()
def moveTriggered(self): def moveTriggered(self):
self.app.model.copy_or_move_marked(False) self.app.model.copy_or_move_marked(False)
def openTriggered(self): def openTriggered(self):
self.app.model.open_selected() self.app.model.open_selected()
def powerMarkerTriggered(self, state=None): def powerMarkerTriggered(self, state=None):
# see deltaTriggered # see deltaTriggered
self.resultsModel.power_marker = self.sender().isChecked() self.resultsModel.power_marker = self.sender().isChecked()
self.actionPowerMarker.setChecked(self.resultsModel.power_marker) self.actionPowerMarker.setChecked(self.resultsModel.power_marker)
self.dupesOnlyCheckBox.setChecked(self.resultsModel.power_marker) self.dupesOnlyCheckBox.setChecked(self.resultsModel.power_marker)
def preferencesTriggered(self): def preferencesTriggered(self):
self.app.show_preferences() self.app.show_preferences()
def removeMarkedTriggered(self): def removeMarkedTriggered(self):
self.app.model.remove_marked() self.app.model.remove_marked()
def removeSelectedTriggered(self): def removeSelectedTriggered(self):
self.app.model.remove_selected() self.app.model.remove_selected()
def renameTriggered(self): def renameTriggered(self):
index = self.resultsView.selectionModel().currentIndex() index = self.resultsView.selectionModel().currentIndex()
# Our index is the current row, with column set to 0. Our filename column is 1 and that's # Our index is the current row, with column set to 0. Our filename column is 1 and that's
# what we want. # what we want.
index = index.sibling(index.row(), 1) index = index.sibling(index.row(), 1)
self.resultsView.edit(index) self.resultsView.edit(index)
def reprioritizeTriggered(self): def reprioritizeTriggered(self):
dlg = PrioritizeDialog(self, self.app) dlg = PrioritizeDialog(self, self.app)
result = dlg.exec() result = dlg.exec()
if result == QDialog.Accepted: if result == QDialog.Accepted:
dlg.model.perform_reprioritization() dlg.model.perform_reprioritization()
def revealTriggered(self): def revealTriggered(self):
self.app.model.reveal_selected() self.app.model.reveal_selected()
def saveResultsTriggered(self): def saveResultsTriggered(self):
title = tr("Select a file to save your results to") title = tr("Select a file to save your results to")
files = tr("dupeGuru Results (*.dupeguru)") files = tr("dupeGuru Results (*.dupeguru)")
@@ -290,13 +305,13 @@ class ResultWindow(QMainWindow):
destination = '{}.dupeguru'.format(destination) destination = '{}.dupeguru'.format(destination)
self.app.model.save_as(destination) self.app.model.save_as(destination)
self.app.recentResults.insertItem(destination) self.app.recentResults.insertItem(destination)
#--- Events #--- Events
def appWillSavePrefs(self): def appWillSavePrefs(self):
prefs = self.app.prefs prefs = self.app.prefs
prefs.resultWindowIsMaximized = self.isMaximized() prefs.resultWindowIsMaximized = self.isMaximized()
prefs.resultWindowRect = self.geometry() prefs.resultWindowRect = self.geometry()
def columnToggled(self, action): def columnToggled(self, action):
index = action.item_index index = action.item_index
if index == -1: if index == -1:
@@ -305,16 +320,16 @@ class ResultWindow(QMainWindow):
else: else:
visible = self.app.model.result_table.columns.toggle_menu_item(index) visible = self.app.model.result_table.columns.toggle_menu_item(index)
action.setChecked(visible) action.setChecked(visible)
def contextMenuEvent(self, event): def contextMenuEvent(self, event):
self.actionActions.menu().exec_(event.globalPos()) self.actionActions.menu().exec_(event.globalPos())
def resultsDoubleClicked(self, modelIndex): def resultsDoubleClicked(self, modelIndex):
self.app.model.open_selected() self.app.model.open_selected()
def resultsSpacePressed(self): def resultsSpacePressed(self):
self.app.model.toggle_selected_mark_state() self.app.model.toggle_selected_mark_state()
def searchChanged(self): def searchChanged(self):
self.app.model.apply_filter(self.searchEdit.text()) self.app.model.apply_filter(self.searchEdit.text())

View File

@@ -1,15 +1,17 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-04-29 # Created On: 2009-04-29
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys import sys
from PyQt5.QtCore import QSize from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, from PyQt5.QtWidgets import (
QApplication) QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget,
QApplication
)
from hscommon.trans import trget from hscommon.trans import trget
from core.scanner import ScanType from core.scanner import ScanType
@@ -31,9 +33,9 @@ SCAN_TYPE_ORDER = [
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def __init__(self, parent, app): def __init__(self, parent, app):
PreferencesDialogBase.__init__(self, parent, app) PreferencesDialogBase.__init__(self, parent, app)
self.scanTypeComboBox.currentIndexChanged[int].connect(self.scanTypeChanged) self.scanTypeComboBox.currentIndexChanged[int].connect(self.scanTypeChanged)
def _setupPreferenceWidgets(self): def _setupPreferenceWidgets(self):
scanTypeLabels = [ scanTypeLabels = [
tr("Filename"), tr("Filename"),
@@ -87,7 +89,7 @@ class PreferencesDialog(PreferencesDialogBase):
self._setupAddCheckbox('debugModeBox', tr("Debug mode (restart required)")) self._setupAddCheckbox('debugModeBox', tr("Debug mode (restart required)"))
self.widgetsVLayout.addWidget(self.debugModeBox) self.widgetsVLayout.addWidget(self.debugModeBox)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs, setchecked): def _load(self, prefs, setchecked):
scan_type_index = SCAN_TYPE_ORDER.index(prefs.scan_type) scan_type_index = SCAN_TYPE_ORDER.index(prefs.scan_type)
self.scanTypeComboBox.setCurrentIndex(scan_type_index) self.scanTypeComboBox.setCurrentIndex(scan_type_index)
@@ -99,7 +101,7 @@ class PreferencesDialog(PreferencesDialogBase):
setchecked(self.tagYearBox, prefs.scan_tag_year) setchecked(self.tagYearBox, prefs.scan_tag_year)
setchecked(self.matchSimilarBox, prefs.match_similar) setchecked(self.matchSimilarBox, prefs.match_similar)
setchecked(self.wordWeightingBox, prefs.word_weighting) setchecked(self.wordWeightingBox, prefs.word_weighting)
def _save(self, prefs, ischecked): def _save(self, prefs, ischecked):
prefs.scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] prefs.scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()]
prefs.scan_tag_track = ischecked(self.tagTrackBox) prefs.scan_tag_track = ischecked(self.tagTrackBox)
@@ -110,15 +112,17 @@ class PreferencesDialog(PreferencesDialogBase):
prefs.scan_tag_year = ischecked(self.tagYearBox) prefs.scan_tag_year = ischecked(self.tagYearBox)
prefs.match_similar = ischecked(self.matchSimilarBox) prefs.match_similar = ischecked(self.matchSimilarBox)
prefs.word_weighting = ischecked(self.wordWeightingBox) prefs.word_weighting = ischecked(self.wordWeightingBox)
def resetToDefaults(self): def resetToDefaults(self):
self.load(preferences.Preferences()) self.load(preferences.Preferences())
#--- Events #--- Events
def scanTypeChanged(self, index): def scanTypeChanged(self, index):
scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()]
word_based = scan_type in (ScanType.Filename, ScanType.Fields, ScanType.FieldsNoOrder, word_based = scan_type in (
ScanType.Tag) ScanType.Filename, ScanType.Fields, ScanType.FieldsNoOrder,
ScanType.Tag
)
tag_based = scan_type == ScanType.Tag tag_based = scan_type == ScanType.Tag
self.filterHardnessSlider.setEnabled(word_based) self.filterHardnessSlider.setEnabled(word_based)
self.matchSimilarBox.setEnabled(word_based) self.matchSimilarBox.setEnabled(word_based)
@@ -129,7 +133,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.tagTitleBox.setEnabled(tag_based) self.tagTitleBox.setEnabled(tag_based)
self.tagGenreBox.setEnabled(tag_based) self.tagGenreBox.setEnabled(tag_based)
self.tagYearBox.setEnabled(tag_based) self.tagYearBox.setEnabled(tag_based)
if __name__ == '__main__': if __name__ == '__main__':
from ..testapp import TestApp from ..testapp import TestApp
@@ -137,4 +141,5 @@ if __name__ == '__main__':
dgapp = TestApp() dgapp = TestApp()
dialog = PreferencesDialog(None, dgapp) dialog = PreferencesDialog(None, dgapp)
dialog.show() dialog.show()
sys.exit(app.exec_()) sys.exit(app.exec_())

View File

@@ -1,8 +1,8 @@
# Created On: 2011-11-27 # Created On: 2011-11-27
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from qtlib.column import Column from qtlib.column import Column
@@ -29,4 +29,5 @@ class ResultsModel(ResultsModelBase):
Column('percentage', defaultWidth=60), Column('percentage', defaultWidth=60),
Column('words', defaultWidth=120), Column('words', defaultWidth=120),
Column('dupe_count', defaultWidth=80), Column('dupe_count', defaultWidth=80),
] ]

View File

@@ -1,12 +1,12 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-05-10 # Created On: 2009-05-10
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from ._block_qt import getblocks from ._block_qt import getblocks # NOQA
# Converted to C # Converted to C
# def getblock(image): # def getblock(image):
@@ -24,7 +24,7 @@ from ._block_qt import getblocks
# return (red // pixel_count, green // pixel_count, blue // pixel_count) # return (red // pixel_count, green // pixel_count, blue // pixel_count)
# else: # else:
# return (0, 0, 0) # return (0, 0, 0)
# #
# def getblocks(image, block_count_per_side): # def getblocks(image, block_count_per_side):
# width = image.width() # width = image.width()
# height = image.height() # height = image.height()

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-04-29 # Created On: 2009-04-29
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys import sys
@@ -25,9 +25,9 @@ SCAN_TYPE_ORDER = [
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def __init__(self, parent, app): def __init__(self, parent, app):
PreferencesDialogBase.__init__(self, parent, app) PreferencesDialogBase.__init__(self, parent, app)
self.scanTypeComboBox.currentIndexChanged[int].connect(self.scanTypeChanged) self.scanTypeComboBox.currentIndexChanged[int].connect(self.scanTypeChanged)
def _setupPreferenceWidgets(self): def _setupPreferenceWidgets(self):
scanTypeLabels = [ scanTypeLabels = [
tr("Contents"), tr("Contents"),
@@ -49,25 +49,25 @@ class PreferencesDialog(PreferencesDialogBase):
self._setupAddCheckbox('debugModeBox', tr("Debug mode (restart required)")) self._setupAddCheckbox('debugModeBox', tr("Debug mode (restart required)"))
self.widgetsVLayout.addWidget(self.debugModeBox) self.widgetsVLayout.addWidget(self.debugModeBox)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs, setchecked): def _load(self, prefs, setchecked):
scan_type_index = SCAN_TYPE_ORDER.index(prefs.scan_type) scan_type_index = SCAN_TYPE_ORDER.index(prefs.scan_type)
self.scanTypeComboBox.setCurrentIndex(scan_type_index) self.scanTypeComboBox.setCurrentIndex(scan_type_index)
setchecked(self.matchScaledBox, prefs.match_scaled) setchecked(self.matchScaledBox, prefs.match_scaled)
def _save(self, prefs, ischecked): def _save(self, prefs, ischecked):
prefs.scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] prefs.scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()]
prefs.match_scaled = ischecked(self.matchScaledBox) prefs.match_scaled = ischecked(self.matchScaledBox)
def resetToDefaults(self): def resetToDefaults(self):
self.load(preferences.Preferences()) self.load(preferences.Preferences())
#--- Events #--- Events
def scanTypeChanged(self, index): def scanTypeChanged(self, index):
scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()]
fuzzy_scan = scan_type == ScanType.FuzzyBlock fuzzy_scan = scan_type == ScanType.FuzzyBlock
self.filterHardnessSlider.setEnabled(fuzzy_scan) self.filterHardnessSlider.setEnabled(fuzzy_scan)
if __name__ == '__main__': if __name__ == '__main__':
from ..testapp import TestApp from ..testapp import TestApp
@@ -75,4 +75,5 @@ if __name__ == '__main__':
dgapp = TestApp() dgapp = TestApp()
dialog = PreferencesDialog(None, dgapp) dialog = PreferencesDialog(None, dgapp)
dialog.show() dialog.show()
sys.exit(app.exec_()) sys.exit(app.exec_())

View File

@@ -1,8 +1,8 @@
# Created On: 2011-11-27 # Created On: 2011-11-27
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from qtlib.column import Column from qtlib.column import Column
@@ -20,4 +20,5 @@ class ResultsModel(ResultsModelBase):
Column('mtime', defaultWidth=120), Column('mtime', defaultWidth=120),
Column('percentage', defaultWidth=60), Column('percentage', defaultWidth=60),
Column('dupe_count', defaultWidth=80), Column('dupe_count', defaultWidth=80),
] ]

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-05-24 # Created On: 2009-05-24
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from core_se import __appname__ from core_se import __appname__
@@ -18,6 +18,7 @@ from .preferences_dialog import PreferencesDialog
class Directories(DirectoriesBase): class Directories(DirectoriesBase):
ROOT_PATH_TO_EXCLUDE = frozenset(['windows', 'program files']) ROOT_PATH_TO_EXCLUDE = frozenset(['windows', 'program files'])
def _default_state_for_path(self, path): def _default_state_for_path(self, path):
result = DirectoriesBase._default_state_for_path(self, path) result = DirectoriesBase._default_state_for_path(self, path)
if result is not None: if result is not None:
@@ -30,16 +31,16 @@ class DupeGuru(DupeGuruBase):
EDITION = 'se' EDITION = 'se'
LOGO_NAME = 'logo_se' LOGO_NAME = 'logo_se'
NAME = __appname__ NAME = __appname__
DETAILS_DIALOG_CLASS = DetailsDialog DETAILS_DIALOG_CLASS = DetailsDialog
RESULT_MODEL_CLASS = ResultsModel RESULT_MODEL_CLASS = ResultsModel
PREFERENCES_CLASS = Preferences PREFERENCES_CLASS = Preferences
PREFERENCES_DIALOG_CLASS = PreferencesDialog PREFERENCES_DIALOG_CLASS = PreferencesDialog
def _setup(self): def _setup(self):
self.directories = Directories() self.directories = Directories()
DupeGuruBase._setup(self) DupeGuruBase._setup(self)
def _update_options(self): def _update_options(self):
DupeGuruBase._update_options(self) DupeGuruBase._update_options(self)
self.model.scanner.min_match_percentage = self.prefs.filter_hardness self.model.scanner.min_match_percentage = self.prefs.filter_hardness
@@ -48,4 +49,4 @@ class DupeGuru(DupeGuruBase):
self.model.scanner.match_similar_words = self.prefs.match_similar self.model.scanner.match_similar_words = self.prefs.match_similar
threshold = self.prefs.small_file_threshold if self.prefs.ignore_small_files else 0 threshold = self.prefs.small_file_threshold if self.prefs.ignore_small_files else 0
self.model.scanner.size_threshold = threshold * 1024 # threshold is in KB. the scanner wants bytes self.model.scanner.size_threshold = threshold * 1024 # threshold is in KB. the scanner wants bytes

View File

@@ -1,15 +1,17 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2009-05-24 # Created On: 2009-05-24
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys import sys
from PyQt5.QtCore import QSize from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, from PyQt5.QtWidgets import (
QLineEdit, QApplication) QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget,
QLineEdit, QApplication
)
from hscommon.plat import ISWINDOWS, ISLINUX from hscommon.plat import ISWINDOWS, ISLINUX
from hscommon.trans import trget from hscommon.trans import trget
@@ -31,9 +33,9 @@ SCAN_TYPE_ORDER = [
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def __init__(self, parent, app, **kwargs): def __init__(self, parent, app, **kwargs):
super().__init__(parent, app, **kwargs) super().__init__(parent, app, **kwargs)
self.scanTypeComboBox.currentIndexChanged[int].connect(self.scanTypeChanged) self.scanTypeComboBox.currentIndexChanged[int].connect(self.scanTypeChanged)
def _setupPreferenceWidgets(self): def _setupPreferenceWidgets(self):
scanTypeLabels = [ scanTypeLabels = [
tr("Filename"), tr("Filename"),
@@ -73,23 +75,26 @@ class PreferencesDialog(PreferencesDialogBase):
spacerItem1 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) spacerItem1 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout_2.addItem(spacerItem1) self.horizontalLayout_2.addItem(spacerItem1)
self.verticalLayout_4.addLayout(self.horizontalLayout_2) self.verticalLayout_4.addLayout(self.horizontalLayout_2)
self._setupAddCheckbox('ignoreHardlinkMatches', tr("Ignore duplicates hardlinking to the same file"), self.widget) self._setupAddCheckbox(
'ignoreHardlinkMatches',
tr("Ignore duplicates hardlinking to the same file"), self.widget
)
self.verticalLayout_4.addWidget(self.ignoreHardlinkMatches) self.verticalLayout_4.addWidget(self.ignoreHardlinkMatches)
self._setupAddCheckbox('debugModeBox', tr("Debug mode (restart required)"), self.widget) self._setupAddCheckbox('debugModeBox', tr("Debug mode (restart required)"), self.widget)
self.verticalLayout_4.addWidget(self.debugModeBox) self.verticalLayout_4.addWidget(self.debugModeBox)
self.widgetsVLayout.addWidget(self.widget) self.widgetsVLayout.addWidget(self.widget)
self._setupBottomPart() self._setupBottomPart()
def _setupUi(self): def _setupUi(self):
PreferencesDialogBase._setupUi(self) PreferencesDialogBase._setupUi(self)
if ISLINUX: if ISLINUX:
# Under linux, whether it's a Qt layout bug or something else, the size threshold text edit # Under linux, whether it's a Qt layout bug or something else, the size threshold text edit
# doesn't have enough space, so we make the pref pane higher to compensate. # doesn't have enough space, so we make the pref pane higher to compensate.
self.resize(self.width(), 530) self.resize(self.width(), 530)
elif ISWINDOWS: elif ISWINDOWS:
self.resize(self.width(), 440) self.resize(self.width(), 440)
def _load(self, prefs, setchecked): def _load(self, prefs, setchecked):
scan_type_index = SCAN_TYPE_ORDER.index(prefs.scan_type) scan_type_index = SCAN_TYPE_ORDER.index(prefs.scan_type)
self.scanTypeComboBox.setCurrentIndex(scan_type_index) self.scanTypeComboBox.setCurrentIndex(scan_type_index)
@@ -97,17 +102,17 @@ class PreferencesDialog(PreferencesDialogBase):
setchecked(self.wordWeightingBox, prefs.word_weighting) setchecked(self.wordWeightingBox, prefs.word_weighting)
setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files) setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files)
self.sizeThresholdEdit.setText(str(prefs.small_file_threshold)) self.sizeThresholdEdit.setText(str(prefs.small_file_threshold))
def _save(self, prefs, ischecked): def _save(self, prefs, ischecked):
prefs.scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] prefs.scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()]
prefs.match_similar = ischecked(self.matchSimilarBox) prefs.match_similar = ischecked(self.matchSimilarBox)
prefs.word_weighting = ischecked(self.wordWeightingBox) prefs.word_weighting = ischecked(self.wordWeightingBox)
prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox) prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox)
prefs.small_file_threshold = tryint(self.sizeThresholdEdit.text()) prefs.small_file_threshold = tryint(self.sizeThresholdEdit.text())
def resetToDefaults(self): def resetToDefaults(self):
self.load(preferences.Preferences()) self.load(preferences.Preferences())
#--- Events #--- Events
def scanTypeChanged(self, index): def scanTypeChanged(self, index):
scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()]
@@ -115,7 +120,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.filterHardnessSlider.setEnabled(word_based) self.filterHardnessSlider.setEnabled(word_based)
self.matchSimilarBox.setEnabled(word_based) self.matchSimilarBox.setEnabled(word_based)
self.wordWeightingBox.setEnabled(word_based) self.wordWeightingBox.setEnabled(word_based)
if __name__ == '__main__': if __name__ == '__main__':
from ..testapp import TestApp from ..testapp import TestApp

View File

@@ -1,8 +1,8 @@
# Created On: 2011-11-27 # Created On: 2011-11-27
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from qtlib.column import Column from qtlib.column import Column
@@ -19,4 +19,5 @@ class ResultsModel(ResultsModelBase):
Column('percentage', defaultWidth=60), Column('percentage', defaultWidth=60),
Column('words', defaultWidth=120), Column('words', defaultWidth=120),
Column('dupe_count', defaultWidth=80), Column('dupe_count', defaultWidth=80),
] ]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 398 B

View File

@@ -1,9 +1,9 @@
# Created By: Virgil Dupras # Created By: Virgil Dupras
# Created On: 2011-09-06 # Created On: 2011-09-06
# 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 "LICENSE" file,
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from PyQt5.QtCore import Qt, QAbstractListModel, QItemSelection, QItemSelectionModel from PyQt5.QtCore import Qt, QAbstractListModel, QItemSelection, QItemSelectionModel
@@ -16,7 +16,7 @@ class SelectableList(QAbstractListModel):
self.model = model self.model = model
self.view.setModel(self) self.view.setModel(self)
self.model.view = self self.model.view = self
#--- Override #--- Override
def data(self, index, role): def data(self, index, role):
if not index.isValid(): if not index.isValid():
@@ -25,26 +25,27 @@ class SelectableList(QAbstractListModel):
if role in {Qt.DisplayRole, Qt.EditRole}: if role in {Qt.DisplayRole, Qt.EditRole}:
return self.model[index.row()] return self.model[index.row()]
return None return None
def rowCount(self, index): def rowCount(self, index):
if index.isValid(): if index.isValid():
return 0 return 0
return len(self.model) return len(self.model)
#--- Virtual #--- Virtual
def _updateSelection(self): def _updateSelection(self):
raise NotImplementedError() raise NotImplementedError()
def _restoreSelection(self): def _restoreSelection(self):
raise NotImplementedError() raise NotImplementedError()
#--- model --> view #--- model --> view
def refresh(self): def refresh(self):
self._updating = True self._updating = True
self.reset() self.beginResetModel()
self.endResetModel()
self._updating = False self._updating = False
self._restoreSelection() self._restoreSelection()
def update_selection(self): def update_selection(self):
self._restoreSelection() self._restoreSelection()
@@ -52,18 +53,18 @@ class ComboboxModel(SelectableList):
def __init__(self, model, view, **kwargs): def __init__(self, model, view, **kwargs):
super().__init__(model, view, **kwargs) super().__init__(model, view, **kwargs)
self.view.currentIndexChanged[int].connect(self.selectionChanged) self.view.currentIndexChanged[int].connect(self.selectionChanged)
#--- Override #--- Override
def _updateSelection(self): def _updateSelection(self):
index = self.view.currentIndex() index = self.view.currentIndex()
if index != self.model.selected_index: if index != self.model.selected_index:
self.model.select(index) self.model.select(index)
def _restoreSelection(self): def _restoreSelection(self):
index = self.model.selected_index index = self.model.selected_index
if index is not None: if index is not None:
self.view.setCurrentIndex(index) self.view.setCurrentIndex(index)
#--- Events #--- Events
def selectionChanged(self, index): def selectionChanged(self, index):
if not self._updating: if not self._updating:
@@ -74,13 +75,13 @@ class ListviewModel(SelectableList):
super().__init__(model, view, **kwargs) super().__init__(model, view, **kwargs)
self.view.selectionModel().selectionChanged[(QItemSelection, QItemSelection)].connect( self.view.selectionModel().selectionChanged[(QItemSelection, QItemSelection)].connect(
self.selectionChanged) self.selectionChanged)
#--- Override #--- Override
def _updateSelection(self): def _updateSelection(self):
newIndexes = [modelIndex.row() for modelIndex in self.view.selectionModel().selectedRows()] newIndexes = [modelIndex.row() for modelIndex in self.view.selectionModel().selectedRows()]
if newIndexes != self.model.selected_indexes: if newIndexes != self.model.selected_indexes:
self.model.select(newIndexes) self.model.select(newIndexes)
def _restoreSelection(self): def _restoreSelection(self):
newSelection = QItemSelection() newSelection = QItemSelection()
for index in self.model.selected_indexes: for index in self.model.selected_indexes:
@@ -94,4 +95,4 @@ class ListviewModel(SelectableList):
def selectionChanged(self, index): def selectionChanged(self, index):
if not self._updating: if not self._updating:
self._updateSelection() self._updateSelection()

View File

@@ -1,2 +1,4 @@
pytest>=2.0.0 pytest>=2.0.0
pytest-monkeyplus>=1.0.0 pytest-monkeyplus>=1.0.0
flake8

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