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

Compare commits

..

55 Commits

Author SHA1 Message Date
Virgil Dupras
47e636e949 Merge branch 'develop' 2013-12-07 11:15:39 -05:00
Virgil Dupras
0562729d8b Improved build script's --clean. 2013-12-07 11:14:59 -05:00
Virgil Dupras
4a36227a18 v3.8.0 2013-12-07 10:57:30 -05:00
Virgil Dupras
28b8b2e415 Sync locs with Transifex 2013-12-07 10:26:01 -05:00
Virgil Dupras
fd82464564 Removed .tx config in hscommon (useless now) 2013-12-07 10:20:13 -05:00
Virgil Dupras
418acf6e5e Merge branch 'regless' into develop
Conflicts:
	cocoa/inter/app.py
	core/app.py
	hscommon/reg.py
	locale/cs/LC_MESSAGES/ui.po
	locale/de/LC_MESSAGES/ui.po
	locale/fr/LC_MESSAGES/ui.po
	locale/hy/LC_MESSAGES/ui.po
	locale/it/LC_MESSAGES/ui.po
	locale/pt_BR/LC_MESSAGES/ui.po
	locale/ru/LC_MESSAGES/ui.po
	locale/ui.pot
	locale/uk/LC_MESSAGES/ui.po
	locale/vi/LC_MESSAGES/ui.po
	locale/zh_CN/LC_MESSAGES/ui.po
	qt/base/app.py
2013-12-07 10:19:31 -05:00
Virgil Dupras
d14d076989 Disable symlink/hardlink option when not relevant (Cocoa)
Fixes #247.
2013-12-06 16:17:04 -05:00
Virgil Dupras
cb8bb5a70e Disable symlink/hardlink option when not relevant (Qt)
When the "Replace with links" option is not enabled, the choice of
symlink or hardlink is irrelevant and causes confusion. Implemented core
mechanism for controlling the enabled state of that option. Also
implemented the Qt interface for it. Cocoa-part is still to be done.

I used this opportunity to greatly enhance documentation of this part of
the code. I'm beginning to like documenting...

Ref #247.
2013-12-06 15:48:01 -05:00
Virgil Dupras
563c9aeff3 Updated README 2013-12-01 11:26:30 -05:00
Virgil Dupras
a0cc1f2e03 Fixed regless cocoa and updated locs 2013-11-30 18:23:42 -05:00
Virgil Dupras
01403a3f92 Removed fairware 2013-11-30 17:54:40 -05:00
Virgil Dupras
7116674663 Improved hscommon docs 2013-11-30 16:13:12 -05:00
Virgil Dupras
b6bc5de79c Improved hscommon docs
TIL sphinx is rather smart about partial class refrences (starting with
a ".")
2013-11-30 12:29:25 -05:00
Virgil Dupras
5a275db67d Improved hscommon doc
* Completed hscommon.gui.table's doc
* Use sphinx.ext.autosummary.
* Moved attribute docstrings directly into properties.
2013-11-30 12:15:03 -05:00
Virgil Dupras
31395d8794 Fix typos in docs 2013-11-28 22:49:26 -05:00
Virgil Dupras
3734bd6f6c Improved hscommon.gui docs
Added docs for Table and Row in hscommon.gui.table.
2013-11-28 22:38:07 -05:00
Virgil Dupras
da06ef8cad Improved hscommon.gui docs 2013-11-24 13:53:52 -05:00
Virgil Dupras
0b00171655 pygettext: explicitly open files as utf-8
When running it through SSH, I couldn't open files with non-ascii chars.
2013-11-24 10:22:05 -05:00
Virgil Dupras
c1cfa86ad1 Make Cmd+A select all folders in the Folder Selection dialog (Cocoa)
Fixes #228.
2013-11-24 10:12:47 -05:00
Virgil Dupras
c34c9562d3 Make non-numeric delta comparison case insensitive
Fixes #239.
2013-11-23 15:31:20 -05:00
Virgil Dupras
0e542577b0 Merge branch 'master' into develop 2013-11-23 12:39:59 -05:00
Virgil Dupras
42be49da83 Fix surrogate-related UnicodeEncodeError on CSV export
Fixes #210.
2013-11-23 12:38:55 -05:00
Virgil Dupras
398ac9b7c6 Greatly improved docs
Added a new scan.rst page, laying out in much more details than before
the inner workings of the scanning process.

Fixes #208, but does much more than that.
2013-11-17 12:03:48 -05:00
Virgil Dupras
508e9a5d94 Reorganized hscommon documentation
Removed hscommon's "docs" folder and moved all documentation directly
into docstrings. Then, in dupeGuru's developer documentation, added
autodoc references to relevant modules.

The result is a much more usable hscommon documentation.
2013-11-16 14:46:34 -05:00
Virgil Dupras
10dbfa9b38 Refactoring: Path API compatibility with pathlib
Refactored dupeGuru to make hscommon.path's API a bit close to pathlib's
API. It's not 100% compatible yet, but it's much better than before.

This is more of a hscommon refactoring than a dupeguru one, but since
duepGuru is the main user of Path, it was the driver behind the
refactoring.

This refactoring also see the introduction of @pathify, which ensure
Path arguments. Previously, we were often unsure of whether the caller
of a function was passing a Path or a str. This problem is now solved
and this allows us to remove hscommon.io, an ill-conceived attempt to
solve that same ambiguity problem.

Fixes #235.
2013-11-16 12:06:16 -05:00
Virgil Dupras
e8c42740cf Fixed tests which were broken 2013-11-10 12:54:35 -05:00
Virgil Dupras
4b6c4f048d Fixed compilation warnings on OS X 2013-11-10 12:41:10 -05:00
Virgil Dupras
7594cccf8c Fixed build on OS X which was broken 2013-11-10 12:39:02 -05:00
Virgil Dupras
1d9573cf6f On OS X, read Exif tags using Cocoa's built-in functionality
This allows for RAW files Exif reading. Fixes #234.
2013-11-10 12:00:16 -05:00
Virgil Dupras
76f45fb5a6 Fixed appdata logic which was broken on OS X. 2013-11-10 11:05:03 -05:00
Virgil Dupras
12cf9b800b Merge branch 'master' into develop 2013-11-09 16:21:59 -05:00
Virgil Dupras
ba7e6494c6 Fixed crash on Dupe Count sorting with Delta + Dupes Only
Fixes #238
2013-11-09 16:20:33 -05:00
Virgil Dupras
72d8160b28 Fix boken tests 2013-11-08 16:45:14 -05:00
Virgil Dupras
6d53511cee Merge branch 'master' into develop 2013-11-08 16:03:35 -05:00
Virgil Dupras
a563327723 Updated cocoalib 2013-10-20 16:01:59 -04:00
Virgil Dupras
096e2bb78a Updated hscommon 2013-10-20 16:01:27 -04:00
Virgil Dupras
8e65f15e1a Fixed core.engine.Match docstring
The way it was set made dupeGuru crash under Python 3.2
2013-10-20 13:33:27 -04:00
Virgil Dupras
9ea9f60e92 Added packaging support for ubuntu 13.10 2013-10-19 14:37:01 -04:00
Virgil Dupras
8efefaf0bf Improved API docs 2013-10-12 13:55:36 -04:00
Virgil Dupras
33d9569427 Refactoring: Created hscommon.desktop
This unit hosts previously awkward UI view methods which weren't related
to the view itself, but to the current desktop environment. These
functions are now at their appropriate place.
2013-10-12 13:54:13 -04:00
Virgil Dupras
2fdfacb34e Docs: Fix ugly nulljob repr in method signatures 2013-10-11 12:15:02 -04:00
Virgil Dupras
97fcf1ffa8 Fixed debian packaging
.so files were included in the source package, which messed up builds
under archs that weren't the same as the srcpkg creator (namely, i386
builds).
2013-09-22 09:38:52 -04:00
Virgil Dupras
350b2c64e0 Fixed nasty crash during PE's Cocoa block scanning
Using PyUnicode_GET_SIZE was obviously wrong, but I'm guessing that the str changes in py3.3 made that wrongness significant...
2013-08-26 07:17:02 -04:00
Virgil Dupras
dcc57a7afb Ah crap, another Cocoa fatal mistake 2013-08-25 17:10:26 -04:00
Virgil Dupras
8b510994ad pe v2.8.0 2013-08-25 10:53:08 -04:00
Virgil Dupras
4a4d1bbfcd Eased "Clear Picture Cache" triggering under Qt
Added a keybinding and added the action to the directories dialog's menu
(it was previously only in the results window's menu). Fixes #230.
2013-08-25 10:47:10 -04:00
Virgil Dupras
78c3c8ec2d Improved dev docs 2013-08-20 22:52:43 -04:00
Virgil Dupras
e99e2b18e0 Call sphinx-build from withing Python instead of a subprocess 2013-08-19 17:43:32 -04:00
Virgil Dupras
ae1283f2e1 se v3.7.1 2013-08-19 16:48:07 -04:00
Virgil Dupras
cc76f3ca87 Fixed SE folder scanning under Cocoa 2013-08-18 21:07:33 -04:00
Virgil Dupras
be8efea081 Fixed folder scanning in SE, which was completely broken
Oops
2013-08-18 20:50:31 -04:00
Virgil Dupras
7e8f9036d8 Began serious code documentation effort
Enabled the autodoc Sphinx extension and started adding docstrings to
classes, methods, etc.. It's quickly becoming quite interesting...
2013-08-18 18:36:09 -04:00
Virgil Dupras
8a8ac027f5 Fixed ME's cocoa interface file, which was broken (again)
The Remove Dead Tracks didn't use the new job system and appscript wasn't properly packaged.
2013-08-18 11:23:20 -04:00
Virgil Dupras
1d9d09fdf7 Fixed ME's cocoa interface file, which was broken
It tried to update JOBID2TITLE from inter.app, but it has moved to core.app.
2013-08-18 10:48:02 -04:00
Virgil Dupras
5dc956870d me v6.6.0 2013-08-18 10:16:39 -04:00
213 changed files with 3848 additions and 5123 deletions

View File

@@ -1,7 +1,17 @@
# dupeGuru # dupeGuru
This package contains the source for dupeGuru. Its documentation is [dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
[available online][documentation]. Here's how this source tree is organised: a system. It's written mostly in Python 3 and has the peculiarity of using
[multiple GUI toolkits][cross-toolkit], all using the same core Python code. On OS X, the UI layer
is written in Objective-C and uses Cocoa. On Linux and Windows, it's written in Python and uses Qt4.
dupeGuru comes in 3 editions (standard, music and picture) which are all buildable from this same
source tree. You choose the edition you want to build in a ``configure.py`` flag.
# Contents of this folder
This folder contains the source for dupeGuru. Its documentation is in ``help``, but is also
[available online][documentation] in its built form. Here's how this source tree is organised:
* core: Contains the core logic code for dupeGuru. It's Python code. * core: Contains the core logic code for dupeGuru. It's Python code.
* core_*: Edition-specific-cross-toolkit code written in Python. * core_*: Edition-specific-cross-toolkit code written in Python.
@@ -86,6 +96,8 @@ You can also package dupeGuru into an installable package with:
$ python package.py $ python package.py
[dupeguru]: http://www.hardcoded.net/dupeguru/
[cross-toolkit]: http://www.hardcoded.net/articles/cross-toolkit-software
[documentation]: http://www.hardcoded.net/dupeguru/help/en/ [documentation]: http://www.hardcoded.net/dupeguru/help/en/
[python]: http://www.python.org/ [python]: http://www.python.org/
[setuptools]: https://pypi.python.org/pypi/setuptools [setuptools]: https://pypi.python.org/pypi/setuptools

View File

@@ -104,7 +104,7 @@ def build_cocoa(edition, dev):
if not op.exists(pydep_folder): if not op.exists(pydep_folder):
os.mkdir(pydep_folder) os.mkdir(pydep_folder)
shutil.copy(op.join(cocoa_project_path, 'dg_cocoa.py'), 'build') shutil.copy(op.join(cocoa_project_path, 'dg_cocoa.py'), 'build')
appscript_pkgs = ['appscript', 'aem', 'mactypes'] appscript_pkgs = ['appscript', 'aem', 'mactypes', 'osax']
specific_packages = { specific_packages = {
'se': ['core_se'], 'se': ['core_se'],
'me': ['core_me'] + appscript_pkgs + ['hsaudiotag'], 'me': ['core_me'] + appscript_pkgs + ['hsaudiotag'],
@@ -135,6 +135,7 @@ def build_cocoa(edition, dev):
print_and_do(cocoa_compile_command(edition)) print_and_do(cocoa_compile_command(edition))
os.chdir('..') os.chdir('..')
app.copy_executable('cocoa/build/dupeGuru') app.copy_executable('cocoa/build/dupeGuru')
build_help(edition)
print("Copying resources and frameworks") print("Copying resources and frameworks")
image_path = ed('cocoa/{}/dupeguru.icns') image_path = ed('cocoa/{}/dupeguru.icns')
resources = [image_path, 'cocoa/base/dsa_pub.pem', 'build/dg_cocoa.py', 'build/help'] resources = [image_path, 'cocoa/base/dsa_pub.pem', 'build/dg_cocoa.py', 'build/help']
@@ -151,6 +152,7 @@ def build_qt(edition, dev, conf):
print("Building Qt stuff") print("Building Qt stuff")
print_and_do("pyrcc4 -py3 {0} > {1}".format(op.join('qt', 'base', 'dg.qrc'), op.join('qt', 'base', 'dg_rc.py'))) print_and_do("pyrcc4 -py3 {0} > {1}".format(op.join('qt', 'base', 'dg.qrc'), op.join('qt', 'base', 'dg_rc.py')))
fix_qt_resource_file(op.join('qt', 'base', 'dg_rc.py')) fix_qt_resource_file(op.join('qt', 'base', 'dg_rc.py'))
build_help(edition)
print("Creating the run.py file") print("Creating the run.py file")
filereplace(op.join('qt', 'run_template.py'), 'run.py', edition=edition) filereplace(op.join('qt', 'run_template.py'), 'run.py', edition=edition)
@@ -168,17 +170,12 @@ def build_help(edition):
conftmpl = op.join(current_path, 'help', 'conf.tmpl') conftmpl = op.join(current_path, 'help', 'conf.tmpl')
sphinxgen.gen(help_basepath, help_destpath, changelog_path, tixurl, confrepl, conftmpl, changelogtmpl) sphinxgen.gen(help_basepath, help_destpath, changelog_path, tixurl, confrepl, conftmpl, changelogtmpl)
def build_base_localizations():
loc.compile_all_po('locale')
loc.compile_all_po(op.join('hscommon', 'locale'))
loc.merge_locale_dir(op.join('hscommon', 'locale'), 'locale')
def build_qt_localizations(): def build_qt_localizations():
loc.compile_all_po(op.join('qtlib', 'locale')) loc.compile_all_po(op.join('qtlib', 'locale'))
loc.merge_locale_dir(op.join('qtlib', 'locale'), 'locale') loc.merge_locale_dir(op.join('qtlib', 'locale'), 'locale')
def build_localizations(ui, edition): def build_localizations(ui, edition):
build_base_localizations() loc.compile_all_po('locale')
if ui == 'cocoa': if ui == 'cocoa':
app = cocoa_app(edition) app = cocoa_app(edition)
loc.build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'base', 'en.lproj', 'Localizable.strings')) loc.build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'base', 'en.lproj', 'Localizable.strings'))
@@ -220,8 +217,6 @@ def build_updatepot():
# We want to merge the generated pot with the old pot in the most preserving way possible. # We want to merge the generated pot with the old pot in the most preserving way possible.
ui_packages = ['qt', op.join('cocoa', 'inter')] ui_packages = ['qt', op.join('cocoa', 'inter')]
loc.generate_pot(ui_packages, op.join('locale', 'ui.pot'), ['tr'], merge=(not ISOSX)) loc.generate_pot(ui_packages, op.join('locale', 'ui.pot'), ['tr'], merge=(not ISOSX))
print("Building hscommon.pot")
loc.generate_pot(['hscommon'], op.join('hscommon', 'locale', 'hscommon.pot'), ['tr'])
print("Building qtlib.pot") print("Building qtlib.pot")
loc.generate_pot(['qtlib'], op.join('qtlib', 'locale', 'qtlib.pot'), ['tr']) loc.generate_pot(['qtlib'], op.join('qtlib', 'locale', 'qtlib.pot'), ['tr'])
if ISOSX: if ISOSX:
@@ -236,13 +231,11 @@ def build_updatepot():
def build_mergepot(): def build_mergepot():
print("Updating .po files using .pot files") print("Updating .po files using .pot files")
loc.merge_pots_into_pos('locale') loc.merge_pots_into_pos('locale')
loc.merge_pots_into_pos(op.join('hscommon', 'locale'))
loc.merge_pots_into_pos(op.join('qtlib', 'locale')) loc.merge_pots_into_pos(op.join('qtlib', 'locale'))
loc.merge_pots_into_pos(op.join('cocoalib', 'locale')) loc.merge_pots_into_pos(op.join('cocoalib', 'locale'))
def build_normpo(): def build_normpo():
loc.normalize_all_pos('locale') loc.normalize_all_pos('locale')
loc.normalize_all_pos(op.join('hscommon', 'locale'))
loc.normalize_all_pos(op.join('qtlib', 'locale')) loc.normalize_all_pos(op.join('qtlib', 'locale'))
loc.normalize_all_pos(op.join('cocoalib', 'locale')) loc.normalize_all_pos(op.join('cocoalib', 'locale'))
@@ -264,7 +257,7 @@ def build_cocoa_bridging_interfaces(edition):
add_to_pythonpath('cocoalib') add_to_pythonpath('cocoalib')
from cocoa.inter import (PyGUIObject, GUIObjectView, PyColumns, ColumnsView, PyOutline, from cocoa.inter import (PyGUIObject, GUIObjectView, PyColumns, ColumnsView, PyOutline,
OutlineView, PySelectableList, SelectableListView, PyTable, TableView, PyBaseApp, OutlineView, PySelectableList, SelectableListView, PyTable, TableView, PyBaseApp,
PyFairware, PyTextField, ProgressWindowView, PyProgressWindow) PyTextField, ProgressWindowView, PyProgressWindow)
from inter.deletion_options import PyDeletionOptions, DeletionOptionsView from inter.deletion_options import PyDeletionOptions, DeletionOptionsView
from inter.details_panel import PyDetailsPanel, DetailsPanelView from inter.details_panel import PyDetailsPanel, DetailsPanelView
from inter.directory_outline import PyDirectoryOutline, DirectoryOutlineView from inter.directory_outline import PyDirectoryOutline, DirectoryOutlineView
@@ -276,7 +269,7 @@ def build_cocoa_bridging_interfaces(edition):
from inter.stats_label import PyStatsLabel, StatsLabelView from inter.stats_label import PyStatsLabel, StatsLabelView
from inter.app import PyDupeGuruBase, DupeGuruView from inter.app import PyDupeGuruBase, DupeGuruView
appmod = importlib.import_module('inter.app_{}'.format(edition)) appmod = importlib.import_module('inter.app_{}'.format(edition))
allclasses = [PyGUIObject, PyColumns, PyOutline, PySelectableList, PyTable, PyBaseApp, PyFairware, allclasses = [PyGUIObject, PyColumns, PyOutline, PySelectableList, PyTable, PyBaseApp,
PyDetailsPanel, PyDirectoryOutline, PyPrioritizeDialog, PyPrioritizeList, PyProblemDialog, PyDetailsPanel, PyDirectoryOutline, PyPrioritizeDialog, PyPrioritizeList, PyProblemDialog,
PyIgnoreListDialog, PyDeletionOptions, PyResultTable, PyStatsLabel, PyDupeGuruBase, PyIgnoreListDialog, PyDeletionOptions, PyResultTable, PyStatsLabel, PyDupeGuruBase,
PyTextField, PyProgressWindow, appmod.PyDupeGuru] PyTextField, PyProgressWindow, appmod.PyDupeGuru]
@@ -317,7 +310,6 @@ def build_pe_modules(ui):
def build_normal(edition, ui, dev, conf): def build_normal(edition, ui, dev, conf):
print("Building dupeGuru {0} with UI {1}".format(edition.upper(), ui)) print("Building dupeGuru {0} with UI {1}".format(edition.upper(), ui))
add_to_pythonpath('.') add_to_pythonpath('.')
build_help(edition)
print("Building dupeGuru") print("Building dupeGuru")
if edition == 'pe': if edition == 'pe':
build_pe_modules(ui) build_pe_modules(ui)
@@ -335,8 +327,9 @@ def main():
if dev: if dev:
print("Building in Dev mode") print("Building in Dev mode")
if options.clean: if options.clean:
if op.exists('build'): for path in ['build', op.join('cocoa', 'build'), op.join('cocoa', 'autogen')]:
shutil.rmtree('build') if op.exists(path):
shutil.rmtree(path)
if not op.exists('build'): if not op.exists('build'):
os.mkdir('build') os.mkdir('build')
if options.doc: if options.doc:

View File

@@ -13,7 +13,7 @@ http://www.hardcoded.net/licenses/bsd_license
#import "DetailsPanel.h" #import "DetailsPanel.h"
#import "DirectoryPanel.h" #import "DirectoryPanel.h"
#import "IgnoreListDialog.h" #import "IgnoreListDialog.h"
#import "HSFairwareAboutBox.h" #import "HSAboutBox.h"
#import "HSRecentFiles.h" #import "HSRecentFiles.h"
#import "HSProgressWindow.h" #import "HSProgressWindow.h"
@@ -30,7 +30,7 @@ http://www.hardcoded.net/licenses/bsd_license
IgnoreListDialog *_ignoreListDialog; IgnoreListDialog *_ignoreListDialog;
HSProgressWindow *_progressWindow; HSProgressWindow *_progressWindow;
NSWindowController *_preferencesPanel; NSWindowController *_preferencesPanel;
HSFairwareAboutBox *_aboutBox; HSAboutBox *_aboutBox;
HSRecentFiles *_recentResults; HSRecentFiles *_recentResults;
} }
@@ -73,6 +73,4 @@ http://www.hardcoded.net/licenses/bsd_license
/* model --> view */ /* model --> view */
- (void)showMessage:(NSString *)msg; - (void)showMessage:(NSString *)msg;
- (void)setupAsRegistered;
- (void)showDemoNagWithPrompt:(NSString *)prompt;
@end @end

View File

