Merge branch 'develop'

This commit is contained in:
Virgil Dupras 2013-12-07 11:15:39 -05:00
commit 47e636e949
196 changed files with 3206 additions and 5037 deletions

View File

@ -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

@ -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}

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,12 +255,12 @@ 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}

View File

@ -9,8 +9,7 @@
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
@ -27,8 +26,9 @@ 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):
@ -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,10 +88,7 @@ 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 = getattr(dupe, attrname, '')
value = rem_file_ext(dupe.name)
else:
value = getattr(dupe, attrname, '')
return value.lower() if isinstance(value, str) else value return value.lower() if isinstance(value, str) else value
def fix_surrogate_encoding(s, encoding='utf-8'): def fix_surrogate_encoding(s, encoding='utf-8'):
@ -112,7 +108,7 @@ def fix_surrogate_encoding(s, encoding='utf-8'):
else: else:
return s return s
class DupeGuru(RegistrableApplication, Broadcaster): class DupeGuru(Broadcaster):
"""Holds everything together. """Holds everything together.
Instantiated once per running application, it holds a reference to every high-level object Instantiated once per running application, it holds a reference to every high-level object
@ -144,6 +140,10 @@ class DupeGuru(RegistrableApplication, Broadcaster):
Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results` Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results`
""" """
#--- View interface #--- View interface
# 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
@ -154,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()
@ -206,13 +205,11 @@ class DupeGuru(RegistrableApplication, Broadcaster):
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
@ -250,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.
@ -337,13 +334,6 @@ 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`. """Adds folder ``d`` to :attr:`directories`.
@ -393,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
@ -401,21 +391,21 @@ 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. """Start an async move (or copy) job on marked duplicates.
@ -430,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
@ -446,8 +434,6 @@ class DupeGuru(RegistrableApplication, Broadcaster):
def delete_marked(self): def delete_marked(self):
"""Start an async job to send marked duplicates to the trash. """Start an async job to send marked duplicates to the trash.
""" """
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
@ -467,7 +453,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
""" """
colnames, rows = self._get_export_data() colnames, rows = self._get_export_data()
export_path = export.export_to_xhtml(colnames, rows) export_path = export.export_to_xhtml(colnames, rows)
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. """Export current results to CSV.
@ -613,7 +599,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
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`. """Remove files that don't exist from :attr:`ignore_list`.
@ -704,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):

View File

@ -73,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):
@ -94,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
@ -143,9 +142,9 @@ class Directories:
:rtype: list of Path :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 []
@ -178,7 +177,7 @@ class Directories:
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:

View File

@ -150,9 +150,9 @@ class File:
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:
@ -173,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):
@ -219,8 +219,7 @@ 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 = [self.__class__(p) for p in subfolders]
return self._subfolders return self._subfolders
@ -248,18 +247,9 @@ def get_files(path, fileclasses=[File]):
:param fileclasses: List of candidate :class:`File` classes :param fileclasses: List of candidate :class:`File` classes
""" """
assert all(issubclass(fileclass, File) for fileclass in fileclasses) assert all(issubclass(fileclass, File) for fileclass in fileclasses)
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
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

@ -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

@ -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):
@ -417,11 +416,11 @@ 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:
@ -444,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')
@ -457,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')
@ -470,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')
@ -480,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

@ -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

@ -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

@ -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.1' __version__ = '3.8.0'
__appname__ = 'dupeGuru' __appname__ = 'dupeGuru'

View File

@ -14,8 +14,8 @@ 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 self.directories.folderclass = fs.Folder

View File

@ -1,3 +1,14 @@
=== 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) === 3.7.1 (2013-08-19)
* Fixed folder scan type, which was broken in v3.7.0. * Fixed folder scan type, which was broken in v3.7.0.

View File

@ -31,6 +31,8 @@ def fix_nulljob_in_sig(app, what, name, obj, options, signature, return_annotati
def setup(app): def setup(app):
app.connect('autodoc-process-signature', fix_nulljob_in_sig) app.connect('autodoc-process-signature', fix_nulljob_in_sig)
autodoc_member_order = 'groupwise'
# -- General configuration ----------------------------------------------------- # -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here. # If your documentation needs a minimal Sphinx version, state it here.
@ -38,7 +40,7 @@ def setup(app):
# 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', 'sphinx.ext.autodoc'] 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']
@ -158,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

@ -1,5 +0,0 @@
core.gui
========
.. automodule:: core.gui
: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 @@
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:

View File

@ -53,9 +53,5 @@ API
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
core/app core/index
core/fs hscommon/index
core/engine
core/directories
core/results
core/gui

View File

@ -29,7 +29,7 @@ What makes it better than other duplicate scanners?
--------------------------------------------------- ---------------------------------------------------
The scanning engine is extremely flexible. You can tweak it to really get the kind of results you The scanning engine is extremely flexible. You can tweak it to really get the kind of results you
want. You can read more about dupeGuru tweaking option at the :doc:`Preferences page <preferences>`. want. You can read more about dupeGuru tweaking option in :doc:`scan`.
How safe is it to use dupeGuru? How safe is it to use dupeGuru?
------------------------------- -------------------------------

View File

@ -1,53 +1,67 @@
Folder Selection Folder Selection
================ ================
The first window you see when you launch dupeGuru is the folder selection window. This windows contains the list of the folders that will be scanned when you click on **Scan**. The first window you see when you launch dupeGuru is the folder selection window. This windows
contains the list of the folders that will be scanned when you click on **Scan**.
This window is quite straightforward to use. If you want to add a folder, click on the **+** button. If you added folder before, a popup menu with a list of recent folders you added will pop. You can click on one of them to add it directly to your list. If you click on the first item of the popup menu, **Add New Folder...**, you will be prompted for a folder to add. If you never added a folder, no menu will pop and you will directly be prompted for a new folder to add. This window is quite straightforward to use. If you want to add a folder, click on the **+** button.
If you added folder before, a popup menu with a list of recent folders you added will pop. You can
click on one of them to add it directly to your list. If you click on the first item of the popup
menu, **Add New Folder...**, you will be prompted for a folder to add. If you never added a folder,
no menu will pop and you will directly be prompted for a new folder to add.
An alternate way to add folders to the list is to drag them in the list. An alternate way to add folders to the list is to drag them in the list.
To remove a folder, select the folder to remove and click on **-**. If a subfolder is selected when you click the button, the selected folder will be set to **excluded** state (see below) instead of being removed. To remove a folder, select the folder to remove and click on **-**. If a subfolder is selected when
you click the button, the selected folder will be set to **excluded** state (see below) instead of
being removed.
Folder states Folder states
------------- -------------
Every folder can be in one of these 3 states: Every folder can be in one of these 3 states:
* **Normal:** Duplicates found in this folder can be deleted. **Normal:**
* **Reference:** Duplicates found in this folder **cannot** be deleted. Files from this folder can only end up in **reference** position in the dupe group. If more than one file from reference folders end up in the same dupe group, only one will be kept. The others will be removed from the group. Duplicates found in this folder can be deleted.
* **Excluded:** Files in this directory will not be included in the scan. **Reference:**
Duplicates found in this folder **cannot** be deleted. Files from this folder can
only end up in **reference** position in the dupe group. If more than one file from reference
folders end up in the same dupe group, only one will be kept. The others will be removed from
the group.
**Excluded:**
Files in this directory will not be included in the scan.
The default state of a folder is, of course, **Normal**. You can use **Reference** state for a folder if you want to be sure that you won't delete any file from it. The default state of a folder is, of course, **Normal**. You can use **Reference** state for a
folder if you want to be sure that you won't delete any file from it.
When you set the state of a directory, all subfolders of this folder automatically inherit this state unless you explicitly set a subfolder's state. When you set the state of a directory, all subfolders of this folder automatically inherit this
state unless you explicitly set a subfolder's state.
.. only:: edition_pe .. _iphoto:
iPhoto and Aperture libraries iPhoto and Aperture libraries
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -----------------------------
dupeGuru PE supports iPhoto and Aperture, which means that it knows how to read these libraries
and how to communicate with iPhoto and Aperture to remove photos from them. To use this feature,
use the special "Add iPhoto Library" and "Add Aperture Library" buttons in the menu that pops
up when you click the "+" button. This will then add a special folder for those libraries.
When duplicates are deleted from an iPhoto library, it's sent to iPhoto's trash.
When duplicates are deleted from an Aperture library, it unfortunately can't send it directly
to trash, but it creates a special project called "dupeGuru Trash" in Aperture and send all
photos in there. You can then send this project to the trash manually.
.. only:: edition_me dupeGuru Picture Edition supports iPhoto and Aperture, which means that it knows how to read these
libraries and how to communicate with iPhoto and Aperture to remove photos from them. To use this
feature, use the special "Add iPhoto Library" and "Add Aperture Library" buttons in the menu that
pops up when you click the "+" button. This will then add a special folder for those libraries.
iTunes library When duplicates are deleted (sent to trash) from an iPhoto library, it's sent to iPhoto's
^^^^^^^^^^^^^^ trash.
dupeGuru ME supports iTunes, which means that it knows how to read its libraries and how to When duplicates are deleted (sent to trash) from an Aperture library, it unfortunately can't
communicate with iTunes to remove songs from it. To use this feature, use the special send it directly to trash, but it creates a special project called "dupeGuru Trash" in Aperture
"Add iTunes Library" button in the menu that pops up when you click the "+" button. This will and send all photos in there. You can then send this project to the trash manually.
then add a special folder for those libraries.
iTunes library
When duplicates are deleted from an iTunes library, it's sent to the system trash, like a --------------
normal file, but it's also removed from iTunes, thus avoiding ending up with missing entries
(entries with the "!" logo next to them). dupeGuru Music Edition supports iTunes, which means that it knows how to read its libraries and how
to communicate with iTunes to remove songs from it. To use this feature, use the special
"Add iTunes Library" button in the menu that pops up when you click the "+" button. This will
then add a special folder for those libraries.
When duplicates are deleted from an iTunes library, it's sent to the system trash, like a
normal file, but it's also removed from iTunes, thus avoiding ending up with missing entries
(entries with the "!" logo next to them).

View File

@ -51,9 +51,16 @@ Contents:
quick_start quick_start
folders folders
preferences preferences
scan
results results
reprioritize reprioritize
faq faq
developer/index developer/index
changelog changelog
credits credits
Indices and tables
==================
* :ref:`genindex`
* :ref:`search`

View File

@ -1,63 +1,87 @@
Preferences Preferences
=========== ===========
.. only:: edition_se **Scan Type:**
Basic scan type to use. See :doc:`scan` for details.
**Scan Type:** This option determines what aspect of the files will be compared in the duplicate scan. If you select **Filename**, dupeGuru will compare every filenames word-by-word and, depending on the other settings below, it will determine if enough words are matching to consider 2 files duplicates. If you select **Content**, only files with the exact same content will match.
The **Folders** scan type is a bit special. When you choose it, dupeGuru will scan for duplicate *folders* instead of duplicate files. To determine whether two folders are duplicates, all files contained in the folders will be scanned, and if the contents of **all** files in the folders match, the folders will be considered duplicates.
**Filter Hardness:** If you chose the **Filename** scan type, this option determines how similar two filenames must be for dupeGuru to consider them duplicates. If the filter hardness is, for example 80, it means that 80% of the words of two filenames must match. To determine the matching percentage, dupeGuru first counts the total number of words in **both** filenames, then count the number of words matching (every word matching count as 2), and then divide the number of words matching by the total number of words. If the result is higher or equal to the filter hardness, we have a duplicate match. For example, "a b c d" and "c d e" have a matching percentage of 57 (4 words matching, 7 total words).
.. only:: edition_me .. only:: edition_me
**Scan Type:** This option determines what aspect of the files will be compared in the duplicate scan. The nature of the duplicate scan varies greatly depending on what you select for this option. **Tags to scan:**
When using the **Tags** scan type, you can select the tags that will be used for comparison.
* **Filename:** Every song will have its filename split into words, and then every word will be compared to compute a matching percentage. If this percentage is higher or equal to the **Filter Hardness** (see below for more details), dupeGuru will consider the 2 songs duplicates.
* **Filename - Fields:** Like **Filename**, except that once filename have been split into words, these words are then grouped into fields. The field separator is " - ". The final matching percentage will be the lowest matching percentage among the fields. Thus, "An Artist - The Title" and "An Artist - Other Title" would have a matching percentage of 50 (With a **Filename** scan, it would be 75).
* **Filename - Fields (No Order):** Like **Filename - Fields**, except that field order doesn't matter. For example, "An Artist - The Title" and "The Title - An Artist" would have a matching percentage of 100 instead of 0.
* **Tags:** This method reads the tag (metadata) of every song and compare their fields. This method, like the **Filename - Fields**, considers the lowest matching field as its final matching percentage.
* **Content:** This scan method use the actual content of the songs to determine which are duplicates. For 2 songs to match with this method, they must have the **exact same content**.
* **Audio Content:** Same as content, but only the audio content is compared (without metadata).
**Filter Hardness:** If you chose a filename or tag based scan type, this option determines how similar two filenames/tags must be for dupeGuru to consider them duplicates. If the filter hardness is, for example 80, it means that 80% of the words of two filenames must match. To determine the matching percentage, dupeGuru first counts the total number of words in **both** filenames, then count the number of words matching (every word matching count as 2), and then divide the number of words matching by the total number of words. If the result is higher or equal to the filter hardness, we have a duplicate match. For example, "a b c d" and "c d e" have a matching percentage of 57 (4 words matching, 7 total words).
**Tags to scan:** When using the **Tags** scan type, you can select the tags that will be used for comparison.
.. only:: edition_se or edition_me .. only:: edition_se or edition_me
**Word weighting:** If you chose the **Filename** scan type, this option slightly changes how matching percentage is calculated. With word weighting, instead of having a value of 1 in the duplicate count and total word count, every word have a value equal to the number of characters they have. With word weighting, "ab cde fghi" and "ab cde fghij" would have a matching percentage of 53% (19 total characters, 10 characters matching (4 for "ab" and 6 for "cde")). **Word weighting:**
See :ref:`word-weighting`.
**Match similar words:** If you turn this option on, similar words will be counted as matches. For example "The White Stripes" and "The White Stripe" would have a match % of 100 instead of 66 with that option turned on. **Warning:** Use this option with caution. It is likely that you will get a lot of false positives in your results when turning it on. However, it will help you to find duplicates that you wouldn't have found otherwise. The scan process also is significantly slower with this option turned on. **Match similar words:**
See :ref:`similarity-matching`.
.. only:: edition_pe .. only:: edition_pe
**Scan Type:** This option determines the type of scan that will be made on your pictures. The **Contents** scan type compares the actual contents of the pictures in a fuzzy way (making it possible to find not only exact duplicates, but also similar ones). The **EXIF Timestamp** scan type looks at the EXIF metadata of the picture (if it exists) and matches pictures that have the same one. It's much faster than the Contents scan. **Warning:** Modified pictures often keep the same EXIF timestamp, so watch out for false positives when you use that scan type. **Match pictures of different dimensions:**
If you check this box, pictures of different dimensions will be allowed in the same
**Filter Hardness:** *Contents scan type only.* The higher is this setting, the "harder" is the filter (In other words, the less results you get). Most pictures of the same quality match at 100% even if the format is different (PNG and JPG for example.). However, if you want to make a PNG match with a lower quality JPG, you will have to set the filer hardness to lower than 100. The default, 95, is a sweet spot. duplicate group.
**Match pictures of different dimensions:** If you check this box, pictures of different dimensions will be allowed in the same duplicate group. .. _filter-hardness:
**Can mix file kind:** If you check this box, duplicate groups are allowed to have files with different extensions. If you don't check it, well, they aren't! **Filter Hardness:**
The threshold needed for two files to be considered duplicates. A lower value means more
duplicates. The meaning of the threshold depends on the scanning type (see :doc:`scan`).
Only works for :ref:`worded <worded-scan>` and :ref:`picture blocks <picture-blocks-scan>`
scans.
**Ignore duplicates hardlinking to the same file:** If this option is enabled, dupeGuru will verify duplicates to see if they refer to the same `inode <http://en.wikipedia.org/wiki/Inode>`_. If they do, they will not be considered duplicates. (Only for OS X and Linux) **Can mix file kind:**
If you check this box, duplicate groups are allowed to have files with different extensions. If
you don't check it, well, they aren't!
**Use regular expressions when filtering:** If you check this box, the filtering feature will treat your filter query as a **regular expression**. Explaining them is beyond the scope of this document. A good place to start learning it is `regular-expressions.info <http://www.regular-expressions.info>`_. **Ignore duplicates hardlinking to the same file:**
If this option is enabled, dupeGuru will verify duplicates to see if they refer to the same
`inode`_. If they do, they will not be considered duplicates. (Only for OS X and Linux)
**Remove empty folders after delete or move:** When this option is enabled, folders are deleted after a file is deleted or moved and the folder is empty. **Use regular expressions when filtering:**
If you check this box, the filtering feature will treat your filter query as a
**regular expression**. Explaining them is beyond the scope of this document. A good place to
start learning it is `regular-expressions.info`_.
**Copy and Move:** Determines how the Copy and Move operations (in the Action menu) will behave. **Remove empty folders after delete or move:**
When this option is enabled, folders are deleted after a file is deleted or moved and the folder
is empty.
* **Right in destination:** All files will be sent directly in the selected destination, without trying to recreate the source path at all. **Copy and Move:**
* **Recreate relative path:** The source file's path will be re-created in the destination folder up to the root selection in the Directories panel. For example, if you added ``/Users/foobar/SomeFolder`` to your Directories panel and you move ``/Users/foobar/SomeFolder/SubFolder/SomeFile.ext`` to the destination ``/Users/foobar/MyDestination``, the final destination for the file will be ``/Users/foobar/MyDestination/SubFolder`` (``SomeFolder`` has been trimmed from source's path in the final destination.). Determines how the Copy and Move operations (in the Action menu) will behave.
* **Recreate absolute path:** The source file's path will be re-created in the destination folder in it's entirety. For example, if you move ``/Users/foobar/SomeFolder/SubFolder/SomeFile.ext`` to the destination ``/Users/foobar/MyDestination``, the final destination for the file will be ``/Users/foobar/MyDestination/Users/foobar/SomeFolder/SubFolder``.
In all cases, dupeGuru nicely handles naming conflicts by prepending a number to the destination filename if the filename already exists in the destination. * **Right in destination:** All files will be sent directly in the selected destination, without
trying to recreate the source path at all.
* **Recreate relative path:** The source file's path will be re-created in the destination folder up
to the root selection in the Directories panel. For example, if you added
``/Users/foobar/SomeFolder`` to your Directories panel and you move
``/Users/foobar/SomeFolder/SubFolder/SomeFile.ext`` to the destination
``/Users/foobar/MyDestination``, the final destination for the file will be
``/Users/foobar/MyDestination/SubFolder`` (``SomeFolder`` has been trimmed from source's path in
the final destination.).
* **Recreate absolute path:** The source file's path will be re-created in the destination folder in
its entirety. For example, if you move ``/Users/foobar/SomeFolder/SubFolder/SomeFile.ext`` to the
destination ``/Users/foobar/MyDestination``, the final destination for the file will be
``/Users/foobar/MyDestination/Users/foobar/SomeFolder/SubFolder``.
**Custom Command:** This preference determines the command that will be invoked by the "Invoke Custom Command" action. You can invoke any external application through this action. This can be useful if, for example, you have a nice diffing application installed. In all cases, dupeGuru nicely handles naming conflicts by prepending a number to the destination
filename if the filename already exists in the destination.
The format of the command is the same as what you would write in the command line, except that there are 2 placeholders: **%d** and **%r**. These placeholders will be replaced by the path of the selected dupe (%d) and the path of the selected dupe's reference file (%r). **Custom Command:**
This preference determines the command that will be invoked by the "Invoke Custom Command"
action. You can invoke any external application through this action. This can be useful if,
for example, you have a nice diffing application installed.
The format of the command is the same as what you would write in the command line, except that there
are 2 placeholders: **%d** and **%r**. These placeholders will be replaced by the path of the
selected dupe (%d) and the path of the selected dupe's reference file (%r).
If the path to your executable contains space characters, you should enclose it in "" quotes. You should also enclose placeholders in quotes because it's very possible that paths to dupes and refs will contain spaces. Here's an example custom command:: If the path to your executable contains space characters, you should enclose it in "" quotes. You
should also enclose placeholders in quotes because it's very possible that paths to dupes and refs
will contain spaces. Here's an example custom command::
"C:\Program Files\SuperDiffProg\SuperDiffProg.exe" "%d" "%r" "C:\Program Files\SuperDiffProg\SuperDiffProg.exe" "%d" "%r"
.. _inode: http://en.wikipedia.org/wiki/Inode
.. _regular-expressions.info: http://www.regular-expressions.info

View File

@ -1,6 +1,8 @@
Results Results
======= =======
.. contents::
When dupeGuru is finished scanning for duplicates, it will show its results in the form of duplicate group list. When dupeGuru is finished scanning for duplicates, it will show its results in the form of duplicate group list.
About duplicate groups About duplicate groups
@ -118,42 +120,54 @@ filtered duplicates.
Action Menu Action Menu
----------- -----------
* **Clear Ignore List:** Remove all ignored matches you added. You have to start a new scan for the **Clear Ignore List:**
newly cleared ignore list to be effective. Remove all ignored matches you added. You have to start a new scan for the
* **Export Results to XHTML:** Take the current results, and create an XHTML file out of it. The newly cleared ignore list to be effective.
columns that are visible when you click on this button will be the columns present in the XHTML **Export Results to XHTML:**
file. The file will automatically be opened in your default browser. Take the current results, and create an XHTML file out of it. The
* **Send Marked to Trash:** Send all marked duplicates to trash, obviously. Before proceeding, columns that are visible when you click on this button will be the columns present in the XHTML
you'll be presented deletion options (see below). file. The file will automatically be opened in your default browser.
* **Move Marked to...:** Prompt you for a destination, and then move all marked files to that **Send Marked to Trash:**
destination. Source file's path might be re-created in destination, depending on the Send all marked duplicates to trash, obviously. Before proceeding,
"Copy and Move" preference. you'll be presented deletion options (see below).
* **Copy Marked to...:** Prompt you for a destination, and then copy all marked files to that **Move Marked to...:**
destination. Source file's path might be re-created in destination, depending on the Prompt you for a destination, and then move all marked files to that
"Copy and Move" preference. destination. Source file's path might be re-created in destination, depending on the
* **Remove Marked from Results:** Remove all marked duplicates from results. The actual files will "Copy and Move" preference.
not be touched and will stay where they are. **Copy Marked to...:**
* **Remove Selected from Results:** Remove all selected duplicates from results. Note that all Prompt you for a destination, and then copy all marked files to that
selected reference files will be ignored, only duplicates can be removed with this action. destination. Source file's path might be re-created in destination, depending on the
* **Make Selected into Reference:** Promote all selected duplicates to reference. If a duplicate is "Copy and Move" preference.
a part of a group having a reference file coming from a reference folder (in blue color), no **Remove Marked from Results:**
action will be taken for this duplicate. If more than one duplicate among the same group are Remove all marked duplicates from results. The actual files will
selected, only the first of each group will be promoted. not be touched and will stay where they are.
* **Add Selected to Ignore List:** This first removes all selected duplicates from results, and **Remove Selected from Results:**
then add the match of that duplicate and the current reference in the ignore list. This match Remove all selected duplicates from results. Note that all
will not come up again in further scan. The duplicate itself might come back, but it will be selected reference files will be ignored, only duplicates can be removed with this action.
matched with another reference file. You can clear the ignore list with the Clear Ignore List **Make Selected into Reference:**
command. Promote all selected duplicates to reference. If a duplicate is
* **Open Selected with Default Application:** Open the file with the application associated with a part of a group having a reference file coming from a reference folder (in blue color), no
selected file's type. action will be taken for this duplicate. If more than one duplicate among the same group are
* **Reveal Selected in Finder:** Open the folder containing selected file. selected, only the first of each group will be promoted.
* **Invoke Custom Command:** Invokes the external application you've set up in your preferences **Add Selected to Ignore List:**
using the current selection as arguments in the invocation. This first removes all selected duplicates from results, and
* **Rename Selected:** Prompts you for a new name, and then rename the selected file. then add the match of that duplicate and the current reference in the ignore list. This match
will not come up again in further scan. The duplicate itself might come back, but it will be
matched with another reference file. You can clear the ignore list with the Clear Ignore List
command.
**Open Selected with Default Application:**
Open the file with the application associated with selected file's type.
**Reveal Selected in Finder:**
Open the folder containing selected file.
**Invoke Custom Command:**
Invokes the external application you've set up in your preferences using the current selection
as arguments in the invocation.
**Rename Selected:**
Prompts you for a new name, and then rename the selected file.
**Warning about moving files in iPhoto/iTunes:** When using the "Move Marked" action on duplicates **Warning about moving files in iPhoto/iTunes/Aperture:** When using the "Move Marked" action on
that come from iPhoto or iTunes, files are copied, not moved. dupeGuru cannot use the Move action duplicates that come from iPhoto, Aperture or iTunes, files are copied, not moved. dupeGuru cannot
on those files. use the Move action on those files.
Deletion Options Deletion Options
---------------- ----------------
@ -161,21 +175,23 @@ Deletion Options
These options affect how duplicate deletion takes place. Most of the time, you don't need to enable These options affect how duplicate deletion takes place. Most of the time, you don't need to enable
any of them. any of them.
* **Link deleted files:** The deleted files are replaced by a link to the reference file. You have **Link deleted files:**
a choice of replacing it either with a `symlink`_ or a `hardlink`_. It's better to read the whole The deleted files are replaced by a link to the reference file. You have a choice of replacing
wikipedia pages about them to make a informed choice, but in short, a symlink is a shortcut to it either with a `symlink`_ or a `hardlink`_. It's better to read the whole
the file's path. If the original file is deleted or moved, the link is broken. A hardlink is a wikipedia pages about them to make a informed choice, but in short, a symlink is a shortcut to
link to the file *itself*. That link is as good as a "real" file. Only when *all* hardlinks to a the file's path. If the original file is deleted or moved, the link is broken. A hardlink is a
file are deleted is the file itself deleted. link to the file *itself*. That link is as good as a "real" file. Only when *all* hardlinks to a
file are deleted is the file itself deleted.
On OSX and Linux, this feature is supported fully, but under Windows, it's a bit complicated. On OSX and Linux, this feature is supported fully, but under Windows, it's a bit complicated.
Windows XP doesn't support it, but Vista and up support it. However, for the feature to work, Windows XP doesn't support it, but Vista and up support it. However, for the feature to work,
dupeGuru has to run with administrative privileges. dupeGuru has to run with administrative privileges.
* **Directly delete files:** Instead of sending files to trash, directly delete them. This is used **Directly delete files:**
for troubleshooting and you normally don't need to enable this unless dupeGuru has problems Instead of sending files to trash, directly delete them. This is used
deleting files normally, something that can happens when you try to delete files on network for troubleshooting and you normally don't need to enable this unless dupeGuru has problems
storage (NAS). deleting files normally, something that can happens when you try to delete files on network
storage (NAS).
.. _regular-expressions.info: http://www.regular-expressions.info .. _regular-expressions.info: http://www.regular-expressions.info
.. _hardlink: http://en.wikipedia.org/wiki/Hard_link .. _hardlink: http://en.wikipedia.org/wiki/Hard_link

186
help/en/scan.rst Normal file
View File

@ -0,0 +1,186 @@
The scanning process
====================
.. contents::
dupeGuru has 3 basic ways of scanning: :ref:`worded-scan` and :ref:`contents-scan` and
:ref:`picture blocks <picture-blocks-scan>`. The first two modes are for the Standard and Music
editions, the last is for the Picture edition. The scanning process is configured through the
:doc:`Preference pane <preferences>`.
.. _worded-scan:
Worded scans
------------
*Standard and Music Editions only*.
Worded scans extract a string from each file and split it into words. The string can come from two
different sources: **Filename** or **Tags** (Music Edition only).
When our source is music tags, we have to choose which tags to use. If, for example, we choose to
analyse *artist* and *title* tags, we'd end up with strings like
"The White Stripes - Seven Nation Army".
Words are split by space characters, with all punctuation removed (some are replaced by spaces, some
by nothing) and all words lowercased. For example, the string "This guy's song(remix)" yields
*this*, *guys*, *song* and *remix*.
Once this is done, the scanning dance begins. Finding duplicates is only a matter of finding how
many words in common two given strings have. If the :ref:`filter hardness <filter-hardness>` is,
for example, ``80``, it means that 80% of the words of two strings must match. To determine the
matching percentage, dupeGuru first counts the total number of words in **both** strings, then count
the number of words matching (every word matching count as 2), and then divide the number of words
matching by the total number of words. If the result is higher or equal than the filter hardness,
we have a duplicate match. For example, "a b c d" and "c d e" have a matching percentage of 57
(4 words matching, 7 total words).
Fields
^^^^^^
*Music Edition only*.
Song filenames often come with multiple and distinct parts and this can cause problems. For example,
let's take these two songs: "Dolly Parton - I Will Always Love You" and
"Whitney Houston - I Will Always Love You". They are clearly not the same song (they come from
different artists), but they still still have a matching score of 71%! This means that, with a naive
scanning method, we would get these songs as a false positive as soon as we try to dig a bit deeper
in our dupe hunt by lowering the threshold a bit.
This is why we have the "Fields" concept. Fields are separated by dashes (``-``). When the
"Filename - Fields" scan type is chosen, each field is compared separately. Our final matching score
will only be the lowest of all the fields. In our example, the title has a 100% match, but the
artist has a 0% match, making our final match score 0.
Sometimes, our song filename policy isn't completely homogenous, which means that we can end up with
"The White Stripes - Seven Nation Army" and "Seven Nation Army - The White Stripes". This is why
we have the "Filename - Fields (No Order)" scan type. With this scan type, all fields are compared
with each other, and the highest score is kept. Then, the final matching score is the lowest of them
all. In our case, the final matching score is 100.
Note: Each field is used once. Thus, "The White Stripes - The White Stripes" and
"The White Stripes - Seven Nation Army" have a match score of 0 because the second
"The White Stripes" can't be compared with the first field of the other name because it has already
been "used up" by the first field. Our final match score would be 0.
*Tags* scanning method is always "fielded". When choosing this scan method, we also choose which
tags are going to be compared, each being a field.
.. _word-weighting:
Word weighting
^^^^^^^^^^^^^^
When enabled, this option slightly changes how matching percentage is calculated by making bigger
words worth more. With word weighting, instead of having a value of 1 in the duplicate count and
total word count, every word have a value equal to the number of characters they have. With word
weighting, "ab cde fghi" and "ab cde fghij" would have a matching percentage of 53% (19 total
characters, 10 characters matching (4 for "ab" and 6 for "cde")).
.. _similarity-matching:
Similarity matching
^^^^^^^^^^^^^^^^^^^
When enabled, similar words will be counted as matches. For example "The White Stripes" and
"The White Stripe" would have a match score of 100 instead of 66 with that option turned on.
Two words are considered similar if they can be made equal with only a few edit operations (removing
a letter, adding one etc.). The process used is not unlike the
`Levenshtein distance`_. For the technically inclined, the actual function used is
Python's `get_close_matches`_ with a ``0.8`` cutoff.
**Warning:** Use this option with caution. It is likely that you will get a lot of false positives
in your results when turning it on. However, it will help you to find duplicates that you wouldn't
have found otherwise. The scan process also is significantly slower with this option turned on.
.. _contents-scan:
Contents scans
--------------
Contents scans are much simpler than worded scans. We read files and if the contents is exactly the
same, we consider the two files duplicates.
This is, of course, quite longer than comparing filenames and, to avoid needlessly reading whole
file contents, we start by looking at file sizes. After having grouped our files by size, we discard
every file that is alone in its group. Then, we proceed to read the contents of our remaining files.
MD5 hashes are used to compute compare contents. Yes, it is widely known that forging files having
the same MD5 hash is easy, but this file has to be knowingly forged. The possibilities of two files
having the same MD5 hash *and* the same size by accident is still very, very small.
The :ref:`filter hardness <filter-hardness>` preference is ignored in this scan.
Audio contents
^^^^^^^^^^^^^^
*Music Edition only*.
This mode is very much like the normal contents scan. The only difference is that it ignores
metadata included in the file and only compares audio data. *It doesn't do audio data fuzzy
matching, only exact matching. It would be really cool to have that, but we aren't there yet.*
Folders
^^^^^^^
*Standard Edition only*.
This is a special Contents scan type. It works like a normal contens scan, but instead of trying to
find duplicate files, it tries to find duplicate folders. A folder is duplicate to another if all
files it contains have the same contents as the other folder's file.
This scan is, of course, recursive and subfolders are checked. dupeGuru keeps only the biggest
fishes. Therefore, if two folders that are considered as matching contain subfolders, these
subfolders will not be included in the final results.
With this mode, we end up with folders as results instead of files.
.. _picture-blocks-scan:
Picture blocks
--------------
*Picture Edition only*.
dupeGuru Picture Edition stands apart of its two friends. Its scan types are completely different.
The first one is its "Contents" scan, which is a bit too generic, hence the name we use here,
"Picture blocks".
We start by opening every picture in RGB bitmap mode, then we "blockify" the picture. We create a
15x15 grid and compute the average color of each grid tile. This is the "picture analysis" phase.
It's very time consuming and the result is cached in a database (the "picture cache").
Once we've done that, we can start comparing them. Each tile in the grid (an average color) is
compared to its corresponding grid on the other picture and a color diff is computer (it's simply
a sum of the difference of R, G and B on each side). All these sums are added up to a final "score".
If that score is smaller or equal to ``100 - threshold``, we have a match.
A threshold of 100 adds an additional constraint that pictures have to be exactly the same (it's
possible, due to averaging, that the tile comparison yields ``0`` for pictures that aren't exactly
the same, but since "100%" suggests "exactly the same", we discard those ocurrences). If you want
to get pictures that are very, very similar but still allow a bit of fuzzy differences, go for 99%.
This second part of the scan is CPU intensive and can take quite a bit of time. This task has been
made to take advatange of multi-core CPUs and has been optimized to the best of my abilities, but
the fact of the matter is that, due to the fuzziness of the task, we still have to compare every picture
to every other, making the algorithm quadratic (if ``N`` is the number of pictures to compare, the
number of comparisons to perform is ``N*N``).
This algorithm is very naive, but in the field, it works rather well. If you master a better
algorithm and want to improve dupeGuru, by all means, let me know!
EXIF Timestamp
--------------
*Picture Edition only*.
This one is easy. We read the EXIF information of every picture and extract the ``DateTimeOriginal``
tag. If the tag is the same for two pictures, they're considered duplicates.
**Warning:** Modified pictures often keep the same EXIF timestamp, so watch out for false positives
when you use that scan type.
.. _Levenshtein distance: http://en.wikipedia.org/wiki/Levenshtein_distance
.. _get_close_matches: http://docs.python.org/3/library/difflib.html#difflib.get_close_matches

View File

@ -1,8 +0,0 @@
[main]
host = https://www.transifex.com
[hscommon.hscommon]
file_filter = locale/<lang>/LC_MESSAGES/hscommon.po
source_file = locale/hscommon.pot
source_lang = en
type = PO

View File

@ -1,9 +1,3 @@
The documentation has to be built with Sphinx. You can get Sphinx at http://sphinx.pocoo.org/ This module is common code used in all Hardcoded Software applications. It has no stable API so
it is not recommended to actually depend on it. But if you want to copy bits and pieces for your own
Once you installed it, you can build the documentation with: apps, be my guest.
cd docs
sphinx-build . ../docs_html
The reason why you have to move in 'docs' is because hscommon.io conflicts with the builtin 'io'
module. The documentation is also available online at http://www.hardcoded.net/docs/hscommon

View File

@ -6,6 +6,9 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
"""This module is a collection of function to help in HS apps build process.
"""
import os import os
import sys import sys
import os.path as op import os.path as op
@ -26,6 +29,8 @@ from .plat import ISWINDOWS
from .util import modified_after, find_in_path, ensure_folder, delete_files_with_pattern from .util import modified_after, find_in_path, ensure_folder, delete_files_with_pattern
def print_and_do(cmd): def print_and_do(cmd):
"""Prints ``cmd`` and executes it in the shell.
"""
print(cmd) print(cmd)
p = Popen(cmd, shell=True) p = Popen(cmd, shell=True)
return p.wait() return p.wait()
@ -125,6 +130,10 @@ def package_cocoa_app_in_dmg(app_path, destfolder, args):
build_dmg(app_path, destfolder) build_dmg(app_path, destfolder)
def build_dmg(app_path, destfolder): def build_dmg(app_path, destfolder):
"""Builds a DMG volume with application at ``app_path`` and puts it in ``dest_path``.
The name of the resulting DMG volume is determined by the app's name and version.
"""
print(repr(op.join(app_path, 'Contents', 'Info.plist'))) print(repr(op.join(app_path, 'Contents', 'Info.plist')))
plist = plistlib.readPlist(op.join(app_path, 'Contents', 'Info.plist')) plist = plistlib.readPlist(op.join(app_path, 'Contents', 'Info.plist'))
workpath = tempfile.mkdtemp() workpath = tempfile.mkdtemp()
@ -153,7 +162,7 @@ sysconfig.get_config_h_filename = lambda: op.join(op.dirname(__file__), 'pyconfi
""") """)
def add_to_pythonpath(path): def add_to_pythonpath(path):
"""Adds `path` to both PYTHONPATH env and sys.path. """Adds ``path`` to both ``PYTHONPATH`` env and ``sys.path``.
""" """
abspath = op.abspath(path) abspath = op.abspath(path)
pythonpath = os.environ.get('PYTHONPATH', '') pythonpath = os.environ.get('PYTHONPATH', '')
@ -166,6 +175,12 @@ def add_to_pythonpath(path):
# in setuptools. We copy the packages *without data* in a build folder and then build the plugin # in setuptools. We copy the packages *without data* in a build folder and then build the plugin
# from there. # from there.
def copy_packages(packages_names, dest, create_links=False, extra_ignores=None): def copy_packages(packages_names, dest, create_links=False, extra_ignores=None):
"""Copy python packages ``packages_names`` to ``dest``, spurious data.
Copy will happen without tests, testdata, mercurial data or C extension module source with it.
``py2app`` include and exclude rules are **quite** funky, and doing this is the only reliable
way to make sure we don't end up with useless stuff in our app.
"""
if ISWINDOWS: if ISWINDOWS:
create_links = False create_links = False
if not extra_ignores: if not extra_ignores:
@ -364,23 +379,14 @@ class OSXFrameworkStructure:
action(op.abspath(path), header_dest) action(op.abspath(path), header_dest)
def build_cocoalib_xibless(dest='cocoa/autogen', withfairware=True): def build_cocoalib_xibless(dest='cocoa/autogen'):
import xibless import xibless
ensure_folder(dest) ensure_folder(dest)
FNPAIRS = [ FNPAIRS = [
('progress.py', 'ProgressController_UI'), ('progress.py', 'ProgressController_UI'),
('error_report.py', 'HSErrorReportWindow_UI'), ('error_report.py', 'HSErrorReportWindow_UI'),
('about.py', 'HSAboutBox_UI'),
] ]
if withfairware:
FNPAIRS += [
('fairware_about.py', 'HSFairwareAboutBox_UI'),
('demo_reminder.py', 'HSDemoReminder_UI'),
('enter_code.py', 'HSEnterCode_UI'),
]
else:
FNPAIRS += [
('about.py', 'HSAboutBox_UI'),
]
for srcname, dstname in FNPAIRS: for srcname, dstname in FNPAIRS:
srcpath = op.join('cocoalib', 'ui', srcname) srcpath = op.join('cocoalib', 'ui', srcname)
dstpath = op.join(dest, dstname) dstpath = op.join(dest, dstname)

View File

@ -6,8 +6,15 @@
# 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
"""When you have to deal with names that have to be unique and can conflict together, you can use
this module that deals with conflicts by prepending unique numbers in ``[]`` brackets to the name.
"""
import re import re
from . import io import os
import shutil
from .path import Path, pathify
#This matches [123], but not [12] (3 digits being the minimum). #This matches [123], but not [12] (3 digits being the minimum).
#It also matches [1234] [12345] etc.. #It also matches [1234] [12345] etc..
@ -15,7 +22,7 @@ from . import io
re_conflict = re.compile(r'^\[\d{3}\d*\] ') re_conflict = re.compile(r'^\[\d{3}\d*\] ')
def get_conflicted_name(other_names, name): def get_conflicted_name(other_names, name):
"""Returns name with a [000] number in front of it. """Returns name with a ``[000]`` number in front of it.
The number between brackets depends on how many conlicted filenames The number between brackets depends on how many conlicted filenames
there already are in other_names. there already are in other_names.
@ -31,32 +38,42 @@ def get_conflicted_name(other_names, name):
i += 1 i += 1
def get_unconflicted_name(name): def get_unconflicted_name(name):
"""Returns ``name`` without ``[]`` brackets.
Brackets which, of course, might have been added by func:`get_conflicted_name`.
"""
return re_conflict.sub('',name,1) return re_conflict.sub('',name,1)
def is_conflicted(name): def is_conflicted(name):
"""Returns whether ``name`` is prepended with a bracketed number.
"""
return re_conflict.match(name) is not None return re_conflict.match(name) is not None
def _smart_move_or_copy(operation, source_path, dest_path): @pathify
''' Use move() or copy() to move and copy file with the conflict management, but without the def _smart_move_or_copy(operation, source_path: Path, dest_path: Path):
slowness of the fs system. """Use move() or copy() to move and copy file with the conflict management.
''' """
if io.isdir(dest_path) and not io.isdir(source_path): if dest_path.isdir() and not source_path.isdir():
dest_path = dest_path + source_path[-1] dest_path = dest_path[source_path.name]
if io.exists(dest_path): if dest_path.exists():
filename = dest_path[-1] filename = dest_path.name
dest_dir_path = dest_path[:-1] dest_dir_path = dest_path.parent()
newname = get_conflicted_name(io.listdir(dest_dir_path), filename) newname = get_conflicted_name(os.listdir(str(dest_dir_path)), filename)
dest_path = dest_dir_path + newname dest_path = dest_dir_path[newname]
operation(source_path, dest_path) operation(str(source_path), str(dest_path))
def smart_move(source_path, dest_path): def smart_move(source_path, dest_path):
_smart_move_or_copy(io.move, source_path, dest_path) """Same as :func:`smart_copy`, but it moves files instead.
"""
_smart_move_or_copy(shutil.move, source_path, dest_path)
def smart_copy(source_path, dest_path): def smart_copy(source_path, dest_path):
"""Copies ``source_path`` to ``dest_path``, recursively and with conflict resolution.
"""
try: try:
_smart_move_or_copy(io.copy, source_path, dest_path) _smart_move_or_copy(shutil.copy, source_path, dest_path)
except IOError as e: except IOError as e:
if e.errno in {21, 13}: # it's a directory, code is 21 on OS X / Linux and 13 on Windows if e.errno in {21, 13}: # it's a directory, code is 21 on OS X / Linux and 13 on Windows
_smart_move_or_copy(io.copytree, source_path, dest_path) _smart_move_or_copy(shutil.copytree, source_path, dest_path)
else: else:
raise raise

View File

@ -6,16 +6,36 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
"""This module facilitates currencies management. It exposes :class:`Currency` which lets you
easily figure out their exchange value.
"""
import os
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import logging import logging
import sqlite3 as sqlite import sqlite3 as sqlite
import threading import threading
from queue import Queue, Empty from queue import Queue, Empty
from . import io
from .path import Path from .path import Path
from .util import iterdaterange
class Currency: class Currency:
"""Represents a currency and allow easy exchange rate lookups.
A ``Currency`` instance is created with either a 3-letter ISO code or with a full name. If it's
present in the database, an instance will be returned. If not, ``ValueError`` is raised. The
easiest way to access a currency instance, however, if by using module-level constants. For
example::
>>> from hscommon.currency import USD, EUR
>>> from datetime import date
>>> USD.value_in(EUR, date.today())
0.6339119851386843
Unless a :class:`RatesDB` global instance is set through :meth:`Currency.set_rate_db` however,
only fallback values will be used as exchange rates.
"""
all = [] all = []
by_code = {} by_code = {}
by_name = {} by_name = {}
@ -67,12 +87,16 @@ class Currency:
@staticmethod @staticmethod
def set_rates_db(db): def set_rates_db(db):
"""Sets a new currency ``RatesDB`` instance to be used with all ``Currency`` instances.
"""
Currency.rates_db = db Currency.rates_db = db
@staticmethod @staticmethod
def get_rates_db(): def get_rates_db():
"""Returns the current ``RatesDB`` instance.
"""
if Currency.rates_db is None: if Currency.rates_db is None:
Currency.rates_db = RatesDB() # Make sure we always have some db to work with Currency.rates_db = RatesDB() # Make sure we always have some db to work with
return Currency.rates_db return Currency.rates_db
def rates_date_range(self): def rates_date_range(self):
@ -270,6 +294,12 @@ EUR = Currency(code='EUR')
class CurrencyNotSupportedException(Exception): class CurrencyNotSupportedException(Exception):
"""The current exchange rate provider doesn't support the requested currency.""" """The current exchange rate provider doesn't support the requested currency."""
class RateProviderUnavailable(Exception):
"""The rate provider is temporarily unavailable."""
def date2str(date):
return '%d%02d%02d' % (date.year, date.month, date.day)
class RatesDB: class RatesDB:
"""Stores exchange rates for currencies. """Stores exchange rates for currencies.
@ -310,7 +340,7 @@ class RatesDB:
logging.warning("Corrupt currency database at {0}. Starting over.".format(repr(self.db_or_path))) logging.warning("Corrupt currency database at {0}. Starting over.".format(repr(self.db_or_path)))
if isinstance(self.db_or_path, (str, Path)): if isinstance(self.db_or_path, (str, Path)):
self.con.close() self.con.close()
io.remove(Path(self.db_or_path)) os.remove(str(self.db_or_path))
self.con = sqlite.connect(str(self.db_or_path)) self.con = sqlite.connect(str(self.db_or_path))
else: else:
logging.warning("Can't re-use the file, using a memory table") logging.warning("Can't re-use the file, using a memory table")
@ -329,12 +359,35 @@ class RatesDB:
return row[0] return row[0]
return seek('<=', 'desc') or seek('>=', '') or Currency(currency_code).latest_rate return seek('<=', 'desc') or seek('>=', '') or Currency(currency_code).latest_rate
def _ensure_filled(self, date_start, date_end, currency_code):
"""Make sure that the cache contains *something* for each of the dates in the range.
Sometimes, our provider doesn't return us the range we sought. When it does, it usually
means that it never will and to avoid repeatedly querying those ranges forever, we have to
fill them. We use the closest rate for this.
"""
# We don't want to fill today, because we want to repeatedly fetch that one until the
# provider gives it to us.
if date_end >= date.today():
date_end = date.today() - timedelta(1)
sql = "select rate from rates where date = ? and currency = ?"
for curdate in iterdaterange(date_start, date_end):
cur = self._execute(sql, [date2str(curdate), currency_code])
if cur.fetchone() is None:
nearby_rate = self._seek_value_in_CAD(date2str(curdate), currency_code)
self.set_CAD_value(curdate, currency_code, nearby_rate)
logging.debug("Filled currency void for %s at %s (value: %2.2f)", currency_code, curdate, nearby_rate)
def _save_fetched_rates(self): def _save_fetched_rates(self):
while True: while True:
try: try:
rates, currency = self._fetched_values.get_nowait() rates, currency, fetch_start, fetch_end = self._fetched_values.get_nowait()
logging.debug("Saving %d rates for the currency %s", len(rates), currency)
for rate_date, rate in rates: for rate_date, rate in rates:
logging.debug("Saving rate %2.2f for %s", rate, rate_date)
self.set_CAD_value(rate_date, currency, rate) self.set_CAD_value(rate_date, currency, rate)
self._ensure_filled(fetch_start, fetch_end, currency)
logging.debug("Finished saving rates for currency %s", currency)
except Empty: except Empty:
break break
@ -342,7 +395,12 @@ class RatesDB:
self._cache = {} self._cache = {}
def date_range(self, currency_code): def date_range(self, currency_code):
"""Returns (start, end) of the cached rates for currency""" """Returns (start, end) of the cached rates for currency.
Returns a tuple ``(start_date, end_date)`` representing dates covered in the database for
currency ``currency_code``. If there are gaps, they are not accounted for (subclasses that
automatically update themselves are not supposed to introduce gaps in the db).
"""
sql = "select min(date), max(date) from rates where currency = '%s'" % currency_code sql = "select min(date), max(date) from rates where currency = '%s'" % currency_code
cur = self._execute(sql) cur = self._execute(sql)
start, end = cur.fetchone() start, end = cur.fetchone()
@ -374,7 +432,7 @@ class RatesDB:
else: else:
value2 = self._cache.get((date, currency2_code)) value2 = self._cache.get((date, currency2_code))
if value1 is None or value2 is None: if value1 is None or value2 is None:
str_date = '%d%02d%02d' % (date.year, date.month, date.day) str_date = date2str(date)
if value1 is None: if value1 is None:
value1 = self._seek_value_in_CAD(str_date, currency1_code) value1 = self._seek_value_in_CAD(str_date, currency1_code)
self._cache[(date, currency1_code)] = value1 self._cache[(date, currency1_code)] = value1
@ -388,7 +446,7 @@ class RatesDB:
# we must clear the whole cache because there might be other dates affected by this change # we must clear the whole cache because there might be other dates affected by this change
# (dates when the currency server has no rates). # (dates when the currency server has no rates).
self.clear_cache() self.clear_cache()
str_date = '%d%02d%02d' % (date.year, date.month, date.day) str_date = date2str(date)
sql = "replace into rates(date, currency, rate) values(?, ?, ?)" sql = "replace into rates(date, currency, rate) values(?, ?, ?)"
self._execute(sql, [str_date, currency_code, value]) self._execute(sql, [str_date, currency_code, value])
self.con.commit() self.con.commit()
@ -419,14 +477,27 @@ class RatesDB:
""" """
def do(): def do():
for currency, fetch_start, fetch_end in currencies_and_range: for currency, fetch_start, fetch_end in currencies_and_range:
logging.debug("Fetching rates for %s for date range %s to %s", currency, fetch_start, fetch_end)
for rate_provider in self._rate_providers: for rate_provider in self._rate_providers:
try: try:
values = rate_provider(currency, fetch_start, fetch_end) values = rate_provider(currency, fetch_start, fetch_end)
except CurrencyNotSupportedException: except CurrencyNotSupportedException:
continue continue
except RateProviderUnavailable:
logging.debug("Fetching failed due to temporary problems.")
break
else: else:
if values: if not values:
self._fetched_values.put((values, currency)) # We didn't get any value from the server, which means that we asked for
# rates that couldn't be delivered. Still, we report empty values so
# that the cache can correctly remember this unavailability so that we
# don't repeatedly fetch those ranges.
values = []
self._fetched_values.put((values, currency, fetch_start, fetch_end))
logging.debug("Fetching successful!")
break
else:
logging.debug("Fetching failed!")
currencies_and_range = [] currencies_and_range = []
for currency in currencies: for currency in currencies:
@ -437,7 +508,9 @@ class RatesDB:
except KeyError: except KeyError:
cached_range = self.date_range(currency) cached_range = self.date_range(currency)
range_start = start_date range_start = start_date
range_end = date.today() # Don't try to fetch today's rate, it's never there and results in useless server
# hitting.
range_end = date.today() - timedelta(1)
if cached_range is not None: if cached_range is not None:
cached_start, cached_end = cached_range cached_start, cached_end = cached_range
if range_start >= cached_start: if range_start >= cached_start:
@ -446,6 +519,10 @@ class RatesDB:
else: else:
# Make a backward fetch # Make a backward fetch
range_end = cached_start - timedelta(days=1) range_end = cached_start - timedelta(days=1)
# We don't want to fetch ranges that are too big. It can cause various problems, such
# as hangs. We prefer to take smaller bites.
if (range_end - range_start).days > 30:
range_start = range_end - timedelta(days=30)
if range_start <= range_end: if range_start <= range_end:
currencies_and_range.append((currency, range_start, range_end)) currencies_and_range.append((currency, range_start, range_end))
self._fetched_ranges[currency] = (start_date, date.today()) self._fetched_ranges[currency] = (start_date, date.today())

91
hscommon/desktop.py Normal file
View File

@ -0,0 +1,91 @@
# Created By: Virgil Dupras
# Created On: 2013-10-12
# 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 os.path as op
import logging
class SpecialFolder:
AppData = 1
Cache = 2
def open_url(url):
"""Open ``url`` with the default browser.
"""
_open_url(url)
def open_path(path):
"""Open ``path`` with its associated application.
"""
_open_path(str(path))
def reveal_path(path):
"""Open the folder containing ``path`` with the default file browser.
"""
_reveal_path(str(path))
def special_folder_path(special_folder, appname=None):
"""Returns the path of ``special_folder``.
``special_folder`` is a SpecialFolder.* const. The result is the special folder for the current
application. The running process' application info is used to determine relevant information.
You can override the application name with ``appname``. This argument is ingored under Qt.
"""
return _special_folder_path(special_folder, appname)
try:
# Normally, we would simply do "from cocoa import proxy", but due to a bug in pytest (currently
# at v2.4.2), our test suite is broken when we do that. This below is a workaround until that
# bug is fixed.
import cocoa
if not hasattr(cocoa, 'proxy'):
raise ImportError()
proxy = cocoa.proxy
_open_url = proxy.openURL_
_open_path = proxy.openPath_
_reveal_path = proxy.revealPath_
def _special_folder_path(special_folder, appname=None):
if special_folder == SpecialFolder.Cache:
base = proxy.getCachePath()
else:
base = proxy.getAppdataPath()
if not appname:
appname = proxy.bundleInfo_('CFBundleName')
return op.join(base, appname)
except ImportError:
try:
from PyQt4.QtCore import QUrl
from PyQt4.QtGui import QDesktopServices
def _open_path(path):
url = QUrl.fromLocalFile(str(path))
QDesktopServices.openUrl(url)
def _reveal_path(path):
_open_path(op.dirname(str(path)))
def _special_folder_path(special_folder, appname=None):
if special_folder == SpecialFolder.Cache:
qtfolder = QDesktopServices.CacheLocation
else:
qtfolder = QDesktopServices.DataLocation
return str(QDesktopServices.storageLocation(qtfolder))
except ImportError:
# We're either running tests, and these functions don't matter much or we're in a really
# weird situation. Let's just have dummy fallbacks.
logging.warning("Can't setup desktop functions!")
def _open_path(path):
pass
def _reveal_path(path):
pass
def _special_folder_path(special_folder, appname=None):
return '/tmp'

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