From 08eac3844e7c2f4c2d469b56d79141dd7c27126e Mon Sep 17 00:00:00 2001 From: Virgil Dupras Date: Sat, 11 Mar 2017 20:18:27 -0500 Subject: [PATCH] Initial commit --- .gitignore | 15 ++ .gitmodules | 9 + Makefile | 37 +++ README.md | 42 +++ build.py | 319 +++++++++++++++++++++++ cocoa/AppDelegate.h | 79 ++++++ cocoa/AppDelegate.m | 394 ++++++++++++++++++++++++++++ cocoa/Consts.h | 24 ++ cocoa/DeletionOptions.h | 33 +++ cocoa/DeletionOptions.m | 72 +++++ cocoa/DetailsPanel.h | 31 +++ cocoa/DetailsPanel.m | 81 ++++++ cocoa/DetailsPanelPicture.h | 32 +++ cocoa/DetailsPanelPicture.m | 96 +++++++ cocoa/DirectoryOutline.h | 21 ++ cocoa/DirectoryOutline.m | 87 +++++++ cocoa/DirectoryPanel.h | 57 ++++ cocoa/DirectoryPanel.m | 256 ++++++++++++++++++ cocoa/IgnoreListDialog.h | 25 ++ cocoa/IgnoreListDialog.m | 51 ++++ cocoa/InfoTemplate.plist | 38 +++ cocoa/PrioritizeDialog.h | 37 +++ cocoa/PrioritizeDialog.m | 56 ++++ cocoa/PrioritizeList.h | 16 ++ cocoa/PrioritizeList.m | 58 +++++ cocoa/ProblemDialog.h | 26 ++ cocoa/ProblemDialog.m | 44 ++++ cocoa/ResultTable.h | 23 ++ cocoa/ResultTable.m | 180 +++++++++++++ cocoa/ResultWindow.h | 76 ++++++ cocoa/ResultWindow.m | 406 +++++++++++++++++++++++++++++ cocoa/StatsLabel.h | 17 ++ cocoa/StatsLabel.m | 34 +++ cocoa/dg_cocoa.py | 19 ++ cocoa/dupeguru.icns | Bin 0 -> 53620 bytes cocoa/en.lproj/Localizable.strings | 140 ++++++++++ cocoa/inter/__init__.py | 0 cocoa/inter/all.py | 10 + cocoa/inter/app.py | 252 ++++++++++++++++++ cocoa/inter/deletion_options.py | 37 +++ cocoa/inter/details_panel.py | 11 + cocoa/inter/directories.py | 53 ++++ cocoa/inter/directory_outline.py | 21 ++ cocoa/inter/ignore_list_dialog.py | 21 ++ cocoa/inter/photo.py | 35 +++ cocoa/inter/prioritize_dialog.py | 29 +++ cocoa/inter/prioritize_list.py | 8 + cocoa/inter/problem_dialog.py | 9 + cocoa/inter/result_table.py | 50 ++++ cocoa/inter/stats_label.py | 9 + cocoa/main.m | 49 ++++ cocoa/run_template.py | 10 + cocoa/ui/deletion_options.py | 49 ++++ cocoa/ui/details_panel.py | 32 +++ cocoa/ui/details_panel_picture.py | 70 +++++ cocoa/ui/directory_panel.py | 76 ++++++ cocoa/ui/ignore_list_dialog.py | 30 +++ cocoa/ui/main_menu.py | 77 ++++++ cocoa/ui/preferences_panel.py | 173 ++++++++++++ cocoa/ui/prioritize_dialog.py | 65 +++++ cocoa/ui/problem_dialog.py | 35 +++ cocoa/ui/result_window.py | 97 +++++++ cocoa/waf | 169 ++++++++++++ cocoa/wscript | 71 +++++ cocoalib | 1 + dupeguru | 1 + hscommon | 1 + requirements.txt | 4 + 68 files changed, 4486 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Makefile create mode 100644 README.md create mode 100644 build.py create mode 100644 cocoa/AppDelegate.h create mode 100644 cocoa/AppDelegate.m create mode 100644 cocoa/Consts.h create mode 100644 cocoa/DeletionOptions.h create mode 100644 cocoa/DeletionOptions.m create mode 100644 cocoa/DetailsPanel.h create mode 100644 cocoa/DetailsPanel.m create mode 100644 cocoa/DetailsPanelPicture.h create mode 100644 cocoa/DetailsPanelPicture.m create mode 100644 cocoa/DirectoryOutline.h create mode 100644 cocoa/DirectoryOutline.m create mode 100644 cocoa/DirectoryPanel.h create mode 100644 cocoa/DirectoryPanel.m create mode 100644 cocoa/IgnoreListDialog.h create mode 100644 cocoa/IgnoreListDialog.m create mode 100644 cocoa/InfoTemplate.plist create mode 100644 cocoa/PrioritizeDialog.h create mode 100644 cocoa/PrioritizeDialog.m create mode 100644 cocoa/PrioritizeList.h create mode 100644 cocoa/PrioritizeList.m create mode 100644 cocoa/ProblemDialog.h create mode 100644 cocoa/ProblemDialog.m create mode 100644 cocoa/ResultTable.h create mode 100644 cocoa/ResultTable.m create mode 100644 cocoa/ResultWindow.h create mode 100644 cocoa/ResultWindow.m create mode 100644 cocoa/StatsLabel.h create mode 100644 cocoa/StatsLabel.m create mode 100644 cocoa/dg_cocoa.py create mode 100755 cocoa/dupeguru.icns create mode 100644 cocoa/en.lproj/Localizable.strings create mode 100644 cocoa/inter/__init__.py create mode 100644 cocoa/inter/all.py create mode 100644 cocoa/inter/app.py create mode 100644 cocoa/inter/deletion_options.py create mode 100644 cocoa/inter/details_panel.py create mode 100644 cocoa/inter/directories.py create mode 100644 cocoa/inter/directory_outline.py create mode 100644 cocoa/inter/ignore_list_dialog.py create mode 100644 cocoa/inter/photo.py create mode 100644 cocoa/inter/prioritize_dialog.py create mode 100644 cocoa/inter/prioritize_list.py create mode 100644 cocoa/inter/problem_dialog.py create mode 100644 cocoa/inter/result_table.py create mode 100644 cocoa/inter/stats_label.py create mode 100644 cocoa/main.m create mode 100644 cocoa/run_template.py create mode 100644 cocoa/ui/deletion_options.py create mode 100644 cocoa/ui/details_panel.py create mode 100644 cocoa/ui/details_panel_picture.py create mode 100644 cocoa/ui/directory_panel.py create mode 100644 cocoa/ui/ignore_list_dialog.py create mode 100644 cocoa/ui/main_menu.py create mode 100644 cocoa/ui/preferences_panel.py create mode 100644 cocoa/ui/prioritize_dialog.py create mode 100644 cocoa/ui/problem_dialog.py create mode 100644 cocoa/ui/result_window.py create mode 100755 cocoa/waf create mode 100644 cocoa/wscript create mode 160000 cocoalib create mode 160000 dupeguru create mode 160000 hscommon create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46e0eab --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +__pycache__ +*.so +*.mo +*.waf* +.lock-waf* +/build +/cocoa/build +/env +/cocoa/autogen +/locale + +/run.py +/cocoa/*/Info.plist +/cocoa/*/build diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8fe9536 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "dupeguru"] + path = dupeguru + url = https://github.com/hsoft/dupeguru.git +[submodule "hscommon"] + path = hscommon + url = https://github.com/hsoft/hscommon.git +[submodule "cocoalib"] + path = cocoalib + url = https://github.com/hsoft/cocoalib.git diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..378bde1 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +PYTHON ?= python3 +REQ_MINOR_VERSION = 4 + +all : | env build + @echo "Build complete! You can run dupeGuru with 'make run'" + +# If you're installing into a path that is not going to be the final path prefix (such as a +# sandbox), set DESTDIR to that path. + +# Our build scripts are not very "make like" yet and perform their task in a bundle. For now, we +# use one of each file to act as a representative, a target, of these groups. +submodules_target = hscommon/__init__.py + +reqs : + @ret=`${PYTHON} -c "import sys; print(int(sys.version_info[:2] >= (3, ${REQ_MINOR_VERSION})))"`; \ + if [ $${ret} -ne 1 ]; then \ + echo "Python 3.${REQ_MINOR_VERSION}+ required. Aborting."; \ + exit 1; \ + fi + @${PYTHON} -m venv -h > /dev/null || \ + echo "Creation of our virtualenv failed. Something's wrong with your python install." + +# Ensure that submodules are initialized +$(submodules_target) : + git submodule init + git submodule update + cd dupeguru; ln -sf ../hscommon .; ln -sf ../cocoalib . + +env : | $(submodules_target) reqs + @echo "Creating our virtualenv" + ${PYTHON} -m venv env + ./env/bin/python -m pip install -r requirements.txt + +build: + ./env/bin/python build.py + +.PHONY : reqs build all \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2005982 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# dupeguru-cocoa + +This is the Cocoa UI for [dupeGuru][dupeguru]. This code was previously directly in the main repo, +but since I'm not planning on supporting MacOS myself any longer, I'm splitting it out. + +Also, to make the job easier on a would-be maintainer for the Cocoa UI of dupeGuru, I'm planning +on restoring the XCode/XIB version of the UI from the grave. + +### OS X maintainer wanted + +My Mac Mini is already a couple of years old and is likely to be my last Apple purchase. When it +dies, I will be unable maintain the OS X version of moneyGuru. I've already stopped paying for the +Mac Developer membership so I can't sign the apps anymore (in the "official way" I mean. The +download is still PGP signed) If you're a Mac developer and are interested in taking this task, +[don't hesitate to let me know][contrib-issue]. + +## How to build dupeGuru from source + +### Prerequisites + +* Python 3.4+ compiled in "framework mode". +* MacOS 10.10+ with XCode command line tools. + +### make + +You can build the app with `make`: + + $ make + $ make run + +### pyenv + +[pyenv][pyenv] is a popular way to manage multiple python versions. However, be aware that dupeGuru +will not compile with a pyenv's python unless it's been built with `--enable-framework`. You can do +this with: + + $ env PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install 3.4.3 + + +[dupeguru]: https://github.com/hsoft/dupeguru +[contrib-issue]: https://github.com/hsoft/dupeguru/issues/300 +[pyenv]: https://github.com/yyuu/pyenv \ No newline at end of file diff --git a/build.py b/build.py new file mode 100644 index 0000000..1c25988 --- /dev/null +++ b/build.py @@ -0,0 +1,319 @@ +# Copyright 2017 Virgil Dupras +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +import sys +sys.path.append('dupeguru') +import os +import os.path as op +from optparse import OptionParser +import shutil +import compileall + +from setuptools import setup, Extension + +from hscommon import sphinxgen +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 +) +from hscommon import loc +from hscommon.plat import ISOSX +from hscommon.util import ensure_folder, delete_files_with_pattern + +def parse_args(): + usage = "usage: %prog [options]" + parser = OptionParser(usage=usage) + parser.add_option( + '--clean', action='store_true', dest='clean', + help="Clean build folder before building" + ) + parser.add_option( + '--doc', action='store_true', dest='doc', + help="Build only the help file" + ) + 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" + ) + parser.add_option( + '--cocoa-ext', action='store_true', dest='cocoa_ext', + help="Build only Cocoa extensions" + ) + parser.add_option( + '--cocoa-compile', action='store_true', dest='cocoa_compile', + help="Build only Cocoa executable" + ) + parser.add_option( + '--xibless', action='store_true', dest='xibless', + help="Build only xibless UIs" + ) + parser.add_option( + '--updatepot', action='store_true', dest='updatepot', + help="Generate .pot files from source code." + ) + parser.add_option( + '--mergepot', action='store_true', dest='mergepot', + help="Update all .po files based on .pot files." + ) + parser.add_option( + '--normpo', action='store_true', dest='normpo', + help="Normalize all PO files (do this before commit)." + ) + (options, args) = parser.parse_args() + return options + +def cocoa_app(): + app_path = 'build/dupeGuru.app' + return OSXAppStructure(app_path) + +def build_xibless(dest='cocoa/autogen'): + import xibless + ensure_folder(dest) + FNPAIRS = [ + ('ignore_list_dialog.py', 'IgnoreListDialog_UI'), + ('deletion_options.py', 'DeletionOptions_UI'), + ('problem_dialog.py', 'ProblemDialog_UI'), + ('directory_panel.py', 'DirectoryPanel_UI'), + ('prioritize_dialog.py', 'PrioritizeDialog_UI'), + ('result_window.py', 'ResultWindow_UI'), + ('main_menu.py', 'MainMenu_UI'), + ('details_panel.py', 'DetailsPanel_UI'), + ('details_panel_picture.py', 'DetailsPanelPicture_UI'), + ] + for srcname, dstname in FNPAIRS: + xibless.generate( + op.join('cocoa', 'ui', srcname), op.join(dest, dstname), + localizationTable='Localizable' + ) + for appmode in ('standard', 'music', 'picture'): + xibless.generate( + op.join('cocoa', 'ui', 'preferences_panel.py'), + op.join(dest, 'PreferencesPanel%s_UI' % appmode.capitalize()), + localizationTable='Localizable', + args={'appmode': appmode}, + ) + +def build_cocoa(dev): + print("Creating OS X app structure") + app = cocoa_app() + app_version = get_module_version('core') + cocoa_project_path = 'cocoa' + 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() + print("Building xibless UIs") + build_cocoalib_xibless() + build_xibless() + print("Building Python extensions") + build_cocoa_proxy_module() + 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') + tocopy = [ + 'dupeguru/core', 'hscommon', 'cocoa/inter', 'cocoalib/cocoa', 'objp', 'send2trash', 'hsaudiotag', + ] + copy_packages(tocopy, pydep_folder, create_links=dev) + sys.path.insert(0, 'build') + # 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. + copy_all('build/inter/*.so', op.join(pydep_folder, 'inter')) + if not dev: + # Important: Don't ever run delete_files_with_pattern('*.py') on dev builds because you'll + # be deleting all py files in symlinked folders. + compileall.compile_dir(pydep_folder, force=True, legacy=True) + delete_files_with_pattern(pydep_folder, '*.py') + delete_files_with_pattern(pydep_folder, '__pycache__') + print("Compiling with WAF") + os.chdir('cocoa') + print_and_do('{0} waf configure && {0} waf'.format(sys.executable)) + os.chdir('..') + app.copy_executable('cocoa/build/dupeGuru') + build_help() + print("Copying resources and frameworks") + image_path = 'cocoa/dupeguru.icns' + resources = [image_path, 'cocoa/dsa_pub.pem', 'build/dg_cocoa.py', 'build/help'] + app.copy_resources(*resources, use_symlinks=dev) + app.copy_frameworks('build/Python') + print("Creating the run.py file") + tmpl = open('cocoa/run_template.py', 'rt').read() + run_contents = tmpl.replace('{{app_path}}', app.dest) + open('run.py', 'wt').write(run_contents) + +def build_help(): + print("Generating Help") + current_path = op.abspath('dupeguru') + help_basepath = op.join(current_path, 'help', 'en') + help_destpath = op.join(current_path, '..', 'build', 'help') + changelog_path = op.join(current_path, 'help', 'changelog') + tixurl = "https://github.com/hsoft/dupeguru/issues/{}" + confrepl = {'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) + +def build_localizations(): + if not op.exists('locale'): + os.symlink('dupeguru/locale', 'locale') + loc.compile_all_po('locale') + app = cocoa_app() + loc.build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'en.lproj', 'Localizable.strings')) + locale_dest = op.join(app.resources, 'locale') + if op.exists(locale_dest): + shutil.rmtree(locale_dest) + shutil.copytree('locale', locale_dest, ignore=shutil.ignore_patterns('*.po', '*.pot')) + +def build_updatepot(): + print("Updating Cocoa strings file.") + build_cocoalib_xibless('cocoalib/autogen') + loc.generate_cocoa_strings_from_code('cocoalib', 'cocoalib/en.lproj') + build_xibless() + loc.generate_cocoa_strings_from_code('cocoa', 'cocoa/en.lproj') + +def build_mergepot(): + print("Updating .po files using .pot files") + loc.merge_pots_into_pos(op.join('cocoalib', 'locale')) + +def build_normpo(): + loc.normalize_all_pos(op.join('cocoalib', 'locale')) + +def build_cocoa_proxy_module(): + print("Building Cocoa Proxy") + import objp.p2o + objp.p2o.generate_python_proxy_code('cocoalib/cocoa/CocoaProxy.h', 'build/CocoaProxy.m') + build_cocoa_ext( + "CocoaProxy", 'cocoalib/cocoa', + [ + 'cocoalib/cocoa/CocoaProxy.m', 'build/CocoaProxy.m', 'build/ObjP.m', + 'cocoalib/HSErrorReportWindow.m', 'cocoa/autogen/HSErrorReportWindow_UI.m' + ], + ['AppKit', 'CoreServices'], + ['cocoalib', 'cocoa/autogen'] + ) + +def build_cocoa_bridging_interfaces(): + print("Building Cocoa Bridging Interfaces") + import objp.o2p + import objp.p2o + add_to_pythonpath('cocoa') + add_to_pythonpath('cocoalib') + from cocoa.inter import ( + PyGUIObject, GUIObjectView, PyColumns, ColumnsView, PyOutline, + OutlineView, PySelectableList, SelectableListView, PyTable, TableView, PyBaseApp, + PyTextField, ProgressWindowView, PyProgressWindow + ) + from inter.deletion_options import PyDeletionOptions, DeletionOptionsView + from inter.details_panel import PyDetailsPanel, DetailsPanelView + from inter.directory_outline import PyDirectoryOutline, DirectoryOutlineView + from inter.prioritize_dialog import PyPrioritizeDialog, PrioritizeDialogView + from inter.prioritize_list import PyPrioritizeList, PrioritizeListView + from inter.problem_dialog import PyProblemDialog + from inter.ignore_list_dialog import PyIgnoreListDialog, IgnoreListDialogView + from inter.result_table import PyResultTable, ResultTableView + from inter.stats_label import PyStatsLabel, StatsLabelView + from inter.app import PyDupeGuru, DupeGuruView + allclasses = [ + PyGUIObject, PyColumns, PyOutline, PySelectableList, PyTable, PyBaseApp, + PyDetailsPanel, PyDirectoryOutline, PyPrioritizeDialog, PyPrioritizeList, PyProblemDialog, + PyIgnoreListDialog, PyDeletionOptions, PyResultTable, PyStatsLabel, PyDupeGuru, + PyTextField, PyProgressWindow + ] + for class_ in allclasses: + objp.o2p.generate_objc_code(class_, 'cocoa/autogen', inherit=True) + allclasses = [ + GUIObjectView, ColumnsView, OutlineView, SelectableListView, TableView, + DetailsPanelView, DirectoryOutlineView, PrioritizeDialogView, PrioritizeListView, + IgnoreListDialogView, DeletionOptionsView, ResultTableView, StatsLabelView, + ProgressWindowView, DupeGuruView + ] + clsspecs = [objp.o2p.spec_from_python_class(class_) for class_ in allclasses] + objp.p2o.generate_python_proxy_code_from_clsspec(clsspecs, 'build/CocoaViews.m') + build_cocoa_ext('CocoaViews', 'cocoa/inter', ['build/CocoaViews.m', 'build/ObjP.m']) + +def build_pe_modules(): + print("Building PE Modules") + exts = [ + Extension( + "_block", + [op.join('dupeguru', 'core', 'pe', 'modules', 'block.c'), op.join('dupeguru', 'core', 'pe', 'modules', 'common.c')] + ), + Extension( + "_cache", + [op.join('dupeguru', 'core', 'pe', 'modules', 'cache.c'), op.join('dupeguru', 'core', 'pe', 'modules', 'common.c')] + ), + ] + exts.append(Extension( + "_block_osx", + [op.join('dupeguru', 'core', 'pe', 'modules', 'block_osx.m'), op.join('dupeguru', 'core', 'pe', 'modules', 'common.c')], + extra_link_args=[ + "-framework", "CoreFoundation", + "-framework", "Foundation", + "-framework", "ApplicationServices", + ] + )) + setup( + script_args=['build_ext', '--inplace'], + ext_modules=exts, + ) + move_all('_block*', op.join('dupeguru', 'core', 'pe')) + move_all('_cache*', op.join('dupeguru', 'core', 'pe')) + +def build_normal(dev): + print("Building dupeGuru with UI cocoa") + add_to_pythonpath('.') + build_pe_modules() + build_cocoa(dev) + +def main(): + options = parse_args() + if options.dev: + print("Building in Dev mode") + if options.clean: + for path in ['build', op.join('cocoa', 'build'), op.join('cocoa', 'autogen')]: + if op.exists(path): + shutil.rmtree(path) + if not op.exists('build'): + os.mkdir('build') + if options.doc: + build_help() + elif options.loc: + build_localizations() + elif options.updatepot: + build_updatepot() + elif options.mergepot: + build_mergepot() + elif options.normpo: + build_normpo() + elif options.cocoa_ext: + build_cocoa_proxy_module() + build_cocoa_bridging_interfaces() + elif options.cocoa_compile: + os.chdir('cocoa') + print_and_do('{0} waf configure && {0} waf'.format(sys.executable)) + os.chdir('..') + cocoa_app().copy_executable('cocoa/build/dupeGuru') + elif options.xibless: + build_cocoalib_xibless() + build_xibless() + else: + build_normal(options.dev) + +if __name__ == '__main__': + main() diff --git a/cocoa/AppDelegate.h b/cocoa/AppDelegate.h new file mode 100644 index 0000000..63a6c1f --- /dev/null +++ b/cocoa/AppDelegate.h @@ -0,0 +1,79 @@ +/* +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 +#import "PyDupeGuru.h" +#import "ResultWindow.h" +#import "ResultTable.h" +#import "DetailsPanel.h" +#import "DirectoryPanel.h" +#import "IgnoreListDialog.h" +#import "ProblemDialog.h" +#import "DeletionOptions.h" +#import "HSAboutBox.h" +#import "HSRecentFiles.h" +#import "HSProgressWindow.h" + +@interface AppDelegate : NSObject +{ + NSMenu *recentResultsMenu; + NSMenu *columnsMenu; + + PyDupeGuru *model; + ResultWindow *_resultWindow; + DirectoryPanel *_directoryPanel; + DetailsPanel *_detailsPanel; + IgnoreListDialog *_ignoreListDialog; + ProblemDialog *_problemDialog; + DeletionOptions *_deletionOptions; + HSProgressWindow *_progressWindow; + NSWindowController *_preferencesPanel; + HSAboutBox *_aboutBox; + HSRecentFiles *_recentResults; +} + +@property (readwrite, retain) NSMenu *recentResultsMenu; +@property (readwrite, retain) NSMenu *columnsMenu; + +/* Virtual */ ++ (NSDictionary *)defaultPreferences; +- (PyDupeGuru *)model; +- (DetailsPanel *)createDetailsPanel; +- (void)setScanOptions; + +/* Public */ +- (void)finalizeInit; +- (ResultWindow *)resultWindow; +- (DirectoryPanel *)directoryPanel; +- (DetailsPanel *)detailsPanel; +- (HSRecentFiles *)recentResults; +- (NSInteger)getAppMode; +- (void)setAppMode:(NSInteger)appMode; + +/* Delegate */ +- (void)applicationDidFinishLaunching:(NSNotification *)aNotification; +- (void)applicationWillBecomeActive:(NSNotification *)aNotification; +- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender; +- (void)applicationWillTerminate:(NSNotification *)aNotification; +- (void)recentFileClicked:(NSString *)path; + +/* Actions */ +- (void)clearPictureCache; +- (void)loadResults; +- (void)openWebsite; +- (void)openHelp; +- (void)showAboutBox; +- (void)showDirectoryWindow; +- (void)showPreferencesPanel; +- (void)showResultWindow; +- (void)showIgnoreList; +- (void)startScanning; + +/* model --> view */ +- (void)showMessage:(NSString *)msg; +@end diff --git a/cocoa/AppDelegate.m b/cocoa/AppDelegate.m new file mode 100644 index 0000000..59fe933 --- /dev/null +++ b/cocoa/AppDelegate.m @@ -0,0 +1,394 @@ +/* +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 "AppDelegate.h" +#import "ProgressController.h" +#import "HSPyUtil.h" +#import "Consts.h" +#import "Dialogs.h" +#import "Utils.h" +#import "ValueTransformers.h" +#import "DetailsPanelPicture.h" +#import "PreferencesPanelStandard_UI.h" +#import "PreferencesPanelMusic_UI.h" +#import "PreferencesPanelPicture_UI.h" + +@implementation AppDelegate + +@synthesize recentResultsMenu; +@synthesize columnsMenu; + ++ (NSDictionary *)defaultPreferences +{ + NSMutableDictionary *d = [NSMutableDictionary dictionary]; + [d setObject:i2n(1) forKey:@"scanTypeStandard"]; + [d setObject:i2n(3) forKey:@"scanTypeMusic"]; + [d setObject:i2n(0) forKey:@"scanTypePicture"]; + [d setObject:i2n(95) forKey:@"minMatchPercentage"]; + [d setObject:i2n(30) forKey:@"smallFileThreshold"]; + [d setObject:b2n(YES) forKey:@"wordWeighting"]; + [d setObject:b2n(NO) forKey:@"matchSimilarWords"]; + [d setObject:b2n(YES) forKey:@"ignoreSmallFiles"]; + [d setObject:b2n(NO) forKey:@"scanTagTrack"]; + [d setObject:b2n(YES) forKey:@"scanTagArtist"]; + [d setObject:b2n(YES) forKey:@"scanTagAlbum"]; + [d setObject:b2n(YES) forKey:@"scanTagTitle"]; + [d setObject:b2n(NO) forKey:@"scanTagGenre"]; + [d setObject:b2n(NO) forKey:@"scanTagYear"]; + [d setObject:b2n(NO) forKey:@"matchScaled"]; + [d setObject:i2n(1) forKey:@"recreatePathType"]; + [d setObject:i2n(11) forKey:TableFontSize]; + [d setObject:b2n(YES) forKey:@"mixFileKind"]; + [d setObject:b2n(NO) forKey:@"useRegexpFilter"]; + [d setObject:b2n(NO) forKey:@"ignoreHardlinkMatches"]; + [d setObject:b2n(NO) forKey:@"removeEmptyFolders"]; + [d setObject:b2n(NO) forKey:@"DebugMode"]; + [d setObject:@"" forKey:@"CustomCommand"]; + [d setObject:[NSArray array] forKey:@"recentDirectories"]; + [d setObject:[NSArray array] forKey:@"columnsOrder"]; + [d setObject:[NSDictionary dictionary] forKey:@"columnsWidth"]; + return d; +} + ++ (void)initialize +{ + HSVTAdd *vt = [[[HSVTAdd alloc] initWithValue:4] autorelease]; + [NSValueTransformer setValueTransformer:vt forName:@"vtRowHeightOffset"]; + NSDictionary *d = [self defaultPreferences]; + [[NSUserDefaultsController sharedUserDefaultsController] setInitialValues:d]; + [[NSUserDefaults standardUserDefaults] registerDefaults:d]; +} + +- (id)init +{ + self = [super init]; + model = [[PyDupeGuru alloc] init]; + [model bindCallback:createCallback(@"DupeGuruView", self)]; + NSMutableIndexSet *contentsIndexes = [NSMutableIndexSet indexSet]; + [contentsIndexes addIndex:1]; + [contentsIndexes addIndex:2]; + VTIsIntIn *vt = [[[VTIsIntIn alloc] initWithValues:contentsIndexes reverse:YES] autorelease]; + [NSValueTransformer setValueTransformer:vt forName:@"vtScanTypeIsNotContent"]; + NSMutableIndexSet *i = [NSMutableIndexSet indexSetWithIndex:0]; + VTIsIntIn *vtScanTypeIsFuzzy = [[[VTIsIntIn alloc] initWithValues:i reverse:NO] autorelease]; + [NSValueTransformer setValueTransformer:vtScanTypeIsFuzzy forName:@"vtScanTypeIsFuzzy"]; + i = [NSMutableIndexSet indexSetWithIndex:4]; + VTIsIntIn *vtScanTypeIsNotContent = [[[VTIsIntIn alloc] initWithValues:i reverse:YES] autorelease]; + [NSValueTransformer setValueTransformer:vtScanTypeIsNotContent forName:@"vtScanTypeMusicIsNotContent"]; + VTIsIntIn *vtScanTypeIsTag = [[[VTIsIntIn alloc] initWithValues:[NSIndexSet indexSetWithIndex:3] reverse:NO] autorelease]; + [NSValueTransformer setValueTransformer:vtScanTypeIsTag forName:@"vtScanTypeIsTag"]; + return self; +} + +- (void)finalizeInit +{ + // We can only finalize initialization once the main menu has been created, which cannot happen + // before AppDelegate is created. + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + _recentResults = [[HSRecentFiles alloc] initWithName:@"recentResults" menu:recentResultsMenu]; + [_recentResults setDelegate:self]; + _directoryPanel = [[DirectoryPanel alloc] initWithParentApp:self]; + _ignoreListDialog = [[IgnoreListDialog alloc] initWithPyRef:[model ignoreListDialog]]; + _problemDialog = [[ProblemDialog alloc] initWithPyRef:[model problemDialog]]; + _deletionOptions = [[DeletionOptions alloc] initWithPyRef:[model deletionOptions]]; + _progressWindow = [[HSProgressWindow alloc] initWithPyRef:[[self model] progressWindow] view:nil]; + [_progressWindow setParentWindow:[_directoryPanel window]]; + // Lazily loaded + _aboutBox = nil; + _preferencesPanel = nil; + _resultWindow = nil; + _detailsPanel = nil; + [[[self directoryPanel] window] makeKeyAndOrderFront:self]; +} + +/* Virtual */ + +- (PyDupeGuru *)model +{ + return model; +} + +- (DetailsPanel *)createDetailsPanel +{ + NSInteger appMode = [self getAppMode]; + if (appMode == AppModePicture) { + return [[DetailsPanelPicture alloc] initWithApp:model]; + } + else { + return [[DetailsPanel alloc] initWithPyRef:[model detailsPanel]]; + } +} + +- (void)setScanOptions +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + NSString *scanTypeOptionName; + NSInteger appMode = [self getAppMode]; + if (appMode == AppModePicture) { + scanTypeOptionName = @"scanTypePicture"; + } + else if (appMode == AppModeMusic) { + scanTypeOptionName = @"scanTypeMusic"; + } + else { + scanTypeOptionName = @"scanTypeStandard"; + } + [model setScanType:n2i([ud objectForKey:scanTypeOptionName])]; + [model setMinMatchPercentage:n2i([ud objectForKey:@"minMatchPercentage"])]; + [model setWordWeighting:n2b([ud objectForKey:@"wordWeighting"])]; + [model setMixFileKind:n2b([ud objectForKey:@"mixFileKind"])]; + [model setIgnoreHardlinkMatches:n2b([ud objectForKey:@"ignoreHardlinkMatches"])]; + [model setMatchSimilarWords:n2b([ud objectForKey:@"matchSimilarWords"])]; + int smallFileThreshold = [ud integerForKey:@"smallFileThreshold"]; // In KB + int sizeThreshold = [ud boolForKey:@"ignoreSmallFiles"] ? smallFileThreshold * 1024 : 0; // The py side wants bytes + [model setSizeThreshold:sizeThreshold]; + [model enable:n2b([ud objectForKey:@"scanTagTrack"]) scanForTag:@"track"]; + [model enable:n2b([ud objectForKey:@"scanTagArtist"]) scanForTag:@"artist"]; + [model enable:n2b([ud objectForKey:@"scanTagAlbum"]) scanForTag:@"album"]; + [model enable:n2b([ud objectForKey:@"scanTagTitle"]) scanForTag:@"title"]; + [model enable:n2b([ud objectForKey:@"scanTagGenre"]) scanForTag:@"genre"]; + [model enable:n2b([ud objectForKey:@"scanTagYear"]) scanForTag:@"year"]; + [model setMatchScaled:n2b([ud objectForKey:@"matchScaled"])]; +} + +/* Public */ +- (ResultWindow *)resultWindow +{ + return _resultWindow; +} + +- (DirectoryPanel *)directoryPanel +{ + return _directoryPanel; +} + +- (DetailsPanel *)detailsPanel +{ + return _detailsPanel; +} + +- (HSRecentFiles *)recentResults +{ + return _recentResults; +} + +- (NSInteger)getAppMode +{ + return [model getAppMode]; +} + +- (void)setAppMode:(NSInteger)appMode +{ + [model setAppMode:appMode]; + if (_preferencesPanel != nil) { + [_preferencesPanel release]; + _preferencesPanel = nil; + } +} + +/* Actions */ +- (void)clearPictureCache +{ + NSString *msg = NSLocalizedString(@"Do you really want to remove all your cached picture analysis?", @""); + if ([Dialogs askYesNo:msg] == NSAlertSecondButtonReturn) // NO + return; + [model clearPictureCache]; +} + +- (void)loadResults +{ + NSOpenPanel *op = [NSOpenPanel openPanel]; + [op setCanChooseFiles:YES]; + [op setCanChooseDirectories:NO]; + [op setCanCreateDirectories:NO]; + [op setAllowsMultipleSelection:NO]; + [op setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]]; + [op setTitle:NSLocalizedString(@"Select a results file to load", @"")]; + if ([op runModal] == NSOKButton) { + NSString *filename = [[[op URLs] objectAtIndex:0] path]; + [model loadResultsFrom:filename]; + [[self recentResults] addFile:filename]; + } +} + +- (void)openWebsite +{ + [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://www.hardcoded.net/dupeguru/"]]; +} + +- (void)openHelp +{ + NSBundle *b = [NSBundle mainBundle]; + NSString *p = [b pathForResource:@"index" ofType:@"html" inDirectory:@"help"]; + NSURL *u = [NSURL fileURLWithPath:p]; + [[NSWorkspace sharedWorkspace] openURL:u]; +} + +- (void)showAboutBox +{ + if (_aboutBox == nil) { + _aboutBox = [[HSAboutBox alloc] initWithApp:model]; + } + [[_aboutBox window] makeKeyAndOrderFront:nil]; +} + +- (void)showDirectoryWindow +{ + [[[self directoryPanel] window] makeKeyAndOrderFront:nil]; +} + +- (void)showPreferencesPanel +{ + if (_preferencesPanel == nil) { + NSWindow *window; + NSInteger appMode = [model getAppMode]; + if (appMode == AppModePicture) { + window = createPreferencesPanelPicture_UI(nil); + } + else if (appMode == AppModeMusic) { + window = createPreferencesPanelMusic_UI(nil); + } + else { + window = createPreferencesPanelStandard_UI(nil); + } + _preferencesPanel = [[NSWindowController alloc] initWithWindow:window]; + } + [_preferencesPanel showWindow:nil]; +} + +- (void)showResultWindow +{ + [[[self resultWindow] window] makeKeyAndOrderFront:nil]; +} + +- (void)showIgnoreList +{ + [model showIgnoreList]; +} + +- (void)startScanning +{ + [[self directoryPanel] startDuplicateScan]; +} + + +/* Delegate */ +- (void)applicationDidFinishLaunching:(NSNotification *)aNotification +{ + [model loadSession]; +} + +- (void)applicationWillBecomeActive:(NSNotification *)aNotification +{ + if (![[[self directoryPanel] window] isVisible]) { + [[self directoryPanel] showWindow:NSApp]; + } +} + +- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender +{ + if ([model resultsAreModified]) { + NSString *msg = NSLocalizedString(@"You have unsaved results, do you really want to quit?", @""); + if ([Dialogs askYesNo:msg] == NSAlertSecondButtonReturn) { // NO + return NSTerminateCancel; + } + } + return NSTerminateNow; +} + +- (void)applicationWillTerminate:(NSNotification *)aNotification +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + NSInteger sc = [ud integerForKey:@"sessionCountSinceLastIgnorePurge"]; + if (sc >= 10) { + sc = -1; + [model purgeIgnoreList]; + } + sc++; + [model saveSession]; + [ud setInteger:sc forKey:@"sessionCountSinceLastIgnorePurge"]; + // NSApplication does not release nib instances objects, we must do it manually + // Well, it isn't needed because the memory is freed anyway (we are quitting the application + // But I need to release HSRecentFiles so it saves the user defaults + [_directoryPanel release]; + [_recentResults release]; +} + +- (void)recentFileClicked:(NSString *)path +{ + [model loadResultsFrom:path]; +} + + +/* model --> view */ +- (void)showMessage:(NSString *)msg +{ + [Dialogs showMessage:msg]; +} + +- (BOOL)askYesNoWithPrompt:(NSString *)prompt +{ + return [Dialogs askYesNo:prompt] == NSAlertFirstButtonReturn; +} + +- (void)createResultsWindow +{ + if (_resultWindow != nil) { + [_resultWindow release]; + } + if (_detailsPanel != nil) { + [_detailsPanel release]; + } + // Warning: creation order is important + // If the details panel is not created first and that there are some results in the model + // (happens if we load results), a dupe selection event triggers a details refresh in the + // core before we have the chance to initialize it, and then we crash. + _detailsPanel = [self createDetailsPanel]; + _resultWindow = [[ResultWindow alloc] initWithParentApp:self]; +} +- (void)showResultsWindow +{ + [[[self resultWindow] window] makeKeyAndOrderFront:nil]; +} + +- (void)showProblemDialog +{ + [_problemDialog showWindow:self]; +} + +- (NSString *)selectDestFolderWithPrompt:(NSString *)prompt +{ + NSOpenPanel *op = [NSOpenPanel openPanel]; + [op setCanChooseFiles:NO]; + [op setCanChooseDirectories:YES]; + [op setCanCreateDirectories:YES]; + [op setAllowsMultipleSelection:NO]; + [op setTitle:prompt]; + if ([op runModal] == NSOKButton) { + return [[[op URLs] objectAtIndex:0] path]; + } + else { + return nil; + } +} + +- (NSString *)selectDestFileWithPrompt:(NSString *)prompt extension:(NSString *)extension +{ + NSSavePanel *sp = [NSSavePanel savePanel]; + [sp setCanCreateDirectories:YES]; + [sp setAllowedFileTypes:[NSArray arrayWithObject:extension]]; + [sp setTitle:prompt]; + if ([sp runModal] == NSOKButton) { + return [[sp URL] path]; + } + else { + return nil; + } +} + +@end diff --git a/cocoa/Consts.h b/cocoa/Consts.h new file mode 100644 index 0000000..ff0c54f --- /dev/null +++ b/cocoa/Consts.h @@ -0,0 +1,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 +http://www.gnu.org/licenses/gpl-3.0.html +*/ + +#define JobStarted @"JobStarted" +#define JobInProgress @"JobInProgress" +#define TableFontSize @"TableFontSize" + +#define jobLoad @"job_load" +#define jobScan @"job_scan" +#define jobCopy @"job_copy" +#define jobMove @"job_move" +#define jobDelete @"job_delete" + +#define DGPrioritizeIndexPasteboardType @"DGPrioritizeIndexPasteboardType" +#define ImageLoadedNotification @"ImageLoadedNotification" + +#define AppModeStandard 0 +#define AppModeMusic 1 +#define AppModePicture 2 \ No newline at end of file diff --git a/cocoa/DeletionOptions.h b/cocoa/DeletionOptions.h new file mode 100644 index 0000000..05f2a28 --- /dev/null +++ b/cocoa/DeletionOptions.h @@ -0,0 +1,33 @@ +/* +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 +#import "PyDeletionOptions.h" + +@interface DeletionOptions : NSWindowController +{ + + PyDeletionOptions *model; + + NSTextField *messageTextField; + NSButton *linkButton; + NSMatrix *linkTypeRadio; + NSButton *directButton; +} + +@property (readwrite, retain) NSTextField *messageTextField; +@property (readwrite, retain) NSButton *linkButton; +@property (readwrite, retain) NSMatrix *linkTypeRadio; +@property (readwrite, retain) NSButton *directButton; + +- (id)initWithPyRef:(PyObject *)aPyRef; + +- (void)updateOptions; +- (void)proceed; +- (void)cancel; +@end \ No newline at end of file diff --git a/cocoa/DeletionOptions.m b/cocoa/DeletionOptions.m new file mode 100644 index 0000000..2e12f67 --- /dev/null +++ b/cocoa/DeletionOptions.m @@ -0,0 +1,72 @@ +/* +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 "DeletionOptions.h" +#import "DeletionOptions_UI.h" +#import "HSPyUtil.h" + +@implementation DeletionOptions + +@synthesize messageTextField; +@synthesize linkButton; +@synthesize linkTypeRadio; +@synthesize directButton; + +- (id)initWithPyRef:(PyObject *)aPyRef +{ + self = [super initWithWindow:nil]; + model = [[PyDeletionOptions alloc] initWithModel:aPyRef]; + [self setWindow:createDeletionOptions_UI(self)]; + [model bindCallback:createCallback(@"DeletionOptionsView", self)]; + return self; +} + +- (void)dealloc +{ + [model release]; + [super dealloc]; +} + +- (void)updateOptions +{ + [model setLinkDeleted:[linkButton state] == NSOnState]; + [model setUseHardlinks:[linkTypeRadio selectedColumn] == 1]; + [model setDirect:[directButton state] == NSOnState]; +} + +- (void)proceed +{ + [NSApp stopModalWithCode:NSOKButton]; +} + +- (void)cancel +{ + [NSApp stopModalWithCode:NSCancelButton]; +} + +/* model --> view */ +- (void)updateMsg:(NSString *)msg +{ + [messageTextField setStringValue:msg]; +} + +- (BOOL)show +{ + [linkButton setState:NSOffState]; + [directButton setState:NSOffState]; + [linkTypeRadio selectCellAtRow:0 column:0]; + NSInteger r = [NSApp runModalForWindow:[self window]]; + [[self window] close]; + return r == NSOKButton; +} + +- (void)setHardlinkOptionEnabled:(BOOL)enabled +{ + [linkTypeRadio setEnabled:enabled]; +} +@end \ No newline at end of file diff --git a/cocoa/DetailsPanel.h b/cocoa/DetailsPanel.h new file mode 100644 index 0000000..1c11f72 --- /dev/null +++ b/cocoa/DetailsPanel.h @@ -0,0 +1,31 @@ +/* +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 +#import +#import "PyDetailsPanel.h" + +@interface DetailsPanel : NSWindowController +{ + NSTableView *detailsTable; + + PyDetailsPanel *model; +} + +@property (readwrite, retain) NSTableView *detailsTable; + +- (id)initWithPyRef:(PyObject *)aPyRef; +- (PyDetailsPanel *)model; + +- (NSWindow *)createWindow; +- (BOOL)isVisible; +- (void)toggleVisibility; + +/* Python --> Cocoa */ +- (void)refresh; +@end \ No newline at end of file diff --git a/cocoa/DetailsPanel.m b/cocoa/DetailsPanel.m new file mode 100644 index 0000000..2efc779 --- /dev/null +++ b/cocoa/DetailsPanel.m @@ -0,0 +1,81 @@ +/* +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 "DetailsPanel.h" +#import "HSPyUtil.h" +#import "DetailsPanel_UI.h" + +@implementation DetailsPanel + +@synthesize detailsTable; + +- (id)initWithPyRef:(PyObject *)aPyRef +{ + self = [super initWithWindow:nil]; + [self setWindow:[self createWindow]]; + model = [[PyDetailsPanel alloc] initWithModel:aPyRef]; + [model bindCallback:createCallback(@"DetailsPanelView", self)]; + return self; +} + +- (void)dealloc +{ + [model release]; + [super dealloc]; +} + +- (PyDetailsPanel *)model +{ + return (PyDetailsPanel *)model; +} + +- (NSWindow *)createWindow +{ + return createDetailsPanel_UI(self); +} + +- (void)refreshDetails +{ + [detailsTable reloadData]; +} + +- (BOOL)isVisible +{ + return [[self window] isVisible]; +} + +- (void)toggleVisibility +{ + if ([self isVisible]) { + [[self window] close]; + } + else { + [self refreshDetails]; // selection might have changed since last time + [[self window] orderFront:nil]; + } +} + +/* NSTableView Delegate */ +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView +{ + return [[self model] numberOfRows]; +} + +- (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)row +{ + return [[self model] valueForColumn:[column identifier] row:row]; +} + +/* Python --> Cocoa */ +- (void)refresh +{ + if ([[self window] isVisible]) { + [self refreshDetails]; + } +} +@end diff --git a/cocoa/DetailsPanelPicture.h b/cocoa/DetailsPanelPicture.h new file mode 100644 index 0000000..ff6b70d --- /dev/null +++ b/cocoa/DetailsPanelPicture.h @@ -0,0 +1,32 @@ +/* +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 +#import "DetailsPanel.h" +#import "PyDupeGuru.h" + +@interface DetailsPanelPicture : DetailsPanel +{ + NSImageView *dupeImage; + NSProgressIndicator *dupeProgressIndicator; + NSImageView *refImage; + NSProgressIndicator *refProgressIndicator; + + PyDupeGuru *pyApp; + BOOL _needsRefresh; + NSString *_dupePath; + NSString *_refPath; +} + +@property (readwrite, retain) NSImageView *dupeImage; +@property (readwrite, retain) NSProgressIndicator *dupeProgressIndicator; +@property (readwrite, retain) NSImageView *refImage; +@property (readwrite, retain) NSProgressIndicator *refProgressIndicator; + +- (id)initWithApp:(PyDupeGuru *)aApp; +@end \ No newline at end of file diff --git a/cocoa/DetailsPanelPicture.m b/cocoa/DetailsPanelPicture.m new file mode 100644 index 0000000..c8287a6 --- /dev/null +++ b/cocoa/DetailsPanelPicture.m @@ -0,0 +1,96 @@ +/* +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 "Utils.h" +#import "NSNotificationAdditions.h" +#import "NSImageAdditions.h" +#import "PyDupeGuru.h" +#import "DetailsPanelPicture.h" +#import "Consts.h" +#import "DetailsPanelPicture_UI.h" + +@implementation DetailsPanelPicture + +@synthesize dupeImage; +@synthesize dupeProgressIndicator; +@synthesize refImage; +@synthesize refProgressIndicator; + +- (id)initWithApp:(PyDupeGuru *)aApp +{ + self = [super initWithPyRef:[aApp detailsPanel]]; + pyApp = aApp; + _needsRefresh = YES; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(imageLoaded:) name:ImageLoadedNotification object:self]; + return self; +} + +- (NSWindow *)createWindow +{ + return createDetailsPanelPicture_UI(self); +} + +- (void)loadImageAsync:(NSString *)imagePath +{ + NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; + NSImage *image = [[NSImage alloc] initByReferencingFile:imagePath]; + NSImage *thumbnail = [image imageByScalingProportionallyToSize:NSMakeSize(512,512)]; + [image release]; + NSMutableDictionary *params = [NSMutableDictionary dictionary]; + [params setValue:imagePath forKey:@"imagePath"]; + [params setValue:thumbnail forKey:@"image"]; + [[NSNotificationCenter defaultCenter] postNotificationOnMainThreadWithName:ImageLoadedNotification object:self userInfo:params waitUntilDone:YES]; + [pool release]; +} + +- (void)refreshDetails +{ + if (!_needsRefresh) + return; + [detailsTable reloadData]; + + NSString *refPath = [pyApp getSelectedDupeRefPath]; + if (_refPath != nil) + [_refPath autorelease]; + _refPath = [refPath retain]; + [NSThread detachNewThreadSelector:@selector(loadImageAsync:) toTarget:self withObject:refPath]; + NSString *dupePath = [pyApp getSelectedDupePath]; + if (_dupePath != nil) + [_dupePath autorelease]; + _dupePath = [dupePath retain]; + if (![dupePath isEqual: refPath]) + [NSThread detachNewThreadSelector:@selector(loadImageAsync:) toTarget:self withObject:dupePath]; + [refProgressIndicator startAnimation:nil]; + [dupeProgressIndicator startAnimation:nil]; + _needsRefresh = NO; +} + +/* Notifications */ +- (void)imageLoaded:(NSNotification *)aNotification +{ + NSString *imagePath = [[aNotification userInfo] valueForKey:@"imagePath"]; + NSImage *image = [[aNotification userInfo] valueForKey:@"image"]; + if ([imagePath isEqual: _refPath]) + { + [refImage setImage:image]; + [refProgressIndicator stopAnimation:nil]; + } + if ([imagePath isEqual: _dupePath]) + { + [dupeImage setImage:image]; + [dupeProgressIndicator stopAnimation:nil]; + } +} + +/* Python --> Cocoa */ +- (void)refresh +{ + _needsRefresh = YES; + [super refresh]; +} +@end diff --git a/cocoa/DirectoryOutline.h b/cocoa/DirectoryOutline.h new file mode 100644 index 0000000..9132b82 --- /dev/null +++ b/cocoa/DirectoryOutline.h @@ -0,0 +1,21 @@ +/* +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 +#import +#import "HSOutline.h" +#import "PyDirectoryOutline.h" + +#define DGAddedFoldersNotification @"DGAddedFoldersNotification" + +@interface DirectoryOutline : HSOutline {} +- (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView; +- (PyDirectoryOutline *)model; + +- (void)selectAll; +@end; \ No newline at end of file diff --git a/cocoa/DirectoryOutline.m b/cocoa/DirectoryOutline.m new file mode 100644 index 0000000..aa63357 --- /dev/null +++ b/cocoa/DirectoryOutline.m @@ -0,0 +1,87 @@ +/* +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 "DirectoryOutline.h" + +@implementation DirectoryOutline +- (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView +{ + self = [super initWithPyRef:aPyRef wrapperClass:[PyDirectoryOutline class] + callbackClassName:@"DirectoryOutlineView" view:aOutlineView]; + [[self view] registerForDraggedTypes:[NSArray arrayWithObject:NSFilenamesPboardType]]; + return self; +} + +- (PyDirectoryOutline *)model +{ + return (PyDirectoryOutline *)model; +} + +/* Public */ +- (void)selectAll +{ + [[self model] selectAll]; +} + +/* Delegate */ +- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id < NSDraggingInfo >)info proposedItem:(id)item proposedChildIndex:(NSInteger)index +{ + NSPasteboard *pboard; + NSDragOperation sourceDragMask; + sourceDragMask = [info draggingSourceOperationMask]; + pboard = [info draggingPasteboard]; + if ([[pboard types] containsObject:NSFilenamesPboardType]) { + if (sourceDragMask & NSDragOperationLink) + return NSDragOperationLink; + } + return NSDragOperationNone; +} + +- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id < NSDraggingInfo >)info item:(id)item childIndex:(NSInteger)index +{ + NSPasteboard *pboard; + NSDragOperation sourceDragMask; + sourceDragMask = [info draggingSourceOperationMask]; + pboard = [info draggingPasteboard]; + if ([[pboard types] containsObject:NSFilenamesPboardType]) { + NSArray *foldernames = [pboard propertyListForType:NSFilenamesPboardType]; + if (!(sourceDragMask & NSDragOperationLink)) + return NO; + for (NSString *foldername in foldernames) { + [[self model] addDirectory:foldername]; + } + NSDictionary *userInfo = [NSDictionary dictionaryWithObject:foldernames forKey:@"foldernames"]; + [[NSNotificationCenter defaultCenter] postNotificationName:DGAddedFoldersNotification + object:self userInfo:userInfo]; + } + return YES; +} + +- (void)outlineView:(NSOutlineView *)aOutlineView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)tableColumn item:(id)item +{ + if ([cell isKindOfClass:[NSTextFieldCell class]]) { + NSTextFieldCell *textCell = cell; + NSIndexPath *path = item; + BOOL selected = [path isEqualTo:[[self view] selectedPath]]; + if (selected) { + [textCell setTextColor:[NSColor blackColor]]; + return; + } + NSInteger state = [self intProperty:@"state" valueAtPath:path]; + if (state == 1) { + [textCell setTextColor:[NSColor blueColor]]; + } + else if (state == 2) { + [textCell setTextColor:[NSColor redColor]]; + } + else { + [textCell setTextColor:[NSColor blackColor]]; + } + } +} +@end \ No newline at end of file diff --git a/cocoa/DirectoryPanel.h b/cocoa/DirectoryPanel.h new file mode 100644 index 0000000..b475f2a --- /dev/null +++ b/cocoa/DirectoryPanel.h @@ -0,0 +1,57 @@ +/* +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 +#import "HSOutlineView.h" +#import "HSRecentFiles.h" +#import "DirectoryOutline.h" +#import "PyDupeGuru.h" + +@class AppDelegate; + +@interface DirectoryPanel : NSWindowController +{ + AppDelegate *_app; + PyDupeGuru *model; + HSRecentFiles *_recentDirectories; + DirectoryOutline *outline; + BOOL _alwaysShowPopUp; + NSSegmentedControl *appModeSelector; + NSPopUpButton *scanTypePopup; + NSPopUpButton *addButtonPopUp; + NSPopUpButton *loadRecentButtonPopUp; + HSOutlineView *outlineView; + NSButton *removeButton; + NSButton *loadResultsButton; +} + +@property (readwrite, retain) NSSegmentedControl *appModeSelector; +@property (readwrite, retain) NSPopUpButton *scanTypePopup; +@property (readwrite, retain) NSPopUpButton *addButtonPopUp; +@property (readwrite, retain) NSPopUpButton *loadRecentButtonPopUp; +@property (readwrite, retain) HSOutlineView *outlineView; +@property (readwrite, retain) NSButton *removeButton; +@property (readwrite, retain) NSButton *loadResultsButton; + +- (id)initWithParentApp:(AppDelegate *)aParentApp; + +- (void)fillPopUpMenu; +- (void)fillScanTypeMenu; +- (void)adjustUIToLocalization; + +- (void)askForDirectory; +- (void)popupAddDirectoryMenu:(id)sender; +- (void)popupLoadRecentMenu:(id)sender; +- (void)removeSelectedDirectory; +- (void)startDuplicateScan; + +- (void)addDirectory:(NSString *)directory; +- (void)refreshRemoveButtonText; +- (void)markAll; + +@end diff --git a/cocoa/DirectoryPanel.m b/cocoa/DirectoryPanel.m new file mode 100644 index 0000000..731b11b --- /dev/null +++ b/cocoa/DirectoryPanel.m @@ -0,0 +1,256 @@ +/* +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 "DirectoryPanel.h" +#import "DirectoryPanel_UI.h" +#import "Dialogs.h" +#import "Utils.h" +#import "AppDelegate.h" +#import "Consts.h" + +@implementation DirectoryPanel + +@synthesize appModeSelector; +@synthesize scanTypePopup; +@synthesize addButtonPopUp; +@synthesize loadRecentButtonPopUp; +@synthesize outlineView; +@synthesize removeButton; +@synthesize loadResultsButton; + +- (id)initWithParentApp:(AppDelegate *)aParentApp +{ + self = [super initWithWindow:nil]; + [self setWindow:createDirectoryPanel_UI(self)]; + _app = aParentApp; + model = [_app model]; + [[self window] setTitle:[model appName]]; + self.appModeSelector.selectedSegment = 0; + [self fillScanTypeMenu]; + _alwaysShowPopUp = NO; + [self fillPopUpMenu]; + _recentDirectories = [[HSRecentFiles alloc] initWithName:@"recentDirectories" menu:[addButtonPopUp menu]]; + [_recentDirectories setDelegate:self]; + outline = [[DirectoryOutline alloc] initWithPyRef:[model directoryTree] outlineView:outlineView]; + [self refreshRemoveButtonText]; + [self adjustUIToLocalization]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(directorySelectionChanged:) + name:NSOutlineViewSelectionDidChangeNotification object:outlineView]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(outlineAddedFolders:) + name:DGAddedFoldersNotification object:outline]; + return self; +} + +- (void)dealloc +{ + [outline release]; + [_recentDirectories release]; + [super dealloc]; +} + +/* Private */ + +- (void)fillPopUpMenu +{ + NSMenu *m = [addButtonPopUp menu]; + NSMenuItem *mi = [m addItemWithTitle:NSLocalizedString(@"Add New Folder...", @"") action:@selector(askForDirectory) keyEquivalent:@""]; + [mi setTarget:self]; + [m addItem:[NSMenuItem separatorItem]]; +} + +- (void)fillScanTypeMenu +{ + [[self scanTypePopup] unbind:@"selectedIndex"]; + [[self scanTypePopup] removeAllItems]; + [[self scanTypePopup] addItemsWithTitles:[[_app model] getScanOptions]]; + NSString *keypath; + NSInteger appMode = [_app getAppMode]; + if (appMode == AppModePicture) { + keypath = @"values.scanTypePicture"; + } + else if (appMode == AppModeMusic) { + keypath = @"values.scanTypeMusic"; + } + else { + keypath = @"values.scanTypeStandard"; + } + [[self scanTypePopup] bind:@"selectedIndex" toObject:[NSUserDefaultsController sharedUserDefaultsController] withKeyPath:keypath options:nil]; +} + +- (void)adjustUIToLocalization +{ + NSString *lang = [[NSBundle preferredLocalizationsFromArray:[[NSBundle mainBundle] localizations]] objectAtIndex:0]; + NSInteger loadResultsWidthDelta = 0; + if ([lang isEqual:@"ru"]) { + loadResultsWidthDelta = 50; + } + else if ([lang isEqual:@"uk"]) { + loadResultsWidthDelta = 70; + } + else if ([lang isEqual:@"hy"]) { + loadResultsWidthDelta = 30; + } + if (loadResultsWidthDelta) { + NSRect r = [loadResultsButton frame]; + r.size.width += loadResultsWidthDelta; + r.origin.x -= loadResultsWidthDelta; + [loadResultsButton setFrame:r]; + } +} + +/* Actions */ + +- (void)askForDirectory +{ + NSOpenPanel *op = [NSOpenPanel openPanel]; + [op setCanChooseFiles:YES]; + [op setCanChooseDirectories:YES]; + [op setAllowsMultipleSelection:YES]; + [op setTitle:NSLocalizedString(@"Select a folder to add to the scanning list", @"")]; + [op setDelegate:self]; + if ([op runModal] == NSOKButton) { + for (NSURL *directoryURL in [op URLs]) { + [self addDirectory:[directoryURL path]]; + } + } +} + +- (void)changeAppMode:(id)sender +{ + NSInteger appMode; + NSUInteger selectedSegment = self.appModeSelector.selectedSegment; + if (selectedSegment == 2) { + appMode = AppModePicture; + } + else if (selectedSegment == 1) { + appMode = AppModeMusic; + } + else { + appMode = AppModeStandard; + } + [_app setAppMode:appMode]; + [self fillScanTypeMenu]; +} + +- (void)popupAddDirectoryMenu:(id)sender +{ + if ((!_alwaysShowPopUp) && ([[_recentDirectories filepaths] count] == 0)) { + [self askForDirectory]; + } + else { + [addButtonPopUp selectItem:nil]; + [[addButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]]; + } +} + +- (void)popupLoadRecentMenu:(id)sender +{ + if ([[[_app recentResults] filepaths] count] > 0) { + NSMenu *m = [loadRecentButtonPopUp menu]; + while ([m numberOfItems] > 0) { + [m removeItemAtIndex:0]; + } + NSMenuItem *mi = [m addItemWithTitle:NSLocalizedString(@"Load from file...", @"") action:@selector(loadResults) keyEquivalent:@""]; + [mi setTarget:_app]; + [m addItem:[NSMenuItem separatorItem]]; + [[_app recentResults] fillMenu:m]; + [loadRecentButtonPopUp selectItem:nil]; + [[loadRecentButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]]; + } + else { + [_app loadResults]; + } +} + +- (void)removeSelectedDirectory +{ + [[self window] makeKeyAndOrderFront:nil]; + [[outline model] removeSelectedDirectory]; + [self refreshRemoveButtonText]; +} + +- (void)startDuplicateScan +{ + if ([model resultsAreModified]) { + if ([Dialogs askYesNo:NSLocalizedString(@"You have unsaved results, do you really want to continue?", @"")] == NSAlertSecondButtonReturn) // NO + return; + } + [_app setScanOptions]; + [model doScan]; +} + +/* Public */ +- (void)addDirectory:(NSString *)directory +{ + [model addDirectory:directory]; + [_recentDirectories addFile:directory]; + [[self window] makeKeyAndOrderFront:nil]; +} + +- (void)refreshRemoveButtonText +{ + if ([outlineView selectedRow] < 0) { + [removeButton setEnabled:NO]; + return; + } + [removeButton setEnabled:YES]; + NSIndexPath *path = [outline selectedIndexPath]; + if (path != nil) { + NSInteger state = [outline intProperty:@"state" valueAtPath:path]; + BOOL shouldDisplayArrow = ([path length] > 1) && (state == 2); + NSString *imgName = shouldDisplayArrow ? @"NSGoLeftTemplate" : @"NSRemoveTemplate"; + [removeButton setImage:[NSImage imageNamed:imgName]]; + } +} + +- (void)markAll +{ + /* markAll isn't very descriptive of what we do, but since we re-use the Mark All button from + the result window, we don't have much choice. + */ + [outline selectAll]; +} + +/* Delegate */ +- (BOOL)panel:(id)sender shouldShowFilename:(NSString *)path +{ + BOOL isdir; + [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isdir]; + return isdir; +} + +- (void)recentFileClicked:(NSString *)path +{ + [self addDirectory:path]; +} + +- (BOOL)validateMenuItem:(NSMenuItem *)item +{ + if ([item action] == @selector(markAll)) { + [item setTitle:NSLocalizedString(@"Select All", @"")]; + } + return YES; +} + +/* Notifications */ + +- (void)directorySelectionChanged:(NSNotification *)aNotification +{ + [self refreshRemoveButtonText]; +} + +- (void)outlineAddedFolders:(NSNotification *)aNotification +{ + NSArray *foldernames = [[aNotification userInfo] objectForKey:@"foldernames"]; + for (NSString *foldername in foldernames) { + [_recentDirectories addFile:foldername]; + } +} + +@end diff --git a/cocoa/IgnoreListDialog.h b/cocoa/IgnoreListDialog.h new file mode 100644 index 0000000..a392ec9 --- /dev/null +++ b/cocoa/IgnoreListDialog.h @@ -0,0 +1,25 @@ +/* +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 +#import "PyIgnoreListDialog.h" +#import "HSTable.h" + +@interface IgnoreListDialog : NSWindowController +{ + PyIgnoreListDialog *model; + HSTable *ignoreListTable; + NSTableView *ignoreListTableView; +} + +@property (readwrite, retain) PyIgnoreListDialog *model; +@property (readwrite, retain) NSTableView *ignoreListTableView; + +- (id)initWithPyRef:(PyObject *)aPyRef; +- (void)initializeColumns; +@end \ No newline at end of file diff --git a/cocoa/IgnoreListDialog.m b/cocoa/IgnoreListDialog.m new file mode 100644 index 0000000..3967b50 --- /dev/null +++ b/cocoa/IgnoreListDialog.m @@ -0,0 +1,51 @@ +/* +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 "IgnoreListDialog.h" +#import "IgnoreListDialog_UI.h" +#import "HSPyUtil.h" + +@implementation IgnoreListDialog + +@synthesize model; +@synthesize ignoreListTableView; + +- (id)initWithPyRef:(PyObject *)aPyRef +{ + self = [super initWithWindow:nil]; + self.model = [[[PyIgnoreListDialog alloc] initWithModel:aPyRef] autorelease]; + [self.model bindCallback:createCallback(@"IgnoreListDialogView", self)]; + [self setWindow:createIgnoreListDialog_UI(self)]; + ignoreListTable = [[HSTable alloc] initWithPyRef:[model ignoreListTable] tableView:ignoreListTableView]; + [self initializeColumns]; + return self; +} + +- (void)dealloc +{ + [ignoreListTable release]; + [super dealloc]; +} + +- (void)initializeColumns +{ + HSColumnDef defs[] = { + {@"path1", 240, 40, 0, NO, nil}, + {@"path2", 240, 40, 0, NO, nil}, + nil + }; + [[ignoreListTable columns] initializeColumns:defs]; + [[ignoreListTable columns] setColumnsAsReadOnly]; +} + +/* model --> view */ +- (void)show +{ + [self showWindow:self]; +} +@end \ No newline at end of file diff --git a/cocoa/InfoTemplate.plist b/cocoa/InfoTemplate.plist new file mode 100644 index 0000000..d9243aa --- /dev/null +++ b/cocoa/InfoTemplate.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + dupeGuru + CFBundleHelpBookFolder + dupeguru_help + CFBundleHelpBookName + dupeGuru Help + CFBundleIconFile + dupeguru + CFBundleIdentifier + com.hardcoded-software.dupeguru + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + dupeGuru + CFBundlePackageType + APPL + CFBundleSignature + hsft + CFBundleShortVersionString + {version} + CFBundleVersion + {version} + NSPrincipalClass + NSApplication + NSHumanReadableCopyright + © Hardcoded Software, 2016 + SUFeedURL + https://www.hardcoded.net/updates/dupeguru.appcast + SUPublicDSAKeyFile + dsa_pub.pem + + diff --git a/cocoa/PrioritizeDialog.h b/cocoa/PrioritizeDialog.h new file mode 100644 index 0000000..35f030c --- /dev/null +++ b/cocoa/PrioritizeDialog.h @@ -0,0 +1,37 @@ +/* +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 +#import "PyPrioritizeDialog.h" +#import "HSPopUpList.h" +#import "HSSelectableList.h" +#import "PrioritizeList.h" +#import "PyDupeGuru.h" + +@interface PrioritizeDialog : NSWindowController +{ + NSPopUpButton *categoryPopUpView; + NSTableView *criteriaTableView; + NSTableView *prioritizationTableView; + + PyPrioritizeDialog *model; + HSPopUpList *categoryPopUp; + HSSelectableList *criteriaList; + PrioritizeList *prioritizationList; +} + +@property (readwrite, retain) NSPopUpButton *categoryPopUpView; +@property (readwrite, retain) NSTableView *criteriaTableView; +@property (readwrite, retain) NSTableView *prioritizationTableView; + +- (id)initWithApp:(PyDupeGuru *)aApp; +- (PyPrioritizeDialog *)model; + +- (void)ok; +- (void)cancel; +@end; \ No newline at end of file diff --git a/cocoa/PrioritizeDialog.m b/cocoa/PrioritizeDialog.m new file mode 100644 index 0000000..9741951 --- /dev/null +++ b/cocoa/PrioritizeDialog.m @@ -0,0 +1,56 @@ +/* +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 "PrioritizeDialog.h" +#import "PrioritizeDialog_UI.h" +#import "HSPyUtil.h" + +@implementation PrioritizeDialog + +@synthesize categoryPopUpView; +@synthesize criteriaTableView; +@synthesize prioritizationTableView; + +- (id)initWithApp:(PyDupeGuru *)aApp +{ + self = [super initWithWindowNibName:@"PrioritizeDialog"]; + model = [[PyPrioritizeDialog alloc] initWithApp:[aApp pyRef]]; + [self setWindow:createPrioritizeDialog_UI(self)]; + categoryPopUp = [[HSPopUpList alloc] initWithPyRef:[[self model] categoryList] popupView:categoryPopUpView]; + criteriaList = [[HSSelectableList alloc] initWithPyRef:[[self model] criteriaList] tableView:criteriaTableView]; + prioritizationList = [[PrioritizeList alloc] initWithPyRef:[[self model] prioritizationList] tableView:prioritizationTableView]; + [model bindCallback:createCallback(@"PrioritizeDialogView", self)]; + return self; +} + +- (void)dealloc +{ + [categoryPopUp release]; + [criteriaList release]; + [prioritizationList release]; + [model release]; + [super dealloc]; +} + +- (PyPrioritizeDialog *)model +{ + return (PyPrioritizeDialog *)model; +} + +- (void)ok +{ + [NSApp stopModal]; + [self close]; +} + +- (void)cancel +{ + [NSApp abortModal]; + [self close]; +} +@end \ No newline at end of file diff --git a/cocoa/PrioritizeList.h b/cocoa/PrioritizeList.h new file mode 100644 index 0000000..a7b3f44 --- /dev/null +++ b/cocoa/PrioritizeList.h @@ -0,0 +1,16 @@ +/* +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 +#import "HSSelectableList.h" +#import "PyPrioritizeList.h" + +@interface PrioritizeList : HSSelectableList {} +- (id)initWithPyRef:(PyObject *)aPyRef tableView:(NSTableView *)aTableView; +- (PyPrioritizeList *)model; +@end \ No newline at end of file diff --git a/cocoa/PrioritizeList.m b/cocoa/PrioritizeList.m new file mode 100644 index 0000000..098e073 --- /dev/null +++ b/cocoa/PrioritizeList.m @@ -0,0 +1,58 @@ +/* +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 "PrioritizeList.h" +#import "Utils.h" +#import "Consts.h" + +@implementation PrioritizeList +- (id)initWithPyRef:(PyObject *)aPyRef tableView:(NSTableView *)aTableView +{ + self = [super initWithPyRef:aPyRef wrapperClass:[PyPrioritizeList class] + callbackClassName:@"PrioritizeListView" view:aTableView]; + return self; +} + +- (PyPrioritizeList *)model +{ + return (PyPrioritizeList *)model; +} + +- (void)setView:(NSTableView *)aTableView +{ + [super setView:aTableView]; + [[self view] registerForDraggedTypes:[NSArray arrayWithObject:DGPrioritizeIndexPasteboardType]]; +} + +- (BOOL)tableView:(NSTableView *)tv writeRowsWithIndexes:(NSIndexSet *)rowIndexes toPasteboard:(NSPasteboard*)pboard +{ + NSData *data = [NSKeyedArchiver archivedDataWithRootObject:rowIndexes]; + [pboard declareTypes:[NSArray arrayWithObject:DGPrioritizeIndexPasteboardType] owner:self]; + [pboard setData:data forType:DGPrioritizeIndexPasteboardType]; + return YES; +} + +- (NSDragOperation)tableView:(NSTableView*)tv validateDrop:(id )info proposedRow:(NSInteger)row + proposedDropOperation:(NSTableViewDropOperation)op +{ + if (op == NSTableViewDropAbove) { + return NSDragOperationMove; + } + return NSDragOperationNone; +} + +- (BOOL)tableView:(NSTableView *)aTableView acceptDrop:(id )info + row:(NSInteger)row dropOperation:(NSTableViewDropOperation)operation +{ + NSPasteboard* pboard = [info draggingPasteboard]; + NSData* rowData = [pboard dataForType:DGPrioritizeIndexPasteboardType]; + NSIndexSet* rowIndexes = [NSKeyedUnarchiver unarchiveObjectWithData:rowData]; + [[self model] moveIndexes:[Utils indexSet2Array:rowIndexes] toIndex:row]; + return YES; +} +@end \ No newline at end of file diff --git a/cocoa/ProblemDialog.h b/cocoa/ProblemDialog.h new file mode 100644 index 0000000..309f278 --- /dev/null +++ b/cocoa/ProblemDialog.h @@ -0,0 +1,26 @@ +/* +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 +#import "PyProblemDialog.h" +#import "HSTable.h" + +@interface ProblemDialog : NSWindowController +{ + PyProblemDialog *model; + HSTable *problemTable; + NSTableView *problemTableView; +} + +@property (readwrite, retain) PyProblemDialog *model; +@property (readwrite, retain) NSTableView *problemTableView; + +- (id)initWithPyRef:(PyObject *)aPyRef; + +- (void)initializeColumns; +@end \ No newline at end of file diff --git a/cocoa/ProblemDialog.m b/cocoa/ProblemDialog.m new file mode 100644 index 0000000..2619aa7 --- /dev/null +++ b/cocoa/ProblemDialog.m @@ -0,0 +1,44 @@ +/* +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 "ProblemDialog.h" +#import "ProblemDialog_UI.h" +#import "Utils.h" + +@implementation ProblemDialog + +@synthesize model; +@synthesize problemTableView; + +- (id)initWithPyRef:(PyObject *)aPyRef +{ + self = [super initWithWindow:nil]; + self.model = [[PyProblemDialog alloc] initWithModel:aPyRef]; + [self setWindow:createProblemDialog_UI(self)]; + problemTable = [[HSTable alloc] initWithPyRef:[self.model problemTable] tableView:problemTableView]; + [self initializeColumns]; + return self; +} + +- (void)dealloc +{ + [problemTable release]; + [super dealloc]; +} + +- (void)initializeColumns +{ + HSColumnDef defs[] = { + {@"path", 202, 40, 0, NO, nil}, + {@"msg", 228, 40, 0, NO, nil}, + nil + }; + [[problemTable columns] initializeColumns:defs]; + [[problemTable columns] setColumnsAsReadOnly]; +} +@end \ No newline at end of file diff --git a/cocoa/ResultTable.h b/cocoa/ResultTable.h new file mode 100644 index 0000000..713f9dc --- /dev/null +++ b/cocoa/ResultTable.h @@ -0,0 +1,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 +*/ + +#import +#import +#import "HSTable.h" +#import "PyResultTable.h" + +@interface ResultTable : HSTable +{ +} +- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTableView *)aTableView; +- (PyResultTable *)model; +- (BOOL)powerMarkerMode; +- (void)setPowerMarkerMode:(BOOL)aPowerMarkerMode; +- (BOOL)deltaValuesMode; +- (void)setDeltaValuesMode:(BOOL)aDeltaValuesMode; +@end; \ No newline at end of file diff --git a/cocoa/ResultTable.m b/cocoa/ResultTable.m new file mode 100644 index 0000000..82d20f6 --- /dev/null +++ b/cocoa/ResultTable.m @@ -0,0 +1,180 @@ +/* +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 "ResultTable.h" +#import "Dialogs.h" +#import "Utils.h" +#import "HSQuicklook.h" + +@interface HSTable (private) +- (void)setPySelection; +- (void)setViewSelection; +@end + +@implementation ResultTable +- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTableView *)aTableView +{ + self = [super initWithPyRef:aPyRef wrapperClass:[PyResultTable class] callbackClassName:@"ResultTableView" view:aTableView]; + return self; +} + +- (PyResultTable *)model +{ + return (PyResultTable *)model; +} + +/* Private */ +- (void)updateQuicklookIfNeeded +{ + if ([[QLPreviewPanel sharedPreviewPanel] dataSource] == self) { + [[QLPreviewPanel sharedPreviewPanel] reloadData]; + } +} + +- (void)setPySelection +{ + [super setPySelection]; + [self updateQuicklookIfNeeded]; +} + +- (void)setViewSelection +{ + [super setViewSelection]; + [self updateQuicklookIfNeeded]; +} + +/* Public */ +- (BOOL)powerMarkerMode +{ + return [[self model] powerMarkerMode]; +} + +- (void)setPowerMarkerMode:(BOOL)aPowerMarkerMode +{ + [[self model] setPowerMarkerMode:aPowerMarkerMode]; +} + +- (BOOL)deltaValuesMode +{ + return [[self model] deltaValuesMode]; +} + +- (void)setDeltaValuesMode:(BOOL)aDeltaValuesMode +{ + [[self model] setDeltaValuesMode:aDeltaValuesMode]; +} + +/* Datasource */ +- (id)tableView:(NSTableView *)aTableView objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)row +{ + NSString *identifier = [column identifier]; + if ([identifier isEqual:@"marked"]) { + return [[self model] valueForColumn:@"marked" row:row]; + } + return [[self model] valueForRow:row column:identifier]; +} + +- (void)tableView:(NSTableView *)aTableView setObjectValue:(id)object forTableColumn:(NSTableColumn *)column row:(NSInteger)row +{ + NSString *identifier = [column identifier]; + if ([identifier isEqual:@"marked"]) { + [[self model] setValue:object forColumn:identifier row:row]; + } + else if ([identifier isEqual:@"name"]) { + NSString *oldName = [[self model] valueForRow:row column:identifier]; + NSString *newName = object; + if (![newName isEqual:oldName]) { + BOOL renamed = [[self model] renameSelected:newName]; + if (!renamed) { + [Dialogs showMessage:[NSString stringWithFormat:NSLocalizedString(@"The name '%@' already exists.", @""), newName]]; + } + else { + [[self view] setNeedsDisplay:YES]; + } + } + } +} + +/* Delegate */ +- (void)tableView:(NSTableView *)aTableView didClickTableColumn:(NSTableColumn *)tableColumn +{ + if ([[[self view] sortDescriptors] count] < 1) + return; + NSSortDescriptor *sd = [[[self view] sortDescriptors] objectAtIndex:0]; + [[self model] sortBy:[sd key] ascending:[sd ascending]]; +} + +- (void)tableView:(NSTableView *)aTableView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)column row:(NSInteger)row +{ + BOOL isSelected = [[self view] isRowSelected:row]; + BOOL isMarkable = n2b([[self model] valueForColumn:@"markable" row:row]); + if ([[column identifier] isEqual:@"marked"]) { + [cell setEnabled:isMarkable]; + // Low-tech solution, for indentation, but it works... + NSCellImagePosition pos = isMarkable ? NSImageRight : NSImageLeft; + [cell setImagePosition:pos]; + } + if ([cell isKindOfClass:[NSTextFieldCell class]]) { + NSColor *color = [NSColor textColor]; + if (isSelected) { + color = [NSColor selectedTextColor]; + } + else if (isMarkable) { + if ([[self model] isDeltaAtRow:row column:[column identifier]]) { + color = [NSColor orangeColor]; + } + } + else { + color = [NSColor blueColor]; + } + [(NSTextFieldCell *)cell setTextColor:color]; + } +} + +- (BOOL)tableViewHadDeletePressed:(NSTableView *)tableView +{ + [[self model] removeSelected]; + return YES; +} + +- (BOOL)tableViewHadSpacePressed:(NSTableView *)tableView +{ + [[self model] markSelected]; + return YES; +} + +/* Quicklook */ +- (NSInteger)numberOfPreviewItemsInPreviewPanel:(QLPreviewPanel *)panel +{ + return [[[self model] selectedRows] count]; +} + +- (id )previewPanel:(QLPreviewPanel *)panel previewItemAtIndex:(NSInteger)index +{ + NSArray *selectedRows = [[self model] selectedRows]; + NSInteger absIndex = n2i([selectedRows objectAtIndex:index]); + NSString *path = [[self model] pathAtIndex:absIndex]; + return [[HSQLPreviewItem alloc] initWithUrl:[NSURL fileURLWithPath:path] title:path]; +} + +- (BOOL)previewPanel:(QLPreviewPanel *)panel handleEvent:(NSEvent *)event +{ + // redirect all key down events to the table view + if ([event type] == NSKeyDown) { + [[self view] keyDown:event]; + return YES; + } + return NO; +} + +/* Python --> Cocoa */ +- (void)invalidateMarkings +{ + [[self view] setNeedsDisplay:YES]; +} +@end \ No newline at end of file diff --git a/cocoa/ResultWindow.h b/cocoa/ResultWindow.h new file mode 100644 index 0000000..509b042 --- /dev/null +++ b/cocoa/ResultWindow.h @@ -0,0 +1,76 @@ +/* +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 +#import +#import "StatsLabel.h" +#import "ResultTable.h" +#import "HSTableView.h" +#import "PyDupeGuru.h" + +@class AppDelegate; + +@interface ResultWindow : NSWindowController +{ +@protected + NSSegmentedControl *optionsSwitch; + NSToolbarItem *optionsToolbarItem; + HSTableView *matches; + NSTextField *stats; + NSSearchField *filterField; + + AppDelegate *app; + PyDupeGuru *model; + ResultTable *table; + StatsLabel *statsLabel; + QLPreviewPanel* previewPanel; +} + +@property (readwrite, retain) NSSegmentedControl *optionsSwitch; +@property (readwrite, retain) NSToolbarItem *optionsToolbarItem; +@property (readwrite, retain) HSTableView *matches; +@property (readwrite, retain) NSTextField *stats; +@property (readwrite, retain) NSSearchField *filterField; + +- (id)initWithParentApp:(AppDelegate *)app; + +/* Helpers */ +- (void)fillColumnsMenu; +- (void)updateOptionSegments; +- (void)adjustUIToLocalization; +- (void)initResultColumns:(ResultTable *)aTable; + +/* Actions */ +- (void)changeOptions; +- (void)copyMarked; +- (void)trashMarked; +- (void)filter; +- (void)focusOnFilterField; +- (void)ignoreSelected; +- (void)invokeCustomCommand; +- (void)markAll; +- (void)markInvert; +- (void)markNone; +- (void)markSelected; +- (void)moveMarked; +- (void)openClicked; +- (void)openSelected; +- (void)removeMarked; +- (void)removeSelected; +- (void)renameSelected; +- (void)reprioritizeResults; +- (void)resetColumnsToDefault; +- (void)revealSelected; +- (void)saveResults; +- (void)switchSelected; +- (void)toggleColumn:(id)sender; +- (void)toggleDelta; +- (void)toggleDetailsPanel; +- (void)togglePowerMarker; +- (void)toggleQuicklookPanel; +@end diff --git a/cocoa/ResultWindow.m b/cocoa/ResultWindow.m new file mode 100644 index 0000000..f0cc860 --- /dev/null +++ b/cocoa/ResultWindow.m @@ -0,0 +1,406 @@ +/* +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 "ResultWindow.h" +#import "ResultWindow_UI.h" +#import "Dialogs.h" +#import "ProgressController.h" +#import "Utils.h" +#import "AppDelegate.h" +#import "Consts.h" +#import "PrioritizeDialog.h" + +@implementation ResultWindow + +@synthesize optionsSwitch; +@synthesize optionsToolbarItem; +@synthesize matches; +@synthesize stats; +@synthesize filterField; + +- (id)initWithParentApp:(AppDelegate *)aApp; +{ + self = [super initWithWindow:nil]; + app = aApp; + model = [app model]; + [self setWindow:createResultWindow_UI(self)]; + [[self window] setTitle:fmt(NSLocalizedString(@"%@ Results", @""), [model appName])]; + /* Put a cute iTunes-like bottom bar */ + [[self window] setContentBorderThickness:28 forEdge:NSMinYEdge]; + table = [[ResultTable alloc] initWithPyRef:[model resultTable] view:matches]; + statsLabel = [[StatsLabel alloc] initWithPyRef:[model statsLabel] view:stats]; + [self initResultColumns:table]; + [[table columns] setColumnsAsReadOnly]; + [self fillColumnsMenu]; + [matches setTarget:self]; + [matches setDoubleAction:@selector(openClicked)]; + [self adjustUIToLocalization]; + return self; +} + +- (void)dealloc +{ + [table release]; + [statsLabel release]; + [super dealloc]; +} + +/* Helpers */ +- (void)fillColumnsMenu +{ + [[app columnsMenu] removeAllItems]; + NSArray *menuItems = [[[table columns] model] menuItems]; + for (NSInteger i=0; i < [menuItems count]; i++) { + NSArray *pair = [menuItems objectAtIndex:i]; + NSString *display = [pair objectAtIndex:0]; + BOOL marked = n2b([pair objectAtIndex:1]); + NSMenuItem *mi = [[app columnsMenu] addItemWithTitle:display action:@selector(toggleColumn:) keyEquivalent:@""]; + [mi setTarget:self]; + [mi setState:marked ? NSOnState : NSOffState]; + [mi setTag:i]; + } + [[app columnsMenu] addItem:[NSMenuItem separatorItem]]; + NSMenuItem *mi = [[app columnsMenu] addItemWithTitle:NSLocalizedString(@"Reset to Default", @"") + action:@selector(resetColumnsToDefault) keyEquivalent:@""]; + [mi setTarget:self]; +} + +- (void)updateOptionSegments +{ + [optionsSwitch setSelected:[[app detailsPanel] isVisible] forSegment:0]; + [optionsSwitch setSelected:[table powerMarkerMode] forSegment:1]; + [optionsSwitch setSelected:[table deltaValuesMode] forSegment:2]; +} + +- (void)adjustUIToLocalization +{ + NSString *lang = [[NSBundle preferredLocalizationsFromArray:[[NSBundle mainBundle] localizations]] objectAtIndex:0]; + NSInteger seg1delta = 0; + NSInteger seg2delta = 0; + if ([lang isEqual:@"ru"]) { + seg2delta = 20; + } + else if ([lang isEqual:@"uk"]) { + seg2delta = 20; + } + else if ([lang isEqual:@"hy"]) { + seg1delta = 20; + } + if (seg1delta || seg2delta) { + [optionsSwitch setWidth:[optionsSwitch widthForSegment:0]+seg1delta forSegment:0]; + [optionsSwitch setWidth:[optionsSwitch widthForSegment:1]+seg2delta forSegment:1]; + NSSize s = [optionsToolbarItem maxSize]; + s.width += seg1delta + seg2delta; + [optionsToolbarItem setMaxSize:s]; + [optionsToolbarItem setMinSize:s]; + } +} + +- (void)initResultColumns:(ResultTable *)aTable +{ + NSInteger appMode = [app getAppMode]; + if (appMode == AppModePicture) { + HSColumnDef defs[] = { + {@"marked", 26, 26, 26, YES, [NSButtonCell class]}, + {@"name", 162, 16, 0, YES, nil}, + {@"folder_path", 142, 16, 0, YES, nil}, + {@"size", 63, 16, 0, YES, nil}, + {@"extension", 40, 16, 0, YES, nil}, + {@"dimensions", 73, 16, 0, YES, nil}, + {@"exif_timestamp", 120, 16, 0, YES, nil}, + {@"mtime", 120, 16, 0, YES, nil}, + {@"percentage", 58, 16, 0, YES, nil}, + {@"dupe_count", 80, 16, 0, YES, nil}, + nil + }; + [[aTable columns] initializeColumns:defs]; + NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"]; + [[c dataCell] setButtonType:NSSwitchButton]; + [[c dataCell] setControlSize:NSSmallControlSize]; + c = [[aTable view] tableColumnWithIdentifier:@"size"]; + [[c dataCell] setAlignment:NSRightTextAlignment]; + } + else if (appMode == AppModeMusic) { + HSColumnDef defs[] = { + {@"marked", 26, 26, 26, YES, [NSButtonCell class]}, + {@"name", 235, 16, 0, YES, nil}, + {@"folder_path", 120, 16, 0, YES, nil}, + {@"size", 63, 16, 0, YES, nil}, + {@"duration", 50, 16, 0, YES, nil}, + {@"bitrate", 50, 16, 0, YES, nil}, + {@"samplerate", 60, 16, 0, YES, nil}, + {@"extension", 40, 16, 0, YES, nil}, + {@"mtime", 120, 16, 0, YES, nil}, + {@"title", 120, 16, 0, YES, nil}, + {@"artist", 120, 16, 0, YES, nil}, + {@"album", 120, 16, 0, YES, nil}, + {@"genre", 80, 16, 0, YES, nil}, + {@"year", 40, 16, 0, YES, nil}, + {@"track", 40, 16, 0, YES, nil}, + {@"comment", 120, 16, 0, YES, nil}, + {@"percentage", 57, 16, 0, YES, nil}, + {@"words", 120, 16, 0, YES, nil}, + {@"dupe_count", 80, 16, 0, YES, nil}, + nil + }; + [[aTable columns] initializeColumns:defs]; + NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"]; + [[c dataCell] setButtonType:NSSwitchButton]; + [[c dataCell] setControlSize:NSSmallControlSize]; + c = [[aTable view] tableColumnWithIdentifier:@"size"]; + [[c dataCell] setAlignment:NSRightTextAlignment]; + c = [[aTable view] tableColumnWithIdentifier:@"duration"]; + [[c dataCell] setAlignment:NSRightTextAlignment]; + c = [[aTable view] tableColumnWithIdentifier:@"bitrate"]; + [[c dataCell] setAlignment:NSRightTextAlignment]; + } + else { + HSColumnDef defs[] = { + {@"marked", 26, 26, 26, YES, [NSButtonCell class]}, + {@"name", 195, 16, 0, YES, nil}, + {@"folder_path", 183, 16, 0, YES, nil}, + {@"size", 63, 16, 0, YES, nil}, + {@"extension", 40, 16, 0, YES, nil}, + {@"mtime", 120, 16, 0, YES, nil}, + {@"percentage", 60, 16, 0, YES, nil}, + {@"words", 120, 16, 0, YES, nil}, + {@"dupe_count", 80, 16, 0, YES, nil}, + nil + }; + [[aTable columns] initializeColumns:defs]; + NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"]; + [[c dataCell] setButtonType:NSSwitchButton]; + [[c dataCell] setControlSize:NSSmallControlSize]; + c = [[aTable view] tableColumnWithIdentifier:@"size"]; + [[c dataCell] setAlignment:NSRightTextAlignment]; + } + [[aTable columns] restoreColumns]; +} + +/* Actions */ +- (void)changeOptions +{ + NSInteger seg = [optionsSwitch selectedSegment]; + if (seg == 0) { + [self toggleDetailsPanel]; + } + else if (seg == 1) { + [self togglePowerMarker]; + } + else if (seg == 2) { + [self toggleDelta]; + } +} + +- (void)copyMarked +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])]; + [model setCopyMoveDestType:n2i([ud objectForKey:@"recreatePathType"])]; + [model copyMarked]; +} + +- (void)trashMarked +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])]; + [model deleteMarked]; +} + +- (void)filter +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [model setEscapeFilterRegexp:!n2b([ud objectForKey:@"useRegexpFilter"])]; + [model applyFilter:[filterField stringValue]]; +} + +- (void)focusOnFilterField +{ + [[self window] makeFirstResponder:filterField]; +} + +- (void)ignoreSelected +{ + [model addSelectedToIgnoreList]; +} + +- (void)invokeCustomCommand +{ + [model invokeCustomCommand]; +} + +- (void)markAll +{ + [model markAll]; +} + +- (void)markInvert +{ + [model markInvert]; +} + +- (void)markNone +{ + [model markNone]; +} + +- (void)markSelected +{ + [model toggleSelectedMark]; +} + +- (void)moveMarked +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])]; + [model setCopyMoveDestType:n2i([ud objectForKey:@"recreatePathType"])]; + [model moveMarked]; +} + +- (void)openClicked +{ + if ([matches clickedRow] < 0) { + return; + } + [matches selectRowIndexes:[NSIndexSet indexSetWithIndex:[matches clickedRow]] byExtendingSelection:NO]; + [model openSelected]; +} + +- (void)openSelected +{ + [model openSelected]; +} + +- (void)removeMarked +{ + [model removeMarked]; +} + +- (void)removeSelected +{ + [model removeSelected]; +} + +- (void)renameSelected +{ + NSInteger col = [matches columnWithIdentifier:@"name"]; + NSInteger row = [matches selectedRow]; + [matches editColumn:col row:row withEvent:[NSApp currentEvent] select:YES]; +} + +- (void)reprioritizeResults +{ + PrioritizeDialog *dlg = [[PrioritizeDialog alloc] initWithApp:model]; + NSInteger result = [NSApp runModalForWindow:[dlg window]]; + if (result == NSRunStoppedResponse) { + [[dlg model] performReprioritization]; + } + [dlg release]; + [[self window] makeKeyAndOrderFront:nil]; +} + +- (void)resetColumnsToDefault +{ + [[[table columns] model] resetToDefaults]; + [self fillColumnsMenu]; +} + +- (void)revealSelected +{ + [model revealSelected]; +} + +- (void)saveResults +{ + NSSavePanel *sp = [NSSavePanel savePanel]; + [sp setCanCreateDirectories:YES]; + [sp setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]]; + [sp setTitle:NSLocalizedString(@"Select a file to save your results to", @"")]; + if ([sp runModal] == NSOKButton) { + [model saveResultsAs:[[sp URL] path]]; + [[app recentResults] addFile:[[sp URL] path]]; + } +} + +- (void)switchSelected +{ + [model makeSelectedReference]; +} + +- (void)toggleColumn:(id)sender +{ + NSMenuItem *mi = sender; + BOOL checked = [[[table columns] model] toggleMenuItem:[mi tag]]; + [mi setState:checked ? NSOnState : NSOffState]; +} + +- (void)toggleDetailsPanel +{ + [[app detailsPanel] toggleVisibility]; + [self updateOptionSegments]; +} + +- (void)toggleDelta +{ + [table setDeltaValuesMode:![table deltaValuesMode]]; + [self updateOptionSegments]; +} + +- (void)togglePowerMarker +{ + [table setPowerMarkerMode:![table powerMarkerMode]]; + [self updateOptionSegments]; +} + +- (void)toggleQuicklookPanel +{ + if ([QLPreviewPanel sharedPreviewPanelExists] && [[QLPreviewPanel sharedPreviewPanel] isVisible]) { + [[QLPreviewPanel sharedPreviewPanel] orderOut:nil]; + } + else { + [[QLPreviewPanel sharedPreviewPanel] makeKeyAndOrderFront:nil]; + } +} + +/* Quicklook */ +- (BOOL)acceptsPreviewPanelControl:(QLPreviewPanel *)panel; +{ + return YES; +} + +- (void)beginPreviewPanelControl:(QLPreviewPanel *)panel +{ + // This document is now responsible of the preview panel + // It is allowed to set the delegate, data source and refresh panel. + previewPanel = [panel retain]; + panel.delegate = table; + panel.dataSource = table; +} + +- (void)endPreviewPanelControl:(QLPreviewPanel *)panel +{ + // This document loses its responsisibility on the preview panel + // Until the next call to -beginPreviewPanelControl: it must not + // change the panel's delegate, data source or refresh it. + [previewPanel release]; + previewPanel = nil; +} + +- (BOOL)validateToolbarItem:(NSToolbarItem *)theItem +{ + return ![[ProgressController mainProgressController] isShown]; +} + +- (BOOL)validateMenuItem:(NSMenuItem *)item +{ + if ([item action] == @selector(markAll)) { + [item setTitle:NSLocalizedString(@"Mark All", @"")]; + } + return ![[ProgressController mainProgressController] isShown]; +} +@end diff --git a/cocoa/StatsLabel.h b/cocoa/StatsLabel.h new file mode 100644 index 0000000..9641d55 --- /dev/null +++ b/cocoa/StatsLabel.h @@ -0,0 +1,17 @@ +/* +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 +#import "HSGUIController.h" +#import "PyStatsLabel.h" + +@interface StatsLabel : HSGUIController {} +- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTextField *)aLabelView; +- (PyStatsLabel *)model; +- (NSTextField *)labelView; +@end \ No newline at end of file diff --git a/cocoa/StatsLabel.m b/cocoa/StatsLabel.m new file mode 100644 index 0000000..ca14533 --- /dev/null +++ b/cocoa/StatsLabel.m @@ -0,0 +1,34 @@ +/* +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 "StatsLabel.h" +#import "Utils.h" + +@implementation StatsLabel +- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTextField *)aLabelView +{ + return [super initWithPyRef:aPyRef wrapperClass:[PyStatsLabel class] + callbackClassName:@"StatsLabelView" view:aLabelView]; +} + +- (PyStatsLabel *)model +{ + return (PyStatsLabel *)model; +} + +- (NSTextField *)labelView +{ + return (NSTextField *)view; +} + +/* Python --> Cocoa */ +- (void)refresh +{ + [[self labelView] setStringValue:[[self model] display]]; +} +@end diff --git a/cocoa/dg_cocoa.py b/cocoa/dg_cocoa.py new file mode 100644 index 0000000..b460c02 --- /dev/null +++ b/cocoa/dg_cocoa.py @@ -0,0 +1,19 @@ +# Copyright 2017 Virgil Dupras +# +# This software is licensed under the "GPLv3" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.gnu.org/licenses/gpl-3.0.html + +import codecs, io, locale + +from hscommon.trans import install_gettext_trans_under_cocoa +install_gettext_trans_under_cocoa() + +from cocoa.inter import PySelectableList, PyColumns, PyTable + +from inter.all import * +from inter.app import PyDupeGuru + +# When built under virtualenv, the dependency collector misses this module, so we have to force it +# to see the module. +import distutils.sysconfig \ No newline at end of file diff --git a/cocoa/dupeguru.icns b/cocoa/dupeguru.icns new file mode 100755 index 0000000000000000000000000000000000000000..6641a6b4a15b02b3d6ddf1b421dcf0313824ff3b GIT binary patch literal 53620 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 P>MA$nJk&{X9)te}Eq7n5 literal 0 HcmV?d00001 diff --git a/cocoa/en.lproj/Localizable.strings b/cocoa/en.lproj/Localizable.strings new file mode 100644 index 0000000..60d7efc --- /dev/null +++ b/cocoa/en.lproj/Localizable.strings @@ -0,0 +1,140 @@ + +"%@ Results" = "%@ Results"; +"About dupeGuru" = "About dupeGuru"; +"Action" = "Action"; +"Actions" = "Actions"; +"Add criteria to the right box and click OK to send the dupes that correspond the best to these criteria to their respective group's reference position. Read the help file for more information." = "Add criteria to the right box and click OK to send the dupes that correspond the best to these criteria to their respective group's reference position. Read the help file for more information."; +"Add New Folder..." = "Add New Folder..."; +"Add Selected to Ignore List" = "Add Selected to Ignore List"; +"Advanced" = "Advanced"; +"After having deleted a duplicate, place a link targeting the reference file to replace the deleted file." = "After having deleted a duplicate, place a link targeting the reference file to replace the deleted file."; +"Album" = "Album"; +"Application Mode:" = "Application Mode:"; +"Artist" = "Artist"; +"Attribute" = "Attribute"; +"Automatically check for updates" = "Automatically check for updates"; +"Basic" = "Basic"; +"Bring All to Front" = "Bring All to Front"; +"Can mix file kind" = "Can mix file kind"; +"Cancel" = "Cancel"; +"Check for update..." = "Check for update..."; +"Clear" = "Clear"; +"Clear Picture Cache" = "Clear Picture Cache"; +"Close" = "Close"; +"Close Window" = "Close Window"; +"Columns" = "Columns"; +"Copy" = "Copy"; +"Copy and Move:" = "Copy and Move:"; +"Copy Marked to..." = "Copy Marked to..."; +"Custom command (arguments: %d for dupe, %r for ref):" = "Custom command (arguments: %d for dupe, %r for ref):"; +"Cut" = "Cut"; +"Debug mode (restart required)" = "Debug mode (restart required)"; +"Deletion Options" = "Deletion Options"; +"Delta" = "Delta"; +"Details" = "Details"; +"Details of Selected File" = "Details of Selected File"; +"Details Panel" = "Details Panel"; +"Directly delete files" = "Directly delete files"; +"Directories" = "Directories"; +"Do you really want to remove all your cached picture analysis?" = "Do you really want to remove all your cached picture analysis?"; +"dupeGuru" = "dupeGuru"; +"dupeGuru Help" = "dupeGuru Help"; +"dupeGuru Preferences" = "dupeGuru Preferences"; +"dupeGuru Results" = "dupeGuru Results"; +"dupeGuru Website" = "dupeGuru Website"; +"Dupes Only" = "Dupes Only"; +"Edit" = "Edit"; +"Excluded" = "Excluded"; +"Export Results to CSV" = "Export Results to CSV"; +"Export Results to XHTML" = "Export Results to XHTML"; +"Fewer results" = "Fewer results"; +"File" = "File"; +"Filter" = "Filter"; +"Filter hardness:" = "Filter hardness:"; +"Filter Results..." = "Filter Results..."; +"Folder Selection Window" = "Folder Selection Window"; +"Font Size:" = "Font Size:"; +"Genre" = "Genre"; +"Help" = "Help"; +"Hide dupeGuru" = "Hide dupeGuru"; +"Hide Others" = "Hide Others"; +"Ignore duplicates hardlinking to the same file" = "Ignore duplicates hardlinking to the same file"; +"Ignore files smaller than:" = "Ignore files smaller than:"; +"Ignore List" = "Ignore List"; +"Instead of sending files to trash, delete them directly. This option is usually used as a workaround when the normal deletion method doesn't work." = "Instead of sending files to trash, delete them directly. This option is usually used as a workaround when the normal deletion method doesn't work."; +"Invert Marking" = "Invert Marking"; +"Invoke Custom Command" = "Invoke Custom Command"; +"KB" = "KB"; +"Link deleted files" = "Link deleted files"; +"Load from file..." = "Load from file..."; +"Load Recent Results" = "Load Recent Results"; +"Load Results" = "Load Results"; +"Load Results..." = "Load Results..."; +"Make Selected into Reference" = "Make Selected into Reference"; +"Mark All" = "Mark All"; +"Mark None" = "Mark None"; +"Mark Selected" = "Mark Selected"; +"Match pictures of different dimensions" = "Match pictures of different dimensions"; +"Match similar words" = "Match similar words"; +"Minimize" = "Minimize"; +"Mode" = "Mode"; +"More results" = "More results"; +"Move Marked to..." = "Move Marked to..."; +"Music" = "Music"; +"Name" = "Name"; +"Normal" = "Normal"; +"Ok" = "Ok"; +"Open Selected with Default Application" = "Open Selected with Default Application"; +"Options" = "Options"; +"Paste" = "Paste"; +"Picture" = "Picture"; +"Preferences..." = "Preferences..."; +"Problems!" = "Problems!"; +"Proceed" = "Proceed"; +"Quick Look" = "Quick Look"; +"Quit dupeGuru" = "Quit dupeGuru"; +"Re-Prioritize duplicates" = "Re-Prioritize duplicates"; +"Re-Prioritize Results..." = "Re-Prioritize Results..."; +"Recreate absolute path" = "Recreate absolute path"; +"Recreate relative path" = "Recreate relative path"; +"Reference" = "Reference"; +"Remove empty folders on delete or move" = "Remove empty folders on delete or move"; +"Remove Marked from Results" = "Remove Marked from Results"; +"Remove Selected" = "Remove Selected"; +"Remove Selected from Results" = "Remove Selected from Results"; +"Rename Selected" = "Rename Selected"; +"Reset to Default" = "Reset to Default"; +"Reset To Defaults" = "Reset To Defaults"; +"Results Window" = "Results Window"; +"Reveal" = "Reveal"; +"Reveal Selected in Finder" = "Reveal Selected in Finder"; +"Right in destination" = "Right in destination"; +"Save Results..." = "Save Results..."; +"Scan" = "Scan"; +"Scan Type:" = "Scan Type:"; +"Select a file to save your results to" = "Select a file to save your results to"; +"Select a folder to add to the scanning list" = "Select a folder to add to the scanning list"; +"Select a results file to load" = "Select a results file to load"; +"Select All" = "Select All"; +"Select folders to scan and press \"Scan\"." = "Select folders to scan and press \"Scan\"."; +"Selected" = "Selected"; +"Send Marked to Trash..." = "Send Marked to Trash..."; +"Services" = "Services"; +"Show All" = "Show All"; +"Show Delta Values" = "Show Delta Values"; +"Show Dupes Only" = "Show Dupes Only"; +"Standard" = "Standard"; +"Start Duplicate Scan" = "Start Duplicate Scan"; +"State" = "State"; +"Tags to scan:" = "Tags to scan:"; +"The name '%@' already exists." = "The name '%@' already exists."; +"There were problems processing some (or all) of the files. The cause of these problems are described in the table below. Those files were not removed from your results." = "There were problems processing some (or all) of the files. The cause of these problems are described in the table below. Those files were not removed from your results."; +"Title" = "Title"; +"Track" = "Track"; +"Use regular expressions when filtering" = "Use regular expressions when filtering"; +"Window" = "Window"; +"Word weighting" = "Word weighting"; +"Year" = "Year"; +"You have unsaved results, do you really want to continue?" = "You have unsaved results, do you really want to continue?"; +"You have unsaved results, do you really want to quit?" = "You have unsaved results, do you really want to quit?"; +"Zoom" = "Zoom"; diff --git a/cocoa/inter/__init__.py b/cocoa/inter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cocoa/inter/all.py b/cocoa/inter/all.py new file mode 100644 index 0000000..fdc9d57 --- /dev/null +++ b/cocoa/inter/all.py @@ -0,0 +1,10 @@ +from cocoa.inter import PyTextField, PyProgressWindow +from .deletion_options import PyDeletionOptions +from .details_panel import PyDetailsPanel +from .directory_outline import PyDirectoryOutline +from .prioritize_dialog import PyPrioritizeDialog +from .prioritize_list import PyPrioritizeList +from .problem_dialog import PyProblemDialog +from .ignore_list_dialog import PyIgnoreListDialog +from .result_table import PyResultTable +from .stats_label import PyStatsLabel \ No newline at end of file diff --git a/cocoa/inter/app.py b/cocoa/inter/app.py new file mode 100644 index 0000000..6bc3e4e --- /dev/null +++ b/cocoa/inter/app.py @@ -0,0 +1,252 @@ +import logging + +from objp.util import pyref, dontwrap +from cocoa import install_exception_hook, install_cocoa_logger, patch_threaded_job_performer +from cocoa.inter import PyBaseApp, BaseAppView + +import core.pe.photo +from core.app import DupeGuru as DupeGuruBase, AppMode +from .directories import Directories, Bundle +from .photo import Photo + +class DupeGuru(DupeGuruBase): + PICTURE_CACHE_TYPE = 'shelve' + + def __init__(self, view): + DupeGuruBase.__init__(self, view) + self.directories = Directories() + + def selected_dupe_path(self): + if not self.selected_dupes: + return None + return self.selected_dupes[0].path + + def selected_dupe_ref_path(self): + if not self.selected_dupes: + return None + ref = self.results.get_group_of_duplicate(self.selected_dupes[0]).ref + if ref is self.selected_dupes[0]: # we don't want the same pic to be displayed on both sides + return None + return ref.path + + def _get_fileclasses(self): + result = DupeGuruBase._get_fileclasses(self) + if self.app_mode == AppMode.Standard: + result = [Bundle] + result + return result + +class DupeGuruView(BaseAppView): + def askYesNoWithPrompt_(self, prompt: str) -> bool: pass + def createResultsWindow(self): pass + def showResultsWindow(self): pass + def showProblemDialog(self): pass + def selectDestFolderWithPrompt_(self, prompt: str) -> str: pass + def selectDestFileWithPrompt_extension_(self, prompt: str, extension: str) -> str: pass + +class PyDupeGuru(PyBaseApp): + @dontwrap + def __init__(self): + core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = Photo + logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(message)s') + install_exception_hook('https://github.com/hsoft/dupeguru/issues') + install_cocoa_logger() + patch_threaded_job_performer() + self.model = DupeGuru(self) + + #---Sub-proxies + def detailsPanel(self) -> pyref: + return self.model.details_panel + + def directoryTree(self) -> pyref: + return self.model.directory_tree + + def problemDialog(self) -> pyref: + return self.model.problem_dialog + + def statsLabel(self) -> pyref: + return self.model.stats_label + + def resultTable(self) -> pyref: + return self.model.result_table + + def ignoreListDialog(self) -> pyref: + return self.model.ignore_list_dialog + + def progressWindow(self) -> pyref: + return self.model.progress_window + + def deletionOptions(self) -> pyref: + return self.model.deletion_options + + #---Directories + def addDirectory_(self, directory: str): + self.model.add_directory(directory) + + #---Results + def doScan(self): + self.model.start_scanning() + + def exportToXHTML(self): + self.model.export_to_xhtml() + + def exportToCSV(self): + self.model.export_to_csv() + + def loadSession(self): + self.model.load() + + def loadResultsFrom_(self, filename: str): + self.model.load_from(filename) + + def markAll(self): + self.model.mark_all() + + def markNone(self): + self.model.mark_none() + + def markInvert(self): + self.model.mark_invert() + + def purgeIgnoreList(self): + self.model.purge_ignore_list() + + def toggleSelectedMark(self): + self.model.toggle_selected_mark_state() + + def saveSession(self): + self.model.save() + + def saveResultsAs_(self, filename: str): + self.model.save_as(filename) + + #---Actions + def addSelectedToIgnoreList(self): + self.model.add_selected_to_ignore_list() + + def deleteMarked(self): + self.model.delete_marked() + + def applyFilter_(self, filter: str): + self.model.apply_filter(filter) + + def makeSelectedReference(self): + self.model.make_selected_reference() + + def copyMarked(self): + self.model.copy_or_move_marked(copy=True) + + def moveMarked(self): + self.model.copy_or_move_marked(copy=False) + + def openSelected(self): + self.model.open_selected() + + def removeMarked(self): + self.model.remove_marked() + + def removeSelected(self): + self.model.remove_selected() + + def revealSelected(self): + self.model.reveal_selected() + + def invokeCustomCommand(self): + self.model.invoke_custom_command() + + def showIgnoreList(self): + self.model.ignore_list_dialog.show() + + def clearPictureCache(self): + self.model.clear_picture_cache() + + #---Information + def getScanOptions(self) -> list: + return [o.label for o in self.model.SCANNER_CLASS.get_scan_options()] + + def resultsAreModified(self) -> bool: + return self.model.results.is_modified + + def getSelectedDupePath(self) -> str: + return str(self.model.selected_dupe_path()) + + def getSelectedDupeRefPath(self) -> str: + return str(self.model.selected_dupe_ref_path()) + + #---Properties + def getAppMode(self) -> int: + return self.model.app_mode + + def setAppMode_(self, app_mode: int): + self.model.app_mode = app_mode + + def setScanType_(self, scan_type_index: int): + scan_options = self.model.SCANNER_CLASS.get_scan_options() + try: + so = scan_options[scan_type_index] + self.model.options['scan_type'] = so.scan_type + except IndexError: + pass + + def setMinMatchPercentage_(self, percentage: int): + self.model.options['min_match_percentage'] = int(percentage) + + def setWordWeighting_(self, words_are_weighted: bool): + self.model.options['word_weighting'] = words_are_weighted + + def setMatchSimilarWords_(self, match_similar_words: bool): + self.model.options['match_similar_words'] = match_similar_words + + def setSizeThreshold_(self, size_threshold: int): + self.model.options['size_threshold'] = size_threshold + + def enable_scanForTag_(self, enable: bool, scan_tag: str): + if 'scanned_tags' not in self.model.options: + self.model.options['scanned_tags'] = set() + if enable: + self.model.options['scanned_tags'].add(scan_tag) + else: + self.model.options['scanned_tags'].discard(scan_tag) + + def setMatchScaled_(self, match_scaled: bool): + self.model.options['match_scaled'] = match_scaled + + def setMixFileKind_(self, mix_file_kind: bool): + self.model.options['mix_file_kind'] = mix_file_kind + + def setEscapeFilterRegexp_(self, escape_filter_regexp: bool): + self.model.options['escape_filter_regexp'] = escape_filter_regexp + + def setRemoveEmptyFolders_(self, remove_empty_folders: bool): + self.model.options['clean_empty_dirs'] = remove_empty_folders + + def setIgnoreHardlinkMatches_(self, ignore_hardlink_matches: bool): + self.model.options['ignore_hardlink_matches'] = ignore_hardlink_matches + + def setCopyMoveDestType_(self, copymove_dest_type: int): + self.model.options['copymove_dest_type'] = copymove_dest_type + + #--- model --> view + @dontwrap + def ask_yes_no(self, prompt): + return self.callback.askYesNoWithPrompt_(prompt) + + @dontwrap + def create_results_window(self): + self.callback.createResultsWindow() + + @dontwrap + def show_results_window(self): + self.callback.showResultsWindow() + + @dontwrap + def show_problem_dialog(self): + self.callback.showProblemDialog() + + @dontwrap + def select_dest_folder(self, prompt): + return self.callback.selectDestFolderWithPrompt_(prompt) + + @dontwrap + def select_dest_file(self, prompt, extension): + return self.callback.selectDestFileWithPrompt_extension_(prompt, extension) + diff --git a/cocoa/inter/deletion_options.py b/cocoa/inter/deletion_options.py new file mode 100644 index 0000000..e483d4d --- /dev/null +++ b/cocoa/inter/deletion_options.py @@ -0,0 +1,37 @@ +# Created On: 2012-05-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 objp.util import dontwrap +from cocoa.inter import PyGUIObject, GUIObjectView + +class DeletionOptionsView(GUIObjectView): + def updateMsg_(self, msg: str): pass + def show(self) -> bool: pass + def setHardlinkOptionEnabled_(self, enabled: bool): pass + +class PyDeletionOptions(PyGUIObject): + def setLinkDeleted_(self, link_deleted: bool): + self.model.link_deleted = link_deleted + + def setUseHardlinks_(self, use_hardlinks: bool): + self.model.use_hardlinks = use_hardlinks + + def setDirect_(self, direct: bool): + self.model.direct = direct + + #--- model --> view + @dontwrap + def update_msg(self, msg): + self.callback.updateMsg_(msg) + + @dontwrap + def show(self): + return self.callback.show() + + @dontwrap + def set_hardlink_option_enabled(self, enabled): + self.callback.setHardlinkOptionEnabled_(enabled) diff --git a/cocoa/inter/details_panel.py b/cocoa/inter/details_panel.py new file mode 100644 index 0000000..b68f8d0 --- /dev/null +++ b/cocoa/inter/details_panel.py @@ -0,0 +1,11 @@ +from cocoa.inter import PyGUIObject, GUIObjectView + +class DetailsPanelView(GUIObjectView): + pass + +class PyDetailsPanel(PyGUIObject): + def numberOfRows(self) -> int: + return self.model.row_count() + + def valueForColumn_row_(self, column: str, row: int) -> object: + return self.model.row(row)[int(column)] diff --git a/cocoa/inter/directories.py b/cocoa/inter/directories.py new file mode 100644 index 0000000..2b31605 --- /dev/null +++ b/cocoa/inter/directories.py @@ -0,0 +1,53 @@ +# 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 cocoa import proxy +from hscommon.path import Path, pathify +from core.se import fs +from core.directories import Directories as DirectoriesBase, DirectoryState + +def is_bundle(str_path): + uti = proxy.getUTI_(str_path) + if uti is None: + logging.warning('There was an error trying to detect the UTI of %s', str_path) + return proxy.type_conformsToType_(uti, 'com.apple.bundle') or proxy.type_conformsToType_(uti, 'com.apple.package') + +class Bundle(fs.Folder): + @classmethod + @pathify + def can_handle(cls, path: Path): + return not path.islink() and path.isdir() and is_bundle(str(path)) + +class Directories(DirectoriesBase): + ROOT_PATH_TO_EXCLUDE = list(map(Path, ['/Library', '/Volumes', '/System', '/bin', '/sbin', '/opt', '/private', '/dev'])) + HOME_PATH_TO_EXCLUDE = [Path('Library')] + + def _default_state_for_path(self, path): + result = DirectoriesBase._default_state_for_path(self, path) + if result is not None: + return result + if path in self.ROOT_PATH_TO_EXCLUDE: + return DirectoryState.Excluded + if path[:2] == Path('/Users') and path[3:] in self.HOME_PATH_TO_EXCLUDE: + return DirectoryState.Excluded + + def _get_folders(self, from_folder, j): + # We don't want to scan bundle's subfolder even in Folders mode. Bundle's integrity has to + # stay intact. + if is_bundle(str(from_folder.path)): + # just yield the current folder and bail + state = self.get_state(from_folder.path) + if state != DirectoryState.Excluded: + from_folder.is_ref = state == DirectoryState.Reference + yield from_folder + return + else: + yield from DirectoriesBase._get_folders(self, from_folder, j) + + @staticmethod + def get_subfolders(path): + result = DirectoriesBase.get_subfolders(path) + return [p for p in result if not is_bundle(str(p))] diff --git a/cocoa/inter/directory_outline.py b/cocoa/inter/directory_outline.py new file mode 100644 index 0000000..a25a13a --- /dev/null +++ b/cocoa/inter/directory_outline.py @@ -0,0 +1,21 @@ +from objp.util import dontwrap +from cocoa.inter import PyOutline, GUIObjectView + +class DirectoryOutlineView(GUIObjectView): + pass + +class PyDirectoryOutline(PyOutline): + def addDirectory_(self, path: str): + self.model.add_directory(path) + + def removeSelectedDirectory(self): + self.model.remove_selected() + + def selectAll(self): + self.model.select_all() + + # python --> cocoa + @dontwrap + def refresh_states(self): + # Under cocoa, both refresh() and refresh_states() do the same thing. + self.callback.refresh() \ No newline at end of file diff --git a/cocoa/inter/ignore_list_dialog.py b/cocoa/inter/ignore_list_dialog.py new file mode 100644 index 0000000..4873b2f --- /dev/null +++ b/cocoa/inter/ignore_list_dialog.py @@ -0,0 +1,21 @@ +from objp.util import pyref, dontwrap +from cocoa.inter import PyGUIObject, GUIObjectView + +class IgnoreListDialogView(GUIObjectView): + def show(self): pass + +class PyIgnoreListDialog(PyGUIObject): + def ignoreListTable(self) -> pyref: + return self.model.ignore_list_table + + def removeSelected(self): + self.model.remove_selected() + + def clear(self): + self.model.clear() + + #--- model --> view + @dontwrap + def show(self): + self.callback.show() + diff --git a/cocoa/inter/photo.py b/cocoa/inter/photo.py new file mode 100644 index 0000000..3e52c8a --- /dev/null +++ b/cocoa/inter/photo.py @@ -0,0 +1,35 @@ +# 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 cocoa import proxy +from core.pe import _block_osx +from core.pe.photo import Photo as PhotoBase + +class Photo(PhotoBase): + HANDLED_EXTS = PhotoBase.HANDLED_EXTS.copy() + HANDLED_EXTS.update({'psd', 'nef', 'cr2', 'orf'}) + + def _plat_get_dimensions(self): + return _block_osx.get_image_size(str(self.path)) + + def _plat_get_blocks(self, block_count_per_side, orientation): + try: + blocks = _block_osx.getblocks(str(self.path), block_count_per_side, orientation) + except Exception as e: + raise IOError('The reading of "%s" failed with "%s"' % (str(self.path), str(e))) + if not blocks: + raise IOError('The picture %s could not be read' % str(self.path)) + return blocks + + def _get_exif_timestamp(self): + exifdata = proxy.readExifData_(str(self.path)) + if exifdata: + try: + return exifdata['{Exif}']['DateTimeOriginal'] + except KeyError: + return '' + else: + return '' \ No newline at end of file diff --git a/cocoa/inter/prioritize_dialog.py b/cocoa/inter/prioritize_dialog.py new file mode 100644 index 0000000..95a5e08 --- /dev/null +++ b/cocoa/inter/prioritize_dialog.py @@ -0,0 +1,29 @@ +from objp.util import pyref +from cocoa.inter import PyGUIObject, GUIObjectView +from core.gui.prioritize_dialog import PrioritizeDialog + +class PrioritizeDialogView(GUIObjectView): + pass + +class PyPrioritizeDialog(PyGUIObject): + def __init__(self, app: pyref): + model = PrioritizeDialog(app.model) + PyGUIObject.__init__(self, model) + + def categoryList(self) -> pyref: + return self.model.category_list + + def criteriaList(self) -> pyref: + return self.model.criteria_list + + def prioritizationList(self) -> pyref: + return self.model.prioritization_list + + def addSelected(self): + self.model.add_selected() + + def removeSelected(self): + self.model.remove_selected() + + def performReprioritization(self): + self.model.perform_reprioritization() diff --git a/cocoa/inter/prioritize_list.py b/cocoa/inter/prioritize_list.py new file mode 100644 index 0000000..d9e86a0 --- /dev/null +++ b/cocoa/inter/prioritize_list.py @@ -0,0 +1,8 @@ +from cocoa.inter import PySelectableList, SelectableListView + +class PrioritizeListView(SelectableListView): + pass + +class PyPrioritizeList(PySelectableList): + def moveIndexes_toIndex_(self, indexes: list, dest_index: int): + self.model.move_indexes(indexes, dest_index) diff --git a/cocoa/inter/problem_dialog.py b/cocoa/inter/problem_dialog.py new file mode 100644 index 0000000..f65f614 --- /dev/null +++ b/cocoa/inter/problem_dialog.py @@ -0,0 +1,9 @@ +from objp.util import pyref +from cocoa.inter import PyGUIObject + +class PyProblemDialog(PyGUIObject): + def problemTable(self) -> pyref: + return self.model.problem_table + + def revealSelected(self): + self.model.reveal_selected_dupe() diff --git a/cocoa/inter/result_table.py b/cocoa/inter/result_table.py new file mode 100644 index 0000000..4a27205 --- /dev/null +++ b/cocoa/inter/result_table.py @@ -0,0 +1,50 @@ +from objp.util import dontwrap +from cocoa.inter import PyTable, TableView + +class ResultTableView(TableView): + def invalidateMarkings(self): pass + +class PyResultTable(PyTable): + def powerMarkerMode(self) -> bool: + return self.model.power_marker + + def setPowerMarkerMode_(self, value: bool): + self.model.power_marker = value + + def deltaValuesMode(self) -> bool: + return self.model.delta_values + + def setDeltaValuesMode_(self, value: bool): + self.model.delta_values = value + + def valueForRow_column_(self, row_index: int, column: str) -> object: + return self.model.get_row_value(row_index, column) + + def isDeltaAtRow_column_(self, row_index: int, column: str) -> bool: + row = self.model[row_index] + return row.is_cell_delta(column) + + def renameSelected_(self, newname: str) -> bool: + return self.model.rename_selected(newname) + + def sortBy_ascending_(self, key: str, asc: bool): + self.model.sort(key, asc) + + def markSelected(self): + self.model.app.toggle_selected_mark_state() + + def removeSelected(self): + self.model.app.remove_selected() + + def selectedDupeCount(self) -> int: + return self.model.selected_dupe_count + + def pathAtIndex_(self, index: int) -> str: + row = self.model[index] + return str(row._dupe.path) + + # python --> cocoa + @dontwrap + def invalidate_markings(self): + self.callback.invalidateMarkings() + \ No newline at end of file diff --git a/cocoa/inter/stats_label.py b/cocoa/inter/stats_label.py new file mode 100644 index 0000000..dafbe51 --- /dev/null +++ b/cocoa/inter/stats_label.py @@ -0,0 +1,9 @@ +from cocoa.inter import PyGUIObject, GUIObjectView + +class StatsLabelView(GUIObjectView): + pass + +class PyStatsLabel(PyGUIObject): + + def display(self) -> str: + return self.model.display diff --git a/cocoa/main.m b/cocoa/main.m new file mode 100644 index 0000000..e97e22a --- /dev/null +++ b/cocoa/main.m @@ -0,0 +1,49 @@ +/* +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 +#import +#import +#import +#import "AppDelegate.h" +#import "MainMenu_UI.h" + +int main(int argc, char *argv[]) +{ + NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; + /* We have to set the locate to UTF8 for mbstowcs() to correctly convert non-ascii chars in paths */ + setlocale(LC_ALL, "en_US.UTF-8"); + NSString *respath = [[NSBundle mainBundle] resourcePath]; + NSString *mainpy = [respath stringByAppendingPathComponent:@"dg_cocoa.py"]; + wchar_t wPythonPath[PATH_MAX+1]; + NSString *pypath = [respath stringByAppendingPathComponent:@"py"]; + mbstowcs(wPythonPath, [pypath fileSystemRepresentation], PATH_MAX+1); + Py_SetPath(wPythonPath); + Py_SetPythonHome(wPythonPath); + Py_Initialize(); + PyEval_InitThreads(); + PyGILState_STATE gilState = PyGILState_Ensure(); + FILE* fp = fopen([mainpy UTF8String], "r"); + PyRun_SimpleFile(fp, [mainpy UTF8String]); + fclose(fp); + PyGILState_Release(gilState); + if (gilState == PyGILState_LOCKED) { + PyThreadState_Swap(NULL); + PyEval_ReleaseLock(); + } + + [NSApplication sharedApplication]; + AppDelegate *appDelegate = [[AppDelegate alloc] init]; + [NSApp setDelegate:appDelegate]; + [NSApp setMainMenu:createMainMenu_UI(appDelegate)]; + [appDelegate finalizeInit]; + [pool release]; + [NSApp run]; + Py_Finalize(); + return 0; +} diff --git a/cocoa/run_template.py b/cocoa/run_template.py new file mode 100644 index 0000000..840e710 --- /dev/null +++ b/cocoa/run_template.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 + +import sys +import os + +def main(): + return os.system('open "{{app_path}}"') + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/cocoa/ui/deletion_options.py b/cocoa/ui/deletion_options.py new file mode 100644 index 0000000..5e6aebd --- /dev/null +++ b/cocoa/ui/deletion_options.py @@ -0,0 +1,49 @@ +ownerclass = 'DeletionOptions' +ownerimport = 'DeletionOptions.h' + +result = Window(450, 240, "Deletion Options") +messageLabel = Label(result, "") +linkCheckbox = Checkbox(result, "Link deleted files") +linkLabel = Label(result, "After having deleted a duplicate, place a link targeting the " + "reference file to replace the deleted file.") +linkTypeChoice = RadioButtons(result, ["Symlink", "Hardlink"], columns=2) +directCheckbox = Checkbox(result, "Directly delete files") +directLabel = Label(result, "Instead of sending files to trash, delete them directly. This option " + "is usually used as a workaround when the normal deletion method doesn't work.") +proceedButton = Button(result, "Proceed") +cancelButton = Button(result, "Cancel") + +owner.linkButton = linkCheckbox +owner.linkTypeRadio = linkTypeChoice +owner.directButton = directCheckbox +owner.messageTextField = messageLabel + +result.canMinimize = False +result.canResize = False +linkLabel.controlSize = ControlSize.Small +directLabel.controlSize = ControlSize.Small +linkTypeChoice.controlSize = ControlSize.Small +proceedButton.keyEquivalent = '\\r' +cancelButton.keyEquivalent = '\\e' +linkCheckbox.action = directCheckbox.action = linkTypeChoice.action = Action(owner, 'updateOptions') +proceedButton.action = Action(owner, 'proceed') +cancelButton.action = Action(owner, 'cancel') + +linkLabel.height *= 2 # 2 lines +directLabel.height *= 3 # 3 lines +proceedButton.width = 92 +cancelButton.width = 92 + +mainLayout = VLayout([messageLabel, linkCheckbox, linkLabel, linkTypeChoice, directCheckbox, + directLabel]) +mainLayout.packToCorner(Pack.UpperLeft) +mainLayout.fill(Pack.Right) +buttonLayout = HLayout([cancelButton, proceedButton]) +buttonLayout.packToCorner(Pack.LowerRight) + +# indent the labels under checkboxes a little bit to the right +for indentedView in (linkLabel, directLabel, linkTypeChoice): + indentedView.x += 20 + indentedView.width -= 20 +# We actually don't want the link choice radio buttons to take all the width, it looks weird. +linkTypeChoice.width = 170 diff --git a/cocoa/ui/details_panel.py b/cocoa/ui/details_panel.py new file mode 100644 index 0000000..d8d0290 --- /dev/null +++ b/cocoa/ui/details_panel.py @@ -0,0 +1,32 @@ +ownerclass = 'DetailsPanel' +ownerimport = 'DetailsPanel.h' + +result = Panel(451, 146, "Details of Selected File") +table = TableView(result) + +owner.detailsTable = table + +result.style = PanelStyle.Utility +result.xProportion = 0.2 +result.yProportion = 0.4 +result.canMinimize = False +result.autosaveName = 'DetailsPanel' +result.minSize = Size(result.width, result.height) + +table.dataSource = owner +table.allowsColumnReordering = False +table.allowsColumnSelection = False +table.allowsMultipleSelection = False +table.font = Font(FontFamily.System, FontSize.SmallSystem) +table.rowHeight = 14 +table.editable = False +col = table.addColumn('0', "Attribute", 70) +col.autoResizable = True +col = table.addColumn('1', "Selected", 198) +col.autoResizable = True +col = table.addColumn('2', "Reference", 172) +col.autoResizable = True + +table.packToCorner(Pack.UpperLeft, margin=0) +table.fill(Pack.LowerRight, margin=0) +table.setAnchor(Pack.UpperLeft, growX=True, growY=True) diff --git a/cocoa/ui/details_panel_picture.py b/cocoa/ui/details_panel_picture.py new file mode 100644 index 0000000..5d0d110 --- /dev/null +++ b/cocoa/ui/details_panel_picture.py @@ -0,0 +1,70 @@ +ownerclass = 'DetailsPanelPicture' +ownerimport = 'DetailsPanelPicture.h' + +result = Panel(593, 398, "Details of Selected File") +table = TableView(result) +split = SplitView(result, 2, vertical=True) +leftSplit, rightSplit = split.subviews +selectedLabel = Label(leftSplit, "Selected") +selectedImage = ImageView(leftSplit, 'NSApplicationIcon') +leftSpinner = ProgressIndicator(leftSplit) +referenceLabel = Label(rightSplit, "Reference") +referenceImage = ImageView(rightSplit, 'NSApplicationIcon') +rightSpinner = ProgressIndicator(rightSplit) + +owner.detailsTable = table +owner.dupeImage = selectedImage +owner.dupeProgressIndicator = leftSpinner +owner.refImage = referenceImage +owner.refProgressIndicator = rightSpinner +table.dataSource = owner + +result.style = PanelStyle.Utility +result.xProportion = 0.6 +result.yProportion = 0.6 +result.canMinimize = False +result.autosaveName = 'DetailsPanel' +result.minSize = Size(451, 240) + +table.allowsColumnReordering = False +table.allowsColumnSelection = False +table.allowsMultipleSelection = False +table.font = Font(FontFamily.System, FontSize.SmallSystem) +table.rowHeight = 14 +table.editable = False +col = table.addColumn('0', "Attribute", 70) +col.autoResizable = True +col = table.addColumn('1', "Selected", 198) +col.autoResizable = True +col = table.addColumn('2', "Reference", 172) +col.autoResizable = True +table.height = 165 + +sides = [ + (leftSplit, selectedLabel, selectedImage, leftSpinner), + (rightSplit, referenceLabel, referenceImage, rightSpinner), +] +for subSplit, label, image, spinner in sides: + label.alignment = TextAlignment.Center + spinner.style = const.NSProgressIndicatorSpinningStyle + spinner.controlSize = const.NSSmallControlSize + spinner.displayedWhenStopped = False + + label.packToCorner(Pack.UpperLeft, margin=0) + label.fill(Pack.Right, margin=0) + label.setAnchor(Pack.UpperLeft, growX=True) + image.packRelativeTo(label, Pack.Below) + image.fill(Pack.LowerRight, margin=0) + image.setAnchor(Pack.UpperLeft, growX=True, growY=True) + spinner.y = label.y + spinner.x = subSplit.width - 30 + spinner.setAnchor(Pack.UpperRight) + +table.packToCorner(Pack.UpperLeft, margin=0) +table.fill(Pack.Right, margin=0) +table.setAnchor(Pack.UpperLeft, growX=True) + +split.packRelativeTo(table, Pack.Below) +split.fill(Pack.LowerRight, margin=0) +split.setAnchor(Pack.UpperLeft, growX=True, growY=True) + diff --git a/cocoa/ui/directory_panel.py b/cocoa/ui/directory_panel.py new file mode 100644 index 0000000..9f36217 --- /dev/null +++ b/cocoa/ui/directory_panel.py @@ -0,0 +1,76 @@ +ownerclass = 'DirectoryPanel' +ownerimport = 'DirectoryPanel.h' + +result = Window(425, 300, "dupeGuru") +promptLabel = Label(result, "Select folders to scan and press \"Scan\".") +directoryOutline = OutlineView(result) +directoryOutline.OBJC_CLASS = 'HSOutlineView' +appModeSelector = SegmentedControl(result) +appModeLabel = Label(result, "Application Mode:") +scanTypePopup = Popup(result) +scanTypeLabel = Label(result, "Scan Type:") +addButton = Button(result, "") +removeButton = Button(result, "") +loadResultsButton = Button(result, "Load Results") +scanButton = Button(result, "Scan") +addPopup = Popup(None) +loadRecentPopup = Popup(None) + +owner.outlineView = directoryOutline +owner.appModeSelector = appModeSelector +owner.scanTypePopup = scanTypePopup +owner.removeButton = removeButton +owner.loadResultsButton = loadResultsButton +owner.addButtonPopUp = addPopup +owner.loadRecentButtonPopUp = loadRecentPopup + +result.autosaveName = 'DirectoryPanel' +result.canMinimize = False +result.minSize = Size(400, 270) +for label in ["Standard", "Music", "Picture"]: + appModeSelector.addSegment(label, 80) +addButton.bezelStyle = removeButton.bezelStyle = const.NSTexturedRoundedBezelStyle +addButton.image = 'NSAddTemplate' +removeButton.image = 'NSRemoveTemplate' +for button in (addButton, removeButton): + button.style = const.NSTexturedRoundedBezelStyle + button.imagePosition = const.NSImageOnly +scanButton.keyEquivalent = '\\r' +appModeSelector.action = Action(owner, 'changeAppMode:') +addButton.action = Action(owner, 'popupAddDirectoryMenu:') +removeButton.action = Action(owner, 'removeSelectedDirectory') +loadResultsButton.action = Action(owner, 'popupLoadRecentMenu:') +scanButton.action = Action(None, 'startScanning') + +directoryOutline.font = Font(FontFamily.System, FontSize.SmallSystem) +col = directoryOutline.addColumn('name', "Name", 100) +col.editable = False +col.autoResizable = True +col = directoryOutline.addColumn('state', "State", 85) +col.editable = True +col.autoResizable = False +col.dataCell = Popup(None, ["Normal", "Reference", "Excluded"]) +col.dataCell.controlSize = const.NSSmallControlSize +directoryOutline.allowsColumnReordering = False +directoryOutline.allowsColumnSelection = False +directoryOutline.allowsMultipleSelection = True + +appModeLabel.width = scanTypeLabel.width = 110 +scanTypePopup.width = 248 +appModeLayout = HLayout([appModeLabel, appModeSelector]) +scanTypeLayout = HLayout([scanTypeLabel, scanTypePopup]) + +for button in (addButton, removeButton): + button.width = 28 +for button in (loadResultsButton, scanButton): + button.width = 118 + +buttonLayout = HLayout([addButton, removeButton, None, loadResultsButton, scanButton]) +mainLayout = VLayout([appModeLayout, scanTypeLayout, promptLabel, directoryOutline, buttonLayout], filler=directoryOutline) +mainLayout.packToCorner(Pack.UpperLeft) +mainLayout.fill(Pack.LowerRight) +directoryOutline.packRelativeTo(promptLabel, Pack.Below) + +promptLabel.setAnchor(Pack.UpperLeft, growX=True) +directoryOutline.setAnchor(Pack.UpperLeft, growX=True, growY=True) +buttonLayout.setAnchor(Pack.Below) diff --git a/cocoa/ui/ignore_list_dialog.py b/cocoa/ui/ignore_list_dialog.py new file mode 100644 index 0000000..60ad46a --- /dev/null +++ b/cocoa/ui/ignore_list_dialog.py @@ -0,0 +1,30 @@ +ownerclass = 'IgnoreListDialog' +ownerimport = 'IgnoreListDialog.h' + +result = Window(550, 350, "Ignore List") +table = TableView(result) +removeSelectedButton = Button(result, "Remove Selected") +clearButton = Button(result, "Clear") +closeButton = Button(result, "Close") + +owner.ignoreListTableView = table + +result.canMinimize = False +removeSelectedButton.action = Action(owner.model, 'removeSelected') +clearButton.action = Action(owner.model, 'clear') +closeButton.action = Action(result, 'performClose:') +closeButton.keyEquivalent = '\\r' +table.allowsColumnReordering = False +table.allowsColumnSelection = False +table.allowsMultipleSelection = True + +removeSelectedButton.width = 142 +clearButton.width = 142 +closeButton.width = 84 +buttonLayout = HLayout([removeSelectedButton, clearButton, None, closeButton]) +buttonLayout.packToCorner(Pack.LowerLeft) +buttonLayout.fill(Pack.Right) +buttonLayout.setAnchor(Pack.Below) +table.packRelativeTo(buttonLayout, Pack.Above) +table.fill(Pack.UpperRight) +table.setAnchor(Pack.UpperLeft, growX=True, growY=True) diff --git a/cocoa/ui/main_menu.py b/cocoa/ui/main_menu.py new file mode 100644 index 0000000..5b0e74b --- /dev/null +++ b/cocoa/ui/main_menu.py @@ -0,0 +1,77 @@ +ownerclass = 'AppDelegate' +ownerimport = 'AppDelegate.h' + +result = Menu("") +appMenu = result.addMenu("dupeGuru") +fileMenu = result.addMenu("File") +editMenu = result.addMenu("Edit") +actionMenu = result.addMenu("Actions") +owner.columnsMenu = result.addMenu("Columns") +modeMenu = result.addMenu("Mode") +windowMenu = result.addMenu("Window") +helpMenu = result.addMenu("Help") + +appMenu.addItem("About dupeGuru", Action(owner, 'showAboutBox')) +appMenu.addSeparator() +appMenu.addItem("Preferences...", Action(owner, 'showPreferencesPanel'), 'cmd+,') +appMenu.addSeparator() +NSApp.servicesMenu = appMenu.addMenu("Services") +appMenu.addSeparator() +appMenu.addItem("Hide dupeGuru", Action(NSApp, 'hide:'), 'cmd+h') +appMenu.addItem("Hide Others", Action(NSApp, 'hideOtherApplications:'), 'cmd+alt+h') +appMenu.addItem("Show All", Action(NSApp, 'unhideAllApplications:')) +appMenu.addSeparator() +appMenu.addItem("Quit dupeGuru", Action(NSApp, 'terminate:'), 'cmd+q') + +fileMenu.addItem("Load Results...", Action(None, 'loadResults'), 'cmd+o') +owner.recentResultsMenu = fileMenu.addMenu("Load Recent Results") +fileMenu.addItem("Save Results...", Action(None, 'saveResults'), 'cmd+s') +fileMenu.addItem("Export Results to XHTML", Action(owner.model, 'exportToXHTML'), 'cmd+shift+e') +fileMenu.addItem("Export Results to CSV", Action(owner.model, 'exportToCSV')) +fileMenu.addItem("Clear Picture Cache", Action(owner, 'clearPictureCache'), 'cmd+shift+p') + +editMenu.addItem("Mark All", Action(None, 'markAll'), 'cmd+a') +editMenu.addItem("Mark None", Action(None, 'markNone'), 'cmd+shift+a') +editMenu.addItem("Invert Marking", Action(None, 'markInvert'), 'cmd+alt+a') +editMenu.addItem("Mark Selected", Action(None, 'markSelected'), 'ctrl+cmd+a') +editMenu.addSeparator() +editMenu.addItem("Cut", Action(None, 'cut:'), 'cmd+x') +editMenu.addItem("Copy", Action(None, 'copy:'), 'cmd+c') +editMenu.addItem("Paste", Action(None, 'paste:'), 'cmd+v') +editMenu.addSeparator() +editMenu.addItem("Filter Results...", Action(None, 'focusOnFilterField'), 'cmd+alt+f') + +actionMenu.addItem("Start Duplicate Scan", Action(owner, 'startScanning'), 'cmd+d') +actionMenu.addSeparator() +actionMenu.addItem("Send Marked to Trash...", Action(None, 'trashMarked'), 'cmd+t') +actionMenu.addItem("Move Marked to...", Action(None, 'moveMarked'), 'cmd+m') +actionMenu.addItem("Copy Marked to...", Action(None, 'copyMarked'), 'cmd+alt+m') +actionMenu.addItem("Remove Marked from Results", Action(None, 'removeMarked'), 'cmd+r') +actionMenu.addItem("Re-Prioritize Results...", Action(None, 'reprioritizeResults')) +actionMenu.addSeparator() +actionMenu.addItem("Remove Selected from Results", Action(None, 'removeSelected'), 'cmd+backspace') +actionMenu.addItem("Add Selected to Ignore List", Action(None, 'ignoreSelected'), 'cmd+g') +actionMenu.addItem("Make Selected into Reference", Action(None, 'switchSelected'), 'cmd+arrowup') +actionMenu.addSeparator() +actionMenu.addItem("Open Selected with Default Application", Action(None, 'openSelected'), 'cmd+return') +actionMenu.addItem("Reveal Selected in Finder", Action(None, 'revealSelected'), 'cmd+alt+return') +actionMenu.addItem("Invoke Custom Command", Action(None, 'invokeCustomCommand'), 'cmd+shift+c') +actionMenu.addItem("Rename Selected", Action(None, 'renameSelected'), 'enter') + +modeMenu.addItem("Show Dupes Only", Action(None, 'togglePowerMarker'), 'cmd+1') +modeMenu.addItem("Show Delta Values", Action(None, 'toggleDelta'), 'cmd+2') + +windowMenu.addItem("Results Window", Action(owner, 'showResultWindow')) +windowMenu.addItem("Folder Selection Window", Action(owner, 'showDirectoryWindow')) +windowMenu.addItem("Ignore List", Action(owner, 'showIgnoreList')) +windowMenu.addItem("Details Panel", Action(None, 'toggleDetailsPanel'), 'cmd+i') +windowMenu.addItem("Quick Look", Action(None, 'toggleQuicklookPanel'), 'cmd+l') +windowMenu.addSeparator() +windowMenu.addItem("Minimize", Action(None, 'performMinimize:')) +windowMenu.addItem("Zoom", Action(None, 'performZoom:')) +windowMenu.addItem("Close Window", Action(None, 'performClose:'), 'cmd+w') +windowMenu.addSeparator() +windowMenu.addItem("Bring All to Front", Action(None, 'arrangeInFront:')) + +helpMenu.addItem("dupeGuru Help", Action(owner, 'openHelp'), 'cmd+?') +helpMenu.addItem("dupeGuru Website", Action(owner, 'openWebsite')) diff --git a/cocoa/ui/preferences_panel.py b/cocoa/ui/preferences_panel.py new file mode 100644 index 0000000..227bc68 --- /dev/null +++ b/cocoa/ui/preferences_panel.py @@ -0,0 +1,173 @@ +appmode = args.get('appmode', 'standard') +dialogHeights = { + 'standard': 325, + 'music': 345, + 'picture': 255, +} + +result = Window(410, dialogHeights[appmode], "dupeGuru Preferences") +tabView = TabView(result) +basicTab = tabView.addTab("Basic") +advancedTab = tabView.addTab("Advanced") +thresholdSlider = Slider(basicTab.view, 1, 100, 80) +thresholdLabel = Label(basicTab.view, "Filter hardness:") +moreResultsLabel = Label(basicTab.view, "More results") +fewerResultsLabel = Label(basicTab.view, "Fewer results") +thresholdValueLabel = Label(basicTab.view, "") +fontSizeCombo = Combobox(basicTab.view, ["11", "12", "13", "14", "18", "24"]) +fontSizeLabel = Label(basicTab.view, "Font Size:") +if appmode in ('standard', 'music'): + wordWeightingBox = Checkbox(basicTab.view, "Word weighting") + matchSimilarWordsBox = Checkbox(basicTab.view, "Match similar words") +elif appmode == 'picture': + matchDifferentDimensionsBox = Checkbox(basicTab.view, "Match pictures of different dimensions") +mixKindBox = Checkbox(basicTab.view, "Can mix file kind") +removeEmptyFoldersBox = Checkbox(basicTab.view, "Remove empty folders on delete or move") +checkForUpdatesBox = Checkbox(basicTab.view, "Automatically check for updates") +if appmode == 'standard': + ignoreSmallFilesBox = Checkbox(basicTab.view, "Ignore files smaller than:") + smallFilesThresholdText = TextField(basicTab.view, "") + smallFilesThresholdSuffixLabel = Label(basicTab.view, "KB") +elif appmode == 'music': + tagsToScanLabel = Label(basicTab.view, "Tags to scan:") + trackBox = Checkbox(basicTab.view, "Track") + artistBox = Checkbox(basicTab.view, "Artist") + albumBox = Checkbox(basicTab.view, "Album") + titleBox = Checkbox(basicTab.view, "Title") + genreBox = Checkbox(basicTab.view, "Genre") + yearBox = Checkbox(basicTab.view, "Year") + tagBoxes = [trackBox, artistBox, albumBox, titleBox, genreBox, yearBox] + +regexpCheckbox = Checkbox(advancedTab.view, "Use regular expressions when filtering") +ignoreHardlinksBox = Checkbox(advancedTab.view, "Ignore duplicates hardlinking to the same file") +debugModeCheckbox = Checkbox(advancedTab.view, "Debug mode (restart required)") +customCommandLabel = Label(advancedTab.view, "Custom command (arguments: %d for dupe, %r for ref):") +customCommandText = TextField(advancedTab.view, "") +copyMoveLabel = Label(advancedTab.view, "Copy and Move:") +copyMovePopup = Popup(advancedTab.view, ["Right in destination", "Recreate relative path", "Recreate absolute path"]) + +resetToDefaultsButton = Button(result, "Reset To Defaults") +thresholdSlider.bind('value', defaults, 'values.minMatchPercentage') +thresholdValueLabel.bind('value', defaults, 'values.minMatchPercentage') +fontSizeCombo.bind('value', defaults, 'values.TableFontSize') +mixKindBox.bind('value', defaults, 'values.mixFileKind') +removeEmptyFoldersBox.bind('value', defaults, 'values.removeEmptyFolders') +checkForUpdatesBox.bind('value', defaults, 'values.SUEnableAutomaticChecks') +regexpCheckbox.bind('value', defaults, 'values.useRegexpFilter') +ignoreHardlinksBox.bind('value', defaults, 'values.ignoreHardlinkMatches') +debugModeCheckbox.bind('value', defaults, 'values.DebugMode') +customCommandText.bind('value', defaults, 'values.CustomCommand') +copyMovePopup.bind('selectedIndex', defaults, 'values.recreatePathType') +if appmode in ('standard', 'music'): + wordWeightingBox.bind('value', defaults, 'values.wordWeighting') + matchSimilarWordsBox.bind('value', defaults, 'values.matchSimilarWords') + disableWhenContentScan = [thresholdSlider, wordWeightingBox, matchSimilarWordsBox] + for control in disableWhenContentScan: + vtname = 'vtScanTypeMusicIsNotContent' if appmode == 'music' else 'vtScanTypeIsNotContent' + prefname = 'values.scanTypeMusic' if appmode == 'music' else 'values.scanTypeStandard' + control.bind('enabled', defaults, prefname, valueTransformer=vtname) + if appmode == 'standard': + ignoreSmallFilesBox.bind('value', defaults, 'values.ignoreSmallFiles') + smallFilesThresholdText.bind('value', defaults, 'values.smallFileThreshold') + elif appmode == 'music': + for box in tagBoxes: + box.bind('enabled', defaults, 'values.scanTypeMusic', valueTransformer='vtScanTypeIsTag') + trackBox.bind('value', defaults, 'values.scanTagTrack') + artistBox.bind('value', defaults, 'values.scanTagArtist') + albumBox.bind('value', defaults, 'values.scanTagAlbum') + titleBox.bind('value', defaults, 'values.scanTagTitle') + genreBox.bind('value', defaults, 'values.scanTagGenre') + yearBox.bind('value', defaults, 'values.scanTagYear') +elif appmode == 'picture': + matchDifferentDimensionsBox.bind('value', defaults, 'values.matchScaled') + thresholdSlider.bind('enabled', defaults, 'values.scanTypePicture', valueTransformer='vtScanTypeIsFuzzy') + +result.canResize = False +result.canMinimize = False +thresholdValueLabel.formatter = NumberFormatter(NumberStyle.Decimal) +thresholdValueLabel.formatter.maximumFractionDigits = 0 +allLabels = [thresholdValueLabel, moreResultsLabel, fewerResultsLabel, + thresholdLabel, fontSizeLabel, customCommandLabel, copyMoveLabel] +allCheckboxes = [mixKindBox, removeEmptyFoldersBox, checkForUpdatesBox, regexpCheckbox, + ignoreHardlinksBox, debugModeCheckbox] +if appmode == 'standard': + allLabels += [smallFilesThresholdSuffixLabel] + allCheckboxes += [ignoreSmallFilesBox, wordWeightingBox, matchSimilarWordsBox] +elif appmode == 'music': + allLabels += [tagsToScanLabel] + allCheckboxes += tagBoxes + [wordWeightingBox, matchSimilarWordsBox] +elif appmode == 'picture': + allCheckboxes += [matchDifferentDimensionsBox] +for label in allLabels: + label.controlSize = ControlSize.Small +fewerResultsLabel.alignment = TextAlignment.Right +for checkbox in allCheckboxes: + checkbox.font = thresholdValueLabel.font +resetToDefaultsButton.action = Action(defaults, 'revertToInitialValues:') + +thresholdLabel.width = fontSizeLabel.width = 94 +fontSizeCombo.width = 66 +thresholdValueLabel.width = 25 +resetToDefaultsButton.width = 136 +if appmode == 'standard': + smallFilesThresholdText.width = 60 + smallFilesThresholdSuffixLabel.width = 40 +elif appmode == 'music': + for box in tagBoxes: + box.width = 70 + +tabView.packToCorner(Pack.UpperLeft) +tabView.fill(Pack.Right) +resetToDefaultsButton.packRelativeTo(tabView, Pack.Below, align=Pack.Right) +tabView.fill(Pack.Below, margin=14) +tabView.setAnchor(Pack.UpperLeft, growX=True, growY=True) +thresholdLayout = HLayout([thresholdLabel, thresholdSlider, thresholdValueLabel], filler=thresholdSlider) +thresholdLayout.packToCorner(Pack.UpperLeft) +thresholdLayout.fill(Pack.Right) +# We want to give the labels as much space as possible, and we only "know" how much is available +# after the slider's fill operation. +moreResultsLabel.width = fewerResultsLabel.width = thresholdSlider.width // 2 +moreResultsLabel.packRelativeTo(thresholdSlider, Pack.Below, align=Pack.Left, margin=6) +fewerResultsLabel.packRelativeTo(thresholdSlider, Pack.Below, align=Pack.Right, margin=6) +fontSizeCombo.packRelativeTo(moreResultsLabel, Pack.Below) +fontSizeLabel.packRelativeTo(fontSizeCombo, Pack.Left) + +if appmode == 'music': + tagsToScanLabel.packRelativeTo(fontSizeCombo, Pack.Below) + tagsToScanLabel.fill(Pack.Left) + tagsToScanLabel.fill(Pack.Right) + trackBox.packRelativeTo(tagsToScanLabel, Pack.Below) + trackBox.x += 10 + artistBox.packRelativeTo(trackBox, Pack.Right) + albumBox.packRelativeTo(artistBox, Pack.Right) + titleBox.packRelativeTo(trackBox, Pack.Below) + genreBox.packRelativeTo(titleBox, Pack.Right) + yearBox.packRelativeTo(genreBox, Pack.Right) + viewToPackCheckboxesUnder = titleBox +else: + viewToPackCheckboxesUnder = fontSizeCombo + +if appmode == 'standard': + checkboxesToLayout = [wordWeightingBox, matchSimilarWordsBox, mixKindBox, removeEmptyFoldersBox, + ignoreSmallFilesBox] +elif appmode == 'music': + checkboxesToLayout = [wordWeightingBox, matchSimilarWordsBox, mixKindBox, removeEmptyFoldersBox, + checkForUpdatesBox] +elif appmode == 'picture': + checkboxesToLayout = [matchDifferentDimensionsBox, mixKindBox, removeEmptyFoldersBox, + checkForUpdatesBox] +checkboxLayout = VLayout(checkboxesToLayout) +checkboxLayout.packRelativeTo(viewToPackCheckboxesUnder, Pack.Below) +checkboxLayout.fill(Pack.Left) +checkboxLayout.fill(Pack.Right) + +if appmode == 'standard': + smallFilesThresholdText.packRelativeTo(ignoreSmallFilesBox, Pack.Below, margin=4) + checkForUpdatesBox.packRelativeTo(smallFilesThresholdText, Pack.Below, margin=4) + checkForUpdatesBox.fill(Pack.Right) + smallFilesThresholdText.x += 20 + smallFilesThresholdSuffixLabel.packRelativeTo(smallFilesThresholdText, Pack.Right) + +advancedLayout = VLayout(advancedTab.view.subviews[:]) +advancedLayout.packToCorner(Pack.UpperLeft) +advancedLayout.fill(Pack.Right) diff --git a/cocoa/ui/prioritize_dialog.py b/cocoa/ui/prioritize_dialog.py new file mode 100644 index 0000000..6927113 --- /dev/null +++ b/cocoa/ui/prioritize_dialog.py @@ -0,0 +1,65 @@ +ownerclass = 'PrioritizeDialog' +ownerimport = 'PrioritizeDialog.h' + +result = Window(610, 400, "Re-Prioritize duplicates") +promptLabel = Label(result, "Add criteria to the right box and click OK to send the dupes that " + "correspond the best to these criteria to their respective group's reference position. Read " + "the help file for more information.") +split = SplitView(result, 2, vertical=True) +categoryPopup = Popup(split.subviews[0]) +criteriaTable = ListView(split.subviews[0]) +prioritizationTable = ListView(split.subviews[1]) +addButton = Button(split.subviews[1], NLSTR("-->")) +removeButton = Button(split.subviews[1], NLSTR("<--")) +okButton = Button(result, "Ok") +cancelButton = Button(result, "Cancel") + +owner.categoryPopUpView = categoryPopup +owner.criteriaTableView = criteriaTable +owner.prioritizationTableView = prioritizationTable + +result.canMinimize = False +result.canClose = False +result.minSize = Size(result.width, result.height) +addButton.action = Action(owner.model, 'addSelected') +removeButton.action = Action(owner.model, 'removeSelected') +okButton.action = Action(owner, 'ok') +cancelButton.action = Action(owner, 'cancel') +okButton.keyEquivalent = '\\r' +cancelButton.keyEquivalent = '\\e' + +# For layouts to correctly work, subviews need to have the dimensions they'll approximately have +# at runtime. +split.subviews[0].width = 260 +split.subviews[0].height = 260 +split.subviews[1].width = 340 +split.subviews[1].height = 260 +promptLabel.height *= 3 # 3 lines + +leftLayout = VLayout([categoryPopup, criteriaTable], filler=criteriaTable) +middleLayout = VLayout([addButton, removeButton], width=41) +buttonLayout = HLayout([None, cancelButton, okButton]) + +#pack split subview 0 +leftLayout.fillAll() + +#pack split subview 1 +prioritizationTable.fillAll() +prioritizationTable.width -= 48 +prioritizationTable.moveTo(Pack.Right) +middleLayout.moveNextTo(prioritizationTable, Pack.Left, align=Pack.Middle) + +# Main layout +promptLabel.packToCorner(Pack.UpperLeft) +promptLabel.fill(Pack.Right) +split.moveNextTo(promptLabel, Pack.Below) +buttonLayout.moveNextTo(split, Pack.Below) +buttonLayout.fill(Pack.Right) +split.fill(Pack.LowerRight) + +promptLabel.setAnchor(Pack.UpperLeft, growX=True) +prioritizationTable.setAnchor(Pack.UpperLeft, growX=True, growY=True) +categoryPopup.setAnchor(Pack.UpperLeft, growX=True) +criteriaTable.setAnchor(Pack.UpperLeft, growX=True, growY=True) +split.setAnchor(Pack.UpperLeft, growX=True, growY=True) +buttonLayout.setAnchor(Pack.Below) diff --git a/cocoa/ui/problem_dialog.py b/cocoa/ui/problem_dialog.py new file mode 100644 index 0000000..cd3430b --- /dev/null +++ b/cocoa/ui/problem_dialog.py @@ -0,0 +1,35 @@ +ownerclass = 'ProblemDialog' +ownerimport = 'ProblemDialog.h' + +result = Window(480, 310, "Problems!") +messageLabel = Label(result, "There were problems processing some (or all) of the files. The cause " + "of these problems are described in the table below. Those files were not removed from your " + "results.") +problemTable = TableView(result) +revealButton = Button(result, "Reveal") +closeButton = Button(result, "Close") + +owner.problemTableView = problemTable + +result.canMinimize = False +result.minSize = Size(300, 300) +closeButton.keyEquivalent = '\\r' +revealButton.action = Action(owner.model, 'revealSelected') +closeButton.action = Action(result, 'performClose:') + +messageLabel.height *= 3 # 3 lines +revealButton.width = 150 +closeButton.width = 98 + +messageLabel.packToCorner(Pack.UpperLeft) +messageLabel.fill(Pack.Right) +problemTable.packRelativeTo(messageLabel, Pack.Below) +problemTable.fill(Pack.Right) +revealButton.packRelativeTo(problemTable, Pack.Below) +closeButton.packRelativeTo(problemTable, Pack.Below, align=Pack.Right) +problemTable.fill(Pack.Below) + +messageLabel.setAnchor(Pack.UpperLeft, growX=True) +problemTable.setAnchor(Pack.UpperLeft, growX=True, growY=True) +revealButton.setAnchor(Pack.LowerLeft) +closeButton.setAnchor(Pack.LowerRight) diff --git a/cocoa/ui/result_window.py b/cocoa/ui/result_window.py new file mode 100644 index 0000000..ad3b075 --- /dev/null +++ b/cocoa/ui/result_window.py @@ -0,0 +1,97 @@ +ownerclass = 'ResultWindow' +ownerimport = 'ResultWindow.h' + +result = Window(557, 400, "dupeGuru Results") +toolbar = result.createToolbar('ResultsToolbar') +table = TableView(result) +table.OBJC_CLASS = 'HSTableView' +statsLabel = Label(result, "") +contextMenu = Menu("") + +#Setup toolbar items +toolbar.displayMode = const.NSToolbarDisplayModeIconOnly +directoriesToolItem = toolbar.addItem('Directories', "Directories", image='folder32') +actionToolItem = toolbar.addItem('Action', "Action") +filterToolItem = toolbar.addItem('Filter', "Filter") +optionsToolItem = toolbar.addItem('Options', "Options") +quicklookToolItem = toolbar.addItem('QuickLook', "Quick Look") +toolbar.defaultItems = [actionToolItem, optionsToolItem, quicklookToolItem, directoriesToolItem, + toolbar.flexibleSpace(), filterToolItem] +actionPopup = Popup(None) +actionPopup.pullsdown = True +actionPopup.bezelStyle = const.NSTexturedRoundedBezelStyle +actionPopup.arrowPosition = const.NSPopUpArrowAtBottom +item = actionPopup.menu.addItem("") # First item is invisible +item.hidden = True +item.image = 'NSActionTemplate' +actionPopup.width = 44 +actionToolItem.view = actionPopup +filterField = SearchField(None, "Filter") +filterField.action = Action(owner, 'filter') +filterField.sendsWholeSearchString = True +filterToolItem.view = filterField +filterToolItem.minSize = Size(80, 22) +filterToolItem.maxSize = Size(300, 22) +quickLookButton = Button(None, "") +quickLookButton.bezelStyle = const.NSTexturedRoundedBezelStyle +quickLookButton.image = 'NSQuickLookTemplate' +quickLookButton.width = 44 +quickLookButton.action = Action(owner, 'toggleQuicklookPanel') +quicklookToolItem.view = quickLookButton +optionsSegments = SegmentedControl(None) +optionsSegments.segmentStyle = const.NSSegmentStyleCapsule +optionsSegments.trackingMode = const.NSSegmentSwitchTrackingSelectAny +optionsSegments.font = Font(FontFamily.System, 11) +optionsSegments.addSegment("Details", 57) +optionsSegments.addSegment("Dupes Only", 82) +optionsSegments.addSegment("Delta", 48) +optionsSegments.action = Action(owner, 'changeOptions') +optionsToolItem.view = optionsSegments + +# Popuplate menus +actionPopup.menu.addItem("Send Marked to Trash...", action=Action(owner, 'trashMarked')) +actionPopup.menu.addItem("Move Marked to...", action=Action(owner, 'moveMarked')) +actionPopup.menu.addItem("Copy Marked to...", action=Action(owner, 'copyMarked')) +actionPopup.menu.addItem("Remove Marked from Results", action=Action(owner, 'removeMarked')) +actionPopup.menu.addSeparator() +for menu in (actionPopup.menu, contextMenu): + menu.addItem("Remove Selected from Results", action=Action(owner, 'removeSelected')) + menu.addItem("Add Selected to Ignore List", action=Action(owner, 'ignoreSelected')) + menu.addItem("Make Selected into Reference", action=Action(owner, 'switchSelected')) + menu.addSeparator() + menu.addItem("Open Selected with Default Application", action=Action(owner, 'openSelected')) + menu.addItem("Reveal Selected in Finder", action=Action(owner, 'revealSelected')) + menu.addItem("Rename Selected", action=Action(owner, 'renameSelected')) + +# Doing connections +owner.filterField = filterField +owner.matches = table +owner.optionsSwitch = optionsSegments +owner.optionsToolbarItem = optionsToolItem +owner.stats = statsLabel +table.bind('rowHeight', defaults, 'values.TableFontSize', valueTransformer='vtRowHeightOffset') + +# Rest of the setup +result.minSize = Size(340, 340) +result.autosaveName = 'MainWindow' +statsLabel.alignment = TextAlignment.Center +table.alternatingRows = True +table.menu = contextMenu +table.allowsColumnReordering = True +table.allowsColumnResizing = True +table.allowsColumnSelection = False +table.allowsEmptySelection = False +table.allowsMultipleSelection = True +table.allowsTypeSelect = True +table.gridStyleMask = const.NSTableViewSolidHorizontalGridLineMask +table.setAnchor(Pack.UpperLeft, growX=True, growY=True) +statsLabel.setAnchor(Pack.LowerLeft, growX=True) + +# Layout +# It's a little weird to pack with a margin of -1, but if I don't do that, I get too thick of a +# border on the upper side of the table. +table.packToCorner(Pack.UpperLeft, margin=-1) +table.fill(Pack.Right, margin=0) +statsLabel.packRelativeTo(table, Pack.Below, margin=6) +statsLabel.fill(Pack.Right, margin=0) +table.fill(Pack.Below, margin=5) diff --git a/cocoa/waf b/cocoa/waf new file mode 100755 index 0000000..d8ea88d --- /dev/null +++ b/cocoa/waf @@ -0,0 +1,169 @@ +#!/usr/bin/env python +# encoding: ISO8859-1 +# Thomas Nagy, 2005-2015 + +""" +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +""" + +import os, sys, inspect + +VERSION="1.8.17" +REVISION="15ca44d2d9ba2cd22650638fdb07c7a7" +GIT="18449feb5e938f9cd2b5dba046dccdb9e3d4c34c" +INSTALL='' +C1='#.' +C2='#-' +C3='#,' +cwd = os.getcwd() +join = os.path.join + + +WAF='waf' +def b(x): + return x +if sys.hexversion>0x300000f: + WAF='waf3' + def b(x): + return x.encode() + +def err(m): + print(('\033[91mError: %s\033[0m' % m)) + sys.exit(1) + +def unpack_wafdir(dir, src): + f = open(src,'rb') + c = 'corrupt archive (%d)' + while 1: + line = f.readline() + if not line: err('run waf-light from a folder containing waflib') + if line == b('#==>\n'): + txt = f.readline() + if not txt: err(c % 1) + if f.readline() != b('#<==\n'): err(c % 2) + break + if not txt: err(c % 3) + txt = txt[1:-1].replace(b(C1), b('\n')).replace(b(C2), b('\r')).replace(b(C3), b('\x00')) + + import shutil, tarfile + try: shutil.rmtree(dir) + except OSError: pass + try: + for x in ('Tools', 'extras'): + os.makedirs(join(dir, 'waflib', x)) + except OSError: + err("Cannot unpack waf lib into %s\nMove waf in a writable directory" % dir) + + os.chdir(dir) + tmp = 't.bz2' + t = open(tmp,'wb') + try: t.write(txt) + finally: t.close() + + try: + t = tarfile.open(tmp) + except: + try: + os.system('bunzip2 t.bz2') + t = tarfile.open('t') + tmp = 't' + except: + os.chdir(cwd) + try: shutil.rmtree(dir) + except OSError: pass + err("Waf cannot be unpacked, check that bzip2 support is present") + + try: + for x in t: t.extract(x) + finally: + t.close() + + for x in ('Tools', 'extras'): + os.chmod(join('waflib',x), 493) + + if sys.hexversion<0x300000f: + sys.path = [join(dir, 'waflib')] + sys.path + import fixpy2 + fixpy2.fixdir(dir) + + os.remove(tmp) + os.chdir(cwd) + + try: dir = unicode(dir, 'mbcs') + except: pass + try: + from ctypes import windll + windll.kernel32.SetFileAttributesW(dir, 2) + except: + pass + +def test(dir): + try: + os.stat(join(dir, 'waflib')) + return os.path.abspath(dir) + except OSError: + pass + +def find_lib(): + src = os.path.abspath(inspect.getfile(inspect.getmodule(err))) + base, name = os.path.split(src) + + #devs use $WAFDIR + w=test(os.environ.get('WAFDIR', '')) + if w: return w + + #waf-light + if name.endswith('waf-light'): + w = test(base) + if w: return w + err('waf-light requires waflib -> export WAFDIR=/folder') + + dirname = '%s-%s-%s' % (WAF, VERSION, REVISION) + for i in (INSTALL,'/usr','/usr/local','/opt'): + w = test(i + '/lib/' + dirname) + if w: return w + + #waf-local + dir = join(base, (sys.platform != 'win32' and '.' or '') + dirname) + w = test(dir) + if w: return w + + #unpack + unpack_wafdir(dir, src) + return dir + +wafdir = find_lib() +sys.path.insert(0, wafdir) + +if __name__ == '__main__': + + from waflib import Scripting + Scripting.waf_entry_point(cwd, VERSION, wafdir) + +#==> +#BZh91AY&SY 7#,Y #h%H5`(a|n#,#,#,#,#,#,#,#,#,#,#,#,#,#,#,#,#,#,#,#,{8`ٴQVT]n/[k}=۹uV^w_|,VRz;جkggwvz7WgfwUw=ro{=||{m/>wcgp#,#,#,#,#,c#,{BYUn;7^w@-5mꈨ#. #,R0)T#.P{tD@h2@0[)#,ݯ^}N3zsRन/sV]΄V׵8ӽ۷fwoyuW{of#-Ώ;CEVٻ{xd4>9jى{׸#,҂=2#,u٠ vֺO=ݺVǗ^5hUX`_;ccB]#.tyw=u9={W<=)Ѡ}q4׷ǻxT6n;^ݞv{{{{;.{n {f{>m}|1@ӑޱJiY;۴|R[m/w;s;޶i>[]F}}<[nX#,}>o pxQV*wd윥ʒӇÕzakw#,q#,T>iovB{^=BНǷv Sut{ܛb͋O^6޷e4'oowb}t7w[noz{n{v}};_ox0[mzutr8OoEG׾_&ZoO{#,}}86÷}׶m|ym7^+}]/\Ѿrc:tP4VN›q>={;۽wyw #-{/#,6mV=>wk쓏vnv]][u$׼{k+SGz}2+M;O^!ҽ۳=0=4tm^]9rmwNJgom[Jvv|ڽ{3I}B=Jh#,@h#,#, D4)@4 dڂS@  &#-S1'4M6Pi#-#,4h#,#,#,#, MlAO'MQ#.g}~‹nuZ8biDr:ƱBaISn\m(d,7Y4HiǍ9g]l/)/ou¯u{=W^ck0MurK]]DΩsI#,ՎZorb@nTW&̱0Y/.Xwv֌*te (C (`)<3XӒҵTؖQ GgM(DlbM=\Μo(`68Qd&ykEhndAhH[#,XOPtHQv#.20lEQiƗ!놠uJ&%7TDmM ђP$e R{zY]J4~nVXͼW{S˒n:(#.T w_8b#.VhuMG.|ү0tqy_$1"UȂ  qk ݉٥FGU/~^}37Z#.}\ę衎O9rj(PCD=IҼ׼vSƉf >D p5hQd=neGce'!ڔV6x17Y;#.X,x_(؁Q;5͒87\]I>l5-!m{a}.ddki45ڐZ|Ry= ޔ//yT6zXV1g=˺Eѯ̲ocU)pid+ڌc:JPXb!!qTRu!%#- ȫ:r&/(O#.Z$"%(Tr{/3`n3u돋Kd޻%_){CE#-[hcFrk@t6?J[Y)'HZwy\۩2FHsAuIk8JD'mi)>)#.T[Jkw۞ +zӻ"#.I ʊ90 L"T%]6m-8Ւd!mL:t±8WЬPfG.DPd4!P[VҞ{7I\X.c'>3B #*8cn욡/ڛF.K#. 0XՋ=4Ɏn9^QmOÎ1~+>[ԣ6GNZc:=S(RUPMxZi*#-|3 )+0j>,uiUŚRoeZwf10""`7Iz38cܞ{󿔧gķC:}xGtH6i%!rOJa0i;[6әW^@&4*m?jÃPwFe{m( ׉sHvYQaG ^rf4ٌ{GP"ǘ"䔈et@"0cm`MgP\k7H~ʹ$=8g4cZD/w9ûAr:Z5x܅@ ,t#KnOvZ3lSNQ(F ջ#.,ԡEAbpC@Km"1wHȦS߶ǎd(s7.6g}zq)8r3 rnEuTvy #0-V!Op~GjC1_gnǬ0Tp'O'q(!2 Õb;v?Uz{y;ΫUgsq*"Y)OdR4O-ruMwT R'̖ꮍTG264TloA"=19'4bN,$}m;,&.!(1/V!(=GWQ$,>#e)|{{{4r8pXĨ&m+۾Vu/*'"S+W!]C-,g3#.~uWTZ^3X::hʚ'EHdNEQ褡ugi(:!*0EႱ\cz:y5dtܟՠ_ž AYS;0hwT$ۮVm7K!0X*ӑ1H"bV?o+j}zː̆% 08J z*B/6g+,gNGm#c]T񑨲qI#-2oT(u,ݵy f=5 J*.1"h<]ϚvɭEz\4+^̏m-#.ᘈ.O"fwgFT!c^[\JnƼP`iMyBҏBn)jE}kf=zgT(AUMh&>ܭVBzLL4RlӌK|-xQogʩ"Z+\#-{xqzԱ/uZ Ҍ(vߟ aO}6#-NgxAX-e#.gkXbkE+RjjM4uF"s$hA azY7mw[$]RꔬW]I[C|2ln%P֪.n97I4w_Duk[zi'CB}#-+3qS%#+ IvTAPgTFBA7HA92ԻH8:G_W՟˗ .H>4VHB ޴WZ(m߅EOY狒DyQM~./^9^\/LK5ASwGPJbZ9N 8U )duzԭ'1Olrwfzk?=3Xvbä́tY'\RO+dЩeКӫ>+#'4Rq#-VG3B6Kڣ{JN'zݼvҟ ]yQ5#"*L#I5~J#-7. wJX;6S$+K7{bJz~;)-K?#,TEeA#-)r%~56[#.ñ=v Xi4)F$)Y,m %jjViIL34IrF@+Z+Ubd^{+ݜѶ1'}8Mҧm$[Sl#-\+}iMSkNbΗU˖ݻ#.:ZX I9u֨{]kfH%_zB>cܸ>?ZTX%$AQ,a*5ҳmTxj[3EHPuxW46򐄚NX:Gt5~oWU"\6Hs8B@)"<|f8W{q݌">JLjc :?D;1H=9ӷM=/p)P~y5xTMY1+0ش䚬?3ר0&KޗuPL$IeVv,!Scxt44AFܒin2W֩{3.i$C7~~7SjLc(avMWl"K<]E+I=NsIU9*Yl`UW?wǎ2:-s? #Ϛd!,Y kUkLTM?I W?F82|y*T6RP<>_ǀ Bcg\ںىu*?/=zSR{ O7U6춲۳:wogu7۳lu2B3s1ƗdrU f&!"@#,?魙ޱF^ ~i_|8ZM-31kr.#_y1PlGDov#,`5Tjbi]}n31? { &gqV~U3JߟHU|rR#E 4B6vzgJ *C8 8;uN#-µJ$ݐģP[48y爙Pv&#.]%ݾ{e*0yD0b!6;7?aj/WRo=A_^#,O䚳(oovݛ}RV7_y 6q,{fWqc_Ն.9hU妟VCWwi04luo_j6ݿ"k5hA^6p"tf#-(R뱑}2hߦ3H&WK()J:)ŝJ.i?\I O"' 8eЇK_W}I꼮ܥD#-rI 2nz7e`ﻳ iҵVr;2^A3γ2a`3+im N:s?a 1KC-jGr1;I$U-#-NrK/hQmDKmIyO;-٦lpx?f2P_ fzѝVLS3Lgs ]==sh/.;h#.:n0-Ű2kICډ7>Nb0݃qae RqvAMOb<jqrKNX˙7;i#.ŧ eC%=N\sm垻U,~rS-WMfPmXV4ݯ&6B>6C!6ѓ9IѮ=SF9g!8y~W4bcQLP{RnXgv^W NRWΘw] ԁ)jl"kS-~\̧(yuZľ0t>=)H*$[FM% __9<~`lV^?FjXjeLf^jEl3}Z!7mxM:#.[2w$M( 0=[a|1>Q=evJ!9*Fvnn0THZ0G( kuf"h4(Lpn [Tj1TSSlsVz'y|kя):Muܒɞ6L_\zkRO$k;A:p#]SJp蚺8m+3@\Hfŏ]ZdjOj8|^שf/QxmytF{z{8ͥ:^u&T碑rcSoN uur#-y#-#-h@r)YTliɏ9a$4C nF)GجJ.#-KgSu|j!?bҾ<܄"͸+0Nз[F2 p.4MsCWަ2X|u2Fkrέ#}Bٜ~Mbh▴1 yFcS뷢ipAքMP+eU@2c]|TCBVDba K"YI b+EmT*LPXL㿧}Qy`SICI5zF5U4[55^ͱ| )PcdFlQ5Xcad={ͺ_`\fo0 m2~=~lTk=Js^^pUsxW;nnt_h՞LŹ?Qʍ?rELn}jzeo].nSDםy zɕgՐ=Y7YѪKUn\ad# `/ֺoV#EZq䬨5Ơ'sRNe;/6n{XUrw~JK}jB %0Bq4#.-K1k80lAi7~Wlk.?Lk3I k i(B$GHMfӂ\c٧Ch\pJCoI*1h@tKǮ`RAw|t^Z]~'Y6aB!0BoQ~s:فFPXs0qvr#-5LMUpqoqR{^*ͰJLVɞ2cK^nX#.-ZZL 4];^j+Szڀ4`H"T6ٛHiEEC#-*"a4l$XFL,]#.a m$41&gb#,%YME:AO5BͲӘƿ"jd#H.A>bъqx!3U}G8rh]W#<ʌ=s'CZGzIVɩl\i .kY7v:- Mu~ÃCu N酇RżhT@za}2ԙx_Ljc|DFyPFS>JXy2?b2F=T}/!;W4et;glFSP|xgEOO-0wC]^!}(R!!]&VTs3zH3]jI8dTc5.\kY#QcOm ;;F1KXYR vĿ^[YdKh9n?ͷ.LI,ʄloÏ[Cq#-UTq'=1#\i%wϴ?7tD0¦0ْ#-,k2]ng@vFEI[)2a|q[l,c,Bu!{NU1PМKdQDd嶹ksZ-kLәnz/Ww>~ʉO$I(dqRpt3kPɊc8a;Oa=A3U)T9=//?z~\C)u=s}JEuaQ6Wk\ڥ}oى|]<z2^8bدV?>a`ٜfH|Fxtgb 8&,b% JV^Wϙ~!AH2ȬB68/Ϣ&c}Rq^O<L)WOzox޷9ڏɧrk3m$h:&JY~.?56(U7ug۷;_Jç;0;迤B0}Ou[Z$~{S_p>&Ӡݡ|^m7C{c=YmDl%&RźٗNE|ovF`0Mv݋ Rrxk}48hT?wwN#-XQctFTjKe꼖ciCM,oC-# Y)#!Z%#-PŮ&ӧT3׬HJ^k'wH!x*4#ޛLzaPXS-IdLHfʖ%I8M%FuB0joreGXFW ƁӉI1NrUy+~>74DbQ(Ka=ኊEpB3ALCػ0 8^G҇d,">)< +Qw`DtD[Mub2؇})KJZ(FE#-LZ|Fp|=ž: Һ}z5N*bꜾs!/~>|Ih:n5w#.($#-KQ`tEi=uV5(ҧF}7#,l8h7Kdၒ,b曑TJR/ Utx.կ=~#.Nnd6zu3W,uyu \l}>oUR <ɼ*?dU2|<&|MNF/a^lm̎O`ؼ1Dnm; l5@B:'e#-[}4~aFpoMI##!OX&wvw q5B4d\<6I8P`__m x!+MB)9i"e_}TGC:S< p[7\Jwt5|RS_b40ݨ#uoމ=^U@$sfwXO4H;t(sY"ipڢ\<~^#.|KPJ#.@. _.JK)%'_ǩzș׃ac%6C% 0|40Q|y Km #.ySֿѷY>nV}7>hw}~1qt[M[qO͑~ݺ?ߝgңGNKS/FeSqJtiqE?#.kbBۗ#-DK%OH)O9^xz_Fq#}:p#_ GpjwL{׾G9я~^ѧVk.k_Q+vUs/WjwF9[:k]Y7W=Hy{SO=x=Ҳ7U᠖An<3lXƚ^{'U׳M+w4O6W."|'g23½ⲩkrne ~˴Y*xE+᥵y~΍s)vҨ*aIJ]O8(m!vOz"~ni]O7__qls{tG.mr+˗.9-wL$]a"b9;(^')AtaV ڏWtw}ϗ>IcR4>=\UZ̦`l$җ,zt[t'ԚDnoe6a!a^?f4n*xK闼0߲yUWsBkno˿iɯ%~4jS,$UzpaU|MY]G}+~bߎ5,LO;uBvc:zU[M~e!?ؒYw^Z ݓs#EJ%=lz}M4u!>{_F)#.R[tW/W(l 4R<9~,@($2LH-ȸkT%a __y_OyٷrÑ~kݤ=^mGn[=ĹEvv>vn׷A`Zz7>>zGQmq>/gG_ 6۳ն3L:~y/7P|_iIQ{ŮC?)G0{K\Nx<|hTo57aCг7s1lt|f.8%#..*xz}]2īxم?++I 쥿/MX_?gCl[Nmgc=>uZChwc/u^CV5FwI,4`w!]=ՋV'nj&]y쥖՞ |[jUO^vhur*ewJsvTLщ٤q1; #-${o\=6ig?N?7eUxumM$ꪎ?E+[ThΞ*|Rϻ1,%04The{뷉96>jڻ1TuȠQ܏;"'Q?v)?Y7qՊ߼r%u7퓮cawY(F$.kmypo6v靈A,4goFVuh䑫K$#YSOW- uA&\YЂldZq`'/F1N9:l.diۮ~v=Yu7HqgH:' q(pU_NFY٠%,9aی/MVS>훷|5v)^/!y,*mO˟w%b<{wF=K|io/|凇i6ʟ. D"{³,F%x{Mfrf>:IO{-TSf(cω/Rd5vgEl}>ȳ g]HI6o;~@K2Fې>xAZ?-c#-цF8"hQZ*Th#.#-#.4p(i09zkmmn'H%J83!1b8&LF"ʚM&Bi"F6ca4}Qji"E,1EYY(?B3p88+È`5*&=nqa#ϛ| k=P]y!wͰ홝wytSo"FuûG> ^۳OFU}uOXc]Yy-3ׯ+q:vr8l͛rhSXޓ{t0j?63EP]%2q3n=9ʕ6GNntfT,軿dIfy^'UbA޴A쿄[UB ur8xP` 2֥}Txd䏫@]5^|w哖W@qlRwI%kNrlhoDֺL/{5CK7Fa qy==V]^ȱR()dY*MLn?,Z&uLDRD`ZFXŒ~&z/hx(_W5A5#̾ve.\ fWrf!#6cLٚ#.<ɁHh8SzYޅ?F୍c8~@L2 !5${ՎOgY_߶ai*,dk4mŗiu1Fj<\#-x`ټ93ƾ'[ZYI݆碣D̎b%۠'/'~8&tB*DԮڤldq!حOf60 ea\LA|z}^ϫٮeP>!#.)cFy.ќT{1,R2/h>LgdEbs&X;e̳Sl#-mI%)Ќ\&Ra<~y> ĎfQ(醫/9ԦgLD13C]Vӎdžpo1 %2w}x4\k,B~ǣxy=pe񚈘1k-O>dGXs8=)ipg!5I!t> 5}lG@(ooꪔ<\0މÐ\ӽE>z)n0L=~电EJ\O/\qeֆ ":(eCD#- SU*:9xo% d.c Kegϫ]5G?y3yKI!5(;S#-!LWef%qiyȝ#-Cum+2Ӭ\p\$ryh VOJP2pZ=$ɴa*K; XCȇw3 5YVH00VQ"9ucht㣙redWR[%BZ3 Ĥf*)՗-l6^[l.%Qhp70k2cM1D;%ؠF,TRN&׵YrXMJ\fd#-u!jN\v8kmTS#H9'Mz6/Ƽwj20&0w"#-d''}7{-hxGmQYROn<@ᢣܲZ欞HbԿOը_t{քLV𝼳)atͅeI^#o81ϚJzAJB.k}x4lմrJ^N\frDT{鬓%(N>#9o]]t?sM֯1עa;Y*RfX]*(9q܎OQakgd1uu{&XÎ΋%K \^ѓD`aݡk#. {7Ac-0t\smkgE nkm[exdhs)֫vIhFVӉp3r*^6,<8z*惺yefk h-(#伌WC_x׵]D5覐Wnov^YrCq.ڽbx'sK5-ZDZ]m|m5(D8Eç{3KJ6ELóW2#&b %ZqZTvC A&SmUDc##,zRvqu|okt/g92!!(rD줉iAzlJ=wƺX>bG`JǣG#--֭ކ{QHa#qbMƷlh|㝶{eL\+5MY1Ȣ^gan=eYȂH,7wDX"B$ n'>i;W,$~<;5~C7Т7=$oeDWXF0ꙖR{`X#.gg9"[kQKczKo#.E1S%Fj?>Pܙ&Ht41R%31}qɽe;O,v-9'!̣}܈Inh{aЌؚTyV驵3#TDMQ"I:dCTLTzs-V?ݺn2}Ծ)$=<86°fC!~mU#.F%Saƚ׬Z,'U-*s=eMWN'إcNbQG_MZl*4;ج?6Uʭ-"Yq"DOukOsOV#-b8 -ڻ*՝Zm^Þ _e}F٣)|g=qH$">\tzo>#.ry?Oi1Y ]߅8>@lsoXodϚumUoT{:yz0Qn:?ʳ4@SyǷ\q2u#-+/7=wX6W9:#-V_BSG}|ޘum*z>KgE+`s+MWMyRl1Uv95+m ЪeT"s35*qWhӿ]هb&j'#G95T\,+Ϻ<\ Zs.A$ NHvsn4|ͽɷ#.aQ8)emVp)qE#-{c+<ڎٺT"uVNzgۄ[dZV8=3:9]\VNVwL]exO=M=I'5}^G_hCjs$=/1k}tQXhQ: ŋ Y/X)`<[{]QO1Tb#-<wg_}̿.\Zc"_z9y^Rti H\9+__W7̗.kdƼ3ZRvΙ3]8O=d?1ӣvݍZќ"qfJTn%JCL;L'z$yaNa>P jΏPMuKD#%Yڃo$;U8\A~AoDqJ&9J(VuqS.ݩ:úi)DQ~#V;p t]~+%}7Z/]XDjj+*$/N$©d" .˜#zv$jRl5ó#-yfPݮ6j%YJ-79$_¿n6Zt\xƴ|wE܈F}: U*UOf5Dî/ݢvZRُ:s.Ҳl[8a+[TTbW]~7Z;xqmuM_O|#,Ey^Knwc{Hs_pJxq?Q5ޛϮ5د//U?#YACIt7dEMotʻjlͺ?gJ-7٫EݝК÷eŹO$RJr*zweHgQ%;=%.cTGҨcm,Is`b@p5"RȲA걪>mêb)螑CM6yΐ?=c&˗M%ybC}+O5Ӧ5xCu`nKjg_hsŹʎrR <"atۋL?3rpeGid'ǚYR1tZNĸ5Vϼl6gM#qquMxїo0uww4BHtн'96J^Ѯۯjsgjw|!N#ԡe0iԦqF/in߹ջ%-Hz.|s*k#-)E@u:gmk#\xys7kS"4T("hXE[njMst@=0aRl{}d9r"I @8|?| ]ej&e\:2S4oێ߇siPBWT;~-d`Uznr9qQ#.y}6R:\|ŏbwLUR)wq:i%RqiǮR׮֤Rl"ٖܢ r&\RBPI֤#DJcˆ>-ΞK]MAp/(]QyG#-Mtx)/UX6`5ڣVֲ-IW\(S(<3BFgΨ1'+'-w:|MxZx9VڙBdB֏߯/^⹋?MlFmOo,MΙT^yy^6] 9P$Q-"6V5LdD(ߥYs׍FZ&UoZXwh*YKiZ!4{m_Ñ]s- dZ|'?xr4enYZZ4?XlFHNPzHjK?1f,7g¤d0#-oCT?t,H8$*! qs ^dS0sŗVpljkEhG׹7t}OlV*4;Pr6q:9wXgɥA͉_voT֎b[pO^ok:ڭ鉶gOmR$*O _>yT%8 !%O O?сcHU!oSgIZԤU#,@dE}iԸ=7hmi9}/w'LRI┎[G WtfuSƩ-u/-.Ct]XE VZʏ4cjAb4xj7JPV/ͽ6 *CNϲM~I#-Y#-P7?m(vQ8ê(%k8%D$U2Ⱦ6TRdI:nI7#.ɘC7#,l9؀W#-#. -D5U`~6 N tu@Nc)mOƔPS*Bƽ; {~{tyʆЍ(DƷ/R}2AsZh\/O1#- CCc B 9# *1,y9ٴ(9MK-=b9L:po$xYnU0)HPd6+3WxQFQ=Ķh plD3T]%Ȗ;_a ڂsRags\xU=|x7ZFyޙ‘*bī p#,opl赯WO˱YiR+7?vOsʾPy0~fzĤ!XdlBV#-*T;=!ȿ}wy7vΥkEOfHt#OǗu+Oy-2ƈűM{MC!o ٌ"J>e;ڋ;40Rb<VT3ʗ㧷|<1eVM=CRI̸vU ?VlzetwV>X#-㺫Mj|z$kbW{)r}=VXmW:%8IRnmz+|wYE!H9I5{lF u6sQ\VFzr1k`aX# 0IjvlbRl[*MvX~H7P5v:|1֣V[_u5l-#-GL7)s$-(ؑ om_#,$.ȝTf֘? hָJ#2M!xtL]UE##Ͳ #u`F84.5PiDw'WLR#-&OƖ#6x=W#p||P58w#-oR&@f4Esσ^Oe;"Sf7Aݼ@+F&&S*va[V:w_~2O*Ƚ4˝Wy}<ᎎzn3C,٤;ʺ&AVg4xѮKax^ܲvtm}wF&Bƅ]f5*묨57ip&G%&$4?妼S+uqG`LU9h[)+'7wG` (PҬ&x!ۂ&^ٮoj1oyt䎝s &`MgIB:ŵZdB{ͪ3Sd)1l|U~;+i"̓t--h5V{$SC2CC' N謆3L:nm]Ѱy㮼4hlrdA!.OmW̔ׯ_t_(<"Ck~z#.GDDa>xyî#-i)*(TRkwLbwsm'VkEE\ѐX i1\}M6y&M(z{8cV79SUvjD0U͖皈@\׽<#-g<|Hó_#-@&pA*Ӷw:'" #-pgf5hwVi./_WKZ׏=V^dF%c62 PD0hf_S >3an>|/7Mf{c5iEKaM?#-~>#9L5g^[8:%7P:B5D4*ڡDTi$kԾl4"Iw_m؟;SBM4Ƿ'-t& )4Jf+4}yyyBT@$wsURҡ3>CP:+\($jz29/o1@_##.׺WCP?WnY_[{~ٝ|v:믕dkpb0AWh]*>7$T8>c rQa&9΂ܛVcSG1b|EK||pt_Mo;0&HK%N򍴩E,:R0!f0cyñ>q61%0Fs F5cqbK`s842*:Q5 3Õ_!Oʌ'M/kj=iM:Hч,";2%^ D:g'"&vVz#-c]#r1iQ5t)f}XɰB-a6!y#.gPdt n'h!a<kL Z7}bjD,葊#,@֖쁧fdH0 K#.PYb[#, #,쎔" /*v%ɳvEXdFK .BO`CcT:-xW}zwzrɗϡٶ&r@&!S/HZrk>oL0$㝝#m?Ccv^~BSOKRlrff#-T)BLPEK#hWYTtm"9T3z90^\VZ̛êb^…bŔDtIږpMڹ5`,MDfW[iYVx܄p݆9#.­K8(Z+e r[ƉpL`eL4/rU#-;he~7)#- |h7zvM*JtHtP#P#-.O!u{a՞ ќfݬ8ZϯBGϑ>p# >p)*RD@$X9Kf:`C"AJ*gfź`PjmG֎u$V,ՙ(#.L &0 +Ī3$Q@~#.2 %/L?k8Da}#.T7Klyʸ2E0ƦAZN#w=L8.NuC70HRI)PH QSD&QS" -w[km9gat] Mxca 1LʜC^qɈ!Jt&@!Lhَ&Hx>ݻ[t0ϕe|;R#- (Q~[xaϐU#-Y'i!T*=um0%@P!DDɿN\d7N811 ̀1:g$3#,c:gH#lMEV+˹>*eS/^rp0Qa@יzKͺ 뼛&ؔ*% It,^c x6aB#.q6,$R+ĭ)RԢzf޼;[!}>S#,RH,4|BBdQꔳ+?[H(h4ܺu^I=@X)hZe3k#,`Z;#.覞Vhxje9 /r$(td|jI8}/NY' X#.'7#.#-zuW#-֦2#,&#,pC0h$w!29ݱ4T+%@6hPtwΉIX""Ad(yy.8wʕBd-,简e͍.RuD^NI m.oX]mǍֶPP¥7y9mo!@$,]#./w6t#u.C]?bCw͂J(!2JNhJ;N;YL>n\־\~81>Q^E3)J<,t}} mv"Q^y)]!$ﮱCI”e{}2ٮ8I#.fTa,?|Y;t5mNN}j3'oO iwW\ azHkf#,(ՑH#Ҵ#-Bl_t]=bMu* ƴ'{1[0>zw]vQ˒DD-a5yi^*j06CC8iT#-R$ G݊Z0:먇)2*)f.UXd퍂ݶdž2 .󭔼?L}XKK9loܷ#,!I'D΍-2*tA"iV0#,;1?eZ#IŁ]l#-uM}vl iݝu:5DyQspX*a #- cHvTgePƻ#s5.p$Ъ+KB"A}5IfI)L|!m},L<)j%6&M"e&1pQ uˇa-61: \·}ڤE fۙ [tӾE'"t$DNQ\#-}]Wr=Emь>1D/X2XrFz 8]ImAjkCnBL T}oc5ָnkXcr&+"'),@J&{ 0^P#,AAqy}&G;=ޅP5PvY|`iU#-22jMxmjԝ僖%䔒4B^ě\AMC!cTDBTpc .M?H? dp"B6~94PwWBST3AID*SRM$Oϣ{X6'#- >CBl>#-fnIa:>!$a AJ&GTs%sۊ,Zx]P{AQV1кugmmڬoz<חFKYn;/U ߞ:sT=kxEKD:ATOUDn_HA&y&DCt=aVH**#.,~?N?eG*cΌdg:EDb#,5X3YH4?AT#,ĔGÅ/R5<͛10\oA/hhblXHB ]ݵ+Zr0jP%b4A},EsV.M[A6'@lfb9.H|;0GvttY'Ms;30GZd5ٙBR=]Oj܅GD'z). uu)Zy~I> x3#-Z[4̆;(~=:_3t$5x>o]p m8<#ͨYǐ7ݺR;J{?V/v>S< 7*LC02Ty9F-W.xaꊡY#, bU駪Tk\7z=>Ve_Fϧӻ30CsjqrkNg&t? aWbѶfIFtaԼLӊN8o\SΨS#.$>ה{4E#-sRt:.Ge[绪}~;4#,&Ls k#-Ѻ%*E9+hI6#2nIbI_g5xӫO`o~F?۴i` @n՗LS!WIYI(V|:$*30mu%eTnRysn]ff ̗g?Z*p]Y6fM)F-omxOB͎\Y64>蘀D/.63~w_UQ/S|1EL3yYo;LU>\4^VZ[g.#-rJq²8y|\lST]J<#.BJ)};D%َ$M9rA5<פazG$YA_Jidk:߉[şsUn a>BQj_HG-BRTt^on$)o_cF-a/b + oBL}o:>U_7@8%'\(u%ǭ~1qOܕVN!ֹm)AxwT#.ϏOw,DxcA4pynPΑԯ+ՃSgh2cjH#,]8j|W3"^v!Ħ G=N'z9vtB!ΫG2}tPD2+ޙ6xtNI$gosl8~PKz2΍jtAylU!]WJHQVYK#..R?inO5NW<3휹wa+H :C~sq^Gt;>gOu!!l]9&hԃ=QI'qN ng˺<ozpaC^~J>]Hě\:&}?5EEc<'Dpy7HBB1Ϥ#-]<3L{'I9o8=zX,F73+cl:| '݈DBgYqXvM.eԧZɧS9j)RCy}TB%H Qgɸ]@On#-nOR\rFWsĩIpB[9qq y?ד_ t:nvԿ:$;%vn}Kssݷ;xhчY}ϔ+!t&Z;;JSKZxކpsO6h߫8RkiMLt #-_? od~x}T3ZJ}΍PWZ-!ޣBҿ侯G1` L<'nE$=u?:j&)%/#WV`R<8p}-T~#UȘK4ub'"w8Wn ,:N^髑Mq(aUïLkf#. _#.Io?(N8DIm@ysTcmYIڎ͋K1r`xzh0tP>L'6Nž1ѧ[ZXBtKJITh3JS:Fё4{>2RܝF֥ɿ˫u\tf#,RW¡#,PD%*@#.TQVѹ1%̚i#rӯ^߿9[Ԃ/L#.'JU|9WEc8~,AkIH/V[b~Z棴hO:15OjfEr~CP<kё!ڟ!WXuu]WCuj$ei$ᙡ/CU|y:d"#-~j}ƿW*U'* ?UX-oQܤ$D=~Z{yU׎/E۩Dߘ4~>of@'f6&#-0*;~7E}>P#-ZxL!'sAԺ#.p~4~Vyc)?~3݆*ڼNOjr3?f[L5A7 5!V)yzGT8ujliPtX9$AɁPՏ3뼝RڟJ&ЁSHq9 Etr'43ul%9#-=}qLcz;1? k#-nn5n+/%C\WkT`M,ι*)5|c8[ߢ>:#OZ| C(Fu,j1|%ݫθKӹivATQB<*1OҳF4{GXiscĚDK])bW#,qU*fW+ OwXҪI_I;jULAd1[?}}PP]bour ڐ-#.6;CYϿ{ۯ&>_..>t{W=.>Xל|0ճYI\Ɓ[+NƊ -D:r<x = 0ycV1<#ߛY$g;c,i+И}Hm8)6('V3nTrnwU(ԇԙ1(TdTH;AyZ\l֍;RT1*ضf*÷}4v33NmKoYJf}-q䝻PSLV.u*knNXp.x;]ji[Wbr%aĩ)}TB뿺:cm]?wu9~g~,Oi}#^1&I-HZqCh΍׽v[vZF.YNTsjzN#G^w gQ,Go x+xJo(Y3%=!0gP^qfZQ#.^QBoM^i+iwDlvwp/#-M:h0|oQ>Mq/<^w2ܜ6z}5rFf 'OOEJ8";߱-W}9=5tblLad*EQ2,^ɐ{t9f CF_TLg=O(ՓEbcd#.I?W_pɠN?<ܫG|u#-Lo +VeVY]}r3}.@4ٹM…Rk̢ZQ=P|3tfbE6jhd*4rVj tل}U%\(t09et+oj\w.V#-1mN(8s>?Nܷ6K?ɜn4G/ۤـj$P$,{0,[^.wN*{32鰭z#.q0sR`z?=Z0j3y;,9s;٠yI$ȵ3u{hFgUTVKsiJr݃z%) ~taOMZ^ g_O.z=;:l856EfR׃?]~HVYdL5?;[#|Gﺃ;N3UU}F?#,}^ 0("P`k$:p6 b?UU2XRGb;,J,Pp",#,HC)#-}C~OfH9&,2?(?@dpܹu 746mwmxXҧq#$U$Uut{*QJ1F¶@[0/##-qЃ:PtwdK>@Q`iLA, .'R%э]@_4fm+XYի$Qoz#-p:z?FD=#,WG'']G{ XgT=Z#}#.o $C#-O]+OԺѦ:+Rmoz=Ss?6q*IΘtѠ Q&Uﲻ }=@ߘ-$`dL,I[cZ N#]N "m-6!I0x,ܸm;t ,ćlI#ʙΖEy{NO- Sϱ*@ $7]\m&;,9dh|>X.AE~fdjzE*;#-Rv=hWN!&496()caP3}D7(~m C2VjpfPpe譢vHKws@4nUR2#,qDBH@!`r;˙pjh\cpG\9ۚeҏ=M|濯vn3Gi6+YwI$|ӾX,uո8g`#5r3^w;###$]޶:rXa#-ѺS`Md4 0䍍\u7%%?Y6? m4:'_uIPl؈T0GtP#-槬8ϸq3|sWñl^6M]8~S8.(yv#. !Wo1a9ewYu6/Bjh\1!` `Sq }Yo?H]hh(u6#-:_a]4@"#-Xyd#-ul>J />xwI LSi?^J#Gi %.xda (]g| 6vrƲt϶;n\BSHR"D:jS9+33R-Li+:OlʰE?U wd8 nXH~tp:AqpbF@]0GisF@|?Ţ8"}.rrQ3ΗI0v*d9+7wuKX7SoZZUf5%q$yiI*U&I*mGQ 26$L:3c̹RE'@cZpn*YEY.6plyȉ"&`RdL^Y?ÁznǍC, 鱾~8Cpf3 RBIC^PSGgw~DPOO'woB2$$EE/ _4#,H\! MTBދX}QMNqFNn{qmpP%a$]ìD3 HX\#.)= ن8e'~-GS_PZfYz_NBd?`PF&Ad㾀հ6I$`E$C~:iTѴă_+0e}юwcvٰp<1t#.3IHr9+r2 a#d?hsgȹO6R(uɱ{EK5#,7Fób bR *&GnϚW һt}\?C;I-I-)?!>#,Pv@7 #-珨-#,ո<ѾBѩGQ #,ž{8``ȃ5bda=H"${5"FG(㐐2l&c;!z_Tge >QZ 6y=X2~=P}_&8^oh D2"E"`@~}%gj~ϥ5kT fA+auLF;`Yhp8= 13Z6 HGUqw!сa%wh :G@l?=0* DB,)3n2(DЇzs|~#-@fzDa՛09@tK^M@;yCpHLW1ɞu{C?7{o hfv5)YSjv!!ɒI'AO 4=bڐ-r4l#,Y ''#.xð-j;ǗZ}MPDVֺg&(Q<_~h>,gJӢ=dMéyW*'PLBִAU~B]:,6ԁƪ3Z@j!ƯH *%f ԼyiH}("7&Ic!Dč#-2bD㳩BC+=τbf#TLH`%Ǖb;o1`F4#-5rbfe}Ɗ IkvhHb2ޖm!+M-tsOB#,#,?8{,b+!P]aCWUګbU6Mb Cd*#,BjjZy矹B63;1Gx#4(G")aV .jO "SyL|5J#.$V`D#WHs7d&:ޝ*:W& yr߉گA=&FfcKjgvZv`P#D26mkU]rj.!G#,_cxbߐD q=ϰ(8.a$ZĢf/$5Au_R5FL@bDH@m.YE42/T#-5@uw ټt'-W^|!5 ٹ:m\ 3w`f=lm8װarh='f!y`MVtz-'Qе+Z$'pl\7tJV*=#.P>#.a%-Л9m  {yV_WhAFH4!ިTycj_U #-M1@Qx%H4#A#-frPd6>;>al#GFIYC9F@8ؤ*7 P>=~п#!';Zq׳j( !lEPC_.} ƛ)WJ|7#- Y1da[$",ܧHb-dl7(>aE{2L*#FAA߿c&e$1wBDDDJ*z:X\ЎM3Q_/>b7e``% DFe&Q[Dpҩ/uAGy>ac/g%Z)`"sy0oHS4 @p?$.l&;Cp`6K }=G#.t^:Ck|&r (eLM@ucG/M#-Nhp'>ZEX``,8}a%zOW)%lg$Ι6'~n1BeZROegu%hߗQaIzB`I4B1"m0?eZ8&NvѣEE5s ~xuNmYI俴G2OTaɷ~9W3P&i]8bZcN,ߨZS;V:MgC{D]#.\ AFi5ר2(:#,D=0I1"B =T^yTLq؄rƃpF뽸Zfa9~DQgj7F0rB* J@m#,Pע'd+=L8A*Z*k׼u:# P@~Ԛ̵)T=(I㥻"g#-j~,|Qx{14_k)#.)raݘINWûNrMogo PE}r %X=堥~T1#+#.~i=>yٹR$""sr=IyvT6h"7gU.trf`?@\)fLR5CsV] 9؄#.61FL+hWW|?Xwؓ!bѠ#.C儴tY :øW;N`>6(1"`vg.ϴ ,mFMyel^=#FXbi|c)ШѨȟpnߍC mbN#-! {^.B93S>{+g$sld +#.B,Lf=f"dK\( |l3BeaVs Ny"Ӟy7.ƛR3Z;}Wn89#?ʏ_~`RHWG!a#-&fHwS;Lx Bd8aMdW7w|)_ղ#m#+wg&יA=>1,e"#UcU$c1jCF\8Obz:<*@ D7~Cff%RȯTeJ}5Ս^w,}/$>^ ܘ=ae7@o#1x`F2=}KU۬)CLʆtwρor^_$#ax}}vZTT*97ifi)wxd$bvYo}#.[_ä%==| [gsK>ۥ@jEs&ЂAx`Kb7)#.ݍIkqw+W«vd;!#-Zo.q2<5v*(YV E#,Q6#@cdݻh1&1(X*+Q#.h #.5B6 Ph I@X[@@HU 6ϮWrs:YĠoTS˗I1_?#-I1ŋ.-DI@ITox.5w raD o}@ Veɹb988K+XPv!:ov_]no!*l|yн^8}-L\EQ#-B@Ȯ}ŔrD)ZV&U(6vUFt;#.5 1R`!#.^i$/,ߣ$$P$ÜU(jHQ@35T.9G3};G*=2Q1ECD'9y*;IV#.uی&"U*;~92r,eXw{ &mPBB̞ăP$& . ۙ?Gk}>V??XQ{# VͧnҀ=E`;^MvG-P#,f v71Car"$ q֚ÁݲDˤ71i>LWI@o!x'-N`H) DPN-Ft)aN">bPcL#F#.t,e&{kCXQ LED$&}NU_FZ'I>=;iG\;\׾f DL1L.w-/>6E-s.2yW=-WWŗi辙 IB_]scy{hVD20|| j SkO" sW>qe$MVզmIR68ȞsI?=:K%N/8(pTf QƩͳB}\xgX5=crd!WujMFchаD#N]*8CU!&+"S|&8>eil8hVnRZX2b|ֿ濋JЋ^]o('\J}үpo5|Wªo^V]V^yZ<,E.VMmb⎼m}O/N𠒿s71vU;ɏC]ĎD>p#Aغ7#,󦦾hVmα+vD0$^eK$ASʪq5pdz#.[##.;_hf7}9О_7sfG},qd0iW; _i'$'2n?^ʂ+,E]n;6JM+<_}<5uZ|J-Ҫ{mre\jw_S9ʁ炽*'UT#.d,e-H,a.ʫi~&%-MT75mo{ylV7qp~T6AiIRO`{U#!!s^;*iȤ/q0!%}LŴh$rMy-.HQj#-ViVBG!{Չ/n)#.u.dxx@a6iAs{a(nh-+LUd#xit8 %q琔V&#.$F~g5"d=/oo8Qoϗ@MK &3v۠\G VA=0 $)(t`E!EQw9d3H@cBQd.2tKGDg[v?_'jK#Ikbpg6Z R0+R\b ~xuUO#, tiwNLϳk[Vtrcwmfsl,|=p68ufp\xAuhG^ntz`"q.xnԻ!aLÀi#'oM0C]z,|eja2~#.C︫f -ŒAPxT& WY^h̍AݔCCC#-=zd)wy)M+rx]CŸmA$èq3ө)kn(3l51ݿn0gpoɤiWW##.S(T#.6הj+ܗ7G"&- ΆȎ KP3@ #-K~3"ʑm'hԃ9oVqwx4B6S9V;0 QYؐM >itӕFsNQ#,ÑARX7C4Gdּ@t=F5wYGkMnLX[ #.nZZEfBHb5C3cvq*˩3Zvd񞀓v3 w&'9IPHs`cJ`i-۝kTҦܦZ]n':cU>ŨԚ#-hLl#.HnA'rqg#rX٦+r,8{777EѹșX(*bBi90XдC={aE:q c-ʱˏ32e̱eʱec.CU{hi eX`IgDKmǬՕwl^9[6Xje[q![VJce£bcv!.Bx؈'ϫWN.^wV9 gZ5L@yb$GE>v>:V{xb (7ҩ(;l@8 sdL|#-4n5dYm2(DI#P1#-J[zmgXqrAgTm*26\)SMMaplp 7(,BU#.S9jԯ!9l8B n o-#,t5IYH$A3a# ԺRf׃>Oo81&k}?{aĄCH4l'iSȘFlk޲t A ;vSn Sg{I{ #,p0{]]<6=V8{J(@b>\ME3`^HB#.24O8Q))2Qxͩ\;X"Sh ''s4pL#.DM} e@a `okI`qAbM9 6SƱ EAbFM#,ګq 붣]_U CoMTt^M7MZ:#q@cn3\#!n%%=z:LsF (\XL%dS-G[.RSU3Pobe6;6L\)d3:{[x^IU44bV;&aO%P[C#2$́rAg#4)fyd30A QFcs2ƋA+ H&mwEb&zʒw٠VuJ}̍\t˵UUU/yˀe#:zP5tkI] .|~)0ڱO%k;SO#- @&Eۘ)* (h$aCm%A=F&hds #,1y3J/h}#.20C5G7{9$3cclx PW=>GM;}v]+s<~ZR D)}-sr? #.y |{y?DVŲVd?Ցu#,ɩL8W{jfX`wۼG>.X`#)"4$J={ #,C[TP)\I@{k:HL:pK(&R"N2!ADdӂk 3.J5H#͆bوT^j]HB n73@{eXi0[y9У-@iv) l"r*lb%N$#,}Fg@K SͦX6ʃ0%}=ۧ^Uł 1ÀP투㛤$@@!#,}Fi#-#,8" r +$=_v#-11ijPS KVjU]m+#-aM#D`VOCW,jU$aR Ƌz뷏JޤWrI/.Xynt1.SyNr{Ihφ$[/{-þRfHP*LMyy~^!q4iK`#.гUPxA=vCMSmx#-2O,x#.XY%pZFJJZkѫIӲ#->Q#Ӓ&Vӝ%!pU]kE" H#,m1##,`HOg#JKu*#.WDț۲>FW31|2*2XqH)NRzk0`BL,Lh6tj}E(QRN;ڲJiK#,XE"?cffWҩÈ0TNQuKtIחQm 9RSfnKdI~nݧ?͖UDFEG| ;ԑJX\.H0PUQMTdTKclm*USl6ضKm#-)*ZkZ߭}:6ʓ8l|Dd ȃ"s[DHbJkOΚ"B0h;.<22!j,uZ;UT/CAɋ)Րw8 wfϪK0XʰEj"lF"f%_g׵7(ݢ#,^۳:#UB$R#-&"f!"NH;A@UP;6m$x8N!#,2 k2#-QS**咹kli4Z+Z#.AMy?|nCX2@ 0Wu GP|n8Fi 8DEzzuC<`v׿ڲ8=ڈBH^=ޏ]r`:V8>""H_w~#-L IF@9F*vޕB OHQGmێãpCS@D@#-p_Đ>Rʩd;Y[nfe! y<%UO[]~9I?E";A#-R gYnB>'rt!OPd0/4171"cHn,VluH7{\`>tGaۼ6Hq@ty` ٠z(531:ᐚ8m=39j᭹P|.~Xye\T헝^x|L-#.Ԛѻ۠$(L$j;FƘD/O­#-gF۳^ej=/`#5!Y9p'h #.,LL0-OZŷ59 -^4H#-.pNhJ|q# &Zی&(c3JײŐ[)V_2|ŽP}.<"#-ڴiz7QXCɏ~¦kSUEJŗu%[z@xt_]YnJbȢf(e#,&'^Oy`9ln[̀#-̟Ppmӑ@D#-gWdY0:lj:x1ԻK8I,OSq$HURF"r#,9׊0AU)AX_oi=hgm0BH1/W#,gՏDZQzHVY433!3̡`4|cAKnxZ4;\`U}c9"d(rr}MƳ1;z-#->*]}eƫ^aŐ?nPȆ9{;7F uϲ6N@z!N5k7Z)dR&ŮԳDjwֳ[)"ZHZQu"L2.HXn r+vd#-E``4n!̈D (zrpCX톒ylxGo5mq3<0 %ʯ!9'粈J$T&äHD;0wQ{ۦnuD8*גݗz|z8w7z#W$ d zݔύY"B%ٌgb)$R Tj#-֧ hn-kF*#. Pd#.(@ŘûY!ZIB"jbt&Pmbi&Qmo)MD2V(CLQ0 u)I+#t}1}Ȥ>\Ņ4^ȪTb4,JBϧ,|abGW"d ##-L͘$9f( $tfZM#{Z+xլSѓYƔq=[$8R`<3(N4#,,F($/de[ڏ\/l.xt> QFȁ̝v#-D[';Z۶ߗ뿕6ĈjSRO׭}B׬JxvQxQ雕)3U)F"*-mR.XсLL7^ #-Q'b1 )‚fZaV,kdGuW#-#,chi H#-n.=LELc#,^`&I (zGT #ы0@!uEDANs`#!vá$X#.:tBFY07֔ᴛwr66죟^r[`e$f"6@Fa݅)8ƀkuHmh#-cQrJ%P̐<m.EeLUۦMG 6=H΅:hch'qPXP8ᡯ鞍m٭e+ Zje^B'L`6FI#,xݨ6e){wy6y~ԗ#-pe^5@諳`а^NmuIu= aۍ$I=k<¬OnL2"7Ws /n7 3ZyR:'u7Ȑ'Gy*\a޼$3"{Wx̹͜2s#f#-l3.Jo1MeC7aH>k:52x_a#.Q9#,Ms)h,2`W%E*q#-"60;#-WA;jEK0bY#, ỳf'˾U}mmNE7)QcH.jUT"aH @"0@>XdG`#,D1k2oU)@#$p`p.Zq> PVKwU :QӜyg_O4dGMO79`+(ƆӹJq$!2X.`q|6@_г2Ⲱg5'c^؂wEH@Q.vλ MDPv(Q$"yչ&Xhصxحm-VP "#-HPO=2b9XM=J+tKvhR'L_7դWL($)#-6)fK1I-&j2d6T!%4F`d%fH%i4hRCf&Q3#,ژM~ SB$RJK Aj2J#-FRX1JFbRl&&Q҃BF(owC;w~rT/FBJ/i{oL=MԈ0z2>mסhꑦD)${ًn 7jf{&)GiJo\],DE6x}$g,&W._OQݦ͝K#,t!R:=q$اSf>df{^yE.%#,{up>xqGL47fE @@sumy:~ q:[ć orAKA$܉ex7ܣ)RόB sP&x߳f#.(?}^:i.H[}fM32:.1f$Y܋#y Gan0|jP31hH#-C6a3#,yExX uVٶS#,7 Ys$X#.kjS =3u6W9y McNUUx&j=]JH5 @zAX Cs#g^9d<$r,fb@ׇ%.`IF6eEd>c_MIeB#,2"d= wvQ &[OC4ȇ#X3Nݬm<8^jRGjO#]7XZqm&]Hnœͼz{XcY[EYKL\MB pa GK"$WQ*TUtZA>3CӢQ2Z$Ľ&mJ#-YL#/&VWEż:YL\>_MjppiփkRSOyD#S!'5mrG5Tf]iؘ/7 9Փ} A 3LjKڝW*M(p!G*M83p1 43yYn!ik8 Qګ9#[hDwL\9z:fBPwdQjdcmHb:|WvGn{duFpzXV3;1 )2;94`&].'8,AñpEj2jZc)∮vhLQ!T-%8:gPēn8Ns{wy_L려kL:Cؑ263sЛ1rAqwhӴ7s1Â,NN͆˄Wۺܮ;tz)y!AkKZ뽴k*+F0Ql݂̎|˫#-g ,cp'Ks \ ϕ1GQ쵵B%BM>0#⢩2XmEj )ׂ:,\CѣblY(jf+H Ta%3 8.cG #.6rp˛ǣ}#-f_ 8DHY踇Fۜ[o`LouB\#B(Zڜj 2sA%ȇ1Pu刺 V-ghnK`lErd:;{һވUE*bAsI`TiBfuk:ms]!u- #.cQ(Ԥ(bdO/8r!<&*BڐRA64#,OZ"Y H+C ƒTbzoڷ:#-f)a0#,pTtvDT"_xF#,(zPp,#.6qC'`d(Q3K#jb JD:91ieu(v)7f6&¥$,4HB*و@cpFD%Ɔ]L3jݐe/u;̌x7!(#-Fb1eP7dƁm"P#-XA#.ửԔ:"i3m۔߉U+]Ɲ]6)sK--]ʡ%(P!`P!a""h.Z lP8F#._~(Ӵ$Si((-}eణR@вE*yd*H2 UP#--#.%QF "iKI,T vZ_WS1LC̢e6T J#-su/DpdXOЋ*z@LMۨ[,\LҵX6: 8\0>&20]0H@pvLρjA#.٫T4p#-fژq78r"H$cШqC%%AA"B6`B"hMw(l"./N:z\:DqE*-UF9yV<@#-y|3l@DWoݴH HB14vg7DضyEM Tz`v#ğH(Myp /"8r2pID—tj+̸ȾdGŌ#.J,~|IQt_m\:zs3Qze>nmHĖT-乪l e ƒ7V2`]PEяDd#.`ZtplVM)2} *Ci{J!i?35A񯇼,TlK#y%̵GIR))iJZ-QTR3Q6hfH w7z3@J=˒d/l>ͦOlEO/Q|5u*:4$i%b"nHro!P.AZ3pL.10[ /#,#,Z%#,4z=xsо0hܢ H)1EʼnlģHT#,E#-F t8CuW2xmFp~45^Ȃf0b-݈ClV#ND8'6#/BlHh2c8p:Bd#-4˖jZ i:fy҆r \$ny1뙂Ua074#.3#'~D QvɝcC9AprL!#0i⦸*ea2M'g:g"K` A=:j4Y%vfыp Cf0M!czB]F!MцfC#,dXi\$e25#,0]#.lXM#,1p##,K\p#-c7 }2\P$Q(Dm]^U#,~UR#R,WeA'.8 #.#,r]NI?h#, ?c] z#dI$^!6$KkFIH9!j|5ǼuV*iX/<"2Wz1HyçV,W+l7-vNhAUlrÖuӆ8i25uD&:JIaj:¬Sf|9L@&I&2`"VPb4e9yۃ)#,1 RR#-&itŌ7k]6Iig'Tni7!FcLlPJ1c:DZسH%1x4RÜtvhg:978"d,8F#.E!ń)O4SҀ:ֹbFP:~(Q4^,mM/ Ds9(}2YgvLq>0y2!GHC$o*%ݳ[IaNhcr#-`YDEQ@ 1&K mkEFQBǙRm$#-j4Z|kF(TF|ۊZdq@&/Ha Hbz#,Vd&f Q-$J#.)8:9_'53mex{jja0]n`L5\n㏗t'լJzmje2#̐3pQVWioXgNم9fWhXtH:ΦZ;@|0j0q#.#-&P̧v"*U0H숩rlh^Iq#.`]j902 JdrpX2KQÍ$ѽ#-Cx&ɮM;E }/7@e^7Ž |iFG3>6$abIݘE%Ԍ6֒h'$6(mݵfM-#dLka+&Օ2lԓ"$QjmKJԮdOZ^ݮ1MVK*lb5_LJVƤ5Z$%RouuME&y׆Lie6f٭ꭚxJ󫧝Ѧbrjx.6%[[k*K}$n RVF;)u}4ݚL"ۼ}7Eld?OO2r"z!-\0S EFH^h&ӦVm*&i3hƬy/;kTEK[ٵM!iij-BhѮX--U"DM)kP֤j֖E1KII{Zn0jVmMVɫ4T̍Q*&ЊS E#-RD3e3,B%RQljMd6*6S(A4(IjT6jcIJd6(6RJI2I)E,ڥf5I5)eVLXҙ5@Ȩ@ TbM6*mmtMRU  D,B@ 1֋e6W+TD#,"D>^/y]8;uCSx#,JxyA>q7ŬPzSuE.xJӯ2ɛłD{!KW<o'T9'Q~'gf._Kd3 #./C謲(ѢZ]::Лfpo$#.#:a'k,1B2ͥ#.r&*9.S@)Lq`{˭-tؚEIp[##,+Q۶*@#,#,*UFdP*ҹh'8Hut@}CW\ǂa{glG ("oU%SAfP#.sښ,cyJ 95x0dvņZYbv%-̄ɂfХ]x\ Ԩ^vJ04G&&PllDhmBR+tb(YYi?RRdUSF(-Q01!ҲlC*`p(Dum!h *"ӉE?5+45kZ#- )!͝Yq#-`Fbфq6Ƌ\#.&n#.?K0\1AUVi[2KC_7]9qᄧ»ONY6>zSlfA#,v:|c!ae6gux6pAF(!66}<'.T\ rN_i{xkۯe0 ƍɄ,W֊hT ?!Ձ`2M5 ڼlDVڹcfbSeushtbob(&5Tkz[)x?jB"PoaBSzh>8c[@d c:mO#-#,f  j`u@,9K#.]C= >㉠j#-`Lų'X#.kE"n6};©HI9j2H lAכ:ݶ#.kLR$"HQP'r p"d3թ/!<*g #,,:"v-M -B#.HXjyIl:fxc]lv[CN~#n0ђ#.UBZ,sF;#-bPqA".φݒ@5m7cs=)koק>oԣ]틾ʛII߅~1]Gix![i$RDEw?pdEbPCK##-o*ʍ܆]&"p?տ 2?PCqF3#.jlAIc_!E#,aD"`TR*DE!QEflyZ-sZt[P8k}w"e iv oՃ0сr#, )FO< BtǩSͼwSDܞOF''NVr+/AHN#.@Rh$!GpZ@0܅E‘QVHW xU%| 4ݧϞ QړGI!hy-ÛGBak:#7XG)~?#,#g@!ՐB#.B%#<FzD#.%2H̅]WT_V[(*ET?e2I3RXfJ/k0oV4JaHBscq)"¿*Bj&V((IH.RlS6ڲ22Cv X(jԺF,1FBCM֫c෥m6"4Z@ƌM.)u5#,0&P)YeXLT -RֈvIb.CQlm62+b \ir#-*Xc\@oYGvouȌo'9\Iwmj2z8Xb`AIP9Zg#-ZuV%o4 3#,aʎz}bZfgnوN" X3-*kD),$[.G@{zED̚mkHVEil`Ɔ*5f)7ՖY DRF1FDELM61cҖ<Ph:taIAc+I!cei4!Ǧ8އrQngql#,TvtK)-b)ѝ A#.l)!H1-J&Y!T4*]T$u4Fd!kd}A$Kv*5Лrmés#,dfٙ' f1X_fSir%:i  !!e1iRAP1D%'6#.|OYύN!Տ#.KmTs}fJ]Jz|(,JY0UD)-S“?Bg)F#.5u52#.,DA^X9٦h/, ,{4 2Ő(@DQRToKU3sM><. I Thko~uM$\MY0r[RPdPĈ##.ɘ1m5JAIvN"bG p !)%(iDb7\8=[?Z G`J8h&jq@83)e2WuP#.;J=#.%C~'oكS^KJy8@EWHlє:IITi$׊N5ړf8kp'0axh%neb0k,#.2Nmj:3:p$A%,Jt 4IMuV^{ٍzt1 o{Szk0,9lY1u[/eDMbbm3B˥j$ .#- V+DI k@Yz{#-T)ElD@Uo6)Rik*J%l{}ERi9NB#-l384Sv>a@ U}Y5_m]ZRm)h*f-__JӺlkh@}intBǍ-66vOmՐ{3k3hU}U5ca^.lh0V‚vtqڜ'wӞ%?k}$ZKV,x˘3Ö3(X UaA(0YmZI8amt#,&pX#,oU z7jpS4zέNCD|.3))`k̡ñɚZofkhZ$ L$#-瓺3΋OjVwiˈzgս'㉄G#.Bg/|)g„{rN3?R<pi#-ggp >Z )DuN M_$Ǿ#'Ĉq1ʦp7#,GLF 'ʕێϨNPIk,e[tf!WQSjb`N)`' o;)>kb<9Yv9MqmIE1#,jHm/4IR(#_(!۪h [غVYeɟQ-Jc;`dv˷Rڝ՞vӽTm Q~ P#,!E&_y^;ΣqGuP>b[wil<ƌ&.v竢L))AVoN'WtL@ڵŔ@<&ZzVL0 5e!;}"+/ðH|R@&AdT#,VNkYeYYmL֙*YkfUUt/$һk_ S1[k)#,'udc:> ?GTC؋)ƢlfI9?)FH7H|SN!#,Z$w~VwGF!5χ6m$_%FD.1Ve=:YĊY )Aiv |~>$%6MJ26_7#-ɕl<\]f`x,#,SWlO[/b'?fķ9_/5+an{_Q!U]A'gaYi]$&qZ CRyn"KuT_-\OK&/i6LRO4R])B;\obKb1ؘ^2$g5oh%{5y#mr#. 3)ST&8\ x;kxU."`-`{M<֚c[9 P ?)jLVC0i ͐ghc"әF0\7 wU *y;`vЪUP~&#-w7/}ÀFw7]w4Ar6|hw=gtS0=@SmLaq%heAd2` ovǠ d/lм#(9"?E}$n#CGS6%li5im&fȝVj o(OCӁɨM mS ^-|n) "DUjHBzN.ѪxERIj@* DE-xFصQkH 6@)Buj;q凣aQ#.F>a&Q/#b8 @}g7DnYz#-+VA!YB y0fޅkYtRt'#,Ɩ&8!o#$Q[4Ɛy[G:4{T%F6=[(o7RPƒp^@d#.#,K<Bx~ɑa)fe2fL*J 6bz5xl$#-+2$8 "m G+"!EEeKʛ1a݄Iܑb>wCŮ7r Ц"Ғ6TNhjH) HIХY)#.Vv:#-auZs`J! [n#8$I3-JT=ze!ciJݥ]#X /Pe&M[ⵋ_}RjB9ej!yMqk91զ7N'n*ahPLP#.^.G:iO]B`$0r(ݙ&`UV%bfi Dž ԰Q>`ھ~S}gն*wE"ɢKΥEXFM$)X-Xs{=kPN^ku店mIkwWlm2Xʋ*w{,Hٔˆ1cMfB6F8h.(Vs`RP1& mUQqѭ ʚD 9C!~F6,pll_?"GVr@"U+ٵfUΑySu24wey6MLh`ү+{ziJsYyySiLQ IV%dj#.Mh-FIRz=N&wwkduۑjAG4ZK #,BD˜`FF2#.ިWRIqBl48u&4FfB`h CumIƂۊʡF 4|Q-ƕ˚ 5QqFD#-7ad6LT(S$p"XI{ZDtZ+cSڭ I,nm2 XdA"+Y%D#.|CEo*\V{J࣐Ɇ+"+#.6&ZV lH4i+cC3 0XLV6N ]&F6PiV&\3JԆHudzl*)GMhzS!_ɚjƞ]}3x1x[E¨d5|h^JMVECx"Y)(4ĆG褙AJTH5!B.mUiAƔk!M#$3i%oH~0І3VB-SYr+)(tZD͛ضp};2#. H;(:-0#SPNll̪JR/R5MK, J( ІUi#-4 1(F!Q),B#,(a.BIt.r\Z#,n5y9ǽ#.sXXpň#-F<&MzNծ®U!~1 K>du# bjA~egi{^Hn8Y#-L!=%C^%,&3yJ)p,M5Sf;{!ލٚٔe #/66ޔWloccr΋He9H8&S w26![c#'gi}7DTH d3$hl2aGa4{$C&Q%ykF'5St!^\08|1ļ. 'iN1zaPJqtqC{~?3\SX0#-`ڿ#.pGGLy2ܧTIm[mF2鼖,O#.9gkxXSUQXҐb]y[K&[pm:MQWRZʻ#3w^+EusK,Y26T8TEi* DzPhƨDJ l#,+I8A,#. X|_."lnLaT,!soU5YpMR-`Y$7eW{ŅO:K–#B K^ݫ#-rv7k{5QbVWS6k@V`SbֈfTkQm-5*)5)&F[* kLXUWUAo: ɰ#,K$!e/Ϸ/CoE^avGs~܀v]:Qvn2500`kIL6hdZq-kn7b%buj]6vZlV2k%n)i^[KIQv ꯗ~@2<*IJU 6PAd#,YȨ o' B*IP\7|+#-wJ2OU0ֶ|`P{5=-G^G)*2F@?#lj0򺌡q٘9oeRAW1L!*CHS0f+"I8^V:[LIe2HybI ϧlBA#cA6 wb"=4q^N#m.jp?Uu%h`gG'̎8gfeڡ`tF:s4MK,KUcߡu,x4#.C⠈#4閫GP(42Hc{p7yPc`B ˴4)fj!?D| *xDZ05CJ޸#on(4V:Sny|!#G#-ц"Ns$S^GjЖCTQ*QФBnwPPDvv"QRC vVLzymߛ耍ﻻO;<ҜቺVC(an@lgGP9ܧw h1D^B#,a;XdQy~E#,y'V@?}ȩ7#-=TuSb ǯ]Mx-oUh?IЁA=v)&ȋTI$T HlB@{ !S9(-Awe#.Nw0jZ%^^_u.RǗEARκ뺖j6(k]KzBU>%fu#-Y&:$jۚJy^Ȇ FAȡ ITVLVc5z"kvϝ.[LH!zhf'+J$/)R1 L`Т @)MGP4#-@UEwTYf6``! VIE5[f5EJ)36#. #,x0#-kU>XՆJRRj0ҙ O"$P pUރ]*5؇(Js j|p  #.k1sl0,1p$b! S/{nWx$a6w0` l 9o Y cKQA8|#-R۱3Ff C{X?aUPkF5QF(W&h8$B(A#-ڲ4on#,&1<#. F`#-7PvdPI0, AQ=zy[Z-GXPah#B1$M:|*1GZOQvr"7,]z-I#-kEQMQhm4+n#lRU#-1B#.!YATT 5 %ѝ]CD-Gz FJ"7ol𳱆Of)168(N5PYT@ #,ĔBT?lSL#.IlAm) \@$!4z;09,)&׏0_IIr6i508JֶRn7U$U#.#)#,*(GwW]wfKՔZx8ƦcvHEJӀiLr8VP+mZ|mtdh#-C#-8kCC K+5WvRM)#A$ QLǕeԐ5pTp_#,2Q>dC5AF\oo,F[7Pm8*ZR`v)\=IF/lKgb95kr֬8~qn5C膍lP~8Q΁XJ IM<7->T_4?7p׹bw}ܖ=Iv/qD;׺]M@D:^uc_=29eECӑxcja>7< #7򃋠)u8C`4#-'82VX ,#ch jo[F63IHagRI"'}l$*0ӌ3zpA(zjzDbi|NqL'g!mŦGDw۷QoqRrm0i]!6oZ VM3O/ַ9lT`!$l @e&BKs-7,Qy )wF6 H#,H⢃@Dc#-8 ̦aLm)pZ+44ap?[#-FxԪ܍ɤr &"4i$gjOʡܝr/ϙaC#.033Ƭ#._彵=a^L>! YyעY-5w*INF,*xQga8Iu^$-?\9r7!3{`09(uz)UGdrʴ ME`#--IݑD³M}jVjf,ӷ~ӯ.Ś #-od1q'BJZO*>s}LZ6TI{#,)d(~ XW͍_o6H$laMCaP'WT^'/h5gl\?09 a_)wڔ MP*h=#.n}Ʒ#,B"]0~¾hGZ*#E7Aj2uMEB&)FP%DIA#-kd#-affF:{Ή*竕Rح7|.dGWXr}q?6N8& *+[]ԎYgL;Ϡ~ #.l0,ݔ>@ D [YO*#,1Ba%BH1e̺Dtݖa$U$J*-TcYb @B,ݲnxR! wg>sQO´hQwiw cbAl IB)bPfF`f%H"Bʔd)'w#,(9 CLJEfUzE)$pٞ16#-%d 82 #-sx8Ah1]GzA+[[%{#OQjch#A` Cpy" q>Sg8B@صYt+3L #-[#-8Q*`d9cq qh=8@ަyֶc7P:K<eC;T"hI SMtT3! vJ֡"I'ajVD``  *1#-"IyH3P\6ZnT9.nۓJ6Ć@DZY2/aVJ!i ] xA ʢ )uzUAjLڙ!D *Е  9n']\6*^XeV07}dgl|@k*#.' :%\7@4HfJ{o)A g4<3z1ڪ|LDnm,VT봡ss`{Jٝ mr1B'`r}㗭t|Ku,E=1[E&>10l>ݛ$frjܴ$N PlP aL6XB:c)Rk##.[Wm֒!HIB5_ު U3W`9Ux]nY=*`e!i2%EhkZ\(7-wbɵ!n׍^6x#.F'#.c '(ޖүc:#-(`Z#-LDt %*#,H8>V#,,DTK;vUq3OU<~hqJP>B9aHa5:Q0I-n-wj*HH'oqp;,FG@BAPHC[uj%t#-Kkכ`In8$Q`IT5mjWy*Ine0`I/ȢyR(:t@%/SA{2f_yPCfOG̢A:)zf͋|D#.?`EffEdJ,ci!CfZf&֋j6[ڛMU+EcXim#-G<-rDV V&0Q'߅. 5226F)"t25W A+DI1ctW#.)`#-eldE1Tr#-LbPa,PT@Ecׄ Q|BD,B}?VvcSKrAڟ]P2h#.ą&SQ#.}@y$`JE{Cs=9َVR*үP>p#,b0=͍ S'Yqocqi +9d-YWb=؍ 9HbHUL-/ MЇRQ/4q<릺\rշ-*s+QV@nD:;69џT *cZrkŐdАr,Jtf&M1sw;sq?&^m廙u Eq#-xKܵ:!/˖w0f}|rJOЎק//EQR s}p6g o5lZttyBIN(.#.%#̾;Z%맼J`ĝiź,Β\["Ό#._^L V㢉C{q u^qXGS:$EEdk|+sʻtH~RJ#;`p{U c'h}Y]hf962qێ|B3j2KG~>Fut$#,^F5. m1:_7jĦإU',<ΙVlώiDPMJC8GSTSfn88“[4A-á1.Dti3WEVz#-s`}O#.Dسu8xs3Lnt9}sm3jMy%IX _#.k*}koO={)&dѐVxuAFm9dڥcʕjJY&rk!fKE>8#Ѫ 48#.cߥbJF)8p8Z"#QSFÿOK\!lf@94(FnP-5l"Dlv7\S::zt-rܑ=01h1J\ #-H4AgI)9Iᱼꠧ5@\,*B"#,9n7̍H6W^޼K-<`;#,Q,iʄ䎮&3$u:354#,/)pĀP6da|hXlULE#.xwWhX]> ghnq'zeD!F24aJ] P~Dm#-/9>*:g=++}˧zmGrZ7%~ZBQ4wݛ n4`cÆa,rNG_HނD#--uw}7pdTӪwfHIk͵,f(I#,2 O'4˳G knThn iS]ЫAR,fJuq#-#Ӧ:Ÿ+k6|vWՅ4g:Wj2y&-Y!sP8+YucunYoH]=q^R:VAr2QH4۩@(֌eSçavY&nY0ٚxVX Z/BP/=A,A,H_-XM O:OIfqkZ*uU(%% A'TayOSMЏkV4z?Y ̗> ^x$پNRV4wN#.QI4PmIXK#,7KYM0o&9Zr#-#O8Y#.8kگaX'p }+pܼ&P:{ `g-B3/eSP 0*2'F`1MY#-ŠelrBF!R(d=+]-HVY$#, hjъ)U·g uHiJM!Cg^:A Gt&#-\+:&S#.$M4 B0g)g>3M $dQ6T`|ԎYUSq"T'+AB`h&b@l)JI"g2s#@F,a2hI%w+mG{l ,#,KDj#2Yj2ZL3^vzq2qLc`b3+n\2CpX7 Phm2,u59377"nov)JԴj6xaIֹ2$câYXCVRSXP Pa#,mk#- c#-M.HʐCP`2!:W3+1%٭!2T6z|k;jVFڻm48&Ap?&O{gjn#-l+LQB*aoi4cyHHܽ8Z8aw"11g3i50um#-7I -`ۺ+fᲐi2pc8ֵ8aer.u!kdAmCFk2iGc9]Ånl.鋡R*#-5 aJd:((i $`T@A#-DFJHv26 ,mJ8maAnBVS37b$b#ԋ]U=8Sҫx8 9Բ㖜koK fR3N6х}Vvw ӺWk4hh-2FHFK3*Nnj!^5E"]ujHa0`J&e ma6".#.`9Q#-%% 0^y)Rh}W:ޯW^#-~CB#.`#fAP)U!߻A̒x>Pb=Eg^dQg" UQz=+^6ЋW> U4{L@#,9H[He#-ۭh#-AlŇ#-pַMLJ#-[u#.Wm\HePUR1EjǦƕFT`Ƴ3<؋gSOPz7D]HfMT?TpBjnyW_"xtS?[)#,I4-6jV70@F {#^_y'/2)Fp1 $QZQId,mLdMfk4ZLY&4إ)-#-4H̕M6JSE4Y2#&dd/wD8=#,9 n|[9n>]Bl{:BotOmz0Nj/LPY ҲHuA-FFe"]!q#-3Qo*@EAFHXs8woN1}ye--yuO?O糤Fu$G|v<7;y q_#,FmFDV5֊>\cXRPE2l@#,B#.SHYA=;KJ$"R >K&*#-Vo\5 5"#9eѢL啜FTEv9Imm4[&-4ƀ6e,`l,P7Mku#.i#-D0Xj"3#- F17Վ-;ޒK"q$A22+F6vqO#.IŇd8odDe#,Nx!S rJ#,a#ebހ-v,=?Vqf 6B|#,@j]ˤ2XJZ#,a:oxk@7=E`F `"$_^W\}Nbm_\fxgvˌ>e0(!fc&#.lMEot9hi3C[KOd+xi=L.#-k4ht2$ #,2Qv"?-s]u8ZB7gYbc_ ˭#-p5cC"Bo>#,YjLD&]4Xb1a[w yFy9ɋ6ecW#-UGqnT<|# U)o *%0F&^).1#-;M~Ht:e\MdV\5vtN"wvw:*Dy\Vː"UCҸXfyN#.tkarq4D(ʔd'ldL #-$:Y򖕉hdKdHTUhpȸtN'PwCKbĂ7G$lDu8Ic#-Q)$֌댌U3ΰ@ifWCUge]}x55aE!1Lrk= )HAN) 92o].<7CɰxaBYD(>p\HQ0ă%-9Ev#-%PHZTjb2#- lE`7'oi|RHms84C8زQ2mgYE`3g1(N+=|\d3t?BADZͼLg.ZoRP]#.@XKl#-cT eC&:V3+:)t[C;Β[.5`p0ۘyv"XBZh#.%*J+@` 5@x#.SqQ2MSJj8ޏ]̮;`1YFbH<#L摝[apm5hʐEq g\=}ShUmŦ#,u:!H`ZDQ,3#\XcXn#-3AkP- A wFH6d: vpkHȟ+f͔@4'vdX܏MנÆuɳPΈxm#,Il3j,&-J /Z!Wq#KJ n zd<7daAↇݐQLȓ<48Jĕ>TJ1nR\SMMr3\9=]X#,#-H%@ҙjs >+RfC/\d<]5 t9fkS(pHp9k!ږ#,.K@юصtMair8MWH.\ËBN!"mf <&#.)6C:i#,s&IC#.@Nc#.GauLlE\B0BnWB$l;Xc‰[5XĕqvwxW5ǜjV*ؗ+," ٬AG/6KGbw'Mp4>9\ib%)bZ>Eս}{#,nPƢ,n[Z y銦MxݓC3& C,e#.AH,0@ΦkVH@Ye(j`:wb/9#x3g#,c㌜խ+]FC?>pGUu>0Y#.@M4&4d 7O(/\]kF6TǶ“@ u,wDD%L ٥GBm,#-ߣ}̝$tM#-s5i#.41uM(IWJSMg)CG`Vh G#,JmdI|0 MlhK}(aP3&fwF*QMFT/U!2#- K.ui#nזQ!]}0:]4(6!]a3!2.adCpN]e%%U"UT b0ub4) k!u hRS@)e`CE6v79B.X"2#-%I#.dX\@0]Y'DBzzO=R%QQ>MuIxq"C5#.,"BAM`#,#,wm[/ePG9wUT#-ԓ,f;,M` 8ӓɆ$nrgc%n#.D8lhL SwMx#ޏ ZPTq k1wp"rw2"$LmY :(vHcnxMl?wd9n1VTML h4J%~c[Y: S̑1!tw;K9kJGw x)KGqnm 誌7DF;aZ'ZNy"*n {Ŗ\֠?1}DD+F\ODp*rCOaw9s>:dGHj!#.^UiK.J;C!;7hJ;9mydFxBaA lyBY!H2<*(cF*#-IUDEl8iQ}N<$#-![r/"(X-AEd$!>O>#,yݫ^i NqQ|RUr4Jъ1*R)nf1Ȃ06&B8S4fY+#.ˤ&:| 76{g9E_e:)ą<>BK3@sEla,MMzȜ`}Q[bb;[3=)鵋b&G1NS"[#,bDBմQ)>2屳"!w2T,UHm56k3qTG$P}@#,HRjdKjJ{$DPT-20C&oK#.AI ] 1P`1#-/dw*l1?Ӓ#.P۬ۻK}}mMQRuG =1'F DQ$AuqV 1An[cS($NbZhB@hD!AD"T(Gjm9UxsFw[EueA,)J"ȡ8!ovfT MX#,0C Yfej李UD`L㴱#.v0#..YlE!e!#,ׯ-aģjYTY_=n[lߪ)+nB7/#.8y,@@Yng׍RL7Y#.qΧȞ7]n4zY#.v#Z־!%.k;EŘ-*0#z{]ih>, pD6C[:OjdACY K6i R1G~c>b`\t}>>p\=@/#.H2if2[1jFҍ~[tJMZ:WCrjP#-s8{&@jԮ@z9o6݂y;I~#,2R9@ ! !O&G8w֦T=#.t 8zv6{N":_fq"ErA>paGKrEsÔ6m!i#.5N(uֹ\×N ;op:oB)$dD:Elv|#.H @}Pb UnΥB U4&$8&Cؤ5|KS#- #,#.JEz݋+YVPKULCYKG6w7EmjV&l&A" H@#,rn5'ȥVG-A=CHt+Fy$@E.'ho9g#.ˤ^/#.˱"#,4{x Z2= Uwjgz!;p`Z=6H ?;9 $oÝ7oyms_I_yU#,n"|l "};d,)_u.M~l>4d&dbV+0)"+Ph-X~ёUBb(aAj݀<0IʢE\*F*(T) m?3ݷ >! ?"=IZALk_Tvgc",H1FItIںk1h&I,Td̖$ھ꿉_"F1FTj%Ee4ҵ~o_[jY#.6ZČBYTh QYc(#-H&`gCXS$< ` Hh6JJX%nMhI6ӥdAD#,WPE`6-e%SG$$H{-a7/5ލE)A8Sx8&= B՜KJF_Ad"l%섭6@K#.#,~iϣuLAxEou W/#-LTdkbJ#.P+~WZJ,HXO:d U,bdƏE4,:ͻ=/t۲ GXD4E6#. m6PHE  m|9|۽#-A _+0嗑EƐ@!,(5C2Wv`Vl#,[Z RIKiQE$F#,B萖4Zbfjf5wV"-"z|29C.2ȨX@P"@_^ҌKH~VQA@P%LipCt셶++M27D)h˫ƼU֙bݚUiJ#-z9XSQFhVD2 X"mL )ZSLevWVWdޘέ*Fây\ AnŅ\%Hr(,C*jٝ1+u^6hbv"M#-Rqt/Y.0lRь)= ugٷD"8w%o@3$"yH\x$!#Db `MbC|: 3Q;0Io7ּu:VOmMm-ȁn4jT`}wReߝ#()Q0IP|iRj#("ys7E?&٤q<%f͑rPnpf\MˉKV#.v94k%Pm0m2!xPӲi?qB/}#-"ȘL9-r2\F) P#,~G~Je?J9Cx5U He~d& 1~袈K#IV3hXǼ0zsF(ۣZ{FE%˺?[ uĵ[ӶN|q.ЃG5 t2*&A7Ci9V*qm-`tj~wlqр[ CT#-Ԡú%B&3p#2za!.x?#*0!#-pU<ؖSq`|7׆weR Y3&4DVx@wۙ~vXm 2C+Bv,DAFE w#E.Y#,ʡ(1$ȍ@(nf#,$b[A}?l,r؂ZݙN`S)AQAJ#.e)l֙2#-+v#.s X'VMUA`œ\L tZCdOUp?jk~]#-1e5#- C%^}Q_vf{嬁'M:03?#.W!֭5\#-pl!S6sol <e&*#.*[C^&Xv&a!+j?!;{E 3W/.-?jk>4l屬Ύވɏmh|wQo=wIoDR#@p xhز?̝ WsAJNyZۿJ$;KN$h]bdgvr`þA:v8N`Zij/8qBב3\&K)MY%r.h$CK~4#-Ƴ;=1.3.1 +xibless>=0.4.1 +