@@ -8,7 +8,6 @@ http://www.hardcoded.net/licenses/bsd_license
#import "AppDelegateBase.h" #import "AppDelegateBase.h"
#import "ProgressController.h" #import "ProgressController.h"
#import "HSFairwareReminder.h"
#import "HSPyUtil.h" #import "HSPyUtil.h"
#import "Consts.h" #import "Consts.h"
#import "Dialogs.h" #import "Dialogs.h"
@@ -140,7 +139,7 @@ http://www.hardcoded.net/licenses/bsd_license
[op setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]]; [op setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]];
[op setTitle:NSLocalizedString(@"Select a results file to load", @"")]; [op setTitle:NSLocalizedString(@"Select a results file to load", @"")];
if ([op runModal] == NSOKButton) { if ([op runModal] == NSOKButton) {
NSString *filename = [[op filenames] objectAtIndex:0]; NSString *filename = [[[op URLs] objectAtIndex:0] path];
[model loadResultsFrom:filename]; [model loadResultsFrom:filename];
[[self recentResults] addFile:filename]; [[self recentResults] addFile:filename];
} }
@@ -162,7 +161,7 @@ http://www.hardcoded.net/licenses/bsd_license
- (void)showAboutBox - (void)showAboutBox
{ {
if (_aboutBox == nil) { if (_aboutBox == nil) {
_aboutBox = [[HSFairwareAboutBox alloc] initWithApp:model]; _aboutBox = [[HSAboutBox alloc] initWithApp:model];
} }
[[_aboutBox window] makeKeyAndOrderFront:nil]; [[_aboutBox window] makeKeyAndOrderFront:nil];
} }
@@ -199,7 +198,6 @@ http://www.hardcoded.net/licenses/bsd_license
/* Delegate */ /* Delegate */
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification - (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{ {
[model initialRegistrationSetup];
[model loadSession]; [model loadSession];
} }
@@ -261,16 +259,6 @@ http://www.hardcoded.net/licenses/bsd_license
[[self resultWindow] showProblemDialog]; [[self resultWindow] showProblemDialog];
} }
- (void)setupAsRegistered
{
// Nothing to do.
}
- (void)showDemoNagWithPrompt:(NSString *)prompt
{
[HSFairwareReminder showDemoNagWithApp:[self model] prompt:prompt];
}
- (NSString *)selectDestFolderWithPrompt:(NSString *)prompt - (NSString *)selectDestFolderWithPrompt:(NSString *)prompt
{ {
NSOpenPanel *op = [NSOpenPanel openPanel]; NSOpenPanel *op = [NSOpenPanel openPanel];
@@ -280,7 +268,7 @@ http://www.hardcoded.net/licenses/bsd_license
[op setAllowsMultipleSelection:NO]; [op setAllowsMultipleSelection:NO];
[op setTitle:prompt]; [op setTitle:prompt];
if ([op runModal] == NSOKButton) { if ([op runModal] == NSOKButton) {
return [[op filenames] objectAtIndex:0]; return [[[op URLs] objectAtIndex:0] path];
} }
else { else {
return nil; return nil;
@@ -294,7 +282,7 @@ http://www.hardcoded.net/licenses/bsd_license
[sp setAllowedFileTypes:[NSArray arrayWithObject:extension]]; [sp setAllowedFileTypes:[NSArray arrayWithObject:extension]];
[sp setTitle:prompt]; [sp setTitle:prompt];
if ([sp runModal] == NSOKButton) { if ([sp runModal] == NSOKButton) {
return [sp filename]; return [[sp URL] path];
} }
else { else {
return nil; return nil;

View File

@@ -64,4 +64,9 @@ http://www.hardcoded.net/licenses/bsd_license
[[self window] close]; [[self window] close];
return r == NSOKButton; return r == NSOKButton;
} }
- (void)setHardlinkOptionEnabled:(BOOL)enabled
{
[linkTypeRadio setEnabled:enabled];
}
@end @end

View File

@@ -16,4 +16,6 @@ http://www.hardcoded.net/licenses/bsd_license
@interface DirectoryOutline : HSOutline {} @interface DirectoryOutline : HSOutline {}
- (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView; - (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView;
- (PyDirectoryOutline *)model; - (PyDirectoryOutline *)model;
- (void)selectAll;
@end; @end;

View File

@@ -22,6 +22,12 @@ http://www.hardcoded.net/licenses/bsd_license
return (PyDirectoryOutline *)model; return (PyDirectoryOutline *)model;
} }
/* Public */
- (void)selectAll
{
[[self model] selectAll];
}
/* Delegate */ /* Delegate */
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id < NSDraggingInfo >)info proposedItem:(id)item proposedChildIndex:(NSInteger)index - (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id < NSDraggingInfo >)info proposedItem:(id)item proposedChildIndex:(NSInteger)index
{ {

View File

@@ -46,4 +46,6 @@ http://www.hardcoded.net/licenses/bsd_license
- (void)addDirectory:(NSString *)directory; - (void)addDirectory:(NSString *)directory;
- (void)refreshRemoveButtonText; - (void)refreshRemoveButtonText;
- (void)markAll;
@end @end

View File

@@ -91,8 +91,8 @@ http://www.hardcoded.net/licenses/bsd_license
[op setTitle:NSLocalizedString(@"Select a folder to add to the scanning list", @"")]; [op setTitle:NSLocalizedString(@"Select a folder to add to the scanning list", @"")];
[op setDelegate:self]; [op setDelegate:self];
if ([op runModal] == NSOKButton) { if ([op runModal] == NSOKButton) {
for (NSString *directory in [op filenames]) { for (NSURL *directoryURL in [op URLs]) {
[self addDirectory:directory]; [self addDirectory:[directoryURL path]];
} }
} }
} }
@@ -158,6 +158,14 @@ http://www.hardcoded.net/licenses/bsd_license
} }
} }
- (void)markAll
{
/* markAll isn't very descriptive of what we do, but since we re-use the Mark All button from
the result window, we don't have much choice.
*/
[outline selectAll];
}
/* Delegate */ /* Delegate */
- (BOOL)panel:(id)sender shouldShowFilename:(NSString *)path - (BOOL)panel:(id)sender shouldShowFilename:(NSString *)path
{ {
@@ -171,6 +179,14 @@ http://www.hardcoded.net/licenses/bsd_license
[self addDirectory:path]; [self addDirectory:path];
} }
- (BOOL)validateMenuItem:(NSMenuItem *)item
{
if ([item action] == @selector(markAll)) {
[item setTitle:NSLocalizedString(@"Select All", @"")];
}
return YES;
}
/* Notifications */ /* Notifications */
- (void)directorySelectionChanged:(NSNotification *)aNotification - (void)directorySelectionChanged:(NSNotification *)aNotification

View File

@@ -258,8 +258,8 @@ http://www.hardcoded.net/licenses/bsd_license
[sp setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]]; [sp setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]];
[sp setTitle:NSLocalizedString(@"Select a file to save your results to", @"")]; [sp setTitle:NSLocalizedString(@"Select a file to save your results to", @"")];
if ([sp runModal] == NSOKButton) { if ([sp runModal] == NSOKButton) {
[model saveResultsAs:[sp filename]]; [model saveResultsAs:[[sp URL] path]];
[[app recentResults] addFile:[sp filename]]; [[app recentResults] addFile:[[sp URL] path]];
} }
} }
@@ -344,6 +344,9 @@ http://www.hardcoded.net/licenses/bsd_license
- (BOOL)validateMenuItem:(NSMenuItem *)item - (BOOL)validateMenuItem:(NSMenuItem *)item
{ {
if ([item action] == @selector(markAll)) {
[item setTitle:NSLocalizedString(@"Mark All", @"")];
}
return ![[ProgressController mainProgressController] isShown]; return ![[ProgressController mainProgressController] isShown];
} }
@end @end

View File

@@ -1,6 +1,5 @@
"%@ Results" = "%@ Results"; "%@ Results" = "%@ Results";
"A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again." = "A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again.";
"About dupeGuru" = "About dupeGuru"; "About dupeGuru" = "About dupeGuru";
"Action" = "Action"; "Action" = "Action";
"Actions" = "Actions"; "Actions" = "Actions";

View File

@@ -1,24 +1,23 @@
import logging import logging
from objp.util import pyref, dontwrap from objp.util import pyref, dontwrap
from cocoa import install_exception_hook, install_cocoa_logger, patch_threaded_job_performer, proxy from cocoa import install_exception_hook, install_cocoa_logger, patch_threaded_job_performer
from cocoa.inter import PyFairware, FairwareView from cocoa.inter import PyBaseApp, BaseAppView
class DupeGuruView(FairwareView): class DupeGuruView(BaseAppView):
def askYesNoWithPrompt_(self, prompt: str) -> bool: pass def askYesNoWithPrompt_(self, prompt: str) -> bool: pass
def showProblemDialog(self): pass def showProblemDialog(self): pass
def selectDestFolderWithPrompt_(self, prompt: str) -> str: pass def selectDestFolderWithPrompt_(self, prompt: str) -> str: pass
def selectDestFileWithPrompt_extension_(self, prompt: str, extension: str) -> str: pass def selectDestFileWithPrompt_extension_(self, prompt: str, extension: str) -> str: pass
class PyDupeGuruBase(PyFairware): class PyDupeGuruBase(PyBaseApp):
@dontwrap @dontwrap
def _init(self, modelclass): def _init(self, modelclass):
logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(message)s') logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(message)s')
install_exception_hook() install_exception_hook()
install_cocoa_logger() install_cocoa_logger()
patch_threaded_job_performer() patch_threaded_job_performer()
appdata = proxy.getAppdataPath() self.model = modelclass(self)
self.model = modelclass(self, appdata)
#---Sub-proxies #---Sub-proxies
def detailsPanel(self) -> pyref: def detailsPanel(self) -> pyref:
@@ -144,14 +143,6 @@ class PyDupeGuruBase(PyFairware):
self.model.options['copymove_dest_type'] = copymove_dest_type self.model.options['copymove_dest_type'] = copymove_dest_type
#--- model --> view #--- model --> view
@dontwrap
def open_path(self, path):
proxy.openPath_(str(path))
@dontwrap
def reveal_path(self, path):
proxy.revealPath_(str(path))
@dontwrap @dontwrap
def ask_yes_no(self, prompt): def ask_yes_no(self, prompt):
return self.callback.askYesNoWithPrompt_(prompt) return self.callback.askYesNoWithPrompt_(prompt)

View File

@@ -19,11 +19,11 @@ from hscommon.path import Path
from hscommon.util import remove_invalid_xml from hscommon.util import remove_invalid_xml
from core import directories from core import directories
from core.app import JobType from core.app import JobType, JOBID2TITLE
from core.scanner import ScanType from core.scanner import ScanType
from core_me.app import DupeGuru as DupeGuruBase from core_me.app import DupeGuru as DupeGuruBase
from core_me import fs from core_me import fs
from .app import JOBID2TITLE, PyDupeGuruBase from .app import PyDupeGuruBase
tr = trget('ui') tr = trget('ui')
@@ -143,9 +143,8 @@ class Directories(directories.Directories):
class DupeGuruME(DupeGuruBase): class DupeGuruME(DupeGuruBase):
def __init__(self, view, appdata): def __init__(self, view):
appdata = op.join(appdata, 'dupeGuru Music Edition') DupeGuruBase.__init__(self, view)
DupeGuruBase.__init__(self, view, appdata)
# Use fileclasses set in DupeGuruBase.__init__() # Use fileclasses set in DupeGuruBase.__init__()
self.directories = Directories(fileclasses=self.directories.fileclasses) self.directories = Directories(fileclasses=self.directories.fileclasses)
self.dead_tracks = [] self.dead_tracks = []
@@ -174,7 +173,7 @@ class DupeGuruME(DupeGuruBase):
DupeGuruBase._do_delete_dupe(self, dupe, *args) DupeGuruBase._do_delete_dupe(self, dupe, *args)
def _create_file(self, path): def _create_file(self, path):
if (self.directories.itunes_libpath is not None) and (path in self.directories.itunes_libpath[:-1]): if (self.directories.itunes_libpath is not None) and (path in self.directories.itunes_libpath.parent()):
if not hasattr(self, 'itunes_songs'): if not hasattr(self, 'itunes_songs'):
songs = get_itunes_songs(self.directories.itunes_libpath) songs = get_itunes_songs(self.directories.itunes_libpath)
self.itunes_songs = {song.path: song for song in songs} self.itunes_songs = {song.path: song for song in songs}
@@ -184,11 +183,14 @@ class DupeGuruME(DupeGuruBase):
pass # We'll return the default file type, as per the last line of this method pass # We'll return the default file type, as per the last line of this method
return DupeGuruBase._create_file(self, path) return DupeGuruBase._create_file(self, path)
def _job_completed(self, jobid, exc): def _job_completed(self, jobid):
if (jobid in {JobType.RemoveDeadTracks, JobType.ScanDeadTracks}) and (exc is not None): # XXX Just before release, I'm realizing that this piece of code below is why I was passing
msg = tr("There were communication problems with iTunes. The operation couldn't be completed.") # job exception as an argument to _job_completed(). I have to comment it for now. It's not
self.view.show_message(msg) # the end of the world, but I should find an elegant solution to this at some point.
return True # if (jobid in {JobType.RemoveDeadTracks, JobType.ScanDeadTracks}) and (exc is not None):
# msg = tr("There were communication problems with iTunes. The operation couldn't be completed.")
# self.view.show_message(msg)
# return True
if jobid == JobType.ScanDeadTracks: if jobid == JobType.ScanDeadTracks:
dead_tracks_count = len(self.dead_tracks) dead_tracks_count = len(self.dead_tracks)
if dead_tracks_count > 0: if dead_tracks_count > 0:
@@ -202,7 +204,7 @@ class DupeGuruME(DupeGuruBase):
if hasattr(self, 'itunes_songs'): if hasattr(self, 'itunes_songs'):
# If we load another file, we want a refresh song list # If we load another file, we want a refresh song list
del self.itunes_songs del self.itunes_songs
DupeGuruBase._job_completed(self, jobid, exc) DupeGuruBase._job_completed(self, jobid)
def copy_or_move(self, dupe, copy, destination, dest_type): def copy_or_move(self, dupe, copy, destination, dest_type):
if isinstance(dupe, ITunesSong): if isinstance(dupe, ITunesSong):
@@ -230,7 +232,7 @@ class DupeGuruME(DupeGuruBase):
except CommandError as e: except CommandError as e:
logging.warning('Error while trying to remove a track from iTunes: %s' % str(e)) logging.warning('Error while trying to remove a track from iTunes: %s' % str(e))
self.view.start_job(JobType.RemoveDeadTracks, do) self._start_job(JobType.RemoveDeadTracks, do)
def scan_dead_tracks(self): def scan_dead_tracks(self):
def do(j): def do(j):
@@ -248,7 +250,7 @@ class DupeGuruME(DupeGuruBase):
self.dead_tracks.append(track) self.dead_tracks.append(track)
logging.info('Found %d dead tracks' % len(self.dead_tracks)) logging.info('Found %d dead tracks' % len(self.dead_tracks))
self.view.start_job(JobType.ScanDeadTracks, do) self._start_job(JobType.ScanDeadTracks, do)
class PyDupeGuru(PyDupeGuruBase): class PyDupeGuru(PyDupeGuruBase):
def __init__(self): def __init__(self):

View File

@@ -6,16 +6,14 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import os.path as op
import plistlib import plistlib
import logging import logging
import re import re
from appscript import app, its, k, CommandError, ApplicationNotFoundError from appscript import app, its, k, CommandError, ApplicationNotFoundError
from hscommon import io
from hscommon.util import remove_invalid_xml, first from hscommon.util import remove_invalid_xml, first
from hscommon.path import Path from hscommon.path import Path, pathify
from hscommon.trans import trget from hscommon.trans import trget
from cocoa import proxy from cocoa import proxy
@@ -48,6 +46,16 @@ class Photo(PhotoBase):
raise IOError('The picture %s could not be read' % str(self.path)) raise IOError('The picture %s could not be read' % str(self.path))
return blocks return blocks
def _get_exif_timestamp(self):
exifdata = proxy.readExifData_(str(self.path))
if exifdata:
try:
return exifdata['{Exif}']['DateTimeOriginal']
except KeyError:
return ''
else:
return ''
class IPhoto(Photo): class IPhoto(Photo):
def __init__(self, path, db_id): def __init__(self, path, db_id):
@@ -67,11 +75,12 @@ class AperturePhoto(Photo):
def display_folder_path(self): def display_folder_path(self):
return APERTURE_PATH return APERTURE_PATH
def get_iphoto_or_aperture_pictures(plistpath, photo_class): @pathify
def get_iphoto_or_aperture_pictures(plistpath: Path, photo_class):
# The structure of iPhoto and Aperture libraries for the base photo list are excactly the same. # The structure of iPhoto and Aperture libraries for the base photo list are excactly the same.
if not io.exists(plistpath): if not plistpath.exists():
return [] return []
s = io.open(plistpath, 'rt', encoding='utf-8').read() s = plistpath.open('rt', encoding='utf-8').read()
# There was a case where a guy had 0x10 chars in his plist, causing expat errors on loading # There was a case where a guy had 0x10 chars in his plist, causing expat errors on loading
s = remove_invalid_xml(s, replace_with='') s = remove_invalid_xml(s, replace_with='')
# It seems that iPhoto sometimes doesn't properly escape & chars. The regexp below is to find # It seems that iPhoto sometimes doesn't properly escape & chars. The regexp below is to find
@@ -114,12 +123,12 @@ class Directories(directories.Directories):
directories.Directories.__init__(self, fileclasses=[Photo]) directories.Directories.__init__(self, fileclasses=[Photo])
try: try:
self.iphoto_libpath = get_iphoto_database_path() self.iphoto_libpath = get_iphoto_database_path()
self.set_state(self.iphoto_libpath[:-1], directories.DirectoryState.Excluded) self.set_state(self.iphoto_libpath.parent(), directories.DirectoryState.Excluded)
except directories.InvalidPathError: except directories.InvalidPathError:
self.iphoto_libpath = None self.iphoto_libpath = None
try: try:
self.aperture_libpath = get_aperture_database_path() self.aperture_libpath = get_aperture_database_path()
self.set_state(self.aperture_libpath[:-1], directories.DirectoryState.Excluded) self.set_state(self.aperture_libpath.parent(), directories.DirectoryState.Excluded)
except directories.InvalidPathError: except directories.InvalidPathError:
self.aperture_libpath = None self.aperture_libpath = None
@@ -171,9 +180,8 @@ class Directories(directories.Directories):
class DupeGuruPE(DupeGuruBase): class DupeGuruPE(DupeGuruBase):
def __init__(self, view, appdata): def __init__(self, view):
appdata = op.join(appdata, 'dupeGuru Picture Edition') DupeGuruBase.__init__(self, view)
DupeGuruBase.__init__(self, view, appdata)
self.directories = Directories() self.directories = Directories()
def _do_delete(self, j, *args): def _do_delete(self, j, *args):
@@ -247,20 +255,20 @@ class DupeGuruPE(DupeGuruBase):
DupeGuruBase._do_delete_dupe(self, dupe, *args) DupeGuruBase._do_delete_dupe(self, dupe, *args)
def _create_file(self, path): def _create_file(self, path):
if (self.directories.iphoto_libpath is not None) and (path in self.directories.iphoto_libpath[:-1]): if (self.directories.iphoto_libpath is not None) and (path in self.directories.iphoto_libpath.parent()):
if not hasattr(self, 'path2iphoto'): if not hasattr(self, 'path2iphoto'):
photos = get_iphoto_pictures(self.directories.iphoto_libpath) photos = get_iphoto_pictures(self.directories.iphoto_libpath)
self.path2iphoto = {p.path: p for p in photos} self.path2iphoto = {p.path: p for p in photos}
return self.path2iphoto.get(path) return self.path2iphoto.get(path)
if (self.directories.aperture_libpath is not None) and (path in self.directories.aperture_libpath[:-1]): if (self.directories.aperture_libpath is not None) and (path in self.directories.aperture_libpath.parent()):
if not hasattr(self, 'path2aperture'): if not hasattr(self, 'path2aperture'):
photos = get_aperture_pictures(self.directories.aperture_libpath) photos = get_aperture_pictures(self.directories.aperture_libpath)
self.path2aperture = {p.path: p for p in photos} self.path2aperture = {p.path: p for p in photos}
return self.path2aperture.get(path) return self.path2aperture.get(path)
return DupeGuruBase._create_file(self, path) return DupeGuruBase._create_file(self, path)
def _job_completed(self, jobid, exc): def _job_completed(self, jobid):
DupeGuruBase._job_completed(self, jobid, exc) DupeGuruBase._job_completed(self, jobid)
if jobid == JobType.Load: if jobid == JobType.Load:
if hasattr(self, 'path2iphoto'): if hasattr(self, 'path2iphoto'):
del self.path2iphoto del self.path2iphoto

View File

@@ -9,15 +9,13 @@
import logging import logging
import os.path as op import os.path as op
from hscommon import io from hscommon.path import Path, pathify
from hscommon.path import Path
from cocoa import proxy from cocoa import proxy
from core.scanner import ScanType from core.scanner import ScanType
from core import fs
from core.directories import Directories as DirectoriesBase, DirectoryState from core.directories import Directories as DirectoriesBase, DirectoryState
from core_se.app import DupeGuru as DupeGuruBase from core_se.app import DupeGuru as DupeGuruBase
from core_se.fs import File from core_se import fs
from .app import PyDupeGuruBase from .app import PyDupeGuruBase
def is_bundle(str_path): def is_bundle(str_path):
@@ -28,15 +26,17 @@ def is_bundle(str_path):
class Bundle(fs.Folder): class Bundle(fs.Folder):
@classmethod @classmethod
def can_handle(cls, path): @pathify
return not io.islink(path) and io.isdir(path) and is_bundle(str(path)) def can_handle(cls, path: Path):
return not path.islink() and path.isdir() and is_bundle(str(path))
class Directories(DirectoriesBase): class Directories(DirectoriesBase):
ROOT_PATH_TO_EXCLUDE = list(map(Path, ['/Library', '/Volumes', '/System', '/bin', '/sbin', '/opt', '/private', '/dev'])) ROOT_PATH_TO_EXCLUDE = list(map(Path, ['/Library', '/Volumes', '/System', '/bin', '/sbin', '/opt', '/private', '/dev']))
HOME_PATH_TO_EXCLUDE = [Path('Library')] HOME_PATH_TO_EXCLUDE = [Path('Library')]
def __init__(self): def __init__(self):
DirectoriesBase.__init__(self, fileclasses=[Bundle, File]) DirectoriesBase.__init__(self, fileclasses=[Bundle, fs.File])
self.folderclass = fs.Folder
def _default_state_for_path(self, path): def _default_state_for_path(self, path):
result = DirectoriesBase._default_state_for_path(self, path) result = DirectoriesBase._default_state_for_path(self, path)
@@ -68,9 +68,10 @@ class Directories(DirectoriesBase):
class DupeGuru(DupeGuruBase): class DupeGuru(DupeGuruBase):
def __init__(self, view, appdata): def __init__(self, view):
appdata = op.join(appdata, 'dupeGuru') # appdata = op.join(appdata, 'dupeGuru')
DupeGuruBase.__init__(self, view, appdata) # print(repr(appdata))
DupeGuruBase.__init__(self, view)
self.directories = Directories() self.directories = Directories()

View File

@@ -11,6 +11,7 @@ from cocoa.inter import PyGUIObject, GUIObjectView
class DeletionOptionsView(GUIObjectView): class DeletionOptionsView(GUIObjectView):
def updateMsg_(self, msg: str): pass def updateMsg_(self, msg: str): pass
def show(self) -> bool: pass def show(self) -> bool: pass
def setHardlinkOptionEnabled_(self, enabled: bool): pass
class PyDeletionOptions(PyGUIObject): class PyDeletionOptions(PyGUIObject):
def setLinkDeleted_(self, link_deleted: bool): def setLinkDeleted_(self, link_deleted: bool):
@@ -31,3 +32,6 @@ class PyDeletionOptions(PyGUIObject):
def show(self): def show(self):
return self.callback.show() return self.callback.show()
@dontwrap
def set_hardlink_option_enabled(self, enabled):
self.callback.setHardlinkOptionEnabled_(enabled)

View File

