From e9a97afdf80ca85a2940385328dbe61d27592b3e Mon Sep 17 00:00:00 2001 From: hsoft Date: Mon, 1 Jun 2009 09:55:11 +0000 Subject: [PATCH] Initial commit. --HG-- extra : convert_revision : svn%3Ac306627e-7827-47d3-bdf0-9a457c9553a1/trunk%402 --- base/cocoa/AppDelegate.h | 17 + base/cocoa/AppDelegate.m | 30 + base/cocoa/Consts.h | 17 + base/cocoa/DirectoryPanel.h | 25 + base/cocoa/DirectoryPanel.m | 178 +++ base/cocoa/English.lproj/InfoPlist.strings | Bin 0 -> 204 bytes base/cocoa/Info.plist | 26 + base/cocoa/PyDupeGuru.h | 56 + base/cocoa/ResultWindow.h | 34 + base/cocoa/ResultWindow.m | 316 ++++ base/cocoa/dgbase.xcodeproj/project.pbxproj | 408 +++++ base/qt/__init__.py | 0 base/qt/about_box.py | 35 + base/qt/about_box.ui | 133 ++ base/qt/app.py | 269 ++++ base/qt/details_table.py | 78 + base/qt/dg.qrc | 17 + base/qt/directories_dialog.py | 80 + base/qt/directories_dialog.ui | 133 ++ base/qt/directories_model.py | 108 ++ base/qt/error_report_dialog.py | 25 + base/qt/error_report_dialog.ui | 117 ++ base/qt/gen.py | 20 + base/qt/main_window.py | 304 ++++ base/qt/main_window.ui | 911 +++++++++++ base/qt/preferences.py | 109 ++ base/qt/reg.py | 34 + base/qt/reg_demo_dialog.py | 45 + base/qt/reg_demo_dialog.ui | 140 ++ base/qt/reg_submit_dialog.py | 47 + base/qt/reg_submit_dialog.ui | 149 ++ base/qt/results_model.py | 175 +++ base/qt/tree_model.py | 66 + images/actions32.png | Bin 0 -> 3039 bytes images/delta32.png | Bin 0 -> 1941 bytes images/details32.png | Bin 0 -> 5090 bytes images/dgme_logo.ico | Bin 0 -> 17542 bytes images/dgme_logo_128.png | Bin 0 -> 18092 bytes images/dgme_logo_32.png | Bin 0 -> 6079 bytes images/dgpe_logo.ico | Bin 0 -> 17542 bytes images/dgpe_logo_128.png | Bin 0 -> 19168 bytes images/dgpe_logo_32.png | Bin 0 -> 5818 bytes images/dgse_logo.ico | Bin 0 -> 17542 bytes images/dgse_logo_128.png | Bin 0 -> 14474 bytes images/dgse_logo_32.png | Bin 0 -> 1828 bytes images/folder32.png | Bin 0 -> 2427 bytes images/folderwin32.png | Bin 0 -> 1519 bytes images/gear.png | Bin 0 -> 394 bytes images/power_marker32.png | Bin 0 -> 2132 bytes images/preferences32.png | Bin 0 -> 2899 bytes me/cocoa/AppDelegate.h | 22 + me/cocoa/AppDelegate.m | 158 ++ me/cocoa/Consts.h | 5 + me/cocoa/DetailsPanel.h | 13 + me/cocoa/DetailsPanel.m | 16 + me/cocoa/DirectoryPanel.h | 8 + me/cocoa/DirectoryPanel.m | 23 + .../English.lproj/Details.nib/classes.nib | 18 + me/cocoa/English.lproj/Details.nib/info.nib | 16 + .../Details.nib/keyedobjects.nib | Bin 0 -> 6122 bytes .../English.lproj/Directories.nib/classes.nib | 64 + .../English.lproj/Directories.nib/info.nib | 20 + .../Directories.nib/keyedobjects.nib | Bin 0 -> 9698 bytes me/cocoa/English.lproj/InfoPlist.strings | Bin 0 -> 204 bytes .../English.lproj/MainMenu.nib/classes.nib | 257 +++ me/cocoa/English.lproj/MainMenu.nib/info.nib | 20 + .../MainMenu.nib/keyedobjects.nib | Bin 0 -> 57645 bytes me/cocoa/Info.plist | 34 + me/cocoa/PyDupeGuru.h | 15 + me/cocoa/ResultWindow.h | 56 + me/cocoa/ResultWindow.m | 505 ++++++ me/cocoa/dupeguru.icns | Bin 0 -> 58956 bytes me/cocoa/dupeguru.xcodeproj/hsoft.mode1 | 1376 ++++++++++++++++ me/cocoa/dupeguru.xcodeproj/project.pbxproj | 563 +++++++ me/cocoa/gen.py | 14 + me/cocoa/main.m | 21 + me/cocoa/py/dg_cocoa.py | 225 +++ me/cocoa/py/gen.py | 18 + me/cocoa/py/setup.py | 14 + me/cocoa/w3/dg.xsl | 75 + me/cocoa/w3/hardcoded.css | 71 + me/help/changelog.yaml | 542 +++++++ me/help/gen.py | 7 + me/help/skeleton/hardcoded.css | 409 +++++ me/help/skeleton/images/hs_title.png | Bin 0 -> 1817 bytes me/help/templates/base_dg.mako | 14 + me/help/templates/credits.mako | 20 + me/help/templates/directories.mako | 24 + me/help/templates/faq.mako | 67 + me/help/templates/intro.mako | 13 + me/help/templates/power_marker.mako | 33 + me/help/templates/preferences.mako | 36 + me/help/templates/quick_start.mako | 18 + me/help/templates/results.mako | 73 + me/help/templates/versions.mako | 9 + me/qt/app.py | 60 + me/qt/app_win.py | 16 + me/qt/build.py | 41 + me/qt/details_dialog.py | 20 + me/qt/details_dialog.ui | 53 + me/qt/dgme.spec | 19 + me/qt/gen.py | 25 + me/qt/installer.aip | 241 +++ me/qt/preferences.py | 70 + me/qt/preferences_dialog.py | 85 + me/qt/preferences_dialog.ui | 434 ++++++ me/qt/profile.py | 20 + me/qt/start.py | 20 + me/qt/verinfo | 28 + pe/cocoa/AppDelegate.h | 21 + pe/cocoa/AppDelegate.m | 116 ++ pe/cocoa/Consts.h | 4 + pe/cocoa/Consts.m | 5 + pe/cocoa/DetailsPanel.h | 21 + pe/cocoa/DetailsPanel.m | 79 + pe/cocoa/DirectoryPanel.h | 8 + pe/cocoa/DirectoryPanel.m | 44 + .../English.lproj/Details.nib/classes.nib | 24 + pe/cocoa/English.lproj/Details.nib/info.nib | 16 + .../Details.nib/keyedobjects.nib | Bin 0 -> 9001 bytes .../English.lproj/Directories.nib/classes.nib | 62 + .../English.lproj/Directories.nib/info.nib | 20 + .../Directories.nib/keyedobjects.nib | Bin 0 -> 9698 bytes pe/cocoa/English.lproj/InfoPlist.strings | Bin 0 -> 204 bytes .../English.lproj/MainMenu.nib/classes.nib | 235 +++ pe/cocoa/English.lproj/MainMenu.nib/info.nib | 20 + .../MainMenu.nib/keyedobjects.nib | Bin 0 -> 45275 bytes pe/cocoa/Info.plist | 34 + pe/cocoa/PictureBlocks.h | 11 + pe/cocoa/PictureBlocks.m | 139 ++ pe/cocoa/PyDupeGuru.h | 9 + pe/cocoa/ResultWindow.h | 59 + pe/cocoa/ResultWindow.m | 569 +++++++ pe/cocoa/dupeguru.icns | Bin 0 -> 59921 bytes pe/cocoa/dupeguru.xcodeproj/hsoft.mode1 | 1380 +++++++++++++++++ pe/cocoa/dupeguru.xcodeproj/project.pbxproj | 588 +++++++ pe/cocoa/main.m | 21 + pe/cocoa/py/build_py.sh | 2 + pe/cocoa/py/dg_cocoa.py | 199 +++ pe/cocoa/py/setup.py | 11 + pe/cocoa/w3/dg.xsl | 75 + pe/cocoa/w3/hardcoded.css | 71 + pe/help/changelog.yaml | 174 +++ pe/help/gen.py | 12 + pe/help/skeleton/hardcoded.css | 409 +++++ pe/help/skeleton/images/hs_title.png | Bin 0 -> 1817 bytes pe/help/templates/base_dg.mako | 14 + pe/help/templates/credits.mako | 25 + pe/help/templates/directories.mako | 24 + pe/help/templates/faq.mako | 64 + pe/help/templates/intro.mako | 13 + pe/help/templates/power_marker.mako | 33 + pe/help/templates/preferences.mako | 23 + pe/help/templates/quick_start.mako | 18 + pe/help/templates/results.mako | 73 + pe/help/templates/versions.mako | 6 + pe/qt/app.py | 97 ++ pe/qt/app_win.py | 16 + pe/qt/base/__init__.py | 0 pe/qt/base/about_box.py | 35 + pe/qt/base/about_box.ui | 133 ++ pe/qt/base/app.py | 269 ++++ pe/qt/base/details_table.py | 78 + pe/qt/base/dg.qrc | 17 + pe/qt/base/directories_dialog.py | 80 + pe/qt/base/directories_dialog.ui | 133 ++ pe/qt/base/directories_model.py | 108 ++ pe/qt/base/error_report_dialog.py | 25 + pe/qt/base/error_report_dialog.ui | 117 ++ pe/qt/base/gen.py | 20 + pe/qt/base/images/actions32.png | Bin 0 -> 3039 bytes pe/qt/base/images/delta32.png | Bin 0 -> 1941 bytes pe/qt/base/images/details32.png | Bin 0 -> 5090 bytes pe/qt/base/images/dgme_logo.ico | Bin 0 -> 17542 bytes pe/qt/base/images/dgme_logo_128.png | Bin 0 -> 18092 bytes pe/qt/base/images/dgme_logo_32.png | Bin 0 -> 6079 bytes pe/qt/base/images/dgpe_logo.ico | Bin 0 -> 17542 bytes pe/qt/base/images/dgpe_logo_128.png | Bin 0 -> 19168 bytes pe/qt/base/images/dgpe_logo_32.png | Bin 0 -> 5818 bytes pe/qt/base/images/dgse_logo.ico | Bin 0 -> 17542 bytes pe/qt/base/images/dgse_logo_128.png | Bin 0 -> 14474 bytes pe/qt/base/images/dgse_logo_32.png | Bin 0 -> 1828 bytes pe/qt/base/images/folder32.png | Bin 0 -> 2427 bytes pe/qt/base/images/folderwin32.png | Bin 0 -> 1519 bytes pe/qt/base/images/gear.png | Bin 0 -> 394 bytes pe/qt/base/images/power_marker32.png | Bin 0 -> 2132 bytes pe/qt/base/images/preferences32.png | Bin 0 -> 2899 bytes pe/qt/base/main_window.py | 304 ++++ pe/qt/base/main_window.ui | 911 +++++++++++ pe/qt/base/preferences.py | 109 ++ pe/qt/base/reg.py | 34 + pe/qt/base/reg_demo_dialog.py | 45 + pe/qt/base/reg_demo_dialog.ui | 140 ++ pe/qt/base/reg_submit_dialog.py | 47 + pe/qt/base/reg_submit_dialog.ui | 149 ++ pe/qt/base/results_model.py | 175 +++ pe/qt/base/tree_model.py | 66 + pe/qt/block.py | 41 + pe/qt/build.py | 40 + pe/qt/details_dialog.py | 61 + pe/qt/details_dialog.ui | 113 ++ pe/qt/dgpe.spec | 19 + pe/qt/dupeguru/__init__.py | 1 + pe/qt/dupeguru/app.py | 229 +++ pe/qt/dupeguru/app_cocoa.py | 304 ++++ pe/qt/dupeguru/app_cocoa_test.py | 320 ++++ pe/qt/dupeguru/app_me_cocoa.py | 68 + pe/qt/dupeguru/app_pe_cocoa.py | 212 +++ pe/qt/dupeguru/app_se_cocoa.py | 13 + pe/qt/dupeguru/app_test.py | 137 ++ pe/qt/dupeguru/data.py | 105 ++ pe/qt/dupeguru/data_me.py | 100 ++ pe/qt/dupeguru/data_pe.py | 77 + pe/qt/dupeguru/directories.py | 161 ++ pe/qt/dupeguru/directories_test.py | 280 ++++ pe/qt/dupeguru/engine.py | 360 +++++ pe/qt/dupeguru/engine_test.py | 822 ++++++++++ pe/qt/dupeguru/export.py | 67 + pe/qt/dupeguru/export_test.py | 91 ++ pe/qt/dupeguru/gen.py | 28 + pe/qt/dupeguru/ignore.py | 117 ++ pe/qt/dupeguru/ignore_test.py | 158 ++ pe/qt/dupeguru/modules/block/block.pyx | 93 ++ pe/qt/dupeguru/modules/block/setup.py | 14 + pe/qt/dupeguru/modules/cache/cache.pyx | 34 + pe/qt/dupeguru/modules/cache/setup.py | 14 + pe/qt/dupeguru/picture/__init__.py | 0 pe/qt/dupeguru/picture/block.py | 124 ++ pe/qt/dupeguru/picture/block_test.py | 313 ++++ pe/qt/dupeguru/picture/cache.py | 134 ++ pe/qt/dupeguru/picture/cache_test.py | 159 ++ pe/qt/dupeguru/picture/matchbase.py | 136 ++ pe/qt/dupeguru/results.py | 359 +++++ pe/qt/dupeguru/results_test.py | 742 +++++++++ pe/qt/dupeguru/scanner.py | 131 ++ pe/qt/dupeguru/scanner_test.py | 468 ++++++ pe/qt/gen.py | 42 + pe/qt/help/changelog.yaml | 174 +++ pe/qt/help/gen.py | 12 + pe/qt/help/skeleton/hardcoded.css | 409 +++++ pe/qt/help/skeleton/images/hs_title.png | Bin 0 -> 1817 bytes pe/qt/help/templates/base_dg.mako | 14 + pe/qt/help/templates/credits.mako | 25 + pe/qt/help/templates/directories.mako | 24 + pe/qt/help/templates/faq.mako | 64 + pe/qt/help/templates/intro.mako | 13 + pe/qt/help/templates/power_marker.mako | 33 + pe/qt/help/templates/preferences.mako | 23 + pe/qt/help/templates/quick_start.mako | 18 + pe/qt/help/templates/results.mako | 73 + pe/qt/help/templates/versions.mako | 6 + pe/qt/installer.aip | 249 +++ pe/qt/main_window.py | 26 + pe/qt/modules/block/block.pyx | 39 + pe/qt/modules/block/setup.py | 10 + pe/qt/preferences.py | 35 + pe/qt/preferences_dialog.py | 56 + pe/qt/preferences_dialog.ui | 257 +++ pe/qt/profile.py | 20 + pe/qt/start.py | 20 + pe/qt/verinfo | 28 + py/__init__.py | 1 + py/app.py | 229 +++ py/app_cocoa.py | 304 ++++ py/app_cocoa_test.py | 320 ++++ py/app_me_cocoa.py | 68 + py/app_pe_cocoa.py | 212 +++ py/app_se_cocoa.py | 13 + py/app_test.py | 137 ++ py/data.py | 105 ++ py/data_me.py | 100 ++ py/data_pe.py | 77 + py/directories.py | 161 ++ py/directories_test.py | 280 ++++ py/engine.py | 360 +++++ py/engine_test.py | 822 ++++++++++ py/export.py | 67 + py/export_test.py | 91 ++ py/gen.py | 28 + py/ignore.py | 117 ++ py/ignore_test.py | 158 ++ py/modules/block/block.pyx | 93 ++ py/modules/block/setup.py | 14 + py/modules/cache/cache.pyx | 34 + py/modules/cache/setup.py | 14 + py/picture/__init__.py | 0 py/picture/block.py | 124 ++ py/picture/block_test.py | 313 ++++ py/picture/cache.py | 134 ++ py/picture/cache_test.py | 159 ++ py/picture/matchbase.py | 136 ++ py/results.py | 359 +++++ py/results_test.py | 742 +++++++++ py/scanner.py | 131 ++ py/scanner_test.py | 468 ++++++ se/cocoa/AppDelegate.h | 18 + se/cocoa/AppDelegate.m | 108 ++ se/cocoa/Consts.h | 3 + se/cocoa/DetailsPanel.h | 13 + se/cocoa/DetailsPanel.m | 16 + se/cocoa/DirectoryPanel.h | 7 + se/cocoa/DirectoryPanel.m | 4 + .../English.lproj/Details.nib/classes.nib | 18 + se/cocoa/English.lproj/Details.nib/info.nib | 16 + .../Details.nib/keyedobjects.nib | Bin 0 -> 6122 bytes .../English.lproj/Directories.nib/classes.nib | 62 + .../English.lproj/Directories.nib/info.nib | 20 + .../Directories.nib/keyedobjects.nib | Bin 0 -> 9698 bytes se/cocoa/English.lproj/InfoPlist.strings | Bin 0 -> 204 bytes .../English.lproj/MainMenu.nib/classes.nib | 229 +++ se/cocoa/English.lproj/MainMenu.nib/info.nib | 20 + .../MainMenu.nib/keyedobjects.nib | Bin 0 -> 48638 bytes se/cocoa/Info.plist | 34 + se/cocoa/PyDupeGuru.h | 9 + se/cocoa/ResultWindow.h | 55 + se/cocoa/ResultWindow.m | 460 ++++++ se/cocoa/dupeguru.icns | Bin 0 -> 53620 bytes se/cocoa/dupeguru.xcodeproj/hsoft.mode1 | 1369 ++++++++++++++++ se/cocoa/dupeguru.xcodeproj/project.pbxproj | 548 +++++++ se/cocoa/gen.py | 14 + se/cocoa/main.m | 21 + se/cocoa/py/dg_cocoa.py | 208 +++ se/cocoa/py/gen.py | 15 + se/cocoa/py/setup.py | 14 + se/cocoa/w3/dg.xsl | 75 + se/cocoa/w3/hardcoded.css | 71 + se/help/changelog.yaml | 230 +++ se/help/gen.py | 12 + se/help/skeleton/hardcoded.css | 409 +++++ se/help/skeleton/images/hs_title.png | Bin 0 -> 1817 bytes se/help/templates/base_dg.mako | 14 + se/help/templates/credits.mako | 20 + se/help/templates/directories.mako | 24 + se/help/templates/faq.mako | 64 + se/help/templates/intro.mako | 13 + se/help/templates/power_marker.mako | 33 + se/help/templates/preferences.mako | 27 + se/help/templates/quick_start.mako | 18 + se/help/templates/results.mako | 73 + se/help/templates/versions.mako | 6 + se/qt/app.py | 41 + se/qt/app_win.py | 16 + se/qt/build.py | 43 + se/qt/details_dialog.py | 20 + se/qt/details_dialog.ui | 53 + se/qt/dgse.spec | 19 + se/qt/gen.py | 27 + se/qt/installer.aip | 257 +++ se/qt/preferences.py | 47 + se/qt/preferences_dialog.py | 82 + se/qt/preferences_dialog.ui | 389 +++++ se/qt/profile.py | 20 + se/qt/start.py | 20 + se/qt/verinfo | 28 + 354 files changed, 38083 insertions(+) create mode 100644 base/cocoa/AppDelegate.h create mode 100644 base/cocoa/AppDelegate.m create mode 100644 base/cocoa/Consts.h create mode 100644 base/cocoa/DirectoryPanel.h create mode 100644 base/cocoa/DirectoryPanel.m create mode 100644 base/cocoa/English.lproj/InfoPlist.strings create mode 100644 base/cocoa/Info.plist create mode 100644 base/cocoa/PyDupeGuru.h create mode 100644 base/cocoa/ResultWindow.h create mode 100644 base/cocoa/ResultWindow.m create mode 100644 base/cocoa/dgbase.xcodeproj/project.pbxproj create mode 100644 base/qt/__init__.py create mode 100644 base/qt/about_box.py create mode 100644 base/qt/about_box.ui create mode 100644 base/qt/app.py create mode 100644 base/qt/details_table.py create mode 100644 base/qt/dg.qrc create mode 100644 base/qt/directories_dialog.py create mode 100644 base/qt/directories_dialog.ui create mode 100644 base/qt/directories_model.py create mode 100644 base/qt/error_report_dialog.py create mode 100644 base/qt/error_report_dialog.ui create mode 100644 base/qt/gen.py create mode 100644 base/qt/main_window.py create mode 100644 base/qt/main_window.ui create mode 100644 base/qt/preferences.py create mode 100644 base/qt/reg.py create mode 100644 base/qt/reg_demo_dialog.py create mode 100644 base/qt/reg_demo_dialog.ui create mode 100644 base/qt/reg_submit_dialog.py create mode 100644 base/qt/reg_submit_dialog.ui create mode 100644 base/qt/results_model.py create mode 100644 base/qt/tree_model.py create mode 100755 images/actions32.png create mode 100755 images/delta32.png create mode 100644 images/details32.png create mode 100644 images/dgme_logo.ico create mode 100644 images/dgme_logo_128.png create mode 100644 images/dgme_logo_32.png create mode 100644 images/dgpe_logo.ico create mode 100644 images/dgpe_logo_128.png create mode 100755 images/dgpe_logo_32.png create mode 100644 images/dgse_logo.ico create mode 100644 images/dgse_logo_128.png create mode 100755 images/dgse_logo_32.png create mode 100755 images/folder32.png create mode 100755 images/folderwin32.png create mode 100755 images/gear.png create mode 100755 images/power_marker32.png create mode 100755 images/preferences32.png create mode 100644 me/cocoa/AppDelegate.h create mode 100644 me/cocoa/AppDelegate.m create mode 100644 me/cocoa/Consts.h create mode 100644 me/cocoa/DetailsPanel.h create mode 100644 me/cocoa/DetailsPanel.m create mode 100644 me/cocoa/DirectoryPanel.h create mode 100644 me/cocoa/DirectoryPanel.m create mode 100644 me/cocoa/English.lproj/Details.nib/classes.nib create mode 100644 me/cocoa/English.lproj/Details.nib/info.nib create mode 100644 me/cocoa/English.lproj/Details.nib/keyedobjects.nib create mode 100644 me/cocoa/English.lproj/Directories.nib/classes.nib create mode 100644 me/cocoa/English.lproj/Directories.nib/info.nib create mode 100644 me/cocoa/English.lproj/Directories.nib/keyedobjects.nib create mode 100644 me/cocoa/English.lproj/InfoPlist.strings create mode 100644 me/cocoa/English.lproj/MainMenu.nib/classes.nib create mode 100644 me/cocoa/English.lproj/MainMenu.nib/info.nib create mode 100644 me/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib create mode 100644 me/cocoa/Info.plist create mode 100644 me/cocoa/PyDupeGuru.h create mode 100644 me/cocoa/ResultWindow.h create mode 100644 me/cocoa/ResultWindow.m create mode 100755 me/cocoa/dupeguru.icns create mode 100644 me/cocoa/dupeguru.xcodeproj/hsoft.mode1 create mode 100644 me/cocoa/dupeguru.xcodeproj/project.pbxproj create mode 100644 me/cocoa/gen.py create mode 100644 me/cocoa/main.m create mode 100644 me/cocoa/py/dg_cocoa.py create mode 100644 me/cocoa/py/gen.py create mode 100644 me/cocoa/py/setup.py create mode 100644 me/cocoa/w3/dg.xsl create mode 100644 me/cocoa/w3/hardcoded.css create mode 100644 me/help/changelog.yaml create mode 100644 me/help/gen.py create mode 100644 me/help/skeleton/hardcoded.css create mode 100644 me/help/skeleton/images/hs_title.png create mode 100644 me/help/templates/base_dg.mako create mode 100644 me/help/templates/credits.mako create mode 100644 me/help/templates/directories.mako create mode 100644 me/help/templates/faq.mako create mode 100644 me/help/templates/intro.mako create mode 100644 me/help/templates/power_marker.mako create mode 100644 me/help/templates/preferences.mako create mode 100644 me/help/templates/quick_start.mako create mode 100644 me/help/templates/results.mako create mode 100644 me/help/templates/versions.mako create mode 100644 me/qt/app.py create mode 100644 me/qt/app_win.py create mode 100644 me/qt/build.py create mode 100644 me/qt/details_dialog.py create mode 100644 me/qt/details_dialog.ui create mode 100644 me/qt/dgme.spec create mode 100644 me/qt/gen.py create mode 100644 me/qt/installer.aip create mode 100644 me/qt/preferences.py create mode 100644 me/qt/preferences_dialog.py create mode 100644 me/qt/preferences_dialog.ui create mode 100644 me/qt/profile.py create mode 100644 me/qt/start.py create mode 100644 me/qt/verinfo create mode 100644 pe/cocoa/AppDelegate.h create mode 100644 pe/cocoa/AppDelegate.m create mode 100644 pe/cocoa/Consts.h create mode 100644 pe/cocoa/Consts.m create mode 100644 pe/cocoa/DetailsPanel.h create mode 100644 pe/cocoa/DetailsPanel.m create mode 100644 pe/cocoa/DirectoryPanel.h create mode 100644 pe/cocoa/DirectoryPanel.m create mode 100644 pe/cocoa/English.lproj/Details.nib/classes.nib create mode 100644 pe/cocoa/English.lproj/Details.nib/info.nib create mode 100644 pe/cocoa/English.lproj/Details.nib/keyedobjects.nib create mode 100644 pe/cocoa/English.lproj/Directories.nib/classes.nib create mode 100644 pe/cocoa/English.lproj/Directories.nib/info.nib create mode 100644 pe/cocoa/English.lproj/Directories.nib/keyedobjects.nib create mode 100644 pe/cocoa/English.lproj/InfoPlist.strings create mode 100644 pe/cocoa/English.lproj/MainMenu.nib/classes.nib create mode 100644 pe/cocoa/English.lproj/MainMenu.nib/info.nib create mode 100644 pe/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib create mode 100644 pe/cocoa/Info.plist create mode 100644 pe/cocoa/PictureBlocks.h create mode 100644 pe/cocoa/PictureBlocks.m create mode 100644 pe/cocoa/PyDupeGuru.h create mode 100644 pe/cocoa/ResultWindow.h create mode 100644 pe/cocoa/ResultWindow.m create mode 100755 pe/cocoa/dupeguru.icns create mode 100644 pe/cocoa/dupeguru.xcodeproj/hsoft.mode1 create mode 100644 pe/cocoa/dupeguru.xcodeproj/project.pbxproj create mode 100644 pe/cocoa/main.m create mode 100755 pe/cocoa/py/build_py.sh create mode 100644 pe/cocoa/py/dg_cocoa.py create mode 100644 pe/cocoa/py/setup.py create mode 100644 pe/cocoa/w3/dg.xsl create mode 100644 pe/cocoa/w3/hardcoded.css create mode 100644 pe/help/changelog.yaml create mode 100644 pe/help/gen.py create mode 100644 pe/help/skeleton/hardcoded.css create mode 100644 pe/help/skeleton/images/hs_title.png create mode 100644 pe/help/templates/base_dg.mako create mode 100644 pe/help/templates/credits.mako create mode 100644 pe/help/templates/directories.mako create mode 100644 pe/help/templates/faq.mako create mode 100644 pe/help/templates/intro.mako create mode 100644 pe/help/templates/power_marker.mako create mode 100644 pe/help/templates/preferences.mako create mode 100644 pe/help/templates/quick_start.mako create mode 100644 pe/help/templates/results.mako create mode 100644 pe/help/templates/versions.mako create mode 100644 pe/qt/app.py create mode 100644 pe/qt/app_win.py create mode 100644 pe/qt/base/__init__.py create mode 100644 pe/qt/base/about_box.py create mode 100644 pe/qt/base/about_box.ui create mode 100644 pe/qt/base/app.py create mode 100644 pe/qt/base/details_table.py create mode 100644 pe/qt/base/dg.qrc create mode 100644 pe/qt/base/directories_dialog.py create mode 100644 pe/qt/base/directories_dialog.ui create mode 100644 pe/qt/base/directories_model.py create mode 100644 pe/qt/base/error_report_dialog.py create mode 100644 pe/qt/base/error_report_dialog.ui create mode 100644 pe/qt/base/gen.py create mode 100755 pe/qt/base/images/actions32.png create mode 100755 pe/qt/base/images/delta32.png create mode 100644 pe/qt/base/images/details32.png create mode 100644 pe/qt/base/images/dgme_logo.ico create mode 100644 pe/qt/base/images/dgme_logo_128.png create mode 100644 pe/qt/base/images/dgme_logo_32.png create mode 100644 pe/qt/base/images/dgpe_logo.ico create mode 100644 pe/qt/base/images/dgpe_logo_128.png create mode 100755 pe/qt/base/images/dgpe_logo_32.png create mode 100644 pe/qt/base/images/dgse_logo.ico create mode 100644 pe/qt/base/images/dgse_logo_128.png create mode 100755 pe/qt/base/images/dgse_logo_32.png create mode 100755 pe/qt/base/images/folder32.png create mode 100755 pe/qt/base/images/folderwin32.png create mode 100755 pe/qt/base/images/gear.png create mode 100755 pe/qt/base/images/power_marker32.png create mode 100755 pe/qt/base/images/preferences32.png create mode 100644 pe/qt/base/main_window.py create mode 100644 pe/qt/base/main_window.ui create mode 100644 pe/qt/base/preferences.py create mode 100644 pe/qt/base/reg.py create mode 100644 pe/qt/base/reg_demo_dialog.py create mode 100644 pe/qt/base/reg_demo_dialog.ui create mode 100644 pe/qt/base/reg_submit_dialog.py create mode 100644 pe/qt/base/reg_submit_dialog.ui create mode 100644 pe/qt/base/results_model.py create mode 100644 pe/qt/base/tree_model.py create mode 100644 pe/qt/block.py create mode 100644 pe/qt/build.py create mode 100644 pe/qt/details_dialog.py create mode 100644 pe/qt/details_dialog.ui create mode 100644 pe/qt/dgpe.spec create mode 100644 pe/qt/dupeguru/__init__.py create mode 100644 pe/qt/dupeguru/app.py create mode 100644 pe/qt/dupeguru/app_cocoa.py create mode 100644 pe/qt/dupeguru/app_cocoa_test.py create mode 100644 pe/qt/dupeguru/app_me_cocoa.py create mode 100644 pe/qt/dupeguru/app_pe_cocoa.py create mode 100644 pe/qt/dupeguru/app_se_cocoa.py create mode 100644 pe/qt/dupeguru/app_test.py create mode 100644 pe/qt/dupeguru/data.py create mode 100644 pe/qt/dupeguru/data_me.py create mode 100644 pe/qt/dupeguru/data_pe.py create mode 100644 pe/qt/dupeguru/directories.py create mode 100644 pe/qt/dupeguru/directories_test.py create mode 100644 pe/qt/dupeguru/engine.py create mode 100644 pe/qt/dupeguru/engine_test.py create mode 100644 pe/qt/dupeguru/export.py create mode 100644 pe/qt/dupeguru/export_test.py create mode 100644 pe/qt/dupeguru/gen.py create mode 100644 pe/qt/dupeguru/ignore.py create mode 100644 pe/qt/dupeguru/ignore_test.py create mode 100644 pe/qt/dupeguru/modules/block/block.pyx create mode 100644 pe/qt/dupeguru/modules/block/setup.py create mode 100644 pe/qt/dupeguru/modules/cache/cache.pyx create mode 100644 pe/qt/dupeguru/modules/cache/setup.py create mode 100644 pe/qt/dupeguru/picture/__init__.py create mode 100644 pe/qt/dupeguru/picture/block.py create mode 100644 pe/qt/dupeguru/picture/block_test.py create mode 100644 pe/qt/dupeguru/picture/cache.py create mode 100644 pe/qt/dupeguru/picture/cache_test.py create mode 100644 pe/qt/dupeguru/picture/matchbase.py create mode 100644 pe/qt/dupeguru/results.py create mode 100644 pe/qt/dupeguru/results_test.py create mode 100644 pe/qt/dupeguru/scanner.py create mode 100644 pe/qt/dupeguru/scanner_test.py create mode 100644 pe/qt/gen.py create mode 100644 pe/qt/help/changelog.yaml create mode 100644 pe/qt/help/gen.py create mode 100644 pe/qt/help/skeleton/hardcoded.css create mode 100644 pe/qt/help/skeleton/images/hs_title.png create mode 100644 pe/qt/help/templates/base_dg.mako create mode 100644 pe/qt/help/templates/credits.mako create mode 100644 pe/qt/help/templates/directories.mako create mode 100644 pe/qt/help/templates/faq.mako create mode 100644 pe/qt/help/templates/intro.mako create mode 100644 pe/qt/help/templates/power_marker.mako create mode 100644 pe/qt/help/templates/preferences.mako create mode 100644 pe/qt/help/templates/quick_start.mako create mode 100644 pe/qt/help/templates/results.mako create mode 100644 pe/qt/help/templates/versions.mako create mode 100644 pe/qt/installer.aip create mode 100644 pe/qt/main_window.py create mode 100644 pe/qt/modules/block/block.pyx create mode 100644 pe/qt/modules/block/setup.py create mode 100644 pe/qt/preferences.py create mode 100644 pe/qt/preferences_dialog.py create mode 100644 pe/qt/preferences_dialog.ui create mode 100644 pe/qt/profile.py create mode 100644 pe/qt/start.py create mode 100644 pe/qt/verinfo create mode 100644 py/__init__.py create mode 100644 py/app.py create mode 100644 py/app_cocoa.py create mode 100644 py/app_cocoa_test.py create mode 100644 py/app_me_cocoa.py create mode 100644 py/app_pe_cocoa.py create mode 100644 py/app_se_cocoa.py create mode 100644 py/app_test.py create mode 100644 py/data.py create mode 100644 py/data_me.py create mode 100644 py/data_pe.py create mode 100644 py/directories.py create mode 100644 py/directories_test.py create mode 100644 py/engine.py create mode 100644 py/engine_test.py create mode 100644 py/export.py create mode 100644 py/export_test.py create mode 100644 py/gen.py create mode 100644 py/ignore.py create mode 100644 py/ignore_test.py create mode 100644 py/modules/block/block.pyx create mode 100644 py/modules/block/setup.py create mode 100644 py/modules/cache/cache.pyx create mode 100644 py/modules/cache/setup.py create mode 100644 py/picture/__init__.py create mode 100644 py/picture/block.py create mode 100644 py/picture/block_test.py create mode 100644 py/picture/cache.py create mode 100644 py/picture/cache_test.py create mode 100644 py/picture/matchbase.py create mode 100644 py/results.py create mode 100644 py/results_test.py create mode 100644 py/scanner.py create mode 100644 py/scanner_test.py create mode 100644 se/cocoa/AppDelegate.h create mode 100644 se/cocoa/AppDelegate.m create mode 100644 se/cocoa/Consts.h create mode 100644 se/cocoa/DetailsPanel.h create mode 100644 se/cocoa/DetailsPanel.m create mode 100644 se/cocoa/DirectoryPanel.h create mode 100644 se/cocoa/DirectoryPanel.m create mode 100644 se/cocoa/English.lproj/Details.nib/classes.nib create mode 100644 se/cocoa/English.lproj/Details.nib/info.nib create mode 100644 se/cocoa/English.lproj/Details.nib/keyedobjects.nib create mode 100644 se/cocoa/English.lproj/Directories.nib/classes.nib create mode 100644 se/cocoa/English.lproj/Directories.nib/info.nib create mode 100644 se/cocoa/English.lproj/Directories.nib/keyedobjects.nib create mode 100644 se/cocoa/English.lproj/InfoPlist.strings create mode 100644 se/cocoa/English.lproj/MainMenu.nib/classes.nib create mode 100644 se/cocoa/English.lproj/MainMenu.nib/info.nib create mode 100644 se/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib create mode 100644 se/cocoa/Info.plist create mode 100644 se/cocoa/PyDupeGuru.h create mode 100644 se/cocoa/ResultWindow.h create mode 100644 se/cocoa/ResultWindow.m create mode 100755 se/cocoa/dupeguru.icns create mode 100644 se/cocoa/dupeguru.xcodeproj/hsoft.mode1 create mode 100644 se/cocoa/dupeguru.xcodeproj/project.pbxproj create mode 100644 se/cocoa/gen.py create mode 100644 se/cocoa/main.m create mode 100644 se/cocoa/py/dg_cocoa.py create mode 100644 se/cocoa/py/gen.py create mode 100644 se/cocoa/py/setup.py create mode 100644 se/cocoa/w3/dg.xsl create mode 100644 se/cocoa/w3/hardcoded.css create mode 100644 se/help/changelog.yaml create mode 100644 se/help/gen.py create mode 100644 se/help/skeleton/hardcoded.css create mode 100644 se/help/skeleton/images/hs_title.png create mode 100644 se/help/templates/base_dg.mako create mode 100644 se/help/templates/credits.mako create mode 100644 se/help/templates/directories.mako create mode 100644 se/help/templates/faq.mako create mode 100644 se/help/templates/intro.mako create mode 100644 se/help/templates/power_marker.mako create mode 100644 se/help/templates/preferences.mako create mode 100644 se/help/templates/quick_start.mako create mode 100644 se/help/templates/results.mako create mode 100644 se/help/templates/versions.mako create mode 100644 se/qt/app.py create mode 100644 se/qt/app_win.py create mode 100644 se/qt/build.py create mode 100644 se/qt/details_dialog.py create mode 100644 se/qt/details_dialog.ui create mode 100644 se/qt/dgse.spec create mode 100644 se/qt/gen.py create mode 100644 se/qt/installer.aip create mode 100644 se/qt/preferences.py create mode 100644 se/qt/preferences_dialog.py create mode 100644 se/qt/preferences_dialog.ui create mode 100644 se/qt/profile.py create mode 100644 se/qt/start.py create mode 100644 se/qt/verinfo diff --git a/base/cocoa/AppDelegate.h b/base/cocoa/AppDelegate.h new file mode 100644 index 00000000..055e0a67 --- /dev/null +++ b/base/cocoa/AppDelegate.h @@ -0,0 +1,17 @@ +#import +#import "RecentDirectories.h" +#import "PyDupeGuru.h" + +@interface AppDelegateBase : NSObject +{ + IBOutlet PyDupeGuruBase *py; + IBOutlet RecentDirectories *recentDirectories; + IBOutlet NSMenuItem *unlockMenuItem; + + NSString *_appName; +} +- (IBAction)unlockApp:(id)sender; + +- (PyDupeGuruBase *)py; +- (RecentDirectories *)recentDirectories; +@end diff --git a/base/cocoa/AppDelegate.m b/base/cocoa/AppDelegate.m new file mode 100644 index 00000000..ef3d9162 --- /dev/null +++ b/base/cocoa/AppDelegate.m @@ -0,0 +1,30 @@ +#import "AppDelegate.h" +#import "ProgressController.h" +#import "RegistrationInterface.h" +#import "Utils.h" +#import "Consts.h" + +@implementation AppDelegateBase +- (id)init +{ + self = [super init]; + _appName = @""; + return self; +} + +- (IBAction)unlockApp:(id)sender +{ + if ([[self py] isRegistered]) + return; + RegistrationInterface *ri = [[RegistrationInterface alloc] initWithApp:[self py] name:_appName limitDescription:LIMIT_DESC]; + if ([ri enterCode] == NSOKButton) + { + NSString *menuTitle = [NSString stringWithFormat:@"Thanks for buying %@",_appName]; + [unlockMenuItem setTitle:menuTitle]; + } + [ri release]; +} + +- (PyDupeGuruBase *)py { return py; } +- (RecentDirectories *)recentDirectories { return recentDirectories; } +@end diff --git a/base/cocoa/Consts.h b/base/cocoa/Consts.h new file mode 100644 index 00000000..c0c8a7ee --- /dev/null +++ b/base/cocoa/Consts.h @@ -0,0 +1,17 @@ +#import + +#define DuplicateSelectionChangedNotification @"DuplicateSelectionChangedNotification" +#define ResultsChangedNotification @"ResultsChangedNotification" +#define ResultsMarkingChangedNotification @"ResultsMarkingChangedNotification" +#define RegistrationRequired @"RegistrationRequired" +#define JobStarted @"JobStarted" +#define JobInProgress @"JobInProgress" + +#define jobLoad @"job_load" +#define jobScan @"job_scan" +#define jobCopy @"job_copy" +#define jobMove @"job_move" +#define jobDelete @"job_delete" + +#define DEMO_MAX_ACTION_COUNT 10 +#define LIMIT_DESC @"In the demo version, only 10 duplicates per session can be sent to Trash, moved or copied." \ No newline at end of file diff --git a/base/cocoa/DirectoryPanel.h b/base/cocoa/DirectoryPanel.h new file mode 100644 index 00000000..d5738b29 --- /dev/null +++ b/base/cocoa/DirectoryPanel.h @@ -0,0 +1,25 @@ +#import +#import "RecentDirectories.h" +#import "Outline.h" +#import "PyDupeGuru.h" + +@interface DirectoryPanelBase : NSWindowController +{ + IBOutlet NSPopUpButton *addButtonPopUp; + IBOutlet OutlineView *directories; + IBOutlet NSButton *removeButton; + + PyDupeGuruBase *_py; + RecentDirectories *_recentDirectories; +} +- (id)initWithParentApp:(id)aParentApp; + +- (IBAction)askForDirectory:(id)sender; +- (IBAction)changeDirectoryState:(id)sender; +- (IBAction)popupAddDirectoryMenu:(id)sender; +- (IBAction)removeSelectedDirectory:(id)sender; +- (IBAction)toggleVisible:(id)sender; + +- (void)addDirectory:(NSString *)directory; +- (void)refreshRemoveButtonText; +@end diff --git a/base/cocoa/DirectoryPanel.m b/base/cocoa/DirectoryPanel.m new file mode 100644 index 00000000..b62b7ae8 --- /dev/null +++ b/base/cocoa/DirectoryPanel.m @@ -0,0 +1,178 @@ +#import "DirectoryPanel.h" +#import "Dialogs.h" +#import "Utils.h" +#import "AppDelegate.h" + +@implementation DirectoryPanelBase +- (id)initWithParentApp:(id)aParentApp +{ + self = [super initWithWindowNibName:@"Directories"]; + [self window]; + AppDelegateBase *app = aParentApp; + _py = [app py]; + _recentDirectories = [app recentDirectories]; + [directories setPy:_py]; + NSPopUpButtonCell *cell = [[directories tableColumnWithIdentifier:@"1"] dataCell]; + [cell addItemWithTitle:@"Normal"]; + [cell addItemWithTitle:@"Reference"]; + [cell addItemWithTitle:@"Excluded"]; + for (int i=0;i<[[cell itemArray] count];i++) + { + NSMenuItem *mi = [[cell itemArray] objectAtIndex:i]; + [mi setTarget:self]; + [mi setAction:@selector(changeDirectoryState:)]; + [mi setTag:i]; + } + [self refreshRemoveButtonText]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(directorySelectionChanged:) name:NSOutlineViewSelectionDidChangeNotification object:directories]; + return self; +} + +/* Actions */ + +- (IBAction)askForDirectory:(id)sender +{ + NSOpenPanel *op = [NSOpenPanel openPanel]; + [op setCanChooseFiles:YES]; + [op setCanChooseDirectories:YES]; + [op setAllowsMultipleSelection:NO]; + [op setTitle:@"Select a directory to add to the scanning list"]; + [op setDelegate:self]; + if ([op runModalForTypes:nil] == NSOKButton) + { + NSString *directory = [[op filenames] objectAtIndex:0]; + [self addDirectory:directory]; + } +} + +- (IBAction)changeDirectoryState:(id)sender +{ + OVNode *node = [directories itemAtRow:[directories clickedRow]]; + [_py setDirectory:p2a([node indexPath]) state:i2n([sender tag])]; + [node resetAllBuffers]; + [directories display]; +} + +- (IBAction)popupAddDirectoryMenu:(id)sender +{ + if ([[_recentDirectories directories] count] == 0) + { + [self askForDirectory:sender]; + return; + } + NSMenu *m = [addButtonPopUp menu]; + while ([m numberOfItems] > 0) + [m removeItemAtIndex:0]; + NSMenuItem *mi = [m addItemWithTitle:@"Add New Directory..." action:@selector(askForDirectory:) keyEquivalent:@""]; + [mi setTarget:self]; + [m addItem:[NSMenuItem separatorItem]]; + [_recentDirectories fillMenu:m]; + [addButtonPopUp selectItem:nil]; + [[addButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]]; +} + +- (IBAction)removeSelectedDirectory:(id)sender +{ + [[self window] makeKeyAndOrderFront:nil]; + if ([directories selectedRow] < 0) + return; + OVNode *node = [directories itemAtRow:[directories selectedRow]]; + if ([node level] == 1) + { + [_py removeDirectory:i2n([node index])]; + [directories reloadData]; + } + else + { + int state = n2i([[node buffer] objectAtIndex:1]); + int newState = state == 2 ? 0 : 2; // If excluded, put it back + [_py setDirectory:p2a([node indexPath]) state:i2n(newState)]; + [node resetAllBuffers]; + [directories display]; + } + [self refreshRemoveButtonText]; +} + +- (IBAction)toggleVisible:(id)sender +{ + if ([[self window] isVisible]) + [[self window] close]; + else + [[self window] makeKeyAndOrderFront:nil]; +} + +/* Public */ + +- (void)addDirectory:(NSString *)directory +{ + int r = [[_py addDirectory:directory] intValue]; + if (r) + { + NSString *m; + switch (r) + { + case 1: + { + m = @"This directory already is in the list."; + break; + } + case 2: + { + m = @"This directory does not exist."; + break; + } + } + [Dialogs showMessage:m]; + } + [directories reloadData]; + [_recentDirectories addDirectory:directory]; + [[self window] makeKeyAndOrderFront:nil]; +} + +- (void)refreshRemoveButtonText +{ + if ([directories selectedRow] < 0) + { + [removeButton setEnabled:NO]; + return; + } + [removeButton setEnabled:YES]; + OVNode *node = [directories itemAtRow:[directories selectedRow]]; + int state = n2i([[node buffer] objectAtIndex:1]); + NSString *buttonText = state == 2 ? @"Put Back" : @"Remove"; + [removeButton setTitle:buttonText]; +} + +/* Delegate */ + +- (void)outlineView:(NSOutlineView *)outlineView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)tableColumn item:(id)item +{ + OVNode *node = item; + int state = n2i([[node buffer] objectAtIndex:1]); + if ([cell isKindOfClass:[NSTextFieldCell class]]) + { + NSTextFieldCell *textCell = cell; + if (state == 1) + [textCell setTextColor:[NSColor blueColor]]; + else if (state == 2) + [textCell setTextColor:[NSColor redColor]]; + else + [textCell setTextColor:[NSColor blackColor]]; + } +} + +- (BOOL)panel:(id)sender shouldShowFilename:(NSString *)path +{ + BOOL isdir; + [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isdir]; + return isdir; +} + +/* Notifications */ + +- (void)directorySelectionChanged:(NSNotification *)aNotification +{ + [self refreshRemoveButtonText]; +} + +@end diff --git a/base/cocoa/English.lproj/InfoPlist.strings b/base/cocoa/English.lproj/InfoPlist.strings new file mode 100644 index 0000000000000000000000000000000000000000..948e12b991d433e9dc3ebc46350cb859c5bc1e07 GIT binary patch literal 204 zcmW-a%?`m}5Jk`0Q#6*1sMuJDl?@3NJb)A}LVwaCsW + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleName + ${PRODUCT_NAME} + CFBundleIconFile + + CFBundleIdentifier + com.yourcompany.yourcocoaframework + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + FMWK + CFBundleSignature + ???? + CFBundleVersion + 1.0 + NSPrincipalClass + + + diff --git a/base/cocoa/PyDupeGuru.h b/base/cocoa/PyDupeGuru.h new file mode 100644 index 00000000..6a660b03 --- /dev/null +++ b/base/cocoa/PyDupeGuru.h @@ -0,0 +1,56 @@ +#import +#import "PyApp.h" + +@interface PyDupeGuruBase : PyApp +//Actions +- (NSNumber *)addDirectory:(NSString *)name; +- (void)removeDirectory:(NSNumber *)index; +- (void)setDirectory:(NSArray *)indexPath state:(NSNumber *)state; +- (void)loadResults; +- (void)saveResults; +- (void)loadIgnoreList; +- (void)saveIgnoreList; +- (void)clearIgnoreList; +- (void)purgeIgnoreList; +- (NSString *)exportToXHTMLwithColumns:(NSArray *)aColIds xslt:(NSString *)xsltPath css:(NSString *)cssPath; + +- (NSNumber *)doScan; + +- (void)selectPowerMarkerNodePaths:(NSArray *)aIndexPaths; +- (void)selectResultNodePaths:(NSArray *)aIndexPaths; + +- (void)toggleSelectedMark; +- (void)markAll; +- (void)markInvert; +- (void)markNone; + +- (void)addSelectedToIgnoreList; +- (void)refreshDetailsWithSelected; +- (void)removeSelected; +- (void)openSelected; +- (NSNumber *)renameSelected:(NSString *)aNewName; +- (void)revealSelected; +- (void)makeSelectedReference; +- (void)applyFilter:(NSString *)filter; + +- (void)sortGroupsBy:(NSNumber *)aIdentifier ascending:(NSNumber *)aAscending; +- (void)sortDupesBy:(NSNumber *)aIdentifier ascending:(NSNumber *)aAscending; + +- (void)copyOrMove:(NSNumber *)aCopy markedTo:(NSString *)destination recreatePath:(NSNumber *)aRecreateType; +- (void)deleteMarked; +- (void)removeMarked; + +//Data +- (NSNumber *)getIgnoreListCount; +- (NSNumber *)getMarkCount; +- (NSString *)getStatLine; +- (NSNumber *)getOperationalErrorCount; + +//Scanning options +- (void)setMinMatchPercentage:(NSNumber *)percentage; +- (void)setMixFileKind:(NSNumber *)mix_file_kind; +- (void)setDisplayDeltaValues:(NSNumber *)display_delta_values; +- (void)setEscapeFilterRegexp:(NSNumber *)escape_filter_regexp; +- (void)setRemoveEmptyFolders:(NSNumber *)remove_empty_folders; +- (void)setSizeThreshold:(int)size_threshold; +@end diff --git a/base/cocoa/ResultWindow.h b/base/cocoa/ResultWindow.h new file mode 100644 index 00000000..1cdfb70f --- /dev/null +++ b/base/cocoa/ResultWindow.h @@ -0,0 +1,34 @@ +#import +#import "Outline.h" +#import "DirectoryPanel.h" +#import "PyDupeGuru.h" + +@interface MatchesView : OutlineView +- (void)keyDown:(NSEvent *)theEvent; +@end + +@interface ResultWindowBase : NSWindowController +{ +@protected + IBOutlet PyDupeGuruBase *py; + IBOutlet id app; + IBOutlet NSView *actionMenuView; + IBOutlet NSSegmentedControl *deltaSwitch; + IBOutlet NSView *deltaSwitchView; + IBOutlet NSView *filterFieldView; + IBOutlet MatchesView *matches; + IBOutlet NSView *pmSwitchView; + + BOOL _powerMode; + BOOL _displayDelta; +} +- (NSString *)logoImageName; +/* Actions */ +- (IBAction)changeDelta:(id)sender; +- (IBAction)copyMarked:(id)sender; +- (IBAction)deleteMarked:(id)sender; +- (IBAction)expandAll:(id)sender; +- (IBAction)moveMarked:(id)sender; +/* Notifications */ +- (void)jobCompleted:(NSNotification *)aNotification; +@end diff --git a/base/cocoa/ResultWindow.m b/base/cocoa/ResultWindow.m new file mode 100644 index 00000000..1aa6cb8a --- /dev/null +++ b/base/cocoa/ResultWindow.m @@ -0,0 +1,316 @@ +#import "ResultWindow.h" +#import "Dialogs.h" +#import "ProgressController.h" +#import "Utils.h" +#import "RegistrationInterface.h" +#import "AppDelegate.h" +#import "Consts.h" + +#define tbbDirectories @"tbbDirectories" +#define tbbDetails @"tbbDetail" +#define tbbPreferences @"tbbPreferences" +#define tbbPowerMarker @"tbbPowerMarker" +#define tbbScan @"tbbScan" +#define tbbAction @"tbbAction" +#define tbbDelta @"tbbDelta" +#define tbbFilter @"tbbFilter" + +@implementation MatchesView +- (void)keyDown:(NSEvent *)theEvent +{ + unichar key = [[theEvent charactersIgnoringModifiers] characterAtIndex:0]; + // get flags and strip the lower 16 (device dependant) bits + unsigned int flags = ( [theEvent modifierFlags] & 0x00FF ); + if (((key == NSDeleteFunctionKey) || (key == NSDeleteCharacter)) && (flags == 0)) + [self sendAction:@selector(removeSelected:) to:[self delegate]]; + else + if ((key == 0x20) && (flags == 0)) // Space + [self sendAction:@selector(markSelected:) to:[self delegate]]; + else + [super keyDown:theEvent]; +} + +- (void)outlineView:(NSOutlineView *)outlineView setObjectValue:(id)object forTableColumn:(NSTableColumn *)tableColumn byItem:(id)item +{ + if (![[tableColumn identifier] isEqual:@"0"]) + return; //We only want to cover renames. + OVNode *node = item; + NSString *oldName = [[node buffer] objectAtIndex:0]; + NSString *newName = object; + if (![newName isEqual:oldName]) + { + BOOL renamed = n2b([(PyDupeGuruBase *)py renameSelected:newName]); + if (renamed) + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; + else + [Dialogs showMessage:[NSString stringWithFormat:@"The name '%@' already exists.",newName]]; + } +} +@end + +@implementation ResultWindowBase +- (void)awakeFromNib +{ + [self window]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(registrationRequired:) name:RegistrationRequired object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jobStarted:) name:JobStarted object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jobInProgress:) name:JobInProgress object:nil]; +} + +/* Virtual */ +- (NSString *)logoImageName +{ + return @"dg_logo32"; +} + +/* Actions */ +- (IBAction)changeDelta:(id)sender +{ + _displayDelta = [deltaSwitch selectedSegment] == 1; + [py setDisplayDeltaValues:b2n(_displayDelta)]; + [matches reloadData]; + [self expandAll:nil]; +} + +- (IBAction)copyMarked:(id)sender +{ + int mark_count = [[py getMarkCount] intValue]; + if (!mark_count) + return; + NSOpenPanel *op = [NSOpenPanel openPanel]; + [op setCanChooseFiles:NO]; + [op setCanChooseDirectories:YES]; + [op setCanCreateDirectories:YES]; + [op setAllowsMultipleSelection:NO]; + [op setTitle:@"Select a directory to copy marked files to"]; + if ([op runModalForTypes:nil] == NSOKButton) + { + NSString *directory = [[op filenames] objectAtIndex:0]; + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [py copyOrMove:b2n(YES) markedTo:directory recreatePath:[ud objectForKey:@"recreatePathType"]]; + } +} + +- (IBAction)deleteMarked:(id)sender +{ + int mark_count = [[py getMarkCount] intValue]; + if (!mark_count) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"You are about to send %d files to Trash. Continue?",mark_count]] == NSAlertSecondButtonReturn) // NO + return; + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [py setRemoveEmptyFolders:[ud objectForKey:@"removeEmptyFolders"]]; + [py deleteMarked]; +} + +- (IBAction)expandAll:(id)sender +{ + for (int i=0;i < [matches numberOfRows];i++) + [matches expandItem:[matches itemAtRow:i]]; +} + +- (IBAction)moveMarked:(id)sender +{ + int mark_count = [[py getMarkCount] intValue]; + if (!mark_count) + return; + NSOpenPanel *op = [NSOpenPanel openPanel]; + [op setCanChooseFiles:NO]; + [op setCanChooseDirectories:YES]; + [op setCanCreateDirectories:YES]; + [op setAllowsMultipleSelection:NO]; + [op setTitle:@"Select a directory to move marked files to"]; + if ([op runModalForTypes:nil] == NSOKButton) + { + NSString *directory = [[op filenames] objectAtIndex:0]; + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [py setRemoveEmptyFolders:[ud objectForKey:@"removeEmptyFolders"]]; + [py copyOrMove:b2n(NO) markedTo:directory recreatePath:[ud objectForKey:@"recreatePathType"]]; + } +} + +/* Delegate */ + +- (void)outlineView:(NSOutlineView *)outlineView didClickTableColumn:(NSTableColumn *)tableColumn +{ + if ([[outlineView sortDescriptors] count] < 1) + return; + NSSortDescriptor *sd = [[outlineView sortDescriptors] objectAtIndex:0]; + if (_powerMode) + [py sortDupesBy:i2n([[sd key] intValue]) ascending:b2n([sd ascending])]; + else + [py sortGroupsBy:i2n([[sd key] intValue]) ascending:b2n([sd ascending])]; + [matches reloadData]; + [self expandAll:nil]; +} + +/* Notifications */ +- (void)windowWillClose:(NSNotification *)aNotification +{ + [NSApp hide:NSApp]; +} + +- (void)jobCompleted:(NSNotification *)aNotification +{ + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; + int r = n2i([py getOperationalErrorCount]); + id lastAction = [[ProgressController mainProgressController] jobId]; + if ([lastAction isEqualTo:jobCopy]) + { + if (r > 0) + [Dialogs showMessage:[NSString stringWithFormat:@"%d file(s) couldn't be copied.",r]]; + else + [Dialogs showMessage:@"All marked files were copied sucessfully."]; + } + if ([lastAction isEqualTo:jobMove]) + { + if (r > 0) + [Dialogs showMessage:[NSString stringWithFormat:@"%d file(s) couldn't be moved. They were kept in the results, and still are marked.",r]]; + else + [Dialogs showMessage:@"All marked files were moved sucessfully."]; + } + if ([lastAction isEqualTo:jobDelete]) + { + if (r > 0) + [Dialogs showMessage:[NSString stringWithFormat:@"%d file(s) couldn't be sent to Trash. They were kept in the results, and still are marked.",r]]; + else + [Dialogs showMessage:@"All marked files were sucessfully sent to Trash."]; + } + // Re-activate toolbar items right after the progress bar stops showing instead of waiting until + // a mouse-over is performed + [[[self window] toolbar] validateVisibleItems]; +} + +- (void)jobInProgress:(NSNotification *)aNotification +{ + [Dialogs showMessage:@"A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again."]; +} + +- (void)jobStarted:(NSNotification *)aNotification +{ + NSDictionary *ui = [aNotification userInfo]; + NSString *desc = [ui valueForKey:@"desc"]; + [[ProgressController mainProgressController] setJobDesc:desc]; + NSString *jobid = [ui valueForKey:@"jobid"]; + // NSLog(jobid); + [[ProgressController mainProgressController] setJobId:jobid]; + [[ProgressController mainProgressController] showSheetForParent:[self window]]; +} + +- (void)registrationRequired:(NSNotification *)aNotification +{ + NSString *msg = @"This is a demo version, which only allows you 10 delete/copy/move actions per session. You cannot continue."; + [Dialogs showMessage:msg]; +} + +/* Toolbar */ +- (NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSString *)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag +{ + NSToolbarItem *tbi = [[[NSToolbarItem alloc] initWithItemIdentifier:itemIdentifier] autorelease]; + if ([itemIdentifier isEqualTo:tbbDirectories]) + { + [tbi setLabel: @"Directories"]; + [tbi setToolTip: @"Show/Hide the directories panel."]; + [tbi setImage: [NSImage imageNamed: @"folder32"]]; + [tbi setTarget: app]; + [tbi setAction: @selector(toggleDirectories:)]; + } + else if ([itemIdentifier isEqualTo:tbbDetails]) + { + [tbi setLabel: @"Details"]; + [tbi setToolTip: @"Show/Hide the details panel."]; + [tbi setImage: [NSImage imageNamed: @"details32"]]; + [tbi setTarget: self]; + [tbi setAction: @selector(toggleDetailsPanel:)]; + } + else if ([itemIdentifier isEqualTo:tbbPreferences]) + { + [tbi setLabel: @"Preferences"]; + [tbi setToolTip: @"Show the preferences panel."]; + [tbi setImage: [NSImage imageNamed: @"preferences32"]]; + [tbi setTarget: self]; + [tbi setAction: @selector(showPreferencesPanel:)]; + } + else if ([itemIdentifier isEqualTo:tbbPowerMarker]) + { + [tbi setLabel: @"Power Marker"]; + [tbi setToolTip: @"When enabled, only the duplicates are shown, not the references."]; + [tbi setView:pmSwitchView]; + [tbi setMinSize:[pmSwitchView frame].size]; + [tbi setMaxSize:[pmSwitchView frame].size]; + } + else if ([itemIdentifier isEqualTo:tbbScan]) + { + [tbi setLabel: @"Start Scanning"]; + [tbi setToolTip: @"Start scanning for duplicates in the selected directories."]; + [tbi setImage: [NSImage imageNamed:[self logoImageName]]]; + [tbi setTarget: self]; + [tbi setAction: @selector(startDuplicateScan:)]; + } + else if ([itemIdentifier isEqualTo:tbbAction]) + { + [tbi setLabel: @"Action"]; + [tbi setView:actionMenuView]; + [tbi setMinSize:[actionMenuView frame].size]; + [tbi setMaxSize:[actionMenuView frame].size]; + } + else if ([itemIdentifier isEqualTo:tbbDelta]) + { + [tbi setLabel: @"Delta Values"]; + [tbi setToolTip: @"When enabled, this option makes dupeGuru display, where applicable, delta values instead of absolute values."]; + [tbi setView:deltaSwitchView]; + [tbi setMinSize:[deltaSwitchView frame].size]; + [tbi setMaxSize:[deltaSwitchView frame].size]; + } + else if ([itemIdentifier isEqualTo:tbbFilter]) + { + [tbi setLabel: @"Filter"]; + [tbi setToolTip: @"Filters the results using regular expression."]; + [tbi setView:filterFieldView]; + [tbi setMinSize:[filterFieldView frame].size]; + [tbi setMaxSize:NSMakeSize(1000, [filterFieldView frame].size.height)]; + } + [tbi setPaletteLabel: [tbi label]]; + return tbi; +} + +- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar +{ + return [NSArray arrayWithObjects: + tbbDirectories, + tbbDetails, + tbbPreferences, + tbbPowerMarker, + tbbScan, + tbbAction, + tbbDelta, + tbbFilter, + NSToolbarSeparatorItemIdentifier, + NSToolbarSpaceItemIdentifier, + NSToolbarFlexibleSpaceItemIdentifier, + nil]; +} + +- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar +{ + return [NSArray arrayWithObjects: + tbbScan, + tbbAction, + tbbDirectories, + tbbDetails, + tbbPowerMarker, + tbbDelta, + tbbFilter, + nil]; +} + +- (BOOL)validateToolbarItem:(NSToolbarItem *)theItem +{ + return ![[ProgressController mainProgressController] isShown]; +} + +- (BOOL)validateMenuItem:(NSMenuItem *)item +{ + return ![[ProgressController mainProgressController] isShown]; +} +@end diff --git a/base/cocoa/dgbase.xcodeproj/project.pbxproj b/base/cocoa/dgbase.xcodeproj/project.pbxproj new file mode 100644 index 00000000..54889c77 --- /dev/null +++ b/base/cocoa/dgbase.xcodeproj/project.pbxproj @@ -0,0 +1,408 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 44; + objects = { + +/* Begin PBXBuildFile section */ + 8DC2EF530486A6940098B216 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 089C1666FE841158C02AAC07 /* InfoPlist.strings */; }; + 8DC2EF570486A6940098B216 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1058C7B1FEA5585E11CA2CBB /* Cocoa.framework */; }; + CE62CA730CFAF80D0001B6E0 /* ResultWindow.h in Headers */ = {isa = PBXBuildFile; fileRef = CE62CA710CFAF80D0001B6E0 /* ResultWindow.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CE62CA740CFAF80D0001B6E0 /* ResultWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CE62CA720CFAF80D0001B6E0 /* ResultWindow.m */; }; + CE70A0FC0CF8C2560048C314 /* PyDupeGuru.h in Headers */ = {isa = PBXBuildFile; fileRef = CE70A0FB0CF8C2560048C314 /* PyDupeGuru.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CE7D77050CF8987800BA287E /* Consts.h in Headers */ = {isa = PBXBuildFile; fileRef = CE7D77030CF8987800BA287E /* Consts.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CE80DA190FC191320086DCA6 /* RecentDirectories.h in Headers */ = {isa = PBXBuildFile; fileRef = CE80DA170FC191320086DCA6 /* RecentDirectories.h */; settings = {ATTRIBUTES = (Private, ); }; }; + CE80DA1A0FC191320086DCA6 /* RecentDirectories.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DA180FC191320086DCA6 /* RecentDirectories.m */; }; + CE80DA2E0FC191980086DCA6 /* Dialogs.h in Headers */ = {isa = PBXBuildFile; fileRef = CE80DA250FC191980086DCA6 /* Dialogs.h */; settings = {ATTRIBUTES = (Private, ); }; }; + CE80DA2F0FC191980086DCA6 /* Dialogs.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DA260FC191980086DCA6 /* Dialogs.m */; }; + CE80DA300FC191980086DCA6 /* ProgressController.h in Headers */ = {isa = PBXBuildFile; fileRef = CE80DA270FC191980086DCA6 /* ProgressController.h */; settings = {ATTRIBUTES = (Private, ); }; }; + CE80DA310FC191980086DCA6 /* ProgressController.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DA280FC191980086DCA6 /* ProgressController.m */; }; + CE80DA320FC191980086DCA6 /* PyApp.h in Headers */ = {isa = PBXBuildFile; fileRef = CE80DA290FC191980086DCA6 /* PyApp.h */; settings = {ATTRIBUTES = (Private, ); }; }; + CE80DA330FC191980086DCA6 /* RegistrationInterface.h in Headers */ = {isa = PBXBuildFile; fileRef = CE80DA2A0FC191980086DCA6 /* RegistrationInterface.h */; settings = {ATTRIBUTES = (Private, ); }; }; + CE80DA340FC191980086DCA6 /* RegistrationInterface.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DA2B0FC191980086DCA6 /* RegistrationInterface.m */; }; + CE80DA350FC191980086DCA6 /* Utils.h in Headers */ = {isa = PBXBuildFile; fileRef = CE80DA2C0FC191980086DCA6 /* Utils.h */; settings = {ATTRIBUTES = (Private, ); }; }; + CE80DA360FC191980086DCA6 /* Utils.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DA2D0FC191980086DCA6 /* Utils.m */; }; + CE80DA3B0FC191C40086DCA6 /* Outline.h in Headers */ = {isa = PBXBuildFile; fileRef = CE80DA370FC191C40086DCA6 /* Outline.h */; settings = {ATTRIBUTES = (Private, ); }; }; + CE80DA3C0FC191C40086DCA6 /* Outline.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DA380FC191C40086DCA6 /* Outline.m */; }; + CE80DA3D0FC191C40086DCA6 /* Table.h in Headers */ = {isa = PBXBuildFile; fileRef = CE80DA390FC191C40086DCA6 /* Table.h */; settings = {ATTRIBUTES = (Private, ); }; }; + CE80DA3E0FC191C40086DCA6 /* Table.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DA3A0FC191C40086DCA6 /* Table.m */; }; + CE80DB6D0FC194560086DCA6 /* progress.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE80DB690FC194560086DCA6 /* progress.nib */; }; + CE80DB6E0FC194560086DCA6 /* registration.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE80DB6B0FC194560086DCA6 /* registration.nib */; }; + CE895B5F0CFAE78300B5ADEA /* AppDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = CE895B5D0CFAE78300B5ADEA /* AppDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CE895B600CFAE78300B5ADEA /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = CE895B5E0CFAE78300B5ADEA /* AppDelegate.m */; }; + CE895C150CFAF0C900B5ADEA /* DirectoryPanel.h in Headers */ = {isa = PBXBuildFile; fileRef = CE895C130CFAF0C900B5ADEA /* DirectoryPanel.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CE895C160CFAF0C900B5ADEA /* DirectoryPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CE895C140CFAF0C900B5ADEA /* DirectoryPanel.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0867D69BFE84028FC02AAC07 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = /System/Library/Frameworks/Foundation.framework; sourceTree = ""; }; + 0867D6A5FE840307C02AAC07 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = /System/Library/Frameworks/AppKit.framework; sourceTree = ""; }; + 089C1667FE841158C02AAC07 /* English */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = English; path = English.lproj/InfoPlist.strings; sourceTree = ""; }; + 1058C7B1FEA5585E11CA2CBB /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; + 8DC2EF5A0486A6940098B216 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8DC2EF5B0486A6940098B216 /* dgbase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = dgbase.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CE62CA710CFAF80D0001B6E0 /* ResultWindow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ResultWindow.h; sourceTree = ""; }; + CE62CA720CFAF80D0001B6E0 /* ResultWindow.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ResultWindow.m; sourceTree = ""; }; + CE70A0FB0CF8C2560048C314 /* PyDupeGuru.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PyDupeGuru.h; sourceTree = ""; }; + CE7D77030CF8987800BA287E /* Consts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Consts.h; sourceTree = ""; }; + CE80DA170FC191320086DCA6 /* RecentDirectories.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RecentDirectories.h; path = ../../../cocoalib/RecentDirectories.h; sourceTree = SOURCE_ROOT; }; + CE80DA180FC191320086DCA6 /* RecentDirectories.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RecentDirectories.m; path = ../../../cocoalib/RecentDirectories.m; sourceTree = SOURCE_ROOT; }; + CE80DA250FC191980086DCA6 /* Dialogs.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Dialogs.h; path = ../../../cocoalib/Dialogs.h; sourceTree = SOURCE_ROOT; }; + CE80DA260FC191980086DCA6 /* Dialogs.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Dialogs.m; path = ../../../cocoalib/Dialogs.m; sourceTree = SOURCE_ROOT; }; + CE80DA270FC191980086DCA6 /* ProgressController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ProgressController.h; path = ../../../cocoalib/ProgressController.h; sourceTree = SOURCE_ROOT; }; + CE80DA280FC191980086DCA6 /* ProgressController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ProgressController.m; path = ../../../cocoalib/ProgressController.m; sourceTree = SOURCE_ROOT; }; + CE80DA290FC191980086DCA6 /* PyApp.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PyApp.h; path = ../../../cocoalib/PyApp.h; sourceTree = SOURCE_ROOT; }; + CE80DA2A0FC191980086DCA6 /* RegistrationInterface.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RegistrationInterface.h; path = ../../../cocoalib/RegistrationInterface.h; sourceTree = SOURCE_ROOT; }; + CE80DA2B0FC191980086DCA6 /* RegistrationInterface.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RegistrationInterface.m; path = ../../../cocoalib/RegistrationInterface.m; sourceTree = SOURCE_ROOT; }; + CE80DA2C0FC191980086DCA6 /* Utils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Utils.h; path = ../../../cocoalib/Utils.h; sourceTree = SOURCE_ROOT; }; + CE80DA2D0FC191980086DCA6 /* Utils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Utils.m; path = ../../../cocoalib/Utils.m; sourceTree = SOURCE_ROOT; }; + CE80DA370FC191C40086DCA6 /* Outline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Outline.h; path = ../../../cocoalib/Outline.h; sourceTree = SOURCE_ROOT; }; + CE80DA380FC191C40086DCA6 /* Outline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Outline.m; path = ../../../cocoalib/Outline.m; sourceTree = SOURCE_ROOT; }; + CE80DA390FC191C40086DCA6 /* Table.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Table.h; path = ../../../cocoalib/Table.h; sourceTree = SOURCE_ROOT; }; + CE80DA3A0FC191C40086DCA6 /* Table.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Table.m; path = ../../../cocoalib/Table.m; sourceTree = SOURCE_ROOT; }; + CE80DB6A0FC194560086DCA6 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = ../../../cocoalib/English.lproj/progress.nib; sourceTree = SOURCE_ROOT; }; + CE80DB6C0FC194560086DCA6 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = ../../../cocoalib/English.lproj/registration.nib; sourceTree = SOURCE_ROOT; }; + CE895B5D0CFAE78300B5ADEA /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + CE895B5E0CFAE78300B5ADEA /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + CE895C130CFAF0C900B5ADEA /* DirectoryPanel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DirectoryPanel.h; sourceTree = ""; }; + CE895C140CFAF0C900B5ADEA /* DirectoryPanel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DirectoryPanel.m; sourceTree = ""; }; + D2F7E79907B2D74100F64583 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = /System/Library/Frameworks/CoreData.framework; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8DC2EF560486A6940098B216 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8DC2EF570486A6940098B216 /* Cocoa.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 034768DFFF38A50411DB9C8B /* Products */ = { + isa = PBXGroup; + children = ( + 8DC2EF5B0486A6940098B216 /* dgbase.framework */, + ); + name = Products; + sourceTree = ""; + }; + 0867D691FE84028FC02AAC07 /* dgbase */ = { + isa = PBXGroup; + children = ( + 08FB77AEFE84172EC02AAC07 /* Classes */, + CE80DA160FC1910F0086DCA6 /* cocoalib */, + 32C88DFF0371C24200C91783 /* Other Sources */, + 089C1665FE841158C02AAC07 /* Resources */, + 0867D69AFE84028FC02AAC07 /* External Frameworks and Libraries */, + 034768DFFF38A50411DB9C8B /* Products */, + ); + name = dgbase; + sourceTree = ""; + }; + 0867D69AFE84028FC02AAC07 /* External Frameworks and Libraries */ = { + isa = PBXGroup; + children = ( + 1058C7B0FEA5585E11CA2CBB /* Linked Frameworks */, + 1058C7B2FEA5585E11CA2CBB /* Other Frameworks */, + ); + name = "External Frameworks and Libraries"; + sourceTree = ""; + }; + 089C1665FE841158C02AAC07 /* Resources */ = { + isa = PBXGroup; + children = ( + CE80DB690FC194560086DCA6 /* progress.nib */, + CE80DB6B0FC194560086DCA6 /* registration.nib */, + 8DC2EF5A0486A6940098B216 /* Info.plist */, + 089C1666FE841158C02AAC07 /* InfoPlist.strings */, + ); + name = Resources; + sourceTree = ""; + }; + 08FB77AEFE84172EC02AAC07 /* Classes */ = { + isa = PBXGroup; + children = ( + CE62CA710CFAF80D0001B6E0 /* ResultWindow.h */, + CE62CA720CFAF80D0001B6E0 /* ResultWindow.m */, + CE895C130CFAF0C900B5ADEA /* DirectoryPanel.h */, + CE895C140CFAF0C900B5ADEA /* DirectoryPanel.m */, + CE895B5D0CFAE78300B5ADEA /* AppDelegate.h */, + CE895B5E0CFAE78300B5ADEA /* AppDelegate.m */, + ); + name = Classes; + sourceTree = ""; + }; + 1058C7B0FEA5585E11CA2CBB /* Linked Frameworks */ = { + isa = PBXGroup; + children = ( + 1058C7B1FEA5585E11CA2CBB /* Cocoa.framework */, + ); + name = "Linked Frameworks"; + sourceTree = ""; + }; + 1058C7B2FEA5585E11CA2CBB /* Other Frameworks */ = { + isa = PBXGroup; + children = ( + 0867D6A5FE840307C02AAC07 /* AppKit.framework */, + D2F7E79907B2D74100F64583 /* CoreData.framework */, + 0867D69BFE84028FC02AAC07 /* Foundation.framework */, + ); + name = "Other Frameworks"; + sourceTree = ""; + }; + 32C88DFF0371C24200C91783 /* Other Sources */ = { + isa = PBXGroup; + children = ( + CE70A0FB0CF8C2560048C314 /* PyDupeGuru.h */, + CE7D77030CF8987800BA287E /* Consts.h */, + ); + name = "Other Sources"; + sourceTree = ""; + }; + CE80DA160FC1910F0086DCA6 /* cocoalib */ = { + isa = PBXGroup; + children = ( + CE80DA370FC191C40086DCA6 /* Outline.h */, + CE80DA380FC191C40086DCA6 /* Outline.m */, + CE80DA390FC191C40086DCA6 /* Table.h */, + CE80DA3A0FC191C40086DCA6 /* Table.m */, + CE80DA250FC191980086DCA6 /* Dialogs.h */, + CE80DA260FC191980086DCA6 /* Dialogs.m */, + CE80DA270FC191980086DCA6 /* ProgressController.h */, + CE80DA280FC191980086DCA6 /* ProgressController.m */, + CE80DA290FC191980086DCA6 /* PyApp.h */, + CE80DA2A0FC191980086DCA6 /* RegistrationInterface.h */, + CE80DA2B0FC191980086DCA6 /* RegistrationInterface.m */, + CE80DA2C0FC191980086DCA6 /* Utils.h */, + CE80DA2D0FC191980086DCA6 /* Utils.m */, + CE80DA170FC191320086DCA6 /* RecentDirectories.h */, + CE80DA180FC191320086DCA6 /* RecentDirectories.m */, + ); + name = cocoalib; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 8DC2EF500486A6940098B216 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + CE7D77050CF8987800BA287E /* Consts.h in Headers */, + CE70A0FC0CF8C2560048C314 /* PyDupeGuru.h in Headers */, + CE895B5F0CFAE78300B5ADEA /* AppDelegate.h in Headers */, + CE895C150CFAF0C900B5ADEA /* DirectoryPanel.h in Headers */, + CE62CA730CFAF80D0001B6E0 /* ResultWindow.h in Headers */, + CE80DA190FC191320086DCA6 /* RecentDirectories.h in Headers */, + CE80DA2E0FC191980086DCA6 /* Dialogs.h in Headers */, + CE80DA300FC191980086DCA6 /* ProgressController.h in Headers */, + CE80DA320FC191980086DCA6 /* PyApp.h in Headers */, + CE80DA330FC191980086DCA6 /* RegistrationInterface.h in Headers */, + CE80DA350FC191980086DCA6 /* Utils.h in Headers */, + CE80DA3B0FC191C40086DCA6 /* Outline.h in Headers */, + CE80DA3D0FC191C40086DCA6 /* Table.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 8DC2EF4F0486A6940098B216 /* dgbase */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1DEB91AD08733DA50010E9CD /* Build configuration list for PBXNativeTarget "dgbase" */; + buildPhases = ( + 8DC2EF500486A6940098B216 /* Headers */, + 8DC2EF520486A6940098B216 /* Resources */, + 8DC2EF540486A6940098B216 /* Sources */, + 8DC2EF560486A6940098B216 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = dgbase; + productInstallPath = "$(HOME)/Library/Frameworks"; + productName = dgbase; + productReference = 8DC2EF5B0486A6940098B216 /* dgbase.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0867D690FE84028FC02AAC07 /* Project object */ = { + isa = PBXProject; + buildConfigurationList = 1DEB91B108733DA50010E9CD /* Build configuration list for PBXProject "dgbase" */; + compatibilityVersion = "Xcode 3.0"; + hasScannedForEncodings = 1; + mainGroup = 0867D691FE84028FC02AAC07 /* dgbase */; + productRefGroup = 034768DFFF38A50411DB9C8B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8DC2EF4F0486A6940098B216 /* dgbase */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8DC2EF520486A6940098B216 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8DC2EF530486A6940098B216 /* InfoPlist.strings in Resources */, + CE80DB6D0FC194560086DCA6 /* progress.nib in Resources */, + CE80DB6E0FC194560086DCA6 /* registration.nib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8DC2EF540486A6940098B216 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CE895B600CFAE78300B5ADEA /* AppDelegate.m in Sources */, + CE895C160CFAF0C900B5ADEA /* DirectoryPanel.m in Sources */, + CE62CA740CFAF80D0001B6E0 /* ResultWindow.m in Sources */, + CE80DA1A0FC191320086DCA6 /* RecentDirectories.m in Sources */, + CE80DA2F0FC191980086DCA6 /* Dialogs.m in Sources */, + CE80DA310FC191980086DCA6 /* ProgressController.m in Sources */, + CE80DA340FC191980086DCA6 /* RegistrationInterface.m in Sources */, + CE80DA360FC191980086DCA6 /* Utils.m in Sources */, + CE80DA3C0FC191C40086DCA6 /* Outline.m in Sources */, + CE80DA3E0FC191C40086DCA6 /* Table.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 089C1666FE841158C02AAC07 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 089C1667FE841158C02AAC07 /* English */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + CE80DB690FC194560086DCA6 /* progress.nib */ = { + isa = PBXVariantGroup; + children = ( + CE80DB6A0FC194560086DCA6 /* English */, + ); + name = progress.nib; + sourceTree = SOURCE_ROOT; + }; + CE80DB6B0FC194560086DCA6 /* registration.nib */ = { + isa = PBXVariantGroup; + children = ( + CE80DB6C0FC194560086DCA6 /* English */, + ); + name = registration.nib; + sourceTree = SOURCE_ROOT; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 1DEB91AE08733DA50010E9CD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COPY_PHASE_STRIP = NO; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/../../../cocoalib/build/Release\"", + ); + FRAMEWORK_VERSION = A; + GCC_DYNAMIC_NO_PIC = NO; + GCC_ENABLE_FIX_AND_CONTINUE = YES; + GCC_MODEL_TUNING = G5; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(HOME)/Library/Frameworks"; + PRODUCT_NAME = dgbase; + WRAPPER_EXTENSION = framework; + ZERO_LINK = YES; + }; + name = Debug; + }; + 1DEB91AF08733DA50010E9CD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/../../../cocoalib/build/Release\"", + ); + FRAMEWORK_VERSION = A; + GCC_MODEL_TUNING = G5; + GCC_PRECOMPILE_PREFIX_HEADER = NO; + GCC_PREFIX_HEADER = ""; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "@executable_path/../Frameworks"; + PRODUCT_NAME = dgbase; + WRAPPER_EXTENSION = framework; + }; + name = Release; + }; + 1DEB91B208733DA50010E9CD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + GCC_C_LANGUAGE_STANDARD = c99; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + PREBINDING = NO; + SDKROOT = "$(DEVELOPER_SDK_DIR)/MacOSX10.4u.sdk"; + }; + name = Debug; + }; + 1DEB91B308733DA50010E9CD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = ( + ppc, + i386, + ); + GCC_C_LANGUAGE_STANDARD = c99; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + PREBINDING = NO; + SDKROOT = "$(DEVELOPER_SDK_DIR)/MacOSX10.4u.sdk"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1DEB91AD08733DA50010E9CD /* Build configuration list for PBXNativeTarget "dgbase" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1DEB91AE08733DA50010E9CD /* Debug */, + 1DEB91AF08733DA50010E9CD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1DEB91B108733DA50010E9CD /* Build configuration list for PBXProject "dgbase" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1DEB91B208733DA50010E9CD /* Debug */, + 1DEB91B308733DA50010E9CD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0867D690FE84028FC02AAC07 /* Project object */; +} diff --git a/base/qt/__init__.py b/base/qt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/base/qt/about_box.py b/base/qt/about_box.py new file mode 100644 index 00000000..55a36eb1 --- /dev/null +++ b/base/qt/about_box.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# Unit Name: about_box +# Created By: Virgil Dupras +# Created On: 2009-05-09 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, QCoreApplication, SIGNAL +from PyQt4.QtGui import QDialog, QDialogButtonBox, QPixmap + +from about_box_ui import Ui_AboutBox + +class AboutBox(QDialog, Ui_AboutBox): + def __init__(self, parent, app): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.MSWindowsFixedSizeDialogHint + QDialog.__init__(self, parent, flags) + self.app = app + self._setupUi() + + self.connect(self.buttonBox, SIGNAL('clicked(QAbstractButton*)'), self.buttonClicked) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be done in the Designer + self.setWindowTitle(u"About %s" % QCoreApplication.instance().applicationName()) + self.nameLabel.setText(QCoreApplication.instance().applicationName()) + self.versionLabel.setText('Version ' + QCoreApplication.instance().applicationVersion()) + self.logoLabel.setPixmap(QPixmap(':/%s_big' % self.app.LOGO_NAME)) + self.registerButton = self.buttonBox.addButton("Register", QDialogButtonBox.ActionRole) + + #--- Events + def buttonClicked(self, button): + if button is self.registerButton: + self.app.ask_for_reg_code() + diff --git a/base/qt/about_box.ui b/base/qt/about_box.ui new file mode 100644 index 00000000..aa9c5ce5 --- /dev/null +++ b/base/qt/about_box.ui @@ -0,0 +1,133 @@ + + + AboutBox + + + + 0 + 0 + 400 + 190 + + + + + 0 + 0 + + + + About dupeGuru + + + + + + + + + :/logo_me_big + + + + + + + + + + 75 + true + + + + dupeGuru + + + + + + + Version + + + + + + + Copyright Hardcoded Software 2009 + + + + + + + + 75 + true + + + + Registered To: + + + + + + + UNREGISTERED + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Ok + + + + + + + + + + + + + buttonBox + accepted() + AboutBox + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + AboutBox + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/base/qt/app.py b/base/qt/app.py new file mode 100644 index 00000000..3fa340bf --- /dev/null +++ b/base/qt/app.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python +# Unit Name: app +# Created By: Virgil Dupras +# Created On: 2009-04-25 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import logging +import os.path as op +import traceback + +from PyQt4.QtCore import Qt, QTimer, QObject, QCoreApplication, QUrl, SIGNAL +from PyQt4.QtGui import QProgressDialog, QDesktopServices, QFileDialog, QDialog, QMessageBox + +from hsutil import job +from hsutil.reg import RegistrationRequired + +from dupeguru import data_pe +from dupeguru.app import (DupeGuru as DupeGuruBase, JOB_SCAN, JOB_LOAD, JOB_MOVE, JOB_COPY, + JOB_DELETE) + +from main_window import MainWindow +from directories_dialog import DirectoriesDialog +from about_box import AboutBox +from reg import Registration +from error_report_dialog import ErrorReportDialog + +JOBID2TITLE = { + JOB_SCAN: "Scanning for duplicates", + JOB_LOAD: "Loading", + JOB_MOVE: "Moving", + JOB_COPY: "Copying", + JOB_DELETE: "Sending files to the recycle bin", +} + +class Progress(QProgressDialog, job.ThreadedJobPerformer): + def __init__(self, parent): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QProgressDialog.__init__(self, '', u"Cancel", 0, 100, parent, flags) + self.setModal(True) + self.setAutoReset(False) + self.setAutoClose(False) + self._timer = QTimer() + self._jobid = '' + self.connect(self._timer, SIGNAL('timeout()'), self.updateProgress) + + def updateProgress(self): + # the values might change before setValue happens + last_progress = self.last_progress + last_desc = self.last_desc + if not self._job_running or last_progress is None: + self._timer.stop() + self.close() + self.emit(SIGNAL('finished(QString)'), self._jobid) + if self._last_error is not None: + s = ''.join(traceback.format_exception(*self._last_error)) + dialog = ErrorReportDialog(self.parent(), s) + dialog.exec_() + return + if self.wasCanceled(): + self.job_cancelled = True + return + if last_desc: + self.setLabelText(last_desc) + self.setValue(last_progress) + + def run(self, jobid, title, target, args=()): + self._jobid = jobid + self.reset() + self.setLabelText('') + self.run_threaded(target, args) + self.setWindowTitle(title) + self.show() + self._timer.start(500) + + +def demo_method(method): + def wrapper(self, *args, **kwargs): + try: + return method(self, *args, **kwargs) + except RegistrationRequired: + msg = "The demo version of dupeGuru only allows 10 actions (delete/move/copy) per session." + QMessageBox.information(self.main_window, 'Demo', msg) + + return wrapper + +class DupeGuru(DupeGuruBase, QObject): + LOGO_NAME = '' + NAME = '' + DELTA_COLUMNS = frozenset() + + def __init__(self, data_module, appid): + appdata = unicode(QDesktopServices.storageLocation(QDesktopServices.DataLocation)) + DupeGuruBase.__init__(self, data_module, appdata, appid) + QObject.__init__(self) + self._setup() + + #--- Private + def _setup(self): + self.selected_dupe = None + self.prefs = self._create_preferences() + self.prefs.load() + self._update_options() + self.main_window = self._create_main_window() + self._progress = Progress(self.main_window) + self.directories_dialog = DirectoriesDialog(self.main_window, self) + self.details_dialog = self._create_details_dialog(self.main_window) + self.preferences_dialog = self._create_preferences_dialog(self.main_window) + self.about_box = AboutBox(self.main_window, self) + + self.reg = Registration(self) + self.set_registration(self.prefs.registration_code, self.prefs.registration_email) + if not self.registered: + self.reg.show_nag() + self.main_window.show() + self.load() + + self.connect(QCoreApplication.instance(), SIGNAL('aboutToQuit()'), self.application_will_terminate) + self.connect(self._progress, SIGNAL('finished(QString)'), self.job_finished) + + def _setup_as_registered(self): + self.prefs.registration_code = self.registration_code + self.prefs.registration_email = self.registration_email + self.main_window.actionRegister.setVisible(False) + self.about_box.registerButton.hide() + self.about_box.registeredEmailLabel.setText(self.prefs.registration_email) + + def _update_options(self): + self.scanner.mix_file_kind = self.prefs.mix_file_kind + self.options['escape_filter_regexp'] = self.prefs.use_regexp + self.options['clean_empty_dirs'] = self.prefs.remove_empty_folders + + #--- Virtual + def _create_details_dialog(self, parent): + raise NotImplementedError() + + def _create_main_window(self): + return MainWindow(app=self) + + def _create_preferences(self): + raise NotImplementedError() + + def _create_preferences_dialog(self, parent): + raise NotImplementedError() + + #--- Override + def _start_job(self, jobid, func): + title = JOBID2TITLE[jobid] + try: + j = self._progress.create_job() + self._progress.run(jobid, title, func, args=(j, )) + except job.JobInProgressError: + msg = "A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again." + QMessageBox.information(self.main_window, 'Action in progress', msg) + + #--- Public + def add_dupes_to_ignore_list(self, duplicates): + for dupe in duplicates: + self.AddToIgnoreList(dupe) + self.remove_duplicates(duplicates) + + def ApplyFilter(self, filter): + DupeGuruBase.ApplyFilter(self, filter) + self.emit(SIGNAL('resultsChanged()')) + + def ask_for_reg_code(self): + if self.reg.ask_for_code(): + self._setup_ui_as_registered() + + @demo_method + def copy_or_move_marked(self, copy): + opname = 'copy' if copy else 'move' + title = "Select a directory to {0} marked files to".format(opname) + flags = QFileDialog.ShowDirsOnly + destination = unicode(QFileDialog.getExistingDirectory(self.main_window, title, '', flags)) + if not destination: + return + recreate_path = self.prefs.destination_type + DupeGuruBase.copy_or_move_marked(self, copy, destination, recreate_path) + + delete_marked = demo_method(DupeGuruBase.delete_marked) + + def make_reference(self, duplicates): + DupeGuruBase.make_reference(self, duplicates) + self.emit(SIGNAL('resultsChanged()')) + + def mark_all(self): + self.results.mark_all() + self.emit(SIGNAL('dupeMarkingChanged()')) + + def mark_invert(self): + self.results.mark_invert() + self.emit(SIGNAL('dupeMarkingChanged()')) + + def mark_none(self): + self.results.mark_none() + self.emit(SIGNAL('dupeMarkingChanged()')) + + def open_selected(self): + if self.selected_dupe is None: + return + url = QUrl.fromLocalFile(unicode(self.selected_dupe.path)) + QDesktopServices.openUrl(url) + + def remove_duplicates(self, duplicates): + self.results.remove_duplicates(duplicates) + self.emit(SIGNAL('resultsChanged()')) + + def remove_marked_duplicates(self): + marked = [d for d in self.results.dupes if self.results.is_marked(d)] + self.remove_duplicates(marked) + + def rename_dupe(self, dupe, newname): + try: + dupe.move(dupe.parent, newname) + return True + except (IndexError, fs.FSError) as e: + logging.warning("dupeGuru Warning: %s" % unicode(e)) + return False + + def reveal_selected(self): + if self.selected_dupe is None: + return + url = QUrl.fromLocalFile(unicode(self.selected_dupe.path[:-1])) + QDesktopServices.openUrl(url) + + def select_duplicate(self, dupe): + self.selected_dupe = dupe + self.emit(SIGNAL('duplicateSelected()')) + + def show_about_box(self): + self.about_box.show() + + def show_details(self): + self.details_dialog.show() + + def show_directories(self): + self.directories_dialog.show() + + def show_help(self): + url = QUrl.fromLocalFile(op.abspath('help/intro.htm')) + QDesktopServices.openUrl(url) + + def show_preferences(self): + self.preferences_dialog.load() + result = self.preferences_dialog.exec_() + if result == QDialog.Accepted: + self.preferences_dialog.save() + self.prefs.save() + self._update_options() + + def toggle_marking_for_dupes(self, dupes): + for dupe in dupes: + self.results.mark_toggle(dupe) + self.emit(SIGNAL('dupeMarkingChanged()')) + + #--- Events + def application_will_terminate(self): + self.Save() + self.SaveIgnoreList() + + def job_finished(self, jobid): + self.emit(SIGNAL('resultsChanged()')) + if jobid == JOB_LOAD: + self.emit(SIGNAL('directoriesChanged()')) + if jobid in (JOB_MOVE, JOB_COPY, JOB_DELETE) and self.last_op_error_count > 0: + msg = "{0} files could not be processed.".format(self.results.mark_count) + QMessageBox.warning(self.main_window, 'Warning', msg) + diff --git a/base/qt/details_table.py b/base/qt/details_table.py new file mode 100644 index 00000000..1c45de1e --- /dev/null +++ b/base/qt/details_table.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# Unit Name: details_table +# Created By: Virgil Dupras +# Created On: 2009-05-17 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, SIGNAL, QAbstractTableModel, QVariant +from PyQt4.QtGui import QHeaderView, QTableView + +HEADER = ['Attribute', 'Selected', 'Reference'] + +class DetailsModel(QAbstractTableModel): + def __init__(self, app): + QAbstractTableModel.__init__(self) + self._app = app + self._data = app.data + self._dupe_data = None + self._ref_data = None + self.connect(app, SIGNAL('duplicateSelected()'), self.duplicateSelected) + + def columnCount(self, parent): + return len(HEADER) + + def data(self, index, role): + if not index.isValid(): + return QVariant() + if role != Qt.DisplayRole: + return QVariant() + column = index.column() + row = index.row() + if column == 0: + return QVariant(self._data.COLUMNS[row]['display']) + elif column == 1 and self._dupe_data: + return QVariant(self._dupe_data[row]) + elif column == 2 and self._ref_data: + return QVariant(self._ref_data[row]) + return QVariant() + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal and role == Qt.DisplayRole and section < len(HEADER): + return QVariant(HEADER[section]) + return QVariant() + + def rowCount(self, parent): + return len(self._data.COLUMNS) + + #--- Events + def duplicateSelected(self): + dupe = self._app.selected_dupe + group = self._app.results.get_group_of_duplicate(dupe) + ref = group.ref + self._dupe_data = self._data.GetDisplayInfo(dupe, group) + self._ref_data = self._data.GetDisplayInfo(ref, group) + self.reset() + + +class DetailsTable(QTableView): + def __init__(self, *args): + QTableView.__init__(self, *args) + self.setAlternatingRowColors(True) + self.setSelectionBehavior(QTableView.SelectRows) + self.setShowGrid(False) + + def setModel(self, model): + QTableView.setModel(self, model) + # The model needs to be set to set header stuff + hheader = self.horizontalHeader() + hheader.setHighlightSections(False) + hheader.setStretchLastSection(False) + hheader.resizeSection(0, 100) + hheader.setResizeMode(0, QHeaderView.Fixed) + hheader.setResizeMode(1, QHeaderView.Stretch) + hheader.setResizeMode(2, QHeaderView.Stretch) + vheader = self.verticalHeader() + vheader.setVisible(False) + vheader.setDefaultSectionSize(18) + diff --git a/base/qt/dg.qrc b/base/qt/dg.qrc new file mode 100644 index 00000000..f2f5e936 --- /dev/null +++ b/base/qt/dg.qrc @@ -0,0 +1,17 @@ + + + images/details32.png + images/dgpe_logo_32.png + images/dgpe_logo_128.png + images/dgme_logo_32.png + images/dgme_logo_128.png + images/dgse_logo_32.png + images/dgse_logo_128.png + images/folderwin32.png + images/gear.png + images/preferences32.png + images/actions32.png + images/delta32.png + images/power_marker32.png + + \ No newline at end of file diff --git a/base/qt/directories_dialog.py b/base/qt/directories_dialog.py new file mode 100644 index 00000000..e2f3ddb3 --- /dev/null +++ b/base/qt/directories_dialog.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +# Unit Name: directories_dialog +# Created By: Virgil Dupras +# Created On: 2009-04-25 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QFileDialog, QHeaderView + +from directories_dialog_ui import Ui_DirectoriesDialog +from directories_model import DirectoriesModel, DirectoriesDelegate + +class DirectoriesDialog(QDialog, Ui_DirectoriesDialog): + def __init__(self, parent, app): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.app = app + self._setupUi() + self._updateRemoveButton() + + self.connect(self.doneButton, SIGNAL('clicked()'), self.doneButtonClicked) + self.connect(self.addButton, SIGNAL('clicked()'), self.addButtonClicked) + self.connect(self.removeButton, SIGNAL('clicked()'), self.removeButtonClicked) + self.connect(self.treeView.selectionModel(), SIGNAL('selectionChanged(QItemSelection,QItemSelection)'), self.selectionChanged) + self.connect(self.app, SIGNAL('directoriesChanged()'), self.directoriesChanged) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be done in the Designer + self.directoriesModel = DirectoriesModel(self.app) + self.directoriesDelegate = DirectoriesDelegate() + self.treeView.setItemDelegate(self.directoriesDelegate) + self.treeView.setModel(self.directoriesModel) + + header = self.treeView.header() + header.setStretchLastSection(False) + header.setResizeMode(0, QHeaderView.Stretch) + header.setResizeMode(1, QHeaderView.Fixed) + header.resizeSection(1, 100) + + def _updateRemoveButton(self): + indexes = self.treeView.selectedIndexes() + if not indexes: + self.removeButton.setEnabled(False) + return + self.removeButton.setEnabled(True) + index = indexes[0] + node = index.internalPointer() + # label = 'Remove' if node.parent is None else 'Exclude' + + def addButtonClicked(self): + title = u"Select a directory to add to the scanning list" + flags = QFileDialog.ShowDirsOnly + dirpath = unicode(QFileDialog.getExistingDirectory(self, title, '', flags)) + if not dirpath: + return + self.app.AddDirectory(dirpath) + self.directoriesModel.reset() + + def directoriesChanged(self): + self.directoriesModel.reset() + + def doneButtonClicked(self): + self.hide() + + def removeButtonClicked(self): + indexes = self.treeView.selectedIndexes() + if not indexes: + return + index = indexes[0] + node = index.internalPointer() + if node.parent is None: + row = index.row() + del self.app.directories[row] + self.directoriesModel.reset() + + def selectionChanged(self, selected, deselected): + self._updateRemoveButton() + diff --git a/base/qt/directories_dialog.ui b/base/qt/directories_dialog.ui new file mode 100644 index 00000000..68bc8d84 --- /dev/null +++ b/base/qt/directories_dialog.ui @@ -0,0 +1,133 @@ + + + DirectoriesDialog + + + + 0 + 0 + 420 + 338 + + + + Directories + + + + + + QAbstractItemView::DoubleClicked|QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked + + + true + + + false + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 91 + 0 + + + + + 16777215 + 32 + + + + Remove + + + + + + + + 91 + 0 + + + + + 16777215 + 32 + + + + Add + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 91 + 0 + + + + + 16777215 + 32 + + + + Done + + + true + + + + + + + + + + diff --git a/base/qt/directories_model.py b/base/qt/directories_model.py new file mode 100644 index 00000000..cae88f39 --- /dev/null +++ b/base/qt/directories_model.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +# Unit Name: directories_model +# Created By: Virgil Dupras +# Created On: 2009-04-25 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import QVariant, QModelIndex, Qt, QRect, QEvent, QPoint +from PyQt4.QtGui import QComboBox, QStyledItemDelegate, QMouseEvent, QApplication, QBrush + +from tree_model import TreeNode, TreeModel + +HEADERS = ['Name', 'State'] +STATES = ['Normal', 'Reference', 'Excluded'] + +class DirectoriesDelegate(QStyledItemDelegate): + def createEditor(self, parent, option, index): + editor = QComboBox(parent); + editor.addItems(STATES) + return editor + + def setEditorData(self, editor, index): + value, ok = index.model().data(index, Qt.EditRole).toInt() + assert ok + editor.setCurrentIndex(value); + press = QMouseEvent(QEvent.MouseButtonPress, QPoint(0, 0), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + release = QMouseEvent(QEvent.MouseButtonRelease, QPoint(0, 0), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + QApplication.sendEvent(editor, press) + QApplication.sendEvent(editor, release) + # editor.showPopup() # this causes a weird glitch. the ugly workaround is above. + + def setModelData(self, editor, model, index): + value = QVariant(editor.currentIndex()) + model.setData(index, value, Qt.EditRole) + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + + +class DirectoryNode(TreeNode): + def __init__(self, parent, ref, row): + TreeNode.__init__(self, parent, row) + self.ref = ref + + def _get_children(self): + children = [] + for index, directory in enumerate(self.ref.dirs): + node = DirectoryNode(self, directory, index) + children.append(node) + return children + + +class DirectoriesModel(TreeModel): + def __init__(self, app): + self._dirs = app.directories + TreeModel.__init__(self) + + def _root_nodes(self): + nodes = [] + for index, directory in enumerate(self._dirs): + nodes.append(DirectoryNode(None, directory, index)) + return nodes + + def columnCount(self, parent): + return 2 + + def data(self, index, role): + if not index.isValid(): + return QVariant() + node = index.internalPointer() + if role == Qt.DisplayRole: + if index.column() == 0: + return QVariant(node.ref.name) + else: + return QVariant(STATES[self._dirs.GetState(node.ref.path)]) + elif role == Qt.EditRole and index.column() == 1: + return QVariant(self._dirs.GetState(node.ref.path)) + elif role == Qt.ForegroundRole: + state = self._dirs.GetState(node.ref.path) + if state == 1: + return QVariant(QBrush(Qt.blue)) + elif state == 2: + return QVariant(QBrush(Qt.red)) + return QVariant() + + def flags(self, index): + if not index.isValid(): + return 0 + result = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if index.column() == 1: + result |= Qt.ItemIsEditable + return result + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal: + if role == Qt.DisplayRole and section < len(HEADERS): + return QVariant(HEADERS[section]) + return QVariant() + + def setData(self, index, value, role): + if not index.isValid() or role != Qt.EditRole or index.column() != 1: + return False + node = index.internalPointer() + state, ok = value.toInt() + assert ok + self._dirs.SetState(node.ref.path, state) + return True + diff --git a/base/qt/error_report_dialog.py b/base/qt/error_report_dialog.py new file mode 100644 index 00000000..4aa8f977 --- /dev/null +++ b/base/qt/error_report_dialog.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# Unit Name: error_report_dialog +# Created By: Virgil Dupras +# Created On: 2009-05-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, QUrl +from PyQt4.QtGui import QDialog, QDesktopServices + +from error_report_dialog_ui import Ui_ErrorReportDialog + +class ErrorReportDialog(QDialog, Ui_ErrorReportDialog): + def __init__(self, parent, error): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.setupUi(self) + self.errorTextEdit.setPlainText(error) + + def accept(self): + text = self.errorTextEdit.toPlainText() + url = QUrl("mailto:support@hardcoded.net?SUBJECT=Error Report&BODY=%s" % text) + QDesktopServices.openUrl(url) + QDialog.accept(self) + diff --git a/base/qt/error_report_dialog.ui b/base/qt/error_report_dialog.ui new file mode 100644 index 00000000..0974dd2f --- /dev/null +++ b/base/qt/error_report_dialog.ui @@ -0,0 +1,117 @@ + + + ErrorReportDialog + + + + 0 + 0 + 553 + 349 + + + + Error Report + + + + + + Something went wrong. Would you like to send the error report to Hardcoded Software? + + + true + + + + + + + true + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 110 + 0 + + + + Don't Send + + + + + + + + 110 + 0 + + + + Send + + + true + + + + + + + + + + + sendButton + clicked() + ErrorReportDialog + accept() + + + 485 + 320 + + + 276 + 174 + + + + + dontSendButton + clicked() + ErrorReportDialog + reject() + + + 373 + 320 + + + 276 + 174 + + + + + diff --git a/base/qt/gen.py b/base/qt/gen.py new file mode 100644 index 00000000..3b0df2fa --- /dev/null +++ b/base/qt/gen.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# Unit Name: gen +# Created By: Virgil Dupras +# Created On: 2009-05-22 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os + +def print_and_do(cmd): + print cmd + os.system(cmd) + +print_and_do("pyuic4 main_window.ui > main_window_ui.py") +print_and_do("pyuic4 directories_dialog.ui > directories_dialog_ui.py") +print_and_do("pyuic4 about_box.ui > about_box_ui.py") +print_and_do("pyuic4 reg_submit_dialog.ui > reg_submit_dialog_ui.py") +print_and_do("pyuic4 reg_demo_dialog.ui > reg_demo_dialog_ui.py") +print_and_do("pyuic4 error_report_dialog.ui > error_report_dialog_ui.py") +print_and_do("pyrcc4 dg.qrc > dg_rc.py") \ No newline at end of file diff --git a/base/qt/main_window.py b/base/qt/main_window.py new file mode 100644 index 00000000..3ca20de3 --- /dev/null +++ b/base/qt/main_window.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python +# Unit Name: main_window +# Created By: Virgil Dupras +# Created On: 2009-04-25 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, QCoreApplication, QProcess, SIGNAL +from PyQt4.QtGui import (QMainWindow, QMenu, QPixmap, QIcon, QToolButton, QLabel, QHeaderView, + QMessageBox, QInputDialog, QLineEdit) + +from hsutil.misc import nonone + +from dupeguru.app import NoScannableFileError, AllFilesAreRefError + +import dg_rc +from main_window_ui import Ui_MainWindow +from results_model import ResultsDelegate, ResultsModel + +class MainWindow(QMainWindow, Ui_MainWindow): + def __init__(self, app): + QMainWindow.__init__(self, None) + self.app = app + self._last_filter = None + self._setupUi() + self.resultsDelegate = ResultsDelegate() + self.resultsModel = ResultsModel(self.app) + self.resultsView.setModel(self.resultsModel) + self.resultsView.setItemDelegate(self.resultsDelegate) + self._load_columns() + self._update_column_actions_status() + self.resultsView.expandAll() + self._update_status_line() + + self.connect(self.app, SIGNAL('resultsChanged()'), self.resultsChanged) + self.connect(self.app, SIGNAL('dupeMarkingChanged()'), self.dupeMarkingChanged) + self.connect(self.actionQuit, SIGNAL('triggered()'), QCoreApplication.instance().quit) + self.connect(self.resultsView.selectionModel(), SIGNAL('selectionChanged(QItemSelection,QItemSelection)'), self.selectionChanged) + self.connect(self.menuColumns, SIGNAL('triggered(QAction*)'), self.columnToggled) + self.connect(QCoreApplication.instance(), SIGNAL('aboutToQuit()'), self.application_will_terminate) + self.connect(self.resultsModel, SIGNAL('modelReset()'), self.resultsReset) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be setup in the Designer + h = self.resultsView.header() + h.setHighlightSections(False) + h.setMovable(True) + h.setStretchLastSection(False) + h.setDefaultAlignment(Qt.AlignLeft) + + self.setWindowTitle(QCoreApplication.instance().applicationName()) + self.actionScan.setIcon(QIcon(QPixmap(':/%s' % self.app.LOGO_NAME))) + + # Columns menu + menu = self.menuColumns + self._column_actions = [] + for index, column in enumerate(self.app.data.COLUMNS): + action = menu.addAction(column['display']) + action.setCheckable(True) + action.column_index = index + self._column_actions.append(action) + menu.addSeparator() + action = menu.addAction("Reset to Defaults") + action.column_index = -1 + + # Action menu + actionMenu = QMenu('Actions', self.toolBar) + actionMenu.setIcon(QIcon(QPixmap(":/actions"))) + actionMenu.addAction(self.actionDeleteMarked) + actionMenu.addAction(self.actionMoveMarked) + actionMenu.addAction(self.actionCopyMarked) + actionMenu.addAction(self.actionRemoveMarked) + actionMenu.addSeparator() + actionMenu.addAction(self.actionRemoveSelected) + actionMenu.addAction(self.actionIgnoreSelected) + actionMenu.addAction(self.actionMakeSelectedReference) + actionMenu.addSeparator() + actionMenu.addAction(self.actionOpenSelected) + actionMenu.addAction(self.actionRevealSelected) + actionMenu.addAction(self.actionRenameSelected) + self.actionActions.setMenu(actionMenu) + button = QToolButton(self.toolBar) + button.setDefaultAction(actionMenu.menuAction()) + button.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + self.actionsButton = button + self.toolBar.insertWidget(self.actionActions, button) # the action is a placeholder + self.toolBar.removeAction(self.actionActions) + + self.statusLabel = QLabel(self) + self.statusbar.addPermanentWidget(self.statusLabel, 1) + + #--- Private + def _confirm(self, title, msg, default_button=QMessageBox.Yes): + buttons = QMessageBox.Yes | QMessageBox.No + answer = QMessageBox.question(self, title, msg, buttons, default_button) + return answer == QMessageBox.Yes + + def _load_columns(self): + h = self.resultsView.header() + h.setResizeMode(QHeaderView.Interactive) + prefs = self.app.prefs + attrs = zip(prefs.columns_width, prefs.columns_visible) + for index, (width, visible) in enumerate(attrs): + h.resizeSection(index, width) + h.setSectionHidden(index, not visible) + h.setResizeMode(0, QHeaderView.Stretch) + + def _redraw_results(self): + # HACK. this is the only way I found to update the widget without reseting everything + self.resultsView.scroll(0, 1) + self.resultsView.scroll(0, -1) + + def _save_columns(self): + h = self.resultsView.header() + widths = [] + visible = [] + for i in range(len(self.app.data.COLUMNS)): + widths.append(h.sectionSize(i)) + visible.append(not h.isSectionHidden(i)) + prefs = self.app.prefs + prefs.columns_width = widths + prefs.columns_visible = visible + prefs.save() + + def _update_column_actions_status(self): + h = self.resultsView.header() + for action in self._column_actions: + colid = action.column_index + action.setChecked(not h.isSectionHidden(colid)) + + def _update_status_line(self): + self.statusLabel.setText(self.app.stat_line) + + #--- Actions + def aboutTriggered(self): + self.app.show_about_box() + + def actionsTriggered(self): + self.actionsButton.showMenu() + + def addToIgnoreListTriggered(self): + dupes = self.resultsView.selectedDupes() + if not dupes: + return + title = "Add to Ignore List" + msg = "All selected {0} matches are going to be ignored in all subsequent scans. Continue?".format(len(dupes)) + if self._confirm(title, msg): + self.app.add_dupes_to_ignore_list(dupes) + + def applyFilterTriggered(self): + title = "Apply Filter" + msg = "Type the filter you want to apply on your results. See help for details." + text = nonone(self._last_filter, '[*]') + answer, ok = QInputDialog.getText(self, title, msg, QLineEdit.Normal, text) + if not ok: + return + answer = unicode(answer) + self.app.ApplyFilter(answer) + self._last_filter = answer + + def cancelFilterTriggered(self): + self.app.ApplyFilter('') + + def checkForUpdateTriggered(self): + QProcess.execute('updater.exe', ['/checknow']) + + def clearIgnoreListTriggered(self): + title = "Clear Ignore List" + count = len(self.app.scanner.ignore_list) + if not count: + QMessageBox.information(self, title, "Nothing to clear.") + return + msg = "Do you really want to remove all {0} items from the ignore list?".format(count) + if self._confirm(title, msg, QMessageBox.No): + self.app.scanner.ignore_list.Clear() + QMessageBox.information(self, title, "Ignore list cleared.") + + def copyTriggered(self): + self.app.copy_or_move_marked(True) + + def deleteTriggered(self): + count = self.app.results.mark_count + if not count: + return + title = "Delete duplicates" + msg = "You are about to send {0} files to the recycle bin. Continue?".format(count) + if self._confirm(title, msg): + self.app.delete_marked() + + def deltaTriggered(self): + self.resultsModel.delta = self.actionDelta.isChecked() + self._redraw_results() + + def detailsTriggered(self): + self.app.show_details() + + def directoriesTriggered(self): + self.app.show_directories() + + def makeReferenceTriggered(self): + self.app.make_reference(self.resultsView.selectedDupes()) + + def markAllTriggered(self): + self.app.mark_all() + + def markInvertTriggered(self): + self.app.mark_invert() + + def markNoneTriggered(self): + self.app.mark_none() + + def markSelectedTriggered(self): + dupes = self.resultsView.selectedDupes() + self.app.toggle_marking_for_dupes(dupes) + + def moveTriggered(self): + self.app.copy_or_move_marked(False) + + def openTriggered(self): + self.app.open_selected() + + def powerMarkerTriggered(self): + self.resultsModel.power_marker = self.actionPowerMarker.isChecked() + + def preferencesTriggered(self): + self.app.show_preferences() + + def registerTrigerred(self): + self.app.ask_for_reg_code() + + def removeMarkedTriggered(self): + count = self.app.results.mark_count + if not count: + return + title = "Remove duplicates" + msg = "You are about to remove {0} files from results. Continue?".format(count) + if self._confirm(title, msg): + self.app.remove_marked_duplicates() + + def removeSelectedTriggered(self): + dupes = self.resultsView.selectedDupes() + if not dupes: + return + title = "Remove duplicates" + msg = "You are about to remove {0} files from results. Continue?".format(len(dupes)) + if self._confirm(title, msg): + self.app.remove_duplicates(dupes) + + def renameTriggered(self): + self.resultsView.edit(self.resultsView.selectionModel().currentIndex()) + + def revealTriggered(self): + self.app.reveal_selected() + + def scanTriggered(self): + title = "Start a new scan" + if len(self.app.results.groups) > 0: + msg = "Are you sure you want to start a new duplicate scan?" + if not self._confirm(title, msg): + return + try: + self.app.start_scanning() + except NoScannableFileError: + msg = "The selected directories contain no scannable file." + QMessageBox.warning(self, title, msg) + self.app.show_directories() + except AllFilesAreRefError: + msg = "You cannot make a duplicate scan with only reference directories." + QMessageBox.warning(self, title, msg) + + def showHelpTriggered(self): + self.app.show_help() + + #--- Events + def application_will_terminate(self): + self._save_columns() + + def columnToggled(self, action): + colid = action.column_index + if colid == -1: + self.app.prefs.reset_columns() + self._load_columns() + else: + h = self.resultsView.header() + h.setSectionHidden(colid, not h.isSectionHidden(colid)) + self._update_column_actions_status() + + def dupeMarkingChanged(self): + self._redraw_results() + self._update_status_line() + + def resultsChanged(self): + self.resultsView.model().reset() + + def resultsReset(self): + self.resultsView.expandAll() + self._update_status_line() + + def selectionChanged(self, selected, deselected): + index = self.resultsView.selectionModel().currentIndex() + dupe = index.internalPointer().dupe if index.isValid() else None + self.app.select_duplicate(dupe) + diff --git a/base/qt/main_window.ui b/base/qt/main_window.ui new file mode 100644 index 00000000..754f265c --- /dev/null +++ b/base/qt/main_window.ui @@ -0,0 +1,911 @@ + + + MainWindow + + + + 0 + 0 + 630 + 514 + + + + dupeGuru + + + + + 0 + + + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + true + + + false + + + true + + + false + + + false + + + + + + + + + 0 + 0 + 630 + 22 + + + + + Columns + + + + + Actions + + + + + + + + + + + + + + + + + + + + Mark + + + + + + + + + Modes + + + + + + + Windows + + + + + + + + Help + + + + + + + + + File + + + + + + + + + + + + + + + + + + toolBar + + + false + + + Qt::ToolButtonTextUnderIcon + + + false + + + TopToolBarArea + + + false + + + + + + + + + + + + true + + + + + + :/logo_pe:/logo_pe + + + Start Scan + + + Start scanning for duplicates + + + Ctrl+S + + + + + + :/folder:/folder + + + Directories + + + Ctrl+4 + + + + + + :/details:/details + + + Details + + + Ctrl+3 + + + + + + :/actions:/actions + + + Actions + + + + + + :/preferences:/preferences + + + Preferences + + + Ctrl+5 + + + + + true + + + + :/delta:/delta + + + Delta Values + + + Ctrl+2 + + + + + true + + + + :/power_marker:/power_marker + + + Power Marker + + + Ctrl+1 + + + + + Send Marked to Recycle Bin + + + Ctrl+D + + + + + Move Marked to... + + + Ctrl+M + + + + + Copy Marked to... + + + Ctrl+Shift+M + + + + + Remove Marked from Results + + + Ctrl+R + + + + + Remove Selected from Results + + + Ctrl+Del + + + + + Add Selected to Ignore List + + + Ctrl+Shift+Del + + + + + Make Selected Reference + + + Ctrl+Space + + + + + Open Selected with Default Application + + + Ctrl+O + + + + + Open Containing Folder of Selected + + + Ctrl+Shift+O + + + + + Rename Selected + + + F2 + + + + + Mark All + + + Ctrl+A + + + + + Mark None + + + Ctrl+Shift+A + + + + + Invert Marking + + + Ctrl+Alt+A + + + + + Mark Selected + + + + + Clear Ignore List + + + + + Quit + + + Ctrl+Q + + + + + Apply Filter + + + Ctrl+F + + + + + Cancel Filter + + + Ctrl+Shift+F + + + + + dupeGuru Help + + + F1 + + + + + About dupeGuru + + + + + Register dupeGuru + + + + + Check for Update + + + + + + ResultsView + QTreeView +
results_model
+
+
+ + + + + + actionDirectories + triggered() + MainWindow + directoriesTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionActions + triggered() + MainWindow + actionsTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionCopyMarked + triggered() + MainWindow + copyTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionDeleteMarked + triggered() + MainWindow + deleteTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionDelta + triggered() + MainWindow + deltaTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionDetails + triggered() + MainWindow + detailsTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionIgnoreSelected + triggered() + MainWindow + addToIgnoreListTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMakeSelectedReference + triggered() + MainWindow + makeReferenceTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMoveMarked + triggered() + MainWindow + moveTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionOpenSelected + triggered() + MainWindow + openTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionPowerMarker + triggered() + MainWindow + powerMarkerTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionPreferences + triggered() + MainWindow + preferencesTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRemoveMarked + triggered() + MainWindow + removeMarkedTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRemoveSelected + triggered() + MainWindow + removeSelectedTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRevealSelected + triggered() + MainWindow + revealTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRenameSelected + triggered() + MainWindow + renameTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionScan + triggered() + MainWindow + scanTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionClearIgnoreList + triggered() + MainWindow + clearIgnoreListTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMarkAll + triggered() + MainWindow + markAllTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMarkNone + triggered() + MainWindow + markNoneTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMarkSelected + triggered() + MainWindow + markSelectedTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionInvertMarking + triggered() + MainWindow + markInvertTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionApplyFilter + triggered() + MainWindow + applyFilterTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionCancelFilter + triggered() + MainWindow + cancelFilterTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionShowHelp + triggered() + MainWindow + showHelpTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionAbout + triggered() + MainWindow + aboutTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRegister + triggered() + MainWindow + registerTrigerred() + + + -1 + -1 + + + 314 + 256 + + + + + actionCheckForUpdate + triggered() + MainWindow + checkForUpdateTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + + directoriesTriggered() + scanTriggered() + actionsTriggered() + detailsTriggered() + preferencesTriggered() + deltaTriggered() + powerMarkerTriggered() + deleteTriggered() + moveTriggered() + copyTriggered() + removeMarkedTriggered() + removeSelectedTriggered() + addToIgnoreListTriggered() + makeReferenceTriggered() + openTriggered() + revealTriggered() + renameTriggered() + clearIgnoreListTriggered() + clearPictureCacheTriggered() + markAllTriggered() + markNoneTriggered() + markInvertTriggered() + markSelectedTriggered() + applyFilterTriggered() + cancelFilterTriggered() + showHelpTriggered() + aboutTriggered() + registerTrigerred() + checkForUpdateTriggered() + +
diff --git a/base/qt/preferences.py b/base/qt/preferences.py new file mode 100644 index 00000000..64ad64d5 --- /dev/null +++ b/base/qt/preferences.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# Unit Name: preferences +# Created By: Virgil Dupras +# Created On: 2009-05-03 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import QSettings, QVariant + +from hsutil.misc import tryint + +def variant_to_py(v): + value = None + ok = False + t = v.type() + if t == QVariant.String: + value = unicode(v.toString()) + ok = True # anyway + # might be bool or int, try them + if v == 'true': + value = True + elif value == 'false': + value = False + else: + value = tryint(value, value) + elif t == QVariant.Int: + value, ok = v.toInt() + elif t == QVariant.Bool: + value, ok = v.toBool(), True + elif t in (QVariant.List, QVariant.StringList): + value, ok = map(variant_to_py, v.toList()), True + if not ok: + raise TypeError() + return value + +class Preferences(object): + # (width, is_visible) + COLUMNS_DEFAULT_ATTRS = [] + + def __init__(self): + self.reset() + self.reset_columns() + + def _load_specific(self, settings, get): + # load prefs specific to the dg edition + pass + + def load(self): + self.reset() + settings = QSettings() + def get(name, default): + if settings.contains(name): + return variant_to_py(settings.value(name)) + else: + return default + + self.filter_hardness = get('FilterHardness', self.filter_hardness) + self.mix_file_kind = get('MixFileKind', self.mix_file_kind) + self.use_regexp = get('UseRegexp', self.use_regexp) + self.remove_empty_folders = get('RemoveEmptyFolders', self.remove_empty_folders) + self.destination_type = get('DestinationType', self.destination_type) + widths = get('ColumnsWidth', self.columns_width) + # only set nonzero values + for index, width in enumerate(widths[:len(self.columns_width)]): + if width > 0: + self.columns_width[index] = width + self.columns_visible = get('ColumnsVisible', self.columns_visible) + self.registration_code = get('RegistrationCode', self.registration_code) + self.registration_email = get('RegistrationEmail', self.registration_email) + self._load_specific(settings, get) + + def _reset_specific(self): + # reset prefs specific to the dg edition + pass + + def reset(self): + self.filter_hardness = 95 + self.mix_file_kind = True + self.use_regexp = False + self.remove_empty_folders = False + self.destination_type = 1 + self.registration_code = '' + self.registration_email = '' + self._reset_specific() + + def reset_columns(self): + self.columns_width = [width for width, _ in self.COLUMNS_DEFAULT_ATTRS] + self.columns_visible = [visible for _, visible in self.COLUMNS_DEFAULT_ATTRS] + + def _save_specific(self, settings, set_): + # save prefs specific to the dg edition + pass + + def save(self): + settings = QSettings() + def set_(name, value): + settings.setValue(name, QVariant(value)) + + set_('FilterHardness', self.filter_hardness) + set_('MixFileKind', self.mix_file_kind) + set_('UseRegexp', self.use_regexp) + set_('RemoveEmptyFolders', self.remove_empty_folders) + set_('DestinationType', self.destination_type) + set_('ColumnsWidth', self.columns_width) + set_('ColumnsVisible', self.columns_visible) + set_('RegistrationCode', self.registration_code) + set_('RegistrationEmail', self.registration_email) + self._save_specific(settings, set_) + diff --git a/base/qt/reg.py b/base/qt/reg.py new file mode 100644 index 00000000..59fd0bc3 --- /dev/null +++ b/base/qt/reg.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# Unit Name: reg +# Created By: Virgil Dupras +# Created On: 2009-05-09 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from hashlib import md5 + +from PyQt4.QtGui import QDialog + +from reg_submit_dialog import RegSubmitDialog +from reg_demo_dialog import RegDemoDialog + +class Registration(object): + def __init__(self, app): + self.app = app + + def ask_for_code(self): + dialog = RegSubmitDialog(self.app.main_window, self.app.is_code_valid) + result = dialog.exec_() + code = unicode(dialog.codeEdit.text()) + email = unicode(dialog.emailEdit.text()) + dialog.setParent(None) # free it + if result == QDialog.Accepted and self.app.is_code_valid(code, email): + self.app.set_registration(code, email) + return True + return False + + def show_nag(self): + dialog = RegDemoDialog(self.app.main_window, self) + dialog.exec_() + dialog.setParent(None) # free it + diff --git a/base/qt/reg_demo_dialog.py b/base/qt/reg_demo_dialog.py new file mode 100644 index 00000000..95280314 --- /dev/null +++ b/base/qt/reg_demo_dialog.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# Unit Name: reg_demo_dialog +# Created By: Virgil Dupras +# Created On: 2009-05-10 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt, QUrl, QCoreApplication +from PyQt4.QtGui import QDialog, QMessageBox, QDesktopServices + +from reg_demo_dialog_ui import Ui_RegDemoDialog + +class RegDemoDialog(QDialog, Ui_RegDemoDialog): + def __init__(self, parent, reg): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.reg = reg + self._setupUi() + + self.connect(self.enterCodeButton, SIGNAL('clicked()'), self.enterCodeClicked) + self.connect(self.purchaseButton, SIGNAL('clicked()'), self.purchaseClicked) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be setup in the Designer + appname = QCoreApplication.instance().applicationName() + title = self.windowTitle() + title = title.replace('$appname', appname) + self.setWindowTitle(title) + title = self.titleLabel.text() + title = title.replace('$appname', appname) + self.titleLabel.setText(title) + desc = self.demoDescLabel.text() + desc = desc.replace('$appname', appname) + self.demoDescLabel.setText(desc) + + #--- Events + def enterCodeClicked(self): + if self.reg.ask_for_code(): + self.accept() + + def purchaseClicked(self): + url = QUrl('http://www.hardcoded.net/purchase.htm') + QDesktopServices.openUrl(url) + diff --git a/base/qt/reg_demo_dialog.ui b/base/qt/reg_demo_dialog.ui new file mode 100644 index 00000000..ef918225 --- /dev/null +++ b/base/qt/reg_demo_dialog.ui @@ -0,0 +1,140 @@ + + + RegDemoDialog + + + + 0 + 0 + 387 + 161 + + + + $appname Demo Version + + + + + + + 75 + true + + + + $appname Demo Version + + + + + + + You are currently running a demo version of $appname. This version has limited functionalities, and you need to buy it to have access to these functionalities. + + + true + + + + + + + In the demo version, only 10 duplicates per session can be sent to the recycle bin, moved or copied. + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 110 + 0 + + + + Try Demo + + + + + + + + 110 + 0 + + + + Enter Code + + + + + + + + 110 + 0 + + + + Purchase + + + + + + + + + + + tryButton + clicked() + RegDemoDialog + accept() + + + 112 + 161 + + + 201 + 94 + + + + + diff --git a/base/qt/reg_submit_dialog.py b/base/qt/reg_submit_dialog.py new file mode 100644 index 00000000..4ba680b6 --- /dev/null +++ b/base/qt/reg_submit_dialog.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# Unit Name: reg_submit_dialog +# Created By: Virgil Dupras +# Created On: 2009-05-09 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt, QUrl, QCoreApplication +from PyQt4.QtGui import QDialog, QMessageBox, QDesktopServices + +from reg_submit_dialog_ui import Ui_RegSubmitDialog + +class RegSubmitDialog(QDialog, Ui_RegSubmitDialog): + def __init__(self, parent, is_valid_func): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self._setupUi() + self.is_valid_func = is_valid_func + + self.connect(self.submitButton, SIGNAL('clicked()'), self.submitClicked) + self.connect(self.purchaseButton, SIGNAL('clicked()'), self.purchaseClicked) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be setup in the Designer + appname = QCoreApplication.instance().applicationName() + prompt = self.promptLabel.text() + prompt = prompt.replace('$appname', appname) + self.promptLabel.setText(prompt) + + #--- Events + def purchaseClicked(self): + url = QUrl('http://www.hardcoded.net/purchase.htm') + QDesktopServices.openUrl(url) + + def submitClicked(self): + code = unicode(self.codeEdit.text()) + email = unicode(self.emailEdit.text()) + title = "Registration" + if self.is_valid_func(code, email): + msg = "This code is valid. Thanks!" + QMessageBox.information(self, title, msg) + self.accept() + else: + msg = "This code is invalid" + QMessageBox.warning(self, title, msg) + diff --git a/base/qt/reg_submit_dialog.ui b/base/qt/reg_submit_dialog.ui new file mode 100644 index 00000000..06de4191 --- /dev/null +++ b/base/qt/reg_submit_dialog.ui @@ -0,0 +1,149 @@ + + + RegSubmitDialog + + + + 0 + 0 + 365 + 134 + + + + Enter your registration code + + + + + + Please enter your $appname registration code and registered e-mail (the e-mail you used for the purchase), then press "Submit". + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + QLayout::SetNoConstraint + + + QFormLayout::ExpandingFieldsGrow + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + Registration code: + + + + + + + Registered e-mail: + + + + + + + + + + + + + + + + + Purchase + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Cancel + + + false + + + + + + + + 0 + 0 + + + + Submit + + + false + + + true + + + + + + + + + + + cancelButton + clicked() + RegSubmitDialog + reject() + + + 260 + 159 + + + 198 + 97 + + + + + diff --git a/base/qt/results_model.py b/base/qt/results_model.py new file mode 100644 index 00000000..d28d6da3 --- /dev/null +++ b/base/qt/results_model.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +# Unit Name: +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt, QAbstractItemModel, QVariant, QModelIndex, QRect +from PyQt4.QtGui import QBrush, QStyledItemDelegate, QFont, QTreeView, QColor + +from tree_model import TreeNode, TreeModel + +class ResultNode(TreeNode): + def __init__(self, model, parent, row, dupe, group): + TreeNode.__init__(self, parent, row) + self.model = model + self.dupe = dupe + self.group = group + self._normalData = None + self._deltaData = None + + def _get_children(self): + children = [] + if self.dupe is self.group.ref: + for index, dupe in enumerate(self.group.dupes): + children.append(ResultNode(self.model, self, index, dupe, self.group)) + return children + + def reset(self): + self._normalData = None + self._deltaData = None + + @property + def normalData(self): + if self._normalData is None: + self._normalData = self.model._data.GetDisplayInfo(self.dupe, self.group, delta=False) + return self._normalData + + @property + def deltaData(self): + if self._deltaData is None: + self._deltaData = self.model._data.GetDisplayInfo(self.dupe, self.group, delta=True) + return self._deltaData + + +class ResultsDelegate(QStyledItemDelegate): + def initStyleOption(self, option, index): + QStyledItemDelegate.initStyleOption(self, option, index) + node = index.internalPointer() + if node.group.ref is node.dupe: + newfont = QFont(option.font) + newfont.setBold(True) + option.font = newfont + + +class ResultsModel(TreeModel): + def __init__(self, app): + self._app = app + self._results = app.results + self._data = app.data + self._delta_columns = app.DELTA_COLUMNS + self.delta = False + self._power_marker = False + TreeModel.__init__(self) + + def _root_nodes(self): + nodes = [] + if self.power_marker: + for index, dupe in enumerate(self._results.dupes): + group = self._results.get_group_of_duplicate(dupe) + nodes.append(ResultNode(self, None, index, dupe, group)) + else: + for index, group in enumerate(self._results.groups): + nodes.append(ResultNode(self, None, index, group.ref, group)) + return nodes + + def columnCount(self, parent): + return len(self._data.COLUMNS) + + def data(self, index, role): + if not index.isValid(): + return QVariant() + node = index.internalPointer() + if role == Qt.DisplayRole: + data = node.deltaData if self.delta else node.normalData + return QVariant(data[index.column()]) + elif role == Qt.CheckStateRole: + if index.column() == 0 and node.dupe is not node.group.ref: + state = Qt.Checked if self._results.is_marked(node.dupe) else Qt.Unchecked + return QVariant(state) + elif role == Qt.ForegroundRole: + if node.dupe is node.group.ref or node.dupe.is_ref: + return QVariant(QBrush(Qt.blue)) + elif self.delta and index.column() in self._delta_columns: + return QVariant(QBrush(QColor(255, 142, 40))) # orange + elif role == Qt.EditRole: + if index.column() == 0: + return QVariant(node.normalData[index.column()]) + return QVariant() + + def dupesForIndexes(self, indexes): + nodes = [index.internalPointer() for index in indexes] + return [node.dupe for node in nodes] + + def flags(self, index): + if not index.isValid(): + return Qt.ItemIsEnabled + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if index.column() == 0: + flags |= Qt.ItemIsUserCheckable | Qt.ItemIsEditable + return flags + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal and role == Qt.DisplayRole and section < len(self._data.COLUMNS): + return QVariant(self._data.COLUMNS[section]['display']) + + return QVariant() + + def setData(self, index, value, role): + if not index.isValid(): + return False + node = index.internalPointer() + if role == Qt.CheckStateRole: + if index.column() == 0: + self._app.toggle_marking_for_dupes([node.dupe]) + return True + if role == Qt.EditRole: + if index.column() == 0: + value = unicode(value.toString()) + if self._app.rename_dupe(node.dupe, value): + node.reset() + return True + return False + + def sort(self, column, order): + if self.power_marker: + self._results.sort_dupes(column, order == Qt.AscendingOrder, self.delta) + else: + self._results.sort_groups(column, order == Qt.AscendingOrder) + self.reset() + + def toggleMarked(self, indexes): + assert indexes + dupes = self.dupesForIndexes(indexes) + self._app.toggle_marking_for_dupes(dupes) + + #--- Properties + @property + def power_marker(self): + return self._power_marker + + @power_marker.setter + def power_marker(self, value): + if value == self._power_marker: + return + self._power_marker = value + self.reset() + + +class ResultsView(QTreeView): + #--- Override + def keyPressEvent(self, event): + if event.text() == ' ': + self.model().toggleMarked(self.selectionModel().selectedRows()) + return + QTreeView.keyPressEvent(self, event) + + def setModel(self, model): + assert isinstance(model, ResultsModel) + QTreeView.setModel(self, model) + + #--- Public + def selectedDupes(self): + return self.model().dupesForIndexes(self.selectionModel().selectedRows()) + diff --git a/base/qt/tree_model.py b/base/qt/tree_model.py new file mode 100644 index 00000000..b3a994b3 --- /dev/null +++ b/base/qt/tree_model.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# Unit Name: tree_model +# Created By: Virgil Dupras +# Created On: 2009-05-04 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, QAbstractItemModel, QVariant, QModelIndex + +class TreeNode(object): + def __init__(self, parent, row): + self.parent = parent + self.row = row + self._children = None + + def _get_children(self): + raise NotImplementedError() + + @property + def children(self): + if self._children is None: + self._children = self._get_children() + return self._children + + +class TreeModel(QAbstractItemModel): + def __init__(self): + QAbstractItemModel.__init__(self) + self._nodes = None + + def _root_nodes(self): + raise NotImplementedError() + + def index(self, row, column, parent): + if not self.nodes: + return QModelIndex() + if not parent.isValid(): + return self.createIndex(row, column, self.nodes[row]) + node = parent.internalPointer() + return self.createIndex(row, column, node.children[row]) + + def parent(self, index): + if not index.isValid(): + return QModelIndex() + node = index.internalPointer() + if node.parent is None: + return QModelIndex() + else: + return self.createIndex(node.parent.row, 0, node.parent) + + def reset(self): + self._nodes = None + QAbstractItemModel.reset(self) + + def rowCount(self, parent): + if not parent.isValid(): + return len(self.nodes) + node = parent.internalPointer() + return len(node.children) + + @property + def nodes(self): + if self._nodes is None: + self._nodes = self._root_nodes() + return self._nodes + diff --git a/images/actions32.png b/images/actions32.png new file mode 100755 index 0000000000000000000000000000000000000000..16d64fc1e439ab6ae90a1e11cc99831813b2416f GIT binary patch literal 3039 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 literal 0 HcmV?d00001 diff --git a/images/delta32.png b/images/delta32.png new file mode 100755 index 0000000000000000000000000000000000000000..f7d95da07919c34aa960d37448642e229e75f1e6 GIT binary patch literal 1941 zcmV;G2Wt3RCwCt zS8Gs|*BO4k%iebZcVR&!3IRdrf+*408YCDms3l`#Qytr+HZj&|%@Yh+mbXj@U6o(toJChVaZb-Lv_IXNj18GV4^IK0!*0jJZM_Wb%P^Xe6)u{Duc z66EDF}KQKfSUMGn@lO; zwOUzKU2W97_}#z81i>1WB*bJ1;yt;ewb8n(K{X}{I z^AyMolN5s?n8@_Gu^4j5{6?Q2Mz0tCYu6xs*$dg{d=(=3I=$>kdu_k%|@y8f>UXBhj%kD+@?;YuETVyevoDhY_WNL#`0Za6G!WILW`cvw7iF9pl!0(hmn2>ln|<5RJ>b?5;1JPgllEk`$85O2DqE!mFQN z!a!yUDCACl)J1U)xTn2vhe8ODC8CrKCrG^H{Xr5f-H$V>Zhxmy^XibphjZ6cCJriY zV_HxgwvR16R4-ilIY*jx8Vbn>&=1y)reziJV7 zWb^@EN&WO4l#;kZH}%kg0s2moBgPc?^xs9i%3iB8{ABz3oP86MZhSU~^h?z4oZIKR;IGDok1U7SN7!T z@hK0bV$IAPxw1nL{48mz(P&;@$?5yc*68091c&Ox0EX#?pFNC;}=J8tI38Z z6J*wkV&It?w0+czvyTo#a^-j;0YQm&dc*6H^Z$Q;be4bN*# zcNd);KwvtABkAJ1dkBX{rx4?3eW9TOIkv^v{YVPTl}oYZ&wqp%i_eoM2yyujTekel zH#LPW0`QlHytc^+4^CZ9mD-VX00`hM%8}<;2D@E_J#|Lnc6m(|;;OZDo_B&wb`579NW`!c+$I9=l z+j70$fI^GGyloY;v+sHky$*EJbUYgXimAJL@oqBtRfKG!W<{^Y(m4l7so=3$SGqJ;|)3(~3=Ka%lfbnWbcSI%|0K~V^;+EvLguF8i}K)1V?RNAx|xp&;4Y&h4=DG{!O<-q8_ z+Z;DX|Ctd$-$cdlMhtsCAbOKXV=Z38LuFJU8jU_Y;j6wf8fRdc>b1H=JQm`a|NGbR be+3u-4f`}H8YPeZ00000NkvXXu0mjf0Q9d* literal 0 HcmV?d00001 diff --git a/images/details32.png b/images/details32.png new file mode 100644 index 0000000000000000000000000000000000000000..8ab245b41ba6f367f19d01227df99b85872bac81 GIT binary patch literal 5090 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 YiNbjNhnHL=%16}s>3mJzOVs%P0VM`{F#rGn literal 0 HcmV?d00001 diff --git a/images/dgme_logo_128.png b/images/dgme_logo_128.png new file mode 100644 index 0000000000000000000000000000000000000000..187c21c8cfb4e7541a63ce36689f8cb444d17b68 GIT binary patch literal 18092 zcmXtgWmr_*`}NE)z|ccC45gHG=a3@Z@X#S4AYGC}cRC>5ARP+QE!`z3NF&|dFTekF zy&uj#ANSe&u63_9OE^5Eq;+qw31B69DZ zDVo^<3lb7byg95Z-+6U3#MCi9V42*GMqs87bBmB3;e*dX3 zb`)`6g(AS6L|C7W_yc0*=R=60@5V;h4GE{ zgm32=hl>Ncb$$=OUTI)*JCCnxuid8sfgtJ-MHmYV17?IkUz=m5#Kbp3U;#N3o6L^| zv~ztM_Sft#2U5Jn@K{I(-paGtoR7);&PVESJpSu8wpa9^`8 z>lxi-sX<*E!0KG(rhx+XRdk zAO!$orD6@A33_Lyrnk^0xEYER^@%+k z8VZ0=oWk4nQDeveOtti{)-iR4vuyZuR1{f=vrqxrk#GK%;EoF;BjbCbn7mU&{P>Kp zCp9IUSWO8a00N0nC|8TcYCN8n7M*Yc3L}OBVcgAy=J<#YzGbtW*8`&FiZ%x4*++)_l_@4T=gL3qXE)^$W<7$q-CKQ!{6iM2xQ6WU`T0a;M%!7pN#a_ zI}9j@1qj7Z0R4{iECS1R(S?B_@DIH=$H9xg#}^;5*G0J$@LAQH>L1xJd3qfRLdHp^99n zdC_1kd`F5rTSNg$Gz9@lXlO1S0b(MbnJ5;j--1TYT2K~?6NWAo4MjlVu0R$rVs1n? z`GG)a>(YK%^^?5MNR%){W>dIKKIG58Dopc159%8Pv#kS@{vy)Bo@(^>8UgxAO zUAykkSM_`JeQ;F!Nz-)n=c8%h)}ZP0q`hGEOB$62_5h@T!+>&tGSi!_3Y@J9kvs}S zPfrMeG#mqg2w}lSLYXncHkV}6CUWOMJ1}wX5&%-#6{zt0!*?0p$y1}((AuM#dEG&m zusPExL!d=?w7YhQ`Hs2=VK{LZu^KFOFF0wZr@(TpLapUyB6ox>@chklHdDXeUsmX`T?rJn^HwK9Gq#!}LuCSo@^Hz86p(XOGSi9`ftAOxLm?}I*@F8enc<);^y$>2t-kmHW^N7_AwO>v=xO= zz|9m({V+jb5GEE7%Fm~cfkOwFeTgERF&^L)BVAr-r5(l2WWCY7Y%hIf1Gt7(=%*Uf`+mv5(S~ zYuw&6VDjwoBh1$5aqO+!VLjNcmzs;h00^e4bm1>8#{>rjp#hvz5wUQ)HPx92r-P0a zGPzuxoJRKv-dQMswQ!+SRT=`c!j3MH`EC*Uss;Ma%K1I+&7q`d*bG*G>+{(};33wr z-!=})7fbao4nT+$nVg3{@b@=RJw~tqRvyKg1Ra=Xbu9%OW=X*Xai=Q|e|S^m72^%CK(UhK4rKTv-_nHz&0Qh zp@}9ZXx^u(@cu5J(dR_Z)U@k-U~cjGu14}{j?!`AQ(tlH-WfRPP&bbpWl{%g5*xC( zfw0zv7lGG6NR6NFIL5Ib{8F$Mn2Fybp&{I8!_17^)IN+KpKCtjRNu^Fp@dQ)kwR~i zQ}I&j=7h|&a*Ok#(Z*uwQ%B)39&6_-S=~PiI*vJ?9_F9C7k?bcwT38p0d1KB;+S?I zXoArw6sZUn(DZg5W>^oIyJ8xYULNur3NTV+DzuL(tqzk-9LQ|LLww(Qw)rwIC;+vw zNX0TXpEPvo`Ug{h0Clu&t-(|8(S9nf<4FcmS^KU2Q1g2jkc^6%-P(!+Dtsowv_z;n znu&84Q6TITx~}t?G5Pu@tL&lDvOs+ZO1EkfL3vM6Igh#`kcQ79sNegSk=FkAApVWc zSwd$idbR#B3gv7mD|4#acaJFgs6mc>P!(%4CBnd~zQUU+ug*i6`>S|kH*SU&c7SbA z-aAZg0@ltCPm96bo?G^>^2h;ASUOtW5Gl2vq1eIT9wGSRNgAX%G*QP$JZj&#ySxqK z8vwA0EdJeGid%~L(FI7#BOuM3R^A=^3SaGdb!!Iq={DWsppf=)DxsV;#%L#!qXN}d zLh}y^Iv0ZHH#s_=CEzo>fibdFhL;$wi2bS{52&U919lmWphBh1?t`$~0NRSBEiH9JmxV9Aw94MP@b4dY5N~Ye2EVQHXUXW${(!#GnAv4g#w^2Dz6x6 zdhr;%kDf=MuFX^rj&o`2c=gy5niwJhZOe)j0x*|f0z6tlt(x$5gJ63-{2LnZcGal$ z#)>Sw3^W%BBT%B(W6&SOdVIa&d!9fiW|NsZVhzl=Uz69Jk^WuO-cXT9? z`T)Gt#Pw{(o-Ye=>(rQ&744fMkj5aA3vraoPvd@=*_dhC7~gj~JXASsPMk8zAp=mN zhaCRy`a>bb%cSjrr~@F;)Y*f*wPm8YLn26E(fs^C=}1_p2$+s|lo=->ET$IV6sZQQ zF_8cJ1A#{RM}F73@upF%NTemknV$c6^wK1FUCK zk8J?~lOpLaAanmykbq#V0E0Ro@J46Hg}4Riup7ggjQ$r^gk>(WK&Ppw_feaO97|~^ zyKL3K{^Va2Hbgj$;pZpEJT`>+j`x;3Mjxhy>C>6y1Rl=Hqa;N5B&ZJPv*_z|b^N>_ ziZDjOYyxP@=kf1Ye!H{EQw}Q61kAbY=;PkkEdR*VZ24fZbN12#7_!4fcGs@F%~TrH zryxWspdg&N_pl83kAYy-;CiG2u(toLyy!Y0;=H9-m8e(QS64*wi#ObYch2)c?k_Z5Q|5uycCOlI;x^+J$|3JiQU z)Bp%1y+#S6I0=e z@z)o$8Ie+&zM(P-&qIp8(kzF+SKD)c3ugE}{17JyFNfgDzEGtz^7?`9*VLn@I)r1t9o)95V=D4YqL z(UVukisa`oGp4H5=(-LE2La%(SUgN=_N{6Q-ce;%nmde4?kw0IS9=^YqVFSa8U+uB zRZRIyq_v3j4R$aUdagW)v<$6NKG#)5k+a8+}imPH(Q4IhS5yf(A9|GcEr<>c7 zJ7_k3p$1Uv&B>=C9NuF<($Em|UD_CL%q=PhGk}mpB+M{t2D>Wl5 zOJH0OfAuRx7KOrh^21}-BE~!bUA%_WuX|#*Dx`=X22!6{9GZc9C?@2gn6m36SJo=L z#HVkCUO(E#ppX@YC2w%B#0I+p+3ci>iDCiR?}@=SbztiME2dM@uaxJ+|IQanb6W_B zTUyQ^P1SjiioC^qOB+wI<#a&ho>yT`6x}&AhGm2f58-=s%BRuXW1HBGI(0 zxcE_#JX|F`mqC3gB6oK!XmvvclUu+p`Rv~xC#o92V*~NQ8Vt-xDI6paMzyPq=8Nv# zSRQFk?h2|x!;gsGxaWuSNLkVZ^vx&SqX$dCh}yhqDJ)_wOy5r z#Jd#Eu+|%f{-BYj_lNEV?P0fkFh5Roy!`pd&7r{gM}16($8bY{Pcfexjn^(50B@@6 zZwvZqUSt+T47 z)Wa6C4C;a+Hg5)*Q2}MsxAdIS^Tg^LP!+j+0d|D+WItA3Uj~1I(w~PQ2NgDkP0fh` zxxt|vZ}HrB1~3EgXE)q_`TK8>io9*;-737icJ>RSIx(7zR<|~e_FNKY>&~&74N(}A zATL7ct~)(VGBrMvoCN|WhD;o~1J9_mzW+)27S5imimz;*$OkX+1vEN*3BeCJCL%zA zypBW2h=1#eVY4I3GeLO2B6r;ig){MirP%n5cq8D|G-*%}4Oki?Ghz*yu;K_*y1(^Y+=enP|Nt3Ze)^`y4ewG@qCYn>i!i-;$+D@aPy@ z=y$Mu#^%kQdyemf(oDEKnEn)^tcCZLgmC&BT9sL1r2#bylPnaW4hqria^t1J`vH{> zpCobU_$56kl|XPk>MR&Su4HD-1khpd4?!``OgoW!dSCBrqx2VH@q$m6I zCtxoamJwNCP?t{+4wCL^*kh(!dkE>TD?+Y%(6ZwPQ9v#X!$ndi{vs7^O0@f3HHXdi zcl61-KFwpK5POUey)e_(mu{oeY0 z?%CaMJhVK_A2`C%XE8o7{t}^hf%nblG{!(gg(t38W(L_=Q!eDI6+V42gjB=!xekDP zaHw)j2$!`z3Bi<4^!4p9x@^~`Ca9%) z1K4q~7mZeg=%BpyfJvSk1=grJLp#X#I~5El(d2qrhpbKvZcjU?nQQW$YN2;%ofsH}3dK zV?(6A>ohF{_{#Xy)#^j>*}aRjpDwIWx<37a7UYh3ZUqKZ+SslxWTDN)2HTRTs_4{u z9p4CqO1V86t>gi^QcbQ?uZ;~KHTk$O7*mWCq=|6YlWm2pFcestk!H$v;!%vrQ`kk@ zQtz&Nj58ukIiy`N2&$XvL^KM4V0HJOsBtqC$uj$C6d2BUZQnqt2NAVO3*yP@E8qA# zuk%SaBx~J2SvYvt*J`G4jxUDE=*cZThD$;BqkDMxynmoLWm6`;Q38ingnC<1`tV?m zqg?2}N)D%)SiJOV`SJWh-o~@M?b%+%LGS{I$3LiOeVsAheU^u?D6v#DF#}dp-v4B| z_UhZ5Y#Dmg%UUy z&ebURp@--Fsz;&(C@#-2B;-e|H6ix5H42JcoEm<75NsKTnN@R$Jzy_<>yT3!JWpDF zP~TJQ{V@Cf=|q2?)aj2%yW~_Oj^pOn46XW^2$_1B$rCccnwri4DO#|U2ag={? z(IpF-hMCu^?4u!~CTYOvejj+8O0~a3+t*9xKmR;E$4|j<7z%n@J*40*{6gsJ`bhyQrcdZ_41AU?uOt(_cy&*9Y!w%k>67 zu8%tx3#wMq@`CjQh<}D{Sv(!TY;N>BJr2%qQR<3mSSd_ypdvNzH7_&=??|XrHA{MH0Uwz_BY!)`P^yv@q*o2U@zWV<(;6!HXwN}1 zXTWENf~Yr=l@W%5+EOF1jht74(y97dtJfKR#|Bo>CcfEVD6HE~7*(b<*}V4k!(}Zi zu;&Yx^&&v~2tXw3>UK?nr3~GQ!QNeKdi$l@Beh?YN^6j7o2)1HxQvXYJzbD)&AQ-E zVRP%dqm9Ihoo|Z$(7HzD*<#}pVyDM!2-o|biXy6IiR3c7F4MocZBGg~Bu~8Fy!v8k zefb=)VrU$D(JyrgdD^-aJ8IeQ%APlAeN1)p_sk9pI2r2eyc?!FpEnV@Ys<@s&(jWv zfM;bXf;M_;wDD)Doh5CVKUcCEI^2ob)02)WNpb zByLRNS7u%YH9JnRT&#O1P;<%_;8ovvCn@nDgXtMp#NDuJ>6+9R_&6VU_JvR7cWnApS@A(>MRlO;@Ft-a*J|Y02YqRtOS#OZLWbJnFsJxm0!E3`@-yFZqB+?w$ zy$*DZijY3dTKUEx+gG75D6ost@IJu)9Jm&dK2`C-7R?St?~+Ka{q(o?K0HS-~sF%`gzsjlSNSv;{Jt_a`d!nc+u6in&$EG)hA+>Da&l_QWBqj$Zn<-*Vnz|o5U72 zS{g}GzdT1`ozh4T($s4#P_iAb^3WYA8Tzo$*D;z1YxVBvd%EX!+HYF}p#c*gY3Lds zeY`RbC>7A}v-Tc3-qQL%Z4X=BR~GdMv&kojwO&Y~OpO>n-%7T7T=R$jyEBUdSFX8e ztL38AEXFYiqSp@Z@W+U|s=Tdz55SMxmb>xdg%+vor48w5sX!n}=JHS#!L9+95uK1z zS`mrBf%#k&<^NiY(ueG8BzTj2g%-u{wRGF?)cr1whYHSQadJ4#2`~~Ag*1C!gQ*C# z!v(Pc(p$V*5vj3=d2#0Q;9?{4nEIXZd2dxsbnntX6J#M)kq4Yy;%|&Qh~@Ze@WlNjlc2Bpf`bAq`_6uGK>&(*X%w;A*h`oBT;aviFQ-&ja`y@}v^p@< z9Dac%uR3&Z)0d{tzPv5`_iZowE!~b0k&}IDNH%o2*OzpC@YvITl}!2Y^8F8|n0QV)TH*7YXs3 z3zotIRK89l>j|#8ZC@YE#sSSj2o^^&f#3XF0ETI}&F8|pa4tG^b`*Km=&sW| zbqdxMtQxA=rs+a$?mi(-8`^`+6&AC~7?>Ef@RgL)n`K*hj{8arP;ZB~I~oX}zNqkj?hh&t z=Yszn7^1iGln*m0Fk<#a_i)a4i7$7f)=&5FYXlCsjSUFYDok}r6N<(8BjA|8HQ~Hy z+ycYANiO8CdZe)AT~DaVwXm^BL#ZS|9jpIF`khY86e<)R=bqo8DYc4vV%3D;iwI>T zQk)IVsO_T0i&d}GE>GKV6;2)U)3Q3qj6*oNR(ZZgMgRY^028zav%h8$95)8y0%x=b{9oi-66))k=@N%lFbmKW|uu5bRXxbDdMUMs1EtHQb6}z>;0{Dx!lF0nv^uA=J(!OW+MP6-LSfShpepG$Lel8JEXU~a#pI4yL3w58n`jMJAA=fAz@Cg6O#jXC#oO4vW$jVI9Jov*vi@w71m3JOcj zUXUF=7en~4B7P9kEDCb5OhmN^w8VB}lT*Hx;-C8aSlhEBNXa;{gl65@TKnyx_;6?e zhg&7Ah$vK}DNM1+OlQMCIbw|4U^MjjhD!&v=e)4BntJi9OzrtVr}Na%>v=upzUFQ& z>eGW}M}Z##-={Huh>be;Kee{?!djsa!z-^T%NnW|H8g4HE$4qTHc417<0Ye<^Cp%1 zQK-o6PzddU|{7qr`uCkH;^t2^-bXbr@i&s5_XkPkeS^n6RMT zVlh?U0RE&8w-2vtHR^Mz_c+)&13xiZP${ST2r~Z|g+qtAgpl1% zk6-XM^qzVLlI4#*44-zLE4QDHV+IR3?>2|@gkPEi`dgo2SFvaYn_>O*+`i>1k|I+h zzl*6aM+0^}uK#Tnyo}JqH2=Z+VJ@GQ(wyn}KB7x0pDC{#w^3bYHCk1`8Rwn5K)-W# zpHPfkqO`O(F2qVbSsY?#Rn+QJ`-WFiq`9Xmy-nAMxm*oAT4lxH*7)8hVwJr$aTXk7 zSXxb#Uy50x8|~e!S@DYJFJ2?6A0egDt$*0o7H6F7MM!%cGb0l$wr41!+NtnLdj7PF zxjSL`(evS6@|Lm1^O>vT(UodN{9tvqAjXIvK?(6TD$plQ)29>qjy=oDgmjJBiFZEu zO>y4&aGmgoVp0e{DF6Zh;d9puMaY-4tyU*2=dd^XI9pX5>O>@r?cQ3t^*@`Gqkr{f z&}uCDjFHl!aTAZAXTr_S>DDSX7+17;s(Z7Vsjqg1eNbLq>E~w1<0&)D1tSk4B2dl8 zTtla1>pU{QesXy@T6uW2{KQM!o!^J=cU&72RuG4X_!k>~pw%=T(F2FCq5}N}!i_n) zsz_W0R6_dOuO&PK+mFVKqpQbXeE}mZ%9y`kHPC9u{XCc*oy`{IA=mK$P3l8}?-Wrx z4kOrUkKbb%4&#Ura|U1Pntv>=`zr_G=*p&mK#=b#f{f)_Y{0%gspyKTRmUeq+=3}H zG=?rehn9B^K2GuIwZB7O3G^VqL$dMnr`)CXFFbx+dJ@Ss9oy<`5+`mIC@Dh_@EAMh z|JlRSXUo~hcVdo{Y`UINIDW+NJBIfDEWlYj*SHp<%I(GK5&gUx>RuWbO|^e-|r9C=~PFFky0%*(iu(+J2Y9c$md4KN>0|Zk*pB+?AURt9q1Feih{j&$DsA)ir zr_{z0q)(JyhQpiq)pa70N4si}iFZCB!sXLsMWyj1=~P1qyF zQ3q{m&COqHKc$v>7^=+%=jESe`=KtK*Kw?)toll{_z>J=PduVsEx8Ehh$$!5{9*nL z#MHsld5sw8`;Ukg6A1GL%vTp6O}kTp)T&myo8Kjco**V6@RM&Zfzs0In=AUEb?;)g zx$%ISIw zUSi~{I#Ka2Dba(f9Bh|E#D_s!<)?U>-(^x+rD_ z`F>d6{c7qD>9XpqTCzfg=j#)Bwm)TS@(DanPeqB=>>O&2A!Jkr%&pfeq&`EdC`H4R zId{R}6#%w9g#zqP3SRvypA$e&Lw<=5KoPD(h~FCR&oI{^65LIywm+e;3Smm?L%YVN zp%}eFzg-HM$8$GbvD$&MYrBc-;f_7YVx2#$Srj_KMd$^$W@Ke7)7vbgtN= zYZY3TAxn^f2+#cwcof+hA5W!8_u4^Lza#;PT_|J&yn+fshZuEv_4u!R5IgofLZ^W> zf$SLRqA-u{602jP_4>V33+^Vdekoyo=3s)Ts;E}B+TW;qmd>+mcDnBmcI8OSgqByR z6Qi2cfp!HekI_>6CoWyKE*DRjf<@H!tx2PgoKd{3w-F;BU9 z$hy;cvA#Oz94*JB%sa5oo=vZ>jHmT;K3{H3+-qvBzDa?rP?xdEcPW2txS3ymoMEPT zNB+`%T4$oFz`WzeaGo6l$FCW|HhFc}^-ph{-Grg4>iR5msQJ9?#R&sWw%{uvxlDa0*W(X{D;*7ke%kkPg-HIm)o>@^6~qsd{z9 za7{B+mXpCHK>GQ3&c2q7>AkP}d(CLCyNmU*7~P+rHuzb+V z->MjbD@p*UT+r{&@(vDUQVpOK?oHR3rsKY-=@s$Mcj1TM&~@Y_qH4<o&rNQX0Vl*C}WsNtrp)p zS33t*DPYjt)Q$*mZjZzAwNJ5*7o#Q~o>or7`i6_D?fbOpKX@^HHp#Ew4M;d2w#P+q zan;k-^sj%8P*Yy=kaIGa@yBgoLu2@iSS+O%KxuBMSgg$Pn!ao0FAG&rmkXL(iQ9~K z@eZTM?(IsCvb-72FWY;3tGVJVewD!bSq!*eao^|^!_9iG-(Tm@1v~vz6^t@asVCEC$9(NJt`%33Ph-GI zqE=ns|B6~(eYGJu@)=33>}L}|HlY@>;l9>GyYAg1|1ipAzholhz-BM8;-?bYs;vVh zAI-DB7RuTJv1<)fyf*39Q)Abq$=&f0f-E!=y~+jE*8Vy;UJ;+Y-alsd-yJ_(|C)c} zi>GEU5$ZZSKle6XW&H0CUgCtW^slG}!ZITBO{#+gXPVFygtsHT`c8OhJ1OITpv;VT zu%{Ry9cZ7d{X3CRX_&KJZH$tBhD02tXZp>Wq0eb6?K9R-jIfLLpNj1gjv2bmQ~zm8 z7}`vQ4e!6;+FJVt852I;Yzgu6-ur>?t0^M-OQ9Cz&M^)BBDe4g^G5T#d5#N4{OTye zF;9l?HdI#@xFuy9lTjNtXHmDqN^Hh>Q3(G`ja#A~woaK_y<>{K`=6p`rl0n0&r(a5 zBc`+))TZ`D@l&6dDdHaQU9VEJ%e?tMWH^Ip*&Vy9=DI%(S}6dQws&a9RtF?q z1zj(Jqgx+0Svgo$0UhtoIg8<|a~hmmiJ6(Qv8gAEovt`f>(@^tuT66h`8^0cg+zU_Eb9kqqB2-rd|pY~IWYL36?cYoUUcIV^8 z8mdmFKW?ZBd{O$Y(B32qP?1o8cYi6fwtrg*%Zk61-BSs5lr4|>jx$|1{i`+uYB4Aj z;`Z!rN^pF$>RW4c+-)%>pZ~A$pG%|)0H)m$;=`1%K8?*9Gjp>raqzQ3O)QG@Y0W=! zm!dum^$1l#t_lkFuvx0Dq+)uH0smNd^$ja&o*EElp@DsIDK0<=W_#(!m)jad%gBwS z-^rU5S)2@edW(Ah{tVgbx zzq)pqz2yAs#8a}79bUA~rkt&lmB3$~SlII8s0*XSRR6+KIx<=9g0ie0! z4}KR%)RSylH^VFo9G~1p^WZ>}J8& zAjUU@)8_;+I=){&{5NOa=%UB7YZNQ$Ed%&pei3EYDl2OqYW6y$rF{R*06pfvNkV*~ zTi5+xCvb*U%0Zah7MY#fCo4|^#H>3>KtrT$0cpFP1ix{)huYRfTiK*md{2%rH~)3v zVyjjOCM*veOmO3Y=dl=M&fT^}?bPS?l?X|VC-G(V&6S!!c`7Iv{X`tyu`1NgocuQI zgRB+C^~0Zl$upvW`|W2=?QN_{N~(8SFB8(u2$JtFgpx%#?uzGnvycB$eI*Z55q6$8 zH_JLDCIE(wZLcevou1~uIA{1Rksn=gG?-#)EyWHR1jr|v%E#vM?N`s9U?AZ??HM9} zxJ*y~mVD%+U~^zjZgRVC(AXG{gJU$4*?IlCX8F^Mx(`%SONu#6+=HN}8m@@DD;!7D z3%r@*nI58%y>VEH%HlfL;kPLr{%)mc`d^x%&p>rhEP=N~QCaVG58C9e*17jf#m)0{ z8c_N=;@k$%JUihq6GK4!z#UjMCSk_o`Ssrr(`VS>pQ)aAEqz^XS`#+BB<{XKq8mJh z(V?JH95DUrPPD;^oo5?9A(Dbys+cYPf)fmO5&|F+*)v@It*l*-O8g5SLvMkxM~p;T zGG?M&E$a%$G*bS}k&$H^H|hWR9o@*jQ7G;xj`(P#W{Hw3DZn$^%AkAm=f398ry1`F zfEW#;9Mg{QYnMH-0$r~>HcMB#$m9-ta!10lH|fLP?Bo=2SgV8~7tZC{7L1wn%ej}s z@d$L_yt@F+Z$O4cPs;oQzSnc+zKHrSoECsfbmM5yuT@p4Jd`{acXHsVu$HulK0k!^5*mUXrmdZ>g2WY?o zc4vbAFJQCp3NO9=&Q6AQLqwA4-M-qeny}vDgs?or ztoDbG)t}S_M7hd!I`2l%t2nsuL7%t@bKCA|lkKoN2(kcojIhR@!Y>7*ysqCPI?alPtM@Md*T z8)l@QymZw@#z&E(02@ii-&JP+et$jLe$mps@4B~NCIy%atG4WaDc<{_nalCSV#szS z)hlz+Fs4>q;g0cTqy4A7$o}6&2>`BT*YGDsEL}`oyw!WYq1RvJGr^T2dC(y}#rKoH z+X?GRZX;Iz=cvPil-Xi0j{5y~zEtn{%01S-*WY?3nuvOAggP=lJJhtw0;hPsR6Mlt z+qNpT-8Xb8nAx7j&tSMPU#1#~v1JB9a{+mOh+E2@LTCPA3^OK35l)15bSLEIK%ypg zLzE_-K2W0Iw+p@pu9pBiP1LFvE2QRy`xvLnw5sn8xd*y`-G2N%akLWV?L{!V?fnpD zd85c-s@pn^XScSF{R~&@pa5nzSFO!&jHRW$SeNrvOqOR$2OYO5;IYbNRxk4 zr=93(YJ86ou4i93t#eZj9-HF}uRvJs1?j48*3THpcNBf;V208DJxPaZg8HWOUl&65 z)|CI1rO>BJ*|bilcbX%`V8vqCd7jmCBg@((c`W|o?GqE=ARb^H=Eb@ghLs$v+<#ox|_+*8j1ap6?ek&7r450AydC!SV3R$nB z4|1j?8M4xMYD*Ur2~?M7U=9nIhKlbK{~;kP{rFzPY<{D;rG1WTjHG{lL*&&5Bhf>-yi^%2S5a8ohsDr{9cRE`05Aa6m0tULMTN$Om2qoRZYUZu~9A#;AgRjZ%sf)5?0dN9i7 z+>+#+6afI0Z>3j^YF|yyR5tLlS=1C*@aT*_4;-oo2xKx9ug?dZcXgmFZg7KJ(8^yj zMIgbrBGUmIc#@s}$3ONTmka{iuTJC>g7k1!wZ%%)1He{Mv9qT?XC34-{t=*(Oem}5 zD0p6rHP&YNt~j$gzGIOZbB+?3+PPnO@E7Hfk_}-eAW)ozypj4DdeOeA+3;hLOC?kD zPbGadL(Fphivn?)q`||_>)$3i$B$};N_lZ&^ANf>QSznxZEvU>y2!&;K|%Ws6W+&A*VMIC+s0zb(~D|owUANOs)EGN&W}k&c3G0WXp%Sa%lz) zre&{Ai~gXBzp5>gVxXaYIjC57kz*or91P7Xp)ml~xuBmI6q}SL!2l2}(t7nml8Yu( zehcXaY%Ud4oY(o%m_eZ*y58t+^mVP2Q_9!<2e(7-&ZGcrC$HtN(G(lq4diOZQC|01 z)c&}T(jd=jrV2d-ngDh`AqHN`?o>A1y_PcIZ-ky_xA@fAy_ju%I{zJZyU6MvaP^zD zUnpe$EDIi_Q>9UQuePhcm27GEo1|!RC@ZTGU)pNVF?O=$?=M6K9+y%cSFDKRmO`<57s(Ueb!ukSDn*3qI3>U8 z>fG_ia_7E6JOYSEnhXgBXzF!qzOcG@E0=>6DHOc!kIaa4cUBz@?ZXt;Vow{>*}d%m zfb||^j|1G>K`bgwGPHAd9-< zX8fw|v0d*lv_)Ii!d_BrQOd)$Y%Xw8d)7|NZXt&T5_Dz(917xC$cacgA{q@#ae(#d zqD{XkHpz0b$I+u*;zV|&kmJx=nq@q;gkF(UOw1h7E`5dqni-)IgDpd z`9EFvN1=RolC|W9f@W3DsPd2PsiC&g5SC&U$cHM|ry2slcPy+4Cu(ZncOY^mquA4| zC=Zy`!5%yDrcegG$Y%Lzo%+Y$D`Up&b^73HJXLhpb5%H%WX_eACt%oQK) zRqJ+guB1A>>-Bs%DlD|wXed^V(F)0%0cB%Li5U~Z+~R|TN73^Npi-~H$zKi1ItM-v z{;a5=UEXnDrYi`PJ`px*xzFNwk=N7Gt0J?yNYniofX-fzxVOK(O)ajxbeS*#2K$#1 zPd6uAtzO;_74v$j`M;z?+i{bhN{t=^{9?PfbJwCzm94qNgwTA97x?gbnsx?kcesCIF9#QNWJNd`y`=HlaNk%_l^3ZeT&Nb_=61xl!7WLb_ zsM;FkC6seZl=Z+Q*UwUP!J`Ib#dpy=7)G)ezdxnp@nHk#R~1;~oC|XLZudHV%}Rz_ zT#8b5{RV!>pd*j%3JRu|R{r)r-3h24g5LFS{HiXlpb;01K@h0IR(j(8);@JS_iix; z#ZxpLO?4%~xjXq(9=JNRK~1-XrG>%Ks#hMKdvhn-d8YvkwTiuGfZKENiWs2xr+tas zL1R6~-ca3EyYJPI-z;z!DK}WRDcs#M>X0gqD%tn=gsY=pEDr?0(=Fnsci0#a0Oev4 zW;h@SU!F&yYxB=E`v=s|R`vi#z|Q7ao}tTwRPNTyPaA=KUGEZ=Jo@u()~9XMSusFY z)ro~^-p%PKC)sg))iL`psA%J-3Ld`MJNroP3g_?sEhj5m|E_*T_MRMWOhJ;#ceoIk z<0WYSdS$JtjB)u10CUL~7F*lvw8fWBZVZzvU(2LYDQfBKQE;rrctk19P>B;*kKCVX z1r=lUu|QG-Hkoq5xy^cwRSQA@p?|(3rivw`{dx1xj+c7a(yk4N|LnRl@=$R4mMs1e zH<@W;Ah=ot&3<#iA{+Qc&G573Kt=-mxBxiQ5*Biuws}7ynl5bsECyp|P~wHKga*lD zWB&IZLlu<>u;5eg_S2x#NfxG?*9$08eJB|QPoRrW}Fu0;VODpicsu{1`-)Jt>3p#_n%ttUR*MtcX%c>)YvQcv^n&>a2IygUyId z_1`Y6{}%=Z`T0^+=jSUvf6ReEN`_a?%|mY1itAJkpdDxsB!UYC*8uQvd&{PRr@0-V zw2KTb;^2!80s##K0Dww8fCqq-YN1jwR)m44+{CjHgml(`mj@S)+a zhRyS-c>oa1T>X=wVVfUUya4AtaN!*Y0A|)MDllr|A9X`e{0&49kb=|y(-I&E9;YI^>^p-{r8?;AQk2;^0HEU5)tO(h{E@Hr zO0r0P9y6?UY9Q1KiaC%u0MJSu8eqqG^pH{o=T8&rFA5+H1VBlJ4InaM2u?7)0v5~O z6=XrIH3A|fj`{gG;({J-Z?S0oFtZlqyPHk*yj$?uUIYjl2Y?~~R0{=Hm|j@>sDYpc zh8z$jGpCrJ69vJ8u_@ET)hVtZDsOZh3cd=G30;o~*RK*4EIa^zF2pfi$Y&LKcaX}L zPZ5#Y8D$1O7yn$FLRU$R13>BSm9AeA@vB+?^?*_S7>Ha^CJ{!)A_JHfq-OvqAO8~ANOJV)j-NO2LP$)#du@+Ed;EF6}7Gx-;_D9C{bMIp^OZc?U zdDYZN0F;59YA~qc_e*^bpdK*tRYZWVCm=>SkbH`QAlPNle%|(+d^4!?()DK01*u>p zmsvrRR)1|nD9;u~9V~#82udmA)3kY3em)VMB+Q4#m*rG#famEIQ2qRuNsR*l3g2Gp z7XL)>rw9O{9x;TM9PrPBiVCus5@2g*J0Y!~xZc48HOB^+#n@{b;0mb9RIaUR|C39F zO1!^*BOd|uEF)E!V>a)a)E8l2<6XuACYgR7iEvSB8~}iN9H1KgtD5pTD0sq7fOi1_ zf>?(004RUyOtO&O#Ioo8{I6Ulj<1ctDWmK03sMVFCYUX z!Gf87K^Q%;EHxLh5FiuhUZii(d8ly!$h&q}%YfFU2OyJPKxG%upCs^pJe&%#0%)zr z08%Wf(IcD)7lV$9_W-6>`ShxnvN+HOs4kNl2LPL7BoLS+gYL!<<@)>87+Mzk%s2&S1_R5`N)6yDb?0`12&3?Eay)XK zJpn|^9S9FlNQDJNO1blm1rfQVR2O^5;6QgFAT$mDheRL$XZz`oe)xlR9PJ&Wb?XMu z>mtqEWbceEGRsIU!?3$qcBNsW6-<(W0Q5%*###+~LGVOG_5jRn%p8T$r^m)V%#3+W z!;QUZ-CO}qX(B}cpa=xOrUpTvaX1)5mY0qRv?q`uE(n0esepK8`8R*r-T&_=_csw~ z4N2T;7Hj>D-K3JDji?mIk{Ku>q@yt&_J@djy$==92SCxEXC`ew+SyYp%fH+@IEG>Y zi2%kIjV!q3Rw?<{Jl#FOeh~D#-KD>a@B;t`8(W*Fi;)HNbxBNlq4etQSKqKlN58#G z^v3sAml3yPWFjykFwDr9VML0RZ+#-DF&2Yig54|wulC+s?k@dNME(Zg-p1DE!U+v` z@4QCOo*n)=?Dv21QXHcdhiBUv6v1em;vh*eZpDvRZmhi%YW-(_-QA6?%?nKwZzKQ! z;LiH`?a?^-DU#&JO5_%RFt@j>mUh+T5(1#~anx>q){dfo`Q}Uy;ab?9+NtC2`knSL z8UHLDkAI?AZo{RMpNay}svpIz&)co|69RoEBLBvW!C2>^#sc8(`W+&oKwNqEO`L%l z&Ko0!NG2lB;5u`S&MJoeH5G_@5V;9gRC;>zfbyCH0M7vQCwnHJml_LzoO~v)hHt}r vhL_}_1AE@)O#Ye*3o?o3T$3-V^C#*5s^P+XME9n|00000NkvXXu0mjf4gzv~ literal 0 HcmV?d00001 diff --git a/images/dgme_logo_32.png b/images/dgme_logo_32.png new file mode 100644 index 0000000000000000000000000000000000000000..88488462f4a694cc1156f7894a0ebf78aa235d88 GIT binary patch literal 6079 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 literal 0 HcmV?d00001 diff --git a/images/dgpe_logo_32.png b/images/dgpe_logo_32.png new file mode 100755 index 0000000000000000000000000000000000000000..5384a81c130023c54c59af9e3a10893998f0ad57 GIT binary patch literal 5818 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 NuboDV(okz!|9`pBT*UwY literal 0 HcmV?d00001 diff --git a/images/dgse_logo_128.png b/images/dgse_logo_128.png new file mode 100644 index 0000000000000000000000000000000000000000..f50221b216d9dcae2626bb246dab97898b7b4e75 GIT binary patch literal 14474 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 literal 0 HcmV?d00001 diff --git a/images/dgse_logo_32.png b/images/dgse_logo_32.png new file mode 100755 index 0000000000000000000000000000000000000000..29d53d2d12fdd9932032c2e60195edd76d7b5f63 GIT binary patch literal 1828 zcmV+<2iy3GP)c$e2xSyP&*6Mde7Eb{o!Olk-rYNg1oB}g zMT|VR>zUo1XP$ZHeV-x582B;^kA9?(Ffy_qX2FCSKxqwxfEBe+LIR7DxKCk&0mJdX z=+7`h!l-3FMJWZvVlOQ2UyNVCL606e3b);I3kWGe1BI)4$j#roy8G$IB+E8^1#eGH zzqj8D?nNN=0@06jbNc;;2&fl^;WXth7|VVR=E0=nIIwZkCV1r0EiDG3!Z-(xv-WX5 zYgTlw|7O2+!=cI))Tb@T6R7}s=hyZy4Ak_)b)o-9T50K;5mC=kX zMm0TS2A5mT2_VupLvHLeKu6v{X-NnJ-S$_d(!}P>OECHc1Bg1XhuZ+j4gw4EKCCJy zR)$JvDPvO|mId_x4FK6iQK6JobTvW5LJT5FvHGy)8hdGHnp_<)dZ;IrJS`r44c{U` zi#c*>0HRpXmDWqT(q^F8MV77TO|6!G5UY#e6NUz`yrjwMa%%k)5&vA8?|=-Dwu*JX z(duhP<24MIoln>o1Q6gHmiL6WZQIa0vg59S{Wo;Tb@kc|gcA380Ie7!8}O60U3VTA z?vcgitY5NHK#;Cv(=U}me@~C}J~Bc)tT8g0`2vW-?B7T2S6=+v$zR!lvb{c2pDLAKGSaT)oyXK0-x`Vv=+Sjk`g)D|1jZ9IcS)K7WOeQnPeeYo={NI|GyEy`g zmM)3Jy|K@GYRj(bkj(ahtlMyETw{f}HmU^b$0uR)w+nZ_Q=J~otxS)u9wR2^}Al>%$@y4DvaE_?%$U7~lV0 z@rPZf3U~JveG{d0!*Bjo)m)ka7GMoAD&(oaTZDZ!di3plZIF>dKu=Tl~}X6oW8r z5b~T%Tr_AzAVxhoyrVK{b0F96^W>d37jm0_ykOyzN8cKQS|NbG<;Cu`%errU{oTpo z6}ik7A9d2^;q-Da$Adx#3{GO2!M=^yEHBEyY{66vqf$L_K!iV7qSZa?dO9DU6hu3s z3&!5#@b>E`VYz4Dq&XX#qI9oqTX3dBiakKm#)wKG7Y6rguesF_!eKsPljBDi!ge^{ z{rpE0*Ge$<50@6VmBZBPYSlrFH=vlZ*G|kdU+7QpHE1H*$-axb}dO9nvNX*>cTFv$6_N!5{?%Z7yh*HC(Sj%Y3jA`>Gc zE8k`z@{HJ=z?6I>f!OW7ED$Rutv?^A3`FID>1hT0^0c2NwYZ4BHs?#XG_n`@GpO0^qtXNGVUBSS~tytnbV?zi+1PRSq7W zTs*AQ9QR#ehljCCj08QrYa-F$Gcz-=`^{arhU%#l17_%h2U0t^5%t1P6U Sm55~k00003552EP)Nkl$NWTs#~O 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 VY%2RVTYUfk002ovPDHLkV1oUj+W`Oo literal 0 HcmV?d00001 diff --git a/images/gear.png b/images/gear.png new file mode 100755 index 0000000000000000000000000000000000000000..41ff2dd6d78b9827ab401185d625f35184687699 GIT binary patch literal 394 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&kwj^(N7l!{JxM1({$v_d#0*}aI z1_o|n5N2eUHAey{$X?><>&kwWhn-hLtURzvhk=1n+|$J|MB{w!#e-f~3I*6doPYCA zR5WthRol+0h;3G_;z!LtNbZuns_b!IL1bS;(uI}T0&liP-(8ZtU_&XVbw`}jtz0XM zq!#AGH9u?X57(scyW_h1l~P=1MeL7wztvg%{u?`4bxy}BwrGp#p)m@*T*{pZ$N2B8h+e&7oveb317H3V|%s+_Ln#?%p zU9;-$wvF+^YCmo!?As)JX4;fI(~`WMlkDyvOYc$2;i%;)=nLJWP$hV7LpwjO-bahe z28VRy9xl+cQ2d`#)xlnIf0cGlqGfRNdew!Y70YaH>?{2+eRlQvhZ65UJUPa8zOYGK m=FuVBjZOViwf|49XOukW9JuC4h#N4}7(8A5T-G@yGywqT51()V literal 0 HcmV?d00001 diff --git a/images/power_marker32.png b/images/power_marker32.png new file mode 100755 index 0000000000000000000000000000000000000000..48129026bc3e9d827169bb5e8d4c7160690c5444 GIT binary patch literal 2132 zcmV-a2&?yrP)Oc*tiKUB_?Sbf!w6DHHj1|DpjEhRip|PB~n$T zl~P4XTA`*&(@;u7fCi_$48)jJEx0(?u`%`rWBghcjJ#}q)?ecHg|Xa z%zSs|+;hKkzVA|oVc@Hb-riocw6uWddDPU@U}R(jBg3OuS-B!>-D3?xR%Vv^|K_s` z-86Iqkw5@KAV6DPj)xeA;b0=AZa<%o(}pX3GPitrFp)^?TD*9170a^VI1ZjXFG7(Z zP7LhIn+{Dqwe2S__`Wm%O_tF6?rvP#yJyK0+kf1xXtYg^MhuFgAYSw1`oCJ}(Z`>D zX6K&w4_G;C&F0OU>uyaXe3Qtk-Y?XA=h@i37+eqlMSccFBh#B(Js+935A_d2 z4u>gCmN85KTnLIcx7Dxz$Rwn1piAU48DQQC)gs`0_-&kg?*fXnwL_aXH~CWnkffPh z?kxA$LxFKj`+U&iafCDt-SIGVuXo?7va&fd?nbO1ANX3ZzIZ$O!Uxg%eh)URcoCPG zep*)Kd*%>*x6jioO{HaK)Wn213`K!K)0i`K%S@aGT5PuRSU3WMtXw8HU95o&8}|=yBy9AZ`)8#YXScfS zD=eGPdq~E*2ezW9qyVva)O-zpKL8fq0-^#V5v$O3q>0g3QUDCuaF3NWa$W9HZ+x_; zYxwH(j}a9Pja)hV$-WDMu3C-WL=R54oq`|;lshebfVcChGr-7+I$0(wk46a|11wR1 zg|btONYyom40&nonvcxb^78Um`h0y&IN_T0H2re)jyJUtt*Q^Fi<3{Ci?F)J##Na&q*XCGZwTr8NeRn^r8JI6*=eyP zw?>!~-1Teg-I64kCIHbgi3@5w&L0~Zf8wWQH&S9i=ekh(J_;u@cVKe#;msTps&WWisM$>t#sT185z46Wxcyc(`EW#FHDoJ(W@pTUXi*eXQ$g+4=bO2NacFeUUt!Nouh$q0W}*><4GsB><&cABNTM3aWX-25FslnV zG4?8MwB}&Tw_buPlgIs-&|Mkucsxc%niC??zbh-fIx-hUM4Si1pudo_b*j*LD!BoG zD2vWQX&wAWa!~)!_n{ghg25mZNlv3u^#M}_8u>VJL&o7ws|fgKkj`kxrY$G4t;(wd zig=&__|%M@3{L#vjW_m}Ivp;JPX}86OS<#oq755xT>nEj@H&zpPlE8ij^xdPhtlyUq#j;OVQBipu4i7N=OX= zaYKj8Q4T_aP8gwKvCJkCOS>X?y|I`R_1kgFmoMK#jOP)R)C+}NKYDH}24~J;SMx7$ z`0(Lc0-!5}Vnp27^x+IwRU$Hvc_D%u2^}4A8Fq?0=AfxtNk~`o6!$a%p*AJw;1!l(Cp;m`3_aTxH5Yjb7b$z&;<-3eq3QVTq&}6qEUl>QJ zwH{$-A3p4Q9W(9uSX8GK8AyJF?Ra61J8pk@{##~1Mwluy7E6)-oX;jMP*qj+r^~r?DZ*0iRpsY@=C_Xti z;br-bI~q4qAt$GA+8j~|rrQ@|P1R~#8S*88MI%u=P1C6XFbU4h$eLX3bP*9ykI1UT zhz3sQvs~2lh%XLBm0#HL^Iv7=Wak>0ya(MqJ@EDSQyVul3TCUGx(^T)kVXtH+VBWs z92~_Ragi0txbqxQQ(Nn4YHISBrTEb`Ymk|l3Et{J_7Zrzx~x)>XPE(U+(?e-iOUQs@W4TEc)k8;HB5Lq3!g?$G;)~3LNAICnWuB zKEs8y1!AsTxe_ZbF2&5u3@C%agtZ-iIr19(Z8qO;b~jy0-3OA5DFs1Oy zk`Pq%hfcz0*^NVO?;v+-J$}%*1v;%!GO^|q88DKqxUNW9gOW73L}y3UWcnG<$?3?D zm1M0jbD%KoYjh6F=7`_#FA>55Z?oD+yqsjkWXGB26v?T1^j-k|E5HDx{l9{o3w;Ct0000< KMNUMnLSTa2=m_or literal 0 HcmV?d00001 diff --git a/images/preferences32.png b/images/preferences32.png new file mode 100755 index 0000000000000000000000000000000000000000..bdbc4051bed43e22ea6057ac4844866396219458 GIT binary patch literal 2899 zcmWlbdpy(oAIHDD4_~Yl8$hqQgSWp0I3ayzVZ>Qtvr z*5#Db#Wtf0GC!#lHJeLH<<4Z=_qYD|eBO`GAD_?X@p^xrulMJj9~u&1Y-ni+0Dy5& zpnsS?Qhp9RRzDxj%9_##bdqndF96g%r(+5}>(`|8jp6A#QWMjU#~eujeB)DN6DUC` zF-Zww2{G}RhhHRk1AyURkiYNF-`>Y+V3;9eA|W6a9UDg(La25yHNS5jI)b?ENH7A% z4^Vsr!T(dra!CLZ@1I1Yxju=HP4q#=Z^NkUC~Hx%F*N5yj4GDmgKnyW)J6mbN_C9+ zHUCoig$oD@#Iz;6Y?rz!+NJr^&*TcQZm;+Jr0zq{Y^@Lbw~78|#Rr&kTSBv~^J-D! zF66Qs$niy#44Ex#mS=%Fyow-(43KDo5kah-omCt^dLY0&nKaF)i(apt|Ne62_hK8x z^#;yt#Nc3I(q4Iu&kx;n>%~0^G3UgXw&y`4tkXUMyN;vsM)^~y+YW?OMC_V!sh^)8 z$UFwx_+&!{w^4@;2}gm$s|kF2y}kj6uHU0w>gd>-l-%E>nUzOBrKj%Ic^%XAiQTT6 zxZ?2zce=gisMVgCfuH{;^V&})R~Mb$+Qwi!J~8?EThH56b~enOme-0cK@gUfSrLg; zJ3BjSUST2MdaO^15`2Dj)y+F|0zFi+kK$v{GzqD#m;jeNW_fLFq)x#ho!VGmQAGA= z?CROBh1*`pB*SxZ;gthZjODJy0W(NV-BYtW>s(;+~lZ7-EhaTQ|i$Q&ok#eAad;tuzMy_~hn^ zj6(w}Z|#E-_b$#U#NSwTS1w9wg!AI%=Hl8mAuU5SfF1)AwejZg z;w2vwyKf(}mdR@w#I*;zn?%P(%tfg0y$$Wulvtxz{Qm=GEk77Hwo60}});k$AVIvKa<00jDogh60Ws|;k%W>ZP!L=vfit!?QW z9Bdv_kH-nB=&7{qA5;0#r!Cde&dyTb9a{=xbecKVj=fK^P1>YeS@(rP3TnlQ6&}MJ zSZC}QPn2?>LRUod#=)l#{yZKCoMCw3y!cgn0GTTD6{*p01L?ijds0M1BtL^>KC+&L*pQY+ zr5;}6HZ?QDGv2-XHPZ5C>zL+UoZ!Hoc5XBCT}pGoVopbMI6v?+7>7uyA)y?l=g~OiU0B#1@WHmxc#nVC zw2rW~c?ryC-0VkxEQexJj@rq3t{x}qbh_rTY3<4ZXZ7MrBMDYt8QTzIFXwhGTef3Q&U#?0t{GzCJO6DEW;mCiM%9U390wTlWI zFj(z2fr8P1n(XHYTx=-YCE!g9Xeb}Jk&Z1#>8th-v`y%d`^$uKy&alqKo|f@Mq~a% z#^W6p916dir_?=(6sm~MCQc;o1#?+Nu$#mn^j(*A6j_7H`a0qjh=_Ns z-s3g`1g6M+%piQLPNUNr)#Ec~zprsOPUD%GKR+V$zDL4rPWuW<)}myZUrvI1Txv0e z9C!v3RRl@qzD-JPfxlO<5id2m&(>WCa^Dd_Ma3^9b6Y(CMb*5 z*pZvj%9384*;NGf*!%7Q6EMcK}43*}Jd!4t5a_nawfNOuKCo#iELk%%&eUyU|g%~FK zuz#R~V5WkvN3c zAvP-60BN*5bd1;S%`D9EZ<4;7Qq0OhPD2BZ{z9$BK%Ptimw(QxyWKr8bZ6LYjJ=V* zLGp8|oGR1+XGDru=L5?tHXpxUBRnZclnPQUg8|BnrE3APG$8z~IZp3dpf6 zCuc!-=h+1H)cCRv+sEK2gB_!u8pL$~p4iY$B^P$Y6VU5URpE9n0ebRC8=19tktd%U zZH1!1%})CzODoR!ZuMJqO>jn8d=HJXxv>D{MRmWhb0^8)e~wrW1+_MI7Q}rk$jJZ@ zO}z8ij}0g0EyGtnLH%GFhR=vmPg^;HEf-yokv?+npcwG*_hDzt(!Mn96?i>Zjoi>j z_ZTrSFrY +#import "dgbase/AppDelegate.h" +#import "ResultWindow.h" +#import "DirectoryPanel.h" +#import "PyDupeGuru.h" + +@interface AppDelegate : AppDelegateBase +{ + IBOutlet NSButton *presetsButton; + IBOutlet NSPopUpButton *presetsPopup; + IBOutlet ResultWindow *result; + + DirectoryPanel *_directoryPanel; +} +- (IBAction)openWebsite:(id)sender; +- (IBAction)popupPresets:(id)sender; +- (IBAction)toggleDirectories:(id)sender; +- (IBAction)usePreset:(id)sender; + +- (DirectoryPanel *)directoryPanel; +- (PyDupeGuru *)py; +@end diff --git a/me/cocoa/AppDelegate.m b/me/cocoa/AppDelegate.m new file mode 100644 index 00000000..804999de --- /dev/null +++ b/me/cocoa/AppDelegate.m @@ -0,0 +1,158 @@ +#import "AppDelegate.h" +#import "cocoalib/ProgressController.h" +#import "cocoalib/RegistrationInterface.h" +#import "cocoalib/Utils.h" +#import "cocoalib/ValueTransformers.h" +#import "cocoalib/Dialogs.h" +#import "Consts.h" + +@implementation AppDelegate ++ (void)initialize +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + NSMutableDictionary *d = [NSMutableDictionary dictionaryWithCapacity:10]; + [d setObject:i2n(3) forKey:@"scanType"]; + [d setObject:i2n(80) forKey:@"minMatchPercentage"]; + [d setObject:i2n(1) forKey:@"recreatePathType"]; + [d setObject:b2n(NO) forKey:@"wordWeighting"]; + [d setObject:b2n(NO) forKey:@"matchSimilarWords"]; + [d setObject:b2n(YES) forKey:@"mixFileKind"]; + [d setObject:b2n(NO) forKey:@"useRegexpFilter"]; + [d setObject:b2n(NO) forKey:@"removeEmptyFolders"]; + [d setObject:b2n(NO) forKey:@"debug"]; + [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:[NSArray array] forKey:@"recentDirectories"]; + [d setObject:[NSArray array] forKey:@"columnsOrder"]; + [d setObject:[NSDictionary dictionary] forKey:@"columnsWidth"]; + [[NSUserDefaultsController sharedUserDefaultsController] setInitialValues:d]; + [ud registerDefaults:d]; +} + +- (id)init +{ + self = [super init]; + NSMutableIndexSet *i = [NSMutableIndexSet indexSetWithIndex:4]; + [i addIndex:5]; + VTIsIntIn *vtScanTypeIsNotContent = [[[VTIsIntIn alloc] initWithValues:i reverse:YES] autorelease]; + [NSValueTransformer setValueTransformer:vtScanTypeIsNotContent forName:@"vtScanTypeIsNotContent"]; + VTIsIntIn *vtScanTypeIsTag = [[[VTIsIntIn alloc] initWithValues:[NSIndexSet indexSetWithIndex:3] reverse:NO] autorelease]; + [NSValueTransformer setValueTransformer:vtScanTypeIsTag forName:@"vtScanTypeIsTag"]; + _directoryPanel = nil; + _appName = APPNAME; + return self; +} + +- (IBAction)openWebsite:(id)sender +{ + [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://www.hardcoded.net/dupeguru_me"]]; +} + +- (IBAction)popupPresets:(id)sender +{ + [presetsPopup selectItem: nil]; + [[presetsPopup cell] performClickWithFrame:[sender frame] inView:[sender superview]]; +} + +- (IBAction)toggleDirectories:(id)sender +{ + [[self directoryPanel] toggleVisible:sender]; +} + +- (IBAction)usePreset:(id)sender +{ + NSUserDefaultsController *ud = [NSUserDefaultsController sharedUserDefaultsController]; + [ud revertToInitialValues:nil]; + NSUserDefaults *d = [ud defaults]; + switch ([sender tag]) + { + case 0: + { + [d setInteger:5 forKey:@"scanType"]; + break; + } + //case 1 is defaults + case 2: + { + [d setInteger:2 forKey:@"scanType"]; + break; + } + case 3: + { + [d setInteger:0 forKey:@"scanType"]; + [d setInteger:50 forKey:@"minMatchPercentage"]; + break; + } + case 4: + { + [d setInteger:0 forKey:@"scanType"]; + [d setInteger:50 forKey:@"minMatchPercentage"]; + [d setBool:YES forKey:@"matchSimilarWords"]; + [d setBool:YES forKey:@"wordWeighting"]; + break; + } + } +} + +- (DirectoryPanel *)directoryPanel +{ + if (!_directoryPanel) + _directoryPanel = [[DirectoryPanel alloc] initWithParentApp:self]; + return _directoryPanel; +} +- (PyDupeGuru *)py { return (PyDupeGuru *)py; } + +//Delegate +- (void)applicationDidFinishLaunching:(NSNotification *)aNotification +{ + [[ProgressController mainProgressController] setWorker:py]; + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + //Restore Columns + NSArray *columnsOrder = [ud arrayForKey:@"columnsOrder"]; + NSDictionary *columnsWidth = [ud dictionaryForKey:@"columnsWidth"]; + if ([columnsOrder count]) + [result restoreColumnsPosition:columnsOrder widths:columnsWidth]; + //Reg stuff + if ([RegistrationInterface showNagWithApp:[self py] name:APPNAME limitDescription:LIMIT_DESC]) + [unlockMenuItem setTitle:@"Thanks for buying dupeGuru ME!"]; + //Restore results + [py loadIgnoreList]; + [py loadResults]; +} + +- (void)applicationWillBecomeActive:(NSNotification *)aNotification +{ + if (![[result window] isVisible]) + [result showWindow:NSApp]; +} + +- (void)applicationWillTerminate:(NSNotification *)aNotification +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [ud setObject: [result getColumnsOrder] forKey:@"columnsOrder"]; + [ud setObject: [result getColumnsWidth] forKey:@"columnsWidth"]; + [py saveIgnoreList]; + [py saveResults]; + int sc = [ud integerForKey:@"sessionCountSinceLastIgnorePurge"]; + if (sc >= 10) + { + sc = -1; + [py purgeIgnoreList]; + } + sc++; + [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 RecentDirectories so it saves the user defaults + [recentDirectories release]; +} + +- (void)recentDirecoryClicked:(NSString *)directory +{ + [[self directoryPanel] addDirectory:directory]; +} +@end diff --git a/me/cocoa/Consts.h b/me/cocoa/Consts.h new file mode 100644 index 00000000..94d203f7 --- /dev/null +++ b/me/cocoa/Consts.h @@ -0,0 +1,5 @@ +#import "dgbase/Consts.h" + +#define APPNAME @"dupeGuru ME" + +#define jobScanDeadTracks @"jobScanDeadTracks" \ No newline at end of file diff --git a/me/cocoa/DetailsPanel.h b/me/cocoa/DetailsPanel.h new file mode 100644 index 00000000..0d4c025d --- /dev/null +++ b/me/cocoa/DetailsPanel.h @@ -0,0 +1,13 @@ +#import +#import "cocoalib/PyApp.h" +#import "cocoalib/Table.h" + + +@interface DetailsPanel : NSWindowController +{ + IBOutlet TableView *detailsTable; +} +- (id)initWithPy:(PyApp *)aPy; + +- (void)refresh; +@end \ No newline at end of file diff --git a/me/cocoa/DetailsPanel.m b/me/cocoa/DetailsPanel.m new file mode 100644 index 00000000..1baac387 --- /dev/null +++ b/me/cocoa/DetailsPanel.m @@ -0,0 +1,16 @@ +#import "DetailsPanel.h" + +@implementation DetailsPanel +- (id)initWithPy:(PyApp *)aPy +{ + self = [super initWithWindowNibName:@"Details"]; + [self window]; //So the detailsTable is initialized. + [detailsTable setPy:aPy]; + return self; +} + +- (void)refresh +{ + [detailsTable reloadData]; +} +@end diff --git a/me/cocoa/DirectoryPanel.h b/me/cocoa/DirectoryPanel.h new file mode 100644 index 00000000..86b00388 --- /dev/null +++ b/me/cocoa/DirectoryPanel.h @@ -0,0 +1,8 @@ +#import +#import "dgbase/DirectoryPanel.h" + +@interface DirectoryPanel : DirectoryPanelBase +{ +} +- (IBAction)addiTunes:(id)sender; +@end diff --git a/me/cocoa/DirectoryPanel.m b/me/cocoa/DirectoryPanel.m new file mode 100644 index 00000000..0f9831ab --- /dev/null +++ b/me/cocoa/DirectoryPanel.m @@ -0,0 +1,23 @@ +#import "DirectoryPanel.h" + +@implementation DirectoryPanel +- (IBAction)addiTunes:(id)sender +{ + [self addDirectory:[@"~/Music/iTunes/iTunes Music" stringByExpandingTildeInPath]]; +} + +- (IBAction)popupAddDirectoryMenu:(id)sender +{ + NSMenu *m = [addButtonPopUp menu]; + while ([m numberOfItems] > 0) + [m removeItemAtIndex:0]; + NSMenuItem *mi = [m addItemWithTitle:@"Add New Directory..." action:@selector(askForDirectory:) keyEquivalent:@""]; + [mi setTarget:self]; + mi = [m addItemWithTitle:@"Add iTunes Directory" action:@selector(addiTunes:) keyEquivalent:@""]; + [mi setTarget:self]; + [m addItem:[NSMenuItem separatorItem]]; + [_recentDirectories fillMenu:m]; + [addButtonPopUp selectItem: nil]; + [[addButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]]; +} +@end diff --git a/me/cocoa/English.lproj/Details.nib/classes.nib b/me/cocoa/English.lproj/Details.nib/classes.nib new file mode 100644 index 00000000..e1b7cb92 --- /dev/null +++ b/me/cocoa/English.lproj/Details.nib/classes.nib @@ -0,0 +1,18 @@ +{ + IBClasses = ( + { + CLASS = DetailsPanel; + LANGUAGE = ObjC; + OUTLETS = {detailsTable = NSTableView; }; + SUPERCLASS = NSWindowController; + }, + {CLASS = FirstResponder; LANGUAGE = ObjC; SUPERCLASS = NSObject; }, + { + CLASS = TableView; + LANGUAGE = ObjC; + OUTLETS = {py = PyApp; }; + SUPERCLASS = NSTableView; + } + ); + IBVersion = 1; +} \ No newline at end of file diff --git a/me/cocoa/English.lproj/Details.nib/info.nib b/me/cocoa/English.lproj/Details.nib/info.nib new file mode 100644 index 00000000..3f14ee77 --- /dev/null +++ b/me/cocoa/English.lproj/Details.nib/info.nib @@ -0,0 +1,16 @@ + + + + + IBDocumentLocation + 432 54 356 240 0 0 1024 746 + IBFramework Version + 443.0 + IBOpenObjects + + 5 + + IBSystem Version + 8I127 + + diff --git a/me/cocoa/English.lproj/Details.nib/keyedobjects.nib b/me/cocoa/English.lproj/Details.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..e50621e9b70e67ef9ab1837bad20b6e5a12a053a GIT binary patch literal 6122 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 + + + + IBClasses + + + CLASS + FirstResponder + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + addiTunes + id + askForDirectory + id + changeDirectoryState + id + popupAddDirectoryMenu + id + removeSelectedDirectory + id + toggleVisible + id + + CLASS + DirectoryPanel + LANGUAGE + ObjC + OUTLETS + + addButtonPopUp + NSPopUpButton + directories + NSOutlineView + removeButton + NSButton + + SUPERCLASS + DirectoryPanelBase + + + CLASS + OutlineView + LANGUAGE + ObjC + OUTLETS + + py + PyApp + + SUPERCLASS + NSOutlineView + + + IBVersion + 1 + + diff --git a/me/cocoa/English.lproj/Directories.nib/info.nib b/me/cocoa/English.lproj/Directories.nib/info.nib new file mode 100644 index 00000000..77f19ce7 --- /dev/null +++ b/me/cocoa/English.lproj/Directories.nib/info.nib @@ -0,0 +1,20 @@ + + + + + IBFramework Version + 629 + IBLastKnownRelativeProjectPath + ../../dupeguru.xcodeproj + IBOldestOS + 5 + IBOpenObjects + + 5 + + IBSystem Version + 9B18 + targetFramework + IBCocoaFramework + + diff --git a/me/cocoa/English.lproj/Directories.nib/keyedobjects.nib b/me/cocoa/English.lproj/Directories.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..c541159e828cdd61d88e04c1cd76746986f25db6 GIT binary patch literal 9698 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 literal 0 HcmV?d00001 diff --git a/me/cocoa/English.lproj/InfoPlist.strings b/me/cocoa/English.lproj/InfoPlist.strings new file mode 100644 index 0000000000000000000000000000000000000000..b0430081705b43f55219aa5ab386484da1f3f892 GIT binary patch literal 204 zcmW-bOA5k35Cv=PDT2!&M%;ffpr(+TGdEs?=PZk+ZTdy;YuvTm#?IsUD@Dfr?4!S!+@j+&G(iVdFkCH)E@- sWNyM$M`QkueaM)Z)8}np$TiZrR3ZKUPa59uc!XWaKyA#(n&_JH1B0<7GXMYp literal 0 HcmV?d00001 diff --git a/me/cocoa/English.lproj/MainMenu.nib/classes.nib b/me/cocoa/English.lproj/MainMenu.nib/classes.nib new file mode 100644 index 00000000..b3d34484 --- /dev/null +++ b/me/cocoa/English.lproj/MainMenu.nib/classes.nib @@ -0,0 +1,257 @@ + + + + + IBClasses + + + CLASS + NSSegmentedControl + LANGUAGE + ObjC + SUPERCLASS + NSControl + + + ACTIONS + + openWebsite + id + popupPresets + id + toggleDirectories + id + unlockApp + id + usePreset + id + + CLASS + AppDelegate + LANGUAGE + ObjC + OUTLETS + + defaultsController + NSUserDefaultsController + presetsButton + NSButton + presetsPopup + NSPopUpButton + py + PyDupeGuru + recentDirectories + RecentDirectories + result + ResultWindow + unlockMenuItem + NSMenuItem + + SUPERCLASS + AppDelegateBase + + + CLASS + PyApp + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + CLASS + MatchesView + LANGUAGE + ObjC + SUPERCLASS + OutlineView + + + CLASS + PyDupeGuruBase + LANGUAGE + ObjC + SUPERCLASS + PyApp + + + CLASS + PyDupeGuru + LANGUAGE + ObjC + SUPERCLASS + PyDupeGuruBase + + + ACTIONS + + changeDelta + id + changePowerMarker + id + clearIgnoreList + id + collapseAll + id + copyMarked + id + deleteMarked + id + expandAll + id + exportToXHTML + id + filter + id + ignoreSelected + id + markAll + id + markInvert + id + markNone + id + markSelected + id + markToggle + id + moveMarked + id + openSelected + id + refresh + id + removeDeadTracks + id + removeMarked + id + removeSelected + id + renameSelected + id + resetColumnsToDefault + id + revealSelected + id + showPreferencesPanel + id + startDuplicateScan + id + switchSelected + id + toggleColumn + id + toggleDelta + id + toggleDetailsPanel + id + togglePowerMarker + id + + CLASS + ResultWindow + LANGUAGE + ObjC + OUTLETS + + actionMenu + NSPopUpButton + actionMenuView + NSView + app + id + columnsMenu + NSMenu + deltaSwitch + NSSegmentedControl + deltaSwitchView + NSView + filterField + NSSearchField + filterFieldView + NSView + matches + MatchesView + pmSwitch + NSSegmentedControl + pmSwitchView + NSView + preferencesPanel + NSWindow + py + PyDupeGuru + stats + NSTextField + + SUPERCLASS + ResultWindowBase + + + CLASS + FirstResponder + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + checkForUpdates + id + + CLASS + SUUpdater + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + clearMenu + id + menuClick + id + + CLASS + RecentDirectories + LANGUAGE + ObjC + OUTLETS + + delegate + id + menu + NSMenu + + SUPERCLASS + NSObject + + + CLASS + ResultWindowBase + LANGUAGE + ObjC + SUPERCLASS + NSWindowController + + + CLASS + OutlineView + LANGUAGE + ObjC + OUTLETS + + py + PyApp + + SUPERCLASS + NSOutlineView + + + IBVersion + 1 + + diff --git a/me/cocoa/English.lproj/MainMenu.nib/info.nib b/me/cocoa/English.lproj/MainMenu.nib/info.nib new file mode 100644 index 00000000..7cd1eddb --- /dev/null +++ b/me/cocoa/English.lproj/MainMenu.nib/info.nib @@ -0,0 +1,20 @@ + + + + + IBFramework Version + 629 + IBLastKnownRelativeProjectPath + ../../dupeguru.xcodeproj + IBOldestOS + 5 + IBOpenObjects + + 598 + + IBSystem Version + 9E17 + targetFramework + IBCocoaFramework + + diff --git a/me/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib b/me/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..649ce170df6fbe0c363a3f9127126dc86a6a10ef GIT binary patch literal 57645 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 literal 0 HcmV?d00001 diff --git a/me/cocoa/Info.plist b/me/cocoa/Info.plist new file mode 100644 index 00000000..97d43767 --- /dev/null +++ b/me/cocoa/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleHelpBookFolder + dupeguru_me_help + CFBundleHelpBookName + dupeGuru ME Help + CFBundleIconFile + dupeguru + CFBundleIdentifier + com.hardcoded_software.dupeguru_me + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleSignature + hsft + CFBundleVersion + 5.6.1 + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + SUFeedURL + http://www.hardcoded.net/updates/dupeguru_me.appcast + + diff --git a/me/cocoa/PyDupeGuru.h b/me/cocoa/PyDupeGuru.h new file mode 100644 index 00000000..04dfce20 --- /dev/null +++ b/me/cocoa/PyDupeGuru.h @@ -0,0 +1,15 @@ +#import +#import "dgbase/PyDupeGuru.h" + +@interface PyDupeGuru : PyDupeGuruBase +//Scanning options +- (void)setScanType:(NSNumber *)scan_type; +- (void)setMinWordCount:(NSNumber *)word_count; +- (void)setMinWordLength:(NSNumber *)word_length; +- (void)setWordWeighting:(NSNumber *)words_are_weighted; +- (void)setMatchSimilarWords:(NSNumber *)match_similar_words; +- (void)enable:(NSNumber *)enable scanForTag:(NSString *)tag; +- (void)scanDeadTracks; +- (void)removeDeadTracks; +- (int)deadTrackCount; +@end diff --git a/me/cocoa/ResultWindow.h b/me/cocoa/ResultWindow.h new file mode 100644 index 00000000..e421b99d --- /dev/null +++ b/me/cocoa/ResultWindow.h @@ -0,0 +1,56 @@ +#import +#import "cocoalib/Outline.h" +#import "dgbase/ResultWindow.h" +#import "DetailsPanel.h" +#import "DirectoryPanel.h" + +@interface ResultWindow : ResultWindowBase +{ + IBOutlet NSPopUpButton *actionMenu; + IBOutlet NSMenu *columnsMenu; + IBOutlet NSSearchField *filterField; + IBOutlet NSSegmentedControl *pmSwitch; + IBOutlet NSWindow *preferencesPanel; + IBOutlet NSTextField *stats; + + NSString *_lastAction; + DetailsPanel *_detailsPanel; + NSMutableArray *_resultColumns; + NSMutableIndexSet *_deltaColumns; +} +- (IBAction)changePowerMarker:(id)sender; +- (IBAction)clearIgnoreList:(id)sender; +- (IBAction)exportToXHTML:(id)sender; +- (IBAction)filter:(id)sender; +- (IBAction)ignoreSelected:(id)sender; +- (IBAction)markAll:(id)sender; +- (IBAction)markInvert:(id)sender; +- (IBAction)markNone:(id)sender; +- (IBAction)markSelected:(id)sender; +- (IBAction)markToggle:(id)sender; +- (IBAction)openSelected:(id)sender; +- (IBAction)refresh:(id)sender; +- (IBAction)removeDeadTracks:(id)sender; +- (IBAction)removeMarked:(id)sender; +- (IBAction)removeSelected:(id)sender; +- (IBAction)renameSelected:(id)sender; +- (IBAction)resetColumnsToDefault:(id)sender; +- (IBAction)revealSelected:(id)sender; +- (IBAction)showPreferencesPanel:(id)sender; +- (IBAction)startDuplicateScan:(id)sender; +- (IBAction)switchSelected:(id)sender; +- (IBAction)toggleColumn:(id)sender; +- (IBAction)toggleDelta:(id)sender; +- (IBAction)toggleDetailsPanel:(id)sender; +- (IBAction)togglePowerMarker:(id)sender; + +- (NSTableColumn *)getColumnForIdentifier:(int)aIdentifier title:(NSString *)aTitle width:(int)aWidth refCol:(NSTableColumn *)aColumn; +- (NSArray *)getColumnsOrder; +- (NSDictionary *)getColumnsWidth; +- (NSArray *)getSelected:(BOOL)aDupesOnly; +- (NSArray *)getSelectedPaths:(BOOL)aDupesOnly; +- (void)initResultColumns; +- (void)performPySelection:(NSArray *)aIndexPaths; +- (void)refreshStats; +- (void)restoreColumnsPosition:(NSArray *)aColumnsOrder widths:(NSDictionary *)aColumnsWidth; +@end diff --git a/me/cocoa/ResultWindow.m b/me/cocoa/ResultWindow.m new file mode 100644 index 00000000..540216e3 --- /dev/null +++ b/me/cocoa/ResultWindow.m @@ -0,0 +1,505 @@ +#import "ResultWindow.h" +#import "cocoalib/Dialogs.h" +#import "cocoalib/ProgressController.h" +#import "cocoalib/RegistrationInterface.h" +#import "cocoalib/Utils.h" +#import "AppDelegate.h" +#import "Consts.h" + +@implementation ResultWindow +/* Override */ +- (void)awakeFromNib +{ + [super awakeFromNib]; + _detailsPanel = nil; + _displayDelta = NO; + _powerMode = NO; + _deltaColumns = [[NSMutableIndexSet indexSetWithIndexesInRange:NSMakeRange(2,7)] retain]; + [_deltaColumns removeIndex:6]; + [deltaSwitch setSelectedSegment:0]; + [pmSwitch setSelectedSegment:0]; + [py setDisplayDeltaValues:b2n(_displayDelta)]; + [matches setTarget:self]; + [matches setDoubleAction:@selector(openSelected:)]; + [[actionMenu itemAtIndex:0] setImage:[NSImage imageNamed: @"gear"]]; + [self initResultColumns]; + [self refreshStats]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(resultsMarkingChanged:) name:ResultsMarkingChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(duplicateSelectionChanged:) name:DuplicateSelectionChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(resultsChanged:) name:ResultsChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jobCompleted:) name:JobCompletedNotification object:nil]; + + NSToolbar *t = [[[NSToolbar alloc] initWithIdentifier:@"ResultWindowToolbar"] autorelease]; + [t setAllowsUserCustomization:YES]; + [t setAutosavesConfiguration:YES]; + [t setDisplayMode:NSToolbarDisplayModeIconAndLabel]; + [t setDelegate:self]; + [[self window] setToolbar:t]; +} + +/* Overrides */ +- (NSString *)logoImageName +{ + return @"dgme_logo32"; +} + +/* Actions */ + +- (IBAction)changePowerMarker:(id)sender +{ + _powerMode = [pmSwitch selectedSegment] == 1; + if (_powerMode) + [matches setTag:2]; + else + [matches setTag:0]; + [self expandAll:nil]; + [self outlineView:matches didClickTableColumn:nil]; +} + +- (IBAction)clearIgnoreList:(id)sender +{ + int i = n2i([py getIgnoreListCount]); + if (!i) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"Do you really want to remove all %d items from the ignore list?",i]] == NSAlertSecondButtonReturn) // NO + return; + [py clearIgnoreList]; +} + +- (IBAction)exportToXHTML:(id)sender +{ + NSString *xsltPath = [[NSBundle mainBundle] pathForResource:@"dg" ofType:@"xsl"]; + NSString *cssPath = [[NSBundle mainBundle] pathForResource:@"hardcoded" ofType:@"css"]; + NSString *exported = [py exportToXHTMLwithColumns:[self getColumnsOrder] xslt:xsltPath css:cssPath]; + [[NSWorkspace sharedWorkspace] openFile:exported]; +} + +- (IBAction)filter:(id)sender +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [py setEscapeFilterRegexp:b2n(!n2b([ud objectForKey:@"useRegexpFilter"]))]; + [py applyFilter:[filterField stringValue]]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)ignoreSelected:(id)sender +{ + NSArray *nodeList = [self getSelected:YES]; + if (![nodeList count]) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"All selected %d matches are going to be ignored in all subsequent scans. Continue?",[nodeList count]]] == NSAlertSecondButtonReturn) // NO + return; + [self performPySelection:[self getSelectedPaths:YES]]; + [py addSelectedToIgnoreList]; + [py removeSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)markAll:(id)sender +{ + [py markAll]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markInvert:(id)sender +{ + [py markInvert]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markNone:(id)sender +{ + [py markNone]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:YES]]; + [py toggleSelectedMark]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markToggle:(id)sender +{ + OVNode *node = [matches itemAtRow:[matches clickedRow]]; + [self performPySelection:[NSArray arrayWithObject:p2a([node indexPath])]]; + [py toggleSelectedMark]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)openSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py openSelected]; +} + +- (IBAction)refresh:(id)sender +{ + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)removeDeadTracks:(id)sender +{ + [(PyDupeGuru *)py scanDeadTracks]; +} + +- (IBAction)removeMarked:(id)sender +{ + int mark_count = [[py getMarkCount] intValue]; + if (!mark_count) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"You are about to remove %d files from results. Continue?",mark_count]] == NSAlertSecondButtonReturn) // NO + return; + [py removeMarked]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)removeSelected:(id)sender +{ + NSArray *nodeList = [self getSelected:YES]; + if (![nodeList count]) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"You are about to remove %d files from results. Continue?",[nodeList count]]] == NSAlertSecondButtonReturn) // NO + return; + [self performPySelection:[self getSelectedPaths:YES]]; + [py removeSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)renameSelected:(id)sender +{ + int col = [matches columnWithIdentifier:@"0"]; + int row = [matches selectedRow]; + [matches editColumn:col row:row withEvent:[NSApp currentEvent] select:YES]; +} + +- (IBAction)resetColumnsToDefault:(id)sender +{ + NSMutableArray *columnsOrder = [NSMutableArray array]; + [columnsOrder addObject:@"0"]; + [columnsOrder addObject:@"2"]; + [columnsOrder addObject:@"3"]; + [columnsOrder addObject:@"4"]; + [columnsOrder addObject:@"16"]; + NSMutableDictionary *columnsWidth = [NSMutableDictionary dictionary]; + [columnsWidth setObject:i2n(214) forKey:@"0"]; + [columnsWidth setObject:i2n(63) forKey:@"2"]; + [columnsWidth setObject:i2n(50) forKey:@"3"]; + [columnsWidth setObject:i2n(50) forKey:@"4"]; + [columnsWidth setObject:i2n(57) forKey:@"16"]; + [self restoreColumnsPosition:columnsOrder widths:columnsWidth]; +} + +- (IBAction)revealSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py revealSelected]; +} + +- (IBAction)showPreferencesPanel:(id)sender +{ + [preferencesPanel makeKeyAndOrderFront:sender]; +} + +- (IBAction)startDuplicateScan:(id)sender +{ + if ([matches numberOfRows] > 0) + { + if ([Dialogs askYesNo:@"Are you sure you want to start a new duplicate scan?"] == NSAlertSecondButtonReturn) // NO + return; + } + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + PyDupeGuru *_py = (PyDupeGuru *)py; + [_py setScanType:[ud objectForKey:@"scanType"]]; + [_py enable:[ud objectForKey:@"scanTagTrack"] scanForTag:@"track"]; + [_py enable:[ud objectForKey:@"scanTagArtist"] scanForTag:@"artist"]; + [_py enable:[ud objectForKey:@"scanTagAlbum"] scanForTag:@"album"]; + [_py enable:[ud objectForKey:@"scanTagTitle"] scanForTag:@"title"]; + [_py enable:[ud objectForKey:@"scanTagGenre"] scanForTag:@"genre"]; + [_py enable:[ud objectForKey:@"scanTagYear"] scanForTag:@"year"]; + [_py setMinMatchPercentage:[ud objectForKey:@"minMatchPercentage"]]; + [_py setWordWeighting:[ud objectForKey:@"wordWeighting"]]; + [_py setMixFileKind:[ud objectForKey:@"mixFileKind"]]; + [_py setMatchSimilarWords:[ud objectForKey:@"matchSimilarWords"]]; + int r = n2i([py doScan]); + [matches reloadData]; + [self refreshStats]; + if (r == 1) + [Dialogs showMessage:@"You cannot make a duplicate scan with only reference directories."]; + if (r == 3) + { + [Dialogs showMessage:@"The selected directories contain no scannable file."]; + [app toggleDirectories:nil]; + } +} + +- (IBAction)switchSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:YES]]; + [py makeSelectedReference]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)toggleColumn:(id)sender +{ + NSMenuItem *mi = sender; + NSString *colId = [NSString stringWithFormat:@"%d",[mi tag]]; + NSTableColumn *col = [matches tableColumnWithIdentifier:colId]; + if (col == nil) + { + //Add Column + col = [_resultColumns objectAtIndex:[mi tag]]; + [matches addTableColumn:col]; + [mi setState:NSOnState]; + } + else + { + //Remove column + [matches removeTableColumn:col]; + [mi setState:NSOffState]; + } +} + +- (IBAction)toggleDelta:(id)sender +{ + if ([deltaSwitch selectedSegment] == 1) + [deltaSwitch setSelectedSegment:0]; + else + [deltaSwitch setSelectedSegment:1]; + [self changeDelta:sender]; +} + +- (IBAction)toggleDetailsPanel:(id)sender +{ + if (!_detailsPanel) + _detailsPanel = [[DetailsPanel alloc] initWithPy:py]; + if ([[_detailsPanel window] isVisible]) + [[_detailsPanel window] close]; + else + [[_detailsPanel window] orderFront:nil]; +} + +- (IBAction)togglePowerMarker:(id)sender +{ + if ([pmSwitch selectedSegment] == 1) + [pmSwitch setSelectedSegment:0]; + else + [pmSwitch setSelectedSegment:1]; + [self changePowerMarker:sender]; +} + +/* Public */ +- (NSTableColumn *)getColumnForIdentifier:(int)aIdentifier title:(NSString *)aTitle width:(int)aWidth refCol:(NSTableColumn *)aColumn +{ + NSNumber *n = [NSNumber numberWithInt:aIdentifier]; + NSTableColumn *col = [[NSTableColumn alloc] initWithIdentifier:[n stringValue]]; + [col setWidth:aWidth]; + [col setEditable:NO]; + [[col dataCell] setFont:[[aColumn dataCell] font]]; + [[col headerCell] setStringValue:aTitle]; + [col setResizingMask:NSTableColumnUserResizingMask]; + [col setSortDescriptorPrototype:[[NSSortDescriptor alloc] initWithKey:[n stringValue] ascending:YES]]; + return col; +} + +//Returns an array of identifiers, in order. +- (NSArray *)getColumnsOrder +{ + NSTableColumn *col; + NSString *colId; + NSMutableArray *result = [NSMutableArray array]; + NSEnumerator *e = [[matches tableColumns] objectEnumerator]; + while (col = [e nextObject]) + { + colId = [col identifier]; + [result addObject:colId]; + } + return result; +} + +- (NSDictionary *)getColumnsWidth +{ + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + NSTableColumn *col; + NSString *colId; + NSNumber *width; + NSEnumerator *e = [[matches tableColumns] objectEnumerator]; + while (col = [e nextObject]) + { + colId = [col identifier]; + width = [NSNumber numberWithFloat:[col width]]; + [result setObject:width forKey:colId]; + } + return result; +} + +- (NSArray *)getSelected:(BOOL)aDupesOnly +{ + if (_powerMode) + aDupesOnly = NO; + NSIndexSet *indexes = [matches selectedRowIndexes]; + NSMutableArray *nodeList = [NSMutableArray array]; + OVNode *node; + int i = [indexes firstIndex]; + while (i != NSNotFound) + { + node = [matches itemAtRow:i]; + if (!aDupesOnly || ([node level] > 1)) + [nodeList addObject:node]; + i = [indexes indexGreaterThanIndex:i]; + } + return nodeList; +} + +- (NSArray *)getSelectedPaths:(BOOL)aDupesOnly +{ + NSMutableArray *r = [NSMutableArray array]; + NSArray *selected = [self getSelected:aDupesOnly]; + NSEnumerator *e = [selected objectEnumerator]; + OVNode *node; + while (node = [e nextObject]) + [r addObject:p2a([node indexPath])]; + return r; +} + +- (void)performPySelection:(NSArray *)aIndexPaths +{ + if (_powerMode) + [py selectPowerMarkerNodePaths:aIndexPaths]; + else + [py selectResultNodePaths:aIndexPaths]; +} + +- (void)initResultColumns +{ + NSTableColumn *refCol = [matches tableColumnWithIdentifier:@"0"]; + _resultColumns = [[NSMutableArray alloc] init]; + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"0"]]; // File Name + [_resultColumns addObject:[self getColumnForIdentifier:1 title:@"Directory" width:120 refCol:refCol]]; + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"2"]]; // Size + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"3"]]; // Time + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"4"]]; // Bitrate + [_resultColumns addObject:[self getColumnForIdentifier:5 title:@"Sample Rate" width:60 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:6 title:@"Kind" width:40 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:7 title:@"Creation" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:8 title:@"Modification" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:9 title:@"Title" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:10 title:@"Artist" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:11 title:@"Album" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:12 title:@"Genre" width:80 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:13 title:@"Year" width:40 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:14 title:@"Track Number" width:40 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:15 title:@"Comment" width:120 refCol:refCol]]; + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"16"]]; // Match % + [_resultColumns addObject:[self getColumnForIdentifier:17 title:@"Words Used" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:18 title:@"Dupe Count" width:80 refCol:refCol]]; +} + +-(void)refreshStats +{ + [stats setStringValue:[py getStatLine]]; +} + +- (void)restoreColumnsPosition:(NSArray *)aColumnsOrder widths:(NSDictionary *)aColumnsWidth +{ + NSTableColumn *col; + NSString *colId; + NSNumber *width; + NSMenuItem *mi; + //Remove all columns + NSEnumerator *e = [[columnsMenu itemArray] objectEnumerator]; + while (mi = [e nextObject]) + { + if ([mi state] == NSOnState) + [self toggleColumn:mi]; + } + //Add columns and set widths + e = [aColumnsOrder objectEnumerator]; + while (colId = [e nextObject]) + { + if (![colId isEqual:@"mark"]) + { + col = [_resultColumns objectAtIndex:[colId intValue]]; + width = [aColumnsWidth objectForKey:[col identifier]]; + mi = [columnsMenu itemWithTag:[colId intValue]]; + if (width) + [col setWidth:[width floatValue]]; + [self toggleColumn:mi]; + } + } +} + +/* Delegate */ +- (void)outlineView:(NSOutlineView *)outlineView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)tableColumn item:(id)item +{ + OVNode *node = item; + if ([[tableColumn identifier] isEqual:@"mark"]) + { + [cell setEnabled: [node isMarkable]]; + } + if ([cell isKindOfClass:[NSTextFieldCell class]]) + { + // Determine if the text color will be blue due to directory being reference. + NSTextFieldCell *textCell = cell; + if ([node isMarkable]) + [textCell setTextColor:[NSColor blackColor]]; + else + [textCell setTextColor:[NSColor blueColor]]; + if ((_displayDelta) && (_powerMode || ([node level] > 1))) + { + int i = [[tableColumn identifier] intValue]; + if ([_deltaColumns containsIndex:i]) + [textCell setTextColor:[NSColor orangeColor]]; + } + } +} + +/* Notifications */ +- (void)duplicateSelectionChanged:(NSNotification *)aNotification +{ + if (_detailsPanel) + [_detailsPanel refresh]; +} + +- (void)jobCompleted:(NSNotification *)aNotification +{ + [super jobCompleted:aNotification]; + id lastAction = [[ProgressController mainProgressController] jobId]; + if ([lastAction isEqualTo:jobScanDeadTracks]) + { + int deadTrackCount = [(PyDupeGuru *)py deadTrackCount]; + if (deadTrackCount > 0) + { + NSString *msg = @"Your iTunes Library contains %d dead tracks ready to be removed. Continue?"; + if ([Dialogs askYesNo:[NSString stringWithFormat:msg,deadTrackCount]] == NSAlertFirstButtonReturn) + [(PyDupeGuru *)py removeDeadTracks]; + } + else + { + [Dialogs showMessage:@"You have no dead tracks in your iTunes Library"]; + } + } +} + +- (void)outlineViewSelectionDidChange:(NSNotification *)notification +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py refreshDetailsWithSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:DuplicateSelectionChangedNotification object:self]; +} + +- (void)resultsChanged:(NSNotification *)aNotification +{ + [matches reloadData]; + [self expandAll:nil]; + [self outlineViewSelectionDidChange:nil]; + [self refreshStats]; +} + +- (void)resultsMarkingChanged:(NSNotification *)aNotification +{ + [matches invalidateMarkings]; + [self refreshStats]; +} +@end diff --git a/me/cocoa/dupeguru.icns b/me/cocoa/dupeguru.icns new file mode 100755 index 0000000000000000000000000000000000000000..42f8641e50bfec321f2ce0facc5cdf11c2922bc2 GIT binary patch literal 58956 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�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 + + + + ActivePerspectiveName + Project + AllowedModules + + + BundleLoadPath + + MaxInstances + n + Module + PBXSmartGroupTreeModule + Name + Groups and Files Outline View + + + BundleLoadPath + + MaxInstances + n + Module + PBXNavigatorGroup + Name + Editor + + + BundleLoadPath + + MaxInstances + n + Module + XCTaskListModule + Name + Task List + + + BundleLoadPath + + MaxInstances + n + Module + XCDetailModule + Name + File and Smart Group Detail Viewer + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXBuildResultsModule + Name + Detailed Build Results Viewer + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXProjectFindModule + Name + Project Batch Find Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXRunSessionModule + Name + Run Log + + + BundleLoadPath + + MaxInstances + n + Module + PBXBookmarksModule + Name + Bookmarks Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXClassBrowserModule + Name + Class Browser + + + BundleLoadPath + + MaxInstances + n + Module + PBXCVSModule + Name + Source Code Control Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXDebugBreakpointsModule + Name + Debug Breakpoints Tool + + + BundleLoadPath + + MaxInstances + n + Module + XCDockableInspector + Name + Inspector + + + BundleLoadPath + + MaxInstances + n + Module + PBXOpenQuicklyModule + Name + Open Quickly Tool + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXDebugSessionModule + Name + Debugger + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXDebugCLIModule + Name + Debug Console + + + Description + DefaultDescriptionKey + DockingSystemVisible + + Extension + mode1 + FavBarConfig + + PBXProjectModuleGUID + CE381CB409914B41003581CE + XCBarModuleItemNames + + XCBarModuleItems + + + FirstTimeWindowDisplayed + + Identifier + com.apple.perspectives.project.mode1 + MajorVersion + 31 + MinorVersion + 1 + Name + Default + Notifications + + OpenEditors + + PerspectiveWidths + + -1 + -1 + + Perspectives + + + ChosenToolbarItems + + active-executable-popup + action + active-buildstyle-popup + active-target-popup + buildOrClean + build-and-runOrDebug + com.apple.ide.PBXToolbarStopButton + get-info + toggle-editor + + ControllerClassBaseName + + IconName + WindowOfProjectWithEditor + Identifier + perspective.project + IsVertical + + Layout + + + BecomeActive + + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C37FBAC04509CD000000102 + 1C37FAAC04509CD000000102 + 1C08E77C0454961000C914BD + 1C37FABC05509CD000000102 + 1C37FABC05539CD112110102 + E2644B35053B69B200211256 + 1C37FABC04509CD000100104 + 1CC0EA4004350EF90044410B + 1CC0EA4004350EF90041110B + + PBXProjectModuleGUID + 1CE0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + yes + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 194 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 29B97314FDCFA39411CA2CEA + 080E96DDFE201D6D7F000001 + 29B97315FDCFA39411CA2CEA + 29B97317FDCFA39411CA2CEA + 29B97323FDCFA39411CA2CEA + 1058C7A0FEA54F0111CA2CBB + CE1425880AFB718500BD5167 + 19C28FACFE9D520D11CA2CBB + 1C37FBAC04509CD000000102 + 1C37FABC05509CD000000102 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 37 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {194, 764}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + + XCSharingToken + com.apple.Xcode.GFSharingToken + + GeometryConfiguration + + Frame + {{0, 0}, {211, 782}} + GroupTreeTableConfiguration + + MainColumn + 194 + + RubberWindowFrame + 0 55 1372 823 0 0 1440 878 + + Module + PBXSmartGroupTreeModule + Proportion + 211pt + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CE0B20306471E060097A5F4 + PBXProjectModuleLabel + Info.plist + PBXSplitModuleInNavigatorKey + + Split0 + + PBXProjectModuleGUID + 1CE0B20406471E060097A5F4 + PBXProjectModuleLabel + Info.plist + _historyCapacity + 10 + bookmark + CE6D39470C9B111800C7FE6C + history + + CEEEF20D0BAD825F00F7AD7F + CEEEF3350BAD8AC700F7AD7F + CE7E210C0BB5B7DD00C69A50 + CE7E21360BB5BD8200C69A50 + CEE301FD0BF73B1900D6840C + CEE301FE0BF73B1900D6840C + CEE301FF0BF73B1900D6840C + CEE302000BF73B1900D6840C + CE6D38650C9B0E1A00C7FE6C + CE6D38680C9B0E1A00C7FE6C + + prevStack + + CE2CB4DA09AE70AA0015538F + CE9DA31409E03DC700B0AAC8 + CE962FD809E1A2310049C9D7 + CECD332A09FEDD9D00964507 + CEF3113D0A06AA42002EC022 + CE2AFF3E0A07838000443588 + CEEEF2130BAD825F00F7AD7F + CE6A176E0BB5A8310090A314 + CE6A176F0BB5A8310090A314 + + + SplitCount + 1 + + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {1156, 544}} + RubberWindowFrame + 0 55 1372 823 0 0 1440 878 + + Module + PBXNavigatorGroup + Proportion + 544pt + + + ContentConfiguration + + PBXProjectModuleGUID + 1CE0B20506471E060097A5F4 + PBXProjectModuleLabel + Detail + + GeometryConfiguration + + Frame + {{0, 549}, {1156, 233}} + RubberWindowFrame + 0 55 1372 823 0 0 1440 878 + + Module + XCDetailModule + Proportion + 233pt + + + Proportion + 1156pt + + + Name + Project + ServiceClasses + + XCModuleDock + PBXSmartGroupTreeModule + XCModuleDock + PBXNavigatorGroup + XCDetailModule + + TableOfContents + + CE6D39480C9B111800C7FE6C + 1CE0B1FE06471DED0097A5F4 + CE6D39490C9B111800C7FE6C + 1CE0B20306471E060097A5F4 + 1CE0B20506471E060097A5F4 + + ToolbarConfiguration + xcode.toolbar.config.default + + + ControllerClassBaseName + + IconName + WindowOfProject + Identifier + perspective.morph + IsVertical + 0 + Layout + + + BecomeActive + 1 + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C37FBAC04509CD000000102 + 1C37FAAC04509CD000000102 + 1C08E77C0454961000C914BD + 1C37FABC05509CD000000102 + 1C37FABC05539CD112110102 + E2644B35053B69B200211256 + 1C37FABC04509CD000100104 + 1CC0EA4004350EF90044410B + 1CC0EA4004350EF90041110B + + PBXProjectModuleGUID + 11E0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + yes + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 186 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 29B97314FDCFA39411CA2CEA + 1C37FABC05509CD000000102 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 0 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {186, 337}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + 1 + XCSharingToken + com.apple.Xcode.GFSharingToken + + GeometryConfiguration + + Frame + {{0, 0}, {203, 355}} + GroupTreeTableConfiguration + + MainColumn + 186 + + RubberWindowFrame + 373 269 690 397 0 0 1440 878 + + Module + PBXSmartGroupTreeModule + Proportion + 100% + + + Name + Morph + PreferredWidth + 300 + ServiceClasses + + XCModuleDock + PBXSmartGroupTreeModule + + TableOfContents + + 11E0B1FE06471DED0097A5F4 + + ToolbarConfiguration + xcode.toolbar.config.default.short + + + PerspectivesBarVisible + + ShelfIsVisible + + SourceDescription + file at '/System/Library/PrivateFrameworks/DevToolsInterface.framework/Versions/A/Resources/XCPerspectivesSpecificationMode1.xcperspec' + StatusbarIsVisible + + TimeStamp + 0.0 + ToolbarDisplayMode + 1 + ToolbarIsVisible + + ToolbarSizeMode + 1 + Type + Perspectives + UpdateMessage + The Default Workspace in this version of Xcode now includes support to hide and show the detail view (what has been referred to as the "Metro-Morph" feature). You must discard your current Default Workspace settings and update to the latest Default Workspace in order to gain this feature. Do you wish to update to the latest Workspace defaults for project '%@'? + WindowJustification + 5 + WindowOrderList + + /Users/hsoft/src/dupeguru_me_cocoa/dupeguru.xcodeproj + + WindowString + 0 55 1372 823 0 0 1440 878 + WindowTools + + + FirstTimeWindowDisplayed + + Identifier + windowTool.build + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CD0528F0623707200166675 + PBXProjectModuleLabel + + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {1024, 404}} + RubberWindowFrame + 289 192 1024 686 0 0 1440 878 + + Module + PBXNavigatorGroup + Proportion + 404pt + + + BecomeActive + + ContentConfiguration + + PBXProjectModuleGUID + XCMainBuildResultsModuleGUID + PBXProjectModuleLabel + Build + XCBuildResultsTrigger_Collapse + 1021 + XCBuildResultsTrigger_Open + 1011 + + GeometryConfiguration + + Frame + {{0, 409}, {1024, 236}} + RubberWindowFrame + 289 192 1024 686 0 0 1440 878 + + Module + PBXBuildResultsModule + Proportion + 236pt + + + Proportion + 645pt + + + Name + Build Results + ServiceClasses + + PBXBuildResultsModule + + StatusbarIsVisible + + TableOfContents + + CE381CCE09914BC8003581CE + CE6D38450C9B0D2500C7FE6C + 1CD0528F0623707200166675 + XCMainBuildResultsModuleGUID + + ToolbarConfiguration + xcode.toolbar.config.build + WindowString + 289 192 1024 686 0 0 1440 878 + WindowToolGUID + CE381CCE09914BC8003581CE + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.debugger + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + Debugger + + HorizontalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {150, 322}} + {{150, 0}, {874, 322}} + + + VerticalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {1024, 322}} + {{0, 322}, {1024, 323}} + + + + LauncherConfigVersion + 8 + PBXProjectModuleGUID + 1C162984064C10D400B95A72 + PBXProjectModuleLabel + Debug - GLUTExamples (Underwater) + + GeometryConfiguration + + DebugConsoleDrawerSize + {100, 120} + DebugConsoleVisible + None + DebugConsoleWindowFrame + {{200, 200}, {500, 300}} + DebugSTDIOWindowFrame + {{200, 200}, {500, 300}} + Frame + {{0, 0}, {1024, 645}} + RubberWindowFrame + 348 192 1024 686 0 0 1440 878 + + Module + PBXDebugSessionModule + Proportion + 645pt + + + Proportion + 645pt + + + Name + Debugger + ServiceClasses + + PBXDebugSessionModule + + StatusbarIsVisible + + TableOfContents + + 1CD10A99069EF8BA00B06720 + CE7E21210BB5BCA400C69A50 + 1C162984064C10D400B95A72 + CE7E21220BB5BCA400C69A50 + CE7E21230BB5BCA400C69A50 + CE7E21240BB5BCA400C69A50 + CE7E21250BB5BCA400C69A50 + CE7E21260BB5BCA400C69A50 + CE7E21270BB5BCA400C69A50 + + ToolbarConfiguration + xcode.toolbar.config.debug + WindowString + 348 192 1024 686 0 0 1440 878 + WindowToolGUID + 1CD10A99069EF8BA00B06720 + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.find + IsVertical + + Layout + + + Dock + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CDD528C0622207200134675 + PBXProjectModuleLabel + ResultWindow.m + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {1377, 620}} + RubberWindowFrame + 0 0 1377 878 0 0 1440 878 + + Module + PBXNavigatorGroup + Proportion + 1377pt + + + Proportion + 620pt + + + BecomeActive + + ContentConfiguration + + PBXProjectModuleGUID + 1CD0528E0623707200166675 + PBXProjectModuleLabel + Project Find + + GeometryConfiguration + + Frame + {{0, 625}, {1377, 212}} + RubberWindowFrame + 0 0 1377 878 0 0 1440 878 + + Module + PBXProjectFindModule + Proportion + 212pt + + + Proportion + 837pt + + + Name + Project Find + ServiceClasses + + PBXProjectFindModule + + StatusbarIsVisible + + TableOfContents + + 1C530D57069F1CE1000CFCEE + CE6A17790BB5A8310090A314 + CE6A177A0BB5A8310090A314 + 1CDD528C0622207200134675 + 1CD0528E0623707200166675 + + WindowString + 0 0 1377 878 0 0 1440 878 + WindowToolGUID + 1C530D57069F1CE1000CFCEE + WindowToolIsVisible + + + + Identifier + MENUSEPARATOR + + + FirstTimeWindowDisplayed + + Identifier + windowTool.debuggerConsole + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1C78EAAC065D492600B07095 + PBXProjectModuleLabel + Debugger Console + + GeometryConfiguration + + Frame + {{0, 0}, {440, 358}} + RubberWindowFrame + 72 414 440 400 0 0 1440 878 + + Module + PBXDebugCLIModule + Proportion + 358pt + + + Proportion + 359pt + + + Name + Debugger Console + ServiceClasses + + PBXDebugCLIModule + + StatusbarIsVisible + + TableOfContents + + CECD0ADE099294C1003DC359 + CE7E21280BB5BCA400C69A50 + 1C78EAAC065D492600B07095 + + WindowString + 72 414 440 400 0 0 1440 878 + WindowToolGUID + CECD0ADE099294C1003DC359 + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.run + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + LauncherConfigVersion + 3 + PBXProjectModuleGUID + 1CD0528B0623707200166675 + PBXProjectModuleLabel + Run + Runner + + HorizontalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {367, 168}} + {{0, 173}, {367, 270}} + + + VerticalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {406, 443}} + {{411, 0}, {517, 443}} + + + + + GeometryConfiguration + + Frame + {{0, 0}, {1024, 645}} + RubberWindowFrame + 262 127 1024 686 0 0 1440 878 + + Module + PBXRunSessionModule + Proportion + 645pt + + + Proportion + 645pt + + + Name + Run Log + ServiceClasses + + PBXRunSessionModule + + StatusbarIsVisible + + TableOfContents + + 1C0AD2B3069F1EA900FABCE6 + CECCEDD50BB6B39F00873A67 + 1CD0528B0623707200166675 + CECCEDD60BB6B39F00873A67 + + ToolbarConfiguration + xcode.toolbar.config.run + WindowString + 262 127 1024 686 0 0 1440 878 + WindowToolGUID + 1C0AD2B3069F1EA900FABCE6 + WindowToolIsVisible + + + + Identifier + windowTool.scm + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1C78EAB2065D492600B07095 + PBXProjectModuleLabel + <No Editor> + PBXSplitModuleInNavigatorKey + + Split0 + + PBXProjectModuleGUID + 1C78EAB3065D492600B07095 + + SplitCount + 1 + + StatusBarVisibility + 1 + + GeometryConfiguration + + Frame + {{0, 0}, {452, 0}} + RubberWindowFrame + 743 379 452 308 0 0 1280 1002 + + Module + PBXNavigatorGroup + Proportion + 0pt + + + BecomeActive + 1 + ContentConfiguration + + PBXProjectModuleGUID + 1CD052920623707200166675 + PBXProjectModuleLabel + SCM + + GeometryConfiguration + + ConsoleFrame + {{0, 259}, {452, 0}} + Frame + {{0, 7}, {452, 259}} + RubberWindowFrame + 743 379 452 308 0 0 1280 1002 + TableConfiguration + + Status + 30 + FileName + 199 + Path + 197.09500122070312 + + TableFrame + {{0, 0}, {452, 250}} + + Module + PBXCVSModule + Proportion + 262pt + + + Proportion + 266pt + + + Name + SCM + ServiceClasses + + PBXCVSModule + + StatusbarIsVisible + 1 + TableOfContents + + 1C78EAB4065D492600B07095 + 1C78EAB5065D492600B07095 + 1C78EAB2065D492600B07095 + 1CD052920623707200166675 + + ToolbarConfiguration + xcode.toolbar.config.scm + WindowString + 743 379 452 308 0 0 1280 1002 + + + FirstTimeWindowDisplayed + + Identifier + windowTool.breakpoints + IsVertical + + Layout + + + Dock + + + BecomeActive + + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C77FABC04509CD000000102 + + PBXProjectModuleGUID + 1CE0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + no + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 168 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 1C77FABC04509CD000000102 + 1C3E0DCA080725EA00A55177 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 2 + 0 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {168, 350}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + + + GeometryConfiguration + + Frame + {{0, 0}, {185, 368}} + GroupTreeTableConfiguration + + MainColumn + 168 + + RubberWindowFrame + 52 435 744 409 0 0 1440 878 + + Module + PBXSmartGroupTreeModule + Proportion + 185pt + + + ContentConfiguration + + PBXProjectModuleGUID + 1CA1AED706398EBD00589147 + PBXProjectModuleLabel + Detail + + GeometryConfiguration + + Frame + {{190, 0}, {554, 368}} + RubberWindowFrame + 52 435 744 409 0 0 1440 878 + + Module + XCDetailModule + Proportion + 554pt + + + Proportion + 368pt + + + MajorVersion + 2 + MinorVersion + 0 + Name + Breakpoints + ServiceClasses + + PBXSmartGroupTreeModule + XCDetailModule + + StatusbarIsVisible + + TableOfContents + + CE6B28F20AFB890700508D93 + CE6B28F30AFB890700508D93 + 1CE0B1FE06471DED0097A5F4 + 1CA1AED706398EBD00589147 + + ToolbarConfiguration + xcode.toolbar.config.breakpoints + WindowString + 52 435 744 409 0 0 1440 878 + WindowToolGUID + CE6B28F20AFB890700508D93 + WindowToolIsVisible + + + + Identifier + windowTool.debugAnimator + Layout + + + Dock + + + Module + PBXNavigatorGroup + Proportion + 100% + + + Proportion + 100% + + + Name + Debug Visualizer + ServiceClasses + + PBXNavigatorGroup + + StatusbarIsVisible + 1 + ToolbarConfiguration + xcode.toolbar.config.debugAnimator + WindowString + 100 100 700 500 0 0 1280 1002 + + + Identifier + windowTool.bookmarks + Layout + + + Dock + + + Module + PBXBookmarksModule + Proportion + 100% + + + Proportion + 100% + + + Name + Bookmarks + ServiceClasses + + PBXBookmarksModule + + StatusbarIsVisible + 0 + WindowString + 538 42 401 187 0 0 1280 1002 + + + Identifier + windowTool.classBrowser + Layout + + + Dock + + + BecomeActive + 1 + ContentConfiguration + + OptionsSetName + Hierarchy, all classes + PBXProjectModuleGUID + 1CA6456E063B45B4001379D8 + PBXProjectModuleLabel + Class Browser - NSObject + + GeometryConfiguration + + ClassesFrame + {{0, 0}, {374, 96}} + ClassesTreeTableConfiguration + + PBXClassNameColumnIdentifier + 208 + PBXClassBookColumnIdentifier + 22 + + Frame + {{0, 0}, {630, 331}} + MembersFrame + {{0, 105}, {374, 395}} + MembersTreeTableConfiguration + + PBXMemberTypeIconColumnIdentifier + 22 + PBXMemberNameColumnIdentifier + 216 + PBXMemberTypeColumnIdentifier + 97 + PBXMemberBookColumnIdentifier + 22 + + PBXModuleWindowStatusBarHidden2 + 1 + RubberWindowFrame + 385 179 630 352 0 0 1440 878 + + Module + PBXClassBrowserModule + Proportion + 332pt + + + Proportion + 332pt + + + Name + Class Browser + ServiceClasses + + PBXClassBrowserModule + + StatusbarIsVisible + 0 + TableOfContents + + 1C0AD2AF069F1E9B00FABCE6 + 1C0AD2B0069F1E9B00FABCE6 + 1CA6456E063B45B4001379D8 + + ToolbarConfiguration + xcode.toolbar.config.classbrowser + WindowString + 385 179 630 352 0 0 1440 878 + WindowToolGUID + 1C0AD2AF069F1E9B00FABCE6 + WindowToolIsVisible + 0 + + + + diff --git a/me/cocoa/dupeguru.xcodeproj/project.pbxproj b/me/cocoa/dupeguru.xcodeproj/project.pbxproj new file mode 100644 index 00000000..da9f71bb --- /dev/null +++ b/me/cocoa/dupeguru.xcodeproj/project.pbxproj @@ -0,0 +1,563 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 44; + objects = { + +/* Begin PBXAppleScriptBuildPhase section */ + CE6B288A0AFB7FC900508D93 /* AppleScript */ = { + isa = PBXAppleScriptBuildPhase; + buildActionMask = 2147483647; + contextName = ""; + files = ( + ); + isSharedContext = 0; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXAppleScriptBuildPhase section */ + +/* Begin PBXBuildFile section */ + 8D11072A0486CEB800E47090 /* MainMenu.nib in Resources */ = {isa = PBXBuildFile; fileRef = 29B97318FDCFA39411CA2CEA /* MainMenu.nib */; }; + 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */; }; + 8D11072D0486CEB800E47090 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B97316FDCFA39411CA2CEA /* main.m */; settings = {ATTRIBUTES = (); }; }; + 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */; }; + CE073F6309CAE1A3005C1D2F /* dupeguru_me_help in Resources */ = {isa = PBXBuildFile; fileRef = CE073F5409CAE1A3005C1D2F /* dupeguru_me_help */; }; + CE12149E0AC86DB900E93983 /* dg.xsl in Resources */ = {isa = PBXBuildFile; fileRef = CE12149C0AC86DB900E93983 /* dg.xsl */; }; + CE12149F0AC86DB900E93983 /* hardcoded.css in Resources */ = {isa = PBXBuildFile; fileRef = CE12149D0AC86DB900E93983 /* hardcoded.css */; }; + CE1425890AFB718500BD5167 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE1425880AFB718500BD5167 /* Sparkle.framework */; }; + CE14259F0AFB719300BD5167 /* Sparkle.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE1425880AFB718500BD5167 /* Sparkle.framework */; }; + CE381C9609914ACE003581CE /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = CE381C9409914ACE003581CE /* AppDelegate.m */; }; + CE381C9C09914ADF003581CE /* ResultWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CE381C9A09914ADF003581CE /* ResultWindow.m */; }; + CE381D0509915304003581CE /* dg_cocoa.plugin in Resources */ = {isa = PBXBuildFile; fileRef = CE381CF509915304003581CE /* dg_cocoa.plugin */; }; + CE3AA46709DB207900DB3A21 /* Directories.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE3AA46509DB207900DB3A21 /* Directories.nib */; }; + CE515DF30FC6C12E00EC695D /* Dialogs.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DE10FC6C12E00EC695D /* Dialogs.m */; }; + CE515DF40FC6C12E00EC695D /* HSErrorReportWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DE30FC6C12E00EC695D /* HSErrorReportWindow.m */; }; + CE515DF50FC6C12E00EC695D /* Outline.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DE50FC6C12E00EC695D /* Outline.m */; }; + CE515DF60FC6C12E00EC695D /* ProgressController.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DE70FC6C12E00EC695D /* ProgressController.m */; }; + CE515DF70FC6C12E00EC695D /* RecentDirectories.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DEA0FC6C12E00EC695D /* RecentDirectories.m */; }; + CE515DF80FC6C12E00EC695D /* RegistrationInterface.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DEC0FC6C12E00EC695D /* RegistrationInterface.m */; }; + CE515DF90FC6C12E00EC695D /* Table.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DEE0FC6C12E00EC695D /* Table.m */; }; + CE515DFA0FC6C12E00EC695D /* Utils.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DF00FC6C12E00EC695D /* Utils.m */; }; + CE515DFB0FC6C12E00EC695D /* ValueTransformers.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515DF20FC6C12E00EC695D /* ValueTransformers.m */; }; + CE515E020FC6C13E00EC695D /* ErrorReportWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = CE515DFC0FC6C13E00EC695D /* ErrorReportWindow.xib */; }; + CE515E030FC6C13E00EC695D /* progress.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE515DFE0FC6C13E00EC695D /* progress.nib */; }; + CE515E040FC6C13E00EC695D /* registration.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE515E000FC6C13E00EC695D /* registration.nib */; }; + CE515E1D0FC6C19300EC695D /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515E160FC6C19300EC695D /* AppDelegate.m */; }; + CE515E1E0FC6C19300EC695D /* DirectoryPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515E190FC6C19300EC695D /* DirectoryPanel.m */; }; + CE515E1F0FC6C19300EC695D /* ResultWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CE515E1C0FC6C19300EC695D /* ResultWindow.m */; }; + CE68EE6809ABC48000971085 /* DirectoryPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CE68EE6609ABC48000971085 /* DirectoryPanel.m */; }; + CE848A1909DD85810004CB44 /* Consts.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE848A1809DD85810004CB44 /* Consts.h */; }; + CECA899909DB12CA00A3D774 /* Details.nib in Resources */ = {isa = PBXBuildFile; fileRef = CECA899709DB12CA00A3D774 /* Details.nib */; }; + CECA899C09DB132E00A3D774 /* DetailsPanel.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = CECA899A09DB132E00A3D774 /* DetailsPanel.h */; }; + CECA899D09DB132E00A3D774 /* DetailsPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CECA899B09DB132E00A3D774 /* DetailsPanel.m */; }; + CED2A6880A05102700AC4C3F /* power_marker32.png in Resources */ = {isa = PBXBuildFile; fileRef = CED2A6870A05102600AC4C3F /* power_marker32.png */; }; + CED2A6970A05128900AC4C3F /* dgme_logo32.png in Resources */ = {isa = PBXBuildFile; fileRef = CED2A6960A05128900AC4C3F /* dgme_logo32.png */; }; + CEEB135209C837A2004D2330 /* dupeguru.icns in Resources */ = {isa = PBXBuildFile; fileRef = CEEB135109C837A2004D2330 /* dupeguru.icns */; }; + CEF7823809C8AA0200EF38FF /* gear.png in Resources */ = {isa = PBXBuildFile; fileRef = CEF7823709C8AA0200EF38FF /* gear.png */; }; + CEFC294609C89E3D00D9F998 /* folder32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC294509C89E3D00D9F998 /* folder32.png */; }; + CEFC295509C89FF200D9F998 /* details32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC295309C89FF200D9F998 /* details32.png */; }; + CEFC295609C89FF200D9F998 /* preferences32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC295409C89FF200D9F998 /* preferences32.png */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + CECC02B709A36E8200CC0A94 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + CE14259F0AFB719300BD5167 /* Sparkle.framework in CopyFiles */, + CECA899C09DB132E00A3D774 /* DetailsPanel.h in CopyFiles */, + CE848A1909DD85810004CB44 /* Consts.h in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 089C165DFE840E0CC02AAC07 /* English */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = English; path = English.lproj/InfoPlist.strings; sourceTree = ""; }; + 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; + 13E42FB307B3F0F600E4EEF1 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = /System/Library/Frameworks/CoreData.framework; sourceTree = ""; }; + 29B97316FDCFA39411CA2CEA /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = SOURCE_ROOT; }; + 29B97319FDCFA39411CA2CEA /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/MainMenu.nib; sourceTree = ""; }; + 29B97324FDCFA39411CA2CEA /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = /System/Library/Frameworks/AppKit.framework; sourceTree = ""; }; + 29B97325FDCFA39411CA2CEA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = /System/Library/Frameworks/Foundation.framework; sourceTree = ""; }; + 8D1107310486CEB800E47090 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = SOURCE_ROOT; }; + 8D1107320486CEB800E47090 /* dupeGuru ME.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "dupeGuru ME.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + CE073F5409CAE1A3005C1D2F /* dupeguru_me_help */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dupeguru_me_help; path = help/dupeguru_me_help; sourceTree = ""; }; + CE12149C0AC86DB900E93983 /* dg.xsl */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = text.xml; name = dg.xsl; path = w3/dg.xsl; sourceTree = SOURCE_ROOT; }; + CE12149D0AC86DB900E93983 /* hardcoded.css */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = text; name = hardcoded.css; path = w3/hardcoded.css; sourceTree = SOURCE_ROOT; }; + CE1425880AFB718500BD5167 /* Sparkle.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Sparkle.framework; path = /Library/Frameworks/Sparkle.framework; sourceTree = ""; }; + CE381C9409914ACE003581CE /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = SOURCE_ROOT; }; + CE381C9509914ACE003581CE /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = SOURCE_ROOT; }; + CE381C9A09914ADF003581CE /* ResultWindow.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = ResultWindow.m; sourceTree = SOURCE_ROOT; }; + CE381C9B09914ADF003581CE /* ResultWindow.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = ResultWindow.h; sourceTree = SOURCE_ROOT; }; + CE381CF509915304003581CE /* dg_cocoa.plugin */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dg_cocoa.plugin; path = py/dist/dg_cocoa.plugin; sourceTree = SOURCE_ROOT; }; + CE3AA46609DB207900DB3A21 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/Directories.nib; sourceTree = ""; }; + CE515DE00FC6C12E00EC695D /* Dialogs.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Dialogs.h; path = cocoalib/Dialogs.h; sourceTree = SOURCE_ROOT; }; + CE515DE10FC6C12E00EC695D /* Dialogs.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Dialogs.m; path = cocoalib/Dialogs.m; sourceTree = SOURCE_ROOT; }; + CE515DE20FC6C12E00EC695D /* HSErrorReportWindow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = HSErrorReportWindow.h; path = cocoalib/HSErrorReportWindow.h; sourceTree = SOURCE_ROOT; }; + CE515DE30FC6C12E00EC695D /* HSErrorReportWindow.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = HSErrorReportWindow.m; path = cocoalib/HSErrorReportWindow.m; sourceTree = SOURCE_ROOT; }; + CE515DE40FC6C12E00EC695D /* Outline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Outline.h; path = cocoalib/Outline.h; sourceTree = SOURCE_ROOT; }; + CE515DE50FC6C12E00EC695D /* Outline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Outline.m; path = cocoalib/Outline.m; sourceTree = SOURCE_ROOT; }; + CE515DE60FC6C12E00EC695D /* ProgressController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ProgressController.h; path = cocoalib/ProgressController.h; sourceTree = SOURCE_ROOT; }; + CE515DE70FC6C12E00EC695D /* ProgressController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ProgressController.m; path = cocoalib/ProgressController.m; sourceTree = SOURCE_ROOT; }; + CE515DE80FC6C12E00EC695D /* PyApp.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PyApp.h; path = cocoalib/PyApp.h; sourceTree = SOURCE_ROOT; }; + CE515DE90FC6C12E00EC695D /* RecentDirectories.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RecentDirectories.h; path = cocoalib/RecentDirectories.h; sourceTree = SOURCE_ROOT; }; + CE515DEA0FC6C12E00EC695D /* RecentDirectories.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RecentDirectories.m; path = cocoalib/RecentDirectories.m; sourceTree = SOURCE_ROOT; }; + CE515DEB0FC6C12E00EC695D /* RegistrationInterface.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RegistrationInterface.h; path = cocoalib/RegistrationInterface.h; sourceTree = SOURCE_ROOT; }; + CE515DEC0FC6C12E00EC695D /* RegistrationInterface.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RegistrationInterface.m; path = cocoalib/RegistrationInterface.m; sourceTree = SOURCE_ROOT; }; + CE515DED0FC6C12E00EC695D /* Table.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Table.h; path = cocoalib/Table.h; sourceTree = SOURCE_ROOT; }; + CE515DEE0FC6C12E00EC695D /* Table.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Table.m; path = cocoalib/Table.m; sourceTree = SOURCE_ROOT; }; + CE515DEF0FC6C12E00EC695D /* Utils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Utils.h; path = cocoalib/Utils.h; sourceTree = SOURCE_ROOT; }; + CE515DF00FC6C12E00EC695D /* Utils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Utils.m; path = cocoalib/Utils.m; sourceTree = SOURCE_ROOT; }; + CE515DF10FC6C12E00EC695D /* ValueTransformers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ValueTransformers.h; path = cocoalib/ValueTransformers.h; sourceTree = SOURCE_ROOT; }; + CE515DF20FC6C12E00EC695D /* ValueTransformers.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ValueTransformers.m; path = cocoalib/ValueTransformers.m; sourceTree = SOURCE_ROOT; }; + CE515DFD0FC6C13E00EC695D /* English */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = English; path = cocoalib/English.lproj/ErrorReportWindow.xib; sourceTree = ""; }; + CE515DFF0FC6C13E00EC695D /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = cocoalib/English.lproj/progress.nib; sourceTree = ""; }; + CE515E010FC6C13E00EC695D /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = cocoalib/English.lproj/registration.nib; sourceTree = ""; }; + CE515E150FC6C19300EC695D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = dgbase/AppDelegate.h; sourceTree = SOURCE_ROOT; }; + CE515E160FC6C19300EC695D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = dgbase/AppDelegate.m; sourceTree = SOURCE_ROOT; }; + CE515E170FC6C19300EC695D /* Consts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Consts.h; path = dgbase/Consts.h; sourceTree = SOURCE_ROOT; }; + CE515E180FC6C19300EC695D /* DirectoryPanel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = DirectoryPanel.h; path = dgbase/DirectoryPanel.h; sourceTree = SOURCE_ROOT; }; + CE515E190FC6C19300EC695D /* DirectoryPanel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = DirectoryPanel.m; path = dgbase/DirectoryPanel.m; sourceTree = SOURCE_ROOT; }; + CE515E1A0FC6C19300EC695D /* PyDupeGuru.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PyDupeGuru.h; path = dgbase/PyDupeGuru.h; sourceTree = SOURCE_ROOT; }; + CE515E1B0FC6C19300EC695D /* ResultWindow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ResultWindow.h; path = dgbase/ResultWindow.h; sourceTree = SOURCE_ROOT; }; + CE515E1C0FC6C19300EC695D /* ResultWindow.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ResultWindow.m; path = dgbase/ResultWindow.m; sourceTree = SOURCE_ROOT; }; + CE68EE6509ABC48000971085 /* DirectoryPanel.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = DirectoryPanel.h; sourceTree = SOURCE_ROOT; }; + CE68EE6609ABC48000971085 /* DirectoryPanel.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = DirectoryPanel.m; sourceTree = SOURCE_ROOT; }; + CE848A1809DD85810004CB44 /* Consts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Consts.h; sourceTree = ""; }; + CECA899809DB12CA00A3D774 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/Details.nib; sourceTree = ""; }; + CECA899A09DB132E00A3D774 /* DetailsPanel.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = DetailsPanel.h; sourceTree = ""; }; + CECA899B09DB132E00A3D774 /* DetailsPanel.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = DetailsPanel.m; sourceTree = ""; }; + CED2A6870A05102600AC4C3F /* power_marker32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = power_marker32.png; path = images/power_marker32.png; sourceTree = SOURCE_ROOT; }; + CED2A6960A05128900AC4C3F /* dgme_logo32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = dgme_logo32.png; path = images/dgme_logo32.png; sourceTree = SOURCE_ROOT; }; + CEEB135109C837A2004D2330 /* dupeguru.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = dupeguru.icns; sourceTree = ""; }; + CEF7823709C8AA0200EF38FF /* gear.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = gear.png; path = images/gear.png; sourceTree = ""; }; + CEFC294509C89E3D00D9F998 /* folder32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = folder32.png; path = images/folder32.png; sourceTree = SOURCE_ROOT; }; + CEFC295309C89FF200D9F998 /* details32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = details32.png; path = images/details32.png; sourceTree = SOURCE_ROOT; }; + CEFC295409C89FF200D9F998 /* preferences32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = preferences32.png; path = images/preferences32.png; sourceTree = SOURCE_ROOT; }; + CEFF18A009A4D387005E6321 /* PyDupeGuru.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = PyDupeGuru.h; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8D11072E0486CEB800E47090 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */, + CE1425890AFB718500BD5167 /* Sparkle.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 080E96DDFE201D6D7F000001 /* Classes */ = { + isa = PBXGroup; + children = ( + CE381C9509914ACE003581CE /* AppDelegate.h */, + CE381C9409914ACE003581CE /* AppDelegate.m */, + CE848A1809DD85810004CB44 /* Consts.h */, + CECA899A09DB132E00A3D774 /* DetailsPanel.h */, + CECA899B09DB132E00A3D774 /* DetailsPanel.m */, + CE68EE6509ABC48000971085 /* DirectoryPanel.h */, + CE68EE6609ABC48000971085 /* DirectoryPanel.m */, + CEFF18A009A4D387005E6321 /* PyDupeGuru.h */, + CE381C9B09914ADF003581CE /* ResultWindow.h */, + CE381C9A09914ADF003581CE /* ResultWindow.m */, + ); + name = Classes; + sourceTree = ""; + }; + 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */ = { + isa = PBXGroup; + children = ( + CE1425880AFB718500BD5167 /* Sparkle.framework */, + 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */, + ); + name = "Linked Frameworks"; + sourceTree = ""; + }; + 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */ = { + isa = PBXGroup; + children = ( + 29B97324FDCFA39411CA2CEA /* AppKit.framework */, + 13E42FB307B3F0F600E4EEF1 /* CoreData.framework */, + 29B97325FDCFA39411CA2CEA /* Foundation.framework */, + ); + name = "Other Frameworks"; + sourceTree = ""; + }; + 19C28FACFE9D520D11CA2CBB /* Products */ = { + isa = PBXGroup; + children = ( + 8D1107320486CEB800E47090 /* dupeGuru ME.app */, + ); + name = Products; + sourceTree = ""; + }; + 29B97314FDCFA39411CA2CEA /* dupeguru */ = { + isa = PBXGroup; + children = ( + 080E96DDFE201D6D7F000001 /* Classes */, + CE515E140FC6C17900EC695D /* dgbase */, + CE515DDD0FC6C09400EC695D /* cocoalib */, + 29B97315FDCFA39411CA2CEA /* Other Sources */, + 29B97317FDCFA39411CA2CEA /* Resources */, + 29B97323FDCFA39411CA2CEA /* Frameworks */, + 19C28FACFE9D520D11CA2CBB /* Products */, + ); + name = dupeguru; + sourceTree = ""; + }; + 29B97315FDCFA39411CA2CEA /* Other Sources */ = { + isa = PBXGroup; + children = ( + 29B97316FDCFA39411CA2CEA /* main.m */, + ); + name = "Other Sources"; + sourceTree = ""; + }; + 29B97317FDCFA39411CA2CEA /* Resources */ = { + isa = PBXGroup; + children = ( + CE073F5409CAE1A3005C1D2F /* dupeguru_me_help */, + CE381CF509915304003581CE /* dg_cocoa.plugin */, + CEFC294309C89E0000D9F998 /* images */, + CE12149B0AC86DB900E93983 /* w3 */, + CEEB135109C837A2004D2330 /* dupeguru.icns */, + 8D1107310486CEB800E47090 /* Info.plist */, + 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */, + CECA899709DB12CA00A3D774 /* Details.nib */, + CE3AA46509DB207900DB3A21 /* Directories.nib */, + 29B97318FDCFA39411CA2CEA /* MainMenu.nib */, + ); + name = Resources; + sourceTree = ""; + }; + 29B97323FDCFA39411CA2CEA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */, + 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */, + ); + name = Frameworks; + sourceTree = ""; + }; + CE12149B0AC86DB900E93983 /* w3 */ = { + isa = PBXGroup; + children = ( + CE12149C0AC86DB900E93983 /* dg.xsl */, + CE12149D0AC86DB900E93983 /* hardcoded.css */, + ); + path = w3; + sourceTree = SOURCE_ROOT; + }; + CE515DDD0FC6C09400EC695D /* cocoalib */ = { + isa = PBXGroup; + children = ( + CE515DFC0FC6C13E00EC695D /* ErrorReportWindow.xib */, + CE515DFE0FC6C13E00EC695D /* progress.nib */, + CE515E000FC6C13E00EC695D /* registration.nib */, + CE515DE00FC6C12E00EC695D /* Dialogs.h */, + CE515DE10FC6C12E00EC695D /* Dialogs.m */, + CE515DE20FC6C12E00EC695D /* HSErrorReportWindow.h */, + CE515DE30FC6C12E00EC695D /* HSErrorReportWindow.m */, + CE515DE40FC6C12E00EC695D /* Outline.h */, + CE515DE50FC6C12E00EC695D /* Outline.m */, + CE515DE60FC6C12E00EC695D /* ProgressController.h */, + CE515DE70FC6C12E00EC695D /* ProgressController.m */, + CE515DE80FC6C12E00EC695D /* PyApp.h */, + CE515DE90FC6C12E00EC695D /* RecentDirectories.h */, + CE515DEA0FC6C12E00EC695D /* RecentDirectories.m */, + CE515DEB0FC6C12E00EC695D /* RegistrationInterface.h */, + CE515DEC0FC6C12E00EC695D /* RegistrationInterface.m */, + CE515DED0FC6C12E00EC695D /* Table.h */, + CE515DEE0FC6C12E00EC695D /* Table.m */, + CE515DEF0FC6C12E00EC695D /* Utils.h */, + CE515DF00FC6C12E00EC695D /* Utils.m */, + CE515DF10FC6C12E00EC695D /* ValueTransformers.h */, + CE515DF20FC6C12E00EC695D /* ValueTransformers.m */, + ); + name = cocoalib; + sourceTree = ""; + }; + CE515E140FC6C17900EC695D /* dgbase */ = { + isa = PBXGroup; + children = ( + CE515E150FC6C19300EC695D /* AppDelegate.h */, + CE515E160FC6C19300EC695D /* AppDelegate.m */, + CE515E170FC6C19300EC695D /* Consts.h */, + CE515E180FC6C19300EC695D /* DirectoryPanel.h */, + CE515E190FC6C19300EC695D /* DirectoryPanel.m */, + CE515E1A0FC6C19300EC695D /* PyDupeGuru.h */, + CE515E1B0FC6C19300EC695D /* ResultWindow.h */, + CE515E1C0FC6C19300EC695D /* ResultWindow.m */, + ); + name = dgbase; + sourceTree = ""; + }; + CEFC294309C89E0000D9F998 /* images */ = { + isa = PBXGroup; + children = ( + CED2A6960A05128900AC4C3F /* dgme_logo32.png */, + CED2A6870A05102600AC4C3F /* power_marker32.png */, + CEF7823709C8AA0200EF38FF /* gear.png */, + CEFC295309C89FF200D9F998 /* details32.png */, + CEFC295409C89FF200D9F998 /* preferences32.png */, + CEFC294509C89E3D00D9F998 /* folder32.png */, + ); + name = images; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8D1107260486CEB800E47090 /* dupeguru */ = { + isa = PBXNativeTarget; + buildConfigurationList = C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "dupeguru" */; + buildPhases = ( + 8D1107290486CEB800E47090 /* Resources */, + 8D11072C0486CEB800E47090 /* Sources */, + 8D11072E0486CEB800E47090 /* Frameworks */, + CECC02B709A36E8200CC0A94 /* CopyFiles */, + CE6B288A0AFB7FC900508D93 /* AppleScript */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = dupeguru; + productInstallPath = "$(HOME)/Applications"; + productName = dupeguru; + productReference = 8D1107320486CEB800E47090 /* dupeGuru ME.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 29B97313FDCFA39411CA2CEA /* Project object */ = { + isa = PBXProject; + buildConfigurationList = C01FCF4E08A954540054247B /* Build configuration list for PBXProject "dupeguru" */; + compatibilityVersion = "Xcode 3.0"; + hasScannedForEncodings = 1; + mainGroup = 29B97314FDCFA39411CA2CEA /* dupeguru */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8D1107260486CEB800E47090 /* dupeguru */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8D1107290486CEB800E47090 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072A0486CEB800E47090 /* MainMenu.nib in Resources */, + 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */, + CE381D0509915304003581CE /* dg_cocoa.plugin in Resources */, + CE073F6309CAE1A3005C1D2F /* dupeguru_me_help in Resources */, + CEEB135209C837A2004D2330 /* dupeguru.icns in Resources */, + CEFC294609C89E3D00D9F998 /* folder32.png in Resources */, + CEFC295509C89FF200D9F998 /* details32.png in Resources */, + CEFC295609C89FF200D9F998 /* preferences32.png in Resources */, + CEF7823809C8AA0200EF38FF /* gear.png in Resources */, + CECA899909DB12CA00A3D774 /* Details.nib in Resources */, + CE3AA46709DB207900DB3A21 /* Directories.nib in Resources */, + CED2A6880A05102700AC4C3F /* power_marker32.png in Resources */, + CED2A6970A05128900AC4C3F /* dgme_logo32.png in Resources */, + CE12149E0AC86DB900E93983 /* dg.xsl in Resources */, + CE12149F0AC86DB900E93983 /* hardcoded.css in Resources */, + CE515E020FC6C13E00EC695D /* ErrorReportWindow.xib in Resources */, + CE515E030FC6C13E00EC695D /* progress.nib in Resources */, + CE515E040FC6C13E00EC695D /* registration.nib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8D11072C0486CEB800E47090 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072D0486CEB800E47090 /* main.m in Sources */, + CE381C9609914ACE003581CE /* AppDelegate.m in Sources */, + CE381C9C09914ADF003581CE /* ResultWindow.m in Sources */, + CE68EE6809ABC48000971085 /* DirectoryPanel.m in Sources */, + CECA899D09DB132E00A3D774 /* DetailsPanel.m in Sources */, + CE515DF30FC6C12E00EC695D /* Dialogs.m in Sources */, + CE515DF40FC6C12E00EC695D /* HSErrorReportWindow.m in Sources */, + CE515DF50FC6C12E00EC695D /* Outline.m in Sources */, + CE515DF60FC6C12E00EC695D /* ProgressController.m in Sources */, + CE515DF70FC6C12E00EC695D /* RecentDirectories.m in Sources */, + CE515DF80FC6C12E00EC695D /* RegistrationInterface.m in Sources */, + CE515DF90FC6C12E00EC695D /* Table.m in Sources */, + CE515DFA0FC6C12E00EC695D /* Utils.m in Sources */, + CE515DFB0FC6C12E00EC695D /* ValueTransformers.m in Sources */, + CE515E1D0FC6C19300EC695D /* AppDelegate.m in Sources */, + CE515E1E0FC6C19300EC695D /* DirectoryPanel.m in Sources */, + CE515E1F0FC6C19300EC695D /* ResultWindow.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 089C165DFE840E0CC02AAC07 /* English */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + 29B97318FDCFA39411CA2CEA /* MainMenu.nib */ = { + isa = PBXVariantGroup; + children = ( + 29B97319FDCFA39411CA2CEA /* English */, + ); + name = MainMenu.nib; + sourceTree = SOURCE_ROOT; + }; + CE3AA46509DB207900DB3A21 /* Directories.nib */ = { + isa = PBXVariantGroup; + children = ( + CE3AA46609DB207900DB3A21 /* English */, + ); + name = Directories.nib; + sourceTree = ""; + }; + CE515DFC0FC6C13E00EC695D /* ErrorReportWindow.xib */ = { + isa = PBXVariantGroup; + children = ( + CE515DFD0FC6C13E00EC695D /* English */, + ); + name = ErrorReportWindow.xib; + sourceTree = SOURCE_ROOT; + }; + CE515DFE0FC6C13E00EC695D /* progress.nib */ = { + isa = PBXVariantGroup; + children = ( + CE515DFF0FC6C13E00EC695D /* English */, + ); + name = progress.nib; + sourceTree = SOURCE_ROOT; + }; + CE515E000FC6C13E00EC695D /* registration.nib */ = { + isa = PBXVariantGroup; + children = ( + CE515E010FC6C13E00EC695D /* English */, + ); + name = registration.nib; + sourceTree = SOURCE_ROOT; + }; + CECA899709DB12CA00A3D774 /* Details.nib */ = { + isa = PBXVariantGroup; + children = ( + CECA899809DB12CA00A3D774 /* English */, + ); + name = Details.nib; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + C01FCF4B08A954540054247B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COPY_PHASE_STRIP = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(FRAMEWORK_SEARCH_PATHS)", + "$(SRCROOT)/../../../cocoalib/build/Release", + "\"$(SRCROOT)/../../base/cocoa/build/Release\"", + ); + GCC_DYNAMIC_NO_PIC = NO; + GCC_ENABLE_FIX_AND_CONTINUE = YES; + GCC_MODEL_TUNING = G5; + GCC_OPTIMIZATION_LEVEL = 0; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(HOME)/Applications"; + PRODUCT_NAME = dupeGuru; + WRAPPER_EXTENSION = app; + ZERO_LINK = YES; + }; + name = Debug; + }; + C01FCF4C08A954540054247B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(FRAMEWORK_SEARCH_PATHS)", + "$(SRCROOT)/../../../cocoalib/build/Release", + "\"$(SRCROOT)/../../base/cocoa/build/Release\"", + ); + GCC_GENERATE_DEBUGGING_SYMBOLS = NO; + GCC_MODEL_TUNING = G5; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(HOME)/Applications"; + PRODUCT_NAME = "dupeGuru ME"; + WRAPPER_EXTENSION = app; + }; + name = Release; + }; + C01FCF4F08A954540054247B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + GCC_C_LANGUAGE_STANDARD = c99; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.4; + PREBINDING = NO; + SDKROOT = "$(DEVELOPER_SDK_DIR)/MacOSX10.4u.sdk"; + }; + name = Debug; + }; + C01FCF5008A954540054247B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = ( + i386, + ppc, + ); + COPY_PHASE_STRIP = NO; + GCC_C_LANGUAGE_STANDARD = c99; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.4; + PREBINDING = NO; + SDKROOT = "$(DEVELOPER_SDK_DIR)/MacOSX10.4u.sdk"; + STRIP_INSTALLED_PRODUCT = NO; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "dupeguru" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C01FCF4B08A954540054247B /* Debug */, + C01FCF4C08A954540054247B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C01FCF4E08A954540054247B /* Build configuration list for PBXProject "dupeguru" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C01FCF4F08A954540054247B /* Debug */, + C01FCF5008A954540054247B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 29B97313FDCFA39411CA2CEA /* Project object */; +} diff --git a/me/cocoa/gen.py b/me/cocoa/gen.py new file mode 100644 index 00000000..45ae1e20 --- /dev/null +++ b/me/cocoa/gen.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +import os + +print "Generating help" +os.chdir('help') +os.system('python -u gen.py') +os.system('/Developer/Applications/Utilities/Help\\ Indexer.app/Contents/MacOS/Help\\ Indexer dupeguru_me_help') +os.chdir('..') + +print "Generating py plugin" +os.chdir('py') +os.system('python -u gen.py') +os.chdir('..') \ No newline at end of file diff --git a/me/cocoa/main.m b/me/cocoa/main.m new file mode 100644 index 00000000..c5f30658 --- /dev/null +++ b/me/cocoa/main.m @@ -0,0 +1,21 @@ +// +// main.m +// dupeguru +// +// Created by Virgil Dupras on 2006/02/01. +// Copyright __MyCompanyName__ 2006. All rights reserved. +// + +#import + +int main(int argc, char *argv[]) +{ + NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; + NSString *pluginPath = [[NSBundle mainBundle] + pathForResource:@"dg_cocoa" + ofType:@"plugin"]; + NSBundle *pluginBundle = [NSBundle bundleWithPath:pluginPath]; + [pluginBundle load]; + [pool release]; + return NSApplicationMain(argc, (const char **) argv); +} diff --git a/me/cocoa/py/dg_cocoa.py b/me/cocoa/py/dg_cocoa.py new file mode 100644 index 00000000..53413c71 --- /dev/null +++ b/me/cocoa/py/dg_cocoa.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python +import objc +from AppKit import * + +from dupeguru import app_me_cocoa, scanner + +# Fix py2app imports which chokes on relative imports +from dupeguru import app, app_cocoa, data, directories, engine, export, ignore, results, scanner +from hsfs import auto, manual, stats, tree, utils, music +from hsfs.phys import music +from hsmedia import aiff, flac, genres, id3v1, id3v2, mp4, mpeg, ogg, wma + +class PyApp(NSObject): + pass #fake class + +class PyDupeGuru(PyApp): + def init(self): + self = super(PyDupeGuru,self).init() + self.app = app_me_cocoa.DupeGuruME() + return self + + #---Directories + def addDirectory_(self,directory): + return self.app.AddDirectory(directory) + + def removeDirectory_(self,index): + self.app.RemoveDirectory(index) + + def setDirectory_state_(self,node_path,state): + self.app.SetDirectoryState(node_path,state) + + #---Results + def clearIgnoreList(self): + self.app.scanner.ignore_list.Clear() + + def doScan(self): + return self.app.start_scanning() + + def exportToXHTMLwithColumns_xslt_css_(self,column_ids,xslt_path,css_path): + return self.app.ExportToXHTML(column_ids,xslt_path,css_path) + + def loadIgnoreList(self): + self.app.LoadIgnoreList() + + def loadResults(self): + self.app.load() + + def markAll(self): + self.app.results.mark_all() + + def markNone(self): + self.app.results.mark_none() + + def markInvert(self): + self.app.results.mark_invert() + + def purgeIgnoreList(self): + self.app.PurgeIgnoreList() + + def toggleSelectedMark(self): + self.app.ToggleSelectedMarkState() + + def saveIgnoreList(self): + self.app.SaveIgnoreList() + + def saveResults(self): + self.app.Save() + + def refreshDetailsWithSelected(self): + self.app.RefreshDetailsWithSelected() + + def selectResultNodePaths_(self,node_paths): + self.app.SelectResultNodePaths(node_paths) + + def selectPowerMarkerNodePaths_(self,node_paths): + self.app.SelectPowerMarkerNodePaths(node_paths) + + #---Actions + def addSelectedToIgnoreList(self): + self.app.AddSelectedToIgnoreList() + + def applyFilter_(self, filter): + self.app.ApplyFilter(filter) + + def deleteMarked(self): + self.app.delete_marked() + + def makeSelectedReference(self): + self.app.MakeSelectedReference() + + def copyOrMove_markedTo_recreatePath_(self,copy,destination,recreate_path): + self.app.copy_or_move_marked(copy, destination, recreate_path) + + def openSelected(self): + self.app.OpenSelected() + + def removeDeadTracks(self): + self.app.remove_dead_tracks() + + def removeMarked(self): + self.app.results.perform_on_marked(lambda x:True, True) + + def removeSelected(self): + self.app.RemoveSelected() + + def renameSelected_(self,newname): + return self.app.RenameSelected(newname) + + def revealSelected(self): + self.app.RevealSelected() + + def scanDeadTracks(self): + self.app.scan_dead_tracks() + + #---Misc + def sortDupesBy_ascending_(self,key,asc): + self.app.sort_dupes(key,asc) + + def sortGroupsBy_ascending_(self,key,asc): + self.app.sort_groups(key,asc) + + #---Information + @objc.signature('i@:') + def deadTrackCount(self): + return len(self.app.dead_tracks) + + def getIgnoreListCount(self): + return len(self.app.scanner.ignore_list) + + def getMarkCount(self): + return self.app.results.mark_count + + def getStatLine(self): + return self.app.stat_line + + def getOperationalErrorCount(self): + return self.app.last_op_error_count + + #---Data + @objc.signature('i@:i') + def getOutlineViewMaxLevel_(self, tag): + return self.app.GetOutlineViewMaxLevel(tag) + + @objc.signature('@@:i@') + def getOutlineView_childCountsForPath_(self, tag, node_path): + return self.app.GetOutlineViewChildCounts(tag, node_path) + + def getOutlineView_valuesForIndexes_(self,tag,node_path): + return self.app.GetOutlineViewValues(tag,node_path) + + def getOutlineView_markedAtIndexes_(self,tag,node_path): + return self.app.GetOutlineViewMarked(tag,node_path) + + def getTableViewCount_(self,tag): + return self.app.GetTableViewCount(tag) + + def getTableViewMarkedIndexes_(self,tag): + return self.app.GetTableViewMarkedIndexes(tag) + + def getTableView_valuesForRow_(self,tag,row): + return self.app.GetTableViewValues(tag,row) + + #---Properties + def setMinMatchPercentage_(self, percentage): + self.app.scanner.min_match_percentage = int(percentage) + + def setScanType_(self, scan_type): + try: + self.app.scanner.scan_type = [ + scanner.SCAN_TYPE_FILENAME, + scanner.SCAN_TYPE_FIELDS, + scanner.SCAN_TYPE_FIELDS_NO_ORDER, + scanner.SCAN_TYPE_TAG, + scanner.SCAN_TYPE_CONTENT, + scanner.SCAN_TYPE_CONTENT_AUDIO + ][scan_type] + except IndexError: + pass + + def setWordWeighting_(self, words_are_weighted): + self.app.scanner.word_weighting = words_are_weighted + + def setMixFileKind_(self, mix_file_kind): + self.app.scanner.mix_file_kind = mix_file_kind + + def setDisplayDeltaValues_(self, display_delta_values): + self.app.display_delta_values = display_delta_values + + def setMatchSimilarWords_(self, match_similar_words): + self.app.scanner.match_similar_words = match_similar_words + + def setEscapeFilterRegexp_(self, escape_filter_regexp): + self.app.options['escape_filter_regexp'] = escape_filter_regexp + + def setRemoveEmptyFolders_(self, remove_empty_folders): + self.app.options['clean_empty_dirs'] = remove_empty_folders + + def enable_scanForTag_(self, enable, scan_tag): + if enable: + self.app.scanner.scanned_tags.add(scan_tag) + else: + self.app.scanner.scanned_tags.discard(scan_tag) + + #---Worker + def getJobProgress(self): + return self.app.progress.last_progress + + def getJobDesc(self): + return self.app.progress.last_desc + + def cancelJob(self): + self.app.progress.job_cancelled = True + + #---Registration + @objc.signature('i@:') + def isRegistered(self): + return self.app.registered + + @objc.signature('i@:@@') + def isCodeValid_withEmail_(self, code, email): + return self.app.is_code_valid(code, email) + + def setRegisteredCode_andEmail_(self, code, email): + self.app.set_registration(code, email) + diff --git a/me/cocoa/py/gen.py b/me/cocoa/py/gen.py new file mode 100644 index 00000000..6195927d --- /dev/null +++ b/me/cocoa/py/gen.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python + +import os +import os.path as op +import shutil + +from hsutil.build import print_and_do + +os.chdir('dupeguru') +print_and_do('python gen.py') +os.chdir('..') + +if op.exists('build'): + shutil.rmtree('build') +if op.exists('dist'): + shutil.rmtree('dist') + +print_and_do('python -u setup.py py2app') \ No newline at end of file diff --git a/me/cocoa/py/setup.py b/me/cocoa/py/setup.py new file mode 100644 index 00000000..af81b3ed --- /dev/null +++ b/me/cocoa/py/setup.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +from distutils.core import setup +import py2app + +from hsutil.build import move_testdata_out, put_testdata_back + +move_log = move_testdata_out() +try: + setup( + plugin = ['dg_cocoa.py'], + ) +finally: + put_testdata_back(move_log) diff --git a/me/cocoa/w3/dg.xsl b/me/cocoa/w3/dg.xsl new file mode 100644 index 00000000..4f982fce --- /dev/null +++ b/me/cocoa/w3/dg.xsl @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + indented + + + + + + + + + + + + + + + + + + + + + + + + + + + + dupeGuru Results + + + +

dupeGuru Results

+ + + + + +
+ + + + + \ No newline at end of file diff --git a/me/cocoa/w3/hardcoded.css b/me/cocoa/w3/hardcoded.css new file mode 100644 index 00000000..ed243bcc --- /dev/null +++ b/me/cocoa/w3/hardcoded.css @@ -0,0 +1,71 @@ +BODY +{ + background-color:white; +} + +BODY,A,P,UL,TABLE,TR,TD +{ + font-family:Tahoma,Arial,sans-serif; + font-size:10pt; + color: #4477AA; +} + +TABLE +{ + background-color: #225588; + margin-left: auto; + margin-right: auto; + width: 90%; +} + +TR +{ + background-color: white; +} + +TH +{ + font-weight: bold; + color: black; + background-color: #C8D6E5; +} + +TH TD +{ + color:black; +} + +TD +{ + padding-left: 2pt; +} + +TD.rightelem +{ + text-align:right; + /*padding-left:0pt;*/ + padding-right: 2pt; + width: 17%; +} + +TD.indented +{ + padding-left: 12pt; +} + +H1 +{ + font-family:"Courier New",monospace; + color:#6699CC; + font-size:18pt; + color:#6da500; + border-color: #70A0CF; + border-width: 1pt; + border-style: solid; + margin-top: 16pt; + margin-left: 5%; + margin-right: 5%; + padding-top: 2pt; + padding-bottom:2pt; + text-align: center; +} \ No newline at end of file diff --git a/me/help/changelog.yaml b/me/help/changelog.yaml new file mode 100644 index 00000000..db4644f2 --- /dev/null +++ b/me/help/changelog.yaml @@ -0,0 +1,542 @@ +- date: 2009-05-30 + version: 5.6.1 + description: | + * Fixed a bug causing a GUI freeze at the beginning of a scan with a lot of files. + * Fixed a bug that sometimes caused a crash when an action was cancelled, and then started again. +- date: 2009-05-23 + version: 5.6.0 + description: | + * Converted the Windows GUI to Qt. + * Improved the reliability of the scanning process. +- date: 2009-03-28 + version: 5.5.2 + description: | + * **Fixed** an occasional crash caused by permission issues. + * **Fixed** a bug where the "X discarded" notice would show a too large number of discarded duplicates. +- date: 2008-09-28 + version: 5.5.1 + description: | + * **Improved** support for AIFF files. + * **Improved** Remove Dead Tracks in iTunes for very large library (Mac OS X). +- date: 2008-09-10 + description: "
    \n\t\t\t\t\t\t
  • Added support for AIFF files.
  • \n\t\ + \t\t\t\t\t
  • Added a notice in the status bar when matches were discarded\ + \ during the scan.
  • \n\t\t\t\t\t\t
  • Improved duplicate prioritization\ + \ (smartly chooses which file you will keep).
  • \n\t\t\t\t\t\t
  • Improved\ + \ scan progress feedback.
  • \n\t\t\t\t\t\t
  • Improved responsiveness\ + \ of the user interface for certain actions.
  • \n\t\t
" + version: 5.5.0 +- date: 2008-08-07 + description: "
    \n\t\t\t\t\t\t
  • Improved the \"Remove Dead Tracks in\ + \ iTunes\" feature.
  • \n\t\t\t\t\t\t
  • Improved the speed of results\ + \ loading and saving.
  • \n\t\t\t\t\t\t
  • Fixed a crash sometimes occurring\ + \ during duplicate deletion.
  • \n\t\t
" + version: 5.4.3 +- date: 2008-06-20 + description: "
    \n\t\t\t\t\t\t
  • Improved unicode handling for filenames\ + \ and tags. dupeGuru ME will now find a lot more duplicates if your files have\ + \ non-ascii characters in it.
  • \n\t\t\t\t\t\t
  • Improved MPEG files\ + \ duration detection.
  • \n\t\t\t\t\t\t
  • Fixed \"Clear Ignore List\"\ + \ crash in Windows.
  • \n\t\t
" + version: 5.4.2 +- date: 2008-01-15 + description: "
    \n\t\t\t\t\t\t
  • Improved scan, delete and move speed\ + \ in situations where there were a lot of duplicates.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ occasional crashes when moving a lot of files at once.
  • \n\t\t \ + \
" + version: 5.4.1 +- date: 2007-12-06 + description: "
    \n\t\t\t\t\t\t
  • Added customizable tag scans.
  • \n\t\ + \t\t\t\t\t
  • Improved the handling of low memory situations.
  • \n\t\t\ + \t\t\t\t
  • Improved the directory panel. The \"Remove\" button changes\ + \ to \"Put Back\" when an excluded directory is selected.
  • \n\t\t \ + \
" + version: 5.4.0 +- date: 2007-11-26 + description: "
    \n\t\t\t\t\t\t
  • Added the \"Remove empty folders\" option.
  • \n\ + \t\t\t\t\t\t
  • Fixed results load/save issues.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ occasional status bar inaccuracies when the results are filtered.
  • \n\t\t\ + \
" + version: 5.3.2 +- date: 2007-08-12 + description: "
    \n\t\t\t\t\t\t
  • Fixed a crash with copy and move.
  • \n\ + \t\t
" + version: 5.3.1 +- date: 2007-07-01 + description: "
    \n\t\t\t\t\t\t
  • Added post scan filtering.
  • \n\t\t\ + \t\t\t\t
  • Fixed a small issue with AAC decoding.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ issues with the rename feature under Windows
  • \n\t\t\t\t\t\t
  • Fixed\ + \ some user interface annoyances under Windows
  • \n\t\t
" + version: 5.3.0 +- date: 2007-03-31 + description: "
    \n\t\t\t\t\t\t
  • Fixed a crash sometimes happening while\ + \ loading results.
  • \n\t\t
" + version: 5.2.7 +- date: 2007-03-25 + description: "
    \n\t\t\t\t\t\t
  • Improved UI responsiveness (using threads)\ + \ under Mac OS X.
  • \n\t\t\t\t\t\t
  • Improved result load/save speed\ + \ and memory usage.
  • \n\t\t\t\t\t\t
  • Fixed a \"bad file descriptor\"\ + \ error occasionally popping up.
  • \n\t\t\t\t\t\t
  • Fixed a bug with\ + \ non-latin directory names.
  • \n\t\t\t\t\t\t
  • Fixed a column mixup\ + \ under Windows. The Artist column couldn't be shown.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ a bug causing the sorting under Power Marker mode not to work under Mac OS X.
  • \n\ + \t\t
" + version: 5.2.6 +- date: 2007-02-14 + description: "
    \n\t\t\t\t\t\t
  • Added Re-orderable columns. In fact,\ + \ I re-added the feature which was lost in the C# conversion in 5.2.0 (Windows).
  • \n\ + \t\t\t\t\t\t
  • Changed the behavior of the scanning engine when setting\ + \ the hardness to 100. It will now only match files that have their words in the\ + \ same order.
  • \n\t\t\t\t\t\t
  • Fixed a bug with all the Delete/Move/Copy\ + \ actions with certain kinds of files.
  • \n\t\t
" + version: 5.2.5 +- date: 2007-01-10 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug with the Move action.
  • \n\ + \t\t\t\t\t\t
  • Fixed a \"ghosting\" bug. Dupes deleted by dupeGuru would\ + \ sometimes come back in subsequent scans (Windows).
  • \n\t\t\t\t\t\t
  • Fixed\ + \ a bug introduced in the last version that caused the status bar not to update\ + \ when dupes were marked (Windows).
  • \n\t\t
" + version: 5.2.4 +- date: 2007-01-04 + description: "
    \n\t\t\t\t\t\t
  • Fixed bugs sometimes making dupeGuru\ + \ crash when marking a dupe (Windows).
  • \n\t\t\t\t\t\t
  • Fixed some\ + \ minor visual glitches (Windows).
  • \n\t\t
" + version: 5.2.3 +- date: 2006-12-21 + description: "
    \n\t\t\t\t\t\t
  • Improved Id3v2.4 tags decoding to support\ + \ some malformed tags that iTunes sometimes produce.
  • \n\t\t\t\t\t\t
  • Improved\ + \ the rename file dialog to exclude the extension from the original selection\ + \ (so when you start typing your new filename, it doesn't overwrite it) (Windows).
  • \n\ + \t\t\t\t\t\t
  • Changed some menu key shortcuts that created conflicts\ + \ (Windows).
  • \n\t\t\t\t\t\t
  • Fixed a bug preventing files from \"\ + reference\" directories to be displayed in blue in the results (Windows).
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug preventing some files to be sent to the recycle\ + \ bin (Windows).
  • \n\t\t\t\t\t\t
  • Fixed a bug with the \"Remove\"\ + \ button of the directories panel (Windows).
  • \n\t\t\t\t\t\t
  • Fixed\ + \ a bug in the packaging preventing certain Windows configurations to start dupeGuru\ + \ at all.
  • \n\t\t
" + version: 5.2.2 +- date: 2006-11-18 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug with directory states.
  • \n\ + \t\t
" + version: 5.2.1 +- date: 2006-11-17 + description: "
    \n\t\t\t\t\t\t
  • Changed the Windows interface. It is\ + \ now .NET based.
  • \n\t\t\t\t\t\t
  • Added an auto-update feature to\ + \ the windows version.
  • \n\t\t\t\t\t\t
  • Changed the way power marking\ + \ works. It is now a mode instead of a separate window.
  • \n\t\t\t\t\t\t
  • Removed\ + \ the min word length/count options. These came from Mp3 Filter, and just aren't\ + \ used anymore. Word weighting does pretty much the same job.
  • \n\t\t\t\t\t\ + \t
  • Fixed a bug sometimes making delete and move operations stall.
  • \n\ + \t\t
" + version: 5.2.0 +- date: 2006-11-03 + description: "
    \n\t\t\t\t\t\t
  • Added an auto-update feature in the Mac\ + \ OS X version (with Sparkle).
  • \n\t\t\t\t\t\t
  • Added a \"Remove Dead\ + \ Tracks in iTunes\" feature in the Mac OS X version.
  • \n\t\t\t\t\t\t
  • Improved\ + \ speed and memory usage of the scanning engine, especially when the scan results\ + \ in a lot of duplicates.
  • \n\t\t\t\t\t\t
  • Improved VBR mp3 support.
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug preventing some duplicate reports to be created\ + \ correctly under Windows.
  • \n\t\t
" + version: 5.1.2 +- date: 2006-09-29 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug (no, not the same as in 5.1.0)\ + \ preventing some duplicates to be found, especially in huge collections.
  • \n\ + \t\t
" + version: 5.1.1 +- date: 2006-09-26 + description: "
    \n\t\t\t\t\t\t
  • Added XHTML export feature.
  • \n\t\t\ + \t\t\t\t
  • Fixed a bug preventing some duplicates to be found when using\ + \ the \"Filename - Fields (No Order)\" scan method.
  • \n\t\t
" + version: 5.1.0 +- date: 2006-08-30 + description: "
    \n\t\t\t\t\t\t
  • Added sticky columns.
  • \n\t\t\t\t\t\ + \t
  • Fixed an issue with file caching between scans.
  • \n\t\t\t\t\t\t\ +
  • Fixed an issue preventing some duplicates from being deleted/moved/copied.
  • \n\ + \t\t
" + version: 5.0.11 +- date: 2006-08-27 + description: "
    \n\t\t\t\t\t\t
  • Fixed an issue with ignore list and unicode.
  • \n\ + \t\t\t\t\t\t
  • Fixed an issue with file attribute fetching sometimes causing\ + \ dupeGuru ME to crash.
  • \n\t\t\t\t\t\t
  • Fixed an issue in the directories\ + \ panel under Windows.
  • \n\t\t
" + version: 5.0.10 +- date: 2006-08-17 + description: "
    \n\t\t\t\t\t\t
  • Fixed an issue in the duplicate seeking\ + \ engine preventing some duplicates to be found.
  • \n\t\t\t\t\t\t
  • (Yeah,\ + \ I'm in a bug fixing frenzy right now :) )
  • \n\t\t
" + version: 5.0.9 +- date: 2006-08-16 + description: "
    \n\t\t\t\t\t\t
  • Fixed an issue with the new track column\ + \ occasionally causing crash.
  • \n\t\t\t\t\t\t
  • Fixed an issue with\ + \ the handling of corrupted files that occasionally caused crash.
  • \n\t\t \ + \
" + version: 5.0.8 +- date: 2006-08-12 + description: "
    \n\t\t\t\t\t\t
  • Improved unicode support.
  • \n\t\t\t\ + \t\t\t
  • Improved the \"Reveal in Finder\" (\"Open Containing Folder\"\ + \ in Windows) feature so it selects the file in the folder it opens.
  • \n\t\t\ + \
" + version: 5.0.7 +- date: 2006-08-08 + description: "
    \n\t\t\t\t\t\t
  • Added the the Track Number detail column.
  • \n\ + \t\t\t\t\t\t
  • Improved the ignore list system.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ a bug in the mp3 metadata decoding unit.
  • \n\t\t\t\t\t\t
  • dupeGuru Music\ + \ Edition is now a Universal application on Mac OS X.
  • \n\t\t
" + version: 5.0.6 +- date: 2006-07-28 + description: "
    \n\t\t\t\t\t\t
  • Improved VBR mp3 metadata decoding.
  • \n\ + \t\t\t\t\t\t
  • Fixed an issue that occasionally made dupeGuru ME crash\ + \ on startup.
  • \n\t\t
" + version: 5.0.5 +- date: 2006-06-26 + description: "
    \n\t\t\t\t\t\t
  • Fixed an issue with Move and Copy features.
  • \n\ + \t\t
" + version: 5.0.4 +- date: 2006-06-17 + description: "
    \n\t\t\t\t\t\t
  • Improved duplicate scanning speed.
  • \n\ + \t\t\t\t\t\t
  • Added a warning that a file couldn't be renamed if a file\ + \ with the same name already exists.
  • \n\t\t
" + version: 5.0.3 +- date: 2006-06-06 + description: "
    \n\t\t\t\t\t\t
  • Added \"Rename Selected\" feature.
  • \n\ + \t\t \t
  • Improved MP3 metadata decoding.
  • \n\t\t\t\t\t\t\ +
  • Fixed some minor issues with \"Reload Last Results\" feature.
  • \n\ + \t\t\t\t\t\t
  • Fixed ignore list issues.
  • \n\t\t
" + version: 5.0.2 +- date: 2006-05-26 + description: "
    \n\t\t \t
  • Fixed occasional progress bar woes\ + \ under Windows.
  • \n\t\t\t\t\t\t
  • Nothing has been changed in the Mac OS\ + \ X version, but I want to keep version in sync.
  • \n\t\t
" + version: 5.0.1 +- date: 2006-05-19 + description: "
    \n\t\t
  • Complete rewrite
  • \n\t\t\t\t\t\t\ +
  • Changed \"Mp3 Filter\" name to \"dupeGuru Music Edition\"
  • \n\t\t\t\t\t\ + \t
  • Now runs on Mac OS X.
  • \n\t\t
" + version: 5.0.0 +- date: 2006-04-13 + description: "
    \n\t\t
  • *fixed* a critical bug introduced\ + \ in 4.2.5: Files couldn't be deleted anymore!
  • \n\t\t
  • *fixed*\ + \ some more issues with WMA decoding.
  • \n\t\t
  • *fixed*\ + \ an issue with profile wizard.
  • \n\t\t
" + version: 4.2.6 +- date: 2006-04-11 + description: "
    \n\t\t
  • *added* a test zone in the Exclusions\ + \ profile section.
  • \n\t\t
  • *fixed* a bug with exclusion\ + \ patterns.
  • \n\t\t
  • *fixed* an issue occuring when\ + \ reading some kinds of WMA files.
  • \n\t\t
" + version: 4.2.5 +- date: 2006-02-16 + description: "
    \n\t\t
  • *fixed* MPL occasional issues\ + \ when saving.
  • \n\t\t
  • *fixed* m4p (protected AAC\ + \ files) bitrate reading.
  • \n\t\t
" + version: 4.2.4 +- date: 2005-10-15 + description: "
    \n\t\t
  • *improved* Added the \"Add Custom\ + \ Extension\" button in the File Priority section of the profile editor.
  • \n\ + \t\t
" + version: 4.2.3 +- date: 2005-10-07 + description: "
    \n\t\t
  • *improved* Results management\ + \ by adding the possibility to remove selected (not only checked) duplicates from\ + \ the list.
  • \n\t\t
  • *fixed* An issue with the \"\ + Switch with reference\" feature.
  • \n\t\t
  • *fixed*\ + \ A stability issue with the result pane.
  • \n\t\t
" + version: 4.2.2 +- date: 2005-09-06 + description: "
    \n\t\t
  • *fixed* A little bug with M4A/M4P\ + \ support.
  • \n\t\t
" + version: 4.2.1 +- date: 2005-08-30 + description: "
    \n\t\t
  • *added* M4A/M4P (iTunes format)\ + \ support.
  • \n\t\t
  • *added* \"Field order doesn't\ + \ matter\" option in Comparison Options.
  • \n\t\t
  • *added*\ + \ A \"Open directory containing this file\" option in the result window's context\ + \ menu.
  • \n\t\t \t
  • *fixed* Some bugs with the \"Load last\ + \ results\" function.
  • \n\t\t
" + version: 4.2.0 +- date: 2005-03-22 + description: "
    \n\t\t \t
  • *fixed* Nasty bug in the wizard\ + \ system.
  • \n\t\t \t
  • *fixed* Yet another nasty bug in\ + \ the Move/Copy option of the result pane.
  • \n\t\t
" + version: 4.1.5 +- date: 2004-11-10 + description: "
    \n\t\t \t
  • *added* \"Load last results\" function.
  • \n\ + \t\t \t
  • *added* Customizable columns in the results window.
  • \n\ + \t\t \t
  • *fixed* A bug related to special characters in the\ + \ XML profiles.
  • \n\t\t \t
  • *fixed* The result window scroll\ + \ didn't move properly on \"Switch with ref.\".
  • \n\t\t \t
  • *fixed*\ + \ A bug with the WMA plugin.
  • \n\t\t
" + version: 4.1.4 +- date: 2004-10-30 + description: "
    \n\t\t \t
  • *added* Profile summary in the\ + \ main window.
  • \n\t\t \t
  • *added* An (Artist + title)\ + \ ID3 tag comparison type.
  • \n\t\t \t
  • *improved* The profile\ + \ system by making it XML based.
  • \n\t\t
" + version: 4.1.3 +- date: 2004-09-28 + description: "
    \n\t\t \t
  • *improved* Changed the ID3 tag\ + \ comparison from (Artist + Title) to (Artist + Title + Album).
  • \n\t\t \ + \
" + version: 4.1.2 +- date: 2004-09-22 + description: "
    \n\t\t \t
  • *fixed* A couple of bugs.
  • \n\ + \t\t
" + version: 4.1.1 +- date: 2004-08-28 + description: "
    \n\t\t \t
  • *added* A \"special selection\"\ + \ wizard in the results window.
  • \n\t\t \t
  • *improved*\ + \ Changed the File content comparison system.
  • \n\t\t \t
  • *fixed*\ + \ A sorting bug in the directory tree displays
  • \n\t\t
" + version: 4.1.0 +- date: 2004-08-10 + description: "
    \n\t\t \t
  • *improved* Redesigned the configuration\ + \ wizard (again!).
  • \n\t\t
" + version: 4.0.6 +- date: 2004-07-23 + description: "
    \n\t\t \t
  • *improved* Redesigned the profile\ + \ directory frame.
  • \n\t\t
  • *fixed* A quite big bug\ + \ with file priority system.
  • \n\t\t
  • *fixed* A bug\ + \ with offline registration.
  • \n\t\t
" + version: 4.0.5 +- date: 2004-07-15 + description: "
    \n\t\t
  • *fixed* A couple of minor bugs\ + \ with profile directories/priorities.
  • \n\t\t
  • *improved*\ + \ Reduced, thus clarified, most of the text in the profile wizard.
  • \n\t\t\ + \
" + version: 4.0.4 +- date: 2004-07-12 + description: "
    \n\t\t
  • *fixed* An issue with \"Similar\ + \ word threshold\" setting, and boosted it's performance.
  • \n\t\t \ + \
  • *fixed* Some issues with the registering system.
  • \n\t\t\ + \
" + version: 4.0.3 +- date: 2004-07-10 + description: "
    \n\t\t
  • *fixed* A couple of obscure bugs.
  • \n\ + \t\t
  • *improved* Changed a couple of minor things in\ + \ this help file.
  • \n\t\t
" + version: 4.0.2 +- date: 2004-07-07 + description: "
    \n\t\t
  • *fixed* A couple of issues with\ + \ the configuration wizard.
  • \n\t\t
  • *fixed* A bug\ + \ with the View Details button when not using WinXP.
  • \n\t\t
" + version: 4.0.1 +- date: 2004-07-05 + description: Mp3 Filter has been rebuilt from scratch for this version. It features + a completely new interface, a profile system and a redesigned configuration wizard. + version: 4.0.0 +- date: 2002-12-31 + description: I never made a history entry for this version, although it has been + the version that went without changes for the most time (1 year and a half). I + also lost track of when I made it, but a quick fix (3.20.0.5) has been made on + 2002/12/31. + version: '3.20' +- date: 2002-08-14 + description: Enhanced the Mp3 List system with locking and improved searching. + version: '3.16' +- date: 2002-08-13 + description: Added Wizard, tips and installation program. + version: '3.15' +- date: 2002-08-12 + description: Added funny animation plugin and Windows Explorer shell extension. + version: '3.14' +- date: 2002-08-11 + description: Minor bugfixes + changed the Edit tag interface. + version: '3.12' +- date: 2002-08-10 + description: Added Import list feature + first 5kb of the files comparison. + version: '3.11' +- date: 2002-07-26 + description: Added extension plugins. + version: '3.10' +- date: 2002-01-30 + description: 'Fixed the ID3 Tag editor a bit. Changed the way comparison works: + it now can use ID3 Tags.' + version: '3.01' +- date: 2002-01-29 + description: The interface simply has been C-O-M-P-L-E-T-E-L-Y redesigned. Customization + level is at it's maximum, too cool. + version: '3.00' +- date: 2002-01-28 + description: Added some speed ONCE AGAIN, improved the memory management and added + a "favourite directories" feature. I also removed some confusing options. The + final result is quite cute! + version: '2.21' +- date: 2002-01-27 + description: Interface has been COMPLETELY rebuilt. Now there are MUCH more place + for everything! Several minor bugs has also been fixed Added mass ID3 tag editing. + version: '2.20' +- date: 2001-12-02 + description: Shareware again. Fixed some major bugs. Rebuilt (again) the mp3 list + system, it's now much more flexible. Added a configuration wizard. Added a renameing + preview. Well, it's a good update after all eh! + version: '2.10' +- date: 2001-12-01 + description: Added multi-language support. Added a "Send to recycle bin" option. + Enhanced rename feature. Corrected some bugs with rename function. Enhanced list + search function. + version: '2.01' +- date: 2001-11-30 + description: "As 11 Sept 2001 entered in the History, the release date of this program\ + \ will too! Ok, here is the list of Mp3 Filter version 2.00 godly features:\r\n\ + \r\n* **SPEED!!!!!!!!!!!!** Forget about what I said before. Previous versions\ + \ were TURTLES compared to that one. (Imagine what other programs are eh! :P).\ + \ What took 1 minute take 3-5 seconds now, and the more files you have to compare\ + \ together, the better will be the files/time ratio will be!\r\n* Multi-list system.\ + \ It is now easier than ever to exchange lists with your friends and select songs!\r\ + \n* Cuter interface." + version: '2.00' +- date: 2001-06-29 + description: There was some stability issues with the internal player I was using. + Mp3 Filter is now using Winamp. Thus, all files playable by Winamp are now playable + by Mp3 Filter. Fixed some minor bugs. Changed the way word exclusion system work. + AND added a song selection system. Now you can select songs from your mp3 list + and copy them to your hard drive without having to worry about where are these + songs. + version: '1.61' +- date: 2001-06-28 + description: 'The main theme of this update is efficiency. Mp3 Filter v1.53 was + already pure speed, you will NOT believe this version''s one. 60% faster on ALL + comparisons! Do not search for God anymore, you found Him and He even got an e-mail + adress: cathedly@hotmail.com :P. Ok, to tell you the truth I did not make Mp3 + Filter 60 % faster, I made it 60% less slow. My previous algorithm wasn''t bad, + but I thought about another one (this one) that has much better performances. + ALSO: Created an option form. Changed the results display (Added some info along + with the results (size,length,bitrate). Added a word excluding system. Also added + a backup system (Instead of deleting it, you can now move your file to the Mp3 + Filter backup directory (Mp3 filter does not compare files in the backup directory).' + version: '1.60' +- date: 2001-06-27 + description: Damnit, big update. Added the conditional file searching, file copying, + and rethought the Mp3List system. That new Mp3List system is damn cool! It load + instantly, even with HUGE lists, and it reduces the comparing time with list by + 30 godly % !!! You're not gonna believe it! This program is now PURE SPEED! + version: '1.53' +- date: 2001-05-05 + description: Quite cool update too. This version now can check if new versions are + available. I also grouped all options in the same menu. I moved the search function. + This function is now a lot cooler. Instead of giving you a list of matching results, + it shows you, in the Mp3 List Stats form, where the song is by positioning itself + in the List Tree. + version: '1.52' +- date: 2001-05-04 + description: Waa! I'm so happy! I implemented a poll system to Mp3 Filter! Now you + can answer my questions directly on the program! I can't wait to see if you, people, + will answer! + version: '1.51' +- date: 2001-05-03 + description: 'MAJOR UPDATE. This one is quite cool :). You ever used the "Edit Mp3 + List" feature? I improved it a lot. Now, when you add a CD to your list, it not + only saves the CD name, but it also saves the whole CD directory system. So when + you use ''Edit Mp3 List'' now, you can browse your CDs as if you would browse + anything. (There''s only one problem: you must REbuild your mp3 list to make it + fit with v1.5)' + version: '1.50' +- date: 2001-05-02 + description: Added an equalizer. This equalizer has been a good reason to add an + INI file to the program to store changed parameters. + version: '1.46' +- date: 2001-05-01 + description: Added a Banlist to the program. You write down a list of unwanted songs + in your ban list and start a scan. This function will not compare as the rest + does. If ALL words contained in a banlist line are in a filename, it will match. + version: '1.45' +- date: 2001-04-30 + description: I made the Mp3 Filter window to minimize when it compares so it can + do it faster (a LOT faster). I also modified the program so it checks the playlist + integrity each time there's a file deleted after a comparison). There was a bug + with the v1.43. When you had the ID3 Tag window up and you closed the program, + it would crash. Fixed that. + version: '1.44' +- date: 2001-04-29 + description: I noticed some days ago that people who had a good resolution but the + option to enlarge the icons on, Mp3 Filter had some big problems to display its + main form right. Since you cant resize the form without having the objects in + to resize too, I had to fix it. I also implemented a MUCH faster file searching + system. It takes less than 2 seconds to find all mp3 on my hard disk now. + version: '1.43' +- date: 2001-04-09 + description: Added some fun and useful feats. First, I made a cute playlist right-click + menu with Play File, Edit Tag, Locate, Remove from list and delete from disk. + I also added a recursive function to add songs to the playlist (Why didn't I think + about it before?? I have no clue...). I also made the Shuffle thing less.... random. + (It builds a random list and play it, so before a song play again, all songs will + be played (I added that feat some time after v1.41 release, but I didn't thought + that it worth a version change, so I only announce it on 1.42)) + version: '1.42' +- date: 2001-04-08 + description: I can't avoid it. there is always some bugs after a major update. I + didn't thought about the fact that it was possible to make a playlist with unplayable + files :) fixed that. + version: '1.41' +- date: 2001-04-07 + description: MAJOR UPDATE! You wanted a playlist. You got it in this version. Those + big buttons were ugly? Made a cute standard menu. You didn't seem to want to buy + that program. I gave up. Here is it. Freeware again. + version: '1.40' +- date: 2001-03-16 + description: Made it possible to play files that are listed after a "Find all Mp3 + on this drive". It also tells what song is currently playing on the main title + bar. + version: '1.36' +- date: 2001-03-15 + description: Added a system icon. Wow! it almost looks like winamp! + version: '1.35' +- date: 2001-03-14 + description: Added music progress bar and made the music playing continuous. + version: '1.34' +- date: 2001-03-13 + description: Added mass renaming functions. + version: '1.33' +- date: 2001-03-12 + description: Fixed some bugs with those useful function :) (and made the program + shareware) + version: '1.32' +- date: 2001-03-11 + description: Added some useful functions. + version: '1.31' +- date: 2001-03-10 + description: MAJOR CHANGE. yeah! I scrapped those radio buttons, and extended the + "recurse" function. Now, you only have 2 choices. Or you compare with your list, + or you compare within the folder (and sub-folders). With that system, you can + tell the program to just compare ALL mp3 in your hard drive. You just have to + select your drive root, and press "Find dupe files in this folder" having "Recurse" + checked. + version: '1.30' +- date: 2001-03-09 + description: Added the Music Control panel. I just love it. do you? + version: '1.23' +- date: 2001-03-08 + description: Fixed some inaccuracy with folder to folder comparison. (Will I be + done fixing someday??) and made mp3 search slightly faster. + version: '1.22' +- date: 2001-03-07 + description: Added Mp3 Player (Didn't know it was so easy to include in a program! + I woulda done this before if I knew...) and ID3 Tag Editor. Hum, The Mp3 Player + has some problems reading some mp3s... know that. + version: '1.21' +- date: 2001-03-04 + description: When I removed the "find mp3 in my list" thing, some people told me + it was useful. However, I still think that the old way to search mp3 was too messed + up, so I just added a little textbox and a search button for quick search. + version: '1.20' +- date: 2001-03-03 + description: Damnit! why didn't I see it? There was a bug with displaying the right + filename on the result boxes with List comparing. The results were switched! Thus, + deleting was impossible after a List compare. Corrected it in 1.18. + version: '1.18' +- date: 2001-03-03 + description: "When I read Yippee review (www.yippee.net thanks for review), I tried\ + \ to somewhat improve it. What changed? This:\r\n\r\n* Compare engine is less\ + \ strict. if one word contains the other, it now match (now, \"Limp Bizkit\" and\ + \ \"Limp Bizkitt\" would match)\r\n* Removed some useless features to simplify\ + \ the interface. \"Save as...\" (why did I put this on???) and \"Find file in\ + \ my list\" (Easier to find a song with \"Edit List\")\r\n* Added some hints to\ + \ buttons and some explicative labels.\r\n* \"List Stats\" changed to \"Edit\ + \ List\" so you don't have to \"hard change\" your mp3 list." + version: '1.19' +- date: 2001-02-06 + description: I never thought that a software history would be useful for such a + small program, but since Mp3 filter won't stop improving, I decided to start it. + So 1.17 is the base version. + version: '1.17' diff --git a/me/help/gen.py b/me/help/gen.py new file mode 100644 index 00000000..7fa6be01 --- /dev/null +++ b/me/help/gen.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python + +import os + +from web import generate_help + +generate_help.main('.', 'dupeguru_me_help', force_render=True) \ No newline at end of file diff --git a/me/help/skeleton/hardcoded.css b/me/help/skeleton/hardcoded.css new file mode 100644 index 00000000..a3b17c5b --- /dev/null +++ b/me/help/skeleton/hardcoded.css @@ -0,0 +1,409 @@ +/***************************************************** + General settings +*****************************************************/ + +BODY +{ + background-color:white; +} + +BODY,A,P,UL,TABLE,TR,TD +{ + font-family:Tahoma,Arial,sans-serif; + font-size:10pt; + color: #4477AA;/*darker than 5588bb for the sake of the eyes*/ +} + +/***************************************************** + "A" settings +*****************************************************/ + +A +{ + color: #ae322b; + text-decoration:underline; + font-weight:bold; +} + +A.glossaryword {color:#A0A0A0;} + +A.noline +{ + text-decoration: none; +} + + +/***************************************************** + Menu and mainframe settings +*****************************************************/ + +.maincontainer +{ + display:block; + margin-left:7%; + margin-right:7%; + padding-left:5px; + padding-right:0px; + border-color:#CCCCCC; + border-style:solid; + border-width:2px; + border-right-width:0px; + border-bottom-width:0px; + border-top-color:#ae322b; + vertical-align:top; +} + +TD.menuframe +{ + width:30%; +} + +.menu +{ + margin:4px 4px 4px 4px; + margin-top: 16pt; + border-color:gray; + border-width:1px; + border-style:dotted; + padding-top:10pt; + padding-bottom:10pt; + padding-right:6pt; +} + +.submenu +{ + list-style-type: none; + margin-left:26pt; + margin-top:0pt; + margin-bottom:0pt; + padding-left:0pt; +} + +A.menuitem,A.menuitem_selected +{ + font-size:14pt; + font-family:Tahoma,Arial,sans-serif; + font-weight:normal; + padding-left:10pt; + color:#5588bb; + margin-right:2pt; + margin-left:4pt; + text-decoration:none; +} + +A.menuitem_selected +{ + font-weight:bold; +} + +A.submenuitem +{ + font-family:Tahoma,Arial,sans-serif; + font-weight:normal; + color:#5588bb; + text-decoration:none; +} + +.titleline +{ + border-width:3px; + border-style:solid; + border-left-width:0px; + border-right-width:0px; + border-top-width:0px; + border-color:#CCCCCC; + margin-left:28pt; + margin-right:2pt; + line-height:1px; + padding-top:0px; + margin-top:0px; + display:block; +} + +.titledescrip +{ + text-align:left; + display:block; + margin-left:26pt; + color:#ae322b; +} + +.mainlogo +{ + display:block; + margin-left:8%; + margin-top:4pt; + margin-bottom:4pt; +} + +/***************************************************** + IMG settings +*****************************************************/ + +IMG +{ + border-style:none; +} + +IMG.smallbutton +{ + margin-right: 20px; + float:none; +} + +IMG.floating +{ + float:left; + margin-right: 4pt; + margin-bottom: 4pt; +} + +IMG.lefticon +{ + vertical-align: middle; + padding-right: 2pt; +} + +IMG.righticon +{ + vertical-align: middle; + padding-left: 2pt; +} + +/***************************************************** + TABLE settings +*****************************************************/ + +TABLE +{ + border-style:none; +} + +TABLE.box +{ + width: 90%; + margin-left:5%; +} + +TABLE.centered +{ + margin-left: auto; + margin-right: auto; +} + +TABLE.hardcoded +{ + background-color: #225588; + margin-left: auto; + margin-right: auto; + width: 90%; +} + +TR { background-color: transparent; } + +TABLE.hardcoded TR { background-color: white } + +TABLE.hardcoded TR.header +{ + font-weight: bold; + color: black; + background-color: #C8D6E5; +} + +TABLE.hardcoded TR.header TD {color:black;} + +TABLE.hardcoded TD { padding-left: 2pt; } + +TD.minimelem { + padding-right:0px; + padding-left:0px; + text-align:center; +} + +TD.rightelem +{ + text-align:right; + /*padding-left:0pt;*/ + padding-right: 2pt; + width: 17%; +} + +/***************************************************** + P settings +*****************************************************/ + +p,.sub{text-align:justify;} +.centered{text-align:center;} +.sub +{ + padding-left: 16pt; + padding-right:16pt; +} + +.Note, .ContactInfo +{ + border-color: #ae322b; + border-width: 1pt; + border-style: dashed; + text-align:justify; + padding: 2pt 2pt 2pt 2pt; + margin-bottom:4pt; + margin-top:8pt; + list-style-position:inside; +} + +.ContactInfo +{ + width:60%; + margin-left:5%; +} + +.NewsItem +{ + border-color:#ae322b; + border-style: solid; + border-right:none; + border-top:none; + border-left:none; + border-bottom-width:1px; + text-align:justify; + padding-left:4pt; + padding-right:4pt; + padding-bottom:8pt; +} + +/***************************************************** + Lists settings +*****************************************************/ +UL.plain +{ + list-style-type: none; + padding-left:0px; + margin-left:0px; +} + +LI.plain +{ + list-style-type: none; +} + +LI.section +{ + padding-top: 6pt; +} + +UL.longtext LI +{ + border-color: #ae322b; + border-width:0px; + border-top-width:1px; + border-style:solid; + margin-top:12px; +} + +/* + with UL.longtext LI, there can be anything between + the UL and the LI, and it will still make the + lontext thing, I must break it with this hack +*/ +UL.longtext UL LI +{ + border-style:none; + margin-top:2px; +} + + +/***************************************************** + Titles settings +*****************************************************/ + +H1,H2,H3 +{ + font-family:"Courier New",monospace; + color:#5588bb; +} + +H1 +{ + font-size:18pt; + color: #ae322b; + border-color: #70A0CF; + border-width: 1pt; + border-style: solid; + margin-top: 16pt; + margin-left: 5%; + margin-right: 5%; + padding-top: 2pt; + padding-bottom:2pt; + text-align: center; +} + +H2 +{ + border-color: #ae322b; + border-bottom-width: 2px; + border-top-width: 0pt; + border-left-width: 2px; + border-right-width: 0pt; + border-bottom-color: #cccccc; + border-style: solid; + margin-top: 16pt; + margin-left: 0pt; + margin-right: 0pt; + padding-bottom:3pt; + padding-left:5pt; + text-align: left; + font-size:16pt; +} + +H3 +{ + display:block; + color:#ae322b; + border-color: #70A0CF; + border-bottom-width: 2px; + border-top-width: 0pt; + border-left-width: 0pt; + border-right-width: 0pt; + border-style: dashed; + margin-top: 12pt; + margin-left: 0pt; + margin-bottom: 4pt; + width:auto; + padding-bottom:3pt; + padding-right:2pt; + padding-left:2pt; + text-align: left; + font-weight:bold; +} + + +/***************************************************** + Misc. classes +*****************************************************/ +.longtext:first-letter {font-size: 150%} + +.price, .loweredprice, .specialprice {font-weight:bold;} + +.loweredprice {text-decoration:line-through} + +.specialprice {color:red} + +form +{ + margin:0px; +} + +.program_summary +{ + float:right; + margin: 32pt; + margin-top:0pt; + margin-bottom:0pt; +} + +.screenshot +{ + float:left; + margin: 8pt; +} \ No newline at end of file diff --git a/me/help/skeleton/images/hs_title.png b/me/help/skeleton/images/hs_title.png new file mode 100644 index 0000000000000000000000000000000000000000..07bd89c69dd50a3967b46a441741f274b8854de8 GIT binary patch literal 1817 zcmWkt2~Y|q^t%aD27PTfWQbSo7y0Xrdp{OF7oMwX4{+byFr3&y7B zlLpel1d+*^<>$!E@c2A9sOuxRq}jS+G}rcy>y2h&rngp=tT$NjxX)#-@ zR;$fwvyo;C3xs)cj4wl35`-y%c{0>!qGG~a8Nvb)0K&L3RHVdNQtA}p%Q4Dz$v^^f z%s`q=W-AZEN|l62NeGp=(QGM^zcEYpnJq53Zg_psX$pDf}jksh9!YZ6*y0d6t`*06htZIC4pHW%9Epf zh*AWzsT7ofh)_XrrKsL$;mT2tj52IVPjY1#SB?oFlp&##5=^Q9qlF{I7FJ9G2rF*a z2s?-pFR&PpFjtBRA?yV{E9}sUAgt)Ih9}1>x(SvT zsqP+UieWa0F{DUM&ub2ZbHs4dvq=VsEE&xV>e|H!OM+4TQTc&M3CEU=q(F(^fAYG# zOS_;aD|@ud29hH~gpd|cpbwr+aO8+kiBoq^eznVg<_$ zP1%d1$UV?R~TV5+i6w>mO|X i&$iNccmR(ItgDH2&`ti^@h8+@ro}}kMz!wGy!C(b`7gZy literal 0 HcmV?d00001 diff --git a/me/help/templates/base_dg.mako b/me/help/templates/base_dg.mako new file mode 100644 index 00000000..7767c49f --- /dev/null +++ b/me/help/templates/base_dg.mako @@ -0,0 +1,14 @@ +<%inherit file="/base_help.mako"/> +${next.body()} + +<%def name="menu()"><% +self.menuitem('intro.htm', 'Introduction', 'Introduction to dupeGuru') +self.menuitem('quick_start.htm', 'Quick Start', 'Quickly get into the action') +self.menuitem('directories.htm', 'Directories', 'Managing dupeGuru directories') +self.menuitem('preferences.htm', 'Preferences', 'Setting dupeGuru preferences') +self.menuitem('results.htm', 'Results', 'Time to delete these duplicates!') +self.menuitem('power_marker.htm', 'Power Marker', 'Take control of your duplicates') +self.menuitem('faq.htm', 'F.A.Q.', 'Frequently Asked Questions') +self.menuitem('versions.htm', 'Version History', 'Changes dupeGuru went through') +self.menuitem('credits.htm', 'Credits', 'People who contributed to dupeGuru') +%> \ No newline at end of file diff --git a/me/help/templates/credits.mako b/me/help/templates/credits.mako new file mode 100644 index 00000000..a170ec3d --- /dev/null +++ b/me/help/templates/credits.mako @@ -0,0 +1,20 @@ +<%! + title = 'Credits' + selected_menu_item = 'Credits' +%> +<%inherit file="/base_dg.mako"/> +Below is the list of people who contributed, directly or indirectly to dupeGuru. + +${self.credit('Virgil Dupras', 'Developer', "That's me, Hardcoded Software founder", 'www.hardcoded.net', 'hsoft@hardcoded.net')} + +${self.credit('Python', 'Programming language', "The bestest of the bests", 'www.python.org')} + +${self.credit('PyObjC', 'Python-to-Cocoa bridge', "Used for the Mac OS X version", 'pyobjc.sourceforge.net')} + +${self.credit('PyQt', 'Python-to-Qt bridge', "Used for the Windows version", 'www.riverbankcomputing.co.uk')} + +${self.credit('Qt', 'GUI Toolkit', "Used for the Windows version", 'www.qtsoftware.com')} + +${self.credit('Sparkle', 'Auto-update library', "Used for the Mac OS X version", 'andymatuschak.org/pages/sparkle')} + +${self.credit('You', 'dupeGuru user', "What would I do without you?")} diff --git a/me/help/templates/directories.mako b/me/help/templates/directories.mako new file mode 100644 index 00000000..e75b47bd --- /dev/null +++ b/me/help/templates/directories.mako @@ -0,0 +1,24 @@ +<%! + title = 'Directories' + selected_menu_item = 'Directories' +%> +<%inherit file="/base_dg.mako"/> + +There is a panel in dupeGuru called **Directories**. You can open it by clicking on the **Directories** button. This directory contains the list of the directories that will be scanned when you click on **Start Scanning**. + +This panel is quite straightforward to use. If you want to add a directory, click on **Add**. If you added directories before, a popup menu with a list of recent directories you added will pop. You can click on one of them to add it directly to your list. If you click on the first item of the popup menu, **Add New Directory...**, you will be prompted for a directory to add. If you never added a directory, no menu will pop and you will directly be prompted for a new directory to add. + +To remove a directory, select the directory to remove and click on **Remove**. If a subdirectory is selected when you click remove, the selected directory will be set to **excluded** state (see below) instead of being removed. + +Directory states +----- + +Every directory can be in one of these 3 states: + +* **Normal:** Duplicates found in these directories can be deleted. +* **Reference:** Duplicates found in this directory **cannot** be deleted. Files in reference directories will be in a blue color in the results. +* **Excluded:** Files in this directory will not be included in the scan. + +The default state of a directory is, of course, **Normal**. You can use **Reference** state for a directory if you want to be sure that you won't delete any file from it. + +When you set the state of a directory, all subdirectories of this directory automatically inherit this state unless you explicitly set a subdirectory's state. diff --git a/me/help/templates/faq.mako b/me/help/templates/faq.mako new file mode 100644 index 00000000..13e2e601 --- /dev/null +++ b/me/help/templates/faq.mako @@ -0,0 +1,67 @@ +<%! + title = 'dupeGuru ME F.A.Q.' + selected_menu_item = 'F.A.Q.' +%> +<%inherit file="/base_dg.mako"/> + +<%text filter="md"> +### What is dupeGuru Music Edition? + +dupeGuru Music Edition is a tool to find duplicate songs in your music collection. It can base its scan on filenames, tags or content. The filename and tag scans feature a fuzzy matching algorithm that can find duplicate filenames or tags even when they are not exactly the same. + +### What makes it better than other duplicate scanners? + +The scanning engine is extremely flexible. You can tweak it to really get the kind of results you want. You can read more about dupeGuru tweaking option at the [Preferences page](preferences.htm). + +### How safe is it to use dupeGuru ME? + +Very safe. dupeGuru has been designed to make sure you don't delete files you didn't mean to delete. First, there is the reference directory system that lets you define directories where you absolutely **don't** want dupeGuru to let you delete files there, and then there is the group reference system that makes sure that you will **always** keep at least one member of the duplicate group. + +### What are the demo limitations of dupeGuru ME? + +In demo mode, you can only perform actions (delete/copy/move) on 10 duplicates per session. + +### The mark box of a file I want to delete is disabled. What must I do? + +You cannot mark the reference (The first file) of a duplicate group. However, what you can do is to promote a duplicate file to reference. Thus, if a file you want to mark is reference, select a duplicate file in the group that you want to promote to reference, and click on **Actions-->Make Selected Reference**. If the reference file is from a reference directory (filename written in blue letters), you cannot remove it from the reference position. + +### I have a directory from which I really don't want to delete files. + +If you want to be sure that dupeGuru will never delete file from a particular directory, just open the **Directories panel**, select that directory, and set its state to **Reference**. + +### What is this '(X discarded)' notice in the status bar? + +In some cases, some matches are not included in the final results for security reasons. Let me use an example. We have 3 file: A, B and C. We scan them using a low filter hardness. The scanner determines that A matches with B, A matches with C, but B does **not** match with C. Here, dupeGuru has kind of a problem. It cannot create a duplicate group with A, B and C in it because not all files in the group would match together. It could create 2 groups: one A-B group and then one A-C group, but it will not, for security reasons. Lets think about it: If B doesn't match with C, it probably means that either B, C or both are not actually duplicates. If there would be 2 groups (A-B and A-C), you would end up delete both B and C. And if one of them is not a duplicate, that is really not what you want to do, right? So what dupeGuru does in a case like this is to discard the A-C match (and adds a notice in the status bar). Thus, if you delete B and re-run a scan, you will have a A-C match in your next results. + +### I want to mark all files from a specific directory. What can I do? + +Enable the [Power Marker](power_marker.htm) mode and click on the Directory column to sort your duplicates by Directory. It will then be easy for you to select all duplicates from the same directory, and then press Space to mark all selected duplicates. + +### I want to remove all songs that are more than 3 seconds away from their reference file. What can I do? + +* Enable the [Power Marker](power_marker.htm) mode. +* Enable the **Delta Values** mode. +* Click on the "Time" column to sort the results by time. +* Select all duplicates below -00:03. +* Click on **Remove Selected from Results**. +* Select all duplicates over 00:03. +* Click on **Remove Selected from Results**. + +### I want to make my highest bitrate songs reference files. What can I do? + +* Enable the [Power Marker](power_marker.htm) mode. +* Enable the **Delta Values** mode. +* Click on the "Bitrate" column to sort the results by bitrate. +* Click on the "Bitrate" column again to reverse the sort order (see Power Marker page to know why). +* Select all duplicates over 0. +* Click on **Make Selected Reference**. + +### I don't want [live] and [remix] versions of my songs counted as duplicates. How do I do that? + +If your comparison threshold is low enough, you will probably end up with live and remix versions of your songs in your results. There's nothing you can do to prevent that, but there's something you can do to easily remove them from your results after the scan: post-scan filtering. If, for example, you want to remove every song with anything inside square brackets []: + +* **Windows**: Click on **Actions --> Apply Filter**, then type "[*]", then click OK. +* **Mac OS X**: Type "[*]" in the "Filter" field in the toolbar. +* Click on **Mark --> Mark All**. +* Click on **Actions --> Remove Selected from Results**. + \ No newline at end of file diff --git a/me/help/templates/intro.mako b/me/help/templates/intro.mako new file mode 100644 index 00000000..2381895d --- /dev/null +++ b/me/help/templates/intro.mako @@ -0,0 +1,13 @@ +<%! + title = 'Introduction to dupeGuru ME' + selected_menu_item = 'introduction' +%> +<%inherit file="/base_dg.mako"/> + +dupeGuru Music Edition is a tool to find duplicate files on your computer. It can scan either filenames or contents. The filename scan features a fuzzy matching algorithm that can find duplicate filenames even when they are not exactly the same. + +Although dupeGuru can easily be used without documentation, reading this file will help you to master it. If you are looking for guidance for your first duplicate scan, you can take a look at the [Quick Start](quick_start.htm) section. + +It is a good idea to keep dupeGuru updated. You can download the latest version on the [dupeGuru ME homepage](http://www.hardcoded.net/dupeguru_me/). + +<%def name="meta()"> diff --git a/me/help/templates/power_marker.mako b/me/help/templates/power_marker.mako new file mode 100644 index 00000000..26078f3d --- /dev/null +++ b/me/help/templates/power_marker.mako @@ -0,0 +1,33 @@ +<%! + title = 'Power Marker' + selected_menu_item = 'Power Marker' +%> +<%inherit file="/base_dg.mako"/> + +You will probably not use the Power Marker feature very often, but if you get into a situation where you need it, you will be pretty happy that this feature exists. + +What is it? +----- + +When the Power Marker mode is enabled, the duplicates are shown without their respective reference file. You can select, mark and sort this list, just like in normal mode. + +So, what is it for? +----- + +The dupeGuru results, when in normal mode, are sorted according to duplicate groups' **reference file**. This means that if you want, for example, to mark all duplicates with the "exe" extension, you cannot just sort the results by "Kind" to have all exe duplicates together because a group can be composed of more than one kind of files. That is where Power Marker comes into play. To mark all your "exe" duplicates, you just have to: + +* Enable the Power marker mode. +* Add the "Kind" column with the "Columns" menu. +* Click on that "Kind" column to sort the list by kind. +* Locate the first duplicate with a "exe" kind. +* Select it. +* Scroll down the list to locate the last duplicate with a "exe" kind. +* Hold Shift and click on it. +* Press Space to mark all selected duplicates. + +Power Marker and delta values +----- + +The Power Marker unveil its true power when you use it with the **Delta Values** switch turned on. When you turn it on, relative values will be displayed instead of absolute ones. So if, for example, you want to remove from your results all duplicates that are more than 300 KB away from their reference, you could sort the Power Marker by Size, select all duplicates under -300 in the Size column, delete them, and then do the same for duplicates over 300 at the bottom of the list. + +You could also use it to change the reference priority of your duplicate list. When you make a fresh scan, if there are no reference directories, the reference file of every group is the biggest file. If you want to change that, for example, to the latest modification time, you can sort the Power Marker by modification time in **descending** order, select all duplicates with a modification time delta value higher than 0 and click on **Make Selected Reference**. The reason why you must make the sort order descending is because if 2 files among the same duplicate group are selected when you click on **Make Selected Reference**, only the first of the list will be made reference, the other will be ignored. And since you want the last modified file to be reference, having the sort order descending assures you that the first item of the list will be the last modified. diff --git a/me/help/templates/preferences.mako b/me/help/templates/preferences.mako new file mode 100644 index 00000000..52006d49 --- /dev/null +++ b/me/help/templates/preferences.mako @@ -0,0 +1,36 @@ +<%! + title = 'Preferences' + selected_menu_item = 'Preferences' +%> +<%inherit file="/base_dg.mako"/> + +**Scan Type:** This option determines what aspect of the files will be compared in the duplicate scan. The nature of the duplicate scan varies greatly depending on what you select for this option. + +* **Filename:** Every song will have its filename split into words, and then every word will be compared to compute a matching percentage. If this percentage is higher or equal to the **Filter Hardness** (see below for more details), dupeGuru will consider the 2 songs duplicates. +* **Filename - Fields:** Like **Filename**, except that once filename have been split into words, these words are then grouped into fields. The field separator is " - ". The final matching percentage will be the lowest matching percentage among the fields. Thus, "An Artist - The Title" and "An Artist - Other Title" would have a matching percentage of 50 (With a **Filename** scan, it would be 75). +* **Filename - Fields (No Order):** Like **Filename - Fields**, except that field order doesn't matter. For example, "An Artist - The Title" and "The Title - An Artist" would have a matching percentage of 100 instead of 0. +* **Tags:** This method reads the tag (metadata) of every song and compare their fields. This method, like the **Filename - Fields**, considers the lowest matching field as its final matching percentage. +* **Content:** This scan method use the actual content of the songs to determine which are duplicates. For 2 songs to match with this method, they must have the **exact same content**. +* **Audio Content:** Same as content, but only the audio content is compared (without metadata). + +**Filter Hardness:** If you chose a filename or tag based scan type, this option determines how similar two filenames/tags must be for dupeGuru to consider them duplicates. If the filter hardness is, for example 80, it means that 80% of the words of two filenames must match. To determine the matching percentage, dupeGuru first counts the total number of words in **both** filenames, then count the number of words matching (every word matching count as 2), and then divide the number of words matching by the total number of words. If the result is higher or equal to the filter hardness, we have a duplicate match. For example, "a b c d" and "c d e" have a matching percentage of 57 (4 words matching, 7 total words). + +**Tags to scan:** When using the **Tags** scan type, you can select the tags that will be used for comparison. + +**Word weighting:** If you chose a filename or tag based scan type, this option slightly changes how matching percentage is calculated. With word weighting, instead of having a value of 1 in the duplicate count and total word count, every word have a value equal to the number of characters they have. With word weighting, "ab cde fghi" and "ab cde fghij" would have a matching percentage of 53% (19 total characters, 10 characters matching (4 for "ab" and 6 for "cde")). + +**Match similar words:** If you turn this option on, similar words will be counted as matches. For example "The White Stripes" and "The White Stripe" would have a match % of 100 instead of 66 with that option turned on. **Warning:** Use this option with caution. It is likely that you will get a lot of false positives in your results when turning it on. However, it will help you to find duplicates that you wouldn't have found otherwise. The scan process also is significantly slower with this option turned on. + +**Can mix file kind:** If you check this box, duplicate groups are allowed to have files with different extensions. If you don't check it, well, they aren't! + +**Use regular expressions when filtering:** If you check this box, the filtering feature will treat your filter query as a **regular expression**. Explaining them is beyond the scope of this document. A good place to start learning it is . + +**Remove empty folders after delete or move:** When this option is enabled, folders are deleted after a file is deleted or moved and the folder is empty. + +**Copy and Move:** Determines how the Copy and Move operations (in the Action menu) will behave. + +* **Right in destination:** All files will be sent directly in the selected destination, without trying to recreate the source path at all. +* **Recreate relative path:** The source file's path will be re-created in the destination directory up to the root selection in the Directories panel. For example, if you added "/Users/foobar/Music" to your Directories panel and you move "/Users/foobar/Music/Artist/Album/the_song.mp3" to the destination "/Users/foobar/MyDestination", the final destination for the file will be "/Users/foobar/MyDestination/Artist/Album" ("/Users/foobar/Music" has been trimmed from source's path in the final destination.). +* **Recreate absolute path:** The source file's path will be re-created in the destination directory in it's entirety. For example, if you move "/Users/foobar/Music/Artist/Album/the_song.mp3" to the destination "/Users/foobar/MyDestination", the final destination for the file will be "/Users/foobar/MyDestination/Users/foobar/Music/Artist/Album". + +In all cases, dupeGuru nicely handles naming conflicts by prepending a number to the destination filename if the filename already exists in the destination. diff --git a/me/help/templates/quick_start.mako b/me/help/templates/quick_start.mako new file mode 100644 index 00000000..dde33c65 --- /dev/null +++ b/me/help/templates/quick_start.mako @@ -0,0 +1,18 @@ +<%! + title = 'Quick Start' + selected_menu_item = 'Quick Start' +%> +<%inherit file="/base_dg.mako"/> + +To get you quickly started with dupeGuru, let's just make a standard scan using default preferences. + +* Click on **Directories**. +* Click on **Add**. +* Choose a directory you want to scan for duplicates. +* Click on **Start Scanning**. +* Wait until the scan process is over. +* Look at every duplicate (The files that are indented) and verify that it is indeed a duplicate to the group's reference (The file above the duplicate that is not indented and have a disabled mark box). +* If a file is a false duplicate, select it and click on **Actions-->Remove Selected from Results**. +* Once you are sure that there is no false duplicate in your results, click on **Edit-->Mark All**, and then **Actions-->Send Marked to Recycle bin**. + +That is only a basic scan. There are a lot of tweaking you can do to get different results and several methods of examining and modifying your results. To know about them, just read the rest of this help file. diff --git a/me/help/templates/results.mako b/me/help/templates/results.mako new file mode 100644 index 00000000..53aa176f --- /dev/null +++ b/me/help/templates/results.mako @@ -0,0 +1,73 @@ +<%! + title = 'Results' + selected_menu_item = 'Results' +%> +<%inherit file="/base_dg.mako"/> + +When dupeGuru is finished scanning for duplicates, it will show its results in the form of duplicate group list. + +About duplicate groups +----- + +A duplicate group is a group of files that all match together. Every group has a **reference file** and one or more **duplicate files**. The reference file is the first file of the group. Its mark box is disabled. Below it, and indented, are the duplicate files. + +You can mark duplicate files, but you can never mark the reference file of a group. This is a security measure to prevent dupeGuru from deleting not only duplicate files, but their reference. You sure don't want that, do you? + +What determines which files are reference and which files are duplicates is first their directory state. A files from a reference directory will always be reference in a duplicate group. If all files are from a normal directory, the size determine which file will be the reference of a duplicate group. dupeGuru assumes that you always want to keep the biggest file, so the biggest files will take the reference position. + +You can change the reference file of a group manually. To do so, select the duplicate file you want to promote to reference, and click on **Actions-->Make Selected Reference**. + +Reviewing results +----- + +Although you can just click on **Edit-->Mark All** and then **Actions-->Send Marked to Recycle bin** to quickly delete all duplicate files in your results, it is always recommended to review all duplicates before deleting them. + +To help you reviewing the results, you can bring up the **Details panel**. This panel shows all the details of the currently selected file as well as its reference's details. This is very handy to quickly determine if a duplicate really is a duplicate. You can also double-click on a file to open it with its associated application. + +If you have more false duplicates than true duplicates (If your filter hardness is very low), the best way to proceed would be to review duplicates, mark true duplicates and then click on **Actions-->Send Marked to Recycle bin**. If you have more true duplicates than false duplicates, you can instead mark all files that are false duplicates, and use **Actions-->Remove Marked from Results**. + +Marking and Selecting +----- + +A **marked** duplicate is a duplicate with the little box next to it having a check-mark. A **selected** duplicate is a duplicate being highlighted. The multiple selection actions can be performed in dupeGuru in the standard way (Shift/Command/Control click). You can toggle all selected duplicates' mark state by pressing **space**. + +Delta Values +----- + +If you turn this switch on, some columns will display the value relative to the duplicate's reference instead of the absolute values. These delta values will also be displayed in a different color so you can spot them easily. For example, if a duplicate is 1.2 MB and its reference is 1.4 MB, the Size column will display -0.2 MB. This option is a killer feature when combined with the [Power Marker](power_marker.htm). + +Filtering +----- + +dupeGuru supports post-scan filtering. With it, you can narrow down your results so you can perform actions on a subset of it. For example, you could easily mark all duplicates with their filename containing "copy" from your results using the filter. + +**Windows:** To use the filtering feature, click on Actions --> Apply Filter, write down the filter you want to apply and click OK. To go back to unfiltered results, click on Actions --> Cancel Filter. + +**Mac OS X:** To use the filtering feature, type your filter in the "Filter" search field in the toolbar. To go back to unfiltered result, blank out the field, or click on the "X". + +In simple mode (the default mode), whatever you type as the filter is the string used to perform the actual filtering, with the exception of one wildcard: **\***. Thus, if you type "[*]" as your filter, it will match anything with [] brackets in it, whatever is in between those brackets. + +For more advanced filtering, you can turn "Use regular expressions when filtering" on. The filtering feature will then use **regular expressions**. A regular expression is a language for matching text. Explaining them is beyond the scope of this document. A good place to start learning it is . + +Matches are case insensitive in both simple and regexp mode. + +For the filter to match, your regular expression don't have to match the whole filename, it just have to contain a string matching the expression. + +You might notice that not all duplicates in the filtered results will match your filter. That is because as soon as one single duplicate in a group matches the filter, the whole group stays in the results so you can have a better view of the duplicate's context. However, non-matching duplicates are in "reference mode". Therefore, you can perform actions like Mark All and be sure to only mark filtered duplicates. + +Action Menu +----- + +* **Start Duplicate Scan:** Starts a new duplicate scan. +* **Clear Ignore List:** Remove all ignored matches you added. You have to start a new scan for the newly cleared ignore list to be effective. +* **Export Results to XHTML:** Take the current results, and create an XHTML file out of it. The columns that are visible when you click on this button will be the columns present in the XHTML file. The file will automatically be opened in your default browser. +* **Send Marked to Trash:** Send all marked duplicates to trash, obviously. +* **Move Marked to...:** Prompt you for a destination, and then move all marked files to that destination. Source file's path might be re-created in destination, depending on the "Copy and Move" preference. +* **Copy Marked to...:** Prompt you for a destination, and then copy all marked files to that destination. Source file's path might be re-created in destination, depending on the "Copy and Move" preference. +* **Remove Marked from Results:** Remove all marked duplicates from results. The actual files will not be touched and will stay where they are. +* **Remove Selected from Results:** Remove all selected duplicates from results. Note that all selected reference files will be ignored, only duplicates can be removed with this action. +* **Make Selected Reference:** Promote all selected duplicates to reference. If a duplicate is a part of a group having a reference file coming from a reference directory (in blue color), no action will be taken for this duplicate. If more than one duplicate among the same group are selected, only the first of each group will be promoted. +* **Add Selected to Ignore List:** This first removes all selected duplicates from results, and then add the match of that duplicate and the current reference in the ignore list. This match will not come up again in further scan. The duplicate itself might come back, but it will be matched with another reference file. You can clear the ignore list with the Clear Ignore List command. +* **Open Selected with Default Application:** Open the file with the application associated with selected file's type. +* **Reveal Selected in Finder:** Open the folder containing selected file. +* **Rename Selected:** Prompts you for a new name, and then rename the selected file. diff --git a/me/help/templates/versions.mako b/me/help/templates/versions.mako new file mode 100644 index 00000000..946cbaa1 --- /dev/null +++ b/me/help/templates/versions.mako @@ -0,0 +1,9 @@ +<%! + title = 'dupeGuru ME version history' + selected_menu_item = 'Version History' +%> +<%inherit file="/base_dg.mako"/> + +A large part of this version history is not serious (especially before v3), but it is always interesting to remember that I learnt most of what I know about designing/implementing/supporting a software through that. + +${self.output_changelogs(changelog)} \ No newline at end of file diff --git a/me/qt/app.py b/me/qt/app.py new file mode 100644 index 00000000..65314e2b --- /dev/null +++ b/me/qt/app.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# Unit Name: app +# Created By: Virgil Dupras +# Created On: 2009-05-21 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import hsfs.phys.music + +from dupeguru import data_me, scanner + +from base.app import DupeGuru as DupeGuruBase +from details_dialog import DetailsDialog +from preferences import Preferences +from preferences_dialog import PreferencesDialog + +class DupeGuru(DupeGuruBase): + LOGO_NAME = 'logo_me' + NAME = 'dupeGuru Music Edition' + VERSION = '5.6.1' + DELTA_COLUMNS = frozenset([2, 3, 4, 5, 7, 8]) + + def __init__(self): + DupeGuruBase.__init__(self, data_me, appid=1) + + def _setup(self): + self.scanner = scanner.ScannerME() + self.directories.dirclass = hsfs.phys.music.Directory + DupeGuruBase._setup(self) + + def _update_options(self): + DupeGuruBase._update_options(self) + self.scanner.min_match_percentage = self.prefs.filter_hardness + self.scanner.scan_type = self.prefs.scan_type + self.scanner.word_weighting = self.prefs.word_weighting + self.scanner.match_similar_words = self.prefs.match_similar + scanned_tags = set() + if self.prefs.scan_tag_track: + scanned_tags.add('track') + if self.prefs.scan_tag_artist: + scanned_tags.add('artist') + if self.prefs.scan_tag_album: + scanned_tags.add('album') + if self.prefs.scan_tag_title: + scanned_tags.add('title') + if self.prefs.scan_tag_genre: + scanned_tags.add('genre') + if self.prefs.scan_tag_year: + scanned_tags.add('year') + self.scanner.scanned_tags = scanned_tags + + def _create_details_dialog(self, parent): + return DetailsDialog(parent, self) + + def _create_preferences(self): + return Preferences() + + def _create_preferences_dialog(self, parent): + return PreferencesDialog(parent, self) + diff --git a/me/qt/app_win.py b/me/qt/app_win.py new file mode 100644 index 00000000..697f8335 --- /dev/null +++ b/me/qt/app_win.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# Unit Name: app_win +# Created By: Virgil Dupras +# Created On: 2009-05-21 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import winshell + +import app + +class DupeGuru(app.DupeGuru): + @staticmethod + def _recycle_dupe(dupe): + winshell.delete_file(unicode(dupe.path), no_confirm=True) + diff --git a/me/qt/build.py b/me/qt/build.py new file mode 100644 index 00000000..6e7e8b5f --- /dev/null +++ b/me/qt/build.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# Unit Name: build +# Created By: Virgil Dupras +# Created On: 2009-05-22 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +# On Windows, PyInstaller is used to build an exe (py2exe creates a very bad looking icon) +# The release version is outdated. Use at least r672 on http://svn.pyinstaller.org/trunk + +import os +import os.path as op +import shutil + +from hsutil.build import print_and_do +from app import DupeGuru + +# Removing build and dist +if op.exists('build'): + shutil.rmtree('build') +if op.exists('dist'): + shutil.rmtree('dist') + +version = DupeGuru.VERSION +versioncomma = version.replace('.', ', ') + ', 0' +verinfo = open('verinfo').read() +verinfo = verinfo.replace('$versioncomma', versioncomma).replace('$version', version) +fp = open('verinfo_tmp', 'w') +fp.write(verinfo) +fp.close() +print_and_do("python C:\\Python26\\pyinstaller\\Build.py dgme.spec") +os.remove('verinfo_tmp') + +print_and_do("xcopy /Y C:\\src\\vs_comp\\msvcrt dist") +print_and_do("xcopy /Y /S /I help\\dupeguru_me_help dist\\help") + +aicom = '"\\Program Files\\Caphyon\\Advanced Installer\\AdvancedInstaller.com"' +shutil.copy('installer.aip', 'installer_tmp.aip') # this is so we don'a have to re-commit installer.aip at every version change +print_and_do('%s /edit installer_tmp.aip /SetVersion %s' % (aicom, version)) +print_and_do('%s /build installer_tmp.aip -force' % aicom) +os.remove('installer_tmp.aip') \ No newline at end of file diff --git a/me/qt/details_dialog.py b/me/qt/details_dialog.py new file mode 100644 index 00000000..b680309c --- /dev/null +++ b/me/qt/details_dialog.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# Unit Name: details_dialog +# Created By: Virgil Dupras +# Created On: 2009-04-27 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt +from PyQt4.QtGui import QDialog + +from base.details_table import DetailsModel +from details_dialog_ui import Ui_DetailsDialog + +class DetailsDialog(QDialog, Ui_DetailsDialog): + def __init__(self, parent, app): + QDialog.__init__(self, parent, Qt.Tool) + self.app = app + self.setupUi(self) + self.model = DetailsModel(app) + self.tableView.setModel(self.model) diff --git a/me/qt/details_dialog.ui b/me/qt/details_dialog.ui new file mode 100644 index 00000000..c0557003 --- /dev/null +++ b/me/qt/details_dialog.ui @@ -0,0 +1,53 @@ + + + DetailsDialog + + + + 0 + 0 + 502 + 295 + + + + + 250 + 250 + + + + Details + + + + 0 + + + 0 + + + + + true + + + QAbstractItemView::SelectRows + + + false + + + + + + + + DetailsTable + QTableView +
base.details_table
+
+
+ + +
diff --git a/me/qt/dgme.spec b/me/qt/dgme.spec new file mode 100644 index 00000000..4d47e350 --- /dev/null +++ b/me/qt/dgme.spec @@ -0,0 +1,19 @@ +# -*- mode: python -*- +a = Analysis([os.path.join(HOMEPATH,'support\\_mountzlib.py'), os.path.join(HOMEPATH,'support\\useUnicode.py'), 'start.py'], + pathex=['C:\\src\\dupeguru\\me\\qt']) +pyz = PYZ(a.pure) +exe = EXE(pyz, + a.scripts, + exclude_binaries=1, + name=os.path.join('build\\pyi.win32\\dupeGuru ME', 'dupeGuru ME.exe'), + debug=False, + strip=False, + upx=True, + console=False , icon='base\\images\\dgme_logo.ico', version='verinfo_tmp') +coll = COLLECT( exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name=os.path.join('dist')) diff --git a/me/qt/gen.py b/me/qt/gen.py new file mode 100644 index 00000000..9edb6040 --- /dev/null +++ b/me/qt/gen.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# Unit Name: gen +# Created By: Virgil Dupras +# Created On: 2009-05-22 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os + +from hsutil.build import print_and_do + +os.chdir('dupeguru') +print_and_do('python gen.py') +os.chdir('..') + +os.chdir('base') +print_and_do('python gen.py') +os.chdir('..') + +print_and_do("pyuic4 details_dialog.ui > details_dialog_ui.py") +print_and_do("pyuic4 preferences_dialog.ui > preferences_dialog_ui.py") + +os.chdir('help') +print_and_do('python gen.py') +os.chdir('..') diff --git a/me/qt/installer.aip b/me/qt/installer.aip new file mode 100644 index 00000000..51cfc4d9 --- /dev/null +++ b/me/qt/installer.aip @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/me/qt/preferences.py b/me/qt/preferences.py new file mode 100644 index 00000000..bdb4c914 --- /dev/null +++ b/me/qt/preferences.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# Unit Name: preferences +# Created By: Virgil Dupras +# Created On: 2009-05-17 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from dupeguru.scanner import (SCAN_TYPE_FILENAME, SCAN_TYPE_FIELDS, SCAN_TYPE_FIELDS_NO_ORDER, + SCAN_TYPE_TAG, SCAN_TYPE_CONTENT, SCAN_TYPE_CONTENT_AUDIO) + +from base.preferences import Preferences as PreferencesBase + +class Preferences(PreferencesBase): + # (width, is_visible) + COLUMNS_DEFAULT_ATTRS = [ + (200, True), # name + (180, True), # path + (60, True), # size + (60, True), # Time + (50, True), # Bitrate + (60, False), # Sample Rate + (40, False), # Kind + (120, False), # creation + (120, False), # modification + (120, False), # Title + (120, False), # Artist + (120, False), # Album + (80, False), # Genre + (40, False), # Year + (40, False), # Track Number + (120, False), # Comment + (60, True), # match % + (120, False), # Words Used + (80, False), # dupe count + ] + + def _load_specific(self, settings, get): + self.scan_type = get('ScanType', self.scan_type) + self.word_weighting = get('WordWeighting', self.word_weighting) + self.match_similar = get('MatchSimilar', self.match_similar) + self.scan_tag_track = get('ScanTagTrack', self.scan_tag_track) + self.scan_tag_artist = get('ScanTagArtist', self.scan_tag_artist) + self.scan_tag_album = get('ScanTagAlbum', self.scan_tag_album) + self.scan_tag_title = get('ScanTagTitle', self.scan_tag_title) + self.scan_tag_genre = get('ScanTagGenre', self.scan_tag_genre) + self.scan_tag_year = get('ScanTagYear', self.scan_tag_year) + + def _reset_specific(self): + self.filter_hardness = 80 + self.scan_type = SCAN_TYPE_TAG + self.word_weighting = True + self.match_similar = False + self.scan_tag_track = False + self.scan_tag_artist = True + self.scan_tag_album = True + self.scan_tag_title = True + self.scan_tag_genre = False + self.scan_tag_year = False + + def _save_specific(self, settings, set_): + set_('ScanType', self.scan_type) + set_('WordWeighting', self.word_weighting) + set_('MatchSimilar', self.match_similar) + set_('ScanTagTrack', self.scan_tag_track) + set_('ScanTagArtist', self.scan_tag_artist) + set_('ScanTagAlbum', self.scan_tag_album) + set_('ScanTagTitle', self.scan_tag_title) + set_('ScanTagGenre', self.scan_tag_genre) + set_('ScanTagYear', self.scan_tag_year) + diff --git a/me/qt/preferences_dialog.py b/me/qt/preferences_dialog.py new file mode 100644 index 00000000..0ec53364 --- /dev/null +++ b/me/qt/preferences_dialog.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +# Unit Name: preferences_dialog +# Created By: Virgil Dupras +# Created On: 2009-04-29 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QDialogButtonBox + +from dupeguru.scanner import (SCAN_TYPE_FILENAME, SCAN_TYPE_FIELDS, SCAN_TYPE_FIELDS_NO_ORDER, + SCAN_TYPE_TAG, SCAN_TYPE_CONTENT, SCAN_TYPE_CONTENT_AUDIO) + +from preferences_dialog_ui import Ui_PreferencesDialog +import preferences + +SCAN_TYPE_ORDER = [ + SCAN_TYPE_FILENAME, + SCAN_TYPE_FIELDS, + SCAN_TYPE_FIELDS_NO_ORDER, + SCAN_TYPE_TAG, + SCAN_TYPE_CONTENT, + SCAN_TYPE_CONTENT_AUDIO, +] + +class PreferencesDialog(QDialog, Ui_PreferencesDialog): + def __init__(self, parent, app): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.app = app + self._setupUi() + + self.connect(self.buttonBox, SIGNAL('clicked(QAbstractButton*)'), self.buttonClicked) + + def _setupUi(self): + self.setupUi(self) + + def load(self, prefs=None): + if prefs is None: + prefs = self.app.prefs + self.filterHardnessSlider.setValue(prefs.filter_hardness) + self.filterHardnessLabel.setNum(prefs.filter_hardness) + scan_type_index = SCAN_TYPE_ORDER.index(prefs.scan_type) + self.scanTypeComboBox.setCurrentIndex(scan_type_index) + setchecked = lambda cb, b: cb.setCheckState(Qt.Checked if b else Qt.Unchecked) + setchecked(self.tagTrackBox, prefs.scan_tag_track) + setchecked(self.tagArtistBox, prefs.scan_tag_artist) + setchecked(self.tagAlbumBox, prefs.scan_tag_album) + setchecked(self.tagTitleBox, prefs.scan_tag_title) + setchecked(self.tagGenreBox, prefs.scan_tag_genre) + setchecked(self.tagYearBox, prefs.scan_tag_year) + setchecked(self.matchSimilarBox, prefs.match_similar) + setchecked(self.wordWeightingBox, prefs.word_weighting) + setchecked(self.mixFileKindBox, prefs.mix_file_kind) + setchecked(self.useRegexpBox, prefs.use_regexp) + setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders) + self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type) + + def save(self): + prefs = self.app.prefs + prefs.filter_hardness = self.filterHardnessSlider.value() + prefs.scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] + ischecked = lambda cb: cb.checkState() == Qt.Checked + prefs.scan_tag_track = ischecked(self.tagTrackBox) + prefs.scan_tag_artist = ischecked(self.tagArtistBox) + prefs.scan_tag_album = ischecked(self.tagAlbumBox) + prefs.scan_tag_title = ischecked(self.tagTitleBox) + prefs.scan_tag_genre = ischecked(self.tagGenreBox) + prefs.scan_tag_year = ischecked(self.tagYearBox) + prefs.match_similar = ischecked(self.matchSimilarBox) + prefs.word_weighting = ischecked(self.wordWeightingBox) + prefs.mix_file_kind = ischecked(self.mixFileKindBox) + prefs.use_regexp = ischecked(self.useRegexpBox) + prefs.remove_empty_folders = ischecked(self.removeEmptyFoldersBox) + prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() + + def resetToDefaults(self): + self.load(preferences.Preferences()) + + #--- Events + def buttonClicked(self, button): + role = self.buttonBox.buttonRole(button) + if role == QDialogButtonBox.ResetRole: + self.resetToDefaults() + diff --git a/me/qt/preferences_dialog.ui b/me/qt/preferences_dialog.ui new file mode 100644 index 00000000..9eefc864 --- /dev/null +++ b/me/qt/preferences_dialog.ui @@ -0,0 +1,434 @@ + + + PreferencesDialog + + + + 0 + 0 + 350 + 322 + + + + Preferences + + + false + + + true + + + + + + + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + Scan Type: + + + + + + + + Filename + + + + + Filename - Fields + + + + + Filename - Fields (No Order) + + + + + Tags + + + + + Contents + + + + + Audio Contents + + + + + + + + + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + Filter Hardness: + + + + + + + 0 + + + + + 12 + + + + + + 0 + 0 + + + + 1 + + + 100 + + + true + + + Qt::Horizontal + + + + + + + + 21 + 0 + + + + 100 + + + + + + + + + 0 + + + + + More Results + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Less Results + + + + + + + + + + + + + + 0 + 40 + + + + + + 0 + 0 + 91 + 17 + + + + Tags to scan: + + + + + + 10 + 20 + 51 + 21 + + + + Track + + + + + + 60 + 20 + 51 + 21 + + + + Artist + + + + + + 110 + 20 + 61 + 21 + + + + Album + + + + + + 170 + 20 + 51 + 21 + + + + Title + + + + + + 220 + 20 + 61 + 21 + + + + Genre + + + + + + 280 + 20 + 51 + 21 + + + + Year + + + + + + + + Word weighting + + + + + + + Match similar words + + + + + + + Can mix file kind + + + + + + + Use regular expressions when filtering + + + + + + + Remove empty folders on delete or move + + + + + + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + Copy and Move: + + + + + + + + 0 + 0 + + + + + Right in destination + + + + + Recreate relative path + + + + + Recreate absolute path + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults + + + + + + + + + filterHardnessSlider + valueChanged(int) + filterHardnessLabel + setNum(int) + + + 182 + 26 + + + 271 + 26 + + + + + buttonBox + accepted() + PreferencesDialog + accept() + + + 182 + 228 + + + 182 + 124 + + + + + buttonBox + rejected() + PreferencesDialog + reject() + + + 182 + 228 + + + 182 + 124 + + + + + diff --git a/me/qt/profile.py b/me/qt/profile.py new file mode 100644 index 00000000..195b1775 --- /dev/null +++ b/me/qt/profile.py @@ -0,0 +1,20 @@ +import sys +import cProfile +import pstats + +from PyQt4.QtCore import QCoreApplication +from PyQt4.QtGui import QApplication + +if sys.platform == 'win32': + from app_win import DupeGuru +else: + from app import DupeGuru + +if __name__ == "__main__": + app = QApplication(sys.argv) + QCoreApplication.setOrganizationName('Hardcoded Software') + QCoreApplication.setApplicationName('dupeGuru Music Edition') + dgapp = DupeGuru() + cProfile.run('app.exec_()', '/tmp/prof') + p = pstats.Stats('/tmp/prof') + p.sort_stats('time').print_stats() \ No newline at end of file diff --git a/me/qt/start.py b/me/qt/start.py new file mode 100644 index 00000000..d4315875 --- /dev/null +++ b/me/qt/start.py @@ -0,0 +1,20 @@ +import sys + +from PyQt4.QtCore import QCoreApplication +from PyQt4.QtGui import QApplication, QIcon, QPixmap + +import base.dg_rc + +if sys.platform == 'win32': + from app_win import DupeGuru +else: + from app import DupeGuru + +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setWindowIcon(QIcon(QPixmap(":/logo_me"))) + QCoreApplication.setOrganizationName('Hardcoded Software') + QCoreApplication.setApplicationName(DupeGuru.NAME) + QCoreApplication.setApplicationVersion(DupeGuru.VERSION) + dgapp = DupeGuru() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/me/qt/verinfo b/me/qt/verinfo new file mode 100644 index 00000000..6384c49e --- /dev/null +++ b/me/qt/verinfo @@ -0,0 +1,28 @@ +VSVersionInfo( + ffi=FixedFileInfo( + filevers=($versioncomma), + prodvers=($versioncomma), + mask=0x17, + flags=0x0, + OS=0x4, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904b0', + [StringStruct('CompanyName', 'Hardcoded Software'), + StringStruct('FileDescription', 'dupeGuru Music Edition'), + StringStruct('FileVersion', '$version'), + StringStruct('InternalName', 'dupeGuru ME.exe'), + StringStruct('LegalCopyright', '(c) Hardcoded Software. All rights reserved.'), + StringStruct('OriginalFilename', 'dupeGuru ME.exe'), + StringStruct('ProductName', 'dupeGuru Music Edition'), + StringStruct('ProductVersion', '$versioncomma')]) + ]), + VarFileInfo([VarStruct('Translation', [1033])]) + ] +) diff --git a/pe/cocoa/AppDelegate.h b/pe/cocoa/AppDelegate.h new file mode 100644 index 00000000..70203dfb --- /dev/null +++ b/pe/cocoa/AppDelegate.h @@ -0,0 +1,21 @@ +#import +#import "dgbase/AppDelegate.h" +#import "ResultWindow.h" +#import "DirectoryPanel.h" +#import "DetailsPanel.h" +#import "PyDupeGuru.h" + +@interface AppDelegate : AppDelegateBase +{ + IBOutlet ResultWindow *result; + + DetailsPanel *_detailsPanel; + DirectoryPanel *_directoryPanel; +} +- (IBAction)openWebsite:(id)sender; +- (IBAction)toggleDetailsPanel:(id)sender; +- (IBAction)toggleDirectories:(id)sender; + +- (DirectoryPanel *)directoryPanel; +- (PyDupeGuru *)py; +@end diff --git a/pe/cocoa/AppDelegate.m b/pe/cocoa/AppDelegate.m new file mode 100644 index 00000000..0a515656 --- /dev/null +++ b/pe/cocoa/AppDelegate.m @@ -0,0 +1,116 @@ +#import "AppDelegate.h" +#import "ProgressController.h" +#import "RegistrationInterface.h" +#import "Utils.h" +#import "ValueTransformers.h" +#import "Consts.h" + +@implementation AppDelegate ++ (void)initialize +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + NSMutableDictionary *d = [NSMutableDictionary dictionaryWithCapacity:10]; + [d setObject:[NSNumber numberWithInt:95] forKey:@"minMatchPercentage"]; + [d setObject:[NSNumber numberWithInt:1] forKey:@"recreatePathType"]; + [d setObject:[NSNumber numberWithBool:NO] forKey:@"matchScaled"]; + [d setObject:[NSNumber numberWithBool:YES] forKey:@"mixFileKind"]; + [d setObject:[NSNumber numberWithBool:NO] forKey:@"useRegexpFilter"]; + [d setObject:[NSNumber numberWithBool:NO] forKey:@"removeEmptyFolders"]; + [d setObject:[NSNumber numberWithBool:NO] forKey:@"debug"]; + [d setObject:[NSArray array] forKey:@"recentDirectories"]; + [d setObject:[NSArray array] forKey:@"columnsOrder"]; + [d setObject:[NSDictionary dictionary] forKey:@"columnsWidth"]; + [[NSUserDefaultsController sharedUserDefaultsController] setInitialValues:d]; + [ud registerDefaults:d]; +} + +- (id)init +{ + self = [super init]; + _directoryPanel = nil; + _detailsPanel = nil; + _appName = APPNAME; + return self; +} + +- (IBAction)openWebsite:(id)sender +{ + [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://www.hardcoded.net/dupeguru_pe"]]; +} + +- (IBAction)toggleDetailsPanel:(id)sender +{ + if (!_detailsPanel) + _detailsPanel = [[DetailsPanel alloc] initWithPy:py]; + if ([[_detailsPanel window] isVisible]) + [[_detailsPanel window] close]; + else + { + [[_detailsPanel window] orderFront:nil]; + [_detailsPanel refresh]; + } +} + +- (IBAction)toggleDirectories:(id)sender +{ + [[self directoryPanel] toggleVisible:sender]; +} + +- (DirectoryPanel *)directoryPanel +{ + if (!_directoryPanel) + _directoryPanel = [[DirectoryPanel alloc] initWithParentApp:self]; + return _directoryPanel; +} +- (PyDupeGuru *)py { return (PyDupeGuru *)py; } + +//Delegate +- (void)applicationDidFinishLaunching:(NSNotification *)aNotification +{ + [[ProgressController mainProgressController] setWorker:py]; + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + //Restore Columns + NSArray *columnsOrder = [ud arrayForKey:@"columnsOrder"]; + NSDictionary *columnsWidth = [ud dictionaryForKey:@"columnsWidth"]; + if ([columnsOrder count]) + [result restoreColumnsPosition:columnsOrder widths:columnsWidth]; + //Reg stuff + if ([RegistrationInterface showNagWithApp:[self py] name:APPNAME limitDescription:LIMIT_DESC]) + [unlockMenuItem setTitle:@"Thanks for buying dupeGuru Picture Edition!"]; + //Restore results + [py loadIgnoreList]; + [py loadResults]; +} + +- (void)applicationWillBecomeActive:(NSNotification *)aNotification +{ + if (![[result window] isVisible]) + [result showWindow:NSApp]; +} + +- (void)applicationWillTerminate:(NSNotification *)aNotification +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [ud setObject: [result getColumnsOrder] forKey:@"columnsOrder"]; + [ud setObject: [result getColumnsWidth] forKey:@"columnsWidth"]; + [py saveIgnoreList]; + [py saveResults]; + int sc = [ud integerForKey:@"sessionCountSinceLastIgnorePurge"]; + if (sc >= 10) + { + sc = -1; + [py purgeIgnoreList]; + } + sc++; + [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 RecentDirectories so it saves the user defaults + [recentDirectories release]; +} + +- (void)recentDirecoryClicked:(NSString *)directory +{ + [[self directoryPanel] addDirectory:directory]; +} +@end diff --git a/pe/cocoa/Consts.h b/pe/cocoa/Consts.h new file mode 100644 index 00000000..c8aea9fa --- /dev/null +++ b/pe/cocoa/Consts.h @@ -0,0 +1,4 @@ +#import "dgbase/Consts.h" + +extern NSString *ImageLoadedNotification; +extern NSString *APPNAME; \ No newline at end of file diff --git a/pe/cocoa/Consts.m b/pe/cocoa/Consts.m new file mode 100644 index 00000000..b7c57178 --- /dev/null +++ b/pe/cocoa/Consts.m @@ -0,0 +1,5 @@ +#import "Consts.h" + +NSString *ImageLoadedNotification = @"ImageLoadedNotification"; +NSString *APPNAME = @"dupeGuru PE"; + diff --git a/pe/cocoa/DetailsPanel.h b/pe/cocoa/DetailsPanel.h new file mode 100644 index 00000000..119e6cf6 --- /dev/null +++ b/pe/cocoa/DetailsPanel.h @@ -0,0 +1,21 @@ +#import +#import "PyApp.h" +#import "Table.h" + + +@interface DetailsPanel : NSWindowController +{ + IBOutlet TableView *detailsTable; + IBOutlet NSImageView *dupeImage; + IBOutlet NSProgressIndicator *dupeProgressIndicator; + IBOutlet NSImageView *refImage; + IBOutlet NSProgressIndicator *refProgressIndicator; + + PyApp *py; + BOOL _needsRefresh; + NSString *_dupePath; + NSString *_refPath; +} +- (id)initWithPy:(PyApp *)aPy; +- (void)refresh; +@end \ No newline at end of file diff --git a/pe/cocoa/DetailsPanel.m b/pe/cocoa/DetailsPanel.m new file mode 100644 index 00000000..00845b33 --- /dev/null +++ b/pe/cocoa/DetailsPanel.m @@ -0,0 +1,79 @@ +#import "Utils.h" +#import "NSNotificationAdditions.h" +#import "NSImageAdditions.h" +#import "PyDupeGuru.h" +#import "DetailsPanel.h" +#import "Consts.h" + +@implementation DetailsPanel +- (id)initWithPy:(PyApp *)aPy +{ + self = [super initWithWindowNibName:@"Details"]; + [self window]; //So the detailsTable is initialized. + [detailsTable setPy:aPy]; + py = aPy; + _needsRefresh = YES; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(duplicateSelectionChanged:) name:DuplicateSelectionChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(imageLoaded:) name:ImageLoadedNotification object:self]; + return 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)refresh +{ + if (!_needsRefresh) + return; + [detailsTable reloadData]; + + NSString *refPath = [(PyDupeGuru *)py getSelectedDupeRefPath]; + if (_refPath != nil) + [_refPath autorelease]; + _refPath = [refPath retain]; + [NSThread detachNewThreadSelector:@selector(loadImageAsync:) toTarget:self withObject:refPath]; + NSString *dupePath = [(PyDupeGuru *)py 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)duplicateSelectionChanged:(NSNotification *)aNotification +{ + _needsRefresh = YES; + if ([[self window] isVisible]) + [self refresh]; +} + +- (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]; + } +} +@end diff --git a/pe/cocoa/DirectoryPanel.h b/pe/cocoa/DirectoryPanel.h new file mode 100644 index 00000000..6033367f --- /dev/null +++ b/pe/cocoa/DirectoryPanel.h @@ -0,0 +1,8 @@ +#import +#import "dgbase/DirectoryPanel.h" + +@interface DirectoryPanel : DirectoryPanelBase +{ +} +- (IBAction)addiPhoto:(id)sender; +@end diff --git a/pe/cocoa/DirectoryPanel.m b/pe/cocoa/DirectoryPanel.m new file mode 100644 index 00000000..9347ddd5 --- /dev/null +++ b/pe/cocoa/DirectoryPanel.m @@ -0,0 +1,44 @@ +#import "DirectoryPanel.h" +#import "ProgressController.h" + +static NSString* jobAddIPhoto = @"jobAddIPhoto"; + +@implementation DirectoryPanel +- (id)initWithParentApp:(id)aParentApp +{ + self = [super initWithParentApp:aParentApp]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jobCompleted:) name:JobCompletedNotification object:nil]; + return self; +} + +- (IBAction)addiPhoto:(id)sender +{ + [[ProgressController mainProgressController] setJobDesc:@"Adding iPhoto Library..."]; + [[ProgressController mainProgressController] setJobId:jobAddIPhoto]; + [[ProgressController mainProgressController] showSheetForParent:[self window]]; + [self addDirectory:@"iPhoto Library"]; +} + +- (IBAction)popupAddDirectoryMenu:(id)sender +{ + NSMenu *m = [addButtonPopUp menu]; + while ([m numberOfItems] > 0) + [m removeItemAtIndex:0]; + NSMenuItem *mi = [m addItemWithTitle:@"Add New Directory..." action:@selector(askForDirectory:) keyEquivalent:@""]; + [mi setTarget:self]; + mi = [m addItemWithTitle:@"Add iPhoto Directory" action:@selector(addiPhoto:) keyEquivalent:@""]; + [mi setTarget:self]; + [m addItem:[NSMenuItem separatorItem]]; + [_recentDirectories fillMenu:m]; + [addButtonPopUp selectItem:nil]; + [[addButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]]; +} + +- (void)jobCompleted:(NSNotification *)aNotification +{ + if ([[ProgressController mainProgressController] jobId] == jobAddIPhoto) + { + [directories reloadData]; + } +} +@end diff --git a/pe/cocoa/English.lproj/Details.nib/classes.nib b/pe/cocoa/English.lproj/Details.nib/classes.nib new file mode 100644 index 00000000..f39cb617 --- /dev/null +++ b/pe/cocoa/English.lproj/Details.nib/classes.nib @@ -0,0 +1,24 @@ +{ + IBClasses = ( + { + CLASS = DetailsPanel; + LANGUAGE = ObjC; + OUTLETS = { + detailsTable = NSTableView; + dupeImage = NSImageView; + dupeProgressIndicator = NSProgressIndicator; + refImage = NSImageView; + refProgressIndicator = NSProgressIndicator; + }; + SUPERCLASS = NSWindowController; + }, + {CLASS = FirstResponder; LANGUAGE = ObjC; SUPERCLASS = NSObject; }, + { + CLASS = TableView; + LANGUAGE = ObjC; + OUTLETS = {py = PyApp; }; + SUPERCLASS = NSTableView; + } + ); + IBVersion = 1; +} \ No newline at end of file diff --git a/pe/cocoa/English.lproj/Details.nib/info.nib b/pe/cocoa/English.lproj/Details.nib/info.nib new file mode 100644 index 00000000..3ad9dc9e --- /dev/null +++ b/pe/cocoa/English.lproj/Details.nib/info.nib @@ -0,0 +1,16 @@ + + + + + IBDocumentLocation + 701 68 356 240 0 0 1440 878 + IBFramework Version + 446.1 + IBOpenObjects + + 5 + + IBSystem Version + 8R2232 + + diff --git a/pe/cocoa/English.lproj/Details.nib/keyedobjects.nib b/pe/cocoa/English.lproj/Details.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..dfc61c70bdcc3191c61f1ae5160b485223253e9f GIT binary patch literal 9001 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 AaR2}S literal 0 HcmV?d00001 diff --git a/pe/cocoa/English.lproj/Directories.nib/classes.nib b/pe/cocoa/English.lproj/Directories.nib/classes.nib new file mode 100644 index 00000000..3ebaa96a --- /dev/null +++ b/pe/cocoa/English.lproj/Directories.nib/classes.nib @@ -0,0 +1,62 @@ + + + + + IBClasses + + + CLASS + FirstResponder + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + askForDirectory + id + changeDirectoryState + id + popupAddDirectoryMenu + id + removeSelectedDirectory + id + toggleVisible + id + + CLASS + DirectoryPanel + LANGUAGE + ObjC + OUTLETS + + addButtonPopUp + NSPopUpButton + directories + NSOutlineView + removeButton + NSButton + + SUPERCLASS + DirectoryPanelBase + + + CLASS + OutlineView + LANGUAGE + ObjC + OUTLETS + + py + PyApp + + SUPERCLASS + NSOutlineView + + + IBVersion + 1 + + diff --git a/pe/cocoa/English.lproj/Directories.nib/info.nib b/pe/cocoa/English.lproj/Directories.nib/info.nib new file mode 100644 index 00000000..77f19ce7 --- /dev/null +++ b/pe/cocoa/English.lproj/Directories.nib/info.nib @@ -0,0 +1,20 @@ + + + + + IBFramework Version + 629 + IBLastKnownRelativeProjectPath + ../../dupeguru.xcodeproj + IBOldestOS + 5 + IBOpenObjects + + 5 + + IBSystem Version + 9B18 + targetFramework + IBCocoaFramework + + diff --git a/pe/cocoa/English.lproj/Directories.nib/keyedobjects.nib b/pe/cocoa/English.lproj/Directories.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..906ea9c34734659e9d70e20070a6b8f4dc797a98 GIT binary patch literal 9698 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 LQhrn4zM20Aagm87 literal 0 HcmV?d00001 diff --git a/pe/cocoa/English.lproj/InfoPlist.strings b/pe/cocoa/English.lproj/InfoPlist.strings new file mode 100644 index 0000000000000000000000000000000000000000..d224a14bd2062242e455ea370c921c67e7045c94 GIT binary patch literal 204 zcmW-bOA5k35Cv=PDT2!&D(*yFxrrby%n4#XD*i$e6}^#{RLh~Ed*=0fHS_s0A|_(R zm7I(d2VRsEYIkQtt8(SyjGUEy>8yVS6Mq literal 0 HcmV?d00001 diff --git a/pe/cocoa/English.lproj/MainMenu.nib/classes.nib b/pe/cocoa/English.lproj/MainMenu.nib/classes.nib new file mode 100644 index 00000000..fbce5b56 --- /dev/null +++ b/pe/cocoa/English.lproj/MainMenu.nib/classes.nib @@ -0,0 +1,235 @@ + + + + + IBClasses + + + CLASS + NSSegmentedControl + LANGUAGE + ObjC + SUPERCLASS + NSControl + + + ACTIONS + + openWebsite + id + toggleDetailsPanel + id + toggleDirectories + id + unlockApp + id + + CLASS + AppDelegate + LANGUAGE + ObjC + OUTLETS + + py + PyDupeGuru + recentDirectories + RecentDirectories + result + ResultWindow + unlockMenuItem + NSMenuItem + + SUPERCLASS + NSObject + + + CLASS + PyApp + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + CLASS + MatchesView + LANGUAGE + ObjC + SUPERCLASS + OutlineView + + + CLASS + PyDupeGuru + LANGUAGE + ObjC + SUPERCLASS + PyApp + + + ACTIONS + + changeDelta + id + changePowerMarker + id + clearIgnoreList + id + clearPictureCache + id + collapseAll + id + copyMarked + id + deleteMarked + id + expandAll + id + exportToXHTML + id + filter + id + ignoreSelected + id + markAll + id + markInvert + id + markNone + id + markSelected + id + markToggle + id + moveMarked + id + openSelected + id + refresh + id + removeMarked + id + removeSelected + id + renameSelected + id + resetColumnsToDefault + id + revealSelected + id + showPreferencesPanel + id + startDuplicateScan + id + switchSelected + id + toggleColumn + id + toggleDelta + id + toggleDetailsPanel + id + toggleDirectories + id + togglePowerMarker + id + + CLASS + ResultWindow + LANGUAGE + ObjC + OUTLETS + + actionMenu + NSPopUpButton + actionMenuView + NSView + app + id + columnsMenu + NSMenu + deltaSwitch + NSSegmentedControl + deltaSwitchView + NSView + filterField + NSSearchField + filterFieldView + NSView + matches + MatchesView + pmSwitch + NSSegmentedControl + pmSwitchView + NSView + preferencesPanel + NSWindow + py + PyDupeGuru + stats + NSTextField + + SUPERCLASS + NSWindowController + + + CLASS + FirstResponder + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + checkForUpdates + id + + CLASS + SUUpdater + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + clearMenu + id + menuClick + id + + CLASS + RecentDirectories + LANGUAGE + ObjC + OUTLETS + + delegate + id + menu + NSMenu + + SUPERCLASS + NSObject + + + CLASS + OutlineView + LANGUAGE + ObjC + OUTLETS + + py + PyApp + + SUPERCLASS + NSOutlineView + + + IBVersion + 1 + + diff --git a/pe/cocoa/English.lproj/MainMenu.nib/info.nib b/pe/cocoa/English.lproj/MainMenu.nib/info.nib new file mode 100644 index 00000000..a75b8270 --- /dev/null +++ b/pe/cocoa/English.lproj/MainMenu.nib/info.nib @@ -0,0 +1,20 @@ + + + + + IBFramework Version + 629 + IBLastKnownRelativeProjectPath + ../../dupeguru.xcodeproj + IBOldestOS + 4 + IBOpenObjects + + 524 + + IBSystem Version + 9B18 + targetFramework + IBCocoaFramework + + diff --git a/pe/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib b/pe/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..1c9f4e3c32d40623fd26a3a2d4f3bff499750da6 GIT binary patch literal 45275 zcmce92YeLO_VC=YvopJ4GIv3WAVhi%By=qFCY^*{!jdcyNU|ZD0HNp{L9us55fLe3 z?-fO{fC>s?0W4te1qCaLeCOWT-OPsYp8x;%{XX6^yE}8sx#ym9?rHblnWaUAvGPux z-qDCgHKuXRr|Fuh1tvCM5GjilMoV&=mq$w{H4m5N&nd)56Prh8&W+@k$3hypc->yj zuNl*Z_RDl17KF>g4#~6AN9GKRR7MK=#w$H2_(CnHrD<7OSFM|NwswxzPn)bw(WYwC zw6GS@qFT9jnRcDFPTQ!xrtQ>rX|HQd25CiF zlQtxibRu0zH`0^zCOKpxnM$US86=;~B4s2-7Lz69d~z|loLoV!CO429$trR?Sw}XI z`^f|3G4eQh27hlOuaTYPO|q99z+VT+SLAE*BRNcd#b3wBKa^2U{WL&>)S?aO>9iqj zLR-<+v_0)WGies>Nqf`~z={P!`PN0+Nbov&3pMFBWq2DsX7^}w` zu`^gx){Hf0tynwOo^@aySvKpz`r`L~Y#9C>&PKAaY&@I7rm`XyV~g1mb|G8NE@PLo z>)7>dE!)KIWcRZN*yHR8_B`9cUSYf09=4C|X9w7a>=X7m`+|MRzF|MGU)Z1Q82gKB zoN>AE@nL*8AHm1+Nqjob;{|*k zFXF|#gva<|em=i|JNzPk1;3JC#joZ!@LTvD{4Rbsf1YpS+xZT@hrh}9^7r`%{3HG~ z|Av37ZR6kZ!~7?Hl>f$m=YRWrzBDc9tL1CpYv^n4Yt7pG+W6Y}vV6Vp+gZK=_;;Xh zgm0v8vTurSnlH~c!x!bM_m-w#mUFo~ZcfIch-z~n?zPo%6`kwGT z>3h!iyl;o^72h7;o4)4AI9raFnmflT2SMRM4)`#e$^)dP+eX^dX&(de>bM!*JM6b}7>dW*M`lb3c`nCEE z`px<+`WpRqeVx8Zze~Sczej&ie@uTye^%eBKc~N>@6z|^Z|ZyXxAgt`0sWwUNdHX# zO8-$mtpB9{tpBS2tsmF_(NFlfKj=^M*Ybz__52O}jkHhvjs0i(oBCV%Tl?GlJNvu% zyZU?i`}+I&`}+s@hx$kQ$NMMv^ZYaXbNq$=`TjEhLjMJR$A7*5M*q$JTl{PMxBEBx z@Ag0Bf7t(s|4IK-{;mF({oDOJ{JZ?``akr4=Kt1z)PKw%hHeCnG(#At8I6n#<4mKe z(adOVbTm2}y^OPrKE^;}h%w9_;L+eQ{PtJy?=+Sc zNDHQ=rPWTWlXhBKqqH;98mF~LYnj$6EijU4wAa#h)tXW3H|q-PO6w}? zYU>*7TI)LNdg})3M(ZYPm36aqi?!Oi)mmfSX05eux7JyASnI6~)<$cSb*Hu2y34xT zy2rZLy3e}bdcbtJZ7QPHUI-y7h*&+uCEjY3;S%vfj4dvEH@zS^KR6)_d0b)(6&y)lx_k#433(u3(~>9x|W^z?K)U8IN7Yp2&q zKP|m(dcE}e=?&7)NI%nVEjo%$B1>e8&Z3LxD!Pg8qKD`ydWo~d+2R~=uIMfLh`yqq z=r0C{fntytEQW}o;yf`-3>PEBNHI!`7GuO%kt1@&I5A#K5EI2DF3DhAr^>*Vv(p6i^USLR4fzc ziwlG!KwK!6ixuJ`aj{q_E)kcC%f#j43UQ^lN?a|j5!Z_A#P#9^aih3NtP(eiTf}N{ zt5_p$6KlooVx71{tQQ-^MzKlUDK?9{#NFC>agVrH+$ZiA4~PfFL*ilahyCLR}0 zh$qEU;%Tu(JR_bJTg7wYd9h8rAYK$NiI>H8u|vEfUKOv2onn`GUA!T7i#_5^u~)n$ z-WKnOcf~%jUmOtciTA|^;zMyz91F80r+t3Uvu}5A_`LWGBRgmxHuA%?cn!8W%y%Zv}~R%<+do_Ove7O0r?eQs0yd* zDBgCoOqLuJEh!%nE(y<$l;zp>$ei(o2v1RDLSackbfF7(d>o19>eT?Z>~<1%8(CD2 zP)v1UaVt0!_yVB&ffWI-a25mo(D4DA2do(ALEte*b9A5|052E3Gq94ka5}QJ&RQ27 ztQw#kjKdv<(Dp4wZ8$$H@$#0|UF)Is)Ot;H5gv(6cE6Nh@%36S?X2lozamy1Ep`Vm zF)<@J&Y}JKNAsiMBrav2=W4yRK8VJ+rW|uB@6h@>{tvbO_;P?YP#dHT)`n<9wez%L z+Hh@zHc}g9!W3?PDR~x5|*CuEawMjU77klFiBMb3IPJUS=QZg2269?eF$t^4| ziXgrw}v#Q(jpV84-@n zljRFbWO>A%1VA;N( z)7VI?G+I&+DVq?deVn44ikS_F&67vTrL?j2a#4L!ILwPXaJ{J4hH!|R>5D$gyoTva`_8q zC*sU?3I)w}>O1x*+wPm8Y3e@*Kk+|7LRcv5;QEjl-WAT|O$g;mg#JS9T6`16M(R5a z-;X2ndWFs#v>S1tOArsMw41eCwAI?J+8XUP{JjQeEZ;>Qej5Im$Y{y%Xnwc|Va;{n&$DYIYnK&b(Qr{xVjvs2 z^CSnRSVgkU?6PP@Nr4(-o*hDgQdERuF1NBYk`pOHe#2V*qh%$LGPf4Wx}i8v*n3e? zPH8wFftgTPP(DXVZ)~NaxTJ4Id9*AND_krAm28N7b8@XAk;2(?%8??J$dUYs@DD7J z+)#i|2bUElfI`b${z`H*O0is7%(kzSIAuh*Jbw-@ST4nmswgiiEQ!cp zlb3YO>MBJ>_htJOy~)|G)ZRc++@qCfZ{p_~ghatD%Ohc)?li!^wNFi+`;a{MBY8ff z$nzORfODML2(zTUdJ~sqp{y~BAwLpg zMzVgH_PO?j_N6B2{0yg&!jXdx7AHN=uCruGhwN_cGn6>Y!Y|lqyw`8F?>1`RxxG%y z^O8z}HGY(Ad06{N`&m1p9d(*G-JF5Wa3|*gGRd#nZ`$wLAKIVVF+|m0+TYr7?H}y~ z(FoDjB+0cuk{bRPT7c4_a8{v`dy3{z?hQcijeL(^QOYNt;sT+*I$fPJos4FE20Lp0 zoQ}=xX)_m7Kh8`?I%8j=We1CKQV|&0whS%NG)P%Pm*-Rne0P}JM1Vf zsO0jFlC3marg$V#AX`o|3O2=2Ax{3$oJ^;s)6{9^v>W}7W|0u7P3n--NL^Bo)W=zo zq>mqRO-G?R%az_On)PYvLh3XZX^o~1ugEVf2oEj`quf{XO&XF$>q#T+EUYRumUpq` z#-s_!AZL=M+OwoNX+c`z0-?ZAJd83-GOSz%)kE2#6uXGQqUda;QnYp2*t*l&X*E8l zGFBcbo-)Uk>r(!`jU6;0ZAnwoo^-&5C(J1>C;2>WJQ;?cwa7?q4H-?w zkg-aF_ANp|R}wBqNu!n~6g4OyM@PyEqXmWeN(?Rt7oj-nS5aObEm1RI)GCd-5Xbc4u@`M!e_*4DBl|<@tYLUr7h8qxx<(Czf;yN?BELt8d z$JNS2X7Ay2Z`Nnd441Lba{4*7$8@UB+2zRD3&=v3vqKydfA(^EO8(51xbf2CGNw;8KG`Ik`L|5d90ZtlOHT55xmv#yJCRv+1*9d9tnTJE&mM9$urU?WK_ zH&bdaDQF#~&c=V5>pRJ2au>N9=lULUuN3fd#s)isn)R6;r`Dm)dCmGXkN>DWCM$vN zRH6K^J9SE`4w17qX+U8aDk{;k%0z`4=O-~WPH!&~Hk0SHtvJp>&M@;oLH`nYnQSLJ zP*S{71^q~8M6*7l9GBikJ7bXEvJ)_*0{w;u=urx2DfTm359`+^QQ>5u_mPVdK!>;{ zDas4^%j5$E`3Pt1e}w#F@(KBre1?#JPQFOseVmiqtj~yJ>m=yT1ZN_yk&E3Qwa0W% zK$JR!?>s{oFNZKeX+s$;62(s*0CGiQ0I9HlLjF*&=Q@*2m#F?Ce{UdvljF)rjax;1 z%gVx)xYVf66xyd6X-yigMblBL&DIL3tyNNi`coOL zLr>Gbq;>IIeH5;_;j-D0a{S`1NT@BKh)2dMM-408DR5>w`A&MX-iL4A+6N~A#Q|zr zojWA}QE+t1Ubbv%UO_ZrGr0^%8__d1(KBe{Bv^^}*oR!IP^P?>1aLt$5SyWa;R4w* z4#=!HAU`$PrT}sAfv_m+x03{^tJL?TL!gwlm8Q-!PCZ;F6yDrY5?rYjs$pbfl`dUX zZKDxL+yFvvH*_L?fwE8%aCUF0?D{hGx)6wDiiH zBBxl8rlwQNoiJ=S+wD5Lv)lCnj|%6C=Kyi&+4LNGt`?(x6e*TEB~H|t7cvm#PVYRs z?FiKUA_cuNI%Ui%EQ-WX$Ia-MnUNbsov7MF&pJy7l6rKoTxZ)N!tfvJXuM7effAJV zbXZ*4&v#l)jf-Zbc`1sgqv+^ObaY}C<65KAWR%8@B-u07{oq#1(LUbbB2Xa$QSGYb zUAo}c9^HE^TQ+$~XZgKzw``n_)0cG4?9wT_M|PxL=N|a5>PMWyC(=pl>7=+ck2_-` z#id24_P8BRp;I@|sdSol7S340UlA$Cg(D|Yo}h{u&g2K}`7}&t(tKJ#BXky>P3O=; zI+xC)MYNcf&?qgX^Jy84(Q;Zr7tn=t5v`<)=@Pn>E~Dqu3n)6q=!JAST|qCR7t@vW z5_&1Uj9yN!pjXnX=+*QZdM&+xZdb)vb zq?_oSbThq+-c9eJ_tN|5{qzC)Abp5FOdp|-(#PoI^a=VTeTqI!x6o(kvvezcjy_Mf z(HH28^du-Yz6WHkk^3h1oAqNH-Nki zsU4V84+8t;Qpgn<}3$!250YHZU9SU?L z(6K;sfQ|<`0q7K<(}2zZS^%^VXbI3#pzDCH2f6|1Mxb{B-3;_@p!Wd17wCOJ?+5w_ z(8qv219U6U7lG~o`U=pUK;HoRCeXK$k?7m>9r`ZaNB7eM^gUD;QJ*VEUq#%L=Bk`1 z_=ksQMv9zAoK?`8S zoPME|&@bs%^lN0Zfs4@1B(nq@4aiMEt^;yCkQ;#9i0b{4CD|xV6yG7YWOrM(>?6Al z@zL+-_tJ!*Ke$E&YEgdrBN<2!)1TSX{v_5OV zPG=2qsuQ@1IX5_0I9HCzzyew}Ys{Lk40fh#TqsYPvWZqY7daPWFREjOURl5YoJjsW z=^{|lkhNef*Rz&!ap^kKaRyZ*-p1QHvb9T?y~j_Y}g#U(B4;!NH^|6<)(cbu@RoNM2&K0R`B)YP0+H8nUf zeH7lV#`#8UA+oL+CjrPlb@hQM)z4GauS-_HURCexB4q?UvYsB1kmDlHMzPU%ve9fz z!ctUpFj@||%3ws?CnhIpKe8N_dne0f;}UBkQi!J-YEo`}6d;U~+G?IBBJoYe*}WM7 zyCqjPnZVO@XY&0lkIi6VHk0ME0v2Jj*lae36|%W(p0nDy)w#{N-MPcr;B0j6bnbHQ zaqe~QcOFE)l$Y4)kyIk5`z7_o(XeX2DjeqvRkR|(4XWUpL_u8AbI_*BaP^Ih@@PhG zSvWT5aVS;FGfTcM5^j%W@esT`J$A{>pYeFM@uXJOXVw5DzC4qeEL}A(yG2^ zp=8NGny)A-kG&s9)(t2dZgi!C!~(XIE(M{s)s+l%KThS)SWX13$S@je$l^m|0}5lx z*^Br@wGRI;>9Pilu1kSyQx#l|;&8c^Yos8<3t3Imc$bHwLpVJ{ zXP2N8J7YLznw^a2hf+YgyQ-ObBf|3{Rqc$G(m7I+ANkjK`ftd1A*JX0s(NlYsx(qk z)iXMF=41?r%nIWyWq4wQCy(W|lolVTYO%rC$bv|?sH)Avl8iwZ1dWu{gxkH=5$v$Ig?^W5~TvNUp~aDf`Uf&f`;E>U5P^6y&(F;#?+XH!f|zVqb4yU%OqOmgq&A zPRY(to=Y_ayZVlOj~zYjJbN!uZ;SQ{m%YyZUA~C(A7X!O+-(1 zVKJKjXhNX@IxAXMET4oio`?^7CC5njvE%F?b^;ZkZO(Jfi}5jXier2pg(^lvQC~`yF+e@V72@yFA6Erq|AomXm_(kn5iM7Rvh>)_zl zJ3ADQZci?=ydJMF2X+zAm1D}Ll_@z9H>py(V)^l|?r>i5ywIBPX1qDVK%Je=8z&)` zw@M(Fx5NHlk0W;%wvbo=cqZ@2J4qNSfGzIF{YnT2pLtx+{8TUyngK|BeI3#z(@G%3YN7S3#sQO*iAu{tJt*>I{ z#~i=&q8HhR_$q$0g5-N3RM{`e5hCNom3%e7m9OEq@wM7kzS{W#2yqSzLY*H?=Z6|> z9llX3;hXrK^lR6?p}l~f1@vsRZzeA3fnJ5|Y_w_8rL%?SS)>(|bJ91jchx+%z&!#$Qry znl{c6=|itwF5yg#i=zdkVn{NAB;!XadPHq980Y0xj3AwdLBd*m7hgq2Yfthw_-^Mg zW}0JQp$h`?i}StnYseVT=fwD!!U^_~=YCVp9(TaK4to3GZ=JH2eSCkspZECgJiD26 zFM3m^Fx?$pp=d7kEytXanHA-cm~&VG@`v*~f^uS9ei4EZ*OK``^ycwH&Y#Fd{A2B0 z{t5q-e}Oho<~+<_a{gJ*KjWV}$DF^`^Dp^V&fm`QX?-Uqf-h)7=h%%wbc8RtLq zBb)e_kXUG&yrO6M(lz##^Koj8&N~(xk{U6k1R}%<3CFQaOMCKBVm1!Rts7n@9`F!I2N{cs6n7$5Z=KAXR zI{G>(_-r7xYcy3AKwVM*>KO+|c)$rEW>I{UMNk{cD8mqIIc^NeC=Hj-Id!Y&rnK5W z-fA6BtEYKe4bP0>B7@D#R%;*@-(cSm-%#nUiW3WvhCu28sUIg5ck+AU_9;u4FW+6mfi$jR5%8sapYQ8gFk4 zt|$c+; zs&vyNis?RHi3M)P0-wqP=S*-lnuJ@(x5k-#FQ4yQ>$}~z&Uc4zy>EkWqc7igr*E_K z36M-69f4#4=?tVRknTW`Ng4y`1>~%RZJP9A;AElBMIQD}7cT!&>l%`nsZ&()rB)g* zD*@@0sFf>&o+oK|ia4J3_D0&vYT3zZ!w?xYSMw^pmD(+`np||HE8)}00}9d7l%C#L z&6QvC_UYR#D|bs&wtW-(OKE|%Yp(p3w=!uiEB8oLo{HX1%v5sIa!-LO@}0K<-$x3+ z;}b2|{ts$$tw>Z!m#f#gg1qz%PR;V959L$E;4;arX2`!!=@{z*IXlrWs%mIFr-+0p z`AT<=N#|OU9;J!k`_1<|atccS-az`+#3{!-J)s2$q))t`UC5c^N{XWS^SpN2_uDUGO_b zAOFo_rP8hR0mRa~_$Hw8JXR^FnBN3soYM?}%Cj50$ptaYYemmqshe#bLm`eab##f> zxW4Mc^x@S(o&aRxNg#Lq--0|ILGD}~nsX(T=WF!5@C!Q!yKw@#0;kE&W@XzZlCxrp1zE+vjiacP(>br%x>1_T-2EGACOr&qh`%g zI+1>ze*Jp=dX?zrse_hf9Euy`aIBIelu1%mkf6$g%>pSLJIdX#p|93&-KgK1$a2JF z=XmLe%4l>uyiH%bNne|Yi+D$XrQD2I;irD)PR5l$uCAd~hU&QA@3yMk#%q9FrKW%TnDS}T5nYZO7gW|w z5_z&=-|YzlqNDS9@(rT(1MIXeqIH=>>#Dc_@Harm1wG&_ow%eMM(iES0RyeD1)U*#QNL% z+eyUU3gosLP~gw3(neesNW=lRJn&yz;vjyIK=}4}VX@E~)^?cl?t#9$Q=s3yQW~AJd&Xz#<4NDA*1db{H=a^0zlPx%eNtc_3kNH_YdI?2&ay8hLXSc*;2Q=nq zR}_WIGSKf@Cbtfvwx6+Z4#vVy#zrY zRvq;FBIkOzT0lpGfBZOfARdD&TNmCjNg;jCrL4oPQ~X8AAlFLyKK{VV(z5yOA6 zf2IEt|D`10zYO;(ZS!C5zrtVPzsi5L_N4zB|F!;W&{?P~Tjj?aSy4PQQZ{Ng}4n!YlLbmSQ z2ISc(lBQ}BzW!(Y&$=)?2V`4KFuaCxXPXBGq`>FnQXMlg)5}z>_+$`p@tmlwq3@ym zeh)XzJ)98VZ#a`T`*-{I_}}#J<)i&?``-cb5|EdHYzMN#IRNApAg|U0$hs=os={1} zgn;*ID(s2)tHOzhe~pC^&B=J944@_A`tKzy`2UjSpn7PUG{bA`!|QU8eI(-TH5T^7 z)K%Mwn7WMUB~s{6p*WsUC~(QD3Rf!9ddCR3)A}k9G!b!{WF-FNX;KUDy2MhD<$Ru8 z>Wtb(9l6x)2J%)77&hv$bfdmY7e+&=dc6r`uOtj0d$18V9m{BJG?7}@8lY=YAudR6 zq8*rlyjD7<_oUbyxksE)|Iw5& zvb86T-qHuIjFf#q-b-}d4~-O~zcB#F`#?UxNlP_SlpQduMytU!5hgC_jz)E=ZiADE zOGJ6t)%C0CFYLJ1B%UuotA!Z-yN!u1DIlADqDVmsgHw>g+5{#-o|j-otCUBXRFm*kYg65@fzGe8d+4_8CmFF=0vLL;{eWTToTqaSMH0Xh&) zg|?@OWh|~j+iy~{Ix3U;Z8y-HF$c3eN)V`CHAC})@uEiol<4^b$e&(lI(I_$i-Xfy zZV^Ff-jiBVB5ZEF8s996`wTGTW*JM3-Nqhr5vHD^1F<+0z^qqL};Q zmP)jiD24uxWKGqB1f+aUv?7LyH>$V75>%ry(ff=3n8lF-x8XTxyT%K}fYt$O0X2cz zn8iSS@=plJUru*?lqP@G!Uv{-D-2g*YvWhrALpODjNgpkjX#V(jbp}N#@_~pWd8<& zfByk;0w`7^Kq*jm7b!M1<16EHlbPK3!8q(-Mm)!kRG$H$LF@)QnVvv_JDs@Wq5|`) z2P0Zb5v$33Mify=+~GcT8FIo3lU;&-J{6dv{TWFzr0jL8)2YjbRePGj23B~A9$LN%Z6>E3MP_6Stq9_Epl#AUWO zJD8bfN3)ZeWoG-Yk#>Xjq}fH=Dv4C>CCrx_|1)K(ObL4aFv$-|5I1+nV&Tl0QA`ET z#7(lZ%jd{Ki5K#F0x>{q8+a&0LZwf9x?9Q2?q-kmW)C?gv>OJvJmDH9o}>{M8NJN2 zHkfA_ugb#ccXUg+Xvf*9QEr&M%|5A8jN-acFDW;0v%Ivgx~okDJ><5iQ;U9c2ujrR zlQN9fmlBnp=JcMf@^z74GDhGcU6TOw4K_!cV>|*(Mw993KpQ3@k^8vN;O^;W67-PU zr2Z!ctT_b*8dNi2X(NI&Pzd9#4pk zsWh;p9;+;sxvmZ3+>a)R_swy0t~n2Nmu*0i{94tZg_onKGR`St3epUZ2pFx_Wda z`NmxCnqELLCGFW7z-G(P0q2bs;Z~n`n8-W+L*~`yH3}q}2{fyQhRo|(x_LvDbq2H} z&`z#-hU4!qGk%gM*7Of!glo)bIWqY?evplNnu-bE#>B{aRvVe`j>MbJ)!wk#t}*M} zg|=*R5U9&%BR(FGn&UpHId+@kQDT$D6cx;KO%?~e7BbP(wR6;)8LsEiL9g4@$v0?V zuEwUHLj&^%+44DY<=cEgW;U&IzL&q_enDJlJg(am-I*QKnwYPdI}y#eKJ*4!Q#{jr zql(OAMk>%gi74a595i5LngP1qDoUi|4Zok-?Pg?~2V5fC2ekiGm&680y*_mc%!B43 zcjgBItts$neu5xvc4uBCBhW$4AeZu6PE|DPX8mJeGwxy^!KX^g)SZNbQsX#E*O=QB-do%AvKXPeJ2&;clp&jOe1Wb2x!vC z{2b^gpkr$2A;8eiJRGNRh5FIBJSz&{HQi$pU}zT?>0->n$0U`$B?Y6BdB0UXBFP*x zz;(#rnva@(szW9aid)Sz7isXMtYo$~P_K&BoQHspb2)7cOTii|jOXj#>%5=xBG5e0 z!bRsqpp$DFY-^MY-gpL`R29mYsL+YjHBsUcKfMFf*04i?&d3Y3B`-{!fNC2uyR;js z_$biBO`8Wg-Fc#h-WfVg0tpwh%=_L9G%r5FLQhLtlfb~hARGuXOc-c>4NV1xqUS2n zl$tdREXKXnxEv^dwMRIQlYCP(97%eCoItLNs|Zlcsyb@oS{ zWOzSy5ZZ~rd^rdQrE+wNoVHw*5nWYEruqjKxTZVM`O0=rq__tb2bKhuO7}?_(A$B= zkOB%zWK1G1>H-cr(*h8=L20Z#WP{kV{WnJHa4|IJaCiDMQY~z zP3M8t$l7ZH1N}YB*8=PG77lJ##blBGK+y|;tX?Te2V)&l;x5L#hkxs7Lt2tMhVcK0 zduP}G-)d9woFggPBXSN-fmD5G;DxHi0+G5y`o?g$%RIO|KUba}7hj+PuOwX;f!CeM zcLkc8KLz&aF9i1LM+0H~3!o@1Q2qiC#NBck)k88R%WERj{g{K2B0nyyk{{EDmSB!> zxmp&`PWhLplTy73<2aI2jGNct=n+h~QE}WqV!iFSL&P)eizm4BlE_^P9Fd#Hg+48i zV_wad2Y&ZcbGcrZrv?5F91ryHZw_if;(sQ92H+H+C30lfm~l|Y^Yn)@#Yr^1v~

NS*?bdVCbz5HmH(L&L*Ij$7Si*|A*Zmt)+P9 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 + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleHelpBookFolder + dupeguru_pe_help + CFBundleHelpBookName + dupeGuru PE Help + CFBundleIconFile + dupeguru + CFBundleIdentifier + com.hardcoded_software.dupeguru_pe + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleSignature + hsft + CFBundleVersion + 1.7.2 + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + SUFeedURL + http://www.hardcoded.net/updates/dupeguru_pe.appcast + + diff --git a/pe/cocoa/PictureBlocks.h b/pe/cocoa/PictureBlocks.h new file mode 100644 index 00000000..6b1a5160 --- /dev/null +++ b/pe/cocoa/PictureBlocks.h @@ -0,0 +1,11 @@ +#import + + +@interface PictureBlocks : NSObject { +} ++ (NSString *)getBlocksFromImagePath:(NSString *)imagePath blockCount:(NSNumber *)blockCount scanArea:(NSNumber *)scanArea; ++ (NSSize)getImageSize:(NSString *)imagePath; +@end + + +NSString* GetBlocks(NSString *filePath, int blockCount, int scanSize); \ No newline at end of file diff --git a/pe/cocoa/PictureBlocks.m b/pe/cocoa/PictureBlocks.m new file mode 100644 index 00000000..bc743694 --- /dev/null +++ b/pe/cocoa/PictureBlocks.m @@ -0,0 +1,139 @@ +#import "PictureBlocks.h" +#import "Utils.h" + + +@implementation PictureBlocks ++ (NSString *)getBlocksFromImagePath:(NSString *)imagePath blockCount:(NSNumber *)blockCount scanArea:(NSNumber *)scanArea +{ + return GetBlocks(imagePath, n2i(blockCount), n2i(scanArea)); +} + ++ (NSSize)getImageSize:(NSString *)imagePath +{ + CFURLRef fileURL = CFURLCreateWithFileSystemPath(NULL, (CFStringRef)imagePath, kCFURLPOSIXPathStyle, FALSE); + CGImageSourceRef source = CGImageSourceCreateWithURL(fileURL, NULL); + if (source == NULL) + return NSMakeSize(0, 0); + CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, NULL); + if (image == NULL) + return NSMakeSize(0, 0); + size_t width = CGImageGetWidth(image); + size_t height = CGImageGetHeight(image); + CGImageRelease(image); + CFRelease(source); + CFRelease(fileURL); + return NSMakeSize(width, height); +} +@end + + CGContextRef MyCreateBitmapContext (int width, int height) + { + CGContextRef context = NULL; + CGColorSpaceRef colorSpace; + void * bitmapData; + int bitmapByteCount; + int bitmapBytesPerRow; + + bitmapBytesPerRow = (width * 4); + bitmapByteCount = (bitmapBytesPerRow * height); + + colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceGenericRGB); + + bitmapData = malloc( bitmapByteCount ); + if (bitmapData == NULL) + { + fprintf (stderr, "Memory not allocated!"); + return NULL; + } + + context = CGBitmapContextCreate (bitmapData,width,height,8,bitmapBytesPerRow,colorSpace,kCGImageAlphaNoneSkipLast); + if (context== NULL) + { + free (bitmapData); + fprintf (stderr, "Context not created!"); + return NULL; + } + CGColorSpaceRelease( colorSpace ); + return context; + } + + // returns 0x00RRGGBB + int GetBlock(unsigned char *imageData, int imageWidth, int imageHeight, int boxX, int boxY, int boxW, int boxH) + { + int i,j; + int totalR = 0; + int totalG = 0; + int totalB = 0; + for(i = boxY; i < boxY + boxH; i++) + { + for(j = boxX; j < boxX + boxW; j++) + { + int offset = (i * imageWidth * 4) + (j * 4); + totalR += *(imageData + offset); + totalG += *(imageData + offset + 1); + totalB += *(imageData + offset + 2); + } + } + int pixelCount = boxH * boxW; + int result = 0; + result += (totalR / pixelCount) << 16; + result += (totalG / pixelCount) << 8; + result += (totalB / pixelCount); + return result; + } + + NSString* GetBlocks (NSString* filePath, int blockCount, int scanSize) + { + CFURLRef fileURL = CFURLCreateWithFileSystemPath(NULL, (CFStringRef)filePath, kCFURLPOSIXPathStyle, FALSE); + CGImageSourceRef source = CGImageSourceCreateWithURL(fileURL, NULL); + if (source == NULL) + return NULL; + CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, NULL); + if (image == NULL) + return NULL; + size_t width = CGImageGetWidth(image); + size_t height = CGImageGetHeight(image); + if ((scanSize > 0) && (width > scanSize)) + width = scanSize; + if ((scanSize > 0) && (height > scanSize)) + height = scanSize; + CGContextRef myContext = MyCreateBitmapContext(width, height); + CGRect myBoundingBox = CGRectMake (0, 0, width, height); + CGContextDrawImage(myContext, myBoundingBox, image); + unsigned char *bitmapData = CGBitmapContextGetData(myContext); + if (bitmapData == NULL) + return NULL; + + int blockHeight = height / blockCount; + if (blockHeight < 1) + blockHeight = 1; + int blockWidth = width / blockCount; + if (blockWidth < 1) + blockWidth = 1; + //blockCount might have changed + int blockXCount = (width / blockWidth); + int blockYCount = (height / blockHeight); + + CFMutableArrayRef blocks = CFArrayCreateMutable(NULL, blockXCount * blockYCount, &kCFTypeArrayCallBacks); + int i,j; + for(i = 0; i < blockYCount; i++) + { + for(j = 0; j < blockXCount; j++) + { + int block = GetBlock(bitmapData, width, height, j * blockWidth, i * blockHeight, blockWidth, blockHeight); + CFStringRef strBlock = CFStringCreateWithFormat(NULL, NULL, CFSTR("%06x"), block); + CFArrayAppendValue(blocks, strBlock); + CFRelease(strBlock); + } + } + + CGContextRelease (myContext); + if (bitmapData) free(bitmapData); + CGImageRelease(image); + CFRelease(source); + CFRelease(fileURL); + + CFStringRef result = CFStringCreateByCombiningStrings(NULL, blocks, CFSTR("")); + CFRelease(blocks); + return (NSString *)result; + } \ No newline at end of file diff --git a/pe/cocoa/PyDupeGuru.h b/pe/cocoa/PyDupeGuru.h new file mode 100644 index 00000000..8ced12b6 --- /dev/null +++ b/pe/cocoa/PyDupeGuru.h @@ -0,0 +1,9 @@ +#import +#import "dgbase/PyDupeGuru.h" + +@interface PyDupeGuru : PyDupeGuruBase +- (void)clearPictureCache; +- (NSString *)getSelectedDupePath; +- (NSString *)getSelectedDupeRefPath; +- (void)setMatchScaled:(NSNumber *)match_scaled; +@end diff --git a/pe/cocoa/ResultWindow.h b/pe/cocoa/ResultWindow.h new file mode 100644 index 00000000..dcc96079 --- /dev/null +++ b/pe/cocoa/ResultWindow.h @@ -0,0 +1,59 @@ +#import +#import "Outline.h" +#import "dgbase/ResultWindow.h" +#import "DirectoryPanel.h" + +@interface ResultWindow : ResultWindowBase +{ + IBOutlet NSPopUpButton *actionMenu; + IBOutlet NSView *actionMenuView; + IBOutlet id app; + IBOutlet NSMenu *columnsMenu; + IBOutlet NSView *deltaSwitchView; + IBOutlet NSSearchField *filterField; + IBOutlet NSView *filterFieldView; + IBOutlet NSSegmentedControl *pmSwitch; + IBOutlet NSView *pmSwitchView; + IBOutlet NSWindow *preferencesPanel; + IBOutlet NSTextField *stats; + + NSMutableArray *_resultColumns; + NSMutableIndexSet *_deltaColumns; +} +- (IBAction)changePowerMarker:(id)sender; +- (IBAction)clearIgnoreList:(id)sender; +- (IBAction)clearPictureCache:(id)sender; +- (IBAction)exportToXHTML:(id)sender; +- (IBAction)filter:(id)sender; +- (IBAction)ignoreSelected:(id)sender; +- (IBAction)markAll:(id)sender; +- (IBAction)markInvert:(id)sender; +- (IBAction)markNone:(id)sender; +- (IBAction)markSelected:(id)sender; +- (IBAction)markToggle:(id)sender; +- (IBAction)openSelected:(id)sender; +- (IBAction)refresh:(id)sender; +- (IBAction)removeMarked:(id)sender; +- (IBAction)removeSelected:(id)sender; +- (IBAction)renameSelected:(id)sender; +- (IBAction)resetColumnsToDefault:(id)sender; +- (IBAction)revealSelected:(id)sender; +- (IBAction)showPreferencesPanel:(id)sender; +- (IBAction)startDuplicateScan:(id)sender; +- (IBAction)switchSelected:(id)sender; +- (IBAction)toggleColumn:(id)sender; +- (IBAction)toggleDelta:(id)sender; +- (IBAction)toggleDetailsPanel:(id)sender; +- (IBAction)togglePowerMarker:(id)sender; +- (IBAction)toggleDirectories:(id)sender; + +- (NSTableColumn *)getColumnForIdentifier:(int)aIdentifier title:(NSString *)aTitle width:(int)aWidth refCol:(NSTableColumn *)aColumn; +- (NSArray *)getColumnsOrder; +- (NSDictionary *)getColumnsWidth; +- (NSArray *)getSelected:(BOOL)aDupesOnly; +- (NSArray *)getSelectedPaths:(BOOL)aDupesOnly; +- (void)performPySelection:(NSArray *)aIndexPaths; +- (void)initResultColumns; +- (void)refreshStats; +- (void)restoreColumnsPosition:(NSArray *)aColumnsOrder widths:(NSDictionary *)aColumnsWidth; +@end diff --git a/pe/cocoa/ResultWindow.m b/pe/cocoa/ResultWindow.m new file mode 100644 index 00000000..e0679e4f --- /dev/null +++ b/pe/cocoa/ResultWindow.m @@ -0,0 +1,569 @@ +#import "ResultWindow.h" +#import "Dialogs.h" +#import "ProgressController.h" +#import "RegistrationInterface.h" +#import "Utils.h" +#import "AppDelegate.h" +#import "Consts.h" + +static NSString* tbbDirectories = @"tbbDirectories"; +static NSString* tbbDetails = @"tbbDetail"; +static NSString* tbbPreferences = @"tbbPreferences"; +static NSString* tbbPowerMarker = @"tbbPowerMarker"; +static NSString* tbbScan = @"tbbScan"; +static NSString* tbbAction = @"tbbAction"; +static NSString* tbbDelta = @"tbbDelta"; +static NSString* tbbFilter = @"tbbFilter"; + +@implementation ResultWindow +/* Override */ +- (void)awakeFromNib +{ + [super awakeFromNib]; + _displayDelta = NO; + _powerMode = NO; + _deltaColumns = [[NSMutableIndexSet indexSetWithIndexesInRange:NSMakeRange(2,5)] retain]; + [_deltaColumns removeIndex:3]; + [_deltaColumns removeIndex:4]; + [deltaSwitch setSelectedSegment:0]; + [pmSwitch setSelectedSegment:0]; + [py setDisplayDeltaValues:b2n(_displayDelta)]; + [matches setTarget:self]; + [matches setDoubleAction:@selector(openSelected:)]; + [[actionMenu itemAtIndex:0] setImage:[NSImage imageNamed: @"gear"]]; + [self initResultColumns]; + [self refreshStats]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(resultsMarkingChanged:) name:ResultsMarkingChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(resultsChanged:) name:ResultsChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jobCompleted:) name:JobCompletedNotification object:nil]; + + NSToolbar *t = [[[NSToolbar alloc] initWithIdentifier:@"ResultWindowToolbar"] autorelease]; + [t setAllowsUserCustomization:YES]; + [t setAutosavesConfiguration:NO]; + [t setDisplayMode:NSToolbarDisplayModeIconAndLabel]; + [t setDelegate:self]; + [[self window] setToolbar:t]; +} + +/* Actions */ + +- (IBAction)changePowerMarker:(id)sender +{ + _powerMode = [pmSwitch selectedSegment] == 1; + if (_powerMode) + [matches setTag:2]; + else + [matches setTag:0]; + [self expandAll:nil]; + [self outlineView:matches didClickTableColumn:nil]; +} + +- (IBAction)clearIgnoreList:(id)sender +{ + int i = n2i([py getIgnoreListCount]); + if (!i) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"Do you really want to remove all %d items from the ignore list?",i]] == NSAlertSecondButtonReturn) // NO + return; + [py clearIgnoreList]; +} + +- (IBAction)clearPictureCache:(id)sender +{ + if ([Dialogs askYesNo:@"Do you really want to remove all your cached picture analysis?"] == NSAlertSecondButtonReturn) // NO + return; + [(PyDupeGuru *)py clearPictureCache]; +} + +- (IBAction)exportToXHTML:(id)sender +{ + NSString *xsltPath = [[NSBundle mainBundle] pathForResource:@"dg" ofType:@"xsl"]; + NSString *cssPath = [[NSBundle mainBundle] pathForResource:@"hardcoded" ofType:@"css"]; + NSString *exported = [py exportToXHTMLwithColumns:[self getColumnsOrder] xslt:xsltPath css:cssPath]; + [[NSWorkspace sharedWorkspace] openFile:exported]; +} + +- (IBAction)filter:(id)sender +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [py setEscapeFilterRegexp:b2n(!n2b([ud objectForKey:@"useRegexpFilter"]))]; + [py applyFilter:[filterField stringValue]]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)ignoreSelected:(id)sender +{ + NSArray *nodeList = [self getSelected:YES]; + if (![nodeList count]) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"All selected %d matches are going to be ignored in all subsequent scans. Continue?",[nodeList count]]] == NSAlertSecondButtonReturn) // NO + return; + [self performPySelection:[self getSelectedPaths:YES]]; + [py addSelectedToIgnoreList]; + [py removeSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)markAll:(id)sender +{ + [py markAll]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markInvert:(id)sender +{ + [py markInvert]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markNone:(id)sender +{ + [py markNone]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:YES]]; + [py toggleSelectedMark]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markToggle:(id)sender +{ + OVNode *node = [matches itemAtRow:[matches clickedRow]]; + [self performPySelection:[NSArray arrayWithObject:p2a([node indexPath])]]; + [py toggleSelectedMark]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)openSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py openSelected]; +} + +- (IBAction)refresh:(id)sender +{ + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)removeMarked:(id)sender +{ + int mark_count = [[py getMarkCount] intValue]; + if (!mark_count) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"You are about to remove %d files from results. Continue?",mark_count]] == NSAlertSecondButtonReturn) // NO + return; + [py removeMarked]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)removeSelected:(id)sender +{ + NSArray *nodeList = [self getSelected:YES]; + if (![nodeList count]) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"You are about to remove %d files from results. Continue?",[nodeList count]]] == NSAlertSecondButtonReturn) // NO + return; + [self performPySelection:[self getSelectedPaths:YES]]; + [py removeSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)renameSelected:(id)sender +{ + int col = [matches columnWithIdentifier:@"0"]; + int row = [matches selectedRow]; + [matches editColumn:col row:row withEvent:[NSApp currentEvent] select:YES]; +} + +- (IBAction)resetColumnsToDefault:(id)sender +{ + NSMutableArray *columnsOrder = [NSMutableArray array]; + [columnsOrder addObject:@"0"]; + [columnsOrder addObject:@"1"]; + [columnsOrder addObject:@"2"]; + [columnsOrder addObject:@"4"]; + [columnsOrder addObject:@"7"]; + NSMutableDictionary *columnsWidth = [NSMutableDictionary dictionary]; + [columnsWidth setObject:i2n(125) forKey:@"0"]; + [columnsWidth setObject:i2n(120) forKey:@"1"]; + [columnsWidth setObject:i2n(63) forKey:@"2"]; + [columnsWidth setObject:i2n(73) forKey:@"4"]; + [columnsWidth setObject:i2n(58) forKey:@"7"]; + [self restoreColumnsPosition:columnsOrder widths:columnsWidth]; +} + +- (IBAction)revealSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py revealSelected]; +} + +- (IBAction)showPreferencesPanel:(id)sender +{ + [preferencesPanel makeKeyAndOrderFront:sender]; +} + +- (IBAction)startDuplicateScan:(id)sender +{ + if ([matches numberOfRows] > 0) + { + if ([Dialogs askYesNo:@"Are you sure you want to start a new duplicate scan?"] == NSAlertSecondButtonReturn) // NO + return; + } + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + PyDupeGuru *_py = (PyDupeGuru *)py; + [_py setMinMatchPercentage:[ud objectForKey:@"minMatchPercentage"]]; + [_py setMixFileKind:[ud objectForKey:@"mixFileKind"]]; + [_py setMatchScaled:[ud objectForKey:@"matchScaled"]]; + int r = n2i([py doScan]); + [matches reloadData]; + [self refreshStats]; + if (r != 0) + [[ProgressController mainProgressController] hide]; + if (r == 1) + [Dialogs showMessage:@"You cannot make a duplicate scan with only reference directories."]; + if (r == 3) + { + [Dialogs showMessage:@"The selected directories contain no scannable file."]; + [app toggleDirectories:nil]; + } +} + +- (IBAction)switchSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:YES]]; + [py makeSelectedReference]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)toggleColumn:(id)sender +{ + NSMenuItem *mi = sender; + NSString *colId = [NSString stringWithFormat:@"%d",[mi tag]]; + NSTableColumn *col = [matches tableColumnWithIdentifier:colId]; + if (col == nil) + { + //Add Column + col = [_resultColumns objectAtIndex:[mi tag]]; + [matches addTableColumn:col]; + [mi setState:NSOnState]; + } + else + { + //Remove column + [matches removeTableColumn:col]; + [mi setState:NSOffState]; + } +} + +- (IBAction)toggleDelta:(id)sender +{ + if ([deltaSwitch selectedSegment] == 1) + [deltaSwitch setSelectedSegment:0]; + else + [deltaSwitch setSelectedSegment:1]; + [self changeDelta:sender]; +} + + +- (IBAction)toggleDetailsPanel:(id)sender +{ + [(AppDelegate *)app toggleDetailsPanel:sender]; +} + +- (IBAction)togglePowerMarker:(id)sender +{ + if ([pmSwitch selectedSegment] == 1) + [pmSwitch setSelectedSegment:0]; + else + [pmSwitch setSelectedSegment:1]; + [self changePowerMarker:sender]; +} + +- (IBAction)toggleDirectories:(id)sender +{ + [(AppDelegate *)app toggleDirectories:sender]; +} + +/* Public */ +- (NSTableColumn *)getColumnForIdentifier:(int)aIdentifier title:(NSString *)aTitle width:(int)aWidth refCol:(NSTableColumn *)aColumn +{ + NSNumber *n = [NSNumber numberWithInt:aIdentifier]; + NSTableColumn *col = [[NSTableColumn alloc] initWithIdentifier:[n stringValue]]; + [col setWidth:aWidth]; + [col setEditable:NO]; + [[col dataCell] setFont:[[aColumn dataCell] font]]; + [[col headerCell] setStringValue:aTitle]; + [col setResizingMask:NSTableColumnUserResizingMask]; + [col setSortDescriptorPrototype:[[NSSortDescriptor alloc] initWithKey:[n stringValue] ascending:YES]]; + return col; +} + +//Returns an array of identifiers, in order. +- (NSArray *)getColumnsOrder +{ + NSTableColumn *col; + NSString *colId; + NSMutableArray *result = [NSMutableArray array]; + NSEnumerator *e = [[matches tableColumns] objectEnumerator]; + while (col = [e nextObject]) + { + colId = [col identifier]; + [result addObject:colId]; + } + return result; +} + +- (NSDictionary *)getColumnsWidth +{ + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + NSTableColumn *col; + NSString *colId; + NSNumber *width; + NSEnumerator *e = [[matches tableColumns] objectEnumerator]; + while (col = [e nextObject]) + { + colId = [col identifier]; + width = [NSNumber numberWithFloat:[col width]]; + [result setObject:width forKey:colId]; + } + return result; +} + +- (NSArray *)getSelected:(BOOL)aDupesOnly +{ + if (_powerMode) + aDupesOnly = NO; + NSIndexSet *indexes = [matches selectedRowIndexes]; + NSMutableArray *nodeList = [NSMutableArray array]; + OVNode *node; + int i = [indexes firstIndex]; + while (i != NSNotFound) + { + node = [matches itemAtRow:i]; + if (!aDupesOnly || ([node level] > 1)) + [nodeList addObject:node]; + i = [indexes indexGreaterThanIndex:i]; + } + return nodeList; +} + +- (NSArray *)getSelectedPaths:(BOOL)aDupesOnly +{ + NSMutableArray *r = [NSMutableArray array]; + NSArray *selected = [self getSelected:aDupesOnly]; + NSEnumerator *e = [selected objectEnumerator]; + OVNode *node; + while (node = [e nextObject]) + [r addObject:p2a([node indexPath])]; + return r; +} + +- (void)performPySelection:(NSArray *)aIndexPaths +{ + if (_powerMode) + [py selectPowerMarkerNodePaths:aIndexPaths]; + else + [py selectResultNodePaths:aIndexPaths]; +} + +- (void)initResultColumns +{ + NSTableColumn *refCol = [matches tableColumnWithIdentifier:@"0"]; + _resultColumns = [[NSMutableArray alloc] init]; + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"0"]]; // File Name + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"1"]]; // Directory + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"2"]]; // Size + [_resultColumns addObject:[self getColumnForIdentifier:3 title:@"Kind" width:40 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:4 title:@"Dimensions" width:80 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:5 title:@"Creation" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:6 title:@"Modification" width:120 refCol:refCol]]; + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"7"]]; // Match % + [_resultColumns addObject:[self getColumnForIdentifier:8 title:@"Dupe Count" width:80 refCol:refCol]]; +} + +-(void)refreshStats +{ + [stats setStringValue:[py getStatLine]]; +} + +- (void)restoreColumnsPosition:(NSArray *)aColumnsOrder widths:(NSDictionary *)aColumnsWidth +{ + NSTableColumn *col; + NSString *colId; + NSNumber *width; + NSMenuItem *mi; + //Remove all columns + NSEnumerator *e = [[columnsMenu itemArray] objectEnumerator]; + while (mi = [e nextObject]) + { + if ([mi state] == NSOnState) + [self toggleColumn:mi]; + } + //Add columns and set widths + e = [aColumnsOrder objectEnumerator]; + while (colId = [e nextObject]) + { + if (![colId isEqual:@"mark"]) + { + col = [_resultColumns objectAtIndex:[colId intValue]]; + width = [aColumnsWidth objectForKey:[col identifier]]; + mi = [columnsMenu itemWithTag:[colId intValue]]; + if (width) + [col setWidth:[width floatValue]]; + [self toggleColumn:mi]; + } + } +} + +/* Delegate */ +- (void)outlineView:(NSOutlineView *)outlineView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)tableColumn item:(id)item +{ + OVNode *node = item; + if ([[tableColumn identifier] isEqual:@"mark"]) + { + [cell setEnabled: [node isMarkable]]; + } + if ([cell isKindOfClass:[NSTextFieldCell class]]) + { + // Determine if the text color will be blue due to directory being reference. + NSTextFieldCell *textCell = cell; + if ([node isMarkable]) + [textCell setTextColor:[NSColor blackColor]]; + else + [textCell setTextColor:[NSColor blueColor]]; + if ((_displayDelta) && (_powerMode || ([node level] > 1))) + { + int i = [[tableColumn identifier] intValue]; + if ([_deltaColumns containsIndex:i]) + [textCell setTextColor:[NSColor orangeColor]]; + } + } +} + +- (void)outlineViewSelectionDidChange:(NSNotification *)notification +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py refreshDetailsWithSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:DuplicateSelectionChangedNotification object:self]; +} + +- (void)resultsChanged:(NSNotification *)aNotification +{ + [matches reloadData]; + [self expandAll:nil]; + [self outlineViewSelectionDidChange:nil]; + [self refreshStats]; +} + +- (void)resultsMarkingChanged:(NSNotification *)aNotification +{ + [matches invalidateMarkings]; + [self refreshStats]; +} + +/* Toolbar */ + +- (NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSString *)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag +{ + NSToolbarItem *tbi = [[[NSToolbarItem alloc] initWithItemIdentifier:itemIdentifier] autorelease]; + if (itemIdentifier == tbbDirectories) + { + [tbi setLabel: @"Directories"]; + [tbi setToolTip: @"Show/Hide the directories panel."]; + [tbi setImage: [NSImage imageNamed: @"folder32"]]; + [tbi setTarget: self]; + [tbi setAction: @selector(toggleDirectories:)]; + } + else if (itemIdentifier == tbbDetails) + { + [tbi setLabel: @"Details"]; + [tbi setToolTip: @"Show/Hide the details panel."]; + [tbi setImage: [NSImage imageNamed: @"details32"]]; + [tbi setTarget: self]; + [tbi setAction: @selector(toggleDetailsPanel:)]; + } + else if (itemIdentifier == tbbPreferences) + { + [tbi setLabel: @"Preferences"]; + [tbi setToolTip: @"Show the preferences panel."]; + [tbi setImage: [NSImage imageNamed: @"preferences32"]]; + [tbi setTarget: self]; + [tbi setAction: @selector(showPreferencesPanel:)]; + } + else if (itemIdentifier == tbbPowerMarker) + { + [tbi setLabel: @"Power Marker"]; + [tbi setToolTip: @"When enabled, only the duplicates are shown, not the references."]; + [tbi setView:pmSwitchView]; + [tbi setMinSize:[pmSwitchView frame].size]; + [tbi setMaxSize:[pmSwitchView frame].size]; + } + else if (itemIdentifier == tbbScan) + { + [tbi setLabel: @"Start Scanning"]; + [tbi setToolTip: @"Start scanning for duplicates in the selected diectories."]; + [tbi setImage: [NSImage imageNamed: @"dgpe_logo_32"]]; + [tbi setTarget: self]; + [tbi setAction: @selector(startDuplicateScan:)]; + } + else if (itemIdentifier == tbbAction) + { + [tbi setLabel: @"Action"]; + [tbi setView:actionMenuView]; + [tbi setMinSize:[actionMenuView frame].size]; + [tbi setMaxSize:[actionMenuView frame].size]; + } + else if (itemIdentifier == tbbDelta) + { + [tbi setLabel: @"Delta Values"]; + [tbi setToolTip: @"When enabled, this option makes dupeGuru display, where applicable, delta values instead of absolute values."]; + [tbi setView:deltaSwitchView]; + [tbi setMinSize:[deltaSwitchView frame].size]; + [tbi setMaxSize:[deltaSwitchView frame].size]; + } + else if (itemIdentifier == tbbFilter) + { + [tbi setLabel: @"Filter"]; + [tbi setToolTip: @"Filters the results using regular expression."]; + [tbi setView:filterFieldView]; + [tbi setMinSize:[filterFieldView frame].size]; + [tbi setMaxSize:NSMakeSize(1000, [filterFieldView frame].size.height)]; + } + [tbi setPaletteLabel: [tbi label]]; + return tbi; +} + +- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar +{ + return [NSArray arrayWithObjects: + tbbDirectories, + tbbDetails, + tbbPreferences, + tbbPowerMarker, + tbbScan, + tbbAction, + tbbDelta, + tbbFilter, + NSToolbarSeparatorItemIdentifier, + NSToolbarSpaceItemIdentifier, + NSToolbarFlexibleSpaceItemIdentifier, + nil]; +} + +- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar +{ + return [NSArray arrayWithObjects: + tbbScan, + tbbAction, + tbbDirectories, + tbbDetails, + tbbPowerMarker, + tbbDelta, + tbbFilter, + nil]; +} +@end \ No newline at end of file diff --git a/pe/cocoa/dupeguru.icns b/pe/cocoa/dupeguru.icns new file mode 100755 index 0000000000000000000000000000000000000000..c143ed8670c29efd911d9db402978756a26b01b5 GIT binary patch literal 59921 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 + + + + ActivePerspectiveName + Project + AllowedModules + + + BundleLoadPath + + MaxInstances + n + Module + PBXSmartGroupTreeModule + Name + Groups and Files Outline View + + + BundleLoadPath + + MaxInstances + n + Module + PBXNavigatorGroup + Name + Editor + + + BundleLoadPath + + MaxInstances + n + Module + XCTaskListModule + Name + Task List + + + BundleLoadPath + + MaxInstances + n + Module + XCDetailModule + Name + File and Smart Group Detail Viewer + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXBuildResultsModule + Name + Detailed Build Results Viewer + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXProjectFindModule + Name + Project Batch Find Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXRunSessionModule + Name + Run Log + + + BundleLoadPath + + MaxInstances + n + Module + PBXBookmarksModule + Name + Bookmarks Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXClassBrowserModule + Name + Class Browser + + + BundleLoadPath + + MaxInstances + n + Module + PBXCVSModule + Name + Source Code Control Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXDebugBreakpointsModule + Name + Debug Breakpoints Tool + + + BundleLoadPath + + MaxInstances + n + Module + XCDockableInspector + Name + Inspector + + + BundleLoadPath + + MaxInstances + n + Module + PBXOpenQuicklyModule + Name + Open Quickly Tool + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXDebugSessionModule + Name + Debugger + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXDebugCLIModule + Name + Debug Console + + + Description + DefaultDescriptionKey + DockingSystemVisible + + Extension + mode1 + FavBarConfig + + PBXProjectModuleGUID + CE381CB409914B41003581CE + XCBarModuleItemNames + + XCBarModuleItems + + + FirstTimeWindowDisplayed + + Identifier + com.apple.perspectives.project.mode1 + MajorVersion + 31 + MinorVersion + 1 + Name + Default + Notifications + + OpenEditors + + PerspectiveWidths + + -1 + -1 + + Perspectives + + + ChosenToolbarItems + + active-executable-popup + action + active-buildstyle-popup + active-target-popup + buildOrClean + build-and-runOrDebug + com.apple.ide.PBXToolbarStopButton + get-info + toggle-editor + + ControllerClassBaseName + + IconName + WindowOfProjectWithEditor + Identifier + perspective.project + IsVertical + + Layout + + + BecomeActive + + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C37FBAC04509CD000000102 + 1C37FAAC04509CD000000102 + 1C08E77C0454961000C914BD + 1C37FABC05509CD000000102 + 1C37FABC05539CD112110102 + E2644B35053B69B200211256 + 1C37FABC04509CD000100104 + 1CC0EA4004350EF90044410B + 1CC0EA4004350EF90041110B + + PBXProjectModuleGUID + 1CE0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + yes + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 194 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 29B97314FDCFA39411CA2CEA + 080E96DDFE201D6D7F000001 + 29B97315FDCFA39411CA2CEA + 29B97317FDCFA39411CA2CEA + 29B97323FDCFA39411CA2CEA + 1058C7A0FEA54F0111CA2CBB + 19C28FACFE9D520D11CA2CBB + 1C37FBAC04509CD000000102 + CE2ACA9A0CA214440012E1E8 + CE2ACA9B0CA214440012E1E8 + CE2ACA9C0CA214440012E1E8 + CE2ACA9D0CA214440012E1E8 + 1C37FAAC04509CD000000102 + 1C37FABC05509CD000000102 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 17 + 15 + 0 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {194, 764}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + + XCSharingToken + com.apple.Xcode.GFSharingToken + + GeometryConfiguration + + Frame + {{0, 0}, {211, 782}} + GroupTreeTableConfiguration + + MainColumn + 194 + + RubberWindowFrame + 4 54 1366 823 0 0 1440 878 + + Module + PBXSmartGroupTreeModule + Proportion + 211pt + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CE0B20306471E060097A5F4 + PBXProjectModuleLabel + PyDupeGuru.h + PBXSplitModuleInNavigatorKey + + Split0 + + PBXProjectModuleGUID + 1CE0B20406471E060097A5F4 + PBXProjectModuleLabel + PyDupeGuru.h + _historyCapacity + 10 + bookmark + CE2ACAA20CA214B50012E1E8 + history + + CEE22C420B9A163B000D3096 + CEE584280BACAE4E004F9755 + CEE586070BACBCA6004F9755 + CEE301880BF7350900D6840C + CEE301890BF7350900D6840C + CEE3018A0BF7350900D6840C + CEE301DC0BF73A0300D6840C + CEE301DD0BF73A0300D6840C + CEE301F30BF73A9A00D6840C + CEB6A23E0C9EA80D00767CC9 + + prevStack + + CE2CB4DA09AE70AA0015538F + CEF411510A11093E00E7F110 + CE6E6AE70AA528B2002F29BE + CEFA86C20AAEE6DE00E0FAA1 + CEFA86C30AAEE6DE00E0FAA1 + CED527320AB0C9EB00D70726 + CEE2E1F30AB0E4A900D458B6 + CEE2E1F40AB0E4A900D458B6 + CEF650770ABABB44009F3C83 + CEE3018C0BF7350900D6840C + CEE3018F0BF7350900D6840C + + + SplitCount + 1 + + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {1150, 544}} + RubberWindowFrame + 4 54 1366 823 0 0 1440 878 + + Module + PBXNavigatorGroup + Proportion + 544pt + + + ContentConfiguration + + PBXProjectModuleGUID + 1CE0B20506471E060097A5F4 + PBXProjectModuleLabel + Detail + + GeometryConfiguration + + Frame + {{0, 549}, {1150, 233}} + RubberWindowFrame + 4 54 1366 823 0 0 1440 878 + + Module + XCDetailModule + Proportion + 233pt + + + Proportion + 1150pt + + + Name + Project + ServiceClasses + + XCModuleDock + PBXSmartGroupTreeModule + XCModuleDock + PBXNavigatorGroup + XCDetailModule + + TableOfContents + + CE2ACA9F0CA214440012E1E8 + 1CE0B1FE06471DED0097A5F4 + CE2ACAA00CA214440012E1E8 + 1CE0B20306471E060097A5F4 + 1CE0B20506471E060097A5F4 + + ToolbarConfiguration + xcode.toolbar.config.default + + + ControllerClassBaseName + + IconName + WindowOfProject + Identifier + perspective.morph + IsVertical + 0 + Layout + + + BecomeActive + 1 + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C37FBAC04509CD000000102 + 1C37FAAC04509CD000000102 + 1C08E77C0454961000C914BD + 1C37FABC05509CD000000102 + 1C37FABC05539CD112110102 + E2644B35053B69B200211256 + 1C37FABC04509CD000100104 + 1CC0EA4004350EF90044410B + 1CC0EA4004350EF90041110B + + PBXProjectModuleGUID + 11E0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + yes + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 186 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 29B97314FDCFA39411CA2CEA + 1C37FABC05509CD000000102 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 0 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {186, 337}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + 1 + XCSharingToken + com.apple.Xcode.GFSharingToken + + GeometryConfiguration + + Frame + {{0, 0}, {203, 355}} + GroupTreeTableConfiguration + + MainColumn + 186 + + RubberWindowFrame + 373 269 690 397 0 0 1440 878 + + Module + PBXSmartGroupTreeModule + Proportion + 100% + + + Name + Morph + PreferredWidth + 300 + ServiceClasses + + XCModuleDock + PBXSmartGroupTreeModule + + TableOfContents + + 11E0B1FE06471DED0097A5F4 + + ToolbarConfiguration + xcode.toolbar.config.default.short + + + PerspectivesBarVisible + + ShelfIsVisible + + SourceDescription + file at '/System/Library/PrivateFrameworks/DevToolsInterface.framework/Versions/A/Resources/XCPerspectivesSpecificationMode1.xcperspec' + StatusbarIsVisible + + TimeStamp + 0.0 + ToolbarDisplayMode + 1 + ToolbarIsVisible + + ToolbarSizeMode + 1 + Type + Perspectives + UpdateMessage + The Default Workspace in this version of Xcode now includes support to hide and show the detail view (what has been referred to as the "Metro-Morph" feature). You must discard your current Default Workspace settings and update to the latest Default Workspace in order to gain this feature. Do you wish to update to the latest Workspace defaults for project '%@'? + WindowJustification + 5 + WindowOrderList + + /Users/hsoft/src/dupeguru_pe_cocoa/dupeguru.xcodeproj + + WindowString + 4 54 1366 823 0 0 1440 878 + WindowTools + + + FirstTimeWindowDisplayed + + Identifier + windowTool.build + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CD0528F0623707200166675 + PBXProjectModuleLabel + + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {1366, 540}} + RubberWindowFrame + 0 56 1366 822 0 0 1440 878 + + Module + PBXNavigatorGroup + Proportion + 540pt + + + BecomeActive + + ContentConfiguration + + PBXProjectModuleGUID + XCMainBuildResultsModuleGUID + PBXProjectModuleLabel + Build + XCBuildResultsTrigger_Collapse + 1021 + XCBuildResultsTrigger_Open + 1011 + + GeometryConfiguration + + Frame + {{0, 545}, {1366, 236}} + RubberWindowFrame + 0 56 1366 822 0 0 1440 878 + + Module + PBXBuildResultsModule + Proportion + 236pt + + + Proportion + 781pt + + + Name + Build Results + ServiceClasses + + PBXBuildResultsModule + + StatusbarIsVisible + + TableOfContents + + CE381CCE09914BC8003581CE + CEB6A2360C9EA7D700767CC9 + 1CD0528F0623707200166675 + XCMainBuildResultsModuleGUID + + ToolbarConfiguration + xcode.toolbar.config.build + WindowString + 0 56 1366 822 0 0 1440 878 + WindowToolGUID + CE381CCE09914BC8003581CE + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.debugger + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + Debugger + + HorizontalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {150, 339}} + {{150, 0}, {874, 339}} + + + VerticalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {1024, 339}} + {{0, 339}, {1024, 306}} + + + + LauncherConfigVersion + 8 + PBXProjectModuleGUID + 1C162984064C10D400B95A72 + PBXProjectModuleLabel + Debug - GLUTExamples (Underwater) + + GeometryConfiguration + + DebugConsoleDrawerSize + {100, 120} + DebugConsoleVisible + None + DebugConsoleWindowFrame + {{200, 200}, {500, 300}} + DebugSTDIOWindowFrame + {{200, 200}, {500, 300}} + Frame + {{0, 0}, {1024, 645}} + RubberWindowFrame + 328 62 1024 686 0 0 1440 878 + + Module + PBXDebugSessionModule + Proportion + 645pt + + + Proportion + 645pt + + + Name + Debugger + ServiceClasses + + PBXDebugSessionModule + + StatusbarIsVisible + + TableOfContents + + 1CD10A99069EF8BA00B06720 + CEC94B9A0BA3652F009F7CBD + 1C162984064C10D400B95A72 + CEC94B9B0BA3652F009F7CBD + CEC94B9C0BA3652F009F7CBD + CEC94B9D0BA3652F009F7CBD + CEC94B9E0BA3652F009F7CBD + CEC94B9F0BA3652F009F7CBD + CEC94BA00BA3652F009F7CBD + + ToolbarConfiguration + xcode.toolbar.config.debug + WindowString + 328 62 1024 686 0 0 1440 878 + WindowToolGUID + 1CD10A99069EF8BA00B06720 + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.find + IsVertical + + Layout + + + Dock + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CDD528C0622207200134675 + PBXProjectModuleLabel + + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {781, 212}} + RubberWindowFrame + 31 253 781 470 0 0 1024 746 + + Module + PBXNavigatorGroup + Proportion + 781pt + + + Proportion + 212pt + + + ContentConfiguration + + PBXProjectModuleGUID + 1CD0528E0623707200166675 + PBXProjectModuleLabel + Project Find + + GeometryConfiguration + + Frame + {{0, 217}, {781, 212}} + RubberWindowFrame + 31 253 781 470 0 0 1024 746 + + Module + PBXProjectFindModule + Proportion + 212pt + + + Proportion + 429pt + + + Name + Project Find + ServiceClasses + + PBXProjectFindModule + + StatusbarIsVisible + + TableOfContents + + 1C530D57069F1CE1000CFCEE + CE3755460A37628100022F3B + CE3755470A37628100022F3B + 1CDD528C0622207200134675 + 1CD0528E0623707200166675 + + WindowString + 31 253 781 470 0 0 1024 746 + WindowToolGUID + 1C530D57069F1CE1000CFCEE + WindowToolIsVisible + + + + Identifier + MENUSEPARATOR + + + FirstTimeWindowDisplayed + + Identifier + windowTool.debuggerConsole + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1C78EAAC065D492600B07095 + PBXProjectModuleLabel + Debugger Console + + GeometryConfiguration + + Frame + {{0, 0}, {440, 358}} + RubberWindowFrame + 72 414 440 400 0 0 1440 878 + + Module + PBXDebugCLIModule + Proportion + 358pt + + + Proportion + 359pt + + + Name + Debugger Console + ServiceClasses + + PBXDebugCLIModule + + StatusbarIsVisible + + TableOfContents + + CECD0ADE099294C1003DC359 + CEC94BA10BA3652F009F7CBD + 1C78EAAC065D492600B07095 + + WindowString + 72 414 440 400 0 0 1440 878 + WindowToolGUID + CECD0ADE099294C1003DC359 + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.run + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + LauncherConfigVersion + 3 + PBXProjectModuleGUID + 1CD0528B0623707200166675 + PBXProjectModuleLabel + Run + Runner + + HorizontalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {366, 168}} + {{0, 173}, {366, 270}} + + + VerticalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {406, 443}} + {{411, 0}, {517, 443}} + + + + + GeometryConfiguration + + Frame + {{0, 0}, {1377, 781}} + RubberWindowFrame + 0 56 1377 822 0 0 1440 878 + + Module + PBXRunSessionModule + Proportion + 781pt + + + Proportion + 781pt + + + Name + Run Log + ServiceClasses + + PBXRunSessionModule + + StatusbarIsVisible + + TableOfContents + + 1C0AD2B3069F1EA900FABCE6 + CE89234E0BFF46580079C065 + 1CD0528B0623707200166675 + CE89234F0BFF46580079C065 + + ToolbarConfiguration + xcode.toolbar.config.run + WindowString + 0 56 1377 822 0 0 1440 878 + WindowToolGUID + 1C0AD2B3069F1EA900FABCE6 + WindowToolIsVisible + + + + Identifier + windowTool.scm + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1C78EAB2065D492600B07095 + PBXProjectModuleLabel + <No Editor> + PBXSplitModuleInNavigatorKey + + Split0 + + PBXProjectModuleGUID + 1C78EAB3065D492600B07095 + + SplitCount + 1 + + StatusBarVisibility + 1 + + GeometryConfiguration + + Frame + {{0, 0}, {452, 0}} + RubberWindowFrame + 743 379 452 308 0 0 1280 1002 + + Module + PBXNavigatorGroup + Proportion + 0pt + + + BecomeActive + 1 + ContentConfiguration + + PBXProjectModuleGUID + 1CD052920623707200166675 + PBXProjectModuleLabel + SCM + + GeometryConfiguration + + ConsoleFrame + {{0, 259}, {452, 0}} + Frame + {{0, 7}, {452, 259}} + RubberWindowFrame + 743 379 452 308 0 0 1280 1002 + TableConfiguration + + Status + 30 + FileName + 199 + Path + 197.09500122070312 + + TableFrame + {{0, 0}, {452, 250}} + + Module + PBXCVSModule + Proportion + 262pt + + + Proportion + 266pt + + + Name + SCM + ServiceClasses + + PBXCVSModule + + StatusbarIsVisible + 1 + TableOfContents + + 1C78EAB4065D492600B07095 + 1C78EAB5065D492600B07095 + 1C78EAB2065D492600B07095 + 1CD052920623707200166675 + + ToolbarConfiguration + xcode.toolbar.config.scm + WindowString + 743 379 452 308 0 0 1280 1002 + + + FirstTimeWindowDisplayed + + Identifier + windowTool.breakpoints + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C77FABC04509CD000000102 + + PBXProjectModuleGUID + 1CE0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + no + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 168 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 1C77FABC04509CD000000102 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 0 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {168, 350}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + + + GeometryConfiguration + + Frame + {{0, 0}, {185, 368}} + GroupTreeTableConfiguration + + MainColumn + 168 + + RubberWindowFrame + 21 314 744 409 0 0 1024 746 + + Module + PBXSmartGroupTreeModule + Proportion + 185pt + + + BecomeActive + + ContentConfiguration + + PBXProjectModuleGUID + 1CA1AED706398EBD00589147 + PBXProjectModuleLabel + Detail + + GeometryConfiguration + + Frame + {{190, 0}, {554, 368}} + RubberWindowFrame + 21 314 744 409 0 0 1024 746 + + Module + XCDetailModule + Proportion + 554pt + + + Proportion + 368pt + + + MajorVersion + 2 + MinorVersion + 0 + Name + Breakpoints + ServiceClasses + + PBXSmartGroupTreeModule + XCDetailModule + + StatusbarIsVisible + + TableOfContents + + CEDA9EAC09D2BBCE00741F3F + CEDA9EAD09D2BBCE00741F3F + 1CE0B1FE06471DED0097A5F4 + 1CA1AED706398EBD00589147 + + ToolbarConfiguration + xcode.toolbar.config.breakpoints + WindowString + 21 314 744 409 0 0 1024 746 + WindowToolGUID + CEDA9EAC09D2BBCE00741F3F + WindowToolIsVisible + + + + Identifier + windowTool.debugAnimator + Layout + + + Dock + + + Module + PBXNavigatorGroup + Proportion + 100% + + + Proportion + 100% + + + Name + Debug Visualizer + ServiceClasses + + PBXNavigatorGroup + + StatusbarIsVisible + 1 + ToolbarConfiguration + xcode.toolbar.config.debugAnimator + WindowString + 100 100 700 500 0 0 1280 1002 + + + Identifier + windowTool.bookmarks + Layout + + + Dock + + + Module + PBXBookmarksModule + Proportion + 100% + + + Proportion + 100% + + + Name + Bookmarks + ServiceClasses + + PBXBookmarksModule + + StatusbarIsVisible + 0 + WindowString + 538 42 401 187 0 0 1280 1002 + + + Identifier + windowTool.classBrowser + Layout + + + Dock + + + BecomeActive + 1 + ContentConfiguration + + OptionsSetName + Hierarchy, all classes + PBXProjectModuleGUID + 1CA6456E063B45B4001379D8 + PBXProjectModuleLabel + Class Browser - NSObject + + GeometryConfiguration + + ClassesFrame + {{0, 0}, {374, 96}} + ClassesTreeTableConfiguration + + PBXClassNameColumnIdentifier + 208 + PBXClassBookColumnIdentifier + 22 + + Frame + {{0, 0}, {630, 331}} + MembersFrame + {{0, 105}, {374, 395}} + MembersTreeTableConfiguration + + PBXMemberTypeIconColumnIdentifier + 22 + PBXMemberNameColumnIdentifier + 216 + PBXMemberTypeColumnIdentifier + 97 + PBXMemberBookColumnIdentifier + 22 + + PBXModuleWindowStatusBarHidden2 + 1 + RubberWindowFrame + 385 179 630 352 0 0 1440 878 + + Module + PBXClassBrowserModule + Proportion + 332pt + + + Proportion + 332pt + + + Name + Class Browser + ServiceClasses + + PBXClassBrowserModule + + StatusbarIsVisible + 0 + TableOfContents + + 1C0AD2AF069F1E9B00FABCE6 + 1C0AD2B0069F1E9B00FABCE6 + 1CA6456E063B45B4001379D8 + + ToolbarConfiguration + xcode.toolbar.config.classbrowser + WindowString + 385 179 630 352 0 0 1440 878 + WindowToolGUID + 1C0AD2AF069F1E9B00FABCE6 + WindowToolIsVisible + 0 + + + + diff --git a/pe/cocoa/dupeguru.xcodeproj/project.pbxproj b/pe/cocoa/dupeguru.xcodeproj/project.pbxproj new file mode 100644 index 00000000..02043065 --- /dev/null +++ b/pe/cocoa/dupeguru.xcodeproj/project.pbxproj @@ -0,0 +1,588 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 42; + objects = { + +/* Begin PBXBuildFile section */ + 8D11072A0486CEB800E47090 /* MainMenu.nib in Resources */ = {isa = PBXBuildFile; fileRef = 29B97318FDCFA39411CA2CEA /* MainMenu.nib */; }; + 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */; }; + 8D11072D0486CEB800E47090 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B97316FDCFA39411CA2CEA /* main.m */; settings = {ATTRIBUTES = (); }; }; + 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */; }; + CE073F6309CAE1A3005C1D2F /* dupeguru_pe_help in Resources */ = {isa = PBXBuildFile; fileRef = CE073F5409CAE1A3005C1D2F /* dupeguru_pe_help */; }; + CE0C46AA0FA0647E000BE99B /* PictureBlocks.m in Sources */ = {isa = PBXBuildFile; fileRef = CE0C46A90FA0647E000BE99B /* PictureBlocks.m */; }; + CE15C8A80ADEB8B50061D4A5 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE15C8A70ADEB8B50061D4A5 /* Sparkle.framework */; }; + CE15C8C00ADEB8D40061D4A5 /* Sparkle.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE15C8A70ADEB8B50061D4A5 /* Sparkle.framework */; }; + CE381C9609914ACE003581CE /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = CE381C9409914ACE003581CE /* AppDelegate.m */; }; + CE381C9C09914ADF003581CE /* ResultWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CE381C9A09914ADF003581CE /* ResultWindow.m */; }; + CE381D0509915304003581CE /* dg_cocoa.plugin in Resources */ = {isa = PBXBuildFile; fileRef = CE381CF509915304003581CE /* dg_cocoa.plugin */; }; + CE3AA46709DB207900DB3A21 /* Directories.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE3AA46509DB207900DB3A21 /* Directories.nib */; }; + CE68EE6809ABC48000971085 /* DirectoryPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CE68EE6609ABC48000971085 /* DirectoryPanel.m */; }; + CE80DB2E0FC192D60086DCA6 /* Dialogs.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB1C0FC192D60086DCA6 /* Dialogs.m */; }; + CE80DB2F0FC192D60086DCA6 /* HSErrorReportWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB1E0FC192D60086DCA6 /* HSErrorReportWindow.m */; }; + CE80DB300FC192D60086DCA6 /* Outline.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB200FC192D60086DCA6 /* Outline.m */; }; + CE80DB310FC192D60086DCA6 /* ProgressController.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB220FC192D60086DCA6 /* ProgressController.m */; }; + CE80DB320FC192D60086DCA6 /* RecentDirectories.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB250FC192D60086DCA6 /* RecentDirectories.m */; }; + CE80DB330FC192D60086DCA6 /* RegistrationInterface.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB270FC192D60086DCA6 /* RegistrationInterface.m */; }; + CE80DB340FC192D60086DCA6 /* Table.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB290FC192D60086DCA6 /* Table.m */; }; + CE80DB350FC192D60086DCA6 /* Utils.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB2B0FC192D60086DCA6 /* Utils.m */; }; + CE80DB360FC192D60086DCA6 /* ValueTransformers.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB2D0FC192D60086DCA6 /* ValueTransformers.m */; }; + CE80DB470FC193650086DCA6 /* NSNotificationAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB460FC193650086DCA6 /* NSNotificationAdditions.m */; }; + CE80DB4A0FC193770086DCA6 /* NSImageAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB490FC193770086DCA6 /* NSImageAdditions.m */; }; + CE80DB760FC194760086DCA6 /* ErrorReportWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = CE80DB700FC194760086DCA6 /* ErrorReportWindow.xib */; }; + CE80DB770FC194760086DCA6 /* progress.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE80DB720FC194760086DCA6 /* progress.nib */; }; + CE80DB780FC194760086DCA6 /* registration.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE80DB740FC194760086DCA6 /* registration.nib */; }; + CE80DB8A0FC1951C0086DCA6 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB830FC1951C0086DCA6 /* AppDelegate.m */; }; + CE80DB8B0FC1951C0086DCA6 /* DirectoryPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB860FC1951C0086DCA6 /* DirectoryPanel.m */; }; + CE80DB8C0FC1951C0086DCA6 /* ResultWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CE80DB890FC1951C0086DCA6 /* ResultWindow.m */; }; + CE848A1909DD85810004CB44 /* Consts.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE848A1809DD85810004CB44 /* Consts.h */; }; + CECA899909DB12CA00A3D774 /* Details.nib in Resources */ = {isa = PBXBuildFile; fileRef = CECA899709DB12CA00A3D774 /* Details.nib */; }; + CECA899C09DB132E00A3D774 /* DetailsPanel.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = CECA899A09DB132E00A3D774 /* DetailsPanel.h */; }; + CECA899D09DB132E00A3D774 /* DetailsPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CECA899B09DB132E00A3D774 /* DetailsPanel.m */; }; + CEDA432E0B07C6E600B3091A /* dg.xsl in Resources */ = {isa = PBXBuildFile; fileRef = CEDA432C0B07C6E600B3091A /* dg.xsl */; }; + CEDA432F0B07C6E600B3091A /* hardcoded.css in Resources */ = {isa = PBXBuildFile; fileRef = CEDA432D0B07C6E600B3091A /* hardcoded.css */; }; + CEEB135209C837A2004D2330 /* dupeguru.icns in Resources */ = {isa = PBXBuildFile; fileRef = CEEB135109C837A2004D2330 /* dupeguru.icns */; }; + CEF4112B0A11069600E7F110 /* Consts.m in Sources */ = {isa = PBXBuildFile; fileRef = CEF4112A0A11069600E7F110 /* Consts.m */; }; + CEF7823809C8AA0200EF38FF /* gear.png in Resources */ = {isa = PBXBuildFile; fileRef = CEF7823709C8AA0200EF38FF /* gear.png */; }; + CEFC294609C89E3D00D9F998 /* folder32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC294509C89E3D00D9F998 /* folder32.png */; }; + CEFC295509C89FF200D9F998 /* details32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC295309C89FF200D9F998 /* details32.png */; }; + CEFC295609C89FF200D9F998 /* preferences32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC295409C89FF200D9F998 /* preferences32.png */; }; + CEFCDE2D0AB0418600C33A93 /* dgpe_logo_32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFCDE2C0AB0418600C33A93 /* dgpe_logo_32.png */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + CECC02B709A36E8200CC0A94 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + CE15C8C00ADEB8D40061D4A5 /* Sparkle.framework in CopyFiles */, + CECA899C09DB132E00A3D774 /* DetailsPanel.h in CopyFiles */, + CE848A1909DD85810004CB44 /* Consts.h in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 089C165DFE840E0CC02AAC07 /* English */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = English; path = English.lproj/InfoPlist.strings; sourceTree = ""; }; + 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; + 13E42FB307B3F0F600E4EEF1 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = /System/Library/Frameworks/CoreData.framework; sourceTree = ""; }; + 29B97316FDCFA39411CA2CEA /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = SOURCE_ROOT; }; + 29B97319FDCFA39411CA2CEA /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/MainMenu.nib; sourceTree = ""; }; + 29B97324FDCFA39411CA2CEA /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = /System/Library/Frameworks/AppKit.framework; sourceTree = ""; }; + 29B97325FDCFA39411CA2CEA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = /System/Library/Frameworks/Foundation.framework; sourceTree = ""; }; + 8D1107310486CEB800E47090 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = SOURCE_ROOT; }; + 8D1107320486CEB800E47090 /* dupeGuru PE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "dupeGuru PE.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + CE073F5409CAE1A3005C1D2F /* dupeguru_pe_help */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dupeguru_pe_help; path = help/dupeguru_pe_help; sourceTree = SOURCE_ROOT; }; + CE0C46A80FA0647E000BE99B /* PictureBlocks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PictureBlocks.h; sourceTree = ""; }; + CE0C46A90FA0647E000BE99B /* PictureBlocks.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PictureBlocks.m; sourceTree = ""; }; + CE15C8A70ADEB8B50061D4A5 /* Sparkle.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Sparkle.framework; path = /Library/Frameworks/Sparkle.framework; sourceTree = ""; }; + CE381C9409914ACE003581CE /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = SOURCE_ROOT; }; + CE381C9509914ACE003581CE /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = SOURCE_ROOT; }; + CE381C9A09914ADF003581CE /* ResultWindow.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = ResultWindow.m; sourceTree = SOURCE_ROOT; }; + CE381C9B09914ADF003581CE /* ResultWindow.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = ResultWindow.h; sourceTree = SOURCE_ROOT; }; + CE381CF509915304003581CE /* dg_cocoa.plugin */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dg_cocoa.plugin; path = py/dist/dg_cocoa.plugin; sourceTree = SOURCE_ROOT; }; + CE3AA46609DB207900DB3A21 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/Directories.nib; sourceTree = ""; }; + CE68EE6509ABC48000971085 /* DirectoryPanel.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = DirectoryPanel.h; sourceTree = SOURCE_ROOT; }; + CE68EE6609ABC48000971085 /* DirectoryPanel.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = DirectoryPanel.m; sourceTree = SOURCE_ROOT; }; + CE80DB1B0FC192D60086DCA6 /* Dialogs.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Dialogs.h; path = cocoalib/Dialogs.h; sourceTree = SOURCE_ROOT; }; + CE80DB1C0FC192D60086DCA6 /* Dialogs.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Dialogs.m; path = cocoalib/Dialogs.m; sourceTree = SOURCE_ROOT; }; + CE80DB1D0FC192D60086DCA6 /* HSErrorReportWindow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = HSErrorReportWindow.h; path = cocoalib/HSErrorReportWindow.h; sourceTree = SOURCE_ROOT; }; + CE80DB1E0FC192D60086DCA6 /* HSErrorReportWindow.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = HSErrorReportWindow.m; path = cocoalib/HSErrorReportWindow.m; sourceTree = SOURCE_ROOT; }; + CE80DB1F0FC192D60086DCA6 /* Outline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Outline.h; path = cocoalib/Outline.h; sourceTree = SOURCE_ROOT; }; + CE80DB200FC192D60086DCA6 /* Outline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Outline.m; path = cocoalib/Outline.m; sourceTree = SOURCE_ROOT; }; + CE80DB210FC192D60086DCA6 /* ProgressController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ProgressController.h; path = cocoalib/ProgressController.h; sourceTree = SOURCE_ROOT; }; + CE80DB220FC192D60086DCA6 /* ProgressController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ProgressController.m; path = cocoalib/ProgressController.m; sourceTree = SOURCE_ROOT; }; + CE80DB230FC192D60086DCA6 /* PyApp.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PyApp.h; path = cocoalib/PyApp.h; sourceTree = SOURCE_ROOT; }; + CE80DB240FC192D60086DCA6 /* RecentDirectories.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RecentDirectories.h; path = cocoalib/RecentDirectories.h; sourceTree = SOURCE_ROOT; }; + CE80DB250FC192D60086DCA6 /* RecentDirectories.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RecentDirectories.m; path = cocoalib/RecentDirectories.m; sourceTree = SOURCE_ROOT; }; + CE80DB260FC192D60086DCA6 /* RegistrationInterface.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RegistrationInterface.h; path = cocoalib/RegistrationInterface.h; sourceTree = SOURCE_ROOT; }; + CE80DB270FC192D60086DCA6 /* RegistrationInterface.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RegistrationInterface.m; path = cocoalib/RegistrationInterface.m; sourceTree = SOURCE_ROOT; }; + CE80DB280FC192D60086DCA6 /* Table.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Table.h; path = cocoalib/Table.h; sourceTree = SOURCE_ROOT; }; + CE80DB290FC192D60086DCA6 /* Table.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Table.m; path = cocoalib/Table.m; sourceTree = SOURCE_ROOT; }; + CE80DB2A0FC192D60086DCA6 /* Utils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Utils.h; path = cocoalib/Utils.h; sourceTree = SOURCE_ROOT; }; + CE80DB2B0FC192D60086DCA6 /* Utils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Utils.m; path = cocoalib/Utils.m; sourceTree = SOURCE_ROOT; }; + CE80DB2C0FC192D60086DCA6 /* ValueTransformers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ValueTransformers.h; path = cocoalib/ValueTransformers.h; sourceTree = SOURCE_ROOT; }; + CE80DB2D0FC192D60086DCA6 /* ValueTransformers.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ValueTransformers.m; path = cocoalib/ValueTransformers.m; sourceTree = SOURCE_ROOT; }; + CE80DB450FC193650086DCA6 /* NSNotificationAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = NSNotificationAdditions.h; path = cocoalib/NSNotificationAdditions.h; sourceTree = SOURCE_ROOT; }; + CE80DB460FC193650086DCA6 /* NSNotificationAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = NSNotificationAdditions.m; path = cocoalib/NSNotificationAdditions.m; sourceTree = SOURCE_ROOT; }; + CE80DB480FC193770086DCA6 /* NSImageAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = NSImageAdditions.h; path = cocoalib/NSImageAdditions.h; sourceTree = SOURCE_ROOT; }; + CE80DB490FC193770086DCA6 /* NSImageAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = NSImageAdditions.m; path = cocoalib/NSImageAdditions.m; sourceTree = SOURCE_ROOT; }; + CE80DB710FC194760086DCA6 /* English */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = English; path = cocoalib/English.lproj/ErrorReportWindow.xib; sourceTree = SOURCE_ROOT; }; + CE80DB730FC194760086DCA6 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = cocoalib/English.lproj/progress.nib; sourceTree = SOURCE_ROOT; }; + CE80DB750FC194760086DCA6 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = cocoalib/English.lproj/registration.nib; sourceTree = SOURCE_ROOT; }; + CE80DB820FC1951C0086DCA6 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = dgbase/AppDelegate.h; sourceTree = SOURCE_ROOT; }; + CE80DB830FC1951C0086DCA6 /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = dgbase/AppDelegate.m; sourceTree = SOURCE_ROOT; }; + CE80DB840FC1951C0086DCA6 /* Consts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Consts.h; path = dgbase/Consts.h; sourceTree = SOURCE_ROOT; }; + CE80DB850FC1951C0086DCA6 /* DirectoryPanel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = DirectoryPanel.h; path = dgbase/DirectoryPanel.h; sourceTree = SOURCE_ROOT; }; + CE80DB860FC1951C0086DCA6 /* DirectoryPanel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = DirectoryPanel.m; path = dgbase/DirectoryPanel.m; sourceTree = SOURCE_ROOT; }; + CE80DB870FC1951C0086DCA6 /* PyDupeGuru.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PyDupeGuru.h; path = dgbase/PyDupeGuru.h; sourceTree = SOURCE_ROOT; }; + CE80DB880FC1951C0086DCA6 /* ResultWindow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ResultWindow.h; path = dgbase/ResultWindow.h; sourceTree = SOURCE_ROOT; }; + CE80DB890FC1951C0086DCA6 /* ResultWindow.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ResultWindow.m; path = dgbase/ResultWindow.m; sourceTree = SOURCE_ROOT; }; + CE848A1809DD85810004CB44 /* Consts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Consts.h; sourceTree = ""; }; + CECA899809DB12CA00A3D774 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/Details.nib; sourceTree = ""; }; + CECA899A09DB132E00A3D774 /* DetailsPanel.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = DetailsPanel.h; sourceTree = ""; }; + CECA899B09DB132E00A3D774 /* DetailsPanel.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = DetailsPanel.m; sourceTree = ""; }; + CEDA432C0B07C6E600B3091A /* dg.xsl */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = text.xml; name = dg.xsl; path = w3/dg.xsl; sourceTree = SOURCE_ROOT; }; + CEDA432D0B07C6E600B3091A /* hardcoded.css */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = text; name = hardcoded.css; path = w3/hardcoded.css; sourceTree = SOURCE_ROOT; }; + CEEB135109C837A2004D2330 /* dupeguru.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = dupeguru.icns; sourceTree = ""; }; + CEF4112A0A11069600E7F110 /* Consts.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = Consts.m; sourceTree = SOURCE_ROOT; }; + CEF7823709C8AA0200EF38FF /* gear.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = gear.png; path = images/gear.png; sourceTree = ""; }; + CEFC294509C89E3D00D9F998 /* folder32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = folder32.png; path = images/folder32.png; sourceTree = SOURCE_ROOT; }; + CEFC295309C89FF200D9F998 /* details32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = details32.png; path = images/details32.png; sourceTree = SOURCE_ROOT; }; + CEFC295409C89FF200D9F998 /* preferences32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = preferences32.png; path = images/preferences32.png; sourceTree = SOURCE_ROOT; }; + CEFCDE2C0AB0418600C33A93 /* dgpe_logo_32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = dgpe_logo_32.png; path = images/dgpe_logo_32.png; sourceTree = SOURCE_ROOT; }; + CEFF18A009A4D387005E6321 /* PyDupeGuru.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = PyDupeGuru.h; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8D11072E0486CEB800E47090 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */, + CE15C8A80ADEB8B50061D4A5 /* Sparkle.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 080E96DDFE201D6D7F000001 /* Classes */ = { + isa = PBXGroup; + children = ( + CE0C46A80FA0647E000BE99B /* PictureBlocks.h */, + CE0C46A90FA0647E000BE99B /* PictureBlocks.m */, + CE381C9509914ACE003581CE /* AppDelegate.h */, + CE381C9409914ACE003581CE /* AppDelegate.m */, + CE848A1809DD85810004CB44 /* Consts.h */, + CEF4112A0A11069600E7F110 /* Consts.m */, + CECA899A09DB132E00A3D774 /* DetailsPanel.h */, + CECA899B09DB132E00A3D774 /* DetailsPanel.m */, + CE68EE6509ABC48000971085 /* DirectoryPanel.h */, + CE68EE6609ABC48000971085 /* DirectoryPanel.m */, + CEFF18A009A4D387005E6321 /* PyDupeGuru.h */, + CE381C9B09914ADF003581CE /* ResultWindow.h */, + CE381C9A09914ADF003581CE /* ResultWindow.m */, + ); + name = Classes; + sourceTree = ""; + }; + 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */ = { + isa = PBXGroup; + children = ( + CE15C8A70ADEB8B50061D4A5 /* Sparkle.framework */, + 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */, + ); + name = "Linked Frameworks"; + sourceTree = ""; + }; + 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */ = { + isa = PBXGroup; + children = ( + 29B97324FDCFA39411CA2CEA /* AppKit.framework */, + 13E42FB307B3F0F600E4EEF1 /* CoreData.framework */, + 29B97325FDCFA39411CA2CEA /* Foundation.framework */, + ); + name = "Other Frameworks"; + sourceTree = ""; + }; + 19C28FACFE9D520D11CA2CBB /* Products */ = { + isa = PBXGroup; + children = ( + 8D1107320486CEB800E47090 /* dupeGuru PE.app */, + ); + name = Products; + sourceTree = ""; + }; + 29B97314FDCFA39411CA2CEA /* dupeguru */ = { + isa = PBXGroup; + children = ( + 080E96DDFE201D6D7F000001 /* Classes */, + CE80DB1A0FC192AB0086DCA6 /* cocoalib */, + CE80DB810FC194BD0086DCA6 /* dgbase */, + 29B97315FDCFA39411CA2CEA /* Other Sources */, + 29B97317FDCFA39411CA2CEA /* Resources */, + 29B97323FDCFA39411CA2CEA /* Frameworks */, + 19C28FACFE9D520D11CA2CBB /* Products */, + ); + name = dupeguru; + sourceTree = ""; + }; + 29B97315FDCFA39411CA2CEA /* Other Sources */ = { + isa = PBXGroup; + children = ( + 29B97316FDCFA39411CA2CEA /* main.m */, + ); + name = "Other Sources"; + sourceTree = ""; + }; + 29B97317FDCFA39411CA2CEA /* Resources */ = { + isa = PBXGroup; + children = ( + CE073F5409CAE1A3005C1D2F /* dupeguru_pe_help */, + CE381CF509915304003581CE /* dg_cocoa.plugin */, + CEFC294309C89E0000D9F998 /* images */, + CEDA432B0B07C6E600B3091A /* w3 */, + CEEB135109C837A2004D2330 /* dupeguru.icns */, + 8D1107310486CEB800E47090 /* Info.plist */, + 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */, + CECA899709DB12CA00A3D774 /* Details.nib */, + CE3AA46509DB207900DB3A21 /* Directories.nib */, + 29B97318FDCFA39411CA2CEA /* MainMenu.nib */, + ); + name = Resources; + sourceTree = ""; + }; + 29B97323FDCFA39411CA2CEA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */, + 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */, + ); + name = Frameworks; + sourceTree = ""; + }; + CE80DB1A0FC192AB0086DCA6 /* cocoalib */ = { + isa = PBXGroup; + children = ( + CE80DB700FC194760086DCA6 /* ErrorReportWindow.xib */, + CE80DB720FC194760086DCA6 /* progress.nib */, + CE80DB740FC194760086DCA6 /* registration.nib */, + CE80DB480FC193770086DCA6 /* NSImageAdditions.h */, + CE80DB490FC193770086DCA6 /* NSImageAdditions.m */, + CE80DB450FC193650086DCA6 /* NSNotificationAdditions.h */, + CE80DB460FC193650086DCA6 /* NSNotificationAdditions.m */, + CE80DB1B0FC192D60086DCA6 /* Dialogs.h */, + CE80DB1C0FC192D60086DCA6 /* Dialogs.m */, + CE80DB1D0FC192D60086DCA6 /* HSErrorReportWindow.h */, + CE80DB1E0FC192D60086DCA6 /* HSErrorReportWindow.m */, + CE80DB1F0FC192D60086DCA6 /* Outline.h */, + CE80DB200FC192D60086DCA6 /* Outline.m */, + CE80DB210FC192D60086DCA6 /* ProgressController.h */, + CE80DB220FC192D60086DCA6 /* ProgressController.m */, + CE80DB230FC192D60086DCA6 /* PyApp.h */, + CE80DB240FC192D60086DCA6 /* RecentDirectories.h */, + CE80DB250FC192D60086DCA6 /* RecentDirectories.m */, + CE80DB260FC192D60086DCA6 /* RegistrationInterface.h */, + CE80DB270FC192D60086DCA6 /* RegistrationInterface.m */, + CE80DB280FC192D60086DCA6 /* Table.h */, + CE80DB290FC192D60086DCA6 /* Table.m */, + CE80DB2A0FC192D60086DCA6 /* Utils.h */, + CE80DB2B0FC192D60086DCA6 /* Utils.m */, + CE80DB2C0FC192D60086DCA6 /* ValueTransformers.h */, + CE80DB2D0FC192D60086DCA6 /* ValueTransformers.m */, + ); + name = cocoalib; + sourceTree = ""; + }; + CE80DB810FC194BD0086DCA6 /* dgbase */ = { + isa = PBXGroup; + children = ( + CE80DB820FC1951C0086DCA6 /* AppDelegate.h */, + CE80DB830FC1951C0086DCA6 /* AppDelegate.m */, + CE80DB840FC1951C0086DCA6 /* Consts.h */, + CE80DB850FC1951C0086DCA6 /* DirectoryPanel.h */, + CE80DB860FC1951C0086DCA6 /* DirectoryPanel.m */, + CE80DB870FC1951C0086DCA6 /* PyDupeGuru.h */, + CE80DB880FC1951C0086DCA6 /* ResultWindow.h */, + CE80DB890FC1951C0086DCA6 /* ResultWindow.m */, + ); + name = dgbase; + sourceTree = ""; + }; + CEDA432B0B07C6E600B3091A /* w3 */ = { + isa = PBXGroup; + children = ( + CEDA432C0B07C6E600B3091A /* dg.xsl */, + CEDA432D0B07C6E600B3091A /* hardcoded.css */, + ); + path = w3; + sourceTree = SOURCE_ROOT; + }; + CEFC294309C89E0000D9F998 /* images */ = { + isa = PBXGroup; + children = ( + CEFCDE2C0AB0418600C33A93 /* dgpe_logo_32.png */, + CEF7823709C8AA0200EF38FF /* gear.png */, + CEFC295309C89FF200D9F998 /* details32.png */, + CEFC295409C89FF200D9F998 /* preferences32.png */, + CEFC294509C89E3D00D9F998 /* folder32.png */, + ); + name = images; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8D1107260486CEB800E47090 /* dupeguru */ = { + isa = PBXNativeTarget; + buildConfigurationList = C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "dupeguru" */; + buildPhases = ( + CEABE1A60FCC00E3005F8031 /* ShellScript */, + 8D1107290486CEB800E47090 /* Resources */, + 8D11072C0486CEB800E47090 /* Sources */, + 8D11072E0486CEB800E47090 /* Frameworks */, + CECC02B709A36E8200CC0A94 /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = dupeguru; + productInstallPath = "$(HOME)/Applications"; + productName = dupeguru; + productReference = 8D1107320486CEB800E47090 /* dupeGuru PE.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 29B97313FDCFA39411CA2CEA /* Project object */ = { + isa = PBXProject; + buildConfigurationList = C01FCF4E08A954540054247B /* Build configuration list for PBXProject "dupeguru" */; + compatibilityVersion = "Xcode 2.4"; + hasScannedForEncodings = 1; + mainGroup = 29B97314FDCFA39411CA2CEA /* dupeguru */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8D1107260486CEB800E47090 /* dupeguru */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8D1107290486CEB800E47090 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072A0486CEB800E47090 /* MainMenu.nib in Resources */, + 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */, + CE381D0509915304003581CE /* dg_cocoa.plugin in Resources */, + CE073F6309CAE1A3005C1D2F /* dupeguru_pe_help in Resources */, + CEEB135209C837A2004D2330 /* dupeguru.icns in Resources */, + CEFC294609C89E3D00D9F998 /* folder32.png in Resources */, + CEFC295509C89FF200D9F998 /* details32.png in Resources */, + CEFC295609C89FF200D9F998 /* preferences32.png in Resources */, + CEF7823809C8AA0200EF38FF /* gear.png in Resources */, + CECA899909DB12CA00A3D774 /* Details.nib in Resources */, + CE3AA46709DB207900DB3A21 /* Directories.nib in Resources */, + CEFCDE2D0AB0418600C33A93 /* dgpe_logo_32.png in Resources */, + CEDA432E0B07C6E600B3091A /* dg.xsl in Resources */, + CEDA432F0B07C6E600B3091A /* hardcoded.css in Resources */, + CE80DB760FC194760086DCA6 /* ErrorReportWindow.xib in Resources */, + CE80DB770FC194760086DCA6 /* progress.nib in Resources */, + CE80DB780FC194760086DCA6 /* registration.nib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + CEABE1A60FCC00E3005F8031 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd help\npython gen.py\n/Developer/Applications/Utilities/Help\\ Indexer.app/Contents/MacOS/Help\\ Indexer dupeguru_pe_help"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8D11072C0486CEB800E47090 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072D0486CEB800E47090 /* main.m in Sources */, + CE381C9609914ACE003581CE /* AppDelegate.m in Sources */, + CE381C9C09914ADF003581CE /* ResultWindow.m in Sources */, + CE68EE6809ABC48000971085 /* DirectoryPanel.m in Sources */, + CECA899D09DB132E00A3D774 /* DetailsPanel.m in Sources */, + CEF4112B0A11069600E7F110 /* Consts.m in Sources */, + CE0C46AA0FA0647E000BE99B /* PictureBlocks.m in Sources */, + CE80DB2E0FC192D60086DCA6 /* Dialogs.m in Sources */, + CE80DB2F0FC192D60086DCA6 /* HSErrorReportWindow.m in Sources */, + CE80DB300FC192D60086DCA6 /* Outline.m in Sources */, + CE80DB310FC192D60086DCA6 /* ProgressController.m in Sources */, + CE80DB320FC192D60086DCA6 /* RecentDirectories.m in Sources */, + CE80DB330FC192D60086DCA6 /* RegistrationInterface.m in Sources */, + CE80DB340FC192D60086DCA6 /* Table.m in Sources */, + CE80DB350FC192D60086DCA6 /* Utils.m in Sources */, + CE80DB360FC192D60086DCA6 /* ValueTransformers.m in Sources */, + CE80DB470FC193650086DCA6 /* NSNotificationAdditions.m in Sources */, + CE80DB4A0FC193770086DCA6 /* NSImageAdditions.m in Sources */, + CE80DB8A0FC1951C0086DCA6 /* AppDelegate.m in Sources */, + CE80DB8B0FC1951C0086DCA6 /* DirectoryPanel.m in Sources */, + CE80DB8C0FC1951C0086DCA6 /* ResultWindow.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 089C165DFE840E0CC02AAC07 /* English */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + 29B97318FDCFA39411CA2CEA /* MainMenu.nib */ = { + isa = PBXVariantGroup; + children = ( + 29B97319FDCFA39411CA2CEA /* English */, + ); + name = MainMenu.nib; + sourceTree = SOURCE_ROOT; + }; + CE3AA46509DB207900DB3A21 /* Directories.nib */ = { + isa = PBXVariantGroup; + children = ( + CE3AA46609DB207900DB3A21 /* English */, + ); + name = Directories.nib; + sourceTree = ""; + }; + CE80DB700FC194760086DCA6 /* ErrorReportWindow.xib */ = { + isa = PBXVariantGroup; + children = ( + CE80DB710FC194760086DCA6 /* English */, + ); + name = ErrorReportWindow.xib; + sourceTree = SOURCE_ROOT; + }; + CE80DB720FC194760086DCA6 /* progress.nib */ = { + isa = PBXVariantGroup; + children = ( + CE80DB730FC194760086DCA6 /* English */, + ); + name = progress.nib; + sourceTree = SOURCE_ROOT; + }; + CE80DB740FC194760086DCA6 /* registration.nib */ = { + isa = PBXVariantGroup; + children = ( + CE80DB750FC194760086DCA6 /* English */, + ); + name = registration.nib; + sourceTree = SOURCE_ROOT; + }; + CECA899709DB12CA00A3D774 /* Details.nib */ = { + isa = PBXVariantGroup; + children = ( + CECA899809DB12CA00A3D774 /* English */, + ); + name = Details.nib; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + C01FCF4B08A954540054247B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COPY_PHASE_STRIP = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(FRAMEWORK_SEARCH_PATHS)", + "$(SRCROOT)/cocoalib/build/Release", + "$(FRAMEWORK_SEARCH_PATHS_QUOTED_FOR_TARGET_1)", + ); + FRAMEWORK_SEARCH_PATHS_QUOTED_FOR_TARGET_1 = "\"$(SRCROOT)/dgbase/build/Release\""; + GCC_DYNAMIC_NO_PIC = NO; + GCC_ENABLE_FIX_AND_CONTINUE = YES; + GCC_MODEL_TUNING = G5; + GCC_OPTIMIZATION_LEVEL = 0; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(HOME)/Applications"; + PRODUCT_NAME = dupeGuru; + WRAPPER_EXTENSION = app; + ZERO_LINK = YES; + }; + name = Debug; + }; + C01FCF4C08A954540054247B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = ( + ppc, + i386, + ); + FRAMEWORK_SEARCH_PATHS = ( + "$(FRAMEWORK_SEARCH_PATHS)", + "$(FRAMEWORK_SEARCH_PATHS_QUOTED_FOR_TARGET_1)", + ); + GCC_GENERATE_DEBUGGING_SYMBOLS = NO; + GCC_MODEL_TUNING = G5; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(HOME)/Applications"; + PRODUCT_NAME = "dupeGuru PE"; + WRAPPER_EXTENSION = app; + }; + name = Release; + }; + C01FCF4F08A954540054247B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + GCC_C_LANGUAGE_STANDARD = c99; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.4; + PREBINDING = NO; + SDKROOT = /Developer/SDKs/MacOSX10.4u.sdk; + }; + name = Debug; + }; + C01FCF5008A954540054247B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = ( + ppc, + i386, + ); + FRAMEWORK_SEARCH_PATHS = ""; + GCC_C_LANGUAGE_STANDARD = c99; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.4; + PREBINDING = NO; + SDKROOT = /Developer/SDKs/MacOSX10.4u.sdk; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "dupeguru" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C01FCF4B08A954540054247B /* Debug */, + C01FCF4C08A954540054247B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C01FCF4E08A954540054247B /* Build configuration list for PBXProject "dupeguru" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C01FCF4F08A954540054247B /* Debug */, + C01FCF5008A954540054247B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 29B97313FDCFA39411CA2CEA /* Project object */; +} diff --git a/pe/cocoa/main.m b/pe/cocoa/main.m new file mode 100644 index 00000000..c5f30658 --- /dev/null +++ b/pe/cocoa/main.m @@ -0,0 +1,21 @@ +// +// main.m +// dupeguru +// +// Created by Virgil Dupras on 2006/02/01. +// Copyright __MyCompanyName__ 2006. All rights reserved. +// + +#import + +int main(int argc, char *argv[]) +{ + NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; + NSString *pluginPath = [[NSBundle mainBundle] + pathForResource:@"dg_cocoa" + ofType:@"plugin"]; + NSBundle *pluginBundle = [NSBundle bundleWithPath:pluginPath]; + [pluginBundle load]; + [pool release]; + return NSApplicationMain(argc, (const char **) argv); +} diff --git a/pe/cocoa/py/build_py.sh b/pe/cocoa/py/build_py.sh new file mode 100755 index 00000000..c9ac2993 --- /dev/null +++ b/pe/cocoa/py/build_py.sh @@ -0,0 +1,2 @@ +#!/bin/bash +python setup.py py2app \ No newline at end of file diff --git a/pe/cocoa/py/dg_cocoa.py b/pe/cocoa/py/dg_cocoa.py new file mode 100644 index 00000000..76126ec1 --- /dev/null +++ b/pe/cocoa/py/dg_cocoa.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python +import objc +from AppKit import * +from PyObjCTools import NibClassBuilder + +NibClassBuilder.extractClasses("MainMenu", bundle=NSBundle.mainBundle()) + +from dupeguru import app_pe_cocoa, scanner + +class PyApp(NibClassBuilder.AutoBaseClass): + pass #fake class + +class PyDupeGuru(PyApp): + def init(self): + self = super(PyDupeGuru,self).init() + self.app = app_pe_cocoa.DupeGuruPE() + return self + + #---Directories + def addDirectory_(self,directory): + return self.app.AddDirectory(directory) + + def removeDirectory_(self,index): + self.app.RemoveDirectory(index) + + def setDirectory_state_(self,node_path,state): + self.app.SetDirectoryState(node_path,state) + + #---Results + def clearIgnoreList(self): + self.app.scanner.ignore_list.Clear() + + def clearPictureCache(self): + self.app.scanner.match_factory.cached_blocks.clear() + + def doScan(self): + return self.app.start_scanning() + + def exportToXHTMLwithColumns_xslt_css_(self,column_ids,xslt_path,css_path): + return self.app.ExportToXHTML(column_ids,xslt_path,css_path) + + def loadIgnoreList(self): + self.app.LoadIgnoreList() + + def loadResults(self): + self.app.load() + + def markAll(self): + self.app.results.mark_all() + + def markNone(self): + self.app.results.mark_none() + + def markInvert(self): + self.app.results.mark_invert() + + def purgeIgnoreList(self): + self.app.PurgeIgnoreList() + + def toggleSelectedMark(self): + self.app.ToggleSelectedMarkState() + + def saveIgnoreList(self): + self.app.SaveIgnoreList() + + def saveResults(self): + self.app.Save() + + def refreshDetailsWithSelected(self): + self.app.RefreshDetailsWithSelected() + + def selectResultNodePaths_(self,node_paths): + self.app.SelectResultNodePaths(node_paths) + + def selectPowerMarkerNodePaths_(self,node_paths): + self.app.SelectPowerMarkerNodePaths(node_paths) + + #---Actions + def addSelectedToIgnoreList(self): + self.app.AddSelectedToIgnoreList() + + def deleteMarked(self): + self.app.delete_marked() + + def applyFilter_(self, filter): + self.app.ApplyFilter(filter) + + def makeSelectedReference(self): + self.app.MakeSelectedReference() + + def copyOrMove_markedTo_recreatePath_(self,copy,destination,recreate_path): + self.app.copy_or_move_marked(copy, destination, recreate_path) + + def openSelected(self): + self.app.OpenSelected() + + def removeMarked(self): + self.app.results.perform_on_marked(lambda x:True,True) + + def removeSelected(self): + self.app.RemoveSelected() + + def renameSelected_(self,newname): + return self.app.RenameSelected(newname) + + def revealSelected(self): + self.app.RevealSelected() + + #---Misc + def sortDupesBy_ascending_(self,key,asc): + self.app.sort_dupes(key,asc) + + def sortGroupsBy_ascending_(self,key,asc): + self.app.sort_groups(key,asc) + + #---Information + def getIgnoreListCount(self): + return len(self.app.scanner.ignore_list) + + def getMarkCount(self): + return self.app.results.mark_count + + def getStatLine(self): + return self.app.stat_line + + def getOperationalErrorCount(self): + return self.app.last_op_error_count + + def getSelectedDupePath(self): + return unicode(self.app.selected_dupe_path()) + + def getSelectedDupeRefPath(self): + return unicode(self.app.selected_dupe_ref_path()) + + #---Data + @objc.signature('i@:i') + def getOutlineViewMaxLevel_(self, tag): + return self.app.GetOutlineViewMaxLevel(tag) + + @objc.signature('@@:i@') + def getOutlineView_childCountsForPath_(self, tag, node_path): + return self.app.GetOutlineViewChildCounts(tag, node_path) + + def getOutlineView_valuesForIndexes_(self,tag,node_path): + return self.app.GetOutlineViewValues(tag,node_path) + + def getOutlineView_markedAtIndexes_(self,tag,node_path): + return self.app.GetOutlineViewMarked(tag,node_path) + + def getTableViewCount_(self,tag): + return self.app.GetTableViewCount(tag) + + def getTableViewMarkedIndexes_(self,tag): + return self.app.GetTableViewMarkedIndexes(tag) + + def getTableView_valuesForRow_(self,tag,row): + return self.app.GetTableViewValues(tag,row) + + #---Properties + def setMatchScaled_(self,match_scaled): + self.app.scanner.match_factory.match_scaled = match_scaled + + def setMinMatchPercentage_(self,percentage): + self.app.scanner.match_factory.threshold = int(percentage) + + def setMixFileKind_(self,mix_file_kind): + self.app.scanner.mix_file_kind = mix_file_kind + + def setDisplayDeltaValues_(self,display_delta_values): + self.app.display_delta_values= display_delta_values + + def setEscapeFilterRegexp_(self, escape_filter_regexp): + self.app.options['escape_filter_regexp'] = escape_filter_regexp + + def setRemoveEmptyFolders_(self, remove_empty_folders): + self.app.options['clean_empty_dirs'] = remove_empty_folders + + #---Worker + def getJobProgress(self): + return self.app.progress.last_progress + + def getJobDesc(self): + return self.app.progress.last_desc + + def cancelJob(self): + self.app.progress.job_cancelled = True + + #---Registration + @objc.signature('i@:') + def isRegistered(self): + return self.app.registered + + @objc.signature('i@:@@') + def isCodeValid_withEmail_(self, code, email): + return self.app.is_code_valid(code, email) + + def setRegisteredCode_andEmail_(self, code, email): + self.app.set_registration(code, email) + diff --git a/pe/cocoa/py/setup.py b/pe/cocoa/py/setup.py new file mode 100644 index 00000000..8bf847db --- /dev/null +++ b/pe/cocoa/py/setup.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +from setuptools import setup + +from hs.build import set_buildenv + +set_buildenv() + +setup( + plugin=['dg_cocoa.py'], + setup_requires=['py2app'], +) diff --git a/pe/cocoa/w3/dg.xsl b/pe/cocoa/w3/dg.xsl new file mode 100644 index 00000000..4f982fce --- /dev/null +++ b/pe/cocoa/w3/dg.xsl @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + indented + + + + + + + + + + + + + + + + + + + + + + + + + + + + dupeGuru Results + + + +

dupeGuru Results

+ + + + + +
+ + +
+ +
\ No newline at end of file diff --git a/pe/cocoa/w3/hardcoded.css b/pe/cocoa/w3/hardcoded.css new file mode 100644 index 00000000..ed243bcc --- /dev/null +++ b/pe/cocoa/w3/hardcoded.css @@ -0,0 +1,71 @@ +BODY +{ + background-color:white; +} + +BODY,A,P,UL,TABLE,TR,TD +{ + font-family:Tahoma,Arial,sans-serif; + font-size:10pt; + color: #4477AA; +} + +TABLE +{ + background-color: #225588; + margin-left: auto; + margin-right: auto; + width: 90%; +} + +TR +{ + background-color: white; +} + +TH +{ + font-weight: bold; + color: black; + background-color: #C8D6E5; +} + +TH TD +{ + color:black; +} + +TD +{ + padding-left: 2pt; +} + +TD.rightelem +{ + text-align:right; + /*padding-left:0pt;*/ + padding-right: 2pt; + width: 17%; +} + +TD.indented +{ + padding-left: 12pt; +} + +H1 +{ + font-family:"Courier New",monospace; + color:#6699CC; + font-size:18pt; + color:#6da500; + border-color: #70A0CF; + border-width: 1pt; + border-style: solid; + margin-top: 16pt; + margin-left: 5%; + margin-right: 5%; + padding-top: 2pt; + padding-bottom:2pt; + text-align: center; +} \ No newline at end of file diff --git a/pe/help/changelog.yaml b/pe/help/changelog.yaml new file mode 100644 index 00000000..f387c334 --- /dev/null +++ b/pe/help/changelog.yaml @@ -0,0 +1,174 @@ +- date: 2009-05-27 + version: 1.7.2 + description: | + * Fixed a bug causing '.jpeg' files not to be scanned. + * Fixed a bug causing a GUI freeze at the beginning of a scan with a lot of files. + * Fixed a bug that sometimes caused a crash when an action was cancelled, and then started again. + * Improved scanning speed. +- date: 2009-05-26 + version: 1.7.1 + description: | + * Fixed a bug causing the "Match Scaled" preference to be inverted. +- date: 2009-05-20 + version: 1.7.0 + description: | + * Fixed the bug from 1.6.0 preventing PowerPC macs from running the application. + * Converted the Windows GUI to Qt, thus enabling multiprocessing and making the scanning process + faster. +- date: 2009-03-24 + description: "* **Improved** scanning speed, mainly on OS X where all cores of the\ + \ CPU are now used.\r\n* **Fixed** an occasional crash caused by permission issues.\r\ + \n* **Fixed** a bug where the \"X discarded\" notice would show a too large number\ + \ of discarded duplicates." + version: 1.6.0 +- date: 2008-09-10 + description: "
    \n\t\t\t\t\t\t
  • Added a notice in the status bar when\ + \ matches were discarded during the scan.
  • \n\t\t\t\t\t\t
  • Improved\ + \ duplicate prioritization (smartly chooses which file you will keep).
  • \n\t\ + \t\t\t\t\t
  • Improved scan progress feedback.
  • \n\t\t\t\t\t\t
  • Improved\ + \ responsiveness of the user interface for certain actions.
  • \n\t\t \ + \
" + version: 1.5.0 +- date: 2008-07-28 + description: "
    \n\t\t\t\t\t\t
  • Improved iPhoto compatibility on Mac\ + \ OS X.
  • \n\t\t\t\t\t\t
  • Improved the speed of results loading and\ + \ saving.
  • \n\t\t\t\t\t\t
  • Fixed a crash sometimes occurring during\ + \ duplicate deletion.
  • \n\t\t
" + version: 1.4.2 +- date: 2008-04-12 + description: "
    \n\t\t\t\t\t\t
  • Improved iPhoto Library loading feedback\ + \ on Mac OS X.
  • \n\t\t\t\t\t\t
  • Fixed the directory selection dialog.\ + \ Bundles can be selected again on Mac OS X.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ \"Clear Ignore List\" crash in Windows.
  • \n\t\t
" + version: 1.4.1 +- date: 2008-02-20 + description: "
    \n\t\t\t\t\t\t
  • Added iPhoto Library support on Mac OS\ + \ X.
  • \n\t\t\t\t\t\t
  • Fixed occasional crashes when scanning corrupted\ + \ pictures.
  • \n\t\t
" + version: 1.4.0 +- date: 2008-02-20 + description: "
    \n\t\t\t\t\t\t
  • Added iPhoto Library support on Mac OS\ + \ X.
  • \n\t\t\t\t\t\t
  • Fixed occasional crashes when scanning corrupted\ + \ pictures.
  • \n\t\t
" + version: 1.4.0 +- date: 2008-01-12 + description: "
    \n\t\t\t\t\t\t
  • Improved scan, delete and move speed\ + \ in situations where there were a lot of duplicates.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ occasional crashes when moving a lot of files at once.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ an issue sometimes preventing the application from starting at all.
  • \n\t\ + \t
" + version: 1.3.4 +- date: 2007-12-03 + description: "
    \n\t\t\t\t\t\t
  • Improved the handling of low memory situations.
  • \n\ + \t\t\t\t\t\t
  • Improved the directory panel. The \"Remove\" button changes\ + \ to \"Put Back\" when an excluded directory is selected.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ the directory selection dialog. iPhoto '08 library files can now be selected.
  • \n\ + \t\t
" + version: 1.3.3 +- date: 2007-11-24 + description: "
    \n\t\t\t\t\t\t
  • Added the \"Remove empty folders\" option.
  • \n\ + \t\t\t\t\t\t
  • Fixed results load/save issues.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ occasional status bar inaccuracies when the results are filtered.
  • \n\t\t\ + \
" + version: 1.3.2 +- date: 2007-10-21 + description: "
    \n\t\t\t\t\t\t
  • Improved results loading speed.
  • \n\ + \t\t\t\t\t\t
  • Improved details panel's picture loading (made it asynchronous).
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug where the stats line at the bottom would sometimes\ + \ go confused while having a filter active.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ a bug under Windows where some duplicate markings would be lost.
  • \n\t\t\ + \
" + version: 1.3.1 +- date: 2007-09-22 + description: "
    \n\t\t\t\t\t\t
  • Added post scan filtering.
  • \n\t\t\ + \t\t\t\t
  • Fixed issues with the rename feature under Windows
  • \n\t\ + \t\t\t\t\t
  • Fixed some user interface annoyances under Windows
  • \n\ + \t\t
" + version: 1.3.0 +- date: 2007-05-19 + description: "
    \n\t\t\t\t\t\t
  • Improved UI responsiveness (using threads)\ + \ under Mac OS X.
  • \n\t\t\t\t\t\t
  • Improved result load/save speed\ + \ and memory usage.
  • \n\t\t
" + version: 1.2.1 +- date: 2007-03-17 + description: "
    \n\t\t\t\t\t\t
  • Changed the picture decoding libraries\ + \ for both Mac OS X and Windows. The Mac OS X version uses the Core Graphics library\ + \ and the Windows version uses the .NET framework imaging capabilities. This results\ + \ in much faster scans. As a bonus, the Mac OS X version of dupeGuru PE now supports\ + \ RAW images.
  • \n\t\t
" + version: 1.2.0 +- date: 2007-02-11 + description: "
    \n\t\t\t\t\t\t
  • Added Re-orderable columns. In fact,\ + \ I re-added the feature which was lost in the C# conversion in 2.4.0 (Windows).
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug with all the Delete/Move/Copy actions with\ + \ certain kinds of files.
  • \n\t\t
" + version: 1.1.6 +- date: 2007-01-11 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug with the Move action.
  • \n\ + \t\t
" + version: 1.1.5 +- date: 2007-01-09 + description: "
    \n\t\t\t\t\t\t
  • Fixed a \"ghosting\" bug. Dupes deleted\ + \ by dupeGuru would sometimes come back in subsequent scans (Windows).
  • \n\t\ + \t\t\t\t\t
  • Fixed bugs sometimes making dupeGuru crash when marking a\ + \ dupe (Windows).
  • \n\t\t\t\t\t\t
  • Fixed some minor visual glitches\ + \ (Windows).
  • \n\t\t
" + version: 1.1.4 +- date: 2006-12-23 + description: "
    \n\t\t\t\t\t\t
  • Improved the caching system. This makes\ + \ duplicate scans significantly faster.
  • \n\t\t\t\t\t\t
  • Improved\ + \ the rename file dialog to exclude the extension from the original selection\ + \ (so when you start typing your new filename, it doesn't overwrite it) (Windows).
  • \n\ + \t\t\t\t\t\t
  • Changed some menu key shortcuts that created conflicts\ + \ (Windows).
  • \n\t\t\t\t\t\t
  • Fixed a bug preventing files from \"\ + reference\" directories to be displayed in blue in the results (Windows).
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug preventing some files to be sent to the recycle\ + \ bin (Windows).
  • \n\t\t\t\t\t\t
  • Fixed a bug with the \"Remove\"\ + \ button of the directories panel (Windows).
  • \n\t\t\t\t\t\t
  • Fixed\ + \ a bug in the packaging preventing certain Windows configurations to start dupeGuru\ + \ at all.
  • \n\t\t
" + version: 1.1.3 +- date: 2006-11-18 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug with directory states.
  • \n\ + \t\t
" + version: 1.1.2 +- date: 2006-11-17 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug causing the ignore list not\ + \ to be saved.
  • \n\t\t\t\t\t\t
  • Fixed a bug with selection under Power\ + \ Marker mode.
  • \n\t\t
" + version: 1.1.1 +- date: 2006-11-15 + description: "
    \n\t\t\t\t\t\t
  • Changed the Windows interface. It is\ + \ now .NET based.
  • \n\t\t\t\t\t\t
  • Added an auto-update feature to\ + \ the windows version.
  • \n\t\t\t\t\t\t
  • Changed the way power marking\ + \ works. It is now a mode instead of a separate window.
  • \n\t\t\t\t\t\t
  • Changed\ + \ the \"Size (MB)\" column for a \"Size (KB)\" column. The values are now \"ceiled\"\ + \ instead of rounded. Therefore, a size \"0\" is now really 0 bytes, not just\ + \ a value too small to be rounded up. It is also the case for delta values.
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug sometimes making delete and move operations\ + \ stall.
  • \n\t\t
" + version: 1.1.0 +- date: 2006-10-12 + description: "
    \n\t\t\t\t\t\t
  • Added an auto-update feature in the Mac\ + \ OS X version (with Sparkle).
  • \n\t\t \t
  • Fixed a bug\ + \ sometimes causing inaccuracies of the Match %.
  • \n\t\t
" + version: 1.0.5 +- date: 2006-09-21 + description: "
    \n\t\t \t
  • Fixed a bug with the cache system.
  • \n\ + \t\t
" + version: 1.0.4 +- date: 2006-09-15 + description: "
    \n\t\t\t\t\t\t
  • Added the ability to search for scaled\ + \ duplicates.
  • \n\t\t\t\t\t\t
  • Added a cache system for faster scans.
  • \n\ + \t\t \t
  • Improved speed of the scanning engine.
  • \n\t\t\ + \
" + version: 1.0.3 +- date: 2006-09-11 + description: "
    \n\t\t \t
  • Improved speed of the scanning\ + \ engine.
  • \n\t\t\t\t\t\t
  • Improved the display of pictures in the\ + \ details panel (Windows).
  • \n\t\t
" + version: 1.0.2 +- date: 2006-09-08 + description: "
    \n\t\t \t
  • Initial release.
  • \n\t\t \ + \
" + version: 1.0.0 diff --git a/pe/help/gen.py b/pe/help/gen.py new file mode 100644 index 00000000..8ed33e4e --- /dev/null +++ b/pe/help/gen.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +# Unit Name: +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os + +from web import generate_help + +generate_help.main('.', 'dupeguru_pe_help', force_render=True) diff --git a/pe/help/skeleton/hardcoded.css b/pe/help/skeleton/hardcoded.css new file mode 100644 index 00000000..a3b17c5b --- /dev/null +++ b/pe/help/skeleton/hardcoded.css @@ -0,0 +1,409 @@ +/***************************************************** + General settings +*****************************************************/ + +BODY +{ + background-color:white; +} + +BODY,A,P,UL,TABLE,TR,TD +{ + font-family:Tahoma,Arial,sans-serif; + font-size:10pt; + color: #4477AA;/*darker than 5588bb for the sake of the eyes*/ +} + +/***************************************************** + "A" settings +*****************************************************/ + +A +{ + color: #ae322b; + text-decoration:underline; + font-weight:bold; +} + +A.glossaryword {color:#A0A0A0;} + +A.noline +{ + text-decoration: none; +} + + +/***************************************************** + Menu and mainframe settings +*****************************************************/ + +.maincontainer +{ + display:block; + margin-left:7%; + margin-right:7%; + padding-left:5px; + padding-right:0px; + border-color:#CCCCCC; + border-style:solid; + border-width:2px; + border-right-width:0px; + border-bottom-width:0px; + border-top-color:#ae322b; + vertical-align:top; +} + +TD.menuframe +{ + width:30%; +} + +.menu +{ + margin:4px 4px 4px 4px; + margin-top: 16pt; + border-color:gray; + border-width:1px; + border-style:dotted; + padding-top:10pt; + padding-bottom:10pt; + padding-right:6pt; +} + +.submenu +{ + list-style-type: none; + margin-left:26pt; + margin-top:0pt; + margin-bottom:0pt; + padding-left:0pt; +} + +A.menuitem,A.menuitem_selected +{ + font-size:14pt; + font-family:Tahoma,Arial,sans-serif; + font-weight:normal; + padding-left:10pt; + color:#5588bb; + margin-right:2pt; + margin-left:4pt; + text-decoration:none; +} + +A.menuitem_selected +{ + font-weight:bold; +} + +A.submenuitem +{ + font-family:Tahoma,Arial,sans-serif; + font-weight:normal; + color:#5588bb; + text-decoration:none; +} + +.titleline +{ + border-width:3px; + border-style:solid; + border-left-width:0px; + border-right-width:0px; + border-top-width:0px; + border-color:#CCCCCC; + margin-left:28pt; + margin-right:2pt; + line-height:1px; + padding-top:0px; + margin-top:0px; + display:block; +} + +.titledescrip +{ + text-align:left; + display:block; + margin-left:26pt; + color:#ae322b; +} + +.mainlogo +{ + display:block; + margin-left:8%; + margin-top:4pt; + margin-bottom:4pt; +} + +/***************************************************** + IMG settings +*****************************************************/ + +IMG +{ + border-style:none; +} + +IMG.smallbutton +{ + margin-right: 20px; + float:none; +} + +IMG.floating +{ + float:left; + margin-right: 4pt; + margin-bottom: 4pt; +} + +IMG.lefticon +{ + vertical-align: middle; + padding-right: 2pt; +} + +IMG.righticon +{ + vertical-align: middle; + padding-left: 2pt; +} + +/***************************************************** + TABLE settings +*****************************************************/ + +TABLE +{ + border-style:none; +} + +TABLE.box +{ + width: 90%; + margin-left:5%; +} + +TABLE.centered +{ + margin-left: auto; + margin-right: auto; +} + +TABLE.hardcoded +{ + background-color: #225588; + margin-left: auto; + margin-right: auto; + width: 90%; +} + +TR { background-color: transparent; } + +TABLE.hardcoded TR { background-color: white } + +TABLE.hardcoded TR.header +{ + font-weight: bold; + color: black; + background-color: #C8D6E5; +} + +TABLE.hardcoded TR.header TD {color:black;} + +TABLE.hardcoded TD { padding-left: 2pt; } + +TD.minimelem { + padding-right:0px; + padding-left:0px; + text-align:center; +} + +TD.rightelem +{ + text-align:right; + /*padding-left:0pt;*/ + padding-right: 2pt; + width: 17%; +} + +/***************************************************** + P settings +*****************************************************/ + +p,.sub{text-align:justify;} +.centered{text-align:center;} +.sub +{ + padding-left: 16pt; + padding-right:16pt; +} + +.Note, .ContactInfo +{ + border-color: #ae322b; + border-width: 1pt; + border-style: dashed; + text-align:justify; + padding: 2pt 2pt 2pt 2pt; + margin-bottom:4pt; + margin-top:8pt; + list-style-position:inside; +} + +.ContactInfo +{ + width:60%; + margin-left:5%; +} + +.NewsItem +{ + border-color:#ae322b; + border-style: solid; + border-right:none; + border-top:none; + border-left:none; + border-bottom-width:1px; + text-align:justify; + padding-left:4pt; + padding-right:4pt; + padding-bottom:8pt; +} + +/***************************************************** + Lists settings +*****************************************************/ +UL.plain +{ + list-style-type: none; + padding-left:0px; + margin-left:0px; +} + +LI.plain +{ + list-style-type: none; +} + +LI.section +{ + padding-top: 6pt; +} + +UL.longtext LI +{ + border-color: #ae322b; + border-width:0px; + border-top-width:1px; + border-style:solid; + margin-top:12px; +} + +/* + with UL.longtext LI, there can be anything between + the UL and the LI, and it will still make the + lontext thing, I must break it with this hack +*/ +UL.longtext UL LI +{ + border-style:none; + margin-top:2px; +} + + +/***************************************************** + Titles settings +*****************************************************/ + +H1,H2,H3 +{ + font-family:"Courier New",monospace; + color:#5588bb; +} + +H1 +{ + font-size:18pt; + color: #ae322b; + border-color: #70A0CF; + border-width: 1pt; + border-style: solid; + margin-top: 16pt; + margin-left: 5%; + margin-right: 5%; + padding-top: 2pt; + padding-bottom:2pt; + text-align: center; +} + +H2 +{ + border-color: #ae322b; + border-bottom-width: 2px; + border-top-width: 0pt; + border-left-width: 2px; + border-right-width: 0pt; + border-bottom-color: #cccccc; + border-style: solid; + margin-top: 16pt; + margin-left: 0pt; + margin-right: 0pt; + padding-bottom:3pt; + padding-left:5pt; + text-align: left; + font-size:16pt; +} + +H3 +{ + display:block; + color:#ae322b; + border-color: #70A0CF; + border-bottom-width: 2px; + border-top-width: 0pt; + border-left-width: 0pt; + border-right-width: 0pt; + border-style: dashed; + margin-top: 12pt; + margin-left: 0pt; + margin-bottom: 4pt; + width:auto; + padding-bottom:3pt; + padding-right:2pt; + padding-left:2pt; + text-align: left; + font-weight:bold; +} + + +/***************************************************** + Misc. classes +*****************************************************/ +.longtext:first-letter {font-size: 150%} + +.price, .loweredprice, .specialprice {font-weight:bold;} + +.loweredprice {text-decoration:line-through} + +.specialprice {color:red} + +form +{ + margin:0px; +} + +.program_summary +{ + float:right; + margin: 32pt; + margin-top:0pt; + margin-bottom:0pt; +} + +.screenshot +{ + float:left; + margin: 8pt; +} \ No newline at end of file diff --git a/pe/help/skeleton/images/hs_title.png b/pe/help/skeleton/images/hs_title.png new file mode 100644 index 0000000000000000000000000000000000000000..07bd89c69dd50a3967b46a441741f274b8854de8 GIT binary patch literal 1817 zcmWkt2~Y|q^t%aD27PTfWQbSo7y0Xrdp{OF7oMwX4{+byFr3&y7B zlLpel1d+*^<>$!E@c2A9sOuxRq}jS+G}rcy>y2h&rngp=tT$NjxX)#-@ zR;$fwvyo;C3xs)cj4wl35`-y%c{0>!qGG~a8Nvb)0K&L3RHVdNQtA}p%Q4Dz$v^^f z%s`q=W-AZEN|l62NeGp=(QGM^zcEYpnJq53Zg_psX$pDf}jksh9!YZ6*y0d6t`*06htZIC4pHW%9Epf zh*AWzsT7ofh)_XrrKsL$;mT2tj52IVPjY1#SB?oFlp&##5=^Q9qlF{I7FJ9G2rF*a z2s?-pFR&PpFjtBRA?yV{E9}sUAgt)Ih9}1>x(SvT zsqP+UieWa0F{DUM&ub2ZbHs4dvq=VsEE&xV>e|H!OM+4TQTc&M3CEU=q(F(^fAYG# zOS_;aD|@ud29hH~gpd|cpbwr+aO8+kiBoq^eznVg<_$ zP1%d1$UV?R~TV5+i6w>mO|X i&$iNccmR(ItgDH2&`ti^@h8+@ro}}kMz!wGy!C(b`7gZy literal 0 HcmV?d00001 diff --git a/pe/help/templates/base_dg.mako b/pe/help/templates/base_dg.mako new file mode 100644 index 00000000..7767c49f --- /dev/null +++ b/pe/help/templates/base_dg.mako @@ -0,0 +1,14 @@ +<%inherit file="/base_help.mako"/> +${next.body()} + +<%def name="menu()"><% +self.menuitem('intro.htm', 'Introduction', 'Introduction to dupeGuru') +self.menuitem('quick_start.htm', 'Quick Start', 'Quickly get into the action') +self.menuitem('directories.htm', 'Directories', 'Managing dupeGuru directories') +self.menuitem('preferences.htm', 'Preferences', 'Setting dupeGuru preferences') +self.menuitem('results.htm', 'Results', 'Time to delete these duplicates!') +self.menuitem('power_marker.htm', 'Power Marker', 'Take control of your duplicates') +self.menuitem('faq.htm', 'F.A.Q.', 'Frequently Asked Questions') +self.menuitem('versions.htm', 'Version History', 'Changes dupeGuru went through') +self.menuitem('credits.htm', 'Credits', 'People who contributed to dupeGuru') +%> \ No newline at end of file diff --git a/pe/help/templates/credits.mako b/pe/help/templates/credits.mako new file mode 100644 index 00000000..9de91bd2 --- /dev/null +++ b/pe/help/templates/credits.mako @@ -0,0 +1,25 @@ +## -*- coding: utf-8 -*- +<%! + title = 'Credits' + selected_menu_item = 'Credits' +%> +<%inherit file="/base_dg.mako"/> +Below is the list of people who contributed, directly or indirectly to dupeGuru. + +${self.credit('Virgil Dupras', 'Developer', "That's me, Hardcoded Software founder", 'www.hardcoded.net', 'hsoft@hardcoded.net')} + +${self.credit(u'Jérôme Cantin', u'Icon designer', u"Icons in dupeGuru are from him")} + +${self.credit('Python', 'Programming language', "The bestest of the bests", 'www.python.org')} + +${self.credit('PyObjC', 'Python-to-Cocoa bridge', "Used for the Mac OS X version", 'pyobjc.sourceforge.net')} + +${self.credit('PyQt', 'Python-to-Qt bridge', "Used for the Windows version", 'www.riverbankcomputing.co.uk')} + +${self.credit('Qt', 'GUI Toolkit', "Used for the Windows version", 'www.qtsoftware.com')} + +${self.credit('Sparkle', 'Auto-update library', "Used for the Mac OS X version", 'andymatuschak.org/pages/sparkle')} + +${self.credit('Python Imaging Library', 'Picture analyzer', "Used for the Windows version", 'www.pythonware.com/products/pil/')} + +${self.credit('You', 'dupeGuru user', "What would I do without you?")} diff --git a/pe/help/templates/directories.mako b/pe/help/templates/directories.mako new file mode 100644 index 00000000..e75b47bd --- /dev/null +++ b/pe/help/templates/directories.mako @@ -0,0 +1,24 @@ +<%! + title = 'Directories' + selected_menu_item = 'Directories' +%> +<%inherit file="/base_dg.mako"/> + +There is a panel in dupeGuru called **Directories**. You can open it by clicking on the **Directories** button. This directory contains the list of the directories that will be scanned when you click on **Start Scanning**. + +This panel is quite straightforward to use. If you want to add a directory, click on **Add**. If you added directories before, a popup menu with a list of recent directories you added will pop. You can click on one of them to add it directly to your list. If you click on the first item of the popup menu, **Add New Directory...**, you will be prompted for a directory to add. If you never added a directory, no menu will pop and you will directly be prompted for a new directory to add. + +To remove a directory, select the directory to remove and click on **Remove**. If a subdirectory is selected when you click remove, the selected directory will be set to **excluded** state (see below) instead of being removed. + +Directory states +----- + +Every directory can be in one of these 3 states: + +* **Normal:** Duplicates found in these directories can be deleted. +* **Reference:** Duplicates found in this directory **cannot** be deleted. Files in reference directories will be in a blue color in the results. +* **Excluded:** Files in this directory will not be included in the scan. + +The default state of a directory is, of course, **Normal**. You can use **Reference** state for a directory if you want to be sure that you won't delete any file from it. + +When you set the state of a directory, all subdirectories of this directory automatically inherit this state unless you explicitly set a subdirectory's state. diff --git a/pe/help/templates/faq.mako b/pe/help/templates/faq.mako new file mode 100644 index 00000000..1c4e998f --- /dev/null +++ b/pe/help/templates/faq.mako @@ -0,0 +1,64 @@ +<%! + title = 'dupeGuru F.A.Q.' + selected_menu_item = 'F.A.Q.' +%> +<%inherit file="/base_dg.mako"/> + +<%text filter="md"> +### What is dupeGuru PE? + +dupeGuru Picture Edition (PE for short) is a tool to find duplicate pictures on your computer. Not only can it find exact matches, but it can also find duplicates among pictures of different kind (PNG, JPG, GIF etc..) and quality. + +### What makes it better than other duplicate scanners? + +The scanning engine is extremely flexible. You can tweak it to really get the kind of results you want. You can read more about dupeGuru tweaking option at the [Preferences page](preferences.htm). + +### How safe is it to use dupeGuru PE? + +Very safe. dupeGuru has been designed to make sure you don't delete files you didn't mean to delete. First, there is the reference directory system that lets you define directories where you absolutely **don't** want dupeGuru to let you delete files there, and then there is the group reference system that makes sure that you will **always** keep at least one member of the duplicate group. + +### What are the demo limitations of dupeGuru PE? + +In demo mode, you can only perform actions (delete/copy/move) on 10 duplicates per session. + +### The mark box of a file I want to delete is disabled. What must I do? + +You cannot mark the reference (The first file) of a duplicate group. However, what you can do is to promote a duplicate file to reference. Thus, if a file you want to mark is reference, select a duplicate file in the group that you want to promote to reference, and click on **Actions-->Make Selected Reference**. If the reference file is from a reference directory (filename written in blue letters), you cannot remove it from the reference position. + +### I have a directory from which I really don't want to delete files. + +If you want to be sure that dupeGuru will never delete file from a particular directory, just open the **Directories panel**, select that directory, and set its state to **Reference**. + +### What is this '(X discarded)' notice in the status bar? + +In some cases, some matches are not included in the final results for security reasons. Let me use an example. We have 3 file: A, B and C. We scan them using a low filter hardness. The scanner determines that A matches with B, A matches with C, but B does **not** match with C. Here, dupeGuru has kind of a problem. It cannot create a duplicate group with A, B and C in it because not all files in the group would match together. It could create 2 groups: one A-B group and then one A-C group, but it will not, for security reasons. Lets think about it: If B doesn't match with C, it probably means that either B, C or both are not actually duplicates. If there would be 2 groups (A-B and A-C), you would end up delete both B and C. And if one of them is not a duplicate, that is really not what you want to do, right? So what dupeGuru does in a case like this is to discard the A-C match (and adds a notice in the status bar). Thus, if you delete B and re-run a scan, you will have a A-C match in your next results. + +### I want to mark all files from a specific directory. What can I do? + +Enable the [Power Marker](power_marker.htm) mode and click on the Directory column to sort your duplicates by Directory. It will then be easy for you to select all duplicates from the same directory, and then press Space to mark all selected duplicates. + +### I want to remove all files that are more than 300 KB away from their reference file. What can I do? + +* Enable the [Power Marker](power_marker.htm) mode. +* Enable the **Delta Values** mode. +* Click on the "Size" column to sort the results by size. +* Select all duplicates below -300. +* Click on **Remove Selected from Results**. +* Select all duplicates over 300. +* Click on **Remove Selected from Results**. + +### I want to make my latest modified files reference files. What can I do? + +* Enable the [Power Marker](power_marker.htm) mode. +* Enable the **Delta Values** mode. +* Click on the "Modification" column to sort the results by modification date. +* Click on the "Modification" column again to reverse the sort order (see Power Marker page to know why). +* Select all duplicates over 0. +* Click on **Make Selected Reference**. + +### I want to mark all duplicates containing the word "copy". How do I do that? + +* **Windows**: Click on **Actions --> Apply Filter**, then type "copy", then click OK. +* **Mac OS X**: Type "copy" in the "Filter" field in the toolbar. +* Click on **Mark --> Mark All**. + \ No newline at end of file diff --git a/pe/help/templates/intro.mako b/pe/help/templates/intro.mako new file mode 100644 index 00000000..51d058c9 --- /dev/null +++ b/pe/help/templates/intro.mako @@ -0,0 +1,13 @@ +<%! + title = 'Introduction to dupeGuru PE' + selected_menu_item = 'introduction' +%> +<%inherit file="/base_dg.mako"/> + +dupeGuru Picture Edition (PE for short) is a tool to find duplicate pictures on your computer. Not only can it find exact matches, but it can also find duplicates among pictures of different kind (PNG, JPG, GIF etc..) and quality. + +Although dupeGuru can easily be used without documentation, reading this file will help you to master it. If you are looking for guidance for your first duplicate scan, you can take a look at the [Quick Start](quick_start.htm) section. + +It is a good idea to keep dupeGuru PE updated. You can download the latest version on the [dupeGuru PE homepage](http://www.hardcoded.net/dupeguru_pe/). + +<%def name="meta()"> diff --git a/pe/help/templates/power_marker.mako b/pe/help/templates/power_marker.mako new file mode 100644 index 00000000..26078f3d --- /dev/null +++ b/pe/help/templates/power_marker.mako @@ -0,0 +1,33 @@ +<%! + title = 'Power Marker' + selected_menu_item = 'Power Marker' +%> +<%inherit file="/base_dg.mako"/> + +You will probably not use the Power Marker feature very often, but if you get into a situation where you need it, you will be pretty happy that this feature exists. + +What is it? +----- + +When the Power Marker mode is enabled, the duplicates are shown without their respective reference file. You can select, mark and sort this list, just like in normal mode. + +So, what is it for? +----- + +The dupeGuru results, when in normal mode, are sorted according to duplicate groups' **reference file**. This means that if you want, for example, to mark all duplicates with the "exe" extension, you cannot just sort the results by "Kind" to have all exe duplicates together because a group can be composed of more than one kind of files. That is where Power Marker comes into play. To mark all your "exe" duplicates, you just have to: + +* Enable the Power marker mode. +* Add the "Kind" column with the "Columns" menu. +* Click on that "Kind" column to sort the list by kind. +* Locate the first duplicate with a "exe" kind. +* Select it. +* Scroll down the list to locate the last duplicate with a "exe" kind. +* Hold Shift and click on it. +* Press Space to mark all selected duplicates. + +Power Marker and delta values +----- + +The Power Marker unveil its true power when you use it with the **Delta Values** switch turned on. When you turn it on, relative values will be displayed instead of absolute ones. So if, for example, you want to remove from your results all duplicates that are more than 300 KB away from their reference, you could sort the Power Marker by Size, select all duplicates under -300 in the Size column, delete them, and then do the same for duplicates over 300 at the bottom of the list. + +You could also use it to change the reference priority of your duplicate list. When you make a fresh scan, if there are no reference directories, the reference file of every group is the biggest file. If you want to change that, for example, to the latest modification time, you can sort the Power Marker by modification time in **descending** order, select all duplicates with a modification time delta value higher than 0 and click on **Make Selected Reference**. The reason why you must make the sort order descending is because if 2 files among the same duplicate group are selected when you click on **Make Selected Reference**, only the first of the list will be made reference, the other will be ignored. And since you want the last modified file to be reference, having the sort order descending assures you that the first item of the list will be the last modified. diff --git a/pe/help/templates/preferences.mako b/pe/help/templates/preferences.mako new file mode 100644 index 00000000..0ef6a2ba --- /dev/null +++ b/pe/help/templates/preferences.mako @@ -0,0 +1,23 @@ +<%! + title = 'Preferences' + selected_menu_item = 'Preferences' +%> +<%inherit file="/base_dg.mako"/> + +**Filter Hardness:** The higher is this setting, the "harder" is the filter (In other words, the less results you get). Most pictures of the same quality match at 100% even if the format is different (PNG and JPG for example.). However, if you want to make a PNG match with a lower quality JPG, you will have to set the filer hardness to lower than 100. The default, 95, is a sweet spot. + +**Match scaled pictures together:** If you check this box, pictures of different dimensions will be allowed in the same duplicate group. + +**Can mix file kind:** If you check this box, duplicate groups are allowed to have files with different extensions. If you don't check it, well, they aren't! + +**Use regular expressions when filtering:** If you check this box, the filtering feature will treat your filter query as a **regular expression**. Explaining them is beyond the scope of this document. A good place to start learning it is . + +**Remove empty folders after delete or move:** When this option is enabled, folders are deleted after a file is deleted or moved and the folder is empty. + +**Copy and Move:** Determines how the Copy and Move operations (in the Action menu) will behave. + +* **Right in destination:** All files will be sent directly in the selected destination, without trying to recreate the source path at all. +* **Recreate relative path:** The source file's path will be re-created in the destination directory up to the root selection in the Directories panel. For example, if you added "/Users/foobar/Picture" to your Directories panel and you move "/Users/foobar/Picture/2006/06/photo.jpg" to the destination "/Users/foobar/MyDestination", the final destination for the file will be "/Users/foobar/MyDestination/2006/06" ("/Users/foobar/Picture" has been trimmed from source's path in the final destination.). +* **Recreate absolute path:** The source file's path will be re-created in the destination directory in it's entirety. For example, if you move "/Users/foobar/Picture/2006/06/photo.jpg" to the destination "/Users/foobar/MyDestination", the final destination for the file will be "/Users/foobar/MyDestination/Users/foobar/Picture/2006/06". + +In all cases, dupeGuru PE nicely handles naming conflicts by prepending a number to the destination filename if the filename already exists in the destination. diff --git a/pe/help/templates/quick_start.mako b/pe/help/templates/quick_start.mako new file mode 100644 index 00000000..dde33c65 --- /dev/null +++ b/pe/help/templates/quick_start.mako @@ -0,0 +1,18 @@ +<%! + title = 'Quick Start' + selected_menu_item = 'Quick Start' +%> +<%inherit file="/base_dg.mako"/> + +To get you quickly started with dupeGuru, let's just make a standard scan using default preferences. + +* Click on **Directories**. +* Click on **Add**. +* Choose a directory you want to scan for duplicates. +* Click on **Start Scanning**. +* Wait until the scan process is over. +* Look at every duplicate (The files that are indented) and verify that it is indeed a duplicate to the group's reference (The file above the duplicate that is not indented and have a disabled mark box). +* If a file is a false duplicate, select it and click on **Actions-->Remove Selected from Results**. +* Once you are sure that there is no false duplicate in your results, click on **Edit-->Mark All**, and then **Actions-->Send Marked to Recycle bin**. + +That is only a basic scan. There are a lot of tweaking you can do to get different results and several methods of examining and modifying your results. To know about them, just read the rest of this help file. diff --git a/pe/help/templates/results.mako b/pe/help/templates/results.mako new file mode 100644 index 00000000..53aa176f --- /dev/null +++ b/pe/help/templates/results.mako @@ -0,0 +1,73 @@ +<%! + title = 'Results' + selected_menu_item = 'Results' +%> +<%inherit file="/base_dg.mako"/> + +When dupeGuru is finished scanning for duplicates, it will show its results in the form of duplicate group list. + +About duplicate groups +----- + +A duplicate group is a group of files that all match together. Every group has a **reference file** and one or more **duplicate files**. The reference file is the first file of the group. Its mark box is disabled. Below it, and indented, are the duplicate files. + +You can mark duplicate files, but you can never mark the reference file of a group. This is a security measure to prevent dupeGuru from deleting not only duplicate files, but their reference. You sure don't want that, do you? + +What determines which files are reference and which files are duplicates is first their directory state. A files from a reference directory will always be reference in a duplicate group. If all files are from a normal directory, the size determine which file will be the reference of a duplicate group. dupeGuru assumes that you always want to keep the biggest file, so the biggest files will take the reference position. + +You can change the reference file of a group manually. To do so, select the duplicate file you want to promote to reference, and click on **Actions-->Make Selected Reference**. + +Reviewing results +----- + +Although you can just click on **Edit-->Mark All** and then **Actions-->Send Marked to Recycle bin** to quickly delete all duplicate files in your results, it is always recommended to review all duplicates before deleting them. + +To help you reviewing the results, you can bring up the **Details panel**. This panel shows all the details of the currently selected file as well as its reference's details. This is very handy to quickly determine if a duplicate really is a duplicate. You can also double-click on a file to open it with its associated application. + +If you have more false duplicates than true duplicates (If your filter hardness is very low), the best way to proceed would be to review duplicates, mark true duplicates and then click on **Actions-->Send Marked to Recycle bin**. If you have more true duplicates than false duplicates, you can instead mark all files that are false duplicates, and use **Actions-->Remove Marked from Results**. + +Marking and Selecting +----- + +A **marked** duplicate is a duplicate with the little box next to it having a check-mark. A **selected** duplicate is a duplicate being highlighted. The multiple selection actions can be performed in dupeGuru in the standard way (Shift/Command/Control click). You can toggle all selected duplicates' mark state by pressing **space**. + +Delta Values +----- + +If you turn this switch on, some columns will display the value relative to the duplicate's reference instead of the absolute values. These delta values will also be displayed in a different color so you can spot them easily. For example, if a duplicate is 1.2 MB and its reference is 1.4 MB, the Size column will display -0.2 MB. This option is a killer feature when combined with the [Power Marker](power_marker.htm). + +Filtering +----- + +dupeGuru supports post-scan filtering. With it, you can narrow down your results so you can perform actions on a subset of it. For example, you could easily mark all duplicates with their filename containing "copy" from your results using the filter. + +**Windows:** To use the filtering feature, click on Actions --> Apply Filter, write down the filter you want to apply and click OK. To go back to unfiltered results, click on Actions --> Cancel Filter. + +**Mac OS X:** To use the filtering feature, type your filter in the "Filter" search field in the toolbar. To go back to unfiltered result, blank out the field, or click on the "X". + +In simple mode (the default mode), whatever you type as the filter is the string used to perform the actual filtering, with the exception of one wildcard: **\***. Thus, if you type "[*]" as your filter, it will match anything with [] brackets in it, whatever is in between those brackets. + +For more advanced filtering, you can turn "Use regular expressions when filtering" on. The filtering feature will then use **regular expressions**. A regular expression is a language for matching text. Explaining them is beyond the scope of this document. A good place to start learning it is . + +Matches are case insensitive in both simple and regexp mode. + +For the filter to match, your regular expression don't have to match the whole filename, it just have to contain a string matching the expression. + +You might notice that not all duplicates in the filtered results will match your filter. That is because as soon as one single duplicate in a group matches the filter, the whole group stays in the results so you can have a better view of the duplicate's context. However, non-matching duplicates are in "reference mode". Therefore, you can perform actions like Mark All and be sure to only mark filtered duplicates. + +Action Menu +----- + +* **Start Duplicate Scan:** Starts a new duplicate scan. +* **Clear Ignore List:** Remove all ignored matches you added. You have to start a new scan for the newly cleared ignore list to be effective. +* **Export Results to XHTML:** Take the current results, and create an XHTML file out of it. The columns that are visible when you click on this button will be the columns present in the XHTML file. The file will automatically be opened in your default browser. +* **Send Marked to Trash:** Send all marked duplicates to trash, obviously. +* **Move Marked to...:** Prompt you for a destination, and then move all marked files to that destination. Source file's path might be re-created in destination, depending on the "Copy and Move" preference. +* **Copy Marked to...:** Prompt you for a destination, and then copy all marked files to that destination. Source file's path might be re-created in destination, depending on the "Copy and Move" preference. +* **Remove Marked from Results:** Remove all marked duplicates from results. The actual files will not be touched and will stay where they are. +* **Remove Selected from Results:** Remove all selected duplicates from results. Note that all selected reference files will be ignored, only duplicates can be removed with this action. +* **Make Selected Reference:** Promote all selected duplicates to reference. If a duplicate is a part of a group having a reference file coming from a reference directory (in blue color), no action will be taken for this duplicate. If more than one duplicate among the same group are selected, only the first of each group will be promoted. +* **Add Selected to Ignore List:** This first removes all selected duplicates from results, and then add the match of that duplicate and the current reference in the ignore list. This match will not come up again in further scan. The duplicate itself might come back, but it will be matched with another reference file. You can clear the ignore list with the Clear Ignore List command. +* **Open Selected with Default Application:** Open the file with the application associated with selected file's type. +* **Reveal Selected in Finder:** Open the folder containing selected file. +* **Rename Selected:** Prompts you for a new name, and then rename the selected file. diff --git a/pe/help/templates/versions.mako b/pe/help/templates/versions.mako new file mode 100644 index 00000000..157c26ba --- /dev/null +++ b/pe/help/templates/versions.mako @@ -0,0 +1,6 @@ +<%! + title = 'dupeGuru PE version history' + selected_menu_item = 'Version History' +%> +<%inherit file="/base_dg.mako"/> +${self.output_changelogs(changelog)} \ No newline at end of file diff --git a/pe/qt/app.py b/pe/qt/app.py new file mode 100644 index 00000000..f40580ad --- /dev/null +++ b/pe/qt/app.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# Unit Name: app +# Created By: Virgil Dupras +# Created On: 2009-04-25 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os.path as op + +from PyQt4.QtGui import QImage +import PIL.Image + +from hs import fs +from hs.fs import phys +from hs.utils.str import get_file_ext + +from dupeguru import data_pe +from dupeguru.picture.cache import Cache +from dupeguru.picture.matchbase import AsyncMatchFactory + +from block import getblocks +from base.app import DupeGuru as DupeGuruBase +from details_dialog import DetailsDialog +from main_window import MainWindow +from preferences import Preferences +from preferences_dialog import PreferencesDialog + +class File(phys.File): + cls_info_map = { + 'size': fs.IT_ATTRS, + 'ctime': fs.IT_ATTRS, + 'mtime': fs.IT_ATTRS, + 'md5': fs.IT_MD5, + 'md5partial': fs.IT_MD5, + 'dimensions': fs.IT_EXTRA, + } + + def _initialize_info(self, section): + super(File, self)._initialize_info(section) + if section == fs.IT_EXTRA: + self._info.update({ + 'dimensions': (0,0), + }) + + def _read_info(self, section): + super(File, self)._read_info(section) + if section == fs.IT_EXTRA: + im = PIL.Image.open(unicode(self.path)) + self._info['dimensions'] = im.size + + def get_blocks(self, block_count_per_side): + image = QImage(unicode(self.path)) + image = image.convertToFormat(QImage.Format_RGB888) + return getblocks(image, block_count_per_side) + + +class Directory(phys.Directory): + cls_file_class = File + cls_supported_exts = ('png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff') + + def _fetch_subitems(self): + subdirs, subfiles = super(Directory, self)._fetch_subitems() + return subdirs, [name for name in subfiles if get_file_ext(name) in self.cls_supported_exts] + + +class DupeGuru(DupeGuruBase): + LOGO_NAME = 'logo_pe' + NAME = 'dupeGuru Picture Edition' + VERSION = '1.7.2' + DELTA_COLUMNS = frozenset([2, 5, 6]) + + def __init__(self): + DupeGuruBase.__init__(self, data_pe, appid=5) + + def _setup(self): + self.scanner.match_factory = AsyncMatchFactory() + self.directories.dirclass = Directory + self.scanner.match_factory.cached_blocks = Cache(op.join(self.appdata, 'cached_pictures.db')) + DupeGuruBase._setup(self) + + def _update_options(self): + DupeGuruBase._update_options(self) + self.scanner.match_factory.match_scaled = self.prefs.match_scaled + self.scanner.match_factory.threshold = self.prefs.filter_hardness + + def _create_details_dialog(self, parent): + return DetailsDialog(parent, self) + + def _create_main_window(self): + return MainWindow(app=self) + + def _create_preferences(self): + return Preferences() + + def _create_preferences_dialog(self, parent): + return PreferencesDialog(parent, self) + diff --git a/pe/qt/app_win.py b/pe/qt/app_win.py new file mode 100644 index 00000000..a18ce2e9 --- /dev/null +++ b/pe/qt/app_win.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# Unit Name: app_win +# Created By: Virgil Dupras +# Created On: 2009-05-02 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import winshell + +import app + +class DupeGuru(app.DupeGuru): + @staticmethod + def _recycle_dupe(dupe): + winshell.delete_file(unicode(dupe.path), no_confirm=True) + diff --git a/pe/qt/base/__init__.py b/pe/qt/base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pe/qt/base/about_box.py b/pe/qt/base/about_box.py new file mode 100644 index 00000000..55a36eb1 --- /dev/null +++ b/pe/qt/base/about_box.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# Unit Name: about_box +# Created By: Virgil Dupras +# Created On: 2009-05-09 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, QCoreApplication, SIGNAL +from PyQt4.QtGui import QDialog, QDialogButtonBox, QPixmap + +from about_box_ui import Ui_AboutBox + +class AboutBox(QDialog, Ui_AboutBox): + def __init__(self, parent, app): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.MSWindowsFixedSizeDialogHint + QDialog.__init__(self, parent, flags) + self.app = app + self._setupUi() + + self.connect(self.buttonBox, SIGNAL('clicked(QAbstractButton*)'), self.buttonClicked) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be done in the Designer + self.setWindowTitle(u"About %s" % QCoreApplication.instance().applicationName()) + self.nameLabel.setText(QCoreApplication.instance().applicationName()) + self.versionLabel.setText('Version ' + QCoreApplication.instance().applicationVersion()) + self.logoLabel.setPixmap(QPixmap(':/%s_big' % self.app.LOGO_NAME)) + self.registerButton = self.buttonBox.addButton("Register", QDialogButtonBox.ActionRole) + + #--- Events + def buttonClicked(self, button): + if button is self.registerButton: + self.app.ask_for_reg_code() + diff --git a/pe/qt/base/about_box.ui b/pe/qt/base/about_box.ui new file mode 100644 index 00000000..aa9c5ce5 --- /dev/null +++ b/pe/qt/base/about_box.ui @@ -0,0 +1,133 @@ + + + AboutBox + + + + 0 + 0 + 400 + 190 + + + + + 0 + 0 + + + + About dupeGuru + + + + + + + + + :/logo_me_big + + + + + + + + + + 75 + true + + + + dupeGuru + + + + + + + Version + + + + + + + Copyright Hardcoded Software 2009 + + + + + + + + 75 + true + + + + Registered To: + + + + + + + UNREGISTERED + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Ok + + + + + + + + + + + + + buttonBox + accepted() + AboutBox + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + AboutBox + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/pe/qt/base/app.py b/pe/qt/base/app.py new file mode 100644 index 00000000..3fa340bf --- /dev/null +++ b/pe/qt/base/app.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python +# Unit Name: app +# Created By: Virgil Dupras +# Created On: 2009-04-25 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import logging +import os.path as op +import traceback + +from PyQt4.QtCore import Qt, QTimer, QObject, QCoreApplication, QUrl, SIGNAL +from PyQt4.QtGui import QProgressDialog, QDesktopServices, QFileDialog, QDialog, QMessageBox + +from hsutil import job +from hsutil.reg import RegistrationRequired + +from dupeguru import data_pe +from dupeguru.app import (DupeGuru as DupeGuruBase, JOB_SCAN, JOB_LOAD, JOB_MOVE, JOB_COPY, + JOB_DELETE) + +from main_window import MainWindow +from directories_dialog import DirectoriesDialog +from about_box import AboutBox +from reg import Registration +from error_report_dialog import ErrorReportDialog + +JOBID2TITLE = { + JOB_SCAN: "Scanning for duplicates", + JOB_LOAD: "Loading", + JOB_MOVE: "Moving", + JOB_COPY: "Copying", + JOB_DELETE: "Sending files to the recycle bin", +} + +class Progress(QProgressDialog, job.ThreadedJobPerformer): + def __init__(self, parent): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QProgressDialog.__init__(self, '', u"Cancel", 0, 100, parent, flags) + self.setModal(True) + self.setAutoReset(False) + self.setAutoClose(False) + self._timer = QTimer() + self._jobid = '' + self.connect(self._timer, SIGNAL('timeout()'), self.updateProgress) + + def updateProgress(self): + # the values might change before setValue happens + last_progress = self.last_progress + last_desc = self.last_desc + if not self._job_running or last_progress is None: + self._timer.stop() + self.close() + self.emit(SIGNAL('finished(QString)'), self._jobid) + if self._last_error is not None: + s = ''.join(traceback.format_exception(*self._last_error)) + dialog = ErrorReportDialog(self.parent(), s) + dialog.exec_() + return + if self.wasCanceled(): + self.job_cancelled = True + return + if last_desc: + self.setLabelText(last_desc) + self.setValue(last_progress) + + def run(self, jobid, title, target, args=()): + self._jobid = jobid + self.reset() + self.setLabelText('') + self.run_threaded(target, args) + self.setWindowTitle(title) + self.show() + self._timer.start(500) + + +def demo_method(method): + def wrapper(self, *args, **kwargs): + try: + return method(self, *args, **kwargs) + except RegistrationRequired: + msg = "The demo version of dupeGuru only allows 10 actions (delete/move/copy) per session." + QMessageBox.information(self.main_window, 'Demo', msg) + + return wrapper + +class DupeGuru(DupeGuruBase, QObject): + LOGO_NAME = '' + NAME = '' + DELTA_COLUMNS = frozenset() + + def __init__(self, data_module, appid): + appdata = unicode(QDesktopServices.storageLocation(QDesktopServices.DataLocation)) + DupeGuruBase.__init__(self, data_module, appdata, appid) + QObject.__init__(self) + self._setup() + + #--- Private + def _setup(self): + self.selected_dupe = None + self.prefs = self._create_preferences() + self.prefs.load() + self._update_options() + self.main_window = self._create_main_window() + self._progress = Progress(self.main_window) + self.directories_dialog = DirectoriesDialog(self.main_window, self) + self.details_dialog = self._create_details_dialog(self.main_window) + self.preferences_dialog = self._create_preferences_dialog(self.main_window) + self.about_box = AboutBox(self.main_window, self) + + self.reg = Registration(self) + self.set_registration(self.prefs.registration_code, self.prefs.registration_email) + if not self.registered: + self.reg.show_nag() + self.main_window.show() + self.load() + + self.connect(QCoreApplication.instance(), SIGNAL('aboutToQuit()'), self.application_will_terminate) + self.connect(self._progress, SIGNAL('finished(QString)'), self.job_finished) + + def _setup_as_registered(self): + self.prefs.registration_code = self.registration_code + self.prefs.registration_email = self.registration_email + self.main_window.actionRegister.setVisible(False) + self.about_box.registerButton.hide() + self.about_box.registeredEmailLabel.setText(self.prefs.registration_email) + + def _update_options(self): + self.scanner.mix_file_kind = self.prefs.mix_file_kind + self.options['escape_filter_regexp'] = self.prefs.use_regexp + self.options['clean_empty_dirs'] = self.prefs.remove_empty_folders + + #--- Virtual + def _create_details_dialog(self, parent): + raise NotImplementedError() + + def _create_main_window(self): + return MainWindow(app=self) + + def _create_preferences(self): + raise NotImplementedError() + + def _create_preferences_dialog(self, parent): + raise NotImplementedError() + + #--- Override + def _start_job(self, jobid, func): + title = JOBID2TITLE[jobid] + try: + j = self._progress.create_job() + self._progress.run(jobid, title, func, args=(j, )) + except job.JobInProgressError: + msg = "A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again." + QMessageBox.information(self.main_window, 'Action in progress', msg) + + #--- Public + def add_dupes_to_ignore_list(self, duplicates): + for dupe in duplicates: + self.AddToIgnoreList(dupe) + self.remove_duplicates(duplicates) + + def ApplyFilter(self, filter): + DupeGuruBase.ApplyFilter(self, filter) + self.emit(SIGNAL('resultsChanged()')) + + def ask_for_reg_code(self): + if self.reg.ask_for_code(): + self._setup_ui_as_registered() + + @demo_method + def copy_or_move_marked(self, copy): + opname = 'copy' if copy else 'move' + title = "Select a directory to {0} marked files to".format(opname) + flags = QFileDialog.ShowDirsOnly + destination = unicode(QFileDialog.getExistingDirectory(self.main_window, title, '', flags)) + if not destination: + return + recreate_path = self.prefs.destination_type + DupeGuruBase.copy_or_move_marked(self, copy, destination, recreate_path) + + delete_marked = demo_method(DupeGuruBase.delete_marked) + + def make_reference(self, duplicates): + DupeGuruBase.make_reference(self, duplicates) + self.emit(SIGNAL('resultsChanged()')) + + def mark_all(self): + self.results.mark_all() + self.emit(SIGNAL('dupeMarkingChanged()')) + + def mark_invert(self): + self.results.mark_invert() + self.emit(SIGNAL('dupeMarkingChanged()')) + + def mark_none(self): + self.results.mark_none() + self.emit(SIGNAL('dupeMarkingChanged()')) + + def open_selected(self): + if self.selected_dupe is None: + return + url = QUrl.fromLocalFile(unicode(self.selected_dupe.path)) + QDesktopServices.openUrl(url) + + def remove_duplicates(self, duplicates): + self.results.remove_duplicates(duplicates) + self.emit(SIGNAL('resultsChanged()')) + + def remove_marked_duplicates(self): + marked = [d for d in self.results.dupes if self.results.is_marked(d)] + self.remove_duplicates(marked) + + def rename_dupe(self, dupe, newname): + try: + dupe.move(dupe.parent, newname) + return True + except (IndexError, fs.FSError) as e: + logging.warning("dupeGuru Warning: %s" % unicode(e)) + return False + + def reveal_selected(self): + if self.selected_dupe is None: + return + url = QUrl.fromLocalFile(unicode(self.selected_dupe.path[:-1])) + QDesktopServices.openUrl(url) + + def select_duplicate(self, dupe): + self.selected_dupe = dupe + self.emit(SIGNAL('duplicateSelected()')) + + def show_about_box(self): + self.about_box.show() + + def show_details(self): + self.details_dialog.show() + + def show_directories(self): + self.directories_dialog.show() + + def show_help(self): + url = QUrl.fromLocalFile(op.abspath('help/intro.htm')) + QDesktopServices.openUrl(url) + + def show_preferences(self): + self.preferences_dialog.load() + result = self.preferences_dialog.exec_() + if result == QDialog.Accepted: + self.preferences_dialog.save() + self.prefs.save() + self._update_options() + + def toggle_marking_for_dupes(self, dupes): + for dupe in dupes: + self.results.mark_toggle(dupe) + self.emit(SIGNAL('dupeMarkingChanged()')) + + #--- Events + def application_will_terminate(self): + self.Save() + self.SaveIgnoreList() + + def job_finished(self, jobid): + self.emit(SIGNAL('resultsChanged()')) + if jobid == JOB_LOAD: + self.emit(SIGNAL('directoriesChanged()')) + if jobid in (JOB_MOVE, JOB_COPY, JOB_DELETE) and self.last_op_error_count > 0: + msg = "{0} files could not be processed.".format(self.results.mark_count) + QMessageBox.warning(self.main_window, 'Warning', msg) + diff --git a/pe/qt/base/details_table.py b/pe/qt/base/details_table.py new file mode 100644 index 00000000..1c45de1e --- /dev/null +++ b/pe/qt/base/details_table.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# Unit Name: details_table +# Created By: Virgil Dupras +# Created On: 2009-05-17 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, SIGNAL, QAbstractTableModel, QVariant +from PyQt4.QtGui import QHeaderView, QTableView + +HEADER = ['Attribute', 'Selected', 'Reference'] + +class DetailsModel(QAbstractTableModel): + def __init__(self, app): + QAbstractTableModel.__init__(self) + self._app = app + self._data = app.data + self._dupe_data = None + self._ref_data = None + self.connect(app, SIGNAL('duplicateSelected()'), self.duplicateSelected) + + def columnCount(self, parent): + return len(HEADER) + + def data(self, index, role): + if not index.isValid(): + return QVariant() + if role != Qt.DisplayRole: + return QVariant() + column = index.column() + row = index.row() + if column == 0: + return QVariant(self._data.COLUMNS[row]['display']) + elif column == 1 and self._dupe_data: + return QVariant(self._dupe_data[row]) + elif column == 2 and self._ref_data: + return QVariant(self._ref_data[row]) + return QVariant() + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal and role == Qt.DisplayRole and section < len(HEADER): + return QVariant(HEADER[section]) + return QVariant() + + def rowCount(self, parent): + return len(self._data.COLUMNS) + + #--- Events + def duplicateSelected(self): + dupe = self._app.selected_dupe + group = self._app.results.get_group_of_duplicate(dupe) + ref = group.ref + self._dupe_data = self._data.GetDisplayInfo(dupe, group) + self._ref_data = self._data.GetDisplayInfo(ref, group) + self.reset() + + +class DetailsTable(QTableView): + def __init__(self, *args): + QTableView.__init__(self, *args) + self.setAlternatingRowColors(True) + self.setSelectionBehavior(QTableView.SelectRows) + self.setShowGrid(False) + + def setModel(self, model): + QTableView.setModel(self, model) + # The model needs to be set to set header stuff + hheader = self.horizontalHeader() + hheader.setHighlightSections(False) + hheader.setStretchLastSection(False) + hheader.resizeSection(0, 100) + hheader.setResizeMode(0, QHeaderView.Fixed) + hheader.setResizeMode(1, QHeaderView.Stretch) + hheader.setResizeMode(2, QHeaderView.Stretch) + vheader = self.verticalHeader() + vheader.setVisible(False) + vheader.setDefaultSectionSize(18) + diff --git a/pe/qt/base/dg.qrc b/pe/qt/base/dg.qrc new file mode 100644 index 00000000..f2f5e936 --- /dev/null +++ b/pe/qt/base/dg.qrc @@ -0,0 +1,17 @@ + + + images/details32.png + images/dgpe_logo_32.png + images/dgpe_logo_128.png + images/dgme_logo_32.png + images/dgme_logo_128.png + images/dgse_logo_32.png + images/dgse_logo_128.png + images/folderwin32.png + images/gear.png + images/preferences32.png + images/actions32.png + images/delta32.png + images/power_marker32.png + + \ No newline at end of file diff --git a/pe/qt/base/directories_dialog.py b/pe/qt/base/directories_dialog.py new file mode 100644 index 00000000..e2f3ddb3 --- /dev/null +++ b/pe/qt/base/directories_dialog.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +# Unit Name: directories_dialog +# Created By: Virgil Dupras +# Created On: 2009-04-25 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QFileDialog, QHeaderView + +from directories_dialog_ui import Ui_DirectoriesDialog +from directories_model import DirectoriesModel, DirectoriesDelegate + +class DirectoriesDialog(QDialog, Ui_DirectoriesDialog): + def __init__(self, parent, app): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.app = app + self._setupUi() + self._updateRemoveButton() + + self.connect(self.doneButton, SIGNAL('clicked()'), self.doneButtonClicked) + self.connect(self.addButton, SIGNAL('clicked()'), self.addButtonClicked) + self.connect(self.removeButton, SIGNAL('clicked()'), self.removeButtonClicked) + self.connect(self.treeView.selectionModel(), SIGNAL('selectionChanged(QItemSelection,QItemSelection)'), self.selectionChanged) + self.connect(self.app, SIGNAL('directoriesChanged()'), self.directoriesChanged) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be done in the Designer + self.directoriesModel = DirectoriesModel(self.app) + self.directoriesDelegate = DirectoriesDelegate() + self.treeView.setItemDelegate(self.directoriesDelegate) + self.treeView.setModel(self.directoriesModel) + + header = self.treeView.header() + header.setStretchLastSection(False) + header.setResizeMode(0, QHeaderView.Stretch) + header.setResizeMode(1, QHeaderView.Fixed) + header.resizeSection(1, 100) + + def _updateRemoveButton(self): + indexes = self.treeView.selectedIndexes() + if not indexes: + self.removeButton.setEnabled(False) + return + self.removeButton.setEnabled(True) + index = indexes[0] + node = index.internalPointer() + # label = 'Remove' if node.parent is None else 'Exclude' + + def addButtonClicked(self): + title = u"Select a directory to add to the scanning list" + flags = QFileDialog.ShowDirsOnly + dirpath = unicode(QFileDialog.getExistingDirectory(self, title, '', flags)) + if not dirpath: + return + self.app.AddDirectory(dirpath) + self.directoriesModel.reset() + + def directoriesChanged(self): + self.directoriesModel.reset() + + def doneButtonClicked(self): + self.hide() + + def removeButtonClicked(self): + indexes = self.treeView.selectedIndexes() + if not indexes: + return + index = indexes[0] + node = index.internalPointer() + if node.parent is None: + row = index.row() + del self.app.directories[row] + self.directoriesModel.reset() + + def selectionChanged(self, selected, deselected): + self._updateRemoveButton() + diff --git a/pe/qt/base/directories_dialog.ui b/pe/qt/base/directories_dialog.ui new file mode 100644 index 00000000..68bc8d84 --- /dev/null +++ b/pe/qt/base/directories_dialog.ui @@ -0,0 +1,133 @@ + + + DirectoriesDialog + + + + 0 + 0 + 420 + 338 + + + + Directories + + + + + + QAbstractItemView::DoubleClicked|QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked + + + true + + + false + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 91 + 0 + + + + + 16777215 + 32 + + + + Remove + + + + + + + + 91 + 0 + + + + + 16777215 + 32 + + + + Add + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 91 + 0 + + + + + 16777215 + 32 + + + + Done + + + true + + + + + + + + + + diff --git a/pe/qt/base/directories_model.py b/pe/qt/base/directories_model.py new file mode 100644 index 00000000..cae88f39 --- /dev/null +++ b/pe/qt/base/directories_model.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +# Unit Name: directories_model +# Created By: Virgil Dupras +# Created On: 2009-04-25 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import QVariant, QModelIndex, Qt, QRect, QEvent, QPoint +from PyQt4.QtGui import QComboBox, QStyledItemDelegate, QMouseEvent, QApplication, QBrush + +from tree_model import TreeNode, TreeModel + +HEADERS = ['Name', 'State'] +STATES = ['Normal', 'Reference', 'Excluded'] + +class DirectoriesDelegate(QStyledItemDelegate): + def createEditor(self, parent, option, index): + editor = QComboBox(parent); + editor.addItems(STATES) + return editor + + def setEditorData(self, editor, index): + value, ok = index.model().data(index, Qt.EditRole).toInt() + assert ok + editor.setCurrentIndex(value); + press = QMouseEvent(QEvent.MouseButtonPress, QPoint(0, 0), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + release = QMouseEvent(QEvent.MouseButtonRelease, QPoint(0, 0), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + QApplication.sendEvent(editor, press) + QApplication.sendEvent(editor, release) + # editor.showPopup() # this causes a weird glitch. the ugly workaround is above. + + def setModelData(self, editor, model, index): + value = QVariant(editor.currentIndex()) + model.setData(index, value, Qt.EditRole) + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + + +class DirectoryNode(TreeNode): + def __init__(self, parent, ref, row): + TreeNode.__init__(self, parent, row) + self.ref = ref + + def _get_children(self): + children = [] + for index, directory in enumerate(self.ref.dirs): + node = DirectoryNode(self, directory, index) + children.append(node) + return children + + +class DirectoriesModel(TreeModel): + def __init__(self, app): + self._dirs = app.directories + TreeModel.__init__(self) + + def _root_nodes(self): + nodes = [] + for index, directory in enumerate(self._dirs): + nodes.append(DirectoryNode(None, directory, index)) + return nodes + + def columnCount(self, parent): + return 2 + + def data(self, index, role): + if not index.isValid(): + return QVariant() + node = index.internalPointer() + if role == Qt.DisplayRole: + if index.column() == 0: + return QVariant(node.ref.name) + else: + return QVariant(STATES[self._dirs.GetState(node.ref.path)]) + elif role == Qt.EditRole and index.column() == 1: + return QVariant(self._dirs.GetState(node.ref.path)) + elif role == Qt.ForegroundRole: + state = self._dirs.GetState(node.ref.path) + if state == 1: + return QVariant(QBrush(Qt.blue)) + elif state == 2: + return QVariant(QBrush(Qt.red)) + return QVariant() + + def flags(self, index): + if not index.isValid(): + return 0 + result = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if index.column() == 1: + result |= Qt.ItemIsEditable + return result + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal: + if role == Qt.DisplayRole and section < len(HEADERS): + return QVariant(HEADERS[section]) + return QVariant() + + def setData(self, index, value, role): + if not index.isValid() or role != Qt.EditRole or index.column() != 1: + return False + node = index.internalPointer() + state, ok = value.toInt() + assert ok + self._dirs.SetState(node.ref.path, state) + return True + diff --git a/pe/qt/base/error_report_dialog.py b/pe/qt/base/error_report_dialog.py new file mode 100644 index 00000000..4aa8f977 --- /dev/null +++ b/pe/qt/base/error_report_dialog.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# Unit Name: error_report_dialog +# Created By: Virgil Dupras +# Created On: 2009-05-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, QUrl +from PyQt4.QtGui import QDialog, QDesktopServices + +from error_report_dialog_ui import Ui_ErrorReportDialog + +class ErrorReportDialog(QDialog, Ui_ErrorReportDialog): + def __init__(self, parent, error): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.setupUi(self) + self.errorTextEdit.setPlainText(error) + + def accept(self): + text = self.errorTextEdit.toPlainText() + url = QUrl("mailto:support@hardcoded.net?SUBJECT=Error Report&BODY=%s" % text) + QDesktopServices.openUrl(url) + QDialog.accept(self) + diff --git a/pe/qt/base/error_report_dialog.ui b/pe/qt/base/error_report_dialog.ui new file mode 100644 index 00000000..0974dd2f --- /dev/null +++ b/pe/qt/base/error_report_dialog.ui @@ -0,0 +1,117 @@ + + + ErrorReportDialog + + + + 0 + 0 + 553 + 349 + + + + Error Report + + + + + + Something went wrong. Would you like to send the error report to Hardcoded Software? + + + true + + + + + + + true + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 110 + 0 + + + + Don't Send + + + + + + + + 110 + 0 + + + + Send + + + true + + + + + + + + + + + sendButton + clicked() + ErrorReportDialog + accept() + + + 485 + 320 + + + 276 + 174 + + + + + dontSendButton + clicked() + ErrorReportDialog + reject() + + + 373 + 320 + + + 276 + 174 + + + + + diff --git a/pe/qt/base/gen.py b/pe/qt/base/gen.py new file mode 100644 index 00000000..3b0df2fa --- /dev/null +++ b/pe/qt/base/gen.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# Unit Name: gen +# Created By: Virgil Dupras +# Created On: 2009-05-22 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os + +def print_and_do(cmd): + print cmd + os.system(cmd) + +print_and_do("pyuic4 main_window.ui > main_window_ui.py") +print_and_do("pyuic4 directories_dialog.ui > directories_dialog_ui.py") +print_and_do("pyuic4 about_box.ui > about_box_ui.py") +print_and_do("pyuic4 reg_submit_dialog.ui > reg_submit_dialog_ui.py") +print_and_do("pyuic4 reg_demo_dialog.ui > reg_demo_dialog_ui.py") +print_and_do("pyuic4 error_report_dialog.ui > error_report_dialog_ui.py") +print_and_do("pyrcc4 dg.qrc > dg_rc.py") \ No newline at end of file diff --git a/pe/qt/base/images/actions32.png b/pe/qt/base/images/actions32.png new file mode 100755 index 0000000000000000000000000000000000000000..16d64fc1e439ab6ae90a1e11cc99831813b2416f GIT binary patch literal 3039 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 literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/delta32.png b/pe/qt/base/images/delta32.png new file mode 100755 index 0000000000000000000000000000000000000000..f7d95da07919c34aa960d37448642e229e75f1e6 GIT binary patch literal 1941 zcmV;G2Wt3RCwCt zS8Gs|*BO4k%iebZcVR&!3IRdrf+*408YCDms3l`#Qytr+HZj&|%@Yh+mbXj@U6o(toJChVaZb-Lv_IXNj18GV4^IK0!*0jJZM_Wb%P^Xe6)u{Duc z66EDF}KQKfSUMGn@lO; zwOUzKU2W97_}#z81i>1WB*bJ1;yt;ewb8n(K{X}{I z^AyMolN5s?n8@_Gu^4j5{6?Q2Mz0tCYu6xs*$dg{d=(=3I=$>kdu_k%|@y8f>UXBhj%kD+@?;YuETVyevoDhY_WNL#`0Za6G!WILW`cvw7iF9pl!0(hmn2>ln|<5RJ>b?5;1JPgllEk`$85O2DqE!mFQN z!a!yUDCACl)J1U)xTn2vhe8ODC8CrKCrG^H{Xr5f-H$V>Zhxmy^XibphjZ6cCJriY zV_HxgwvR16R4-ilIY*jx8Vbn>&=1y)reziJV7 zWb^@EN&WO4l#;kZH}%kg0s2moBgPc?^xs9i%3iB8{ABz3oP86MZhSU~^h?z4oZIKR;IGDok1U7SN7!T z@hK0bV$IAPxw1nL{48mz(P&;@$?5yc*68091c&Ox0EX#?pFNC;}=J8tI38Z z6J*wkV&It?w0+czvyTo#a^-j;0YQm&dc*6H^Z$Q;be4bN*# zcNd);KwvtABkAJ1dkBX{rx4?3eW9TOIkv^v{YVPTl}oYZ&wqp%i_eoM2yyujTekel zH#LPW0`QlHytc^+4^CZ9mD-VX00`hM%8}<;2D@E_J#|Lnc6m(|;;OZDo_B&wb`579NW`!c+$I9=l z+j70$fI^GGyloY;v+sHky$*EJbUYgXimAJL@oqBtRfKG!W<{^Y(m4l7so=3$SGqJ;|)3(~3=Ka%lfbnWbcSI%|0K~V^;+EvLguF8i}K)1V?RNAx|xp&;4Y&h4=DG{!O<-q8_ z+Z;DX|Ctd$-$cdlMhtsCAbOKXV=Z38LuFJU8jU_Y;j6wf8fRdc>b1H=JQm`a|NGbR be+3u-4f`}H8YPeZ00000NkvXXu0mjf0Q9d* literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/details32.png b/pe/qt/base/images/details32.png new file mode 100644 index 0000000000000000000000000000000000000000..8ab245b41ba6f367f19d01227df99b85872bac81 GIT binary patch literal 5090 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 YiNbjNhnHL=%16}s>3mJzOVs%P0VM`{F#rGn literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/dgme_logo_128.png b/pe/qt/base/images/dgme_logo_128.png new file mode 100644 index 0000000000000000000000000000000000000000..187c21c8cfb4e7541a63ce36689f8cb444d17b68 GIT binary patch literal 18092 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 literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/dgme_logo_32.png b/pe/qt/base/images/dgme_logo_32.png new file mode 100644 index 0000000000000000000000000000000000000000..88488462f4a694cc1156f7894a0ebf78aa235d88 GIT binary patch literal 6079 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 literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/dgpe_logo_32.png b/pe/qt/base/images/dgpe_logo_32.png new file mode 100755 index 0000000000000000000000000000000000000000..5384a81c130023c54c59af9e3a10893998f0ad57 GIT binary patch literal 5818 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 NuboDV(okz!|9`pBT*UwY literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/dgse_logo_128.png b/pe/qt/base/images/dgse_logo_128.png new file mode 100644 index 0000000000000000000000000000000000000000..f50221b216d9dcae2626bb246dab97898b7b4e75 GIT binary patch literal 14474 zcmV;5ICaN~P)}~vR`4+yP0WbhG0D6xd^F1wh69D$*^Rpi> zL479?O(z+%lLRcu0KNa^B?Ax%xo*4m(-!a=C3551=Cv&V>dhW>et!PjRhs^gFf>IB z765*$_Zt9=^UadyeUp>*n&N7ch<2BjmZG=vWB>mmcLe|^Po8XkWTEpr4^-ukpJ~i7 zTb7V0fg}Rr7*Gl@M9_xvJrT4vh{G7Ku5RI5LD&u})juw|_8)4-p1gkj`X+7<0?}$T z{`qvA{G;>DTE%iLC;*xW${1*qAIA{E5Q@El(S&XkV>?w{UaLMh-Gt zlDly|-b->f0AT<0_f`Mk$ImVN=(!V@kwywxtf4a?ivg7~s07f)0MFvU}GziQR zxPEm7|NhnM*lN^&uk6^rn;P@pB>#W|LX@YpD8mbks1TCEs)?aoI`U4;ta$&j9>ugAdzq297<_OTcT!3Y<7Al zgy;1fAzrgxcRL7z?6BA5y(IFmUvRFK{pfus&%+1?ceaJ@)td;{+sHzLG$N=NNMj(4 z0Hq680D=Y}n-$b9o#D&AY-ygJnVDJI+}zw(8EFvzt&g-CGmdQ| zR0=xD&|BU@rZqA{`RGUCY6vjM-2efA;IL{XXhU#32Q#+Cwxjq#S*@L}AbE8i2o8Hq z-b-?50JxHu+_DE*a*_4|#Otfjkp^W9lmSK|-zR`d`T)pXLK-G;ryH; zr(lV?<2d#~#}v$*nyZ(gv<7!9gg4faMah5zXb==JXfiP91Au`5m@ES^z-d*XlMEHt z#i$CqV!g0!(Yp z7)ghS4?c86#HjM>U@4&)~$%bG%nT<1jjHX^xhY4CXXT=BU7faT{50MPVy=0xsUIlKixei2#D zuOK9H>Q;<=^z~x^odH?_S;oK^z=?oNK?n}c7^EdYiaa=QrBW6S7>#8nW-C&H7y}ss znW3`B6n)?s)9|$;GuHQ)LjynvfH49qMiUnV61mbv0RUqF(tzdWuE6LV92^X!Gz911 z45&Ib7?DeWmkDto0EFO{;2fF(IlJ4dMH)fDo{>NLzQbOC_qZGy006)^8?yo=kzaV3 zD_WXjuL>)W0VZZJARyD=g^PA<3!E7TfGhL>0F%NwXBJrQPR1g|32oBoNCyEd2LZzo zbM&{6!&?BB^DmZ%+hBs_rX7Ij56;30NIQ=T`c}Xs0F(v;1LqvJ5P;Gi0GDy*Ag2m? z@{k~7_m!9r|JF|7NXy|#AOlcD0bl?tRL1wAZ!{7fk_J=?=*+;x8LXBI z0l;A-$(RZ;R`NV=Pcq1LU29hIio+R<;4qmUCVmdoDI0^s`!bZnlR#du@&4%Nx!E^t z+&c6L8ZeC2wE~PLP#VLso3Oz!=Q!f3R&^%rD(8IP)U0I*5?~OV1KOZC{4J(7$Cb|x zczhJ)?j(XhV{QP28z2mU01P97e)7)B7<5KpCV`X^LNH7@7IZhN!Ddy)n7i*W4S{n8 z27n8`k3tWqK$+Md{LZ5zcP9_fH~*ul)#A$X{zI0Bh5f_~8Nj%J&In)$ghh~+g}IUk z)(NVNu__2ICX72{j5K8821N9UCFnSy8?j0M9hcD|F)%erRK`RuAWhSxV|3d9bIcH=B?spJAamZIGI(G1 z%3TQqG3f&On&?l`6=3K;KgtP611eMCEP>N>FyE}A#!v|iS{7q9{@*Zf-0R*xK4knB*Y?z&Xpef@g5X_EqlW;D6s34CU|u=*t8N93+PN zKqwMKTn?v-g#}25!LkWVM-VolT(fYz<_e=!6O!#$7@|Nd6^wxwDdaxj`XvCjjtKUg z_K}vm0{}+D`io-6`H&R$!^1IaFj6ldZ35FVgwU`&;Nol zI7Bh>^KG084)*U4`+emuSpYU1d3+=i7@a2&;}(DyZa@}pfKdb?G?=Av->C*#mYOme zvqGsVCI)Jtbtl#uk|7TtIfp=D5ioQ?lc%`P!y}K2LjxfHg1whMnK+}Q{qCR@A%o5k)B!ev_1Osf_f&dC0FboIz$SEB(S9H|f!Jz?QfTAE{ zC~VXM6#FrEAOOZGCx#<=0cJu-8(INfzS>PPUoZwi1jludhlMCCL16{nSNd(`E|I`}DE7paADb{5jSNP^ zLt#Kk7gpH?w}6Yso4jhNd1J(Uxm>RFzmIt zSa5_sXBb;(wOZcbupYj(6ZcSZ9F!^^T*rpR0Eq$wBN0LW;Cpj~@5|vyAaNSdzyb)j zoq8Ry1{ebY1A_u3ZHSTw&J3=caNW6jdPZxp;CWtca9k;#?wKwuDd4ycys`@m3>IUM zV1)(9vx8$?fKfpR^JnOY&l4XS0JV~?67o7QqxIODywTM;qcZA~_f!;f1B%)Zjtww_ z(=&BEa3VgP8Jx6WO`~uFMC|z;)9o^oLV}^(sKDYpw*rL)$jRWrW1nHrB#1LcQd`!c z`z5^B^Au60=Qd`3NpG(&Bs3D=Uj72vrg7=QqMK2V^os7)gPQV5VzRRV8u z&1)D5H;;lG3ILxtpZvmw#S?RsDUczkpqE$tVp#XL@ABLU8Mp%mFu?$mLHnEFt_NAJ z;h}R=_J>Y~4@AaXsM)nv@v&q(#g`JV2g_urG%F~TUD%AlW`!&0bAjR*{n1Z`V3Yw9 z44x(78Qn;-?25G6h2zJM&*E-XO1%eU@`rNZv<;|Rup~*xk7wnNU+Vp%|LjA}|F%_{ z$$R}M1rta4+Pl%Hu&?+&`MX64_h1$g1d2O=PGEF`sp&<0;=aw(UwwsLfWbU~6#(g) zFTNaD{{J{to8tgSwN*#26TsF4s{lb-fIt;Yg|Xc4t0;k8FGB?}PBg3h#oe8cn?|GU z*>-kraq&3{?9H^CrnkF*Ns@@7lSzj6V1FSA0N?x2vHLE}rT=-5NXzJ`L?CI5f-%VL zav?+@$D-=xIsK9EyK>^gr)N(?Co$~ld4$(rhRGBdV|o2QN3lEz`Lew{Wmu5K{4pB= z#?Uzzt2eV4l=J{$!E$T(_H*U(rN!uh=iVrtJbwK6tINyF+pAsujomc3{(*UUCfiKV zI5vfyr42;JNAc965YN;y9TRsbM{bZv0ghLdDCUn@73c6WbfSICb@%YK?zw8=tB z`=?|uzSe;QEF(%Qp7*)=`T1AZ*4B3MUIjpq>h#jF?1w)xt-rg85)1*NIt{Ba4WkrD z=e-n(08Ivtbm5hoFxo)WT8LI|AnWXoDSWUMmxM+D!A8o%_ktn!)I=IO*oHlK9y;t{ zVc`sZ8+1Q>z=VZ#`(TJ13= z#1kC23E&o1^WGH!0IM6p?ze7MKUuEh(y@}duW8lMxcp%t%z!9`TWP^|Yq0GyT(<`4 zR>5o+Mk~b2*O6>*f|MS!>})?1zt7{b3}Nv?KaET}gQ!da04%$LS#QVv+UqJ_+jVYC zm+Xyp5CoN4O@GI^>}ML)85l6=RKj1|EqVn38iZ`nENTRs1duV%X$HnPglB^Z4o3=j zmIc?g;Mfv2;}A-tQZK^>15$-`GBC}?(~WB7{{hhpG7MLCoG8|IniUlP8ZIuc{z{tTeIv#wM~%K{G&6 z-Ze}iZ%~y7C?jWL2s%?RX$CULT^RB}h6@3!T0#~kV2mS)6I_pD=tlj8YPtHEOwpI& z^OxUful4r+^7n>}#l4+QCtmhv9<5aU?^v*+)2;clNV**`X(3#?foOFJ>DD@u-5sdV z2W7fw&r&2+`d`#9zdmRVmW^A0{eY2wlmMe3%1tl;LVB3;Hmi@nrozphwcKjeH(z^g zb@kF|``ncU>m#;R2Qh(gD?`){idJzzRscXF?-FfH{xW?2^tJZ2F82C@KQCm$t&cx3@ABaJN%7OAH^HmHGmVnc!Ku8HvxrKXWa|cPa_Ee)<`Tb1MWAOQY%15hK z`)9J-`8QD(|DbeTVM`g5tJO}o+f8tfT7v^qpSwX2tVJ`ADz3h)XNaI1eAs`2Cr`J zBCA$kY*cHXRw{dp1>&Xs%zmrU__7N%k@HAiuH1}>ffs%xMl9U z*Xuzqv3sWm^>w)rtqHl)V{-XPr`I z$KtG0t=D^_E8ktY?c_(V*Nb*B^SDy0-#u-aW3AdWvM_`_Jp&!5&`~_F1byY2 z5tl%tz%aw-#&QJ{7NUQN#;z0rlqHb$S&-Hcb`2-%k=yn=^>1FcuTNF#og3HFubx~8 zAG&mU=DdjsR;>aZOZb~zD5IeX!sHj{BPVi`mKwzQ;uK^S{sld)G8iWrpjAA|GeduA_3}_t5v_(>)lhF;7!eb z?e2DWwtCG+w$%2en(19SHno5(O5hz^gibPKehibDy!A`J2hfPJ`yD~{{7*&O7#jW$ z+6xjYCU8hAkV+t1i!f0S<@y57G`D7-U&`E-9k~<2TDiI@zVh&irT^mCv6Eir2PnCv#H+G<^wQp9dm4B~5UqF{XwY0Ugr+s~`T>aNA zt$yXgY)g_h;I;)2K{z(FHmKSbq}H|dcKboARAv^pI+a>w_g-=bZ+iN(9R$H{HvMQ% zb?&RE?xo{X$B=Y-@D`3k$0;&Df>8!!*dAr)y@6=>8jS$t01`Rc0hUJMHqsdYf^5t~ z1syb+Ggv5JJ^ADfI|(DJy}E-J+;U2nr*}SHpE`}C-GNn|L9i7djgvuMAOiqPz;0H+ zZ3)UWj3K{mU~mP3TNPmR+R15NR)j&J?V& z3u6dX*MT(J+30jHTO}uPgjly^r9jbV@R zu=DJGK7Xpw_%EF}{_P91Q*x;M2+EIO41u%;6(G==Lbc>V8f|a%f^$x}ylr#duGH)P zy}$|HasfoI*9+6E@rCVp>$|3eIWgCmLfQ-AOiv?=5@bOFqiEO-Oy}l(&>V84P24Cz z`t4-Rhzl^27GmHcG)x>qlpA2ug0QMsY#O)Wte<)2rW^W^y#B_D{ExF!TOT^v;wQN& zfwX{KZz1+0XaLT_6kuCOgBXSZFK+B0sZ^e?RjQ8=nJ=bBJ$++ybC20?)muOBB;jvg zm}$Bog7h4uew4dGGkhy)1JVXo)yqNPxe!Ksn|^r4a!Zi_^$Je#u3CaSs(@(+K@i#X zKfSuM{Q)QLT{tthfHX)^T3kdHWk|abR7$y$RnZJ;kzA9u^M<)tf6Ty;C`gkO8=n*GUUb>;qZ$GkHZuOit^P(8H>ccy_P zPLRbZl+pOs(gu=R{j1eV^^+RrQ3E`^w6ruy_;|fs{k5ja{^f<)DG4HQ$41%>kOlFe zVfCnx(x6PkDtj=-pkiB)O4Zlfod>N_87`MSyX1C!-+$NU5$?DIs@)F#n71BX4ZByY zpnKu;^em#bkMhamP(cm?ozZaQtNeZe{l`X6Y-(r;2Fg|kxCqKQ1(brx5?IrVFmV9s zHgI;of6jXB#p~xuc=%}+FKQ7%{K0kYBE>fp04yoQyM_j0pVeT0mDRCDx|Kc>=Vv-#6r8vo|SnU>Y} z`I#R>1-DjyUr1|^(vYQs6O>&@t(}cd_mWdCx4Gb3wOZ|6;RJ^+g7keqGZDOIb%J3V)BSE^uxAP7*Ie)L-_8y}!fc>eOSImCW~%BclpeulUmLnSo0VMM=W z{NVpdo{$ayr%~52N+QQB1i{1+Oyt9=&%me*UTGRv7Olpq=E?`3UvsiomaWHM-LgMh zp6y+}p~J;5`{h5aluMr>t-q*RSEU#o3n40SL!N+6|HU(Y`+a zb`>F{G>kH^%kF>^2%~Fjo!$eM=R$Jcn{wUWZV-fbLoaVe?phgiuh;8Z)yAJayV-t# zcKq{~=VuYMLsZWlM;4?=dJ(juappg15eg;izRB+^e3TRxIfOyJpg2Am0%ZwI8o{19 z4i)=w%QHCLB)3sozx3>qovpX67neHrV{Iv3>V@{_In1+~v6pV%yxGBo>222QztAG} zKVO`i;zk(=*D8E|GN$}nDSf}!NEsNVhj{|WhSa*W(eB=7dF4#l)@GeGciVCM9dQSD zwGKWAf}m7yJofB%`+TbcR&f}E5RXQ>6f8e!ssaD(U`OS|0f+4dMXW8oN>S}2>K)xhj4KYr87zt!ucbpZkoT5Hri7h=E(N`SDxzEnoO3u+voJ{r zZad)qE=peDzP!oO8|(ISB~mwTuCH%~VYuf4H=E6$ZY1%)eRy%+GfF{}J*0k~;WL`{ zRqCA-1Cll%rC?QD=rrd9Qfp_+4^F#fuWd2beJ7GdZ_7%UAP9o`{M@6D-(3GT9)#!a zTbx7G@lic950ffnVUpJq^c6mG1&8xN#eM!pfG{X9?f)4?0TCEu5MBx8)8}DSnzxWk zyLkHM>*iPg-_q9~f7$(0!SGBkOI8Zyk4dZ1_@ygE|LYH2Ip;6}nCl?k=_2WdAiW=G zhtgl#F!|hm?;HHqB08`tMV`R1V1ue_?d}7X=aOWsSGSu%J8Xw|n?UfkErsfKyFIT_ zd-U1u_P6n_fA0R%$C3CUs%ICVqBKvQ^lvNPU#wGHox$T`X!o;&7|}sLGn`Ku6F6fK zu8Z=?(-4jgN@56R@bcAf;n%<9J^77SN}qOZ^A!d2(%Rad8b0e-v-vN+J=4GVgO^To z;X2TqA@2By+kSCh!W@)fIbh$+sDCr>$t^)YSwtvX7Npjt^=|jFRVt;z#%`rr-F>^F zgST}pTo{IarBQ$ErFQp0-u2Jjw=j#S3};A!1W6bVZaPiH!5Iz)!5z5+V~V+h+!9nh2SRCgv+G~7OI~aXu~uiz-rGS8 z-}c3jVHoiX&I zjwgecL5) z+lCGT5NUk&o8O?{f8PGaOUva?Nv+5?$y?^>stzZ6o z-<$l#y6;Ti-1c5gEcwST1if0k)xL7&_$<;Ofj8HH)&^;mz>wJ&1aH;%A0++gz%DpJ z)v-|$%vh$er_oyt459J9~8at?3GgsNWuiQ z({p(#c90A?0Wc1P3762%0?Lw$)(6f&dNy<~1n0n?KKV`j;T!hXS3B+_j-)SZ#-7_( z`9E3z7eDn8^NXiT<>unF{~b>)NxtjLH=K(1=@)jpUMWr=y8q-n{M{a$**a9Jkc3Ij zN7&FE(B%2;bINZ;2E?%J!DxafCAe0l&5nPcT`DKslAThy-0AgtcVz@*=n5Le|~Wm{*DjXtRjgLs7wuRML+n`TL%bx&;NkOgRkpzg2EEiTnAib z?nc+YE%C-;Lwju3)~Eyel0sy4`NCK0WpM=hwF% zaFXo&+1VD7UWn4d)F4kl{dO~>7nU*DwGw7NcpkFsLU#g$afaXiizn%EEuYpX|B>YM zsOj>jZfxwG*!epjum9tp`=0V|+;{qcG7N#)E|~3t$iTMBI5l0jX6>cRuWyS=r)Ry! zT^B+Dt-9Q?ruo@MpB!fz`L3D7d4kULhG@dc+a{`CK-Rk-mq*v0i$(pSkbk9Fksy>e+edC`A^Ird@NwCrb`yK6nno zv7tHvIA{3Xzj_K!aO>-yQ~orE`GNwTxxRjVPbJUa{_ff@{nW$tUwQESLzYn*qS`>V zy^5^84q2*!IEO79oSJRQDz0C8X3fr$*nTbJ`ctoW!-`DO51*TDAq-;Jtt!$WMwY4m z+2K~|J`4~>If2#&725{WS#`bByJC5z)Rv;_dfpuumAtDxal$b4r{?D#dv0s{A=!;i zUtXNcIl-BE=rBPR#egw|&v($ee6kq%K7N2*GqhBclr49tIoC~?JW}c z^itQquxQE4t!f!jl0sBm#Jvc5WMS;+ZE+5gJIfOkl|@w#TIH62YvpeA{ELoPj%{IY zwghY6i92}Ln<90)-Ck>M?y+Y#wjc76^z@~L8HC-S06~h>k03o8t;;9j%{F1WAvovw z-LE`}uK{mVtJOayqYw1?m(Nc9*pFST{N^X_e@JFw0DEROU*Nj20@4Z~$}eyn!fG9Z zP9nHo9SspxUfXQXto7Utm)XnktiO1DW&MZG)~hu~2xI^>1F|U1=QaTBStN@?x&aK= z#f%a|tu^YN3$8M6qvv0AN=}D6&U#ab;H`q-T?YV=gP=Cm`ur>H?xL5b51gH8A?`&e zAD@C#E2DOL9>TR?dLcS~gn#?Br}31OPgN?lKQfB`j0e4^u5WDYspL6-qVeDV@afWT z{?G?LPy!LGdL7Zq3Zj)2kjnCc60oAiFolUjSj~B;Bu1lDM^$alzPg>(JF(TVNIe%j z&X=yQZv4QhMzv-!4)Sa$rJ&MmIEnQZfBWq?!${66=LDICRdNPX$u1*rs~239UMYp` z`EKd0ae{jS06;#0)SR39;y0H!KI%mA>HAL3Bi!l2X;z@36qG110KfCqr|5ah`9{4` z{$tYW3x?yFYwPQKeE!08?Vtaxh3fzEcR%v54P#)p8t7fUfpB#VMk(}Hh7K!@2|9^k zk`z{J0VY*gs83^Oq{$3Ut+c9Yg-#zfBV98QzmH!S@n>H35?Ri+yL7* zdVR~X-&Yu&7fj@qAm;?qnDSbud&McasUu~#;W+-AO%~lV008o2QEP7Y3(sxse%R{x zr#^6Y5ow&lwk=Sa;s5^Yr|_(0JzcMq|JW$?mDHFQ3bQ{^z*+kze{-hxOF#3S45H+Ye)9`_x&_MQ?2f|KaJE@pZwkRVuYVB(1)X8uP;P^4=xd z^?K{a|3}Aq!*J%{e4~j7Ft} z_R^;J)ONzTv|n>PZxf7tc_)ZJcD7kPiA;fc4s@oVl59LfSjb^AH3zwffjg*rFeWc5 z;aQ&(cpDsq}@b3=iyh?q7Ib z=@A(9IDG%vtGl~Dn#1D)5`a^(+-4MwVr^sqLu zU!SNIpc6vUOTe5elyP8X7J}Wx`NmF&wrlmUXJ6^}~&l2UX-MvM9MD zGI*!buQnd#1TKTS+Ub74DY>yN#4S0&JqrK;h@vQIPEGw87vf1t=6VKPFWCNs@*Dr; zd#m0r|NZa%Fp^FHcfN_87p}vCcX@}oxo zZ$JNuk0RXaz?*M@afU32VAsp=rm7IGTNIn->-7xfKp2?)zVg$2Qwc*F5u#lM5@i_6 zg$z^N+}v@NjEP*wzFw|W)>cDiEu1ttVI7qBZHl3s*HtqkJ_rL8!+=y>H&NU_d2?@n@pdmaFV^s4~Iu4!wg^|KEo z*>C>D{G2=Wz*+bk?Yz5}C1I6a*tIh3S{ZJ$ipt^)tV#)4m_fxFrdYP76<{(zCxA+T zEC$jDNCJkaONavnZ*dxFrr?0^&GoI4EP2a<=;m&>yEZ*D{rq;j_k*(~XI@GHI5JNT zX>*_jxRsy3!7U413V<=_G|S0h*@cs7urMj@rpYDS@xrp} z?0V&LG|CCyD*%`jKHF;jv+v0Cum4!3=5n1c$!?sOf#e)4Rp8oy7cnz825At(ovx#D zVis{bLeh=%5uX7n&9^Kb*%BaA25FLk*F0#?h9?DHU)m^dOVM?>b)8%C`r6uB$F0}4 zlAiw))2_=%2pAAloZa%i2?u434*~#VfRF-Cy#iUXVN!*`?Qr_bs11P;V7WGQlpynC zR2YY=l(&_}-zMEs&lY^ATCaCIolXM0N4sLrwpu@G#qrMsj=S>7B({w)g1+@4i*MM{aRGCejR>qKOX>=cM>rS&`P1Rx&vo% z8p;sPHLG~N8=j@AJmEM_9YC*CDt&HaD|+IjQXjQIuu3kHZUk-AfxqD(Z1gMMCSPZq z!K!$Wo&_DI;DTez53!r1Ah1Aip7lRQL`8ewd@7Yo0VpMU-S+o18;!ptA`d_pfPPN` zz}1y6W_9Opyu$ec5ufSbU|OU2J1iL{M$&$EW$Pnfy0-FD|KPhHoIcAql;9|zm_hrM zr946ysXppg`xq4MWXK@c?SaiT5G5H-Ox0m;?jF;X>Is{%7J#*-rKPAFf&G*^`F#KJ9_1rnmS&Y*s33u6z}59_M3%i&s@A_Ir1d07FB9>UF_zCs zP1yE&{oI1)MXuQ?>k_$j0F6ZkGbbd!bRS{fQy{6-D$CfL-7BHRt@6 zhp}y2udJ+O$Xg}Rd(r|FqE=Q`4z{s-WFJArJKlsiE9XJ+5)dTgyVJ{-& zATU7DQ!w9BWe~P~u$ekcn&Eh*#8%_@v{$JeE2CW5#HPR0UD}zd)c&j!Cl|_6Lb!I` zbu3CSI?no|d~y#QU?m7(Qh-|$CP|SQgP6;$dbReKM(MAl&|h0xTH1N*moJyQblbT1 zhA^g|*o?EQah!q-LD*JN-$w(JZ-7A)Nkc`4Q6faWFmEcMG^Q#ZkfzgMnB#U4A&}A# zwl#j2iGAeuZh?xMcI1_(nISOFBLE$p2_JpxbBA)I004k2aALf_?obaZeDg#@VFe0H zpbMaA5VC$*IA^e=KvQyuG|dvwnx3Av0bqG~c{>x$*+^aHc>! z$wa1b=hOng(F4F>O&JmLuAa!ly?zr(!-|nV!T~e^N^3;D2qZWR5o(TY8LeiR5L2VK zx3|jPf7tXx%{b5RsN&k=hrY#Wy``VQ3^BIfZ8_eW96bQ=A=w-33wO}>`;#1?PYTm0 zhT#la6Uw%P(YgtsUgF#vIl@z2ZC=i_&ci`lj@%4uuZ)x>yeo3_0DyiS0LGL)hyZBB z9UyNp-?s>9oEG7sfo(|`rE1KeMu3OnT5fJ``r5L-(n}I>!NDyF#&b8YUyT25DZVd9 z4giDZll_rD00I@04Wpx*{P-8=mxd#s1})|LH<#$&cuE6MnTAR=3kf@Y|WKJE8y>DGI@Gnz0XpF)lzO;4lVRmcf7(pEouc zT_(Vb<9Gmojg5_U#rZRFra&t1BF^?(_54=O_t3w3e>s`}=(~Uc6&!H^qaeU|c&H6Q zD-GiL?PzUdYy&ER$g8NzSiDDEel6B{^J6YJnBaFjlHbI7lRK8934nc)KYdUPKu{!t z#=t-Xm8oLN4Un91GVlbGPFhOG((1M?p9r)z{R(7$7xDpbha5Qo49q_UQ|QBLlYYpD zg8h$>KvVp$GYw)4Chrj|4CxX9o}Jla0AQB&t(a&P3_y`~-cR1`*|;MI0AMusr$L9Z z;pm^p3ye4lLjA=R%hwW}FKJ_(=d*h6_K=^DBMAW0CxOHUN*x{L z!%Sd*MsTQn({~X?t+_VZV$4`t>pd*bFj|RHH9!TE{adT=l{dX0orB#G{s#mm=tf?v`;2~-cJPG zMd=52!}x#1i24L@Oi%50yQ3I;Lls}j1s}ZQc0T{@ zO#hC`kpsYBw2%1mand(Z7g!{M`UxT$`8*AYz%37_f#p;ewX2L3*hgxF|s#xll8bH(?)e;8+CE$w$D$)9*r=lOmV@OBBA zK|F70eB}3Ye1j?A5ugk<&V~Ro1aM}EnBrXRTb;`t>uO>Q`uDV9;@-OC_tx3GLo%-P z{Zqc9iJ;N>fMMJKY|rXt05HaEC0R}a`W6KH)N;OClR`%h0E1*te+qac z{X023IB*61+yMrWW3FiiaLzR|&<9oMT9&n)0y}-TF*+acZX|vt<;Vd5z|hQ(hJs@* zU_$95Ti#u+crh2v7-^AZ`?>%tw7MXKC;-RVU{UvhN@v(7rhJ>^XaazS*}g&SkI~qF z#Qaap7WUJ+`4<5UTpPx8a)1lC(YX<3M(^r8VeqC%;MnorW;t>I7)$72v<7gr{%AhLagcjO@=ui8`^KSQuzH;@ZAzqD4)!rFTkF-e~+o`z_! z4q$AuXh0V8&od$Nj-a_l4C5jNr!}*XBt{-Xl-Enn=yFDLKjVy@3r2u6L zYux~LsqzXl>~(0pv9hvqpfaQ@|Dp|ib+_ZgvL$qs=6#C?{-)pMYiNaF@}7n|Nx=YE zTwrs%i^#FNwr#&)lv-ho9r`x&_hzyB+bCfeuAy3uR+iU)&{GS{9-n@BpPuJA9D(`k#`nx68{QUfnh$Q)h zr}PIMDVl~zYV`Fm!v04i1VfZ@whhO-;koYDImcfIN%HF2+S(4@M5m60g@uQ*F#I{8 z^uq#F2V;`4+mr|qL2E16Fnu}F04PKtZ#nm-+#%i_@% literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/dgse_logo_32.png b/pe/qt/base/images/dgse_logo_32.png new file mode 100755 index 0000000000000000000000000000000000000000..29d53d2d12fdd9932032c2e60195edd76d7b5f63 GIT binary patch literal 1828 zcmV+<2iy3GP)c$e2xSyP&*6Mde7Eb{o!Olk-rYNg1oB}g zMT|VR>zUo1XP$ZHeV-x582B;^kA9?(Ffy_qX2FCSKxqwxfEBe+LIR7DxKCk&0mJdX z=+7`h!l-3FMJWZvVlOQ2UyNVCL606e3b);I3kWGe1BI)4$j#roy8G$IB+E8^1#eGH zzqj8D?nNN=0@06jbNc;;2&fl^;WXth7|VVR=E0=nIIwZkCV1r0EiDG3!Z-(xv-WX5 zYgTlw|7O2+!=cI))Tb@T6R7}s=hyZy4Ak_)b)o-9T50K;5mC=kX zMm0TS2A5mT2_VupLvHLeKu6v{X-NnJ-S$_d(!}P>OECHc1Bg1XhuZ+j4gw4EKCCJy zR)$JvDPvO|mId_x4FK6iQK6JobTvW5LJT5FvHGy)8hdGHnp_<)dZ;IrJS`r44c{U` zi#c*>0HRpXmDWqT(q^F8MV77TO|6!G5UY#e6NUz`yrjwMa%%k)5&vA8?|=-Dwu*JX z(duhP<24MIoln>o1Q6gHmiL6WZQIa0vg59S{Wo;Tb@kc|gcA380Ie7!8}O60U3VTA z?vcgitY5NHK#;Cv(=U}me@~C}J~Bc)tT8g0`2vW-?B7T2S6=+v$zR!lvb{c2pDLAKGSaT)oyXK0-x`Vv=+Sjk`g)D|1jZ9IcS)K7WOeQnPeeYo={NI|GyEy`g zmM)3Jy|K@GYRj(bkj(ahtlMyETw{f}HmU^b$0uR)w+nZ_Q=J~otxS)u9wR2^}Al>%$@y4DvaE_?%$U7~lV0 z@rPZf3U~JveG{d0!*Bjo)m)ka7GMoAD&(oaTZDZ!di3plZIF>dKu=Tl~}X6oW8r z5b~T%Tr_AzAVxhoyrVK{b0F96^W>d37jm0_ykOyzN8cKQS|NbG<;Cu`%errU{oTpo z6}ik7A9d2^;q-Da$Adx#3{GO2!M=^yEHBEyY{66vqf$L_K!iV7qSZa?dO9DU6hu3s z3&!5#@b>E`VYz4Dq&XX#qI9oqTX3dBiakKm#)wKG7Y6rguesF_!eKsPljBDi!ge^{ z{rpE0*Ge$<50@6VmBZBPYSlrFH=vlZ*G|kdU+7QpHE1H*$-axb}dO9nvNX*>cTFv$6_N!5{?%Z7yh*HC(Sj%Y3jA`>Gc zE8k`z@{HJ=z?6I>f!OW7ED$Rutv?^A3`FID>1hT0^0c2NwYZ4BHs?#XG_n`@GpO0^qtXNGVUBSS~tytnbV?zi+1PRSq7W zTs*AQ9QR#ehljCCj08QrYa-F$Gcz-=`^{arhU%#l17_%h2U0t^5%t1P6U Sm55~k00003552EP)Nkl$NWTs#~O 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 VY%2RVTYUfk002ovPDHLkV1oUj+W`Oo literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/gear.png b/pe/qt/base/images/gear.png new file mode 100755 index 0000000000000000000000000000000000000000..41ff2dd6d78b9827ab401185d625f35184687699 GIT binary patch literal 394 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&kwj^(N7l!{JxM1({$v_d#0*}aI z1_o|n5N2eUHAey{$X?><>&kwWhn-hLtURzvhk=1n+|$J|MB{w!#e-f~3I*6doPYCA zR5WthRol+0h;3G_;z!LtNbZuns_b!IL1bS;(uI}T0&liP-(8ZtU_&XVbw`}jtz0XM zq!#AGH9u?X57(scyW_h1l~P=1MeL7wztvg%{u?`4bxy}BwrGp#p)m@*T*{pZ$N2B8h+e&7oveb317H3V|%s+_Ln#?%p zU9;-$wvF+^YCmo!?As)JX4;fI(~`WMlkDyvOYc$2;i%;)=nLJWP$hV7LpwjO-bahe z28VRy9xl+cQ2d`#)xlnIf0cGlqGfRNdew!Y70YaH>?{2+eRlQvhZ65UJUPa8zOYGK m=FuVBjZOViwf|49XOukW9JuC4h#N4}7(8A5T-G@yGywqT51()V literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/power_marker32.png b/pe/qt/base/images/power_marker32.png new file mode 100755 index 0000000000000000000000000000000000000000..48129026bc3e9d827169bb5e8d4c7160690c5444 GIT binary patch literal 2132 zcmV-a2&?yrP)Oc*tiKUB_?Sbf!w6DHHj1|DpjEhRip|PB~n$T zl~P4XTA`*&(@;u7fCi_$48)jJEx0(?u`%`rWBghcjJ#}q)?ecHg|Xa z%zSs|+;hKkzVA|oVc@Hb-riocw6uWddDPU@U}R(jBg3OuS-B!>-D3?xR%Vv^|K_s` z-86Iqkw5@KAV6DPj)xeA;b0=AZa<%o(}pX3GPitrFp)^?TD*9170a^VI1ZjXFG7(Z zP7LhIn+{Dqwe2S__`Wm%O_tF6?rvP#yJyK0+kf1xXtYg^MhuFgAYSw1`oCJ}(Z`>D zX6K&w4_G;C&F0OU>uyaXe3Qtk-Y?XA=h@i37+eqlMSccFBh#B(Js+935A_d2 z4u>gCmN85KTnLIcx7Dxz$Rwn1piAU48DQQC)gs`0_-&kg?*fXnwL_aXH~CWnkffPh z?kxA$LxFKj`+U&iafCDt-SIGVuXo?7va&fd?nbO1ANX3ZzIZ$O!Uxg%eh)URcoCPG zep*)Kd*%>*x6jioO{HaK)Wn213`K!K)0i`K%S@aGT5PuRSU3WMtXw8HU95o&8}|=yBy9AZ`)8#YXScfS zD=eGPdq~E*2ezW9qyVva)O-zpKL8fq0-^#V5v$O3q>0g3QUDCuaF3NWa$W9HZ+x_; zYxwH(j}a9Pja)hV$-WDMu3C-WL=R54oq`|;lshebfVcChGr-7+I$0(wk46a|11wR1 zg|btONYyom40&nonvcxb^78Um`h0y&IN_T0H2re)jyJUtt*Q^Fi<3{Ci?F)J##Na&q*XCGZwTr8NeRn^r8JI6*=eyP zw?>!~-1Teg-I64kCIHbgi3@5w&L0~Zf8wWQH&S9i=ekh(J_;u@cVKe#;msTps&WWisM$>t#sT185z46Wxcyc(`EW#FHDoJ(W@pTUXi*eXQ$g+4=bO2NacFeUUt!Nouh$q0W}*><4GsB><&cABNTM3aWX-25FslnV zG4?8MwB}&Tw_buPlgIs-&|Mkucsxc%niC??zbh-fIx-hUM4Si1pudo_b*j*LD!BoG zD2vWQX&wAWa!~)!_n{ghg25mZNlv3u^#M}_8u>VJL&o7ws|fgKkj`kxrY$G4t;(wd zig=&__|%M@3{L#vjW_m}Ivp;JPX}86OS<#oq755xT>nEj@H&zpPlE8ij^xdPhtlyUq#j;OVQBipu4i7N=OX= zaYKj8Q4T_aP8gwKvCJkCOS>X?y|I`R_1kgFmoMK#jOP)R)C+}NKYDH}24~J;SMx7$ z`0(Lc0-!5}Vnp27^x+IwRU$Hvc_D%u2^}4A8Fq?0=AfxtNk~`o6!$a%p*AJw;1!l(Cp;m`3_aTxH5Yjb7b$z&;<-3eq3QVTq&}6qEUl>QJ zwH{$-A3p4Q9W(9uSX8GK8AyJF?Ra61J8pk@{##~1Mwluy7E6)-oX;jMP*qj+r^~r?DZ*0iRpsY@=C_Xti z;br-bI~q4qAt$GA+8j~|rrQ@|P1R~#8S*88MI%u=P1C6XFbU4h$eLX3bP*9ykI1UT zhz3sQvs~2lh%XLBm0#HL^Iv7=Wak>0ya(MqJ@EDSQyVul3TCUGx(^T)kVXtH+VBWs z92~_Ragi0txbqxQQ(Nn4YHISBrTEb`Ymk|l3Et{J_7Zrzx~x)>XPE(U+(?e-iOUQs@W4TEc)k8;HB5Lq3!g?$G;)~3LNAICnWuB zKEs8y1!AsTxe_ZbF2&5u3@C%agtZ-iIr19(Z8qO;b~jy0-3OA5DFs1Oy zk`Pq%hfcz0*^NVO?;v+-J$}%*1v;%!GO^|q88DKqxUNW9gOW73L}y3UWcnG<$?3?D zm1M0jbD%KoYjh6F=7`_#FA>55Z?oD+yqsjkWXGB26v?T1^j-k|E5HDx{l9{o3w;Ct0000< KMNUMnLSTa2=m_or literal 0 HcmV?d00001 diff --git a/pe/qt/base/images/preferences32.png b/pe/qt/base/images/preferences32.png new file mode 100755 index 0000000000000000000000000000000000000000..bdbc4051bed43e22ea6057ac4844866396219458 GIT binary patch literal 2899 zcmWlbdpy(oAIHDD4_~Yl8$hqQgSWp0I3ayzVZ>Qtvr z*5#Db#Wtf0GC!#lHJeLH<<4Z=_qYD|eBO`GAD_?X@p^xrulMJj9~u&1Y-ni+0Dy5& zpnsS?Qhp9RRzDxj%9_##bdqndF96g%r(+5}>(`|8jp6A#QWMjU#~eujeB)DN6DUC` zF-Zww2{G}RhhHRk1AyURkiYNF-`>Y+V3;9eA|W6a9UDg(La25yHNS5jI)b?ENH7A% z4^Vsr!T(dra!CLZ@1I1Yxju=HP4q#=Z^NkUC~Hx%F*N5yj4GDmgKnyW)J6mbN_C9+ zHUCoig$oD@#Iz;6Y?rz!+NJr^&*TcQZm;+Jr0zq{Y^@Lbw~78|#Rr&kTSBv~^J-D! zF66Qs$niy#44Ex#mS=%Fyow-(43KDo5kah-omCt^dLY0&nKaF)i(apt|Ne62_hK8x z^#;yt#Nc3I(q4Iu&kx;n>%~0^G3UgXw&y`4tkXUMyN;vsM)^~y+YW?OMC_V!sh^)8 z$UFwx_+&!{w^4@;2}gm$s|kF2y}kj6uHU0w>gd>-l-%E>nUzOBrKj%Ic^%XAiQTT6 zxZ?2zce=gisMVgCfuH{;^V&})R~Mb$+Qwi!J~8?EThH56b~enOme-0cK@gUfSrLg; zJ3BjSUST2MdaO^15`2Dj)y+F|0zFi+kK$v{GzqD#m;jeNW_fLFq)x#ho!VGmQAGA= z?CROBh1*`pB*SxZ;gthZjODJy0W(NV-BYtW>s(;+~lZ7-EhaTQ|i$Q&ok#eAad;tuzMy_~hn^ zj6(w}Z|#E-_b$#U#NSwTS1w9wg!AI%=Hl8mAuU5SfF1)AwejZg z;w2vwyKf(}mdR@w#I*;zn?%P(%tfg0y$$Wulvtxz{Qm=GEk77Hwo60}});k$AVIvKa<00jDogh60Ws|;k%W>ZP!L=vfit!?QW z9Bdv_kH-nB=&7{qA5;0#r!Cde&dyTb9a{=xbecKVj=fK^P1>YeS@(rP3TnlQ6&}MJ zSZC}QPn2?>LRUod#=)l#{yZKCoMCw3y!cgn0GTTD6{*p01L?ijds0M1BtL^>KC+&L*pQY+ zr5;}6HZ?QDGv2-XHPZ5C>zL+UoZ!Hoc5XBCT}pGoVopbMI6v?+7>7uyA)y?l=g~OiU0B#1@WHmxc#nVC zw2rW~c?ryC-0VkxEQexJj@rq3t{x}qbh_rTY3<4ZXZ7MrBMDYt8QTzIFXwhGTef3Q&U#?0t{GzCJO6DEW;mCiM%9U390wTlWI zFj(z2fr8P1n(XHYTx=-YCE!g9Xeb}Jk&Z1#>8th-v`y%d`^$uKy&alqKo|f@Mq~a% z#^W6p916dir_?=(6sm~MCQc;o1#?+Nu$#mn^j(*A6j_7H`a0qjh=_Ns z-s3g`1g6M+%piQLPNUNr)#Ec~zprsOPUD%GKR+V$zDL4rPWuW<)}myZUrvI1Txv0e z9C!v3RRl@qzD-JPfxlO<5id2m&(>WCa^Dd_Ma3^9b6Y(CMb*5 z*pZvj%9384*;NGf*!%7Q6EMcK}43*}Jd!4t5a_nawfNOuKCo#iELk%%&eUyU|g%~FK zuz#R~V5WkvN3c zAvP-60BN*5bd1;S%`D9EZ<4;7Qq0OhPD2BZ{z9$BK%Ptimw(QxyWKr8bZ6LYjJ=V* zLGp8|oGR1+XGDru=L5?tHXpxUBRnZclnPQUg8|BnrE3APG$8z~IZp3dpf6 zCuc!-=h+1H)cCRv+sEK2gB_!u8pL$~p4iY$B^P$Y6VU5URpE9n0ebRC8=19tktd%U zZH1!1%})CzODoR!ZuMJqO>jn8d=HJXxv>D{MRmWhb0^8)e~wrW1+_MI7Q}rk$jJZ@ zO}z8ij}0g0EyGtnLH%GFhR=vmPg^;HEf-yokv?+npcwG*_hDzt(!Mn96?i>Zjoi>j z_ZTrSFrY 0: + msg = "Are you sure you want to start a new duplicate scan?" + if not self._confirm(title, msg): + return + try: + self.app.start_scanning() + except NoScannableFileError: + msg = "The selected directories contain no scannable file." + QMessageBox.warning(self, title, msg) + self.app.show_directories() + except AllFilesAreRefError: + msg = "You cannot make a duplicate scan with only reference directories." + QMessageBox.warning(self, title, msg) + + def showHelpTriggered(self): + self.app.show_help() + + #--- Events + def application_will_terminate(self): + self._save_columns() + + def columnToggled(self, action): + colid = action.column_index + if colid == -1: + self.app.prefs.reset_columns() + self._load_columns() + else: + h = self.resultsView.header() + h.setSectionHidden(colid, not h.isSectionHidden(colid)) + self._update_column_actions_status() + + def dupeMarkingChanged(self): + self._redraw_results() + self._update_status_line() + + def resultsChanged(self): + self.resultsView.model().reset() + + def resultsReset(self): + self.resultsView.expandAll() + self._update_status_line() + + def selectionChanged(self, selected, deselected): + index = self.resultsView.selectionModel().currentIndex() + dupe = index.internalPointer().dupe if index.isValid() else None + self.app.select_duplicate(dupe) + diff --git a/pe/qt/base/main_window.ui b/pe/qt/base/main_window.ui new file mode 100644 index 00000000..754f265c --- /dev/null +++ b/pe/qt/base/main_window.ui @@ -0,0 +1,911 @@ + + + MainWindow + + + + 0 + 0 + 630 + 514 + + + + dupeGuru + + + + + 0 + + + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + true + + + false + + + true + + + false + + + false + + + + + + + + + 0 + 0 + 630 + 22 + + + + + Columns + + + + + Actions + + + + + + + + + + + + + + + + + + + + Mark + + + + + + + + + Modes + + + + + + + Windows + + + + + + + + Help + + + + + + + + + File + + + + + + + + + + + + + + + + + + toolBar + + + false + + + Qt::ToolButtonTextUnderIcon + + + false + + + TopToolBarArea + + + false + + + + + + + + + + + + true + + + + + + :/logo_pe:/logo_pe + + + Start Scan + + + Start scanning for duplicates + + + Ctrl+S + + + + + + :/folder:/folder + + + Directories + + + Ctrl+4 + + + + + + :/details:/details + + + Details + + + Ctrl+3 + + + + + + :/actions:/actions + + + Actions + + + + + + :/preferences:/preferences + + + Preferences + + + Ctrl+5 + + + + + true + + + + :/delta:/delta + + + Delta Values + + + Ctrl+2 + + + + + true + + + + :/power_marker:/power_marker + + + Power Marker + + + Ctrl+1 + + + + + Send Marked to Recycle Bin + + + Ctrl+D + + + + + Move Marked to... + + + Ctrl+M + + + + + Copy Marked to... + + + Ctrl+Shift+M + + + + + Remove Marked from Results + + + Ctrl+R + + + + + Remove Selected from Results + + + Ctrl+Del + + + + + Add Selected to Ignore List + + + Ctrl+Shift+Del + + + + + Make Selected Reference + + + Ctrl+Space + + + + + Open Selected with Default Application + + + Ctrl+O + + + + + Open Containing Folder of Selected + + + Ctrl+Shift+O + + + + + Rename Selected + + + F2 + + + + + Mark All + + + Ctrl+A + + + + + Mark None + + + Ctrl+Shift+A + + + + + Invert Marking + + + Ctrl+Alt+A + + + + + Mark Selected + + + + + Clear Ignore List + + + + + Quit + + + Ctrl+Q + + + + + Apply Filter + + + Ctrl+F + + + + + Cancel Filter + + + Ctrl+Shift+F + + + + + dupeGuru Help + + + F1 + + + + + About dupeGuru + + + + + Register dupeGuru + + + + + Check for Update + + + + + + ResultsView + QTreeView +

results_model
+ + + + + + + + actionDirectories + triggered() + MainWindow + directoriesTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionActions + triggered() + MainWindow + actionsTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionCopyMarked + triggered() + MainWindow + copyTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionDeleteMarked + triggered() + MainWindow + deleteTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionDelta + triggered() + MainWindow + deltaTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionDetails + triggered() + MainWindow + detailsTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionIgnoreSelected + triggered() + MainWindow + addToIgnoreListTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMakeSelectedReference + triggered() + MainWindow + makeReferenceTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMoveMarked + triggered() + MainWindow + moveTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionOpenSelected + triggered() + MainWindow + openTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionPowerMarker + triggered() + MainWindow + powerMarkerTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionPreferences + triggered() + MainWindow + preferencesTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRemoveMarked + triggered() + MainWindow + removeMarkedTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRemoveSelected + triggered() + MainWindow + removeSelectedTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRevealSelected + triggered() + MainWindow + revealTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRenameSelected + triggered() + MainWindow + renameTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionScan + triggered() + MainWindow + scanTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionClearIgnoreList + triggered() + MainWindow + clearIgnoreListTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMarkAll + triggered() + MainWindow + markAllTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMarkNone + triggered() + MainWindow + markNoneTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionMarkSelected + triggered() + MainWindow + markSelectedTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionInvertMarking + triggered() + MainWindow + markInvertTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionApplyFilter + triggered() + MainWindow + applyFilterTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionCancelFilter + triggered() + MainWindow + cancelFilterTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionShowHelp + triggered() + MainWindow + showHelpTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionAbout + triggered() + MainWindow + aboutTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + actionRegister + triggered() + MainWindow + registerTrigerred() + + + -1 + -1 + + + 314 + 256 + + + + + actionCheckForUpdate + triggered() + MainWindow + checkForUpdateTriggered() + + + -1 + -1 + + + 314 + 256 + + + + + + directoriesTriggered() + scanTriggered() + actionsTriggered() + detailsTriggered() + preferencesTriggered() + deltaTriggered() + powerMarkerTriggered() + deleteTriggered() + moveTriggered() + copyTriggered() + removeMarkedTriggered() + removeSelectedTriggered() + addToIgnoreListTriggered() + makeReferenceTriggered() + openTriggered() + revealTriggered() + renameTriggered() + clearIgnoreListTriggered() + clearPictureCacheTriggered() + markAllTriggered() + markNoneTriggered() + markInvertTriggered() + markSelectedTriggered() + applyFilterTriggered() + cancelFilterTriggered() + showHelpTriggered() + aboutTriggered() + registerTrigerred() + checkForUpdateTriggered() + + diff --git a/pe/qt/base/preferences.py b/pe/qt/base/preferences.py new file mode 100644 index 00000000..64ad64d5 --- /dev/null +++ b/pe/qt/base/preferences.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# Unit Name: preferences +# Created By: Virgil Dupras +# Created On: 2009-05-03 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import QSettings, QVariant + +from hsutil.misc import tryint + +def variant_to_py(v): + value = None + ok = False + t = v.type() + if t == QVariant.String: + value = unicode(v.toString()) + ok = True # anyway + # might be bool or int, try them + if v == 'true': + value = True + elif value == 'false': + value = False + else: + value = tryint(value, value) + elif t == QVariant.Int: + value, ok = v.toInt() + elif t == QVariant.Bool: + value, ok = v.toBool(), True + elif t in (QVariant.List, QVariant.StringList): + value, ok = map(variant_to_py, v.toList()), True + if not ok: + raise TypeError() + return value + +class Preferences(object): + # (width, is_visible) + COLUMNS_DEFAULT_ATTRS = [] + + def __init__(self): + self.reset() + self.reset_columns() + + def _load_specific(self, settings, get): + # load prefs specific to the dg edition + pass + + def load(self): + self.reset() + settings = QSettings() + def get(name, default): + if settings.contains(name): + return variant_to_py(settings.value(name)) + else: + return default + + self.filter_hardness = get('FilterHardness', self.filter_hardness) + self.mix_file_kind = get('MixFileKind', self.mix_file_kind) + self.use_regexp = get('UseRegexp', self.use_regexp) + self.remove_empty_folders = get('RemoveEmptyFolders', self.remove_empty_folders) + self.destination_type = get('DestinationType', self.destination_type) + widths = get('ColumnsWidth', self.columns_width) + # only set nonzero values + for index, width in enumerate(widths[:len(self.columns_width)]): + if width > 0: + self.columns_width[index] = width + self.columns_visible = get('ColumnsVisible', self.columns_visible) + self.registration_code = get('RegistrationCode', self.registration_code) + self.registration_email = get('RegistrationEmail', self.registration_email) + self._load_specific(settings, get) + + def _reset_specific(self): + # reset prefs specific to the dg edition + pass + + def reset(self): + self.filter_hardness = 95 + self.mix_file_kind = True + self.use_regexp = False + self.remove_empty_folders = False + self.destination_type = 1 + self.registration_code = '' + self.registration_email = '' + self._reset_specific() + + def reset_columns(self): + self.columns_width = [width for width, _ in self.COLUMNS_DEFAULT_ATTRS] + self.columns_visible = [visible for _, visible in self.COLUMNS_DEFAULT_ATTRS] + + def _save_specific(self, settings, set_): + # save prefs specific to the dg edition + pass + + def save(self): + settings = QSettings() + def set_(name, value): + settings.setValue(name, QVariant(value)) + + set_('FilterHardness', self.filter_hardness) + set_('MixFileKind', self.mix_file_kind) + set_('UseRegexp', self.use_regexp) + set_('RemoveEmptyFolders', self.remove_empty_folders) + set_('DestinationType', self.destination_type) + set_('ColumnsWidth', self.columns_width) + set_('ColumnsVisible', self.columns_visible) + set_('RegistrationCode', self.registration_code) + set_('RegistrationEmail', self.registration_email) + self._save_specific(settings, set_) + diff --git a/pe/qt/base/reg.py b/pe/qt/base/reg.py new file mode 100644 index 00000000..59fd0bc3 --- /dev/null +++ b/pe/qt/base/reg.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# Unit Name: reg +# Created By: Virgil Dupras +# Created On: 2009-05-09 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from hashlib import md5 + +from PyQt4.QtGui import QDialog + +from reg_submit_dialog import RegSubmitDialog +from reg_demo_dialog import RegDemoDialog + +class Registration(object): + def __init__(self, app): + self.app = app + + def ask_for_code(self): + dialog = RegSubmitDialog(self.app.main_window, self.app.is_code_valid) + result = dialog.exec_() + code = unicode(dialog.codeEdit.text()) + email = unicode(dialog.emailEdit.text()) + dialog.setParent(None) # free it + if result == QDialog.Accepted and self.app.is_code_valid(code, email): + self.app.set_registration(code, email) + return True + return False + + def show_nag(self): + dialog = RegDemoDialog(self.app.main_window, self) + dialog.exec_() + dialog.setParent(None) # free it + diff --git a/pe/qt/base/reg_demo_dialog.py b/pe/qt/base/reg_demo_dialog.py new file mode 100644 index 00000000..95280314 --- /dev/null +++ b/pe/qt/base/reg_demo_dialog.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# Unit Name: reg_demo_dialog +# Created By: Virgil Dupras +# Created On: 2009-05-10 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt, QUrl, QCoreApplication +from PyQt4.QtGui import QDialog, QMessageBox, QDesktopServices + +from reg_demo_dialog_ui import Ui_RegDemoDialog + +class RegDemoDialog(QDialog, Ui_RegDemoDialog): + def __init__(self, parent, reg): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.reg = reg + self._setupUi() + + self.connect(self.enterCodeButton, SIGNAL('clicked()'), self.enterCodeClicked) + self.connect(self.purchaseButton, SIGNAL('clicked()'), self.purchaseClicked) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be setup in the Designer + appname = QCoreApplication.instance().applicationName() + title = self.windowTitle() + title = title.replace('$appname', appname) + self.setWindowTitle(title) + title = self.titleLabel.text() + title = title.replace('$appname', appname) + self.titleLabel.setText(title) + desc = self.demoDescLabel.text() + desc = desc.replace('$appname', appname) + self.demoDescLabel.setText(desc) + + #--- Events + def enterCodeClicked(self): + if self.reg.ask_for_code(): + self.accept() + + def purchaseClicked(self): + url = QUrl('http://www.hardcoded.net/purchase.htm') + QDesktopServices.openUrl(url) + diff --git a/pe/qt/base/reg_demo_dialog.ui b/pe/qt/base/reg_demo_dialog.ui new file mode 100644 index 00000000..ef918225 --- /dev/null +++ b/pe/qt/base/reg_demo_dialog.ui @@ -0,0 +1,140 @@ + + + RegDemoDialog + + + + 0 + 0 + 387 + 161 + + + + $appname Demo Version + + + + + + + 75 + true + + + + $appname Demo Version + + + + + + + You are currently running a demo version of $appname. This version has limited functionalities, and you need to buy it to have access to these functionalities. + + + true + + + + + + + In the demo version, only 10 duplicates per session can be sent to the recycle bin, moved or copied. + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 110 + 0 + + + + Try Demo + + + + + + + + 110 + 0 + + + + Enter Code + + + + + + + + 110 + 0 + + + + Purchase + + + + + + + + + + + tryButton + clicked() + RegDemoDialog + accept() + + + 112 + 161 + + + 201 + 94 + + + + + diff --git a/pe/qt/base/reg_submit_dialog.py b/pe/qt/base/reg_submit_dialog.py new file mode 100644 index 00000000..4ba680b6 --- /dev/null +++ b/pe/qt/base/reg_submit_dialog.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# Unit Name: reg_submit_dialog +# Created By: Virgil Dupras +# Created On: 2009-05-09 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt, QUrl, QCoreApplication +from PyQt4.QtGui import QDialog, QMessageBox, QDesktopServices + +from reg_submit_dialog_ui import Ui_RegSubmitDialog + +class RegSubmitDialog(QDialog, Ui_RegSubmitDialog): + def __init__(self, parent, is_valid_func): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self._setupUi() + self.is_valid_func = is_valid_func + + self.connect(self.submitButton, SIGNAL('clicked()'), self.submitClicked) + self.connect(self.purchaseButton, SIGNAL('clicked()'), self.purchaseClicked) + + def _setupUi(self): + self.setupUi(self) + # Stuff that can't be setup in the Designer + appname = QCoreApplication.instance().applicationName() + prompt = self.promptLabel.text() + prompt = prompt.replace('$appname', appname) + self.promptLabel.setText(prompt) + + #--- Events + def purchaseClicked(self): + url = QUrl('http://www.hardcoded.net/purchase.htm') + QDesktopServices.openUrl(url) + + def submitClicked(self): + code = unicode(self.codeEdit.text()) + email = unicode(self.emailEdit.text()) + title = "Registration" + if self.is_valid_func(code, email): + msg = "This code is valid. Thanks!" + QMessageBox.information(self, title, msg) + self.accept() + else: + msg = "This code is invalid" + QMessageBox.warning(self, title, msg) + diff --git a/pe/qt/base/reg_submit_dialog.ui b/pe/qt/base/reg_submit_dialog.ui new file mode 100644 index 00000000..06de4191 --- /dev/null +++ b/pe/qt/base/reg_submit_dialog.ui @@ -0,0 +1,149 @@ + + + RegSubmitDialog + + + + 0 + 0 + 365 + 134 + + + + Enter your registration code + + + + + + Please enter your $appname registration code and registered e-mail (the e-mail you used for the purchase), then press "Submit". + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + QLayout::SetNoConstraint + + + QFormLayout::ExpandingFieldsGrow + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + Registration code: + + + + + + + Registered e-mail: + + + + + + + + + + + + + + + + + Purchase + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Cancel + + + false + + + + + + + + 0 + 0 + + + + Submit + + + false + + + true + + + + + + + + + + + cancelButton + clicked() + RegSubmitDialog + reject() + + + 260 + 159 + + + 198 + 97 + + + + + diff --git a/pe/qt/base/results_model.py b/pe/qt/base/results_model.py new file mode 100644 index 00000000..d28d6da3 --- /dev/null +++ b/pe/qt/base/results_model.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +# Unit Name: +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt, QAbstractItemModel, QVariant, QModelIndex, QRect +from PyQt4.QtGui import QBrush, QStyledItemDelegate, QFont, QTreeView, QColor + +from tree_model import TreeNode, TreeModel + +class ResultNode(TreeNode): + def __init__(self, model, parent, row, dupe, group): + TreeNode.__init__(self, parent, row) + self.model = model + self.dupe = dupe + self.group = group + self._normalData = None + self._deltaData = None + + def _get_children(self): + children = [] + if self.dupe is self.group.ref: + for index, dupe in enumerate(self.group.dupes): + children.append(ResultNode(self.model, self, index, dupe, self.group)) + return children + + def reset(self): + self._normalData = None + self._deltaData = None + + @property + def normalData(self): + if self._normalData is None: + self._normalData = self.model._data.GetDisplayInfo(self.dupe, self.group, delta=False) + return self._normalData + + @property + def deltaData(self): + if self._deltaData is None: + self._deltaData = self.model._data.GetDisplayInfo(self.dupe, self.group, delta=True) + return self._deltaData + + +class ResultsDelegate(QStyledItemDelegate): + def initStyleOption(self, option, index): + QStyledItemDelegate.initStyleOption(self, option, index) + node = index.internalPointer() + if node.group.ref is node.dupe: + newfont = QFont(option.font) + newfont.setBold(True) + option.font = newfont + + +class ResultsModel(TreeModel): + def __init__(self, app): + self._app = app + self._results = app.results + self._data = app.data + self._delta_columns = app.DELTA_COLUMNS + self.delta = False + self._power_marker = False + TreeModel.__init__(self) + + def _root_nodes(self): + nodes = [] + if self.power_marker: + for index, dupe in enumerate(self._results.dupes): + group = self._results.get_group_of_duplicate(dupe) + nodes.append(ResultNode(self, None, index, dupe, group)) + else: + for index, group in enumerate(self._results.groups): + nodes.append(ResultNode(self, None, index, group.ref, group)) + return nodes + + def columnCount(self, parent): + return len(self._data.COLUMNS) + + def data(self, index, role): + if not index.isValid(): + return QVariant() + node = index.internalPointer() + if role == Qt.DisplayRole: + data = node.deltaData if self.delta else node.normalData + return QVariant(data[index.column()]) + elif role == Qt.CheckStateRole: + if index.column() == 0 and node.dupe is not node.group.ref: + state = Qt.Checked if self._results.is_marked(node.dupe) else Qt.Unchecked + return QVariant(state) + elif role == Qt.ForegroundRole: + if node.dupe is node.group.ref or node.dupe.is_ref: + return QVariant(QBrush(Qt.blue)) + elif self.delta and index.column() in self._delta_columns: + return QVariant(QBrush(QColor(255, 142, 40))) # orange + elif role == Qt.EditRole: + if index.column() == 0: + return QVariant(node.normalData[index.column()]) + return QVariant() + + def dupesForIndexes(self, indexes): + nodes = [index.internalPointer() for index in indexes] + return [node.dupe for node in nodes] + + def flags(self, index): + if not index.isValid(): + return Qt.ItemIsEnabled + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if index.column() == 0: + flags |= Qt.ItemIsUserCheckable | Qt.ItemIsEditable + return flags + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal and role == Qt.DisplayRole and section < len(self._data.COLUMNS): + return QVariant(self._data.COLUMNS[section]['display']) + + return QVariant() + + def setData(self, index, value, role): + if not index.isValid(): + return False + node = index.internalPointer() + if role == Qt.CheckStateRole: + if index.column() == 0: + self._app.toggle_marking_for_dupes([node.dupe]) + return True + if role == Qt.EditRole: + if index.column() == 0: + value = unicode(value.toString()) + if self._app.rename_dupe(node.dupe, value): + node.reset() + return True + return False + + def sort(self, column, order): + if self.power_marker: + self._results.sort_dupes(column, order == Qt.AscendingOrder, self.delta) + else: + self._results.sort_groups(column, order == Qt.AscendingOrder) + self.reset() + + def toggleMarked(self, indexes): + assert indexes + dupes = self.dupesForIndexes(indexes) + self._app.toggle_marking_for_dupes(dupes) + + #--- Properties + @property + def power_marker(self): + return self._power_marker + + @power_marker.setter + def power_marker(self, value): + if value == self._power_marker: + return + self._power_marker = value + self.reset() + + +class ResultsView(QTreeView): + #--- Override + def keyPressEvent(self, event): + if event.text() == ' ': + self.model().toggleMarked(self.selectionModel().selectedRows()) + return + QTreeView.keyPressEvent(self, event) + + def setModel(self, model): + assert isinstance(model, ResultsModel) + QTreeView.setModel(self, model) + + #--- Public + def selectedDupes(self): + return self.model().dupesForIndexes(self.selectionModel().selectedRows()) + diff --git a/pe/qt/base/tree_model.py b/pe/qt/base/tree_model.py new file mode 100644 index 00000000..b3a994b3 --- /dev/null +++ b/pe/qt/base/tree_model.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# Unit Name: tree_model +# Created By: Virgil Dupras +# Created On: 2009-05-04 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, QAbstractItemModel, QVariant, QModelIndex + +class TreeNode(object): + def __init__(self, parent, row): + self.parent = parent + self.row = row + self._children = None + + def _get_children(self): + raise NotImplementedError() + + @property + def children(self): + if self._children is None: + self._children = self._get_children() + return self._children + + +class TreeModel(QAbstractItemModel): + def __init__(self): + QAbstractItemModel.__init__(self) + self._nodes = None + + def _root_nodes(self): + raise NotImplementedError() + + def index(self, row, column, parent): + if not self.nodes: + return QModelIndex() + if not parent.isValid(): + return self.createIndex(row, column, self.nodes[row]) + node = parent.internalPointer() + return self.createIndex(row, column, node.children[row]) + + def parent(self, index): + if not index.isValid(): + return QModelIndex() + node = index.internalPointer() + if node.parent is None: + return QModelIndex() + else: + return self.createIndex(node.parent.row, 0, node.parent) + + def reset(self): + self._nodes = None + QAbstractItemModel.reset(self) + + def rowCount(self, parent): + if not parent.isValid(): + return len(self.nodes) + node = parent.internalPointer() + return len(node.children) + + @property + def nodes(self): + if self._nodes is None: + self._nodes = self._root_nodes() + return self._nodes + diff --git a/pe/qt/block.py b/pe/qt/block.py new file mode 100644 index 00000000..0270aba1 --- /dev/null +++ b/pe/qt/block.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# Unit Name: block +# Created By: Virgil Dupras +# Created On: 2009-05-10 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from _block import getblocks + +# Converted to Cython +# def getblock(image): +# width = image.width() +# height = image.height() +# if width: +# pixel_count = width * height +# red = green = blue = 0 +# s = image.bits().asstring(image.numBytes()) +# for i in xrange(pixel_count): +# offset = i * 3 +# red += ord(s[offset]) +# green += ord(s[offset + 1]) +# blue += ord(s[offset + 2]) +# return (red // pixel_count, green // pixel_count, blue // pixel_count) +# else: +# return (0, 0, 0) +# +# def getblocks(image, block_count_per_side): +# width = image.width() +# height = image.height() +# if not width: +# return [] +# block_width = max(width // block_count_per_side, 1) +# block_height = max(height // block_count_per_side, 1) +# result = [] +# for ih in xrange(block_count_per_side): +# top = min(ih * block_height, height - block_height) +# for iw in range(block_count_per_side): +# left = min(iw * block_width, width - block_width) +# crop = image.copy(left, top, block_width, block_height) +# result.append(getblock(crop)) +# return result diff --git a/pe/qt/build.py b/pe/qt/build.py new file mode 100644 index 00000000..6e454952 --- /dev/null +++ b/pe/qt/build.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# Unit Name: build +# Created By: Virgil Dupras +# Created On: 2009-05-22 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +# On Windows, PyInstaller is used to build an exe (py2exe creates a very bad looking icon +# The release version is outdated. Use at least r672 on http://svn.pyinstaller.org/trunk + +import os +import shutil +from app import DupeGuru + +def print_and_do(cmd): + print cmd + os.system(cmd) + +# Removing build and dist +shutil.rmtree('build') +shutil.rmtree('dist') + +version = DupeGuru.VERSION +versioncomma = version.replace('.', ', ') + ', 0' +verinfo = open('verinfo').read() +verinfo = verinfo.replace('$versioncomma', versioncomma).replace('$version', version) +fp = open('verinfo_tmp', 'w') +fp.write(verinfo) +fp.close() +print_and_do("python C:\\Python26\\pyinstaller\\Build.py dgpe.spec") +os.remove('verinfo_tmp') + +print_and_do("xcopy /Y C:\\src\\vs_comp\\msvcrt dist") +print_and_do("xcopy /Y /S /I help\\dupeguru_pe_help dist\\help") + +aicom = '"\\Program Files\\Caphyon\\Advanced Installer\\AdvancedInstaller.com"' +shutil.copy('installer.aip', 'installer_tmp.aip') # this is so we don'a have to re-commit installer.aip at every version change +print_and_do('%s /edit installer_tmp.aip /SetVersion %s' % (aicom, version)) +print_and_do('%s /build installer_tmp.aip -force' % aicom) +os.remove('installer_tmp.aip') \ No newline at end of file diff --git a/pe/qt/details_dialog.py b/pe/qt/details_dialog.py new file mode 100644 index 00000000..0c7503a6 --- /dev/null +++ b/pe/qt/details_dialog.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# Unit Name: details_dialog +# Created By: Virgil Dupras +# Created On: 2009-04-27 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt, SIGNAL, QAbstractTableModel, QVariant +from PyQt4.QtGui import QDialog, QHeaderView, QPixmap + +from base.details_table import DetailsModel +from details_dialog_ui import Ui_DetailsDialog + +class DetailsDialog(QDialog, Ui_DetailsDialog): + def __init__(self, parent, app): + QDialog.__init__(self, parent, Qt.Tool) + self.app = app + self.selectedPixmap = None + self.referencePixmap = None + self.setupUi(self) + self.model = DetailsModel(app) + self.tableView.setModel(self.model) + self.connect(app, SIGNAL('duplicateSelected()'), self.duplicateSelected) + + def _update(self): + dupe = self.app.selected_dupe + if dupe is None: + return + group = self.app.results.get_group_of_duplicate(dupe) + ref = group.ref + + self.selectedPixmap = QPixmap(unicode(dupe.path)) + if ref is dupe: + self.referencePixmap = self.selectedPixmap + else: + self.referencePixmap = QPixmap(unicode(ref.path)) + self._updateImages() + + def _updateImages(self): + if self.selectedPixmap is not None: + target_size = self.selectedImage.size() + scaledPixmap = self.selectedPixmap.scaled(target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.selectedImage.setPixmap(scaledPixmap) + if self.referencePixmap is not None: + target_size = self.referenceImage.size() + scaledPixmap = self.referencePixmap.scaled(target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.referenceImage.setPixmap(scaledPixmap) + + #--- Override + def resizeEvent(self, event): + self._updateImages() + + def show(self): + QDialog.show(self) + self._update() + + #--- Events + def duplicateSelected(self): + if self.isVisible(): + self._update() + diff --git a/pe/qt/details_dialog.ui b/pe/qt/details_dialog.ui new file mode 100644 index 00000000..cee1adb1 --- /dev/null +++ b/pe/qt/details_dialog.ui @@ -0,0 +1,113 @@ + + + DetailsDialog + + + + 0 + 0 + 502 + 295 + + + + + 250 + 250 + + + + Details + + + + 0 + + + 0 + + + + + 4 + + + + + + 0 + 0 + + + + + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + + + + false + + + Qt::AlignCenter + + + + + + + + + + 0 + 0 + + + + + 0 + 188 + + + + + 16777215 + 188 + + + + true + + + QAbstractItemView::SelectRows + + + false + + + + + + + + DetailsTable + QTableView +
base.details_table
+
+
+ + +
diff --git a/pe/qt/dgpe.spec b/pe/qt/dgpe.spec new file mode 100644 index 00000000..06e92f4e --- /dev/null +++ b/pe/qt/dgpe.spec @@ -0,0 +1,19 @@ +# -*- mode: python -*- +a = Analysis([os.path.join(HOMEPATH,'support\\_mountzlib.py'), os.path.join(HOMEPATH,'support\\useUnicode.py'), 'start.py'], + pathex=['C:\\src\\dupeguru\\pe\\qt']) +pyz = PYZ(a.pure) +exe = EXE(pyz, + a.scripts, + exclude_binaries=1, + name=os.path.join('build\\pyi.win32\\dupeGuru PE', 'dupeGuru PE.exe'), + debug=False, + strip=False, + upx=True, + console=False , icon='base\\images\\dgpe_logo.ico', version='verinfo_tmp') +coll = COLLECT( exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name=os.path.join('dist')) diff --git a/pe/qt/dupeguru/__init__.py b/pe/qt/dupeguru/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/pe/qt/dupeguru/__init__.py @@ -0,0 +1 @@ + diff --git a/pe/qt/dupeguru/app.py b/pe/qt/dupeguru/app.py new file mode 100644 index 00000000..0e03603d --- /dev/null +++ b/pe/qt/dupeguru/app.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.app +Created By: Virgil Dupras +Created On: 2006/11/11 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:02:48 +0200 (Thu, 28 May 2009) $ + $Revision: 4388 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +import os +import os.path as op +import logging + +from hsfs import IT_ATTRS, IT_EXTRA +from hsutil import job, io, files +from hsutil.path import Path +from hsutil.reg import RegistrableApplication, RegistrationRequired +from hsutil.misc import flatten, first +from hsutil.str import escape + +import directories +import results +import scanner + +JOB_SCAN = 'job_scan' +JOB_LOAD = 'job_load' +JOB_MOVE = 'job_move' +JOB_COPY = 'job_copy' +JOB_DELETE = 'job_delete' + +class NoScannableFileError(Exception): + pass + +class AllFilesAreRefError(Exception): + pass + +class DupeGuru(RegistrableApplication): + def __init__(self, data_module, appdata, appid): + RegistrableApplication.__init__(self, appid) + self.appdata = appdata + if not op.exists(self.appdata): + os.makedirs(self.appdata) + self.data = data_module + self.directories = directories.Directories() + self.results = results.Results(data_module) + self.scanner = scanner.Scanner() + self.action_count = 0 + self.last_op_error_count = 0 + self.options = { + 'escape_filter_regexp': True, + 'clean_empty_dirs': False, + } + + def _demo_check(self): + if self.registered: + return + count = self.results.mark_count + if count + self.action_count > 10: + raise RegistrationRequired() + else: + self.action_count += count + + def _do_delete(self, j): + def op(dupe): + j.add_progress() + return self._do_delete_dupe(dupe) + + j.start_job(self.results.mark_count) + self.last_op_error_count = self.results.perform_on_marked(op, True) + + def _do_delete_dupe(self, dupe): + if not io.exists(dupe.path): + dupe.parent = None + return True + self._recycle_dupe(dupe) + self.clean_empty_dirs(dupe.path[:-1]) + if not io.exists(dupe.path): + dupe.parent = None + return True + logging.warning(u"Could not send {0} to trash.".format(unicode(dupe.path))) + return False + + def _do_load(self, j): + self.directories.LoadFromFile(op.join(self.appdata, 'last_directories.xml')) + j = j.start_subjob([1, 9]) + self.results.load_from_xml(op.join(self.appdata, 'last_results.xml'), self._get_file, j) + files = flatten(g[:] for g in self.results.groups) + for file in j.iter_with_progress(files, 'Reading metadata %d/%d'): + file._read_all_info(sections=[IT_ATTRS, IT_EXTRA]) + + def _get_file(self, str_path): + p = Path(str_path) + for d in self.directories: + if p not in d.path: + continue + result = d.find_path(p[d.path:]) + if result is not None: + return result + + @staticmethod + def _recycle_dupe(dupe): + raise NotImplementedError() + + def _start_job(self, jobid, func): + # func(j) + raise NotImplementedError() + + def AddDirectory(self, d): + try: + self.directories.add_path(Path(d)) + return 0 + except directories.AlreadyThereError: + return 1 + except directories.InvalidPathError: + return 2 + + def AddToIgnoreList(self, dupe): + g = self.results.get_group_of_duplicate(dupe) + for other in g: + if other is not dupe: + self.scanner.ignore_list.Ignore(unicode(other.path), unicode(dupe.path)) + + def ApplyFilter(self, filter): + self.results.apply_filter(None) + if self.options['escape_filter_regexp']: + filter = escape(filter, '()[]\\.|+?^') + filter = escape(filter, '*', '.') + self.results.apply_filter(filter) + + def clean_empty_dirs(self, path): + if self.options['clean_empty_dirs']: + while files.delete_if_empty(path, ['.DS_Store']): + path = path[:-1] + + def CopyOrMove(self, dupe, copy, destination, dest_type): + """ + copy: True = Copy False = Move + destination: string. + dest_type: 0 = right in destination. + 1 = relative re-creation. + 2 = absolute re-creation. + """ + source_path = dupe.path + location_path = dupe.root.path + dest_path = Path(destination) + if dest_type == 2: + dest_path = dest_path + source_path[1:-1] #Remove drive letter and filename + elif dest_type == 1: + dest_path = dest_path + source_path[location_path:-1] + if not io.exists(dest_path): + io.makedirs(dest_path) + try: + if copy: + files.copy(source_path, dest_path) + else: + files.move(source_path, dest_path) + self.clean_empty_dirs(source_path[:-1]) + except (IOError, OSError) as e: + operation = 'Copy' if copy else 'Move' + logging.warning('%s operation failed on %s. Error: %s' % (operation, unicode(dupe.path), unicode(e))) + return False + return True + + def copy_or_move_marked(self, copy, destination, recreate_path): + def do(j): + def op(dupe): + j.add_progress() + return self.CopyOrMove(dupe, copy, destination, recreate_path) + + j.start_job(self.results.mark_count) + self.last_op_error_count = self.results.perform_on_marked(op, not copy) + + self._demo_check() + jobid = JOB_COPY if copy else JOB_MOVE + self._start_job(jobid, do) + + def delete_marked(self): + self._demo_check() + self._start_job(JOB_DELETE, self._do_delete) + + def load(self): + self._start_job(JOB_LOAD, self._do_load) + self.LoadIgnoreList() + + def LoadIgnoreList(self): + p = op.join(self.appdata, 'ignore_list.xml') + self.scanner.ignore_list.load_from_xml(p) + + def make_reference(self, duplicates): + changed_groups = set() + for dupe in duplicates: + g = self.results.get_group_of_duplicate(dupe) + if g not in changed_groups: + self.results.make_ref(dupe) + changed_groups.add(g) + + def Save(self): + self.directories.SaveToFile(op.join(self.appdata, 'last_directories.xml')) + self.results.save_to_xml(op.join(self.appdata, 'last_results.xml')) + + def SaveIgnoreList(self): + p = op.join(self.appdata, 'ignore_list.xml') + self.scanner.ignore_list.save_to_xml(p) + + def start_scanning(self): + def do(j): + j.set_progress(0, 'Collecting files to scan') + files = list(self.directories.get_files()) + logging.info('Scanning %d files' % len(files)) + self.results.groups = self.scanner.GetDupeGroups(files, j) + + files = self.directories.get_files() + first_file = first(files) + if first_file is None: + raise NoScannableFileError() + if first_file.is_ref and all(f.is_ref for f in files): + raise AllFilesAreRefError() + self.results.groups = [] + self._start_job(JOB_SCAN, do) + + #--- Properties + @property + def stat_line(self): + result = self.results.stat_line + if self.scanner.discarded_file_count: + result = '%s (%d discarded)' % (result, self.scanner.discarded_file_count) + return result + diff --git a/pe/qt/dupeguru/app_cocoa.py b/pe/qt/dupeguru/app_cocoa.py new file mode 100644 index 00000000..4974d700 --- /dev/null +++ b/pe/qt/dupeguru/app_cocoa.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.app_cocoa +Created By: Virgil Dupras +Created On: 2006/11/11 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +from AppKit import * +import logging +import os.path as op + +import hsfs as fs +from hsfs.phys.bundle import Bundle +from hsutil.cocoa import install_exception_hook +from hsutil.str import get_file_ext +from hsutil import io, cocoa, job +from hsutil.reg import RegistrationRequired + +import export, app, data + +JOBID2TITLE = { + app.JOB_SCAN: "Scanning for duplicates", + app.JOB_LOAD: "Loading", + app.JOB_MOVE: "Moving", + app.JOB_COPY: "Copying", + app.JOB_DELETE: "Sending to Trash", +} + +class DGDirectory(fs.phys.Directory): + def _create_sub_dir(self,name,with_parent = True): + ext = get_file_ext(name) + if ext == 'app': + if with_parent: + parent = self + else: + parent = None + return Bundle(parent,name) + else: + return super(DGDirectory,self)._create_sub_dir(name,with_parent) + + +def demo_method(method): + def wrapper(self, *args, **kwargs): + try: + return method(self, *args, **kwargs) + except RegistrationRequired: + NSNotificationCenter.defaultCenter().postNotificationName_object_('RegistrationRequired', self) + + return wrapper + +class DupeGuru(app.DupeGuru): + def __init__(self, data_module, appdata_subdir, appid): + LOGGING_LEVEL = logging.DEBUG if NSUserDefaults.standardUserDefaults().boolForKey_('debug') else logging.WARNING + logging.basicConfig(level=LOGGING_LEVEL, format='%(levelname)s %(message)s') + logging.debug('started in debug mode') + install_exception_hook() + if data_module is None: + data_module = data + appdata = op.expanduser(op.join('~', '.hsoftdata', appdata_subdir)) + app.DupeGuru.__init__(self, data_module, appdata, appid) + self.progress = cocoa.ThreadedJobPerformer() + self.directories.dirclass = DGDirectory + self.display_delta_values = False + self.selected_dupes = [] + self.RefreshDetailsTable(None,None) + + #--- Override + @staticmethod + def _recycle_dupe(dupe): + if not io.exists(dupe.path): + dupe.parent = None + return True + directory = unicode(dupe.parent.path) + filename = dupe.name + result, tag = NSWorkspace.sharedWorkspace().performFileOperation_source_destination_files_tag_( + NSWorkspaceRecycleOperation, directory, '', [filename]) + if not io.exists(dupe.path): + dupe.parent = None + return True + logging.warning('Could not send %s to trash. tag: %d' % (unicode(dupe.path), tag)) + return False + + def _start_job(self, jobid, func): + try: + j = self.progress.create_job() + self.progress.run_threaded(func, args=(j, )) + except job.JobInProgressError: + NSNotificationCenter.defaultCenter().postNotificationName_object_('JobInProgress', self) + else: + ud = {'desc': JOBID2TITLE[jobid], 'jobid':jobid} + NSNotificationCenter.defaultCenter().postNotificationName_object_userInfo_('JobStarted', self, ud) + + #---Helpers + def GetObjects(self,node_path): + #returns a tuple g,d + try: + g = self.results.groups[node_path[0]] + if len(node_path) == 2: + return (g,self.results.groups[node_path[0]].dupes[node_path[1]]) + else: + return (g,None) + except IndexError: + return (None,None) + + def GetDirectory(self,node_path,curr_dir=None): + if not node_path: + return curr_dir + if curr_dir is not None: + l = curr_dir.dirs + else: + l = self.directories + d = l[node_path[0]] + return self.GetDirectory(node_path[1:],d) + + def RefreshDetailsTable(self,dupe,group): + l1 = self.data.GetDisplayInfo(dupe,group,False) + if group is not None: + l2 = self.data.GetDisplayInfo(group.ref,group,False) + else: + l2 = l1 #To have a list of empty '---' values + names = [c['display'] for c in self.data.COLUMNS] + self.details_table = zip(names,l1,l2) + + #---Public + def AddSelectedToIgnoreList(self): + for dupe in self.selected_dupes: + self.AddToIgnoreList(dupe) + + copy_or_move_marked = demo_method(app.DupeGuru.copy_or_move_marked) + delete_marked = demo_method(app.DupeGuru.delete_marked) + + def ExportToXHTML(self,column_ids,xslt_path,css_path): + columns = [] + for index,column in enumerate(self.data.COLUMNS): + display = column['display'] + enabled = str(index) in column_ids + columns.append((display,enabled)) + xml_path = op.join(self.appdata,'results_export.xml') + self.results.save_to_xml(xml_path,self.data.GetDisplayInfo) + return export.export_to_xhtml(xml_path,xslt_path,css_path,columns) + + def MakeSelectedReference(self): + self.make_reference(self.selected_dupes) + + def OpenSelected(self): + if self.selected_dupes: + path = unicode(self.selected_dupes[0].path) + NSWorkspace.sharedWorkspace().openFile_(path) + + def PurgeIgnoreList(self): + self.scanner.ignore_list.Filter(lambda f,s:op.exists(f) and op.exists(s)) + + def RefreshDetailsWithSelected(self): + if self.selected_dupes: + self.RefreshDetailsTable( + self.selected_dupes[0], + self.results.get_group_of_duplicate(self.selected_dupes[0]) + ) + else: + self.RefreshDetailsTable(None,None) + + def RemoveDirectory(self,index): + try: + del self.directories[index] + except IndexError: + pass + + def RemoveSelected(self): + self.results.remove_duplicates(self.selected_dupes) + + def RenameSelected(self,newname): + try: + d = self.selected_dupes[0] + d = d.move(d.parent,newname) + return True + except (IndexError,fs.FSError),e: + logging.warning("dupeGuru Warning: %s" % str(e)) + return False + + def RevealSelected(self): + if self.selected_dupes: + path = unicode(self.selected_dupes[0].path) + NSWorkspace.sharedWorkspace().selectFile_inFileViewerRootedAtPath_(path,'') + + def start_scanning(self): + self.RefreshDetailsTable(None, None) + try: + app.DupeGuru.start_scanning(self) + return 0 + except app.NoScannableFileError: + return 3 + except app.AllFilesAreRefError: + return 1 + + def SelectResultNodePaths(self,node_paths): + def extract_dupe(t): + g,d = t + if d is not None: + return d + else: + if g is not None: + return g.ref + + selected = [extract_dupe(self.GetObjects(p)) for p in node_paths] + self.selected_dupes = [dupe for dupe in selected if dupe is not None] + + def SelectPowerMarkerNodePaths(self,node_paths): + rows = [p[0] for p in node_paths] + self.selected_dupes = [ + self.results.dupes[row] for row in rows if row in xrange(len(self.results.dupes)) + ] + + def SetDirectoryState(self,node_path,state): + d = self.GetDirectory(node_path) + self.directories.SetState(d.path,state) + + def sort_dupes(self,key,asc): + self.results.sort_dupes(key,asc,self.display_delta_values) + + def sort_groups(self,key,asc): + self.results.sort_groups(key,asc) + + def ToggleSelectedMarkState(self): + for dupe in self.selected_dupes: + self.results.mark_toggle(dupe) + + #---Data + def GetOutlineViewMaxLevel(self, tag): + if tag == 0: + return 2 + elif tag == 1: + return 0 + elif tag == 2: + return 1 + + def GetOutlineViewChildCounts(self, tag, node_path): + if self.progress._job_running: + return [] + if tag == 0: #Normal results + assert not node_path # no other value is possible + return [len(g.dupes) for g in self.results.groups] + elif tag == 1: #Directories + dirs = self.GetDirectory(node_path).dirs if node_path else self.directories + return [d.dircount for d in dirs] + else: #Power Marker + assert not node_path # no other value is possible + return [0 for d in self.results.dupes] + + def GetOutlineViewValues(self, tag, node_path): + if self.progress._job_running: + return + if not node_path: + return + if tag in (0,2): #Normal results / Power Marker + if tag == 0: + g, d = self.GetObjects(node_path) + if d is None: + d = g.ref + else: + d = self.results.dupes[node_path[0]] + g = self.results.get_group_of_duplicate(d) + result = self.data.GetDisplayInfo(d, g, self.display_delta_values) + return result + elif tag == 1: #Directories + d = self.GetDirectory(node_path) + return [ + d.name, + self.directories.GetState(d.path) + ] + + def GetOutlineViewMarked(self, tag, node_path): + # 0=unmarked 1=marked 2=unmarkable + if self.progress._job_running: + return + if not node_path: + return 2 + if tag == 1: #Directories + return 2 + if tag == 0: #Normal results + g, d = self.GetObjects(node_path) + else: #Power Marker + d = self.results.dupes[node_path[0]] + if (d is None) or (not self.results.is_markable(d)): + return 2 + elif self.results.is_marked(d): + return 1 + else: + return 0 + + def GetTableViewCount(self, tag): + if self.progress._job_running: + return 0 + return len(self.details_table) + + def GetTableViewMarkedIndexes(self,tag): + return [] + + def GetTableViewValues(self,tag,row): + return self.details_table[row] + + diff --git a/pe/qt/dupeguru/app_cocoa_test.py b/pe/qt/dupeguru/app_cocoa_test.py new file mode 100644 index 00000000..ad8b937a --- /dev/null +++ b/pe/qt/dupeguru/app_cocoa_test.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.app_cocoa +Created By: Virgil Dupras +Created On: 2006/11/11 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-29 17:51:41 +0200 (Fri, 29 May 2009) $ + $Revision: 4409 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +import tempfile +import shutil +import logging + +from hsutil.path import Path +from hsutil.testcase import TestCase +from hsutil.decorators import log_calls +import hsfs.phys +import os.path as op + +from . import engine, data +try: + from .app_cocoa import DupeGuru as DupeGuruBase, DGDirectory +except ImportError: + from nose.plugins.skip import SkipTest + raise SkipTest("These tests can only be run on OS X") +from .results_test import GetTestGroups + +class DupeGuru(DupeGuruBase): + def __init__(self): + DupeGuruBase.__init__(self, data, '/tmp', appid=4) + + def _start_job(self, jobid, func): + func(nulljob) + + +def r2np(rows): + #Transforms a list of rows [1,2,3] into a list of node paths [[1],[2],[3]] + return [[i] for i in rows] + +class TCDupeGuru(TestCase): + def setUp(self): + self.app = DupeGuru() + self.objects,self.matches,self.groups = GetTestGroups() + self.app.results.groups = self.groups + + def test_GetObjects(self): + app = self.app + objects = self.objects + groups = self.groups + g,d = app.GetObjects([0]) + self.assert_(g is groups[0]) + self.assert_(d is None) + g,d = app.GetObjects([0,0]) + self.assert_(g is groups[0]) + self.assert_(d is objects[1]) + g,d = app.GetObjects([1,0]) + self.assert_(g is groups[1]) + self.assert_(d is objects[4]) + + def test_GetObjects_after_sort(self): + app = self.app + objects = self.objects + groups = self.groups[:] #To keep the old order in memory + app.sort_groups(0,False) #0 = Filename + #Now, the group order is supposed to be reversed + g,d = app.GetObjects([0,0]) + self.assert_(g is groups[1]) + self.assert_(d is objects[4]) + + def test_GetObjects_out_of_range(self): + app = self.app + self.assertEqual((None,None),app.GetObjects([2])) + self.assertEqual((None,None),app.GetObjects([])) + self.assertEqual((None,None),app.GetObjects([1,2])) + + def test_selectResultNodePaths(self): + app = self.app + objects = self.objects + app.SelectResultNodePaths([[0,0],[0,1]]) + self.assertEqual(2,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[1]) + self.assert_(app.selected_dupes[1] is objects[2]) + + def test_selectResultNodePaths_with_ref(self): + app = self.app + objects = self.objects + app.SelectResultNodePaths([[0,0],[0,1],[1]]) + self.assertEqual(3,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[1]) + self.assert_(app.selected_dupes[1] is objects[2]) + self.assert_(app.selected_dupes[2] is self.groups[1].ref) + + def test_selectResultNodePaths_empty(self): + self.app.SelectResultNodePaths([]) + self.assertEqual(0,len(self.app.selected_dupes)) + + def test_selectResultNodePaths_after_sort(self): + app = self.app + objects = self.objects + groups = self.groups[:] #To keep the old order in memory + app.sort_groups(0,False) #0 = Filename + #Now, the group order is supposed to be reversed + app.SelectResultNodePaths([[0,0],[1],[1,0]]) + self.assertEqual(3,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[4]) + self.assert_(app.selected_dupes[1] is groups[0].ref) + self.assert_(app.selected_dupes[2] is objects[1]) + + def test_selectResultNodePaths_out_of_range(self): + app = self.app + app.SelectResultNodePaths([[0,0],[0,1],[1],[1,1],[2]]) + self.assertEqual(3,len(app.selected_dupes)) + + def test_selectPowerMarkerRows(self): + app = self.app + objects = self.objects + app.SelectPowerMarkerNodePaths(r2np([0,1,2])) + self.assertEqual(3,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[1]) + self.assert_(app.selected_dupes[1] is objects[2]) + self.assert_(app.selected_dupes[2] is objects[4]) + + def test_selectPowerMarkerRows_empty(self): + self.app.SelectPowerMarkerNodePaths([]) + self.assertEqual(0,len(self.app.selected_dupes)) + + def test_selectPowerMarkerRows_after_sort(self): + app = self.app + objects = self.objects + app.sort_dupes(0,False) #0 = Filename + app.SelectPowerMarkerNodePaths(r2np([0,1,2])) + self.assertEqual(3,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[4]) + self.assert_(app.selected_dupes[1] is objects[2]) + self.assert_(app.selected_dupes[2] is objects[1]) + + def test_selectPowerMarkerRows_out_of_range(self): + app = self.app + app.SelectPowerMarkerNodePaths(r2np([0,1,2,3])) + self.assertEqual(3,len(app.selected_dupes)) + + def test_toggleSelectedMark(self): + app = self.app + objects = self.objects + app.ToggleSelectedMarkState() + self.assertEqual(0,app.results.mark_count) + app.SelectPowerMarkerNodePaths(r2np([0,2])) + app.ToggleSelectedMarkState() + self.assertEqual(2,app.results.mark_count) + self.assert_(not app.results.is_marked(objects[0])) + self.assert_(app.results.is_marked(objects[1])) + self.assert_(not app.results.is_marked(objects[2])) + self.assert_(not app.results.is_marked(objects[3])) + self.assert_(app.results.is_marked(objects[4])) + + def test_refreshDetailsWithSelected(self): + def mock_refresh(dupe,group): + self.called = True + if self.app.selected_dupes: + self.assert_(dupe is self.app.selected_dupes[0]) + self.assert_(group is self.app.results.get_group_of_duplicate(dupe)) + else: + self.assert_(dupe is None) + self.assert_(group is None) + + self.app.RefreshDetailsTable = mock_refresh + self.called = False + self.app.SelectPowerMarkerNodePaths(r2np([0,2])) + self.app.RefreshDetailsWithSelected() + self.assert_(self.called) + self.called = False + self.app.SelectPowerMarkerNodePaths([]) + self.app.RefreshDetailsWithSelected() + self.assert_(self.called) + + def test_makeSelectedReference(self): + app = self.app + objects = self.objects + groups = self.groups + app.SelectPowerMarkerNodePaths(r2np([0,2])) + app.MakeSelectedReference() + self.assert_(groups[0].ref is objects[1]) + self.assert_(groups[1].ref is objects[4]) + + def test_makeSelectedReference_by_selecting_two_dupes_in_the_same_group(self): + app = self.app + objects = self.objects + groups = self.groups + app.SelectPowerMarkerNodePaths(r2np([0,1,2])) + #Only 0 and 2 must go ref, not 1 because it is a part of the same group + app.MakeSelectedReference() + self.assert_(groups[0].ref is objects[1]) + self.assert_(groups[1].ref is objects[4]) + + def test_removeSelected(self): + app = self.app + app.SelectPowerMarkerNodePaths(r2np([0,2])) + app.RemoveSelected() + self.assertEqual(1,len(app.results.dupes)) + app.RemoveSelected() + self.assertEqual(1,len(app.results.dupes)) + app.SelectPowerMarkerNodePaths(r2np([0,2])) + app.RemoveSelected() + self.assertEqual(0,len(app.results.dupes)) + + def test_addDirectory_simple(self): + app = self.app + self.assertEqual(0,app.AddDirectory(self.datadirpath())) + self.assertEqual(1,len(app.directories)) + + def test_addDirectory_already_there(self): + app = self.app + self.assertEqual(0,app.AddDirectory(self.datadirpath())) + self.assertEqual(1,app.AddDirectory(self.datadirpath())) + + def test_addDirectory_does_not_exist(self): + app = self.app + self.assertEqual(2,app.AddDirectory('/does_not_exist')) + + def test_ignore(self): + app = self.app + app.SelectPowerMarkerNodePaths(r2np([2])) #The dupe of the second, 2 sized group + app.AddSelectedToIgnoreList() + self.assertEqual(1,len(app.scanner.ignore_list)) + app.SelectPowerMarkerNodePaths(r2np([0])) #first dupe of the 3 dupes group + app.AddSelectedToIgnoreList() + #BOTH the ref and the other dupe should have been added + self.assertEqual(3,len(app.scanner.ignore_list)) + + def test_purgeIgnoreList(self): + app = self.app + p1 = self.filepath('zerofile') + p2 = self.filepath('zerofill') + dne = '/does_not_exist' + app.scanner.ignore_list.Ignore(dne,p1) + app.scanner.ignore_list.Ignore(p2,dne) + app.scanner.ignore_list.Ignore(p1,p2) + app.PurgeIgnoreList() + self.assertEqual(1,len(app.scanner.ignore_list)) + self.assert_(app.scanner.ignore_list.AreIgnored(p1,p2)) + self.assert_(not app.scanner.ignore_list.AreIgnored(dne,p1)) + + def test_only_unicode_is_added_to_ignore_list(self): + def FakeIgnore(first,second): + if not isinstance(first,unicode): + self.fail() + if not isinstance(second,unicode): + self.fail() + + app = self.app + app.scanner.ignore_list.Ignore = FakeIgnore + app.SelectPowerMarkerNodePaths(r2np([2])) #The dupe of the second, 2 sized group + app.AddSelectedToIgnoreList() + + def test_dirclass(self): + self.assert_(self.app.directories.dirclass is DGDirectory) + + +class TCDupeGuru_renameSelected(TestCase): + def setUp(self): + p = Path(tempfile.mkdtemp()) + fp = open(str(p + 'foo bar 1'),mode='w') + fp.close() + fp = open(str(p + 'foo bar 2'),mode='w') + fp.close() + fp = open(str(p + 'foo bar 3'),mode='w') + fp.close() + refdir = hsfs.phys.Directory(None,str(p)) + matches = engine.MatchFactory().getmatches(refdir.files) + groups = engine.get_groups(matches) + g = groups[0] + g.prioritize(lambda x:x.name) + app = DupeGuru() + app.results.groups = groups + self.app = app + self.groups = groups + self.p = p + self.refdir = refdir + + def tearDown(self): + shutil.rmtree(str(self.p)) + + def test_simple(self): + app = self.app + refdir = self.refdir + g = self.groups[0] + app.SelectPowerMarkerNodePaths(r2np([0])) + self.assert_(app.RenameSelected('renamed')) + self.assert_('renamed' in refdir) + self.assert_('foo bar 2' not in refdir) + self.assert_(g.dupes[0] is refdir['renamed']) + self.assert_(g.dupes[0] in refdir) + + def test_none_selected(self): + app = self.app + refdir = self.refdir + g = self.groups[0] + app.SelectPowerMarkerNodePaths([]) + self.mock(logging, 'warning', log_calls(lambda msg: None)) + self.assert_(not app.RenameSelected('renamed')) + msg = logging.warning.calls[0]['msg'] + self.assertEqual('dupeGuru Warning: list index out of range', msg) + self.assert_('renamed' not in refdir) + self.assert_('foo bar 2' in refdir) + self.assert_(g.dupes[0] is refdir['foo bar 2']) + + def test_name_already_exists(self): + app = self.app + refdir = self.refdir + g = self.groups[0] + app.SelectPowerMarkerNodePaths(r2np([0])) + self.mock(logging, 'warning', log_calls(lambda msg: None)) + self.assert_(not app.RenameSelected('foo bar 1')) + msg = logging.warning.calls[0]['msg'] + self.assert_(msg.startswith('dupeGuru Warning: \'foo bar 2\' already exists in')) + self.assert_('foo bar 1' in refdir) + self.assert_('foo bar 2' in refdir) + self.assert_(g.dupes[0] is refdir['foo bar 2']) + diff --git a/pe/qt/dupeguru/app_me_cocoa.py b/pe/qt/dupeguru/app_me_cocoa.py new file mode 100644 index 00000000..51a61767 --- /dev/null +++ b/pe/qt/dupeguru/app_me_cocoa.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.app_me_cocoa +Created By: Virgil Dupras +Created On: 2006/11/16 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +import os.path as op +import logging +from appscript import app, k, CommandError +import time + +from hsutil.cocoa import as_fetch +import hsfs.phys.music + +import app_cocoa, data_me, scanner + +JOB_REMOVE_DEAD_TRACKS = 'jobRemoveDeadTracks' +JOB_SCAN_DEAD_TRACKS = 'jobScanDeadTracks' + +app_cocoa.JOBID2TITLE.update({ + JOB_REMOVE_DEAD_TRACKS: "Removing dead tracks from your iTunes Library", + JOB_SCAN_DEAD_TRACKS: "Scanning the iTunes Library", +}) + +class DupeGuruME(app_cocoa.DupeGuru): + def __init__(self): + app_cocoa.DupeGuru.__init__(self, data_me, 'dupeguru_me', appid=1) + self.scanner = scanner.ScannerME() + self.directories.dirclass = hsfs.phys.music.Directory + self.dead_tracks = [] + + def remove_dead_tracks(self): + def do(j): + a = app('iTunes') + for index, track in enumerate(j.iter_with_progress(self.dead_tracks)): + if index % 100 == 0: + time.sleep(.1) + try: + track.delete() + except CommandError as e: + logging.warning('Error while trying to remove a track from iTunes: %s' % unicode(e)) + + self._start_job(JOB_REMOVE_DEAD_TRACKS, do) + + def scan_dead_tracks(self): + def do(j): + a = app('iTunes') + try: + [source] = [s for s in a.sources() if s.kind() == k.library] + [library] = source.library_playlists() + except ValueError: + logging.warning('Some unexpected iTunes configuration encountered') + return + self.dead_tracks = [] + tracks = as_fetch(library.file_tracks, k.file_track) + for index, track in enumerate(j.iter_with_progress(tracks)): + if index % 100 == 0: + time.sleep(.1) + if track.location() == k.missing_value: + self.dead_tracks.append(track) + logging.info('Found %d dead tracks' % len(self.dead_tracks)) + + self._start_job(JOB_SCAN_DEAD_TRACKS, do) + diff --git a/pe/qt/dupeguru/app_pe_cocoa.py b/pe/qt/dupeguru/app_pe_cocoa.py new file mode 100644 index 00000000..5969d1c3 --- /dev/null +++ b/pe/qt/dupeguru/app_pe_cocoa.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.app_pe_cocoa +Created By: Virgil Dupras +Created On: 2006/11/13 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +import os +import os.path as op +import logging +import plistlib + +import objc +from Foundation import * +from AppKit import * +from appscript import app, k + +from hsutil import job, io +import hsfs as fs +from hsfs import phys +from hsutil import files +from hsutil.str import get_file_ext +from hsutil.path import Path +from hsutil.cocoa import as_fetch + +import app_cocoa, data_pe, directories, picture.matchbase +from picture.cache import string_to_colors, Cache + +mainBundle = NSBundle.mainBundle() +PictureBlocks = mainBundle.classNamed_('PictureBlocks') +assert PictureBlocks is not None + +class Photo(phys.File): + cls_info_map = { + 'size': fs.IT_ATTRS, + 'ctime': fs.IT_ATTRS, + 'mtime': fs.IT_ATTRS, + 'md5': fs.IT_MD5, + 'md5partial': fs.IT_MD5, + 'dimensions': fs.IT_EXTRA, + } + + def _initialize_info(self,section): + super(Photo, self)._initialize_info(section) + if section == fs.IT_EXTRA: + self._info.update({ + 'dimensions': (0,0), + }) + + def _read_info(self,section): + super(Photo, self)._read_info(section) + if section == fs.IT_EXTRA: + size = PictureBlocks.getImageSize_(unicode(self.path)) + self._info['dimensions'] = (size.width, size.height) + + def get_blocks(self, block_count_per_side): + try: + blocks = PictureBlocks.getBlocksFromImagePath_blockCount_scanArea_(unicode(self.path), block_count_per_side, 0) + except Exception, e: + raise IOError('The reading of "%s" failed with "%s"' % (unicode(self.path), unicode(e))) + if not blocks: + raise IOError('The picture %s could not be read' % unicode(self.path)) + return string_to_colors(blocks) + + +class IPhoto(Photo): + def __init__(self, parent, whole_path): + super(IPhoto, self).__init__(parent, whole_path[-1]) + self.whole_path = whole_path + + def _build_path(self): + return self.whole_path + + @property + def display_path(self): + return super(IPhoto, self)._build_path() + + +class Directory(phys.Directory): + cls_file_class = Photo + cls_supported_exts = ('png', 'jpg', 'jpeg', 'gif', 'psd', 'bmp', 'tiff', 'nef', 'cr2') + + def _fetch_subitems(self): + subdirs, subfiles = super(Directory,self)._fetch_subitems() + return subdirs, [name for name in subfiles if get_file_ext(name) in self.cls_supported_exts] + + +class IPhotoLibrary(fs.Directory): + def __init__(self, plistpath): + self.plistpath = plistpath + self.refpath = plistpath[:-1] + # the AlbumData.xml file lives right in the library path + super(IPhotoLibrary, self).__init__(None, 'iPhoto Library') + + def _update_photo(self, photo_data): + if photo_data['MediaType'] != 'Image': + return + photo_path = Path(photo_data['ImagePath']) + subpath = photo_path[len(self.refpath):-1] + subdir = self + for element in subpath: + try: + subdir = subdir[element] + except KeyError: + subdir = fs.Directory(subdir, element) + IPhoto(subdir, photo_path) + + def update(self): + self.clear() + s = open(unicode(self.plistpath)).read() + # There was a case where a guy had 0x10 chars in his plist, causing expat errors on loading + s = s.replace('\x10', '') + plist = plistlib.readPlistFromString(s) + for photo_data in plist['Master Image List'].values(): + self._update_photo(photo_data) + + def force_update(self): # Don't update + pass + + +class DupeGuruPE(app_cocoa.DupeGuru): + def __init__(self): + app_cocoa.DupeGuru.__init__(self, data_pe, 'dupeguru_pe', appid=5) + self.scanner.match_factory = picture.matchbase.AsyncMatchFactory() + self.directories.dirclass = Directory + self.directories.special_dirclasses[Path('iPhoto Library')] = lambda _, __: self._create_iphoto_library() + p = op.join(self.appdata, 'cached_pictures.db') + self.scanner.match_factory.cached_blocks = Cache(p) + + def _create_iphoto_library(self): + ud = NSUserDefaults.standardUserDefaults() + prefs = ud.persistentDomainForName_('com.apple.iApps') + plisturl = NSURL.URLWithString_(prefs['iPhotoRecentDatabases'][0]) + plistpath = Path(plisturl.path()) + return IPhotoLibrary(plistpath) + + def _do_delete(self, j): + def op(dupe): + j.add_progress() + return self._do_delete_dupe(dupe) + + marked = [dupe for dupe in self.results.dupes if self.results.is_marked(dupe)] + self.path2iphoto = {} + if any(isinstance(dupe, IPhoto) for dupe in marked): + a = app('iPhoto') + a.select(a.photo_library_album()) + photos = as_fetch(a.photo_library_album().photos, k.item) + for photo in photos: + self.path2iphoto[photo.image_path()] = photo + self.last_op_error_count = self.results.perform_on_marked(op, True) + del self.path2iphoto + + def _do_delete_dupe(self, dupe): + if isinstance(dupe, IPhoto): + photo = self.path2iphoto[unicode(dupe.path)] + app('iPhoto').remove(photo) + return True + else: + return app_cocoa.DupeGuru._do_delete_dupe(self, dupe) + + def _do_load(self, j): + self.directories.LoadFromFile(op.join(self.appdata, 'last_directories.xml')) + for d in self.directories: + if isinstance(d, IPhotoLibrary): + d.update() + self.results.load_from_xml(op.join(self.appdata, 'last_results.xml'), self._get_file, j) + + def _get_file(self, str_path): + p = Path(str_path) + for d in self.directories: + result = None + if p in d.path: + result = d.find_path(p[d.path:]) + if isinstance(d, IPhotoLibrary) and p in d.refpath: + result = d.find_path(p[d.refpath:]) + if result is not None: + return result + + def AddDirectory(self, d): + try: + added = self.directories.add_path(Path(d)) + if d == 'iPhoto Library': + added.update() + return 0 + except directories.AlreadyThereError: + return 1 + + def CopyOrMove(self, dupe, copy, destination, dest_type): + if isinstance(dupe, IPhoto): + copy = True + return app_cocoa.DupeGuru.CopyOrMove(self, dupe, copy, destination, dest_type) + + def start_scanning(self): + for directory in self.directories: + if isinstance(directory, IPhotoLibrary): + self.directories.SetState(directory.refpath, directories.STATE_EXCLUDED) + return app_cocoa.DupeGuru.start_scanning(self) + + 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 + return ref.path + diff --git a/pe/qt/dupeguru/app_se_cocoa.py b/pe/qt/dupeguru/app_se_cocoa.py new file mode 100644 index 00000000..3d8c62b2 --- /dev/null +++ b/pe/qt/dupeguru/app_se_cocoa.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +# Unit Name: app_se_cocoa +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import app_cocoa, data + +class DupeGuru(app_cocoa.DupeGuru): + def __init__(self): + app_cocoa.DupeGuru.__init__(self, data, 'dupeguru', appid=4) + diff --git a/pe/qt/dupeguru/app_test.py b/pe/qt/dupeguru/app_test.py new file mode 100644 index 00000000..af47067f --- /dev/null +++ b/pe/qt/dupeguru/app_test.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.app +Created By: Virgil Dupras +Created On: 2007-06-23 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:02:48 +0200 (Thu, 28 May 2009) $ + $Revision: 4388 $ +Copyright 2007 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import os + +from hsutil.testcase import TestCase +from hsutil import io +from hsutil.path import Path +from hsutil.decorators import log_calls +import hsfs as fs +import hsfs.phys +import hsutil.files +from hsutil.job import nulljob + +from . import data, app +from .app import DupeGuru as DupeGuruBase + +class DupeGuru(DupeGuruBase): + def __init__(self): + DupeGuruBase.__init__(self, data, '/tmp', appid=4) + + def _start_job(self, jobid, func): + func(nulljob) + + +class TCDupeGuru(TestCase): + cls_tested_module = app + def test_ApplyFilter_calls_results_apply_filter(self): + app = DupeGuru() + self.mock(app.results, 'apply_filter', log_calls(app.results.apply_filter)) + app.ApplyFilter('foo') + self.assertEqual(2, len(app.results.apply_filter.calls)) + call = app.results.apply_filter.calls[0] + self.assert_(call['filter_str'] is None) + call = app.results.apply_filter.calls[1] + self.assertEqual('foo', call['filter_str']) + + def test_ApplyFilter_escapes_regexp(self): + app = DupeGuru() + self.mock(app.results, 'apply_filter', log_calls(app.results.apply_filter)) + app.ApplyFilter('()[]\\.|+?^abc') + call = app.results.apply_filter.calls[1] + self.assertEqual('\\(\\)\\[\\]\\\\\\.\\|\\+\\?\\^abc', call['filter_str']) + app.ApplyFilter('(*)') # In "simple mode", we want the * to behave as a wilcard + call = app.results.apply_filter.calls[3] + self.assertEqual('\(.*\)', call['filter_str']) + app.options['escape_filter_regexp'] = False + app.ApplyFilter('(abc)') + call = app.results.apply_filter.calls[5] + self.assertEqual('(abc)', call['filter_str']) + + def test_CopyOrMove(self): + # The goal here is just to have a test for a previous blowup I had. I know my test coverage + # for this unit is pathetic. What's done is done. My approach now is to add tests for + # every change I want to make. The blowup was caused by a missing import. + dupe_parent = fs.Directory(None, 'foo') + dupe = fs.File(dupe_parent, 'bar') + dupe.copy = log_calls(lambda dest, newname: None) + self.mock(hsutil.files, 'copy', log_calls(lambda source_path, dest_path: None)) + self.mock(os, 'makedirs', lambda path: None) # We don't want the test to create that fake directory + self.mock(fs.phys, 'Directory', fs.Directory) # We don't want an error because makedirs didn't work + app = DupeGuru() + app.CopyOrMove(dupe, True, 'some_destination', 0) + self.assertEqual(1, len(hsutil.files.copy.calls)) + call = hsutil.files.copy.calls[0] + self.assertEqual('some_destination', call['dest_path']) + self.assertEqual(dupe.path, call['source_path']) + + def test_CopyOrMove_clean_empty_dirs(self): + tmppath = Path(self.tmpdir()) + sourcepath = tmppath + 'source' + io.mkdir(sourcepath) + io.open(sourcepath + 'myfile', 'w') + tmpdir = hsfs.phys.Directory(None, unicode(tmppath)) + myfile = tmpdir['source']['myfile'] + app = DupeGuru() + self.mock(app, 'clean_empty_dirs', log_calls(lambda path: None)) + app.CopyOrMove(myfile, False, tmppath + 'dest', 0) + calls = app.clean_empty_dirs.calls + self.assertEqual(1, len(calls)) + self.assertEqual(sourcepath, calls[0]['path']) + + def test_Scan_with_objects_evaluating_to_false(self): + # At some point, any() was used in a wrong way that made Scan() wrongly return 1 + app = DupeGuru() + f1, f2 = [fs.File(None, 'foo') for i in range(2)] + f1.is_ref, f2.is_ref = (False, False) + assert not (bool(f1) and bool(f2)) + app.directories.get_files = lambda: [f1, f2] + app.directories._dirs.append('this is just so Scan() doesnt return 3') + app.start_scanning() # no exception + + +class TCDupeGuru_clean_empty_dirs(TestCase): + cls_tested_module = app + def setUp(self): + self.mock(hsutil.files, 'delete_if_empty', log_calls(lambda path, files_to_delete=[]: None)) + self.app = DupeGuru() + + def test_option_off(self): + self.app.clean_empty_dirs(Path('/foo/bar')) + self.assertEqual(0, len(hsutil.files.delete_if_empty.calls)) + + def test_option_on(self): + self.app.options['clean_empty_dirs'] = True + self.app.clean_empty_dirs(Path('/foo/bar')) + calls = hsutil.files.delete_if_empty.calls + self.assertEqual(1, len(calls)) + self.assertEqual(Path('/foo/bar'), calls[0]['path']) + self.assertEqual(['.DS_Store'], calls[0]['files_to_delete']) + + def test_recurse_up(self): + # delete_if_empty must be recursively called up in the path until it returns False + @log_calls + def mock_delete_if_empty(path, files_to_delete=[]): + return len(path) > 1 + + self.mock(hsutil.files, 'delete_if_empty', mock_delete_if_empty) + self.app.options['clean_empty_dirs'] = True + self.app.clean_empty_dirs(Path('not-empty/empty/empty')) + calls = hsutil.files.delete_if_empty.calls + self.assertEqual(3, len(calls)) + self.assertEqual(Path('not-empty/empty/empty'), calls[0]['path']) + self.assertEqual(Path('not-empty/empty'), calls[1]['path']) + self.assertEqual(Path('not-empty'), calls[2]['path']) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/pe/qt/dupeguru/data.py b/pe/qt/dupeguru/data.py new file mode 100644 index 00000000..568a3400 --- /dev/null +++ b/pe/qt/dupeguru/data.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.data +Created By: Virgil Dupras +Created On: 2006/03/15 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" + +from hsutil.str import format_time, FT_DECIMAL, format_size + +import time + +def format_path(p): + return unicode(p[:-1]) + +def format_timestamp(t, delta): + if delta: + return format_time(t, FT_DECIMAL) + else: + if t > 0: + return time.strftime('%Y/%m/%d %H:%M:%S', time.localtime(t)) + else: + return '---' + +def format_words(w): + def do_format(w): + if isinstance(w, list): + return '(%s)' % ', '.join(do_format(item) for item in w) + else: + return w.replace('\n', ' ') + + return ', '.join(do_format(item) for item in w) + +def format_perc(p): + return "%0.0f" % p + +def format_dupe_count(c): + return str(c) if c else '---' + +def cmp_value(value): + return value.lower() if isinstance(value, basestring) else value + +COLUMNS = [ + {'attr':'name','display':'Filename'}, + {'attr':'path','display':'Directory'}, + {'attr':'size','display':'Size (KB)'}, + {'attr':'extension','display':'Kind'}, + {'attr':'ctime','display':'Creation'}, + {'attr':'mtime','display':'Modification'}, + {'attr':'percentage','display':'Match %'}, + {'attr':'words','display':'Words Used'}, + {'attr':'dupe_count','display':'Dupe Count'}, +] + +def GetDisplayInfo(dupe, group, delta=False): + if (dupe is None) or (group is None): + return ['---'] * len(COLUMNS) + size = dupe.size + ctime = dupe.ctime + mtime = dupe.mtime + m = group.get_match_of(dupe) + if m: + percentage = m.percentage + dupe_count = 0 + if delta: + r = group.ref + size -= r.size + ctime -= r.ctime + mtime -= r.mtime + else: + percentage = group.percentage + dupe_count = len(group.dupes) + return [ + dupe.name, + format_path(dupe.path), + format_size(size, 0, 1, False), + dupe.extension, + format_timestamp(ctime, delta and m), + format_timestamp(mtime, delta and m), + format_perc(percentage), + format_words(dupe.words), + format_dupe_count(dupe_count) + ] + +def GetDupeSortKey(dupe, get_group, key, delta): + if key == 6: + m = get_group().get_match_of(dupe) + return m.percentage + if key == 8: + return 0 + r = cmp_value(getattr(dupe, COLUMNS[key]['attr'])) + if delta and (key in (2, 4, 5)): + r -= cmp_value(getattr(get_group().ref, COLUMNS[key]['attr'])) + return r + +def GetGroupSortKey(group, key): + if key == 6: + return group.percentage + if key == 8: + return len(group) + return cmp_value(getattr(group.ref, COLUMNS[key]['attr'])) + diff --git a/pe/qt/dupeguru/data_me.py b/pe/qt/dupeguru/data_me.py new file mode 100644 index 00000000..70d3ae66 --- /dev/null +++ b/pe/qt/dupeguru/data_me.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.data +Created By: Virgil Dupras +Created On: 2006/03/15 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" + +from hsutil.str import format_time, FT_MINUTES, format_size +from .data import (format_path, format_timestamp, format_words, format_perc, + format_dupe_count, cmp_value) + +COLUMNS = [ + {'attr':'name','display':'Filename'}, + {'attr':'path','display':'Directory'}, + {'attr':'size','display':'Size (MB)'}, + {'attr':'duration','display':'Time'}, + {'attr':'bitrate','display':'Bitrate'}, + {'attr':'samplerate','display':'Sample Rate'}, + {'attr':'extension','display':'Kind'}, + {'attr':'ctime','display':'Creation'}, + {'attr':'mtime','display':'Modification'}, + {'attr':'title','display':'Title'}, + {'attr':'artist','display':'Artist'}, + {'attr':'album','display':'Album'}, + {'attr':'genre','display':'Genre'}, + {'attr':'year','display':'Year'}, + {'attr':'track','display':'Track Number'}, + {'attr':'comment','display':'Comment'}, + {'attr':'percentage','display':'Match %'}, + {'attr':'words','display':'Words Used'}, + {'attr':'dupe_count','display':'Dupe Count'}, +] + +def GetDisplayInfo(dupe, group, delta=False): + if (dupe is None) or (group is None): + return ['---'] * len(COLUMNS) + size = dupe.size + duration = dupe.duration + bitrate = dupe.bitrate + samplerate = dupe.samplerate + ctime = dupe.ctime + mtime = dupe.mtime + m = group.get_match_of(dupe) + if m: + percentage = m.percentage + dupe_count = 0 + if delta: + r = group.ref + size -= r.size + duration -= r.duration + bitrate -= r.bitrate + samplerate -= r.samplerate + ctime -= r.ctime + mtime -= r.mtime + else: + percentage = group.percentage + dupe_count = len(group.dupes) + return [ + dupe.name, + format_path(dupe.path), + format_size(size, 2, 2, False), + format_time(duration, FT_MINUTES), + str(bitrate), + str(samplerate), + dupe.extension, + format_timestamp(ctime,delta and m), + format_timestamp(mtime,delta and m), + dupe.title, + dupe.artist, + dupe.album, + dupe.genre, + dupe.year, + str(dupe.track), + dupe.comment, + format_perc(percentage), + format_words(dupe.words), + format_dupe_count(dupe_count) + ] + +def GetDupeSortKey(dupe, get_group, key, delta): + if key == 16: + m = get_group().get_match_of(dupe) + return m.percentage + if key == 18: + return 0 + r = cmp_value(getattr(dupe, COLUMNS[key]['attr'])) + if delta and (key in (2, 3, 4, 7, 8)): + r -= cmp_value(getattr(get_group().ref, COLUMNS[key]['attr'])) + return r + +def GetGroupSortKey(group, key): + if key == 16: + return group.percentage + if key == 18: + return len(group) + return cmp_value(getattr(group.ref, COLUMNS[key]['attr'])) diff --git a/pe/qt/dupeguru/data_pe.py b/pe/qt/dupeguru/data_pe.py new file mode 100644 index 00000000..94bdd99d --- /dev/null +++ b/pe/qt/dupeguru/data_pe.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.data +Created By: Virgil Dupras +Created On: 2006/03/15 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +from hsutil.str import format_size +from .data import format_path, format_timestamp, format_perc, format_dupe_count, cmp_value + +def format_dimensions(dimensions): + return '%d x %d' % (dimensions[0], dimensions[1]) + +COLUMNS = [ + {'attr':'name','display':'Filename'}, + {'attr':'path','display':'Directory'}, + {'attr':'size','display':'Size (KB)'}, + {'attr':'extension','display':'Kind'}, + {'attr':'dimensions','display':'Dimensions'}, + {'attr':'ctime','display':'Creation'}, + {'attr':'mtime','display':'Modification'}, + {'attr':'percentage','display':'Match %'}, + {'attr':'dupe_count','display':'Dupe Count'}, +] + +def GetDisplayInfo(dupe,group,delta=False): + if (dupe is None) or (group is None): + return ['---'] * len(COLUMNS) + size = dupe.size + ctime = dupe.ctime + mtime = dupe.mtime + m = group.get_match_of(dupe) + if m: + percentage = m.percentage + dupe_count = 0 + if delta: + r = group.ref + size -= r.size + ctime -= r.ctime + mtime -= r.mtime + else: + percentage = group.percentage + dupe_count = len(group.dupes) + dupe_path = getattr(dupe, 'display_path', dupe.path) + return [ + dupe.name, + format_path(dupe_path), + format_size(size, 0, 1, False), + dupe.extension, + format_dimensions(dupe.dimensions), + format_timestamp(ctime, delta and m), + format_timestamp(mtime, delta and m), + format_perc(percentage), + format_dupe_count(dupe_count) + ] + +def GetDupeSortKey(dupe, get_group, key, delta): + if key == 7: + m = get_group().get_match_of(dupe) + return m.percentage + if key == 8: + return 0 + r = cmp_value(getattr(dupe, COLUMNS[key]['attr'])) + if delta and (key in (2, 5, 6)): + r -= cmp_value(getattr(get_group().ref, COLUMNS[key]['attr'])) + return r + +def GetGroupSortKey(group, key): + if key == 7: + return group.percentage + if key == 8: + return len(group) + return cmp_value(getattr(group.ref, COLUMNS[key]['attr'])) + diff --git a/pe/qt/dupeguru/directories.py b/pe/qt/dupeguru/directories.py new file mode 100644 index 00000000..3d73b5c5 --- /dev/null +++ b/pe/qt/dupeguru/directories.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.directories +Created By: Virgil Dupras +Created On: 2006/02/27 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:02:48 +0200 (Thu, 28 May 2009) $ + $Revision: 4388 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import xml.dom.minidom + +from hsfs import phys +import hsfs as fs +from hsutil.files import FileOrPath +from hsutil.path import Path + +(STATE_NORMAL, +STATE_REFERENCE, +STATE_EXCLUDED) = range(3) + +class AlreadyThereError(Exception): + """The path being added is already in the directory list""" + +class InvalidPathError(Exception): + """The path being added is invalid""" + +class Directories(object): + #---Override + def __init__(self): + self._dirs = [] + self.states = {} + self.dirclass = phys.Directory + self.special_dirclasses = {} + + def __contains__(self,path): + for d in self._dirs: + if path in d.path: + return True + return False + + def __delitem__(self,key): + self._dirs.__delitem__(key) + + def __getitem__(self,key): + return self._dirs.__getitem__(key) + + def __len__(self): + return len(self._dirs) + + #---Private + def _get_files(self, from_dir, state=STATE_NORMAL): + state = self.states.get(from_dir.path, state) + result = [] + for subdir in from_dir.dirs: + for file in self._get_files(subdir, state): + yield file + if state != STATE_EXCLUDED: + for file in from_dir.files: + file.is_ref = state == STATE_REFERENCE + yield file + + #---Public + def add_path(self, path): + """Adds 'path' to self, if not already there. + + Raises AlreadyThereError if 'path' is already in self. If path is a directory containing + some of the directories already present in self, 'path' will be added, but all directories + under it will be removed. Can also raise InvalidPathError if 'path' does not exist. + """ + if path in self: + raise AlreadyThereError + self._dirs = [d for d in self._dirs if d.path not in path] + try: + dirclass = self.special_dirclasses.get(path, self.dirclass) + d = dirclass(None, unicode(path)) + d[:] #If an InvalidPath exception has to be raised, it will be raised here + self._dirs.append(d) + return d + except fs.InvalidPath: + raise InvalidPathError + + def get_files(self): + """Returns a list of all files that are not excluded. + + Returned files also have their 'is_ref' attr set. + """ + for d in self._dirs: + d.force_update() + try: + for file in self._get_files(d): + yield file + except fs.InvalidPath: + pass + + def GetState(self, path): + """Returns the state of 'path' (One of the STATE_* const.) + + Raises LookupError if 'path' is not in self. + """ + if path not in self: + raise LookupError("The path '%s' is not in the directory list." % str(path)) + try: + return self.states[path] + except KeyError: + if path[-1].startswith('.'): # hidden + return STATE_EXCLUDED + parent = path[:-1] + if parent in self: + return self.GetState(parent) + else: + return STATE_NORMAL + + def LoadFromFile(self,infile): + try: + doc = xml.dom.minidom.parse(infile) + except: + return + root_dir_nodes = doc.getElementsByTagName('root_directory') + for rdn in root_dir_nodes: + if not rdn.getAttributeNode('path'): + continue + path = rdn.getAttributeNode('path').nodeValue + try: + self.add_path(Path(path)) + except (AlreadyThereError,InvalidPathError): + pass + state_nodes = doc.getElementsByTagName('state') + for sn in state_nodes: + if not (sn.getAttributeNode('path') and sn.getAttributeNode('value')): + continue + path = sn.getAttributeNode('path').nodeValue + state = sn.getAttributeNode('value').nodeValue + self.SetState(Path(path), int(state)) + + def Remove(self,directory): + self._dirs.remove(directory) + + def SaveToFile(self,outfile): + with FileOrPath(outfile, 'wb') as fp: + doc = xml.dom.minidom.Document() + root = doc.appendChild(doc.createElement('directories')) + for root_dir in self: + root_dir_node = root.appendChild(doc.createElement('root_directory')) + root_dir_node.setAttribute('path', unicode(root_dir.path).encode('utf-8')) + for path,state in self.states.iteritems(): + state_node = root.appendChild(doc.createElement('state')) + state_node.setAttribute('path', unicode(path).encode('utf-8')) + state_node.setAttribute('value', str(state)) + doc.writexml(fp,'\t','\t','\n',encoding='utf-8') + + def SetState(self,path,state): + try: + if self.GetState(path) == state: + return + self.states[path] = state + if (self.GetState(path[:-1]) == state) and (not path[-1].startswith('.')): + del self.states[path] + except LookupError: + pass + diff --git a/pe/qt/dupeguru/directories_test.py b/pe/qt/dupeguru/directories_test.py new file mode 100644 index 00000000..7d34c343 --- /dev/null +++ b/pe/qt/dupeguru/directories_test.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.directories +Created By: Virgil Dupras +Created On: 2006/02/27 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-29 08:51:14 +0200 (Fri, 29 May 2009) $ + $Revision: 4398 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import os.path as op +import os +import time +import shutil + +from hsutil import job, io +from hsutil.path import Path +from hsutil.testcase import TestCase +import hsfs.phys +from hsfs.phys import phys_test + +from directories import * + +testpath = Path(TestCase.datadirpath()) + +class TCDirectories(TestCase): + def test_empty(self): + d = Directories() + self.assertEqual(0,len(d)) + self.assert_('foobar' not in d) + + def test_add_path(self): + d = Directories() + p = testpath + 'utils' + added = d.add_path(p) + self.assertEqual(1,len(d)) + self.assert_(p in d) + self.assert_((p + 'foobar') in d) + self.assert_(p[:-1] not in d) + self.assertEqual(p,added.path) + self.assert_(d[0] is added) + p = self.tmppath() + d.add_path(p) + self.assertEqual(2,len(d)) + self.assert_(p in d) + + def test_AddPath_when_path_is_already_there(self): + d = Directories() + p = testpath + 'utils' + d.add_path(p) + self.assertRaises(AlreadyThereError, d.add_path, p) + self.assertRaises(AlreadyThereError, d.add_path, p + 'foobar') + self.assertEqual(1, len(d)) + + def test_AddPath_containing_paths_already_there(self): + d = Directories() + d.add_path(testpath + 'utils') + self.assertEqual(1, len(d)) + added = d.add_path(testpath) + self.assertEqual(1, len(d)) + self.assert_(added is d[0]) + + def test_AddPath_non_latin(self): + p = Path(self.tmpdir()) + to_add = p + u'unicode\u201a' + os.mkdir(unicode(to_add)) + d = Directories() + try: + d.add_path(to_add) + except UnicodeDecodeError: + self.fail() + + def test_del(self): + d = Directories() + d.add_path(testpath + 'utils') + try: + del d[1] + self.fail() + except IndexError: + pass + d.add_path(self.tmppath()) + del d[1] + self.assertEqual(1, len(d)) + + def test_states(self): + d = Directories() + p = testpath + 'utils' + d.add_path(p) + self.assertEqual(STATE_NORMAL,d.GetState(p)) + d.SetState(p,STATE_REFERENCE) + self.assertEqual(STATE_REFERENCE,d.GetState(p)) + self.assertEqual(STATE_REFERENCE,d.GetState(p + 'dir1')) + self.assertEqual(1,len(d.states)) + self.assertEqual(p,d.states.keys()[0]) + self.assertEqual(STATE_REFERENCE,d.states[p]) + + def test_GetState_with_path_not_there(self): + d = Directories() + d.add_path(testpath + 'utils') + self.assertRaises(LookupError,d.GetState,testpath) + + def test_states_remain_when_larger_directory_eat_smaller_ones(self): + d = Directories() + p = testpath + 'utils' + d.add_path(p) + d.SetState(p,STATE_EXCLUDED) + d.add_path(testpath) + d.SetState(testpath,STATE_REFERENCE) + self.assertEqual(STATE_EXCLUDED,d.GetState(p)) + self.assertEqual(STATE_EXCLUDED,d.GetState(p + 'dir1')) + self.assertEqual(STATE_REFERENCE,d.GetState(testpath)) + + def test_SetState_keep_state_dict_size_to_minimum(self): + d = Directories() + p = Path(phys_test.create_fake_fs(self.tmpdir())) + d.add_path(p) + d.SetState(p,STATE_REFERENCE) + d.SetState(p + 'dir1',STATE_REFERENCE) + self.assertEqual(1,len(d.states)) + self.assertEqual(STATE_REFERENCE,d.GetState(p + 'dir1')) + d.SetState(p + 'dir1',STATE_NORMAL) + self.assertEqual(2,len(d.states)) + self.assertEqual(STATE_NORMAL,d.GetState(p + 'dir1')) + d.SetState(p + 'dir1',STATE_REFERENCE) + self.assertEqual(1,len(d.states)) + self.assertEqual(STATE_REFERENCE,d.GetState(p + 'dir1')) + + def test_get_files(self): + d = Directories() + p = Path(phys_test.create_fake_fs(self.tmpdir())) + d.add_path(p) + d.SetState(p + 'dir1',STATE_REFERENCE) + d.SetState(p + 'dir2',STATE_EXCLUDED) + files = d.get_files() + self.assertEqual(5, len(list(files))) + for f in files: + if f.parent.path == p + 'dir1': + self.assert_(f.is_ref) + else: + self.assert_(not f.is_ref) + + def test_get_files_with_inherited_exclusion(self): + d = Directories() + p = testpath + 'utils' + d.add_path(p) + d.SetState(p,STATE_EXCLUDED) + self.assertEqual([], list(d.get_files())) + + def test_save_and_load(self): + d1 = Directories() + d2 = Directories() + p1 = self.tmppath() + p2 = self.tmppath() + d1.add_path(p1) + d1.add_path(p2) + d1.SetState(p1, STATE_REFERENCE) + d1.SetState(p1 + 'dir1',STATE_EXCLUDED) + tmpxml = op.join(self.tmpdir(), 'directories_testunit.xml') + d1.SaveToFile(tmpxml) + d2.LoadFromFile(tmpxml) + self.assertEqual(2, len(d2)) + self.assertEqual(STATE_REFERENCE,d2.GetState(p1)) + self.assertEqual(STATE_EXCLUDED,d2.GetState(p1 + 'dir1')) + + def test_invalid_path(self): + d = Directories() + p = Path('does_not_exist') + self.assertRaises(InvalidPathError, d.add_path, p) + self.assertEqual(0, len(d)) + + def test_SetState_on_invalid_path(self): + d = Directories() + try: + d.SetState(Path('foobar',),STATE_NORMAL) + except LookupError: + self.fail() + + def test_default_dirclass(self): + self.assert_(Directories().dirclass is hsfs.phys.Directory) + + def test_dirclass(self): + class MySpecialDirclass(hsfs.phys.Directory): pass + d = Directories() + d.dirclass = MySpecialDirclass + d.add_path(testpath) + self.assert_(isinstance(d[0], MySpecialDirclass)) + + def test_LoadFromFile_with_invalid_path(self): + #This test simulates a load from file resulting in a + #InvalidPath raise. Other directories must be loaded. + d1 = Directories() + d1.add_path(testpath + 'utils') + #Will raise InvalidPath upon loading + d1.add_path(self.tmppath()).name = 'does_not_exist' + tmpxml = op.join(self.tmpdir(), 'directories_testunit.xml') + d1.SaveToFile(tmpxml) + d2 = Directories() + d2.LoadFromFile(tmpxml) + self.assertEqual(1, len(d2)) + + def test_LoadFromFile_with_same_paths(self): + #This test simulates a load from file resulting in a + #AlreadyExists raise. Other directories must be loaded. + d1 = Directories() + p1 = self.tmppath() + p2 = self.tmppath() + d1.add_path(p1) + d1.add_path(p2) + #Will raise AlreadyExists upon loading + d1.add_path(self.tmppath()).name = unicode(p1) + tmpxml = op.join(self.tmpdir(), 'directories_testunit.xml') + d1.SaveToFile(tmpxml) + d2 = Directories() + d2.LoadFromFile(tmpxml) + self.assertEqual(2, len(d2)) + + def test_Remove(self): + d = Directories() + d1 = d.add_path(self.tmppath()) + d2 = d.add_path(self.tmppath()) + d.Remove(d1) + self.assertEqual(1, len(d)) + self.assert_(d[0] is d2) + + def test_unicode_save(self): + d = Directories() + p1 = self.tmppath() + u'hello\xe9' + io.mkdir(p1) + io.mkdir(p1 + u'foo\xe9') + d.add_path(p1) + d.SetState(d[0][0].path, STATE_EXCLUDED) + tmpxml = op.join(self.tmpdir(), 'directories_testunit.xml') + try: + d.SaveToFile(tmpxml) + except UnicodeDecodeError: + self.fail() + + def test_get_files_refreshes_its_directories(self): + d = Directories() + p = Path(phys_test.create_fake_fs(self.tmpdir())) + d.add_path(p) + files = d.get_files() + self.assertEqual(6, len(list(files))) + time.sleep(1) + os.remove(str(p + ('dir1','file1.test'))) + files = d.get_files() + self.assertEqual(5, len(list(files))) + + def test_get_files_does_not_choke_on_non_existing_directories(self): + d = Directories() + p = Path(self.tmpdir()) + d.add_path(p) + io.rmtree(p) + self.assertEqual([], list(d.get_files())) + + def test_GetState_returns_excluded_by_default_for_hidden_directories(self): + d = Directories() + p = Path(self.tmpdir()) + hidden_dir_path = p + '.foo' + io.mkdir(p + '.foo') + d.add_path(p) + self.assertEqual(d.GetState(hidden_dir_path), STATE_EXCLUDED) + # But it can be overriden + d.SetState(hidden_dir_path, STATE_NORMAL) + self.assertEqual(d.GetState(hidden_dir_path), STATE_NORMAL) + + def test_special_dirclasses(self): + # if a path is in special_dirclasses, use this class instead + class MySpecialDirclass(hsfs.phys.Directory): pass + d = Directories() + p1 = self.tmppath() + p2 = self.tmppath() + d.special_dirclasses[p1] = MySpecialDirclass + self.assert_(isinstance(d.add_path(p2), hsfs.phys.Directory)) + self.assert_(isinstance(d.add_path(p1), MySpecialDirclass)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/pe/qt/dupeguru/engine.py b/pe/qt/dupeguru/engine.py new file mode 100644 index 00000000..a826902d --- /dev/null +++ b/pe/qt/dupeguru/engine.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.engine +Created By: Virgil Dupras +Created On: 2006/01/29 +Last modified by:$Author: virgil $ +Last modified on:$Date: $ + $Revision: $ +Copyright 2007 Hardcoded Software (http://www.hardcoded.net) +""" +from __future__ import division +import difflib +import logging +import string +from collections import defaultdict, namedtuple +from unicodedata import normalize + +from hsutil.str import multi_replace +from hsutil import job + +(WEIGHT_WORDS, +MATCH_SIMILAR_WORDS, +NO_FIELD_ORDER) = range(3) + +JOB_REFRESH_RATE = 100 + +def getwords(s): + if isinstance(s, unicode): + s = normalize('NFD', s) + s = multi_replace(s, "-_&+():;\\[]{}.,<>/?~!@#$*", ' ').lower() + s = ''.join(c for c in s if c in string.ascii_letters + string.digits + string.whitespace) + return filter(None, s.split(' ')) # filter() is to remove empty elements + +def getfields(s): + fields = [getwords(field) for field in s.split(' - ')] + return filter(None, fields) + +def unpack_fields(fields): + result = [] + for field in fields: + if isinstance(field, list): + result += field + else: + result.append(field) + return result + +def compare(first, second, flags=()): + """Returns the % of words that match between first and second + + The result is a int in the range 0..100. + First and second can be either a string or a list. + """ + if not (first and second): + return 0 + if any(isinstance(element, list) for element in first): + return compare_fields(first, second, flags) + second = second[:] #We must use a copy of second because we remove items from it + match_similar = MATCH_SIMILAR_WORDS in flags + weight_words = WEIGHT_WORDS in flags + joined = first + second + total_count = (sum(len(word) for word in joined) if weight_words else len(joined)) + match_count = 0 + in_order = True + for word in first: + if match_similar and (word not in second): + similar = difflib.get_close_matches(word, second, 1, 0.8) + if similar: + word = similar[0] + if word in second: + if second[0] != word: + in_order = False + second.remove(word) + match_count += (len(word) if weight_words else 1) + result = round(((match_count * 2) / total_count) * 100) + if (result == 100) and (not in_order): + result = 99 # We cannot consider a match exact unless the ordering is the same + return result + +def compare_fields(first, second, flags=()): + """Returns the score for the lowest matching fields. + + first and second must be lists of lists of string. + """ + if len(first) != len(second): + return 0 + if NO_FIELD_ORDER in flags: + results = [] + #We don't want to remove field directly in the list. We must work on a copy. + second = second[:] + for field1 in first: + max = 0 + matched_field = None + for field2 in second: + r = compare(field1, field2, flags) + if r > max: + max = r + matched_field = field2 + results.append(max) + if matched_field: + second.remove(matched_field) + else: + results = [compare(word1, word2, flags) for word1, word2 in zip(first, second)] + return min(results) if results else 0 + +def build_word_dict(objects, j=job.nulljob): + """Returns a dict of objects mapped by their words. + + objects must have a 'words' attribute being a list of strings or a list of lists of strings. + + The result will be a dict with words as keys, lists of objects as values. + """ + result = defaultdict(set) + for object in j.iter_with_progress(objects, 'Prepared %d/%d files', JOB_REFRESH_RATE): + for word in unpack_fields(object.words): + result[word].add(object) + return result + +def merge_similar_words(word_dict): + """Take all keys in word_dict that are similar, and merge them together. + """ + keys = word_dict.keys() + keys.sort(key=len)# we want the shortest word to stay + while keys: + key = keys.pop(0) + similars = difflib.get_close_matches(key, keys, 100, 0.8) + if not similars: + continue + objects = word_dict[key] + for similar in similars: + objects |= word_dict[similar] + del word_dict[similar] + keys.remove(similar) + +def reduce_common_words(word_dict, threshold): + """Remove all objects from word_dict values where the object count >= threshold + + The exception to this removal are the objects where all the words of the object are common. + Because if we remove them, we will miss some duplicates! + """ + uncommon_words = set(word for word, objects in word_dict.items() if len(objects) < threshold) + for word, objects in word_dict.items(): + if len(objects) < threshold: + continue + reduced = set() + for o in objects: + if not any(w in uncommon_words for w in unpack_fields(o.words)): + reduced.add(o) + if reduced: + word_dict[word] = reduced + else: + del word_dict[word] + +Match = namedtuple('Match', 'first second percentage') +def get_match(first, second, flags=()): + #it is assumed here that first and second both have a "words" attribute + percentage = compare(first.words, second.words, flags) + return Match(first, second, percentage) + +class MatchFactory(object): + common_word_threshold = 50 + match_similar_words = False + min_match_percentage = 0 + weight_words = False + no_field_order = False + limit = 5000000 + + def getmatches(self, objects, j=job.nulljob): + j = j.start_subjob(2) + sj = j.start_subjob(2) + for o in objects: + if not hasattr(o, 'words'): + o.words = getwords(o.name) + word_dict = build_word_dict(objects, sj) + reduce_common_words(word_dict, self.common_word_threshold) + if self.match_similar_words: + merge_similar_words(word_dict) + match_flags = [] + if self.weight_words: + match_flags.append(WEIGHT_WORDS) + if self.match_similar_words: + match_flags.append(MATCH_SIMILAR_WORDS) + if self.no_field_order: + match_flags.append(NO_FIELD_ORDER) + j.start_job(len(word_dict), '0 matches found') + compared = defaultdict(set) + result = [] + try: + # This whole 'popping' thing is there to avoid taking too much memory at the same time. + while word_dict: + items = word_dict.popitem()[1] + while items: + ref = items.pop() + compared_already = compared[ref] + to_compare = items - compared_already + compared_already |= to_compare + for other in to_compare: + m = get_match(ref, other, match_flags) + if m.percentage >= self.min_match_percentage: + result.append(m) + if len(result) >= self.limit: + return result + j.add_progress(desc='%d matches found' % len(result)) + except MemoryError: + # This is the place where the memory usage is at its peak during the scan. + # Just continue the process with an incomplete list of matches. + del compared # This should give us enough room to call logging. + logging.warning('Memory Overflow. Matches: %d. Word dict: %d' % (len(result), len(word_dict))) + return result + return result + + +class Group(object): + #---Override + def __init__(self): + self._clear() + + def __contains__(self, item): + return item in self.unordered + + def __getitem__(self, key): + return self.ordered.__getitem__(key) + + def __iter__(self): + return iter(self.ordered) + + def __len__(self): + return len(self.ordered) + + #---Private + def _clear(self): + self._percentage = None + self._matches_for_ref = None + self.matches = set() + self.candidates = defaultdict(set) + self.ordered = [] + self.unordered = set() + + def _get_matches_for_ref(self): + if self._matches_for_ref is None: + ref = self.ref + self._matches_for_ref = [match for match in self.matches if ref in match] + return self._matches_for_ref + + #---Public + def add_match(self, match): + def add_candidate(item, match): + matches = self.candidates[item] + matches.add(match) + if self.unordered <= matches: + self.ordered.append(item) + self.unordered.add(item) + + if match in self.matches: + return + self.matches.add(match) + first, second, _ = match + if first not in self.unordered: + add_candidate(first, second) + if second not in self.unordered: + add_candidate(second, first) + self._percentage = None + self._matches_for_ref = None + + def clean_matches(self): + self.matches = set(m for m in self.matches if (m.first in self.unordered) and (m.second in self.unordered)) + self.candidates = defaultdict(set) + + def get_match_of(self, item): + if item is self.ref: + return + for m in self._get_matches_for_ref(): + if item in m: + return m + + def prioritize(self, key_func, tie_breaker=None): + # tie_breaker(ref, dupe) --> True if dupe should be ref + self.ordered.sort(key=key_func) + if tie_breaker is None: + return + ref = self.ref + key_value = key_func(ref) + for dupe in self.dupes: + if key_func(dupe) != key_value: + break + if tie_breaker(ref, dupe): + ref = dupe + if ref is not self.ref: + self.switch_ref(ref) + + def remove_dupe(self, item, clean_matches=True): + try: + self.ordered.remove(item) + self.unordered.remove(item) + self._percentage = None + self._matches_for_ref = None + if (len(self) > 1) and any(not getattr(item, 'is_ref', False) for item in self): + if clean_matches: + self.matches = set(m for m in self.matches if item not in m) + else: + self._clear() + except ValueError: + pass + + def switch_ref(self, with_dupe): + try: + self.ordered.remove(with_dupe) + self.ordered.insert(0, with_dupe) + self._percentage = None + self._matches_for_ref = None + except ValueError: + pass + + dupes = property(lambda self: self[1:]) + + @property + def percentage(self): + if self._percentage is None: + if self.dupes: + matches = self._get_matches_for_ref() + self._percentage = sum(match.percentage for match in matches) // len(matches) + else: + self._percentage = 0 + return self._percentage + + @property + def ref(self): + if self: + return self[0] + + +def get_groups(matches, j=job.nulljob): + matches.sort(key=lambda match: -match.percentage) + dupe2group = {} + groups = [] + for match in j.iter_with_progress(matches, 'Grouped %d/%d matches', JOB_REFRESH_RATE): + first, second, _ = match + first_group = dupe2group.get(first) + second_group = dupe2group.get(second) + if first_group: + if second_group: + if first_group is second_group: + target_group = first_group + else: + continue + else: + target_group = first_group + dupe2group[second] = target_group + else: + if second_group: + target_group = second_group + dupe2group[first] = target_group + else: + target_group = Group() + groups.append(target_group) + dupe2group[first] = target_group + dupe2group[second] = target_group + target_group.add_match(match) + for group in groups: + group.clean_matches() + return groups diff --git a/pe/qt/dupeguru/engine_test.py b/pe/qt/dupeguru/engine_test.py new file mode 100644 index 00000000..8e9706d9 --- /dev/null +++ b/pe/qt/dupeguru/engine_test.py @@ -0,0 +1,822 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.engine_test +Created By: Virgil Dupras +Created On: 2006/01/29 +Last modified by:$Author: virgil $ +Last modified on:$Date: $ + $Revision: $ +Copyright 2004-2008 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import sys + +from hsutil import job +from hsutil.decorators import log_calls +from hsutil.testcase import TestCase + +from . import engine +from .engine import * + +class NamedObject(object): + def __init__(self, name="foobar", with_words=False): + self.name = name + if with_words: + self.words = getwords(name) + + +def get_match_triangle(): + o1 = NamedObject(with_words=True) + o2 = NamedObject(with_words=True) + o3 = NamedObject(with_words=True) + m1 = get_match(o1,o2) + m2 = get_match(o1,o3) + m3 = get_match(o2,o3) + return [m1, m2, m3] + +def get_test_group(): + m1, m2, m3 = get_match_triangle() + result = Group() + result.add_match(m1) + result.add_match(m2) + result.add_match(m3) + return result + +class TCgetwords(TestCase): + def test_spaces(self): + self.assertEqual(['a', 'b', 'c', 'd'], getwords("a b c d")) + self.assertEqual(['a', 'b', 'c', 'd'], getwords(" a b c d ")) + + def test_splitter_chars(self): + self.assertEqual( + [chr(i) for i in xrange(ord('a'),ord('z')+1)], + getwords("a-b_c&d+e(f)g;h\\i[j]k{l}m:n.o,pr/s?t~u!v@w#x$y*z") + ) + + def test_joiner_chars(self): + self.assertEqual(["aec"], getwords(u"a'e\u0301c")) + + def test_empty(self): + self.assertEqual([], getwords('')) + + def test_returns_lowercase(self): + self.assertEqual(['foo', 'bar'], getwords('FOO BAR')) + + def test_decompose_unicode(self): + self.assertEqual(getwords(u'foo\xe9bar'), ['fooebar']) + + +class TCgetfields(TestCase): + def test_simple(self): + self.assertEqual([['a', 'b'], ['c', 'd', 'e']], getfields('a b - c d e')) + + def test_empty(self): + self.assertEqual([], getfields('')) + + def test_cleans_empty_fields(self): + expected = [['a', 'bc', 'def']] + actual = getfields(' - a bc def') + self.assertEqual(expected, actual) + expected = [['bc', 'def']] + + +class TCunpack_fields(TestCase): + def test_with_fields(self): + expected = ['a', 'b', 'c', 'd', 'e', 'f'] + actual = unpack_fields([['a'], ['b', 'c'], ['d', 'e', 'f']]) + self.assertEqual(expected, actual) + + def test_without_fields(self): + expected = ['a', 'b', 'c', 'd', 'e', 'f'] + actual = unpack_fields(['a', 'b', 'c', 'd', 'e', 'f']) + self.assertEqual(expected, actual) + + def test_empty(self): + self.assertEqual([], unpack_fields([])) + + +class TCWordCompare(TestCase): + def test_list(self): + self.assertEqual(100, compare(['a', 'b', 'c', 'd'],['a', 'b', 'c', 'd'])) + self.assertEqual(86, compare(['a', 'b', 'c', 'd'],['a', 'b', 'c'])) + + def test_unordered(self): + #Sometimes, users don't want fuzzy matching too much When they set the slider + #to 100, they don't expect a filename with the same words, but not the same order, to match. + #Thus, we want to return 99 in that case. + self.assertEqual(99, compare(['a', 'b', 'c', 'd'], ['d', 'b', 'c', 'a'])) + + def test_word_occurs_twice(self): + #if a word occurs twice in first, but once in second, we want the word to be only counted once + self.assertEqual(89, compare(['a', 'b', 'c', 'd', 'a'], ['d', 'b', 'c', 'a'])) + + def test_uses_copy_of_lists(self): + first = ['foo', 'bar'] + second = ['bar', 'bleh'] + compare(first, second) + self.assertEqual(['foo', 'bar'], first) + self.assertEqual(['bar', 'bleh'], second) + + def test_word_weight(self): + self.assertEqual(int((6.0 / 13.0) * 100), compare(['foo', 'bar'], ['bar', 'bleh'], (WEIGHT_WORDS, ))) + + def test_similar_words(self): + self.assertEqual(100, compare(['the', 'white', 'stripes'],['the', 'whites', 'stripe'], (MATCH_SIMILAR_WORDS, ))) + + def test_empty(self): + self.assertEqual(0, compare([], [])) + + def test_with_fields(self): + self.assertEqual(67, compare([['a', 'b'], ['c', 'd', 'e']], [['a', 'b'], ['c', 'd', 'f']])) + + def test_propagate_flags_with_fields(self): + def mock_compare(first, second, flags): + self.assertEqual((0, 1, 2, 3, 5), flags) + + self.mock(engine, 'compare_fields', mock_compare) + compare([['a']], [['a']], (0, 1, 2, 3, 5)) + + +class TCWordCompareWithFields(TestCase): + def test_simple(self): + self.assertEqual(67, compare_fields([['a', 'b'], ['c', 'd', 'e']], [['a', 'b'], ['c', 'd', 'f']])) + + def test_empty(self): + self.assertEqual(0, compare_fields([], [])) + + def test_different_length(self): + self.assertEqual(0, compare_fields([['a'], ['b']], [['a'], ['b'], ['c']])) + + def test_propagates_flags(self): + def mock_compare(first, second, flags): + self.assertEqual((0, 1, 2, 3, 5), flags) + + self.mock(engine, 'compare_fields', mock_compare) + compare_fields([['a']], [['a']],(0, 1, 2, 3, 5)) + + def test_order(self): + first = [['a', 'b'], ['c', 'd', 'e']] + second = [['c', 'd', 'f'], ['a', 'b']] + self.assertEqual(0, compare_fields(first, second)) + + def test_no_order(self): + first = [['a','b'],['c','d','e']] + second = [['c','d','f'],['a','b']] + self.assertEqual(67, compare_fields(first, second, (NO_FIELD_ORDER, ))) + first = [['a','b'],['a','b']] #a field can only be matched once. + second = [['c','d','f'],['a','b']] + self.assertEqual(0, compare_fields(first, second, (NO_FIELD_ORDER, ))) + first = [['a','b'],['a','b','c']] + second = [['c','d','f'],['a','b']] + self.assertEqual(33, compare_fields(first, second, (NO_FIELD_ORDER, ))) + + def test_compare_fields_without_order_doesnt_alter_fields(self): + #The NO_ORDER comp type altered the fields! + first = [['a','b'],['c','d','e']] + second = [['c','d','f'],['a','b']] + self.assertEqual(67, compare_fields(first, second, (NO_FIELD_ORDER, ))) + self.assertEqual([['a','b'],['c','d','e']],first) + self.assertEqual([['c','d','f'],['a','b']],second) + + +class TCbuild_word_dict(TestCase): + def test_with_standard_words(self): + l = [NamedObject('foo bar',True)] + l.append(NamedObject('bar baz',True)) + l.append(NamedObject('baz bleh foo',True)) + d = build_word_dict(l) + self.assertEqual(4,len(d)) + self.assertEqual(2,len(d['foo'])) + self.assert_(l[0] in d['foo']) + self.assert_(l[2] in d['foo']) + self.assertEqual(2,len(d['bar'])) + self.assert_(l[0] in d['bar']) + self.assert_(l[1] in d['bar']) + self.assertEqual(2,len(d['baz'])) + self.assert_(l[1] in d['baz']) + self.assert_(l[2] in d['baz']) + self.assertEqual(1,len(d['bleh'])) + self.assert_(l[2] in d['bleh']) + + def test_unpack_fields(self): + o = NamedObject('') + o.words = [['foo','bar'],['baz']] + d = build_word_dict([o]) + self.assertEqual(3,len(d)) + self.assertEqual(1,len(d['foo'])) + + def test_words_are_unaltered(self): + o = NamedObject('') + o.words = [['foo','bar'],['baz']] + d = build_word_dict([o]) + self.assertEqual([['foo','bar'],['baz']],o.words) + + def test_object_instances_can_only_be_once_in_words_object_list(self): + o = NamedObject('foo foo',True) + d = build_word_dict([o]) + self.assertEqual(1,len(d['foo'])) + + def test_job(self): + def do_progress(p,d=''): + self.log.append(p) + return True + + j = job.Job(1,do_progress) + self.log = [] + s = "foo bar" + build_word_dict([NamedObject(s, True), NamedObject(s, True), NamedObject(s, True)], j) + self.assertEqual(0,self.log[0]) + self.assertEqual(33,self.log[1]) + self.assertEqual(66,self.log[2]) + self.assertEqual(100,self.log[3]) + + +class TCmerge_similar_words(TestCase): + def test_some_similar_words(self): + d = { + 'foobar':set([1]), + 'foobar1':set([2]), + 'foobar2':set([3]), + } + merge_similar_words(d) + self.assertEqual(1,len(d)) + self.assertEqual(3,len(d['foobar'])) + + + +class TCreduce_common_words(TestCase): + def test_typical(self): + d = { + 'foo': set([NamedObject('foo bar',True) for i in range(50)]), + 'bar': set([NamedObject('foo bar',True) for i in range(49)]) + } + reduce_common_words(d, 50) + self.assert_('foo' not in d) + self.assertEqual(49,len(d['bar'])) + + def test_dont_remove_objects_with_only_common_words(self): + d = { + 'common': set([NamedObject("common uncommon",True) for i in range(50)] + [NamedObject("common",True)]), + 'uncommon': set([NamedObject("common uncommon",True)]) + } + reduce_common_words(d, 50) + self.assertEqual(1,len(d['common'])) + self.assertEqual(1,len(d['uncommon'])) + + def test_values_still_are_set_instances(self): + d = { + 'common': set([NamedObject("common uncommon",True) for i in range(50)] + [NamedObject("common",True)]), + 'uncommon': set([NamedObject("common uncommon",True)]) + } + reduce_common_words(d, 50) + self.assert_(isinstance(d['common'],set)) + self.assert_(isinstance(d['uncommon'],set)) + + def test_dont_raise_KeyError_when_a_word_has_been_removed(self): + #If a word has been removed by the reduce, an object in a subsequent common word that + #contains the word that has been removed would cause a KeyError. + d = { + 'foo': set([NamedObject('foo bar baz',True) for i in range(50)]), + 'bar': set([NamedObject('foo bar baz',True) for i in range(50)]), + 'baz': set([NamedObject('foo bar baz',True) for i in range(49)]) + } + try: + reduce_common_words(d, 50) + except KeyError: + self.fail() + + def test_unpack_fields(self): + #object.words may be fields. + def create_it(): + o = NamedObject('') + o.words = [['foo','bar'],['baz']] + return o + + d = { + 'foo': set([create_it() for i in range(50)]) + } + try: + reduce_common_words(d, 50) + except TypeError: + self.fail("must support fields.") + + def test_consider_a_reduced_common_word_common_even_after_reduction(self): + #There was a bug in the code that causeda word that has already been reduced not to + #be counted as a common word for subsequent words. For example, if 'foo' is processed + #as a common word, keeping a "foo bar" file in it, and the 'bar' is processed, "foo bar" + #would not stay in 'bar' because 'foo' is not a common word anymore. + only_common = NamedObject('foo bar',True) + d = { + 'foo': set([NamedObject('foo bar baz',True) for i in range(49)] + [only_common]), + 'bar': set([NamedObject('foo bar baz',True) for i in range(49)] + [only_common]), + 'baz': set([NamedObject('foo bar baz',True) for i in range(49)]) + } + reduce_common_words(d, 50) + self.assertEqual(1,len(d['foo'])) + self.assertEqual(1,len(d['bar'])) + self.assertEqual(49,len(d['baz'])) + + +class TCget_match(TestCase): + def test_simple(self): + o1 = NamedObject("foo bar",True) + o2 = NamedObject("bar bleh",True) + m = get_match(o1,o2) + self.assertEqual(50,m.percentage) + self.assertEqual(['foo','bar'],m.first.words) + self.assertEqual(['bar','bleh'],m.second.words) + self.assert_(m.first is o1) + self.assert_(m.second is o2) + + def test_in(self): + o1 = NamedObject("foo",True) + o2 = NamedObject("bar",True) + m = get_match(o1,o2) + self.assert_(o1 in m) + self.assert_(o2 in m) + self.assert_(object() not in m) + + def test_word_weight(self): + self.assertEqual(int((6.0 / 13.0) * 100),get_match(NamedObject("foo bar",True),NamedObject("bar bleh",True),(WEIGHT_WORDS,)).percentage) + + +class TCMatchFactory(TestCase): + def test_empty(self): + self.assertEqual([],MatchFactory().getmatches([])) + + def test_defaults(self): + mf = MatchFactory() + self.assertEqual(50,mf.common_word_threshold) + self.assertEqual(False,mf.weight_words) + self.assertEqual(False,mf.match_similar_words) + self.assertEqual(False,mf.no_field_order) + self.assertEqual(0,mf.min_match_percentage) + + def test_simple(self): + l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("a b c foo")] + r = MatchFactory().getmatches(l) + self.assertEqual(2,len(r)) + seek = [m for m in r if m.percentage == 50] #"foo bar" and "bar bleh" + m = seek[0] + self.assertEqual(['foo','bar'],m.first.words) + self.assertEqual(['bar','bleh'],m.second.words) + seek = [m for m in r if m.percentage == 33] #"foo bar" and "a b c foo" + m = seek[0] + self.assertEqual(['foo','bar'],m.first.words) + self.assertEqual(['a','b','c','foo'],m.second.words) + + def test_null_and_unrelated_objects(self): + l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject(""),NamedObject("unrelated object")] + r = MatchFactory().getmatches(l) + self.assertEqual(1,len(r)) + m = r[0] + self.assertEqual(50,m.percentage) + self.assertEqual(['foo','bar'],m.first.words) + self.assertEqual(['bar','bleh'],m.second.words) + + def test_twice_the_same_word(self): + l = [NamedObject("foo foo bar"),NamedObject("bar bleh")] + r = MatchFactory().getmatches(l) + self.assertEqual(1,len(r)) + + def test_twice_the_same_word_when_preworded(self): + l = [NamedObject("foo foo bar",True),NamedObject("bar bleh",True)] + r = MatchFactory().getmatches(l) + self.assertEqual(1,len(r)) + + def test_two_words_match(self): + l = [NamedObject("foo bar"),NamedObject("foo bar bleh")] + r = MatchFactory().getmatches(l) + self.assertEqual(1,len(r)) + + def test_match_files_with_only_common_words(self): + #If a word occurs more than 50 times, it is excluded from the matching process + #The problem with the common_word_threshold is that the files containing only common + #words will never be matched together. We *should* match them. + mf = MatchFactory() + mf.common_word_threshold = 50 + l = [NamedObject("foo") for i in range(50)] + r = mf.getmatches(l) + self.assertEqual(1225,len(r)) + + def test_use_words_already_there_if_there(self): + o1 = NamedObject('foo') + o2 = NamedObject('bar') + o2.words = ['foo'] + self.assertEqual(1,len(MatchFactory().getmatches([o1,o2]))) + + def test_job(self): + def do_progress(p,d=''): + self.log.append(p) + return True + + j = job.Job(1,do_progress) + self.log = [] + s = "foo bar" + MatchFactory().getmatches([NamedObject(s),NamedObject(s),NamedObject(s)],j) + self.assert_(len(self.log) > 2) + self.assertEqual(0,self.log[0]) + self.assertEqual(100,self.log[-1]) + + def test_weight_words(self): + mf = MatchFactory() + mf.weight_words = True + l = [NamedObject("foo bar"),NamedObject("bar bleh")] + m = mf.getmatches(l)[0] + self.assertEqual(int((6.0 / 13.0) * 100),m.percentage) + + def test_similar_word(self): + mf = MatchFactory() + mf.match_similar_words = True + l = [NamedObject("foobar"),NamedObject("foobars")] + self.assertEqual(1,len(mf.getmatches(l))) + self.assertEqual(100,mf.getmatches(l)[0].percentage) + l = [NamedObject("foobar"),NamedObject("foo")] + self.assertEqual(0,len(mf.getmatches(l))) #too far + l = [NamedObject("bizkit"),NamedObject("bizket")] + self.assertEqual(1,len(mf.getmatches(l))) + l = [NamedObject("foobar"),NamedObject("foosbar")] + self.assertEqual(1,len(mf.getmatches(l))) + + def test_single_object_with_similar_words(self): + mf = MatchFactory() + mf.match_similar_words = True + l = [NamedObject("foo foos")] + self.assertEqual(0,len(mf.getmatches(l))) + + def test_double_words_get_counted_only_once(self): + mf = MatchFactory() + l = [NamedObject("foo bar foo bleh"),NamedObject("foo bar bleh bar")] + m = mf.getmatches(l)[0] + self.assertEqual(75,m.percentage) + + def test_with_fields(self): + mf = MatchFactory() + o1 = NamedObject("foo bar - foo bleh") + o2 = NamedObject("foo bar - bleh bar") + o1.words = getfields(o1.name) + o2.words = getfields(o2.name) + m = mf.getmatches([o1, o2])[0] + self.assertEqual(50, m.percentage) + + def test_with_fields_no_order(self): + mf = MatchFactory() + mf.no_field_order = True + o1 = NamedObject("foo bar - foo bleh") + o2 = NamedObject("bleh bang - foo bar") + o1.words = getfields(o1.name) + o2.words = getfields(o2.name) + m = mf.getmatches([o1, o2])[0] + self.assertEqual(50 ,m.percentage) + + def test_only_match_similar_when_the_option_is_set(self): + mf = MatchFactory() + mf.match_similar_words = False + l = [NamedObject("foobar"),NamedObject("foobars")] + self.assertEqual(0,len(mf.getmatches(l))) + + def test_dont_recurse_do_match(self): + # with nosetests, the stack is increased. The number has to be high enough not to be failing falsely + sys.setrecursionlimit(100) + mf = MatchFactory() + files = [NamedObject('foo bar') for i in range(101)] + try: + mf.getmatches(files) + except RuntimeError: + self.fail() + finally: + sys.setrecursionlimit(1000) + + def test_min_match_percentage(self): + l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("a b c foo")] + mf = MatchFactory() + mf.min_match_percentage = 50 + r = mf.getmatches(l) + self.assertEqual(1,len(r)) #Only "foo bar" / "bar bleh" should match + + def test_limit(self): + l = [NamedObject(),NamedObject(),NamedObject()] + mf = MatchFactory() + mf.limit = 2 + r = mf.getmatches(l) + self.assertEqual(2,len(r)) + + def test_MemoryError(self): + @log_calls + def mocked_match(first, second, flags): + if len(mocked_match.calls) > 42: + raise MemoryError() + return Match(first, second, 0) + + objects = [NamedObject() for i in range(10)] # results in 45 matches + self.mock(engine, 'get_match', mocked_match) + mf = MatchFactory() + try: + r = mf.getmatches(objects) + except MemoryError: + self.fail('MemorryError must be handled') + self.assertEqual(42, len(r)) + + +class TCGroup(TestCase): + def test_empy(self): + g = Group() + self.assertEqual(None,g.ref) + self.assertEqual([],g.dupes) + self.assertEqual(0,len(g.matches)) + + def test_add_match(self): + g = Group() + m = get_match(NamedObject("foo",True),NamedObject("bar",True)) + g.add_match(m) + self.assert_(g.ref is m.first) + self.assertEqual([m.second],g.dupes) + self.assertEqual(1,len(g.matches)) + self.assert_(m in g.matches) + + def test_multiple_add_match(self): + g = Group() + o1 = NamedObject("a",True) + o2 = NamedObject("b",True) + o3 = NamedObject("c",True) + o4 = NamedObject("d",True) + g.add_match(get_match(o1,o2)) + self.assert_(g.ref is o1) + self.assertEqual([o2],g.dupes) + self.assertEqual(1,len(g.matches)) + g.add_match(get_match(o1,o3)) + self.assertEqual([o2],g.dupes) + self.assertEqual(2,len(g.matches)) + g.add_match(get_match(o2,o3)) + self.assertEqual([o2,o3],g.dupes) + self.assertEqual(3,len(g.matches)) + g.add_match(get_match(o1,o4)) + self.assertEqual([o2,o3],g.dupes) + self.assertEqual(4,len(g.matches)) + g.add_match(get_match(o2,o4)) + self.assertEqual([o2,o3],g.dupes) + self.assertEqual(5,len(g.matches)) + g.add_match(get_match(o3,o4)) + self.assertEqual([o2,o3,o4],g.dupes) + self.assertEqual(6,len(g.matches)) + + def test_len(self): + g = Group() + self.assertEqual(0,len(g)) + g.add_match(get_match(NamedObject("foo",True),NamedObject("bar",True))) + self.assertEqual(2,len(g)) + + def test_add_same_match_twice(self): + g = Group() + m = get_match(NamedObject("foo",True),NamedObject("foo",True)) + g.add_match(m) + self.assertEqual(2,len(g)) + self.assertEqual(1,len(g.matches)) + g.add_match(m) + self.assertEqual(2,len(g)) + self.assertEqual(1,len(g.matches)) + + def test_in(self): + g = Group() + o1 = NamedObject("foo",True) + o2 = NamedObject("bar",True) + self.assert_(o1 not in g) + g.add_match(get_match(o1,o2)) + self.assert_(o1 in g) + self.assert_(o2 in g) + + def test_remove(self): + g = Group() + o1 = NamedObject("foo",True) + o2 = NamedObject("bar",True) + o3 = NamedObject("bleh",True) + g.add_match(get_match(o1,o2)) + g.add_match(get_match(o1,o3)) + g.add_match(get_match(o2,o3)) + self.assertEqual(3,len(g.matches)) + self.assertEqual(3,len(g)) + g.remove_dupe(o3) + self.assertEqual(1,len(g.matches)) + self.assertEqual(2,len(g)) + g.remove_dupe(o1) + self.assertEqual(0,len(g.matches)) + self.assertEqual(0,len(g)) + + def test_remove_with_ref_dupes(self): + g = Group() + o1 = NamedObject("foo",True) + o2 = NamedObject("bar",True) + o3 = NamedObject("bleh",True) + g.add_match(get_match(o1,o2)) + g.add_match(get_match(o1,o3)) + g.add_match(get_match(o2,o3)) + o1.is_ref = True + o2.is_ref = True + g.remove_dupe(o3) + self.assertEqual(0,len(g)) + + def test_switch_ref(self): + o1 = NamedObject(with_words=True) + o2 = NamedObject(with_words=True) + g = Group() + g.add_match(get_match(o1,o2)) + self.assert_(o1 is g.ref) + g.switch_ref(o2) + self.assert_(o2 is g.ref) + self.assertEqual([o1],g.dupes) + g.switch_ref(o2) + self.assert_(o2 is g.ref) + g.switch_ref(NamedObject('',True)) + self.assert_(o2 is g.ref) + + def test_get_match_of(self): + g = Group() + for m in get_match_triangle(): + g.add_match(m) + o = g.dupes[0] + m = g.get_match_of(o) + self.assert_(g.ref in m) + self.assert_(o in m) + self.assert_(g.get_match_of(NamedObject('',True)) is None) + self.assert_(g.get_match_of(g.ref) is None) + + def test_percentage(self): + #percentage should return the avg percentage in relation to the ref + m1,m2,m3 = get_match_triangle() + m1 = Match(m1[0], m1[1], 100) + m2 = Match(m2[0], m2[1], 50) + m3 = Match(m3[0], m3[1], 33) + g = Group() + g.add_match(m1) + g.add_match(m2) + g.add_match(m3) + self.assertEqual(75,g.percentage) + g.switch_ref(g.dupes[0]) + self.assertEqual(66,g.percentage) + g.remove_dupe(g.dupes[0]) + self.assertEqual(33,g.percentage) + g.add_match(m1) + g.add_match(m2) + self.assertEqual(66,g.percentage) + + def test_percentage_on_empty_group(self): + g = Group() + self.assertEqual(0,g.percentage) + + def test_prioritize(self): + m1,m2,m3 = get_match_triangle() + o1 = m1.first + o2 = m1.second + o3 = m2.second + o1.name = 'c' + o2.name = 'b' + o3.name = 'a' + g = Group() + g.add_match(m1) + g.add_match(m2) + g.add_match(m3) + self.assert_(o1 is g.ref) + g.prioritize(lambda x:x.name) + self.assert_(o3 is g.ref) + + def test_prioritize_with_tie_breaker(self): + # if the ref has the same key as one or more of the dupe, run the tie_breaker func among them + g = get_test_group() + o1, o2, o3 = g.ordered + tie_breaker = lambda ref, dupe: dupe is o3 + g.prioritize(lambda x:0, tie_breaker) + self.assertTrue(g.ref is o3) + + def test_prioritize_with_tie_breaker_runs_on_all_dupes(self): + # Even if a dupe is chosen to switch with ref with a tie breaker, we still run the tie breaker + # with other dupes and the newly chosen ref + g = get_test_group() + o1, o2, o3 = g.ordered + o1.foo = 1 + o2.foo = 2 + o3.foo = 3 + tie_breaker = lambda ref, dupe: dupe.foo > ref.foo + g.prioritize(lambda x:0, tie_breaker) + self.assertTrue(g.ref is o3) + + def test_prioritize_with_tie_breaker_runs_only_on_tie_dupes(self): + # The tie breaker only runs on dupes that had the same value for the key_func + g = get_test_group() + o1, o2, o3 = g.ordered + o1.foo = 2 + o2.foo = 2 + o3.foo = 1 + o1.bar = 1 + o2.bar = 2 + o3.bar = 3 + key_func = lambda x: -x.foo + tie_breaker = lambda ref, dupe: dupe.bar > ref.bar + g.prioritize(key_func, tie_breaker) + self.assertTrue(g.ref is o2) + + def test_list_like(self): + g = Group() + o1,o2 = (NamedObject("foo",True),NamedObject("bar",True)) + g.add_match(get_match(o1,o2)) + self.assert_(g[0] is o1) + self.assert_(g[1] is o2) + + def test_clean_matches(self): + g = Group() + o1,o2,o3 = (NamedObject("foo",True),NamedObject("bar",True),NamedObject("baz",True)) + g.add_match(get_match(o1,o2)) + g.add_match(get_match(o1,o3)) + g.clean_matches() + self.assertEqual(1,len(g.matches)) + self.assertEqual(0,len(g.candidates)) + + +class TCget_groups(TestCase): + def test_empty(self): + r = get_groups([]) + self.assertEqual([],r) + + def test_simple(self): + l = [NamedObject("foo bar"),NamedObject("bar bleh")] + matches = MatchFactory().getmatches(l) + m = matches[0] + r = get_groups(matches) + self.assertEqual(1,len(r)) + g = r[0] + self.assert_(g.ref is m.first) + self.assertEqual([m.second],g.dupes) + + def test_group_with_multiple_matches(self): + #This results in 3 matches + l = [NamedObject("foo"),NamedObject("foo"),NamedObject("foo")] + matches = MatchFactory().getmatches(l) + r = get_groups(matches) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(3,len(g)) + + def test_must_choose_a_group(self): + l = [NamedObject("a b"),NamedObject("a b"),NamedObject("b c"),NamedObject("c d"),NamedObject("c d")] + #There will be 2 groups here: group "a b" and group "c d" + #"b c" can go either of them, but not both. + matches = MatchFactory().getmatches(l) + r = get_groups(matches) + self.assertEqual(2,len(r)) + self.assertEqual(5,len(r[0])+len(r[1])) + + def test_should_all_go_in_the_same_group(self): + l = [NamedObject("a b"),NamedObject("a b"),NamedObject("a b"),NamedObject("a b")] + #There will be 2 groups here: group "a b" and group "c d" + #"b c" can fit in both, but it must be in only one of them + matches = MatchFactory().getmatches(l) + r = get_groups(matches) + self.assertEqual(1,len(r)) + + def test_give_priority_to_matches_with_higher_percentage(self): + o1 = NamedObject(with_words=True) + o2 = NamedObject(with_words=True) + o3 = NamedObject(with_words=True) + m1 = Match(o1, o2, 1) + m2 = Match(o2, o3, 2) + r = get_groups([m1,m2]) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(2,len(g)) + self.assert_(o1 not in g) + self.assert_(o2 in g) + self.assert_(o3 in g) + + def test_four_sized_group(self): + l = [NamedObject("foobar") for i in xrange(4)] + m = MatchFactory().getmatches(l) + r = get_groups(m) + self.assertEqual(1,len(r)) + self.assertEqual(4,len(r[0])) + + def test_referenced_by_ref2(self): + o1 = NamedObject(with_words=True) + o2 = NamedObject(with_words=True) + o3 = NamedObject(with_words=True) + m1 = get_match(o1,o2) + m2 = get_match(o3,o1) + m3 = get_match(o3,o2) + r = get_groups([m1,m2,m3]) + self.assertEqual(3,len(r[0])) + + def test_job(self): + def do_progress(p,d=''): + self.log.append(p) + return True + + self.log = [] + j = job.Job(1,do_progress) + m1,m2,m3 = get_match_triangle() + #101%: To make sure it is processed first so the job test works correctly + m4 = Match(NamedObject('a',True), NamedObject('a',True), 101) + get_groups([m1,m2,m3,m4],j) + self.assertEqual(0,self.log[0]) + self.assertEqual(100,self.log[-1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pe/qt/dupeguru/export.py b/pe/qt/dupeguru/export.py new file mode 100644 index 00000000..c6293a5d --- /dev/null +++ b/pe/qt/dupeguru/export.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.export +Created By: Virgil Dupras +Created On: 2006/09/16 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +from xml.dom import minidom +import tempfile +import os.path as op +import os +from StringIO import StringIO + +from hsutil.files import FileOrPath + +def output_column_xml(outfile, columns): + """Creates a xml file outfile with the supplied columns. + + outfile can be a filename or a file object. + columns is a list of 2 sized tuples (display,enabled) + """ + doc = minidom.Document() + root = doc.appendChild(doc.createElement('columns')) + for display,enabled in columns: + col_node = root.appendChild(doc.createElement('column')) + col_node.setAttribute('display', display) + col_node.setAttribute('enabled', {True:'y',False:'n'}[enabled]) + with FileOrPath(outfile, 'wb') as fp: + doc.writexml(fp, '\t','\t','\n', encoding='utf-8') + +def merge_css_into_xhtml(xhtml, css): + with FileOrPath(xhtml, 'r+') as xhtml: + with FileOrPath(css) as css: + try: + doc = minidom.parse(xhtml) + except Exception: + return False + head = doc.getElementsByTagName('head')[0] + links = head.getElementsByTagName('link') + for link in links: + if link.getAttribute('rel') == 'stylesheet': + head.removeChild(link) + style = head.appendChild(doc.createElement('style')) + style.setAttribute('type','text/css') + style.appendChild(doc.createTextNode(css.read())) + xhtml.truncate(0) + doc.writexml(xhtml, '\t','\t','\n', encoding='utf-8') + xhtml.seek(0) + return True + +def export_to_xhtml(xml, xslt, css, columns, cmd='xsltproc --path "%(folder)s" "%(xslt)s" "%(xml)s"'): + folder = op.split(xml)[0] + output_column_xml(op.join(folder,'columns.xml'),columns) + html = StringIO() + cmd = cmd % {'folder': folder, 'xslt': xslt, 'xml': xml} + html.write(os.popen(cmd).read()) + html.seek(0) + merge_css_into_xhtml(html,css) + html.seek(0) + html_path = op.join(folder,'export.htm') + html_file = open(html_path,'w') + html_file.write(html.read().encode('utf-8')) + html_file.close() + return html_path diff --git a/pe/qt/dupeguru/export_test.py b/pe/qt/dupeguru/export_test.py new file mode 100644 index 00000000..5c4a6d87 --- /dev/null +++ b/pe/qt/dupeguru/export_test.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.export +Created By: Virgil Dupras +Created On: 2006/09/16 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +from xml.dom import minidom +from StringIO import StringIO + +from hsutil.testcase import TestCase + +from .export import * +from . import export + +class TCoutput_columns_xml(TestCase): + def test_empty_columns(self): + f = StringIO() + output_column_xml(f,[]) + f.seek(0) + doc = minidom.parse(f) + root = doc.documentElement + self.assertEqual('columns',root.nodeName) + self.assertEqual(0,len(root.childNodes)) + + def test_some_columns(self): + f = StringIO() + output_column_xml(f,[('foo',True),('bar',False),('baz',True)]) + f.seek(0) + doc = minidom.parse(f) + columns = doc.getElementsByTagName('column') + self.assertEqual(3,len(columns)) + c1,c2,c3 = columns + self.assertEqual('foo',c1.getAttribute('display')) + self.assertEqual('bar',c2.getAttribute('display')) + self.assertEqual('baz',c3.getAttribute('display')) + self.assertEqual('y',c1.getAttribute('enabled')) + self.assertEqual('n',c2.getAttribute('enabled')) + self.assertEqual('y',c3.getAttribute('enabled')) + + +class TCmerge_css_into_xhtml(TestCase): + def test_main(self): + css = StringIO() + css.write('foobar') + css.seek(0) + xhtml = StringIO() + xhtml.write(""" + + + + + dupeGuru - Duplicate file scanner + + + + + + """) + xhtml.seek(0) + self.assert_(merge_css_into_xhtml(xhtml,css)) + xhtml.seek(0) + doc = minidom.parse(xhtml) + head = doc.getElementsByTagName('head')[0] + #A style node should have been added in head. + styles = head.getElementsByTagName('style') + self.assertEqual(1,len(styles)) + style = styles[0] + self.assertEqual('text/css',style.getAttribute('type')) + self.assertEqual('foobar',style.firstChild.nodeValue.strip()) + #all should be removed + self.assertEqual(1,len(head.getElementsByTagName('link'))) + + def test_empty(self): + self.assert_(not merge_css_into_xhtml(StringIO(),StringIO())) + + def test_malformed(self): + xhtml = StringIO() + xhtml.write(""" + + """) + xhtml.seek(0) + self.assert_(not merge_css_into_xhtml(xhtml,StringIO())) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/pe/qt/dupeguru/gen.py b/pe/qt/dupeguru/gen.py new file mode 100644 index 00000000..0a842372 --- /dev/null +++ b/pe/qt/dupeguru/gen.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# Unit Name: gen +# Created By: Virgil Dupras +# Created On: 2009-05-26 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os +import os.path as op + +def move(src, dst): + if not op.exists(src): + return + if op.exists(dst): + os.remove(dst) + print 'Moving %s --> %s' % (src, dst) + os.rename(src, dst) + + +os.chdir(op.join('modules', 'block')) +os.system('python setup.py build_ext --inplace') +os.chdir(op.join('..', 'cache')) +os.system('python setup.py build_ext --inplace') +os.chdir(op.join('..', '..')) +move(op.join('modules', 'block', '_block.so'), op.join('picture', '_block.so')) +move(op.join('modules', 'block', '_block.pyd'), op.join('picture', '_block.pyd')) +move(op.join('modules', 'cache', '_cache.so'), op.join('picture', '_cache.so')) +move(op.join('modules', 'cache', '_cache.pyd'), op.join('picture', '_cache.pyd')) diff --git a/pe/qt/dupeguru/ignore.py b/pe/qt/dupeguru/ignore.py new file mode 100644 index 00000000..97060786 --- /dev/null +++ b/pe/qt/dupeguru/ignore.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +""" +Unit Name: ignore +Created By: Virgil Dupras +Created On: 2006/05/02 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +from hsutil.files import FileOrPath + +import xml.dom.minidom + +class IgnoreList(object): + """An ignore list implementation that is iterable, filterable and exportable to XML. + + Call Ignore to add an ignore list entry, and AreIgnore to check if 2 items are in the list. + When iterated, 2 sized tuples will be returned, the tuples containing 2 items ignored together. + """ + #---Override + def __init__(self): + self._ignored = {} + self._count = 0 + + def __iter__(self): + for first,seconds in self._ignored.iteritems(): + for second in seconds: + yield (first,second) + + def __len__(self): + return self._count + + #---Public + def AreIgnored(self,first,second): + def do_check(first,second): + try: + matches = self._ignored[first] + return second in matches + except KeyError: + return False + + return do_check(first,second) or do_check(second,first) + + def Clear(self): + self._ignored = {} + self._count = 0 + + def Filter(self,func): + """Applies a filter on all ignored items, and remove all matches where func(first,second) + doesn't return True. + """ + filtered = IgnoreList() + for first,second in self: + if func(first,second): + filtered.Ignore(first,second) + self._ignored = filtered._ignored + self._count = filtered._count + + def Ignore(self,first,second): + if self.AreIgnored(first,second): + return + try: + matches = self._ignored[first] + matches.add(second) + except KeyError: + try: + matches = self._ignored[second] + matches.add(first) + except KeyError: + matches = set() + matches.add(second) + self._ignored[first] = matches + self._count += 1 + + def load_from_xml(self,infile): + """Loads the ignore list from a XML created with save_to_xml. + + infile can be a file object or a filename. + """ + try: + doc = xml.dom.minidom.parse(infile) + except Exception: + return + file_nodes = doc.getElementsByTagName('file') + for fn in file_nodes: + if not fn.getAttributeNode('path'): + continue + file_path = fn.getAttributeNode('path').nodeValue + subfile_nodes = fn.getElementsByTagName('file') + for sfn in subfile_nodes: + if not sfn.getAttributeNode('path'): + continue + subfile_path = sfn.getAttributeNode('path').nodeValue + self.Ignore(file_path,subfile_path) + + def save_to_xml(self,outfile): + """Create a XML file that can be used by load_from_xml. + + outfile can be a file object or a filename. + """ + doc = xml.dom.minidom.Document() + root = doc.appendChild(doc.createElement('ignore_list')) + for file,subfiles in self._ignored.items(): + file_node = root.appendChild(doc.createElement('file')) + if isinstance(file,unicode): + file = file.encode('utf-8') + file_node.setAttribute('path',file) + for subfile in subfiles: + subfile_node = file_node.appendChild(doc.createElement('file')) + if isinstance(subfile,unicode): + subfile = subfile.encode('utf-8') + subfile_node.setAttribute('path',subfile) + with FileOrPath(outfile, 'wb') as fp: + doc.writexml(fp,'\t','\t','\n',encoding='utf-8') + + diff --git a/pe/qt/dupeguru/ignore_test.py b/pe/qt/dupeguru/ignore_test.py new file mode 100644 index 00000000..8ff91f52 --- /dev/null +++ b/pe/qt/dupeguru/ignore_test.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python +""" +Unit Name: ignore +Created By: Virgil Dupras +Created On: 2006/05/02 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import cStringIO +import xml.dom.minidom + +from .ignore import * + +class TCIgnoreList(unittest.TestCase): + def test_empty(self): + il = IgnoreList() + self.assertEqual(0,len(il)) + self.assert_(not il.AreIgnored('foo','bar')) + + def test_simple(self): + il = IgnoreList() + il.Ignore('foo','bar') + self.assert_(il.AreIgnored('foo','bar')) + self.assert_(il.AreIgnored('bar','foo')) + self.assert_(not il.AreIgnored('foo','bleh')) + self.assert_(not il.AreIgnored('bleh','bar')) + self.assertEqual(1,len(il)) + + def test_multiple(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('foo','bleh') + il.Ignore('bleh','bar') + il.Ignore('aybabtu','bleh') + self.assert_(il.AreIgnored('foo','bar')) + self.assert_(il.AreIgnored('bar','foo')) + self.assert_(il.AreIgnored('foo','bleh')) + self.assert_(il.AreIgnored('bleh','bar')) + self.assert_(not il.AreIgnored('aybabtu','bar')) + self.assertEqual(4,len(il)) + + def test_clear(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Clear() + self.assert_(not il.AreIgnored('foo','bar')) + self.assert_(not il.AreIgnored('bar','foo')) + self.assertEqual(0,len(il)) + + def test_add_same_twice(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('bar','foo') + self.assertEqual(1,len(il)) + + def test_save_to_xml(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('foo','bleh') + il.Ignore('bleh','bar') + f = cStringIO.StringIO() + il.save_to_xml(f) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + self.assertEqual('ignore_list',root.nodeName) + children = [c for c in root.childNodes if c.localName] + self.assertEqual(2,len(children)) + self.assertEqual(2,len([c for c in children if c.nodeName == 'file'])) + f1,f2 = children + subchildren = [c for c in f1.childNodes if c.localName == 'file'] +\ + [c for c in f2.childNodes if c.localName == 'file'] + self.assertEqual(3,len(subchildren)) + + def test_SaveThenLoad(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('foo','bleh') + il.Ignore('bleh','bar') + il.Ignore(u'\u00e9','bar') + f = cStringIO.StringIO() + il.save_to_xml(f) + f.seek(0) + il = IgnoreList() + il.load_from_xml(f) + self.assertEqual(4,len(il)) + self.assert_(il.AreIgnored(u'\u00e9','bar')) + + def test_LoadXML_with_empty_file_tags(self): + f = cStringIO.StringIO() + f.write('') + f.seek(0) + il = IgnoreList() + il.load_from_xml(f) + self.assertEqual(0,len(il)) + + def test_AreIgnore_works_when_a_child_is_a_key_somewhere_else(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('bar','baz') + self.assert_(il.AreIgnored('bar','foo')) + + + def test_no_dupes_when_a_child_is_a_key_somewhere_else(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('bar','baz') + il.Ignore('bar','foo') + self.assertEqual(2,len(il)) + + def test_iterate(self): + #It must be possible to iterate through ignore list + il = IgnoreList() + expected = [('foo','bar'),('bar','baz'),('foo','baz')] + for i in expected: + il.Ignore(i[0],i[1]) + for i in il: + expected.remove(i) #No exception should be raised + self.assert_(not expected) #expected should be empty + + def test_filter(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('bar','baz') + il.Ignore('foo','baz') + il.Filter(lambda f,s: f == 'bar') + self.assertEqual(1,len(il)) + self.assert_(not il.AreIgnored('foo','bar')) + self.assert_(il.AreIgnored('bar','baz')) + + def test_save_with_non_ascii_non_unicode_items(self): + il = IgnoreList() + il.Ignore('\xac','\xbf') + f = cStringIO.StringIO() + try: + il.save_to_xml(f) + except Exception,e: + self.fail(str(e)) + + def test_len(self): + il = IgnoreList() + self.assertEqual(0,len(il)) + il.Ignore('foo','bar') + self.assertEqual(1,len(il)) + + def test_nonzero(self): + il = IgnoreList() + self.assert_(not il) + il.Ignore('foo','bar') + self.assert_(il) + + +if __name__ == "__main__": + unittest.main() + diff --git a/pe/qt/dupeguru/modules/block/block.pyx b/pe/qt/dupeguru/modules/block/block.pyx new file mode 100644 index 00000000..db4c7500 --- /dev/null +++ b/pe/qt/dupeguru/modules/block/block.pyx @@ -0,0 +1,93 @@ +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +cdef extern from "stdlib.h": + int abs(int n) # required so that abs() is applied on ints, not python objects + +class NoBlocksError(Exception): + """avgdiff/maxdiff has been called with empty lists""" + +class DifferentBlockCountError(Exception): + """avgdiff/maxdiff has been called with 2 block lists of different size.""" + + +cdef object getblock(object image): + """Returns a 3 sized tuple containing the mean color of 'image'. + + image: a PIL image or crop. + """ + cdef int pixel_count, red, green, blue, r, g, b + if image.size[0]: + pixel_count = image.size[0] * image.size[1] + red = green = blue = 0 + for r, g, b in image.getdata(): + red += r + green += g + blue += b + return (red // pixel_count, green // pixel_count, blue // pixel_count) + else: + return (0, 0, 0) + +def getblocks2(image, int block_count_per_side): + """Returns a list of blocks (3 sized tuples). + + image: A PIL image to base the blocks on. + block_count_per_side: This integer determine the number of blocks the function will return. + If it is 10, for example, 100 blocks will be returns (10 width, 10 height). The blocks will not + necessarely cover square areas. The area covered by each block will be proportional to the image + itself. + """ + if not image.size[0]: + return [] + cdef int width, height, block_width, block_height, ih, iw, top, bottom, left, right + width, height = image.size + block_width = max(width // block_count_per_side, 1) + block_height = max(height // block_count_per_side, 1) + result = [] + for ih in range(block_count_per_side): + top = min(ih * block_height, height - block_height) + bottom = top + block_height + for iw in range(block_count_per_side): + left = min(iw * block_width, width - block_width) + right = left + block_width + box = (left, top, right, bottom) + crop = image.crop(box) + result.append(getblock(crop)) + return result + +cdef int diff(first, second): + """Returns the difference between the first block and the second. + + It returns an absolute sum of the 3 differences (RGB). + """ + cdef int r1, g1, b1, r2, g2, b2 + r1, g1, b1 = first + r2, g2, b2 = second + return abs(r1 - r2) + abs(g1 - g2) + abs(b1 - b2) + +def avgdiff(first, second, int limit, int min_iterations): + """Returns the average diff between first blocks and seconds. + + If the result surpasses limit, limit + 1 is returned, except if less than min_iterations + iterations have been made in the blocks. + """ + cdef int count, sum, i, iteration_count + count = len(first) + if count != len(second): + raise DifferentBlockCountError() + if not count: + raise NoBlocksError() + sum = 0 + for i in range(count): + iteration_count = i + 1 + item1 = first[i] + item2 = second[i] + sum += diff(item1, item2) + if sum > limit * iteration_count and iteration_count >= min_iterations: + return limit + 1 + result = sum // count + if (not result) and sum: + result = 1 + return result \ No newline at end of file diff --git a/pe/qt/dupeguru/modules/block/setup.py b/pe/qt/dupeguru/modules/block/setup.py new file mode 100644 index 00000000..9d8f4cb5 --- /dev/null +++ b/pe/qt/dupeguru/modules/block/setup.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from distutils.core import setup +from distutils.extension import Extension +from Cython.Distutils import build_ext + +setup( + cmdclass = {'build_ext': build_ext}, + ext_modules = [Extension("_block", ["block.pyx"])] +) \ No newline at end of file diff --git a/pe/qt/dupeguru/modules/cache/cache.pyx b/pe/qt/dupeguru/modules/cache/cache.pyx new file mode 100644 index 00000000..7bd2407d --- /dev/null +++ b/pe/qt/dupeguru/modules/cache/cache.pyx @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +# ok, this is hacky and stuff, but I don't know C well enough to play with char buffers, copy +# them around and stuff +cdef int xchar_to_int(char c): + if 48 <= c <= 57: # 0-9 + return c - 48 + elif 65 <= c <= 70: # A-F + return c - 55 + elif 97 <= c <= 102: # a-f + return c - 87 + +def string_to_colors(s): + """Transform the string 's' in a list of 3 sized tuples. + """ + result = [] + cdef int i, char_count, r, g, b + cdef char* cs + char_count = len(s) + char_count = (char_count // 6) * 6 + cs = s + for i in range(0, char_count, 6): + r = xchar_to_int(cs[i]) << 4 + r += xchar_to_int(cs[i+1]) + g = xchar_to_int(cs[i+2]) << 4 + g += xchar_to_int(cs[i+3]) + b = xchar_to_int(cs[i+4]) << 4 + b += xchar_to_int(cs[i+5]) + result.append((r, g, b)) + return result diff --git a/pe/qt/dupeguru/modules/cache/setup.py b/pe/qt/dupeguru/modules/cache/setup.py new file mode 100644 index 00000000..2b6cd31b --- /dev/null +++ b/pe/qt/dupeguru/modules/cache/setup.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from distutils.core import setup +from distutils.extension import Extension +from Cython.Distutils import build_ext + +setup( + cmdclass = {'build_ext': build_ext}, + ext_modules = [Extension("_cache", ["cache.pyx"])] +) \ No newline at end of file diff --git a/pe/qt/dupeguru/picture/__init__.py b/pe/qt/dupeguru/picture/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pe/qt/dupeguru/picture/block.py b/pe/qt/dupeguru/picture/block.py new file mode 100644 index 00000000..70015a50 --- /dev/null +++ b/pe/qt/dupeguru/picture/block.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +""" +Unit Name: hs.picture.block +Created By: Virgil Dupras +Created On: 2006/09/01 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-26 18:12:39 +0200 (Tue, 26 May 2009) $ + $Revision: 4365 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +from _block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 + +# Converted to Cython +# def getblock(image): +# """Returns a 3 sized tuple containing the mean color of 'image'. +# +# image: a PIL image or crop. +# """ +# if image.size[0]: +# pixel_count = image.size[0] * image.size[1] +# red = green = blue = 0 +# for r,g,b in image.getdata(): +# red += r +# green += g +# blue += b +# return (red // pixel_count, green // pixel_count, blue // pixel_count) +# else: +# return (0,0,0) + +# This is not used anymore +# def getblocks(image,blocksize): +# """Returns a list of blocks (3 sized tuples). +# +# image: A PIL image to base the blocks on. +# blocksize: The size of the blocks to be create. This is a single integer, defining +# both width and height (blocks are square). +# """ +# if min(image.size) < blocksize: +# return () +# result = [] +# for i in xrange(image.size[1] // blocksize): +# for j in xrange(image.size[0] // blocksize): +# box = (blocksize * j, blocksize * i, blocksize * (j + 1), blocksize * (i + 1)) +# crop = image.crop(box) +# result.append(getblock(crop)) +# return result + +# Converted to Cython +# def getblocks2(image,block_count_per_side): +# """Returns a list of blocks (3 sized tuples). +# +# image: A PIL image to base the blocks on. +# block_count_per_side: This integer determine the number of blocks the function will return. +# If it is 10, for example, 100 blocks will be returns (10 width, 10 height). The blocks will not +# necessarely cover square areas. The area covered by each block will be proportional to the image +# itself. +# """ +# if not image.size[0]: +# return [] +# width,height = image.size +# block_width = max(width // block_count_per_side,1) +# block_height = max(height // block_count_per_side,1) +# result = [] +# for ih in range(block_count_per_side): +# top = min(ih * block_height, height - block_height) +# bottom = top + block_height +# for iw in range(block_count_per_side): +# left = min(iw * block_width, width - block_width) +# right = left + block_width +# box = (left,top,right,bottom) +# crop = image.crop(box) +# result.append(getblock(crop)) +# return result + +# Converted to Cython +# def diff(first, second): +# """Returns the difference between the first block and the second. +# +# It returns an absolute sum of the 3 differences (RGB). +# """ +# r1, g1, b1 = first +# r2, g2, b2 = second +# return abs(r1 - r2) + abs(g1 - g2) + abs(b1 - b2) + +# Converted to Cython +# def avgdiff(first, second, limit=768, min_iterations=1): +# """Returns the average diff between first blocks and seconds. +# +# If the result surpasses limit, limit + 1 is returned, except if less than min_iterations +# iterations have been made in the blocks. +# """ +# if len(first) != len(second): +# raise DifferentBlockCountError +# if not first: +# raise NoBlocksError +# count = len(first) +# sum = 0 +# zipped = izip(xrange(1, count + 1), first, second) +# for i, first, second in zipped: +# sum += diff(first, second) +# if sum > limit * i and i >= min_iterations: +# return limit + 1 +# result = sum // count +# if (not result) and sum: +# result = 1 +# return result + +# This is not used anymore +# def maxdiff(first,second,limit=768): +# """Returns the max diff between first blocks and seconds. +# +# If the result surpasses limit, the first max being over limit is returned. +# """ +# if len(first) != len(second): +# raise DifferentBlockCountError +# if not first: +# raise NoBlocksError +# result = 0 +# zipped = zip(first,second) +# for first,second in zipped: +# result = max(result,diff(first,second)) +# if result > limit: +# return result +# return result diff --git a/pe/qt/dupeguru/picture/block_test.py b/pe/qt/dupeguru/picture/block_test.py new file mode 100644 index 00000000..a06cf617 --- /dev/null +++ b/pe/qt/dupeguru/picture/block_test.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python +""" +Unit Name: tests.picture.block +Created By: Virgil Dupras +Created On: 2006/09/01 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +# The commented out tests are tests for function that have been converted to pure C for speed +import unittest + +from .block import * + +def my_avgdiff(first, second, limit=768, min_iter=3): # this is so I don't have to re-write every call + return avgdiff(first, second, limit, min_iter) + +BLACK = (0,0,0) +RED = (0xff,0,0) +GREEN = (0,0xff,0) +BLUE = (0,0,0xff) + +class FakeImage(object): + def __init__(self, size, data): + self.size = size + self.data = data + + def getdata(self): + return self.data + + def crop(self, box): + pixels = [] + for i in range(box[1], box[3]): + for j in range(box[0], box[2]): + pixel = self.data[i * self.size[0] + j] + pixels.append(pixel) + return FakeImage((box[2] - box[0], box[3] - box[1]), pixels) + +def empty(): + return FakeImage((0,0), []) + +def single_pixel(): #one red pixel + return FakeImage((1, 1), [(0xff,0,0)]) + +def four_pixels(): + pixels = [RED,(0,0x80,0xff),(0x80,0,0),(0,0x40,0x80)] + return FakeImage((2, 2), pixels) + +class TCgetblock(unittest.TestCase): + def test_single_pixel(self): + im = single_pixel() + [b] = getblocks2(im, 1) + self.assertEqual(RED,b) + + def test_no_pixel(self): + im = empty() + self.assertEqual([], getblocks2(im, 1)) + + def test_four_pixels(self): + im = four_pixels() + [b] = getblocks2(im, 1) + meanred = (0xff + 0x80) // 4 + meangreen = (0x80 + 0x40) // 4 + meanblue = (0xff + 0x80) // 4 + self.assertEqual((meanred,meangreen,meanblue),b) + + +# class TCdiff(unittest.TestCase): +# def test_diff(self): +# b1 = (10, 20, 30) +# b2 = (1, 2, 3) +# self.assertEqual(9 + 18 + 27,diff(b1,b2)) +# +# def test_diff_negative(self): +# b1 = (10, 20, 30) +# b2 = (1, 2, 3) +# self.assertEqual(9 + 18 + 27,diff(b2,b1)) +# +# def test_diff_mixed_positive_and_negative(self): +# b1 = (1, 5, 10) +# b2 = (10, 1, 15) +# self.assertEqual(9 + 4 + 5,diff(b1,b2)) +# + +# class TCgetblocks(unittest.TestCase): +# def test_empty_image(self): +# im = empty() +# blocks = getblocks(im,1) +# self.assertEqual(0,len(blocks)) +# +# def test_one_block_image(self): +# im = four_pixels() +# blocks = getblocks2(im, 1) +# self.assertEqual(1,len(blocks)) +# block = blocks[0] +# meanred = (0xff + 0x80) // 4 +# meangreen = (0x80 + 0x40) // 4 +# meanblue = (0xff + 0x80) // 4 +# self.assertEqual((meanred,meangreen,meanblue),block) +# +# def test_not_enough_height_to_fit_a_block(self): +# im = FakeImage((2,1), [BLACK, BLACK]) +# blocks = getblocks(im,2) +# self.assertEqual(0,len(blocks)) +# +# def xtest_dont_include_leftovers(self): +# # this test is disabled because getblocks is not used and getblock in cdeffed +# pixels = [ +# RED,(0,0x80,0xff),BLACK, +# (0x80,0,0),(0,0x40,0x80),BLACK, +# BLACK,BLACK,BLACK +# ] +# im = FakeImage((3,3), pixels) +# blocks = getblocks(im,2) +# block = blocks[0] +# #Because the block is smaller than the image, only blocksize must be considered. +# meanred = (0xff + 0x80) // 4 +# meangreen = (0x80 + 0x40) // 4 +# meanblue = (0xff + 0x80) // 4 +# self.assertEqual((meanred,meangreen,meanblue),block) +# +# def xtest_two_blocks(self): +# # this test is disabled because getblocks is not used and getblock in cdeffed +# pixels = [BLACK for i in xrange(4 * 2)] +# pixels[0] = RED +# pixels[1] = (0,0x80,0xff) +# pixels[4] = (0x80,0,0) +# pixels[5] = (0,0x40,0x80) +# im = FakeImage((4, 2), pixels) +# blocks = getblocks(im,2) +# self.assertEqual(2,len(blocks)) +# block = blocks[0] +# #Because the block is smaller than the image, only blocksize must be considered. +# meanred = (0xff + 0x80) // 4 +# meangreen = (0x80 + 0x40) // 4 +# meanblue = (0xff + 0x80) // 4 +# self.assertEqual((meanred,meangreen,meanblue),block) +# self.assertEqual(BLACK,blocks[1]) +# +# def test_four_blocks(self): +# pixels = [BLACK for i in xrange(4 * 4)] +# pixels[0] = RED +# pixels[1] = (0,0x80,0xff) +# pixels[4] = (0x80,0,0) +# pixels[5] = (0,0x40,0x80) +# im = FakeImage((4, 4), pixels) +# blocks = getblocks2(im, 2) +# self.assertEqual(4,len(blocks)) +# block = blocks[0] +# #Because the block is smaller than the image, only blocksize must be considered. +# meanred = (0xff + 0x80) // 4 +# meangreen = (0x80 + 0x40) // 4 +# meanblue = (0xff + 0x80) // 4 +# self.assertEqual((meanred,meangreen,meanblue),block) +# self.assertEqual(BLACK,blocks[1]) +# self.assertEqual(BLACK,blocks[2]) +# self.assertEqual(BLACK,blocks[3]) +# + +class TCgetblocks2(unittest.TestCase): + def test_empty_image(self): + im = empty() + blocks = getblocks2(im,1) + self.assertEqual(0,len(blocks)) + + def test_one_block_image(self): + im = four_pixels() + blocks = getblocks2(im,1) + self.assertEqual(1,len(blocks)) + block = blocks[0] + meanred = (0xff + 0x80) // 4 + meangreen = (0x80 + 0x40) // 4 + meanblue = (0xff + 0x80) // 4 + self.assertEqual((meanred,meangreen,meanblue),block) + + def test_four_blocks_all_black(self): + im = FakeImage((2, 2), [BLACK, BLACK, BLACK, BLACK]) + blocks = getblocks2(im,2) + self.assertEqual(4,len(blocks)) + for block in blocks: + self.assertEqual(BLACK,block) + + def test_two_pixels_image_horizontal(self): + pixels = [RED,BLUE] + im = FakeImage((2, 1), pixels) + blocks = getblocks2(im,2) + self.assertEqual(4,len(blocks)) + self.assertEqual(RED,blocks[0]) + self.assertEqual(BLUE,blocks[1]) + self.assertEqual(RED,blocks[2]) + self.assertEqual(BLUE,blocks[3]) + + def test_two_pixels_image_vertical(self): + pixels = [RED,BLUE] + im = FakeImage((1, 2), pixels) + blocks = getblocks2(im,2) + self.assertEqual(4,len(blocks)) + self.assertEqual(RED,blocks[0]) + self.assertEqual(RED,blocks[1]) + self.assertEqual(BLUE,blocks[2]) + self.assertEqual(BLUE,blocks[3]) + + +class TCavgdiff(unittest.TestCase): + def test_empty(self): + self.assertRaises(NoBlocksError, my_avgdiff, [], []) + + def test_two_blocks(self): + im = empty() + b1 = (5,10,15) + b2 = (255,250,245) + b3 = (0,0,0) + b4 = (255,0,255) + blocks1 = [b1,b2] + blocks2 = [b3,b4] + expected1 = 5 + 10 + 15 + expected2 = 0 + 250 + 10 + expected = (expected1 + expected2) // 2 + self.assertEqual(expected, my_avgdiff(blocks1, blocks2)) + + def test_blocks_not_the_same_size(self): + b = (0,0,0) + self.assertRaises(DifferentBlockCountError,my_avgdiff,[b,b],[b]) + + def test_first_arg_is_empty_but_not_second(self): + #Don't return 0 (as when the 2 lists are empty), raise! + b = (0,0,0) + self.assertRaises(DifferentBlockCountError,my_avgdiff,[],[b]) + + def test_limit(self): + ref = (0,0,0) + b1 = (10,10,10) #avg 30 + b2 = (20,20,20) #avg 45 + b3 = (30,30,30) #avg 60 + blocks1 = [ref,ref,ref] + blocks2 = [b1,b2,b3] + self.assertEqual(45,my_avgdiff(blocks1,blocks2,44)) + + def test_min_iterations(self): + ref = (0,0,0) + b1 = (10,10,10) #avg 30 + b2 = (20,20,20) #avg 45 + b3 = (10,10,10) #avg 40 + blocks1 = [ref,ref,ref] + blocks2 = [b1,b2,b3] + self.assertEqual(40,my_avgdiff(blocks1,blocks2,45 - 1,3)) + + # Bah, I don't know why this test fails, but I don't think it matters very much + # def test_just_over_the_limit(self): + # #A score just over the limit might return exactly the limit due to truncating. We should + # #ceil() the result in this case. + # ref = (0,0,0) + # b1 = (10,0,0) + # b2 = (11,0,0) + # blocks1 = [ref,ref] + # blocks2 = [b1,b2] + # self.assertEqual(11,my_avgdiff(blocks1,blocks2,10)) + # + def test_return_at_least_1_at_the_slightest_difference(self): + ref = (0,0,0) + b1 = (1,0,0) + blocks1 = [ref for i in xrange(250)] + blocks2 = [ref for i in xrange(250)] + blocks2[0] = b1 + self.assertEqual(1,my_avgdiff(blocks1,blocks2)) + + def test_return_0_if_there_is_no_difference(self): + ref = (0,0,0) + blocks1 = [ref,ref] + blocks2 = [ref,ref] + self.assertEqual(0,my_avgdiff(blocks1,blocks2)) + + +# class TCmaxdiff(unittest.TestCase): +# def test_empty(self): +# self.assertRaises(NoBlocksError,maxdiff,[],[]) +# +# def test_two_blocks(self): +# b1 = (5,10,15) +# b2 = (255,250,245) +# b3 = (0,0,0) +# b4 = (255,0,255) +# blocks1 = [b1,b2] +# blocks2 = [b3,b4] +# expected1 = 5 + 10 + 15 +# expected2 = 0 + 250 + 10 +# expected = max(expected1,expected2) +# self.assertEqual(expected,maxdiff(blocks1,blocks2)) +# +# def test_blocks_not_the_same_size(self): +# b = (0,0,0) +# self.assertRaises(DifferentBlockCountError,maxdiff,[b,b],[b]) +# +# def test_first_arg_is_empty_but_not_second(self): +# #Don't return 0 (as when the 2 lists are empty), raise! +# b = (0,0,0) +# self.assertRaises(DifferentBlockCountError,maxdiff,[],[b]) +# +# def test_limit(self): +# b1 = (5,10,15) +# b2 = (255,250,245) +# b3 = (0,0,0) +# b4 = (255,0,255) +# blocks1 = [b1,b2] +# blocks2 = [b3,b4] +# expected1 = 5 + 10 + 15 +# expected2 = 0 + 250 + 10 +# self.assertEqual(expected1,maxdiff(blocks1,blocks2,expected1 - 1)) +# + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/pe/qt/dupeguru/picture/cache.py b/pe/qt/dupeguru/picture/cache.py new file mode 100644 index 00000000..6ff0d2d1 --- /dev/null +++ b/pe/qt/dupeguru/picture/cache.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +""" +Unit Name: hs.picture.cache +Created By: Virgil Dupras +Created On: 2006/09/14 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import os +import logging +import sqlite3 as sqlite + +import hsutil.sqlite + +from _cache import string_to_colors + +def colors_to_string(colors): + """Transform the 3 sized tuples 'colors' into a hex string. + + [(0,100,255)] --> 0064ff + [(1,2,3),(4,5,6)] --> 010203040506 + """ + return ''.join(['%02x%02x%02x' % (r,g,b) for r,g,b in colors]) + +# This function is an important bottleneck of dupeGuru PE. It has been converted to Cython. +# def string_to_colors(s): +# """Transform the string 's' in a list of 3 sized tuples. +# """ +# result = [] +# for i in xrange(0, len(s), 6): +# number = int(s[i:i+6], 16) +# result.append((number >> 16, (number >> 8) & 0xff, number & 0xff)) +# return result + +class Cache(object): + """A class to cache picture blocks. + """ + def __init__(self, db=':memory:', threaded=True): + def create_tables(): + sql = "create table pictures(path TEXT, blocks TEXT)" + self.con.execute(sql); + sql = "create index idx_path on pictures (path)" + self.con.execute(sql) + + self.dbname = db + if threaded: + self.con = hsutil.sqlite.ThreadedConn(db, True) + else: + self.con = sqlite.connect(db, isolation_level=None) + try: + self.con.execute("select * from pictures where 1=2") + except sqlite.OperationalError: # new db + create_tables() + except sqlite.DatabaseError, e: # corrupted db + logging.warning('Could not create picture cache because of an error: %s', str(e)) + self.con.close() + os.remove(db) + if threaded: + self.con = hsutil.sqlite.ThreadedConn(db, True) + else: + self.con = sqlite.connect(db, isolation_level=None) + create_tables() + + def __contains__(self, key): + sql = "select count(*) from pictures where path = ?" + result = self.con.execute(sql, [key]).fetchall() + return result[0][0] > 0 + + def __delitem__(self, key): + if key not in self: + raise KeyError(key) + sql = "delete from pictures where path = ?" + self.con.execute(sql, [key]) + + # Optimized + def __getitem__(self, key): + if isinstance(key, int): + sql = "select blocks from pictures where rowid = ?" + else: + sql = "select blocks from pictures where path = ?" + result = self.con.execute(sql, [key]).fetchone() + if result: + result = string_to_colors(result[0]) + return result + else: + raise KeyError(key) + + def __iter__(self): + sql = "select path from pictures" + result = self.con.execute(sql) + return (row[0] for row in result) + + def __len__(self): + sql = "select count(*) from pictures" + result = self.con.execute(sql).fetchall() + return result[0][0] + + def __setitem__(self, key, value): + value = colors_to_string(value) + if key in self: + sql = "update pictures set blocks = ? where path = ?" + else: + sql = "insert into pictures(blocks,path) values(?,?)" + try: + self.con.execute(sql, [value, key]) + except sqlite.OperationalError: + logging.warning('Picture cache could not set %r for key %r', value, key) + except sqlite.DatabaseError, e: + logging.warning('DatabaseError while setting %r for key %r: %s', value, key, str(e)) + + def clear(self): + sql = "delete from pictures" + self.con.execute(sql) + + def filter(self, func): + to_delete = [key for key in self if not func(key)] + for key in to_delete: + del self[key] + + def get_id(self, path): + sql = "select rowid from pictures where path = ?" + result = self.con.execute(sql, [path]).fetchone() + if result: + return result[0] + else: + raise ValueError(path) + + def get_multiple(self, rowids): + sql = "select rowid, blocks from pictures where rowid in (%s)" % ','.join(map(str, rowids)) + cur = self.con.execute(sql) + return ((rowid, string_to_colors(blocks)) for rowid, blocks in cur) + diff --git a/pe/qt/dupeguru/picture/cache_test.py b/pe/qt/dupeguru/picture/cache_test.py new file mode 100644 index 00000000..f453112f --- /dev/null +++ b/pe/qt/dupeguru/picture/cache_test.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +""" +Unit Name: tests.picture.cache +Created By: Virgil Dupras +Created On: 2006/09/14 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +from StringIO import StringIO +import os.path as op +import os +import threading + +from hsutil.testcase import TestCase +from .cache import * + +class TCcolors_to_string(unittest.TestCase): + def test_no_color(self): + self.assertEqual('',colors_to_string([])) + + def test_single_color(self): + self.assertEqual('000000',colors_to_string([(0,0,0)])) + self.assertEqual('010101',colors_to_string([(1,1,1)])) + self.assertEqual('0a141e',colors_to_string([(10,20,30)])) + + def test_two_colors(self): + self.assertEqual('000102030405',colors_to_string([(0,1,2),(3,4,5)])) + + +class TCstring_to_colors(unittest.TestCase): + def test_empty(self): + self.assertEqual([],string_to_colors('')) + + def test_single_color(self): + self.assertEqual([(0,0,0)],string_to_colors('000000')) + self.assertEqual([(2,3,4)],string_to_colors('020304')) + self.assertEqual([(10,20,30)],string_to_colors('0a141e')) + + def test_two_colors(self): + self.assertEqual([(10,20,30),(40,50,60)],string_to_colors('0a141e28323c')) + + def test_incomplete_color(self): + # don't return anything if it's not a complete color + self.assertEqual([],string_to_colors('102')) + + +class TCCache(TestCase): + def test_empty(self): + c = Cache() + self.assertEqual(0,len(c)) + self.assertRaises(KeyError,c.__getitem__,'foo') + + def test_set_then_retrieve_blocks(self): + c = Cache() + b = [(0,0,0),(1,2,3)] + c['foo'] = b + self.assertEqual(b,c['foo']) + + def test_delitem(self): + c = Cache() + c['foo'] = '' + del c['foo'] + self.assert_('foo' not in c) + self.assertRaises(KeyError,c.__delitem__,'foo') + + def test_persistance(self): + DBNAME = op.join(self.tmpdir(), 'hstest.db') + c = Cache(DBNAME) + c['foo'] = [(1,2,3)] + del c + c = Cache(DBNAME) + self.assertEqual([(1,2,3)],c['foo']) + del c + os.remove(DBNAME) + + def test_filter(self): + c = Cache() + c['foo'] = '' + c['bar'] = '' + c['baz'] = '' + c.filter(lambda p:p != 'bar') #only 'bar' is removed + self.assertEqual(2,len(c)) + self.assert_('foo' in c) + self.assert_('baz' in c) + self.assert_('bar' not in c) + + def test_clear(self): + c = Cache() + c['foo'] = '' + c['bar'] = '' + c['baz'] = '' + c.clear() + self.assertEqual(0,len(c)) + self.assert_('foo' not in c) + self.assert_('baz' not in c) + self.assert_('bar' not in c) + + def test_corrupted_db(self): + dbname = op.join(self.tmpdir(), 'foo.db') + fp = open(dbname, 'w') + fp.write('invalid sqlite content') + fp.close() + c = Cache(dbname) # should not raise a DatabaseError + c['foo'] = [(1, 2, 3)] + del c + c = Cache(dbname) + self.assertEqual(c['foo'], [(1, 2, 3)]) + + def test_by_id(self): + # it's possible to use the cache by referring to the files by their row_id + c = Cache() + b = [(0,0,0),(1,2,3)] + c['foo'] = b + foo_id = c.get_id('foo') + self.assertEqual(c[foo_id], b) + + +class TCCacheSQLEscape(unittest.TestCase): + def test_contains(self): + c = Cache() + self.assert_("foo'bar" not in c) + + def test_getitem(self): + c = Cache() + self.assertRaises(KeyError, c.__getitem__, "foo'bar") + + def test_setitem(self): + c = Cache() + c["foo'bar"] = [] + + def test_delitem(self): + c = Cache() + c["foo'bar"] = [] + try: + del c["foo'bar"] + except KeyError: + self.fail() + + +class TCCacheThreaded(unittest.TestCase): + def test_access_cache(self): + def thread_run(): + try: + c['foo'] = [(1,2,3)] + except sqlite.ProgrammingError: + self.fail() + + c = Cache() + t = threading.Thread(target=thread_run) + t.start() + t.join() + self.assertEqual([(1,2,3)], c['foo']) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/pe/qt/dupeguru/picture/matchbase.py b/pe/qt/dupeguru/picture/matchbase.py new file mode 100644 index 00000000..cf0d1e89 --- /dev/null +++ b/pe/qt/dupeguru/picture/matchbase.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +""" +Unit Name: hs.picture._match +Created By: Virgil Dupras +Created On: 2007/02/25 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:02:48 +0200 (Thu, 28 May 2009) $ + $Revision: 4388 $ +Copyright 2007 Hardcoded Software (http://www.hardcoded.net) +""" +import logging +import multiprocessing +from Queue import Empty +from collections import defaultdict + +from hsutil import job +from hs.utils.misc import dedupe + +from dupeguru.engine import Match +from block import avgdiff, DifferentBlockCountError, NoBlocksError +from cache import Cache + +MIN_ITERATIONS = 3 + +def get_match(first,second,percentage): + if percentage < 0: + percentage = 0 + return Match(first,second,percentage) + +class MatchFactory(object): + cached_blocks = None + block_count_per_side = 15 + threshold = 75 + match_scaled = False + + def _do_getmatches(self, files, j): + raise NotImplementedError() + + def getmatches(self, files, j=job.nulljob): + # The MemoryError handlers in there use logging without first caring about whether or not + # there is enough memory left to carry on the operation because it is assumed that the + # MemoryError happens when trying to read an image file, which is freed from memory by the + # time that MemoryError is raised. + j = j.start_subjob([2, 8]) + logging.info('Preparing %d files' % len(files)) + prepared = self.prepare_files(files, j) + logging.info('Finished preparing %d files' % len(prepared)) + return self._do_getmatches(prepared, j) + + def prepare_files(self, files, j=job.nulljob): + prepared = [] # only files for which there was no error getting blocks + try: + for picture in j.iter_with_progress(files, 'Analyzed %d/%d pictures'): + picture.dimensions + picture.unicode_path = unicode(picture.path) + try: + if picture.unicode_path not in self.cached_blocks: + blocks = picture.get_blocks(self.block_count_per_side) + self.cached_blocks[picture.unicode_path] = blocks + prepared.append(picture) + except IOError as e: + logging.warning(unicode(e)) + except MemoryError: + logging.warning(u'Ran out of memory while reading %s of size %d' % (picture.unicode_path, picture.size)) + if picture.size < 10 * 1024 * 1024: # We're really running out of memory + raise + except MemoryError: + logging.warning('Ran out of memory while preparing files') + return prepared + + +def async_compare(ref_id, other_ids, dbname, threshold): + cache = Cache(dbname, threaded=False) + limit = 100 - threshold + ref_blocks = cache[ref_id] + pairs = cache.get_multiple(other_ids) + results = [] + for other_id, other_blocks in pairs: + try: + diff = avgdiff(ref_blocks, other_blocks, limit, MIN_ITERATIONS) + percentage = 100 - diff + except (DifferentBlockCountError, NoBlocksError): + percentage = 0 + if percentage >= threshold: + results.append((ref_id, other_id, percentage)) + cache.con.close() + return results + +class AsyncMatchFactory(MatchFactory): + def _do_getmatches(self, pictures, j): + def empty_out_queue(queue, into): + try: + while True: + into.append(queue.get(block=False)) + except Empty: + pass + + j = j.start_subjob([1, 8, 1], 'Preparing for matching') + cache = self.cached_blocks + id2picture = {} + dimensions2pictures = defaultdict(set) + for picture in pictures[:]: + try: + picture.cache_id = cache.get_id(picture.unicode_path) + id2picture[picture.cache_id] = picture + except ValueError: + pictures.remove(picture) + if not self.match_scaled: + dimensions2pictures[picture.dimensions].add(picture) + pool = multiprocessing.Pool() + async_results = [] + pictures_copy = set(pictures) + for ref in j.iter_with_progress(pictures): + others = pictures_copy if self.match_scaled else dimensions2pictures[ref.dimensions] + others.remove(ref) + if others: + cache_ids = [f.cache_id for f in others] + args = (ref.cache_id, cache_ids, self.cached_blocks.dbname, self.threshold) + async_results.append(pool.apply_async(async_compare, args)) + + matches = [] + for result in j.iter_with_progress(async_results, 'Matched %d/%d pictures'): + matches.extend(result.get()) + + result = [] + for ref_id, other_id, percentage in j.iter_with_progress(matches, 'Verified %d/%d matches', every=10): + ref = id2picture[ref_id] + other = id2picture[other_id] + if percentage == 100 and ref.md5 != other.md5: + percentage = 99 + if percentage >= self.threshold: + result.append(get_match(ref, other, percentage)) + return result + + +multiprocessing.freeze_support() \ No newline at end of file diff --git a/pe/qt/dupeguru/results.py b/pe/qt/dupeguru/results.py new file mode 100644 index 00000000..a7ded5c0 --- /dev/null +++ b/pe/qt/dupeguru/results.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.results +Created By: Virgil Dupras +Created On: 2006/02/23 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import re +from xml.sax import handler, make_parser, SAXException +from xml.sax.saxutils import XMLGenerator +from xml.sax.xmlreader import AttributesImpl + +from . import engine +from hsutil.job import nulljob +from hsutil.markable import Markable +from hsutil.misc import flatten, cond, nonone +from hsutil.str import format_size +from hsutil.files import open_if_filename + +class Results(Markable): + #---Override + def __init__(self, data_module): + super(Results, self).__init__() + self.__groups = [] + self.__group_of_duplicate = {} + self.__groups_sort_descriptor = None # This is a tuple (key, asc) + self.__dupes = None + self.__dupes_sort_descriptor = None # This is a tuple (key, asc, delta) + self.__filters = None + self.__filtered_dupes = None + self.__filtered_groups = None + self.__recalculate_stats() + self.__marked_size = 0 + self.data = data_module + + def _did_mark(self, dupe): + self.__marked_size += dupe.size + + def _did_unmark(self, dupe): + self.__marked_size -= dupe.size + + def _get_markable_count(self): + return self.__total_count + + def _is_markable(self, dupe): + if dupe.is_ref: + return False + g = self.get_group_of_duplicate(dupe) + if not g: + return False + if dupe is g.ref: + return False + if self.__filtered_dupes and dupe not in self.__filtered_dupes: + return False + return True + + #---Private + def __get_dupe_list(self): + if self.__dupes is None: + self.__dupes = flatten(group.dupes for group in self.groups) + if self.__filtered_dupes: + self.__dupes = [dupe for dupe in self.__dupes if dupe in self.__filtered_dupes] + sd = self.__dupes_sort_descriptor + if sd: + self.sort_dupes(sd[0], sd[1], sd[2]) + return self.__dupes + + def __get_groups(self): + if self.__filtered_groups is None: + return self.__groups + else: + return self.__filtered_groups + + def __get_stat_line(self): + if self.__filtered_dupes is None: + mark_count = self.mark_count + marked_size = self.__marked_size + total_count = self.__total_count + total_size = self.__total_size + else: + mark_count = len([dupe for dupe in self.__filtered_dupes if self.is_marked(dupe)]) + marked_size = sum(dupe.size for dupe in self.__filtered_dupes if self.is_marked(dupe)) + total_count = len([dupe for dupe in self.__filtered_dupes if self.is_markable(dupe)]) + total_size = sum(dupe.size for dupe in self.__filtered_dupes if self.is_markable(dupe)) + if self.mark_inverted: + marked_size = self.__total_size - marked_size + result = '%d / %d (%s / %s) duplicates marked.' % ( + mark_count, + total_count, + format_size(marked_size, 2), + format_size(total_size, 2), + ) + if self.__filters: + result += ' filter: %s' % ' --> '.join(self.__filters) + return result + + def __recalculate_stats(self): + self.__total_size = 0 + self.__total_count = 0 + for group in self.groups: + markable = [dupe for dupe in group.dupes if self._is_markable(dupe)] + self.__total_count += len(markable) + self.__total_size += sum(dupe.size for dupe in markable) + + def __set_groups(self, new_groups): + self.mark_none() + self.__groups = new_groups + self.__group_of_duplicate = {} + for g in self.__groups: + for dupe in g: + self.__group_of_duplicate[dupe] = g + if not hasattr(dupe, 'is_ref'): + dupe.is_ref = False + old_filters = nonone(self.__filters, []) + self.apply_filter(None) + for filter_str in old_filters: + self.apply_filter(filter_str) + + #---Public + def apply_filter(self, filter_str): + ''' Applies a filter 'filter_str' to self.groups + + When you apply the filter, only dupes with the filename matching 'filter_str' will be in + in the results. To cancel the filter, just call apply_filter with 'filter_str' to None, + and the results will go back to normal. + + If call apply_filter on a filtered results, the filter will be applied + *on the filtered results*. + + 'filter_str' is a string containing a regexp to filter dupes with. + ''' + if not filter_str: + self.__filtered_dupes = None + self.__filtered_groups = None + self.__filters = None + else: + if not self.__filters: + self.__filters = [] + self.__filters.append(filter_str) + filter_re = re.compile(filter_str, re.IGNORECASE) + if self.__filtered_dupes is None: + self.__filtered_dupes = flatten(g[:] for g in self.groups) + self.__filtered_dupes = set(dupe for dupe in self.__filtered_dupes if filter_re.search(dupe.name)) + filtered_groups = set() + for dupe in self.__filtered_dupes: + filtered_groups.add(self.get_group_of_duplicate(dupe)) + self.__filtered_groups = list(filtered_groups) + self.__recalculate_stats() + sd = self.__groups_sort_descriptor + if sd: + self.sort_groups(sd[0], sd[1]) + self.__dupes = None + + def get_group_of_duplicate(self, dupe): + try: + return self.__group_of_duplicate[dupe] + except (TypeError, KeyError): + return None + + is_markable = _is_markable + + def load_from_xml(self, infile, get_file, j=nulljob): + self.apply_filter(None) + handler = _ResultsHandler(get_file) + parser = make_parser() + parser.setContentHandler(handler) + try: + infile, must_close = open_if_filename(infile) + except IOError: + return + BUFSIZE = 1024 * 1024 # 1mb buffer + infile.seek(0, 2) + j.start_job(infile.tell() // BUFSIZE) + infile.seek(0, 0) + try: + while True: + data = infile.read(BUFSIZE) + if not data: + break + parser.feed(data) + j.add_progress() + except SAXException: + return + self.groups = handler.groups + for dupe_file in handler.marked: + self.mark(dupe_file) + + def make_ref(self, dupe): + g = self.get_group_of_duplicate(dupe) + r = g.ref + self._remove_mark_flag(dupe) + g.switch_ref(dupe); + if not r.is_ref: + self.__total_count += 1 + self.__total_size += r.size + if not dupe.is_ref: + self.__total_count -= 1 + self.__total_size -= dupe.size + self.__dupes = None + + def perform_on_marked(self, func, remove_from_results): + problems = [] + for d in self.dupes: + if self.is_marked(d) and (not func(d)): + problems.append(d) + if remove_from_results: + to_remove = [d for d in self.dupes if self.is_marked(d) and (d not in problems)] + self.remove_duplicates(to_remove) + self.mark_none() + for d in problems: + self.mark(d) + return len(problems) + + def remove_duplicates(self, dupes): + '''Remove 'dupes' from their respective group, and remove the group is it ends up empty. + ''' + affected_groups = set() + for dupe in dupes: + group = self.get_group_of_duplicate(dupe) + if dupe not in group.dupes: + return + group.remove_dupe(dupe, False) + self._remove_mark_flag(dupe) + self.__total_count -= 1 + self.__total_size -= dupe.size + if not group: + self.__groups.remove(group) + if self.__filtered_groups: + self.__filtered_groups.remove(group) + else: + affected_groups.add(group) + for group in affected_groups: + group.clean_matches() + self.__dupes = None + + def save_to_xml(self, outfile, with_data=False): + self.apply_filter(None) + outfile, must_close = open_if_filename(outfile, 'wb') + writer = XMLGenerator(outfile, 'utf-8') + writer.startDocument() + empty_attrs = AttributesImpl({}) + writer.startElement('results', empty_attrs) + for g in self.groups: + writer.startElement('group', empty_attrs) + dupe2index = {} + for index, d in enumerate(g): + dupe2index[d] = index + try: + words = engine.unpack_fields(d.words) + except AttributeError: + words = () + attrs = AttributesImpl({ + 'path': unicode(d.path), + 'is_ref': cond(d.is_ref, 'y', 'n'), + 'words': ','.join(words), + 'marked': cond(self.is_marked(d), 'y', 'n') + }) + writer.startElement('file', attrs) + if with_data: + data_list = self.data.GetDisplayInfo(d, g) + for data in data_list: + attrs = AttributesImpl({ + 'value': data, + }) + writer.startElement('data', attrs) + writer.endElement('data') + writer.endElement('file') + for match in g.matches: + attrs = AttributesImpl({ + 'first': str(dupe2index[match.first]), + 'second': str(dupe2index[match.second]), + 'percentage': str(int(match.percentage)), + }) + writer.startElement('match', attrs) + writer.endElement('match') + writer.endElement('group') + writer.endElement('results') + writer.endDocument() + if must_close: + outfile.close() + + def sort_dupes(self, key, asc=True, delta=False): + if not self.__dupes: + self.__get_dupe_list() + self.__dupes.sort(key=lambda d: self.data.GetDupeSortKey(d, lambda: self.get_group_of_duplicate(d), key, delta)) + if not asc: + self.__dupes.reverse() + self.__dupes_sort_descriptor = (key,asc,delta) + + def sort_groups(self,key,asc=True): + self.groups.sort(key=lambda g: self.data.GetGroupSortKey(g, key)) + if not asc: + self.groups.reverse() + self.__groups_sort_descriptor = (key,asc) + + #---Properties + dupes = property(__get_dupe_list) + groups = property(__get_groups, __set_groups) + stat_line = property(__get_stat_line) + +class _ResultsHandler(handler.ContentHandler): + def __init__(self, get_file): + self.group = None + self.dupes = None + self.marked = set() + self.groups = [] + self.get_file = get_file + + def startElement(self, name, attrs): + if name == 'group': + self.group = engine.Group() + self.dupes = [] + return + if (name == 'file') and (self.group is not None): + if not (('path' in attrs) and ('words' in attrs)): + return + path = attrs['path'] + file = self.get_file(path) + if file is None: + return + file.words = attrs['words'].split(',') + file.is_ref = attrs.get('is_ref') == 'y' + self.dupes.append(file) + if attrs.get('marked') == 'y': + self.marked.add(file) + if (name == 'match') and (self.group is not None): + try: + first_file = self.dupes[int(attrs['first'])] + second_file = self.dupes[int(attrs['second'])] + percentage = int(attrs['percentage']) + self.group.add_match(engine.Match(first_file, second_file, percentage)) + except (IndexError, KeyError, ValueError): # Covers missing attr, non-int values and indexes out of bounds + pass + + def endElement(self, name): + def do_match(ref_file, other_files, group): + if not other_files: + return + for other_file in other_files: + group.add_match(engine.get_match(ref_file, other_file)) + do_match(other_files[0], other_files[1:], group) + + if name == 'group': + group = self.group + self.group = None + dupes = self.dupes + self.dupes = [] + if group is None: + return + if len(dupes) < 2: + return + if not group.matches: # elements not present, do it manually, without % + do_match(dupes[0], dupes[1:], group) + group.prioritize(lambda x: dupes.index(x)) + self.groups.append(group) + diff --git a/pe/qt/dupeguru/results_test.py b/pe/qt/dupeguru/results_test.py new file mode 100644 index 00000000..1e74efc6 --- /dev/null +++ b/pe/qt/dupeguru/results_test.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.results +Created By: Virgil Dupras +Created On: 2006/02/23 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import StringIO +import xml.dom.minidom +import os.path as op + +from hsutil.path import Path +from hsutil.testcase import TestCase +from hsutil.misc import first + +from . import engine_test +from . import data +from . import engine +from .results import * + +class NamedObject(engine_test.NamedObject): + size = 1 + path = property(lambda x:Path('basepath') + x.name) + is_ref = False + + def __nonzero__(self): + return False #Make sure that operations are made correctly when the bool value of files is false. + +# Returns a group set that looks like that: +# "foo bar" (1) +# "bar bleh" (1024) +# "foo bleh" (1) +# "ibabtu" (1) +# "ibabtu" (1) +def GetTestGroups(): + objects = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("foo bleh"),NamedObject("ibabtu"),NamedObject("ibabtu")] + objects[1].size = 1024 + matches = engine.MatchFactory().getmatches(objects) #we should have 5 matches + groups = engine.get_groups(matches) #We should have 2 groups + for g in groups: + g.prioritize(lambda x:objects.index(x)) #We want the dupes to be in the same order as the list is + groups.sort(key=len, reverse=True) # We want the group with 3 members to be first. + return (objects,matches,groups) + +class TCResultsEmpty(TestCase): + def setUp(self): + self.results = Results(data) + + def test_stat_line(self): + self.assertEqual("0 / 0 (0.00 B / 0.00 B) duplicates marked.",self.results.stat_line) + + def test_groups(self): + self.assertEqual(0,len(self.results.groups)) + + def test_get_group_of_duplicate(self): + self.assert_(self.results.get_group_of_duplicate('foo') is None) + + def test_save_to_xml(self): + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + self.assertEqual('results',root.nodeName) + + +class TCResultsWithSomeGroups(TestCase): + def setUp(self): + self.results = Results(data) + self.objects,self.matches,self.groups = GetTestGroups() + self.results.groups = self.groups + + def test_stat_line(self): + self.assertEqual("0 / 3 (0.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + + def test_groups(self): + self.assertEqual(2,len(self.results.groups)) + + def test_get_group_of_duplicate(self): + for o in self.objects: + g = self.results.get_group_of_duplicate(o) + self.assert_(isinstance(g, engine.Group)) + self.assert_(o in g) + self.assert_(self.results.get_group_of_duplicate(self.groups[0]) is None) + + def test_remove_duplicates(self): + g1,g2 = self.results.groups + self.results.remove_duplicates([g1.dupes[0]]) + self.assertEqual(2,len(g1)) + self.assert_(g1 in self.results.groups) + self.results.remove_duplicates([g1.ref]) + self.assertEqual(2,len(g1)) + self.assert_(g1 in self.results.groups) + self.results.remove_duplicates([g1.dupes[0]]) + self.assertEqual(0,len(g1)) + self.assert_(g1 not in self.results.groups) + self.results.remove_duplicates([g2.dupes[0]]) + self.assertEqual(0,len(g2)) + self.assert_(g2 not in self.results.groups) + self.assertEqual(0,len(self.results.groups)) + + def test_remove_duplicates_with_ref_files(self): + g1,g2 = self.results.groups + self.objects[0].is_ref = True + self.objects[1].is_ref = True + self.results.remove_duplicates([self.objects[2]]) + self.assertEqual(0,len(g1)) + self.assert_(g1 not in self.results.groups) + + def test_make_ref(self): + g = self.results.groups[0] + d = g.dupes[0] + self.results.make_ref(d) + self.assert_(d is g.ref) + + def test_sort_groups(self): + self.results.make_ref(self.objects[1]) #We want to make the 1024 sized object to go ref. + g1,g2 = self.groups + self.results.sort_groups(2) #2 is the key for size + self.assert_(self.results.groups[0] is g2) + self.assert_(self.results.groups[1] is g1) + self.results.sort_groups(2,False) + self.assert_(self.results.groups[0] is g1) + self.assert_(self.results.groups[1] is g2) + + def test_set_groups_when_sorted(self): + self.results.make_ref(self.objects[1]) #We want to make the 1024 sized object to go ref. + self.results.sort_groups(2) + objects,matches,groups = GetTestGroups() + g1,g2 = groups + g1.switch_ref(objects[1]) + self.results.groups = groups + self.assert_(self.results.groups[0] is g2) + self.assert_(self.results.groups[1] is g1) + + def test_get_dupe_list(self): + self.assertEqual([self.objects[1],self.objects[2],self.objects[4]],self.results.dupes) + + def test_dupe_list_is_cached(self): + self.assert_(self.results.dupes is self.results.dupes) + + def test_dupe_list_cache_is_invalidated_when_needed(self): + o1,o2,o3,o4,o5 = self.objects + self.assertEqual([o2,o3,o5],self.results.dupes) + self.results.make_ref(o2) + self.assertEqual([o1,o3,o5],self.results.dupes) + objects,matches,groups = GetTestGroups() + o1,o2,o3,o4,o5 = objects + self.results.groups = groups + self.assertEqual([o2,o3,o5],self.results.dupes) + + def test_dupe_list_sort(self): + o1,o2,o3,o4,o5 = self.objects + o1.size = 5 + o2.size = 4 + o3.size = 3 + o4.size = 2 + o5.size = 1 + self.results.sort_dupes(2) + self.assertEqual([o5,o3,o2],self.results.dupes) + self.results.sort_dupes(2,False) + self.assertEqual([o2,o3,o5],self.results.dupes) + + def test_dupe_list_remember_sort(self): + o1,o2,o3,o4,o5 = self.objects + o1.size = 5 + o2.size = 4 + o3.size = 3 + o4.size = 2 + o5.size = 1 + self.results.sort_dupes(2) + self.results.make_ref(o2) + self.assertEqual([o5,o3,o1],self.results.dupes) + + def test_dupe_list_sort_delta_values(self): + o1,o2,o3,o4,o5 = self.objects + o1.size = 10 + o2.size = 2 #-8 + o3.size = 3 #-7 + o4.size = 20 + o5.size = 1 #-19 + self.results.sort_dupes(2,delta=True) + self.assertEqual([o5,o2,o3],self.results.dupes) + + def test_sort_empty_list(self): + #There was an infinite loop when sorting an empty list. + r = Results(data) + r.sort_dupes(0) + self.assertEqual([],r.dupes) + + def test_dupe_list_update_on_remove_duplicates(self): + o1,o2,o3,o4,o5 = self.objects + self.assertEqual(3,len(self.results.dupes)) + self.results.remove_duplicates([o2]) + self.assertEqual(2,len(self.results.dupes)) + + +class TCResultsMarkings(TestCase): + def setUp(self): + self.results = Results(data) + self.objects,self.matches,self.groups = GetTestGroups() + self.results.groups = self.groups + + def test_stat_line(self): + self.assertEqual("0 / 3 (0.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.mark(self.objects[1]) + self.assertEqual("1 / 3 (1.00 KB / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.mark_invert() + self.assertEqual("2 / 3 (2.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.mark_invert() + self.results.unmark(self.objects[1]) + self.results.mark(self.objects[2]) + self.results.mark(self.objects[4]) + self.assertEqual("2 / 3 (2.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.mark(self.objects[0]) #this is a ref, it can't be counted + self.assertEqual("2 / 3 (2.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.groups = self.groups + self.assertEqual("0 / 3 (0.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + + def test_with_ref_duplicate(self): + self.objects[1].is_ref = True + self.results.groups = self.groups + self.assert_(not self.results.mark(self.objects[1])) + self.results.mark(self.objects[2]) + self.assertEqual("1 / 2 (1.00 B / 2.00 B) duplicates marked.",self.results.stat_line) + + def test_perform_on_marked(self): + def log_object(o): + log.append(o) + return True + + log = [] + self.results.mark_all() + self.results.perform_on_marked(log_object,False) + self.assert_(self.objects[1] in log) + self.assert_(self.objects[2] in log) + self.assert_(self.objects[4] in log) + self.assertEqual(3,len(log)) + log = [] + self.results.mark_none() + self.results.mark(self.objects[4]) + self.results.perform_on_marked(log_object,True) + self.assertEqual(1,len(log)) + self.assert_(self.objects[4] in log) + self.assertEqual(1,len(self.results.groups)) + + def test_perform_on_marked_with_problems(self): + def log_object(o): + log.append(o) + return o is not self.objects[1] + + log = [] + self.results.mark_all() + self.assert_(self.results.is_marked(self.objects[1])) + self.assertEqual(1,self.results.perform_on_marked(log_object, True)) + self.assertEqual(3,len(log)) + self.assertEqual(1,len(self.results.groups)) + self.assertEqual(2,len(self.results.groups[0])) + self.assert_(self.objects[1] in self.results.groups[0]) + self.assert_(not self.results.is_marked(self.objects[2])) + self.assert_(self.results.is_marked(self.objects[1])) + + def test_perform_on_marked_with_ref(self): + def log_object(o): + log.append(o) + return True + + log = [] + self.objects[0].is_ref = True + self.objects[1].is_ref = True + self.results.mark_all() + self.results.perform_on_marked(log_object,True) + self.assert_(self.objects[1] not in log) + self.assert_(self.objects[2] in log) + self.assert_(self.objects[4] in log) + self.assertEqual(2,len(log)) + self.assertEqual(0,len(self.results.groups)) + + def test_perform_on_marked_remove_objects_only_at_the_end(self): + def check_groups(o): + self.assertEqual(3,len(g1)) + self.assertEqual(2,len(g2)) + return True + + g1,g2 = self.results.groups + self.results.mark_all() + self.results.perform_on_marked(check_groups,True) + self.assertEqual(0,len(g1)) + self.assertEqual(0,len(g2)) + self.assertEqual(0,len(self.results.groups)) + + def test_remove_duplicates(self): + g1 = self.results.groups[0] + g2 = self.results.groups[1] + self.results.mark(g1.dupes[0]) + self.assertEqual("1 / 3 (1.00 KB / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.remove_duplicates([g1.dupes[1]]) + self.assertEqual("1 / 2 (1.00 KB / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.remove_duplicates([g1.dupes[0]]) + self.assertEqual("0 / 1 (0.00 B / 1.00 B) duplicates marked.",self.results.stat_line) + + def test_make_ref(self): + g = self.results.groups[0] + d = g.dupes[0] + self.results.mark(d) + self.assertEqual("1 / 3 (1.00 KB / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.make_ref(d) + self.assertEqual("0 / 3 (0.00 B / 3.00 B) duplicates marked.",self.results.stat_line) + self.results.make_ref(d) + self.assertEqual("0 / 3 (0.00 B / 3.00 B) duplicates marked.",self.results.stat_line) + + def test_SaveXML(self): + self.results.mark(self.objects[1]) + self.results.mark_invert() + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + g1,g2 = root.getElementsByTagName('group') + d1,d2,d3 = g1.getElementsByTagName('file') + self.assertEqual('n',d1.getAttributeNode('marked').nodeValue) + self.assertEqual('n',d2.getAttributeNode('marked').nodeValue) + self.assertEqual('y',d3.getAttributeNode('marked').nodeValue) + d1,d2 = g2.getElementsByTagName('file') + self.assertEqual('n',d1.getAttributeNode('marked').nodeValue) + self.assertEqual('y',d2.getAttributeNode('marked').nodeValue) + + def test_LoadXML(self): + def get_file(path): + return [f for f in self.objects if str(f.path) == path][0] + + self.objects[4].name = 'ibabtu 2' #we can't have 2 files with the same path + self.results.mark(self.objects[1]) + self.results.mark_invert() + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + self.assert_(not r.is_marked(self.objects[0])) + self.assert_(not r.is_marked(self.objects[1])) + self.assert_(r.is_marked(self.objects[2])) + self.assert_(not r.is_marked(self.objects[3])) + self.assert_(r.is_marked(self.objects[4])) + + +class TCResultsXML(TestCase): + def setUp(self): + self.results = Results(data) + self.objects, self.matches, self.groups = GetTestGroups() + self.results.groups = self.groups + + def get_file(self, path): # use this as a callback for load_from_xml + return [o for o in self.objects if o.path == path][0] + + def test_save_to_xml(self): + self.objects[0].is_ref = True + self.objects[0].words = [['foo','bar']] + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + self.assertEqual('results',root.nodeName) + children = [c for c in root.childNodes if c.localName] + self.assertEqual(2,len(children)) + self.assertEqual(2,len([c for c in children if c.nodeName == 'group'])) + g1,g2 = children + children = [c for c in g1.childNodes if c.localName] + self.assertEqual(6,len(children)) + self.assertEqual(3,len([c for c in children if c.nodeName == 'file'])) + self.assertEqual(3,len([c for c in children if c.nodeName == 'match'])) + d1,d2,d3 = [c for c in children if c.nodeName == 'file'] + self.assertEqual(op.join('basepath','foo bar'),d1.getAttributeNode('path').nodeValue) + self.assertEqual(op.join('basepath','bar bleh'),d2.getAttributeNode('path').nodeValue) + self.assertEqual(op.join('basepath','foo bleh'),d3.getAttributeNode('path').nodeValue) + self.assertEqual('y',d1.getAttributeNode('is_ref').nodeValue) + self.assertEqual('n',d2.getAttributeNode('is_ref').nodeValue) + self.assertEqual('n',d3.getAttributeNode('is_ref').nodeValue) + self.assertEqual('foo,bar',d1.getAttributeNode('words').nodeValue) + self.assertEqual('bar,bleh',d2.getAttributeNode('words').nodeValue) + self.assertEqual('foo,bleh',d3.getAttributeNode('words').nodeValue) + children = [c for c in g2.childNodes if c.localName] + self.assertEqual(3,len(children)) + self.assertEqual(2,len([c for c in children if c.nodeName == 'file'])) + self.assertEqual(1,len([c for c in children if c.nodeName == 'match'])) + d1,d2 = [c for c in children if c.nodeName == 'file'] + self.assertEqual(op.join('basepath','ibabtu'),d1.getAttributeNode('path').nodeValue) + self.assertEqual(op.join('basepath','ibabtu'),d2.getAttributeNode('path').nodeValue) + self.assertEqual('n',d1.getAttributeNode('is_ref').nodeValue) + self.assertEqual('n',d2.getAttributeNode('is_ref').nodeValue) + self.assertEqual('ibabtu',d1.getAttributeNode('words').nodeValue) + self.assertEqual('ibabtu',d2.getAttributeNode('words').nodeValue) + + def test_save_to_xml_with_columns(self): + class FakeDataModule: + def GetDisplayInfo(self,dupe,group): + return [str(dupe.size),dupe.foo.upper()] + + for i,object in enumerate(self.objects): + object.size = i + object.foo = u'bar\u00e9' + f = StringIO.StringIO() + self.results.data = FakeDataModule() + self.results.save_to_xml(f,True) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + g1,g2 = root.getElementsByTagName('group') + d1,d2,d3 = g1.getElementsByTagName('file') + d4,d5 = g2.getElementsByTagName('file') + self.assertEqual('0',d1.getElementsByTagName('data')[0].getAttribute('value')) + self.assertEqual(u'BAR\u00c9',d1.getElementsByTagName('data')[1].getAttribute('value')) #\u00c9 is upper of \u00e9 + self.assertEqual('1',d2.getElementsByTagName('data')[0].getAttribute('value')) + self.assertEqual('2',d3.getElementsByTagName('data')[0].getAttribute('value')) + self.assertEqual('3',d4.getElementsByTagName('data')[0].getAttribute('value')) + self.assertEqual('4',d5.getElementsByTagName('data')[0].getAttribute('value')) + + def test_LoadXML(self): + def get_file(path): + return [f for f in self.objects if str(f.path) == path][0] + + self.objects[0].is_ref = True + self.objects[4].name = 'ibabtu 2' #we can't have 2 files with the same path + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + self.assertEqual(2,len(r.groups)) + g1,g2 = r.groups + self.assertEqual(3,len(g1)) + self.assert_(g1[0].is_ref) + self.assert_(not g1[1].is_ref) + self.assert_(not g1[2].is_ref) + self.assert_(g1[0] is self.objects[0]) + self.assert_(g1[1] is self.objects[1]) + self.assert_(g1[2] is self.objects[2]) + self.assertEqual(['foo','bar'],g1[0].words) + self.assertEqual(['bar','bleh'],g1[1].words) + self.assertEqual(['foo','bleh'],g1[2].words) + self.assertEqual(2,len(g2)) + self.assert_(not g2[0].is_ref) + self.assert_(not g2[1].is_ref) + self.assert_(g2[0] is self.objects[3]) + self.assert_(g2[1] is self.objects[4]) + self.assertEqual(['ibabtu'],g2[0].words) + self.assertEqual(['ibabtu'],g2[1].words) + + def test_LoadXML_with_filename(self): + def get_file(path): + return [f for f in self.objects if str(f.path) == path][0] + + filename = op.join(self.tmpdir(), 'dupeguru_results.xml') + self.objects[4].name = 'ibabtu 2' #we can't have 2 files with the same path + self.results.save_to_xml(filename) + r = Results(data) + r.load_from_xml(filename,get_file) + self.assertEqual(2,len(r.groups)) + + def test_LoadXML_with_some_files_that_dont_exist_anymore(self): + def get_file(path): + if path.endswith('ibabtu 2'): + return None + return [f for f in self.objects if str(f.path) == path][0] + + self.objects[4].name = 'ibabtu 2' #we can't have 2 files with the same path + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + self.assertEqual(1,len(r.groups)) + self.assertEqual(3,len(r.groups[0])) + + def test_LoadXML_missing_attributes_and_bogus_elements(self): + def get_file(path): + return [f for f in self.objects if str(f.path) == path][0] + + doc = xml.dom.minidom.Document() + root = doc.appendChild(doc.createElement('foobar')) #The root element shouldn't matter, really. + group_node = root.appendChild(doc.createElement('group')) + dupe_node = group_node.appendChild(doc.createElement('file')) #Perfectly correct file + dupe_node.setAttribute('path',op.join('basepath','foo bar')) + dupe_node.setAttribute('is_ref','y') + dupe_node.setAttribute('words','foo,bar') + dupe_node = group_node.appendChild(doc.createElement('file')) #is_ref missing, default to 'n' + dupe_node.setAttribute('path',op.join('basepath','foo bleh')) + dupe_node.setAttribute('words','foo,bleh') + dupe_node = group_node.appendChild(doc.createElement('file')) #words are missing, invalid. + dupe_node.setAttribute('path',op.join('basepath','bar bleh')) + dupe_node = group_node.appendChild(doc.createElement('file')) #path is missing, invalid. + dupe_node.setAttribute('words','foo,bleh') + dupe_node = group_node.appendChild(doc.createElement('foobar')) #Invalid element name + dupe_node.setAttribute('path',op.join('basepath','bar bleh')) + dupe_node.setAttribute('is_ref','y') + dupe_node.setAttribute('words','bar,bleh') + match_node = group_node.appendChild(doc.createElement('match')) # match pointing to a bad index + match_node.setAttribute('first', '42') + match_node.setAttribute('second', '45') + match_node = group_node.appendChild(doc.createElement('match')) # match with missing attrs + match_node = group_node.appendChild(doc.createElement('match')) # match with non-int values + match_node.setAttribute('first', 'foo') + match_node.setAttribute('second', 'bar') + match_node.setAttribute('percentage', 'baz') + group_node = root.appendChild(doc.createElement('foobar')) #invalid group + group_node = root.appendChild(doc.createElement('group')) #empty group + f = StringIO.StringIO() + doc.writexml(f,'\t','\t','\n',encoding='utf-8') + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + self.assertEqual(1,len(r.groups)) + self.assertEqual(2,len(r.groups[0])) + + def test_xml_non_ascii(self): + def get_file(path): + if path == op.join('basepath',u'\xe9foo bar'): + return objects[0] + if path == op.join('basepath',u'bar bleh'): + return objects[1] + + objects = [NamedObject(u"\xe9foo bar",True),NamedObject("bar bleh",True)] + matches = engine.MatchFactory().getmatches(objects) #we should have 5 matches + groups = engine.get_groups(matches) #We should have 2 groups + for g in groups: + g.prioritize(lambda x:objects.index(x)) #We want the dupes to be in the same order as the list is + results = Results(data) + results.groups = groups + f = StringIO.StringIO() + results.save_to_xml(f) + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + g = r.groups[0] + self.assertEqual(u"\xe9foo bar",g[0].name) + self.assertEqual(['efoo','bar'],g[0].words) + + def test_load_invalid_xml(self): + f = StringIO.StringIO() + f.write(' len(ref.path) + + def GetDupeGroups(self, files, j=job.nulljob): + j = j.start_subjob([8, 2]) + for f in [f for f in files if not hasattr(f, 'is_ref')]: + f.is_ref = False + if self.size_threshold: + files = [f for f in files if f.size >= self.size_threshold] + logging.info('Getting matches') + if self.match_factory is None: + matches = self._getmatches(files, j) + else: + matches = self.match_factory.getmatches(files, j) + logging.info('Found %d matches' % len(matches)) + if not self.mix_file_kind: + j.set_progress(100, 'Removing false matches') + matches = [m for m in matches if get_file_ext(m.first.name) == get_file_ext(m.second.name)] + if self.ignore_list: + j = j.start_subjob(2) + iter_matches = j.iter_with_progress(matches, 'Processed %d/%d matches against the ignore list') + matches = [m for m in iter_matches + if not self.ignore_list.AreIgnored(unicode(m.first.path), unicode(m.second.path))] + matched_files = dedupe([m.first for m in matches] + [m.second for m in matches]) + if self.scan_type in (SCAN_TYPE_CONTENT, SCAN_TYPE_CONTENT_AUDIO): + md5attrname = 'md5partial' if self.scan_type == SCAN_TYPE_CONTENT_AUDIO else 'md5' + md5 = lambda f: getattr(f, md5attrname) + j = j.start_subjob(2) + for matched_file in j.iter_with_progress(matched_files, 'Analyzed %d/%d matching files'): + md5(matched_file) + j.set_progress(100, 'Removing false matches') + matches = [m for m in matches if md5(m.first) == md5(m.second)] + words_for_content = ['--'] # We compared md5. No words were involved. + for m in matches: + m.first.words = words_for_content + m.second.words = words_for_content + logging.info('Grouping matches') + groups = engine.get_groups(matches, j) + groups = [g for g in groups if any(not f.is_ref for f in g)] + logging.info('Created %d groups' % len(groups)) + j.set_progress(100, 'Doing group prioritization') + for g in groups: + g.prioritize(self._key_func, self._tie_breaker) + matched_files = dedupe([m.first for m in matches] + [m.second for m in matches]) + self.discarded_file_count = len(matched_files) - sum(len(g) for g in groups) + return groups + + match_factory = None + match_similar_words = False + min_match_percentage = 80 + mix_file_kind = True + scan_type = SCAN_TYPE_FILENAME + scanned_tags = set(['artist', 'title']) + size_threshold = 0 + word_weighting = False + +class ScannerME(Scanner): # Scanner for Music Edition + @staticmethod + def _key_func(dupe): + return (not dupe.is_ref, -dupe.bitrate, -dupe.size) + diff --git a/pe/qt/dupeguru/scanner_test.py b/pe/qt/dupeguru/scanner_test.py new file mode 100644 index 00000000..89ad1417 --- /dev/null +++ b/pe/qt/dupeguru/scanner_test.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.scanner +Created By: Virgil Dupras +Created On: 2006/03/03 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest + +from hsutil import job +from hsutil.path import Path +from hsutil.testcase import TestCase + +from .engine import getwords, Match +from .ignore import IgnoreList +from .scanner import * + +class NamedObject(object): + def __init__(self, name="foobar", size=1): + self.name = name + self.size = size + self.path = Path('') + self.words = getwords(name) + + +no = NamedObject + +class TCScanner(TestCase): + def test_empty(self): + s = Scanner() + r = s.GetDupeGroups([]) + self.assertEqual([],r) + + def test_default_settings(self): + s = Scanner() + self.assertEqual(80,s.min_match_percentage) + self.assertEqual(SCAN_TYPE_FILENAME,s.scan_type) + self.assertEqual(True,s.mix_file_kind) + self.assertEqual(False,s.word_weighting) + self.assertEqual(False,s.match_similar_words) + self.assert_(isinstance(s.ignore_list,IgnoreList)) + + def test_simple_with_default_settings(self): + s = Scanner() + f = [no('foo bar'),no('foo bar'),no('foo bleh')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + g = r[0] + #'foo bleh' cannot be in the group because the default min match % is 80 + self.assertEqual(2,len(g)) + self.assert_(g.ref in f[:2]) + self.assert_(g.dupes[0] in f[:2]) + + def test_simple_with_lower_min_match(self): + s = Scanner() + s.min_match_percentage = 50 + f = [no('foo bar'),no('foo bar'),no('foo bleh')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(3,len(g)) + + def test_trim_all_ref_groups(self): + s = Scanner() + f = [no('foo'),no('foo'),no('bar'),no('bar')] + f[2].is_ref = True + f[3].is_ref = True + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + + def test_priorize(self): + s = Scanner() + f = [no('foo'),no('foo'),no('bar'),no('bar')] + f[1].size = 2 + f[2].size = 3 + f[3].is_ref = True + r = s.GetDupeGroups(f) + g1,g2 = r + self.assert_(f[1] in (g1.ref,g2.ref)) + self.assert_(f[0] in (g1.dupes[0],g2.dupes[0])) + self.assert_(f[3] in (g1.ref,g2.ref)) + self.assert_(f[2] in (g1.dupes[0],g2.dupes[0])) + + def test_content_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT + f = [no('foo'), no('bar'), no('bleh')] + f[0].md5 = 'foobar' + f[1].md5 = 'foobar' + f[2].md5 = 'bleh' + r = s.GetDupeGroups(f) + self.assertEqual(len(r), 1) + self.assertEqual(len(r[0]), 2) + self.assertEqual(s.discarded_file_count, 0) # don't count the different md5 as discarded! + + def test_content_scan_compare_sizes_first(self): + class MyFile(no): + def get_md5(file): + self.fail() + md5 = property(get_md5) + + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT + f = [MyFile('foo',1),MyFile('bar',2)] + self.assertEqual(0,len(s.GetDupeGroups(f))) + + def test_min_match_perc_doesnt_matter_for_content_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT + f = [no('foo'),no('bar'),no('bleh')] + f[0].md5 = 'foobar' + f[1].md5 = 'foobar' + f[2].md5 = 'bleh' + s.min_match_percentage = 101 + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + self.assertEqual(2,len(r[0])) + s.min_match_percentage = 0 + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + self.assertEqual(2,len(r[0])) + + def test_content_scan_puts_md5_in_words_at_the_end(self): + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT + f = [no('foo'),no('bar')] + f[0].md5 = '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f' + f[1].md5 = '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f' + r = s.GetDupeGroups(f) + g = r[0] + self.assertEqual(['--'],g.ref.words) + self.assertEqual(['--'],g.dupes[0].words) + + def test_extension_is_not_counted_in_filename_scan(self): + s = Scanner() + s.min_match_percentage = 100 + f = [no('foo.bar'),no('foo.bleh')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + self.assertEqual(2,len(r[0])) + + def test_job(self): + def do_progress(progress,desc=''): + log.append(progress) + return True + s = Scanner() + log = [] + f = [no('foo bar'),no('foo bar'),no('foo bleh')] + r = s.GetDupeGroups(f, job.Job(1,do_progress)) + self.assertEqual(0,log[0]) + self.assertEqual(100,log[-1]) + + def test_mix_file_kind(self): + s = Scanner() + s.mix_file_kind = False + f = [no('foo.1'),no('foo.2')] + r = s.GetDupeGroups(f) + self.assertEqual(0,len(r)) + + def test_word_weighting(self): + s = Scanner() + s.min_match_percentage = 75 + s.word_weighting = True + f = [no('foo bar'),no('foo bar bleh')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + g = r[0] + m = g.get_match_of(g.dupes[0]) + self.assertEqual(75,m.percentage) # 16 letters, 12 matching + + def test_similar_words(self): + s = Scanner() + s.match_similar_words = True + f = [no('The White Stripes'),no('The Whites Stripe'),no('Limp Bizkit'),no('Limp Bizkitt')] + r = s.GetDupeGroups(f) + self.assertEqual(2,len(r)) + + def test_fields(self): + s = Scanner() + s.scan_type = SCAN_TYPE_FIELDS + f = [no('The White Stripes - Little Ghost'),no('The White Stripes - Little Acorn')] + r = s.GetDupeGroups(f) + self.assertEqual(0,len(r)) + + def test_fields_no_order(self): + s = Scanner() + s.scan_type = SCAN_TYPE_FIELDS_NO_ORDER + f = [no('The White Stripes - Little Ghost'),no('Little Ghost - The White Stripes')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + + def test_tag_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + o1 = no('foo') + o2 = no('bar') + o1.artist = 'The White Stripes' + o1.title = 'The Air Near My Fingers' + o2.artist = 'The White Stripes' + o2.title = 'The Air Near My Fingers' + r = s.GetDupeGroups([o1,o2]) + self.assertEqual(1,len(r)) + + def test_tag_with_album_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG_WITH_ALBUM + o1 = no('foo') + o2 = no('bar') + o3 = no('bleh') + o1.artist = 'The White Stripes' + o1.title = 'The Air Near My Fingers' + o1.album = 'Elephant' + o2.artist = 'The White Stripes' + o2.title = 'The Air Near My Fingers' + o2.album = 'Elephant' + o3.artist = 'The White Stripes' + o3.title = 'The Air Near My Fingers' + o3.album = 'foobar' + r = s.GetDupeGroups([o1,o2,o3]) + self.assertEqual(1,len(r)) + + def test_that_dash_in_tags_dont_create_new_fields(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG_WITH_ALBUM + s.min_match_percentage = 50 + o1 = no('foo') + o2 = no('bar') + o1.artist = 'The White Stripes - a' + o1.title = 'The Air Near My Fingers - a' + o1.album = 'Elephant - a' + o2.artist = 'The White Stripes - b' + o2.title = 'The Air Near My Fingers - b' + o2.album = 'Elephant - b' + r = s.GetDupeGroups([o1,o2]) + self.assertEqual(1,len(r)) + + def test_tag_scan_with_different_scanned(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + s.scanned_tags = set(['track', 'year']) + o1 = no('foo') + o2 = no('bar') + o1.artist = 'The White Stripes' + o1.title = 'some title' + o1.track = 'foo' + o1.year = 'bar' + o2.artist = 'The White Stripes' + o2.title = 'another title' + o2.track = 'foo' + o2.year = 'bar' + r = s.GetDupeGroups([o1, o2]) + self.assertEqual(1, len(r)) + + def test_tag_scan_only_scans_existing_tags(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + s.scanned_tags = set(['artist', 'foo']) + o1 = no('foo') + o2 = no('bar') + o1.artist = 'The White Stripes' + o1.foo = 'foo' + o2.artist = 'The White Stripes' + o2.foo = 'bar' + r = s.GetDupeGroups([o1, o2]) + self.assertEqual(1, len(r)) # Because 'foo' is not scanned, they match + + def test_tag_scan_converts_to_str(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + s.scanned_tags = set(['track']) + o1 = no('foo') + o2 = no('bar') + o1.track = 42 + o2.track = 42 + try: + r = s.GetDupeGroups([o1, o2]) + except TypeError: + self.fail() + self.assertEqual(1, len(r)) + + def test_tag_scan_non_ascii(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + s.scanned_tags = set(['title']) + o1 = no('foo') + o2 = no('bar') + o1.title = u'foobar\u00e9' + o2.title = u'foobar\u00e9' + try: + r = s.GetDupeGroups([o1, o2]) + except UnicodeEncodeError: + self.fail() + self.assertEqual(1, len(r)) + + def test_audio_content_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT_AUDIO + f = [no('foo'),no('bar'),no('bleh')] + f[0].md5 = 'foo' + f[1].md5 = 'bar' + f[2].md5 = 'bleh' + f[0].md5partial = 'foo' + f[1].md5partial = 'foo' + f[2].md5partial = 'bleh' + f[0].audiosize = 1 + f[1].audiosize = 1 + f[2].audiosize = 1 + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + self.assertEqual(2,len(r[0])) + + def test_audio_content_scan_compare_sizes_first(self): + class MyFile(no): + def get_md5(file): + self.fail() + md5partial = property(get_md5) + + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT_AUDIO + f = [MyFile('foo'),MyFile('bar')] + f[0].audiosize = 1 + f[1].audiosize = 2 + self.assertEqual(0,len(s.GetDupeGroups(f))) + + def test_ignore_list(self): + s = Scanner() + f1 = no('foobar') + f2 = no('foobar') + f3 = no('foobar') + f1.path = Path('dir1/foobar') + f2.path = Path('dir2/foobar') + f3.path = Path('dir3/foobar') + s.ignore_list.Ignore(str(f1.path),str(f2.path)) + s.ignore_list.Ignore(str(f1.path),str(f3.path)) + r = s.GetDupeGroups([f1,f2,f3]) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(1,len(g.dupes)) + self.assert_(f1 not in g) + self.assert_(f2 in g) + self.assert_(f3 in g) + # Ignored matches are not counted as discarded + self.assertEqual(s.discarded_file_count, 0) + + def test_ignore_list_checks_for_unicode(self): + #scanner was calling path_str for ignore list checks. Since the Path changes, it must + #be unicode(path) + s = Scanner() + f1 = no('foobar') + f2 = no('foobar') + f3 = no('foobar') + f1.path = Path(u'foo1\u00e9') + f2.path = Path(u'foo2\u00e9') + f3.path = Path(u'foo3\u00e9') + s.ignore_list.Ignore(unicode(f1.path),unicode(f2.path)) + s.ignore_list.Ignore(unicode(f1.path),unicode(f3.path)) + r = s.GetDupeGroups([f1,f2,f3]) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(1,len(g.dupes)) + self.assert_(f1 not in g) + self.assert_(f2 in g) + self.assert_(f3 in g) + + def test_custom_match_factory(self): + class MatchFactory(object): + def getmatches(self,objects,j=None): + return [Match(objects[0], objects[1], 420)] + + + s = Scanner() + s.match_factory = MatchFactory() + o1,o2 = no('foo'),no('bar') + groups = s.GetDupeGroups([o1,o2]) + self.assertEqual(1,len(groups)) + g = groups[0] + self.assertEqual(2,len(g)) + g.switch_ref(o1) + m = g.get_match_of(o2) + self.assertEqual((o1,o2,420),m) + + def test_file_evaluates_to_false(self): + # A very wrong way to use any() was added at some point, causing resulting group list + # to be empty. + class FalseNamedObject(NamedObject): + def __nonzero__(self): + return False + + + s = Scanner() + f1 = FalseNamedObject('foobar') + f2 = FalseNamedObject('foobar') + r = s.GetDupeGroups([f1,f2]) + self.assertEqual(1,len(r)) + + def test_size_threshold(self): + # Only file equal or higher than the size_threshold in size are scanned + s = Scanner() + f1 = no('foo', 1) + f2 = no('foo', 2) + f3 = no('foo', 3) + s.size_threshold = 2 + groups = s.GetDupeGroups([f1,f2,f3]) + self.assertEqual(len(groups), 1) + [group] = groups + self.assertEqual(len(group), 2) + self.assertTrue(f1 not in group) + self.assertTrue(f2 in group) + self.assertTrue(f3 in group) + + def test_tie_breaker_path_deepness(self): + # If there is a tie in prioritization, path deepness is used as a tie breaker + s = Scanner() + o1, o2 = no('foo'), no('foo') + o1.path = Path('foo') + o2.path = Path('foo/bar') + [group] = s.GetDupeGroups([o1, o2]) + self.assertTrue(group.ref is o2) + + def test_tie_breaker_copy(self): + # if copy is in the words used (even if it has a deeper path), it becomes a dupe + s = Scanner() + o1, o2 = no('foo bar Copy'), no('foo bar') + o1.path = Path('deeper/path') + o2.path = Path('foo') + [group] = s.GetDupeGroups([o1, o2]) + self.assertTrue(group.ref is o2) + + def test_tie_breaker_same_name_plus_digit(self): + # if ref has the same words as dupe, but has some just one extra word which is a digit, it + # becomes a dupe + s = Scanner() + o1, o2 = no('foo bar 42'), no('foo bar') + o1.path = Path('deeper/path') + o2.path = Path('foo') + [group] = s.GetDupeGroups([o1, o2]) + self.assertTrue(group.ref is o2) + + def test_partial_group_match(self): + # Count the number od discarded matches (when a file doesn't match all other dupes of the + # group) in Scanner.discarded_file_count + s = Scanner() + o1, o2, o3 = no('a b'), no('a'), no('b') + s.min_match_percentage = 50 + [group] = s.GetDupeGroups([o1, o2, o3]) + self.assertEqual(len(group), 2) + self.assertTrue(o1 in group) + self.assertTrue(o2 in group) + self.assertTrue(o3 not in group) + self.assertEqual(s.discarded_file_count, 1) + + +class TCScannerME(TestCase): + def test_priorize(self): + # in ScannerME, bitrate goes first (right after is_ref) in priorization + s = ScannerME() + o1, o2 = no('foo'), no('foo') + o1.bitrate = 1 + o2.bitrate = 2 + [group] = s.GetDupeGroups([o1, o2]) + self.assertTrue(group.ref is o2) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/pe/qt/gen.py b/pe/qt/gen.py new file mode 100644 index 00000000..8bec8d5b --- /dev/null +++ b/pe/qt/gen.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# Unit Name: gen +# Created By: Virgil Dupras +# Created On: 2009-05-22 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os +import os.path as op + +def print_and_do(cmd): + print cmd + os.system(cmd) + +def move(src, dst): + if not op.exists(src): + return + if op.exists(dst): + os.remove(dst) + print 'Moving %s --> %s' % (src, dst) + os.rename(src, dst) + +os.chdir('dupeguru') +print_and_do('python gen.py') +os.chdir('..') + +os.chdir('base') +print_and_do('python gen.py') +os.chdir('..') + +os.chdir(op.join('modules', 'block')) +os.system('python setup.py build_ext --inplace') +move(op.join('modules', 'block', '_block.so'), op.join('picture', '_block.so')) +move(op.join('modules', 'block', '_block.pyd'), op.join('picture', '_block.pyd')) +os.chdir(op.join('..', '..')) + +print_and_do("pyuic4 details_dialog.ui > details_dialog_ui.py") +print_and_do("pyuic4 preferences_dialog.ui > preferences_dialog_ui.py") + +os.chdir('help') +print_and_do('python gen.py') +os.chdir('..') \ No newline at end of file diff --git a/pe/qt/help/changelog.yaml b/pe/qt/help/changelog.yaml new file mode 100644 index 00000000..f387c334 --- /dev/null +++ b/pe/qt/help/changelog.yaml @@ -0,0 +1,174 @@ +- date: 2009-05-27 + version: 1.7.2 + description: | + * Fixed a bug causing '.jpeg' files not to be scanned. + * Fixed a bug causing a GUI freeze at the beginning of a scan with a lot of files. + * Fixed a bug that sometimes caused a crash when an action was cancelled, and then started again. + * Improved scanning speed. +- date: 2009-05-26 + version: 1.7.1 + description: | + * Fixed a bug causing the "Match Scaled" preference to be inverted. +- date: 2009-05-20 + version: 1.7.0 + description: | + * Fixed the bug from 1.6.0 preventing PowerPC macs from running the application. + * Converted the Windows GUI to Qt, thus enabling multiprocessing and making the scanning process + faster. +- date: 2009-03-24 + description: "* **Improved** scanning speed, mainly on OS X where all cores of the\ + \ CPU are now used.\r\n* **Fixed** an occasional crash caused by permission issues.\r\ + \n* **Fixed** a bug where the \"X discarded\" notice would show a too large number\ + \ of discarded duplicates." + version: 1.6.0 +- date: 2008-09-10 + description: "
    \n\t\t\t\t\t\t
  • Added a notice in the status bar when\ + \ matches were discarded during the scan.
  • \n\t\t\t\t\t\t
  • Improved\ + \ duplicate prioritization (smartly chooses which file you will keep).
  • \n\t\ + \t\t\t\t\t
  • Improved scan progress feedback.
  • \n\t\t\t\t\t\t
  • Improved\ + \ responsiveness of the user interface for certain actions.
  • \n\t\t \ + \
" + version: 1.5.0 +- date: 2008-07-28 + description: "
    \n\t\t\t\t\t\t
  • Improved iPhoto compatibility on Mac\ + \ OS X.
  • \n\t\t\t\t\t\t
  • Improved the speed of results loading and\ + \ saving.
  • \n\t\t\t\t\t\t
  • Fixed a crash sometimes occurring during\ + \ duplicate deletion.
  • \n\t\t
" + version: 1.4.2 +- date: 2008-04-12 + description: "
    \n\t\t\t\t\t\t
  • Improved iPhoto Library loading feedback\ + \ on Mac OS X.
  • \n\t\t\t\t\t\t
  • Fixed the directory selection dialog.\ + \ Bundles can be selected again on Mac OS X.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ \"Clear Ignore List\" crash in Windows.
  • \n\t\t
" + version: 1.4.1 +- date: 2008-02-20 + description: "
    \n\t\t\t\t\t\t
  • Added iPhoto Library support on Mac OS\ + \ X.
  • \n\t\t\t\t\t\t
  • Fixed occasional crashes when scanning corrupted\ + \ pictures.
  • \n\t\t
" + version: 1.4.0 +- date: 2008-02-20 + description: "
    \n\t\t\t\t\t\t
  • Added iPhoto Library support on Mac OS\ + \ X.
  • \n\t\t\t\t\t\t
  • Fixed occasional crashes when scanning corrupted\ + \ pictures.
  • \n\t\t
" + version: 1.4.0 +- date: 2008-01-12 + description: "
    \n\t\t\t\t\t\t
  • Improved scan, delete and move speed\ + \ in situations where there were a lot of duplicates.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ occasional crashes when moving a lot of files at once.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ an issue sometimes preventing the application from starting at all.
  • \n\t\ + \t
" + version: 1.3.4 +- date: 2007-12-03 + description: "
    \n\t\t\t\t\t\t
  • Improved the handling of low memory situations.
  • \n\ + \t\t\t\t\t\t
  • Improved the directory panel. The \"Remove\" button changes\ + \ to \"Put Back\" when an excluded directory is selected.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ the directory selection dialog. iPhoto '08 library files can now be selected.
  • \n\ + \t\t
" + version: 1.3.3 +- date: 2007-11-24 + description: "
    \n\t\t\t\t\t\t
  • Added the \"Remove empty folders\" option.
  • \n\ + \t\t\t\t\t\t
  • Fixed results load/save issues.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ occasional status bar inaccuracies when the results are filtered.
  • \n\t\t\ + \
" + version: 1.3.2 +- date: 2007-10-21 + description: "
    \n\t\t\t\t\t\t
  • Improved results loading speed.
  • \n\ + \t\t\t\t\t\t
  • Improved details panel's picture loading (made it asynchronous).
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug where the stats line at the bottom would sometimes\ + \ go confused while having a filter active.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ a bug under Windows where some duplicate markings would be lost.
  • \n\t\t\ + \
" + version: 1.3.1 +- date: 2007-09-22 + description: "
    \n\t\t\t\t\t\t
  • Added post scan filtering.
  • \n\t\t\ + \t\t\t\t
  • Fixed issues with the rename feature under Windows
  • \n\t\ + \t\t\t\t\t
  • Fixed some user interface annoyances under Windows
  • \n\ + \t\t
" + version: 1.3.0 +- date: 2007-05-19 + description: "
    \n\t\t\t\t\t\t
  • Improved UI responsiveness (using threads)\ + \ under Mac OS X.
  • \n\t\t\t\t\t\t
  • Improved result load/save speed\ + \ and memory usage.
  • \n\t\t
" + version: 1.2.1 +- date: 2007-03-17 + description: "
    \n\t\t\t\t\t\t
  • Changed the picture decoding libraries\ + \ for both Mac OS X and Windows. The Mac OS X version uses the Core Graphics library\ + \ and the Windows version uses the .NET framework imaging capabilities. This results\ + \ in much faster scans. As a bonus, the Mac OS X version of dupeGuru PE now supports\ + \ RAW images.
  • \n\t\t
" + version: 1.2.0 +- date: 2007-02-11 + description: "
    \n\t\t\t\t\t\t
  • Added Re-orderable columns. In fact,\ + \ I re-added the feature which was lost in the C# conversion in 2.4.0 (Windows).
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug with all the Delete/Move/Copy actions with\ + \ certain kinds of files.
  • \n\t\t
" + version: 1.1.6 +- date: 2007-01-11 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug with the Move action.
  • \n\ + \t\t
" + version: 1.1.5 +- date: 2007-01-09 + description: "
    \n\t\t\t\t\t\t
  • Fixed a \"ghosting\" bug. Dupes deleted\ + \ by dupeGuru would sometimes come back in subsequent scans (Windows).
  • \n\t\ + \t\t\t\t\t
  • Fixed bugs sometimes making dupeGuru crash when marking a\ + \ dupe (Windows).
  • \n\t\t\t\t\t\t
  • Fixed some minor visual glitches\ + \ (Windows).
  • \n\t\t
" + version: 1.1.4 +- date: 2006-12-23 + description: "
    \n\t\t\t\t\t\t
  • Improved the caching system. This makes\ + \ duplicate scans significantly faster.
  • \n\t\t\t\t\t\t
  • Improved\ + \ the rename file dialog to exclude the extension from the original selection\ + \ (so when you start typing your new filename, it doesn't overwrite it) (Windows).
  • \n\ + \t\t\t\t\t\t
  • Changed some menu key shortcuts that created conflicts\ + \ (Windows).
  • \n\t\t\t\t\t\t
  • Fixed a bug preventing files from \"\ + reference\" directories to be displayed in blue in the results (Windows).
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug preventing some files to be sent to the recycle\ + \ bin (Windows).
  • \n\t\t\t\t\t\t
  • Fixed a bug with the \"Remove\"\ + \ button of the directories panel (Windows).
  • \n\t\t\t\t\t\t
  • Fixed\ + \ a bug in the packaging preventing certain Windows configurations to start dupeGuru\ + \ at all.
  • \n\t\t
" + version: 1.1.3 +- date: 2006-11-18 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug with directory states.
  • \n\ + \t\t
" + version: 1.1.2 +- date: 2006-11-17 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug causing the ignore list not\ + \ to be saved.
  • \n\t\t\t\t\t\t
  • Fixed a bug with selection under Power\ + \ Marker mode.
  • \n\t\t
" + version: 1.1.1 +- date: 2006-11-15 + description: "
    \n\t\t\t\t\t\t
  • Changed the Windows interface. It is\ + \ now .NET based.
  • \n\t\t\t\t\t\t
  • Added an auto-update feature to\ + \ the windows version.
  • \n\t\t\t\t\t\t
  • Changed the way power marking\ + \ works. It is now a mode instead of a separate window.
  • \n\t\t\t\t\t\t
  • Changed\ + \ the \"Size (MB)\" column for a \"Size (KB)\" column. The values are now \"ceiled\"\ + \ instead of rounded. Therefore, a size \"0\" is now really 0 bytes, not just\ + \ a value too small to be rounded up. It is also the case for delta values.
  • \n\ + \t\t\t\t\t\t
  • Fixed a bug sometimes making delete and move operations\ + \ stall.
  • \n\t\t
" + version: 1.1.0 +- date: 2006-10-12 + description: "
    \n\t\t\t\t\t\t
  • Added an auto-update feature in the Mac\ + \ OS X version (with Sparkle).
  • \n\t\t \t
  • Fixed a bug\ + \ sometimes causing inaccuracies of the Match %.
  • \n\t\t
" + version: 1.0.5 +- date: 2006-09-21 + description: "
    \n\t\t \t
  • Fixed a bug with the cache system.
  • \n\ + \t\t
" + version: 1.0.4 +- date: 2006-09-15 + description: "
    \n\t\t\t\t\t\t
  • Added the ability to search for scaled\ + \ duplicates.
  • \n\t\t\t\t\t\t
  • Added a cache system for faster scans.
  • \n\ + \t\t \t
  • Improved speed of the scanning engine.
  • \n\t\t\ + \
" + version: 1.0.3 +- date: 2006-09-11 + description: "
    \n\t\t \t
  • Improved speed of the scanning\ + \ engine.
  • \n\t\t\t\t\t\t
  • Improved the display of pictures in the\ + \ details panel (Windows).
  • \n\t\t
" + version: 1.0.2 +- date: 2006-09-08 + description: "
    \n\t\t \t
  • Initial release.
  • \n\t\t \ + \
" + version: 1.0.0 diff --git a/pe/qt/help/gen.py b/pe/qt/help/gen.py new file mode 100644 index 00000000..8ed33e4e --- /dev/null +++ b/pe/qt/help/gen.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +# Unit Name: +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os + +from web import generate_help + +generate_help.main('.', 'dupeguru_pe_help', force_render=True) diff --git a/pe/qt/help/skeleton/hardcoded.css b/pe/qt/help/skeleton/hardcoded.css new file mode 100644 index 00000000..a3b17c5b --- /dev/null +++ b/pe/qt/help/skeleton/hardcoded.css @@ -0,0 +1,409 @@ +/***************************************************** + General settings +*****************************************************/ + +BODY +{ + background-color:white; +} + +BODY,A,P,UL,TABLE,TR,TD +{ + font-family:Tahoma,Arial,sans-serif; + font-size:10pt; + color: #4477AA;/*darker than 5588bb for the sake of the eyes*/ +} + +/***************************************************** + "A" settings +*****************************************************/ + +A +{ + color: #ae322b; + text-decoration:underline; + font-weight:bold; +} + +A.glossaryword {color:#A0A0A0;} + +A.noline +{ + text-decoration: none; +} + + +/***************************************************** + Menu and mainframe settings +*****************************************************/ + +.maincontainer +{ + display:block; + margin-left:7%; + margin-right:7%; + padding-left:5px; + padding-right:0px; + border-color:#CCCCCC; + border-style:solid; + border-width:2px; + border-right-width:0px; + border-bottom-width:0px; + border-top-color:#ae322b; + vertical-align:top; +} + +TD.menuframe +{ + width:30%; +} + +.menu +{ + margin:4px 4px 4px 4px; + margin-top: 16pt; + border-color:gray; + border-width:1px; + border-style:dotted; + padding-top:10pt; + padding-bottom:10pt; + padding-right:6pt; +} + +.submenu +{ + list-style-type: none; + margin-left:26pt; + margin-top:0pt; + margin-bottom:0pt; + padding-left:0pt; +} + +A.menuitem,A.menuitem_selected +{ + font-size:14pt; + font-family:Tahoma,Arial,sans-serif; + font-weight:normal; + padding-left:10pt; + color:#5588bb; + margin-right:2pt; + margin-left:4pt; + text-decoration:none; +} + +A.menuitem_selected +{ + font-weight:bold; +} + +A.submenuitem +{ + font-family:Tahoma,Arial,sans-serif; + font-weight:normal; + color:#5588bb; + text-decoration:none; +} + +.titleline +{ + border-width:3px; + border-style:solid; + border-left-width:0px; + border-right-width:0px; + border-top-width:0px; + border-color:#CCCCCC; + margin-left:28pt; + margin-right:2pt; + line-height:1px; + padding-top:0px; + margin-top:0px; + display:block; +} + +.titledescrip +{ + text-align:left; + display:block; + margin-left:26pt; + color:#ae322b; +} + +.mainlogo +{ + display:block; + margin-left:8%; + margin-top:4pt; + margin-bottom:4pt; +} + +/***************************************************** + IMG settings +*****************************************************/ + +IMG +{ + border-style:none; +} + +IMG.smallbutton +{ + margin-right: 20px; + float:none; +} + +IMG.floating +{ + float:left; + margin-right: 4pt; + margin-bottom: 4pt; +} + +IMG.lefticon +{ + vertical-align: middle; + padding-right: 2pt; +} + +IMG.righticon +{ + vertical-align: middle; + padding-left: 2pt; +} + +/***************************************************** + TABLE settings +*****************************************************/ + +TABLE +{ + border-style:none; +} + +TABLE.box +{ + width: 90%; + margin-left:5%; +} + +TABLE.centered +{ + margin-left: auto; + margin-right: auto; +} + +TABLE.hardcoded +{ + background-color: #225588; + margin-left: auto; + margin-right: auto; + width: 90%; +} + +TR { background-color: transparent; } + +TABLE.hardcoded TR { background-color: white } + +TABLE.hardcoded TR.header +{ + font-weight: bold; + color: black; + background-color: #C8D6E5; +} + +TABLE.hardcoded TR.header TD {color:black;} + +TABLE.hardcoded TD { padding-left: 2pt; } + +TD.minimelem { + padding-right:0px; + padding-left:0px; + text-align:center; +} + +TD.rightelem +{ + text-align:right; + /*padding-left:0pt;*/ + padding-right: 2pt; + width: 17%; +} + +/***************************************************** + P settings +*****************************************************/ + +p,.sub{text-align:justify;} +.centered{text-align:center;} +.sub +{ + padding-left: 16pt; + padding-right:16pt; +} + +.Note, .ContactInfo +{ + border-color: #ae322b; + border-width: 1pt; + border-style: dashed; + text-align:justify; + padding: 2pt 2pt 2pt 2pt; + margin-bottom:4pt; + margin-top:8pt; + list-style-position:inside; +} + +.ContactInfo +{ + width:60%; + margin-left:5%; +} + +.NewsItem +{ + border-color:#ae322b; + border-style: solid; + border-right:none; + border-top:none; + border-left:none; + border-bottom-width:1px; + text-align:justify; + padding-left:4pt; + padding-right:4pt; + padding-bottom:8pt; +} + +/***************************************************** + Lists settings +*****************************************************/ +UL.plain +{ + list-style-type: none; + padding-left:0px; + margin-left:0px; +} + +LI.plain +{ + list-style-type: none; +} + +LI.section +{ + padding-top: 6pt; +} + +UL.longtext LI +{ + border-color: #ae322b; + border-width:0px; + border-top-width:1px; + border-style:solid; + margin-top:12px; +} + +/* + with UL.longtext LI, there can be anything between + the UL and the LI, and it will still make the + lontext thing, I must break it with this hack +*/ +UL.longtext UL LI +{ + border-style:none; + margin-top:2px; +} + + +/***************************************************** + Titles settings +*****************************************************/ + +H1,H2,H3 +{ + font-family:"Courier New",monospace; + color:#5588bb; +} + +H1 +{ + font-size:18pt; + color: #ae322b; + border-color: #70A0CF; + border-width: 1pt; + border-style: solid; + margin-top: 16pt; + margin-left: 5%; + margin-right: 5%; + padding-top: 2pt; + padding-bottom:2pt; + text-align: center; +} + +H2 +{ + border-color: #ae322b; + border-bottom-width: 2px; + border-top-width: 0pt; + border-left-width: 2px; + border-right-width: 0pt; + border-bottom-color: #cccccc; + border-style: solid; + margin-top: 16pt; + margin-left: 0pt; + margin-right: 0pt; + padding-bottom:3pt; + padding-left:5pt; + text-align: left; + font-size:16pt; +} + +H3 +{ + display:block; + color:#ae322b; + border-color: #70A0CF; + border-bottom-width: 2px; + border-top-width: 0pt; + border-left-width: 0pt; + border-right-width: 0pt; + border-style: dashed; + margin-top: 12pt; + margin-left: 0pt; + margin-bottom: 4pt; + width:auto; + padding-bottom:3pt; + padding-right:2pt; + padding-left:2pt; + text-align: left; + font-weight:bold; +} + + +/***************************************************** + Misc. classes +*****************************************************/ +.longtext:first-letter {font-size: 150%} + +.price, .loweredprice, .specialprice {font-weight:bold;} + +.loweredprice {text-decoration:line-through} + +.specialprice {color:red} + +form +{ + margin:0px; +} + +.program_summary +{ + float:right; + margin: 32pt; + margin-top:0pt; + margin-bottom:0pt; +} + +.screenshot +{ + float:left; + margin: 8pt; +} \ No newline at end of file diff --git a/pe/qt/help/skeleton/images/hs_title.png b/pe/qt/help/skeleton/images/hs_title.png new file mode 100644 index 0000000000000000000000000000000000000000..07bd89c69dd50a3967b46a441741f274b8854de8 GIT binary patch literal 1817 zcmWkt2~Y|q^t%aD27PTfWQbSo7y0Xrdp{OF7oMwX4{+byFr3&y7B zlLpel1d+*^<>$!E@c2A9sOuxRq}jS+G}rcy>y2h&rngp=tT$NjxX)#-@ zR;$fwvyo;C3xs)cj4wl35`-y%c{0>!qGG~a8Nvb)0K&L3RHVdNQtA}p%Q4Dz$v^^f z%s`q=W-AZEN|l62NeGp=(QGM^zcEYpnJq53Zg_psX$pDf}jksh9!YZ6*y0d6t`*06htZIC4pHW%9Epf zh*AWzsT7ofh)_XrrKsL$;mT2tj52IVPjY1#SB?oFlp&##5=^Q9qlF{I7FJ9G2rF*a z2s?-pFR&PpFjtBRA?yV{E9}sUAgt)Ih9}1>x(SvT zsqP+UieWa0F{DUM&ub2ZbHs4dvq=VsEE&xV>e|H!OM+4TQTc&M3CEU=q(F(^fAYG# zOS_;aD|@ud29hH~gpd|cpbwr+aO8+kiBoq^eznVg<_$ zP1%d1$UV?R~TV5+i6w>mO|X i&$iNccmR(ItgDH2&`ti^@h8+@ro}}kMz!wGy!C(b`7gZy literal 0 HcmV?d00001 diff --git a/pe/qt/help/templates/base_dg.mako b/pe/qt/help/templates/base_dg.mako new file mode 100644 index 00000000..7767c49f --- /dev/null +++ b/pe/qt/help/templates/base_dg.mako @@ -0,0 +1,14 @@ +<%inherit file="/base_help.mako"/> +${next.body()} + +<%def name="menu()"><% +self.menuitem('intro.htm', 'Introduction', 'Introduction to dupeGuru') +self.menuitem('quick_start.htm', 'Quick Start', 'Quickly get into the action') +self.menuitem('directories.htm', 'Directories', 'Managing dupeGuru directories') +self.menuitem('preferences.htm', 'Preferences', 'Setting dupeGuru preferences') +self.menuitem('results.htm', 'Results', 'Time to delete these duplicates!') +self.menuitem('power_marker.htm', 'Power Marker', 'Take control of your duplicates') +self.menuitem('faq.htm', 'F.A.Q.', 'Frequently Asked Questions') +self.menuitem('versions.htm', 'Version History', 'Changes dupeGuru went through') +self.menuitem('credits.htm', 'Credits', 'People who contributed to dupeGuru') +%> \ No newline at end of file diff --git a/pe/qt/help/templates/credits.mako b/pe/qt/help/templates/credits.mako new file mode 100644 index 00000000..9de91bd2 --- /dev/null +++ b/pe/qt/help/templates/credits.mako @@ -0,0 +1,25 @@ +## -*- coding: utf-8 -*- +<%! + title = 'Credits' + selected_menu_item = 'Credits' +%> +<%inherit file="/base_dg.mako"/> +Below is the list of people who contributed, directly or indirectly to dupeGuru. + +${self.credit('Virgil Dupras', 'Developer', "That's me, Hardcoded Software founder", 'www.hardcoded.net', 'hsoft@hardcoded.net')} + +${self.credit(u'Jérôme Cantin', u'Icon designer', u"Icons in dupeGuru are from him")} + +${self.credit('Python', 'Programming language', "The bestest of the bests", 'www.python.org')} + +${self.credit('PyObjC', 'Python-to-Cocoa bridge', "Used for the Mac OS X version", 'pyobjc.sourceforge.net')} + +${self.credit('PyQt', 'Python-to-Qt bridge', "Used for the Windows version", 'www.riverbankcomputing.co.uk')} + +${self.credit('Qt', 'GUI Toolkit', "Used for the Windows version", 'www.qtsoftware.com')} + +${self.credit('Sparkle', 'Auto-update library', "Used for the Mac OS X version", 'andymatuschak.org/pages/sparkle')} + +${self.credit('Python Imaging Library', 'Picture analyzer', "Used for the Windows version", 'www.pythonware.com/products/pil/')} + +${self.credit('You', 'dupeGuru user', "What would I do without you?")} diff --git a/pe/qt/help/templates/directories.mako b/pe/qt/help/templates/directories.mako new file mode 100644 index 00000000..e75b47bd --- /dev/null +++ b/pe/qt/help/templates/directories.mako @@ -0,0 +1,24 @@ +<%! + title = 'Directories' + selected_menu_item = 'Directories' +%> +<%inherit file="/base_dg.mako"/> + +There is a panel in dupeGuru called **Directories**. You can open it by clicking on the **Directories** button. This directory contains the list of the directories that will be scanned when you click on **Start Scanning**. + +This panel is quite straightforward to use. If you want to add a directory, click on **Add**. If you added directories before, a popup menu with a list of recent directories you added will pop. You can click on one of them to add it directly to your list. If you click on the first item of the popup menu, **Add New Directory...**, you will be prompted for a directory to add. If you never added a directory, no menu will pop and you will directly be prompted for a new directory to add. + +To remove a directory, select the directory to remove and click on **Remove**. If a subdirectory is selected when you click remove, the selected directory will be set to **excluded** state (see below) instead of being removed. + +Directory states +----- + +Every directory can be in one of these 3 states: + +* **Normal:** Duplicates found in these directories can be deleted. +* **Reference:** Duplicates found in this directory **cannot** be deleted. Files in reference directories will be in a blue color in the results. +* **Excluded:** Files in this directory will not be included in the scan. + +The default state of a directory is, of course, **Normal**. You can use **Reference** state for a directory if you want to be sure that you won't delete any file from it. + +When you set the state of a directory, all subdirectories of this directory automatically inherit this state unless you explicitly set a subdirectory's state. diff --git a/pe/qt/help/templates/faq.mako b/pe/qt/help/templates/faq.mako new file mode 100644 index 00000000..1c4e998f --- /dev/null +++ b/pe/qt/help/templates/faq.mako @@ -0,0 +1,64 @@ +<%! + title = 'dupeGuru F.A.Q.' + selected_menu_item = 'F.A.Q.' +%> +<%inherit file="/base_dg.mako"/> + +<%text filter="md"> +### What is dupeGuru PE? + +dupeGuru Picture Edition (PE for short) is a tool to find duplicate pictures on your computer. Not only can it find exact matches, but it can also find duplicates among pictures of different kind (PNG, JPG, GIF etc..) and quality. + +### What makes it better than other duplicate scanners? + +The scanning engine is extremely flexible. You can tweak it to really get the kind of results you want. You can read more about dupeGuru tweaking option at the [Preferences page](preferences.htm). + +### How safe is it to use dupeGuru PE? + +Very safe. dupeGuru has been designed to make sure you don't delete files you didn't mean to delete. First, there is the reference directory system that lets you define directories where you absolutely **don't** want dupeGuru to let you delete files there, and then there is the group reference system that makes sure that you will **always** keep at least one member of the duplicate group. + +### What are the demo limitations of dupeGuru PE? + +In demo mode, you can only perform actions (delete/copy/move) on 10 duplicates per session. + +### The mark box of a file I want to delete is disabled. What must I do? + +You cannot mark the reference (The first file) of a duplicate group. However, what you can do is to promote a duplicate file to reference. Thus, if a file you want to mark is reference, select a duplicate file in the group that you want to promote to reference, and click on **Actions-->Make Selected Reference**. If the reference file is from a reference directory (filename written in blue letters), you cannot remove it from the reference position. + +### I have a directory from which I really don't want to delete files. + +If you want to be sure that dupeGuru will never delete file from a particular directory, just open the **Directories panel**, select that directory, and set its state to **Reference**. + +### What is this '(X discarded)' notice in the status bar? + +In some cases, some matches are not included in the final results for security reasons. Let me use an example. We have 3 file: A, B and C. We scan them using a low filter hardness. The scanner determines that A matches with B, A matches with C, but B does **not** match with C. Here, dupeGuru has kind of a problem. It cannot create a duplicate group with A, B and C in it because not all files in the group would match together. It could create 2 groups: one A-B group and then one A-C group, but it will not, for security reasons. Lets think about it: If B doesn't match with C, it probably means that either B, C or both are not actually duplicates. If there would be 2 groups (A-B and A-C), you would end up delete both B and C. And if one of them is not a duplicate, that is really not what you want to do, right? So what dupeGuru does in a case like this is to discard the A-C match (and adds a notice in the status bar). Thus, if you delete B and re-run a scan, you will have a A-C match in your next results. + +### I want to mark all files from a specific directory. What can I do? + +Enable the [Power Marker](power_marker.htm) mode and click on the Directory column to sort your duplicates by Directory. It will then be easy for you to select all duplicates from the same directory, and then press Space to mark all selected duplicates. + +### I want to remove all files that are more than 300 KB away from their reference file. What can I do? + +* Enable the [Power Marker](power_marker.htm) mode. +* Enable the **Delta Values** mode. +* Click on the "Size" column to sort the results by size. +* Select all duplicates below -300. +* Click on **Remove Selected from Results**. +* Select all duplicates over 300. +* Click on **Remove Selected from Results**. + +### I want to make my latest modified files reference files. What can I do? + +* Enable the [Power Marker](power_marker.htm) mode. +* Enable the **Delta Values** mode. +* Click on the "Modification" column to sort the results by modification date. +* Click on the "Modification" column again to reverse the sort order (see Power Marker page to know why). +* Select all duplicates over 0. +* Click on **Make Selected Reference**. + +### I want to mark all duplicates containing the word "copy". How do I do that? + +* **Windows**: Click on **Actions --> Apply Filter**, then type "copy", then click OK. +* **Mac OS X**: Type "copy" in the "Filter" field in the toolbar. +* Click on **Mark --> Mark All**. + \ No newline at end of file diff --git a/pe/qt/help/templates/intro.mako b/pe/qt/help/templates/intro.mako new file mode 100644 index 00000000..51d058c9 --- /dev/null +++ b/pe/qt/help/templates/intro.mako @@ -0,0 +1,13 @@ +<%! + title = 'Introduction to dupeGuru PE' + selected_menu_item = 'introduction' +%> +<%inherit file="/base_dg.mako"/> + +dupeGuru Picture Edition (PE for short) is a tool to find duplicate pictures on your computer. Not only can it find exact matches, but it can also find duplicates among pictures of different kind (PNG, JPG, GIF etc..) and quality. + +Although dupeGuru can easily be used without documentation, reading this file will help you to master it. If you are looking for guidance for your first duplicate scan, you can take a look at the [Quick Start](quick_start.htm) section. + +It is a good idea to keep dupeGuru PE updated. You can download the latest version on the [dupeGuru PE homepage](http://www.hardcoded.net/dupeguru_pe/). + +<%def name="meta()"> diff --git a/pe/qt/help/templates/power_marker.mako b/pe/qt/help/templates/power_marker.mako new file mode 100644 index 00000000..26078f3d --- /dev/null +++ b/pe/qt/help/templates/power_marker.mako @@ -0,0 +1,33 @@ +<%! + title = 'Power Marker' + selected_menu_item = 'Power Marker' +%> +<%inherit file="/base_dg.mako"/> + +You will probably not use the Power Marker feature very often, but if you get into a situation where you need it, you will be pretty happy that this feature exists. + +What is it? +----- + +When the Power Marker mode is enabled, the duplicates are shown without their respective reference file. You can select, mark and sort this list, just like in normal mode. + +So, what is it for? +----- + +The dupeGuru results, when in normal mode, are sorted according to duplicate groups' **reference file**. This means that if you want, for example, to mark all duplicates with the "exe" extension, you cannot just sort the results by "Kind" to have all exe duplicates together because a group can be composed of more than one kind of files. That is where Power Marker comes into play. To mark all your "exe" duplicates, you just have to: + +* Enable the Power marker mode. +* Add the "Kind" column with the "Columns" menu. +* Click on that "Kind" column to sort the list by kind. +* Locate the first duplicate with a "exe" kind. +* Select it. +* Scroll down the list to locate the last duplicate with a "exe" kind. +* Hold Shift and click on it. +* Press Space to mark all selected duplicates. + +Power Marker and delta values +----- + +The Power Marker unveil its true power when you use it with the **Delta Values** switch turned on. When you turn it on, relative values will be displayed instead of absolute ones. So if, for example, you want to remove from your results all duplicates that are more than 300 KB away from their reference, you could sort the Power Marker by Size, select all duplicates under -300 in the Size column, delete them, and then do the same for duplicates over 300 at the bottom of the list. + +You could also use it to change the reference priority of your duplicate list. When you make a fresh scan, if there are no reference directories, the reference file of every group is the biggest file. If you want to change that, for example, to the latest modification time, you can sort the Power Marker by modification time in **descending** order, select all duplicates with a modification time delta value higher than 0 and click on **Make Selected Reference**. The reason why you must make the sort order descending is because if 2 files among the same duplicate group are selected when you click on **Make Selected Reference**, only the first of the list will be made reference, the other will be ignored. And since you want the last modified file to be reference, having the sort order descending assures you that the first item of the list will be the last modified. diff --git a/pe/qt/help/templates/preferences.mako b/pe/qt/help/templates/preferences.mako new file mode 100644 index 00000000..0ef6a2ba --- /dev/null +++ b/pe/qt/help/templates/preferences.mako @@ -0,0 +1,23 @@ +<%! + title = 'Preferences' + selected_menu_item = 'Preferences' +%> +<%inherit file="/base_dg.mako"/> + +**Filter Hardness:** The higher is this setting, the "harder" is the filter (In other words, the less results you get). Most pictures of the same quality match at 100% even if the format is different (PNG and JPG for example.). However, if you want to make a PNG match with a lower quality JPG, you will have to set the filer hardness to lower than 100. The default, 95, is a sweet spot. + +**Match scaled pictures together:** If you check this box, pictures of different dimensions will be allowed in the same duplicate group. + +**Can mix file kind:** If you check this box, duplicate groups are allowed to have files with different extensions. If you don't check it, well, they aren't! + +**Use regular expressions when filtering:** If you check this box, the filtering feature will treat your filter query as a **regular expression**. Explaining them is beyond the scope of this document. A good place to start learning it is . + +**Remove empty folders after delete or move:** When this option is enabled, folders are deleted after a file is deleted or moved and the folder is empty. + +**Copy and Move:** Determines how the Copy and Move operations (in the Action menu) will behave. + +* **Right in destination:** All files will be sent directly in the selected destination, without trying to recreate the source path at all. +* **Recreate relative path:** The source file's path will be re-created in the destination directory up to the root selection in the Directories panel. For example, if you added "/Users/foobar/Picture" to your Directories panel and you move "/Users/foobar/Picture/2006/06/photo.jpg" to the destination "/Users/foobar/MyDestination", the final destination for the file will be "/Users/foobar/MyDestination/2006/06" ("/Users/foobar/Picture" has been trimmed from source's path in the final destination.). +* **Recreate absolute path:** The source file's path will be re-created in the destination directory in it's entirety. For example, if you move "/Users/foobar/Picture/2006/06/photo.jpg" to the destination "/Users/foobar/MyDestination", the final destination for the file will be "/Users/foobar/MyDestination/Users/foobar/Picture/2006/06". + +In all cases, dupeGuru PE nicely handles naming conflicts by prepending a number to the destination filename if the filename already exists in the destination. diff --git a/pe/qt/help/templates/quick_start.mako b/pe/qt/help/templates/quick_start.mako new file mode 100644 index 00000000..dde33c65 --- /dev/null +++ b/pe/qt/help/templates/quick_start.mako @@ -0,0 +1,18 @@ +<%! + title = 'Quick Start' + selected_menu_item = 'Quick Start' +%> +<%inherit file="/base_dg.mako"/> + +To get you quickly started with dupeGuru, let's just make a standard scan using default preferences. + +* Click on **Directories**. +* Click on **Add**. +* Choose a directory you want to scan for duplicates. +* Click on **Start Scanning**. +* Wait until the scan process is over. +* Look at every duplicate (The files that are indented) and verify that it is indeed a duplicate to the group's reference (The file above the duplicate that is not indented and have a disabled mark box). +* If a file is a false duplicate, select it and click on **Actions-->Remove Selected from Results**. +* Once you are sure that there is no false duplicate in your results, click on **Edit-->Mark All**, and then **Actions-->Send Marked to Recycle bin**. + +That is only a basic scan. There are a lot of tweaking you can do to get different results and several methods of examining and modifying your results. To know about them, just read the rest of this help file. diff --git a/pe/qt/help/templates/results.mako b/pe/qt/help/templates/results.mako new file mode 100644 index 00000000..53aa176f --- /dev/null +++ b/pe/qt/help/templates/results.mako @@ -0,0 +1,73 @@ +<%! + title = 'Results' + selected_menu_item = 'Results' +%> +<%inherit file="/base_dg.mako"/> + +When dupeGuru is finished scanning for duplicates, it will show its results in the form of duplicate group list. + +About duplicate groups +----- + +A duplicate group is a group of files that all match together. Every group has a **reference file** and one or more **duplicate files**. The reference file is the first file of the group. Its mark box is disabled. Below it, and indented, are the duplicate files. + +You can mark duplicate files, but you can never mark the reference file of a group. This is a security measure to prevent dupeGuru from deleting not only duplicate files, but their reference. You sure don't want that, do you? + +What determines which files are reference and which files are duplicates is first their directory state. A files from a reference directory will always be reference in a duplicate group. If all files are from a normal directory, the size determine which file will be the reference of a duplicate group. dupeGuru assumes that you always want to keep the biggest file, so the biggest files will take the reference position. + +You can change the reference file of a group manually. To do so, select the duplicate file you want to promote to reference, and click on **Actions-->Make Selected Reference**. + +Reviewing results +----- + +Although you can just click on **Edit-->Mark All** and then **Actions-->Send Marked to Recycle bin** to quickly delete all duplicate files in your results, it is always recommended to review all duplicates before deleting them. + +To help you reviewing the results, you can bring up the **Details panel**. This panel shows all the details of the currently selected file as well as its reference's details. This is very handy to quickly determine if a duplicate really is a duplicate. You can also double-click on a file to open it with its associated application. + +If you have more false duplicates than true duplicates (If your filter hardness is very low), the best way to proceed would be to review duplicates, mark true duplicates and then click on **Actions-->Send Marked to Recycle bin**. If you have more true duplicates than false duplicates, you can instead mark all files that are false duplicates, and use **Actions-->Remove Marked from Results**. + +Marking and Selecting +----- + +A **marked** duplicate is a duplicate with the little box next to it having a check-mark. A **selected** duplicate is a duplicate being highlighted. The multiple selection actions can be performed in dupeGuru in the standard way (Shift/Command/Control click). You can toggle all selected duplicates' mark state by pressing **space**. + +Delta Values +----- + +If you turn this switch on, some columns will display the value relative to the duplicate's reference instead of the absolute values. These delta values will also be displayed in a different color so you can spot them easily. For example, if a duplicate is 1.2 MB and its reference is 1.4 MB, the Size column will display -0.2 MB. This option is a killer feature when combined with the [Power Marker](power_marker.htm). + +Filtering +----- + +dupeGuru supports post-scan filtering. With it, you can narrow down your results so you can perform actions on a subset of it. For example, you could easily mark all duplicates with their filename containing "copy" from your results using the filter. + +**Windows:** To use the filtering feature, click on Actions --> Apply Filter, write down the filter you want to apply and click OK. To go back to unfiltered results, click on Actions --> Cancel Filter. + +**Mac OS X:** To use the filtering feature, type your filter in the "Filter" search field in the toolbar. To go back to unfiltered result, blank out the field, or click on the "X". + +In simple mode (the default mode), whatever you type as the filter is the string used to perform the actual filtering, with the exception of one wildcard: **\***. Thus, if you type "[*]" as your filter, it will match anything with [] brackets in it, whatever is in between those brackets. + +For more advanced filtering, you can turn "Use regular expressions when filtering" on. The filtering feature will then use **regular expressions**. A regular expression is a language for matching text. Explaining them is beyond the scope of this document. A good place to start learning it is . + +Matches are case insensitive in both simple and regexp mode. + +For the filter to match, your regular expression don't have to match the whole filename, it just have to contain a string matching the expression. + +You might notice that not all duplicates in the filtered results will match your filter. That is because as soon as one single duplicate in a group matches the filter, the whole group stays in the results so you can have a better view of the duplicate's context. However, non-matching duplicates are in "reference mode". Therefore, you can perform actions like Mark All and be sure to only mark filtered duplicates. + +Action Menu +----- + +* **Start Duplicate Scan:** Starts a new duplicate scan. +* **Clear Ignore List:** Remove all ignored matches you added. You have to start a new scan for the newly cleared ignore list to be effective. +* **Export Results to XHTML:** Take the current results, and create an XHTML file out of it. The columns that are visible when you click on this button will be the columns present in the XHTML file. The file will automatically be opened in your default browser. +* **Send Marked to Trash:** Send all marked duplicates to trash, obviously. +* **Move Marked to...:** Prompt you for a destination, and then move all marked files to that destination. Source file's path might be re-created in destination, depending on the "Copy and Move" preference. +* **Copy Marked to...:** Prompt you for a destination, and then copy all marked files to that destination. Source file's path might be re-created in destination, depending on the "Copy and Move" preference. +* **Remove Marked from Results:** Remove all marked duplicates from results. The actual files will not be touched and will stay where they are. +* **Remove Selected from Results:** Remove all selected duplicates from results. Note that all selected reference files will be ignored, only duplicates can be removed with this action. +* **Make Selected Reference:** Promote all selected duplicates to reference. If a duplicate is a part of a group having a reference file coming from a reference directory (in blue color), no action will be taken for this duplicate. If more than one duplicate among the same group are selected, only the first of each group will be promoted. +* **Add Selected to Ignore List:** This first removes all selected duplicates from results, and then add the match of that duplicate and the current reference in the ignore list. This match will not come up again in further scan. The duplicate itself might come back, but it will be matched with another reference file. You can clear the ignore list with the Clear Ignore List command. +* **Open Selected with Default Application:** Open the file with the application associated with selected file's type. +* **Reveal Selected in Finder:** Open the folder containing selected file. +* **Rename Selected:** Prompts you for a new name, and then rename the selected file. diff --git a/pe/qt/help/templates/versions.mako b/pe/qt/help/templates/versions.mako new file mode 100644 index 00000000..157c26ba --- /dev/null +++ b/pe/qt/help/templates/versions.mako @@ -0,0 +1,6 @@ +<%! + title = 'dupeGuru PE version history' + selected_menu_item = 'Version History' +%> +<%inherit file="/base_dg.mako"/> +${self.output_changelogs(changelog)} \ No newline at end of file diff --git a/pe/qt/installer.aip b/pe/qt/installer.aip new file mode 100644 index 00000000..d7f3ed6c --- /dev/null +++ b/pe/qt/installer.aip @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pe/qt/main_window.py b/pe/qt/main_window.py new file mode 100644 index 00000000..c1ba71bd --- /dev/null +++ b/pe/qt/main_window.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +# Unit Name: main_window +# Created By: Virgil Dupras +# Created On: 2009-05-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL +from PyQt4.QtGui import QMessageBox, QAction + +from base.main_window import MainWindow as MainWindowBase + +class MainWindow(MainWindowBase): + def _setupUi(self): + MainWindowBase._setupUi(self) + self.actionClearPictureCache = QAction("Clear Picture Cache", self) + self.menuFile.insertAction(self.actionClearIgnoreList, self.actionClearPictureCache) + self.connect(self.actionClearPictureCache, SIGNAL("triggered()"), self.clearPictureCacheTriggered) + + def clearPictureCacheTriggered(self): + title = "Clear Picture Cache" + msg = "Do you really want to remove all your cached picture analysis?" + if self._confirm(title, msg, QMessageBox.No): + self.app.scanner.match_factory.cached_blocks.clear() + QMessageBox.information(self, title, "Picture cache cleared.") + \ No newline at end of file diff --git a/pe/qt/modules/block/block.pyx b/pe/qt/modules/block/block.pyx new file mode 100644 index 00000000..777ff723 --- /dev/null +++ b/pe/qt/modules/block/block.pyx @@ -0,0 +1,39 @@ +cdef object getblock(object image): + cdef int width, height, pixel_count, red, green, blue, i, offset + cdef char *s + cdef unsigned char r, g, b + width = image.width() + height = image.height() + if width: + pixel_count = width * height + red = green = blue = 0 + tmp = image.bits().asstring(image.numBytes()) + s = tmp + for i in range(pixel_count): + offset = i * 3 + r = s[offset] + g = s[offset + 1] + b = s[offset + 2] + red += r + green += g + blue += b + return (red // pixel_count, green // pixel_count, blue // pixel_count) + else: + return (0, 0, 0) + +def getblocks(image, int block_count_per_side): + cdef int width, height, block_width, block_height, ih, iw, top, left + width = image.width() + height = image.height() + if not width: + return [] + block_width = max(width // block_count_per_side, 1) + block_height = max(height // block_count_per_side, 1) + result = [] + for ih in range(block_count_per_side): + top = min(ih * block_height, height - block_height) + for iw in range(block_count_per_side): + left = min(iw * block_width, width - block_width) + crop = image.copy(left, top, block_width, block_height) + result.append(getblock(crop)) + return result diff --git a/pe/qt/modules/block/setup.py b/pe/qt/modules/block/setup.py new file mode 100644 index 00000000..e37aee94 --- /dev/null +++ b/pe/qt/modules/block/setup.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python + +from distutils.core import setup +from distutils.extension import Extension +from Cython.Distutils import build_ext + +setup( + cmdclass = {'build_ext': build_ext}, + ext_modules = [Extension("_block", ["block.pyx"])] +) \ No newline at end of file diff --git a/pe/qt/preferences.py b/pe/qt/preferences.py new file mode 100644 index 00000000..4dd748fd --- /dev/null +++ b/pe/qt/preferences.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# Unit Name: preferences +# Created By: Virgil Dupras +# Created On: 2009-05-17 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import QSettings, QVariant + +from base.preferences import Preferences as PreferencesBase + +class Preferences(PreferencesBase): + # (width, is_visible) + COLUMNS_DEFAULT_ATTRS = [ + (200, True), # name + (180, True), # path + (60, True), # size + (40, False), # kind + (100, True), # dimensions + (120, False), # creation + (120, False), # modification + (60, True), # match % + (80, False), # dupe count + ] + + def _load_specific(self, settings, get): + self.match_scaled = get('MatchScaled', self.match_scaled) + + def _reset_specific(self): + self.filter_hardness = 95 + self.match_scaled = False + + def _save_specific(self, settings, set_): + set_('MatchScaled', self.match_scaled) + diff --git a/pe/qt/preferences_dialog.py b/pe/qt/preferences_dialog.py new file mode 100644 index 00000000..12505f95 --- /dev/null +++ b/pe/qt/preferences_dialog.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# Unit Name: preferences_dialog +# Created By: Virgil Dupras +# Created On: 2009-04-29 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QDialogButtonBox + +from preferences_dialog_ui import Ui_PreferencesDialog +import preferences + +class PreferencesDialog(QDialog, Ui_PreferencesDialog): + def __init__(self, parent, app): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.app = app + self._setupUi() + + self.connect(self.buttonBox, SIGNAL('clicked(QAbstractButton*)'), self.buttonClicked) + + def _setupUi(self): + self.setupUi(self) + + def load(self, prefs=None): + if prefs is None: + prefs = self.app.prefs + self.filterHardnessSlider.setValue(prefs.filter_hardness) + self.filterHardnessLabel.setNum(prefs.filter_hardness) + setchecked = lambda cb, b: cb.setCheckState(Qt.Checked if b else Qt.Unchecked) + setchecked(self.matchScaledBox, prefs.match_scaled) + setchecked(self.mixFileKindBox, prefs.mix_file_kind) + setchecked(self.useRegexpBox, prefs.use_regexp) + setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders) + self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type) + + def save(self): + prefs = self.app.prefs + prefs.filter_hardness = self.filterHardnessSlider.value() + ischecked = lambda cb: cb.checkState() == Qt.Checked + prefs.match_scaled = ischecked(self.matchScaledBox) + prefs.mix_file_kind = ischecked(self.mixFileKindBox) + prefs.use_regexp = ischecked(self.useRegexpBox) + prefs.remove_empty_folders = ischecked(self.removeEmptyFoldersBox) + prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() + + def resetToDefaults(self): + self.load(preferences.Preferences()) + + #--- Events + def buttonClicked(self, button): + role = self.buttonBox.buttonRole(button) + if role == QDialogButtonBox.ResetRole: + self.resetToDefaults() + diff --git a/pe/qt/preferences_dialog.ui b/pe/qt/preferences_dialog.ui new file mode 100644 index 00000000..f91b24cb --- /dev/null +++ b/pe/qt/preferences_dialog.ui @@ -0,0 +1,257 @@ + + + PreferencesDialog + + + + 0 + 0 + 366 + 249 + + + + Preferences + + + false + + + true + + + + + + + + + + + 0 + 0 + + + + Filter Hardness: + + + + + + + 0 + + + + + 12 + + + + + + 0 + 0 + + + + 1 + + + 100 + + + true + + + Qt::Horizontal + + + + + + + + 21 + 0 + + + + 100 + + + + + + + + + 0 + + + + + More Results + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Less Results + + + + + + + + + + + + + Match scaled pictures together + + + + + + + Can mix file kind + + + + + + + Use regular expressions when filtering + + + + + + + Remove empty folders on delete or move + + + + + + + + + + 0 + 0 + + + + Copy and Move: + + + + + + + + 0 + 0 + + + + + Right in destination + + + + + Recreate relative path + + + + + Recreate absolute path + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults + + + + + + + + + filterHardnessSlider + valueChanged(int) + filterHardnessLabel + setNum(int) + + + 182 + 26 + + + 271 + 26 + + + + + buttonBox + accepted() + PreferencesDialog + accept() + + + 182 + 228 + + + 182 + 124 + + + + + buttonBox + rejected() + PreferencesDialog + reject() + + + 182 + 228 + + + 182 + 124 + + + + + diff --git a/pe/qt/profile.py b/pe/qt/profile.py new file mode 100644 index 00000000..e285fafe --- /dev/null +++ b/pe/qt/profile.py @@ -0,0 +1,20 @@ +import sys +import cProfile +import pstats + +from PyQt4.QtCore import QCoreApplication +from PyQt4.QtGui import QApplication + +if sys.platform == 'win32': + from app_win import DupeGuru +else: + from app import DupeGuru + +if __name__ == "__main__": + app = QApplication(sys.argv) + QCoreApplication.setOrganizationName('Hardcoded Software') + QCoreApplication.setApplicationName('dupeGuru Picture Edition') + dgapp = DupeGuru() + cProfile.run('app.exec_()', '/tmp/prof') + p = pstats.Stats('/tmp/prof') + p.sort_stats('time').print_stats() \ No newline at end of file diff --git a/pe/qt/start.py b/pe/qt/start.py new file mode 100644 index 00000000..6de46e8e --- /dev/null +++ b/pe/qt/start.py @@ -0,0 +1,20 @@ +import sys + +from PyQt4.QtCore import QCoreApplication +from PyQt4.QtGui import QApplication, QIcon, QPixmap + +import base.dg_rc + +if sys.platform == 'win32': + from app_win import DupeGuru +else: + from app import DupeGuru + +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setWindowIcon(QIcon(QPixmap(":/logo_pe"))) + QCoreApplication.setOrganizationName('Hardcoded Software') + QCoreApplication.setApplicationName(DupeGuru.NAME) + QCoreApplication.setApplicationVersion(DupeGuru.VERSION) + dgapp = DupeGuru() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/pe/qt/verinfo b/pe/qt/verinfo new file mode 100644 index 00000000..d70ee2a0 --- /dev/null +++ b/pe/qt/verinfo @@ -0,0 +1,28 @@ +VSVersionInfo( + ffi=FixedFileInfo( + filevers=($versioncomma), + prodvers=($versioncomma), + mask=0x17, + flags=0x0, + OS=0x4, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904b0', + [StringStruct('CompanyName', 'Hardcoded Software'), + StringStruct('FileDescription', 'dupeGuru Picture Edition'), + StringStruct('FileVersion', '$version'), + StringStruct('InternalName', 'dupeGuru PE.exe'), + StringStruct('LegalCopyright', '(c) Hardcoded Software. All rights reserved.'), + StringStruct('OriginalFilename', 'dupeGuru PE.exe'), + StringStruct('ProductName', 'dupeGuru Picture Edition'), + StringStruct('ProductVersion', '$versioncomma')]) + ]), + VarFileInfo([VarStruct('Translation', [1033])]) + ] +) diff --git a/py/__init__.py b/py/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/py/__init__.py @@ -0,0 +1 @@ + diff --git a/py/app.py b/py/app.py new file mode 100644 index 00000000..0e03603d --- /dev/null +++ b/py/app.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.app +Created By: Virgil Dupras +Created On: 2006/11/11 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:02:48 +0200 (Thu, 28 May 2009) $ + $Revision: 4388 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +import os +import os.path as op +import logging + +from hsfs import IT_ATTRS, IT_EXTRA +from hsutil import job, io, files +from hsutil.path import Path +from hsutil.reg import RegistrableApplication, RegistrationRequired +from hsutil.misc import flatten, first +from hsutil.str import escape + +import directories +import results +import scanner + +JOB_SCAN = 'job_scan' +JOB_LOAD = 'job_load' +JOB_MOVE = 'job_move' +JOB_COPY = 'job_copy' +JOB_DELETE = 'job_delete' + +class NoScannableFileError(Exception): + pass + +class AllFilesAreRefError(Exception): + pass + +class DupeGuru(RegistrableApplication): + def __init__(self, data_module, appdata, appid): + RegistrableApplication.__init__(self, appid) + self.appdata = appdata + if not op.exists(self.appdata): + os.makedirs(self.appdata) + self.data = data_module + self.directories = directories.Directories() + self.results = results.Results(data_module) + self.scanner = scanner.Scanner() + self.action_count = 0 + self.last_op_error_count = 0 + self.options = { + 'escape_filter_regexp': True, + 'clean_empty_dirs': False, + } + + def _demo_check(self): + if self.registered: + return + count = self.results.mark_count + if count + self.action_count > 10: + raise RegistrationRequired() + else: + self.action_count += count + + def _do_delete(self, j): + def op(dupe): + j.add_progress() + return self._do_delete_dupe(dupe) + + j.start_job(self.results.mark_count) + self.last_op_error_count = self.results.perform_on_marked(op, True) + + def _do_delete_dupe(self, dupe): + if not io.exists(dupe.path): + dupe.parent = None + return True + self._recycle_dupe(dupe) + self.clean_empty_dirs(dupe.path[:-1]) + if not io.exists(dupe.path): + dupe.parent = None + return True + logging.warning(u"Could not send {0} to trash.".format(unicode(dupe.path))) + return False + + def _do_load(self, j): + self.directories.LoadFromFile(op.join(self.appdata, 'last_directories.xml')) + j = j.start_subjob([1, 9]) + self.results.load_from_xml(op.join(self.appdata, 'last_results.xml'), self._get_file, j) + files = flatten(g[:] for g in self.results.groups) + for file in j.iter_with_progress(files, 'Reading metadata %d/%d'): + file._read_all_info(sections=[IT_ATTRS, IT_EXTRA]) + + def _get_file(self, str_path): + p = Path(str_path) + for d in self.directories: + if p not in d.path: + continue + result = d.find_path(p[d.path:]) + if result is not None: + return result + + @staticmethod + def _recycle_dupe(dupe): + raise NotImplementedError() + + def _start_job(self, jobid, func): + # func(j) + raise NotImplementedError() + + def AddDirectory(self, d): + try: + self.directories.add_path(Path(d)) + return 0 + except directories.AlreadyThereError: + return 1 + except directories.InvalidPathError: + return 2 + + def AddToIgnoreList(self, dupe): + g = self.results.get_group_of_duplicate(dupe) + for other in g: + if other is not dupe: + self.scanner.ignore_list.Ignore(unicode(other.path), unicode(dupe.path)) + + def ApplyFilter(self, filter): + self.results.apply_filter(None) + if self.options['escape_filter_regexp']: + filter = escape(filter, '()[]\\.|+?^') + filter = escape(filter, '*', '.') + self.results.apply_filter(filter) + + def clean_empty_dirs(self, path): + if self.options['clean_empty_dirs']: + while files.delete_if_empty(path, ['.DS_Store']): + path = path[:-1] + + def CopyOrMove(self, dupe, copy, destination, dest_type): + """ + copy: True = Copy False = Move + destination: string. + dest_type: 0 = right in destination. + 1 = relative re-creation. + 2 = absolute re-creation. + """ + source_path = dupe.path + location_path = dupe.root.path + dest_path = Path(destination) + if dest_type == 2: + dest_path = dest_path + source_path[1:-1] #Remove drive letter and filename + elif dest_type == 1: + dest_path = dest_path + source_path[location_path:-1] + if not io.exists(dest_path): + io.makedirs(dest_path) + try: + if copy: + files.copy(source_path, dest_path) + else: + files.move(source_path, dest_path) + self.clean_empty_dirs(source_path[:-1]) + except (IOError, OSError) as e: + operation = 'Copy' if copy else 'Move' + logging.warning('%s operation failed on %s. Error: %s' % (operation, unicode(dupe.path), unicode(e))) + return False + return True + + def copy_or_move_marked(self, copy, destination, recreate_path): + def do(j): + def op(dupe): + j.add_progress() + return self.CopyOrMove(dupe, copy, destination, recreate_path) + + j.start_job(self.results.mark_count) + self.last_op_error_count = self.results.perform_on_marked(op, not copy) + + self._demo_check() + jobid = JOB_COPY if copy else JOB_MOVE + self._start_job(jobid, do) + + def delete_marked(self): + self._demo_check() + self._start_job(JOB_DELETE, self._do_delete) + + def load(self): + self._start_job(JOB_LOAD, self._do_load) + self.LoadIgnoreList() + + def LoadIgnoreList(self): + p = op.join(self.appdata, 'ignore_list.xml') + self.scanner.ignore_list.load_from_xml(p) + + def make_reference(self, duplicates): + changed_groups = set() + for dupe in duplicates: + g = self.results.get_group_of_duplicate(dupe) + if g not in changed_groups: + self.results.make_ref(dupe) + changed_groups.add(g) + + def Save(self): + self.directories.SaveToFile(op.join(self.appdata, 'last_directories.xml')) + self.results.save_to_xml(op.join(self.appdata, 'last_results.xml')) + + def SaveIgnoreList(self): + p = op.join(self.appdata, 'ignore_list.xml') + self.scanner.ignore_list.save_to_xml(p) + + def start_scanning(self): + def do(j): + j.set_progress(0, 'Collecting files to scan') + files = list(self.directories.get_files()) + logging.info('Scanning %d files' % len(files)) + self.results.groups = self.scanner.GetDupeGroups(files, j) + + files = self.directories.get_files() + first_file = first(files) + if first_file is None: + raise NoScannableFileError() + if first_file.is_ref and all(f.is_ref for f in files): + raise AllFilesAreRefError() + self.results.groups = [] + self._start_job(JOB_SCAN, do) + + #--- Properties + @property + def stat_line(self): + result = self.results.stat_line + if self.scanner.discarded_file_count: + result = '%s (%d discarded)' % (result, self.scanner.discarded_file_count) + return result + diff --git a/py/app_cocoa.py b/py/app_cocoa.py new file mode 100644 index 00000000..4974d700 --- /dev/null +++ b/py/app_cocoa.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.app_cocoa +Created By: Virgil Dupras +Created On: 2006/11/11 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +from AppKit import * +import logging +import os.path as op + +import hsfs as fs +from hsfs.phys.bundle import Bundle +from hsutil.cocoa import install_exception_hook +from hsutil.str import get_file_ext +from hsutil import io, cocoa, job +from hsutil.reg import RegistrationRequired + +import export, app, data + +JOBID2TITLE = { + app.JOB_SCAN: "Scanning for duplicates", + app.JOB_LOAD: "Loading", + app.JOB_MOVE: "Moving", + app.JOB_COPY: "Copying", + app.JOB_DELETE: "Sending to Trash", +} + +class DGDirectory(fs.phys.Directory): + def _create_sub_dir(self,name,with_parent = True): + ext = get_file_ext(name) + if ext == 'app': + if with_parent: + parent = self + else: + parent = None + return Bundle(parent,name) + else: + return super(DGDirectory,self)._create_sub_dir(name,with_parent) + + +def demo_method(method): + def wrapper(self, *args, **kwargs): + try: + return method(self, *args, **kwargs) + except RegistrationRequired: + NSNotificationCenter.defaultCenter().postNotificationName_object_('RegistrationRequired', self) + + return wrapper + +class DupeGuru(app.DupeGuru): + def __init__(self, data_module, appdata_subdir, appid): + LOGGING_LEVEL = logging.DEBUG if NSUserDefaults.standardUserDefaults().boolForKey_('debug') else logging.WARNING + logging.basicConfig(level=LOGGING_LEVEL, format='%(levelname)s %(message)s') + logging.debug('started in debug mode') + install_exception_hook() + if data_module is None: + data_module = data + appdata = op.expanduser(op.join('~', '.hsoftdata', appdata_subdir)) + app.DupeGuru.__init__(self, data_module, appdata, appid) + self.progress = cocoa.ThreadedJobPerformer() + self.directories.dirclass = DGDirectory + self.display_delta_values = False + self.selected_dupes = [] + self.RefreshDetailsTable(None,None) + + #--- Override + @staticmethod + def _recycle_dupe(dupe): + if not io.exists(dupe.path): + dupe.parent = None + return True + directory = unicode(dupe.parent.path) + filename = dupe.name + result, tag = NSWorkspace.sharedWorkspace().performFileOperation_source_destination_files_tag_( + NSWorkspaceRecycleOperation, directory, '', [filename]) + if not io.exists(dupe.path): + dupe.parent = None + return True + logging.warning('Could not send %s to trash. tag: %d' % (unicode(dupe.path), tag)) + return False + + def _start_job(self, jobid, func): + try: + j = self.progress.create_job() + self.progress.run_threaded(func, args=(j, )) + except job.JobInProgressError: + NSNotificationCenter.defaultCenter().postNotificationName_object_('JobInProgress', self) + else: + ud = {'desc': JOBID2TITLE[jobid], 'jobid':jobid} + NSNotificationCenter.defaultCenter().postNotificationName_object_userInfo_('JobStarted', self, ud) + + #---Helpers + def GetObjects(self,node_path): + #returns a tuple g,d + try: + g = self.results.groups[node_path[0]] + if len(node_path) == 2: + return (g,self.results.groups[node_path[0]].dupes[node_path[1]]) + else: + return (g,None) + except IndexError: + return (None,None) + + def GetDirectory(self,node_path,curr_dir=None): + if not node_path: + return curr_dir + if curr_dir is not None: + l = curr_dir.dirs + else: + l = self.directories + d = l[node_path[0]] + return self.GetDirectory(node_path[1:],d) + + def RefreshDetailsTable(self,dupe,group): + l1 = self.data.GetDisplayInfo(dupe,group,False) + if group is not None: + l2 = self.data.GetDisplayInfo(group.ref,group,False) + else: + l2 = l1 #To have a list of empty '---' values + names = [c['display'] for c in self.data.COLUMNS] + self.details_table = zip(names,l1,l2) + + #---Public + def AddSelectedToIgnoreList(self): + for dupe in self.selected_dupes: + self.AddToIgnoreList(dupe) + + copy_or_move_marked = demo_method(app.DupeGuru.copy_or_move_marked) + delete_marked = demo_method(app.DupeGuru.delete_marked) + + def ExportToXHTML(self,column_ids,xslt_path,css_path): + columns = [] + for index,column in enumerate(self.data.COLUMNS): + display = column['display'] + enabled = str(index) in column_ids + columns.append((display,enabled)) + xml_path = op.join(self.appdata,'results_export.xml') + self.results.save_to_xml(xml_path,self.data.GetDisplayInfo) + return export.export_to_xhtml(xml_path,xslt_path,css_path,columns) + + def MakeSelectedReference(self): + self.make_reference(self.selected_dupes) + + def OpenSelected(self): + if self.selected_dupes: + path = unicode(self.selected_dupes[0].path) + NSWorkspace.sharedWorkspace().openFile_(path) + + def PurgeIgnoreList(self): + self.scanner.ignore_list.Filter(lambda f,s:op.exists(f) and op.exists(s)) + + def RefreshDetailsWithSelected(self): + if self.selected_dupes: + self.RefreshDetailsTable( + self.selected_dupes[0], + self.results.get_group_of_duplicate(self.selected_dupes[0]) + ) + else: + self.RefreshDetailsTable(None,None) + + def RemoveDirectory(self,index): + try: + del self.directories[index] + except IndexError: + pass + + def RemoveSelected(self): + self.results.remove_duplicates(self.selected_dupes) + + def RenameSelected(self,newname): + try: + d = self.selected_dupes[0] + d = d.move(d.parent,newname) + return True + except (IndexError,fs.FSError),e: + logging.warning("dupeGuru Warning: %s" % str(e)) + return False + + def RevealSelected(self): + if self.selected_dupes: + path = unicode(self.selected_dupes[0].path) + NSWorkspace.sharedWorkspace().selectFile_inFileViewerRootedAtPath_(path,'') + + def start_scanning(self): + self.RefreshDetailsTable(None, None) + try: + app.DupeGuru.start_scanning(self) + return 0 + except app.NoScannableFileError: + return 3 + except app.AllFilesAreRefError: + return 1 + + def SelectResultNodePaths(self,node_paths): + def extract_dupe(t): + g,d = t + if d is not None: + return d + else: + if g is not None: + return g.ref + + selected = [extract_dupe(self.GetObjects(p)) for p in node_paths] + self.selected_dupes = [dupe for dupe in selected if dupe is not None] + + def SelectPowerMarkerNodePaths(self,node_paths): + rows = [p[0] for p in node_paths] + self.selected_dupes = [ + self.results.dupes[row] for row in rows if row in xrange(len(self.results.dupes)) + ] + + def SetDirectoryState(self,node_path,state): + d = self.GetDirectory(node_path) + self.directories.SetState(d.path,state) + + def sort_dupes(self,key,asc): + self.results.sort_dupes(key,asc,self.display_delta_values) + + def sort_groups(self,key,asc): + self.results.sort_groups(key,asc) + + def ToggleSelectedMarkState(self): + for dupe in self.selected_dupes: + self.results.mark_toggle(dupe) + + #---Data + def GetOutlineViewMaxLevel(self, tag): + if tag == 0: + return 2 + elif tag == 1: + return 0 + elif tag == 2: + return 1 + + def GetOutlineViewChildCounts(self, tag, node_path): + if self.progress._job_running: + return [] + if tag == 0: #Normal results + assert not node_path # no other value is possible + return [len(g.dupes) for g in self.results.groups] + elif tag == 1: #Directories + dirs = self.GetDirectory(node_path).dirs if node_path else self.directories + return [d.dircount for d in dirs] + else: #Power Marker + assert not node_path # no other value is possible + return [0 for d in self.results.dupes] + + def GetOutlineViewValues(self, tag, node_path): + if self.progress._job_running: + return + if not node_path: + return + if tag in (0,2): #Normal results / Power Marker + if tag == 0: + g, d = self.GetObjects(node_path) + if d is None: + d = g.ref + else: + d = self.results.dupes[node_path[0]] + g = self.results.get_group_of_duplicate(d) + result = self.data.GetDisplayInfo(d, g, self.display_delta_values) + return result + elif tag == 1: #Directories + d = self.GetDirectory(node_path) + return [ + d.name, + self.directories.GetState(d.path) + ] + + def GetOutlineViewMarked(self, tag, node_path): + # 0=unmarked 1=marked 2=unmarkable + if self.progress._job_running: + return + if not node_path: + return 2 + if tag == 1: #Directories + return 2 + if tag == 0: #Normal results + g, d = self.GetObjects(node_path) + else: #Power Marker + d = self.results.dupes[node_path[0]] + if (d is None) or (not self.results.is_markable(d)): + return 2 + elif self.results.is_marked(d): + return 1 + else: + return 0 + + def GetTableViewCount(self, tag): + if self.progress._job_running: + return 0 + return len(self.details_table) + + def GetTableViewMarkedIndexes(self,tag): + return [] + + def GetTableViewValues(self,tag,row): + return self.details_table[row] + + diff --git a/py/app_cocoa_test.py b/py/app_cocoa_test.py new file mode 100644 index 00000000..ad8b937a --- /dev/null +++ b/py/app_cocoa_test.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.app_cocoa +Created By: Virgil Dupras +Created On: 2006/11/11 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-29 17:51:41 +0200 (Fri, 29 May 2009) $ + $Revision: 4409 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +import tempfile +import shutil +import logging + +from hsutil.path import Path +from hsutil.testcase import TestCase +from hsutil.decorators import log_calls +import hsfs.phys +import os.path as op + +from . import engine, data +try: + from .app_cocoa import DupeGuru as DupeGuruBase, DGDirectory +except ImportError: + from nose.plugins.skip import SkipTest + raise SkipTest("These tests can only be run on OS X") +from .results_test import GetTestGroups + +class DupeGuru(DupeGuruBase): + def __init__(self): + DupeGuruBase.__init__(self, data, '/tmp', appid=4) + + def _start_job(self, jobid, func): + func(nulljob) + + +def r2np(rows): + #Transforms a list of rows [1,2,3] into a list of node paths [[1],[2],[3]] + return [[i] for i in rows] + +class TCDupeGuru(TestCase): + def setUp(self): + self.app = DupeGuru() + self.objects,self.matches,self.groups = GetTestGroups() + self.app.results.groups = self.groups + + def test_GetObjects(self): + app = self.app + objects = self.objects + groups = self.groups + g,d = app.GetObjects([0]) + self.assert_(g is groups[0]) + self.assert_(d is None) + g,d = app.GetObjects([0,0]) + self.assert_(g is groups[0]) + self.assert_(d is objects[1]) + g,d = app.GetObjects([1,0]) + self.assert_(g is groups[1]) + self.assert_(d is objects[4]) + + def test_GetObjects_after_sort(self): + app = self.app + objects = self.objects + groups = self.groups[:] #To keep the old order in memory + app.sort_groups(0,False) #0 = Filename + #Now, the group order is supposed to be reversed + g,d = app.GetObjects([0,0]) + self.assert_(g is groups[1]) + self.assert_(d is objects[4]) + + def test_GetObjects_out_of_range(self): + app = self.app + self.assertEqual((None,None),app.GetObjects([2])) + self.assertEqual((None,None),app.GetObjects([])) + self.assertEqual((None,None),app.GetObjects([1,2])) + + def test_selectResultNodePaths(self): + app = self.app + objects = self.objects + app.SelectResultNodePaths([[0,0],[0,1]]) + self.assertEqual(2,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[1]) + self.assert_(app.selected_dupes[1] is objects[2]) + + def test_selectResultNodePaths_with_ref(self): + app = self.app + objects = self.objects + app.SelectResultNodePaths([[0,0],[0,1],[1]]) + self.assertEqual(3,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[1]) + self.assert_(app.selected_dupes[1] is objects[2]) + self.assert_(app.selected_dupes[2] is self.groups[1].ref) + + def test_selectResultNodePaths_empty(self): + self.app.SelectResultNodePaths([]) + self.assertEqual(0,len(self.app.selected_dupes)) + + def test_selectResultNodePaths_after_sort(self): + app = self.app + objects = self.objects + groups = self.groups[:] #To keep the old order in memory + app.sort_groups(0,False) #0 = Filename + #Now, the group order is supposed to be reversed + app.SelectResultNodePaths([[0,0],[1],[1,0]]) + self.assertEqual(3,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[4]) + self.assert_(app.selected_dupes[1] is groups[0].ref) + self.assert_(app.selected_dupes[2] is objects[1]) + + def test_selectResultNodePaths_out_of_range(self): + app = self.app + app.SelectResultNodePaths([[0,0],[0,1],[1],[1,1],[2]]) + self.assertEqual(3,len(app.selected_dupes)) + + def test_selectPowerMarkerRows(self): + app = self.app + objects = self.objects + app.SelectPowerMarkerNodePaths(r2np([0,1,2])) + self.assertEqual(3,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[1]) + self.assert_(app.selected_dupes[1] is objects[2]) + self.assert_(app.selected_dupes[2] is objects[4]) + + def test_selectPowerMarkerRows_empty(self): + self.app.SelectPowerMarkerNodePaths([]) + self.assertEqual(0,len(self.app.selected_dupes)) + + def test_selectPowerMarkerRows_after_sort(self): + app = self.app + objects = self.objects + app.sort_dupes(0,False) #0 = Filename + app.SelectPowerMarkerNodePaths(r2np([0,1,2])) + self.assertEqual(3,len(app.selected_dupes)) + self.assert_(app.selected_dupes[0] is objects[4]) + self.assert_(app.selected_dupes[1] is objects[2]) + self.assert_(app.selected_dupes[2] is objects[1]) + + def test_selectPowerMarkerRows_out_of_range(self): + app = self.app + app.SelectPowerMarkerNodePaths(r2np([0,1,2,3])) + self.assertEqual(3,len(app.selected_dupes)) + + def test_toggleSelectedMark(self): + app = self.app + objects = self.objects + app.ToggleSelectedMarkState() + self.assertEqual(0,app.results.mark_count) + app.SelectPowerMarkerNodePaths(r2np([0,2])) + app.ToggleSelectedMarkState() + self.assertEqual(2,app.results.mark_count) + self.assert_(not app.results.is_marked(objects[0])) + self.assert_(app.results.is_marked(objects[1])) + self.assert_(not app.results.is_marked(objects[2])) + self.assert_(not app.results.is_marked(objects[3])) + self.assert_(app.results.is_marked(objects[4])) + + def test_refreshDetailsWithSelected(self): + def mock_refresh(dupe,group): + self.called = True + if self.app.selected_dupes: + self.assert_(dupe is self.app.selected_dupes[0]) + self.assert_(group is self.app.results.get_group_of_duplicate(dupe)) + else: + self.assert_(dupe is None) + self.assert_(group is None) + + self.app.RefreshDetailsTable = mock_refresh + self.called = False + self.app.SelectPowerMarkerNodePaths(r2np([0,2])) + self.app.RefreshDetailsWithSelected() + self.assert_(self.called) + self.called = False + self.app.SelectPowerMarkerNodePaths([]) + self.app.RefreshDetailsWithSelected() + self.assert_(self.called) + + def test_makeSelectedReference(self): + app = self.app + objects = self.objects + groups = self.groups + app.SelectPowerMarkerNodePaths(r2np([0,2])) + app.MakeSelectedReference() + self.assert_(groups[0].ref is objects[1]) + self.assert_(groups[1].ref is objects[4]) + + def test_makeSelectedReference_by_selecting_two_dupes_in_the_same_group(self): + app = self.app + objects = self.objects + groups = self.groups + app.SelectPowerMarkerNodePaths(r2np([0,1,2])) + #Only 0 and 2 must go ref, not 1 because it is a part of the same group + app.MakeSelectedReference() + self.assert_(groups[0].ref is objects[1]) + self.assert_(groups[1].ref is objects[4]) + + def test_removeSelected(self): + app = self.app + app.SelectPowerMarkerNodePaths(r2np([0,2])) + app.RemoveSelected() + self.assertEqual(1,len(app.results.dupes)) + app.RemoveSelected() + self.assertEqual(1,len(app.results.dupes)) + app.SelectPowerMarkerNodePaths(r2np([0,2])) + app.RemoveSelected() + self.assertEqual(0,len(app.results.dupes)) + + def test_addDirectory_simple(self): + app = self.app + self.assertEqual(0,app.AddDirectory(self.datadirpath())) + self.assertEqual(1,len(app.directories)) + + def test_addDirectory_already_there(self): + app = self.app + self.assertEqual(0,app.AddDirectory(self.datadirpath())) + self.assertEqual(1,app.AddDirectory(self.datadirpath())) + + def test_addDirectory_does_not_exist(self): + app = self.app + self.assertEqual(2,app.AddDirectory('/does_not_exist')) + + def test_ignore(self): + app = self.app + app.SelectPowerMarkerNodePaths(r2np([2])) #The dupe of the second, 2 sized group + app.AddSelectedToIgnoreList() + self.assertEqual(1,len(app.scanner.ignore_list)) + app.SelectPowerMarkerNodePaths(r2np([0])) #first dupe of the 3 dupes group + app.AddSelectedToIgnoreList() + #BOTH the ref and the other dupe should have been added + self.assertEqual(3,len(app.scanner.ignore_list)) + + def test_purgeIgnoreList(self): + app = self.app + p1 = self.filepath('zerofile') + p2 = self.filepath('zerofill') + dne = '/does_not_exist' + app.scanner.ignore_list.Ignore(dne,p1) + app.scanner.ignore_list.Ignore(p2,dne) + app.scanner.ignore_list.Ignore(p1,p2) + app.PurgeIgnoreList() + self.assertEqual(1,len(app.scanner.ignore_list)) + self.assert_(app.scanner.ignore_list.AreIgnored(p1,p2)) + self.assert_(not app.scanner.ignore_list.AreIgnored(dne,p1)) + + def test_only_unicode_is_added_to_ignore_list(self): + def FakeIgnore(first,second): + if not isinstance(first,unicode): + self.fail() + if not isinstance(second,unicode): + self.fail() + + app = self.app + app.scanner.ignore_list.Ignore = FakeIgnore + app.SelectPowerMarkerNodePaths(r2np([2])) #The dupe of the second, 2 sized group + app.AddSelectedToIgnoreList() + + def test_dirclass(self): + self.assert_(self.app.directories.dirclass is DGDirectory) + + +class TCDupeGuru_renameSelected(TestCase): + def setUp(self): + p = Path(tempfile.mkdtemp()) + fp = open(str(p + 'foo bar 1'),mode='w') + fp.close() + fp = open(str(p + 'foo bar 2'),mode='w') + fp.close() + fp = open(str(p + 'foo bar 3'),mode='w') + fp.close() + refdir = hsfs.phys.Directory(None,str(p)) + matches = engine.MatchFactory().getmatches(refdir.files) + groups = engine.get_groups(matches) + g = groups[0] + g.prioritize(lambda x:x.name) + app = DupeGuru() + app.results.groups = groups + self.app = app + self.groups = groups + self.p = p + self.refdir = refdir + + def tearDown(self): + shutil.rmtree(str(self.p)) + + def test_simple(self): + app = self.app + refdir = self.refdir + g = self.groups[0] + app.SelectPowerMarkerNodePaths(r2np([0])) + self.assert_(app.RenameSelected('renamed')) + self.assert_('renamed' in refdir) + self.assert_('foo bar 2' not in refdir) + self.assert_(g.dupes[0] is refdir['renamed']) + self.assert_(g.dupes[0] in refdir) + + def test_none_selected(self): + app = self.app + refdir = self.refdir + g = self.groups[0] + app.SelectPowerMarkerNodePaths([]) + self.mock(logging, 'warning', log_calls(lambda msg: None)) + self.assert_(not app.RenameSelected('renamed')) + msg = logging.warning.calls[0]['msg'] + self.assertEqual('dupeGuru Warning: list index out of range', msg) + self.assert_('renamed' not in refdir) + self.assert_('foo bar 2' in refdir) + self.assert_(g.dupes[0] is refdir['foo bar 2']) + + def test_name_already_exists(self): + app = self.app + refdir = self.refdir + g = self.groups[0] + app.SelectPowerMarkerNodePaths(r2np([0])) + self.mock(logging, 'warning', log_calls(lambda msg: None)) + self.assert_(not app.RenameSelected('foo bar 1')) + msg = logging.warning.calls[0]['msg'] + self.assert_(msg.startswith('dupeGuru Warning: \'foo bar 2\' already exists in')) + self.assert_('foo bar 1' in refdir) + self.assert_('foo bar 2' in refdir) + self.assert_(g.dupes[0] is refdir['foo bar 2']) + diff --git a/py/app_me_cocoa.py b/py/app_me_cocoa.py new file mode 100644 index 00000000..51a61767 --- /dev/null +++ b/py/app_me_cocoa.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.app_me_cocoa +Created By: Virgil Dupras +Created On: 2006/11/16 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +import os.path as op +import logging +from appscript import app, k, CommandError +import time + +from hsutil.cocoa import as_fetch +import hsfs.phys.music + +import app_cocoa, data_me, scanner + +JOB_REMOVE_DEAD_TRACKS = 'jobRemoveDeadTracks' +JOB_SCAN_DEAD_TRACKS = 'jobScanDeadTracks' + +app_cocoa.JOBID2TITLE.update({ + JOB_REMOVE_DEAD_TRACKS: "Removing dead tracks from your iTunes Library", + JOB_SCAN_DEAD_TRACKS: "Scanning the iTunes Library", +}) + +class DupeGuruME(app_cocoa.DupeGuru): + def __init__(self): + app_cocoa.DupeGuru.__init__(self, data_me, 'dupeguru_me', appid=1) + self.scanner = scanner.ScannerME() + self.directories.dirclass = hsfs.phys.music.Directory + self.dead_tracks = [] + + def remove_dead_tracks(self): + def do(j): + a = app('iTunes') + for index, track in enumerate(j.iter_with_progress(self.dead_tracks)): + if index % 100 == 0: + time.sleep(.1) + try: + track.delete() + except CommandError as e: + logging.warning('Error while trying to remove a track from iTunes: %s' % unicode(e)) + + self._start_job(JOB_REMOVE_DEAD_TRACKS, do) + + def scan_dead_tracks(self): + def do(j): + a = app('iTunes') + try: + [source] = [s for s in a.sources() if s.kind() == k.library] + [library] = source.library_playlists() + except ValueError: + logging.warning('Some unexpected iTunes configuration encountered') + return + self.dead_tracks = [] + tracks = as_fetch(library.file_tracks, k.file_track) + for index, track in enumerate(j.iter_with_progress(tracks)): + if index % 100 == 0: + time.sleep(.1) + if track.location() == k.missing_value: + self.dead_tracks.append(track) + logging.info('Found %d dead tracks' % len(self.dead_tracks)) + + self._start_job(JOB_SCAN_DEAD_TRACKS, do) + diff --git a/py/app_pe_cocoa.py b/py/app_pe_cocoa.py new file mode 100644 index 00000000..5969d1c3 --- /dev/null +++ b/py/app_pe_cocoa.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.app_pe_cocoa +Created By: Virgil Dupras +Created On: 2006/11/13 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2006 Hardcoded Software (http://www.hardcoded.net) +""" +import os +import os.path as op +import logging +import plistlib + +import objc +from Foundation import * +from AppKit import * +from appscript import app, k + +from hsutil import job, io +import hsfs as fs +from hsfs import phys +from hsutil import files +from hsutil.str import get_file_ext +from hsutil.path import Path +from hsutil.cocoa import as_fetch + +import app_cocoa, data_pe, directories, picture.matchbase +from picture.cache import string_to_colors, Cache + +mainBundle = NSBundle.mainBundle() +PictureBlocks = mainBundle.classNamed_('PictureBlocks') +assert PictureBlocks is not None + +class Photo(phys.File): + cls_info_map = { + 'size': fs.IT_ATTRS, + 'ctime': fs.IT_ATTRS, + 'mtime': fs.IT_ATTRS, + 'md5': fs.IT_MD5, + 'md5partial': fs.IT_MD5, + 'dimensions': fs.IT_EXTRA, + } + + def _initialize_info(self,section): + super(Photo, self)._initialize_info(section) + if section == fs.IT_EXTRA: + self._info.update({ + 'dimensions': (0,0), + }) + + def _read_info(self,section): + super(Photo, self)._read_info(section) + if section == fs.IT_EXTRA: + size = PictureBlocks.getImageSize_(unicode(self.path)) + self._info['dimensions'] = (size.width, size.height) + + def get_blocks(self, block_count_per_side): + try: + blocks = PictureBlocks.getBlocksFromImagePath_blockCount_scanArea_(unicode(self.path), block_count_per_side, 0) + except Exception, e: + raise IOError('The reading of "%s" failed with "%s"' % (unicode(self.path), unicode(e))) + if not blocks: + raise IOError('The picture %s could not be read' % unicode(self.path)) + return string_to_colors(blocks) + + +class IPhoto(Photo): + def __init__(self, parent, whole_path): + super(IPhoto, self).__init__(parent, whole_path[-1]) + self.whole_path = whole_path + + def _build_path(self): + return self.whole_path + + @property + def display_path(self): + return super(IPhoto, self)._build_path() + + +class Directory(phys.Directory): + cls_file_class = Photo + cls_supported_exts = ('png', 'jpg', 'jpeg', 'gif', 'psd', 'bmp', 'tiff', 'nef', 'cr2') + + def _fetch_subitems(self): + subdirs, subfiles = super(Directory,self)._fetch_subitems() + return subdirs, [name for name in subfiles if get_file_ext(name) in self.cls_supported_exts] + + +class IPhotoLibrary(fs.Directory): + def __init__(self, plistpath): + self.plistpath = plistpath + self.refpath = plistpath[:-1] + # the AlbumData.xml file lives right in the library path + super(IPhotoLibrary, self).__init__(None, 'iPhoto Library') + + def _update_photo(self, photo_data): + if photo_data['MediaType'] != 'Image': + return + photo_path = Path(photo_data['ImagePath']) + subpath = photo_path[len(self.refpath):-1] + subdir = self + for element in subpath: + try: + subdir = subdir[element] + except KeyError: + subdir = fs.Directory(subdir, element) + IPhoto(subdir, photo_path) + + def update(self): + self.clear() + s = open(unicode(self.plistpath)).read() + # There was a case where a guy had 0x10 chars in his plist, causing expat errors on loading + s = s.replace('\x10', '') + plist = plistlib.readPlistFromString(s) + for photo_data in plist['Master Image List'].values(): + self._update_photo(photo_data) + + def force_update(self): # Don't update + pass + + +class DupeGuruPE(app_cocoa.DupeGuru): + def __init__(self): + app_cocoa.DupeGuru.__init__(self, data_pe, 'dupeguru_pe', appid=5) + self.scanner.match_factory = picture.matchbase.AsyncMatchFactory() + self.directories.dirclass = Directory + self.directories.special_dirclasses[Path('iPhoto Library')] = lambda _, __: self._create_iphoto_library() + p = op.join(self.appdata, 'cached_pictures.db') + self.scanner.match_factory.cached_blocks = Cache(p) + + def _create_iphoto_library(self): + ud = NSUserDefaults.standardUserDefaults() + prefs = ud.persistentDomainForName_('com.apple.iApps') + plisturl = NSURL.URLWithString_(prefs['iPhotoRecentDatabases'][0]) + plistpath = Path(plisturl.path()) + return IPhotoLibrary(plistpath) + + def _do_delete(self, j): + def op(dupe): + j.add_progress() + return self._do_delete_dupe(dupe) + + marked = [dupe for dupe in self.results.dupes if self.results.is_marked(dupe)] + self.path2iphoto = {} + if any(isinstance(dupe, IPhoto) for dupe in marked): + a = app('iPhoto') + a.select(a.photo_library_album()) + photos = as_fetch(a.photo_library_album().photos, k.item) + for photo in photos: + self.path2iphoto[photo.image_path()] = photo + self.last_op_error_count = self.results.perform_on_marked(op, True) + del self.path2iphoto + + def _do_delete_dupe(self, dupe): + if isinstance(dupe, IPhoto): + photo = self.path2iphoto[unicode(dupe.path)] + app('iPhoto').remove(photo) + return True + else: + return app_cocoa.DupeGuru._do_delete_dupe(self, dupe) + + def _do_load(self, j): + self.directories.LoadFromFile(op.join(self.appdata, 'last_directories.xml')) + for d in self.directories: + if isinstance(d, IPhotoLibrary): + d.update() + self.results.load_from_xml(op.join(self.appdata, 'last_results.xml'), self._get_file, j) + + def _get_file(self, str_path): + p = Path(str_path) + for d in self.directories: + result = None + if p in d.path: + result = d.find_path(p[d.path:]) + if isinstance(d, IPhotoLibrary) and p in d.refpath: + result = d.find_path(p[d.refpath:]) + if result is not None: + return result + + def AddDirectory(self, d): + try: + added = self.directories.add_path(Path(d)) + if d == 'iPhoto Library': + added.update() + return 0 + except directories.AlreadyThereError: + return 1 + + def CopyOrMove(self, dupe, copy, destination, dest_type): + if isinstance(dupe, IPhoto): + copy = True + return app_cocoa.DupeGuru.CopyOrMove(self, dupe, copy, destination, dest_type) + + def start_scanning(self): + for directory in self.directories: + if isinstance(directory, IPhotoLibrary): + self.directories.SetState(directory.refpath, directories.STATE_EXCLUDED) + return app_cocoa.DupeGuru.start_scanning(self) + + 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 + return ref.path + diff --git a/py/app_se_cocoa.py b/py/app_se_cocoa.py new file mode 100644 index 00000000..3d8c62b2 --- /dev/null +++ b/py/app_se_cocoa.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +# Unit Name: app_se_cocoa +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import app_cocoa, data + +class DupeGuru(app_cocoa.DupeGuru): + def __init__(self): + app_cocoa.DupeGuru.__init__(self, data, 'dupeguru', appid=4) + diff --git a/py/app_test.py b/py/app_test.py new file mode 100644 index 00000000..af47067f --- /dev/null +++ b/py/app_test.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.app +Created By: Virgil Dupras +Created On: 2007-06-23 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:02:48 +0200 (Thu, 28 May 2009) $ + $Revision: 4388 $ +Copyright 2007 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import os + +from hsutil.testcase import TestCase +from hsutil import io +from hsutil.path import Path +from hsutil.decorators import log_calls +import hsfs as fs +import hsfs.phys +import hsutil.files +from hsutil.job import nulljob + +from . import data, app +from .app import DupeGuru as DupeGuruBase + +class DupeGuru(DupeGuruBase): + def __init__(self): + DupeGuruBase.__init__(self, data, '/tmp', appid=4) + + def _start_job(self, jobid, func): + func(nulljob) + + +class TCDupeGuru(TestCase): + cls_tested_module = app + def test_ApplyFilter_calls_results_apply_filter(self): + app = DupeGuru() + self.mock(app.results, 'apply_filter', log_calls(app.results.apply_filter)) + app.ApplyFilter('foo') + self.assertEqual(2, len(app.results.apply_filter.calls)) + call = app.results.apply_filter.calls[0] + self.assert_(call['filter_str'] is None) + call = app.results.apply_filter.calls[1] + self.assertEqual('foo', call['filter_str']) + + def test_ApplyFilter_escapes_regexp(self): + app = DupeGuru() + self.mock(app.results, 'apply_filter', log_calls(app.results.apply_filter)) + app.ApplyFilter('()[]\\.|+?^abc') + call = app.results.apply_filter.calls[1] + self.assertEqual('\\(\\)\\[\\]\\\\\\.\\|\\+\\?\\^abc', call['filter_str']) + app.ApplyFilter('(*)') # In "simple mode", we want the * to behave as a wilcard + call = app.results.apply_filter.calls[3] + self.assertEqual('\(.*\)', call['filter_str']) + app.options['escape_filter_regexp'] = False + app.ApplyFilter('(abc)') + call = app.results.apply_filter.calls[5] + self.assertEqual('(abc)', call['filter_str']) + + def test_CopyOrMove(self): + # The goal here is just to have a test for a previous blowup I had. I know my test coverage + # for this unit is pathetic. What's done is done. My approach now is to add tests for + # every change I want to make. The blowup was caused by a missing import. + dupe_parent = fs.Directory(None, 'foo') + dupe = fs.File(dupe_parent, 'bar') + dupe.copy = log_calls(lambda dest, newname: None) + self.mock(hsutil.files, 'copy', log_calls(lambda source_path, dest_path: None)) + self.mock(os, 'makedirs', lambda path: None) # We don't want the test to create that fake directory + self.mock(fs.phys, 'Directory', fs.Directory) # We don't want an error because makedirs didn't work + app = DupeGuru() + app.CopyOrMove(dupe, True, 'some_destination', 0) + self.assertEqual(1, len(hsutil.files.copy.calls)) + call = hsutil.files.copy.calls[0] + self.assertEqual('some_destination', call['dest_path']) + self.assertEqual(dupe.path, call['source_path']) + + def test_CopyOrMove_clean_empty_dirs(self): + tmppath = Path(self.tmpdir()) + sourcepath = tmppath + 'source' + io.mkdir(sourcepath) + io.open(sourcepath + 'myfile', 'w') + tmpdir = hsfs.phys.Directory(None, unicode(tmppath)) + myfile = tmpdir['source']['myfile'] + app = DupeGuru() + self.mock(app, 'clean_empty_dirs', log_calls(lambda path: None)) + app.CopyOrMove(myfile, False, tmppath + 'dest', 0) + calls = app.clean_empty_dirs.calls + self.assertEqual(1, len(calls)) + self.assertEqual(sourcepath, calls[0]['path']) + + def test_Scan_with_objects_evaluating_to_false(self): + # At some point, any() was used in a wrong way that made Scan() wrongly return 1 + app = DupeGuru() + f1, f2 = [fs.File(None, 'foo') for i in range(2)] + f1.is_ref, f2.is_ref = (False, False) + assert not (bool(f1) and bool(f2)) + app.directories.get_files = lambda: [f1, f2] + app.directories._dirs.append('this is just so Scan() doesnt return 3') + app.start_scanning() # no exception + + +class TCDupeGuru_clean_empty_dirs(TestCase): + cls_tested_module = app + def setUp(self): + self.mock(hsutil.files, 'delete_if_empty', log_calls(lambda path, files_to_delete=[]: None)) + self.app = DupeGuru() + + def test_option_off(self): + self.app.clean_empty_dirs(Path('/foo/bar')) + self.assertEqual(0, len(hsutil.files.delete_if_empty.calls)) + + def test_option_on(self): + self.app.options['clean_empty_dirs'] = True + self.app.clean_empty_dirs(Path('/foo/bar')) + calls = hsutil.files.delete_if_empty.calls + self.assertEqual(1, len(calls)) + self.assertEqual(Path('/foo/bar'), calls[0]['path']) + self.assertEqual(['.DS_Store'], calls[0]['files_to_delete']) + + def test_recurse_up(self): + # delete_if_empty must be recursively called up in the path until it returns False + @log_calls + def mock_delete_if_empty(path, files_to_delete=[]): + return len(path) > 1 + + self.mock(hsutil.files, 'delete_if_empty', mock_delete_if_empty) + self.app.options['clean_empty_dirs'] = True + self.app.clean_empty_dirs(Path('not-empty/empty/empty')) + calls = hsutil.files.delete_if_empty.calls + self.assertEqual(3, len(calls)) + self.assertEqual(Path('not-empty/empty/empty'), calls[0]['path']) + self.assertEqual(Path('not-empty/empty'), calls[1]['path']) + self.assertEqual(Path('not-empty'), calls[2]['path']) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/py/data.py b/py/data.py new file mode 100644 index 00000000..568a3400 --- /dev/null +++ b/py/data.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.data +Created By: Virgil Dupras +Created On: 2006/03/15 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" + +from hsutil.str import format_time, FT_DECIMAL, format_size + +import time + +def format_path(p): + return unicode(p[:-1]) + +def format_timestamp(t, delta): + if delta: + return format_time(t, FT_DECIMAL) + else: + if t > 0: + return time.strftime('%Y/%m/%d %H:%M:%S', time.localtime(t)) + else: + return '---' + +def format_words(w): + def do_format(w): + if isinstance(w, list): + return '(%s)' % ', '.join(do_format(item) for item in w) + else: + return w.replace('\n', ' ') + + return ', '.join(do_format(item) for item in w) + +def format_perc(p): + return "%0.0f" % p + +def format_dupe_count(c): + return str(c) if c else '---' + +def cmp_value(value): + return value.lower() if isinstance(value, basestring) else value + +COLUMNS = [ + {'attr':'name','display':'Filename'}, + {'attr':'path','display':'Directory'}, + {'attr':'size','display':'Size (KB)'}, + {'attr':'extension','display':'Kind'}, + {'attr':'ctime','display':'Creation'}, + {'attr':'mtime','display':'Modification'}, + {'attr':'percentage','display':'Match %'}, + {'attr':'words','display':'Words Used'}, + {'attr':'dupe_count','display':'Dupe Count'}, +] + +def GetDisplayInfo(dupe, group, delta=False): + if (dupe is None) or (group is None): + return ['---'] * len(COLUMNS) + size = dupe.size + ctime = dupe.ctime + mtime = dupe.mtime + m = group.get_match_of(dupe) + if m: + percentage = m.percentage + dupe_count = 0 + if delta: + r = group.ref + size -= r.size + ctime -= r.ctime + mtime -= r.mtime + else: + percentage = group.percentage + dupe_count = len(group.dupes) + return [ + dupe.name, + format_path(dupe.path), + format_size(size, 0, 1, False), + dupe.extension, + format_timestamp(ctime, delta and m), + format_timestamp(mtime, delta and m), + format_perc(percentage), + format_words(dupe.words), + format_dupe_count(dupe_count) + ] + +def GetDupeSortKey(dupe, get_group, key, delta): + if key == 6: + m = get_group().get_match_of(dupe) + return m.percentage + if key == 8: + return 0 + r = cmp_value(getattr(dupe, COLUMNS[key]['attr'])) + if delta and (key in (2, 4, 5)): + r -= cmp_value(getattr(get_group().ref, COLUMNS[key]['attr'])) + return r + +def GetGroupSortKey(group, key): + if key == 6: + return group.percentage + if key == 8: + return len(group) + return cmp_value(getattr(group.ref, COLUMNS[key]['attr'])) + diff --git a/py/data_me.py b/py/data_me.py new file mode 100644 index 00000000..70d3ae66 --- /dev/null +++ b/py/data_me.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.data +Created By: Virgil Dupras +Created On: 2006/03/15 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" + +from hsutil.str import format_time, FT_MINUTES, format_size +from .data import (format_path, format_timestamp, format_words, format_perc, + format_dupe_count, cmp_value) + +COLUMNS = [ + {'attr':'name','display':'Filename'}, + {'attr':'path','display':'Directory'}, + {'attr':'size','display':'Size (MB)'}, + {'attr':'duration','display':'Time'}, + {'attr':'bitrate','display':'Bitrate'}, + {'attr':'samplerate','display':'Sample Rate'}, + {'attr':'extension','display':'Kind'}, + {'attr':'ctime','display':'Creation'}, + {'attr':'mtime','display':'Modification'}, + {'attr':'title','display':'Title'}, + {'attr':'artist','display':'Artist'}, + {'attr':'album','display':'Album'}, + {'attr':'genre','display':'Genre'}, + {'attr':'year','display':'Year'}, + {'attr':'track','display':'Track Number'}, + {'attr':'comment','display':'Comment'}, + {'attr':'percentage','display':'Match %'}, + {'attr':'words','display':'Words Used'}, + {'attr':'dupe_count','display':'Dupe Count'}, +] + +def GetDisplayInfo(dupe, group, delta=False): + if (dupe is None) or (group is None): + return ['---'] * len(COLUMNS) + size = dupe.size + duration = dupe.duration + bitrate = dupe.bitrate + samplerate = dupe.samplerate + ctime = dupe.ctime + mtime = dupe.mtime + m = group.get_match_of(dupe) + if m: + percentage = m.percentage + dupe_count = 0 + if delta: + r = group.ref + size -= r.size + duration -= r.duration + bitrate -= r.bitrate + samplerate -= r.samplerate + ctime -= r.ctime + mtime -= r.mtime + else: + percentage = group.percentage + dupe_count = len(group.dupes) + return [ + dupe.name, + format_path(dupe.path), + format_size(size, 2, 2, False), + format_time(duration, FT_MINUTES), + str(bitrate), + str(samplerate), + dupe.extension, + format_timestamp(ctime,delta and m), + format_timestamp(mtime,delta and m), + dupe.title, + dupe.artist, + dupe.album, + dupe.genre, + dupe.year, + str(dupe.track), + dupe.comment, + format_perc(percentage), + format_words(dupe.words), + format_dupe_count(dupe_count) + ] + +def GetDupeSortKey(dupe, get_group, key, delta): + if key == 16: + m = get_group().get_match_of(dupe) + return m.percentage + if key == 18: + return 0 + r = cmp_value(getattr(dupe, COLUMNS[key]['attr'])) + if delta and (key in (2, 3, 4, 7, 8)): + r -= cmp_value(getattr(get_group().ref, COLUMNS[key]['attr'])) + return r + +def GetGroupSortKey(group, key): + if key == 16: + return group.percentage + if key == 18: + return len(group) + return cmp_value(getattr(group.ref, COLUMNS[key]['attr'])) diff --git a/py/data_pe.py b/py/data_pe.py new file mode 100644 index 00000000..94bdd99d --- /dev/null +++ b/py/data_pe.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.data +Created By: Virgil Dupras +Created On: 2006/03/15 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +from hsutil.str import format_size +from .data import format_path, format_timestamp, format_perc, format_dupe_count, cmp_value + +def format_dimensions(dimensions): + return '%d x %d' % (dimensions[0], dimensions[1]) + +COLUMNS = [ + {'attr':'name','display':'Filename'}, + {'attr':'path','display':'Directory'}, + {'attr':'size','display':'Size (KB)'}, + {'attr':'extension','display':'Kind'}, + {'attr':'dimensions','display':'Dimensions'}, + {'attr':'ctime','display':'Creation'}, + {'attr':'mtime','display':'Modification'}, + {'attr':'percentage','display':'Match %'}, + {'attr':'dupe_count','display':'Dupe Count'}, +] + +def GetDisplayInfo(dupe,group,delta=False): + if (dupe is None) or (group is None): + return ['---'] * len(COLUMNS) + size = dupe.size + ctime = dupe.ctime + mtime = dupe.mtime + m = group.get_match_of(dupe) + if m: + percentage = m.percentage + dupe_count = 0 + if delta: + r = group.ref + size -= r.size + ctime -= r.ctime + mtime -= r.mtime + else: + percentage = group.percentage + dupe_count = len(group.dupes) + dupe_path = getattr(dupe, 'display_path', dupe.path) + return [ + dupe.name, + format_path(dupe_path), + format_size(size, 0, 1, False), + dupe.extension, + format_dimensions(dupe.dimensions), + format_timestamp(ctime, delta and m), + format_timestamp(mtime, delta and m), + format_perc(percentage), + format_dupe_count(dupe_count) + ] + +def GetDupeSortKey(dupe, get_group, key, delta): + if key == 7: + m = get_group().get_match_of(dupe) + return m.percentage + if key == 8: + return 0 + r = cmp_value(getattr(dupe, COLUMNS[key]['attr'])) + if delta and (key in (2, 5, 6)): + r -= cmp_value(getattr(get_group().ref, COLUMNS[key]['attr'])) + return r + +def GetGroupSortKey(group, key): + if key == 7: + return group.percentage + if key == 8: + return len(group) + return cmp_value(getattr(group.ref, COLUMNS[key]['attr'])) + diff --git a/py/directories.py b/py/directories.py new file mode 100644 index 00000000..3d73b5c5 --- /dev/null +++ b/py/directories.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.directories +Created By: Virgil Dupras +Created On: 2006/02/27 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:02:48 +0200 (Thu, 28 May 2009) $ + $Revision: 4388 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import xml.dom.minidom + +from hsfs import phys +import hsfs as fs +from hsutil.files import FileOrPath +from hsutil.path import Path + +(STATE_NORMAL, +STATE_REFERENCE, +STATE_EXCLUDED) = range(3) + +class AlreadyThereError(Exception): + """The path being added is already in the directory list""" + +class InvalidPathError(Exception): + """The path being added is invalid""" + +class Directories(object): + #---Override + def __init__(self): + self._dirs = [] + self.states = {} + self.dirclass = phys.Directory + self.special_dirclasses = {} + + def __contains__(self,path): + for d in self._dirs: + if path in d.path: + return True + return False + + def __delitem__(self,key): + self._dirs.__delitem__(key) + + def __getitem__(self,key): + return self._dirs.__getitem__(key) + + def __len__(self): + return len(self._dirs) + + #---Private + def _get_files(self, from_dir, state=STATE_NORMAL): + state = self.states.get(from_dir.path, state) + result = [] + for subdir in from_dir.dirs: + for file in self._get_files(subdir, state): + yield file + if state != STATE_EXCLUDED: + for file in from_dir.files: + file.is_ref = state == STATE_REFERENCE + yield file + + #---Public + def add_path(self, path): + """Adds 'path' to self, if not already there. + + Raises AlreadyThereError if 'path' is already in self. If path is a directory containing + some of the directories already present in self, 'path' will be added, but all directories + under it will be removed. Can also raise InvalidPathError if 'path' does not exist. + """ + if path in self: + raise AlreadyThereError + self._dirs = [d for d in self._dirs if d.path not in path] + try: + dirclass = self.special_dirclasses.get(path, self.dirclass) + d = dirclass(None, unicode(path)) + d[:] #If an InvalidPath exception has to be raised, it will be raised here + self._dirs.append(d) + return d + except fs.InvalidPath: + raise InvalidPathError + + def get_files(self): + """Returns a list of all files that are not excluded. + + Returned files also have their 'is_ref' attr set. + """ + for d in self._dirs: + d.force_update() + try: + for file in self._get_files(d): + yield file + except fs.InvalidPath: + pass + + def GetState(self, path): + """Returns the state of 'path' (One of the STATE_* const.) + + Raises LookupError if 'path' is not in self. + """ + if path not in self: + raise LookupError("The path '%s' is not in the directory list." % str(path)) + try: + return self.states[path] + except KeyError: + if path[-1].startswith('.'): # hidden + return STATE_EXCLUDED + parent = path[:-1] + if parent in self: + return self.GetState(parent) + else: + return STATE_NORMAL + + def LoadFromFile(self,infile): + try: + doc = xml.dom.minidom.parse(infile) + except: + return + root_dir_nodes = doc.getElementsByTagName('root_directory') + for rdn in root_dir_nodes: + if not rdn.getAttributeNode('path'): + continue + path = rdn.getAttributeNode('path').nodeValue + try: + self.add_path(Path(path)) + except (AlreadyThereError,InvalidPathError): + pass + state_nodes = doc.getElementsByTagName('state') + for sn in state_nodes: + if not (sn.getAttributeNode('path') and sn.getAttributeNode('value')): + continue + path = sn.getAttributeNode('path').nodeValue + state = sn.getAttributeNode('value').nodeValue + self.SetState(Path(path), int(state)) + + def Remove(self,directory): + self._dirs.remove(directory) + + def SaveToFile(self,outfile): + with FileOrPath(outfile, 'wb') as fp: + doc = xml.dom.minidom.Document() + root = doc.appendChild(doc.createElement('directories')) + for root_dir in self: + root_dir_node = root.appendChild(doc.createElement('root_directory')) + root_dir_node.setAttribute('path', unicode(root_dir.path).encode('utf-8')) + for path,state in self.states.iteritems(): + state_node = root.appendChild(doc.createElement('state')) + state_node.setAttribute('path', unicode(path).encode('utf-8')) + state_node.setAttribute('value', str(state)) + doc.writexml(fp,'\t','\t','\n',encoding='utf-8') + + def SetState(self,path,state): + try: + if self.GetState(path) == state: + return + self.states[path] = state + if (self.GetState(path[:-1]) == state) and (not path[-1].startswith('.')): + del self.states[path] + except LookupError: + pass + diff --git a/py/directories_test.py b/py/directories_test.py new file mode 100644 index 00000000..7d34c343 --- /dev/null +++ b/py/directories_test.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.directories +Created By: Virgil Dupras +Created On: 2006/02/27 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-29 08:51:14 +0200 (Fri, 29 May 2009) $ + $Revision: 4398 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import os.path as op +import os +import time +import shutil + +from hsutil import job, io +from hsutil.path import Path +from hsutil.testcase import TestCase +import hsfs.phys +from hsfs.phys import phys_test + +from directories import * + +testpath = Path(TestCase.datadirpath()) + +class TCDirectories(TestCase): + def test_empty(self): + d = Directories() + self.assertEqual(0,len(d)) + self.assert_('foobar' not in d) + + def test_add_path(self): + d = Directories() + p = testpath + 'utils' + added = d.add_path(p) + self.assertEqual(1,len(d)) + self.assert_(p in d) + self.assert_((p + 'foobar') in d) + self.assert_(p[:-1] not in d) + self.assertEqual(p,added.path) + self.assert_(d[0] is added) + p = self.tmppath() + d.add_path(p) + self.assertEqual(2,len(d)) + self.assert_(p in d) + + def test_AddPath_when_path_is_already_there(self): + d = Directories() + p = testpath + 'utils' + d.add_path(p) + self.assertRaises(AlreadyThereError, d.add_path, p) + self.assertRaises(AlreadyThereError, d.add_path, p + 'foobar') + self.assertEqual(1, len(d)) + + def test_AddPath_containing_paths_already_there(self): + d = Directories() + d.add_path(testpath + 'utils') + self.assertEqual(1, len(d)) + added = d.add_path(testpath) + self.assertEqual(1, len(d)) + self.assert_(added is d[0]) + + def test_AddPath_non_latin(self): + p = Path(self.tmpdir()) + to_add = p + u'unicode\u201a' + os.mkdir(unicode(to_add)) + d = Directories() + try: + d.add_path(to_add) + except UnicodeDecodeError: + self.fail() + + def test_del(self): + d = Directories() + d.add_path(testpath + 'utils') + try: + del d[1] + self.fail() + except IndexError: + pass + d.add_path(self.tmppath()) + del d[1] + self.assertEqual(1, len(d)) + + def test_states(self): + d = Directories() + p = testpath + 'utils' + d.add_path(p) + self.assertEqual(STATE_NORMAL,d.GetState(p)) + d.SetState(p,STATE_REFERENCE) + self.assertEqual(STATE_REFERENCE,d.GetState(p)) + self.assertEqual(STATE_REFERENCE,d.GetState(p + 'dir1')) + self.assertEqual(1,len(d.states)) + self.assertEqual(p,d.states.keys()[0]) + self.assertEqual(STATE_REFERENCE,d.states[p]) + + def test_GetState_with_path_not_there(self): + d = Directories() + d.add_path(testpath + 'utils') + self.assertRaises(LookupError,d.GetState,testpath) + + def test_states_remain_when_larger_directory_eat_smaller_ones(self): + d = Directories() + p = testpath + 'utils' + d.add_path(p) + d.SetState(p,STATE_EXCLUDED) + d.add_path(testpath) + d.SetState(testpath,STATE_REFERENCE) + self.assertEqual(STATE_EXCLUDED,d.GetState(p)) + self.assertEqual(STATE_EXCLUDED,d.GetState(p + 'dir1')) + self.assertEqual(STATE_REFERENCE,d.GetState(testpath)) + + def test_SetState_keep_state_dict_size_to_minimum(self): + d = Directories() + p = Path(phys_test.create_fake_fs(self.tmpdir())) + d.add_path(p) + d.SetState(p,STATE_REFERENCE) + d.SetState(p + 'dir1',STATE_REFERENCE) + self.assertEqual(1,len(d.states)) + self.assertEqual(STATE_REFERENCE,d.GetState(p + 'dir1')) + d.SetState(p + 'dir1',STATE_NORMAL) + self.assertEqual(2,len(d.states)) + self.assertEqual(STATE_NORMAL,d.GetState(p + 'dir1')) + d.SetState(p + 'dir1',STATE_REFERENCE) + self.assertEqual(1,len(d.states)) + self.assertEqual(STATE_REFERENCE,d.GetState(p + 'dir1')) + + def test_get_files(self): + d = Directories() + p = Path(phys_test.create_fake_fs(self.tmpdir())) + d.add_path(p) + d.SetState(p + 'dir1',STATE_REFERENCE) + d.SetState(p + 'dir2',STATE_EXCLUDED) + files = d.get_files() + self.assertEqual(5, len(list(files))) + for f in files: + if f.parent.path == p + 'dir1': + self.assert_(f.is_ref) + else: + self.assert_(not f.is_ref) + + def test_get_files_with_inherited_exclusion(self): + d = Directories() + p = testpath + 'utils' + d.add_path(p) + d.SetState(p,STATE_EXCLUDED) + self.assertEqual([], list(d.get_files())) + + def test_save_and_load(self): + d1 = Directories() + d2 = Directories() + p1 = self.tmppath() + p2 = self.tmppath() + d1.add_path(p1) + d1.add_path(p2) + d1.SetState(p1, STATE_REFERENCE) + d1.SetState(p1 + 'dir1',STATE_EXCLUDED) + tmpxml = op.join(self.tmpdir(), 'directories_testunit.xml') + d1.SaveToFile(tmpxml) + d2.LoadFromFile(tmpxml) + self.assertEqual(2, len(d2)) + self.assertEqual(STATE_REFERENCE,d2.GetState(p1)) + self.assertEqual(STATE_EXCLUDED,d2.GetState(p1 + 'dir1')) + + def test_invalid_path(self): + d = Directories() + p = Path('does_not_exist') + self.assertRaises(InvalidPathError, d.add_path, p) + self.assertEqual(0, len(d)) + + def test_SetState_on_invalid_path(self): + d = Directories() + try: + d.SetState(Path('foobar',),STATE_NORMAL) + except LookupError: + self.fail() + + def test_default_dirclass(self): + self.assert_(Directories().dirclass is hsfs.phys.Directory) + + def test_dirclass(self): + class MySpecialDirclass(hsfs.phys.Directory): pass + d = Directories() + d.dirclass = MySpecialDirclass + d.add_path(testpath) + self.assert_(isinstance(d[0], MySpecialDirclass)) + + def test_LoadFromFile_with_invalid_path(self): + #This test simulates a load from file resulting in a + #InvalidPath raise. Other directories must be loaded. + d1 = Directories() + d1.add_path(testpath + 'utils') + #Will raise InvalidPath upon loading + d1.add_path(self.tmppath()).name = 'does_not_exist' + tmpxml = op.join(self.tmpdir(), 'directories_testunit.xml') + d1.SaveToFile(tmpxml) + d2 = Directories() + d2.LoadFromFile(tmpxml) + self.assertEqual(1, len(d2)) + + def test_LoadFromFile_with_same_paths(self): + #This test simulates a load from file resulting in a + #AlreadyExists raise. Other directories must be loaded. + d1 = Directories() + p1 = self.tmppath() + p2 = self.tmppath() + d1.add_path(p1) + d1.add_path(p2) + #Will raise AlreadyExists upon loading + d1.add_path(self.tmppath()).name = unicode(p1) + tmpxml = op.join(self.tmpdir(), 'directories_testunit.xml') + d1.SaveToFile(tmpxml) + d2 = Directories() + d2.LoadFromFile(tmpxml) + self.assertEqual(2, len(d2)) + + def test_Remove(self): + d = Directories() + d1 = d.add_path(self.tmppath()) + d2 = d.add_path(self.tmppath()) + d.Remove(d1) + self.assertEqual(1, len(d)) + self.assert_(d[0] is d2) + + def test_unicode_save(self): + d = Directories() + p1 = self.tmppath() + u'hello\xe9' + io.mkdir(p1) + io.mkdir(p1 + u'foo\xe9') + d.add_path(p1) + d.SetState(d[0][0].path, STATE_EXCLUDED) + tmpxml = op.join(self.tmpdir(), 'directories_testunit.xml') + try: + d.SaveToFile(tmpxml) + except UnicodeDecodeError: + self.fail() + + def test_get_files_refreshes_its_directories(self): + d = Directories() + p = Path(phys_test.create_fake_fs(self.tmpdir())) + d.add_path(p) + files = d.get_files() + self.assertEqual(6, len(list(files))) + time.sleep(1) + os.remove(str(p + ('dir1','file1.test'))) + files = d.get_files() + self.assertEqual(5, len(list(files))) + + def test_get_files_does_not_choke_on_non_existing_directories(self): + d = Directories() + p = Path(self.tmpdir()) + d.add_path(p) + io.rmtree(p) + self.assertEqual([], list(d.get_files())) + + def test_GetState_returns_excluded_by_default_for_hidden_directories(self): + d = Directories() + p = Path(self.tmpdir()) + hidden_dir_path = p + '.foo' + io.mkdir(p + '.foo') + d.add_path(p) + self.assertEqual(d.GetState(hidden_dir_path), STATE_EXCLUDED) + # But it can be overriden + d.SetState(hidden_dir_path, STATE_NORMAL) + self.assertEqual(d.GetState(hidden_dir_path), STATE_NORMAL) + + def test_special_dirclasses(self): + # if a path is in special_dirclasses, use this class instead + class MySpecialDirclass(hsfs.phys.Directory): pass + d = Directories() + p1 = self.tmppath() + p2 = self.tmppath() + d.special_dirclasses[p1] = MySpecialDirclass + self.assert_(isinstance(d.add_path(p2), hsfs.phys.Directory)) + self.assert_(isinstance(d.add_path(p1), MySpecialDirclass)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/py/engine.py b/py/engine.py new file mode 100644 index 00000000..a826902d --- /dev/null +++ b/py/engine.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.engine +Created By: Virgil Dupras +Created On: 2006/01/29 +Last modified by:$Author: virgil $ +Last modified on:$Date: $ + $Revision: $ +Copyright 2007 Hardcoded Software (http://www.hardcoded.net) +""" +from __future__ import division +import difflib +import logging +import string +from collections import defaultdict, namedtuple +from unicodedata import normalize + +from hsutil.str import multi_replace +from hsutil import job + +(WEIGHT_WORDS, +MATCH_SIMILAR_WORDS, +NO_FIELD_ORDER) = range(3) + +JOB_REFRESH_RATE = 100 + +def getwords(s): + if isinstance(s, unicode): + s = normalize('NFD', s) + s = multi_replace(s, "-_&+():;\\[]{}.,<>/?~!@#$*", ' ').lower() + s = ''.join(c for c in s if c in string.ascii_letters + string.digits + string.whitespace) + return filter(None, s.split(' ')) # filter() is to remove empty elements + +def getfields(s): + fields = [getwords(field) for field in s.split(' - ')] + return filter(None, fields) + +def unpack_fields(fields): + result = [] + for field in fields: + if isinstance(field, list): + result += field + else: + result.append(field) + return result + +def compare(first, second, flags=()): + """Returns the % of words that match between first and second + + The result is a int in the range 0..100. + First and second can be either a string or a list. + """ + if not (first and second): + return 0 + if any(isinstance(element, list) for element in first): + return compare_fields(first, second, flags) + second = second[:] #We must use a copy of second because we remove items from it + match_similar = MATCH_SIMILAR_WORDS in flags + weight_words = WEIGHT_WORDS in flags + joined = first + second + total_count = (sum(len(word) for word in joined) if weight_words else len(joined)) + match_count = 0 + in_order = True + for word in first: + if match_similar and (word not in second): + similar = difflib.get_close_matches(word, second, 1, 0.8) + if similar: + word = similar[0] + if word in second: + if second[0] != word: + in_order = False + second.remove(word) + match_count += (len(word) if weight_words else 1) + result = round(((match_count * 2) / total_count) * 100) + if (result == 100) and (not in_order): + result = 99 # We cannot consider a match exact unless the ordering is the same + return result + +def compare_fields(first, second, flags=()): + """Returns the score for the lowest matching fields. + + first and second must be lists of lists of string. + """ + if len(first) != len(second): + return 0 + if NO_FIELD_ORDER in flags: + results = [] + #We don't want to remove field directly in the list. We must work on a copy. + second = second[:] + for field1 in first: + max = 0 + matched_field = None + for field2 in second: + r = compare(field1, field2, flags) + if r > max: + max = r + matched_field = field2 + results.append(max) + if matched_field: + second.remove(matched_field) + else: + results = [compare(word1, word2, flags) for word1, word2 in zip(first, second)] + return min(results) if results else 0 + +def build_word_dict(objects, j=job.nulljob): + """Returns a dict of objects mapped by their words. + + objects must have a 'words' attribute being a list of strings or a list of lists of strings. + + The result will be a dict with words as keys, lists of objects as values. + """ + result = defaultdict(set) + for object in j.iter_with_progress(objects, 'Prepared %d/%d files', JOB_REFRESH_RATE): + for word in unpack_fields(object.words): + result[word].add(object) + return result + +def merge_similar_words(word_dict): + """Take all keys in word_dict that are similar, and merge them together. + """ + keys = word_dict.keys() + keys.sort(key=len)# we want the shortest word to stay + while keys: + key = keys.pop(0) + similars = difflib.get_close_matches(key, keys, 100, 0.8) + if not similars: + continue + objects = word_dict[key] + for similar in similars: + objects |= word_dict[similar] + del word_dict[similar] + keys.remove(similar) + +def reduce_common_words(word_dict, threshold): + """Remove all objects from word_dict values where the object count >= threshold + + The exception to this removal are the objects where all the words of the object are common. + Because if we remove them, we will miss some duplicates! + """ + uncommon_words = set(word for word, objects in word_dict.items() if len(objects) < threshold) + for word, objects in word_dict.items(): + if len(objects) < threshold: + continue + reduced = set() + for o in objects: + if not any(w in uncommon_words for w in unpack_fields(o.words)): + reduced.add(o) + if reduced: + word_dict[word] = reduced + else: + del word_dict[word] + +Match = namedtuple('Match', 'first second percentage') +def get_match(first, second, flags=()): + #it is assumed here that first and second both have a "words" attribute + percentage = compare(first.words, second.words, flags) + return Match(first, second, percentage) + +class MatchFactory(object): + common_word_threshold = 50 + match_similar_words = False + min_match_percentage = 0 + weight_words = False + no_field_order = False + limit = 5000000 + + def getmatches(self, objects, j=job.nulljob): + j = j.start_subjob(2) + sj = j.start_subjob(2) + for o in objects: + if not hasattr(o, 'words'): + o.words = getwords(o.name) + word_dict = build_word_dict(objects, sj) + reduce_common_words(word_dict, self.common_word_threshold) + if self.match_similar_words: + merge_similar_words(word_dict) + match_flags = [] + if self.weight_words: + match_flags.append(WEIGHT_WORDS) + if self.match_similar_words: + match_flags.append(MATCH_SIMILAR_WORDS) + if self.no_field_order: + match_flags.append(NO_FIELD_ORDER) + j.start_job(len(word_dict), '0 matches found') + compared = defaultdict(set) + result = [] + try: + # This whole 'popping' thing is there to avoid taking too much memory at the same time. + while word_dict: + items = word_dict.popitem()[1] + while items: + ref = items.pop() + compared_already = compared[ref] + to_compare = items - compared_already + compared_already |= to_compare + for other in to_compare: + m = get_match(ref, other, match_flags) + if m.percentage >= self.min_match_percentage: + result.append(m) + if len(result) >= self.limit: + return result + j.add_progress(desc='%d matches found' % len(result)) + except MemoryError: + # This is the place where the memory usage is at its peak during the scan. + # Just continue the process with an incomplete list of matches. + del compared # This should give us enough room to call logging. + logging.warning('Memory Overflow. Matches: %d. Word dict: %d' % (len(result), len(word_dict))) + return result + return result + + +class Group(object): + #---Override + def __init__(self): + self._clear() + + def __contains__(self, item): + return item in self.unordered + + def __getitem__(self, key): + return self.ordered.__getitem__(key) + + def __iter__(self): + return iter(self.ordered) + + def __len__(self): + return len(self.ordered) + + #---Private + def _clear(self): + self._percentage = None + self._matches_for_ref = None + self.matches = set() + self.candidates = defaultdict(set) + self.ordered = [] + self.unordered = set() + + def _get_matches_for_ref(self): + if self._matches_for_ref is None: + ref = self.ref + self._matches_for_ref = [match for match in self.matches if ref in match] + return self._matches_for_ref + + #---Public + def add_match(self, match): + def add_candidate(item, match): + matches = self.candidates[item] + matches.add(match) + if self.unordered <= matches: + self.ordered.append(item) + self.unordered.add(item) + + if match in self.matches: + return + self.matches.add(match) + first, second, _ = match + if first not in self.unordered: + add_candidate(first, second) + if second not in self.unordered: + add_candidate(second, first) + self._percentage = None + self._matches_for_ref = None + + def clean_matches(self): + self.matches = set(m for m in self.matches if (m.first in self.unordered) and (m.second in self.unordered)) + self.candidates = defaultdict(set) + + def get_match_of(self, item): + if item is self.ref: + return + for m in self._get_matches_for_ref(): + if item in m: + return m + + def prioritize(self, key_func, tie_breaker=None): + # tie_breaker(ref, dupe) --> True if dupe should be ref + self.ordered.sort(key=key_func) + if tie_breaker is None: + return + ref = self.ref + key_value = key_func(ref) + for dupe in self.dupes: + if key_func(dupe) != key_value: + break + if tie_breaker(ref, dupe): + ref = dupe + if ref is not self.ref: + self.switch_ref(ref) + + def remove_dupe(self, item, clean_matches=True): + try: + self.ordered.remove(item) + self.unordered.remove(item) + self._percentage = None + self._matches_for_ref = None + if (len(self) > 1) and any(not getattr(item, 'is_ref', False) for item in self): + if clean_matches: + self.matches = set(m for m in self.matches if item not in m) + else: + self._clear() + except ValueError: + pass + + def switch_ref(self, with_dupe): + try: + self.ordered.remove(with_dupe) + self.ordered.insert(0, with_dupe) + self._percentage = None + self._matches_for_ref = None + except ValueError: + pass + + dupes = property(lambda self: self[1:]) + + @property + def percentage(self): + if self._percentage is None: + if self.dupes: + matches = self._get_matches_for_ref() + self._percentage = sum(match.percentage for match in matches) // len(matches) + else: + self._percentage = 0 + return self._percentage + + @property + def ref(self): + if self: + return self[0] + + +def get_groups(matches, j=job.nulljob): + matches.sort(key=lambda match: -match.percentage) + dupe2group = {} + groups = [] + for match in j.iter_with_progress(matches, 'Grouped %d/%d matches', JOB_REFRESH_RATE): + first, second, _ = match + first_group = dupe2group.get(first) + second_group = dupe2group.get(second) + if first_group: + if second_group: + if first_group is second_group: + target_group = first_group + else: + continue + else: + target_group = first_group + dupe2group[second] = target_group + else: + if second_group: + target_group = second_group + dupe2group[first] = target_group + else: + target_group = Group() + groups.append(target_group) + dupe2group[first] = target_group + dupe2group[second] = target_group + target_group.add_match(match) + for group in groups: + group.clean_matches() + return groups diff --git a/py/engine_test.py b/py/engine_test.py new file mode 100644 index 00000000..8e9706d9 --- /dev/null +++ b/py/engine_test.py @@ -0,0 +1,822 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.engine_test +Created By: Virgil Dupras +Created On: 2006/01/29 +Last modified by:$Author: virgil $ +Last modified on:$Date: $ + $Revision: $ +Copyright 2004-2008 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import sys + +from hsutil import job +from hsutil.decorators import log_calls +from hsutil.testcase import TestCase + +from . import engine +from .engine import * + +class NamedObject(object): + def __init__(self, name="foobar", with_words=False): + self.name = name + if with_words: + self.words = getwords(name) + + +def get_match_triangle(): + o1 = NamedObject(with_words=True) + o2 = NamedObject(with_words=True) + o3 = NamedObject(with_words=True) + m1 = get_match(o1,o2) + m2 = get_match(o1,o3) + m3 = get_match(o2,o3) + return [m1, m2, m3] + +def get_test_group(): + m1, m2, m3 = get_match_triangle() + result = Group() + result.add_match(m1) + result.add_match(m2) + result.add_match(m3) + return result + +class TCgetwords(TestCase): + def test_spaces(self): + self.assertEqual(['a', 'b', 'c', 'd'], getwords("a b c d")) + self.assertEqual(['a', 'b', 'c', 'd'], getwords(" a b c d ")) + + def test_splitter_chars(self): + self.assertEqual( + [chr(i) for i in xrange(ord('a'),ord('z')+1)], + getwords("a-b_c&d+e(f)g;h\\i[j]k{l}m:n.o,pr/s?t~u!v@w#x$y*z") + ) + + def test_joiner_chars(self): + self.assertEqual(["aec"], getwords(u"a'e\u0301c")) + + def test_empty(self): + self.assertEqual([], getwords('')) + + def test_returns_lowercase(self): + self.assertEqual(['foo', 'bar'], getwords('FOO BAR')) + + def test_decompose_unicode(self): + self.assertEqual(getwords(u'foo\xe9bar'), ['fooebar']) + + +class TCgetfields(TestCase): + def test_simple(self): + self.assertEqual([['a', 'b'], ['c', 'd', 'e']], getfields('a b - c d e')) + + def test_empty(self): + self.assertEqual([], getfields('')) + + def test_cleans_empty_fields(self): + expected = [['a', 'bc', 'def']] + actual = getfields(' - a bc def') + self.assertEqual(expected, actual) + expected = [['bc', 'def']] + + +class TCunpack_fields(TestCase): + def test_with_fields(self): + expected = ['a', 'b', 'c', 'd', 'e', 'f'] + actual = unpack_fields([['a'], ['b', 'c'], ['d', 'e', 'f']]) + self.assertEqual(expected, actual) + + def test_without_fields(self): + expected = ['a', 'b', 'c', 'd', 'e', 'f'] + actual = unpack_fields(['a', 'b', 'c', 'd', 'e', 'f']) + self.assertEqual(expected, actual) + + def test_empty(self): + self.assertEqual([], unpack_fields([])) + + +class TCWordCompare(TestCase): + def test_list(self): + self.assertEqual(100, compare(['a', 'b', 'c', 'd'],['a', 'b', 'c', 'd'])) + self.assertEqual(86, compare(['a', 'b', 'c', 'd'],['a', 'b', 'c'])) + + def test_unordered(self): + #Sometimes, users don't want fuzzy matching too much When they set the slider + #to 100, they don't expect a filename with the same words, but not the same order, to match. + #Thus, we want to return 99 in that case. + self.assertEqual(99, compare(['a', 'b', 'c', 'd'], ['d', 'b', 'c', 'a'])) + + def test_word_occurs_twice(self): + #if a word occurs twice in first, but once in second, we want the word to be only counted once + self.assertEqual(89, compare(['a', 'b', 'c', 'd', 'a'], ['d', 'b', 'c', 'a'])) + + def test_uses_copy_of_lists(self): + first = ['foo', 'bar'] + second = ['bar', 'bleh'] + compare(first, second) + self.assertEqual(['foo', 'bar'], first) + self.assertEqual(['bar', 'bleh'], second) + + def test_word_weight(self): + self.assertEqual(int((6.0 / 13.0) * 100), compare(['foo', 'bar'], ['bar', 'bleh'], (WEIGHT_WORDS, ))) + + def test_similar_words(self): + self.assertEqual(100, compare(['the', 'white', 'stripes'],['the', 'whites', 'stripe'], (MATCH_SIMILAR_WORDS, ))) + + def test_empty(self): + self.assertEqual(0, compare([], [])) + + def test_with_fields(self): + self.assertEqual(67, compare([['a', 'b'], ['c', 'd', 'e']], [['a', 'b'], ['c', 'd', 'f']])) + + def test_propagate_flags_with_fields(self): + def mock_compare(first, second, flags): + self.assertEqual((0, 1, 2, 3, 5), flags) + + self.mock(engine, 'compare_fields', mock_compare) + compare([['a']], [['a']], (0, 1, 2, 3, 5)) + + +class TCWordCompareWithFields(TestCase): + def test_simple(self): + self.assertEqual(67, compare_fields([['a', 'b'], ['c', 'd', 'e']], [['a', 'b'], ['c', 'd', 'f']])) + + def test_empty(self): + self.assertEqual(0, compare_fields([], [])) + + def test_different_length(self): + self.assertEqual(0, compare_fields([['a'], ['b']], [['a'], ['b'], ['c']])) + + def test_propagates_flags(self): + def mock_compare(first, second, flags): + self.assertEqual((0, 1, 2, 3, 5), flags) + + self.mock(engine, 'compare_fields', mock_compare) + compare_fields([['a']], [['a']],(0, 1, 2, 3, 5)) + + def test_order(self): + first = [['a', 'b'], ['c', 'd', 'e']] + second = [['c', 'd', 'f'], ['a', 'b']] + self.assertEqual(0, compare_fields(first, second)) + + def test_no_order(self): + first = [['a','b'],['c','d','e']] + second = [['c','d','f'],['a','b']] + self.assertEqual(67, compare_fields(first, second, (NO_FIELD_ORDER, ))) + first = [['a','b'],['a','b']] #a field can only be matched once. + second = [['c','d','f'],['a','b']] + self.assertEqual(0, compare_fields(first, second, (NO_FIELD_ORDER, ))) + first = [['a','b'],['a','b','c']] + second = [['c','d','f'],['a','b']] + self.assertEqual(33, compare_fields(first, second, (NO_FIELD_ORDER, ))) + + def test_compare_fields_without_order_doesnt_alter_fields(self): + #The NO_ORDER comp type altered the fields! + first = [['a','b'],['c','d','e']] + second = [['c','d','f'],['a','b']] + self.assertEqual(67, compare_fields(first, second, (NO_FIELD_ORDER, ))) + self.assertEqual([['a','b'],['c','d','e']],first) + self.assertEqual([['c','d','f'],['a','b']],second) + + +class TCbuild_word_dict(TestCase): + def test_with_standard_words(self): + l = [NamedObject('foo bar',True)] + l.append(NamedObject('bar baz',True)) + l.append(NamedObject('baz bleh foo',True)) + d = build_word_dict(l) + self.assertEqual(4,len(d)) + self.assertEqual(2,len(d['foo'])) + self.assert_(l[0] in d['foo']) + self.assert_(l[2] in d['foo']) + self.assertEqual(2,len(d['bar'])) + self.assert_(l[0] in d['bar']) + self.assert_(l[1] in d['bar']) + self.assertEqual(2,len(d['baz'])) + self.assert_(l[1] in d['baz']) + self.assert_(l[2] in d['baz']) + self.assertEqual(1,len(d['bleh'])) + self.assert_(l[2] in d['bleh']) + + def test_unpack_fields(self): + o = NamedObject('') + o.words = [['foo','bar'],['baz']] + d = build_word_dict([o]) + self.assertEqual(3,len(d)) + self.assertEqual(1,len(d['foo'])) + + def test_words_are_unaltered(self): + o = NamedObject('') + o.words = [['foo','bar'],['baz']] + d = build_word_dict([o]) + self.assertEqual([['foo','bar'],['baz']],o.words) + + def test_object_instances_can_only_be_once_in_words_object_list(self): + o = NamedObject('foo foo',True) + d = build_word_dict([o]) + self.assertEqual(1,len(d['foo'])) + + def test_job(self): + def do_progress(p,d=''): + self.log.append(p) + return True + + j = job.Job(1,do_progress) + self.log = [] + s = "foo bar" + build_word_dict([NamedObject(s, True), NamedObject(s, True), NamedObject(s, True)], j) + self.assertEqual(0,self.log[0]) + self.assertEqual(33,self.log[1]) + self.assertEqual(66,self.log[2]) + self.assertEqual(100,self.log[3]) + + +class TCmerge_similar_words(TestCase): + def test_some_similar_words(self): + d = { + 'foobar':set([1]), + 'foobar1':set([2]), + 'foobar2':set([3]), + } + merge_similar_words(d) + self.assertEqual(1,len(d)) + self.assertEqual(3,len(d['foobar'])) + + + +class TCreduce_common_words(TestCase): + def test_typical(self): + d = { + 'foo': set([NamedObject('foo bar',True) for i in range(50)]), + 'bar': set([NamedObject('foo bar',True) for i in range(49)]) + } + reduce_common_words(d, 50) + self.assert_('foo' not in d) + self.assertEqual(49,len(d['bar'])) + + def test_dont_remove_objects_with_only_common_words(self): + d = { + 'common': set([NamedObject("common uncommon",True) for i in range(50)] + [NamedObject("common",True)]), + 'uncommon': set([NamedObject("common uncommon",True)]) + } + reduce_common_words(d, 50) + self.assertEqual(1,len(d['common'])) + self.assertEqual(1,len(d['uncommon'])) + + def test_values_still_are_set_instances(self): + d = { + 'common': set([NamedObject("common uncommon",True) for i in range(50)] + [NamedObject("common",True)]), + 'uncommon': set([NamedObject("common uncommon",True)]) + } + reduce_common_words(d, 50) + self.assert_(isinstance(d['common'],set)) + self.assert_(isinstance(d['uncommon'],set)) + + def test_dont_raise_KeyError_when_a_word_has_been_removed(self): + #If a word has been removed by the reduce, an object in a subsequent common word that + #contains the word that has been removed would cause a KeyError. + d = { + 'foo': set([NamedObject('foo bar baz',True) for i in range(50)]), + 'bar': set([NamedObject('foo bar baz',True) for i in range(50)]), + 'baz': set([NamedObject('foo bar baz',True) for i in range(49)]) + } + try: + reduce_common_words(d, 50) + except KeyError: + self.fail() + + def test_unpack_fields(self): + #object.words may be fields. + def create_it(): + o = NamedObject('') + o.words = [['foo','bar'],['baz']] + return o + + d = { + 'foo': set([create_it() for i in range(50)]) + } + try: + reduce_common_words(d, 50) + except TypeError: + self.fail("must support fields.") + + def test_consider_a_reduced_common_word_common_even_after_reduction(self): + #There was a bug in the code that causeda word that has already been reduced not to + #be counted as a common word for subsequent words. For example, if 'foo' is processed + #as a common word, keeping a "foo bar" file in it, and the 'bar' is processed, "foo bar" + #would not stay in 'bar' because 'foo' is not a common word anymore. + only_common = NamedObject('foo bar',True) + d = { + 'foo': set([NamedObject('foo bar baz',True) for i in range(49)] + [only_common]), + 'bar': set([NamedObject('foo bar baz',True) for i in range(49)] + [only_common]), + 'baz': set([NamedObject('foo bar baz',True) for i in range(49)]) + } + reduce_common_words(d, 50) + self.assertEqual(1,len(d['foo'])) + self.assertEqual(1,len(d['bar'])) + self.assertEqual(49,len(d['baz'])) + + +class TCget_match(TestCase): + def test_simple(self): + o1 = NamedObject("foo bar",True) + o2 = NamedObject("bar bleh",True) + m = get_match(o1,o2) + self.assertEqual(50,m.percentage) + self.assertEqual(['foo','bar'],m.first.words) + self.assertEqual(['bar','bleh'],m.second.words) + self.assert_(m.first is o1) + self.assert_(m.second is o2) + + def test_in(self): + o1 = NamedObject("foo",True) + o2 = NamedObject("bar",True) + m = get_match(o1,o2) + self.assert_(o1 in m) + self.assert_(o2 in m) + self.assert_(object() not in m) + + def test_word_weight(self): + self.assertEqual(int((6.0 / 13.0) * 100),get_match(NamedObject("foo bar",True),NamedObject("bar bleh",True),(WEIGHT_WORDS,)).percentage) + + +class TCMatchFactory(TestCase): + def test_empty(self): + self.assertEqual([],MatchFactory().getmatches([])) + + def test_defaults(self): + mf = MatchFactory() + self.assertEqual(50,mf.common_word_threshold) + self.assertEqual(False,mf.weight_words) + self.assertEqual(False,mf.match_similar_words) + self.assertEqual(False,mf.no_field_order) + self.assertEqual(0,mf.min_match_percentage) + + def test_simple(self): + l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("a b c foo")] + r = MatchFactory().getmatches(l) + self.assertEqual(2,len(r)) + seek = [m for m in r if m.percentage == 50] #"foo bar" and "bar bleh" + m = seek[0] + self.assertEqual(['foo','bar'],m.first.words) + self.assertEqual(['bar','bleh'],m.second.words) + seek = [m for m in r if m.percentage == 33] #"foo bar" and "a b c foo" + m = seek[0] + self.assertEqual(['foo','bar'],m.first.words) + self.assertEqual(['a','b','c','foo'],m.second.words) + + def test_null_and_unrelated_objects(self): + l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject(""),NamedObject("unrelated object")] + r = MatchFactory().getmatches(l) + self.assertEqual(1,len(r)) + m = r[0] + self.assertEqual(50,m.percentage) + self.assertEqual(['foo','bar'],m.first.words) + self.assertEqual(['bar','bleh'],m.second.words) + + def test_twice_the_same_word(self): + l = [NamedObject("foo foo bar"),NamedObject("bar bleh")] + r = MatchFactory().getmatches(l) + self.assertEqual(1,len(r)) + + def test_twice_the_same_word_when_preworded(self): + l = [NamedObject("foo foo bar",True),NamedObject("bar bleh",True)] + r = MatchFactory().getmatches(l) + self.assertEqual(1,len(r)) + + def test_two_words_match(self): + l = [NamedObject("foo bar"),NamedObject("foo bar bleh")] + r = MatchFactory().getmatches(l) + self.assertEqual(1,len(r)) + + def test_match_files_with_only_common_words(self): + #If a word occurs more than 50 times, it is excluded from the matching process + #The problem with the common_word_threshold is that the files containing only common + #words will never be matched together. We *should* match them. + mf = MatchFactory() + mf.common_word_threshold = 50 + l = [NamedObject("foo") for i in range(50)] + r = mf.getmatches(l) + self.assertEqual(1225,len(r)) + + def test_use_words_already_there_if_there(self): + o1 = NamedObject('foo') + o2 = NamedObject('bar') + o2.words = ['foo'] + self.assertEqual(1,len(MatchFactory().getmatches([o1,o2]))) + + def test_job(self): + def do_progress(p,d=''): + self.log.append(p) + return True + + j = job.Job(1,do_progress) + self.log = [] + s = "foo bar" + MatchFactory().getmatches([NamedObject(s),NamedObject(s),NamedObject(s)],j) + self.assert_(len(self.log) > 2) + self.assertEqual(0,self.log[0]) + self.assertEqual(100,self.log[-1]) + + def test_weight_words(self): + mf = MatchFactory() + mf.weight_words = True + l = [NamedObject("foo bar"),NamedObject("bar bleh")] + m = mf.getmatches(l)[0] + self.assertEqual(int((6.0 / 13.0) * 100),m.percentage) + + def test_similar_word(self): + mf = MatchFactory() + mf.match_similar_words = True + l = [NamedObject("foobar"),NamedObject("foobars")] + self.assertEqual(1,len(mf.getmatches(l))) + self.assertEqual(100,mf.getmatches(l)[0].percentage) + l = [NamedObject("foobar"),NamedObject("foo")] + self.assertEqual(0,len(mf.getmatches(l))) #too far + l = [NamedObject("bizkit"),NamedObject("bizket")] + self.assertEqual(1,len(mf.getmatches(l))) + l = [NamedObject("foobar"),NamedObject("foosbar")] + self.assertEqual(1,len(mf.getmatches(l))) + + def test_single_object_with_similar_words(self): + mf = MatchFactory() + mf.match_similar_words = True + l = [NamedObject("foo foos")] + self.assertEqual(0,len(mf.getmatches(l))) + + def test_double_words_get_counted_only_once(self): + mf = MatchFactory() + l = [NamedObject("foo bar foo bleh"),NamedObject("foo bar bleh bar")] + m = mf.getmatches(l)[0] + self.assertEqual(75,m.percentage) + + def test_with_fields(self): + mf = MatchFactory() + o1 = NamedObject("foo bar - foo bleh") + o2 = NamedObject("foo bar - bleh bar") + o1.words = getfields(o1.name) + o2.words = getfields(o2.name) + m = mf.getmatches([o1, o2])[0] + self.assertEqual(50, m.percentage) + + def test_with_fields_no_order(self): + mf = MatchFactory() + mf.no_field_order = True + o1 = NamedObject("foo bar - foo bleh") + o2 = NamedObject("bleh bang - foo bar") + o1.words = getfields(o1.name) + o2.words = getfields(o2.name) + m = mf.getmatches([o1, o2])[0] + self.assertEqual(50 ,m.percentage) + + def test_only_match_similar_when_the_option_is_set(self): + mf = MatchFactory() + mf.match_similar_words = False + l = [NamedObject("foobar"),NamedObject("foobars")] + self.assertEqual(0,len(mf.getmatches(l))) + + def test_dont_recurse_do_match(self): + # with nosetests, the stack is increased. The number has to be high enough not to be failing falsely + sys.setrecursionlimit(100) + mf = MatchFactory() + files = [NamedObject('foo bar') for i in range(101)] + try: + mf.getmatches(files) + except RuntimeError: + self.fail() + finally: + sys.setrecursionlimit(1000) + + def test_min_match_percentage(self): + l = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("a b c foo")] + mf = MatchFactory() + mf.min_match_percentage = 50 + r = mf.getmatches(l) + self.assertEqual(1,len(r)) #Only "foo bar" / "bar bleh" should match + + def test_limit(self): + l = [NamedObject(),NamedObject(),NamedObject()] + mf = MatchFactory() + mf.limit = 2 + r = mf.getmatches(l) + self.assertEqual(2,len(r)) + + def test_MemoryError(self): + @log_calls + def mocked_match(first, second, flags): + if len(mocked_match.calls) > 42: + raise MemoryError() + return Match(first, second, 0) + + objects = [NamedObject() for i in range(10)] # results in 45 matches + self.mock(engine, 'get_match', mocked_match) + mf = MatchFactory() + try: + r = mf.getmatches(objects) + except MemoryError: + self.fail('MemorryError must be handled') + self.assertEqual(42, len(r)) + + +class TCGroup(TestCase): + def test_empy(self): + g = Group() + self.assertEqual(None,g.ref) + self.assertEqual([],g.dupes) + self.assertEqual(0,len(g.matches)) + + def test_add_match(self): + g = Group() + m = get_match(NamedObject("foo",True),NamedObject("bar",True)) + g.add_match(m) + self.assert_(g.ref is m.first) + self.assertEqual([m.second],g.dupes) + self.assertEqual(1,len(g.matches)) + self.assert_(m in g.matches) + + def test_multiple_add_match(self): + g = Group() + o1 = NamedObject("a",True) + o2 = NamedObject("b",True) + o3 = NamedObject("c",True) + o4 = NamedObject("d",True) + g.add_match(get_match(o1,o2)) + self.assert_(g.ref is o1) + self.assertEqual([o2],g.dupes) + self.assertEqual(1,len(g.matches)) + g.add_match(get_match(o1,o3)) + self.assertEqual([o2],g.dupes) + self.assertEqual(2,len(g.matches)) + g.add_match(get_match(o2,o3)) + self.assertEqual([o2,o3],g.dupes) + self.assertEqual(3,len(g.matches)) + g.add_match(get_match(o1,o4)) + self.assertEqual([o2,o3],g.dupes) + self.assertEqual(4,len(g.matches)) + g.add_match(get_match(o2,o4)) + self.assertEqual([o2,o3],g.dupes) + self.assertEqual(5,len(g.matches)) + g.add_match(get_match(o3,o4)) + self.assertEqual([o2,o3,o4],g.dupes) + self.assertEqual(6,len(g.matches)) + + def test_len(self): + g = Group() + self.assertEqual(0,len(g)) + g.add_match(get_match(NamedObject("foo",True),NamedObject("bar",True))) + self.assertEqual(2,len(g)) + + def test_add_same_match_twice(self): + g = Group() + m = get_match(NamedObject("foo",True),NamedObject("foo",True)) + g.add_match(m) + self.assertEqual(2,len(g)) + self.assertEqual(1,len(g.matches)) + g.add_match(m) + self.assertEqual(2,len(g)) + self.assertEqual(1,len(g.matches)) + + def test_in(self): + g = Group() + o1 = NamedObject("foo",True) + o2 = NamedObject("bar",True) + self.assert_(o1 not in g) + g.add_match(get_match(o1,o2)) + self.assert_(o1 in g) + self.assert_(o2 in g) + + def test_remove(self): + g = Group() + o1 = NamedObject("foo",True) + o2 = NamedObject("bar",True) + o3 = NamedObject("bleh",True) + g.add_match(get_match(o1,o2)) + g.add_match(get_match(o1,o3)) + g.add_match(get_match(o2,o3)) + self.assertEqual(3,len(g.matches)) + self.assertEqual(3,len(g)) + g.remove_dupe(o3) + self.assertEqual(1,len(g.matches)) + self.assertEqual(2,len(g)) + g.remove_dupe(o1) + self.assertEqual(0,len(g.matches)) + self.assertEqual(0,len(g)) + + def test_remove_with_ref_dupes(self): + g = Group() + o1 = NamedObject("foo",True) + o2 = NamedObject("bar",True) + o3 = NamedObject("bleh",True) + g.add_match(get_match(o1,o2)) + g.add_match(get_match(o1,o3)) + g.add_match(get_match(o2,o3)) + o1.is_ref = True + o2.is_ref = True + g.remove_dupe(o3) + self.assertEqual(0,len(g)) + + def test_switch_ref(self): + o1 = NamedObject(with_words=True) + o2 = NamedObject(with_words=True) + g = Group() + g.add_match(get_match(o1,o2)) + self.assert_(o1 is g.ref) + g.switch_ref(o2) + self.assert_(o2 is g.ref) + self.assertEqual([o1],g.dupes) + g.switch_ref(o2) + self.assert_(o2 is g.ref) + g.switch_ref(NamedObject('',True)) + self.assert_(o2 is g.ref) + + def test_get_match_of(self): + g = Group() + for m in get_match_triangle(): + g.add_match(m) + o = g.dupes[0] + m = g.get_match_of(o) + self.assert_(g.ref in m) + self.assert_(o in m) + self.assert_(g.get_match_of(NamedObject('',True)) is None) + self.assert_(g.get_match_of(g.ref) is None) + + def test_percentage(self): + #percentage should return the avg percentage in relation to the ref + m1,m2,m3 = get_match_triangle() + m1 = Match(m1[0], m1[1], 100) + m2 = Match(m2[0], m2[1], 50) + m3 = Match(m3[0], m3[1], 33) + g = Group() + g.add_match(m1) + g.add_match(m2) + g.add_match(m3) + self.assertEqual(75,g.percentage) + g.switch_ref(g.dupes[0]) + self.assertEqual(66,g.percentage) + g.remove_dupe(g.dupes[0]) + self.assertEqual(33,g.percentage) + g.add_match(m1) + g.add_match(m2) + self.assertEqual(66,g.percentage) + + def test_percentage_on_empty_group(self): + g = Group() + self.assertEqual(0,g.percentage) + + def test_prioritize(self): + m1,m2,m3 = get_match_triangle() + o1 = m1.first + o2 = m1.second + o3 = m2.second + o1.name = 'c' + o2.name = 'b' + o3.name = 'a' + g = Group() + g.add_match(m1) + g.add_match(m2) + g.add_match(m3) + self.assert_(o1 is g.ref) + g.prioritize(lambda x:x.name) + self.assert_(o3 is g.ref) + + def test_prioritize_with_tie_breaker(self): + # if the ref has the same key as one or more of the dupe, run the tie_breaker func among them + g = get_test_group() + o1, o2, o3 = g.ordered + tie_breaker = lambda ref, dupe: dupe is o3 + g.prioritize(lambda x:0, tie_breaker) + self.assertTrue(g.ref is o3) + + def test_prioritize_with_tie_breaker_runs_on_all_dupes(self): + # Even if a dupe is chosen to switch with ref with a tie breaker, we still run the tie breaker + # with other dupes and the newly chosen ref + g = get_test_group() + o1, o2, o3 = g.ordered + o1.foo = 1 + o2.foo = 2 + o3.foo = 3 + tie_breaker = lambda ref, dupe: dupe.foo > ref.foo + g.prioritize(lambda x:0, tie_breaker) + self.assertTrue(g.ref is o3) + + def test_prioritize_with_tie_breaker_runs_only_on_tie_dupes(self): + # The tie breaker only runs on dupes that had the same value for the key_func + g = get_test_group() + o1, o2, o3 = g.ordered + o1.foo = 2 + o2.foo = 2 + o3.foo = 1 + o1.bar = 1 + o2.bar = 2 + o3.bar = 3 + key_func = lambda x: -x.foo + tie_breaker = lambda ref, dupe: dupe.bar > ref.bar + g.prioritize(key_func, tie_breaker) + self.assertTrue(g.ref is o2) + + def test_list_like(self): + g = Group() + o1,o2 = (NamedObject("foo",True),NamedObject("bar",True)) + g.add_match(get_match(o1,o2)) + self.assert_(g[0] is o1) + self.assert_(g[1] is o2) + + def test_clean_matches(self): + g = Group() + o1,o2,o3 = (NamedObject("foo",True),NamedObject("bar",True),NamedObject("baz",True)) + g.add_match(get_match(o1,o2)) + g.add_match(get_match(o1,o3)) + g.clean_matches() + self.assertEqual(1,len(g.matches)) + self.assertEqual(0,len(g.candidates)) + + +class TCget_groups(TestCase): + def test_empty(self): + r = get_groups([]) + self.assertEqual([],r) + + def test_simple(self): + l = [NamedObject("foo bar"),NamedObject("bar bleh")] + matches = MatchFactory().getmatches(l) + m = matches[0] + r = get_groups(matches) + self.assertEqual(1,len(r)) + g = r[0] + self.assert_(g.ref is m.first) + self.assertEqual([m.second],g.dupes) + + def test_group_with_multiple_matches(self): + #This results in 3 matches + l = [NamedObject("foo"),NamedObject("foo"),NamedObject("foo")] + matches = MatchFactory().getmatches(l) + r = get_groups(matches) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(3,len(g)) + + def test_must_choose_a_group(self): + l = [NamedObject("a b"),NamedObject("a b"),NamedObject("b c"),NamedObject("c d"),NamedObject("c d")] + #There will be 2 groups here: group "a b" and group "c d" + #"b c" can go either of them, but not both. + matches = MatchFactory().getmatches(l) + r = get_groups(matches) + self.assertEqual(2,len(r)) + self.assertEqual(5,len(r[0])+len(r[1])) + + def test_should_all_go_in_the_same_group(self): + l = [NamedObject("a b"),NamedObject("a b"),NamedObject("a b"),NamedObject("a b")] + #There will be 2 groups here: group "a b" and group "c d" + #"b c" can fit in both, but it must be in only one of them + matches = MatchFactory().getmatches(l) + r = get_groups(matches) + self.assertEqual(1,len(r)) + + def test_give_priority_to_matches_with_higher_percentage(self): + o1 = NamedObject(with_words=True) + o2 = NamedObject(with_words=True) + o3 = NamedObject(with_words=True) + m1 = Match(o1, o2, 1) + m2 = Match(o2, o3, 2) + r = get_groups([m1,m2]) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(2,len(g)) + self.assert_(o1 not in g) + self.assert_(o2 in g) + self.assert_(o3 in g) + + def test_four_sized_group(self): + l = [NamedObject("foobar") for i in xrange(4)] + m = MatchFactory().getmatches(l) + r = get_groups(m) + self.assertEqual(1,len(r)) + self.assertEqual(4,len(r[0])) + + def test_referenced_by_ref2(self): + o1 = NamedObject(with_words=True) + o2 = NamedObject(with_words=True) + o3 = NamedObject(with_words=True) + m1 = get_match(o1,o2) + m2 = get_match(o3,o1) + m3 = get_match(o3,o2) + r = get_groups([m1,m2,m3]) + self.assertEqual(3,len(r[0])) + + def test_job(self): + def do_progress(p,d=''): + self.log.append(p) + return True + + self.log = [] + j = job.Job(1,do_progress) + m1,m2,m3 = get_match_triangle() + #101%: To make sure it is processed first so the job test works correctly + m4 = Match(NamedObject('a',True), NamedObject('a',True), 101) + get_groups([m1,m2,m3,m4],j) + self.assertEqual(0,self.log[0]) + self.assertEqual(100,self.log[-1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/py/export.py b/py/export.py new file mode 100644 index 00000000..c6293a5d --- /dev/null +++ b/py/export.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.export +Created By: Virgil Dupras +Created On: 2006/09/16 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +from xml.dom import minidom +import tempfile +import os.path as op +import os +from StringIO import StringIO + +from hsutil.files import FileOrPath + +def output_column_xml(outfile, columns): + """Creates a xml file outfile with the supplied columns. + + outfile can be a filename or a file object. + columns is a list of 2 sized tuples (display,enabled) + """ + doc = minidom.Document() + root = doc.appendChild(doc.createElement('columns')) + for display,enabled in columns: + col_node = root.appendChild(doc.createElement('column')) + col_node.setAttribute('display', display) + col_node.setAttribute('enabled', {True:'y',False:'n'}[enabled]) + with FileOrPath(outfile, 'wb') as fp: + doc.writexml(fp, '\t','\t','\n', encoding='utf-8') + +def merge_css_into_xhtml(xhtml, css): + with FileOrPath(xhtml, 'r+') as xhtml: + with FileOrPath(css) as css: + try: + doc = minidom.parse(xhtml) + except Exception: + return False + head = doc.getElementsByTagName('head')[0] + links = head.getElementsByTagName('link') + for link in links: + if link.getAttribute('rel') == 'stylesheet': + head.removeChild(link) + style = head.appendChild(doc.createElement('style')) + style.setAttribute('type','text/css') + style.appendChild(doc.createTextNode(css.read())) + xhtml.truncate(0) + doc.writexml(xhtml, '\t','\t','\n', encoding='utf-8') + xhtml.seek(0) + return True + +def export_to_xhtml(xml, xslt, css, columns, cmd='xsltproc --path "%(folder)s" "%(xslt)s" "%(xml)s"'): + folder = op.split(xml)[0] + output_column_xml(op.join(folder,'columns.xml'),columns) + html = StringIO() + cmd = cmd % {'folder': folder, 'xslt': xslt, 'xml': xml} + html.write(os.popen(cmd).read()) + html.seek(0) + merge_css_into_xhtml(html,css) + html.seek(0) + html_path = op.join(folder,'export.htm') + html_file = open(html_path,'w') + html_file.write(html.read().encode('utf-8')) + html_file.close() + return html_path diff --git a/py/export_test.py b/py/export_test.py new file mode 100644 index 00000000..5c4a6d87 --- /dev/null +++ b/py/export_test.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.export +Created By: Virgil Dupras +Created On: 2006/09/16 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +from xml.dom import minidom +from StringIO import StringIO + +from hsutil.testcase import TestCase + +from .export import * +from . import export + +class TCoutput_columns_xml(TestCase): + def test_empty_columns(self): + f = StringIO() + output_column_xml(f,[]) + f.seek(0) + doc = minidom.parse(f) + root = doc.documentElement + self.assertEqual('columns',root.nodeName) + self.assertEqual(0,len(root.childNodes)) + + def test_some_columns(self): + f = StringIO() + output_column_xml(f,[('foo',True),('bar',False),('baz',True)]) + f.seek(0) + doc = minidom.parse(f) + columns = doc.getElementsByTagName('column') + self.assertEqual(3,len(columns)) + c1,c2,c3 = columns + self.assertEqual('foo',c1.getAttribute('display')) + self.assertEqual('bar',c2.getAttribute('display')) + self.assertEqual('baz',c3.getAttribute('display')) + self.assertEqual('y',c1.getAttribute('enabled')) + self.assertEqual('n',c2.getAttribute('enabled')) + self.assertEqual('y',c3.getAttribute('enabled')) + + +class TCmerge_css_into_xhtml(TestCase): + def test_main(self): + css = StringIO() + css.write('foobar') + css.seek(0) + xhtml = StringIO() + xhtml.write(""" + + + + + dupeGuru - Duplicate file scanner + + + + + + """) + xhtml.seek(0) + self.assert_(merge_css_into_xhtml(xhtml,css)) + xhtml.seek(0) + doc = minidom.parse(xhtml) + head = doc.getElementsByTagName('head')[0] + #A style node should have been added in head. + styles = head.getElementsByTagName('style') + self.assertEqual(1,len(styles)) + style = styles[0] + self.assertEqual('text/css',style.getAttribute('type')) + self.assertEqual('foobar',style.firstChild.nodeValue.strip()) + #all should be removed + self.assertEqual(1,len(head.getElementsByTagName('link'))) + + def test_empty(self): + self.assert_(not merge_css_into_xhtml(StringIO(),StringIO())) + + def test_malformed(self): + xhtml = StringIO() + xhtml.write(""" + + """) + xhtml.seek(0) + self.assert_(not merge_css_into_xhtml(xhtml,StringIO())) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/py/gen.py b/py/gen.py new file mode 100644 index 00000000..0a842372 --- /dev/null +++ b/py/gen.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# Unit Name: gen +# Created By: Virgil Dupras +# Created On: 2009-05-26 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os +import os.path as op + +def move(src, dst): + if not op.exists(src): + return + if op.exists(dst): + os.remove(dst) + print 'Moving %s --> %s' % (src, dst) + os.rename(src, dst) + + +os.chdir(op.join('modules', 'block')) +os.system('python setup.py build_ext --inplace') +os.chdir(op.join('..', 'cache')) +os.system('python setup.py build_ext --inplace') +os.chdir(op.join('..', '..')) +move(op.join('modules', 'block', '_block.so'), op.join('picture', '_block.so')) +move(op.join('modules', 'block', '_block.pyd'), op.join('picture', '_block.pyd')) +move(op.join('modules', 'cache', '_cache.so'), op.join('picture', '_cache.so')) +move(op.join('modules', 'cache', '_cache.pyd'), op.join('picture', '_cache.pyd')) diff --git a/py/ignore.py b/py/ignore.py new file mode 100644 index 00000000..97060786 --- /dev/null +++ b/py/ignore.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +""" +Unit Name: ignore +Created By: Virgil Dupras +Created On: 2006/05/02 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +from hsutil.files import FileOrPath + +import xml.dom.minidom + +class IgnoreList(object): + """An ignore list implementation that is iterable, filterable and exportable to XML. + + Call Ignore to add an ignore list entry, and AreIgnore to check if 2 items are in the list. + When iterated, 2 sized tuples will be returned, the tuples containing 2 items ignored together. + """ + #---Override + def __init__(self): + self._ignored = {} + self._count = 0 + + def __iter__(self): + for first,seconds in self._ignored.iteritems(): + for second in seconds: + yield (first,second) + + def __len__(self): + return self._count + + #---Public + def AreIgnored(self,first,second): + def do_check(first,second): + try: + matches = self._ignored[first] + return second in matches + except KeyError: + return False + + return do_check(first,second) or do_check(second,first) + + def Clear(self): + self._ignored = {} + self._count = 0 + + def Filter(self,func): + """Applies a filter on all ignored items, and remove all matches where func(first,second) + doesn't return True. + """ + filtered = IgnoreList() + for first,second in self: + if func(first,second): + filtered.Ignore(first,second) + self._ignored = filtered._ignored + self._count = filtered._count + + def Ignore(self,first,second): + if self.AreIgnored(first,second): + return + try: + matches = self._ignored[first] + matches.add(second) + except KeyError: + try: + matches = self._ignored[second] + matches.add(first) + except KeyError: + matches = set() + matches.add(second) + self._ignored[first] = matches + self._count += 1 + + def load_from_xml(self,infile): + """Loads the ignore list from a XML created with save_to_xml. + + infile can be a file object or a filename. + """ + try: + doc = xml.dom.minidom.parse(infile) + except Exception: + return + file_nodes = doc.getElementsByTagName('file') + for fn in file_nodes: + if not fn.getAttributeNode('path'): + continue + file_path = fn.getAttributeNode('path').nodeValue + subfile_nodes = fn.getElementsByTagName('file') + for sfn in subfile_nodes: + if not sfn.getAttributeNode('path'): + continue + subfile_path = sfn.getAttributeNode('path').nodeValue + self.Ignore(file_path,subfile_path) + + def save_to_xml(self,outfile): + """Create a XML file that can be used by load_from_xml. + + outfile can be a file object or a filename. + """ + doc = xml.dom.minidom.Document() + root = doc.appendChild(doc.createElement('ignore_list')) + for file,subfiles in self._ignored.items(): + file_node = root.appendChild(doc.createElement('file')) + if isinstance(file,unicode): + file = file.encode('utf-8') + file_node.setAttribute('path',file) + for subfile in subfiles: + subfile_node = file_node.appendChild(doc.createElement('file')) + if isinstance(subfile,unicode): + subfile = subfile.encode('utf-8') + subfile_node.setAttribute('path',subfile) + with FileOrPath(outfile, 'wb') as fp: + doc.writexml(fp,'\t','\t','\n',encoding='utf-8') + + diff --git a/py/ignore_test.py b/py/ignore_test.py new file mode 100644 index 00000000..8ff91f52 --- /dev/null +++ b/py/ignore_test.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python +""" +Unit Name: ignore +Created By: Virgil Dupras +Created On: 2006/05/02 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import cStringIO +import xml.dom.minidom + +from .ignore import * + +class TCIgnoreList(unittest.TestCase): + def test_empty(self): + il = IgnoreList() + self.assertEqual(0,len(il)) + self.assert_(not il.AreIgnored('foo','bar')) + + def test_simple(self): + il = IgnoreList() + il.Ignore('foo','bar') + self.assert_(il.AreIgnored('foo','bar')) + self.assert_(il.AreIgnored('bar','foo')) + self.assert_(not il.AreIgnored('foo','bleh')) + self.assert_(not il.AreIgnored('bleh','bar')) + self.assertEqual(1,len(il)) + + def test_multiple(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('foo','bleh') + il.Ignore('bleh','bar') + il.Ignore('aybabtu','bleh') + self.assert_(il.AreIgnored('foo','bar')) + self.assert_(il.AreIgnored('bar','foo')) + self.assert_(il.AreIgnored('foo','bleh')) + self.assert_(il.AreIgnored('bleh','bar')) + self.assert_(not il.AreIgnored('aybabtu','bar')) + self.assertEqual(4,len(il)) + + def test_clear(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Clear() + self.assert_(not il.AreIgnored('foo','bar')) + self.assert_(not il.AreIgnored('bar','foo')) + self.assertEqual(0,len(il)) + + def test_add_same_twice(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('bar','foo') + self.assertEqual(1,len(il)) + + def test_save_to_xml(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('foo','bleh') + il.Ignore('bleh','bar') + f = cStringIO.StringIO() + il.save_to_xml(f) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + self.assertEqual('ignore_list',root.nodeName) + children = [c for c in root.childNodes if c.localName] + self.assertEqual(2,len(children)) + self.assertEqual(2,len([c for c in children if c.nodeName == 'file'])) + f1,f2 = children + subchildren = [c for c in f1.childNodes if c.localName == 'file'] +\ + [c for c in f2.childNodes if c.localName == 'file'] + self.assertEqual(3,len(subchildren)) + + def test_SaveThenLoad(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('foo','bleh') + il.Ignore('bleh','bar') + il.Ignore(u'\u00e9','bar') + f = cStringIO.StringIO() + il.save_to_xml(f) + f.seek(0) + il = IgnoreList() + il.load_from_xml(f) + self.assertEqual(4,len(il)) + self.assert_(il.AreIgnored(u'\u00e9','bar')) + + def test_LoadXML_with_empty_file_tags(self): + f = cStringIO.StringIO() + f.write('') + f.seek(0) + il = IgnoreList() + il.load_from_xml(f) + self.assertEqual(0,len(il)) + + def test_AreIgnore_works_when_a_child_is_a_key_somewhere_else(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('bar','baz') + self.assert_(il.AreIgnored('bar','foo')) + + + def test_no_dupes_when_a_child_is_a_key_somewhere_else(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('bar','baz') + il.Ignore('bar','foo') + self.assertEqual(2,len(il)) + + def test_iterate(self): + #It must be possible to iterate through ignore list + il = IgnoreList() + expected = [('foo','bar'),('bar','baz'),('foo','baz')] + for i in expected: + il.Ignore(i[0],i[1]) + for i in il: + expected.remove(i) #No exception should be raised + self.assert_(not expected) #expected should be empty + + def test_filter(self): + il = IgnoreList() + il.Ignore('foo','bar') + il.Ignore('bar','baz') + il.Ignore('foo','baz') + il.Filter(lambda f,s: f == 'bar') + self.assertEqual(1,len(il)) + self.assert_(not il.AreIgnored('foo','bar')) + self.assert_(il.AreIgnored('bar','baz')) + + def test_save_with_non_ascii_non_unicode_items(self): + il = IgnoreList() + il.Ignore('\xac','\xbf') + f = cStringIO.StringIO() + try: + il.save_to_xml(f) + except Exception,e: + self.fail(str(e)) + + def test_len(self): + il = IgnoreList() + self.assertEqual(0,len(il)) + il.Ignore('foo','bar') + self.assertEqual(1,len(il)) + + def test_nonzero(self): + il = IgnoreList() + self.assert_(not il) + il.Ignore('foo','bar') + self.assert_(il) + + +if __name__ == "__main__": + unittest.main() + diff --git a/py/modules/block/block.pyx b/py/modules/block/block.pyx new file mode 100644 index 00000000..db4c7500 --- /dev/null +++ b/py/modules/block/block.pyx @@ -0,0 +1,93 @@ +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +cdef extern from "stdlib.h": + int abs(int n) # required so that abs() is applied on ints, not python objects + +class NoBlocksError(Exception): + """avgdiff/maxdiff has been called with empty lists""" + +class DifferentBlockCountError(Exception): + """avgdiff/maxdiff has been called with 2 block lists of different size.""" + + +cdef object getblock(object image): + """Returns a 3 sized tuple containing the mean color of 'image'. + + image: a PIL image or crop. + """ + cdef int pixel_count, red, green, blue, r, g, b + if image.size[0]: + pixel_count = image.size[0] * image.size[1] + red = green = blue = 0 + for r, g, b in image.getdata(): + red += r + green += g + blue += b + return (red // pixel_count, green // pixel_count, blue // pixel_count) + else: + return (0, 0, 0) + +def getblocks2(image, int block_count_per_side): + """Returns a list of blocks (3 sized tuples). + + image: A PIL image to base the blocks on. + block_count_per_side: This integer determine the number of blocks the function will return. + If it is 10, for example, 100 blocks will be returns (10 width, 10 height). The blocks will not + necessarely cover square areas. The area covered by each block will be proportional to the image + itself. + """ + if not image.size[0]: + return [] + cdef int width, height, block_width, block_height, ih, iw, top, bottom, left, right + width, height = image.size + block_width = max(width // block_count_per_side, 1) + block_height = max(height // block_count_per_side, 1) + result = [] + for ih in range(block_count_per_side): + top = min(ih * block_height, height - block_height) + bottom = top + block_height + for iw in range(block_count_per_side): + left = min(iw * block_width, width - block_width) + right = left + block_width + box = (left, top, right, bottom) + crop = image.crop(box) + result.append(getblock(crop)) + return result + +cdef int diff(first, second): + """Returns the difference between the first block and the second. + + It returns an absolute sum of the 3 differences (RGB). + """ + cdef int r1, g1, b1, r2, g2, b2 + r1, g1, b1 = first + r2, g2, b2 = second + return abs(r1 - r2) + abs(g1 - g2) + abs(b1 - b2) + +def avgdiff(first, second, int limit, int min_iterations): + """Returns the average diff between first blocks and seconds. + + If the result surpasses limit, limit + 1 is returned, except if less than min_iterations + iterations have been made in the blocks. + """ + cdef int count, sum, i, iteration_count + count = len(first) + if count != len(second): + raise DifferentBlockCountError() + if not count: + raise NoBlocksError() + sum = 0 + for i in range(count): + iteration_count = i + 1 + item1 = first[i] + item2 = second[i] + sum += diff(item1, item2) + if sum > limit * iteration_count and iteration_count >= min_iterations: + return limit + 1 + result = sum // count + if (not result) and sum: + result = 1 + return result \ No newline at end of file diff --git a/py/modules/block/setup.py b/py/modules/block/setup.py new file mode 100644 index 00000000..9d8f4cb5 --- /dev/null +++ b/py/modules/block/setup.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from distutils.core import setup +from distutils.extension import Extension +from Cython.Distutils import build_ext + +setup( + cmdclass = {'build_ext': build_ext}, + ext_modules = [Extension("_block", ["block.pyx"])] +) \ No newline at end of file diff --git a/py/modules/cache/cache.pyx b/py/modules/cache/cache.pyx new file mode 100644 index 00000000..7bd2407d --- /dev/null +++ b/py/modules/cache/cache.pyx @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +# ok, this is hacky and stuff, but I don't know C well enough to play with char buffers, copy +# them around and stuff +cdef int xchar_to_int(char c): + if 48 <= c <= 57: # 0-9 + return c - 48 + elif 65 <= c <= 70: # A-F + return c - 55 + elif 97 <= c <= 102: # a-f + return c - 87 + +def string_to_colors(s): + """Transform the string 's' in a list of 3 sized tuples. + """ + result = [] + cdef int i, char_count, r, g, b + cdef char* cs + char_count = len(s) + char_count = (char_count // 6) * 6 + cs = s + for i in range(0, char_count, 6): + r = xchar_to_int(cs[i]) << 4 + r += xchar_to_int(cs[i+1]) + g = xchar_to_int(cs[i+2]) << 4 + g += xchar_to_int(cs[i+3]) + b = xchar_to_int(cs[i+4]) << 4 + b += xchar_to_int(cs[i+5]) + result.append((r, g, b)) + return result diff --git a/py/modules/cache/setup.py b/py/modules/cache/setup.py new file mode 100644 index 00000000..2b6cd31b --- /dev/null +++ b/py/modules/cache/setup.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# Created By: Virgil Dupras +# Created On: 2009-04-23 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from distutils.core import setup +from distutils.extension import Extension +from Cython.Distutils import build_ext + +setup( + cmdclass = {'build_ext': build_ext}, + ext_modules = [Extension("_cache", ["cache.pyx"])] +) \ No newline at end of file diff --git a/py/picture/__init__.py b/py/picture/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/py/picture/block.py b/py/picture/block.py new file mode 100644 index 00000000..70015a50 --- /dev/null +++ b/py/picture/block.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +""" +Unit Name: hs.picture.block +Created By: Virgil Dupras +Created On: 2006/09/01 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-26 18:12:39 +0200 (Tue, 26 May 2009) $ + $Revision: 4365 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +from _block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 + +# Converted to Cython +# def getblock(image): +# """Returns a 3 sized tuple containing the mean color of 'image'. +# +# image: a PIL image or crop. +# """ +# if image.size[0]: +# pixel_count = image.size[0] * image.size[1] +# red = green = blue = 0 +# for r,g,b in image.getdata(): +# red += r +# green += g +# blue += b +# return (red // pixel_count, green // pixel_count, blue // pixel_count) +# else: +# return (0,0,0) + +# This is not used anymore +# def getblocks(image,blocksize): +# """Returns a list of blocks (3 sized tuples). +# +# image: A PIL image to base the blocks on. +# blocksize: The size of the blocks to be create. This is a single integer, defining +# both width and height (blocks are square). +# """ +# if min(image.size) < blocksize: +# return () +# result = [] +# for i in xrange(image.size[1] // blocksize): +# for j in xrange(image.size[0] // blocksize): +# box = (blocksize * j, blocksize * i, blocksize * (j + 1), blocksize * (i + 1)) +# crop = image.crop(box) +# result.append(getblock(crop)) +# return result + +# Converted to Cython +# def getblocks2(image,block_count_per_side): +# """Returns a list of blocks (3 sized tuples). +# +# image: A PIL image to base the blocks on. +# block_count_per_side: This integer determine the number of blocks the function will return. +# If it is 10, for example, 100 blocks will be returns (10 width, 10 height). The blocks will not +# necessarely cover square areas. The area covered by each block will be proportional to the image +# itself. +# """ +# if not image.size[0]: +# return [] +# width,height = image.size +# block_width = max(width // block_count_per_side,1) +# block_height = max(height // block_count_per_side,1) +# result = [] +# for ih in range(block_count_per_side): +# top = min(ih * block_height, height - block_height) +# bottom = top + block_height +# for iw in range(block_count_per_side): +# left = min(iw * block_width, width - block_width) +# right = left + block_width +# box = (left,top,right,bottom) +# crop = image.crop(box) +# result.append(getblock(crop)) +# return result + +# Converted to Cython +# def diff(first, second): +# """Returns the difference between the first block and the second. +# +# It returns an absolute sum of the 3 differences (RGB). +# """ +# r1, g1, b1 = first +# r2, g2, b2 = second +# return abs(r1 - r2) + abs(g1 - g2) + abs(b1 - b2) + +# Converted to Cython +# def avgdiff(first, second, limit=768, min_iterations=1): +# """Returns the average diff between first blocks and seconds. +# +# If the result surpasses limit, limit + 1 is returned, except if less than min_iterations +# iterations have been made in the blocks. +# """ +# if len(first) != len(second): +# raise DifferentBlockCountError +# if not first: +# raise NoBlocksError +# count = len(first) +# sum = 0 +# zipped = izip(xrange(1, count + 1), first, second) +# for i, first, second in zipped: +# sum += diff(first, second) +# if sum > limit * i and i >= min_iterations: +# return limit + 1 +# result = sum // count +# if (not result) and sum: +# result = 1 +# return result + +# This is not used anymore +# def maxdiff(first,second,limit=768): +# """Returns the max diff between first blocks and seconds. +# +# If the result surpasses limit, the first max being over limit is returned. +# """ +# if len(first) != len(second): +# raise DifferentBlockCountError +# if not first: +# raise NoBlocksError +# result = 0 +# zipped = zip(first,second) +# for first,second in zipped: +# result = max(result,diff(first,second)) +# if result > limit: +# return result +# return result diff --git a/py/picture/block_test.py b/py/picture/block_test.py new file mode 100644 index 00000000..a06cf617 --- /dev/null +++ b/py/picture/block_test.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python +""" +Unit Name: tests.picture.block +Created By: Virgil Dupras +Created On: 2006/09/01 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +# The commented out tests are tests for function that have been converted to pure C for speed +import unittest + +from .block import * + +def my_avgdiff(first, second, limit=768, min_iter=3): # this is so I don't have to re-write every call + return avgdiff(first, second, limit, min_iter) + +BLACK = (0,0,0) +RED = (0xff,0,0) +GREEN = (0,0xff,0) +BLUE = (0,0,0xff) + +class FakeImage(object): + def __init__(self, size, data): + self.size = size + self.data = data + + def getdata(self): + return self.data + + def crop(self, box): + pixels = [] + for i in range(box[1], box[3]): + for j in range(box[0], box[2]): + pixel = self.data[i * self.size[0] + j] + pixels.append(pixel) + return FakeImage((box[2] - box[0], box[3] - box[1]), pixels) + +def empty(): + return FakeImage((0,0), []) + +def single_pixel(): #one red pixel + return FakeImage((1, 1), [(0xff,0,0)]) + +def four_pixels(): + pixels = [RED,(0,0x80,0xff),(0x80,0,0),(0,0x40,0x80)] + return FakeImage((2, 2), pixels) + +class TCgetblock(unittest.TestCase): + def test_single_pixel(self): + im = single_pixel() + [b] = getblocks2(im, 1) + self.assertEqual(RED,b) + + def test_no_pixel(self): + im = empty() + self.assertEqual([], getblocks2(im, 1)) + + def test_four_pixels(self): + im = four_pixels() + [b] = getblocks2(im, 1) + meanred = (0xff + 0x80) // 4 + meangreen = (0x80 + 0x40) // 4 + meanblue = (0xff + 0x80) // 4 + self.assertEqual((meanred,meangreen,meanblue),b) + + +# class TCdiff(unittest.TestCase): +# def test_diff(self): +# b1 = (10, 20, 30) +# b2 = (1, 2, 3) +# self.assertEqual(9 + 18 + 27,diff(b1,b2)) +# +# def test_diff_negative(self): +# b1 = (10, 20, 30) +# b2 = (1, 2, 3) +# self.assertEqual(9 + 18 + 27,diff(b2,b1)) +# +# def test_diff_mixed_positive_and_negative(self): +# b1 = (1, 5, 10) +# b2 = (10, 1, 15) +# self.assertEqual(9 + 4 + 5,diff(b1,b2)) +# + +# class TCgetblocks(unittest.TestCase): +# def test_empty_image(self): +# im = empty() +# blocks = getblocks(im,1) +# self.assertEqual(0,len(blocks)) +# +# def test_one_block_image(self): +# im = four_pixels() +# blocks = getblocks2(im, 1) +# self.assertEqual(1,len(blocks)) +# block = blocks[0] +# meanred = (0xff + 0x80) // 4 +# meangreen = (0x80 + 0x40) // 4 +# meanblue = (0xff + 0x80) // 4 +# self.assertEqual((meanred,meangreen,meanblue),block) +# +# def test_not_enough_height_to_fit_a_block(self): +# im = FakeImage((2,1), [BLACK, BLACK]) +# blocks = getblocks(im,2) +# self.assertEqual(0,len(blocks)) +# +# def xtest_dont_include_leftovers(self): +# # this test is disabled because getblocks is not used and getblock in cdeffed +# pixels = [ +# RED,(0,0x80,0xff),BLACK, +# (0x80,0,0),(0,0x40,0x80),BLACK, +# BLACK,BLACK,BLACK +# ] +# im = FakeImage((3,3), pixels) +# blocks = getblocks(im,2) +# block = blocks[0] +# #Because the block is smaller than the image, only blocksize must be considered. +# meanred = (0xff + 0x80) // 4 +# meangreen = (0x80 + 0x40) // 4 +# meanblue = (0xff + 0x80) // 4 +# self.assertEqual((meanred,meangreen,meanblue),block) +# +# def xtest_two_blocks(self): +# # this test is disabled because getblocks is not used and getblock in cdeffed +# pixels = [BLACK for i in xrange(4 * 2)] +# pixels[0] = RED +# pixels[1] = (0,0x80,0xff) +# pixels[4] = (0x80,0,0) +# pixels[5] = (0,0x40,0x80) +# im = FakeImage((4, 2), pixels) +# blocks = getblocks(im,2) +# self.assertEqual(2,len(blocks)) +# block = blocks[0] +# #Because the block is smaller than the image, only blocksize must be considered. +# meanred = (0xff + 0x80) // 4 +# meangreen = (0x80 + 0x40) // 4 +# meanblue = (0xff + 0x80) // 4 +# self.assertEqual((meanred,meangreen,meanblue),block) +# self.assertEqual(BLACK,blocks[1]) +# +# def test_four_blocks(self): +# pixels = [BLACK for i in xrange(4 * 4)] +# pixels[0] = RED +# pixels[1] = (0,0x80,0xff) +# pixels[4] = (0x80,0,0) +# pixels[5] = (0,0x40,0x80) +# im = FakeImage((4, 4), pixels) +# blocks = getblocks2(im, 2) +# self.assertEqual(4,len(blocks)) +# block = blocks[0] +# #Because the block is smaller than the image, only blocksize must be considered. +# meanred = (0xff + 0x80) // 4 +# meangreen = (0x80 + 0x40) // 4 +# meanblue = (0xff + 0x80) // 4 +# self.assertEqual((meanred,meangreen,meanblue),block) +# self.assertEqual(BLACK,blocks[1]) +# self.assertEqual(BLACK,blocks[2]) +# self.assertEqual(BLACK,blocks[3]) +# + +class TCgetblocks2(unittest.TestCase): + def test_empty_image(self): + im = empty() + blocks = getblocks2(im,1) + self.assertEqual(0,len(blocks)) + + def test_one_block_image(self): + im = four_pixels() + blocks = getblocks2(im,1) + self.assertEqual(1,len(blocks)) + block = blocks[0] + meanred = (0xff + 0x80) // 4 + meangreen = (0x80 + 0x40) // 4 + meanblue = (0xff + 0x80) // 4 + self.assertEqual((meanred,meangreen,meanblue),block) + + def test_four_blocks_all_black(self): + im = FakeImage((2, 2), [BLACK, BLACK, BLACK, BLACK]) + blocks = getblocks2(im,2) + self.assertEqual(4,len(blocks)) + for block in blocks: + self.assertEqual(BLACK,block) + + def test_two_pixels_image_horizontal(self): + pixels = [RED,BLUE] + im = FakeImage((2, 1), pixels) + blocks = getblocks2(im,2) + self.assertEqual(4,len(blocks)) + self.assertEqual(RED,blocks[0]) + self.assertEqual(BLUE,blocks[1]) + self.assertEqual(RED,blocks[2]) + self.assertEqual(BLUE,blocks[3]) + + def test_two_pixels_image_vertical(self): + pixels = [RED,BLUE] + im = FakeImage((1, 2), pixels) + blocks = getblocks2(im,2) + self.assertEqual(4,len(blocks)) + self.assertEqual(RED,blocks[0]) + self.assertEqual(RED,blocks[1]) + self.assertEqual(BLUE,blocks[2]) + self.assertEqual(BLUE,blocks[3]) + + +class TCavgdiff(unittest.TestCase): + def test_empty(self): + self.assertRaises(NoBlocksError, my_avgdiff, [], []) + + def test_two_blocks(self): + im = empty() + b1 = (5,10,15) + b2 = (255,250,245) + b3 = (0,0,0) + b4 = (255,0,255) + blocks1 = [b1,b2] + blocks2 = [b3,b4] + expected1 = 5 + 10 + 15 + expected2 = 0 + 250 + 10 + expected = (expected1 + expected2) // 2 + self.assertEqual(expected, my_avgdiff(blocks1, blocks2)) + + def test_blocks_not_the_same_size(self): + b = (0,0,0) + self.assertRaises(DifferentBlockCountError,my_avgdiff,[b,b],[b]) + + def test_first_arg_is_empty_but_not_second(self): + #Don't return 0 (as when the 2 lists are empty), raise! + b = (0,0,0) + self.assertRaises(DifferentBlockCountError,my_avgdiff,[],[b]) + + def test_limit(self): + ref = (0,0,0) + b1 = (10,10,10) #avg 30 + b2 = (20,20,20) #avg 45 + b3 = (30,30,30) #avg 60 + blocks1 = [ref,ref,ref] + blocks2 = [b1,b2,b3] + self.assertEqual(45,my_avgdiff(blocks1,blocks2,44)) + + def test_min_iterations(self): + ref = (0,0,0) + b1 = (10,10,10) #avg 30 + b2 = (20,20,20) #avg 45 + b3 = (10,10,10) #avg 40 + blocks1 = [ref,ref,ref] + blocks2 = [b1,b2,b3] + self.assertEqual(40,my_avgdiff(blocks1,blocks2,45 - 1,3)) + + # Bah, I don't know why this test fails, but I don't think it matters very much + # def test_just_over_the_limit(self): + # #A score just over the limit might return exactly the limit due to truncating. We should + # #ceil() the result in this case. + # ref = (0,0,0) + # b1 = (10,0,0) + # b2 = (11,0,0) + # blocks1 = [ref,ref] + # blocks2 = [b1,b2] + # self.assertEqual(11,my_avgdiff(blocks1,blocks2,10)) + # + def test_return_at_least_1_at_the_slightest_difference(self): + ref = (0,0,0) + b1 = (1,0,0) + blocks1 = [ref for i in xrange(250)] + blocks2 = [ref for i in xrange(250)] + blocks2[0] = b1 + self.assertEqual(1,my_avgdiff(blocks1,blocks2)) + + def test_return_0_if_there_is_no_difference(self): + ref = (0,0,0) + blocks1 = [ref,ref] + blocks2 = [ref,ref] + self.assertEqual(0,my_avgdiff(blocks1,blocks2)) + + +# class TCmaxdiff(unittest.TestCase): +# def test_empty(self): +# self.assertRaises(NoBlocksError,maxdiff,[],[]) +# +# def test_two_blocks(self): +# b1 = (5,10,15) +# b2 = (255,250,245) +# b3 = (0,0,0) +# b4 = (255,0,255) +# blocks1 = [b1,b2] +# blocks2 = [b3,b4] +# expected1 = 5 + 10 + 15 +# expected2 = 0 + 250 + 10 +# expected = max(expected1,expected2) +# self.assertEqual(expected,maxdiff(blocks1,blocks2)) +# +# def test_blocks_not_the_same_size(self): +# b = (0,0,0) +# self.assertRaises(DifferentBlockCountError,maxdiff,[b,b],[b]) +# +# def test_first_arg_is_empty_but_not_second(self): +# #Don't return 0 (as when the 2 lists are empty), raise! +# b = (0,0,0) +# self.assertRaises(DifferentBlockCountError,maxdiff,[],[b]) +# +# def test_limit(self): +# b1 = (5,10,15) +# b2 = (255,250,245) +# b3 = (0,0,0) +# b4 = (255,0,255) +# blocks1 = [b1,b2] +# blocks2 = [b3,b4] +# expected1 = 5 + 10 + 15 +# expected2 = 0 + 250 + 10 +# self.assertEqual(expected1,maxdiff(blocks1,blocks2,expected1 - 1)) +# + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/py/picture/cache.py b/py/picture/cache.py new file mode 100644 index 00000000..6ff0d2d1 --- /dev/null +++ b/py/picture/cache.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +""" +Unit Name: hs.picture.cache +Created By: Virgil Dupras +Created On: 2006/09/14 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import os +import logging +import sqlite3 as sqlite + +import hsutil.sqlite + +from _cache import string_to_colors + +def colors_to_string(colors): + """Transform the 3 sized tuples 'colors' into a hex string. + + [(0,100,255)] --> 0064ff + [(1,2,3),(4,5,6)] --> 010203040506 + """ + return ''.join(['%02x%02x%02x' % (r,g,b) for r,g,b in colors]) + +# This function is an important bottleneck of dupeGuru PE. It has been converted to Cython. +# def string_to_colors(s): +# """Transform the string 's' in a list of 3 sized tuples. +# """ +# result = [] +# for i in xrange(0, len(s), 6): +# number = int(s[i:i+6], 16) +# result.append((number >> 16, (number >> 8) & 0xff, number & 0xff)) +# return result + +class Cache(object): + """A class to cache picture blocks. + """ + def __init__(self, db=':memory:', threaded=True): + def create_tables(): + sql = "create table pictures(path TEXT, blocks TEXT)" + self.con.execute(sql); + sql = "create index idx_path on pictures (path)" + self.con.execute(sql) + + self.dbname = db + if threaded: + self.con = hsutil.sqlite.ThreadedConn(db, True) + else: + self.con = sqlite.connect(db, isolation_level=None) + try: + self.con.execute("select * from pictures where 1=2") + except sqlite.OperationalError: # new db + create_tables() + except sqlite.DatabaseError, e: # corrupted db + logging.warning('Could not create picture cache because of an error: %s', str(e)) + self.con.close() + os.remove(db) + if threaded: + self.con = hsutil.sqlite.ThreadedConn(db, True) + else: + self.con = sqlite.connect(db, isolation_level=None) + create_tables() + + def __contains__(self, key): + sql = "select count(*) from pictures where path = ?" + result = self.con.execute(sql, [key]).fetchall() + return result[0][0] > 0 + + def __delitem__(self, key): + if key not in self: + raise KeyError(key) + sql = "delete from pictures where path = ?" + self.con.execute(sql, [key]) + + # Optimized + def __getitem__(self, key): + if isinstance(key, int): + sql = "select blocks from pictures where rowid = ?" + else: + sql = "select blocks from pictures where path = ?" + result = self.con.execute(sql, [key]).fetchone() + if result: + result = string_to_colors(result[0]) + return result + else: + raise KeyError(key) + + def __iter__(self): + sql = "select path from pictures" + result = self.con.execute(sql) + return (row[0] for row in result) + + def __len__(self): + sql = "select count(*) from pictures" + result = self.con.execute(sql).fetchall() + return result[0][0] + + def __setitem__(self, key, value): + value = colors_to_string(value) + if key in self: + sql = "update pictures set blocks = ? where path = ?" + else: + sql = "insert into pictures(blocks,path) values(?,?)" + try: + self.con.execute(sql, [value, key]) + except sqlite.OperationalError: + logging.warning('Picture cache could not set %r for key %r', value, key) + except sqlite.DatabaseError, e: + logging.warning('DatabaseError while setting %r for key %r: %s', value, key, str(e)) + + def clear(self): + sql = "delete from pictures" + self.con.execute(sql) + + def filter(self, func): + to_delete = [key for key in self if not func(key)] + for key in to_delete: + del self[key] + + def get_id(self, path): + sql = "select rowid from pictures where path = ?" + result = self.con.execute(sql, [path]).fetchone() + if result: + return result[0] + else: + raise ValueError(path) + + def get_multiple(self, rowids): + sql = "select rowid, blocks from pictures where rowid in (%s)" % ','.join(map(str, rowids)) + cur = self.con.execute(sql) + return ((rowid, string_to_colors(blocks)) for rowid, blocks in cur) + diff --git a/py/picture/cache_test.py b/py/picture/cache_test.py new file mode 100644 index 00000000..f453112f --- /dev/null +++ b/py/picture/cache_test.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +""" +Unit Name: tests.picture.cache +Created By: Virgil Dupras +Created On: 2006/09/14 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +from StringIO import StringIO +import os.path as op +import os +import threading + +from hsutil.testcase import TestCase +from .cache import * + +class TCcolors_to_string(unittest.TestCase): + def test_no_color(self): + self.assertEqual('',colors_to_string([])) + + def test_single_color(self): + self.assertEqual('000000',colors_to_string([(0,0,0)])) + self.assertEqual('010101',colors_to_string([(1,1,1)])) + self.assertEqual('0a141e',colors_to_string([(10,20,30)])) + + def test_two_colors(self): + self.assertEqual('000102030405',colors_to_string([(0,1,2),(3,4,5)])) + + +class TCstring_to_colors(unittest.TestCase): + def test_empty(self): + self.assertEqual([],string_to_colors('')) + + def test_single_color(self): + self.assertEqual([(0,0,0)],string_to_colors('000000')) + self.assertEqual([(2,3,4)],string_to_colors('020304')) + self.assertEqual([(10,20,30)],string_to_colors('0a141e')) + + def test_two_colors(self): + self.assertEqual([(10,20,30),(40,50,60)],string_to_colors('0a141e28323c')) + + def test_incomplete_color(self): + # don't return anything if it's not a complete color + self.assertEqual([],string_to_colors('102')) + + +class TCCache(TestCase): + def test_empty(self): + c = Cache() + self.assertEqual(0,len(c)) + self.assertRaises(KeyError,c.__getitem__,'foo') + + def test_set_then_retrieve_blocks(self): + c = Cache() + b = [(0,0,0),(1,2,3)] + c['foo'] = b + self.assertEqual(b,c['foo']) + + def test_delitem(self): + c = Cache() + c['foo'] = '' + del c['foo'] + self.assert_('foo' not in c) + self.assertRaises(KeyError,c.__delitem__,'foo') + + def test_persistance(self): + DBNAME = op.join(self.tmpdir(), 'hstest.db') + c = Cache(DBNAME) + c['foo'] = [(1,2,3)] + del c + c = Cache(DBNAME) + self.assertEqual([(1,2,3)],c['foo']) + del c + os.remove(DBNAME) + + def test_filter(self): + c = Cache() + c['foo'] = '' + c['bar'] = '' + c['baz'] = '' + c.filter(lambda p:p != 'bar') #only 'bar' is removed + self.assertEqual(2,len(c)) + self.assert_('foo' in c) + self.assert_('baz' in c) + self.assert_('bar' not in c) + + def test_clear(self): + c = Cache() + c['foo'] = '' + c['bar'] = '' + c['baz'] = '' + c.clear() + self.assertEqual(0,len(c)) + self.assert_('foo' not in c) + self.assert_('baz' not in c) + self.assert_('bar' not in c) + + def test_corrupted_db(self): + dbname = op.join(self.tmpdir(), 'foo.db') + fp = open(dbname, 'w') + fp.write('invalid sqlite content') + fp.close() + c = Cache(dbname) # should not raise a DatabaseError + c['foo'] = [(1, 2, 3)] + del c + c = Cache(dbname) + self.assertEqual(c['foo'], [(1, 2, 3)]) + + def test_by_id(self): + # it's possible to use the cache by referring to the files by their row_id + c = Cache() + b = [(0,0,0),(1,2,3)] + c['foo'] = b + foo_id = c.get_id('foo') + self.assertEqual(c[foo_id], b) + + +class TCCacheSQLEscape(unittest.TestCase): + def test_contains(self): + c = Cache() + self.assert_("foo'bar" not in c) + + def test_getitem(self): + c = Cache() + self.assertRaises(KeyError, c.__getitem__, "foo'bar") + + def test_setitem(self): + c = Cache() + c["foo'bar"] = [] + + def test_delitem(self): + c = Cache() + c["foo'bar"] = [] + try: + del c["foo'bar"] + except KeyError: + self.fail() + + +class TCCacheThreaded(unittest.TestCase): + def test_access_cache(self): + def thread_run(): + try: + c['foo'] = [(1,2,3)] + except sqlite.ProgrammingError: + self.fail() + + c = Cache() + t = threading.Thread(target=thread_run) + t.start() + t.join() + self.assertEqual([(1,2,3)], c['foo']) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/py/picture/matchbase.py b/py/picture/matchbase.py new file mode 100644 index 00000000..cf0d1e89 --- /dev/null +++ b/py/picture/matchbase.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +""" +Unit Name: hs.picture._match +Created By: Virgil Dupras +Created On: 2007/02/25 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:02:48 +0200 (Thu, 28 May 2009) $ + $Revision: 4388 $ +Copyright 2007 Hardcoded Software (http://www.hardcoded.net) +""" +import logging +import multiprocessing +from Queue import Empty +from collections import defaultdict + +from hsutil import job +from hs.utils.misc import dedupe + +from dupeguru.engine import Match +from block import avgdiff, DifferentBlockCountError, NoBlocksError +from cache import Cache + +MIN_ITERATIONS = 3 + +def get_match(first,second,percentage): + if percentage < 0: + percentage = 0 + return Match(first,second,percentage) + +class MatchFactory(object): + cached_blocks = None + block_count_per_side = 15 + threshold = 75 + match_scaled = False + + def _do_getmatches(self, files, j): + raise NotImplementedError() + + def getmatches(self, files, j=job.nulljob): + # The MemoryError handlers in there use logging without first caring about whether or not + # there is enough memory left to carry on the operation because it is assumed that the + # MemoryError happens when trying to read an image file, which is freed from memory by the + # time that MemoryError is raised. + j = j.start_subjob([2, 8]) + logging.info('Preparing %d files' % len(files)) + prepared = self.prepare_files(files, j) + logging.info('Finished preparing %d files' % len(prepared)) + return self._do_getmatches(prepared, j) + + def prepare_files(self, files, j=job.nulljob): + prepared = [] # only files for which there was no error getting blocks + try: + for picture in j.iter_with_progress(files, 'Analyzed %d/%d pictures'): + picture.dimensions + picture.unicode_path = unicode(picture.path) + try: + if picture.unicode_path not in self.cached_blocks: + blocks = picture.get_blocks(self.block_count_per_side) + self.cached_blocks[picture.unicode_path] = blocks + prepared.append(picture) + except IOError as e: + logging.warning(unicode(e)) + except MemoryError: + logging.warning(u'Ran out of memory while reading %s of size %d' % (picture.unicode_path, picture.size)) + if picture.size < 10 * 1024 * 1024: # We're really running out of memory + raise + except MemoryError: + logging.warning('Ran out of memory while preparing files') + return prepared + + +def async_compare(ref_id, other_ids, dbname, threshold): + cache = Cache(dbname, threaded=False) + limit = 100 - threshold + ref_blocks = cache[ref_id] + pairs = cache.get_multiple(other_ids) + results = [] + for other_id, other_blocks in pairs: + try: + diff = avgdiff(ref_blocks, other_blocks, limit, MIN_ITERATIONS) + percentage = 100 - diff + except (DifferentBlockCountError, NoBlocksError): + percentage = 0 + if percentage >= threshold: + results.append((ref_id, other_id, percentage)) + cache.con.close() + return results + +class AsyncMatchFactory(MatchFactory): + def _do_getmatches(self, pictures, j): + def empty_out_queue(queue, into): + try: + while True: + into.append(queue.get(block=False)) + except Empty: + pass + + j = j.start_subjob([1, 8, 1], 'Preparing for matching') + cache = self.cached_blocks + id2picture = {} + dimensions2pictures = defaultdict(set) + for picture in pictures[:]: + try: + picture.cache_id = cache.get_id(picture.unicode_path) + id2picture[picture.cache_id] = picture + except ValueError: + pictures.remove(picture) + if not self.match_scaled: + dimensions2pictures[picture.dimensions].add(picture) + pool = multiprocessing.Pool() + async_results = [] + pictures_copy = set(pictures) + for ref in j.iter_with_progress(pictures): + others = pictures_copy if self.match_scaled else dimensions2pictures[ref.dimensions] + others.remove(ref) + if others: + cache_ids = [f.cache_id for f in others] + args = (ref.cache_id, cache_ids, self.cached_blocks.dbname, self.threshold) + async_results.append(pool.apply_async(async_compare, args)) + + matches = [] + for result in j.iter_with_progress(async_results, 'Matched %d/%d pictures'): + matches.extend(result.get()) + + result = [] + for ref_id, other_id, percentage in j.iter_with_progress(matches, 'Verified %d/%d matches', every=10): + ref = id2picture[ref_id] + other = id2picture[other_id] + if percentage == 100 and ref.md5 != other.md5: + percentage = 99 + if percentage >= self.threshold: + result.append(get_match(ref, other, percentage)) + return result + + +multiprocessing.freeze_support() \ No newline at end of file diff --git a/py/results.py b/py/results.py new file mode 100644 index 00000000..a7ded5c0 --- /dev/null +++ b/py/results.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.results +Created By: Virgil Dupras +Created On: 2006/02/23 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 16:33:32 +0200 (Thu, 28 May 2009) $ + $Revision: 4392 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import re +from xml.sax import handler, make_parser, SAXException +from xml.sax.saxutils import XMLGenerator +from xml.sax.xmlreader import AttributesImpl + +from . import engine +from hsutil.job import nulljob +from hsutil.markable import Markable +from hsutil.misc import flatten, cond, nonone +from hsutil.str import format_size +from hsutil.files import open_if_filename + +class Results(Markable): + #---Override + def __init__(self, data_module): + super(Results, self).__init__() + self.__groups = [] + self.__group_of_duplicate = {} + self.__groups_sort_descriptor = None # This is a tuple (key, asc) + self.__dupes = None + self.__dupes_sort_descriptor = None # This is a tuple (key, asc, delta) + self.__filters = None + self.__filtered_dupes = None + self.__filtered_groups = None + self.__recalculate_stats() + self.__marked_size = 0 + self.data = data_module + + def _did_mark(self, dupe): + self.__marked_size += dupe.size + + def _did_unmark(self, dupe): + self.__marked_size -= dupe.size + + def _get_markable_count(self): + return self.__total_count + + def _is_markable(self, dupe): + if dupe.is_ref: + return False + g = self.get_group_of_duplicate(dupe) + if not g: + return False + if dupe is g.ref: + return False + if self.__filtered_dupes and dupe not in self.__filtered_dupes: + return False + return True + + #---Private + def __get_dupe_list(self): + if self.__dupes is None: + self.__dupes = flatten(group.dupes for group in self.groups) + if self.__filtered_dupes: + self.__dupes = [dupe for dupe in self.__dupes if dupe in self.__filtered_dupes] + sd = self.__dupes_sort_descriptor + if sd: + self.sort_dupes(sd[0], sd[1], sd[2]) + return self.__dupes + + def __get_groups(self): + if self.__filtered_groups is None: + return self.__groups + else: + return self.__filtered_groups + + def __get_stat_line(self): + if self.__filtered_dupes is None: + mark_count = self.mark_count + marked_size = self.__marked_size + total_count = self.__total_count + total_size = self.__total_size + else: + mark_count = len([dupe for dupe in self.__filtered_dupes if self.is_marked(dupe)]) + marked_size = sum(dupe.size for dupe in self.__filtered_dupes if self.is_marked(dupe)) + total_count = len([dupe for dupe in self.__filtered_dupes if self.is_markable(dupe)]) + total_size = sum(dupe.size for dupe in self.__filtered_dupes if self.is_markable(dupe)) + if self.mark_inverted: + marked_size = self.__total_size - marked_size + result = '%d / %d (%s / %s) duplicates marked.' % ( + mark_count, + total_count, + format_size(marked_size, 2), + format_size(total_size, 2), + ) + if self.__filters: + result += ' filter: %s' % ' --> '.join(self.__filters) + return result + + def __recalculate_stats(self): + self.__total_size = 0 + self.__total_count = 0 + for group in self.groups: + markable = [dupe for dupe in group.dupes if self._is_markable(dupe)] + self.__total_count += len(markable) + self.__total_size += sum(dupe.size for dupe in markable) + + def __set_groups(self, new_groups): + self.mark_none() + self.__groups = new_groups + self.__group_of_duplicate = {} + for g in self.__groups: + for dupe in g: + self.__group_of_duplicate[dupe] = g + if not hasattr(dupe, 'is_ref'): + dupe.is_ref = False + old_filters = nonone(self.__filters, []) + self.apply_filter(None) + for filter_str in old_filters: + self.apply_filter(filter_str) + + #---Public + def apply_filter(self, filter_str): + ''' Applies a filter 'filter_str' to self.groups + + When you apply the filter, only dupes with the filename matching 'filter_str' will be in + in the results. To cancel the filter, just call apply_filter with 'filter_str' to None, + and the results will go back to normal. + + If call apply_filter on a filtered results, the filter will be applied + *on the filtered results*. + + 'filter_str' is a string containing a regexp to filter dupes with. + ''' + if not filter_str: + self.__filtered_dupes = None + self.__filtered_groups = None + self.__filters = None + else: + if not self.__filters: + self.__filters = [] + self.__filters.append(filter_str) + filter_re = re.compile(filter_str, re.IGNORECASE) + if self.__filtered_dupes is None: + self.__filtered_dupes = flatten(g[:] for g in self.groups) + self.__filtered_dupes = set(dupe for dupe in self.__filtered_dupes if filter_re.search(dupe.name)) + filtered_groups = set() + for dupe in self.__filtered_dupes: + filtered_groups.add(self.get_group_of_duplicate(dupe)) + self.__filtered_groups = list(filtered_groups) + self.__recalculate_stats() + sd = self.__groups_sort_descriptor + if sd: + self.sort_groups(sd[0], sd[1]) + self.__dupes = None + + def get_group_of_duplicate(self, dupe): + try: + return self.__group_of_duplicate[dupe] + except (TypeError, KeyError): + return None + + is_markable = _is_markable + + def load_from_xml(self, infile, get_file, j=nulljob): + self.apply_filter(None) + handler = _ResultsHandler(get_file) + parser = make_parser() + parser.setContentHandler(handler) + try: + infile, must_close = open_if_filename(infile) + except IOError: + return + BUFSIZE = 1024 * 1024 # 1mb buffer + infile.seek(0, 2) + j.start_job(infile.tell() // BUFSIZE) + infile.seek(0, 0) + try: + while True: + data = infile.read(BUFSIZE) + if not data: + break + parser.feed(data) + j.add_progress() + except SAXException: + return + self.groups = handler.groups + for dupe_file in handler.marked: + self.mark(dupe_file) + + def make_ref(self, dupe): + g = self.get_group_of_duplicate(dupe) + r = g.ref + self._remove_mark_flag(dupe) + g.switch_ref(dupe); + if not r.is_ref: + self.__total_count += 1 + self.__total_size += r.size + if not dupe.is_ref: + self.__total_count -= 1 + self.__total_size -= dupe.size + self.__dupes = None + + def perform_on_marked(self, func, remove_from_results): + problems = [] + for d in self.dupes: + if self.is_marked(d) and (not func(d)): + problems.append(d) + if remove_from_results: + to_remove = [d for d in self.dupes if self.is_marked(d) and (d not in problems)] + self.remove_duplicates(to_remove) + self.mark_none() + for d in problems: + self.mark(d) + return len(problems) + + def remove_duplicates(self, dupes): + '''Remove 'dupes' from their respective group, and remove the group is it ends up empty. + ''' + affected_groups = set() + for dupe in dupes: + group = self.get_group_of_duplicate(dupe) + if dupe not in group.dupes: + return + group.remove_dupe(dupe, False) + self._remove_mark_flag(dupe) + self.__total_count -= 1 + self.__total_size -= dupe.size + if not group: + self.__groups.remove(group) + if self.__filtered_groups: + self.__filtered_groups.remove(group) + else: + affected_groups.add(group) + for group in affected_groups: + group.clean_matches() + self.__dupes = None + + def save_to_xml(self, outfile, with_data=False): + self.apply_filter(None) + outfile, must_close = open_if_filename(outfile, 'wb') + writer = XMLGenerator(outfile, 'utf-8') + writer.startDocument() + empty_attrs = AttributesImpl({}) + writer.startElement('results', empty_attrs) + for g in self.groups: + writer.startElement('group', empty_attrs) + dupe2index = {} + for index, d in enumerate(g): + dupe2index[d] = index + try: + words = engine.unpack_fields(d.words) + except AttributeError: + words = () + attrs = AttributesImpl({ + 'path': unicode(d.path), + 'is_ref': cond(d.is_ref, 'y', 'n'), + 'words': ','.join(words), + 'marked': cond(self.is_marked(d), 'y', 'n') + }) + writer.startElement('file', attrs) + if with_data: + data_list = self.data.GetDisplayInfo(d, g) + for data in data_list: + attrs = AttributesImpl({ + 'value': data, + }) + writer.startElement('data', attrs) + writer.endElement('data') + writer.endElement('file') + for match in g.matches: + attrs = AttributesImpl({ + 'first': str(dupe2index[match.first]), + 'second': str(dupe2index[match.second]), + 'percentage': str(int(match.percentage)), + }) + writer.startElement('match', attrs) + writer.endElement('match') + writer.endElement('group') + writer.endElement('results') + writer.endDocument() + if must_close: + outfile.close() + + def sort_dupes(self, key, asc=True, delta=False): + if not self.__dupes: + self.__get_dupe_list() + self.__dupes.sort(key=lambda d: self.data.GetDupeSortKey(d, lambda: self.get_group_of_duplicate(d), key, delta)) + if not asc: + self.__dupes.reverse() + self.__dupes_sort_descriptor = (key,asc,delta) + + def sort_groups(self,key,asc=True): + self.groups.sort(key=lambda g: self.data.GetGroupSortKey(g, key)) + if not asc: + self.groups.reverse() + self.__groups_sort_descriptor = (key,asc) + + #---Properties + dupes = property(__get_dupe_list) + groups = property(__get_groups, __set_groups) + stat_line = property(__get_stat_line) + +class _ResultsHandler(handler.ContentHandler): + def __init__(self, get_file): + self.group = None + self.dupes = None + self.marked = set() + self.groups = [] + self.get_file = get_file + + def startElement(self, name, attrs): + if name == 'group': + self.group = engine.Group() + self.dupes = [] + return + if (name == 'file') and (self.group is not None): + if not (('path' in attrs) and ('words' in attrs)): + return + path = attrs['path'] + file = self.get_file(path) + if file is None: + return + file.words = attrs['words'].split(',') + file.is_ref = attrs.get('is_ref') == 'y' + self.dupes.append(file) + if attrs.get('marked') == 'y': + self.marked.add(file) + if (name == 'match') and (self.group is not None): + try: + first_file = self.dupes[int(attrs['first'])] + second_file = self.dupes[int(attrs['second'])] + percentage = int(attrs['percentage']) + self.group.add_match(engine.Match(first_file, second_file, percentage)) + except (IndexError, KeyError, ValueError): # Covers missing attr, non-int values and indexes out of bounds + pass + + def endElement(self, name): + def do_match(ref_file, other_files, group): + if not other_files: + return + for other_file in other_files: + group.add_match(engine.get_match(ref_file, other_file)) + do_match(other_files[0], other_files[1:], group) + + if name == 'group': + group = self.group + self.group = None + dupes = self.dupes + self.dupes = [] + if group is None: + return + if len(dupes) < 2: + return + if not group.matches: # elements not present, do it manually, without % + do_match(dupes[0], dupes[1:], group) + group.prioritize(lambda x: dupes.index(x)) + self.groups.append(group) + diff --git a/py/results_test.py b/py/results_test.py new file mode 100644 index 00000000..1e74efc6 --- /dev/null +++ b/py/results_test.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.results +Created By: Virgil Dupras +Created On: 2006/02/23 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest +import StringIO +import xml.dom.minidom +import os.path as op + +from hsutil.path import Path +from hsutil.testcase import TestCase +from hsutil.misc import first + +from . import engine_test +from . import data +from . import engine +from .results import * + +class NamedObject(engine_test.NamedObject): + size = 1 + path = property(lambda x:Path('basepath') + x.name) + is_ref = False + + def __nonzero__(self): + return False #Make sure that operations are made correctly when the bool value of files is false. + +# Returns a group set that looks like that: +# "foo bar" (1) +# "bar bleh" (1024) +# "foo bleh" (1) +# "ibabtu" (1) +# "ibabtu" (1) +def GetTestGroups(): + objects = [NamedObject("foo bar"),NamedObject("bar bleh"),NamedObject("foo bleh"),NamedObject("ibabtu"),NamedObject("ibabtu")] + objects[1].size = 1024 + matches = engine.MatchFactory().getmatches(objects) #we should have 5 matches + groups = engine.get_groups(matches) #We should have 2 groups + for g in groups: + g.prioritize(lambda x:objects.index(x)) #We want the dupes to be in the same order as the list is + groups.sort(key=len, reverse=True) # We want the group with 3 members to be first. + return (objects,matches,groups) + +class TCResultsEmpty(TestCase): + def setUp(self): + self.results = Results(data) + + def test_stat_line(self): + self.assertEqual("0 / 0 (0.00 B / 0.00 B) duplicates marked.",self.results.stat_line) + + def test_groups(self): + self.assertEqual(0,len(self.results.groups)) + + def test_get_group_of_duplicate(self): + self.assert_(self.results.get_group_of_duplicate('foo') is None) + + def test_save_to_xml(self): + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + self.assertEqual('results',root.nodeName) + + +class TCResultsWithSomeGroups(TestCase): + def setUp(self): + self.results = Results(data) + self.objects,self.matches,self.groups = GetTestGroups() + self.results.groups = self.groups + + def test_stat_line(self): + self.assertEqual("0 / 3 (0.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + + def test_groups(self): + self.assertEqual(2,len(self.results.groups)) + + def test_get_group_of_duplicate(self): + for o in self.objects: + g = self.results.get_group_of_duplicate(o) + self.assert_(isinstance(g, engine.Group)) + self.assert_(o in g) + self.assert_(self.results.get_group_of_duplicate(self.groups[0]) is None) + + def test_remove_duplicates(self): + g1,g2 = self.results.groups + self.results.remove_duplicates([g1.dupes[0]]) + self.assertEqual(2,len(g1)) + self.assert_(g1 in self.results.groups) + self.results.remove_duplicates([g1.ref]) + self.assertEqual(2,len(g1)) + self.assert_(g1 in self.results.groups) + self.results.remove_duplicates([g1.dupes[0]]) + self.assertEqual(0,len(g1)) + self.assert_(g1 not in self.results.groups) + self.results.remove_duplicates([g2.dupes[0]]) + self.assertEqual(0,len(g2)) + self.assert_(g2 not in self.results.groups) + self.assertEqual(0,len(self.results.groups)) + + def test_remove_duplicates_with_ref_files(self): + g1,g2 = self.results.groups + self.objects[0].is_ref = True + self.objects[1].is_ref = True + self.results.remove_duplicates([self.objects[2]]) + self.assertEqual(0,len(g1)) + self.assert_(g1 not in self.results.groups) + + def test_make_ref(self): + g = self.results.groups[0] + d = g.dupes[0] + self.results.make_ref(d) + self.assert_(d is g.ref) + + def test_sort_groups(self): + self.results.make_ref(self.objects[1]) #We want to make the 1024 sized object to go ref. + g1,g2 = self.groups + self.results.sort_groups(2) #2 is the key for size + self.assert_(self.results.groups[0] is g2) + self.assert_(self.results.groups[1] is g1) + self.results.sort_groups(2,False) + self.assert_(self.results.groups[0] is g1) + self.assert_(self.results.groups[1] is g2) + + def test_set_groups_when_sorted(self): + self.results.make_ref(self.objects[1]) #We want to make the 1024 sized object to go ref. + self.results.sort_groups(2) + objects,matches,groups = GetTestGroups() + g1,g2 = groups + g1.switch_ref(objects[1]) + self.results.groups = groups + self.assert_(self.results.groups[0] is g2) + self.assert_(self.results.groups[1] is g1) + + def test_get_dupe_list(self): + self.assertEqual([self.objects[1],self.objects[2],self.objects[4]],self.results.dupes) + + def test_dupe_list_is_cached(self): + self.assert_(self.results.dupes is self.results.dupes) + + def test_dupe_list_cache_is_invalidated_when_needed(self): + o1,o2,o3,o4,o5 = self.objects + self.assertEqual([o2,o3,o5],self.results.dupes) + self.results.make_ref(o2) + self.assertEqual([o1,o3,o5],self.results.dupes) + objects,matches,groups = GetTestGroups() + o1,o2,o3,o4,o5 = objects + self.results.groups = groups + self.assertEqual([o2,o3,o5],self.results.dupes) + + def test_dupe_list_sort(self): + o1,o2,o3,o4,o5 = self.objects + o1.size = 5 + o2.size = 4 + o3.size = 3 + o4.size = 2 + o5.size = 1 + self.results.sort_dupes(2) + self.assertEqual([o5,o3,o2],self.results.dupes) + self.results.sort_dupes(2,False) + self.assertEqual([o2,o3,o5],self.results.dupes) + + def test_dupe_list_remember_sort(self): + o1,o2,o3,o4,o5 = self.objects + o1.size = 5 + o2.size = 4 + o3.size = 3 + o4.size = 2 + o5.size = 1 + self.results.sort_dupes(2) + self.results.make_ref(o2) + self.assertEqual([o5,o3,o1],self.results.dupes) + + def test_dupe_list_sort_delta_values(self): + o1,o2,o3,o4,o5 = self.objects + o1.size = 10 + o2.size = 2 #-8 + o3.size = 3 #-7 + o4.size = 20 + o5.size = 1 #-19 + self.results.sort_dupes(2,delta=True) + self.assertEqual([o5,o2,o3],self.results.dupes) + + def test_sort_empty_list(self): + #There was an infinite loop when sorting an empty list. + r = Results(data) + r.sort_dupes(0) + self.assertEqual([],r.dupes) + + def test_dupe_list_update_on_remove_duplicates(self): + o1,o2,o3,o4,o5 = self.objects + self.assertEqual(3,len(self.results.dupes)) + self.results.remove_duplicates([o2]) + self.assertEqual(2,len(self.results.dupes)) + + +class TCResultsMarkings(TestCase): + def setUp(self): + self.results = Results(data) + self.objects,self.matches,self.groups = GetTestGroups() + self.results.groups = self.groups + + def test_stat_line(self): + self.assertEqual("0 / 3 (0.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.mark(self.objects[1]) + self.assertEqual("1 / 3 (1.00 KB / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.mark_invert() + self.assertEqual("2 / 3 (2.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.mark_invert() + self.results.unmark(self.objects[1]) + self.results.mark(self.objects[2]) + self.results.mark(self.objects[4]) + self.assertEqual("2 / 3 (2.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.mark(self.objects[0]) #this is a ref, it can't be counted + self.assertEqual("2 / 3 (2.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.groups = self.groups + self.assertEqual("0 / 3 (0.00 B / 1.01 KB) duplicates marked.",self.results.stat_line) + + def test_with_ref_duplicate(self): + self.objects[1].is_ref = True + self.results.groups = self.groups + self.assert_(not self.results.mark(self.objects[1])) + self.results.mark(self.objects[2]) + self.assertEqual("1 / 2 (1.00 B / 2.00 B) duplicates marked.",self.results.stat_line) + + def test_perform_on_marked(self): + def log_object(o): + log.append(o) + return True + + log = [] + self.results.mark_all() + self.results.perform_on_marked(log_object,False) + self.assert_(self.objects[1] in log) + self.assert_(self.objects[2] in log) + self.assert_(self.objects[4] in log) + self.assertEqual(3,len(log)) + log = [] + self.results.mark_none() + self.results.mark(self.objects[4]) + self.results.perform_on_marked(log_object,True) + self.assertEqual(1,len(log)) + self.assert_(self.objects[4] in log) + self.assertEqual(1,len(self.results.groups)) + + def test_perform_on_marked_with_problems(self): + def log_object(o): + log.append(o) + return o is not self.objects[1] + + log = [] + self.results.mark_all() + self.assert_(self.results.is_marked(self.objects[1])) + self.assertEqual(1,self.results.perform_on_marked(log_object, True)) + self.assertEqual(3,len(log)) + self.assertEqual(1,len(self.results.groups)) + self.assertEqual(2,len(self.results.groups[0])) + self.assert_(self.objects[1] in self.results.groups[0]) + self.assert_(not self.results.is_marked(self.objects[2])) + self.assert_(self.results.is_marked(self.objects[1])) + + def test_perform_on_marked_with_ref(self): + def log_object(o): + log.append(o) + return True + + log = [] + self.objects[0].is_ref = True + self.objects[1].is_ref = True + self.results.mark_all() + self.results.perform_on_marked(log_object,True) + self.assert_(self.objects[1] not in log) + self.assert_(self.objects[2] in log) + self.assert_(self.objects[4] in log) + self.assertEqual(2,len(log)) + self.assertEqual(0,len(self.results.groups)) + + def test_perform_on_marked_remove_objects_only_at_the_end(self): + def check_groups(o): + self.assertEqual(3,len(g1)) + self.assertEqual(2,len(g2)) + return True + + g1,g2 = self.results.groups + self.results.mark_all() + self.results.perform_on_marked(check_groups,True) + self.assertEqual(0,len(g1)) + self.assertEqual(0,len(g2)) + self.assertEqual(0,len(self.results.groups)) + + def test_remove_duplicates(self): + g1 = self.results.groups[0] + g2 = self.results.groups[1] + self.results.mark(g1.dupes[0]) + self.assertEqual("1 / 3 (1.00 KB / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.remove_duplicates([g1.dupes[1]]) + self.assertEqual("1 / 2 (1.00 KB / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.remove_duplicates([g1.dupes[0]]) + self.assertEqual("0 / 1 (0.00 B / 1.00 B) duplicates marked.",self.results.stat_line) + + def test_make_ref(self): + g = self.results.groups[0] + d = g.dupes[0] + self.results.mark(d) + self.assertEqual("1 / 3 (1.00 KB / 1.01 KB) duplicates marked.",self.results.stat_line) + self.results.make_ref(d) + self.assertEqual("0 / 3 (0.00 B / 3.00 B) duplicates marked.",self.results.stat_line) + self.results.make_ref(d) + self.assertEqual("0 / 3 (0.00 B / 3.00 B) duplicates marked.",self.results.stat_line) + + def test_SaveXML(self): + self.results.mark(self.objects[1]) + self.results.mark_invert() + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + g1,g2 = root.getElementsByTagName('group') + d1,d2,d3 = g1.getElementsByTagName('file') + self.assertEqual('n',d1.getAttributeNode('marked').nodeValue) + self.assertEqual('n',d2.getAttributeNode('marked').nodeValue) + self.assertEqual('y',d3.getAttributeNode('marked').nodeValue) + d1,d2 = g2.getElementsByTagName('file') + self.assertEqual('n',d1.getAttributeNode('marked').nodeValue) + self.assertEqual('y',d2.getAttributeNode('marked').nodeValue) + + def test_LoadXML(self): + def get_file(path): + return [f for f in self.objects if str(f.path) == path][0] + + self.objects[4].name = 'ibabtu 2' #we can't have 2 files with the same path + self.results.mark(self.objects[1]) + self.results.mark_invert() + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + self.assert_(not r.is_marked(self.objects[0])) + self.assert_(not r.is_marked(self.objects[1])) + self.assert_(r.is_marked(self.objects[2])) + self.assert_(not r.is_marked(self.objects[3])) + self.assert_(r.is_marked(self.objects[4])) + + +class TCResultsXML(TestCase): + def setUp(self): + self.results = Results(data) + self.objects, self.matches, self.groups = GetTestGroups() + self.results.groups = self.groups + + def get_file(self, path): # use this as a callback for load_from_xml + return [o for o in self.objects if o.path == path][0] + + def test_save_to_xml(self): + self.objects[0].is_ref = True + self.objects[0].words = [['foo','bar']] + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + self.assertEqual('results',root.nodeName) + children = [c for c in root.childNodes if c.localName] + self.assertEqual(2,len(children)) + self.assertEqual(2,len([c for c in children if c.nodeName == 'group'])) + g1,g2 = children + children = [c for c in g1.childNodes if c.localName] + self.assertEqual(6,len(children)) + self.assertEqual(3,len([c for c in children if c.nodeName == 'file'])) + self.assertEqual(3,len([c for c in children if c.nodeName == 'match'])) + d1,d2,d3 = [c for c in children if c.nodeName == 'file'] + self.assertEqual(op.join('basepath','foo bar'),d1.getAttributeNode('path').nodeValue) + self.assertEqual(op.join('basepath','bar bleh'),d2.getAttributeNode('path').nodeValue) + self.assertEqual(op.join('basepath','foo bleh'),d3.getAttributeNode('path').nodeValue) + self.assertEqual('y',d1.getAttributeNode('is_ref').nodeValue) + self.assertEqual('n',d2.getAttributeNode('is_ref').nodeValue) + self.assertEqual('n',d3.getAttributeNode('is_ref').nodeValue) + self.assertEqual('foo,bar',d1.getAttributeNode('words').nodeValue) + self.assertEqual('bar,bleh',d2.getAttributeNode('words').nodeValue) + self.assertEqual('foo,bleh',d3.getAttributeNode('words').nodeValue) + children = [c for c in g2.childNodes if c.localName] + self.assertEqual(3,len(children)) + self.assertEqual(2,len([c for c in children if c.nodeName == 'file'])) + self.assertEqual(1,len([c for c in children if c.nodeName == 'match'])) + d1,d2 = [c for c in children if c.nodeName == 'file'] + self.assertEqual(op.join('basepath','ibabtu'),d1.getAttributeNode('path').nodeValue) + self.assertEqual(op.join('basepath','ibabtu'),d2.getAttributeNode('path').nodeValue) + self.assertEqual('n',d1.getAttributeNode('is_ref').nodeValue) + self.assertEqual('n',d2.getAttributeNode('is_ref').nodeValue) + self.assertEqual('ibabtu',d1.getAttributeNode('words').nodeValue) + self.assertEqual('ibabtu',d2.getAttributeNode('words').nodeValue) + + def test_save_to_xml_with_columns(self): + class FakeDataModule: + def GetDisplayInfo(self,dupe,group): + return [str(dupe.size),dupe.foo.upper()] + + for i,object in enumerate(self.objects): + object.size = i + object.foo = u'bar\u00e9' + f = StringIO.StringIO() + self.results.data = FakeDataModule() + self.results.save_to_xml(f,True) + f.seek(0) + doc = xml.dom.minidom.parse(f) + root = doc.documentElement + g1,g2 = root.getElementsByTagName('group') + d1,d2,d3 = g1.getElementsByTagName('file') + d4,d5 = g2.getElementsByTagName('file') + self.assertEqual('0',d1.getElementsByTagName('data')[0].getAttribute('value')) + self.assertEqual(u'BAR\u00c9',d1.getElementsByTagName('data')[1].getAttribute('value')) #\u00c9 is upper of \u00e9 + self.assertEqual('1',d2.getElementsByTagName('data')[0].getAttribute('value')) + self.assertEqual('2',d3.getElementsByTagName('data')[0].getAttribute('value')) + self.assertEqual('3',d4.getElementsByTagName('data')[0].getAttribute('value')) + self.assertEqual('4',d5.getElementsByTagName('data')[0].getAttribute('value')) + + def test_LoadXML(self): + def get_file(path): + return [f for f in self.objects if str(f.path) == path][0] + + self.objects[0].is_ref = True + self.objects[4].name = 'ibabtu 2' #we can't have 2 files with the same path + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + self.assertEqual(2,len(r.groups)) + g1,g2 = r.groups + self.assertEqual(3,len(g1)) + self.assert_(g1[0].is_ref) + self.assert_(not g1[1].is_ref) + self.assert_(not g1[2].is_ref) + self.assert_(g1[0] is self.objects[0]) + self.assert_(g1[1] is self.objects[1]) + self.assert_(g1[2] is self.objects[2]) + self.assertEqual(['foo','bar'],g1[0].words) + self.assertEqual(['bar','bleh'],g1[1].words) + self.assertEqual(['foo','bleh'],g1[2].words) + self.assertEqual(2,len(g2)) + self.assert_(not g2[0].is_ref) + self.assert_(not g2[1].is_ref) + self.assert_(g2[0] is self.objects[3]) + self.assert_(g2[1] is self.objects[4]) + self.assertEqual(['ibabtu'],g2[0].words) + self.assertEqual(['ibabtu'],g2[1].words) + + def test_LoadXML_with_filename(self): + def get_file(path): + return [f for f in self.objects if str(f.path) == path][0] + + filename = op.join(self.tmpdir(), 'dupeguru_results.xml') + self.objects[4].name = 'ibabtu 2' #we can't have 2 files with the same path + self.results.save_to_xml(filename) + r = Results(data) + r.load_from_xml(filename,get_file) + self.assertEqual(2,len(r.groups)) + + def test_LoadXML_with_some_files_that_dont_exist_anymore(self): + def get_file(path): + if path.endswith('ibabtu 2'): + return None + return [f for f in self.objects if str(f.path) == path][0] + + self.objects[4].name = 'ibabtu 2' #we can't have 2 files with the same path + f = StringIO.StringIO() + self.results.save_to_xml(f) + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + self.assertEqual(1,len(r.groups)) + self.assertEqual(3,len(r.groups[0])) + + def test_LoadXML_missing_attributes_and_bogus_elements(self): + def get_file(path): + return [f for f in self.objects if str(f.path) == path][0] + + doc = xml.dom.minidom.Document() + root = doc.appendChild(doc.createElement('foobar')) #The root element shouldn't matter, really. + group_node = root.appendChild(doc.createElement('group')) + dupe_node = group_node.appendChild(doc.createElement('file')) #Perfectly correct file + dupe_node.setAttribute('path',op.join('basepath','foo bar')) + dupe_node.setAttribute('is_ref','y') + dupe_node.setAttribute('words','foo,bar') + dupe_node = group_node.appendChild(doc.createElement('file')) #is_ref missing, default to 'n' + dupe_node.setAttribute('path',op.join('basepath','foo bleh')) + dupe_node.setAttribute('words','foo,bleh') + dupe_node = group_node.appendChild(doc.createElement('file')) #words are missing, invalid. + dupe_node.setAttribute('path',op.join('basepath','bar bleh')) + dupe_node = group_node.appendChild(doc.createElement('file')) #path is missing, invalid. + dupe_node.setAttribute('words','foo,bleh') + dupe_node = group_node.appendChild(doc.createElement('foobar')) #Invalid element name + dupe_node.setAttribute('path',op.join('basepath','bar bleh')) + dupe_node.setAttribute('is_ref','y') + dupe_node.setAttribute('words','bar,bleh') + match_node = group_node.appendChild(doc.createElement('match')) # match pointing to a bad index + match_node.setAttribute('first', '42') + match_node.setAttribute('second', '45') + match_node = group_node.appendChild(doc.createElement('match')) # match with missing attrs + match_node = group_node.appendChild(doc.createElement('match')) # match with non-int values + match_node.setAttribute('first', 'foo') + match_node.setAttribute('second', 'bar') + match_node.setAttribute('percentage', 'baz') + group_node = root.appendChild(doc.createElement('foobar')) #invalid group + group_node = root.appendChild(doc.createElement('group')) #empty group + f = StringIO.StringIO() + doc.writexml(f,'\t','\t','\n',encoding='utf-8') + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + self.assertEqual(1,len(r.groups)) + self.assertEqual(2,len(r.groups[0])) + + def test_xml_non_ascii(self): + def get_file(path): + if path == op.join('basepath',u'\xe9foo bar'): + return objects[0] + if path == op.join('basepath',u'bar bleh'): + return objects[1] + + objects = [NamedObject(u"\xe9foo bar",True),NamedObject("bar bleh",True)] + matches = engine.MatchFactory().getmatches(objects) #we should have 5 matches + groups = engine.get_groups(matches) #We should have 2 groups + for g in groups: + g.prioritize(lambda x:objects.index(x)) #We want the dupes to be in the same order as the list is + results = Results(data) + results.groups = groups + f = StringIO.StringIO() + results.save_to_xml(f) + f.seek(0) + r = Results(data) + r.load_from_xml(f,get_file) + g = r.groups[0] + self.assertEqual(u"\xe9foo bar",g[0].name) + self.assertEqual(['efoo','bar'],g[0].words) + + def test_load_invalid_xml(self): + f = StringIO.StringIO() + f.write(' len(ref.path) + + def GetDupeGroups(self, files, j=job.nulljob): + j = j.start_subjob([8, 2]) + for f in [f for f in files if not hasattr(f, 'is_ref')]: + f.is_ref = False + if self.size_threshold: + files = [f for f in files if f.size >= self.size_threshold] + logging.info('Getting matches') + if self.match_factory is None: + matches = self._getmatches(files, j) + else: + matches = self.match_factory.getmatches(files, j) + logging.info('Found %d matches' % len(matches)) + if not self.mix_file_kind: + j.set_progress(100, 'Removing false matches') + matches = [m for m in matches if get_file_ext(m.first.name) == get_file_ext(m.second.name)] + if self.ignore_list: + j = j.start_subjob(2) + iter_matches = j.iter_with_progress(matches, 'Processed %d/%d matches against the ignore list') + matches = [m for m in iter_matches + if not self.ignore_list.AreIgnored(unicode(m.first.path), unicode(m.second.path))] + matched_files = dedupe([m.first for m in matches] + [m.second for m in matches]) + if self.scan_type in (SCAN_TYPE_CONTENT, SCAN_TYPE_CONTENT_AUDIO): + md5attrname = 'md5partial' if self.scan_type == SCAN_TYPE_CONTENT_AUDIO else 'md5' + md5 = lambda f: getattr(f, md5attrname) + j = j.start_subjob(2) + for matched_file in j.iter_with_progress(matched_files, 'Analyzed %d/%d matching files'): + md5(matched_file) + j.set_progress(100, 'Removing false matches') + matches = [m for m in matches if md5(m.first) == md5(m.second)] + words_for_content = ['--'] # We compared md5. No words were involved. + for m in matches: + m.first.words = words_for_content + m.second.words = words_for_content + logging.info('Grouping matches') + groups = engine.get_groups(matches, j) + groups = [g for g in groups if any(not f.is_ref for f in g)] + logging.info('Created %d groups' % len(groups)) + j.set_progress(100, 'Doing group prioritization') + for g in groups: + g.prioritize(self._key_func, self._tie_breaker) + matched_files = dedupe([m.first for m in matches] + [m.second for m in matches]) + self.discarded_file_count = len(matched_files) - sum(len(g) for g in groups) + return groups + + match_factory = None + match_similar_words = False + min_match_percentage = 80 + mix_file_kind = True + scan_type = SCAN_TYPE_FILENAME + scanned_tags = set(['artist', 'title']) + size_threshold = 0 + word_weighting = False + +class ScannerME(Scanner): # Scanner for Music Edition + @staticmethod + def _key_func(dupe): + return (not dupe.is_ref, -dupe.bitrate, -dupe.size) + diff --git a/py/scanner_test.py b/py/scanner_test.py new file mode 100644 index 00000000..89ad1417 --- /dev/null +++ b/py/scanner_test.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python +""" +Unit Name: dupeguru.tests.scanner +Created By: Virgil Dupras +Created On: 2006/03/03 +Last modified by:$Author: virgil $ +Last modified on:$Date: 2009-05-28 15:22:39 +0200 (Thu, 28 May 2009) $ + $Revision: 4385 $ +Copyright 2004-2006 Hardcoded Software (http://www.hardcoded.net) +""" +import unittest + +from hsutil import job +from hsutil.path import Path +from hsutil.testcase import TestCase + +from .engine import getwords, Match +from .ignore import IgnoreList +from .scanner import * + +class NamedObject(object): + def __init__(self, name="foobar", size=1): + self.name = name + self.size = size + self.path = Path('') + self.words = getwords(name) + + +no = NamedObject + +class TCScanner(TestCase): + def test_empty(self): + s = Scanner() + r = s.GetDupeGroups([]) + self.assertEqual([],r) + + def test_default_settings(self): + s = Scanner() + self.assertEqual(80,s.min_match_percentage) + self.assertEqual(SCAN_TYPE_FILENAME,s.scan_type) + self.assertEqual(True,s.mix_file_kind) + self.assertEqual(False,s.word_weighting) + self.assertEqual(False,s.match_similar_words) + self.assert_(isinstance(s.ignore_list,IgnoreList)) + + def test_simple_with_default_settings(self): + s = Scanner() + f = [no('foo bar'),no('foo bar'),no('foo bleh')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + g = r[0] + #'foo bleh' cannot be in the group because the default min match % is 80 + self.assertEqual(2,len(g)) + self.assert_(g.ref in f[:2]) + self.assert_(g.dupes[0] in f[:2]) + + def test_simple_with_lower_min_match(self): + s = Scanner() + s.min_match_percentage = 50 + f = [no('foo bar'),no('foo bar'),no('foo bleh')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(3,len(g)) + + def test_trim_all_ref_groups(self): + s = Scanner() + f = [no('foo'),no('foo'),no('bar'),no('bar')] + f[2].is_ref = True + f[3].is_ref = True + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + + def test_priorize(self): + s = Scanner() + f = [no('foo'),no('foo'),no('bar'),no('bar')] + f[1].size = 2 + f[2].size = 3 + f[3].is_ref = True + r = s.GetDupeGroups(f) + g1,g2 = r + self.assert_(f[1] in (g1.ref,g2.ref)) + self.assert_(f[0] in (g1.dupes[0],g2.dupes[0])) + self.assert_(f[3] in (g1.ref,g2.ref)) + self.assert_(f[2] in (g1.dupes[0],g2.dupes[0])) + + def test_content_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT + f = [no('foo'), no('bar'), no('bleh')] + f[0].md5 = 'foobar' + f[1].md5 = 'foobar' + f[2].md5 = 'bleh' + r = s.GetDupeGroups(f) + self.assertEqual(len(r), 1) + self.assertEqual(len(r[0]), 2) + self.assertEqual(s.discarded_file_count, 0) # don't count the different md5 as discarded! + + def test_content_scan_compare_sizes_first(self): + class MyFile(no): + def get_md5(file): + self.fail() + md5 = property(get_md5) + + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT + f = [MyFile('foo',1),MyFile('bar',2)] + self.assertEqual(0,len(s.GetDupeGroups(f))) + + def test_min_match_perc_doesnt_matter_for_content_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT + f = [no('foo'),no('bar'),no('bleh')] + f[0].md5 = 'foobar' + f[1].md5 = 'foobar' + f[2].md5 = 'bleh' + s.min_match_percentage = 101 + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + self.assertEqual(2,len(r[0])) + s.min_match_percentage = 0 + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + self.assertEqual(2,len(r[0])) + + def test_content_scan_puts_md5_in_words_at_the_end(self): + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT + f = [no('foo'),no('bar')] + f[0].md5 = '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f' + f[1].md5 = '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f' + r = s.GetDupeGroups(f) + g = r[0] + self.assertEqual(['--'],g.ref.words) + self.assertEqual(['--'],g.dupes[0].words) + + def test_extension_is_not_counted_in_filename_scan(self): + s = Scanner() + s.min_match_percentage = 100 + f = [no('foo.bar'),no('foo.bleh')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + self.assertEqual(2,len(r[0])) + + def test_job(self): + def do_progress(progress,desc=''): + log.append(progress) + return True + s = Scanner() + log = [] + f = [no('foo bar'),no('foo bar'),no('foo bleh')] + r = s.GetDupeGroups(f, job.Job(1,do_progress)) + self.assertEqual(0,log[0]) + self.assertEqual(100,log[-1]) + + def test_mix_file_kind(self): + s = Scanner() + s.mix_file_kind = False + f = [no('foo.1'),no('foo.2')] + r = s.GetDupeGroups(f) + self.assertEqual(0,len(r)) + + def test_word_weighting(self): + s = Scanner() + s.min_match_percentage = 75 + s.word_weighting = True + f = [no('foo bar'),no('foo bar bleh')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + g = r[0] + m = g.get_match_of(g.dupes[0]) + self.assertEqual(75,m.percentage) # 16 letters, 12 matching + + def test_similar_words(self): + s = Scanner() + s.match_similar_words = True + f = [no('The White Stripes'),no('The Whites Stripe'),no('Limp Bizkit'),no('Limp Bizkitt')] + r = s.GetDupeGroups(f) + self.assertEqual(2,len(r)) + + def test_fields(self): + s = Scanner() + s.scan_type = SCAN_TYPE_FIELDS + f = [no('The White Stripes - Little Ghost'),no('The White Stripes - Little Acorn')] + r = s.GetDupeGroups(f) + self.assertEqual(0,len(r)) + + def test_fields_no_order(self): + s = Scanner() + s.scan_type = SCAN_TYPE_FIELDS_NO_ORDER + f = [no('The White Stripes - Little Ghost'),no('Little Ghost - The White Stripes')] + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + + def test_tag_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + o1 = no('foo') + o2 = no('bar') + o1.artist = 'The White Stripes' + o1.title = 'The Air Near My Fingers' + o2.artist = 'The White Stripes' + o2.title = 'The Air Near My Fingers' + r = s.GetDupeGroups([o1,o2]) + self.assertEqual(1,len(r)) + + def test_tag_with_album_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG_WITH_ALBUM + o1 = no('foo') + o2 = no('bar') + o3 = no('bleh') + o1.artist = 'The White Stripes' + o1.title = 'The Air Near My Fingers' + o1.album = 'Elephant' + o2.artist = 'The White Stripes' + o2.title = 'The Air Near My Fingers' + o2.album = 'Elephant' + o3.artist = 'The White Stripes' + o3.title = 'The Air Near My Fingers' + o3.album = 'foobar' + r = s.GetDupeGroups([o1,o2,o3]) + self.assertEqual(1,len(r)) + + def test_that_dash_in_tags_dont_create_new_fields(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG_WITH_ALBUM + s.min_match_percentage = 50 + o1 = no('foo') + o2 = no('bar') + o1.artist = 'The White Stripes - a' + o1.title = 'The Air Near My Fingers - a' + o1.album = 'Elephant - a' + o2.artist = 'The White Stripes - b' + o2.title = 'The Air Near My Fingers - b' + o2.album = 'Elephant - b' + r = s.GetDupeGroups([o1,o2]) + self.assertEqual(1,len(r)) + + def test_tag_scan_with_different_scanned(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + s.scanned_tags = set(['track', 'year']) + o1 = no('foo') + o2 = no('bar') + o1.artist = 'The White Stripes' + o1.title = 'some title' + o1.track = 'foo' + o1.year = 'bar' + o2.artist = 'The White Stripes' + o2.title = 'another title' + o2.track = 'foo' + o2.year = 'bar' + r = s.GetDupeGroups([o1, o2]) + self.assertEqual(1, len(r)) + + def test_tag_scan_only_scans_existing_tags(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + s.scanned_tags = set(['artist', 'foo']) + o1 = no('foo') + o2 = no('bar') + o1.artist = 'The White Stripes' + o1.foo = 'foo' + o2.artist = 'The White Stripes' + o2.foo = 'bar' + r = s.GetDupeGroups([o1, o2]) + self.assertEqual(1, len(r)) # Because 'foo' is not scanned, they match + + def test_tag_scan_converts_to_str(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + s.scanned_tags = set(['track']) + o1 = no('foo') + o2 = no('bar') + o1.track = 42 + o2.track = 42 + try: + r = s.GetDupeGroups([o1, o2]) + except TypeError: + self.fail() + self.assertEqual(1, len(r)) + + def test_tag_scan_non_ascii(self): + s = Scanner() + s.scan_type = SCAN_TYPE_TAG + s.scanned_tags = set(['title']) + o1 = no('foo') + o2 = no('bar') + o1.title = u'foobar\u00e9' + o2.title = u'foobar\u00e9' + try: + r = s.GetDupeGroups([o1, o2]) + except UnicodeEncodeError: + self.fail() + self.assertEqual(1, len(r)) + + def test_audio_content_scan(self): + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT_AUDIO + f = [no('foo'),no('bar'),no('bleh')] + f[0].md5 = 'foo' + f[1].md5 = 'bar' + f[2].md5 = 'bleh' + f[0].md5partial = 'foo' + f[1].md5partial = 'foo' + f[2].md5partial = 'bleh' + f[0].audiosize = 1 + f[1].audiosize = 1 + f[2].audiosize = 1 + r = s.GetDupeGroups(f) + self.assertEqual(1,len(r)) + self.assertEqual(2,len(r[0])) + + def test_audio_content_scan_compare_sizes_first(self): + class MyFile(no): + def get_md5(file): + self.fail() + md5partial = property(get_md5) + + s = Scanner() + s.scan_type = SCAN_TYPE_CONTENT_AUDIO + f = [MyFile('foo'),MyFile('bar')] + f[0].audiosize = 1 + f[1].audiosize = 2 + self.assertEqual(0,len(s.GetDupeGroups(f))) + + def test_ignore_list(self): + s = Scanner() + f1 = no('foobar') + f2 = no('foobar') + f3 = no('foobar') + f1.path = Path('dir1/foobar') + f2.path = Path('dir2/foobar') + f3.path = Path('dir3/foobar') + s.ignore_list.Ignore(str(f1.path),str(f2.path)) + s.ignore_list.Ignore(str(f1.path),str(f3.path)) + r = s.GetDupeGroups([f1,f2,f3]) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(1,len(g.dupes)) + self.assert_(f1 not in g) + self.assert_(f2 in g) + self.assert_(f3 in g) + # Ignored matches are not counted as discarded + self.assertEqual(s.discarded_file_count, 0) + + def test_ignore_list_checks_for_unicode(self): + #scanner was calling path_str for ignore list checks. Since the Path changes, it must + #be unicode(path) + s = Scanner() + f1 = no('foobar') + f2 = no('foobar') + f3 = no('foobar') + f1.path = Path(u'foo1\u00e9') + f2.path = Path(u'foo2\u00e9') + f3.path = Path(u'foo3\u00e9') + s.ignore_list.Ignore(unicode(f1.path),unicode(f2.path)) + s.ignore_list.Ignore(unicode(f1.path),unicode(f3.path)) + r = s.GetDupeGroups([f1,f2,f3]) + self.assertEqual(1,len(r)) + g = r[0] + self.assertEqual(1,len(g.dupes)) + self.assert_(f1 not in g) + self.assert_(f2 in g) + self.assert_(f3 in g) + + def test_custom_match_factory(self): + class MatchFactory(object): + def getmatches(self,objects,j=None): + return [Match(objects[0], objects[1], 420)] + + + s = Scanner() + s.match_factory = MatchFactory() + o1,o2 = no('foo'),no('bar') + groups = s.GetDupeGroups([o1,o2]) + self.assertEqual(1,len(groups)) + g = groups[0] + self.assertEqual(2,len(g)) + g.switch_ref(o1) + m = g.get_match_of(o2) + self.assertEqual((o1,o2,420),m) + + def test_file_evaluates_to_false(self): + # A very wrong way to use any() was added at some point, causing resulting group list + # to be empty. + class FalseNamedObject(NamedObject): + def __nonzero__(self): + return False + + + s = Scanner() + f1 = FalseNamedObject('foobar') + f2 = FalseNamedObject('foobar') + r = s.GetDupeGroups([f1,f2]) + self.assertEqual(1,len(r)) + + def test_size_threshold(self): + # Only file equal or higher than the size_threshold in size are scanned + s = Scanner() + f1 = no('foo', 1) + f2 = no('foo', 2) + f3 = no('foo', 3) + s.size_threshold = 2 + groups = s.GetDupeGroups([f1,f2,f3]) + self.assertEqual(len(groups), 1) + [group] = groups + self.assertEqual(len(group), 2) + self.assertTrue(f1 not in group) + self.assertTrue(f2 in group) + self.assertTrue(f3 in group) + + def test_tie_breaker_path_deepness(self): + # If there is a tie in prioritization, path deepness is used as a tie breaker + s = Scanner() + o1, o2 = no('foo'), no('foo') + o1.path = Path('foo') + o2.path = Path('foo/bar') + [group] = s.GetDupeGroups([o1, o2]) + self.assertTrue(group.ref is o2) + + def test_tie_breaker_copy(self): + # if copy is in the words used (even if it has a deeper path), it becomes a dupe + s = Scanner() + o1, o2 = no('foo bar Copy'), no('foo bar') + o1.path = Path('deeper/path') + o2.path = Path('foo') + [group] = s.GetDupeGroups([o1, o2]) + self.assertTrue(group.ref is o2) + + def test_tie_breaker_same_name_plus_digit(self): + # if ref has the same words as dupe, but has some just one extra word which is a digit, it + # becomes a dupe + s = Scanner() + o1, o2 = no('foo bar 42'), no('foo bar') + o1.path = Path('deeper/path') + o2.path = Path('foo') + [group] = s.GetDupeGroups([o1, o2]) + self.assertTrue(group.ref is o2) + + def test_partial_group_match(self): + # Count the number od discarded matches (when a file doesn't match all other dupes of the + # group) in Scanner.discarded_file_count + s = Scanner() + o1, o2, o3 = no('a b'), no('a'), no('b') + s.min_match_percentage = 50 + [group] = s.GetDupeGroups([o1, o2, o3]) + self.assertEqual(len(group), 2) + self.assertTrue(o1 in group) + self.assertTrue(o2 in group) + self.assertTrue(o3 not in group) + self.assertEqual(s.discarded_file_count, 1) + + +class TCScannerME(TestCase): + def test_priorize(self): + # in ScannerME, bitrate goes first (right after is_ref) in priorization + s = ScannerME() + o1, o2 = no('foo'), no('foo') + o1.bitrate = 1 + o2.bitrate = 2 + [group] = s.GetDupeGroups([o1, o2]) + self.assertTrue(group.ref is o2) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/se/cocoa/AppDelegate.h b/se/cocoa/AppDelegate.h new file mode 100644 index 00000000..77d3b57f --- /dev/null +++ b/se/cocoa/AppDelegate.h @@ -0,0 +1,18 @@ +#import +#import "dgbase/AppDelegate.h" +#import "ResultWindow.h" +#import "DirectoryPanel.h" +#import "PyDupeGuru.h" + +@interface AppDelegate : AppDelegateBase +{ + IBOutlet ResultWindow *result; + + DirectoryPanel *_directoryPanel; +} +- (IBAction)openWebsite:(id)sender; +- (IBAction)toggleDirectories:(id)sender; + +- (DirectoryPanel *)directoryPanel; +- (PyDupeGuru *)py; +@end diff --git a/se/cocoa/AppDelegate.m b/se/cocoa/AppDelegate.m new file mode 100644 index 00000000..c1378a8c --- /dev/null +++ b/se/cocoa/AppDelegate.m @@ -0,0 +1,108 @@ +#import "AppDelegate.h" +#import "cocoalib/ProgressController.h" +#import "cocoalib/RegistrationInterface.h" +#import "cocoalib/Utils.h" +#import "cocoalib/ValueTransformers.h" +#import "Consts.h" + +@implementation AppDelegate ++ (void)initialize +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + NSMutableDictionary *d = [NSMutableDictionary dictionary]; + [d setObject:i2n(1) forKey:@"scanType"]; + [d setObject:i2n(80) forKey:@"minMatchPercentage"]; + [d setObject:i2n(30) forKey:@"smallFileThreshold"]; + [d setObject:i2n(1) forKey:@"recreatePathType"]; + [d setObject:b2n(YES) forKey:@"wordWeighting"]; + [d setObject:b2n(NO) forKey:@"matchSimilarWords"]; + [d setObject:b2n(YES) forKey:@"mixFileKind"]; + [d setObject:b2n(NO) forKey:@"useRegexpFilter"]; + [d setObject:b2n(NO) forKey:@"removeEmptyFolders"]; + [d setObject:b2n(YES) forKey:@"ignoreSmallFiles"]; + [d setObject:b2n(NO) forKey:@"debug"]; + [d setObject:[NSArray array] forKey:@"recentDirectories"]; + [d setObject:[NSArray array] forKey:@"columnsOrder"]; + [d setObject:[NSDictionary dictionary] forKey:@"columnsWidth"]; + [[NSUserDefaultsController sharedUserDefaultsController] setInitialValues:d]; + [ud registerDefaults:d]; +} + +- (id)init +{ + self = [super init]; + VTIsIntIn *vt = [[[VTIsIntIn alloc] initWithValues:[NSIndexSet indexSetWithIndex:1] reverse:YES] autorelease]; + [NSValueTransformer setValueTransformer:vt forName:@"vtScanTypeIsNotContent"]; + _directoryPanel = nil; + _appName = APPNAME; + return self; +} + +- (IBAction)openWebsite:(id)sender +{ + [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://www.hardcoded.net/dupeguru"]]; +} + +- (IBAction)toggleDirectories:(id)sender +{ + [[self directoryPanel] toggleVisible:sender]; +} + +- (DirectoryPanel *)directoryPanel +{ + if (!_directoryPanel) + _directoryPanel = [[DirectoryPanel alloc] initWithParentApp:self]; + return _directoryPanel; +} +- (PyDupeGuru *)py { return (PyDupeGuru *)py; } + +//Delegate +- (void)applicationDidFinishLaunching:(NSNotification *)aNotification +{ + [[ProgressController mainProgressController] setWorker:py]; + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + //Restore Columns + NSArray *columnsOrder = [ud arrayForKey:@"columnsOrder"]; + NSDictionary *columnsWidth = [ud dictionaryForKey:@"columnsWidth"]; + if ([columnsOrder count]) + [result restoreColumnsPosition:columnsOrder widths:columnsWidth]; + //Reg stuff + if ([RegistrationInterface showNagWithApp:[self py] name:APPNAME limitDescription:LIMIT_DESC]) + [unlockMenuItem setTitle:@"Thanks for buying dupeGuru!"]; + //Restore results + [py loadIgnoreList]; + [py loadResults]; +} + +- (void)applicationWillBecomeActive:(NSNotification *)aNotification +{ + if (![[result window] isVisible]) + [result showWindow:NSApp]; +} + +- (void)applicationWillTerminate:(NSNotification *)aNotification +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [ud setObject: [result getColumnsOrder] forKey:@"columnsOrder"]; + [ud setObject: [result getColumnsWidth] forKey:@"columnsWidth"]; + [py saveResults]; + int sc = [ud integerForKey:@"sessionCountSinceLastIgnorePurge"]; + if (sc >= 10) + { + sc = -1; + [py purgeIgnoreList]; + } + sc++; + [ud setInteger:sc forKey:@"sessionCountSinceLastIgnorePurge"]; + [py saveIgnoreList]; + // 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 RecentDirectories so it saves the user defaults + [recentDirectories release]; +} + +- (void)recentDirecoryClicked:(NSString *)directory +{ + [[self directoryPanel] addDirectory:directory]; +} +@end diff --git a/se/cocoa/Consts.h b/se/cocoa/Consts.h new file mode 100644 index 00000000..b27af158 --- /dev/null +++ b/se/cocoa/Consts.h @@ -0,0 +1,3 @@ +#import "dgbase/Consts.h" + +#define APPNAME @"dupeGuru" \ No newline at end of file diff --git a/se/cocoa/DetailsPanel.h b/se/cocoa/DetailsPanel.h new file mode 100644 index 00000000..0d4c025d --- /dev/null +++ b/se/cocoa/DetailsPanel.h @@ -0,0 +1,13 @@ +#import +#import "cocoalib/PyApp.h" +#import "cocoalib/Table.h" + + +@interface DetailsPanel : NSWindowController +{ + IBOutlet TableView *detailsTable; +} +- (id)initWithPy:(PyApp *)aPy; + +- (void)refresh; +@end \ No newline at end of file diff --git a/se/cocoa/DetailsPanel.m b/se/cocoa/DetailsPanel.m new file mode 100644 index 00000000..1baac387 --- /dev/null +++ b/se/cocoa/DetailsPanel.m @@ -0,0 +1,16 @@ +#import "DetailsPanel.h" + +@implementation DetailsPanel +- (id)initWithPy:(PyApp *)aPy +{ + self = [super initWithWindowNibName:@"Details"]; + [self window]; //So the detailsTable is initialized. + [detailsTable setPy:aPy]; + return self; +} + +- (void)refresh +{ + [detailsTable reloadData]; +} +@end diff --git a/se/cocoa/DirectoryPanel.h b/se/cocoa/DirectoryPanel.h new file mode 100644 index 00000000..6f8141b9 --- /dev/null +++ b/se/cocoa/DirectoryPanel.h @@ -0,0 +1,7 @@ +#import +#import "dgbase/DirectoryPanel.h" + +@interface DirectoryPanel : DirectoryPanelBase +{ +} +@end diff --git a/se/cocoa/DirectoryPanel.m b/se/cocoa/DirectoryPanel.m new file mode 100644 index 00000000..dd07462d --- /dev/null +++ b/se/cocoa/DirectoryPanel.m @@ -0,0 +1,4 @@ +#import "DirectoryPanel.h" + +@implementation DirectoryPanel +@end diff --git a/se/cocoa/English.lproj/Details.nib/classes.nib b/se/cocoa/English.lproj/Details.nib/classes.nib new file mode 100644 index 00000000..e1b7cb92 --- /dev/null +++ b/se/cocoa/English.lproj/Details.nib/classes.nib @@ -0,0 +1,18 @@ +{ + IBClasses = ( + { + CLASS = DetailsPanel; + LANGUAGE = ObjC; + OUTLETS = {detailsTable = NSTableView; }; + SUPERCLASS = NSWindowController; + }, + {CLASS = FirstResponder; LANGUAGE = ObjC; SUPERCLASS = NSObject; }, + { + CLASS = TableView; + LANGUAGE = ObjC; + OUTLETS = {py = PyApp; }; + SUPERCLASS = NSTableView; + } + ); + IBVersion = 1; +} \ No newline at end of file diff --git a/se/cocoa/English.lproj/Details.nib/info.nib b/se/cocoa/English.lproj/Details.nib/info.nib new file mode 100644 index 00000000..3f14ee77 --- /dev/null +++ b/se/cocoa/English.lproj/Details.nib/info.nib @@ -0,0 +1,16 @@ + + + + + IBDocumentLocation + 432 54 356 240 0 0 1024 746 + IBFramework Version + 443.0 + IBOpenObjects + + 5 + + IBSystem Version + 8I127 + + diff --git a/se/cocoa/English.lproj/Details.nib/keyedobjects.nib b/se/cocoa/English.lproj/Details.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..d4df1a7889bd47136abada1956a01f83a9ab97d2 GIT binary patch literal 6122 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 tUi(V>yY{2@lXf{xOq0@FX{|C-a6WIU& literal 0 HcmV?d00001 diff --git a/se/cocoa/English.lproj/Directories.nib/classes.nib b/se/cocoa/English.lproj/Directories.nib/classes.nib new file mode 100644 index 00000000..3ebaa96a --- /dev/null +++ b/se/cocoa/English.lproj/Directories.nib/classes.nib @@ -0,0 +1,62 @@ + + + + + IBClasses + + + CLASS + FirstResponder + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + askForDirectory + id + changeDirectoryState + id + popupAddDirectoryMenu + id + removeSelectedDirectory + id + toggleVisible + id + + CLASS + DirectoryPanel + LANGUAGE + ObjC + OUTLETS + + addButtonPopUp + NSPopUpButton + directories + NSOutlineView + removeButton + NSButton + + SUPERCLASS + DirectoryPanelBase + + + CLASS + OutlineView + LANGUAGE + ObjC + OUTLETS + + py + PyApp + + SUPERCLASS + NSOutlineView + + + IBVersion + 1 + + diff --git a/se/cocoa/English.lproj/Directories.nib/info.nib b/se/cocoa/English.lproj/Directories.nib/info.nib new file mode 100644 index 00000000..5c508e04 --- /dev/null +++ b/se/cocoa/English.lproj/Directories.nib/info.nib @@ -0,0 +1,20 @@ + + + + + IBFramework Version + 629 + IBLastKnownRelativeProjectPath + ../../dupeguru.xcodeproj + IBOldestOS + 5 + IBOpenObjects + + 6 + + IBSystem Version + 9B18 + targetFramework + IBCocoaFramework + + diff --git a/se/cocoa/English.lproj/Directories.nib/keyedobjects.nib b/se/cocoa/English.lproj/Directories.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..2275d202d0e2f6eaaa97521edfef9371f56b2f95 GIT binary patch literal 9698 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 Baclqp literal 0 HcmV?d00001 diff --git a/se/cocoa/English.lproj/InfoPlist.strings b/se/cocoa/English.lproj/InfoPlist.strings new file mode 100644 index 0000000000000000000000000000000000000000..d224a14bd2062242e455ea370c921c67e7045c94 GIT binary patch literal 204 zcmW-bOA5k35Cv=PDT2!&D(*yFxrrby%n4#XD*i$e6}^#{RLh~Ed*=0fHS_s0A|_(R zm7I(d2VRsEYIkQtt8(SyjGUEy>8yVS6Mq literal 0 HcmV?d00001 diff --git a/se/cocoa/English.lproj/MainMenu.nib/classes.nib b/se/cocoa/English.lproj/MainMenu.nib/classes.nib new file mode 100644 index 00000000..3a85cf2a --- /dev/null +++ b/se/cocoa/English.lproj/MainMenu.nib/classes.nib @@ -0,0 +1,229 @@ + + + + + IBClasses + + + CLASS + NSSegmentedControl + LANGUAGE + ObjC + SUPERCLASS + NSControl + + + ACTIONS + + openWebsite + id + toggleDirectories + id + unlockApp + id + + CLASS + AppDelegate + LANGUAGE + ObjC + OUTLETS + + py + PyDupeGuru + recentDirectories + RecentDirectories + result + ResultWindow + unlockMenuItem + NSMenuItem + + SUPERCLASS + NSObject + + + CLASS + PyApp + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + CLASS + MatchesView + LANGUAGE + ObjC + SUPERCLASS + OutlineView + + + CLASS + PyDupeGuru + LANGUAGE + ObjC + SUPERCLASS + PyApp + + + ACTIONS + + changeDelta + id + changePowerMarker + id + clearIgnoreList + id + collapseAll + id + copyMarked + id + deleteMarked + id + expandAll + id + exportToXHTML + id + filter + id + ignoreSelected + id + markAll + id + markInvert + id + markNone + id + markSelected + id + markToggle + id + moveMarked + id + openSelected + id + refresh + id + removeMarked + id + removeSelected + id + renameSelected + id + resetColumnsToDefault + id + revealSelected + id + showPreferencesPanel + id + startDuplicateScan + id + switchSelected + id + toggleColumn + id + toggleDelta + id + toggleDetailsPanel + id + togglePowerMarker + id + + CLASS + ResultWindow + LANGUAGE + ObjC + OUTLETS + + actionMenu + NSPopUpButton + actionMenuView + NSView + app + id + columnsMenu + NSMenu + deltaSwitch + NSSegmentedControl + deltaSwitchView + NSView + filterField + NSSearchField + filterFieldView + NSView + matches + MatchesView + pmSwitch + NSSegmentedControl + pmSwitchView + NSView + preferencesPanel + NSWindow + py + PyDupeGuru + stats + NSTextField + + SUPERCLASS + NSWindowController + + + CLASS + FirstResponder + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + checkForUpdates + id + + CLASS + SUUpdater + LANGUAGE + ObjC + SUPERCLASS + NSObject + + + ACTIONS + + clearMenu + id + menuClick + id + + CLASS + RecentDirectories + LANGUAGE + ObjC + OUTLETS + + delegate + id + menu + NSMenu + + SUPERCLASS + NSObject + + + CLASS + OutlineView + LANGUAGE + ObjC + OUTLETS + + py + PyApp + + SUPERCLASS + NSOutlineView + + + IBVersion + 1 + + diff --git a/se/cocoa/English.lproj/MainMenu.nib/info.nib b/se/cocoa/English.lproj/MainMenu.nib/info.nib new file mode 100644 index 00000000..6799cea9 --- /dev/null +++ b/se/cocoa/English.lproj/MainMenu.nib/info.nib @@ -0,0 +1,20 @@ + + + + + IBFramework Version + 629 + IBLastKnownRelativeProjectPath + ../../dupeguru.xcodeproj + IBOldestOS + 5 + IBOpenObjects + + 524 + + IBSystem Version + 9E17 + targetFramework + IBCocoaFramework + + diff --git a/se/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib b/se/cocoa/English.lproj/MainMenu.nib/keyedobjects.nib new file mode 100644 index 0000000000000000000000000000000000000000..7136407cef8a5712c05487ac7165ec08616e1cfd GIT binary patch literal 48638 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𝔗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 literal 0 HcmV?d00001 diff --git a/se/cocoa/Info.plist b/se/cocoa/Info.plist new file mode 100644 index 00000000..d24efa47 --- /dev/null +++ b/se/cocoa/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleHelpBookFolder + dupeguru_help + CFBundleHelpBookName + dupeGuru Help + CFBundleIconFile + dupeguru + CFBundleIdentifier + com.hardcoded_software.dupeguru + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleSignature + hsft + CFBundleVersion + 2.7.1 + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + SUFeedURL + http://www.hardcoded.net/updates/dupeguru.appcast + + diff --git a/se/cocoa/PyDupeGuru.h b/se/cocoa/PyDupeGuru.h new file mode 100644 index 00000000..85874157 --- /dev/null +++ b/se/cocoa/PyDupeGuru.h @@ -0,0 +1,9 @@ +#import +#import "dgbase/PyDupeGuru.h" + +@interface PyDupeGuru : PyDupeGuruBase +//Scanning options +- (void)setScanType:(NSNumber *)scan_type; +- (void)setWordWeighting:(NSNumber *)words_are_weighted; +- (void)setMatchSimilarWords:(NSNumber *)match_similar_words; +@end diff --git a/se/cocoa/ResultWindow.h b/se/cocoa/ResultWindow.h new file mode 100644 index 00000000..443d5afe --- /dev/null +++ b/se/cocoa/ResultWindow.h @@ -0,0 +1,55 @@ +#import +#import "cocoalib/Outline.h" +#import "dgbase/ResultWindow.h" +#import "DetailsPanel.h" +#import "DirectoryPanel.h" + +@interface ResultWindow : ResultWindowBase +{ + IBOutlet NSPopUpButton *actionMenu; + IBOutlet NSMenu *columnsMenu; + IBOutlet NSSearchField *filterField; + IBOutlet NSSegmentedControl *pmSwitch; + IBOutlet NSWindow *preferencesPanel; + IBOutlet NSTextField *stats; + + NSString *_lastAction; + DetailsPanel *_detailsPanel; + NSMutableArray *_resultColumns; + NSMutableIndexSet *_deltaColumns; +} +- (IBAction)changePowerMarker:(id)sender; +- (IBAction)clearIgnoreList:(id)sender; +- (IBAction)exportToXHTML:(id)sender; +- (IBAction)filter:(id)sender; +- (IBAction)ignoreSelected:(id)sender; +- (IBAction)markAll:(id)sender; +- (IBAction)markInvert:(id)sender; +- (IBAction)markNone:(id)sender; +- (IBAction)markSelected:(id)sender; +- (IBAction)markToggle:(id)sender; +- (IBAction)openSelected:(id)sender; +- (IBAction)refresh:(id)sender; +- (IBAction)removeMarked:(id)sender; +- (IBAction)removeSelected:(id)sender; +- (IBAction)renameSelected:(id)sender; +- (IBAction)resetColumnsToDefault:(id)sender; +- (IBAction)revealSelected:(id)sender; +- (IBAction)showPreferencesPanel:(id)sender; +- (IBAction)startDuplicateScan:(id)sender; +- (IBAction)switchSelected:(id)sender; +- (IBAction)toggleColumn:(id)sender; +- (IBAction)toggleDelta:(id)sender; +- (IBAction)toggleDetailsPanel:(id)sender; +- (IBAction)togglePowerMarker:(id)sender; + +- (NSTableColumn *)getColumnForIdentifier:(int)aIdentifier title:(NSString *)aTitle width:(int)aWidth refCol:(NSTableColumn *)aColumn; +- (NSArray *)getColumnsOrder; +- (NSDictionary *)getColumnsWidth; +- (NSArray *)getSelected:(BOOL)aDupesOnly; +- (NSArray *)getSelectedPaths:(BOOL)aDupesOnly; +- (void)initResultColumns; +- (void)performPySelection:(NSArray *)aIndexPaths; +- (void)refreshStats; +- (void)restoreColumnsPosition:(NSArray *)aColumnsOrder widths:(NSDictionary *)aColumnsWidth; +@end diff --git a/se/cocoa/ResultWindow.m b/se/cocoa/ResultWindow.m new file mode 100644 index 00000000..49b80b56 --- /dev/null +++ b/se/cocoa/ResultWindow.m @@ -0,0 +1,460 @@ +#import "ResultWindow.h" +#import "cocoalib/Dialogs.h" +#import "cocoalib/ProgressController.h" +#import "cocoalib/Utils.h" +#import "AppDelegate.h" +#import "Consts.h" + +@implementation ResultWindow +/* Override */ +- (void)awakeFromNib +{ + [super awakeFromNib]; + _detailsPanel = nil; + _displayDelta = NO; + _powerMode = NO; + _deltaColumns = [[NSMutableIndexSet indexSetWithIndexesInRange:NSMakeRange(2,4)] retain]; + [_deltaColumns removeIndex:3]; + [deltaSwitch setSelectedSegment:0]; + [pmSwitch setSelectedSegment:0]; + [py setDisplayDeltaValues:b2n(_displayDelta)]; + [matches setTarget:self]; + [matches setDoubleAction:@selector(openSelected:)]; + [[actionMenu itemAtIndex:0] setImage:[NSImage imageNamed: @"gear"]]; + [self initResultColumns]; + [self refreshStats]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(resultsMarkingChanged:) name:ResultsMarkingChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(duplicateSelectionChanged:) name:DuplicateSelectionChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(resultsChanged:) name:ResultsChangedNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jobCompleted:) name:JobCompletedNotification object:nil]; + + NSToolbar *t = [[[NSToolbar alloc] initWithIdentifier:@"ResultWindowToolbar"] autorelease]; + [t setAllowsUserCustomization:YES]; + [t setAutosavesConfiguration:YES]; + [t setDisplayMode:NSToolbarDisplayModeIconAndLabel]; + [t setDelegate:self]; + [[self window] setToolbar:t]; +} + +/* Actions */ + +- (IBAction)changePowerMarker:(id)sender +{ + _powerMode = [pmSwitch selectedSegment] == 1; + if (_powerMode) + [matches setTag:2]; + else + [matches setTag:0]; + [self expandAll:nil]; + [self outlineView:matches didClickTableColumn:nil]; +} + +- (IBAction)clearIgnoreList:(id)sender +{ + int i = n2i([py getIgnoreListCount]); + if (!i) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"Do you really want to remove all %d items from the ignore list?",i]] == NSAlertSecondButtonReturn) // NO + return; + [py clearIgnoreList]; +} + +- (IBAction)exportToXHTML:(id)sender +{ + NSString *xsltPath = [[NSBundle mainBundle] pathForResource:@"dg" ofType:@"xsl"]; + NSString *cssPath = [[NSBundle mainBundle] pathForResource:@"hardcoded" ofType:@"css"]; + NSString *exported = [py exportToXHTMLwithColumns:[self getColumnsOrder] xslt:xsltPath css:cssPath]; + [[NSWorkspace sharedWorkspace] openFile:exported]; +} + +- (IBAction)filter:(id)sender +{ + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + [py setEscapeFilterRegexp:b2n(!n2b([ud objectForKey:@"useRegexpFilter"]))]; + [py applyFilter:[filterField stringValue]]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)ignoreSelected:(id)sender +{ + NSArray *nodeList = [self getSelected:YES]; + if (![nodeList count]) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"All selected %d matches are going to be ignored in all subsequent scans. Continue?",[nodeList count]]] == NSAlertSecondButtonReturn) // NO + return; + [self performPySelection:[self getSelectedPaths:YES]]; + [py addSelectedToIgnoreList]; + [py removeSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)markAll:(id)sender +{ + [py markAll]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markInvert:(id)sender +{ + [py markInvert]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markNone:(id)sender +{ + [py markNone]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:YES]]; + [py toggleSelectedMark]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)markToggle:(id)sender +{ + OVNode *node = [matches itemAtRow:[matches clickedRow]]; + [self performPySelection:[NSArray arrayWithObject:p2a([node indexPath])]]; + [py toggleSelectedMark]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsMarkingChangedNotification object:self]; +} + +- (IBAction)openSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py openSelected]; +} + +- (IBAction)refresh:(id)sender +{ + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)removeMarked:(id)sender +{ + int mark_count = [[py getMarkCount] intValue]; + if (!mark_count) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"You are about to remove %d files from results. Continue?",mark_count]] == NSAlertSecondButtonReturn) // NO + return; + [py removeMarked]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)removeSelected:(id)sender +{ + NSArray *nodeList = [self getSelected:YES]; + if (![nodeList count]) + return; + if ([Dialogs askYesNo:[NSString stringWithFormat:@"You are about to remove %d files from results. Continue?",[nodeList count]]] == NSAlertSecondButtonReturn) // NO + return; + [self performPySelection:[self getSelectedPaths:YES]]; + [py removeSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)renameSelected:(id)sender +{ + int col = [matches columnWithIdentifier:@"0"]; + int row = [matches selectedRow]; + [matches editColumn:col row:row withEvent:[NSApp currentEvent] select:YES]; +} + +- (IBAction)resetColumnsToDefault:(id)sender +{ + NSMutableArray *columnsOrder = [NSMutableArray array]; + [columnsOrder addObject:@"0"]; + [columnsOrder addObject:@"1"]; + [columnsOrder addObject:@"2"]; + [columnsOrder addObject:@"6"]; + NSMutableDictionary *columnsWidth = [NSMutableDictionary dictionary]; + [columnsWidth setObject:i2n(195) forKey:@"0"]; + [columnsWidth setObject:i2n(120) forKey:@"1"]; + [columnsWidth setObject:i2n(63) forKey:@"2"]; + [columnsWidth setObject:i2n(60) forKey:@"6"]; + [self restoreColumnsPosition:columnsOrder widths:columnsWidth]; +} + +- (IBAction)revealSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py revealSelected]; +} + +- (IBAction)showPreferencesPanel:(id)sender +{ + [preferencesPanel makeKeyAndOrderFront:sender]; +} + +- (IBAction)startDuplicateScan:(id)sender +{ + if ([matches numberOfRows] > 0) + { + if ([Dialogs askYesNo:@"Are you sure you want to start a new duplicate scan?"] == NSAlertSecondButtonReturn) // NO + return; + } + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + PyDupeGuru *_py = (PyDupeGuru *)py; + [_py setScanType:[ud objectForKey:@"scanType"]]; + [_py setMinMatchPercentage:[ud objectForKey:@"minMatchPercentage"]]; + [_py setWordWeighting:[ud objectForKey:@"wordWeighting"]]; + [_py setMixFileKind:[ud objectForKey:@"mixFileKind"]]; + [_py setMatchSimilarWords:[ud objectForKey:@"matchSimilarWords"]]; + int smallFileThreshold = [ud integerForKey:@"smallFileThreshold"]; // In KB + int sizeThreshold = [ud boolForKey:@"ignoreSmallFiles"] ? smallFileThreshold * 1024 : 0; // The py side wants bytes + [_py setSizeThreshold:sizeThreshold]; + int r = n2i([py doScan]); + [matches reloadData]; + [self refreshStats]; + if (r != 0) + [[ProgressController mainProgressController] hide]; + if (r == 1) + [Dialogs showMessage:@"You cannot make a duplicate scan with only reference directories."]; + if (r == 3) + { + [Dialogs showMessage:@"The selected directories contain no scannable file."]; + [app toggleDirectories:nil]; + } +} + +- (IBAction)switchSelected:(id)sender +{ + [self performPySelection:[self getSelectedPaths:YES]]; + [py makeSelectedReference]; + [[NSNotificationCenter defaultCenter] postNotificationName:ResultsChangedNotification object:self]; +} + +- (IBAction)toggleColumn:(id)sender +{ + NSMenuItem *mi = sender; + NSString *colId = [NSString stringWithFormat:@"%d",[mi tag]]; + NSTableColumn *col = [matches tableColumnWithIdentifier:colId]; + if (col == nil) + { + //Add Column + col = [_resultColumns objectAtIndex:[mi tag]]; + [matches addTableColumn:col]; + [mi setState:NSOnState]; + } + else + { + //Remove column + [matches removeTableColumn:col]; + [mi setState:NSOffState]; + } +} + +- (IBAction)toggleDelta:(id)sender +{ + if ([deltaSwitch selectedSegment] == 1) + [deltaSwitch setSelectedSegment:0]; + else + [deltaSwitch setSelectedSegment:1]; + [self changeDelta:sender]; +} + +- (IBAction)toggleDetailsPanel:(id)sender +{ + if (!_detailsPanel) + _detailsPanel = [[DetailsPanel alloc] initWithPy:py]; + if ([[_detailsPanel window] isVisible]) + [[_detailsPanel window] close]; + else + [[_detailsPanel window] orderFront:nil]; +} + +- (IBAction)togglePowerMarker:(id)sender +{ + if ([pmSwitch selectedSegment] == 1) + [pmSwitch setSelectedSegment:0]; + else + [pmSwitch setSelectedSegment:1]; + [self changePowerMarker:sender]; +} + +/* Public */ +- (NSTableColumn *)getColumnForIdentifier:(int)aIdentifier title:(NSString *)aTitle width:(int)aWidth refCol:(NSTableColumn *)aColumn +{ + NSNumber *n = [NSNumber numberWithInt:aIdentifier]; + NSTableColumn *col = [[NSTableColumn alloc] initWithIdentifier:[n stringValue]]; + [col setWidth:aWidth]; + [col setEditable:NO]; + [[col dataCell] setFont:[[aColumn dataCell] font]]; + [[col headerCell] setStringValue:aTitle]; + [col setResizingMask:NSTableColumnUserResizingMask]; + [col setSortDescriptorPrototype:[[NSSortDescriptor alloc] initWithKey:[n stringValue] ascending:YES]]; + return col; +} + +//Returns an array of identifiers, in order. +- (NSArray *)getColumnsOrder +{ + NSTableColumn *col; + NSString *colId; + NSMutableArray *result = [NSMutableArray array]; + NSEnumerator *e = [[matches tableColumns] objectEnumerator]; + while (col = [e nextObject]) + { + colId = [col identifier]; + [result addObject:colId]; + } + return result; +} + +- (NSDictionary *)getColumnsWidth +{ + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + NSTableColumn *col; + NSString *colId; + NSNumber *width; + NSEnumerator *e = [[matches tableColumns] objectEnumerator]; + while (col = [e nextObject]) + { + colId = [col identifier]; + width = [NSNumber numberWithFloat:[col width]]; + [result setObject:width forKey:colId]; + } + return result; +} + +- (NSArray *)getSelected:(BOOL)aDupesOnly +{ + if (_powerMode) + aDupesOnly = NO; + NSIndexSet *indexes = [matches selectedRowIndexes]; + NSMutableArray *nodeList = [NSMutableArray array]; + OVNode *node; + int i = [indexes firstIndex]; + while (i != NSNotFound) + { + node = [matches itemAtRow:i]; + if (!aDupesOnly || ([node level] > 1)) + [nodeList addObject:node]; + i = [indexes indexGreaterThanIndex:i]; + } + return nodeList; +} + +- (NSArray *)getSelectedPaths:(BOOL)aDupesOnly +{ + NSMutableArray *r = [NSMutableArray array]; + NSArray *selected = [self getSelected:aDupesOnly]; + NSEnumerator *e = [selected objectEnumerator]; + OVNode *node; + while (node = [e nextObject]) + [r addObject:p2a([node indexPath])]; + return r; +} + +- (void)performPySelection:(NSArray *)aIndexPaths +{ + if (_powerMode) + [py selectPowerMarkerNodePaths:aIndexPaths]; + else + [py selectResultNodePaths:aIndexPaths]; +} + +- (void)initResultColumns +{ + NSTableColumn *refCol = [matches tableColumnWithIdentifier:@"0"]; + _resultColumns = [[NSMutableArray alloc] init]; + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"0"]]; // File Name + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"1"]]; // Directory + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"2"]]; // Size + [_resultColumns addObject:[self getColumnForIdentifier:3 title:@"Kind" width:40 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:4 title:@"Creation" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:5 title:@"Modification" width:120 refCol:refCol]]; + [_resultColumns addObject:[matches tableColumnWithIdentifier:@"6"]]; // Match % + [_resultColumns addObject:[self getColumnForIdentifier:7 title:@"Words Used" width:120 refCol:refCol]]; + [_resultColumns addObject:[self getColumnForIdentifier:8 title:@"Dupe Count" width:80 refCol:refCol]]; +} + +-(void)refreshStats +{ + [stats setStringValue:[py getStatLine]]; +} + +- (void)restoreColumnsPosition:(NSArray *)aColumnsOrder widths:(NSDictionary *)aColumnsWidth +{ + NSTableColumn *col; + NSString *colId; + NSNumber *width; + NSMenuItem *mi; + //Remove all columns + NSEnumerator *e = [[columnsMenu itemArray] objectEnumerator]; + while (mi = [e nextObject]) + { + if ([mi state] == NSOnState) + [self toggleColumn:mi]; + } + //Add columns and set widths + e = [aColumnsOrder objectEnumerator]; + while (colId = [e nextObject]) + { + if (![colId isEqual:@"mark"]) + { + col = [_resultColumns objectAtIndex:[colId intValue]]; + width = [aColumnsWidth objectForKey:[col identifier]]; + mi = [columnsMenu itemWithTag:[colId intValue]]; + if (width) + [col setWidth:[width floatValue]]; + [self toggleColumn:mi]; + } + } +} + +/* Delegate */ +- (void)outlineView:(NSOutlineView *)outlineView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)tableColumn item:(id)item +{ + OVNode *node = item; + if ([[tableColumn identifier] isEqual:@"mark"]) + { + [cell setEnabled: [node isMarkable]]; + } + if ([cell isKindOfClass:[NSTextFieldCell class]]) + { + // Determine if the text color will be blue due to directory being reference. + NSTextFieldCell *textCell = cell; + if ([node isMarkable]) + [textCell setTextColor:[NSColor blackColor]]; + else + [textCell setTextColor:[NSColor blueColor]]; + if ((_displayDelta) && (_powerMode || ([node level] > 1))) + { + int i = [[tableColumn identifier] intValue]; + if ([_deltaColumns containsIndex:i]) + [textCell setTextColor:[NSColor orangeColor]]; + } + } +} + +/* Notifications */ +- (void)duplicateSelectionChanged:(NSNotification *)aNotification +{ + if (_detailsPanel) + [_detailsPanel refresh]; +} + +- (void)outlineViewSelectionDidChange:(NSNotification *)notification +{ + [self performPySelection:[self getSelectedPaths:NO]]; + [py refreshDetailsWithSelected]; + [[NSNotificationCenter defaultCenter] postNotificationName:DuplicateSelectionChangedNotification object:self]; +} + +- (void)resultsChanged:(NSNotification *)aNotification +{ + [matches reloadData]; + [self expandAll:nil]; + [self outlineViewSelectionDidChange:nil]; + [self refreshStats]; +} + +- (void)resultsMarkingChanged:(NSNotification *)aNotification +{ + [matches invalidateMarkings]; + [self refreshStats]; +} +@end diff --git a/se/cocoa/dupeguru.icns b/se/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/se/cocoa/dupeguru.xcodeproj/hsoft.mode1 b/se/cocoa/dupeguru.xcodeproj/hsoft.mode1 new file mode 100644 index 00000000..6ffe0908 --- /dev/null +++ b/se/cocoa/dupeguru.xcodeproj/hsoft.mode1 @@ -0,0 +1,1369 @@ + + + + + ActivePerspectiveName + Project + AllowedModules + + + BundleLoadPath + + MaxInstances + n + Module + PBXSmartGroupTreeModule + Name + Groups and Files Outline View + + + BundleLoadPath + + MaxInstances + n + Module + PBXNavigatorGroup + Name + Editor + + + BundleLoadPath + + MaxInstances + n + Module + XCTaskListModule + Name + Task List + + + BundleLoadPath + + MaxInstances + n + Module + XCDetailModule + Name + File and Smart Group Detail Viewer + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXBuildResultsModule + Name + Detailed Build Results Viewer + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXProjectFindModule + Name + Project Batch Find Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXRunSessionModule + Name + Run Log + + + BundleLoadPath + + MaxInstances + n + Module + PBXBookmarksModule + Name + Bookmarks Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXClassBrowserModule + Name + Class Browser + + + BundleLoadPath + + MaxInstances + n + Module + PBXCVSModule + Name + Source Code Control Tool + + + BundleLoadPath + + MaxInstances + n + Module + PBXDebugBreakpointsModule + Name + Debug Breakpoints Tool + + + BundleLoadPath + + MaxInstances + n + Module + XCDockableInspector + Name + Inspector + + + BundleLoadPath + + MaxInstances + n + Module + PBXOpenQuicklyModule + Name + Open Quickly Tool + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXDebugSessionModule + Name + Debugger + + + BundleLoadPath + + MaxInstances + 1 + Module + PBXDebugCLIModule + Name + Debug Console + + + Description + DefaultDescriptionKey + DockingSystemVisible + + Extension + mode1 + FavBarConfig + + PBXProjectModuleGUID + CE381CB409914B41003581CE + XCBarModuleItemNames + + XCBarModuleItems + + + FirstTimeWindowDisplayed + + Identifier + com.apple.perspectives.project.mode1 + MajorVersion + 31 + MinorVersion + 1 + Name + Default + Notifications + + OpenEditors + + PerspectiveWidths + + -1 + -1 + + Perspectives + + + ChosenToolbarItems + + active-executable-popup + action + active-buildstyle-popup + active-target-popup + buildOrClean + build-and-runOrDebug + com.apple.ide.PBXToolbarStopButton + get-info + toggle-editor + + ControllerClassBaseName + + IconName + WindowOfProjectWithEditor + Identifier + perspective.project + IsVertical + + Layout + + + BecomeActive + + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C37FBAC04509CD000000102 + 1C37FAAC04509CD000000102 + 1C08E77C0454961000C914BD + 1C37FABC05509CD000000102 + 1C37FABC05539CD112110102 + E2644B35053B69B200211256 + 1C37FABC04509CD000100104 + + PBXProjectModuleGUID + 1CE0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + yes + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 194 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 29B97314FDCFA39411CA2CEA + 080E96DDFE201D6D7F000001 + 29B97317FDCFA39411CA2CEA + 29B97323FDCFA39411CA2CEA + 1058C7A0FEA54F0111CA2CBB + 1058C7A2FEA54F0111CA2CBB + 19C28FACFE9D520D11CA2CBB + 1C37FBAC04509CD000000102 + 1C37FAAC04509CD000000102 + 1C37FABC05509CD000000102 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 37 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {194, 764}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + + XCSharingToken + com.apple.Xcode.GFSharingToken + + GeometryConfiguration + + Frame + {{0, 0}, {211, 782}} + GroupTreeTableConfiguration + + MainColumn + 194 + + RubberWindowFrame + 1 55 1366 823 0 0 1440 878 + + Module + PBXSmartGroupTreeModule + Proportion + 211pt + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CE0B20306471E060097A5F4 + PBXProjectModuleLabel + Info.plist + PBXSplitModuleInNavigatorKey + + Split0 + + PBXProjectModuleGUID + 1CE0B20406471E060097A5F4 + PBXProjectModuleLabel + Info.plist + _historyCapacity + 10 + bookmark + CE6D39D50C9B15B600C7FE6C + history + + CEDA43440B07D11900B3091A + CECEEB580B0CAE8E00E6972C + CECEEBE40B0CB68A00E6972C + CECEEBE60B0CB68A00E6972C + CECEEBE70B0CB68A00E6972C + CED9635E0B0F954E00DDBB8C + CE24ED9F0B552A5D00DDF502 + CE6D394A0C9B111900C7FE6C + + prevStack + + CEF411790A110C7F00E7F110 + CE6E6AE60AA528B2002F29BE + CE6E6AE70AA528B2002F29BE + CE17C9AC0B04D16D0023E222 + CE17C9AD0B04D16D0023E222 + CE17CA230B04E15F0023E222 + CED963600B0F954E00DDBB8C + CE24EDA00B552A5D00DDF502 + + + SplitCount + 1 + + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {1150, 544}} + RubberWindowFrame + 1 55 1366 823 0 0 1440 878 + + Module + PBXNavigatorGroup + Proportion + 544pt + + + ContentConfiguration + + PBXProjectModuleGUID + 1CE0B20506471E060097A5F4 + PBXProjectModuleLabel + Detail + + GeometryConfiguration + + Frame + {{0, 549}, {1150, 233}} + RubberWindowFrame + 1 55 1366 823 0 0 1440 878 + + Module + XCDetailModule + Proportion + 233pt + + + Proportion + 1150pt + + + Name + Project + ServiceClasses + + XCModuleDock + PBXSmartGroupTreeModule + XCModuleDock + PBXNavigatorGroup + XCDetailModule + + TableOfContents + + CE6D39CF0C9B114700C7FE6C + 1CE0B1FE06471DED0097A5F4 + CE6D39D00C9B114700C7FE6C + 1CE0B20306471E060097A5F4 + 1CE0B20506471E060097A5F4 + + ToolbarConfiguration + xcode.toolbar.config.default + + + ControllerClassBaseName + + IconName + WindowOfProject + Identifier + perspective.morph + IsVertical + 0 + Layout + + + BecomeActive + 1 + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C37FBAC04509CD000000102 + 1C37FAAC04509CD000000102 + 1C08E77C0454961000C914BD + 1C37FABC05509CD000000102 + 1C37FABC05539CD112110102 + E2644B35053B69B200211256 + 1C37FABC04509CD000100104 + 1CC0EA4004350EF90044410B + 1CC0EA4004350EF90041110B + + PBXProjectModuleGUID + 11E0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + yes + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 186 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 29B97314FDCFA39411CA2CEA + 1C37FABC05509CD000000102 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 0 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {186, 337}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + 1 + XCSharingToken + com.apple.Xcode.GFSharingToken + + GeometryConfiguration + + Frame + {{0, 0}, {203, 355}} + GroupTreeTableConfiguration + + MainColumn + 186 + + RubberWindowFrame + 373 269 690 397 0 0 1440 878 + + Module + PBXSmartGroupTreeModule + Proportion + 100% + + + Name + Morph + PreferredWidth + 300 + ServiceClasses + + XCModuleDock + PBXSmartGroupTreeModule + + TableOfContents + + 11E0B1FE06471DED0097A5F4 + + ToolbarConfiguration + xcode.toolbar.config.default.short + + + PerspectivesBarVisible + + ShelfIsVisible + + SourceDescription + file at '/System/Library/PrivateFrameworks/DevToolsInterface.framework/Versions/A/Resources/XCPerspectivesSpecificationMode1.xcperspec' + StatusbarIsVisible + + TimeStamp + 0.0 + ToolbarDisplayMode + 1 + ToolbarIsVisible + + ToolbarSizeMode + 1 + Type + Perspectives + UpdateMessage + The Default Workspace in this version of Xcode now includes support to hide and show the detail view (what has been referred to as the "Metro-Morph" feature). You must discard your current Default Workspace settings and update to the latest Default Workspace in order to gain this feature. Do you wish to update to the latest Workspace defaults for project '%@'? + WindowJustification + 5 + WindowOrderList + + 1C0AD2B3069F1EA900FABCE6 + CE381CCE09914BC8003581CE + /Users/hsoft/src/dupeguru_cocoa/dupeguru.xcodeproj + + WindowString + 1 55 1366 823 0 0 1440 878 + WindowTools + + + FirstTimeWindowDisplayed + + Identifier + windowTool.build + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CD0528F0623707200166675 + PBXProjectModuleLabel + + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {1366, 540}} + RubberWindowFrame + 0 56 1366 822 0 0 1440 878 + + Module + PBXNavigatorGroup + Proportion + 540pt + + + ContentConfiguration + + PBXProjectModuleGUID + XCMainBuildResultsModuleGUID + PBXProjectModuleLabel + Build + XCBuildResultsTrigger_Collapse + 1021 + XCBuildResultsTrigger_Open + 1011 + + GeometryConfiguration + + Frame + {{0, 545}, {1366, 236}} + RubberWindowFrame + 0 56 1366 822 0 0 1440 878 + + Module + PBXBuildResultsModule + Proportion + 236pt + + + Proportion + 781pt + + + Name + Build Results + ServiceClasses + + PBXBuildResultsModule + + StatusbarIsVisible + + TableOfContents + + CE381CCE09914BC8003581CE + CE6D39D10C9B114700C7FE6C + 1CD0528F0623707200166675 + XCMainBuildResultsModuleGUID + + ToolbarConfiguration + xcode.toolbar.config.build + WindowString + 0 56 1366 822 0 0 1440 878 + WindowToolGUID + CE381CCE09914BC8003581CE + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.debugger + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + Debugger + + HorizontalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {0, 258}} + {{0, 0}, {1024, 258}} + + + VerticalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {1024, 258}} + {{0, 258}, {1024, 387}} + + + + LauncherConfigVersion + 8 + PBXProjectModuleGUID + 1C162984064C10D400B95A72 + PBXProjectModuleLabel + Debug - GLUTExamples (Underwater) + + GeometryConfiguration + + DebugConsoleDrawerSize + {100, 120} + DebugConsoleVisible + None + DebugConsoleWindowFrame + {{200, 200}, {500, 300}} + DebugSTDIOWindowFrame + {{200, 200}, {500, 300}} + Frame + {{0, 0}, {1024, 645}} + RubberWindowFrame + 342 192 1024 686 0 0 1440 878 + + Module + PBXDebugSessionModule + Proportion + 645pt + + + Proportion + 645pt + + + Name + Debugger + ServiceClasses + + PBXDebugSessionModule + + StatusbarIsVisible + + TableOfContents + + 1CD10A99069EF8BA00B06720 + CE24EDA90B552A6300DDF502 + 1C162984064C10D400B95A72 + CE24EDAA0B552A6300DDF502 + CE24EDAB0B552A6300DDF502 + CE24EDAC0B552A6300DDF502 + CE24EDAD0B552A6300DDF502 + CE24EDAE0B552A6300DDF502 + CE24EDAF0B552A6300DDF502 + + ToolbarConfiguration + xcode.toolbar.config.debug + WindowString + 342 192 1024 686 0 0 1440 878 + WindowToolGUID + 1CD10A99069EF8BA00B06720 + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.find + IsVertical + + Layout + + + Dock + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1CDD528C0622207200134675 + PBXProjectModuleLabel + AppDelegate.m + StatusBarVisibility + + + GeometryConfiguration + + Frame + {{0, 0}, {781, 212}} + RubberWindowFrame + 84 374 781 470 0 0 1440 878 + + Module + PBXNavigatorGroup + Proportion + 781pt + + + Proportion + 212pt + + + BecomeActive + + ContentConfiguration + + PBXProjectModuleGUID + 1CD0528E0623707200166675 + PBXProjectModuleLabel + Project Find + + GeometryConfiguration + + Frame + {{0, 217}, {781, 212}} + RubberWindowFrame + 84 374 781 470 0 0 1440 878 + + Module + PBXProjectFindModule + Proportion + 212pt + + + Proportion + 429pt + + + Name + Project Find + ServiceClasses + + PBXProjectFindModule + + StatusbarIsVisible + + TableOfContents + + 1C530D57069F1CE1000CFCEE + CECEEB510B0CAE8800E6972C + CECEEB520B0CAE8800E6972C + 1CDD528C0622207200134675 + 1CD0528E0623707200166675 + + WindowString + 84 374 781 470 0 0 1440 878 + WindowToolGUID + 1C530D57069F1CE1000CFCEE + WindowToolIsVisible + + + + Identifier + MENUSEPARATOR + + + FirstTimeWindowDisplayed + + Identifier + windowTool.debuggerConsole + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1C78EAAC065D492600B07095 + PBXProjectModuleLabel + Debugger Console + + GeometryConfiguration + + Frame + {{0, 0}, {440, 358}} + RubberWindowFrame + 72 414 440 400 0 0 1440 878 + + Module + PBXDebugCLIModule + Proportion + 358pt + + + Proportion + 359pt + + + Name + Debugger Console + ServiceClasses + + PBXDebugCLIModule + + StatusbarIsVisible + + TableOfContents + + CECD0ADE099294C1003DC359 + CE24EDB00B552A6300DDF502 + 1C78EAAC065D492600B07095 + + WindowString + 72 414 440 400 0 0 1440 878 + WindowToolGUID + CECD0ADE099294C1003DC359 + WindowToolIsVisible + + + + FirstTimeWindowDisplayed + + Identifier + windowTool.run + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + LauncherConfigVersion + 3 + PBXProjectModuleGUID + 1CD0528B0623707200166675 + PBXProjectModuleLabel + Run + Runner + + HorizontalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {367, 168}} + {{0, 173}, {367, 270}} + + + VerticalSplitView + + _collapsingFrameDimension + 0.0 + _indexOfCollapsedView + 0 + _percentageOfCollapsedView + 0.0 + isCollapsed + yes + sizes + + {{0, 0}, {406, 443}} + {{411, 0}, {517, 443}} + + + + + GeometryConfiguration + + Frame + {{0, 0}, {1024, 645}} + RubberWindowFrame + 343 192 1024 686 0 0 1440 878 + + Module + PBXRunSessionModule + Proportion + 645pt + + + Proportion + 645pt + + + Name + Run Log + ServiceClasses + + PBXRunSessionModule + + StatusbarIsVisible + + TableOfContents + + 1C0AD2B3069F1EA900FABCE6 + CE6D39D20C9B114700C7FE6C + 1CD0528B0623707200166675 + CE6D39D30C9B114700C7FE6C + + ToolbarConfiguration + xcode.toolbar.config.run + WindowString + 343 192 1024 686 0 0 1440 878 + WindowToolGUID + 1C0AD2B3069F1EA900FABCE6 + WindowToolIsVisible + + + + Identifier + windowTool.scm + Layout + + + Dock + + + ContentConfiguration + + PBXProjectModuleGUID + 1C78EAB2065D492600B07095 + PBXProjectModuleLabel + <No Editor> + PBXSplitModuleInNavigatorKey + + Split0 + + PBXProjectModuleGUID + 1C78EAB3065D492600B07095 + + SplitCount + 1 + + StatusBarVisibility + 1 + + GeometryConfiguration + + Frame + {{0, 0}, {452, 0}} + RubberWindowFrame + 743 379 452 308 0 0 1280 1002 + + Module + PBXNavigatorGroup + Proportion + 0pt + + + BecomeActive + 1 + ContentConfiguration + + PBXProjectModuleGUID + 1CD052920623707200166675 + PBXProjectModuleLabel + SCM + + GeometryConfiguration + + ConsoleFrame + {{0, 259}, {452, 0}} + Frame + {{0, 7}, {452, 259}} + RubberWindowFrame + 743 379 452 308 0 0 1280 1002 + TableConfiguration + + Status + 30 + FileName + 199 + Path + 197.09500122070312 + + TableFrame + {{0, 0}, {452, 250}} + + Module + PBXCVSModule + Proportion + 262pt + + + Proportion + 266pt + + + Name + SCM + ServiceClasses + + PBXCVSModule + + StatusbarIsVisible + 1 + TableOfContents + + 1C78EAB4065D492600B07095 + 1C78EAB5065D492600B07095 + 1C78EAB2065D492600B07095 + 1CD052920623707200166675 + + ToolbarConfiguration + xcode.toolbar.config.scm + WindowString + 743 379 452 308 0 0 1280 1002 + + + FirstTimeWindowDisplayed + + Identifier + windowTool.breakpoints + IsVertical + + Layout + + + Dock + + + ContentConfiguration + + PBXBottomSmartGroupGIDs + + 1C77FABC04509CD000000102 + + PBXProjectModuleGUID + 1CE0B1FE06471DED0097A5F4 + PBXProjectModuleLabel + Files + PBXProjectStructureProvided + no + PBXSmartGroupTreeModuleColumnData + + PBXSmartGroupTreeModuleColumnWidthsKey + + 168 + + PBXSmartGroupTreeModuleColumnsKey_v4 + + MainColumn + + + PBXSmartGroupTreeModuleOutlineStateKey_v7 + + PBXSmartGroupTreeModuleOutlineStateExpansionKey + + 1C77FABC04509CD000000102 + + PBXSmartGroupTreeModuleOutlineStateSelectionKey + + + 0 + + + PBXSmartGroupTreeModuleOutlineStateVisibleRectKey + {{0, 0}, {168, 350}} + + PBXTopSmartGroupGIDs + + XCIncludePerspectivesSwitch + + + GeometryConfiguration + + Frame + {{0, 0}, {185, 368}} + GroupTreeTableConfiguration + + MainColumn + 168 + + RubberWindowFrame + 21 314 744 409 0 0 1024 746 + + Module + PBXSmartGroupTreeModule + Proportion + 185pt + + + BecomeActive + + ContentConfiguration + + PBXProjectModuleGUID + 1CA1AED706398EBD00589147 + PBXProjectModuleLabel + Detail + + GeometryConfiguration + + Frame + {{190, 0}, {554, 368}} + RubberWindowFrame + 21 314 744 409 0 0 1024 746 + + Module + XCDetailModule + Proportion + 554pt + + + Proportion + 368pt + + + MajorVersion + 2 + MinorVersion + 0 + Name + Breakpoints + ServiceClasses + + PBXSmartGroupTreeModule + XCDetailModule + + StatusbarIsVisible + + TableOfContents + + CEDA9EAC09D2BBCE00741F3F + CEDA9EAD09D2BBCE00741F3F + 1CE0B1FE06471DED0097A5F4 + 1CA1AED706398EBD00589147 + + ToolbarConfiguration + xcode.toolbar.config.breakpoints + WindowString + 21 314 744 409 0 0 1024 746 + WindowToolGUID + CEDA9EAC09D2BBCE00741F3F + WindowToolIsVisible + + + + Identifier + windowTool.debugAnimator + Layout + + + Dock + + + Module + PBXNavigatorGroup + Proportion + 100% + + + Proportion + 100% + + + Name + Debug Visualizer + ServiceClasses + + PBXNavigatorGroup + + StatusbarIsVisible + 1 + ToolbarConfiguration + xcode.toolbar.config.debugAnimator + WindowString + 100 100 700 500 0 0 1280 1002 + + + Identifier + windowTool.bookmarks + Layout + + + Dock + + + Module + PBXBookmarksModule + Proportion + 100% + + + Proportion + 100% + + + Name + Bookmarks + ServiceClasses + + PBXBookmarksModule + + StatusbarIsVisible + 0 + WindowString + 538 42 401 187 0 0 1280 1002 + + + Identifier + windowTool.classBrowser + Layout + + + Dock + + + BecomeActive + 1 + ContentConfiguration + + OptionsSetName + Hierarchy, all classes + PBXProjectModuleGUID + 1CA6456E063B45B4001379D8 + PBXProjectModuleLabel + Class Browser - NSObject + + GeometryConfiguration + + ClassesFrame + {{0, 0}, {374, 96}} + ClassesTreeTableConfiguration + + PBXClassNameColumnIdentifier + 208 + PBXClassBookColumnIdentifier + 22 + + Frame + {{0, 0}, {630, 331}} + MembersFrame + {{0, 105}, {374, 395}} + MembersTreeTableConfiguration + + PBXMemberTypeIconColumnIdentifier + 22 + PBXMemberNameColumnIdentifier + 216 + PBXMemberTypeColumnIdentifier + 97 + PBXMemberBookColumnIdentifier + 22 + + PBXModuleWindowStatusBarHidden2 + 1 + RubberWindowFrame + 385 179 630 352 0 0 1440 878 + + Module + PBXClassBrowserModule + Proportion + 332pt + + + Proportion + 332pt + + + Name + Class Browser + ServiceClasses + + PBXClassBrowserModule + + StatusbarIsVisible + 0 + TableOfContents + + 1C0AD2AF069F1E9B00FABCE6 + 1C0AD2B0069F1E9B00FABCE6 + 1CA6456E063B45B4001379D8 + + ToolbarConfiguration + xcode.toolbar.config.classbrowser + WindowString + 385 179 630 352 0 0 1440 878 + WindowToolGUID + 1C0AD2AF069F1E9B00FABCE6 + WindowToolIsVisible + 0 + + + + diff --git a/se/cocoa/dupeguru.xcodeproj/project.pbxproj b/se/cocoa/dupeguru.xcodeproj/project.pbxproj new file mode 100644 index 00000000..3755a27e --- /dev/null +++ b/se/cocoa/dupeguru.xcodeproj/project.pbxproj @@ -0,0 +1,548 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 44; + objects = { + +/* Begin PBXBuildFile section */ + 8D11072A0486CEB800E47090 /* MainMenu.nib in Resources */ = {isa = PBXBuildFile; fileRef = 29B97318FDCFA39411CA2CEA /* MainMenu.nib */; }; + 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */; }; + 8D11072D0486CEB800E47090 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B97316FDCFA39411CA2CEA /* main.m */; settings = {ATTRIBUTES = (); }; }; + 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */; }; + CE073F6309CAE1A3005C1D2F /* dupeguru_help in Resources */ = {isa = PBXBuildFile; fileRef = CE073F5409CAE1A3005C1D2F /* dupeguru_help */; }; + CE0D67640ABC2D3E00E2FFD9 /* dg.xsl in Resources */ = {isa = PBXBuildFile; fileRef = CE0D67620ABC2D3E00E2FFD9 /* dg.xsl */; }; + CE0D67650ABC2D3E00E2FFD9 /* hardcoded.css in Resources */ = {isa = PBXBuildFile; fileRef = CE0D67630ABC2D3E00E2FFD9 /* hardcoded.css */; }; + CE381C9609914ACE003581CE /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = CE381C9409914ACE003581CE /* AppDelegate.m */; }; + CE381C9C09914ADF003581CE /* ResultWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CE381C9A09914ADF003581CE /* ResultWindow.m */; }; + CE381D0509915304003581CE /* dg_cocoa.plugin in Resources */ = {isa = PBXBuildFile; fileRef = CE381CF509915304003581CE /* dg_cocoa.plugin */; }; + CE3AA46709DB207900DB3A21 /* Directories.nib in Resources */ = {isa = PBXBuildFile; fileRef = CE3AA46509DB207900DB3A21 /* Directories.nib */; }; + CE45579B0AE3BC2B005A9546 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE45579A0AE3BC2B005A9546 /* Sparkle.framework */; }; + CE4557B40AE3BC50005A9546 /* Sparkle.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE45579A0AE3BC2B005A9546 /* Sparkle.framework */; }; + CE68EE6809ABC48000971085 /* DirectoryPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CE68EE6609ABC48000971085 /* DirectoryPanel.m */; }; + CE848A1909DD85810004CB44 /* Consts.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE848A1809DD85810004CB44 /* Consts.h */; }; + CECA899909DB12CA00A3D774 /* Details.nib in Resources */ = {isa = PBXBuildFile; fileRef = CECA899709DB12CA00A3D774 /* Details.nib */; }; + CECA899C09DB132E00A3D774 /* DetailsPanel.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = CECA899A09DB132E00A3D774 /* DetailsPanel.h */; }; + CECA899D09DB132E00A3D774 /* DetailsPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CECA899B09DB132E00A3D774 /* DetailsPanel.m */; }; + CEEB135209C837A2004D2330 /* dupeguru.icns in Resources */ = {isa = PBXBuildFile; fileRef = CEEB135109C837A2004D2330 /* dupeguru.icns */; }; + CEF7823809C8AA0200EF38FF /* gear.png in Resources */ = {isa = PBXBuildFile; fileRef = CEF7823709C8AA0200EF38FF /* gear.png */; }; + CEFC294609C89E3D00D9F998 /* folder32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC294509C89E3D00D9F998 /* folder32.png */; }; + CEFC295509C89FF200D9F998 /* details32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC295309C89FF200D9F998 /* details32.png */; }; + CEFC295609C89FF200D9F998 /* preferences32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC295409C89FF200D9F998 /* preferences32.png */; }; + CEFC295E09C8A0B000D9F998 /* dg_logo32.png in Resources */ = {isa = PBXBuildFile; fileRef = CEFC295D09C8A0B000D9F998 /* dg_logo32.png */; }; + CEFC7F9E0FC9517500CD5728 /* Dialogs.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F8B0FC9517500CD5728 /* Dialogs.m */; }; + CEFC7F9F0FC9517500CD5728 /* HSErrorReportWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F8D0FC9517500CD5728 /* HSErrorReportWindow.m */; }; + CEFC7FA00FC9517500CD5728 /* Outline.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F8F0FC9517500CD5728 /* Outline.m */; }; + CEFC7FA10FC9517500CD5728 /* ProgressController.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F910FC9517500CD5728 /* ProgressController.m */; }; + CEFC7FA20FC9517500CD5728 /* RecentDirectories.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F950FC9517500CD5728 /* RecentDirectories.m */; }; + CEFC7FA30FC9517500CD5728 /* RegistrationInterface.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F970FC9517500CD5728 /* RegistrationInterface.m */; }; + CEFC7FA40FC9517500CD5728 /* Table.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F990FC9517500CD5728 /* Table.m */; }; + CEFC7FA50FC9517500CD5728 /* Utils.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F9B0FC9517500CD5728 /* Utils.m */; }; + CEFC7FA60FC9517500CD5728 /* ValueTransformers.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7F9D0FC9517500CD5728 /* ValueTransformers.m */; }; + CEFC7FAD0FC9518A00CD5728 /* ErrorReportWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = CEFC7FA70FC9518A00CD5728 /* ErrorReportWindow.xib */; }; + CEFC7FAE0FC9518A00CD5728 /* progress.nib in Resources */ = {isa = PBXBuildFile; fileRef = CEFC7FA90FC9518A00CD5728 /* progress.nib */; }; + CEFC7FAF0FC9518A00CD5728 /* registration.nib in Resources */ = {isa = PBXBuildFile; fileRef = CEFC7FAB0FC9518A00CD5728 /* registration.nib */; }; + CEFC7FB90FC951A700CD5728 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7FB20FC951A700CD5728 /* AppDelegate.m */; }; + CEFC7FBA0FC951A700CD5728 /* DirectoryPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7FB50FC951A700CD5728 /* DirectoryPanel.m */; }; + CEFC7FBB0FC951A700CD5728 /* ResultWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = CEFC7FB80FC951A700CD5728 /* ResultWindow.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + CECC02B709A36E8200CC0A94 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + CE4557B40AE3BC50005A9546 /* Sparkle.framework in CopyFiles */, + CECA899C09DB132E00A3D774 /* DetailsPanel.h in CopyFiles */, + CE848A1909DD85810004CB44 /* Consts.h in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 089C165DFE840E0CC02AAC07 /* English */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = English; path = English.lproj/InfoPlist.strings; sourceTree = ""; }; + 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; + 13E42FB307B3F0F600E4EEF1 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = /System/Library/Frameworks/CoreData.framework; sourceTree = ""; }; + 29B97316FDCFA39411CA2CEA /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 29B97319FDCFA39411CA2CEA /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/MainMenu.nib; sourceTree = ""; }; + 29B97324FDCFA39411CA2CEA /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = /System/Library/Frameworks/AppKit.framework; sourceTree = ""; }; + 29B97325FDCFA39411CA2CEA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = /System/Library/Frameworks/Foundation.framework; sourceTree = ""; }; + 8D1107310486CEB800E47090 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = SOURCE_ROOT; }; + 8D1107320486CEB800E47090 /* dupeGuru.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = dupeGuru.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CE073F5409CAE1A3005C1D2F /* dupeguru_help */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dupeguru_help; path = help/dupeguru_help; sourceTree = ""; }; + CE0D67620ABC2D3E00E2FFD9 /* dg.xsl */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = text.xml; name = dg.xsl; path = w3/dg.xsl; sourceTree = SOURCE_ROOT; }; + CE0D67630ABC2D3E00E2FFD9 /* hardcoded.css */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = text; name = hardcoded.css; path = w3/hardcoded.css; sourceTree = SOURCE_ROOT; }; + CE381C9409914ACE003581CE /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = SOURCE_ROOT; }; + CE381C9509914ACE003581CE /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = SOURCE_ROOT; }; + CE381C9A09914ADF003581CE /* ResultWindow.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = ResultWindow.m; sourceTree = SOURCE_ROOT; }; + CE381C9B09914ADF003581CE /* ResultWindow.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = ResultWindow.h; sourceTree = SOURCE_ROOT; }; + CE381CF509915304003581CE /* dg_cocoa.plugin */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dg_cocoa.plugin; path = py/dist/dg_cocoa.plugin; sourceTree = SOURCE_ROOT; }; + CE3AA46609DB207900DB3A21 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/Directories.nib; sourceTree = ""; }; + CE45579A0AE3BC2B005A9546 /* Sparkle.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Sparkle.framework; path = /Library/Frameworks/Sparkle.framework; sourceTree = ""; }; + CE68EE6509ABC48000971085 /* DirectoryPanel.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = DirectoryPanel.h; sourceTree = SOURCE_ROOT; }; + CE68EE6609ABC48000971085 /* DirectoryPanel.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = DirectoryPanel.m; sourceTree = SOURCE_ROOT; }; + CE848A1809DD85810004CB44 /* Consts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Consts.h; sourceTree = ""; }; + CECA899809DB12CA00A3D774 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = English.lproj/Details.nib; sourceTree = ""; }; + CECA899A09DB132E00A3D774 /* DetailsPanel.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = DetailsPanel.h; sourceTree = ""; }; + CECA899B09DB132E00A3D774 /* DetailsPanel.m */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.objc; path = DetailsPanel.m; sourceTree = ""; }; + CEEB135109C837A2004D2330 /* dupeguru.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = dupeguru.icns; sourceTree = ""; }; + CEF7823709C8AA0200EF38FF /* gear.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = gear.png; path = images/gear.png; sourceTree = ""; }; + CEFC294509C89E3D00D9F998 /* folder32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = folder32.png; path = images/folder32.png; sourceTree = SOURCE_ROOT; }; + CEFC295309C89FF200D9F998 /* details32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = details32.png; path = images/details32.png; sourceTree = SOURCE_ROOT; }; + CEFC295409C89FF200D9F998 /* preferences32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = preferences32.png; path = images/preferences32.png; sourceTree = SOURCE_ROOT; }; + CEFC295D09C8A0B000D9F998 /* dg_logo32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = dg_logo32.png; path = images/dg_logo32.png; sourceTree = SOURCE_ROOT; }; + CEFC7F8A0FC9517500CD5728 /* Dialogs.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Dialogs.h; path = cocoalib/Dialogs.h; sourceTree = SOURCE_ROOT; }; + CEFC7F8B0FC9517500CD5728 /* Dialogs.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Dialogs.m; path = cocoalib/Dialogs.m; sourceTree = SOURCE_ROOT; }; + CEFC7F8C0FC9517500CD5728 /* HSErrorReportWindow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = HSErrorReportWindow.h; path = cocoalib/HSErrorReportWindow.h; sourceTree = SOURCE_ROOT; }; + CEFC7F8D0FC9517500CD5728 /* HSErrorReportWindow.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = HSErrorReportWindow.m; path = cocoalib/HSErrorReportWindow.m; sourceTree = SOURCE_ROOT; }; + CEFC7F8E0FC9517500CD5728 /* Outline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Outline.h; path = cocoalib/Outline.h; sourceTree = SOURCE_ROOT; }; + CEFC7F8F0FC9517500CD5728 /* Outline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Outline.m; path = cocoalib/Outline.m; sourceTree = SOURCE_ROOT; }; + CEFC7F900FC9517500CD5728 /* ProgressController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ProgressController.h; path = cocoalib/ProgressController.h; sourceTree = SOURCE_ROOT; }; + CEFC7F910FC9517500CD5728 /* ProgressController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ProgressController.m; path = cocoalib/ProgressController.m; sourceTree = SOURCE_ROOT; }; + CEFC7F920FC9517500CD5728 /* PyApp.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PyApp.h; path = cocoalib/PyApp.h; sourceTree = SOURCE_ROOT; }; + CEFC7F930FC9517500CD5728 /* PyRegistrable.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PyRegistrable.h; path = cocoalib/PyRegistrable.h; sourceTree = SOURCE_ROOT; }; + CEFC7F940FC9517500CD5728 /* RecentDirectories.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RecentDirectories.h; path = cocoalib/RecentDirectories.h; sourceTree = SOURCE_ROOT; }; + CEFC7F950FC9517500CD5728 /* RecentDirectories.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RecentDirectories.m; path = cocoalib/RecentDirectories.m; sourceTree = SOURCE_ROOT; }; + CEFC7F960FC9517500CD5728 /* RegistrationInterface.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RegistrationInterface.h; path = cocoalib/RegistrationInterface.h; sourceTree = SOURCE_ROOT; }; + CEFC7F970FC9517500CD5728 /* RegistrationInterface.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RegistrationInterface.m; path = cocoalib/RegistrationInterface.m; sourceTree = SOURCE_ROOT; }; + CEFC7F980FC9517500CD5728 /* Table.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Table.h; path = cocoalib/Table.h; sourceTree = SOURCE_ROOT; }; + CEFC7F990FC9517500CD5728 /* Table.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Table.m; path = cocoalib/Table.m; sourceTree = SOURCE_ROOT; }; + CEFC7F9A0FC9517500CD5728 /* Utils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Utils.h; path = cocoalib/Utils.h; sourceTree = SOURCE_ROOT; }; + CEFC7F9B0FC9517500CD5728 /* Utils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Utils.m; path = cocoalib/Utils.m; sourceTree = SOURCE_ROOT; }; + CEFC7F9C0FC9517500CD5728 /* ValueTransformers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ValueTransformers.h; path = cocoalib/ValueTransformers.h; sourceTree = SOURCE_ROOT; }; + CEFC7F9D0FC9517500CD5728 /* ValueTransformers.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ValueTransformers.m; path = cocoalib/ValueTransformers.m; sourceTree = SOURCE_ROOT; }; + CEFC7FA80FC9518A00CD5728 /* English */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = English; path = cocoalib/English.lproj/ErrorReportWindow.xib; sourceTree = ""; }; + CEFC7FAA0FC9518A00CD5728 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = cocoalib/English.lproj/progress.nib; sourceTree = ""; }; + CEFC7FAC0FC9518A00CD5728 /* English */ = {isa = PBXFileReference; lastKnownFileType = wrapper.nib; name = English; path = cocoalib/English.lproj/registration.nib; sourceTree = ""; }; + CEFC7FB10FC951A700CD5728 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = dgbase/AppDelegate.h; sourceTree = SOURCE_ROOT; }; + CEFC7FB20FC951A700CD5728 /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = dgbase/AppDelegate.m; sourceTree = SOURCE_ROOT; }; + CEFC7FB30FC951A700CD5728 /* Consts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Consts.h; path = dgbase/Consts.h; sourceTree = SOURCE_ROOT; }; + CEFC7FB40FC951A700CD5728 /* DirectoryPanel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = DirectoryPanel.h; path = dgbase/DirectoryPanel.h; sourceTree = SOURCE_ROOT; }; + CEFC7FB50FC951A700CD5728 /* DirectoryPanel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = DirectoryPanel.m; path = dgbase/DirectoryPanel.m; sourceTree = SOURCE_ROOT; }; + CEFC7FB60FC951A700CD5728 /* PyDupeGuru.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PyDupeGuru.h; path = dgbase/PyDupeGuru.h; sourceTree = SOURCE_ROOT; }; + CEFC7FB70FC951A700CD5728 /* ResultWindow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ResultWindow.h; path = dgbase/ResultWindow.h; sourceTree = SOURCE_ROOT; }; + CEFC7FB80FC951A700CD5728 /* ResultWindow.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ResultWindow.m; path = dgbase/ResultWindow.m; sourceTree = SOURCE_ROOT; }; + CEFF18A009A4D387005E6321 /* PyDupeGuru.h */ = {isa = PBXFileReference; fileEncoding = 5; lastKnownFileType = sourcecode.c.h; path = PyDupeGuru.h; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8D11072E0486CEB800E47090 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */, + CE45579B0AE3BC2B005A9546 /* Sparkle.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 080E96DDFE201D6D7F000001 /* DGSE */ = { + isa = PBXGroup; + children = ( + CE381C9509914ACE003581CE /* AppDelegate.h */, + CE381C9409914ACE003581CE /* AppDelegate.m */, + CE848A1809DD85810004CB44 /* Consts.h */, + CECA899A09DB132E00A3D774 /* DetailsPanel.h */, + CECA899B09DB132E00A3D774 /* DetailsPanel.m */, + CE68EE6509ABC48000971085 /* DirectoryPanel.h */, + CE68EE6609ABC48000971085 /* DirectoryPanel.m */, + CEFF18A009A4D387005E6321 /* PyDupeGuru.h */, + CE381C9B09914ADF003581CE /* ResultWindow.h */, + CE381C9A09914ADF003581CE /* ResultWindow.m */, + 29B97316FDCFA39411CA2CEA /* main.m */, + ); + name = DGSE; + sourceTree = ""; + }; + 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */ = { + isa = PBXGroup; + children = ( + CE45579A0AE3BC2B005A9546 /* Sparkle.framework */, + 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */, + ); + name = "Linked Frameworks"; + sourceTree = ""; + }; + 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */ = { + isa = PBXGroup; + children = ( + 29B97324FDCFA39411CA2CEA /* AppKit.framework */, + 13E42FB307B3F0F600E4EEF1 /* CoreData.framework */, + 29B97325FDCFA39411CA2CEA /* Foundation.framework */, + ); + name = "Other Frameworks"; + sourceTree = ""; + }; + 19C28FACFE9D520D11CA2CBB /* Products */ = { + isa = PBXGroup; + children = ( + 8D1107320486CEB800E47090 /* dupeGuru.app */, + ); + name = Products; + sourceTree = ""; + }; + 29B97314FDCFA39411CA2CEA /* dupeguru */ = { + isa = PBXGroup; + children = ( + 080E96DDFE201D6D7F000001 /* DGSE */, + CEFC7FB00FC9518F00CD5728 /* dgbase */, + CEFC7F890FC9513600CD5728 /* cocoalib */, + 29B97317FDCFA39411CA2CEA /* Resources */, + 29B97323FDCFA39411CA2CEA /* Frameworks */, + 19C28FACFE9D520D11CA2CBB /* Products */, + ); + name = dupeguru; + sourceTree = ""; + }; + 29B97317FDCFA39411CA2CEA /* Resources */ = { + isa = PBXGroup; + children = ( + CE073F5409CAE1A3005C1D2F /* dupeguru_help */, + CE381CF509915304003581CE /* dg_cocoa.plugin */, + CEFC294309C89E0000D9F998 /* images */, + CE0D67610ABC2D3E00E2FFD9 /* w3 */, + CEEB135109C837A2004D2330 /* dupeguru.icns */, + 8D1107310486CEB800E47090 /* Info.plist */, + 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */, + CECA899709DB12CA00A3D774 /* Details.nib */, + CE3AA46509DB207900DB3A21 /* Directories.nib */, + 29B97318FDCFA39411CA2CEA /* MainMenu.nib */, + ); + name = Resources; + sourceTree = ""; + }; + 29B97323FDCFA39411CA2CEA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */, + 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */, + ); + name = Frameworks; + sourceTree = ""; + }; + CE0D67610ABC2D3E00E2FFD9 /* w3 */ = { + isa = PBXGroup; + children = ( + CE0D67620ABC2D3E00E2FFD9 /* dg.xsl */, + CE0D67630ABC2D3E00E2FFD9 /* hardcoded.css */, + ); + path = w3; + sourceTree = ""; + }; + CEFC294309C89E0000D9F998 /* images */ = { + isa = PBXGroup; + children = ( + CEF7823709C8AA0200EF38FF /* gear.png */, + CEFC295D09C8A0B000D9F998 /* dg_logo32.png */, + CEFC295309C89FF200D9F998 /* details32.png */, + CEFC295409C89FF200D9F998 /* preferences32.png */, + CEFC294509C89E3D00D9F998 /* folder32.png */, + ); + name = images; + sourceTree = ""; + }; + CEFC7F890FC9513600CD5728 /* cocoalib */ = { + isa = PBXGroup; + children = ( + CEFC7FA70FC9518A00CD5728 /* ErrorReportWindow.xib */, + CEFC7FA90FC9518A00CD5728 /* progress.nib */, + CEFC7FAB0FC9518A00CD5728 /* registration.nib */, + CEFC7F8A0FC9517500CD5728 /* Dialogs.h */, + CEFC7F8B0FC9517500CD5728 /* Dialogs.m */, + CEFC7F8C0FC9517500CD5728 /* HSErrorReportWindow.h */, + CEFC7F8D0FC9517500CD5728 /* HSErrorReportWindow.m */, + CEFC7F8E0FC9517500CD5728 /* Outline.h */, + CEFC7F8F0FC9517500CD5728 /* Outline.m */, + CEFC7F900FC9517500CD5728 /* ProgressController.h */, + CEFC7F910FC9517500CD5728 /* ProgressController.m */, + CEFC7F920FC9517500CD5728 /* PyApp.h */, + CEFC7F930FC9517500CD5728 /* PyRegistrable.h */, + CEFC7F940FC9517500CD5728 /* RecentDirectories.h */, + CEFC7F950FC9517500CD5728 /* RecentDirectories.m */, + CEFC7F960FC9517500CD5728 /* RegistrationInterface.h */, + CEFC7F970FC9517500CD5728 /* RegistrationInterface.m */, + CEFC7F980FC9517500CD5728 /* Table.h */, + CEFC7F990FC9517500CD5728 /* Table.m */, + CEFC7F9A0FC9517500CD5728 /* Utils.h */, + CEFC7F9B0FC9517500CD5728 /* Utils.m */, + CEFC7F9C0FC9517500CD5728 /* ValueTransformers.h */, + CEFC7F9D0FC9517500CD5728 /* ValueTransformers.m */, + ); + name = cocoalib; + sourceTree = ""; + }; + CEFC7FB00FC9518F00CD5728 /* dgbase */ = { + isa = PBXGroup; + children = ( + CEFC7FB10FC951A700CD5728 /* AppDelegate.h */, + CEFC7FB20FC951A700CD5728 /* AppDelegate.m */, + CEFC7FB30FC951A700CD5728 /* Consts.h */, + CEFC7FB40FC951A700CD5728 /* DirectoryPanel.h */, + CEFC7FB50FC951A700CD5728 /* DirectoryPanel.m */, + CEFC7FB60FC951A700CD5728 /* PyDupeGuru.h */, + CEFC7FB70FC951A700CD5728 /* ResultWindow.h */, + CEFC7FB80FC951A700CD5728 /* ResultWindow.m */, + ); + name = dgbase; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8D1107260486CEB800E47090 /* dupeguru */ = { + isa = PBXNativeTarget; + buildConfigurationList = C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "dupeguru" */; + buildPhases = ( + 8D1107290486CEB800E47090 /* Resources */, + 8D11072C0486CEB800E47090 /* Sources */, + 8D11072E0486CEB800E47090 /* Frameworks */, + CECC02B709A36E8200CC0A94 /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = dupeguru; + productInstallPath = "$(HOME)/Applications"; + productName = dupeguru; + productReference = 8D1107320486CEB800E47090 /* dupeGuru.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 29B97313FDCFA39411CA2CEA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = NO; + }; + buildConfigurationList = C01FCF4E08A954540054247B /* Build configuration list for PBXProject "dupeguru" */; + compatibilityVersion = "Xcode 3.0"; + hasScannedForEncodings = 1; + mainGroup = 29B97314FDCFA39411CA2CEA /* dupeguru */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8D1107260486CEB800E47090 /* dupeguru */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8D1107290486CEB800E47090 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072A0486CEB800E47090 /* MainMenu.nib in Resources */, + 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */, + CE381D0509915304003581CE /* dg_cocoa.plugin in Resources */, + CE073F6309CAE1A3005C1D2F /* dupeguru_help in Resources */, + CEEB135209C837A2004D2330 /* dupeguru.icns in Resources */, + CEFC294609C89E3D00D9F998 /* folder32.png in Resources */, + CEFC295509C89FF200D9F998 /* details32.png in Resources */, + CEFC295609C89FF200D9F998 /* preferences32.png in Resources */, + CEFC295E09C8A0B000D9F998 /* dg_logo32.png in Resources */, + CEF7823809C8AA0200EF38FF /* gear.png in Resources */, + CECA899909DB12CA00A3D774 /* Details.nib in Resources */, + CE3AA46709DB207900DB3A21 /* Directories.nib in Resources */, + CE0D67640ABC2D3E00E2FFD9 /* dg.xsl in Resources */, + CE0D67650ABC2D3E00E2FFD9 /* hardcoded.css in Resources */, + CEFC7FAD0FC9518A00CD5728 /* ErrorReportWindow.xib in Resources */, + CEFC7FAE0FC9518A00CD5728 /* progress.nib in Resources */, + CEFC7FAF0FC9518A00CD5728 /* registration.nib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8D11072C0486CEB800E47090 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D11072D0486CEB800E47090 /* main.m in Sources */, + CE381C9609914ACE003581CE /* AppDelegate.m in Sources */, + CE381C9C09914ADF003581CE /* ResultWindow.m in Sources */, + CE68EE6809ABC48000971085 /* DirectoryPanel.m in Sources */, + CECA899D09DB132E00A3D774 /* DetailsPanel.m in Sources */, + CEFC7F9E0FC9517500CD5728 /* Dialogs.m in Sources */, + CEFC7F9F0FC9517500CD5728 /* HSErrorReportWindow.m in Sources */, + CEFC7FA00FC9517500CD5728 /* Outline.m in Sources */, + CEFC7FA10FC9517500CD5728 /* ProgressController.m in Sources */, + CEFC7FA20FC9517500CD5728 /* RecentDirectories.m in Sources */, + CEFC7FA30FC9517500CD5728 /* RegistrationInterface.m in Sources */, + CEFC7FA40FC9517500CD5728 /* Table.m in Sources */, + CEFC7FA50FC9517500CD5728 /* Utils.m in Sources */, + CEFC7FA60FC9517500CD5728 /* ValueTransformers.m in Sources */, + CEFC7FB90FC951A700CD5728 /* AppDelegate.m in Sources */, + CEFC7FBA0FC951A700CD5728 /* DirectoryPanel.m in Sources */, + CEFC7FBB0FC951A700CD5728 /* ResultWindow.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 089C165DFE840E0CC02AAC07 /* English */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + 29B97318FDCFA39411CA2CEA /* MainMenu.nib */ = { + isa = PBXVariantGroup; + children = ( + 29B97319FDCFA39411CA2CEA /* English */, + ); + name = MainMenu.nib; + sourceTree = SOURCE_ROOT; + }; + CE3AA46509DB207900DB3A21 /* Directories.nib */ = { + isa = PBXVariantGroup; + children = ( + CE3AA46609DB207900DB3A21 /* English */, + ); + name = Directories.nib; + sourceTree = ""; + }; + CECA899709DB12CA00A3D774 /* Details.nib */ = { + isa = PBXVariantGroup; + children = ( + CECA899809DB12CA00A3D774 /* English */, + ); + name = Details.nib; + sourceTree = ""; + }; + CEFC7FA70FC9518A00CD5728 /* ErrorReportWindow.xib */ = { + isa = PBXVariantGroup; + children = ( + CEFC7FA80FC9518A00CD5728 /* English */, + ); + name = ErrorReportWindow.xib; + sourceTree = SOURCE_ROOT; + }; + CEFC7FA90FC9518A00CD5728 /* progress.nib */ = { + isa = PBXVariantGroup; + children = ( + CEFC7FAA0FC9518A00CD5728 /* English */, + ); + name = progress.nib; + sourceTree = SOURCE_ROOT; + }; + CEFC7FAB0FC9518A00CD5728 /* registration.nib */ = { + isa = PBXVariantGroup; + children = ( + CEFC7FAC0FC9518A00CD5728 /* English */, + ); + name = registration.nib; + sourceTree = SOURCE_ROOT; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + C01FCF4B08A954540054247B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COPY_PHASE_STRIP = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(FRAMEWORK_SEARCH_PATHS)", + "$(SRCROOT)/../../../cocoalib/build/Release", + "$(FRAMEWORK_SEARCH_PATHS_QUOTED_FOR_TARGET_1)", + ); + FRAMEWORK_SEARCH_PATHS_QUOTED_FOR_TARGET_1 = "\"$(SRCROOT)/../../base/cocoa/build/Release\""; + GCC_DYNAMIC_NO_PIC = NO; + GCC_ENABLE_FIX_AND_CONTINUE = YES; + GCC_MODEL_TUNING = G5; + GCC_OPTIMIZATION_LEVEL = 0; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(HOME)/Applications"; + PRODUCT_NAME = dupeGuru; + WRAPPER_EXTENSION = app; + ZERO_LINK = YES; + }; + name = Debug; + }; + C01FCF4C08A954540054247B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = ( + ppc, + i386, + ); + FRAMEWORK_SEARCH_PATHS = ( + "$(FRAMEWORK_SEARCH_PATHS)", + "$(SRCROOT)/../../../cocoalib/build/Release", + "$(FRAMEWORK_SEARCH_PATHS_QUOTED_FOR_TARGET_1)", + ); + FRAMEWORK_SEARCH_PATHS_QUOTED_FOR_TARGET_1 = "\"$(SRCROOT)/../../base/cocoa/build/Release\""; + GCC_GENERATE_DEBUGGING_SYMBOLS = NO; + GCC_MODEL_TUNING = G5; + INFOPLIST_FILE = Info.plist; + INSTALL_PATH = "$(HOME)/Applications"; + PRODUCT_NAME = dupeGuru; + WRAPPER_EXTENSION = app; + }; + name = Release; + }; + C01FCF4F08A954540054247B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + GCC_C_LANGUAGE_STANDARD = c99; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.4; + PREBINDING = NO; + SDKROOT = "$(DEVELOPER_SDK_DIR)/MacOSX10.4u.sdk"; + }; + name = Debug; + }; + C01FCF5008A954540054247B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = ( + ppc, + i386, + ); + FRAMEWORK_SEARCH_PATHS = "@executable_path/../Frameworks"; + GCC_C_LANGUAGE_STANDARD = c99; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.4; + PREBINDING = NO; + SDKROOT = "$(DEVELOPER_SDK_DIR)/MacOSX10.4u.sdk"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "dupeguru" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C01FCF4B08A954540054247B /* Debug */, + C01FCF4C08A954540054247B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C01FCF4E08A954540054247B /* Build configuration list for PBXProject "dupeguru" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C01FCF4F08A954540054247B /* Debug */, + C01FCF5008A954540054247B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 29B97313FDCFA39411CA2CEA /* Project object */; +} diff --git a/se/cocoa/gen.py b/se/cocoa/gen.py new file mode 100644 index 00000000..4101b2a0 --- /dev/null +++ b/se/cocoa/gen.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +import os + +print "Generating help" +os.chdir('help') +os.system('python -u gen.py') +os.system('/Developer/Applications/Utilities/Help\\ Indexer.app/Contents/MacOS/Help\\ Indexer dupeguru_help') +os.chdir('..') + +print "Generating py plugin" +os.chdir('py') +os.system('python -u gen.py') +os.chdir('..') \ No newline at end of file diff --git a/se/cocoa/main.m b/se/cocoa/main.m new file mode 100644 index 00000000..c5f30658 --- /dev/null +++ b/se/cocoa/main.m @@ -0,0 +1,21 @@ +// +// main.m +// dupeguru +// +// Created by Virgil Dupras on 2006/02/01. +// Copyright __MyCompanyName__ 2006. All rights reserved. +// + +#import + +int main(int argc, char *argv[]) +{ + NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; + NSString *pluginPath = [[NSBundle mainBundle] + pathForResource:@"dg_cocoa" + ofType:@"plugin"]; + NSBundle *pluginBundle = [NSBundle bundleWithPath:pluginPath]; + [pluginBundle load]; + [pool release]; + return NSApplicationMain(argc, (const char **) argv); +} diff --git a/se/cocoa/py/dg_cocoa.py b/se/cocoa/py/dg_cocoa.py new file mode 100644 index 00000000..63a4df2c --- /dev/null +++ b/se/cocoa/py/dg_cocoa.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +import objc +from AppKit import * + +from dupeguru import app_se_cocoa, scanner + +# Fix py2app imports with chokes on relative imports +from dupeguru import app, app_cocoa, data, directories, engine, export, ignore, results, scanner +from hsfs import auto, manual, stats, tree, utils +from hsfs.phys import bundle + +class PyApp(NSObject): + pass #fake class + +class PyDupeGuru(PyApp): + def init(self): + self = super(PyDupeGuru,self).init() + self.app = app_se_cocoa.DupeGuru() + return self + + #---Directories + def addDirectory_(self,directory): + return self.app.AddDirectory(directory) + + def removeDirectory_(self,index): + self.app.RemoveDirectory(index) + + def setDirectory_state_(self,node_path,state): + self.app.SetDirectoryState(node_path,state) + + #---Results + def clearIgnoreList(self): + self.app.scanner.ignore_list.Clear() + + def doScan(self): + return self.app.start_scanning() + + def exportToXHTMLwithColumns_xslt_css_(self,column_ids,xslt_path,css_path): + return self.app.ExportToXHTML(column_ids,xslt_path,css_path) + + def loadIgnoreList(self): + self.app.LoadIgnoreList() + + def loadResults(self): + self.app.load() + + def markAll(self): + self.app.results.mark_all() + + def markNone(self): + self.app.results.mark_none() + + def markInvert(self): + self.app.results.mark_invert() + + def purgeIgnoreList(self): + self.app.PurgeIgnoreList() + + def toggleSelectedMark(self): + self.app.ToggleSelectedMarkState() + + def saveIgnoreList(self): + self.app.SaveIgnoreList() + + def saveResults(self): + self.app.Save() + + def refreshDetailsWithSelected(self): + self.app.RefreshDetailsWithSelected() + + def selectResultNodePaths_(self,node_paths): + self.app.SelectResultNodePaths(node_paths) + + def selectPowerMarkerNodePaths_(self,node_paths): + self.app.SelectPowerMarkerNodePaths(node_paths) + + #---Actions + def addSelectedToIgnoreList(self): + self.app.AddSelectedToIgnoreList() + + def deleteMarked(self): + self.app.delete_marked() + + def applyFilter_(self, filter): + self.app.ApplyFilter(filter) + + def makeSelectedReference(self): + self.app.MakeSelectedReference() + + def copyOrMove_markedTo_recreatePath_(self,copy,destination,recreate_path): + self.app.copy_or_move_marked(copy, destination, recreate_path) + + def openSelected(self): + self.app.OpenSelected() + + def removeMarked(self): + self.app.results.perform_on_marked(lambda x:True, True) + + def removeSelected(self): + self.app.RemoveSelected() + + def renameSelected_(self,newname): + return self.app.RenameSelected(newname) + + def revealSelected(self): + self.app.RevealSelected() + + #---Misc + def sortDupesBy_ascending_(self,key,asc): + self.app.sort_dupes(key,asc) + + def sortGroupsBy_ascending_(self,key,asc): + self.app.sort_groups(key,asc) + + #---Information + def getIgnoreListCount(self): + return len(self.app.scanner.ignore_list) + + def getMarkCount(self): + return self.app.results.mark_count + + def getStatLine(self): + return self.app.stat_line + + def getOperationalErrorCount(self): + return self.app.last_op_error_count + + #---Data + @objc.signature('i@:i') + def getOutlineViewMaxLevel_(self, tag): + return self.app.GetOutlineViewMaxLevel(tag) + + @objc.signature('@@:i@') + def getOutlineView_childCountsForPath_(self, tag, node_path): + return self.app.GetOutlineViewChildCounts(tag, node_path) + + def getOutlineView_valuesForIndexes_(self,tag,node_path): + return self.app.GetOutlineViewValues(tag,node_path) + + def getOutlineView_markedAtIndexes_(self,tag,node_path): + return self.app.GetOutlineViewMarked(tag,node_path) + + def getTableViewCount_(self,tag): + return self.app.GetTableViewCount(tag) + + def getTableViewMarkedIndexes_(self,tag): + return self.app.GetTableViewMarkedIndexes(tag) + + def getTableView_valuesForRow_(self,tag,row): + return self.app.GetTableViewValues(tag,row) + + #---Properties + def setMinMatchPercentage_(self,percentage): + self.app.scanner.min_match_percentage = int(percentage) + + def setScanType_(self,scan_type): + try: + self.app.scanner.scan_type = [ + scanner.SCAN_TYPE_FILENAME, + scanner.SCAN_TYPE_CONTENT + ][scan_type] + except IndexError: + pass + + def setWordWeighting_(self,words_are_weighted): + self.app.scanner.word_weighting = words_are_weighted + + def setMixFileKind_(self,mix_file_kind): + self.app.scanner.mix_file_kind = mix_file_kind + + def setDisplayDeltaValues_(self,display_delta_values): + self.app.display_delta_values= display_delta_values + + def setMatchSimilarWords_(self,match_similar_words): + self.app.scanner.match_similar_words = match_similar_words + + def setEscapeFilterRegexp_(self, escape_filter_regexp): + self.app.options['escape_filter_regexp'] = escape_filter_regexp + + def setRemoveEmptyFolders_(self, remove_empty_folders): + self.app.options['clean_empty_dirs'] = remove_empty_folders + + @objc.signature('v@:i') + def setSizeThreshold_(self, size_threshold): + self.app.scanner.size_threshold = size_threshold + + #---Worker + def getJobProgress(self): + return self.app.progress.last_progress + + def getJobDesc(self): + return self.app.progress.last_desc + + def cancelJob(self): + self.app.progress.job_cancelled = True + + #---Registration + @objc.signature('i@:') + def isRegistered(self): + return self.app.registered + + @objc.signature('i@:@@') + def isCodeValid_withEmail_(self, code, email): + return self.app.is_code_valid(code, email) + + def setRegisteredCode_andEmail_(self, code, email): + self.app.set_registration(code, email) + diff --git a/se/cocoa/py/gen.py b/se/cocoa/py/gen.py new file mode 100644 index 00000000..2279eb36 --- /dev/null +++ b/se/cocoa/py/gen.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python + +import os +import os.path as op +import shutil + +print "Cleaning build and dist" +if op.exists('build'): + shutil.rmtree('build') +if op.exists('dist'): + shutil.rmtree('dist') + +print "Buiding the py2app plugin" + +os.system('python -u setup.py py2app') \ No newline at end of file diff --git a/se/cocoa/py/setup.py b/se/cocoa/py/setup.py new file mode 100644 index 00000000..6d37e3c5 --- /dev/null +++ b/se/cocoa/py/setup.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +from distutils.core import setup +import py2app + +from hsutil.build import move_testdata_out, put_testdata_back + +move_log = move_testdata_out() +try: + setup( + plugin = ['dg_cocoa.py'], + ) +finally: + put_testdata_back(move_log) \ No newline at end of file diff --git a/se/cocoa/w3/dg.xsl b/se/cocoa/w3/dg.xsl new file mode 100644 index 00000000..4f982fce --- /dev/null +++ b/se/cocoa/w3/dg.xsl @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + indented + + + + + + + + + + + + + + + + + + + + + + + + + + + + dupeGuru Results + + + +

dupeGuru Results

+ + + + + +
+ + + + + \ No newline at end of file diff --git a/se/cocoa/w3/hardcoded.css b/se/cocoa/w3/hardcoded.css new file mode 100644 index 00000000..ed243bcc --- /dev/null +++ b/se/cocoa/w3/hardcoded.css @@ -0,0 +1,71 @@ +BODY +{ + background-color:white; +} + +BODY,A,P,UL,TABLE,TR,TD +{ + font-family:Tahoma,Arial,sans-serif; + font-size:10pt; + color: #4477AA; +} + +TABLE +{ + background-color: #225588; + margin-left: auto; + margin-right: auto; + width: 90%; +} + +TR +{ + background-color: white; +} + +TH +{ + font-weight: bold; + color: black; + background-color: #C8D6E5; +} + +TH TD +{ + color:black; +} + +TD +{ + padding-left: 2pt; +} + +TD.rightelem +{ + text-align:right; + /*padding-left:0pt;*/ + padding-right: 2pt; + width: 17%; +} + +TD.indented +{ + padding-left: 12pt; +} + +H1 +{ + font-family:"Courier New",monospace; + color:#6699CC; + font-size:18pt; + color:#6da500; + border-color: #70A0CF; + border-width: 1pt; + border-style: solid; + margin-top: 16pt; + margin-left: 5%; + margin-right: 5%; + padding-top: 2pt; + padding-bottom:2pt; + text-align: center; +} \ No newline at end of file diff --git a/se/help/changelog.yaml b/se/help/changelog.yaml new file mode 100644 index 00000000..8bb0a392 --- /dev/null +++ b/se/help/changelog.yaml @@ -0,0 +1,230 @@ +- date: 2009-05-29 + version: 2.7.1 + description: | + * Fixed a bug causing crashes when having application files in the results. + * Fixed a bug causing a GUI freeze at the beginning of a scan with a lot of files. + * Fixed a bug that sometimes caused a crash when an action was cancelled, and then started again. +- date: 2009-05-25 + version: 2.7.0 + description: | + * Converted the Windows GUI to Qt. + * Improved the reliability of the scanning process. +- date: 2009-03-27 + version: 2.6.1 + description: | + * **Fixed** an occasional crash caused by permission issues. + * **Fixed** a bug where the "X discarded" notice would show a too large number of discarded + duplicates. +- date: 2008-09-10 + description: "* **Added** a small file threshold preference.\r\n* **Added** a notice\ + \ in the status bar when matches were discarded during the scan.\r\n* **Improved**\ + \ duplicate prioritization (smartly chooses which file you will keep).\r\n* **Improved**\ + \ scan progress feedback.\r\n* **Improved** responsiveness of the user interface\ + \ for certain actions." + version: 2.6.0 +- date: 2008-08-10 + description: "
    \n\t\t\t\t\t\t
  • Improved the speed of results loading\ + \ and saving.
  • \n\t\t\t\t\t\t
  • Fixed a crash sometimes occurring during\ + \ duplicate deletion.
  • \n\t\t
" + version: 2.5.4 +- date: 2008-07-08 + description: "
    \n\t\t\t\t\t\t
  • Improved unicode handling for filenames.\ + \ dupeGuru will now find a lot more duplicates if your files have non-ascii characters\ + \ in it.
  • \n\t\t\t\t\t\t
  • Fixed \"Clear Ignore List\" crash in Windows.
  • \n\ + \t\t
" + version: 2.5.3 +- date: 2008-01-10 + description: "
    \n\t\t\t\t\t\t
  • Improved the handling of low memory situations.
  • \n\ + \t\t\t\t\t\t
  • Improved the directory panel. The \"Remove\" button changes\ + \ to \"Put Back\" when an excluded directory is selected.
  • \n\t\t\t\t\t\t
  • Improved\ + \ scan, delete and move speed in situations where there were a lot of duplicates.
  • \n\ + \t\t\t\t\t\t
  • Fixed occasional crashes when moving bundles (such as .app\ + \ files).
  • \n\t\t\t\t\t\t
  • Fixed occasional crashes when moving a\ + \ lot of files at once.
  • \n\t\t
" + version: 2.5.2 +- date: 2007-11-22 + description: "
    \n\t\t\t\t\t\t
  • Added the \"Remove empty folders\" option.
  • \n\ + \t\t\t\t\t\t
  • Fixed results load/save issues.
  • \n\t\t\t\t\t\t
  • Fixed\ + \ occasional status bar inaccuracies when the results are filtered.
  • \n\t\t\ + \
" + version: 2.5.1 +- date: 2007-09-15 + description: "
    \n\t\t\t\t\t\t
  • Added post scan filtering.
  • \n\t\t\ + \t\t\t\t
  • Fixed issues with the rename feature under Windows
  • \n\t\ + \t\t\t\t\t
  • Fixed some user interface annoyances under Windows
  • \n\ + \t\t
" + version: 2.5.0 +- date: 2007-04-14 + description: "
    \n\t\t\t\t\t\t
  • Improved UI responsiveness (using threads)\ + \ under Mac OS X.
  • \n\t\t\t\t\t\t
  • Improved result load/save speed\ + \ and memory usage.
  • \n\t\t
" + version: 2.4.8 +- date: 2007-03-10 + description: "
    \n\t\t\t\t\t\t
  • Fixed a \"bad file descriptor\" error\ + \ occasionally popping up.
  • \n\t\t\t\t\t\t
  • Fixed a bug with non-latin\ + \ directory names.
  • \n\t\t
" + version: 2.4.7 +- date: 2007-02-10 + description: "
    \n\t\t\t\t\t\t
  • Added Re-orderable columns. In fact,\ + \ I re-added the feature which was lost in the C# conversion in 2.4.0 (Windows).
  • \n\ + \t\t\t\t\t\t
  • Changed the behavior of the scanning engine when setting\ + \ the hardness to 100. It will now only match files that have their words in the\ + \ same order.
  • \n\t\t\t\t\t\t
  • Fixed a bug with all the Delete/Move/Copy\ + \ actions with certain kinds of files.
  • \n\t\t
" + version: 2.4.6 +- date: 2007-01-11 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug with the Move action.
  • \n\ + \t\t
" + version: 2.4.5 +- date: 2007-01-07 + description: "
    \n\t\t\t\t\t\t
  • Fixed a \"ghosting\" bug. Dupes deleted\ + \ by dupeGuru would sometimes come back in subsequent scans (Windows).
  • \n\t\ + \t\t\t\t\t
  • Fixed bugs sometimes making dupeGuru crash when marking a\ + \ dupe (Windows).
  • \n\t\t\t\t\t\t
  • Fixed some minor visual glitches\ + \ (Windows).
  • \n\t\t
" + version: 2.4.4 +- date: 2006-12-08 + description: "
    \n\t\t\t\t\t\t
  • Fixed a mishandling of \".app\" files\ + \ (OS X).
  • \n\t\t\t\t\t\t
  • Fixed a bug preventing files from \"reference\"\ + \ directories to be displayed in blue in the results (Windows).
  • \n\t\t\t\t\ + \t\t
  • Fixed a bug preventing some files to be sent to the recycle bin\ + \ (Windows).
  • \n\t\t\t\t\t\t
  • Fixed a bug in the packaging preventing\ + \ certain Windows configurations to start dupeGuru at all.
  • \n\t\t \ + \
" + version: 2.4.3 +- date: 2006-11-18 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug with directory states.
  • \n\ + \t\t
" + version: 2.4.2 +- date: 2006-11-15 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug causing the ignore list not\ + \ to be saved.
  • \n\t\t\t\t\t\t
  • Fixed a bug sometimes making delete\ + \ and move operations stall.
  • \n\t\t
" + version: 2.4.1 +- date: 2006-11-10 + description: "
    \n\t\t\t\t\t\t
  • Changed the Windows interface. It is\ + \ now .NET based.
  • \n\t\t\t\t\t\t
  • Added an auto-update feature to\ + \ the windows version.
  • \n\t\t\t\t\t\t
  • Changed the way power marking\ + \ works. It is now a mode instead of a separate window.
  • \n\t\t\t\t\t\t
  • Changed\ + \ the \"Size (MB)\" column for a \"Size (KB)\" column. The values are now \"ceiled\"\ + \ instead of rounded. Therefore, a size \"0\" is now really 0 bytes, not just\ + \ a value too small to be rounded up. It is also the case for delta values.
  • \n\ + \t\t\t\t\t\t
  • Removed the min word length/count options. These came from\ + \ Mp3 Filter, and just aren't used anymore. Word weighting does pretty much the\ + \ same job.
  • \n\t\t
" + version: 2.4.0 +- date: 2006-11-07 + description: "
    \n\t\t\t\t\t\t
  • Improved speed and memory usage of the\ + \ scanning engine, again. Does it mean there was a lot of improvements to be made?\ + \ Nah...
  • \n\t\t
" + version: 2.3.4 +- date: 2006-11-02 + description: "
    \n\t\t\t\t\t\t
  • Improved speed and memory usage of the\ + \ scanning engine, especially when the scan results in a lot of duplicates.
  • \n\ + \t\t\t\t\t\t
  • Now I wonder if Sparkle is going to work well...
  • \n\t\t \ + \
" + version: 2.3.3 +- date: 2006-10-16 + description: "
    \n\t\t\t\t\t\t
  • Added an auto-update feature in the Mac\ + \ OS X version (with Sparkle).
  • \n\t\t\t\t\t\t
  • Fixed a bug preventing\ + \ some duplicate reports to be created correctly under Windows.
  • \n\t\t \ + \
" + version: 2.3.2 +- date: 2006-10-02 + description: "
    \n\t\t\t\t\t\t
  • Fixed a bug preventing some duplicates\ + \ to be found, especially when scanning lots of files.
  • \n\t\t
" + version: 2.3.1 +- date: 2006-09-22 + description: "
    \n\t\t\t\t\t\t
  • Added XHTML export feature.
  • \n\t\t\ + \
" + version: 2.3.0 +- date: 2006-08-31 + description: "
    \n\t\t\t\t\t\t
  • Added sticky columns.
  • \n\t\t\t\t\t\ + \t
  • Fixed an issue with file caching between scans.
  • \n\t\t\t\t\t\t\ +
  • Fixed an issue preventing some duplicates from being deleted/moved/copied.
  • \n\ + \t\t
" + version: 2.2.10 +- date: 2006-08-27 + description: "
    \n\t\t\t\t\t\t
  • Fixed an issue with ignore list and unicode.
  • \n\ + \t\t\t\t\t\t
  • Fixed an issue with file attribute fetching sometimes causing\ + \ dupeGuru to crash.
  • \n\t\t\t\t\t\t
  • Fixed an issue in the directories\ + \ panel under Windows.
  • \n\t\t
" + version: 2.2.9 +- date: 2006-08-17 + description: "
    \n\t\t\t\t\t\t
  • Fixed an issue in the duplicate seeking\ + \ engine preventing some duplicates to be found.
  • \n\t\t
" + version: 2.2.8 +- date: 2006-08-12 + description: "
    \n\t\t\t\t\t\t
  • Improved unicode support.
  • \n\t\t\t\ + \t\t\t
  • Improved the \"Reveal in Finder\" (\"Open Containing Folder\"\ + \ in Windows) feature so it selects the file in the folder it opens.
  • \n\t\t\ + \
" + version: 2.2.7 +- date: 2006-08-07 + description: "
    \n\t\t\t\t\t\t
  • Improved the ignore list system.
  • \n\ + \t\t\t\t\t\t
  • dupeGuru is now a Universal application on Mac OS X.
  • \n\t\t\ + \
" + version: 2.2.6 +- date: 2006-07-26 + description: "
    \n\t\t\t\t\t\t
  • Improved application (.app) dupe detection\ + \ on Mac OS X.
  • \n\t\t\t\t\t\t
  • Fixed an issue that occasionally made\ + \ dupeGuru crash on startup.
  • \n\t\t
" + version: 2.2.5 +- date: 2006-06-27 + description: "
    \n\t\t\t\t\t\t
  • Fixed an issue with Move and Copy features.
  • \n\ + \t\t
" + version: 2.2.4 +- date: 2006-06-15 + description: "
    \n\t\t\t\t\t\t
  • Improved duplicate scanning speed.
  • \n\ + \t\t\t\t\t\t
  • Added a warning that a file couldn't be renamed if a file\ + \ with the same name already exists.
  • \n\t\t
" + version: 2.2.3 +- date: 2006-06-07 + description: "
    \n\t\t\t\t\t\t
  • Added \"Rename Selected\" feature.
  • \n\ + \t\t\t\t\t\t
  • Fixed some minor issues with \"Reload Last Results\" feature.
  • \n\ + \t\t\t\t\t\t
  • Fixed ignore list issues.
  • \n\t\t
" + version: 2.2.2 +- date: 2006-05-22 + description: "
    \n\t\t \t
  • Fixed occasional progress bar woes\ + \ under Windows.
  • \n\t\t\t\t\t\t
  • Fixed a bug in the registration\ + \ system under Windows.
  • \n\t\t\t\t\t\t
  • Nothing has been changed in the\ + \ Mac OS X version, but I want to keep version in sync.
  • \n\t\t \ + \
" + version: 2.2.1 +- date: 2006-05-10 + description: "
    \n\t\t \t
  • Added destination path re-creation\ + \ options.
  • \n\t\t\t\t\t\t
  • Added an ignore list.
  • \n\t\t\t\t\t\ + \t
  • Changed the main icon.
  • \n\t\t\t\t\t\t
  • Improved dramatically\ + \ the delta values feature.
  • \n\t\t
" + version: 2.2.0 +- date: 2006-04-18 + description: "
    \n\t\t \t
  • Added the \"Match similar words\"\ + \ option.
  • \n\t\t\t\t\t\t
  • Fixed Power marking issues under Mac.
  • \n\ + \t\t
" + version: 2.1.2 +- date: 2006-04-14 + description: "
    \n\t\t \t
  • Added the \"Display delta values\"\ + \ option.
  • \n\t\t\t\t\t\t
  • Improved Power marking sorting speed under\ + \ Mac.
  • \n\t\t\t\t\t\t
  • Fixed Power marking sorting issues.
  • \n\ + \t\t
" + version: 2.1.1 +- date: 2006-04-03 + description: "
    \n\t\t \t
  • Added the Power Marker feature.
  • \n\ + \t\t\t\t\t\t
  • Fixed a column sorting bug. The results would sometimes\ + \ lose their sort order.
  • \n\t\t\t\t\t\t
  • Fixed a bug with the Make\ + \ Reference feature. The results sometimes wasn't correctly refreshed after the\ + \ reference switch.
  • \n\t\t
" + version: 2.1.0 +- date: 2006-03-23 + description: "
    \n\t\t \t
  • Fixed an issue occasionally occurring\ + \ when trying to reload results from removable media that is no longer present.
  • \n\ + \t\t
" + version: 2.0.1 +- date: 2006-03-17 + description: "
    \n\t\t \t
  • Complete rewrite.
  • \n\t\t \ + \ \t
  • Now runs on Mac OS X.
  • \n\t\t
" + version: 2.0.0 +- date: 2004-09-24 + description: "
    \n\t\t \t
  • Initial release.
  • \n\t\t \ + \
" + version: 1.0.0 diff --git a/se/help/gen.py b/se/help/gen.py new file mode 100644 index 00000000..3f067c83 --- /dev/null +++ b/se/help/gen.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +# Unit Name: +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os + +from web import generate_help + +generate_help.main('.', 'dupeguru_help', force_render=True) diff --git a/se/help/skeleton/hardcoded.css b/se/help/skeleton/hardcoded.css new file mode 100644 index 00000000..a3b17c5b --- /dev/null +++ b/se/help/skeleton/hardcoded.css @@ -0,0 +1,409 @@ +/***************************************************** + General settings +*****************************************************/ + +BODY +{ + background-color:white; +} + +BODY,A,P,UL,TABLE,TR,TD +{ + font-family:Tahoma,Arial,sans-serif; + font-size:10pt; + color: #4477AA;/*darker than 5588bb for the sake of the eyes*/ +} + +/***************************************************** + "A" settings +*****************************************************/ + +A +{ + color: #ae322b; + text-decoration:underline; + font-weight:bold; +} + +A.glossaryword {color:#A0A0A0;} + +A.noline +{ + text-decoration: none; +} + + +/***************************************************** + Menu and mainframe settings +*****************************************************/ + +.maincontainer +{ + display:block; + margin-left:7%; + margin-right:7%; + padding-left:5px; + padding-right:0px; + border-color:#CCCCCC; + border-style:solid; + border-width:2px; + border-right-width:0px; + border-bottom-width:0px; + border-top-color:#ae322b; + vertical-align:top; +} + +TD.menuframe +{ + width:30%; +} + +.menu +{ + margin:4px 4px 4px 4px; + margin-top: 16pt; + border-color:gray; + border-width:1px; + border-style:dotted; + padding-top:10pt; + padding-bottom:10pt; + padding-right:6pt; +} + +.submenu +{ + list-style-type: none; + margin-left:26pt; + margin-top:0pt; + margin-bottom:0pt; + padding-left:0pt; +} + +A.menuitem,A.menuitem_selected +{ + font-size:14pt; + font-family:Tahoma,Arial,sans-serif; + font-weight:normal; + padding-left:10pt; + color:#5588bb; + margin-right:2pt; + margin-left:4pt; + text-decoration:none; +} + +A.menuitem_selected +{ + font-weight:bold; +} + +A.submenuitem +{ + font-family:Tahoma,Arial,sans-serif; + font-weight:normal; + color:#5588bb; + text-decoration:none; +} + +.titleline +{ + border-width:3px; + border-style:solid; + border-left-width:0px; + border-right-width:0px; + border-top-width:0px; + border-color:#CCCCCC; + margin-left:28pt; + margin-right:2pt; + line-height:1px; + padding-top:0px; + margin-top:0px; + display:block; +} + +.titledescrip +{ + text-align:left; + display:block; + margin-left:26pt; + color:#ae322b; +} + +.mainlogo +{ + display:block; + margin-left:8%; + margin-top:4pt; + margin-bottom:4pt; +} + +/***************************************************** + IMG settings +*****************************************************/ + +IMG +{ + border-style:none; +} + +IMG.smallbutton +{ + margin-right: 20px; + float:none; +} + +IMG.floating +{ + float:left; + margin-right: 4pt; + margin-bottom: 4pt; +} + +IMG.lefticon +{ + vertical-align: middle; + padding-right: 2pt; +} + +IMG.righticon +{ + vertical-align: middle; + padding-left: 2pt; +} + +/***************************************************** + TABLE settings +*****************************************************/ + +TABLE +{ + border-style:none; +} + +TABLE.box +{ + width: 90%; + margin-left:5%; +} + +TABLE.centered +{ + margin-left: auto; + margin-right: auto; +} + +TABLE.hardcoded +{ + background-color: #225588; + margin-left: auto; + margin-right: auto; + width: 90%; +} + +TR { background-color: transparent; } + +TABLE.hardcoded TR { background-color: white } + +TABLE.hardcoded TR.header +{ + font-weight: bold; + color: black; + background-color: #C8D6E5; +} + +TABLE.hardcoded TR.header TD {color:black;} + +TABLE.hardcoded TD { padding-left: 2pt; } + +TD.minimelem { + padding-right:0px; + padding-left:0px; + text-align:center; +} + +TD.rightelem +{ + text-align:right; + /*padding-left:0pt;*/ + padding-right: 2pt; + width: 17%; +} + +/***************************************************** + P settings +*****************************************************/ + +p,.sub{text-align:justify;} +.centered{text-align:center;} +.sub +{ + padding-left: 16pt; + padding-right:16pt; +} + +.Note, .ContactInfo +{ + border-color: #ae322b; + border-width: 1pt; + border-style: dashed; + text-align:justify; + padding: 2pt 2pt 2pt 2pt; + margin-bottom:4pt; + margin-top:8pt; + list-style-position:inside; +} + +.ContactInfo +{ + width:60%; + margin-left:5%; +} + +.NewsItem +{ + border-color:#ae322b; + border-style: solid; + border-right:none; + border-top:none; + border-left:none; + border-bottom-width:1px; + text-align:justify; + padding-left:4pt; + padding-right:4pt; + padding-bottom:8pt; +} + +/***************************************************** + Lists settings +*****************************************************/ +UL.plain +{ + list-style-type: none; + padding-left:0px; + margin-left:0px; +} + +LI.plain +{ + list-style-type: none; +} + +LI.section +{ + padding-top: 6pt; +} + +UL.longtext LI +{ + border-color: #ae322b; + border-width:0px; + border-top-width:1px; + border-style:solid; + margin-top:12px; +} + +/* + with UL.longtext LI, there can be anything between + the UL and the LI, and it will still make the + lontext thing, I must break it with this hack +*/ +UL.longtext UL LI +{ + border-style:none; + margin-top:2px; +} + + +/***************************************************** + Titles settings +*****************************************************/ + +H1,H2,H3 +{ + font-family:"Courier New",monospace; + color:#5588bb; +} + +H1 +{ + font-size:18pt; + color: #ae322b; + border-color: #70A0CF; + border-width: 1pt; + border-style: solid; + margin-top: 16pt; + margin-left: 5%; + margin-right: 5%; + padding-top: 2pt; + padding-bottom:2pt; + text-align: center; +} + +H2 +{ + border-color: #ae322b; + border-bottom-width: 2px; + border-top-width: 0pt; + border-left-width: 2px; + border-right-width: 0pt; + border-bottom-color: #cccccc; + border-style: solid; + margin-top: 16pt; + margin-left: 0pt; + margin-right: 0pt; + padding-bottom:3pt; + padding-left:5pt; + text-align: left; + font-size:16pt; +} + +H3 +{ + display:block; + color:#ae322b; + border-color: #70A0CF; + border-bottom-width: 2px; + border-top-width: 0pt; + border-left-width: 0pt; + border-right-width: 0pt; + border-style: dashed; + margin-top: 12pt; + margin-left: 0pt; + margin-bottom: 4pt; + width:auto; + padding-bottom:3pt; + padding-right:2pt; + padding-left:2pt; + text-align: left; + font-weight:bold; +} + + +/***************************************************** + Misc. classes +*****************************************************/ +.longtext:first-letter {font-size: 150%} + +.price, .loweredprice, .specialprice {font-weight:bold;} + +.loweredprice {text-decoration:line-through} + +.specialprice {color:red} + +form +{ + margin:0px; +} + +.program_summary +{ + float:right; + margin: 32pt; + margin-top:0pt; + margin-bottom:0pt; +} + +.screenshot +{ + float:left; + margin: 8pt; +} \ No newline at end of file diff --git a/se/help/skeleton/images/hs_title.png b/se/help/skeleton/images/hs_title.png new file mode 100644 index 0000000000000000000000000000000000000000..07bd89c69dd50a3967b46a441741f274b8854de8 GIT binary patch literal 1817 zcmWkt2~Y|q^t%aD27PTfWQbSo7y0Xrdp{OF7oMwX4{+byFr3&y7B zlLpel1d+*^<>$!E@c2A9sOuxRq}jS+G}rcy>y2h&rngp=tT$NjxX)#-@ zR;$fwvyo;C3xs)cj4wl35`-y%c{0>!qGG~a8Nvb)0K&L3RHVdNQtA}p%Q4Dz$v^^f z%s`q=W-AZEN|l62NeGp=(QGM^zcEYpnJq53Zg_psX$pDf}jksh9!YZ6*y0d6t`*06htZIC4pHW%9Epf zh*AWzsT7ofh)_XrrKsL$;mT2tj52IVPjY1#SB?oFlp&##5=^Q9qlF{I7FJ9G2rF*a z2s?-pFR&PpFjtBRA?yV{E9}sUAgt)Ih9}1>x(SvT zsqP+UieWa0F{DUM&ub2ZbHs4dvq=VsEE&xV>e|H!OM+4TQTc&M3CEU=q(F(^fAYG# zOS_;aD|@ud29hH~gpd|cpbwr+aO8+kiBoq^eznVg<_$ zP1%d1$UV?R~TV5+i6w>mO|X i&$iNccmR(ItgDH2&`ti^@h8+@ro}}kMz!wGy!C(b`7gZy literal 0 HcmV?d00001 diff --git a/se/help/templates/base_dg.mako b/se/help/templates/base_dg.mako new file mode 100644 index 00000000..7767c49f --- /dev/null +++ b/se/help/templates/base_dg.mako @@ -0,0 +1,14 @@ +<%inherit file="/base_help.mako"/> +${next.body()} + +<%def name="menu()"><% +self.menuitem('intro.htm', 'Introduction', 'Introduction to dupeGuru') +self.menuitem('quick_start.htm', 'Quick Start', 'Quickly get into the action') +self.menuitem('directories.htm', 'Directories', 'Managing dupeGuru directories') +self.menuitem('preferences.htm', 'Preferences', 'Setting dupeGuru preferences') +self.menuitem('results.htm', 'Results', 'Time to delete these duplicates!') +self.menuitem('power_marker.htm', 'Power Marker', 'Take control of your duplicates') +self.menuitem('faq.htm', 'F.A.Q.', 'Frequently Asked Questions') +self.menuitem('versions.htm', 'Version History', 'Changes dupeGuru went through') +self.menuitem('credits.htm', 'Credits', 'People who contributed to dupeGuru') +%> \ No newline at end of file diff --git a/se/help/templates/credits.mako b/se/help/templates/credits.mako new file mode 100644 index 00000000..269d7463 --- /dev/null +++ b/se/help/templates/credits.mako @@ -0,0 +1,20 @@ +<%! + title = 'Credits' + selected_menu_item = 'Credits' +%> +<%inherit file="/base_dg.mako"/> +Below is the list of people who contributed, directly or indirectly to dupeGuru. + +${self.credit('Virgil Dupras', 'Developer', "That's me, Hardcoded Software founder", 'www.hardcoded.net', 'hsoft@hardcoded.net')} + +${self.credit('Jerome', 'Icon designer', "Icons in dupeGuru are from him")} + +${self.credit('Python', 'Programming language', "The bestest of the bests", 'www.python.org')} + +${self.credit('PyObjC', 'Python-to-Cocoa bridge', "Used for the Mac OS X version", 'pyobjc.sourceforge.net')} + +${self.credit('Python for .NET', 'Python-to-.NET bridge', "Used for the Windows version", 'sourceforge.net/projects/pythonnet/')} + +${self.credit('Sparkle', 'Auto-update library', "Used for the Mac OS X version", 'andymatuschak.org/pages/sparkle')} + +${self.credit('You', 'dupeGuru user', "What would I do without you?")} diff --git a/se/help/templates/directories.mako b/se/help/templates/directories.mako new file mode 100644 index 00000000..e75b47bd --- /dev/null +++ b/se/help/templates/directories.mako @@ -0,0 +1,24 @@ +<%! + title = 'Directories' + selected_menu_item = 'Directories' +%> +<%inherit file="/base_dg.mako"/> + +There is a panel in dupeGuru called **Directories**. You can open it by clicking on the **Directories** button. This directory contains the list of the directories that will be scanned when you click on **Start Scanning**. + +This panel is quite straightforward to use. If you want to add a directory, click on **Add**. If you added directories before, a popup menu with a list of recent directories you added will pop. You can click on one of them to add it directly to your list. If you click on the first item of the popup menu, **Add New Directory...**, you will be prompted for a directory to add. If you never added a directory, no menu will pop and you will directly be prompted for a new directory to add. + +To remove a directory, select the directory to remove and click on **Remove**. If a subdirectory is selected when you click remove, the selected directory will be set to **excluded** state (see below) instead of being removed. + +Directory states +----- + +Every directory can be in one of these 3 states: + +* **Normal:** Duplicates found in these directories can be deleted. +* **Reference:** Duplicates found in this directory **cannot** be deleted. Files in reference directories will be in a blue color in the results. +* **Excluded:** Files in this directory will not be included in the scan. + +The default state of a directory is, of course, **Normal**. You can use **Reference** state for a directory if you want to be sure that you won't delete any file from it. + +When you set the state of a directory, all subdirectories of this directory automatically inherit this state unless you explicitly set a subdirectory's state. diff --git a/se/help/templates/faq.mako b/se/help/templates/faq.mako new file mode 100644 index 00000000..659d8b14 --- /dev/null +++ b/se/help/templates/faq.mako @@ -0,0 +1,64 @@ +<%! + title = 'dupeGuru F.A.Q.' + selected_menu_item = 'F.A.Q.' +%> +<%inherit file="/base_dg.mako"/> + +<%text filter="md"> +### What is dupeGuru? + +dupeGuru is a tool to find duplicate files on your computer. It can scan either filenames or content. The filename scan features a fuzzy matching algorithm that can find duplicate filenames even when they are not exactly the same. + +### What makes it better than other duplicate scanners? + +The scanning engine is extremely flexible. You can tweak it to really get the kind of results you want. You can read more about dupeGuru tweaking option at the [Preferences page](preferences.htm). + +### How safe is it to use dupeGuru? + +Very safe. dupeGuru has been designed to make sure you don't delete files you didn't mean to delete. First, there is the reference directory system that lets you define directories where you absolutely **don't** want dupeGuru to let you delete files there, and then there is the group reference system that makes sure that you will **always** keep at least one member of the duplicate group. + +### What are the demo limitations of dupeGuru? + +In demo mode, you can only perform actions (delete/copy/move) on 10 duplicates per session. + +### The mark box of a file I want to delete is disabled. What must I do? + +You cannot mark the reference (The first file) of a duplicate group. However, what you can do is to promote a duplicate file to reference. Thus, if a file you want to mark is reference, select a duplicate file in the group that you want to promote to reference, and click on **Actions-->Make Selected Reference**. If the reference file is from a reference directory (filename written in blue letters), you cannot remove it from the reference position. + +### I have a directory from which I really don't want to delete files. + +If you want to be sure that dupeGuru will never delete file from a particular directory, just open the **Directories panel**, select that directory, and set its state to **Reference**. + +### What is this '(X discarded)' notice in the status bar? + +In some cases, some matches are not included in the final results for security reasons. Let me use an example. We have 3 file: A, B and C. We scan them using a low filter hardness. The scanner determines that A matches with B, A matches with C, but B does **not** match with C. Here, dupeGuru has kind of a problem. It cannot create a duplicate group with A, B and C in it because not all files in the group would match together. It could create 2 groups: one A-B group and then one A-C group, but it will not, for security reasons. Lets think about it: If B doesn't match with C, it probably means that either B, C or both are not actually duplicates. If there would be 2 groups (A-B and A-C), you would end up delete both B and C. And if one of them is not a duplicate, that is really not what you want to do, right? So what dupeGuru does in a case like this is to discard the A-C match (and adds a notice in the status bar). Thus, if you delete B and re-run a scan, you will have a A-C match in your next results. + +### I want to mark all files from a specific directory. What can I do? + +Enable the [Power Marker](power_marker.htm) mode and click on the Directory column to sort your duplicates by Directory. It will then be easy for you to select all duplicates from the same directory, and then press Space to mark all selected duplicates. + +### I want to remove all files that are more than 300 KB away from their reference file. What can I do? + +* Enable the [Power Marker](power_marker.htm) mode. +* Enable the **Delta Values** mode. +* Click on the "Size" column to sort the results by size. +* Select all duplicates below -300. +* Click on **Remove Selected from Results**. +* Select all duplicates over 300. +* Click on **Remove Selected from Results**. + +### I want to make my latest modified files reference files. What can I do? + +* Enable the [Power Marker](power_marker.htm) mode. +* Enable the **Delta Values** mode. +* Click on the "Modification" column to sort the results by modification date. +* Click on the "Modification" column again to reverse the sort order (see Power Marker page to know why). +* Select all duplicates over 0. +* Click on **Make Selected Reference**. + +### I want to mark all duplicates containing the word "copy". How do I do that? + +* **Windows**: Click on **Actions --> Apply Filter**, then type "copy", then click OK. +* **Mac OS X**: Type "copy" in the "Filter" field in the toolbar. +* Click on **Mark --> Mark All**. + \ No newline at end of file diff --git a/se/help/templates/intro.mako b/se/help/templates/intro.mako new file mode 100644 index 00000000..0fd3019b --- /dev/null +++ b/se/help/templates/intro.mako @@ -0,0 +1,13 @@ +<%! + title = 'Introduction to dupeGuru' + selected_menu_item = 'introduction' +%> +<%inherit file="/base_dg.mako"/> + +dupeGuru is a tool to find duplicate files on your computer. It can scan either filenames or contents. The filename scan features a fuzzy matching algorithm that can find duplicate filenames even when they are not exactly the same. + +Although dupeGuru can easily be used without documentation, reading this file will help you to master it. If you are looking for guidance for your first duplicate scan, you can take a look at the [Quick Start](quick_start.htm) section. + +It is a good idea to keep dupeGuru updated. You can download the latest version on the [dupeGuru homepage](http://www.hardcoded.net/dupeguru/). + +<%def name="meta()"> \ No newline at end of file diff --git a/se/help/templates/power_marker.mako b/se/help/templates/power_marker.mako new file mode 100644 index 00000000..26078f3d --- /dev/null +++ b/se/help/templates/power_marker.mako @@ -0,0 +1,33 @@ +<%! + title = 'Power Marker' + selected_menu_item = 'Power Marker' +%> +<%inherit file="/base_dg.mako"/> + +You will probably not use the Power Marker feature very often, but if you get into a situation where you need it, you will be pretty happy that this feature exists. + +What is it? +----- + +When the Power Marker mode is enabled, the duplicates are shown without their respective reference file. You can select, mark and sort this list, just like in normal mode. + +So, what is it for? +----- + +The dupeGuru results, when in normal mode, are sorted according to duplicate groups' **reference file**. This means that if you want, for example, to mark all duplicates with the "exe" extension, you cannot just sort the results by "Kind" to have all exe duplicates together because a group can be composed of more than one kind of files. That is where Power Marker comes into play. To mark all your "exe" duplicates, you just have to: + +* Enable the Power marker mode. +* Add the "Kind" column with the "Columns" menu. +* Click on that "Kind" column to sort the list by kind. +* Locate the first duplicate with a "exe" kind. +* Select it. +* Scroll down the list to locate the last duplicate with a "exe" kind. +* Hold Shift and click on it. +* Press Space to mark all selected duplicates. + +Power Marker and delta values +----- + +The Power Marker unveil its true power when you use it with the **Delta Values** switch turned on. When you turn it on, relative values will be displayed instead of absolute ones. So if, for example, you want to remove from your results all duplicates that are more than 300 KB away from their reference, you could sort the Power Marker by Size, select all duplicates under -300 in the Size column, delete them, and then do the same for duplicates over 300 at the bottom of the list. + +You could also use it to change the reference priority of your duplicate list. When you make a fresh scan, if there are no reference directories, the reference file of every group is the biggest file. If you want to change that, for example, to the latest modification time, you can sort the Power Marker by modification time in **descending** order, select all duplicates with a modification time delta value higher than 0 and click on **Make Selected Reference**. The reason why you must make the sort order descending is because if 2 files among the same duplicate group are selected when you click on **Make Selected Reference**, only the first of the list will be made reference, the other will be ignored. And since you want the last modified file to be reference, having the sort order descending assures you that the first item of the list will be the last modified. diff --git a/se/help/templates/preferences.mako b/se/help/templates/preferences.mako new file mode 100644 index 00000000..578fe8b2 --- /dev/null +++ b/se/help/templates/preferences.mako @@ -0,0 +1,27 @@ +<%! + title = 'Preferences' + selected_menu_item = 'Preferences' +%> +<%inherit file="/base_dg.mako"/> + +**Scan Type:** This option determines what aspect of the files will be compared in the duplicate scan. If you select **Filename**, dupeGuru will compare every filenames word-by-word and, depending on the other settings below, it will determine if enough words are matching to consider 2 files duplicates. If you select **Content**, only files with the exact same content will match. + +**Filter Hardness:** If you chose the **Filename** scan type, this option determines how similar two filenames must be for dupeGuru to consider them duplicates. If the filter hardness is, for example 80, it means that 80% of the words of two filenames must match. To determine the matching percentage, dupeGuru first counts the total number of words in **both** filenames, then count the number of words matching (every word matching count as 2), and then divide the number of words matching by the total number of words. If the result is higher or equal to the filter hardness, we have a duplicate match. For example, "a b c d" and "c d e" have a matching percentage of 57 (4 words matching, 7 total words). + +**Word weighting:** If you chose the **Filename** scan type, this option slightly changes how matching percentage is calculated. With word weighting, instead of having a value of 1 in the duplicate count and total word count, every word have a value equal to the number of characters they have. With word weighting, "ab cde fghi" and "ab cde fghij" would have a matching percentage of 53% (19 total characters, 10 characters matching (4 for "ab" and 6 for "cde")). + +**Match similar words:** If you turn this option on, similar words will be counted as matches. For example "The White Stripes" and "The White Stripe" would have a match % of 100 instead of 66 with that option turned on. **Warning:** Use this option with caution. It is likely that you will get a lot of false positives in your results when turning it on. However, it will help you to find duplicates that you wouldn't have found otherwise. The scan process also is significantly slower with this option turned on. + +**Can mix file kind:** If you check this box, duplicate groups are allowed to have files with different extensions. If you don't check it, well, they aren't! + +**Use regular expressions when filtering:** If you check this box, the filtering feature will treat your filter query as a **regular expression**. Explaining them is beyond the scope of this document. A good place to start learning it is . + +**Remove empty folders after delete or move:** When this option is enabled, folders are deleted after a file is deleted or moved and the folder is empty. + +**Copy and Move:** Determines how the Copy and Move operations (in the Action menu) will behave. + +* **Right in destination:** All files will be sent directly in the selected destination, without trying to recreate the source path at all. +* **Recreate relative path:** The source file's path will be re-created in the destination directory up to the root selection in the Directories panel. For example, if you added "/Users/foobar/Music" to your Directories panel and you move "/Users/foobar/Music/Artist/Album/the_song.mp3" to the destination "/Users/foobar/MyDestination", the final destination for the file will be "/Users/foobar/MyDestination/Artist/Album" ("/Users/foobar/Music" has been trimmed from source's path in the final destination.). +* **Recreate absolute path:** The source file's path will be re-created in the destination directory in it's entirety. For example, if you move "/Users/foobar/Music/Artist/Album/the_song.mp3" to the destination "/Users/foobar/MyDestination", the final destination for the file will be "/Users/foobar/MyDestination/Users/foobar/Music/Artist/Album". + +In all cases, dupeGuru nicely handles naming conflicts by prepending a number to the destination filename if the filename already exists in the destination. diff --git a/se/help/templates/quick_start.mako b/se/help/templates/quick_start.mako new file mode 100644 index 00000000..dde33c65 --- /dev/null +++ b/se/help/templates/quick_start.mako @@ -0,0 +1,18 @@ +<%! + title = 'Quick Start' + selected_menu_item = 'Quick Start' +%> +<%inherit file="/base_dg.mako"/> + +To get you quickly started with dupeGuru, let's just make a standard scan using default preferences. + +* Click on **Directories**. +* Click on **Add**. +* Choose a directory you want to scan for duplicates. +* Click on **Start Scanning**. +* Wait until the scan process is over. +* Look at every duplicate (The files that are indented) and verify that it is indeed a duplicate to the group's reference (The file above the duplicate that is not indented and have a disabled mark box). +* If a file is a false duplicate, select it and click on **Actions-->Remove Selected from Results**. +* Once you are sure that there is no false duplicate in your results, click on **Edit-->Mark All**, and then **Actions-->Send Marked to Recycle bin**. + +That is only a basic scan. There are a lot of tweaking you can do to get different results and several methods of examining and modifying your results. To know about them, just read the rest of this help file. diff --git a/se/help/templates/results.mako b/se/help/templates/results.mako new file mode 100644 index 00000000..53aa176f --- /dev/null +++ b/se/help/templates/results.mako @@ -0,0 +1,73 @@ +<%! + title = 'Results' + selected_menu_item = 'Results' +%> +<%inherit file="/base_dg.mako"/> + +When dupeGuru is finished scanning for duplicates, it will show its results in the form of duplicate group list. + +About duplicate groups +----- + +A duplicate group is a group of files that all match together. Every group has a **reference file** and one or more **duplicate files**. The reference file is the first file of the group. Its mark box is disabled. Below it, and indented, are the duplicate files. + +You can mark duplicate files, but you can never mark the reference file of a group. This is a security measure to prevent dupeGuru from deleting not only duplicate files, but their reference. You sure don't want that, do you? + +What determines which files are reference and which files are duplicates is first their directory state. A files from a reference directory will always be reference in a duplicate group. If all files are from a normal directory, the size determine which file will be the reference of a duplicate group. dupeGuru assumes that you always want to keep the biggest file, so the biggest files will take the reference position. + +You can change the reference file of a group manually. To do so, select the duplicate file you want to promote to reference, and click on **Actions-->Make Selected Reference**. + +Reviewing results +----- + +Although you can just click on **Edit-->Mark All** and then **Actions-->Send Marked to Recycle bin** to quickly delete all duplicate files in your results, it is always recommended to review all duplicates before deleting them. + +To help you reviewing the results, you can bring up the **Details panel**. This panel shows all the details of the currently selected file as well as its reference's details. This is very handy to quickly determine if a duplicate really is a duplicate. You can also double-click on a file to open it with its associated application. + +If you have more false duplicates than true duplicates (If your filter hardness is very low), the best way to proceed would be to review duplicates, mark true duplicates and then click on **Actions-->Send Marked to Recycle bin**. If you have more true duplicates than false duplicates, you can instead mark all files that are false duplicates, and use **Actions-->Remove Marked from Results**. + +Marking and Selecting +----- + +A **marked** duplicate is a duplicate with the little box next to it having a check-mark. A **selected** duplicate is a duplicate being highlighted. The multiple selection actions can be performed in dupeGuru in the standard way (Shift/Command/Control click). You can toggle all selected duplicates' mark state by pressing **space**. + +Delta Values +----- + +If you turn this switch on, some columns will display the value relative to the duplicate's reference instead of the absolute values. These delta values will also be displayed in a different color so you can spot them easily. For example, if a duplicate is 1.2 MB and its reference is 1.4 MB, the Size column will display -0.2 MB. This option is a killer feature when combined with the [Power Marker](power_marker.htm). + +Filtering +----- + +dupeGuru supports post-scan filtering. With it, you can narrow down your results so you can perform actions on a subset of it. For example, you could easily mark all duplicates with their filename containing "copy" from your results using the filter. + +**Windows:** To use the filtering feature, click on Actions --> Apply Filter, write down the filter you want to apply and click OK. To go back to unfiltered results, click on Actions --> Cancel Filter. + +**Mac OS X:** To use the filtering feature, type your filter in the "Filter" search field in the toolbar. To go back to unfiltered result, blank out the field, or click on the "X". + +In simple mode (the default mode), whatever you type as the filter is the string used to perform the actual filtering, with the exception of one wildcard: **\***. Thus, if you type "[*]" as your filter, it will match anything with [] brackets in it, whatever is in between those brackets. + +For more advanced filtering, you can turn "Use regular expressions when filtering" on. The filtering feature will then use **regular expressions**. A regular expression is a language for matching text. Explaining them is beyond the scope of this document. A good place to start learning it is . + +Matches are case insensitive in both simple and regexp mode. + +For the filter to match, your regular expression don't have to match the whole filename, it just have to contain a string matching the expression. + +You might notice that not all duplicates in the filtered results will match your filter. That is because as soon as one single duplicate in a group matches the filter, the whole group stays in the results so you can have a better view of the duplicate's context. However, non-matching duplicates are in "reference mode". Therefore, you can perform actions like Mark All and be sure to only mark filtered duplicates. + +Action Menu +----- + +* **Start Duplicate Scan:** Starts a new duplicate scan. +* **Clear Ignore List:** Remove all ignored matches you added. You have to start a new scan for the newly cleared ignore list to be effective. +* **Export Results to XHTML:** Take the current results, and create an XHTML file out of it. The columns that are visible when you click on this button will be the columns present in the XHTML file. The file will automatically be opened in your default browser. +* **Send Marked to Trash:** Send all marked duplicates to trash, obviously. +* **Move Marked to...:** Prompt you for a destination, and then move all marked files to that destination. Source file's path might be re-created in destination, depending on the "Copy and Move" preference. +* **Copy Marked to...:** Prompt you for a destination, and then copy all marked files to that destination. Source file's path might be re-created in destination, depending on the "Copy and Move" preference. +* **Remove Marked from Results:** Remove all marked duplicates from results. The actual files will not be touched and will stay where they are. +* **Remove Selected from Results:** Remove all selected duplicates from results. Note that all selected reference files will be ignored, only duplicates can be removed with this action. +* **Make Selected Reference:** Promote all selected duplicates to reference. If a duplicate is a part of a group having a reference file coming from a reference directory (in blue color), no action will be taken for this duplicate. If more than one duplicate among the same group are selected, only the first of each group will be promoted. +* **Add Selected to Ignore List:** This first removes all selected duplicates from results, and then add the match of that duplicate and the current reference in the ignore list. This match will not come up again in further scan. The duplicate itself might come back, but it will be matched with another reference file. You can clear the ignore list with the Clear Ignore List command. +* **Open Selected with Default Application:** Open the file with the application associated with selected file's type. +* **Reveal Selected in Finder:** Open the folder containing selected file. +* **Rename Selected:** Prompts you for a new name, and then rename the selected file. diff --git a/se/help/templates/versions.mako b/se/help/templates/versions.mako new file mode 100644 index 00000000..354a294d --- /dev/null +++ b/se/help/templates/versions.mako @@ -0,0 +1,6 @@ +<%! + title = 'dupeGuru version history' + selected_menu_item = 'Version History' +%> +<%inherit file="/base_dg.mako"/> +${self.output_changelogs(changelog)} \ No newline at end of file diff --git a/se/qt/app.py b/se/qt/app.py new file mode 100644 index 00000000..3859a5f8 --- /dev/null +++ b/se/qt/app.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# Unit Name: app +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from dupeguru import data + +from base.app import DupeGuru as DupeGuruBase +from details_dialog import DetailsDialog +from preferences import Preferences +from preferences_dialog import PreferencesDialog + +class DupeGuru(DupeGuruBase): + LOGO_NAME = 'logo_se' + NAME = 'dupeGuru' + VERSION = '2.7.1' + DELTA_COLUMNS = frozenset([2, 4, 5]) + + def __init__(self): + DupeGuruBase.__init__(self, data, appid=4) + + def _update_options(self): + DupeGuruBase._update_options(self) + self.scanner.min_match_percentage = self.prefs.filter_hardness + self.scanner.scan_type = self.prefs.scan_type + self.scanner.word_weighting = self.prefs.word_weighting + self.scanner.match_similar_words = self.prefs.match_similar + threshold = self.prefs.small_file_threshold if self.prefs.ignore_small_files else 0 + self.scanner.size_threshold = threshold * 1024 # threshold is in KB. the scanner wants bytes + + def _create_details_dialog(self, parent): + return DetailsDialog(parent, self) + + def _create_preferences(self): + return Preferences() + + def _create_preferences_dialog(self, parent): + return PreferencesDialog(parent, self) + diff --git a/se/qt/app_win.py b/se/qt/app_win.py new file mode 100644 index 00000000..8088bce6 --- /dev/null +++ b/se/qt/app_win.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# Unit Name: app_win +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import winshell + +import app + +class DupeGuru(app.DupeGuru): + @staticmethod + def _recycle_dupe(dupe): + winshell.delete_file(unicode(dupe.path), no_confirm=True) + diff --git a/se/qt/build.py b/se/qt/build.py new file mode 100644 index 00000000..ff2b3e69 --- /dev/null +++ b/se/qt/build.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# Unit Name: build +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +# On Windows, PyInstaller is used to build an exe (py2exe creates a very bad looking icon +# The release version is outdated. Use at least r672 on http://svn.pyinstaller.org/trunk + +import os +import os.path as op +import shutil +from app import DupeGuru + +def print_and_do(cmd): + print cmd + os.system(cmd) + +# Removing build and dist +if op.exists('build'): + shutil.rmtree('build') +if op.exists('dist'): + shutil.rmtree('dist') + +version = DupeGuru.VERSION +versioncomma = version.replace('.', ', ') + ', 0' +verinfo = open('verinfo').read() +verinfo = verinfo.replace('$versioncomma', versioncomma).replace('$version', version) +fp = open('verinfo_tmp', 'w') +fp.write(verinfo) +fp.close() +print_and_do("python C:\\Python26\\pyinstaller\\Build.py dgse.spec") +os.remove('verinfo_tmp') + +print_and_do("xcopy /Y C:\\src\\vs_comp\\msvcrt dist") +print_and_do("xcopy /Y /S /I help\\dupeguru_help dist\\help") + +aicom = '"\\Program Files\\Caphyon\\Advanced Installer\\AdvancedInstaller.com"' +shutil.copy('installer.aip', 'installer_tmp.aip') # this is so we don'a have to re-commit installer.aip at every version change +print_and_do('%s /edit installer_tmp.aip /SetVersion %s' % (aicom, version)) +print_and_do('%s /build installer_tmp.aip -force' % aicom) +os.remove('installer_tmp.aip') \ No newline at end of file diff --git a/se/qt/details_dialog.py b/se/qt/details_dialog.py new file mode 100644 index 00000000..df72ee52 --- /dev/null +++ b/se/qt/details_dialog.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# Unit Name: details_dialog +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import Qt +from PyQt4.QtGui import QDialog + +from base.details_table import DetailsModel +from details_dialog_ui import Ui_DetailsDialog + +class DetailsDialog(QDialog, Ui_DetailsDialog): + def __init__(self, parent, app): + QDialog.__init__(self, parent, Qt.Tool) + self.app = app + self.setupUi(self) + self.model = DetailsModel(app) + self.tableView.setModel(self.model) diff --git a/se/qt/details_dialog.ui b/se/qt/details_dialog.ui new file mode 100644 index 00000000..1dc063d0 --- /dev/null +++ b/se/qt/details_dialog.ui @@ -0,0 +1,53 @@ + + + DetailsDialog + + + + 0 + 0 + 502 + 186 + + + + + 200 + 0 + + + + Details + + + + 0 + + + 0 + + + + + true + + + QAbstractItemView::SelectRows + + + false + + + + + + + + DetailsTable + QTableView +
base.details_table
+
+
+ + +
diff --git a/se/qt/dgse.spec b/se/qt/dgse.spec new file mode 100644 index 00000000..e2229597 --- /dev/null +++ b/se/qt/dgse.spec @@ -0,0 +1,19 @@ +# -*- mode: python -*- +a = Analysis([os.path.join(HOMEPATH,'support\\_mountzlib.py'), os.path.join(HOMEPATH,'support\\useUnicode.py'), 'start.py'], + pathex=['C:\\src\\dupeguru\\se\\qt']) +pyz = PYZ(a.pure) +exe = EXE(pyz, + a.scripts, + exclude_binaries=1, + name=os.path.join('build\\pyi.win32\\dupeGuru', 'dupeGuru.exe'), + debug=False, + strip=False, + upx=True, + console=False , icon='base\\images\\dgse_logo.ico', version='verinfo_tmp') +coll = COLLECT( exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name=os.path.join('dist')) diff --git a/se/qt/gen.py b/se/qt/gen.py new file mode 100644 index 00000000..b712a5b6 --- /dev/null +++ b/se/qt/gen.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# Unit Name: gen +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +import os + +def print_and_do(cmd): + print cmd + os.system(cmd) + +os.chdir('dupeguru') +print_and_do('python gen.py') +os.chdir('..') + +os.chdir('base') +print_and_do('python gen.py') +os.chdir('..') + +print_and_do("pyuic4 details_dialog.ui > details_dialog_ui.py") +print_and_do("pyuic4 preferences_dialog.ui > preferences_dialog_ui.py") + +os.chdir('help') +print_and_do('python gen.py') +os.chdir('..') diff --git a/se/qt/installer.aip b/se/qt/installer.aip new file mode 100644 index 00000000..c60f88ae --- /dev/null +++ b/se/qt/installer.aip @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/se/qt/preferences.py b/se/qt/preferences.py new file mode 100644 index 00000000..b978bc6d --- /dev/null +++ b/se/qt/preferences.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# Unit Name: preferences +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from dupeguru.scanner import SCAN_TYPE_FILENAME, SCAN_TYPE_CONTENT + +from base.preferences import Preferences as PreferencesBase + +class Preferences(PreferencesBase): + # (width, is_visible) + COLUMNS_DEFAULT_ATTRS = [ + (200, True), # name + (180, True), # path + (60, True), # size + (40, False), # Kind + (120, False), # creation + (120, False), # modification + (60, True), # match % + (120, False), # Words Used + (80, False), # dupe count + ] + + def _load_specific(self, settings, get): + self.scan_type = get('ScanType', self.scan_type) + self.word_weighting = get('WordWeighting', self.word_weighting) + self.match_similar = get('MatchSimilar', self.match_similar) + self.ignore_small_files = get('IgnoreSmallFiles', self.ignore_small_files) + self.small_file_threshold = get('SmallFileThreshold', self.small_file_threshold) + + def _reset_specific(self): + self.filter_hardness = 80 + self.scan_type = SCAN_TYPE_CONTENT + self.word_weighting = True + self.match_similar = False + self.ignore_small_files = True + self.small_file_threshold = 10 # KB + + def _save_specific(self, settings, set_): + set_('ScanType', self.scan_type) + set_('WordWeighting', self.word_weighting) + set_('MatchSimilar', self.match_similar) + set_('IgnoreSmallFiles', self.ignore_small_files) + set_('SmallFileThreshold', self.small_file_threshold) + diff --git a/se/qt/preferences_dialog.py b/se/qt/preferences_dialog.py new file mode 100644 index 00000000..80be1ac9 --- /dev/null +++ b/se/qt/preferences_dialog.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +# Unit Name: preferences_dialog +# Created By: Virgil Dupras +# Created On: 2009-05-24 +# $Id$ +# Copyright 2009 Hardcoded Software (http://www.hardcoded.net) + +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QDialogButtonBox + +from hsutil.misc import tryint + +from dupeguru.scanner import SCAN_TYPE_FILENAME, SCAN_TYPE_CONTENT + +from preferences_dialog_ui import Ui_PreferencesDialog +import preferences + +SCAN_TYPE_ORDER = [ + SCAN_TYPE_FILENAME, + SCAN_TYPE_CONTENT, +] + +class PreferencesDialog(QDialog, Ui_PreferencesDialog): + def __init__(self, parent, app): + flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint + QDialog.__init__(self, parent, flags) + self.app = app + self._setupUi() + + self.connect(self.buttonBox, SIGNAL('clicked(QAbstractButton*)'), self.buttonClicked) + self.connect(self.scanTypeComboBox, SIGNAL('currentIndexChanged(int)'), self.scanTypeChanged) + + def _setupUi(self): + self.setupUi(self) + + def load(self, prefs=None): + if prefs is None: + prefs = self.app.prefs + self.filterHardnessSlider.setValue(prefs.filter_hardness) + self.filterHardnessLabel.setNum(prefs.filter_hardness) + scan_type_index = SCAN_TYPE_ORDER.index(prefs.scan_type) + self.scanTypeComboBox.setCurrentIndex(scan_type_index) + setchecked = lambda cb, b: cb.setCheckState(Qt.Checked if b else Qt.Unchecked) + setchecked(self.matchSimilarBox, prefs.match_similar) + setchecked(self.wordWeightingBox, prefs.word_weighting) + setchecked(self.mixFileKindBox, prefs.mix_file_kind) + setchecked(self.useRegexpBox, prefs.use_regexp) + setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders) + setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files) + self.sizeThresholdEdit.setText(unicode(prefs.small_file_threshold)) + self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type) + + def save(self): + prefs = self.app.prefs + prefs.filter_hardness = self.filterHardnessSlider.value() + prefs.scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] + ischecked = lambda cb: cb.checkState() == Qt.Checked + prefs.match_similar = ischecked(self.matchSimilarBox) + prefs.word_weighting = ischecked(self.wordWeightingBox) + prefs.mix_file_kind = ischecked(self.mixFileKindBox) + prefs.use_regexp = ischecked(self.useRegexpBox) + prefs.remove_empty_folders = ischecked(self.removeEmptyFoldersBox) + prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox) + prefs.small_file_threshold = tryint(self.sizeThresholdEdit.text()) + prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() + + def resetToDefaults(self): + self.load(preferences.Preferences()) + + #--- Events + def buttonClicked(self, button): + role = self.buttonBox.buttonRole(button) + if role == QDialogButtonBox.ResetRole: + self.resetToDefaults() + + def scanTypeChanged(self, index): + scan_type = SCAN_TYPE_ORDER[self.scanTypeComboBox.currentIndex()] + word_based = scan_type == SCAN_TYPE_FILENAME + self.filterHardnessSlider.setEnabled(word_based) + self.matchSimilarBox.setEnabled(word_based) + self.wordWeightingBox.setEnabled(word_based) + diff --git a/se/qt/preferences_dialog.ui b/se/qt/preferences_dialog.ui new file mode 100644 index 00000000..368228fa --- /dev/null +++ b/se/qt/preferences_dialog.ui @@ -0,0 +1,389 @@ + + + PreferencesDialog + + + + 0 + 0 + 294 + 296 + + + + Preferences + + + false + + + true + + + + + + + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + Scan Type: + + + + + + + + Filename + + + + + Contents + + + + + + + + + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + Filter Hardness: + + + + + + + 0 + + + + + 12 + + + + + + 0 + 0 + + + + 1 + + + 100 + + + true + + + Qt::Horizontal + + + + + + + + 21 + 0 + + + + 100 + + + + + + + + + 0 + + + + + More Results + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Less Results + + + + + + + + + + + + + + 0 + 134 + + + + + + 0 + 0 + 350 + 21 + + + + Word weighting + + + + + + 0 + 22 + 350 + 21 + + + + Match similar words + + + + + + 0 + 44 + 350 + 21 + + + + Can mix file kind + + + + + + 0 + 66 + 350 + 21 + + + + Use regular expressions when filtering + + + + + + 0 + 88 + 350 + 21 + + + + Remove empty folders on delete or move + + + + + + 0 + 110 + 171 + 21 + + + + Ignore files smaller than + + + + + + 170 + 110 + 51 + 22 + + + + + + + 230 + 110 + 21 + 17 + + + + KB + + + + + + + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + Copy and Move: + + + + + + + + 0 + 0 + + + + + Right in destination + + + + + Recreate relative path + + + + + Recreate absolute path + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults + + + + + + + + + filterHardnessSlider + valueChanged(int) + filterHardnessLabel + setNum(int) + + + 182 + 26 + + + 271 + 26 + + + + + buttonBox + accepted() + PreferencesDialog + accept() + + + 182 + 228 + + + 182 + 124 + + + + + buttonBox + rejected() + PreferencesDialog + reject() + + + 182 + 228 + + + 182 + 124 + + + + + diff --git a/se/qt/profile.py b/se/qt/profile.py new file mode 100644 index 00000000..c1aefca9 --- /dev/null +++ b/se/qt/profile.py @@ -0,0 +1,20 @@ +import sys +import cProfile +import pstats + +from PyQt4.QtCore import QCoreApplication +from PyQt4.QtGui import QApplication + +if sys.platform == 'win32': + from app_win import DupeGuru +else: + from app import DupeGuru + +if __name__ == "__main__": + app = QApplication(sys.argv) + QCoreApplication.setOrganizationName('Hardcoded Software') + QCoreApplication.setApplicationName('dupeGuru') + dgapp = DupeGuru() + cProfile.run('app.exec_()', '/tmp/prof') + p = pstats.Stats('/tmp/prof') + p.sort_stats('time').print_stats() \ No newline at end of file diff --git a/se/qt/start.py b/se/qt/start.py new file mode 100644 index 00000000..41118430 --- /dev/null +++ b/se/qt/start.py @@ -0,0 +1,20 @@ +import sys + +from PyQt4.QtCore import QCoreApplication +from PyQt4.QtGui import QApplication, QIcon, QPixmap + +import base.dg_rc + +if sys.platform == 'win32': + from app_win import DupeGuru +else: + from app import DupeGuru + +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setWindowIcon(QIcon(QPixmap(":/logo_se"))) + QCoreApplication.setOrganizationName('Hardcoded Software') + QCoreApplication.setApplicationName(DupeGuru.NAME) + QCoreApplication.setApplicationVersion(DupeGuru.VERSION) + dgapp = DupeGuru() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/se/qt/verinfo b/se/qt/verinfo new file mode 100644 index 00000000..b32801d5 --- /dev/null +++ b/se/qt/verinfo @@ -0,0 +1,28 @@ +VSVersionInfo( + ffi=FixedFileInfo( + filevers=($versioncomma), + prodvers=($versioncomma), + mask=0x17, + flags=0x0, + OS=0x4, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904b0', + [StringStruct('CompanyName', 'Hardcoded Software'), + StringStruct('FileDescription', 'dupeGuru'), + StringStruct('FileVersion', '$version'), + StringStruct('InternalName', 'dupeGuru.exe'), + StringStruct('LegalCopyright', '(c) Hardcoded Software. All rights reserved.'), + StringStruct('OriginalFilename', 'dupeGuru.exe'), + StringStruct('ProductName', 'dupeGuru'), + StringStruct('ProductVersion', '$versioncomma')]) + ]), + VarFileInfo([VarStruct('Translation', [1033])]) + ] +)