diff --git a/.gitignore b/.gitignore index 831c9fe5..da5cda66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,21 @@ .DS_Store -*.pyc +__pycache__ *.so *.mo *.pyd *.waf* .lock-waf* -.idea .tox build dist -install -installer_tmp-cache env /deps cocoa/autogen /run.py -/conf.json /cocoa/*/Info.plist /cocoa/*/build -/qt/base/*_rc.py +/qt/*_rc.py /help/*/conf.py /help/*/changelog.rst diff --git a/README.md b/README.md index 59cc9b3a..428eac9d 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,6 @@ 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 is written in Objective-C and uses Cocoa. On Linux, it's written in Python and uses Qt5. -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. - # Current status: People wanted dupeGuru has currently only one maintainer, me. This is a dangerous situation that needs to be @@ -121,7 +118,6 @@ For OS X: With your virtualenv activated, you can build and run dupeGuru with these commands: - $ python configure.py $ python build.py $ python run.py diff --git a/bootstrap.sh b/bootstrap.sh index cbab7bc0..58b9d89b 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -44,4 +44,4 @@ else fi echo "Bootstrapping complete! You can now configure, build and run dupeGuru with:" -echo ". env/bin/activate && python configure.py && python build.py && python run.py" +echo ". env/bin/activate && python build.py && python run.py" diff --git a/build.py b/build.py index eaacd274..56de46de 100644 --- a/build.py +++ b/build.py @@ -1,6 +1,4 @@ -# Created By: Virgil Dupras -# Created On: 2009-12-30 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) # # This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # which should be included with this package. The terms are also available at @@ -11,7 +9,6 @@ import os import os.path as op from optparse import OptionParser import shutil -import json import importlib import compileall @@ -22,10 +19,10 @@ from hscommon.build import ( add_to_pythonpath, print_and_do, copy_packages, filereplace, get_module_version, move_all, copy_all, OSXAppStructure, build_cocoalib_xibless, fix_qt_resource_file, build_cocoa_ext, copy_embeddable_python_dylib, - collect_stdlib_dependencies, copy + collect_stdlib_dependencies ) from hscommon import loc -from hscommon.plat import ISOSX, ISLINUX +from hscommon.plat import ISOSX from hscommon.util import ensure_folder, delete_files_with_pattern def parse_args(): @@ -39,6 +36,14 @@ def parse_args(): '--doc', action='store_true', dest='doc', help="Build only the help file" ) + parser.add_option( + '--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." + ) parser.add_option( '--loc', action='store_true', dest='loc', help="Build only localization" @@ -70,15 +75,11 @@ def parse_args(): (options, args) = parser.parse_args() return options -def cocoa_app(edition): - app_path = { - 'se': 'build/dupeGuru.app', - 'me': 'build/dupeGuru ME.app', - 'pe': 'build/dupeGuru PE.app', - }[edition] +def cocoa_app(): + app_path = 'build/dupeGuru.app' return OSXAppStructure(app_path) -def build_xibless(edition, dest='cocoa/autogen'): +def build_xibless(dest='cocoa/autogen'): import xibless ensure_folder(dest) FNPAIRS = [ @@ -94,56 +95,50 @@ def build_xibless(edition, dest='cocoa/autogen'): for srcname, dstname in FNPAIRS: xibless.generate( op.join('cocoa', 'base', 'ui', srcname), op.join(dest, dstname), - localizationTable='Localizable', args={'edition': edition} - ) - if edition == 'pe': - xibless.generate( - 'cocoa/pe/ui/details_panel.py', op.join(dest, 'DetailsPanel_UI'), - localizationTable='Localizable' - ) - else: - xibless.generate( - 'cocoa/base/ui/details_panel.py', op.join(dest, 'DetailsPanel_UI'), localizationTable='Localizable' ) + # XXX This is broken + assert False + # if edition == 'pe': + # xibless.generate( + # 'cocoa/pe/ui/details_panel.py', op.join(dest, 'DetailsPanel_UI'), + # localizationTable='Localizable' + # ) + # else: + # xibless.generate( + # 'cocoa/base/ui/details_panel.py', op.join(dest, 'DetailsPanel_UI'), + # localizationTable='Localizable' + # ) -def build_cocoa(edition, dev): +def build_cocoa(dev): print("Creating OS X app structure") - ed = lambda s: s.format(edition) - app = cocoa_app(edition) - app_version = get_module_version(ed('core_{}')) - cocoa_project_path = ed('cocoa/{}') + app = cocoa_app() + app_version = get_module_version('core') + cocoa_project_path = 'cocoa/se' filereplace(op.join(cocoa_project_path, 'InfoTemplate.plist'), op.join('build', 'Info.plist'), version=app_version) app.create(op.join('build', 'Info.plist')) print("Building localizations") - build_localizations('cocoa', edition) + build_localizations('cocoa') print("Building xibless UIs") build_cocoalib_xibless() - build_xibless(edition) + build_xibless() print("Building Python extensions") build_cocoa_proxy_module() - build_cocoa_bridging_interfaces(edition) + build_cocoa_bridging_interfaces() print("Building the cocoa layer") copy_embeddable_python_dylib('build') pydep_folder = op.join(app.resources, 'py') if not op.exists(pydep_folder): os.mkdir(pydep_folder) shutil.copy(op.join(cocoa_project_path, 'dg_cocoa.py'), 'build') - specific_packages = { - 'se': ['core_se'], - 'me': ['core_me', 'hsaudiotag'], - 'pe': ['core_pe'], - }[edition] tocopy = [ - 'core', 'hscommon', 'cocoa/inter', 'cocoalib/cocoa', 'objp', 'send2trash' - ] + specific_packages + 'core', 'hscommon', 'cocoa/inter', 'cocoalib/cocoa', 'objp', 'send2trash', 'hsaudiotag', + ] copy_packages(tocopy, pydep_folder, create_links=dev) sys.path.insert(0, 'build') - extra_deps = None - if edition == 'pe': - # ModuleFinder can't seem to correctly detect the multiprocessing dependency, so we have - # to manually specify it. - extra_deps = ['multiprocessing'] + # ModuleFinder can't seem to correctly detect the multiprocessing dependency, so we have + # to manually specify it. + extra_deps = ['multiprocessing'] collect_stdlib_dependencies('build/dg_cocoa.py', pydep_folder, extra_deps=extra_deps) del sys.path[0] # Views are not referenced by python code, so they're not found by the collector. @@ -156,12 +151,12 @@ def build_cocoa(edition, dev): delete_files_with_pattern(pydep_folder, '__pycache__') print("Compiling with WAF") os.chdir('cocoa') - print_and_do('{0} waf configure --edition {1} && {0} waf'.format(sys.executable, edition)) + print_and_do('{0} waf configure && {0} waf'.format(sys.executable)) os.chdir('..') app.copy_executable('cocoa/build/dupeGuru') - build_help(edition) + build_help() print("Copying resources and frameworks") - image_path = ed('cocoa/{}/dupeguru.icns') + image_path = 'cocoa/se/dupeguru.icns' resources = [image_path, 'cocoa/base/dsa_pub.pem', 'build/dg_cocoa.py', 'build/help'] app.copy_resources(*resources, use_symlinks=dev) app.copy_frameworks('build/Python', 'cocoalib/Sparkle.framework') @@ -170,26 +165,26 @@ def build_cocoa(edition, dev): run_contents = tmpl.replace('{{app_path}}', app.dest) open('run.py', 'wt').write(run_contents) -def build_qt(edition, dev, conf): +def build_qt(dev): print("Building localizations") - build_localizations('qt', edition) + build_localizations('qt') print("Building Qt stuff") - print_and_do("pyrcc5 {0} > {1}".format(op.join('qt', 'base', 'dg.qrc'), op.join('qt', 'base', 'dg_rc.py'))) - fix_qt_resource_file(op.join('qt', 'base', 'dg_rc.py')) - build_help(edition) + print_and_do("pyrcc5 {0} > {1}".format(op.join('qt', 'dg.qrc'), op.join('qt', 'dg_rc.py'))) + fix_qt_resource_file(op.join('qt', 'dg_rc.py')) + build_help() print("Creating the run.py file") - filereplace(op.join('qt', 'run_template.py'), 'run.py', edition=edition) + shutil.copy(op.join('qt', 'run_template.py'), 'run.py') -def build_help(edition): +def build_help(): print("Generating Help") current_path = op.abspath('.') help_basepath = op.join(current_path, 'help', 'en') - help_destpath = op.join(current_path, 'build', 'help'.format(edition)) - changelog_path = op.join(current_path, 'help', 'changelog_{}'.format(edition)) + help_destpath = op.join(current_path, 'build', 'help') + changelog_path = op.join(current_path, 'help', 'changelog_se') tixurl = "https://github.com/hsoft/dupeguru/issues/{}" - appname = {'se': 'dupeGuru', 'me': 'dupeGuru Music Edition', 'pe': 'dupeGuru Picture Edition'}[edition] - homepage = 'http://www.hardcoded.net/dupeguru{}/'.format('_' + edition if edition != 'se' else '') - confrepl = {'edition': edition, 'appname': appname, 'homepage': homepage, 'language': 'en'} + appname = 'dupeGuru' + homepage = 'https://www.hardcoded.net/dupeguru/' + confrepl = {'appname': appname, 'homepage': homepage, 'language': 'en'} changelogtmpl = op.join(current_path, 'help', 'changelog.tmpl') conftmpl = op.join(current_path, 'help', 'conf.tmpl') sphinxgen.gen(help_basepath, help_destpath, changelog_path, tixurl, confrepl, conftmpl, changelogtmpl) @@ -198,10 +193,10 @@ def build_qt_localizations(): loc.compile_all_po(op.join('qtlib', 'locale')) loc.merge_locale_dir(op.join('qtlib', 'locale'), 'locale') -def build_localizations(ui, edition): +def build_localizations(ui): loc.compile_all_po('locale') if ui == 'cocoa': - app = cocoa_app(edition) + app = cocoa_app() loc.build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'base', 'en.lproj', 'Localizable.strings')) locale_dest = op.join(app.resources, 'locale') elif ui == 'qt': @@ -210,32 +205,19 @@ def build_localizations(ui, edition): if op.exists(locale_dest): shutil.rmtree(locale_dest) shutil.copytree('locale', locale_dest, ignore=shutil.ignore_patterns('*.po', '*.pot')) - if ui == 'qt' and not ISLINUX: - print("Copying qt_*.qm files into the 'locale' folder") - from PyQt5.QtCore import QLibraryInfo - trfolder = QLibraryInfo.location(QLibraryInfo.TranslationsPath) - for lang in loc.get_langs('locale'): - qmname = 'qt_%s.qm' % lang - src = op.join(trfolder, qmname) - if op.exists(src): - copy(src, op.join('build', 'locale', qmname)) def build_updatepot(): if ISOSX: print("Updating Cocoa strings file.") - # We need to have strings from *all* editions in here, so we'll call xibless for all editions - # in dummy subfolders. build_cocoalib_xibless('cocoalib/autogen') loc.generate_cocoa_strings_from_code('cocoalib', 'cocoalib/en.lproj') - for edition in ('se', 'me', 'pe'): - build_xibless(edition, op.join('cocoa', 'autogen', edition)) + build_xibless('se', op.join('cocoa', 'autogen', 'se')) loc.generate_cocoa_strings_from_code('cocoa', 'cocoa/base/en.lproj') print("Building .pot files from source files") print("Building core.pot") - all_cores = ['core', 'core_se', 'core_me', 'core_pe'] - loc.generate_pot(all_cores, op.join('locale', 'core.pot'), ['tr']) + loc.generate_pot(['core'], op.join('locale', 'core.pot'), ['tr']) print("Building columns.pot") - loc.generate_pot(all_cores, op.join('locale', 'columns.pot'), ['coltr']) + loc.generate_pot(['core'], op.join('locale', 'columns.pot'), ['coltr']) print("Building ui.pot") # When we're not under OS X, we don't want to overwrite ui.pot because it contains Cocoa locs # We want to merge the generated pot with the old pot in the most preserving way possible. @@ -279,7 +261,7 @@ def build_cocoa_proxy_module(): ['cocoalib', 'cocoa/autogen'] ) -def build_cocoa_bridging_interfaces(edition): +def build_cocoa_bridging_interfaces(): print("Building Cocoa Bridging Interfaces") import objp.o2p import objp.p2o @@ -300,7 +282,7 @@ def build_cocoa_bridging_interfaces(edition): from inter.result_table import PyResultTable, ResultTableView from inter.stats_label import PyStatsLabel, StatsLabelView from inter.app import PyDupeGuruBase, DupeGuruView - appmod = importlib.import_module('inter.app_{}'.format(edition)) + appmod = importlib.import_module('inter.app_se') allclasses = [ PyGUIObject, PyColumns, PyOutline, PySelectableList, PyTable, PyBaseApp, PyDetailsPanel, PyDirectoryOutline, PyPrioritizeDialog, PyPrioritizeList, PyProblemDialog, @@ -322,14 +304,21 @@ def build_cocoa_bridging_interfaces(edition): def build_pe_modules(ui): print("Building PE Modules") exts = [ - Extension("_block", [op.join('core_pe', 'modules', 'block.c'), op.join('core_pe', 'modules', 'common.c')]), - Extension("_cache", [op.join('core_pe', 'modules', 'cache.c'), op.join('core_pe', 'modules', 'common.c')]), + Extension( + "_block", + [op.join('core', 'pe', 'modules', 'block.c'), op.join('core', 'pe', 'modules', 'common.c')] + ), + Extension( + "_cache", + [op.join('core', 'pe', 'modules', 'cache.c'), op.join('core', 'pe', 'modules', 'common.c')] + ), ] if ui == 'qt': exts.append(Extension("_block_qt", [op.join('qt', 'pe', 'modules', 'block.c')])) elif ui == 'cocoa': exts.append(Extension( - "_block_osx", [op.join('core_pe', 'modules', 'block_osx.m'), op.join('core_pe', 'modules', 'common.c')], + "_block_osx", + [op.join('core', 'pe', 'modules', 'block_osx.m'), op.join('core', 'pe', 'modules', 'common.c')], extra_link_args=[ "-framework", "CoreFoundation", "-framework", "Foundation", @@ -341,27 +330,25 @@ def build_pe_modules(ui): ext_modules=exts, ) move_all('_block_qt*', op.join('qt', 'pe')) - move_all('_block*', 'core_pe') - move_all('_cache*', 'core_pe') + move_all('_block*', op.join('core', 'pe')) + move_all('_cache*', op.join('core', 'pe')) -def build_normal(edition, ui, dev, conf): - print("Building dupeGuru {0} with UI {1}".format(edition.upper(), ui)) +def build_normal(ui, dev): + print("Building dupeGuru with UI {}".format(ui)) add_to_pythonpath('.') print("Building dupeGuru") - if edition == 'pe': - build_pe_modules(ui) + build_pe_modules(ui) if ui == 'cocoa': - build_cocoa(edition, dev) + build_cocoa(dev) elif ui == 'qt': - build_qt(edition, dev, conf) + build_qt(dev) def main(): options = parse_args() - conf = json.load(open('conf.json')) - edition = conf['edition'] - ui = conf['ui'] - dev = conf['dev'] - if dev: + ui = options.ui + if ui not in ('cocoa', 'qt'): + ui = 'cocoa' if ISOSX else 'qt' + if options.dev: print("Building in Dev mode") if options.clean: for path in ['build', op.join('cocoa', 'build'), op.join('cocoa', 'autogen')]: @@ -370,9 +357,9 @@ def main(): if not op.exists('build'): os.mkdir('build') if options.doc: - build_help(edition) + build_help() elif options.loc: - build_localizations(ui, edition) + build_localizations(ui) elif options.updatepot: build_updatepot() elif options.mergepot: @@ -381,17 +368,17 @@ def main(): build_normpo() elif options.cocoa_ext: build_cocoa_proxy_module() - build_cocoa_bridging_interfaces(edition) + build_cocoa_bridging_interfaces() elif options.cocoa_compile: os.chdir('cocoa') - print_and_do('{0} waf configure --edition {1} && {0} waf'.format(sys.executable, edition)) + print_and_do('{0} waf configure && {0} waf'.format(sys.executable)) os.chdir('..') - cocoa_app(edition).copy_executable('cocoa/build/dupeGuru') + cocoa_app().copy_executable('cocoa/build/dupeGuru') elif options.xibless: build_cocoalib_xibless() - build_xibless(edition) + build_xibless() else: - build_normal(edition, ui, dev, conf) + build_normal(ui, options.dev) if __name__ == '__main__': main() diff --git a/configure.py b/configure.py deleted file mode 100644 index 30e2906c..00000000 --- a/configure.py +++ /dev/null @@ -1,45 +0,0 @@ -# Created By: Virgil Dupras -# Created On: 2009-12-30 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from optparse import OptionParser -import json - -from hscommon.plat import ISOSX - -def main(options): - if options.edition not in {'se', 'me', 'pe'}: - options.edition = 'se' - if options.ui not in {'cocoa', 'qt'}: - options.ui = 'cocoa' if ISOSX else 'qt' - build_type = 'Dev' if options.dev else 'Release' - print("Configuring dupeGuru {0} for UI {1} ({2})".format(options.edition.upper(), options.ui, build_type)) - conf = { - 'edition': options.edition, - 'ui': options.ui, - 'dev': options.dev, - } - json.dump(conf, open('conf.json', 'w')) - -if __name__ == '__main__': - usage = "usage: %prog [options]" - parser = OptionParser(usage=usage) - parser.add_option( - '--edition', dest='edition', - help="dupeGuru edition to build (se, me or pe). Default is se." - ) - parser.add_option( - '--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() - main(options) - diff --git a/core/__init__.py b/core/__init__.py index 8b137891..8b0777bf 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1 +1,3 @@ +__version__ = '3.9.1' +__appname__ = 'dupeGuru' diff --git a/core/app.py b/core/app.py index 5f2a8f94..a5fcedde 100644 --- a/core/app.py +++ b/core/app.py @@ -9,7 +9,6 @@ import os.path as op import logging import subprocess import re -import time import shutil from send2trash import send2trash @@ -18,14 +17,16 @@ from hscommon.notify import Broadcaster from hscommon.path import Path from hscommon.conflict import smart_move, smart_copy from hscommon.gui.progress_window import ProgressWindow -from hscommon.util import delete_if_empty, first, escape, nonone, format_time_decimal, allsame +from hscommon.util import delete_if_empty, first, escape, nonone, allsame from hscommon.trans import tr -from hscommon.plat import ISWINDOWS from hscommon import desktop -from . import directories, results, export, fs +from . import se, me, pe +from .pe.photo import get_delta_dimensions +from .util import cmp_value, fix_surrogate_encoding +from . import directories, results, export, fs, prioritize from .ignore import IgnoreList -from .scanner import ScanType, Scanner +from .scanner import ScanType from .gui.deletion_options import DeletionOptions from .gui.details_panel import DetailsPanel from .gui.directory_tree import DirectoryTree @@ -55,6 +56,11 @@ class JobType: Copy = 'job_copy' Delete = 'job_delete' +class AppMode: + Standard = 0 + Music = 1 + Picture = 2 + JOBID2TITLE = { JobType.Scan: tr("Scanning for duplicates"), JobType.Load: tr("Loading"), @@ -62,53 +68,6 @@ JOBID2TITLE = { JobType.Copy: tr("Copying"), JobType.Delete: tr("Sending to Trash"), } -if ISWINDOWS: - JOBID2TITLE[JobType.Delete] = tr("Sending files to the recycle bin") - -def format_timestamp(t, delta): - if delta: - return format_time_decimal(t) - else: - if t > 0: - return time.strftime('%Y/%m/%d %H:%M:%S', time.localtime(t)) - else: - return '---' - -def format_words(w): - def do_format(w): - if isinstance(w, list): - return '(%s)' % ', '.join(do_format(item) for item in w) - else: - return w.replace('\n', ' ') - - return ', '.join(do_format(item) for item in w) - -def format_perc(p): - return "%0.0f" % p - -def format_dupe_count(c): - return str(c) if c else '---' - -def cmp_value(dupe, attrname): - value = getattr(dupe, attrname, '') - return value.lower() if isinstance(value, str) else value - -def fix_surrogate_encoding(s, encoding='utf-8'): - # ref #210. It's possible to end up with file paths that, while correct unicode strings, are - # decoded with the 'surrogateescape' option, which make the string unencodable to utf-8. We fix - # these strings here by trying to encode them and, if it fails, we do an encode/decode dance - # to remove the problematic characters. This dance is *lossy* but there's not much we can do - # because if we end up with this type of string, it means that we don't know the encoding of the - # underlying filesystem that brought them. Don't use this for strings you're going to re-use in - # fs-related functions because you're going to lose your path (it's going to change). Use this - # if you need to export the path somewhere else, outside of the unicode realm. - # See http://lucumr.pocoo.org/2013/7/2/the-updated-guide-to-unicode/ - try: - s.encode(encoding) - except UnicodeEncodeError: - return s.encode(encoding, 'replace').decode(encoding) - else: - return s class DupeGuru(Broadcaster): """Holds everything together. @@ -149,13 +108,13 @@ class DupeGuru(Broadcaster): # open_path(path) # reveal_path(path) # ask_yes_no(prompt) --> bool + # create_results_window() # show_results_window() # show_problem_dialog() # select_dest_folder(prompt: str) --> str # select_dest_file(prompt: str, ext: str) --> str - PROMPT_NAME = "dupeGuru" - SCANNER_CLASS = Scanner + NAME = PROMPT_NAME = "dupeGuru" def __init__(self, view): if view.get_default(DEBUG_MODE_PREFERENCE): @@ -166,9 +125,8 @@ class DupeGuru(Broadcaster): self.appdata = desktop.special_folder_path(desktop.SpecialFolder.AppData, appname=self.NAME) if not op.exists(self.appdata): os.makedirs(self.appdata) + self.app_mode = AppMode.Standard self.discarded_file_count = 0 - self.fileclasses = [fs.File] - self.folderclass = fs.Folder self.directories = directories.Directories() self.results = results.Results(self) self.ignore_list = IgnoreList() @@ -180,6 +138,7 @@ class DupeGuru(Broadcaster): 'clean_empty_dirs': False, 'ignore_hardlink_matches': False, 'copymove_dest_type': DestType.Relative, + 'cache_path': op.join(self.appdata, 'cached_pictures.db'), } self.selected_dupes = [] self.details_panel = DetailsPanel(self) @@ -187,22 +146,32 @@ class DupeGuru(Broadcaster): self.problem_dialog = ProblemDialog(self) self.ignore_list_dialog = IgnoreListDialog(self) self.stats_label = StatsLabel(self) - self.result_table = self._create_result_table() + self.result_table = None self.deletion_options = DeletionOptions() self.progress_window = ProgressWindow(self._job_completed) - children = [self.result_table, self.directory_tree, self.stats_label, self.details_panel] + children = [self.directory_tree, self.stats_label, self.details_panel] for child in children: child.connect() - #--- Virtual - def _prioritization_categories(self): - raise NotImplementedError() - - def _create_result_table(self): - raise NotImplementedError() - #--- Private + def _create_result_table(self): + if self.app_mode == AppMode.Picture: + return pe.result_table.ResultTable(self) + elif self.app_mode == AppMode.Music: + return me.result_table.ResultTable(self) + else: + return se.result_table.ResultTable(self) + def _get_dupe_sort_key(self, dupe, get_group, key, delta): + if self.app_mode in (AppMode.Music, AppMode.Picture): + if key == 'folder_path': + dupe_folder_path = getattr(dupe, 'display_folder_path', dupe.folder_path) + return str(dupe_folder_path).lower() + if self.app_mode == AppMode.Picture: + if delta and key == 'dimensions': + r = cmp_value(dupe, key) + ref_value = cmp_value(get_group().ref, key) + return get_delta_dimensions(r, ref_value) if key == 'marked': return self.results.is_marked(dupe) if key == 'percentage': @@ -222,6 +191,10 @@ class DupeGuru(Broadcaster): return result def _get_group_sort_key(self, group, key): + if self.app_mode in (AppMode.Music, AppMode.Picture): + if key == 'folder_path': + dupe_folder_path = getattr(group.ref, 'display_folder_path', group.ref.folder_path) + return str(dupe_folder_path).lower() if key == 'percentage': return group.percentage if key == 'dupe_count': @@ -349,6 +322,15 @@ class DupeGuru(Broadcaster): self.selected_dupes = dupes self.notify('dupes_selected') + #--- Protected + def _prioritization_categories(self): + if self.app_mode == AppMode.Picture: + return pe.prioritize.all_categories() + elif self.app_mode == AppMode.Music: + return me.prioritize.all_categories() + else: + return prioritize.all_categories() + #--- Public def add_directory(self, d): """Adds folder ``d`` to :attr:`directories`. @@ -400,6 +382,11 @@ class DupeGuru(Broadcaster): while delete_if_empty(path, ['.DS_Store']): path = path.parent() + def clear_picture_cache(self): + cache = pe.cache.Cache(self.options['cache_path']) + cache.clear() + cache.close() + def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType): source_path = dupe.path location_path = first(p for p in self.directories if dupe.path in p) @@ -746,12 +733,17 @@ class DupeGuru(Broadcaster): if hasattr(scanner, k): setattr(scanner, k, v) self.results.groups = [] + if self.result_table is not None: + self.result_table.disconnect() + self.result_table = self._create_result_table() + self.result_table.connect() + self.view.create_results_window() self._results_changed() def do(j): j.set_progress(0, tr("Collecting files to scan")) if scanner.scan_type == ScanType.Folders: - files = list(self.directories.get_folders(folderclass=self.folderclass, j=j)) + files = list(self.directories.get_folders(folderclass=se.fs.folder, j=j)) else: files = list(self.directories.get_files(fileclasses=self.fileclasses, j=j)) if self.options['ignore_hardlink_matches']: @@ -800,3 +792,33 @@ class DupeGuru(Broadcaster): result = tr("%s (%d discarded)") % (result, self.discarded_file_count) return result + @property + def fileclasses(self): + if self.app_mode == AppMode.Picture: + return [pe.photo.PLAT_SPECIFIC_PHOTO_CLASS] + elif self.app_mode == AppMode.Music: + return [me.fs.MusicFile] + else: + return [se.fs.File] + + @property + def SCANNER_CLASS(self): + if self.app_mode == AppMode.Picture: + return pe.scanner.ScannerPE + elif self.app_mode == AppMode.Music: + return me.scanner.ScannerME + else: + return se.scanner.ScannerSE + + @property + def METADATA_TO_READ(self): + if self.app_mode == AppMode.Picture: + return ['size', 'mtime', 'dimensions', 'exif_timestamp'] + elif self.app_mode == AppMode.Music: + return [ + 'size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist', + 'album', 'genre', 'year', 'track', 'comment' + ] + else: + return ['size', 'mtime'] + diff --git a/core/gui/details_panel.py b/core/gui/details_panel.py index 7245c3f6..5490edf3 100644 --- a/core/gui/details_panel.py +++ b/core/gui/details_panel.py @@ -1,9 +1,9 @@ # Created By: Virgil Dupras # Created On: 2010-02-05 # Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from hscommon.gui.base import GUIObject @@ -11,14 +11,14 @@ from .base import DupeGuruGUIObject class DetailsPanel(GUIObject, DupeGuruGUIObject): def __init__(self, app): - GUIObject.__init__(self) + GUIObject.__init__(self, multibind=True) DupeGuruGUIObject.__init__(self, app) self._table = [] - + def _view_updated(self): self._refresh() self.view.refresh() - + #--- Private def _refresh(self): if self.app.selected_dupes: @@ -33,16 +33,16 @@ class DetailsPanel(GUIObject, DupeGuruGUIObject): data2 = self.app.get_display_info(ref, group, False) columns = self.app.result_table.COLUMNS[1:] # first column is the 'marked' column self._table = [(c.display, data1[c.name], data2[c.name]) for c in columns] - + #--- Public def row_count(self): return len(self._table) - + def row(self, row_index): return self._table[row_index] - + #--- Event Handlers def dupes_selected(self): self._refresh() self.view.refresh() - + diff --git a/core/me/__init__.py b/core/me/__init__.py new file mode 100644 index 00000000..03f4e58c --- /dev/null +++ b/core/me/__init__.py @@ -0,0 +1 @@ +from . import fs, prioritize, result_table, scanner # noqa diff --git a/core_me/fs.py b/core/me/fs.py similarity index 97% rename from core_me/fs.py rename to core/me/fs.py index 1be57252..eb060128 100644 --- a/core_me/fs.py +++ b/core/me/fs.py @@ -9,7 +9,7 @@ from hsaudiotag import auto 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.util import format_timestamp, format_perc, format_words, format_dupe_count from core import fs TAG_FIELDS = { diff --git a/core_me/prioritize.py b/core/me/prioritize.py similarity index 100% rename from core_me/prioritize.py rename to core/me/prioritize.py diff --git a/core_me/result_table.py b/core/me/result_table.py similarity index 100% rename from core_me/result_table.py rename to core/me/result_table.py diff --git a/core_me/scanner.py b/core/me/scanner.py similarity index 100% rename from core_me/scanner.py rename to core/me/scanner.py diff --git a/core/pe/__init__.py b/core/pe/__init__.py new file mode 100644 index 00000000..9cac7a5f --- /dev/null +++ b/core/pe/__init__.py @@ -0,0 +1 @@ +from . import block, cache, exif, iphoto_plist, matchblock, matchexif, photo, prioritize, result_table, scanner # noqa diff --git a/core_pe/block.py b/core/pe/block.py similarity index 100% rename from core_pe/block.py rename to core/pe/block.py diff --git a/core_pe/cache.py b/core/pe/cache.py similarity index 100% rename from core_pe/cache.py rename to core/pe/cache.py diff --git a/core_pe/exif.py b/core/pe/exif.py similarity index 100% rename from core_pe/exif.py rename to core/pe/exif.py diff --git a/core/pe/iphoto_plist.py b/core/pe/iphoto_plist.py new file mode 100644 index 00000000..40f6ca85 --- /dev/null +++ b/core/pe/iphoto_plist.py @@ -0,0 +1,31 @@ +# Created By: Virgil Dupras +# Created On: 2014-03-15 +# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +import plistlib + +class IPhotoPlistParser(plistlib._PlistParser): + """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 + lenient. + """ + def __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 + # log it in case of an exception + self.lastdata = '' + + def get_data(self): + self.lastdata = plistlib._PlistParser.get_data(self) + return self.lastdata + + def end_integer(self): + try: + self.add_object(int(self.get_data())) + except ValueError: + self.add_object(0) diff --git a/core_pe/matchblock.py b/core/pe/matchblock.py similarity index 100% rename from core_pe/matchblock.py rename to core/pe/matchblock.py diff --git a/core_pe/matchexif.py b/core/pe/matchexif.py similarity index 100% rename from core_pe/matchexif.py rename to core/pe/matchexif.py diff --git a/core_pe/modules/block.c b/core/pe/modules/block.c similarity index 100% rename from core_pe/modules/block.c rename to core/pe/modules/block.c diff --git a/core_pe/modules/block_osx.m b/core/pe/modules/block_osx.m similarity index 100% rename from core_pe/modules/block_osx.m rename to core/pe/modules/block_osx.m diff --git a/core_pe/modules/cache.c b/core/pe/modules/cache.c similarity index 100% rename from core_pe/modules/cache.c rename to core/pe/modules/cache.c diff --git a/core_pe/modules/common.c b/core/pe/modules/common.c similarity index 100% rename from core_pe/modules/common.c rename to core/pe/modules/common.c diff --git a/core_pe/modules/common.h b/core/pe/modules/common.h similarity index 100% rename from core_pe/modules/common.h rename to core/pe/modules/common.h diff --git a/core_pe/photo.py b/core/pe/photo.py similarity index 93% rename from core_pe/photo.py rename to core/pe/photo.py index 6d9c9126..fdaea94e 100644 --- a/core_pe/photo.py +++ b/core/pe/photo.py @@ -1,6 +1,4 @@ -# Created By: Virgil Dupras -# Created On: 2011-05-29 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) # # This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # which should be included with this package. The terms are also available at @@ -9,10 +7,13 @@ import logging from hscommon.util import get_file_ext, format_size -from core.app import format_timestamp, format_perc, format_dupe_count +from core.util import format_timestamp, format_perc, format_dupe_count from core import fs from . import exif +# This global value is set by the platform-specific subclasser of the Photo base class +PLAT_SPECIFIC_PHOTO_CLASS = None + def format_dimensions(dimensions): return '%d x %d' % (dimensions[0], dimensions[1]) diff --git a/core_pe/prioritize.py b/core/pe/prioritize.py similarity index 100% rename from core_pe/prioritize.py rename to core/pe/prioritize.py diff --git a/core_pe/result_table.py b/core/pe/result_table.py similarity index 100% rename from core_pe/result_table.py rename to core/pe/result_table.py diff --git a/core_pe/scanner.py b/core/pe/scanner.py similarity index 88% rename from core_pe/scanner.py rename to core/pe/scanner.py index 44c3a5e4..dcc9dd9c 100644 --- a/core_pe/scanner.py +++ b/core/pe/scanner.py @@ -9,7 +9,6 @@ from hscommon.trans import tr from core.scanner import Scanner, ScanType, ScanOption from . import matchblock, matchexif -from .cache import Cache class ScannerPE(Scanner): cache_path = None @@ -31,8 +30,3 @@ class ScannerPE(Scanner): else: raise Exception("Invalid scan type") - def clear_picture_cache(self): - cache = Cache(self.cache_path) - cache.clear() - cache.close() - diff --git a/core/se/__init__.py b/core/se/__init__.py new file mode 100644 index 00000000..b627d223 --- /dev/null +++ b/core/se/__init__.py @@ -0,0 +1 @@ +from . import fs, result_table, scanner # noqa diff --git a/core_se/fs.py b/core/se/fs.py similarity index 90% rename from core_se/fs.py rename to core/se/fs.py index 6b15cdfa..8e691251 100644 --- a/core_se/fs.py +++ b/core/se/fs.py @@ -1,15 +1,15 @@ # Created By: Virgil Dupras # Created On: 2013-07-14 # Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from hscommon.util import format_size from core import fs -from core.app import format_timestamp, format_perc, format_words, format_dupe_count +from core.util import format_timestamp, format_perc, format_words, format_dupe_count def get_display_info(dupe, group, delta): size = dupe.size @@ -39,9 +39,9 @@ def get_display_info(dupe, group, delta): class File(fs.File): def get_display_info(self, group, delta): return get_display_info(self, group, delta) - + class Folder(fs.Folder): def get_display_info(self, group, delta): return get_display_info(self, group, delta) - + diff --git a/core_se/result_table.py b/core/se/result_table.py similarity index 100% rename from core_se/result_table.py rename to core/se/result_table.py diff --git a/core_se/scanner.py b/core/se/scanner.py similarity index 100% rename from core_se/scanner.py rename to core/se/scanner.py diff --git a/core/tests/app_test.py b/core/tests/app_test.py index f3b1acb1..25a8e97d 100644 --- a/core/tests/app_test.py +++ b/core/tests/app_test.py @@ -384,7 +384,7 @@ class TestCaseDupeGuruWithResults: app.JOB = Job(1, lambda *args, **kw: False) # Cancels the task add_fake_files_to_directories(app.directories, self.objects) # We want the scan to at least start app.start_scanning() # will be cancelled immediately - eq_(len(self.rtable), 0) + eq_(len(app.result_table), 0) 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 diff --git a/core/tests/base.py b/core/tests/base.py index 232496a9..693e655e 100644 --- a/core/tests/base.py +++ b/core/tests/base.py @@ -4,7 +4,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from hscommon.testutil import TestApp as TestAppBase, eq_, with_app # noqa +from hscommon.testutil import TestApp as TestAppBase, CallLogger, eq_, with_app # noqa from hscommon.path import Path from hscommon.util import get_file_ext, format_size from hscommon.gui.column import Column @@ -41,6 +41,8 @@ class DupeGuruView: def ask_yes_no(self, prompt): return True # always answer yes + def create_results_window(self): + pass class ResultTable(ResultTableBase): COLUMNS = [ @@ -59,12 +61,16 @@ class DupeGuru(DupeGuruBase): def __init__(self): DupeGuruBase.__init__(self, DupeGuruView()) self.appdata = '/tmp' + self.result_table = self._create_result_table() + self.result_table.connect() def _prioritization_categories(self): return prioritize.all_categories() def _create_result_table(self): - return ResultTable(self) + result = ResultTable(self) + result.view = CallLogger() + return result class NamedObject: @@ -141,7 +147,6 @@ class TestApp(TestAppBase): TestAppBase.__init__(self) self.app = DupeGuru() self.default_parent = self.app - self.rtable = link_gui(self.app.result_table) self.dtree = link_gui(self.app.directory_tree) self.dpanel = link_gui(self.app.details_panel) self.slabel = link_gui(self.app.stats_label) @@ -155,6 +160,11 @@ class TestApp(TestAppBase): link_gui(self.app.progress_window.jobdesc_textfield) link_gui(self.app.progress_window.progressdesc_textfield) + @property + def rtable(self): + # rtable is a property because its instance can be replaced during execution + return self.app.result_table + #--- Helpers def select_pri_criterion(self, name): # Select a main prioritize criterion by name instead of by index. Makes tests more diff --git a/core_pe/tests/block_test.py b/core/tests/block_test.py similarity index 99% rename from core_pe/tests/block_test.py rename to core/tests/block_test.py index d281d518..187e9129 100644 --- a/core_pe/tests/block_test.py +++ b/core/tests/block_test.py @@ -9,7 +9,7 @@ from pytest import raises, skip from hscommon.testutil import eq_ try: - from ..block import avgdiff, getblocks2, NoBlocksError, DifferentBlockCountError + from ..pe.block import avgdiff, getblocks2, NoBlocksError, DifferentBlockCountError except ImportError: skip("Can't import the block module, probably hasn't been compiled.") diff --git a/core_pe/tests/cache_test.py b/core/tests/cache_test.py similarity index 98% rename from core_pe/tests/cache_test.py rename to core/tests/cache_test.py index 4c06ba26..3dbcd16d 100644 --- a/core_pe/tests/cache_test.py +++ b/core/tests/cache_test.py @@ -10,7 +10,7 @@ from pytest import raises, skip from hscommon.testutil import eq_ try: - from ..cache import Cache, colors_to_string, string_to_colors + from ..pe.cache import Cache, colors_to_string, string_to_colors except ImportError: skip("Can't import the cache module, probably hasn't been compiled.") diff --git a/core/tests/scanner_test.py b/core/tests/scanner_test.py index 04d1de77..dc1e8998 100644 --- a/core/tests/scanner_test.py +++ b/core/tests/scanner_test.py @@ -12,6 +12,7 @@ from .. import fs from ..engine import getwords, Match from ..ignore import IgnoreList from ..scanner import Scanner, ScanType +from ..me.scanner import ScannerME class NamedObject: def __init__(self, name="foobar", size=1, path=None): @@ -528,3 +529,13 @@ def test_dont_count_ref_files_as_discarded(fake_fileexists): o2.is_ref = True eq_(len(s.get_dupe_groups([o1, o2, o3])), 1) eq_(s.discarded_file_count, 0) + +def test_priorize_me(fake_fileexists): + # in ScannerME, bitrate goes first (right after is_ref) in priorization + s = ScannerME() + o1, o2 = no('foo', path='p1'), no('foo', path='p2') + o1.bitrate = 1 + o2.bitrate = 2 + [group] = s.get_dupe_groups([o1, o2]) + assert group.ref is o2 + diff --git a/core/util.py b/core/util.py new file mode 100644 index 00000000..036e46f6 --- /dev/null +++ b/core/util.py @@ -0,0 +1,56 @@ +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +import time + +from hscommon.util import format_time_decimal + +def format_timestamp(t, delta): + if delta: + return format_time_decimal(t) + else: + if t > 0: + return time.strftime('%Y/%m/%d %H:%M:%S', time.localtime(t)) + else: + return '---' + +def format_words(w): + def do_format(w): + if isinstance(w, list): + return '(%s)' % ', '.join(do_format(item) for item in w) + else: + return w.replace('\n', ' ') + + return ', '.join(do_format(item) for item in w) + +def format_perc(p): + return "%0.0f" % p + +def format_dupe_count(c): + return str(c) if c else '---' + +def cmp_value(dupe, attrname): + value = getattr(dupe, attrname, '') + return value.lower() if isinstance(value, str) else value + +def fix_surrogate_encoding(s, encoding='utf-8'): + # ref #210. It's possible to end up with file paths that, while correct unicode strings, are + # decoded with the 'surrogateescape' option, which make the string unencodable to utf-8. We fix + # these strings here by trying to encode them and, if it fails, we do an encode/decode dance + # to remove the problematic characters. This dance is *lossy* but there's not much we can do + # because if we end up with this type of string, it means that we don't know the encoding of the + # underlying filesystem that brought them. Don't use this for strings you're going to re-use in + # fs-related functions because you're going to lose your path (it's going to change). Use this + # if you need to export the path somewhere else, outside of the unicode realm. + # See http://lucumr.pocoo.org/2013/7/2/the-updated-guide-to-unicode/ + try: + s.encode(encoding) + except UnicodeEncodeError: + return s.encode(encoding, 'replace').decode(encoding) + else: + return s + + diff --git a/core_me/__init__.py b/core_me/__init__.py deleted file mode 100644 index f48793de..00000000 --- a/core_me/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__version__ = '6.8.1' -__appname__ = 'dupeGuru Music Edition' - diff --git a/core_me/app.py b/core_me/app.py deleted file mode 100644 index 82961d37..00000000 --- a/core_me/app.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from core.app import DupeGuru as DupeGuruBase -from . import prioritize -from . import __appname__ -from . import scanner, fs -from .result_table import ResultTable - -class DupeGuru(DupeGuruBase): - NAME = __appname__ - METADATA_TO_READ = [ - 'size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist', - 'album', 'genre', 'year', 'track', 'comment' - ] - SCANNER_CLASS = scanner.ScannerME - - def __init__(self, view): - DupeGuruBase.__init__(self, view) - self.fileclasses = [fs.MusicFile] - - def _get_dupe_sort_key(self, dupe, get_group, key, delta): - if key == 'folder_path': - dupe_folder_path = getattr(dupe, 'display_folder_path', dupe.folder_path) - return str(dupe_folder_path).lower() - return DupeGuruBase._get_dupe_sort_key(self, dupe, get_group, key, delta) - - def _get_group_sort_key(self, group, key): - if key == 'folder_path': - dupe_folder_path = getattr(group.ref, 'display_folder_path', group.ref.folder_path) - return str(dupe_folder_path).lower() - return DupeGuruBase._get_group_sort_key(self, group, key) - - def _prioritization_categories(self): - return prioritize.all_categories() - - def _create_result_table(self): - return ResultTable(self) diff --git a/core_me/tests/__init__.py b/core_me/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/core_me/tests/scanner_test.py b/core_me/tests/scanner_test.py deleted file mode 100644 index 609570b6..00000000 --- a/core_me/tests/scanner_test.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from hscommon.path import Path - -from core.tests.scanner_test import no -from ..scanner import ScannerME - -def pytest_funcarg__fake_fileexists(request): - # This is a hack to avoid invalidating all previous tests since the scanner started to test - # for file existence before doing the match grouping. - monkeypatch = request.getfuncargvalue('monkeypatch') - monkeypatch.setattr(Path, 'exists', lambda _: True) - -def test_priorize_me(fake_fileexists): - # in ScannerME, bitrate goes first (right after is_ref) in priorization - s = ScannerME() - o1, o2 = no('foo', path='p1'), no('foo', path='p2') - o1.bitrate = 1 - o2.bitrate = 2 - [group] = s.get_dupe_groups([o1, o2]) - assert group.ref is o2 - diff --git a/core_pe/__init__.py b/core_pe/__init__.py deleted file mode 100644 index 65652dfa..00000000 --- a/core_pe/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = '2.10.1' -__appname__ = 'dupeGuru Picture Edition' diff --git a/core_pe/app.py b/core_pe/app.py deleted file mode 100644 index bce62610..00000000 --- a/core_pe/app.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -import os.path as op - -from core.app import DupeGuru as DupeGuruBase, cmp_value -from .scanner import ScannerPE -from . import prioritize -from . import __appname__ -from .photo import get_delta_dimensions -from .result_table import ResultTable - -class DupeGuru(DupeGuruBase): - NAME = __appname__ - METADATA_TO_READ = ['size', 'mtime', 'dimensions', 'exif_timestamp'] - SCANNER_CLASS = ScannerPE - - def __init__(self, view): - DupeGuruBase.__init__(self, view) - self.options['cache_path'] = op.join(self.appdata, 'cached_pictures.db') - - def _get_dupe_sort_key(self, dupe, get_group, key, delta): - if key == 'folder_path': - dupe_folder_path = getattr(dupe, 'display_folder_path', dupe.folder_path) - return str(dupe_folder_path).lower() - if delta and key == 'dimensions': - r = cmp_value(dupe, key) - ref_value = cmp_value(get_group().ref, key) - return get_delta_dimensions(r, ref_value) - return DupeGuruBase._get_dupe_sort_key(self, dupe, get_group, key, delta) - - def _get_group_sort_key(self, group, key): - if key == 'folder_path': - dupe_folder_path = getattr(group.ref, 'display_folder_path', group.ref.folder_path) - return str(dupe_folder_path).lower() - return DupeGuruBase._get_group_sort_key(self, group, key) - - def _prioritization_categories(self): - return prioritize.all_categories() - - def _create_result_table(self): - return ResultTable(self) diff --git a/core_pe/tests/__init__.py b/core_pe/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/core_se/__init__.py b/core_se/__init__.py deleted file mode 100644 index 8b0777bf..00000000 --- a/core_se/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__version__ = '3.9.1' -__appname__ = 'dupeGuru' - diff --git a/core_se/app.py b/core_se/app.py deleted file mode 100644 index a764bb52..00000000 --- a/core_se/app.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from core.app import DupeGuru as DupeGuruBase -from core import prioritize -from . import __appname__, fs, scanner -from .result_table import ResultTable - -class DupeGuru(DupeGuruBase): - NAME = __appname__ - METADATA_TO_READ = ['size', 'mtime'] - SCANNER_CLASS = scanner.ScannerSE - - def __init__(self, view): - DupeGuruBase.__init__(self, view) - self.fileclasses = [fs.File] - self.folderclass = fs.Folder - - def _prioritization_categories(self): - return prioritize.all_categories() - - def _create_result_table(self): - return ResultTable(self) - diff --git a/hscommon b/hscommon index ea634cef..316af1bc 160000 --- a/hscommon +++ b/hscommon @@ -1 +1 @@ -Subproject commit ea634cefdf78ae9e4c7470e571fce859760f6f38 +Subproject commit 316af1bca53915f99b9bb874064cba6bee881cc1 diff --git a/package.py b/package.py index 405dc609..84473884 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,4 @@ -# Created By: Virgil Dupras -# Created On: 2009-12-30 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) # # This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # which should be included with this package. The terms are also available at @@ -13,13 +11,12 @@ import shutil import json from argparse import ArgumentParser import platform -import glob -from hscommon.plat import ISWINDOWS, ISLINUX +from hscommon.plat import ISOSX 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, - package_cocoa_app_in_dmg, copy_all, find_in_path + print_and_do, copy_packages, build_debian_changelog, + get_module_version, filereplace, copy, setup_package_argparser, + package_cocoa_app_in_dmg, copy_all ) def parse_args(): @@ -27,90 +24,10 @@ def parse_args(): setup_package_argparser(parser) return parser.parse_args() -def package_cocoa(edition, args): - app_path = { - 'se': 'build/dupeGuru.app', - 'me': 'build/dupeGuru ME.app', - 'pe': 'build/dupeGuru PE.app', - }[edition] +def package_cocoa(args): + app_path = 'build/dupeGuru.app' package_cocoa_app_in_dmg(app_path, '.', args) -def package_windows(edition, dev): - if not ISWINDOWS: - print("Qt packaging only works under Windows.") - return - from cx_Freeze import setup, Executable - from PyQt5.QtCore import QLibraryInfo - add_to_pythonpath('.') - app_version = get_module_version('core_{}'.format(edition)) - distdir = 'dist' - - if op.exists(distdir): - shutil.rmtree(distdir) - - if not dev: - # Copy qt plugins - plugin_dest = distdir - plugin_names = ['accessible', 'codecs', 'iconengines', 'imageformats'] - 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. - options = { - 'build_exe': { - 'includes': 'atexit', - 'excludes': ['tkinter'], - 'bin_excludes': ['icudt51', 'icuin51.dll', 'icuuc51.dll'], - 'icon': 'images\\dg{0}_logo.ico'.format(edition), - 'include_msvcr': True, - }, - 'install_exe': { - 'install_dir': 'dist', - } - } - - executables = [ - Executable( - 'run.py', - base='Win32GUI', - targetDir=distdir, - targetName={'se': 'dupeGuru', 'me': 'dupeGuru ME', 'pe': 'dupeGuru PE'}[edition] + '.exe', - ) - ] - - setup( - script_args=['install'], - options=options, - executables=executables - ) - - print("Removing useless files") - # Debug info that cx_freeze brings in. - for fn in glob.glob(op.join(distdir, '*', '*.pdb')): - 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') - print("Copying {} to dist\\help".format(help_path)) - shutil.copytree(help_path, op.join(distdir, 'help')) - locale_path = op.join('build', 'locale') - print("Copying {} to dist\\locale".format(locale_path)) - shutil.copytree(locale_path, op.join(distdir, 'locale')) - - # AdvancedInstaller.com has to be in your PATH - # this is so we don'a have to re-commit installer.aip at every version change - installer_file = 'installer.aip' - installer_path = op.join('qt', edition, installer_file) - shutil.copy(installer_path, 'installer_tmp.aip') - print_and_do('AdvancedInstaller.com /edit installer_tmp.aip /SetVersion %s' % app_version) - print_and_do('AdvancedInstaller.com /build installer_tmp.aip -force') - os.remove('installer_tmp.aip') - if op.exists('installer_tmp.back.aip'): - os.remove('installer_tmp.back.aip') - def copy_files_to_package(destpath, packages, with_so): # when with_so is true, we keep .so files in the package, and otherwise, we don't. We need this # flag because when building debian src pkg, we *don't* want .so files (they're compiled later) @@ -126,71 +43,68 @@ def copy_files_to_package(destpath, packages, with_so): shutil.copytree(op.join('build', 'locale'), op.join(destpath, 'locale')) compileall.compile_dir(destpath) -def package_debian_distribution(edition, distribution): - app_version = get_module_version('core_{}'.format(edition)) +def package_debian_distribution(distribution): + app_version = get_module_version('core') version = '{}~{}'.format(app_version, distribution) - ed = lambda s: s.format(edition) - destpath = op.join('build', 'dupeguru-{0}-{1}'.format(edition, version)) + destpath = op.join('build', 'dupeguru-{}'.format(version)) srcpath = op.join(destpath, 'src') - packages = ['hscommon', 'core', ed('core_{0}'), 'qtlib', 'qt', 'send2trash'] - if edition == 'me': - packages.append('hsaudiotag') + packages = [ + 'hscommon', 'core', 'qtlib', 'qt', 'send2trash', 'hsaudiotag' + ] copy_files_to_package(srcpath, packages, with_so=False) - if edition == 'pe': - os.mkdir(op.join(destpath, 'modules')) - copy_all(op.join('core_pe', 'modules', '*.*'), op.join(destpath, 'modules')) - copy(op.join('qt', 'pe', 'modules', 'block.c'), op.join(destpath, 'modules', 'block_qt.c')) - copy(op.join('pkg', 'debian', 'build_pe_modules.py'), op.join(destpath, 'build_pe_modules.py')) + os.mkdir(op.join(destpath, 'modules')) + copy_all(op.join('core', 'pe', 'modules', '*.*'), op.join(destpath, 'modules')) + copy(op.join('qt', 'pe', 'modules', 'block.c'), op.join(destpath, 'modules', 'block_qt.c')) + copy(op.join('pkg', 'debian', 'build_pe_modules.py'), op.join(destpath, 'build_pe_modules.py')) debdest = op.join(destpath, 'debian') debskel = op.join('pkg', 'debian') os.makedirs(debdest) - debopts = json.load(open(op.join(debskel, ed('{}.json')))) + debopts = json.load(open(op.join(debskel, 'se.json'))) for fn in ['compat', 'copyright', 'dirs', 'rules']: copy(op.join(debskel, fn), op.join(debdest, fn)) filereplace(op.join(debskel, 'control'), op.join(debdest, 'control'), **debopts) filereplace(op.join(debskel, 'Makefile'), op.join(destpath, 'Makefile'), **debopts) - filereplace(op.join(debskel, 'dupeguru.desktop'), op.join(debdest, ed('dupeguru_{}.desktop')), **debopts) - changelogpath = op.join('help', ed('changelog_{}')) + filereplace(op.join(debskel, 'dupeguru.desktop'), op.join(debdest, 'dupeguru.desktop'), **debopts) + changelogpath = op.join('help', 'changelog_se') changelog_dest = op.join(debdest, 'changelog') project_name = debopts['pkgname'] - from_version = {'se': '2.9.2', 'me': '5.7.2', 'pe': '1.8.5'}[edition] + from_version = '2.9.2' build_debian_changelog( 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', 'dgse_logo_128.png'), srcpath) os.chdir(destpath) cmd = "dpkg-buildpackage -S" os.system(cmd) os.chdir('../..') -def package_debian(edition): +def package_debian(): print("Packaging for Ubuntu") for distribution in ['trusty', 'utopic']: - package_debian_distribution(edition, distribution) + package_debian_distribution(distribution) -def package_arch(edition): +def package_arch(): # For now, package_arch() will only copy the source files into build/. It copies less packages # than package_debian because there are more python packages available in Arch (so we don't # need to include them). print("Packaging for Arch") - ed = lambda s: s.format(edition) - srcpath = op.join('build', ed('dupeguru-{}-arch')) - packages = ['hscommon', 'core', ed('core_{0}'), 'qtlib', 'qt', 'send2trash'] - if edition == 'me': - packages.append('hsaudiotag') + srcpath = op.join('build', 'dupeguru-arch') + packages = [ + 'hscommon', 'core', 'qtlib', 'qt', 'send2trash', 'hsaudiotag', + ] copy_files_to_package(srcpath, packages, with_so=True) - shutil.copy(op.join('images', ed('dg{}_logo_128.png')), srcpath) - debopts = json.load(open(op.join('pkg', 'arch', ed('{}.json')))) - filereplace(op.join('pkg', 'arch', 'dupeguru.desktop'), op.join(srcpath, ed('dupeguru-{}.desktop')), **debopts) + shutil.copy(op.join('images', 'dgse_logo_128.png'), srcpath) + debopts = json.load(open(op.join('pkg', 'arch', 'se.json'))) + filereplace(op.join('pkg', 'arch', 'dupeguru.desktop'), op.join(srcpath, 'dupeguru.desktop'), **debopts) -def package_source_tgz(edition): +def package_source_tgz(): if not op.exists('deps'): print("Downloading PyPI dependencies") print_and_do('./download_deps.sh') print("Creating git archive") - app_version = get_module_version('core_{}'.format(edition)) - name = 'dupeguru-{}-src-{}.tar'.format(edition, app_version) + app_version = get_module_version('core') + name = 'dupeguru-src-{}.tar'.format(app_version) dest = op.join('build', name) print_and_do('git archive -o {} HEAD'.format(dest)) print("Adding dependencies and wrapping up") @@ -199,31 +113,23 @@ def package_source_tgz(edition): def main(): args = parse_args() - conf = json.load(open('conf.json')) - edition = conf['edition'] - ui = conf['ui'] - dev = conf['dev'] + ui = 'cocoa' if ISOSX else 'qt' if args.src_pkg: - print("Creating source package for dupeGuru {}".format(edition.upper())) - package_source_tgz(edition) + print("Creating source package for dupeGuru") + package_source_tgz() return - print("Packaging dupeGuru {0} with UI {1}".format(edition.upper(), ui)) + print("Packaging dupeGuru with UI {}".format(ui)) if ui == 'cocoa': - package_cocoa(edition, args) + package_cocoa(args) elif ui == 'qt': - if ISWINDOWS: - package_windows(edition, dev) - elif ISLINUX: - if not args.arch_pkg: - distname, _, _ = platform.dist() - else: - distname = 'arch' - if distname == 'arch': - package_arch(edition) - else: - package_debian(edition) + if not args.arch_pkg: + distname, _, _ = platform.dist() else: - print("Qt packaging only works under Windows or Linux.") + distname = 'arch' + if distname == 'arch': + package_arch() + else: + package_debian() if __name__ == '__main__': main() diff --git a/qt/base/app.py b/qt/app.py similarity index 56% rename from qt/base/app.py rename to qt/app.py index 3f41ded8..aa2a382b 100644 --- a/qt/base/app.py +++ b/qt/app.py @@ -1,6 +1,4 @@ -# Created By: Virgil Dupras -# Created On: 2009-04-25 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) # # This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # which should be included with this package. The terms are also available at @@ -21,49 +19,51 @@ from qtlib.recent import Recent from qtlib.util import createActions from qtlib.progress_window import ProgressWindow +from core.app import AppMode, DupeGuru as DupeGuruModel +import core.pe.photo from . import platform +from .preferences import Preferences from .result_window import ResultWindow from .directories_dialog import DirectoriesDialog from .problem_dialog import ProblemDialog from .ignore_list_dialog import IgnoreListDialog from .deletion_options import DeletionOptions +from .se.details_dialog import DetailsDialog as DetailsDialogStandard +from .me.details_dialog import DetailsDialog as DetailsDialogMusic +from .pe.details_dialog import DetailsDialog as DetailsDialogPicture +from .se.preferences_dialog import PreferencesDialog as PreferencesDialogStandard +from .me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic +from .pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture +from .pe.photo import File as PlatSpecificPhoto tr = trget('ui') class DupeGuru(QObject): - MODELCLASS = None - LOGO_NAME = '' - NAME = '' - - DETAILS_DIALOG_CLASS = None - RESULT_WINDOW_CLASS = ResultWindow - RESULT_MODEL_CLASS = None - PREFERENCES_CLASS = None - PREFERENCES_DIALOG_CLASS = None + LOGO_NAME = 'logo_se' + NAME = 'dupeGuru' def __init__(self, **kwargs): super().__init__(**kwargs) - self.prefs = self.PREFERENCES_CLASS() + self.prefs = Preferences() self.prefs.load() - self.model = self.MODELCLASS(view=self) + self.model = DupeGuruModel(view=self) self._setup() - self.prefsChanged.emit(self.prefs) #--- Private def _setup(self): + core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto self._setupActions() self._update_options() self.recentResults = Recent(self, 'recentResults') self.recentResults.mustOpenItem.connect(self.model.load_from) + self.resultWindow = None + self.details_dialog = None self.directories_dialog = DirectoriesDialog(self) - self.resultWindow = self.RESULT_WINDOW_CLASS(self.directories_dialog, self) - self.progress_window = ProgressWindow(self.resultWindow, self.model.progress_window) - self.details_dialog = self.DETAILS_DIALOG_CLASS(self.resultWindow, self) - self.problemDialog = ProblemDialog(parent=self.resultWindow, model=self.model.problem_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.preferences_dialog = self.PREFERENCES_DIALOG_CLASS(self.resultWindow, self) - self.about_box = AboutBox(self.resultWindow, self) + self.progress_window = ProgressWindow(self.directories_dialog, self.model.progress_window) + self.problemDialog = ProblemDialog(parent=self.directories_dialog, model=self.model.problem_dialog) + self.ignoreListDialog = IgnoreListDialog(parent=self.directories_dialog, model=self.model.ignore_list_dialog) + self.deletionOptions = DeletionOptions(parent=self.directories_dialog, model=self.model.deletion_options) + self.about_box = AboutBox(self.directories_dialog, self) self.directories_dialog.show() self.model.load() @@ -82,6 +82,7 @@ class DupeGuru(QObject): ('actionQuit', 'Ctrl+Q', '', tr("Quit"), self.quitTriggered), ('actionPreferences', 'Ctrl+P', '', tr("Options"), self.preferencesTriggered), ('actionIgnoreList', '', '', tr("Ignore List"), self.ignoreListTriggered), + ('actionClearPictureCache', 'Ctrl+Shift+P', '', tr("Clear Picture Cache"), self.clearPictureCacheTriggered), ('actionShowHelp', 'F1', '', tr("dupeGuru Help"), self.showHelpTriggered), ('actionAbout', '', '', tr("About dupeGuru"), self.showAboutBoxTriggered), ('actionOpenDebugLog', '', '', tr("Open Debug Log"), self.openDebugLogTriggered), @@ -94,6 +95,44 @@ class DupeGuru(QObject): 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['copymove_dest_type'] = self.prefs.destination_type + self.model.options['scan_type'] = self.prefs.get_scan_type(self.model.app_mode) + self.model.options['min_match_percentage'] = self.prefs.filter_hardness + self.model.options['word_weighting'] = self.prefs.word_weighting + self.model.options['match_similar_words'] = self.prefs.match_similar + threshold = self.prefs.small_file_threshold if self.prefs.ignore_small_files else 0 + self.model.options['size_threshold'] = threshold * 1024 # threshold is in KB. the scanner wants bytes + scanned_tags = set() + if self.prefs.scan_tag_track: + scanned_tags.add('track') + if self.prefs.scan_tag_artist: + scanned_tags.add('artist') + if self.prefs.scan_tag_album: + scanned_tags.add('album') + if self.prefs.scan_tag_title: + scanned_tags.add('title') + if self.prefs.scan_tag_genre: + scanned_tags.add('genre') + if self.prefs.scan_tag_year: + scanned_tags.add('year') + self.model.options['scanned_tags'] = scanned_tags + self.model.options['match_scaled'] = self.prefs.match_scaled + + #--- Private + def _get_details_dialog_class(self): + if self.model.app_mode == AppMode.Picture: + return DetailsDialogPicture + elif self.model.app_mode == AppMode.Music: + return DetailsDialogMusic + else: + return DetailsDialogStandard + + def _get_preferences_dialog_class(self): + if self.model.app_mode == AppMode.Picture: + return PreferencesDialogPicture + elif self.model.app_mode == AppMode.Music: + return PreferencesDialogMusic + else: + return PreferencesDialogStandard #--- Public def add_selected_to_ignore_list(self): @@ -112,14 +151,15 @@ class DupeGuru(QObject): self.model.invoke_custom_command() def show_details(self): - self.details_dialog.show() + if self.details_dialog is not None: + self.details_dialog.show() def showResultsWindow(self): - self.resultWindow.show() + if self.resultWindow is not None: + self.resultWindow.show() #--- Signals willSavePrefs = pyqtSignal() - prefsChanged = pyqtSignal(object) #--- Events def finishedLaunching(self): @@ -135,6 +175,14 @@ class DupeGuru(QObject): self.prefs.save() self.model.save() + def clearPictureCacheTriggered(self): + title = tr("Clear Picture Cache") + msg = tr("Do you really want to remove all your cached picture analysis?") + if self.confirm(title, msg, QMessageBox.No): + self.model.clear_picture_cache() + active = QApplication.activeWindow() + QMessageBox.information(active, title, tr("Picture cache cleared.")) + def ignoreListTriggered(self): self.model.ignore_list_dialog.show() @@ -143,13 +191,14 @@ class DupeGuru(QObject): desktop.open_path(debugLogPath) def preferencesTriggered(self): - self.preferences_dialog.load() - result = self.preferences_dialog.exec() + preferences_dialog = self._get_preferences_dialog_class()(self.directories_dialog, self) + preferences_dialog.load() + result = preferences_dialog.exec() if result == QDialog.Accepted: - self.preferences_dialog.save() + preferences_dialog.save() self.prefs.save() self._update_options() - self.prefsChanged.emit(self.prefs) + preferences_dialog.setParent(None) def quitTriggered(self): self.directories_dialog.close() @@ -176,6 +225,18 @@ class DupeGuru(QObject): def ask_yes_no(self, prompt): return self.confirm('', prompt) + def create_results_window(self): + """Creates resultWindow and details_dialog depending on the selected ``app_mode``. + """ + if self.details_dialog is not None: + self.details_dialog.close() + self.details_dialog.setParent(None) + if self.resultWindow is not None: + self.resultWindow.close() + self.resultWindow.setParent(None) + self.resultWindow = ResultWindow(self.directories_dialog, self) + self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self) + def show_results_window(self): self.showResultsWindow() diff --git a/qt/base/__init__.py b/qt/base/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/qt/base/cxfreeze_fix.py b/qt/base/cxfreeze_fix.py deleted file mode 100644 index d659d077..00000000 --- a/qt/base/cxfreeze_fix.py +++ /dev/null @@ -1,8 +0,0 @@ -# cxfreeze has some problems detecting all dependencies. -# This modules explicitly import those problematic modules. -# flake8: noqa - -import xml.etree.ElementPath -import gzip - -import os diff --git a/qt/base/dg.qrc b/qt/base/dg.qrc deleted file mode 100644 index f506b64a..00000000 --- a/qt/base/dg.qrc +++ /dev/null @@ -1,13 +0,0 @@ - - - ../../images/dgpe_logo_32.png - ../../images/dgpe_logo_128.png - ../../images/dgme_logo_32.png - ../../images/dgme_logo_128.png - ../../images/dgse_logo_32.png - ../../images/dgse_logo_128.png - ../../images/plus_8.png - ../../images/minus_8.png - ../../qtlib/images/search_clear_13.png - - \ No newline at end of file diff --git a/qt/base/deletion_options.py b/qt/deletion_options.py similarity index 100% rename from qt/base/deletion_options.py rename to qt/deletion_options.py diff --git a/qt/base/details_dialog.py b/qt/details_dialog.py similarity index 100% rename from qt/base/details_dialog.py rename to qt/details_dialog.py diff --git a/qt/base/details_table.py b/qt/details_table.py similarity index 100% rename from qt/base/details_table.py rename to qt/details_table.py diff --git a/qt/dg.qrc b/qt/dg.qrc new file mode 100644 index 00000000..545a9806 --- /dev/null +++ b/qt/dg.qrc @@ -0,0 +1,9 @@ + + + ../images/dgse_logo_32.png + ../images/dgse_logo_128.png + ../images/plus_8.png + ../images/minus_8.png + ../qtlib/images/search_clear_13.png + + diff --git a/qt/base/directories_dialog.py b/qt/directories_dialog.py similarity index 84% rename from qt/base/directories_dialog.py rename to qt/directories_dialog.py index 3a721180..db61d4fa 100644 --- a/qt/base/directories_dialog.py +++ b/qt/directories_dialog.py @@ -13,6 +13,8 @@ from PyQt5.QtWidgets import ( from PyQt5.QtGui import QPixmap, QIcon from hscommon.trans import trget +from core.app import AppMode +from qtlib.radio_box import RadioBox from qtlib.recent import Recent from qtlib.util import moveToScreenCenter, createActions @@ -28,9 +30,7 @@ class DirectoriesDialog(QMainWindow): self.lastAddedFolder = platform.INITIAL_FOLDER_IN_DIALOGS self.recentFolders = Recent(self.app, 'recentFolders') self._setupUi() - SCAN_TYPE_ORDER = [so.scan_type for so in self.app.model.SCANNER_CLASS.get_scan_options()] - scan_type_index = SCAN_TYPE_ORDER.index(self.app.prefs.scan_type) - self.scanTypeComboBox.setCurrentIndex(scan_type_index) + self._updateScanTypeList() self.directoriesModel = DirectoriesModel(self.app.model.directory_tree, view=self.treeView) self.directoriesDelegate = DirectoriesDelegate() self.treeView.setItemDelegate(self.directoriesDelegate) @@ -41,11 +41,12 @@ class DirectoriesDialog(QMainWindow): self._updateAddButton() self._updateRemoveButton() self._updateLoadResultsButton() + self._updateActionsState() self._setupBindings() def _setupBindings(self): + self.appModeRadioBox.itemSelected.connect(self.appModeButtonSelected) self.showPreferencesButton.clicked.connect(self.app.actionPreferences.trigger) - self.scanTypeComboBox.currentIndexChanged[int].connect(self.scanTypeChanged) self.scanButton.clicked.connect(self.scanButtonClicked) self.loadResultsButton.clicked.connect(self.actionLoadResults.trigger) self.addFolderButton.clicked.connect(self.actionAddFolder.trigger) @@ -82,6 +83,8 @@ class DirectoriesDialog(QMainWindow): self.menuFile.addAction(self.actionLoadResults) self.menuFile.addAction(self.menuLoadRecent.menuAction()) self.menuFile.addSeparator() + self.menuFile.addAction(self.app.actionClearPictureCache) + self.menuFile.addSeparator() self.menuFile.addAction(self.app.actionQuit) self.menuView.addAction(self.app.actionPreferences) self.menuView.addAction(self.actionShowResultsWindow) @@ -123,6 +126,17 @@ class DirectoriesDialog(QMainWindow): self.treeView.setUniformRowHeights(True) self.verticalLayout.addWidget(self.treeView) hl = QHBoxLayout() + label = QLabel(tr("Application Mode:"), self) + label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + hl.addWidget(label) + self.appModeRadioBox = RadioBox( + self, + items=[tr("Standard"), tr("Music"), tr("Picture")], + spread=False + ) + hl.addWidget(self.appModeRadioBox) + self.verticalLayout.addLayout(hl) + hl = QHBoxLayout() hl.setAlignment(Qt.AlignLeft) label = QLabel(tr("Scan Type:"), self) label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) @@ -130,10 +144,8 @@ class DirectoriesDialog(QMainWindow): self.scanTypeComboBox = QComboBox(self) self.scanTypeComboBox.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)) self.scanTypeComboBox.setMaximumWidth(400) - for scan_option in self.app.model.SCANNER_CLASS.get_scan_options(): - self.scanTypeComboBox.addItem(scan_option.label) hl.addWidget(self.scanTypeComboBox) - self.showPreferencesButton = QPushButton(tr("Options"), self.centralwidget) + self.showPreferencesButton = QPushButton(tr("More Options"), self.centralwidget) self.showPreferencesButton.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) hl.addWidget(self.showPreferencesButton) self.verticalLayout.addLayout(hl) @@ -172,6 +184,9 @@ class DirectoriesDialog(QMainWindow): header.setSectionResizeMode(1, QHeaderView.Fixed) header.resizeSection(1, 100) + def _updateActionsState(self): + self.actionShowResultsWindow.setEnabled(self.app.resultWindow is not None) + def _updateAddButton(self): if self.recentFolders.isEmpty(): self.addFolderButton.setMenu(None) @@ -191,6 +206,23 @@ class DirectoriesDialog(QMainWindow): else: self.loadResultsButton.setMenu(self.menuRecentResults) + def _updateScanTypeList(self): + try: + self.scanTypeComboBox.currentIndexChanged[int].disconnect(self.scanTypeChanged) + except TypeError: + # Not connected, ignore + pass + self.scanTypeComboBox.clear() + scan_options = self.app.model.SCANNER_CLASS.get_scan_options() + for scan_option in scan_options: + self.scanTypeComboBox.addItem(scan_option.label) + SCAN_TYPE_ORDER = [so.scan_type for so in scan_options] + selected_scan_type = self.app.prefs.get_scan_type(self.app.model.app_mode) + scan_type_index = SCAN_TYPE_ORDER.index(selected_scan_type) + self.scanTypeComboBox.setCurrentIndex(scan_type_index) + self.scanTypeComboBox.currentIndexChanged[int].connect(self.scanTypeChanged) + self.app._update_options() + #--- QWidget overrides def closeEvent(self, event): event.accept() @@ -213,6 +245,16 @@ class DirectoriesDialog(QMainWindow): self.app.model.add_directory(dirpath) self.recentFolders.insertItem(dirpath) + def appModeButtonSelected(self, index): + if index == 2: + mode = AppMode.Picture + elif index == 1: + mode = AppMode.Music + else: + mode = AppMode.Standard + self.app.model.app_mode = mode + self._updateScanTypeList() + def appWillSavePrefs(self): self.app.prefs.directoriesWindowRect = self.geometry() @@ -241,7 +283,7 @@ class DirectoriesDialog(QMainWindow): def scanTypeChanged(self, index): scan_options = self.app.model.SCANNER_CLASS.get_scan_options() - self.app.prefs.scan_type = scan_options[index].scan_type + self.app.prefs.set_scan_type(self.app.model.app_mode, scan_options[index].scan_type) self.app._update_options() def selectionChanged(self, selected, deselected): diff --git a/qt/base/directories_model.py b/qt/directories_model.py similarity index 100% rename from qt/base/directories_model.py rename to qt/directories_model.py diff --git a/qt/base/ignore_list_dialog.py b/qt/ignore_list_dialog.py similarity index 100% rename from qt/base/ignore_list_dialog.py rename to qt/ignore_list_dialog.py diff --git a/qt/base/ignore_list_table.py b/qt/ignore_list_table.py similarity index 100% rename from qt/base/ignore_list_table.py rename to qt/ignore_list_table.py diff --git a/qt/me/app.py b/qt/me/app.py deleted file mode 100644 index 3158274f..00000000 --- a/qt/me/app.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from core_me import __appname__ -from core_me.app import DupeGuru as DupeGuruModel - -from ..base.app import DupeGuru as DupeGuruBase -from .details_dialog import DetailsDialog -from .results_model import ResultsModel -from .preferences import Preferences -from .preferences_dialog import PreferencesDialog - -class DupeGuru(DupeGuruBase): - MODELCLASS = DupeGuruModel - EDITION = 'me' - LOGO_NAME = 'logo_me' - NAME = __appname__ - - DETAILS_DIALOG_CLASS = DetailsDialog - RESULT_MODEL_CLASS = ResultsModel - PREFERENCES_CLASS = Preferences - PREFERENCES_DIALOG_CLASS = PreferencesDialog - - def _update_options(self): - DupeGuruBase._update_options(self) - self.model.options['min_match_percentage'] = self.prefs.filter_hardness - self.model.options['scan_type'] = self.prefs.scan_type - self.model.options['word_weighting'] = self.prefs.word_weighting - self.model.options['match_similar_words'] = self.prefs.match_similar - scanned_tags = set() - if self.prefs.scan_tag_track: - scanned_tags.add('track') - if self.prefs.scan_tag_artist: - scanned_tags.add('artist') - if self.prefs.scan_tag_album: - scanned_tags.add('album') - if self.prefs.scan_tag_title: - scanned_tags.add('title') - if self.prefs.scan_tag_genre: - scanned_tags.add('genre') - if self.prefs.scan_tag_year: - scanned_tags.add('year') - self.model.options['scanned_tags'] = scanned_tags - diff --git a/qt/me/details_dialog.py b/qt/me/details_dialog.py index 9f1ca2bf..61ccca1e 100644 --- a/qt/me/details_dialog.py +++ b/qt/me/details_dialog.py @@ -1,17 +1,15 @@ -# Created By: Virgil Dupras -# Created On: 2009-04-27 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import QSize from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView from hscommon.trans import trget -from ..base.details_dialog import DetailsDialog as DetailsDialogBase -from ..base.details_table import DetailsTable +from ..details_dialog import DetailsDialog as DetailsDialogBase +from ..details_table import DetailsTable tr = trget('ui') @@ -28,4 +26,4 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) - + diff --git a/qt/me/installer.aip b/qt/me/installer.aip deleted file mode 100644 index f3fb76a7..00000000 --- a/qt/me/installer.aip +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/qt/me/preferences.py b/qt/me/preferences.py deleted file mode 100644 index 47e08903..00000000 --- a/qt/me/preferences.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from core.scanner import ScanType - -from ..base.preferences import Preferences as PreferencesBase - -class Preferences(PreferencesBase): - DEFAULT_SCAN_TYPE = ScanType.Tag - - def _load_specific(self, settings): - get = self.get_value - self.word_weighting = get('WordWeighting', self.word_weighting) - self.match_similar = get('MatchSimilar', self.match_similar) - self.scan_tag_track = get('ScanTagTrack', self.scan_tag_track) - self.scan_tag_artist = get('ScanTagArtist', self.scan_tag_artist) - self.scan_tag_album = get('ScanTagAlbum', self.scan_tag_album) - self.scan_tag_title = get('ScanTagTitle', self.scan_tag_title) - self.scan_tag_genre = get('ScanTagGenre', self.scan_tag_genre) - self.scan_tag_year = get('ScanTagYear', self.scan_tag_year) - - def _reset_specific(self): - self.filter_hardness = 80 - self.word_weighting = True - self.match_similar = False - self.scan_tag_track = False - self.scan_tag_artist = True - self.scan_tag_album = True - self.scan_tag_title = True - self.scan_tag_genre = False - self.scan_tag_year = False - - def _save_specific(self, settings): - set_ = self.set_value - set_('WordWeighting', self.word_weighting) - set_('MatchSimilar', self.match_similar) - set_('ScanTagTrack', self.scan_tag_track) - set_('ScanTagArtist', self.scan_tag_artist) - set_('ScanTagAlbum', self.scan_tag_album) - set_('ScanTagTitle', self.scan_tag_title) - set_('ScanTagGenre', self.scan_tag_genre) - set_('ScanTagYear', self.scan_tag_year) - diff --git a/qt/me/preferences_dialog.py b/qt/me/preferences_dialog.py index 777a2e7d..9eaa1712 100644 --- a/qt/me/preferences_dialog.py +++ b/qt/me/preferences_dialog.py @@ -10,10 +10,10 @@ from PyQt5.QtWidgets import ( ) from hscommon.trans import trget +from core.app import AppMode from core.scanner import ScanType -from ..base.preferences_dialog import PreferencesDialogBase -from . import preferences +from ..preferences_dialog import PreferencesDialogBase tr = trget('ui') @@ -74,7 +74,7 @@ class PreferencesDialog(PreferencesDialogBase): setchecked(self.wordWeightingBox, prefs.word_weighting) # Update UI state based on selected scan type - scan_type = prefs.scan_type + scan_type = prefs.get_scan_type(AppMode.Music) word_based = scan_type in ( ScanType.Filename, ScanType.Fields, ScanType.FieldsNoOrder, ScanType.Tag @@ -100,6 +100,3 @@ class PreferencesDialog(PreferencesDialogBase): prefs.match_similar = ischecked(self.matchSimilarBox) prefs.word_weighting = ischecked(self.wordWeightingBox) - def resetToDefaults(self): - self.load(preferences.Preferences()) - diff --git a/qt/me/results_model.py b/qt/me/results_model.py index 552e0afe..1fe58741 100644 --- a/qt/me/results_model.py +++ b/qt/me/results_model.py @@ -1,12 +1,11 @@ -# Created On: 2011-11-27 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) # # This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from qtlib.column import Column -from ..base.results_model import ResultsModel as ResultsModelBase +from ..results_model import ResultsModel as ResultsModelBase class ResultsModel(ResultsModelBase): COLUMNS = [ diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 8d032a60..7820e6ac 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -1,9 +1,7 @@ -# Created By: Virgil Dupras -# Created On: 2009-04-27 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QSize @@ -11,8 +9,8 @@ from PyQt5.QtGui import QPixmap from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, QHBoxLayout, QLabel, QSizePolicy from hscommon.trans import trget -from ..base.details_dialog import DetailsDialog as DetailsDialogBase -from ..base.details_table import DetailsTable +from ..details_dialog import DetailsDialog as DetailsDialogBase +from ..details_table import DetailsTable tr = trget('ui') @@ -21,7 +19,7 @@ class DetailsDialog(DetailsDialogBase): DetailsDialogBase.__init__(self, parent, app) self.selectedPixmap = None self.referencePixmap = None - + def _setupUi(self): self.setWindowTitle(tr("Details")) self.resize(502, 295) @@ -61,21 +59,21 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) - + def _update(self): if not self.app.model.selected_dupes: return dupe = self.app.model.selected_dupes[0] group = self.app.model.results.get_group_of_duplicate(dupe) ref = group.ref - + self.selectedPixmap = QPixmap(str(dupe.path)) if ref is dupe: self.referencePixmap = None else: self.referencePixmap = QPixmap(str(ref.path)) self._updateImages() - + def _updateImages(self): if self.selectedPixmap is not None: target_size = self.selectedImage.size() @@ -89,18 +87,18 @@ class DetailsDialog(DetailsDialogBase): self.referenceImage.setPixmap(scaledPixmap) else: self.referenceImage.setPixmap(QPixmap()) - + #--- Override def resizeEvent(self, event): self._updateImages() - + def show(self): DetailsDialogBase.show(self) self._update() - + # model --> view def refresh(self): DetailsDialogBase.refresh(self) if self.isVisible(): self._update() - + diff --git a/qt/pe/installer.aip b/qt/pe/installer.aip deleted file mode 100644 index 16e5b7fd..00000000 --- a/qt/pe/installer.aip +++ /dev/null @@ -1,159 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/qt/pe/app.py b/qt/pe/photo.py similarity index 62% rename from qt/pe/app.py rename to qt/pe/photo.py index cb733c1d..da1b2d93 100644 --- a/qt/pe/app.py +++ b/qt/pe/photo.py @@ -8,17 +8,9 @@ import logging from PyQt5.QtGui import QImage, QImageReader, QTransform -from core_pe import __appname__ -from core_pe.photo import Photo as PhotoBase -from core_pe.app import DupeGuru as DupeGuruModel +from core.pe.photo import Photo as PhotoBase -from ..base.app import DupeGuru as DupeGuruBase from .block import getblocks -from .details_dialog import DetailsDialog -from .result_window import ResultWindow -from .results_model import ResultsModel -from .preferences import Preferences -from .preferences_dialog import PreferencesDialog class File(PhotoBase): def _plat_get_dimensions(self): @@ -62,29 +54,3 @@ class File(PhotoBase): image = image.transformed(t) return getblocks(image, block_count_per_side) - -class DupeGuru(DupeGuruBase): - MODELCLASS = DupeGuruModel - EDITION = 'pe' - LOGO_NAME = 'logo_pe' - NAME = __appname__ - - DETAILS_DIALOG_CLASS = DetailsDialog - RESULT_WINDOW_CLASS = ResultWindow - RESULT_MODEL_CLASS = ResultsModel - PREFERENCES_CLASS = Preferences - PREFERENCES_DIALOG_CLASS = PreferencesDialog - - def _setup(self): - self.model.fileclasses = [File] - DupeGuruBase._setup(self) - self.directories_dialog.menuFile.insertAction( - self.directories_dialog.actionLoadResults, self.resultWindow.actionClearPictureCache - ) - - def _update_options(self): - DupeGuruBase._update_options(self) - self.model.options['scan_type'] = self.prefs.scan_type - self.model.options['match_scaled'] = self.prefs.match_scaled - self.model.options['threshold'] = self.prefs.filter_hardness - diff --git a/qt/pe/preferences.py b/qt/pe/preferences.py deleted file mode 100644 index 27bc4915..00000000 --- a/qt/pe/preferences.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from core.scanner import ScanType - -from ..base.preferences import Preferences as PreferencesBase - -class Preferences(PreferencesBase): - DEFAULT_SCAN_TYPE = ScanType.FuzzyBlock - - def _load_specific(self, settings): - get = self.get_value - self.match_scaled = get('MatchScaled', self.match_scaled) - - def _reset_specific(self): - self.filter_hardness = 95 - self.match_scaled = False - - def _save_specific(self, settings): - set_ = self.set_value - set_('MatchScaled', self.match_scaled) - diff --git a/qt/pe/preferences_dialog.py b/qt/pe/preferences_dialog.py index cd513412..047c4948 100644 --- a/qt/pe/preferences_dialog.py +++ b/qt/pe/preferences_dialog.py @@ -6,9 +6,9 @@ from hscommon.trans import trget from core.scanner import ScanType +from core.app import AppMode -from ..base.preferences_dialog import PreferencesDialogBase -from . import preferences +from ..preferences_dialog import PreferencesDialogBase tr = trget('ui') @@ -34,13 +34,10 @@ class PreferencesDialog(PreferencesDialogBase): setchecked(self.matchScaledBox, prefs.match_scaled) # Update UI state based on selected scan type - scan_type = prefs.scan_type + scan_type = prefs.get_scan_type(AppMode.Picture) fuzzy_scan = scan_type == ScanType.FuzzyBlock self.filterHardnessSlider.setEnabled(fuzzy_scan) def _save(self, prefs, ischecked): prefs.match_scaled = ischecked(self.matchScaledBox) - def resetToDefaults(self): - self.load(preferences.Preferences()) - diff --git a/qt/pe/result_window.py b/qt/pe/result_window.py deleted file mode 100644 index f5aca049..00000000 --- a/qt/pe/result_window.py +++ /dev/null @@ -1,30 +0,0 @@ -# Created By: Virgil Dupras -# Created On: 2009-05-23 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from PyQt5.QtWidgets import QMessageBox, QAction - -from hscommon.trans import trget -from ..base.result_window import ResultWindow as ResultWindowBase - -tr = trget('ui') - -class ResultWindow(ResultWindowBase): - def _setupMenu(self): - ResultWindowBase._setupMenu(self) - self.actionClearPictureCache = QAction(tr("Clear Picture Cache"), self) - self.actionClearPictureCache.setShortcut('Ctrl+Shift+P') - self.menuFile.insertAction(self.actionSaveResults, self.actionClearPictureCache) - self.actionClearPictureCache.triggered.connect(self.clearPictureCacheTriggered) - - def clearPictureCacheTriggered(self): - title = tr("Clear Picture Cache") - msg = tr("Do you really want to remove all your cached picture analysis?") - if self.app.confirm(title, msg, QMessageBox.No): - self.app.model.scanner.clear_picture_cache() - QMessageBox.information(self, title, tr("Picture cache cleared.")) - \ No newline at end of file diff --git a/qt/pe/results_model.py b/qt/pe/results_model.py index c9f9d415..caec1a93 100644 --- a/qt/pe/results_model.py +++ b/qt/pe/results_model.py @@ -1,12 +1,11 @@ -# Created On: 2011-11-27 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) # # This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from qtlib.column import Column -from ..base.results_model import ResultsModel as ResultsModelBase +from ..results_model import ResultsModel as ResultsModelBase class ResultsModel(ResultsModelBase): COLUMNS = [ diff --git a/qt/base/platform.py b/qt/platform.py similarity index 100% rename from qt/base/platform.py rename to qt/platform.py diff --git a/qt/base/preferences.py b/qt/preferences.py similarity index 57% rename from qt/base/preferences.py rename to qt/preferences.py index 96838f12..78357597 100644 --- a/qt/base/preferences.py +++ b/qt/preferences.py @@ -7,15 +7,11 @@ from PyQt5.QtWidgets import QApplication from hscommon import trans +from core.app import AppMode +from core.scanner import ScanType from qtlib.preferences import Preferences as PreferencesBase class Preferences(PreferencesBase): - DEFAULT_SCAN_TYPE = None # edition-specific - - def _load_specific(self, settings): - # load prefs specific to the dg edition - pass - def _load_values(self, settings): get = self.get_value self.filter_hardness = get('FilterHardness', self.filter_hardness) @@ -37,11 +33,17 @@ class Preferences(PreferencesBase): self.recentResults = get('RecentResults', self.recentResults) self.recentFolders = get('RecentFolders', self.recentFolders) - self._load_specific(settings) - - def _reset_specific(self): - # reset prefs specific to the dg edition - pass + self.word_weighting = get('WordWeighting', self.word_weighting) + self.match_similar = get('MatchSimilar', self.match_similar) + self.ignore_small_files = get('IgnoreSmallFiles', self.ignore_small_files) + self.small_file_threshold = get('SmallFileThreshold', self.small_file_threshold) + self.scan_tag_track = get('ScanTagTrack', self.scan_tag_track) + self.scan_tag_artist = get('ScanTagArtist', self.scan_tag_artist) + self.scan_tag_album = get('ScanTagAlbum', self.scan_tag_album) + self.scan_tag_title = get('ScanTagTitle', self.scan_tag_title) + self.scan_tag_genre = get('ScanTagGenre', self.scan_tag_genre) + self.scan_tag_year = get('ScanTagYear', self.scan_tag_year) + self.match_scaled = get('MatchScaled', self.match_scaled) def reset(self): self.filter_hardness = 95 @@ -61,11 +63,17 @@ class Preferences(PreferencesBase): self.recentResults = [] self.recentFolders = [] - self._reset_specific() - - def _save_specific(self, settings): - # save prefs specific to the dg edition - pass + self.word_weighting = True + self.match_similar = False + self.ignore_small_files = True + self.small_file_threshold = 10 # KB + self.scan_tag_track = False + self.scan_tag_artist = True + self.scan_tag_album = True + self.scan_tag_title = True + self.scan_tag_genre = False + self.scan_tag_year = False + self.match_scaled = False def _save_values(self, settings): set_ = self.set_value @@ -86,13 +94,31 @@ class Preferences(PreferencesBase): set_('RecentResults', self.recentResults) set_('RecentFolders', self.recentFolders) - self._save_specific(settings) + set_('WordWeighting', self.word_weighting) + set_('MatchSimilar', self.match_similar) + set_('IgnoreSmallFiles', self.ignore_small_files) + set_('SmallFileThreshold', self.small_file_threshold) + set_('ScanTagTrack', self.scan_tag_track) + set_('ScanTagArtist', self.scan_tag_artist) + set_('ScanTagAlbum', self.scan_tag_album) + set_('ScanTagTitle', self.scan_tag_title) + set_('ScanTagGenre', self.scan_tag_genre) + set_('ScanTagYear', self.scan_tag_year) + set_('MatchScaled', self.match_scaled) # scan_type is special because we save it immediately when we set it. - @property - def scan_type(self): - return self.get_value('ScanType', self.DEFAULT_SCAN_TYPE) + def get_scan_type(self, app_mode): + if app_mode == AppMode.Picture: + return self.get_value('ScanTypePicture', ScanType.FuzzyBlock) + elif app_mode == AppMode.Music: + return self.get_value('ScanTypeMusic', ScanType.Tag) + else: + return self.get_value('ScanTypeStandard', ScanType.Contents) - @scan_type.setter - def scan_type(self, value): - self.set_value('ScanType', value) + def set_scan_type(self, app_mode, value): + if app_mode == AppMode.Picture: + self.set_value('ScanTypePicture', value) + elif app_mode == AppMode.Music: + self.set_value('ScanTypeMusic', value) + else: + self.set_value('ScanTypeStandard', value) diff --git a/qt/base/preferences_dialog.py b/qt/preferences_dialog.py similarity index 97% rename from qt/base/preferences_dialog.py rename to qt/preferences_dialog.py index d3b873ff..506ba977 100644 --- a/qt/base/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -10,11 +10,12 @@ from PyQt5.QtWidgets import ( QSlider, QSizePolicy, QSpacerItem, QCheckBox, QLineEdit, QMessageBox, QSpinBox ) -from hscommon.plat import ISOSX, ISLINUX from hscommon.trans import trget from qtlib.util import horizontalWrap from qtlib.preferences import get_langnames +from .preferences import Preferences + tr = trget('ui') SUPPORTED_LANGUAGES = [ @@ -123,9 +124,6 @@ class PreferencesDialogBase(QDialog): self.buttonBox = QDialogButtonBox(self) self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok|QDialogButtonBox.RestoreDefaults) self.mainVLayout.addWidget(self.buttonBox) - if (not ISOSX) and (not ISLINUX): - self.mainVLayout.removeWidget(self.ignoreHardlinkMatches) - self.ignoreHardlinkMatches.setHidden(True) def _load(self, prefs, setchecked): # Edition-specific @@ -177,6 +175,9 @@ class PreferencesDialogBase(QDialog): self.app.prefs.language = lang self._save(prefs, ischecked) + def resetToDefaults(self): + self.load(Preferences()) + #--- Events def buttonClicked(self, button): role = self.buttonBox.buttonRole(button) diff --git a/qt/base/prioritize_dialog.py b/qt/prioritize_dialog.py similarity index 100% rename from qt/base/prioritize_dialog.py rename to qt/prioritize_dialog.py diff --git a/qt/base/problem_dialog.py b/qt/problem_dialog.py similarity index 91% rename from qt/base/problem_dialog.py rename to qt/problem_dialog.py index a1fe38c7..51eb794b 100644 --- a/qt/base/problem_dialog.py +++ b/qt/problem_dialog.py @@ -9,7 +9,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QSpacerItem, QSizePolicy, - QLabel, QTableView, QAbstractItemView, QApplication + QLabel, QTableView, QAbstractItemView ) from hscommon.trans import trget @@ -63,12 +63,3 @@ class ProblemDialog(QDialog): self.horizontalLayout.addWidget(self.closeButton) self.verticalLayout.addLayout(self.horizontalLayout) - -if __name__ == '__main__': - import sys - from ..testapp import TestApp - app = QApplication([]) - dgapp = TestApp() - dialog = ProblemDialog(None, dgapp) - dialog.show() - sys.exit(app.exec_()) diff --git a/qt/base/problem_table.py b/qt/problem_table.py similarity index 100% rename from qt/base/problem_table.py rename to qt/problem_table.py diff --git a/qt/base/result_window.py b/qt/result_window.py similarity index 96% rename from qt/base/result_window.py rename to qt/result_window.py index a3310f92..529aee14 100644 --- a/qt/base/result_window.py +++ b/qt/result_window.py @@ -16,9 +16,13 @@ from hscommon.trans import trget from qtlib.util import moveToScreenCenter, horizontalWrap, createActions from qtlib.search_edit import SearchEdit +from core.app import AppMode from .results_model import ResultsView from .stats_label import StatsLabel from .prioritize_dialog import PrioritizeDialog +from .se.results_model import ResultsModel as ResultsModelStandard +from .me.results_model import ResultsModel as ResultsModelMusic +from .pe.results_model import ResultsModel as ResultsModelPicture tr = trget('ui') @@ -27,7 +31,13 @@ class ResultWindow(QMainWindow): super().__init__(parent, **kwargs) self.app = app self._setupUi() - self.resultsModel = app.RESULT_MODEL_CLASS(self.app, self.resultsView) + if app.model.app_mode == AppMode.Picture: + MODEL_CLASS = ResultsModelPicture + elif app.model.app_mode == AppMode.Music: + MODEL_CLASS = ResultsModelMusic + else: + MODEL_CLASS = ResultsModelStandard + self.resultsModel = MODEL_CLASS(self.app, self.resultsView) self.stats = StatsLabel(app.model.stats_label, self.statusLabel) self._update_column_actions_status() diff --git a/qt/base/results_model.py b/qt/results_model.py similarity index 89% rename from qt/base/results_model.py rename to qt/results_model.py index 0b08a415..85841780 100644 --- a/qt/base/results_model.py +++ b/qt/results_model.py @@ -1,9 +1,9 @@ # Created By: Virgil Dupras # Created On: 2009-04-23 # Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex @@ -17,13 +17,17 @@ class ResultsModel(Table): model = app.model.result_table super().__init__(model, view, **kwargs) view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder) - - app.prefsChanged.connect(self.appPrefsChanged) + font = view.font() + font.setPointSize(app.prefs.tableFontSize) + self.view.setFont(font) + fm = QFontMetrics(font) + view.verticalHeader().setDefaultSectionSize(fm.height()+2) + app.willSavePrefs.connect(self.appWillSavePrefs) - + def _getData(self, row, column, role): if column.name == 'marked': - if role == Qt.CheckStateRole and row.markable: + if role == Qt.CheckStateRole and row.markable: return Qt.Checked if row.marked else Qt.Unchecked return None if role == Qt.DisplayRole: @@ -43,7 +47,7 @@ class ResultsModel(Table): if column.name == 'name': return row.data[column.name] return None - + def _getFlags(self, row, column): flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable if column.name == 'marked': @@ -52,7 +56,7 @@ class ResultsModel(Table): elif column.name == 'name': flags |= Qt.ItemIsEditable return flags - + def _setData(self, row, column, value, role): if role == Qt.CheckStateRole: if column.name == 'marked': @@ -62,46 +66,39 @@ class ResultsModel(Table): if column.name == 'name': return self.model.rename_selected(value) return False - + def sort(self, column, order): column = self.model.COLUMNS[column] self.model.sort(column.name, order == Qt.AscendingOrder) - + #--- Properties @property def power_marker(self): return self.model.power_marker - + @power_marker.setter def power_marker(self, value): self.model.power_marker = value - + @property def delta_values(self): return self.model.delta_values - + @delta_values.setter def delta_values(self, value): self.model.delta_values = value - + #--- Events - def appPrefsChanged(self, prefs): - font = self.view.font() - font.setPointSize(prefs.tableFontSize) - self.view.setFont(font) - fm = QFontMetrics(font) - self.view.verticalHeader().setDefaultSectionSize(fm.height()+2) - def appWillSavePrefs(self): self.model.columns.save_columns() - + #--- model --> view def invalidate_markings(self): # redraw view # HACK. this is the only way I found to update the widget without reseting everything self.view.scroll(0, 1) self.view.scroll(0, -1) - + class ResultsView(QTableView): #--- Override @@ -110,10 +107,10 @@ class ResultsView(QTableView): self.spacePressed.emit() return super().keyPressEvent(event) - + def mouseDoubleClickEvent(self, event): self.doubleClicked.emit(QModelIndex()) # We don't call the superclass' method because the default behavior is to rename the cell. - + #--- Signals spacePressed = pyqtSignal() diff --git a/qt/run_template.py b/qt/run_template.py index 109a2587..7e42a474 100644 --- a/qt/run_template.py +++ b/qt/run_template.py @@ -1,8 +1,8 @@ #!/usr/bin/python3 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html import sys @@ -13,16 +13,12 @@ from PyQt5.QtCore import QCoreApplication, QSettings from PyQt5.QtGui import QIcon, QPixmap from PyQt5.QtWidgets import QApplication -from hscommon.plat import ISWINDOWS from hscommon.trans import install_gettext_trans_under_qt from qtlib.error_report_dialog import install_excepthook from qtlib.util import setupQtLogging -from qt.base import dg_rc -from qt.base.platform import BASE_PATH -from core_{edition} import __version__, __appname__ - -if ISWINDOWS: - import qt.base.cxfreeze_fix +from qt import dg_rc +from qt.platform import BASE_PATH +from core import __version__, __appname__ def main(): app = QApplication(sys.argv) @@ -36,7 +32,7 @@ def main(): install_gettext_trans_under_qt(locale_folder, lang) # Many strings are translated at import time, so this is why we only import after the translator # has been installed - from qt.{edition}.app import DupeGuru + from qt.app import DupeGuru app.setWindowIcon(QIcon(QPixmap(":/{0}".format(DupeGuru.LOGO_NAME)))) dgapp = DupeGuru() install_excepthook('https://github.com/hsoft/dupeguru/issues') diff --git a/qt/se/app.py b/qt/se/app.py deleted file mode 100644 index 43005a70..00000000 --- a/qt/se/app.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from core_se import __appname__ -from core_se.app import DupeGuru as DupeGuruModel -from core.directories import Directories as DirectoriesBase, DirectoryState - -from ..base.app import DupeGuru as DupeGuruBase -from .details_dialog import DetailsDialog -from .results_model import ResultsModel -from .preferences import Preferences -from .preferences_dialog import PreferencesDialog - -class Directories(DirectoriesBase): - ROOT_PATH_TO_EXCLUDE = frozenset(['windows', 'program files']) - - def _default_state_for_path(self, path): - result = DirectoriesBase._default_state_for_path(self, path) - if result is not None: - return result - if len(path) == 2 and path[1].lower() in self.ROOT_PATH_TO_EXCLUDE: - return DirectoryState.Excluded - -class DupeGuru(DupeGuruBase): - MODELCLASS = DupeGuruModel - EDITION = 'se' - LOGO_NAME = 'logo_se' - NAME = __appname__ - - DETAILS_DIALOG_CLASS = DetailsDialog - RESULT_MODEL_CLASS = ResultsModel - PREFERENCES_CLASS = Preferences - PREFERENCES_DIALOG_CLASS = PreferencesDialog - - def _setup(self): - self.directories = Directories() - DupeGuruBase._setup(self) - - def _update_options(self): - DupeGuruBase._update_options(self) - self.model.options['min_match_percentage'] = self.prefs.filter_hardness - self.model.options['scan_type'] = self.prefs.scan_type - self.model.options['word_weighting'] = self.prefs.word_weighting - self.model.options['match_similar_words'] = self.prefs.match_similar - threshold = self.prefs.small_file_threshold if self.prefs.ignore_small_files else 0 - self.model.options['size_threshold'] = threshold * 1024 # threshold is in KB. the scanner wants bytes - diff --git a/qt/se/details_dialog.py b/qt/se/details_dialog.py index 7810e795..d715a3a4 100644 --- a/qt/se/details_dialog.py +++ b/qt/se/details_dialog.py @@ -1,17 +1,15 @@ -# Created By: Virgil Dupras -# Created On: 2009-05-24 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import QSize from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView from hscommon.trans import trget -from ..base.details_dialog import DetailsDialog as DetailsDialogBase -from ..base.details_table import DetailsTable +from ..details_dialog import DetailsDialog as DetailsDialogBase +from ..details_table import DetailsTable tr = trget('ui') @@ -28,4 +26,4 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) - + diff --git a/qt/se/installer.aip b/qt/se/installer.aip deleted file mode 100644 index 0e348ba8..00000000 --- a/qt/se/installer.aip +++ /dev/null @@ -1,159 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/qt/se/preferences.py b/qt/se/preferences.py deleted file mode 100644 index 1ae96082..00000000 --- a/qt/se/preferences.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.gnu.org/licenses/gpl-3.0.html - -from core.scanner import ScanType - -from ..base.preferences import Preferences as PreferencesBase - -class Preferences(PreferencesBase): - DEFAULT_SCAN_TYPE = ScanType.Contents - - def _load_specific(self, settings): - get = self.get_value - self.word_weighting = get('WordWeighting', self.word_weighting) - self.match_similar = get('MatchSimilar', self.match_similar) - self.ignore_small_files = get('IgnoreSmallFiles', self.ignore_small_files) - self.small_file_threshold = get('SmallFileThreshold', self.small_file_threshold) - - def _reset_specific(self): - self.filter_hardness = 80 - self.word_weighting = True - self.match_similar = False - self.ignore_small_files = True - self.small_file_threshold = 10 # KB - - def _save_specific(self, settings): - set_ = self.set_value - set_('WordWeighting', self.word_weighting) - set_('MatchSimilar', self.match_similar) - set_('IgnoreSmallFiles', self.ignore_small_files) - set_('SmallFileThreshold', self.small_file_threshold) - diff --git a/qt/se/preferences_dialog.py b/qt/se/preferences_dialog.py index 5ee5ca23..62db72d4 100644 --- a/qt/se/preferences_dialog.py +++ b/qt/se/preferences_dialog.py @@ -13,10 +13,10 @@ from hscommon.plat import ISWINDOWS, ISLINUX from hscommon.trans import trget from hscommon.util import tryint +from core.app import AppMode from core.scanner import ScanType -from ..base.preferences_dialog import PreferencesDialogBase -from . import preferences +from ..preferences_dialog import PreferencesDialogBase tr = trget('ui') @@ -81,7 +81,7 @@ class PreferencesDialog(PreferencesDialogBase): self.sizeThresholdEdit.setText(str(prefs.small_file_threshold)) # Update UI state based on selected scan type - scan_type = prefs.scan_type + scan_type = prefs.get_scan_type(AppMode.Standard) word_based = scan_type == ScanType.Filename self.filterHardnessSlider.setEnabled(word_based) self.matchSimilarBox.setEnabled(word_based) @@ -93,6 +93,3 @@ class PreferencesDialog(PreferencesDialogBase): prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox) prefs.small_file_threshold = tryint(self.sizeThresholdEdit.text()) - def resetToDefaults(self): - self.load(preferences.Preferences()) - diff --git a/qt/se/results_model.py b/qt/se/results_model.py index 85f720d8..24c6f077 100644 --- a/qt/se/results_model.py +++ b/qt/se/results_model.py @@ -1,12 +1,11 @@ -# Created On: 2011-11-27 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) +# Copyright 2016 Hardcoded Software (http://www.hardcoded.net) # # This software is licensed under the "GPLv3" License as described in the "LICENSE" file, # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html from qtlib.column import Column -from ..base.results_model import ResultsModel as ResultsModelBase +from ..results_model import ResultsModel as ResultsModelBase class ResultsModel(ResultsModelBase): COLUMNS = [ diff --git a/qt/base/stats_label.py b/qt/stats_label.py similarity index 100% rename from qt/base/stats_label.py rename to qt/stats_label.py diff --git a/qt/testapp.py b/qt/testapp.py deleted file mode 100644 index 69693a6f..00000000 --- a/qt/testapp.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# Created By: Virgil Dupras -# Created On: 2010-10-04 -# Copyright 2015 Hardcoded Software (http://www.hardcoded.net) -# -# This software is licensed under the "HS" 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/hs_license - -from qtlib.recent import Recent - -from .se.app import DupeGuru - -class TestApp(DupeGuru): - # Use this for as a mock for UI testing. - def mustShowNag(self): - pass - - def _setup(self): - self.prefs = self._create_preferences() - self.prefs.load() - self.recentResults = Recent(self, 'recentResults') - self._setupActions() -