@@ -11,6 +11,9 @@ class PyDirectoryOutline(PyOutline):
def removeSelectedDirectory(self): def removeSelectedDirectory(self):
self.model.remove_selected() self.model.remove_selected()
def selectAll(self):
self.model.select_all()
# python --> cocoa # python --> cocoa
@dontwrap @dontwrap
def refresh_states(self): def refresh_states(self):

View File

@@ -44,7 +44,7 @@ def build(ctx):
cocoalib_node = ctx.srcnode.find_dir('..').find_dir('cocoalib') cocoalib_node = ctx.srcnode.find_dir('..').find_dir('cocoalib')
cocoalib_folders = ['controllers', 'views'] cocoalib_folders = ['controllers', 'views']
cocoalib_includes = [cocoalib_node] + [cocoalib_node.find_dir(folder) for folder in cocoalib_folders] cocoalib_includes = [cocoalib_node] + [cocoalib_node.find_dir(folder) for folder in cocoalib_folders]
cocoalib_uses = ['NSEventAdditions', 'Dialogs', 'HSFairwareAboutBox', 'HSFairwareReminder', 'Utils', cocoalib_uses = ['NSEventAdditions', 'Dialogs', 'HSAboutBox', 'Utils',
'HSPyUtil', 'ProgressController', 'HSRecentFiles', 'HSQuicklook', 'ValueTransformers', 'HSPyUtil', 'ProgressController', 'HSRecentFiles', 'HSQuicklook', 'ValueTransformers',
'NSImageAdditions', 'NSNotificationAdditions', 'NSImageAdditions', 'NSNotificationAdditions',
'views/HSTableView', 'views/HSOutlineView', 'views/NSIndexPathAdditions', 'views/HSTableView', 'views/HSOutlineView', 'views/NSIndexPathAdditions',

View File

@@ -14,8 +14,6 @@ http://www.hardcoded.net/licenses/bsd_license
NSTextField *titleTextField; NSTextField *titleTextField;
NSTextField *versionTextField; NSTextField *versionTextField;
NSTextField *copyrightTextField; NSTextField *copyrightTextField;
NSTextField *registeredTextField;
NSButton *registerButton;
PyBaseApp *app; PyBaseApp *app;
} }

View File

@@ -1,19 +0,0 @@
/*
Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "BSD" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.hardcoded.net/licenses/bsd_license
*/
#import <Cocoa/Cocoa.h>
#import "HSFairwareProtocol.h"
@interface HSFairware : NSObject <HSFairwareProtocol>
{
NSInteger appId;
NSString *name;
BOOL registered;
}
- (id)initWithAppId:(NSInteger)aAppId name:(NSString *)aName;
@end

View File

@@ -1,150 +0,0 @@
/*
Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "BSD" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.hardcoded.net/licenses/bsd_license
*/
#import "HSFairware.h"
#import <CommonCrypto/CommonDigest.h>
#import "HSFairwareReminder.h"
#import "Dialogs.h"
#import "Utils.h"
NSString* md5str(NSString *source)
{
const char *cSource = [source UTF8String];
unsigned char result[16];
CC_MD5(cSource, strlen(cSource), result);
return fmt(@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x",
result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7],
result[8], result[9], result[10], result[11], result[12], result[13], result[14], result[15]
);
}
BOOL validateCode(NSString *code, NSString *email, NSInteger appId)
{
if ([code length] != 32) {
return NO;
}
NSInteger i;
for (i=0; i<=100; i++) {
NSString *blob = fmt(@"%i%@%iaybabtu", appId, email, i);
if ([md5str(blob) isEqualTo:code]) {
return YES;
}
}
return NO;
}
NSString* normalizeString(NSString *str)
{
return [[str stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString];
}
@implementation HSFairware
- (id)initWithAppId:(NSInteger)aAppId name:(NSString *)aName;
{
self = [super init];
appId = aAppId;
name = [aName retain];
registered = NO;
return self;
}
- (void)dealloc
{
[name release];
[super dealloc];
}
/* Private */
- (void)setRegistrationCode:(NSString *)aCode email:(NSString *)aEmail
{
registered = validateCode(aCode, aEmail, appId);
}
/* Public */
- (void)initialRegistrationSetup
{
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSString *code = [ud stringForKey:@"RegistrationCode"];
NSString *email = [ud stringForKey:@"RegistrationEmail"];
if (code && email) {
[self setRegistrationCode:code email:email];
}
if (!registered) {
BOOL fairwareMode = [ud boolForKey:@"FairwareMode"];
if (!fairwareMode) {
NSString *prompt = @"%@ is fairware, which means \"open source software developed "
"with expectation of fair contributions from users\". It's a very interesting "
"concept, but one year of fairware has shown that most people just want to know "
"how much it costs and not be bothered with theories about intellectual property."
"\n\n"
"So I won't bother you and will be very straightforward: You can try %@ for "
"free but you have to buy it in order to use it without limitations. In demo mode, "
"%@ will show this dialog on startup."
"\n\n"
"So it's as simple as this. If you're curious about fairware, however, I encourage "
"you to read more about it by clicking on the \"Fairware?\" button.";
[HSFairwareReminder showDemoNagWithApp:self prompt:fmt(prompt, name, name, name)];
}
}
}
- (NSString *)appName
{
return name;
}
- (NSString *)appLongName
{
return name;
}
- (BOOL)isRegistered
{
return registered;
}
- (BOOL)setRegisteredCode:(NSString *)code andEmail:(NSString *)email
{
code = normalizeString(code);
email = normalizeString(email);
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
if (([code isEqualTo:@"fairware"]) || ([email isEqualTo:@"fairware"])) {
[ud setBool:YES forKey:@"FairwareMode"];
[Dialogs showMessage:@"Fairware mode enabled."];
return YES;
}
[self setRegistrationCode:code email:email];
if (registered) {
[ud setObject:code forKey:@"RegistrationCode"];
[ud setObject:email forKey:@"RegistrationEmail"];
[Dialogs showMessage:@"Your code is valid, thanks!"];
return YES;
}
else {
[Dialogs showMessage:@"Your code is invalid. Make sure that you wrote the good code. Also "
"make sure that the e-mail you gave is the same as the e-mail you used for your purchase."];
return NO;
}
}
- (void)contribute
{
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://open.hardcoded.net/contribute/"]];
}
- (void)buy
{
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://www.hardcoded.net/purchase.htm"]];
}
- (void)aboutFairware
{
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://open.hardcoded.net/about/"]];
}
@end

View File

@@ -1,33 +0,0 @@
/*
Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "BSD" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.hardcoded.net/licenses/bsd_license
*/
#import <Cocoa/Cocoa.h>
#import "PyFairware.h"
@interface HSFairwareAboutBox : NSWindowController
{
NSTextField *titleTextField;
NSTextField *versionTextField;
NSTextField *copyrightTextField;
NSTextField *registeredTextField;
NSButton *registerButton;
PyFairware *app;
}
@property (readwrite, retain) NSTextField *titleTextField;
@property (readwrite, retain) NSTextField *versionTextField;
@property (readwrite, retain) NSTextField *copyrightTextField;
@property (readwrite, retain) NSTextField *registeredTextField;
@property (readwrite, retain) NSButton *registerButton;
- (id)initWithApp:(PyFairware *)app;
- (void)updateFields;
- (void)showRegisterDialog;
@end

View File

@@ -1,60 +0,0 @@
/*
Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "BSD" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.hardcoded.net/licenses/bsd_license
*/
#import "HSFairwareAboutBox.h"
#import "HSFairwareAboutBox_UI.h"
#import "HSFairwareReminder.h"
@implementation HSFairwareAboutBox
@synthesize titleTextField;
@synthesize versionTextField;
@synthesize copyrightTextField;
@synthesize registeredTextField;
@synthesize registerButton;
- (id)initWithApp:(PyFairware *)aApp
{
self = [super initWithWindow:nil];
[self setWindow:createHSFairwareAboutBox_UI(self)];
app = [aApp retain];
[self updateFields];
return self;
}
- (void)dealloc
{
[app release];
[super dealloc];
}
- (void)updateFields
{
[titleTextField setStringValue:[app appLongName]];
NSString *version = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"];
[versionTextField setStringValue:[NSString stringWithFormat:@"Version: %@",version]];
NSString *copyright = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"NSHumanReadableCopyright"];
[copyrightTextField setStringValue:copyright];
if ([app isRegistered]) {
[registeredTextField setHidden:NO];
[registerButton setHidden:YES];
}
else {
[registeredTextField setHidden:YES];
[registerButton setHidden:NO];
}
}
- (void)showRegisterDialog
{
HSFairwareReminder *fr = [[HSFairwareReminder alloc] initWithApp:app];
[fr enterCode];
[fr release];
[self updateFields];
}
@end

View File

@@ -1,20 +0,0 @@
/*
Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "BSD" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.hardcoded.net/licenses/bsd_license
*/
#import <Cocoa/Cocoa.h>
@protocol HSFairwareProtocol
- (void)initialRegistrationSetup;
- (NSString *)appName;
- (NSString *)appLongName;
- (BOOL)isRegistered;
- (BOOL)setRegisteredCode:(NSString *)code andEmail:(NSString *)email;
- (void)contribute;
- (void)buy;
- (void)aboutFairware;
@end

View File

@@ -1,46 +0,0 @@
/*
Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "BSD" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.hardcoded.net/licenses/bsd_license
*/
#import <Cocoa/Cocoa.h>
#import "HSFairwareProtocol.h"
@interface HSFairwareReminder : NSObject
{
NSWindow *codePanel;
NSTextField *codePromptTextField;
NSTextField *codeTextField;
NSTextField *emailTextField;
NSWindow *demoNagPanel;
NSTextField *demoPromptTextField;
id <HSFairwareProtocol> app;
}
@property (readwrite, retain) NSWindow *codePanel;
@property (readwrite, retain) NSTextField *codePromptTextField;
@property (readwrite, retain) NSTextField *codeTextField;
@property (readwrite, retain) NSTextField *emailTextField;
@property (readwrite, retain) NSWindow *demoNagPanel;
@property (readwrite, retain) NSTextField *demoPromptTextField;
//Show nag only if needed
+ (BOOL)showDemoNagWithApp:(id <HSFairwareProtocol>)app prompt:(NSString *)prompt;
- (id)initWithApp:(id <HSFairwareProtocol>)app;
- (void)contribute;
- (void)buy;
- (void)moreInfo;
- (void)cancelCode;
- (void)showEnterCode;
- (void)submitCode;
- (void)closeDialog;
- (BOOL)showNagPanel:(NSWindow *)panel; //YES: The code has been sucessfully submitted NO: The use wan't to try the demo.
- (BOOL)showDemoNagPanelWithPrompt:(NSString *)prompt;
- (NSInteger)enterCode; //returns the modal code.
@end

View File

@@ -1,115 +0,0 @@
/*
Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "BSD" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.hardcoded.net/licenses/bsd_license
*/
#import "HSFairwareReminder.h"
#import "HSDemoReminder_UI.h"
#import "HSEnterCode_UI.h"
#import "Dialogs.h"
#import "Utils.h"
@implementation HSFairwareReminder
@synthesize codePanel;
@synthesize codePromptTextField;
@synthesize codeTextField;
@synthesize emailTextField;
@synthesize demoNagPanel;
@synthesize demoPromptTextField;
+ (BOOL)showDemoNagWithApp:(id <HSFairwareProtocol>)app prompt:(NSString *)prompt
{
HSFairwareReminder *fr = [[HSFairwareReminder alloc] initWithApp:app];
BOOL r = [fr showDemoNagPanelWithPrompt:prompt];
[fr release];
return r;
}
- (id)initWithApp:(id <HSFairwareProtocol>)aApp
{
self = [super init];
app = aApp;
[self setDemoNagPanel:createHSDemoReminder_UI(self)];
[self setCodePanel:createHSEnterCode_UI(self)];
[codePanel update];
[codePromptTextField setStringValue:fmt([codePromptTextField stringValue],[app appName])];
return self;
}
- (void)contribute
{
[app contribute];
}
- (void)buy
{
[app buy];
}
- (void)moreInfo
{
[app aboutFairware];
}
- (void)cancelCode
{
[codePanel close];
[NSApp stopModalWithCode:NSCancelButton];
}
- (void)showEnterCode
{
[demoNagPanel close];
[NSApp stopModalWithCode:NSOKButton];
}
- (void)submitCode
{
NSString *code = [codeTextField stringValue];
NSString *email = [emailTextField stringValue];
if ([app setRegisteredCode:code andEmail:email]) {
[codePanel close];
[NSApp stopModalWithCode:NSOKButton];
}
}
- (void)closeDialog
{
[demoNagPanel close];
[NSApp stopModalWithCode:NSCancelButton];
}
- (BOOL)showNagPanel:(NSWindow *)panel;
{
NSInteger r;
while (YES) {
r = [NSApp runModalForWindow:panel];
if (r == NSOKButton) {
r = [self enterCode];
if (r == NSOKButton) {
return YES;
}
}
else {
return NO;
}
}
}
- (BOOL)showDemoNagPanelWithPrompt:(NSString *)prompt
{
[demoNagPanel setTitle:fmt([demoNagPanel title],[app appName])];
[demoPromptTextField setStringValue:prompt];
return [self showNagPanel:demoNagPanel];
}
- (NSInteger)enterCode
{
return [NSApp runModalForWindow:codePanel];
}
@end

View File

@@ -20,6 +20,7 @@
- (NSString *)bundleIdentifier; - (NSString *)bundleIdentifier;
- (NSString *)appVersion; - (NSString *)appVersion;
- (NSString *)osxVersion; - (NSString *)osxVersion;
- (NSString *)bundleInfo:(NSString *)key;
- (void)postNotification:(NSString *)name userInfo:(NSDictionary *)userInfo; - (void)postNotification:(NSString *)name userInfo:(NSDictionary *)userInfo;
- (id)prefValue:(NSString *)prefname; - (id)prefValue:(NSString *)prefname;
- (void)setPrefValue:(NSString *)prefname value:(id)value; - (void)setPrefValue:(NSString *)prefname value:(id)value;
@@ -29,4 +30,5 @@
- (void)destroyPool; - (void)destroyPool;
- (void)reportCrash:(NSString *)crashReport; - (void)reportCrash:(NSString *)crashReport;
- (void)log:(NSString *)s; - (void)log:(NSString *)s;
- (NSDictionary *)readExifData:(NSString *)imagePath;
@end @end

View File

@@ -1,5 +1,4 @@
#import "CocoaProxy.h" #import "CocoaProxy.h"
#import <CoreServices/CoreServices.h>
#import "HSErrorReportWindow.h" #import "HSErrorReportWindow.h"
@implementation CocoaProxy @implementation CocoaProxy
@@ -92,13 +91,14 @@
return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"];
} }
- (NSString *)bundleInfo:(NSString *)key
{
return [[NSBundle mainBundle] objectForInfoDictionaryKey:key];
}
- (NSString *)osxVersion - (NSString *)osxVersion
{ {
SInt32 major, minor, bugfix; return [[NSProcessInfo processInfo] operatingSystemVersionString];
Gestalt(gestaltSystemVersionMajor, &major);
Gestalt(gestaltSystemVersionMinor, &minor);
Gestalt(gestaltSystemVersionBugFix, &bugfix);
return [NSString stringWithFormat:@"%d.%d.%d", major, minor, bugfix];
} }
- (void)postNotification:(NSString *)name userInfo:(NSDictionary *)userInfo - (void)postNotification:(NSString *)name userInfo:(NSDictionary *)userInfo
@@ -152,4 +152,20 @@
{ {
NSLog(@"%@", s); NSLog(@"%@", s);
} }
- (NSDictionary *)readExifData:(NSString *)imagePath
{
NSDictionary *result = nil;
NSURL* url = [NSURL fileURLWithPath:imagePath];
CGImageSourceRef source = CGImageSourceCreateWithURL((CFURLRef)url, nil);
if (source != nil) {
CFDictionaryRef metadataRef = CGImageSourceCopyPropertiesAtIndex (source, 0, nil);
if (metadataRef != nil) {
result = [NSDictionary dictionaryWithDictionary:(NSDictionary *)metadataRef];
CFRelease(metadataRef);
}
CFRelease(source);
}
return result;
}
@end @end

View File

@@ -294,45 +294,7 @@ class PyBaseApp(PyGUIObject):
def set_default(self, key_name, value): def set_default(self, key_name, value):
proxy.setPrefValue_value_(key_name, value) proxy.setPrefValue_value_(key_name, value)
@dontwrap
def open_url(self, url):
proxy.openURL_(url)
@dontwrap @dontwrap
def show_message(self, msg): def show_message(self, msg):
self.callback.showMessage_(msg) self.callback.showMessage_(msg)
class FairwareView(BaseAppView):
def setupAsRegistered(self): pass
def showDemoNagWithPrompt_(self, prompt: str): pass
class PyFairware(PyBaseApp):
FOLLOW_PROTOCOLS = ['HSFairwareProtocol']
def initialRegistrationSetup(self):
self.model.initial_registration_setup()
def isRegistered(self) -> bool:
return self.model.registered
def setRegisteredCode_andEmail_(self, code: str, email: str) -> bool:
return self.model.set_registration(code, email, False)
def contribute(self):
self.model.contribute()
def buy(self):
self.model.buy()
def aboutFairware(self):
self.model.about_fairware()
#--- Python --> Cocoa
@dontwrap
def setup_as_registered(self):
self.callback.setupAsRegistered()
@dontwrap
def show_demo_nag(self, prompt):
self.callback.showDemoNagWithPrompt_(prompt)

View File

@@ -101,7 +101,13 @@ http://www.hardcoded.net/licenses/bsd_license
[[self view] setDelegate:nil]; [[self view] setDelegate:nil];
[[self view] reloadData]; [[self view] reloadData];
[[self view] setDelegate:self]; [[self view] setDelegate:self];
[oldRetainer release]; /* Item retainer and releasing
In theory, [oldRetainer release] should work, but in practice, doing so causes occasional
crashes during drag & drop, which I guess keep the reference of an item a bit longer than it
should. This is why we autorelease here. See #354.
*/
[oldRetainer autorelease];
[self updateSelection]; [self updateSelection];
} }

View File

@@ -1,28 +1,15 @@
"%@ is Fairware" = "%@ is Fairware";
"Although the application should continue to run after this error, it may be in an instable state, so it is recommended that you restart the application." = "Although the application should continue to run after this error, it may be in an instable state, so it is recommended that you restart the application."; "Although the application should continue to run after this error, it may be in an instable state, so it is recommended that you restart the application." = "Although the application should continue to run after this error, it may be in an instable state, so it is recommended that you restart the application.";
"Buy" = "Buy";
"Cancel" = "Cancel"; "Cancel" = "Cancel";
"Clear List" = "Clear List"; "Clear List" = "Clear List";
"Contribute" = "Contribute";
"Don't Send" = "Don't Send"; "Don't Send" = "Don't Send";
"Enter Key" = "Enter Key";
"Enter your key" = "Enter your key";
"Error Report" = "Error Report"; "Error Report" = "Error Report";
"Fairware?" = "Fairware?";
"No" = "No"; "No" = "No";
"OK" = "OK"; "OK" = "OK";
"Please wait..." = "Please wait..."; "Please wait..." = "Please wait...";
"Register" = "Register";
"Registration e-mail:" = "Registration e-mail:";
"Registration key:" = "Registration key:";
"Send" = "Send"; "Send" = "Send";
"Something went wrong. Would you like to send the error report to Hardcoded Software?" = "Something went wrong. Would you like to send the error report to Hardcoded Software?"; "Something went wrong. Would you like to send the error report to Hardcoded Software?" = "Something went wrong. Would you like to send the error report to Hardcoded Software?";
"Status: Working..." = "Status: Working..."; "Status: Working..." = "Status: Working...";
"Submit" = "Submit";
"This app is registered, thanks!" = "This app is registered, thanks!";
"Try" = "Try";
"Type the key you received when you contributed to %@, as well as the e-mail used as a reference for the purchase." = "Type the key you received when you contributed to %@, as well as the e-mail used as a reference for the purchase.";
"Work in progress, please wait." = "Work in progress, please wait."; "Work in progress, please wait." = "Work in progress, please wait.";
"Work in progress..." = "Work in progress..."; "Work in progress..." = "Work in progress...";
"Yes" = "Yes"; "Yes" = "Yes";

View File

@@ -1,12 +1,6 @@
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: utf-8\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
@@ -14,10 +8,6 @@ msgid ""
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
@@ -26,30 +16,14 @@ msgstr ""
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -62,18 +36,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "" msgstr ""
@@ -88,24 +50,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,20 +9,12 @@ msgstr ""
"Language: cs\n" "Language: cs\n"
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ is Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Buy"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Cancel"
@@ -31,30 +23,14 @@ msgstr "Cancel"
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Contribute"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Don't Send" msgstr "Don't Send"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Enter Key"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Enter your key"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -67,18 +43,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Register"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Registration key:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Send" msgstr "Send"
@@ -95,26 +59,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Submit"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "This app is registered, thanks!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Try"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,20 +9,12 @@ msgstr ""
"Language: de\n" "Language: de\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ is Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Buy"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Abbrechen" msgstr "Abbrechen"
@@ -31,30 +23,14 @@ msgstr "Abbrechen"
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Spenden"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Don't Send" msgstr "Don't Send"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Registrieren"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Schlüssel eingeben"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -67,18 +43,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Register"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Registrierungsschlüssel:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Send" msgstr "Send"
@@ -95,26 +59,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Abschicken"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "This app is registered, thanks!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Try"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Geben Sie den empfangenen Schlüssel und die E-Mail-Adresse als Referenz für "
"den Kauf an, wenn Sie für %@ gespendet haben."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,10 +9,6 @@ msgstr ""
"Language: es\n" "Language: es\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ es Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
@@ -21,10 +17,6 @@ msgstr ""
"Aunque la aplicación debería continuar funcionado tras el fallo, sin embargo" "Aunque la aplicación debería continuar funcionado tras el fallo, sin embargo"
" podría volverse inestable. Se recomienda reiniciar la aplicación." " podría volverse inestable. Se recomienda reiniciar la aplicación."
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Comprar"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Cancelar" msgstr "Cancelar"
@@ -33,30 +25,14 @@ msgstr "Cancelar"
msgid "Clear List" msgid "Clear List"
msgstr "Limpiar lista" msgstr "Limpiar lista"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Donar"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "No envíar" msgstr "No envíar"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Introducir clave"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Introduzca su clave"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "Informe de error" msgstr "Informe de error"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "¿Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "No" msgstr "No"
@@ -69,18 +45,6 @@ msgstr "Aceptar"
msgid "Please wait..." msgid "Please wait..."
msgstr "Por favor, espere..." msgstr "Por favor, espere..."
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Registrar"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr "Correo electrónico de registro:"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Clave de registro"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Enviar" msgstr "Enviar"
@@ -97,26 +61,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "Estado: procesando..." msgstr "Estado: procesando..."
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Enviar"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "La aplicación está registrada. ¡Gracias!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Probar"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Escriba la clave que recibió al donar a %@, así como el correo electrónico "
"que usó en el proceso."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "En proceso, por favor, espere." msgstr "En proceso, por favor, espere."

View File

@@ -9,20 +9,12 @@ msgstr ""
"Language: fr\n" "Language: fr\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ est Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Acheter"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@@ -31,30 +23,14 @@ msgstr "Annuler"
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Contribuer"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Ignorer" msgstr "Ignorer"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Enregistrer"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Entrez votre clé"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -67,18 +43,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Enregistrer"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Clé d'enregistrement:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Envoyer" msgstr "Envoyer"
@@ -94,26 +58,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Soumettre"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "L'application est enregistrée, merci!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Essayer"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Entrez la clé que vous avez reçue en contribuant à %@, ainsi que le courriel"
" utilisé pour la contribution."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,20 +9,12 @@ msgstr ""
"Language: hy\n" "Language: hy\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "$appname-ը Fairware է"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Գնել"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Չեղարկել" msgstr "Չեղարկել"
@@ -31,30 +23,14 @@ msgstr "Չեղարկել"
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Մասնակցել"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Չուղարկել" msgstr "Չուղարկել"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Գրել բանալին"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Շարունակել"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware է՞"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -67,18 +43,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Գրանցել"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Գրանցման բանալին."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Ուղարկել" msgstr "Ուղարկել"
@@ -94,26 +58,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Հաստատել"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "Այս ծրագիրը գրանցված է, շնորհակալություն!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Փորձել"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Մուտքագրեք այն բանալին, որը ստացել եք %@-ին աջակցելիս, քանզի Ձեր էլ. հասցեն "
"օգտագործվել է գնման ժամանակ:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,20 +9,12 @@ msgstr ""
"Language: it\n" "Language: it\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ è Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Acquista"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Annulla" msgstr "Annulla"
@@ -31,30 +23,14 @@ msgstr "Annulla"
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Contribuisci"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Non inviare" msgstr "Non inviare"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Inserisci Codice"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Inserisci il tuo codice"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -67,18 +43,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Registra"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Codice di registrazione:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Invia" msgstr "Invia"
@@ -95,26 +59,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Invia"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "Questa applicazione è registrata, grazie!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Prova"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Inserisci il codice che hai ricevuto quando hai contribuito a %@, così come "
"l'email di riferimento utilizzata per l'acquisto."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,20 +9,12 @@ msgstr ""
"Language: nl\n" "Language: nl\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
@@ -31,30 +23,14 @@ msgstr ""
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -67,18 +43,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "" msgstr ""
@@ -93,24 +57,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "This app is registered, thanks!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,10 +9,6 @@ msgstr ""
"Language: pt_BR\n" "Language: pt_BR\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ é Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
@@ -21,10 +17,6 @@ msgstr ""
"Embora o aplicativo continue a funcionar após este erro, ele pode estar " "Embora o aplicativo continue a funcionar após este erro, ele pode estar "
"instável. É recomendável reiniciá-lo." "instável. É recomendável reiniciá-lo."
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Comprar"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Cancelar" msgstr "Cancelar"
@@ -33,30 +25,14 @@ msgstr "Cancelar"
msgid "Clear List" msgid "Clear List"
msgstr "Limpar Lista" msgstr "Limpar Lista"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Contribuir"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Não Enviar" msgstr "Não Enviar"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Entrar Chave"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Entre sua chave"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "Não" msgstr "Não"
@@ -69,18 +45,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "Aguarde..." msgstr "Aguarde..."
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Registrar"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Chave de registro:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Enviar" msgstr "Enviar"
@@ -96,26 +60,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Enviar"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "O app está registrado, obrigado!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Testar"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Digite a chave que você recebeu ao contribuir com o %@, assim como o e-mail "
"usado para a compra."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,20 +9,12 @@ msgstr ""
"Language: ru\n" "Language: ru\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ является Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Купить"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Отменить" msgstr "Отменить"
@@ -31,30 +23,14 @@ msgstr "Отменить"
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Способствовайте"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Не отправлять" msgstr "Не отправлять"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Видите ключ"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Видите Ваш ключ"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -67,18 +43,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Регистрация"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Регистрационный ключ:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Отправить" msgstr "Отправить"
@@ -94,26 +58,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Передать"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "Это приложение зарегистрировано, спасибо!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Пробовать"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Тип ключа, который вы получили при способствовала %@, а также электронной "
"почты используется в качестве ссылки для покупки."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,10 +9,6 @@ msgstr ""
"Language: uk\n" "Language: uk\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ це Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
@@ -21,10 +17,6 @@ msgstr ""
"Хоча програма має продовжувати роботу після цієї помилки, вона може " "Хоча програма має продовжувати роботу після цієї помилки, вона може "
"перебувати у нестабільному стані, тож рекомендується перезапустити програму." "перебувати у нестабільному стані, тож рекомендується перезапустити програму."
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Купити"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "Відмінити" msgstr "Відмінити"
@@ -33,30 +25,14 @@ msgstr "Відмінити"
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "Зробити внесок"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Не надсилати" msgstr "Не надсилати"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "Введіть ключ"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Введіть Ваш ключ"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -69,18 +45,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Зареєструвати"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "Реєстраційний ключ:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Надіслати" msgstr "Надіслати"
@@ -96,26 +60,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "Надіслати"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "Програму зареєстровано, дякуємо!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Спробувати"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
"Введіть ключ, який Ви отримали зробивши внесок за %@, а також адресу "
"електронної пошти, яка була вказана під час покупки."
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -10,20 +10,12 @@ msgstr ""
"Language: vi\n" "Language: vi\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
@@ -32,30 +24,14 @@ msgstr ""
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -68,18 +44,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "" msgstr ""
@@ -94,24 +58,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -9,20 +9,12 @@ msgstr ""
"Language: zh_CN\n" "Language: zh_CN\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "%@ is Fairware"
msgstr "%@ is Fairware"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "" msgid ""
"Although the application should continue to run after this error, it may be " "Although the application should continue to run after this error, it may be "
"in an instable state, so it is recommended that you restart the application." "in an instable state, so it is recommended that you restart the application."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Buy"
msgstr "Buy"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Cancel" msgid "Cancel"
msgstr "取消" msgstr "取消"
@@ -31,30 +23,14 @@ msgstr "取消"
msgid "Clear List" msgid "Clear List"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Contribute"
msgstr "捐助"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Don't Send" msgid "Don't Send"
msgstr "Don't Send" msgstr "Don't Send"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter Key"
msgstr "输入密钥"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Enter your key"
msgstr "Enter your key"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Error Report" msgid "Error Report"
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Fairware?"
msgstr "Fairware?"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "No" msgid "No"
msgstr "" msgstr ""
@@ -67,18 +43,6 @@ msgstr ""
msgid "Please wait..." msgid "Please wait..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Register"
msgstr "Register"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration e-mail:"
msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Registration key:"
msgstr "密钥:"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Send" msgid "Send"
msgstr "Send" msgstr "Send"
@@ -95,24 +59,6 @@ msgstr ""
msgid "Status: Working..." msgid "Status: Working..."
msgstr "" msgstr ""
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Submit"
msgstr "提交"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "This app is registered, thanks!"
msgstr "This app is registered, thanks!"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid "Try"
msgstr "Try"
#: cocoalib/en.lproj/cocoalib.strings:0
msgid ""
"Type the key you received when you contributed to %@, as well as the e-mail "
"used as a reference for the purchase."
msgstr "当您捐助 %@ 后,请输入收到的注册密钥以及电子邮件,这将作为购买凭证。"
#: cocoalib/en.lproj/cocoalib.strings:0 #: cocoalib/en.lproj/cocoalib.strings:0
msgid "Work in progress, please wait." msgid "Work in progress, please wait."
msgstr "" msgstr ""

View File

@@ -1,32 +0,0 @@
ownerclass = 'HSFairwareReminder'
ownerimport = 'HSFairwareReminder.h'
result = Window(528, 253, "%@ is Fairware")
result.canClose = False
result.canResize = False
result.canMinimize = False
demoPromptLabel = Label(result, NLSTR("<demo prompt>"))
tryButton = Button(result, "Try")
enterKeyButton = Button(result, "Enter Key")
buyButton = Button(result, "Buy")
fairwareButton = Button(result, "Fairware?")
owner.demoPromptTextField = demoPromptLabel
result.initialFirstResponder = tryButton
demoPromptLabel.font = Font(FontFamily.Label, FontSize.SmallControl)
tryButton.action = Action(owner, 'closeDialog')
tryButton.keyEquivalent = "\\r"
enterKeyButton.action = Action(owner, 'showEnterCode')
buyButton.action = Action(owner, 'buy')
fairwareButton.action = Action(owner, 'moreInfo')
for button in (tryButton, enterKeyButton, buyButton, fairwareButton):
button.width = 113
demoPromptLabel.height = 185
demoPromptLabel.packToCorner(Pack.UpperLeft)
demoPromptLabel.fill(Pack.Right)
tryButton.packRelativeTo(demoPromptLabel, Pack.Below, Pack.Left)
enterKeyButton.packRelativeTo(tryButton, Pack.Right, Pack.Middle)
buyButton.packRelativeTo(enterKeyButton, Pack.Right, Pack.Middle)
fairwareButton.packRelativeTo(buyButton, Pack.Right, Pack.Middle)

View File

@@ -1,52 +0,0 @@
ownerclass = 'HSFairwareReminder'
ownerimport = 'HSFairwareReminder.h'
result = Window(450, 185, "Enter Key")
result.canClose = False
result.canResize = False
result.canMinimize = False
titleLabel = Label(result, "Enter your key")
promptLabel = Label(result, "Type the key you received when you contributed to %@, as well as the e-mail used as a reference for the purchase.")
regkeyLabel = Label(result, "Registration key:")
regkeyField = TextField(result, "")
regemailLabel = Label(result, "Registration e-mail:")
regemailField = TextField(result, "")
contributeButton = Button(result, "Contribute")
cancelButton = Button(result, "Cancel")
submitButton = Button(result, "Submit")
owner.codePromptTextField = promptLabel
owner.codeTextField = regkeyField
owner.emailTextField = regemailField
result.initialFirstResponder = regkeyField
titleLabel.font = Font(FontFamily.Label, FontSize.RegularControl, traits=[FontTrait.Bold])
smallerFont = Font(FontFamily.Label, FontSize.SmallControl)
for control in (promptLabel, regkeyLabel, regemailLabel):
control.font = smallerFont
regkeyField.usesSingleLineMode = regemailField.usesSingleLineMode = True
contributeButton.action = Action(owner, 'contribute')
cancelButton.action = Action(owner, 'cancelCode')
cancelButton.keyEquivalent = "\\E"
submitButton.action = Action(owner, 'submitCode')
submitButton.keyEquivalent = "\\r"
for button in (contributeButton, cancelButton, submitButton):
button.width = 100
regkeyLabel.width = 128
regemailLabel.width = 128
promptLabel.height = 32
titleLabel.packToCorner(Pack.UpperLeft)
titleLabel.fill(Pack.Right)
promptLabel.packRelativeTo(titleLabel, Pack.Below, Pack.Left)
promptLabel.fill(Pack.Right)
regkeyField.packRelativeTo(promptLabel, Pack.Below, Pack.Right)
regkeyLabel.packRelativeTo(regkeyField, Pack.Left, Pack.Middle)
regkeyField.fill(Pack.Left)
regemailField.packRelativeTo(regkeyField, Pack.Below, Pack.Right)
regemailLabel.packRelativeTo(regemailField, Pack.Left, Pack.Middle)
regemailField.fill(Pack.Left)
contributeButton.packRelativeTo(regemailLabel, Pack.Below, Pack.Left)
submitButton.packRelativeTo(regemailField, Pack.Below, Pack.Right)
cancelButton.packRelativeTo(submitButton, Pack.Left, Pack.Middle)

View File

@@ -1,46 +0,0 @@
ownerclass = 'HSFairwareAboutBox'
ownerimport = 'HSFairwareAboutBox.h'
result = Window(259, 217, "")
result.canResize = False
result.canMinimize = False
image = ImageView(result, "NSApplicationIcon")
titleLabel = Label(result, NLSTR("AppTitle"))
versionLabel = Label(result, NLSTR("AppVersion"))
copyrightLabel = Label(result, NLSTR("AppCopyright"))
registeredLabel = Label(result, "This app is registered, thanks!")
registerButton = Button(result, "Register")
owner.window = result
owner.titleTextField = titleLabel
owner.versionTextField = versionLabel
owner.copyrightTextField = copyrightLabel
owner.registeredTextField = registeredLabel
owner.registerButton = registerButton
for label in (titleLabel, versionLabel, copyrightLabel, registeredLabel):
label.alignment = const.NSCenterTextAlignment
titleLabel.font = Font(FontFamily.Label, FontSize.RegularControl, traits=[FontTrait.Bold])
for label in (versionLabel, copyrightLabel, registeredLabel):
label.font = Font(FontFamily.Label, FontSize.SmallControl)
label.height = 14
registerButton.bezelStyle = const.NSRoundRectBezelStyle
registerButton.action = Action(owner, 'showRegisterDialog')
image.height = 96
image.packToCorner(Pack.UpperLeft)
image.y = result.height - 10 - image.height
image.fill(Pack.Right)
image.setAnchor(Pack.UpperLeft, growX=True)
titleLabel.packRelativeTo(image, Pack.Below, Pack.Left)
titleLabel.fill(Pack.Right)
titleLabel.setAnchor(Pack.UpperLeft, growX=True)
versionLabel.packRelativeTo(titleLabel, Pack.Below, Pack.Left)
versionLabel.fill(Pack.Right)
versionLabel.setAnchor(Pack.UpperLeft, growX=True)
copyrightLabel.packRelativeTo(versionLabel, Pack.Below, Pack.Left)
copyrightLabel.fill(Pack.Right)
copyrightLabel.setAnchor(Pack.UpperLeft, growX=True)
registeredLabel.packRelativeTo(copyrightLabel, Pack.Below, Pack.Left)
registeredLabel.fill(Pack.Right)
registeredLabel.setAnchor(Pack.UpperLeft, growX=True)
registerButton.packRelativeTo(copyrightLabel, Pack.Below, Pack.Middle)

View File

@@ -16,15 +16,14 @@ import shutil
from send2trash import send2trash from send2trash import send2trash
from jobprogress import job from jobprogress import job
from hscommon.reg import RegistrableApplication
from hscommon.notify import Broadcaster from hscommon.notify import Broadcaster
from hscommon.path import Path from hscommon.path import Path
from hscommon.conflict import smart_move, smart_copy from hscommon.conflict import smart_move, smart_copy
from hscommon.gui.progress_window import ProgressWindow from hscommon.gui.progress_window import ProgressWindow
from hscommon.util import (delete_if_empty, first, escape, nonone, format_time_decimal, allsame, from hscommon.util import delete_if_empty, first, escape, nonone, format_time_decimal, allsame
rem_file_ext)
from hscommon.trans import tr from hscommon.trans import tr
from hscommon.plat import ISWINDOWS from hscommon.plat import ISWINDOWS
from hscommon import desktop
from . import directories, results, scanner, export, fs from . import directories, results, scanner, export, fs
from .gui.deletion_options import DeletionOptions from .gui.deletion_options import DeletionOptions
@@ -89,14 +88,62 @@ def format_dupe_count(c):
return str(c) if c else '---' return str(c) if c else '---'
def cmp_value(dupe, attrname): def cmp_value(dupe, attrname):
if attrname == 'name':
value = rem_file_ext(dupe.name)
else:
value = getattr(dupe, attrname, '') value = getattr(dupe, attrname, '')
return value.lower() if isinstance(value, str) else value return value.lower() if isinstance(value, str) else value
class DupeGuru(RegistrableApplication, Broadcaster): def fix_surrogate_encoding(s, encoding='utf-8'):
# ref #210. It's possible to end up with file paths that, while correct unicode strings, are
# decoded with the 'surrogateescape' option, which make the string unencodable to utf-8. We fix
# these strings here by trying to encode them and, if it fails, we do an encode/decode dance
# to remove the problematic characters. This dance is *lossy* but there's not much we can do
# because if we end up with this type of string, it means that we don't know the encoding of the
# underlying filesystem that brought them. Don't use this for strings you're going to re-use in
# fs-related functions because you're going to lose your path (it's going to change). Use this
# if you need to export the path somewhere else, outside of the unicode realm.
# See http://lucumr.pocoo.org/2013/7/2/the-updated-guide-to-unicode/
try:
s.encode(encoding)
except UnicodeEncodeError:
return s.encode(encoding, 'replace').decode(encoding)
else:
return s
class DupeGuru(Broadcaster):
"""Holds everything together.
Instantiated once per running application, it holds a reference to every high-level object
whose reference needs to be held: :class:`~core.results.Results`, :class:`Scanner`,
:class:`~core.directories.Directories`, :mod:`core.gui` instances, etc..
It also hosts high level methods and acts as a coordinator for all those elements. This is why
some of its methods seem a bit shallow, like for example :meth:`mark_all` and
:meth:`remove_duplicates`. These methos are just proxies for a method in :attr:`results`, but
they are also followed by a notification call which is very important if we want GUI elements
to be correctly notified of a change in the data they're presenting.
.. attribute:: directories
Instance of :class:`~core.directories.Directories`. It holds the current folder selection.
.. attribute:: results
Instance of :class:`core.results.Results`. Holds the results of the latest scan.
.. attribute:: selected_dupes
List of currently selected dupes from our :attr:`results`. Whenever the user changes its
selection at the UI level, :attr:`result_table` takes care of updating this attribute, so
you can trust that it's always up-to-date.
.. attribute:: result_table
Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results`
"""
#--- View interface #--- View interface
# get_default(key_name)
# set_default(key_name, value)
# show_message(msg)
# open_url(url)
# open_path(path) # open_path(path)
# reveal_path(path) # reveal_path(path)
# ask_yes_no(prompt) --> bool # ask_yes_no(prompt) --> bool
@@ -107,15 +154,14 @@ class DupeGuru(RegistrableApplication, Broadcaster):
# in fairware prompts, we don't mention the edition, it's too long. # in fairware prompts, we don't mention the edition, it's too long.
PROMPT_NAME = "dupeGuru" PROMPT_NAME = "dupeGuru"
DEMO_LIMITATION = tr("will only be able to delete, move or copy 10 duplicates at once")
def __init__(self, view, appdata): def __init__(self, view):
if view.get_default(DEBUG_MODE_PREFERENCE): if view.get_default(DEBUG_MODE_PREFERENCE):
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
logging.debug("Debug mode enabled") logging.debug("Debug mode enabled")
RegistrableApplication.__init__(self, view, appid=1)
Broadcaster.__init__(self) Broadcaster.__init__(self)
self.appdata = appdata self.view = view
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.AppData, appname=self.NAME)
if not op.exists(self.appdata): if not op.exists(self.appdata):
os.makedirs(self.appdata) os.makedirs(self.appdata)
self.directories = directories.Directories() self.directories = directories.Directories()
@@ -153,19 +199,17 @@ class DupeGuru(RegistrableApplication, Broadcaster):
return self.results.is_marked(dupe) return self.results.is_marked(dupe)
if key == 'percentage': if key == 'percentage':
m = get_group().get_match_of(dupe) m = get_group().get_match_of(dupe)
result = m.percentage return m.percentage
elif key == 'dupe_count': elif key == 'dupe_count':
result = 0 return 0
else: else:
result = cmp_value(dupe, key) result = cmp_value(dupe, key)
if delta: if delta:
refval = getattr(get_group().ref, key) refval = cmp_value(get_group().ref, key)
if key in self.result_table.DELTA_COLUMNS: if key in self.result_table.DELTA_COLUMNS:
result -= refval result -= refval
else: else:
# We use directly getattr() because cmp_value() does thing that we don't want to do same = cmp_value(dupe, key) == refval
# when we want to determine whether two values are exactly the same.
same = getattr(dupe, key) == refval
result = (same, result) result = (same, result)
return result return result
@@ -203,7 +247,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
ref = group.ref ref = group.ref
linkfunc = os.link if use_hardlinks else os.symlink linkfunc = os.link if use_hardlinks else os.symlink
linkfunc(str(ref.path), str_path) linkfunc(str(ref.path), str_path)
self.clean_empty_dirs(dupe.path[:-1]) self.clean_empty_dirs(dupe.path.parent())
def _create_file(self, path): def _create_file(self, path):
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths. # We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
@@ -228,7 +272,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
for group_id, group in enumerate(self.results.groups): for group_id, group in enumerate(self.results.groups):
for dupe in group: for dupe in group:
data = self.get_display_info(dupe, group) data = self.get_display_info(dupe, group)
row = [data[col.name] for col in columns] row = [fix_surrogate_encoding(data[col.name]) for col in columns]
row.insert(0, group_id) row.insert(0, group_id)
rows.append(row) rows.append(row)
return colnames, rows return colnames, rows
@@ -290,15 +334,14 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.selected_dupes = dupes self.selected_dupes = dupes
self.notify('dupes_selected') self.notify('dupes_selected')
def _check_demo(self):
if self.should_apply_demo_limitation and self.results.mark_count > 10:
msg = tr("You cannot delete, move or copy more than 10 duplicates at once in demo mode.")
self.view.show_message(msg)
return False
return True
#--- Public #--- Public
def add_directory(self, d): def add_directory(self, d):
"""Adds folder ``d`` to :attr:`directories`.
Shows an error message dialog if something bad happens.
:param str d: path of folder to add
"""
try: try:
self.directories.add_path(Path(d)) self.directories.add_path(Path(d))
self.notify('directories_changed') self.notify('directories_changed')
@@ -308,6 +351,8 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.view.show_message(tr("'{}' does not exist.").format(d)) self.view.show_message(tr("'{}' does not exist.").format(d))
def add_selected_to_ignore_list(self): def add_selected_to_ignore_list(self):
"""Adds :attr:`selected_dupes` to :attr:`scanner`'s ignore list.
"""
dupes = self.without_ref(self.selected_dupes) dupes = self.without_ref(self.selected_dupes)
if not dupes: if not dupes:
self.view.show_message(MSG_NO_SELECTED_DUPES) self.view.show_message(MSG_NO_SELECTED_DUPES)
@@ -324,6 +369,10 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
def apply_filter(self, filter): def apply_filter(self, filter):
"""Apply a filter ``filter`` to the results so that it shows only dupe groups that match it.
:param str filter: filter to apply
"""
self.results.apply_filter(None) self.results.apply_filter(None)
if self.options['escape_filter_regexp']: if self.options['escape_filter_regexp']:
filter = escape(filter, set('()[]\\.|+?^')) filter = escape(filter, set('()[]\\.|+?^'))
@@ -334,7 +383,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
def clean_empty_dirs(self, path): def clean_empty_dirs(self, path):
if self.options['clean_empty_dirs']: if self.options['clean_empty_dirs']:
while delete_if_empty(path, ['.DS_Store']): while delete_if_empty(path, ['.DS_Store']):
path = path[:-1] path = path.parent()
def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType): def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType):
source_path = dupe.path source_path = dupe.path
@@ -342,23 +391,27 @@ class DupeGuru(RegistrableApplication, Broadcaster):
dest_path = Path(destination) dest_path = Path(destination)
if dest_type in {DestType.Relative, DestType.Absolute}: if dest_type in {DestType.Relative, DestType.Absolute}:
# no filename, no windows drive letter # no filename, no windows drive letter
source_base = source_path.remove_drive_letter()[:-1] source_base = source_path.remove_drive_letter().parent()
if dest_type == DestType.Relative: if dest_type == DestType.Relative:
source_base = source_base[location_path:] source_base = source_base[location_path:]
dest_path = dest_path + source_base dest_path = dest_path[source_base]
if not dest_path.exists(): if not dest_path.exists():
dest_path.makedirs() dest_path.makedirs()
# Add filename to dest_path. For file move/copy, it's not required, but for folders, yes. # Add filename to dest_path. For file move/copy, it's not required, but for folders, yes.
dest_path = dest_path + source_path[-1] dest_path = dest_path[source_path.name]
logging.debug("Copy/Move operation from '%s' to '%s'", source_path, dest_path) logging.debug("Copy/Move operation from '%s' to '%s'", source_path, dest_path)
# Raises an EnvironmentError if there's a problem # Raises an EnvironmentError if there's a problem
if copy: if copy:
smart_copy(source_path, dest_path) smart_copy(source_path, dest_path)
else: else:
smart_move(source_path, dest_path) smart_move(source_path, dest_path)
self.clean_empty_dirs(source_path[:-1]) self.clean_empty_dirs(source_path.parent())
def copy_or_move_marked(self, copy): def copy_or_move_marked(self, copy):
"""Start an async move (or copy) job on marked duplicates.
:param bool copy: If True, duplicates will be copied instead of moved
"""
def do(j): def do(j):
def op(dupe): def op(dupe):
j.add_progress() j.add_progress()
@@ -367,8 +420,6 @@ class DupeGuru(RegistrableApplication, Broadcaster):
j.start_job(self.results.mark_count) j.start_job(self.results.mark_count)
self.results.perform_on_marked(op, not copy) self.results.perform_on_marked(op, not copy)
if not self._check_demo():
return
if not self.results.mark_count: if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES) self.view.show_message(MSG_NO_MARKED_DUPES)
return return
@@ -381,8 +432,8 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self._start_job(jobid, do) self._start_job(jobid, do)
def delete_marked(self): def delete_marked(self):
if not self._check_demo(): """Start an async job to send marked duplicates to the trash.
return """
if not self.results.mark_count: if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES) self.view.show_message(MSG_NO_MARKED_DUPES)
return return
@@ -394,11 +445,22 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self._start_job(JobType.Delete, self._do_delete, args=args) self._start_job(JobType.Delete, self._do_delete, args=args)
def export_to_xhtml(self): def export_to_xhtml(self):
"""Export current results to XHTML.
The configuration of the :attr:`result_table` (columns order and visibility) is used to
determine how the data is presented in the export. In other words, the exported table in
the resulting XHTML will look just like the results table.
"""
colnames, rows = self._get_export_data() colnames, rows = self._get_export_data()
export_path = export.export_to_xhtml(colnames, rows) export_path = export.export_to_xhtml(colnames, rows)
self.view.open_path(export_path) desktop.open_path(export_path)
def export_to_csv(self): def export_to_csv(self):
"""Export current results to CSV.
The columns and their order in the resulting CSV file is determined in the same way as in
:meth:`export_to_xhtml`.
"""
dest_file = self.view.select_dest_file(tr("Select a destination for your exported CSV"), 'csv') dest_file = self.view.select_dest_file(tr("Select a destination for your exported CSV"), 'csv')
if dest_file: if dest_file:
colnames, rows = self._get_export_data() colnames, rows = self._get_export_data()
@@ -416,11 +478,11 @@ class DupeGuru(RegistrableApplication, Broadcaster):
return empty_data() return empty_data()
def invoke_custom_command(self): def invoke_custom_command(self):
"""Calls command in 'CustomCommand' pref with %d and %r placeholders replaced. """Calls command in ``CustomCommand`` pref with ``%d`` and ``%r`` placeholders replaced.
Using the current selection, %d is replaced with the currently selected dupe and %r is Using the current selection, ``%d`` is replaced with the currently selected dupe and ``%r``
replaced with that dupe's ref file. If there's no selection, the command is not invoked. is replaced with that dupe's ref file. If there's no selection, the command is not invoked.
If the dupe is a ref, %d and %r will be the same. If the dupe is a ref, ``%d`` and ``%r`` will be the same.
""" """
cmd = self.view.get_default('CustomCommand') cmd = self.view.get_default('CustomCommand')
if not cmd: if not cmd:
@@ -446,6 +508,12 @@ class DupeGuru(RegistrableApplication, Broadcaster):
subprocess.Popen(cmd, shell=True) subprocess.Popen(cmd, shell=True)
def load(self): def load(self):
"""Load directory selection and ignore list from files in appdata.
This method is called during startup so that directory selection and ignore list, which
is persistent data, is the same as when the last session was closed (when :meth:`save` was
called).
"""
self.directories.load_from_file(op.join(self.appdata, 'last_directories.xml')) self.directories.load_from_file(op.join(self.appdata, 'last_directories.xml'))
self.notify('directories_changed') self.notify('directories_changed')
p = op.join(self.appdata, 'ignore_list.xml') p = op.join(self.appdata, 'ignore_list.xml')
@@ -453,11 +521,21 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
def load_from(self, filename): def load_from(self, filename):
"""Start an async job to load results from ``filename``.
:param str filename: path of the XML file (created with :meth:`save_as`) to load
"""
def do(j): def do(j):
self.results.load_from_xml(filename, self._get_file, j) self.results.load_from_xml(filename, self._get_file, j)
self._start_job(JobType.Load, do) self._start_job(JobType.Load, do)
def make_selected_reference(self): def make_selected_reference(self):
"""Promote :attr:`selected_dupes` to reference position within their respective groups.
Each selected dupe will become the :attr:`~core.engine.Group.ref` of its group. If there's
more than one dupe selected for the same group, only the first (in the order currently shown
in :attr:`result_table`) dupe will be promoted.
"""
dupes = self.without_ref(self.selected_dupes) dupes = self.without_ref(self.selected_dupes)
changed_groups = set() changed_groups = set()
for dupe in dupes: for dupe in dupes:
@@ -484,18 +562,30 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.notify('results_changed_but_keep_selection') self.notify('results_changed_but_keep_selection')
def mark_all(self): def mark_all(self):
"""Set all dupes in the results as marked.
"""
self.results.mark_all() self.results.mark_all()
self.notify('marking_changed') self.notify('marking_changed')
def mark_none(self): def mark_none(self):
"""Set all dupes in the results as unmarked.
"""
self.results.mark_none() self.results.mark_none()
self.notify('marking_changed') self.notify('marking_changed')
def mark_invert(self): def mark_invert(self):
"""Invert the marked state of all dupes in the results.
"""
self.results.mark_invert() self.results.mark_invert()
self.notify('marking_changed') self.notify('marking_changed')
def mark_dupe(self, dupe, marked): def mark_dupe(self, dupe, marked):
"""Change marked status of ``dupe``.
:param dupe: dupe to mark/unmark
:type dupe: :class:`~core.fs.File`
:param bool marked: True = mark, False = unmark
"""
if marked: if marked:
self.results.mark(dupe) self.results.mark(dupe)
else: else:
@@ -503,17 +593,26 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.notify('marking_changed') self.notify('marking_changed')
def open_selected(self): def open_selected(self):
"""Open :attr:`selected_dupes` with their associated application.
"""
if len(self.selected_dupes) > 10: if len(self.selected_dupes) > 10:
if not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN): if not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN):
return return
for dupe in self.selected_dupes: for dupe in self.selected_dupes:
self.view.open_path(dupe.path) desktop.open_path(dupe.path)
def purge_ignore_list(self): def purge_ignore_list(self):
"""Remove files that don't exist from :attr:`ignore_list`.
"""
self.scanner.ignore_list.Filter(lambda f,s:op.exists(f) and op.exists(s)) self.scanner.ignore_list.Filter(lambda f,s:op.exists(f) and op.exists(s))
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
def remove_directories(self, indexes): def remove_directories(self, indexes):
"""Remove root directories at ``indexes`` from :attr:`directories`.
:param indexes: Indexes of the directories to remove.
:type indexes: list of int
"""
try: try:
indexes = sorted(indexes, reverse=True) indexes = sorted(indexes, reverse=True)
for index in indexes: for index in indexes:
@@ -523,10 +622,19 @@ class DupeGuru(RegistrableApplication, Broadcaster):
pass pass
def remove_duplicates(self, duplicates): def remove_duplicates(self, duplicates):
"""Remove ``duplicates`` from :attr:`results`.
Calls :meth:`~core.results.Results.remove_duplicates` and send appropriate notifications.
:param duplicates: duplicates to remove.
:type duplicates: list of :class:`~core.fs.File`
"""
self.results.remove_duplicates(self.without_ref(duplicates)) self.results.remove_duplicates(self.without_ref(duplicates))
self.notify('results_changed_but_keep_selection') self.notify('results_changed_but_keep_selection')
def remove_marked(self): def remove_marked(self):
"""Removed marked duplicates from the results (without touching the files themselves).
"""
if not self.results.mark_count: if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES) self.view.show_message(MSG_NO_MARKED_DUPES)
return return
@@ -537,6 +645,8 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self._results_changed() self._results_changed()
def remove_selected(self): def remove_selected(self):
"""Removed :attr:`selected_dupes` from the results (without touching the files themselves).
"""
dupes = self.without_ref(self.selected_dupes) dupes = self.without_ref(self.selected_dupes)
if not dupes: if not dupes:
self.view.show_message(MSG_NO_SELECTED_DUPES) self.view.show_message(MSG_NO_SELECTED_DUPES)
@@ -547,6 +657,12 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.remove_duplicates(dupes) self.remove_duplicates(dupes)
def rename_selected(self, newname): def rename_selected(self, newname):
"""Renames the selected dupes's file to ``newname``.
If there's more than one selected dupes, the first one is used.
:param str newname: The filename to rename the dupe's file to.
"""
try: try:
d = self.selected_dupes[0] d = self.selected_dupes[0]
d.rename(newname) d.rename(newname)
@@ -556,6 +672,14 @@ class DupeGuru(RegistrableApplication, Broadcaster):
return False return False
def reprioritize_groups(self, sort_key): def reprioritize_groups(self, sort_key):
"""Sort dupes in each group (in :attr:`results`) according to ``sort_key``.
Called by the re-prioritize dialog. Calls :meth:`~core.engine.Group.prioritize` and, once
the sorting is done, show a message that confirms the action.
:param sort_key: The key being sent to :meth:`~core.engine.Group.prioritize`
:type sort_key: f(dupe)
"""
count = 0 count = 0
for group in self.results.groups: for group in self.results.groups:
if group.prioritize(key_func=sort_key): if group.prioritize(key_func=sort_key):
@@ -566,7 +690,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
def reveal_selected(self): def reveal_selected(self):
if self.selected_dupes: if self.selected_dupes:
self.view.reveal_path(self.selected_dupes[0].path) desktop.reveal_path(self.selected_dupes[0].path)
def save(self): def save(self):
if not op.exists(self.appdata): if not op.exists(self.appdata):
@@ -577,9 +701,17 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.notify('save_session') self.notify('save_session')
def save_as(self, filename): def save_as(self, filename):
"""Save results in ``filename``.
:param str filename: path of the file to save results (as XML) to.
"""
self.results.save_to_xml(filename) self.results.save_to_xml(filename)
def start_scanning(self): def start_scanning(self):
"""Starts an async job to scan for duplicates.
Scans folders selected in :attr:`directories` and put the results in :attr:`results`
"""
def do(j): def do(j):
j.set_progress(0, tr("Collecting files to scan")) j.set_progress(0, tr("Collecting files to scan"))
if self.scanner.scan_type == scanner.ScanType.Folders: if self.scanner.scan_type == scanner.ScanType.Folders:
@@ -611,6 +743,8 @@ class DupeGuru(RegistrableApplication, Broadcaster):
self.notify('marking_changed') self.notify('marking_changed')
def without_ref(self, dupes): def without_ref(self, dupes):
"""Returns ``dupes`` with all reference elements removed.
"""
return [dupe for dupe in dupes if self.results.get_group_of_duplicate(dupe).ref is not dupe] return [dupe for dupe in dupes if self.results.get_group_of_duplicate(dupe).ref is not dupe]
def get_default(self, key, fallback_value=None): def get_default(self, key, fallback_value=None):

View File

@@ -15,7 +15,20 @@ from hscommon.util import FileOrPath
from . import fs from . import fs
__all__ = [
'Directories',
'DirectoryState',
'AlreadyThereError',
'InvalidPathError',
]
class DirectoryState: class DirectoryState:
"""Enum describing how a folder should be considered.
* DirectoryState.Normal: Scan all files normally
* DirectoryState.Reference: Scan files, but make sure never to delete any of them
* DirectoryState.Excluded: Don't scan this folder
"""
Normal = 0 Normal = 0
Reference = 1 Reference = 1
Excluded = 2 Excluded = 2
@@ -27,11 +40,20 @@ class InvalidPathError(Exception):
"""The path being added is invalid""" """The path being added is invalid"""
class Directories: class Directories:
"""Holds user folder selection.
Manages the selection that the user make through the folder selection dialog. It also manages
folder states, and how recursion applies to them.
Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped
in :mod:`core.fs`) that have to be scanned according to the chosen folders/states.
"""
#---Override #---Override
def __init__(self, fileclasses=[fs.File]): def __init__(self, fileclasses=[fs.File]):
self._dirs = [] self._dirs = []
self.states = {} self.states = {}
self.fileclasses = fileclasses self.fileclasses = fileclasses
self.folderclass = fs.Folder
def __contains__(self, path): def __contains__(self, path):
for p in self._dirs: for p in self._dirs:
@@ -51,7 +73,7 @@ class Directories:
#---Private #---Private
def _default_state_for_path(self, path): def _default_state_for_path(self, path):
# Override this in subclasses to specify the state of some special folders. # Override this in subclasses to specify the state of some special folders.
if path[-1].startswith('.'): # hidden if path.name.startswith('.'): # hidden
return DirectoryState.Excluded return DirectoryState.Excluded
def _get_files(self, from_path, j): def _get_files(self, from_path, j):
@@ -72,9 +94,8 @@ class Directories:
file.is_ref = state == DirectoryState.Reference file.is_ref = state == DirectoryState.Reference
filepaths.add(file.path) filepaths.add(file.path)
yield file yield file
subpaths = [from_path + name for name in from_path.listdir()]
# it's possible that a folder (bundle) gets into the file list. in that case, we don't want to recurse into it # it's possible that a folder (bundle) gets into the file list. in that case, we don't want to recurse into it
subfolders = [p for p in subpaths if not p.islink() and p.isdir() and p not in filepaths] subfolders = [p for p in from_path.listdir() if not p.islink() and p.isdir() and p not in filepaths]
for subfolder in subfolders: for subfolder in subfolders:
for file in self._get_files(subfolder, j): for file in self._get_files(subfolder, j):
yield file yield file
@@ -97,11 +118,14 @@ class Directories:
#---Public #---Public
def add_path(self, path): def add_path(self, path):
"""Adds 'path' to self, if not already there. """Adds ``path`` to self, if not already there.
Raises AlreadyThereError if 'path' is already in self. If path is a directory containing Raises :exc:`AlreadyThereError` if ``path`` is already in self. If path is a directory
some of the directories already present in self, 'path' will be added, but all directories containing some of the directories already present in self, ``path`` will be added, but all
under it will be removed. Can also raise InvalidPathError if 'path' does not exist. directories under it will be removed. Can also raise :exc:`InvalidPathError` if ``path``
does not exist.
:param Path path: path to add
""" """
if path in self: if path in self:
raise AlreadyThereError() raise AlreadyThereError()
@@ -112,18 +136,22 @@ class Directories:
@staticmethod @staticmethod
def get_subfolders(path): def get_subfolders(path):
"""returns a sorted list of paths corresponding to subfolders in `path`""" """Returns a sorted list of paths corresponding to subfolders in ``path``.
:param Path path: get subfolders from there
:rtype: list of Path
"""
try: try:
names = [name for name in path.listdir() if (path + name).isdir()] subpaths = [p for p in path.listdir() if p.isdir()]
names.sort(key=lambda x:x.lower()) subpaths.sort(key=lambda x:x.name.lower())
return [path + name for name in names] return subpaths
except EnvironmentError: except EnvironmentError:
return [] return []
def get_files(self, j=job.nulljob): def get_files(self, j=job.nulljob):
"""Returns a list of all files that are not excluded. """Returns a list of all files that are not excluded.
Returned files also have their 'is_ref' attr set. Returned files also have their ``is_ref`` attr set if applicable.
""" """
for path in self._dirs: for path in self._dirs:
for file in self._get_files(path, j): for file in self._get_files(path, j):
@@ -132,28 +160,36 @@ class Directories:
def get_folders(self, j=job.nulljob): def get_folders(self, j=job.nulljob):
"""Returns a list of all folders that are not excluded. """Returns a list of all folders that are not excluded.
Returned folders also have their 'is_ref' attr set. Returned folders also have their ``is_ref`` attr set if applicable.
""" """
for path in self._dirs: for path in self._dirs:
from_folder = fs.Folder(path) from_folder = self.folderclass(path)
for folder in self._get_folders(from_folder, j): for folder in self._get_folders(from_folder, j):
yield folder yield folder
def get_state(self, path): def get_state(self, path):
"""Returns the state of 'path' (One of the STATE_* const.) """Returns the state of ``path``.
:rtype: :class:`DirectoryState`
""" """
if path in self.states: if path in self.states:
return self.states[path] return self.states[path]
default_state = self._default_state_for_path(path) default_state = self._default_state_for_path(path)
if default_state is not None: if default_state is not None:
return default_state return default_state
parent = path[:-1] parent = path.parent()
if parent in self: if parent in self:
return self.get_state(parent) return self.get_state(parent)
else: else:
return DirectoryState.Normal return DirectoryState.Normal
def has_any_file(self): def has_any_file(self):
"""Returns whether selected folders contain any file.
Because it stops at the first file it finds, it's much faster than get_files().
:rtype: bool
"""
try: try:
next(self.get_files()) next(self.get_files())
return True return True
@@ -161,6 +197,10 @@ class Directories:
return False return False
def load_from_file(self, infile): def load_from_file(self, infile):
"""Load folder selection from ``infile``.
:param file infile: path or file pointer to XML generated through :meth:`save_to_file`
"""
try: try:
root = ET.parse(infile).getroot() root = ET.parse(infile).getroot()
except Exception: except Exception:
@@ -183,6 +223,10 @@ class Directories:
self.set_state(Path(path), int(state)) self.set_state(Path(path), int(state))
def save_to_file(self, outfile): def save_to_file(self, outfile):
"""Save folder selection as XML to ``outfile``.
:param file outfile: path or file pointer to XML file to save to.
"""
with FileOrPath(outfile, 'wb') as fp: with FileOrPath(outfile, 'wb') as fp:
root = ET.Element('directories') root = ET.Element('directories')
for root_path in self: for root_path in self:
@@ -196,6 +240,12 @@ class Directories:
tree.write(fp, encoding='utf-8') tree.write(fp, encoding='utf-8')
def set_state(self, path, state): def set_state(self, path, state):
"""Set the state of folder at ``path``.
:param Path path: path of the target folder
:param state: state to set folder to
:type state: :class:`DirectoryState`
"""
if self.get_state(path) == state: if self.get_state(path) == state:
return return
# we don't want to needlessly fill self.states. if get_state returns the same thing # we don't want to needlessly fill self.states. if get_state returns the same thing

View File

@@ -44,10 +44,10 @@ def unpack_fields(fields):
return result return result
def compare(first, second, flags=()): def compare(first, second, flags=()):
"""Returns the % of words that match between first and second """Returns the % of words that match between ``first`` and ``second``
The result is a int in the range 0..100. The result is a ``int`` in the range 0..100.
First and second can be either a string or a list. ``first`` and ``second`` can be either a string or a list (of words).
""" """
if not (first and second): if not (first and second):
return 0 return 0
@@ -76,9 +76,10 @@ def compare(first, second, flags=()):
return result return result
def compare_fields(first, second, flags=()): def compare_fields(first, second, flags=()):
"""Returns the score for the lowest matching fields. """Returns the score for the lowest matching :ref:`fields`.
first and second must be lists of lists of string. ``first`` and ``second`` must be lists of lists of string. Each sub-list is then compared with
:func:`compare`.
""" """
if len(first) != len(second): if len(first) != len(second):
return 0 return 0
@@ -98,13 +99,14 @@ def compare_fields(first, second, flags=()):
if matched_field: if matched_field:
second.remove(matched_field) second.remove(matched_field)
else: else:
results = [compare(word1, word2, flags) for word1, word2 in zip(first, second)] results = [compare(field1, field2, flags) for field1, field2 in zip(first, second)]
return min(results) if results else 0 return min(results) if results else 0
def build_word_dict(objects, j=job.nulljob): def build_word_dict(objects, j=job.nulljob):
"""Returns a dict of objects mapped by their words. """Returns a dict of objects mapped by their words.
objects must have a 'words' attribute being a list of strings or a list of lists of strings. objects must have a ``words`` attribute being a list of strings or a list of lists of strings
(:ref:`fields`).
The result will be a dict with words as keys, lists of objects as values. The result will be a dict with words as keys, lists of objects as values.
""" """
@@ -115,7 +117,11 @@ def build_word_dict(objects, j=job.nulljob):
return result return result
def merge_similar_words(word_dict): def merge_similar_words(word_dict):
"""Take all keys in word_dict that are similar, and merge them together. """Take all keys in ``word_dict`` that are similar, and merge them together.
``word_dict`` has been built with :func:`build_word_dict`. Similarity is computed with Python's
``difflib.get_close_matches()``, which computes the number of edits that are necessary to make
a word equal to the other.
""" """
keys = list(word_dict.keys()) keys = list(word_dict.keys())
keys.sort(key=len)# we want the shortest word to stay keys.sort(key=len)# we want the shortest word to stay
@@ -131,7 +137,9 @@ def merge_similar_words(word_dict):
keys.remove(similar) keys.remove(similar)
def reduce_common_words(word_dict, threshold): def reduce_common_words(word_dict, threshold):
"""Remove all objects from word_dict values where the object count >= threshold """Remove all objects from ``word_dict`` values where the object count >= ``threshold``
``word_dict`` has been built with :func:`build_word_dict`.
The exception to this removal are the objects where all the words of the object are common. The exception to this removal are the objects where all the words of the object are common.
Because if we remove them, we will miss some duplicates! Because if we remove them, we will miss some duplicates!
@@ -149,14 +157,48 @@ def reduce_common_words(word_dict, threshold):
else: else:
del word_dict[word] del word_dict[word]
Match = namedtuple('Match', 'first second percentage') # Writing docstrings in a namedtuple is tricky. From Python 3.3, it's possible to set __doc__, but
# some research allowed me to find a more elegant solution, which is what is done here. See
# http://stackoverflow.com/questions/1606436/adding-docstrings-to-namedtuples-in-python
class Match(namedtuple('Match', 'first second percentage')):
"""Represents a match between two :class:`~core.fs.File`.
Regarless of the matching method, when two files are determined to match, a Match pair is created,
which holds, of course, the two matched files, but also their match "level".
.. attribute:: first
first file of the pair.
.. attribute:: second
second file of the pair.
.. attribute:: percentage
their match level according to the scan method which found the match. int from 1 to 100. For
exact scan methods, such as Contents scans, this will always be 100.
"""
__slots__ = ()
def get_match(first, second, flags=()): def get_match(first, second, flags=()):
#it is assumed here that first and second both have a "words" attribute #it is assumed here that first and second both have a "words" attribute
percentage = compare(first.words, second.words, flags) percentage = compare(first.words, second.words, flags)
return Match(first, second, percentage) return Match(first, second, percentage)
def getmatches(objects, min_match_percentage=0, match_similar_words=False, weight_words=False, def getmatches(
objects, min_match_percentage=0, match_similar_words=False, weight_words=False,
no_field_order=False, j=job.nulljob): no_field_order=False, j=job.nulljob):
"""Returns a list of :class:`Match` within ``objects`` after fuzzily matching their words.
:param objects: List of :class:`~core.fs.File` to match.
:param int min_match_percentage: minimum % of words that have to match.
:param bool match_similar_words: make similar words (see :func:`merge_similar_words`) match.
:param bool weight_words: longer words are worth more in match % computations.
:param bool no_field_order: match :ref:`fields` regardless of their order.
:param j: A :ref:`job progress instance <jobs>`.
"""
COMMON_WORD_THRESHOLD = 50 COMMON_WORD_THRESHOLD = 50
LIMIT = 5000000 LIMIT = 5000000
j = j.start_subjob(2) j = j.start_subjob(2)
@@ -203,6 +245,14 @@ def getmatches(objects, min_match_percentage=0, match_similar_words=False, weigh
return result return result
def getmatches_by_contents(files, sizeattr='size', partial=False, j=job.nulljob): def getmatches_by_contents(files, sizeattr='size', partial=False, j=job.nulljob):
"""Returns a list of :class:`Match` within ``files`` if their contents is the same.
:param str sizeattr: attibute name of the :class:`~core.fs.file` that returns the size of the
file to use for comparison.
:param bool partial: if true, will use the "md5partial" attribute instead of "md5" to compute
contents hash.
:param j: A :ref:`job progress instance <jobs>`.
"""
j = j.start_subjob([2, 8]) j = j.start_subjob([2, 8])
size2files = defaultdict(set) size2files = defaultdict(set)
for file in j.iter_with_progress(files, tr("Read size of %d/%d files")): for file in j.iter_with_progress(files, tr("Read size of %d/%d files")):
@@ -224,6 +274,32 @@ def getmatches_by_contents(files, sizeattr='size', partial=False, j=job.nulljob)
return result return result
class Group: class Group:
"""A group of :class:`~core.fs.File` that match together.
This manages match pairs into groups and ensures that all files in the group match to each
other.
.. attribute:: ref
The "reference" file, which is the file among the group that isn't going to be deleted.
.. attribute:: ordered
Ordered list of duplicates in the group (including the :attr:`ref`).
.. attribute:: unordered
Set duplicates in the group (including the :attr:`ref`).
.. attribute:: dupes
An ordered list of the group's duplicate, without :attr:`ref`. Equivalent to
``ordered[1:]``
.. attribute:: percentage
Average match percentage of match pairs containing :attr:`ref`.
"""
#---Override #---Override
def __init__(self): def __init__(self):
self._clear() self._clear()
@@ -257,6 +333,15 @@ class Group:
#---Public #---Public
def add_match(self, match): def add_match(self, match):
"""Adds ``match`` to internal match list and possibly add duplicates to the group.
A duplicate can only be considered as such if it matches all other duplicates in the group.
This method registers that pair (A, B) represented in ``match`` as possible candidates and,
if A and/or B end up matching every other duplicates in the group, add these duplicates to
the group.
:param tuple match: pair of :class:`~core.fs.File` to add
"""
def add_candidate(item, match): def add_candidate(item, match):
matches = self.candidates[item] matches = self.candidates[item]
matches.add(match) matches.add(match)
@@ -276,12 +361,18 @@ class Group:
self._matches_for_ref = None self._matches_for_ref = None
def discard_matches(self): def discard_matches(self):
"""Remove all recorded matches that didn't result in a duplicate being added to the group.
You can call this after the duplicate scanning process to free a bit of memory.
"""
discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second])) discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second]))
self.matches -= discarded self.matches -= discarded
self.candidates = defaultdict(set) self.candidates = defaultdict(set)
return discarded return discarded
def get_match_of(self, item): def get_match_of(self, item):
"""Returns the match pair between ``item`` and :attr:`ref`.
"""
if item is self.ref: if item is self.ref:
return return
for m in self._get_matches_for_ref(): for m in self._get_matches_for_ref():
@@ -289,6 +380,12 @@ class Group:
return m return m
def prioritize(self, key_func, tie_breaker=None): def prioritize(self, key_func, tie_breaker=None):
"""Reorders :attr:`ordered` according to ``key_func``.
:param key_func: Key (f(x)) to be used for sorting
:param tie_breaker: function to be used to select the reference position in case the top
duplicates have the same key_func() result.
"""
# tie_breaker(ref, dupe) --> True if dupe should be ref # tie_breaker(ref, dupe) --> True if dupe should be ref
# Returns True if anything changed during prioritization. # Returns True if anything changed during prioritization.
master_key_func = lambda x: (-x.is_ref, key_func(x)) master_key_func = lambda x: (-x.is_ref, key_func(x))
@@ -324,6 +421,8 @@ class Group:
pass pass
def switch_ref(self, with_dupe): def switch_ref(self, with_dupe):
"""Make the :attr:`ref` dupe of the group switch position with ``with_dupe``.
"""
if self.ref.is_ref: if self.ref.is_ref:
return False return False
try: try:
@@ -354,6 +453,10 @@ class Group:
def get_groups(matches, j=job.nulljob): def get_groups(matches, j=job.nulljob):
"""Returns a list of :class:`Group` from ``matches``.
Create groups out of match pairs in the smartest way possible.
"""
matches.sort(key=lambda match: -match.percentage) matches.sort(key=lambda match: -match.percentage)
dupe2group = {} dupe2group = {}
groups = [] groups = []

View File

@@ -16,6 +16,18 @@ import logging
from hscommon.util import nonone, get_file_ext from hscommon.util import nonone, get_file_ext
__all__ = [
'File',
'Folder',
'get_file',
'get_files',
'FSError',
'AlreadyExistsError',
'InvalidPath',
'InvalidDestinationError',
'OperationError',
]
NOT_SET = object() NOT_SET = object()
class FSError(Exception): class FSError(Exception):
@@ -50,6 +62,8 @@ class OperationError(FSError):
cls_message = "Operation on '{name}' failed." cls_message = "Operation on '{name}' failed."
class File: class File:
"""Represents a file and holds metadata to be used for scanning.
"""
INITIAL_INFO = { INITIAL_INFO = {
'size': 0, 'size': 0,
'mtime': 0, 'mtime': 0,
@@ -129,14 +143,16 @@ class File:
#--- Public #--- Public
@classmethod @classmethod
def can_handle(cls, path): def can_handle(cls, path):
"""Returns whether this file wrapper class can handle ``path``.
"""
return not path.islink() and path.isfile() return not path.islink() and path.isfile()
def rename(self, newname): def rename(self, newname):
if newname == self.name: if newname == self.name:
return return
destpath = self.path[:-1] + newname destpath = self.path.parent()[newname]
if destpath.exists(): if destpath.exists():
raise AlreadyExistsError(newname, self.path[:-1]) raise AlreadyExistsError(newname, self.path.parent())
try: try:
self.path.rename(destpath) self.path.rename(destpath)
except EnvironmentError: except EnvironmentError:
@@ -157,11 +173,11 @@ class File:
@property @property
def name(self): def name(self):
return self.path[-1] return self.path.name
@property @property
def folder_path(self): def folder_path(self):
return self.path[:-1] return self.path.parent()
class Folder(File): class Folder(File):
@@ -203,9 +219,8 @@ class Folder(File):
@property @property
def subfolders(self): def subfolders(self):
if self._subfolders is None: if self._subfolders is None:
subpaths = [self.path + name for name in self.path.listdir()] subfolders = [p for p in self.path.listdir() if not p.islink() and p.isdir()]
subfolders = [p for p in subpaths if not p.islink() and p.isdir()] self._subfolders = [self.__class__(p) for p in subfolders]
self._subfolders = [Folder(p) for p in subfolders]
return self._subfolders return self._subfolders
@classmethod @classmethod
@@ -214,24 +229,27 @@ class Folder(File):
def get_file(path, fileclasses=[File]): def get_file(path, fileclasses=[File]):
"""Wraps ``path`` around its appropriate :class:`File` class.
Whether a class is "appropriate" is decided by :meth:`File.can_handle`
:param Path path: path to wrap
:param fileclasses: List of candidate :class:`File` classes
"""
for fileclass in fileclasses: for fileclass in fileclasses:
if fileclass.can_handle(path): if fileclass.can_handle(path):
return fileclass(path) return fileclass(path)
def get_files(path, fileclasses=[File]): def get_files(path, fileclasses=[File]):
assert all(issubclass(fileclass, File) for fileclass in fileclasses) """Returns a list of :class:`File` for each file contained in ``path``.
def combine_paths(p1, p2):
try:
return p1 + p2
except Exception:
# This is temporary debug logging for #84.
logging.warning("Failed to combine %r and %r.", p1, p2)
raise
:param Path path: path to scan
:param fileclasses: List of candidate :class:`File` classes
"""
assert all(issubclass(fileclass, File) for fileclass in fileclasses)
try: try:
paths = [combine_paths(path, name) for name in path.listdir()]
result = [] result = []
for path in paths: for path in path.listdir():
file = get_file(path, fileclasses=fileclasses) file = get_file(path, fileclasses=fileclasses)
if file is not None: if file is not None:
result.append(file) result.append(file)

View File

@@ -0,0 +1,15 @@
"""
Meta GUI elements in dupeGuru
-----------------------------
dupeGuru is designed with a `cross-toolkit`_ approach in mind. It means that its core code
(which doesn't depend on any GUI toolkit) has elements which preformat core information in a way
that makes it easy for a UI layer to consume.
For example, we have :class:`~core.gui.ResultTable` which takes information from
:class:`~core.results.Results` and mashes it in rows and columns which are ready to be fetched by
either Cocoa's ``NSTableView`` or Qt's ``QTableView``. It tells them which cell is supposed to be
blue, which is supposed to be orange, does the sorting logic, holds selection, etc..
.. _cross-toolkit: http://www.hardcoded.net/articles/cross-toolkit-software
"""

View File

@@ -10,14 +10,60 @@ import os
from hscommon.gui.base import GUIObject from hscommon.gui.base import GUIObject
from hscommon.trans import tr from hscommon.trans import tr
class DeletionOptionsView:
"""Expected interface for :class:`DeletionOptions`'s view.
*Not actually used in the code. For documentation purposes only.*
Our view presents the user with an appropriate way (probably a mix of checkboxes and radio
buttons) to set the different flags in :class:`DeletionOptions`. Note that
:attr:`DeletionOptions.use_hardlinks` is only relevant if :attr:`DeletionOptions.link_deleted`
is true. This is why we toggle the "enabled" state of that flag.
We expect the view to set :attr:`DeletionOptions.link_deleted` immediately as the user changes
its value because it will toggle :meth:`set_hardlink_option_enabled`
Other than the flags, there's also a prompt message which has a dynamic content, defined by
:meth:`update_msg`.
"""
def update_msg(self, msg: str):
"""Update the dialog's prompt with ``str``.
"""
def show(self):
"""Show the dialog in a modal fashion.
Returns whether the dialog was "accepted" (the user pressed OK).
"""
def set_hardlink_option_enabled(self, is_enabled: bool):
"""Enable or disable the widget controlling :attr:`DeletionOptions.use_hardlinks`.
"""
class DeletionOptions(GUIObject): class DeletionOptions(GUIObject):
#--- View interface """Present the user with deletion options before proceeding.
# update_msg(msg: str)
# show() When the user activates "Send to trash", we present him with a couple of options that changes
# the behavior of that deletion operation.
"""
def __init__(self):
GUIObject.__init__(self)
#: Whether symlinks or hardlinks are used when doing :attr:`link_deleted`.
#: *bool*. *get/set*
self.use_hardlinks = False
#: Delete dupes directly and don't send to trash.
#: *bool*. *get/set*
self.direct = False
def show(self, mark_count): def show(self, mark_count):
self.link_deleted = False """Prompt the user with a modal dialog offering our deletion options.
:param int mark_count: Number of dupes marked for deletion.
:rtype: bool
:returns: Whether the user accepted the dialog (we cancel deletion if false).
"""
self._link_deleted = False
self.view.set_hardlink_option_enabled(False)
self.use_hardlinks = False self.use_hardlinks = False
self.direct = False self.direct = False
msg = tr("You are sending {} file(s) to the Trash.").format(mark_count) msg = tr("You are sending {} file(s) to the Trash.").format(mark_count)
@@ -25,6 +71,8 @@ class DeletionOptions(GUIObject):
return self.view.show() return self.view.show()
def supports_links(self): def supports_links(self):
"""Returns whether our platform supports symlinks.
"""
# When on a platform that doesn't implement it, calling os.symlink() (with the wrong number # When on a platform that doesn't implement it, calling os.symlink() (with the wrong number
# of arguments) raises NotImplementedError, which allows us to gracefully check for the # of arguments) raises NotImplementedError, which allows us to gracefully check for the
# feature. # feature.
@@ -40,3 +88,20 @@ class DeletionOptions(GUIObject):
# wrong number of arguments # wrong number of arguments
return True return True
@property
def link_deleted(self):
"""Replace deleted dupes with symlinks (or hardlinks) to the dupe group reference.
*bool*. *get/set*
Whether the link is a symlink or hardlink is decided by :attr:`use_hardlinks`.
"""
return self._link_deleted
@link_deleted.setter
def link_deleted(self, value):
self._link_deleted = value
hardlinks_enabled = value and self.supports_links()
self.view.set_hardlink_option_enabled(hardlinks_enabled)

View File

@@ -31,7 +31,7 @@ class DirectoryNode(Node):
self.clear() self.clear()
subpaths = self._tree.app.directories.get_subfolders(self._directory_path) subpaths = self._tree.app.directories.get_subfolders(self._directory_path)
for path in subpaths: for path in subpaths:
self.append(DirectoryNode(self._tree, path, path[-1])) self.append(DirectoryNode(self._tree, path, path.name))
self._loaded = True self._loaded = True
def update_all_states(self): def update_all_states(self):
@@ -91,6 +91,10 @@ class DirectoryTree(Tree, DupeGuruGUIObject):
for node in nodes: for node in nodes:
node.state = newstate node.state = newstate
def select_all(self):
self.selected_nodes = list(self)
self.view.refresh()
def update_all_states(self): def update_all_states(self):
for node in self: for node in self:
node.update_all_states() node.update_all_states()

View File

@@ -6,6 +6,8 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from hscommon import desktop
from .problem_table import ProblemTable from .problem_table import ProblemTable
class ProblemDialog: class ProblemDialog:
@@ -20,7 +22,7 @@ class ProblemDialog:
def reveal_selected_dupe(self): def reveal_selected_dupe(self):
if self._selected_dupe is not None: if self._selected_dupe is not None:
self.app.view.reveal_path(self._selected_dupe.path) desktop.reveal_path(self._selected_dupe.path)
def select_dupe(self, dupe): def select_dupe(self, dupe):
self._selected_dupe = dupe self._selected_dupe = dupe

View File

@@ -42,7 +42,7 @@ class DupeRow(Row):
dupe_info = self.data dupe_info = self.data
ref_info = self._group.ref.get_display_info(group=self._group, delta=False) ref_info = self._group.ref.get_display_info(group=self._group, delta=False)
for key, value in dupe_info.items(): for key, value in dupe_info.items():
if ref_info[key] != value: if (key not in self._delta_columns) and (ref_info[key].lower() != value.lower()):
self._delta_columns.add(key) self._delta_columns.add(key)
return column_name in self._delta_columns return column_name in self._delta_columns

View File

@@ -21,6 +21,19 @@ from . import engine
from .markable import Markable from .markable import Markable
class Results(Markable): class Results(Markable):
"""Manages a collection of duplicate :class:`~core.engine.Group`.
This class takes care or marking, sorting and filtering duplicate groups.
.. attribute:: groups
The list of :class:`~core.engine.Group` contained managed by this instance.
.. attribute:: dupes
A list of all duplicates (:class:`~core.fs.File` instances), without ref, contained in the
currently managed :attr:`groups`.
"""
#---Override #---Override
def __init__(self, app): def __init__(self, app):
Markable.__init__(self) Markable.__init__(self)
@@ -145,17 +158,17 @@ class Results(Markable):
#---Public #---Public
def apply_filter(self, filter_str): def apply_filter(self, filter_str):
''' Applies a filter 'filter_str' to self.groups """Applies a filter ``filter_str`` to :attr:`groups`
When you apply the filter, only dupes with the filename matching 'filter_str' will be in When you apply the filter, only dupes with the filename matching ``filter_str`` will be in
in the results. To cancel the filter, just call apply_filter with 'filter_str' to None, in the results. To cancel the filter, just call apply_filter with ``filter_str`` to None,
and the results will go back to normal. and the results will go back to normal.
If call apply_filter on a filtered results, the filter will be applied If call apply_filter on a filtered results, the filter will be applied
*on the filtered results*. *on the filtered results*.
'filter_str' is a string containing a regexp to filter dupes with. :param str filter_str: a string containing a regexp to filter dupes with.
''' """
if not filter_str: if not filter_str:
self.__filtered_dupes = None self.__filtered_dupes = None
self.__filtered_groups = None self.__filtered_groups = None
@@ -182,6 +195,8 @@ class Results(Markable):
self.__dupes = None self.__dupes = None
def get_group_of_duplicate(self, dupe): def get_group_of_duplicate(self, dupe):
"""Returns :class:`~core.engine.Group` in which ``dupe`` belongs.
"""
try: try:
return self.__group_of_duplicate[dupe] return self.__group_of_duplicate[dupe]
except (TypeError, KeyError): except (TypeError, KeyError):
@@ -190,6 +205,12 @@ class Results(Markable):
is_markable = _is_markable is_markable = _is_markable
def load_from_xml(self, infile, get_file, j=nulljob): def load_from_xml(self, infile, get_file, j=nulljob):
"""Load results from ``infile``.
:param infile: a file or path pointing to an XML file created with :meth:`save_to_xml`.
:param get_file: a function f(path) returning a :class:`~core.fs.File` wrapping the path.
:param j: A :ref:`job progress instance <jobs>`.
"""
def do_match(ref_file, other_files, group): def do_match(ref_file, other_files, group):
if not other_files: if not other_files:
return return
@@ -242,6 +263,8 @@ class Results(Markable):
self.is_modified = False self.is_modified = False
def make_ref(self, dupe): def make_ref(self, dupe):
"""Make ``dupe`` take the :attr:`~core.engine.Group.ref` position of its group.
"""
g = self.get_group_of_duplicate(dupe) g = self.get_group_of_duplicate(dupe)
r = g.ref r = g.ref
if not g.switch_ref(dupe): if not g.switch_ref(dupe):
@@ -258,8 +281,14 @@ class Results(Markable):
return True return True
def perform_on_marked(self, func, remove_from_results): def perform_on_marked(self, func, remove_from_results):
# Performs `func` on all marked dupes. If an EnvironmentError is raised during the call, """Performs ``func`` on all marked dupes.
# the problematic dupe is added to self.problems.
If an ``EnvironmentError`` is raised during the call, the problematic dupe is added to
self.problems.
:param bool remove_from_results: If true, dupes which had ``func`` applied and didn't cause
any problem.
"""
self.problems = [] self.problems = []
to_remove = [] to_remove = []
marked = (dupe for dupe in self.dupes if self.is_marked(dupe)) marked = (dupe for dupe in self.dupes if self.is_marked(dupe))
@@ -276,8 +305,10 @@ class Results(Markable):
self.mark(dupe) self.mark(dupe)
def remove_duplicates(self, dupes): def remove_duplicates(self, dupes):
'''Remove 'dupes' from their respective group, and remove the group is it ends up empty. """Remove ``dupes`` from their respective :class:`~core.engine.Group`.
'''
Also, remove the group from :attr:`groups` if it ends up empty.
"""
affected_groups = set() affected_groups = set()
for dupe in dupes: for dupe in dupes:
group = self.get_group_of_duplicate(dupe) group = self.get_group_of_duplicate(dupe)
@@ -302,9 +333,12 @@ class Results(Markable):
self.is_modified = bool(self.__groups) self.is_modified = bool(self.__groups)
def save_to_xml(self, outfile): def save_to_xml(self, outfile):
"""Save results to ``outfile`` in XML.
:param outfile: file object or path.
"""
self.apply_filter(None) self.apply_filter(None)
root = ET.Element('results') root = ET.Element('results')
# writer = XMLGenerator(outfile, 'utf-8')
for g in self.groups: for g in self.groups:
group_elem = ET.SubElement(root, 'group') group_elem = ET.SubElement(root, 'group')
dupe2index = {} dupe2index = {}
@@ -349,6 +383,12 @@ class Results(Markable):
self.is_modified = False self.is_modified = False
def sort_dupes(self, key, asc=True, delta=False): def sort_dupes(self, key, asc=True, delta=False):
"""Sort :attr:`dupes` according to ``key``.
:param str key: key attribute name to sort with.
:param bool asc: If false, sorting is reversed.
:param bool delta: If true, sorting occurs using :ref:`delta values <deltavalues>`.
"""
if not self.__dupes: if not self.__dupes:
self.__get_dupe_list() self.__get_dupe_list()
keyfunc = lambda d: self.app._get_dupe_sort_key(d, lambda: self.get_group_of_duplicate(d), key, delta) keyfunc = lambda d: self.app._get_dupe_sort_key(d, lambda: self.get_group_of_duplicate(d), key, delta)
@@ -356,6 +396,13 @@ class Results(Markable):
self.__dupes_sort_descriptor = (key,asc,delta) self.__dupes_sort_descriptor = (key,asc,delta)
def sort_groups(self, key, asc=True): def sort_groups(self, key, asc=True):
"""Sort :attr:`groups` according to ``key``.
The :attr:`~core.engine.Group.ref` of each group is used to extract values for sorting.
:param str key: key attribute name to sort with.
:param bool asc: If false, sorting is reversed.
"""
keyfunc = lambda g: self.app._get_group_sort_key(g, key) keyfunc = lambda g: self.app._get_group_sort_key(g, key)
self.groups.sort(key=keyfunc, reverse=not asc) self.groups.sort(key=keyfunc, reverse=not asc)
self.__groups_sort_descriptor = (key,asc) self.__groups_sort_descriptor = (key,asc)

View File

@@ -11,7 +11,6 @@ import os.path as op
import logging import logging
from pytest import mark from pytest import mark
from hscommon import io
from hscommon.path import Path from hscommon.path import Path
import hscommon.conflict import hscommon.conflict
import hscommon.util import hscommon.util
@@ -57,7 +56,7 @@ class TestCaseDupeGuru:
# for this unit is pathetic. What's done is done. My approach now is to add tests for # for this unit is pathetic. What's done is done. My approach now is to add tests for
# every change I want to make. The blowup was caused by a missing import. # every change I want to make. The blowup was caused by a missing import.
p = Path(str(tmpdir)) p = Path(str(tmpdir))
io.open(p + 'foo', 'w').close() p['foo'].open('w').close()
monkeypatch.setattr(hscommon.conflict, 'smart_copy', log_calls(lambda source_path, dest_path: None)) monkeypatch.setattr(hscommon.conflict, 'smart_copy', log_calls(lambda source_path, dest_path: None))
# XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher. # XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher.
monkeypatch.setattr(app, 'smart_copy', hscommon.conflict.smart_copy) monkeypatch.setattr(app, 'smart_copy', hscommon.conflict.smart_copy)
@@ -73,14 +72,14 @@ class TestCaseDupeGuru:
def test_copy_or_move_clean_empty_dirs(self, tmpdir, monkeypatch): def test_copy_or_move_clean_empty_dirs(self, tmpdir, monkeypatch):
tmppath = Path(str(tmpdir)) tmppath = Path(str(tmpdir))
sourcepath = tmppath + 'source' sourcepath = tmppath['source']
io.mkdir(sourcepath) sourcepath.mkdir()
io.open(sourcepath + 'myfile', 'w') sourcepath['myfile'].open('w')
app = TestApp().app app = TestApp().app
app.directories.add_path(tmppath) app.directories.add_path(tmppath)
[myfile] = app.directories.get_files() [myfile] = app.directories.get_files()
monkeypatch.setattr(app, 'clean_empty_dirs', log_calls(lambda path: None)) monkeypatch.setattr(app, 'clean_empty_dirs', log_calls(lambda path: None))
app.copy_or_move(myfile, False, tmppath + 'dest', 0) app.copy_or_move(myfile, False, tmppath['dest'], 0)
calls = app.clean_empty_dirs.calls calls = app.clean_empty_dirs.calls
eq_(1, len(calls)) eq_(1, len(calls))
eq_(sourcepath, calls[0]['path']) eq_(sourcepath, calls[0]['path'])
@@ -104,8 +103,8 @@ class TestCaseDupeGuru:
# If the ignore_hardlink_matches option is set, don't match files hardlinking to the same # If the ignore_hardlink_matches option is set, don't match files hardlinking to the same
# inode. # inode.
tmppath = Path(str(tmpdir)) tmppath = Path(str(tmpdir))
io.open(tmppath + 'myfile', 'w').write('foo') tmppath['myfile'].open('w').write('foo')
os.link(str(tmppath + 'myfile'), str(tmppath + 'hardlink')) os.link(str(tmppath['myfile']), str(tmppath['hardlink']))
app = TestApp().app app = TestApp().app
app.directories.add_path(tmppath) app.directories.add_path(tmppath)
app.scanner.scan_type = ScanType.Contents app.scanner.scan_type = ScanType.Contents
@@ -171,8 +170,8 @@ class TestCaseDupeGuruWithResults:
self.rtable.refresh() self.rtable.refresh()
tmpdir = request.getfuncargvalue('tmpdir') tmpdir = request.getfuncargvalue('tmpdir')
tmppath = Path(str(tmpdir)) tmppath = Path(str(tmpdir))
io.mkdir(tmppath + 'foo') tmppath['foo'].mkdir()
io.mkdir(tmppath + 'bar') tmppath['bar'].mkdir()
self.app.directories.add_path(tmppath) self.app.directories.add_path(tmppath)
def test_GetObjects(self, do_setup): def test_GetObjects(self, do_setup):
@@ -400,15 +399,28 @@ class TestCaseDupeGuruWithResults:
eq_(len(self.rtable), 0) eq_(len(self.rtable), 0)
eq_(app.selected_dupes, []) eq_(app.selected_dupes, [])
def test_dont_crash_on_delta_powermarker_dupecount_sort(self, do_setup):
# Don't crash when sorting by dupe count or percentage while delta+powermarker are enabled.
# Ref #238
app = self.app
objects = self.objects
self.rtable.delta_values = True
self.rtable.power_marker = True
self.rtable.sort('dupe_count', False)
# don't crash
self.rtable.sort('percentage', False)
# don't crash
class TestCaseDupeGuru_renameSelected: class TestCaseDupeGuru_renameSelected:
def pytest_funcarg__do_setup(self, request): def pytest_funcarg__do_setup(self, request):
tmpdir = request.getfuncargvalue('tmpdir') tmpdir = request.getfuncargvalue('tmpdir')
p = Path(str(tmpdir)) p = Path(str(tmpdir))
fp = open(str(p + 'foo bar 1'),mode='w') fp = open(str(p['foo bar 1']),mode='w')
fp.close() fp.close()
fp = open(str(p + 'foo bar 2'),mode='w') fp = open(str(p['foo bar 2']),mode='w')
fp.close() fp.close()
fp = open(str(p + 'foo bar 3'),mode='w') fp = open(str(p['foo bar 3']),mode='w')
fp.close() fp.close()
files = fs.get_files(p) files = fs.get_files(p)
for f in files: for f in files:
@@ -431,7 +443,7 @@ class TestCaseDupeGuru_renameSelected:
g = self.groups[0] g = self.groups[0]
self.rtable.select([1]) self.rtable.select([1])
assert app.rename_selected('renamed') assert app.rename_selected('renamed')
names = io.listdir(self.p) names = [p.name for p in self.p.listdir()]
assert 'renamed' in names assert 'renamed' in names
assert 'foo bar 2' not in names assert 'foo bar 2' not in names
eq_(g.dupes[0].name, 'renamed') eq_(g.dupes[0].name, 'renamed')
@@ -444,7 +456,7 @@ class TestCaseDupeGuru_renameSelected:
assert not app.rename_selected('renamed') assert not app.rename_selected('renamed')
msg = logging.warning.calls[0]['msg'] msg = logging.warning.calls[0]['msg']
eq_('dupeGuru Warning: list index out of range', msg) eq_('dupeGuru Warning: list index out of range', msg)
names = io.listdir(self.p) names = [p.name for p in self.p.listdir()]
assert 'renamed' not in names assert 'renamed' not in names
assert 'foo bar 2' in names assert 'foo bar 2' in names
eq_(g.dupes[0].name, 'foo bar 2') eq_(g.dupes[0].name, 'foo bar 2')
@@ -457,7 +469,7 @@ class TestCaseDupeGuru_renameSelected:
assert not app.rename_selected('foo bar 1') assert not app.rename_selected('foo bar 1')
msg = logging.warning.calls[0]['msg'] msg = logging.warning.calls[0]['msg']
assert msg.startswith('dupeGuru Warning: \'foo bar 1\' already exists in') assert msg.startswith('dupeGuru Warning: \'foo bar 1\' already exists in')
names = io.listdir(self.p) names = [p.name for p in self.p.listdir()]
assert 'foo bar 1' in names assert 'foo bar 1' in names
assert 'foo bar 2' in names assert 'foo bar 2' in names
eq_(g.dupes[0].name, 'foo bar 2') eq_(g.dupes[0].name, 'foo bar 2')
@@ -467,9 +479,9 @@ class TestAppWithDirectoriesInTree:
def pytest_funcarg__do_setup(self, request): def pytest_funcarg__do_setup(self, request):
tmpdir = request.getfuncargvalue('tmpdir') tmpdir = request.getfuncargvalue('tmpdir')
p = Path(str(tmpdir)) p = Path(str(tmpdir))
io.mkdir(p + 'sub1') p['sub1'].mkdir()
io.mkdir(p + 'sub2') p['sub2'].mkdir()
io.mkdir(p + 'sub3') p['sub3'].mkdir()
app = TestApp() app = TestApp()
self.app = app.app self.app = app.app
self.dtree = app.dtree self.dtree = app.dtree

View File

@@ -57,10 +57,12 @@ class ResultTable(ResultTableBase):
DELTA_COLUMNS = {'size', } DELTA_COLUMNS = {'size', }
class DupeGuru(DupeGuruBase): class DupeGuru(DupeGuruBase):
NAME = 'dupeGuru'
METADATA_TO_READ = ['size'] METADATA_TO_READ = ['size']
def __init__(self): def __init__(self):
DupeGuruBase.__init__(self, DupeGuruView(), '/tmp') DupeGuruBase.__init__(self, DupeGuruView())
self.appdata = '/tmp'
def _prioritization_categories(self): def _prioritization_categories(self):
return prioritize.all_categories() return prioritize.all_categories()
@@ -100,11 +102,11 @@ class NamedObject:
@property @property
def path(self): def path(self):
return self._folder + self.name return self._folder[self.name]
@property @property
def folder_path(self): def folder_path(self):
return self.path[:-1] return self.path.parent()
@property @property
def extension(self): def extension(self):

View File

@@ -12,7 +12,6 @@ import tempfile
import shutil import shutil
from pytest import raises from pytest import raises
from hscommon import io
from hscommon.path import Path from hscommon.path import Path
from hscommon.testutil import eq_ from hscommon.testutil import eq_
@@ -20,27 +19,27 @@ from ..directories import *
def create_fake_fs(rootpath): def create_fake_fs(rootpath):
# We have it as a separate function because other units are using it. # We have it as a separate function because other units are using it.
rootpath = rootpath + 'fs' rootpath = rootpath['fs']
io.mkdir(rootpath) rootpath.mkdir()
io.mkdir(rootpath + 'dir1') rootpath['dir1'].mkdir()
io.mkdir(rootpath + 'dir2') rootpath['dir2'].mkdir()
io.mkdir(rootpath + 'dir3') rootpath['dir3'].mkdir()
fp = io.open(rootpath + 'file1.test', 'w') fp = rootpath['file1.test'].open('w')
fp.write('1') fp.write('1')
fp.close() fp.close()
fp = io.open(rootpath + 'file2.test', 'w') fp = rootpath['file2.test'].open('w')
fp.write('12') fp.write('12')
fp.close() fp.close()
fp = io.open(rootpath + 'file3.test', 'w') fp = rootpath['file3.test'].open('w')
fp.write('123') fp.write('123')
fp.close() fp.close()
fp = io.open(rootpath + ('dir1', 'file1.test'), 'w') fp = rootpath['dir1']['file1.test'].open('w')
fp.write('1') fp.write('1')
fp.close() fp.close()
fp = io.open(rootpath + ('dir2', 'file2.test'), 'w') fp = rootpath['dir2']['file2.test'].open('w')
fp.write('12') fp.write('12')
fp.close() fp.close()
fp = io.open(rootpath + ('dir3', 'file3.test'), 'w') fp = rootpath['dir3']['file3.test'].open('w')
fp.write('123') fp.write('123')
fp.close() fp.close()
return rootpath return rootpath
@@ -50,9 +49,9 @@ def setup_module(module):
# and another with a more complex structure. # and another with a more complex structure.
testpath = Path(tempfile.mkdtemp()) testpath = Path(tempfile.mkdtemp())
module.testpath = testpath module.testpath = testpath
rootpath = testpath + 'onefile' rootpath = testpath['onefile']
io.mkdir(rootpath) rootpath.mkdir()
fp = io.open(rootpath + 'test.txt', 'w') fp = rootpath['test.txt'].open('w')
fp.write('test_data') fp.write('test_data')
fp.close() fp.close()
create_fake_fs(testpath) create_fake_fs(testpath)
@@ -67,30 +66,30 @@ def test_empty():
def test_add_path(): def test_add_path():
d = Directories() d = Directories()
p = testpath + 'onefile' p = testpath['onefile']
d.add_path(p) d.add_path(p)
eq_(1,len(d)) eq_(1,len(d))
assert p in d assert p in d
assert (p + 'foobar') in d assert (p['foobar']) in d
assert p[:-1] not in d assert p.parent() not in d
p = testpath + 'fs' p = testpath['fs']
d.add_path(p) d.add_path(p)
eq_(2,len(d)) eq_(2,len(d))
assert p in d assert p in d
def test_AddPath_when_path_is_already_there(): def test_AddPath_when_path_is_already_there():
d = Directories() d = Directories()
p = testpath + 'onefile' p = testpath['onefile']
d.add_path(p) d.add_path(p)
with raises(AlreadyThereError): with raises(AlreadyThereError):
d.add_path(p) d.add_path(p)
with raises(AlreadyThereError): with raises(AlreadyThereError):
d.add_path(p + 'foobar') d.add_path(p['foobar'])
eq_(1, len(d)) eq_(1, len(d))
def test_add_path_containing_paths_already_there(): def test_add_path_containing_paths_already_there():
d = Directories() d = Directories()
d.add_path(testpath + 'onefile') d.add_path(testpath['onefile'])
eq_(1, len(d)) eq_(1, len(d))
d.add_path(testpath) d.add_path(testpath)
eq_(len(d), 1) eq_(len(d), 1)
@@ -98,7 +97,7 @@ def test_add_path_containing_paths_already_there():
def test_AddPath_non_latin(tmpdir): def test_AddPath_non_latin(tmpdir):
p = Path(str(tmpdir)) p = Path(str(tmpdir))
to_add = p + 'unicode\u201a' to_add = p['unicode\u201a']
os.mkdir(str(to_add)) os.mkdir(str(to_add))
d = Directories() d = Directories()
try: try:
@@ -108,24 +107,24 @@ def test_AddPath_non_latin(tmpdir):
def test_del(): def test_del():
d = Directories() d = Directories()
d.add_path(testpath + 'onefile') d.add_path(testpath['onefile'])
try: try:
del d[1] del d[1]
assert False assert False
except IndexError: except IndexError:
pass pass
d.add_path(testpath + 'fs') d.add_path(testpath['fs'])
del d[1] del d[1]
eq_(1, len(d)) eq_(1, len(d))
def test_states(): def test_states():
d = Directories() d = Directories()
p = testpath + 'onefile' p = testpath['onefile']
d.add_path(p) d.add_path(p)
eq_(DirectoryState.Normal ,d.get_state(p)) eq_(DirectoryState.Normal ,d.get_state(p))
d.set_state(p, DirectoryState.Reference) d.set_state(p, DirectoryState.Reference)
eq_(DirectoryState.Reference ,d.get_state(p)) eq_(DirectoryState.Reference ,d.get_state(p))
eq_(DirectoryState.Reference ,d.get_state(p + 'dir1')) eq_(DirectoryState.Reference ,d.get_state(p['dir1']))
eq_(1,len(d.states)) eq_(1,len(d.states))
eq_(p,list(d.states.keys())[0]) eq_(p,list(d.states.keys())[0])
eq_(DirectoryState.Reference ,d.states[p]) eq_(DirectoryState.Reference ,d.states[p])
@@ -133,67 +132,67 @@ def test_states():
def test_get_state_with_path_not_there(): def test_get_state_with_path_not_there():
# When the path's not there, just return DirectoryState.Normal # When the path's not there, just return DirectoryState.Normal
d = Directories() d = Directories()
d.add_path(testpath + 'onefile') d.add_path(testpath['onefile'])
eq_(d.get_state(testpath), DirectoryState.Normal) eq_(d.get_state(testpath), DirectoryState.Normal)
def test_states_remain_when_larger_directory_eat_smaller_ones(): def test_states_remain_when_larger_directory_eat_smaller_ones():
d = Directories() d = Directories()
p = testpath + 'onefile' p = testpath['onefile']
d.add_path(p) d.add_path(p)
d.set_state(p, DirectoryState.Excluded) d.set_state(p, DirectoryState.Excluded)
d.add_path(testpath) d.add_path(testpath)
d.set_state(testpath, DirectoryState.Reference) d.set_state(testpath, DirectoryState.Reference)
eq_(DirectoryState.Excluded ,d.get_state(p)) eq_(DirectoryState.Excluded ,d.get_state(p))
eq_(DirectoryState.Excluded ,d.get_state(p + 'dir1')) eq_(DirectoryState.Excluded ,d.get_state(p['dir1']))
eq_(DirectoryState.Reference ,d.get_state(testpath)) eq_(DirectoryState.Reference ,d.get_state(testpath))
def test_set_state_keep_state_dict_size_to_minimum(): def test_set_state_keep_state_dict_size_to_minimum():
d = Directories() d = Directories()
p = testpath + 'fs' p = testpath['fs']
d.add_path(p) d.add_path(p)
d.set_state(p, DirectoryState.Reference) d.set_state(p, DirectoryState.Reference)
d.set_state(p + 'dir1', DirectoryState.Reference) d.set_state(p['dir1'], DirectoryState.Reference)
eq_(1,len(d.states)) eq_(1,len(d.states))
eq_(DirectoryState.Reference ,d.get_state(p + 'dir1')) eq_(DirectoryState.Reference ,d.get_state(p['dir1']))
d.set_state(p + 'dir1', DirectoryState.Normal) d.set_state(p['dir1'], DirectoryState.Normal)
eq_(2,len(d.states)) eq_(2,len(d.states))
eq_(DirectoryState.Normal ,d.get_state(p + 'dir1')) eq_(DirectoryState.Normal ,d.get_state(p['dir1']))
d.set_state(p + 'dir1', DirectoryState.Reference) d.set_state(p['dir1'], DirectoryState.Reference)
eq_(1,len(d.states)) eq_(1,len(d.states))
eq_(DirectoryState.Reference ,d.get_state(p + 'dir1')) eq_(DirectoryState.Reference ,d.get_state(p['dir1']))
def test_get_files(): def test_get_files():
d = Directories() d = Directories()
p = testpath + 'fs' p = testpath['fs']
d.add_path(p) d.add_path(p)
d.set_state(p + 'dir1', DirectoryState.Reference) d.set_state(p['dir1'], DirectoryState.Reference)
d.set_state(p + 'dir2', DirectoryState.Excluded) d.set_state(p['dir2'], DirectoryState.Excluded)
files = list(d.get_files()) files = list(d.get_files())
eq_(5, len(files)) eq_(5, len(files))
for f in files: for f in files:
if f.path[:-1] == p + 'dir1': if f.path.parent() == p['dir1']:
assert f.is_ref assert f.is_ref
else: else:
assert not f.is_ref assert not f.is_ref
def test_get_folders(): def test_get_folders():
d = Directories() d = Directories()
p = testpath + 'fs' p = testpath['fs']
d.add_path(p) d.add_path(p)
d.set_state(p + 'dir1', DirectoryState.Reference) d.set_state(p['dir1'], DirectoryState.Reference)
d.set_state(p + 'dir2', DirectoryState.Excluded) d.set_state(p['dir2'], DirectoryState.Excluded)
folders = list(d.get_folders()) folders = list(d.get_folders())
eq_(len(folders), 3) eq_(len(folders), 3)
ref = [f for f in folders if f.is_ref] ref = [f for f in folders if f.is_ref]
not_ref = [f for f in folders if not f.is_ref] not_ref = [f for f in folders if not f.is_ref]
eq_(len(ref), 1) eq_(len(ref), 1)
eq_(ref[0].path, p + 'dir1') eq_(ref[0].path, p['dir1'])
eq_(len(not_ref), 2) eq_(len(not_ref), 2)
eq_(ref[0].size, 1) eq_(ref[0].size, 1)
def test_get_files_with_inherited_exclusion(): def test_get_files_with_inherited_exclusion():
d = Directories() d = Directories()
p = testpath + 'onefile' p = testpath['onefile']
d.add_path(p) d.add_path(p)
d.set_state(p, DirectoryState.Excluded) d.set_state(p, DirectoryState.Excluded)
eq_([], list(d.get_files())) eq_([], list(d.get_files()))
@@ -202,19 +201,19 @@ def test_save_and_load(tmpdir):
d1 = Directories() d1 = Directories()
d2 = Directories() d2 = Directories()
p1 = Path(str(tmpdir.join('p1'))) p1 = Path(str(tmpdir.join('p1')))
io.mkdir(p1) p1.mkdir()
p2 = Path(str(tmpdir.join('p2'))) p2 = Path(str(tmpdir.join('p2')))
io.mkdir(p2) p2.mkdir()
d1.add_path(p1) d1.add_path(p1)
d1.add_path(p2) d1.add_path(p2)
d1.set_state(p1, DirectoryState.Reference) d1.set_state(p1, DirectoryState.Reference)
d1.set_state(p1 + 'dir1', DirectoryState.Excluded) d1.set_state(p1['dir1'], DirectoryState.Excluded)
tmpxml = str(tmpdir.join('directories_testunit.xml')) tmpxml = str(tmpdir.join('directories_testunit.xml'))
d1.save_to_file(tmpxml) d1.save_to_file(tmpxml)
d2.load_from_file(tmpxml) d2.load_from_file(tmpxml)
eq_(2, len(d2)) eq_(2, len(d2))
eq_(DirectoryState.Reference ,d2.get_state(p1)) eq_(DirectoryState.Reference ,d2.get_state(p1))
eq_(DirectoryState.Excluded ,d2.get_state(p1 + 'dir1')) eq_(DirectoryState.Excluded ,d2.get_state(p1['dir1']))
def test_invalid_path(): def test_invalid_path():
d = Directories() d = Directories()
@@ -234,12 +233,12 @@ def test_load_from_file_with_invalid_path(tmpdir):
#This test simulates a load from file resulting in a #This test simulates a load from file resulting in a
#InvalidPath raise. Other directories must be loaded. #InvalidPath raise. Other directories must be loaded.
d1 = Directories() d1 = Directories()
d1.add_path(testpath + 'onefile') d1.add_path(testpath['onefile'])
#Will raise InvalidPath upon loading #Will raise InvalidPath upon loading
p = Path(str(tmpdir.join('toremove'))) p = Path(str(tmpdir.join('toremove')))
io.mkdir(p) p.mkdir()
d1.add_path(p) d1.add_path(p)
io.rmdir(p) p.rmdir()
tmpxml = str(tmpdir.join('directories_testunit.xml')) tmpxml = str(tmpdir.join('directories_testunit.xml'))
d1.save_to_file(tmpxml) d1.save_to_file(tmpxml)
d2 = Directories() d2 = Directories()
@@ -248,11 +247,11 @@ def test_load_from_file_with_invalid_path(tmpdir):
def test_unicode_save(tmpdir): def test_unicode_save(tmpdir):
d = Directories() d = Directories()
p1 = Path(str(tmpdir)) + 'hello\xe9' p1 = Path(str(tmpdir))['hello\xe9']
io.mkdir(p1) p1.mkdir()
io.mkdir(p1 + 'foo\xe9') p1['foo\xe9'].mkdir()
d.add_path(p1) d.add_path(p1)
d.set_state(p1 + 'foo\xe9', DirectoryState.Excluded) d.set_state(p1['foo\xe9'], DirectoryState.Excluded)
tmpxml = str(tmpdir.join('directories_testunit.xml')) tmpxml = str(tmpdir.join('directories_testunit.xml'))
try: try:
d.save_to_file(tmpxml) d.save_to_file(tmpxml)
@@ -261,12 +260,12 @@ def test_unicode_save(tmpdir):
def test_get_files_refreshes_its_directories(): def test_get_files_refreshes_its_directories():
d = Directories() d = Directories()
p = testpath + 'fs' p = testpath['fs']
d.add_path(p) d.add_path(p)
files = d.get_files() files = d.get_files()
eq_(6, len(list(files))) eq_(6, len(list(files)))
time.sleep(1) time.sleep(1)
os.remove(str(p + ('dir1','file1.test'))) os.remove(str(p['dir1']['file1.test']))
files = d.get_files() files = d.get_files()
eq_(5, len(list(files))) eq_(5, len(list(files)))
@@ -274,14 +273,14 @@ def test_get_files_does_not_choke_on_non_existing_directories(tmpdir):
d = Directories() d = Directories()
p = Path(str(tmpdir)) p = Path(str(tmpdir))
d.add_path(p) d.add_path(p)
io.rmtree(p) p.rmtree()
eq_([], list(d.get_files())) eq_([], list(d.get_files()))
def test_get_state_returns_excluded_by_default_for_hidden_directories(tmpdir): def test_get_state_returns_excluded_by_default_for_hidden_directories(tmpdir):
d = Directories() d = Directories()
p = Path(str(tmpdir)) p = Path(str(tmpdir))
hidden_dir_path = p + '.foo' hidden_dir_path = p['.foo']
io.mkdir(p + '.foo') p['.foo'].mkdir()
d.add_path(p) d.add_path(p)
eq_(d.get_state(hidden_dir_path), DirectoryState.Excluded) eq_(d.get_state(hidden_dir_path), DirectoryState.Excluded)
# But it can be overriden # But it can be overriden
@@ -297,16 +296,16 @@ def test_default_path_state_override(tmpdir):
d = MyDirectories() d = MyDirectories()
p1 = Path(str(tmpdir)) p1 = Path(str(tmpdir))
io.mkdir(p1 + 'foobar') p1['foobar'].mkdir()
io.open(p1 + 'foobar/somefile', 'w').close() p1['foobar/somefile'].open('w').close()
io.mkdir(p1 + 'foobaz') p1['foobaz'].mkdir()
io.open(p1 + 'foobaz/somefile', 'w').close() p1['foobaz/somefile'].open('w').close()
d.add_path(p1) d.add_path(p1)
eq_(d.get_state(p1 + 'foobaz'), DirectoryState.Normal) eq_(d.get_state(p1['foobaz']), DirectoryState.Normal)
eq_(d.get_state(p1 + 'foobar'), DirectoryState.Excluded) eq_(d.get_state(p1['foobar']), DirectoryState.Excluded)
eq_(len(list(d.get_files())), 1) # only the 'foobaz' file is there eq_(len(list(d.get_files())), 1) # only the 'foobaz' file is there
# However, the default state can be changed # However, the default state can be changed
d.set_state(p1 + 'foobar', DirectoryState.Normal) d.set_state(p1['foobar'], DirectoryState.Normal)
eq_(d.get_state(p1 + 'foobar'), DirectoryState.Normal) eq_(d.get_state(p1['foobar']), DirectoryState.Normal)
eq_(len(list(d.get_files())), 2) eq_(len(list(d.get_files())), 2)

View File

@@ -25,12 +25,12 @@ def test_md5_aggregate_subfiles_sorted(tmpdir):
#same order everytime. #same order everytime.
p = create_fake_fs(Path(str(tmpdir))) p = create_fake_fs(Path(str(tmpdir)))
b = fs.Folder(p) b = fs.Folder(p)
md51 = fs.File(p + ('dir1', 'file1.test')).md5 md51 = fs.File(p['dir1']['file1.test']).md5
md52 = fs.File(p + ('dir2', 'file2.test')).md5 md52 = fs.File(p['dir2']['file2.test']).md5
md53 = fs.File(p + ('dir3', 'file3.test')).md5 md53 = fs.File(p['dir3']['file3.test']).md5
md54 = fs.File(p + 'file1.test').md5 md54 = fs.File(p['file1.test']).md5
md55 = fs.File(p + 'file2.test').md5 md55 = fs.File(p['file2.test']).md5
md56 = fs.File(p + 'file3.test').md5 md56 = fs.File(p['file3.test']).md5
# The expected md5 is the md5 of md5s for folders and the direct md5 for files # The expected md5 is the md5 of md5s for folders and the direct md5 for files
folder_md51 = hashlib.md5(md51).digest() folder_md51 = hashlib.md5(md51).digest()
folder_md52 = hashlib.md5(md52).digest() folder_md52 = hashlib.md5(md52).digest()

View File

@@ -44,3 +44,13 @@ def test_delta_flags_delta_mode_on_non_delta_columns():
assert not app.rtable[3].is_cell_delta('name') assert not app.rtable[3].is_cell_delta('name')
# "ibabtu" == "ibabtu", flag off # "ibabtu" == "ibabtu", flag off
assert not app.rtable[4].is_cell_delta('name') assert not app.rtable[4].is_cell_delta('name')
def test_delta_flags_delta_mode_on_non_delta_columns_case_insensitive():
# Comparison that occurs for non-numeric columns to check whether they're delta is case
# insensitive
app = app_with_results()
app.app.results.groups[1].ref.name = "ibAbtu"
app.app.results.groups[1].dupes[0].name = "IBaBTU"
app.rtable.delta_values = True
# "ibAbtu" == "IBaBTU", flag off
assert not app.rtable[4].is_cell_delta('name')

View File

@@ -230,6 +230,23 @@ class TestCaseResultsWithSomeGroups:
# also remove group ref # also remove group ref
assert self.results.get_group_of_duplicate(ref) is None assert self.results.get_group_of_duplicate(ref) is None
def test_dupe_list_sort_delta_values_nonnumeric(self):
# When sorting dupes in delta mode on a non-numeric column, our first sort criteria is if
# the string is the same as its ref.
g1r, g1d1, g1d2, g2r, g2d1 = self.objects
# "aaa" makes our dupe go first in alphabetical order, but since we have the same value as
# ref, we're going last.
g2r.name = g2d1.name = "aaa"
self.results.sort_dupes('name', delta=True)
eq_("aaa", self.results.dupes[2].name)
def test_dupe_list_sort_delta_values_nonnumeric_case_insensitive(self):
# Non-numeric delta sorting comparison is case insensitive
g1r, g1d1, g1d2, g2r, g2d1 = self.objects
g2r.name = "AaA"
g2d1.name = "aAa"
self.results.sort_dupes('name', delta=True)
eq_("aAa", self.results.dupes[2].name)
class TestCaseResultsWithSavedResults: class TestCaseResultsWithSavedResults:
def setup_method(self, method): def setup_method(self, method):

View File

@@ -7,7 +7,6 @@
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
from jobprogress import job from jobprogress import job
from hscommon import io
from hscommon.path import Path from hscommon.path import Path
from hscommon.testutil import eq_ from hscommon.testutil import eq_
@@ -21,7 +20,7 @@ class NamedObject:
if path is None: if path is None:
path = Path(name) path = Path(name)
else: else:
path = Path(path) + name path = Path(path)[name]
self.name = name self.name = name
self.size = size self.size = size
self.path = path self.path = path
@@ -37,7 +36,6 @@ def pytest_funcarg__fake_fileexists(request):
# This is a hack to avoid invalidating all previous tests since the scanner started to test # This is a hack to avoid invalidating all previous tests since the scanner started to test
# for file existence before doing the match grouping. # for file existence before doing the match grouping.
monkeypatch = request.getfuncargvalue('monkeypatch') monkeypatch = request.getfuncargvalue('monkeypatch')
monkeypatch.setattr(io, 'exists', lambda _: True)
monkeypatch.setattr(Path, 'exists', lambda _: True) monkeypatch.setattr(Path, 'exists', lambda _: True)
def test_empty(fake_fileexists): def test_empty(fake_fileexists):
@@ -471,11 +469,11 @@ def test_dont_group_files_that_dont_exist(tmpdir):
s = Scanner() s = Scanner()
s.scan_type = ScanType.Contents s.scan_type = ScanType.Contents
p = Path(str(tmpdir)) p = Path(str(tmpdir))
io.open(p + 'file1', 'w').write('foo') p['file1'].open('w').write('foo')
io.open(p + 'file2', 'w').write('foo') p['file2'].open('w').write('foo')
file1, file2 = fs.get_files(p) file1, file2 = fs.get_files(p)
def getmatches(*args, **kw): def getmatches(*args, **kw):
io.remove(file2.path) file2.path.remove()
return [Match(file1, file2, 100)] return [Match(file1, file2, 100)]
s._getmatches = getmatches s._getmatches = getmatches

View File

@@ -1,2 +1,2 @@
__version__ = '6.5.1' __version__ = '6.6.0'
__appname__ = 'dupeGuru Music Edition' __appname__ = 'dupeGuru Music Edition'

View File

@@ -16,8 +16,8 @@ class DupeGuru(DupeGuruBase):
METADATA_TO_READ = ['size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist', METADATA_TO_READ = ['size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist',
'album', 'genre', 'year', 'track', 'comment'] 'album', 'genre', 'year', 'track', 'comment']
def __init__(self, view, appdata): def __init__(self, view):
DupeGuruBase.__init__(self, view, appdata) DupeGuruBase.__init__(self, view)
self.scanner = scanner.ScannerME() self.scanner = scanner.ScannerME()
self.directories.fileclasses = [fs.MusicFile] self.directories.fileclasses = [fs.MusicFile]

View File

@@ -36,7 +36,7 @@ class MusicFile(fs.File):
def can_handle(cls, path): def can_handle(cls, path):
if not fs.File.can_handle(path): if not fs.File.can_handle(path):
return False return False
return get_file_ext(path[-1]) in auto.EXT2CLASS return get_file_ext(path.name) in auto.EXT2CLASS
def get_display_info(self, group, delta): def get_display_info(self, group, delta):
size = self.size size = self.size

View File

@@ -1,2 +1,2 @@
__version__ = '2.7.1' __version__ = '2.8.0'
__appname__ = 'dupeGuru Picture Edition' __appname__ = 'dupeGuru Picture Edition'

View File

@@ -18,8 +18,8 @@ class DupeGuru(DupeGuruBase):
NAME = __appname__ NAME = __appname__
METADATA_TO_READ = ['size', 'mtime', 'dimensions', 'exif_timestamp'] METADATA_TO_READ = ['size', 'mtime', 'dimensions', 'exif_timestamp']
def __init__(self, view, appdata): def __init__(self, view):
DupeGuruBase.__init__(self, view, appdata) DupeGuruBase.__init__(self, view)
self.scanner = ScannerPE() self.scanner = ScannerPE()
self.scanner.cache_path = op.join(self.appdata, 'cached_pictures.db') self.scanner.cache_path = op.join(self.appdata, 'cached_pictures.db')

View File

@@ -32,7 +32,7 @@ pystring2cfstring(PyObject *pystring)
} }
s = (UInt8*)PyBytes_AS_STRING(encoded); s = (UInt8*)PyBytes_AS_STRING(encoded);
size = PyUnicode_GET_SIZE(encoded); size = PyBytes_GET_SIZE(encoded);
result = CFStringCreateWithBytes(NULL, s, size, kCFStringEncodingUTF8, FALSE); result = CFStringCreateWithBytes(NULL, s, size, kCFStringEncodingUTF8, FALSE);
Py_DECREF(encoded); Py_DECREF(encoded);
return result; return result;
@@ -113,7 +113,7 @@ MyCreateBitmapContext(int width, int height)
} }
context = CGBitmapContextCreate(bitmapData, width, height, 8, bitmapBytesPerRow, colorSpace, context = CGBitmapContextCreate(bitmapData, width, height, 8, bitmapBytesPerRow, colorSpace,
kCGImageAlphaNoneSkipLast); (CGBitmapInfo)kCGImageAlphaNoneSkipLast);
if (context== NULL) { if (context== NULL) {
free(bitmapData); free(bitmapData);
fprintf(stderr, "Context not created!"); fprintf(stderr, "Context not created!");

View File

@@ -49,9 +49,18 @@ class Photo(fs.File):
self._cached_orientation = 0 self._cached_orientation = 0
return self._cached_orientation return self._cached_orientation
def _get_exif_timestamp(self):
try:
with self.path.open('rb') as fp:
exifdata = exif.get_fields(fp)
return exifdata['DateTimeOriginal']
except Exception:
logging.info("Couldn't read EXIF of picture: %s", self.path)
return ''
@classmethod @classmethod
def can_handle(cls, path): def can_handle(cls, path):
return fs.File.can_handle(path) and get_file_ext(path[-1]) in cls.HANDLED_EXTS return fs.File.can_handle(path) and get_file_ext(path.name) in cls.HANDLED_EXTS
def get_display_info(self, group, delta): def get_display_info(self, group, delta):
size = self.size size = self.size
@@ -89,12 +98,7 @@ class Photo(fs.File):
if self._get_orientation() in {5, 6, 7, 8}: if self._get_orientation() in {5, 6, 7, 8}:
self.dimensions = (self.dimensions[1], self.dimensions[0]) self.dimensions = (self.dimensions[1], self.dimensions[0])
elif field == 'exif_timestamp': elif field == 'exif_timestamp':
try: self.exif_timestamp = self._get_exif_timestamp()
with self.path.open('rb') as fp:
exifdata = exif.get_fields(fp)
self.exif_timestamp = exifdata['DateTimeOriginal']
except Exception:
logging.info("Couldn't read EXIF of picture: %s", self.path)
def get_blocks(self, block_count_per_side): def get_blocks(self, block_count_per_side):
return self._plat_get_blocks(block_count_per_side, self._get_orientation()) return self._plat_get_blocks(block_count_per_side, self._get_orientation())

View File

@@ -1,2 +1,2 @@
__version__ = '3.7.0' __version__ = '3.8.0'
__appname__ = 'dupeGuru' __appname__ = 'dupeGuru'

View File

@@ -14,9 +14,10 @@ class DupeGuru(DupeGuruBase):
NAME = __appname__ NAME = __appname__
METADATA_TO_READ = ['size', 'mtime'] METADATA_TO_READ = ['size', 'mtime']
def __init__(self, view, appdata): def __init__(self, view):
DupeGuruBase.__init__(self, view, appdata) DupeGuruBase.__init__(self, view)
self.directories.fileclasses = [fs.File] self.directories.fileclasses = [fs.File]
self.directories.folderclass = fs.Folder
def _prioritization_categories(self): def _prioritization_categories(self):
return prioritize.all_categories() return prioritize.all_categories()

View File

@@ -11,11 +11,10 @@ from hscommon.util import format_size
from core import fs from core import fs
from core.app import format_timestamp, format_perc, format_words, format_dupe_count from core.app import format_timestamp, format_perc, format_words, format_dupe_count
class File(fs.File): def get_display_info(dupe, group, delta):
def get_display_info(self, group, delta): size = dupe.size
size = self.size mtime = dupe.mtime
mtime = self.mtime m = group.get_match_of(dupe)
m = group.get_match_of(self)
if m: if m:
percentage = m.percentage percentage = m.percentage
dupe_count = 0 dupe_count = 0
@@ -27,13 +26,22 @@ class File(fs.File):
percentage = group.percentage percentage = group.percentage
dupe_count = len(group.dupes) dupe_count = len(group.dupes)
return { return {
'name': self.name, 'name': dupe.name,
'folder_path': str(self.folder_path), 'folder_path': str(dupe.folder_path),
'size': format_size(size, 0, 1, False), 'size': format_size(size, 0, 1, False),
'extension': self.extension, 'extension': dupe.extension,
'mtime': format_timestamp(mtime, delta and m), 'mtime': format_timestamp(mtime, delta and m),
'percentage': format_perc(percentage), 'percentage': format_perc(percentage),
'words': format_words(self.words) if hasattr(self, 'words') else '', 'words': format_words(dupe.words) if hasattr(dupe, 'words') else '',
'dupe_count': format_dupe_count(dupe_count), 'dupe_count': format_dupe_count(dupe_count),
} }
class File(fs.File):
def get_display_info(self, group, delta):
return get_display_info(self, group, delta)
class Folder(fs.Folder):
def get_display_info(self, group, delta):
return get_display_info(self, group, delta)

View File

@@ -1,3 +1,11 @@
=== 6.6.0 (2013-08-18)
* Improved delta values to support non-numerical values. (#213)
* Improved the Re-Prioritize dialog's UI. (#224)
* Added hardlink/symlink support on Windows Vista+. (#220)
* Dropped 32bit support on Mac OS X.
* Added Vietnamese localization by Phan Anh.
=== 6.5.1 (2013-05-18) === 6.5.1 (2013-05-18)
* Improved "Make Selection Reference" to make it clearer. (#222) * Improved "Make Selection Reference" to make it clearer. (#222)

View File

@@ -1,3 +1,12 @@
=== 2.8.0 (2013-08-25)
* Improved delta values to support non-numerical values. (#213)
* Improved the Re-Prioritize dialog's UI. (#224)
* Added hardlink/symlink support on Windows Vista+. (#220)
* Added keybinding for the "Clear Picture Cache" action. [Linux, Windows] (#230)
* Dropped 32bit support on Mac OS X.
* Added Vietnamese localization by Phan Anh.
=== 2.7.1 (2013-05-05) === 2.7.1 (2013-05-05)
* Fixed false matching bug in EXIF matching. (#219) * Fixed false matching bug in EXIF matching. (#219)

View File

@@ -1,3 +1,18 @@
=== 3.8.0 (2013-12-07)
* Disable symlink/hardlink deletion option when not relevant. (#247)
* Make Cmd+A select all folders in the Folder Selection dialog. [Mac] (#228)
* Make non-numeric delta comparison case insensitive. (#239)
* Fix surrogate-related UnicodeEncodeError on CSV export. (#210)
* Fixed crash on Dupe Count sorting with Delta + Dupes Only. (#238)
* Improved documentation.
* Important internal refactorings.
* Removed the fairware dialog ([More Info](http://www.hardcoded.net/articles/phasing-out-fairware)).
=== 3.7.1 (2013-08-19)
* Fixed folder scan type, which was broken in v3.7.0.
=== 3.7.0 (2013-08-17) === 3.7.0 (2013-08-17)
* Improved delta values to support non-numerical values. (#213) * Improved delta values to support non-numerical values. (#213)

View File

@@ -12,11 +12,26 @@
# serve to show the default. # serve to show the default.
import sys, os import sys, os
import re
# If extensions (or modules to document with autodoc) are in another directory, # If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# for autodocs
sys.path.insert(0, os.path.abspath(os.path.join('..', '..')))
# -- Misc fixes for autodoc
def fix_nulljob_in_sig(app, what, name, obj, options, signature, return_annotation):
if signature:
signature = re.sub(r"<jobprogress.job.NullJob object at 0x[\da-f]+>", "nulljob", signature)
return signature, return_annotation
def setup(app):
app.connect('autodoc-process-signature', fix_nulljob_in_sig)
autodoc_member_order = 'groupwise'
# -- General configuration ----------------------------------------------------- # -- General configuration -----------------------------------------------------
@@ -25,7 +40,7 @@ import sys, os
# Add any Sphinx extension module names here, as strings. They can be extensions # Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.todo'] extensions = ['sphinx.ext.todo', 'sphinx.ext.autodoc', 'sphinx.ext.autosummary']
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ['_templates']
@@ -145,10 +160,10 @@ html_theme = 'haiku'
#html_additional_pages = {} #html_additional_pages = {}
# If false, no module index is generated. # If false, no module index is generated.
html_domain_indices = False # html_domain_indices = False
# If false, no index is generated. # If false, no index is generated.
html_use_index = False # html_use_index = False
# If true, the index is split into individual pages for each letter. # If true, the index is split into individual pages for each letter.
#html_split_index = False #html_split_index = False

View File

@@ -0,0 +1,5 @@
core.app
========
.. automodule:: core.app
:members:

View File

@@ -0,0 +1,5 @@
core.directories
================
.. automodule:: core.directories
:members:

View File

@@ -0,0 +1,36 @@
core.engine
===========
.. automodule:: core.engine
.. autoclass:: Match
.. autoclass:: Group
:members:
.. autofunction:: build_word_dict
.. autofunction:: compare
.. autofunction:: compare_fields
.. autofunction:: getmatches
.. autofunction:: getmatches_by_contents
.. autofunction:: get_groups
.. autofunction:: merge_similar_words
.. autofunction:: reduce_common_words
.. _fields:
Fields
------
Fields are groups of words which each represent a significant part of the whole name. This concept
is sifnificant in music file names, where we often have names like "My Artist - a very long title
with many many words".
This title has 10 words. If you run as scan with a bit of tolerance, let's say 90%, you'll be able
to find a dupe that has only one "many" in the song title. However, you would also get false
duplicates from a title like "My Giraffe - a very long title with many many words", which is of
course a very different song and it doesn't make sense to match them.
When matching by fields, each field (separated by "-") is considered as a separate string to match
independently. After all fields are matched, the lowest result is kept. In the "Giraffe" example we
gave, the result would be 50% instead of 90% in normal mode.

View File

@@ -0,0 +1,5 @@
core.fs
=======
.. automodule:: core.fs
:members:

View File

@@ -0,0 +1,5 @@
core.gui.deletion_options
=========================
.. automodule:: core.gui.deletion_options
:members:

View File

@@ -0,0 +1,10 @@
core.gui
========
.. automodule:: core.gui
:members:
.. toctree::
:maxdepth: 2
deletion_options

View File

@@ -0,0 +1,12 @@
core
====
.. toctree::
:maxdepth: 2
app
fs
engine
directories
results
gui/index

View File

@@ -0,0 +1,5 @@
core.results
============
.. automodule:: core.results
:members:

View File

@@ -0,0 +1,5 @@
hscommon.build
==============
.. automodule:: hscommon.build
:members:

View File

@@ -0,0 +1,5 @@
hscommon.conflict
=================
.. automodule:: hscommon.conflict
:members:

View File

@@ -0,0 +1,5 @@
hscommon.desktop
================
.. automodule:: hscommon.desktop
:members:

View File

@@ -0,0 +1,12 @@
hscommon.gui.base
=================
.. automodule:: hscommon.gui.base
.. autosummary::
GUIObject
.. autoclass:: GUIObject
:members:
:private-members:

View File

@@ -0,0 +1,25 @@
hscommon.gui.column
============================
.. automodule:: hscommon.gui.column
.. autosummary::
Columns
Column
ColumnsView
PrefAccessInterface
.. autoclass:: Columns
:members:
:private-members:
.. autoclass:: Column
:members:
:private-members:
.. autoclass:: ColumnsView
:members:
.. autoclass:: PrefAccessInterface
:members:

View File

@@ -0,0 +1,18 @@
hscommon.gui.progress_window
============================
.. automodule:: hscommon.gui.progress_window
.. autosummary::
ProgressWindow
ProgressWindowView
.. autoclass:: ProgressWindow
:members:
:private-members:
.. autoclass:: ProgressWindowView
:members:
:private-members:

View File

@@ -0,0 +1,26 @@
hscommon.gui.selectable_list
============================
.. automodule:: hscommon.gui.selectable_list
.. autosummary::
Selectable
SelectableList
GUISelectableList
GUISelectableListView
.. autoclass:: Selectable
:members:
:private-members:
.. autoclass:: SelectableList
:members:
:private-members:
.. autoclass:: GUISelectableList
:members:
:private-members:
.. autoclass:: GUISelectableListView
:members:

View File

@@ -0,0 +1,26 @@
hscommon.gui.table
==================
.. automodule:: hscommon.gui.table
.. autosummary::
Table
Row
GUITable
GUITableView
.. autoclass:: Table
:members:
:private-members:
.. autoclass:: Row
:members:
:private-members:
.. autoclass:: GUITable
:members:
:private-members:
.. autoclass:: GUITableView
:members:

View File

@@ -0,0 +1,16 @@
hscommon.gui.text_field
=======================
.. automodule:: hscommon.gui.text_field
.. autosummary::
TextField
TextFieldView
.. autoclass:: TextField
:members:
:private-members:
.. autoclass:: TextFieldView
:members:

View File

@@ -0,0 +1,18 @@
hscommon.gui.tree
=================
.. automodule:: hscommon.gui.tree
.. autosummary::
Tree
Node
.. autoclass:: Tree
:members:
:private-members:
.. autoclass:: Node
:members:
:private-members:

View File

@@ -0,0 +1,19 @@
hscommon
========
.. toctree::
:maxdepth: 2
build
conflict
desktop
notify
path
util
gui/base
gui/text_field
gui/selectable_list
gui/table
gui/tree
gui/column
gui/progress_window

View File

@@ -0,0 +1,5 @@
hscommon.notify
===============
.. automodule:: hscommon.notify
:members:

View File

@@ -0,0 +1,5 @@
hscommon.path
=============
.. automodule:: hscommon.path
:members:

View File

@@ -0,0 +1,5 @@
hscommon.util
=============
.. automodule:: hscommon.util
:members:

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