mirror of
https://github.com/arsenetar/dupeguru.git
synced 2026-01-25 16:11:39 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
152f5f37ce | ||
|
|
3e42ad8469 | ||
|
|
c7c7a73384 | ||
|
|
47e636e949 | ||
|
|
0562729d8b | ||
|
|
4a36227a18 | ||
|
|
28b8b2e415 | ||
|
|
fd82464564 | ||
|
|
418acf6e5e | ||
|
|
d14d076989 | ||
|
|
cb8bb5a70e | ||
|
|
563c9aeff3 | ||
|
|
a0cc1f2e03 | ||
|
|
01403a3f92 | ||
|
|
7116674663 | ||
|
|
b6bc5de79c | ||
|
|
5a275db67d | ||
|
|
31395d8794 | ||
|
|
3734bd6f6c | ||
|
|
da06ef8cad | ||
|
|
0b00171655 | ||
|
|
c1cfa86ad1 | ||
|
|
c34c9562d3 | ||
|
|
0e542577b0 | ||
|
|
42be49da83 | ||
|
|
398ac9b7c6 | ||
|
|
508e9a5d94 | ||
|
|
10dbfa9b38 | ||
|
|
e8c42740cf | ||
|
|
4b6c4f048d | ||
|
|
7594cccf8c | ||
|
|
1d9573cf6f | ||
|
|
76f45fb5a6 | ||
|
|
12cf9b800b | ||
|
|
ba7e6494c6 | ||
|
|
72d8160b28 | ||
|
|
6d53511cee | ||
|
|
a563327723 | ||
|
|
096e2bb78a | ||
|
|
8e65f15e1a | ||
|
|
9ea9f60e92 | ||
|
|
8efefaf0bf | ||
|
|
33d9569427 | ||
|
|
2fdfacb34e | ||
|
|
97fcf1ffa8 | ||
|
|
350b2c64e0 | ||
|
|
dcc57a7afb | ||
|
|
8b510994ad | ||
|
|
4a4d1bbfcd | ||
|
|
78c3c8ec2d | ||
|
|
e99e2b18e0 | ||
|
|
ae1283f2e1 | ||
|
|
cc76f3ca87 | ||
|
|
be8efea081 | ||
|
|
7e8f9036d8 |
44
README.md
44
README.md
@@ -1,7 +1,17 @@
|
||||
# dupeGuru
|
||||
|
||||
This package contains the source for dupeGuru. Its documentation is
|
||||
[available online][documentation]. Here's how this source tree is organised:
|
||||
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
|
||||
a system. It's written mostly in Python 3 and has the peculiarity of using
|
||||
[multiple GUI toolkits][cross-toolkit], all using the same core Python code. On OS X, the UI layer
|
||||
is written in Objective-C and uses Cocoa. On Linux and Windows, it's written in Python and uses Qt4.
|
||||
|
||||
dupeGuru comes in 3 editions (standard, music and picture) which are all buildable from this same
|
||||
source tree. You choose the edition you want to build in a ``configure.py`` flag.
|
||||
|
||||
# Contents of this folder
|
||||
|
||||
This folder contains the source for dupeGuru. Its documentation is in ``help``, but is also
|
||||
[available online][documentation] in its built form. Here's how this source tree is organised:
|
||||
|
||||
* core: Contains the core logic code for dupeGuru. It's Python code.
|
||||
* core_*: Edition-specific-cross-toolkit code written in Python.
|
||||
@@ -36,7 +46,7 @@ and follow instructions from the script. You can then ignore the rest of the bui
|
||||
Prerequisites are installed through `pip`. However, some of them are not "pip installable" and have
|
||||
to be installed manually.
|
||||
|
||||
* All systems: [Python 3.2+][python] and [setuptools][setuptools]
|
||||
* All systems: [Python 3.3+][python] and [setuptools][setuptools]
|
||||
* Mac OS X: The last XCode to have the 10.6 SDK included.
|
||||
* Windows: Visual Studio 2008, [PyQt 4.7+][pyqt], [cx_Freeze][cxfreeze] and
|
||||
[Advanced Installer][advinst] (you only need the last two if you want to create an installer)
|
||||
@@ -45,17 +55,16 @@ On Ubuntu, the apt-get command to install all pre-requisites is:
|
||||
|
||||
$ apt-get install python3-dev python3-pyqt4 pyqt4-dev-tools python3-setuptools
|
||||
|
||||
## Virtualenv setup
|
||||
## Setting up the virtual environment
|
||||
|
||||
First, you need `pip` and `virtualenv` in your system Python install:
|
||||
Use Python's built-in `pyvenv` to create a virtual environment in which we're going to install our.
|
||||
Python-related dependencies. `pyvenv` is built-in Python but, unlike its `virtualenv` predecessor,
|
||||
it doesn't install setuptools and pip, so it has to be installed manually:
|
||||
|
||||
$ sudo easy_install pip
|
||||
$ sudo pip install virtualenv
|
||||
|
||||
Then, in dupeGuru's source folder, create a virtual environment and activate it:
|
||||
|
||||
$ virtualenv --system-site-packages env
|
||||
$ pyvenv --system-site-packages env
|
||||
$ source env/bin/activate
|
||||
$ wget https://bitbucket.org/pypa/setuptools/raw/bootstrap/ez_setup.py -O - | python
|
||||
$ easy_install pip
|
||||
|
||||
Then, you can install pip requirements in your virtualenv:
|
||||
|
||||
@@ -63,17 +72,6 @@ Then, you can install pip requirements in your virtualenv:
|
||||
|
||||
([osx|win] depends, of course, on your platform. On other platforms, just use requirements.txt).
|
||||
|
||||
## Alternative: pyvenv
|
||||
|
||||
If you're on Python 3.3+, you can use the built-in `pyvenv` instead of `virtualenv`. `pyvenv` is
|
||||
pretty much the same thing as `virtualenv`, except that it doesn't install setuptools and pip, so it
|
||||
has to be installed manually:
|
||||
|
||||
$ pyvenv --system-site-packages env
|
||||
$ source env/bin/activate
|
||||
$ wget https://bitbucket.org/pypa/setuptools/raw/bootstrap/ez_setup.py -O - | python
|
||||
$ easy_install pip
|
||||
|
||||
## Actual building and running
|
||||
|
||||
With your virtualenv activated, you can build and run dupeGuru with these commands:
|
||||
@@ -86,6 +84,8 @@ You can also package dupeGuru into an installable package with:
|
||||
|
||||
$ python package.py
|
||||
|
||||
[dupeguru]: http://www.hardcoded.net/dupeguru/
|
||||
[cross-toolkit]: http://www.hardcoded.net/articles/cross-toolkit-software
|
||||
[documentation]: http://www.hardcoded.net/dupeguru/help/en/
|
||||
[python]: http://www.python.org/
|
||||
[setuptools]: https://pypi.python.org/pypi/setuptools
|
||||
|
||||
26
build.py
26
build.py
@@ -19,7 +19,7 @@ from setuptools import setup, Extension
|
||||
|
||||
from hscommon import sphinxgen
|
||||
from hscommon.build import (add_to_pythonpath, print_and_do, copy_packages, filereplace,
|
||||
get_module_version, move_all, copy_sysconfig_files_for_embed, copy_all, OSXAppStructure,
|
||||
get_module_version, move_all, copy_all, OSXAppStructure,
|
||||
build_cocoalib_xibless, fix_qt_resource_file, build_cocoa_ext, copy_embeddable_python_dylib,
|
||||
collect_stdlib_dependencies, copy)
|
||||
from hscommon import loc
|
||||
@@ -123,7 +123,6 @@ def build_cocoa(edition, dev):
|
||||
del sys.path[0]
|
||||
# Views are not referenced by python code, so they're not found by the collector.
|
||||
copy_all('build/inter/*.so', op.join(pydep_folder, 'inter'))
|
||||
copy_sysconfig_files_for_embed(pydep_folder)
|
||||
if not dev:
|
||||
# Important: Don't ever run delete_files_with_pattern('*.py') on dev builds because you'll
|
||||
# be deleting all py files in symlinked folders.
|
||||
@@ -135,6 +134,7 @@ def build_cocoa(edition, dev):
|
||||
print_and_do(cocoa_compile_command(edition))
|
||||
os.chdir('..')
|
||||
app.copy_executable('cocoa/build/dupeGuru')
|
||||
build_help(edition)
|
||||
print("Copying resources and frameworks")
|
||||
image_path = ed('cocoa/{}/dupeguru.icns')
|
||||
resources = [image_path, 'cocoa/base/dsa_pub.pem', 'build/dg_cocoa.py', 'build/help']
|
||||
@@ -151,6 +151,7 @@ def build_qt(edition, dev, conf):
|
||||
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')))
|
||||
fix_qt_resource_file(op.join('qt', 'base', 'dg_rc.py'))
|
||||
build_help(edition)
|
||||
print("Creating the run.py file")
|
||||
filereplace(op.join('qt', 'run_template.py'), 'run.py', edition=edition)
|
||||
|
||||
@@ -168,17 +169,12 @@ def build_help(edition):
|
||||
conftmpl = op.join(current_path, 'help', 'conf.tmpl')
|
||||
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():
|
||||
loc.compile_all_po(op.join('qtlib', 'locale'))
|
||||
loc.merge_locale_dir(op.join('qtlib', 'locale'), 'locale')
|
||||
|
||||
def build_localizations(ui, edition):
|
||||
build_base_localizations()
|
||||
loc.compile_all_po('locale')
|
||||
if ui == 'cocoa':
|
||||
app = cocoa_app(edition)
|
||||
loc.build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'base', 'en.lproj', 'Localizable.strings'))
|
||||
@@ -220,8 +216,6 @@ def build_updatepot():
|
||||
# We want to merge the generated pot with the old pot in the most preserving way possible.
|
||||
ui_packages = ['qt', op.join('cocoa', 'inter')]
|
||||
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")
|
||||
loc.generate_pot(['qtlib'], op.join('qtlib', 'locale', 'qtlib.pot'), ['tr'])
|
||||
if ISOSX:
|
||||
@@ -236,13 +230,11 @@ def build_updatepot():
|
||||
def build_mergepot():
|
||||
print("Updating .po files using .pot files")
|
||||
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('cocoalib', 'locale'))
|
||||
|
||||
def build_normpo():
|
||||
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('cocoalib', 'locale'))
|
||||
|
||||
@@ -264,7 +256,7 @@ def build_cocoa_bridging_interfaces(edition):
|
||||
add_to_pythonpath('cocoalib')
|
||||
from cocoa.inter import (PyGUIObject, GUIObjectView, PyColumns, ColumnsView, PyOutline,
|
||||
OutlineView, PySelectableList, SelectableListView, PyTable, TableView, PyBaseApp,
|
||||
PyFairware, PyTextField, ProgressWindowView, PyProgressWindow)
|
||||
PyTextField, ProgressWindowView, PyProgressWindow)
|
||||
from inter.deletion_options import PyDeletionOptions, DeletionOptionsView
|
||||
from inter.details_panel import PyDetailsPanel, DetailsPanelView
|
||||
from inter.directory_outline import PyDirectoryOutline, DirectoryOutlineView
|
||||
@@ -276,7 +268,7 @@ def build_cocoa_bridging_interfaces(edition):
|
||||
from inter.stats_label import PyStatsLabel, StatsLabelView
|
||||
from inter.app import PyDupeGuruBase, DupeGuruView
|
||||
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,
|
||||
PyIgnoreListDialog, PyDeletionOptions, PyResultTable, PyStatsLabel, PyDupeGuruBase,
|
||||
PyTextField, PyProgressWindow, appmod.PyDupeGuru]
|
||||
@@ -317,7 +309,6 @@ def build_pe_modules(ui):
|
||||
def build_normal(edition, ui, dev, conf):
|
||||
print("Building dupeGuru {0} with UI {1}".format(edition.upper(), ui))
|
||||
add_to_pythonpath('.')
|
||||
build_help(edition)
|
||||
print("Building dupeGuru")
|
||||
if edition == 'pe':
|
||||
build_pe_modules(ui)
|
||||
@@ -335,8 +326,9 @@ def main():
|
||||
if dev:
|
||||
print("Building in Dev mode")
|
||||
if options.clean:
|
||||
if op.exists('build'):
|
||||
shutil.rmtree('build')
|
||||
for path in ['build', op.join('cocoa', 'build'), op.join('cocoa', 'autogen')]:
|
||||
if op.exists(path):
|
||||
shutil.rmtree(path)
|
||||
if not op.exists('build'):
|
||||
os.mkdir('build')
|
||||
if options.doc:
|
||||
|
||||
@@ -13,7 +13,7 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
#import "DetailsPanel.h"
|
||||
#import "DirectoryPanel.h"
|
||||
#import "IgnoreListDialog.h"
|
||||
#import "HSFairwareAboutBox.h"
|
||||
#import "HSAboutBox.h"
|
||||
#import "HSRecentFiles.h"
|
||||
#import "HSProgressWindow.h"
|
||||
|
||||
@@ -30,7 +30,7 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
IgnoreListDialog *_ignoreListDialog;
|
||||
HSProgressWindow *_progressWindow;
|
||||
NSWindowController *_preferencesPanel;
|
||||
HSFairwareAboutBox *_aboutBox;
|
||||
HSAboutBox *_aboutBox;
|
||||
HSRecentFiles *_recentResults;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,4 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
|
||||
/* model --> view */
|
||||
- (void)showMessage:(NSString *)msg;
|
||||
- (void)setupAsRegistered;
|
||||
- (void)showDemoNagWithPrompt:(NSString *)prompt;
|
||||
@end
|
||||
|
||||
@@ -8,7 +8,6 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
|
||||
#import "AppDelegateBase.h"
|
||||
#import "ProgressController.h"
|
||||
#import "HSFairwareReminder.h"
|
||||
#import "HSPyUtil.h"
|
||||
#import "Consts.h"
|
||||
#import "Dialogs.h"
|
||||
@@ -140,7 +139,7 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[op setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]];
|
||||
[op setTitle:NSLocalizedString(@"Select a results file to load", @"")];
|
||||
if ([op runModal] == NSOKButton) {
|
||||
NSString *filename = [[op filenames] objectAtIndex:0];
|
||||
NSString *filename = [[[op URLs] objectAtIndex:0] path];
|
||||
[model loadResultsFrom:filename];
|
||||
[[self recentResults] addFile:filename];
|
||||
}
|
||||
@@ -162,7 +161,7 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
- (void)showAboutBox
|
||||
{
|
||||
if (_aboutBox == nil) {
|
||||
_aboutBox = [[HSFairwareAboutBox alloc] initWithApp:model];
|
||||
_aboutBox = [[HSAboutBox alloc] initWithApp:model];
|
||||
}
|
||||
[[_aboutBox window] makeKeyAndOrderFront:nil];
|
||||
}
|
||||
@@ -199,7 +198,6 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
/* Delegate */
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
|
||||
{
|
||||
[model initialRegistrationSetup];
|
||||
[model loadSession];
|
||||
}
|
||||
|
||||
@@ -261,16 +259,6 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[[self resultWindow] showProblemDialog];
|
||||
}
|
||||
|
||||
- (void)setupAsRegistered
|
||||
{
|
||||
// Nothing to do.
|
||||
}
|
||||
|
||||
- (void)showDemoNagWithPrompt:(NSString *)prompt
|
||||
{
|
||||
[HSFairwareReminder showDemoNagWithApp:[self model] prompt:prompt];
|
||||
}
|
||||
|
||||
- (NSString *)selectDestFolderWithPrompt:(NSString *)prompt
|
||||
{
|
||||
NSOpenPanel *op = [NSOpenPanel openPanel];
|
||||
@@ -280,7 +268,7 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[op setAllowsMultipleSelection:NO];
|
||||
[op setTitle:prompt];
|
||||
if ([op runModal] == NSOKButton) {
|
||||
return [[op filenames] objectAtIndex:0];
|
||||
return [[[op URLs] objectAtIndex:0] path];
|
||||
}
|
||||
else {
|
||||
return nil;
|
||||
@@ -294,7 +282,7 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[sp setAllowedFileTypes:[NSArray arrayWithObject:extension]];
|
||||
[sp setTitle:prompt];
|
||||
if ([sp runModal] == NSOKButton) {
|
||||
return [sp filename];
|
||||
return [[sp URL] path];
|
||||
}
|
||||
else {
|
||||
return nil;
|
||||
|
||||
@@ -64,4 +64,9 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[[self window] close];
|
||||
return r == NSOKButton;
|
||||
}
|
||||
|
||||
- (void)setHardlinkOptionEnabled:(BOOL)enabled
|
||||
{
|
||||
[linkTypeRadio setEnabled:enabled];
|
||||
}
|
||||
@end
|
||||
@@ -16,4 +16,6 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
@interface DirectoryOutline : HSOutline {}
|
||||
- (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView;
|
||||
- (PyDirectoryOutline *)model;
|
||||
|
||||
- (void)selectAll;
|
||||
@end;
|
||||
@@ -22,6 +22,12 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
return (PyDirectoryOutline *)model;
|
||||
}
|
||||
|
||||
/* Public */
|
||||
- (void)selectAll
|
||||
{
|
||||
[[self model] selectAll];
|
||||
}
|
||||
|
||||
/* Delegate */
|
||||
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id < NSDraggingInfo >)info proposedItem:(id)item proposedChildIndex:(NSInteger)index
|
||||
{
|
||||
|
||||
@@ -46,4 +46,6 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
|
||||
- (void)addDirectory:(NSString *)directory;
|
||||
- (void)refreshRemoveButtonText;
|
||||
- (void)markAll;
|
||||
|
||||
@end
|
||||
|
||||
@@ -91,8 +91,8 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[op setTitle:NSLocalizedString(@"Select a folder to add to the scanning list", @"")];
|
||||
[op setDelegate:self];
|
||||
if ([op runModal] == NSOKButton) {
|
||||
for (NSString *directory in [op filenames]) {
|
||||
[self addDirectory:directory];
|
||||
for (NSURL *directoryURL in [op URLs]) {
|
||||
[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 */
|
||||
- (BOOL)panel:(id)sender shouldShowFilename:(NSString *)path
|
||||
{
|
||||
@@ -171,6 +179,14 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[self addDirectory:path];
|
||||
}
|
||||
|
||||
- (BOOL)validateMenuItem:(NSMenuItem *)item
|
||||
{
|
||||
if ([item action] == @selector(markAll)) {
|
||||
[item setTitle:NSLocalizedString(@"Select All", @"")];
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
|
||||
- (void)directorySelectionChanged:(NSNotification *)aNotification
|
||||
|
||||
@@ -258,8 +258,8 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[sp setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]];
|
||||
[sp setTitle:NSLocalizedString(@"Select a file to save your results to", @"")];
|
||||
if ([sp runModal] == NSOKButton) {
|
||||
[model saveResultsAs:[sp filename]];
|
||||
[[app recentResults] addFile:[sp filename]];
|
||||
[model saveResultsAs:[[sp URL] path]];
|
||||
[[app recentResults] addFile:[[sp URL] path]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +344,9 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
|
||||
- (BOOL)validateMenuItem:(NSMenuItem *)item
|
||||
{
|
||||
if ([item action] == @selector(markAll)) {
|
||||
[item setTitle:NSLocalizedString(@"Mark All", @"")];
|
||||
}
|
||||
return ![[ProgressController mainProgressController] isShown];
|
||||
}
|
||||
@end
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
"%@ 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";
|
||||
"Action" = "Action";
|
||||
"Actions" = "Actions";
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import logging
|
||||
|
||||
from objp.util import pyref, dontwrap
|
||||
from cocoa import install_exception_hook, install_cocoa_logger, patch_threaded_job_performer, proxy
|
||||
from cocoa.inter import PyFairware, FairwareView
|
||||
from cocoa import install_exception_hook, install_cocoa_logger, patch_threaded_job_performer
|
||||
from cocoa.inter import PyBaseApp, BaseAppView
|
||||
|
||||
class DupeGuruView(FairwareView):
|
||||
class DupeGuruView(BaseAppView):
|
||||
def askYesNoWithPrompt_(self, prompt: str) -> bool: pass
|
||||
def showProblemDialog(self): pass
|
||||
def selectDestFolderWithPrompt_(self, prompt: str) -> str: pass
|
||||
def selectDestFileWithPrompt_extension_(self, prompt: str, extension: str) -> str: pass
|
||||
|
||||
class PyDupeGuruBase(PyFairware):
|
||||
class PyDupeGuruBase(PyBaseApp):
|
||||
@dontwrap
|
||||
def _init(self, modelclass):
|
||||
logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(message)s')
|
||||
install_exception_hook()
|
||||
install_cocoa_logger()
|
||||
patch_threaded_job_performer()
|
||||
appdata = proxy.getAppdataPath()
|
||||
self.model = modelclass(self, appdata)
|
||||
self.model = modelclass(self)
|
||||
|
||||
#---Sub-proxies
|
||||
def detailsPanel(self) -> pyref:
|
||||
@@ -144,14 +143,6 @@ class PyDupeGuruBase(PyFairware):
|
||||
self.model.options['copymove_dest_type'] = copymove_dest_type
|
||||
|
||||
#--- model --> view
|
||||
@dontwrap
|
||||
def open_path(self, path):
|
||||
proxy.openPath_(str(path))
|
||||
|
||||
@dontwrap
|
||||
def reveal_path(self, path):
|
||||
proxy.revealPath_(str(path))
|
||||
|
||||
@dontwrap
|
||||
def ask_yes_no(self, prompt):
|
||||
return self.callback.askYesNoWithPrompt_(prompt)
|
||||
|
||||
@@ -143,16 +143,13 @@ class Directories(directories.Directories):
|
||||
|
||||
|
||||
class DupeGuruME(DupeGuruBase):
|
||||
def __init__(self, view, appdata):
|
||||
appdata = op.join(appdata, 'dupeGuru Music Edition')
|
||||
DupeGuruBase.__init__(self, view, appdata)
|
||||
def __init__(self, view):
|
||||
DupeGuruBase.__init__(self, view)
|
||||
# Use fileclasses set in DupeGuruBase.__init__()
|
||||
self.directories = Directories(fileclasses=self.directories.fileclasses)
|
||||
self.dead_tracks = []
|
||||
|
||||
def _do_delete(self, j, *args):
|
||||
# XXX If I read correctly, Python 3.3 will allow us to go fetch inner function easily, so
|
||||
# we'll be able to replace "op" below with DupeGuruBase._do_delete.op.
|
||||
def op(dupe):
|
||||
j.add_progress()
|
||||
return self._do_delete_dupe(dupe, *args)
|
||||
@@ -174,7 +171,7 @@ class DupeGuruME(DupeGuruBase):
|
||||
DupeGuruBase._do_delete_dupe(self, dupe, *args)
|
||||
|
||||
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'):
|
||||
songs = get_itunes_songs(self.directories.itunes_libpath)
|
||||
self.itunes_songs = {song.path: song for song in songs}
|
||||
|
||||
@@ -6,16 +6,14 @@
|
||||
# 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 plistlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from appscript import app, its, k, CommandError, ApplicationNotFoundError
|
||||
|
||||
from hscommon import io
|
||||
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 cocoa import proxy
|
||||
|
||||
@@ -48,6 +46,16 @@ class Photo(PhotoBase):
|
||||
raise IOError('The picture %s could not be read' % str(self.path))
|
||||
return blocks
|
||||
|
||||
def _get_exif_timestamp(self):
|
||||
exifdata = proxy.readExifData_(str(self.path))
|
||||
if exifdata:
|
||||
try:
|
||||
return exifdata['{Exif}']['DateTimeOriginal']
|
||||
except KeyError:
|
||||
return ''
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
class IPhoto(Photo):
|
||||
def __init__(self, path, db_id):
|
||||
@@ -67,11 +75,12 @@ class AperturePhoto(Photo):
|
||||
def display_folder_path(self):
|
||||
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.
|
||||
if not io.exists(plistpath):
|
||||
if not plistpath.exists():
|
||||
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
|
||||
s = remove_invalid_xml(s, replace_with='')
|
||||
# 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])
|
||||
try:
|
||||
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:
|
||||
self.iphoto_libpath = None
|
||||
try:
|
||||
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:
|
||||
self.aperture_libpath = None
|
||||
|
||||
@@ -171,9 +180,8 @@ class Directories(directories.Directories):
|
||||
|
||||
|
||||
class DupeGuruPE(DupeGuruBase):
|
||||
def __init__(self, view, appdata):
|
||||
appdata = op.join(appdata, 'dupeGuru Picture Edition')
|
||||
DupeGuruBase.__init__(self, view, appdata)
|
||||
def __init__(self, view):
|
||||
DupeGuruBase.__init__(self, view)
|
||||
self.directories = Directories()
|
||||
|
||||
def _do_delete(self, j, *args):
|
||||
@@ -247,20 +255,20 @@ class DupeGuruPE(DupeGuruBase):
|
||||
DupeGuruBase._do_delete_dupe(self, dupe, *args)
|
||||
|
||||
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'):
|
||||
photos = get_iphoto_pictures(self.directories.iphoto_libpath)
|
||||
self.path2iphoto = {p.path: p for p in photos}
|
||||
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'):
|
||||
photos = get_aperture_pictures(self.directories.aperture_libpath)
|
||||
self.path2aperture = {p.path: p for p in photos}
|
||||
return self.path2aperture.get(path)
|
||||
return DupeGuruBase._create_file(self, path)
|
||||
|
||||
def _job_completed(self, jobid, exc):
|
||||
DupeGuruBase._job_completed(self, jobid, exc)
|
||||
def _job_completed(self, jobid):
|
||||
DupeGuruBase._job_completed(self, jobid)
|
||||
if jobid == JobType.Load:
|
||||
if hasattr(self, 'path2iphoto'):
|
||||
del self.path2iphoto
|
||||
|
||||
@@ -9,15 +9,13 @@
|
||||
import logging
|
||||
import os.path as op
|
||||
|
||||
from hscommon import io
|
||||
from hscommon.path import Path
|
||||
from hscommon.path import Path, pathify
|
||||
from cocoa import proxy
|
||||
|
||||
from core.scanner import ScanType
|
||||
from core import fs
|
||||
from core.directories import Directories as DirectoriesBase, DirectoryState
|
||||
from core_se.app import DupeGuru as DupeGuruBase
|
||||
from core_se.fs import File
|
||||
from core_se import fs
|
||||
from .app import PyDupeGuruBase
|
||||
|
||||
def is_bundle(str_path):
|
||||
@@ -28,15 +26,17 @@ def is_bundle(str_path):
|
||||
|
||||
class Bundle(fs.Folder):
|
||||
@classmethod
|
||||
def can_handle(cls, path):
|
||||
return not io.islink(path) and io.isdir(path) and is_bundle(str(path))
|
||||
@pathify
|
||||
def can_handle(cls, path: Path):
|
||||
return not path.islink() and path.isdir() and is_bundle(str(path))
|
||||
|
||||
|
||||
class Directories(DirectoriesBase):
|
||||
ROOT_PATH_TO_EXCLUDE = list(map(Path, ['/Library', '/Volumes', '/System', '/bin', '/sbin', '/opt', '/private', '/dev']))
|
||||
HOME_PATH_TO_EXCLUDE = [Path('Library')]
|
||||
def __init__(self):
|
||||
DirectoriesBase.__init__(self, fileclasses=[Bundle, File])
|
||||
DirectoriesBase.__init__(self, fileclasses=[Bundle, fs.File])
|
||||
self.folderclass = fs.Folder
|
||||
|
||||
def _default_state_for_path(self, path):
|
||||
result = DirectoriesBase._default_state_for_path(self, path)
|
||||
@@ -68,9 +68,10 @@ class Directories(DirectoriesBase):
|
||||
|
||||
|
||||
class DupeGuru(DupeGuruBase):
|
||||
def __init__(self, view, appdata):
|
||||
appdata = op.join(appdata, 'dupeGuru')
|
||||
DupeGuruBase.__init__(self, view, appdata)
|
||||
def __init__(self, view):
|
||||
# appdata = op.join(appdata, 'dupeGuru')
|
||||
# print(repr(appdata))
|
||||
DupeGuruBase.__init__(self, view)
|
||||
self.directories = Directories()
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from cocoa.inter import PyGUIObject, GUIObjectView
|
||||
class DeletionOptionsView(GUIObjectView):
|
||||
def updateMsg_(self, msg: str): pass
|
||||
def show(self) -> bool: pass
|
||||
def setHardlinkOptionEnabled_(self, enabled: bool): pass
|
||||
|
||||
class PyDeletionOptions(PyGUIObject):
|
||||
def setLinkDeleted_(self, link_deleted: bool):
|
||||
@@ -31,3 +32,6 @@ class PyDeletionOptions(PyGUIObject):
|
||||
def show(self):
|
||||
return self.callback.show()
|
||||
|
||||
@dontwrap
|
||||
def set_hardlink_option_enabled(self, enabled):
|
||||
self.callback.setHardlinkOptionEnabled_(enabled)
|
||||
|
||||
@@ -11,6 +11,9 @@ class PyDirectoryOutline(PyOutline):
|
||||
def removeSelectedDirectory(self):
|
||||
self.model.remove_selected()
|
||||
|
||||
def selectAll(self):
|
||||
self.model.select_all()
|
||||
|
||||
# python --> cocoa
|
||||
@dontwrap
|
||||
def refresh_states(self):
|
||||
|
||||
@@ -31,7 +31,7 @@ def configure(conf):
|
||||
os.symlink('../build/Python', versioned_dylib_path)
|
||||
# The rest is standard WAF code that you can find the the python and macapp demos.
|
||||
conf.load('compiler_c python')
|
||||
conf.check_python_version((3,2,0))
|
||||
conf.check_python_version((3,3,0))
|
||||
conf.check_python_headers()
|
||||
conf.env.FRAMEWORK_COCOA = 'Cocoa'
|
||||
conf.env.ARCH_COCOA = ['x86_64']
|
||||
@@ -44,7 +44,7 @@ def build(ctx):
|
||||
cocoalib_node = ctx.srcnode.find_dir('..').find_dir('cocoalib')
|
||||
cocoalib_folders = ['controllers', 'views']
|
||||
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',
|
||||
'NSImageAdditions', 'NSNotificationAdditions',
|
||||
'views/HSTableView', 'views/HSOutlineView', 'views/NSIndexPathAdditions',
|
||||
|
||||
@@ -14,8 +14,6 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
NSTextField *titleTextField;
|
||||
NSTextField *versionTextField;
|
||||
NSTextField *copyrightTextField;
|
||||
NSTextField *registeredTextField;
|
||||
NSButton *registerButton;
|
||||
|
||||
PyBaseApp *app;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -20,6 +20,7 @@
|
||||
- (NSString *)bundleIdentifier;
|
||||
- (NSString *)appVersion;
|
||||
- (NSString *)osxVersion;
|
||||
- (NSString *)bundleInfo:(NSString *)key;
|
||||
- (void)postNotification:(NSString *)name userInfo:(NSDictionary *)userInfo;
|
||||
- (id)prefValue:(NSString *)prefname;
|
||||
- (void)setPrefValue:(NSString *)prefname value:(id)value;
|
||||
@@ -29,4 +30,5 @@
|
||||
- (void)destroyPool;
|
||||
- (void)reportCrash:(NSString *)crashReport;
|
||||
- (void)log:(NSString *)s;
|
||||
- (NSDictionary *)readExifData:(NSString *)imagePath;
|
||||
@end
|
||||
@@ -1,5 +1,4 @@
|
||||
#import "CocoaProxy.h"
|
||||
#import <CoreServices/CoreServices.h>
|
||||
#import "HSErrorReportWindow.h"
|
||||
|
||||
@implementation CocoaProxy
|
||||
@@ -92,13 +91,14 @@
|
||||
return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"];
|
||||
}
|
||||
|
||||
- (NSString *)bundleInfo:(NSString *)key
|
||||
{
|
||||
return [[NSBundle mainBundle] objectForInfoDictionaryKey:key];
|
||||
}
|
||||
|
||||
- (NSString *)osxVersion
|
||||
{
|
||||
SInt32 major, minor, bugfix;
|
||||
Gestalt(gestaltSystemVersionMajor, &major);
|
||||
Gestalt(gestaltSystemVersionMinor, &minor);
|
||||
Gestalt(gestaltSystemVersionBugFix, &bugfix);
|
||||
return [NSString stringWithFormat:@"%d.%d.%d", major, minor, bugfix];
|
||||
return [[NSProcessInfo processInfo] operatingSystemVersionString];
|
||||
}
|
||||
|
||||
- (void)postNotification:(NSString *)name userInfo:(NSDictionary *)userInfo
|
||||
@@ -152,4 +152,20 @@
|
||||
{
|
||||
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
|
||||
@@ -294,45 +294,7 @@ class PyBaseApp(PyGUIObject):
|
||||
def set_default(self, key_name, value):
|
||||
proxy.setPrefValue_value_(key_name, value)
|
||||
|
||||
@dontwrap
|
||||
def open_url(self, url):
|
||||
proxy.openURL_(url)
|
||||
|
||||
@dontwrap
|
||||
def show_message(self, 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)
|
||||
|
||||
|
||||
@@ -101,7 +101,13 @@ http://www.hardcoded.net/licenses/bsd_license
|
||||
[[self view] setDelegate:nil];
|
||||
[[self view] reloadData];
|
||||
[[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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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.";
|
||||
"Buy" = "Buy";
|
||||
"Cancel" = "Cancel";
|
||||
"Clear List" = "Clear List";
|
||||
"Contribute" = "Contribute";
|
||||
"Don't Send" = "Don't Send";
|
||||
"Enter Key" = "Enter Key";
|
||||
"Enter your key" = "Enter your key";
|
||||
"Error Report" = "Error Report";
|
||||
"Fairware?" = "Fairware?";
|
||||
"No" = "No";
|
||||
"OK" = "OK";
|
||||
"Please wait..." = "Please wait...";
|
||||
"Register" = "Register";
|
||||
"Registration e-mail:" = "Registration e-mail:";
|
||||
"Registration key:" = "Registration key:";
|
||||
"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?";
|
||||
"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..." = "Work in progress...";
|
||||
"Yes" = "Yes";
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
#
|
||||
msgid ""
|
||||
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
|
||||
msgid ""
|
||||
@@ -14,10 +8,6 @@ msgid ""
|
||||
"in an instable state, so it is recommended that you restart the application."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
@@ -26,30 +16,14 @@ msgstr ""
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -62,18 +36,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr ""
|
||||
@@ -88,24 +50,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,20 +9,12 @@ msgstr ""
|
||||
"Language: cs\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
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr "Buy"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
@@ -31,30 +23,14 @@ msgstr "Cancel"
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Contribute"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -67,18 +43,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Send"
|
||||
@@ -95,26 +59,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,20 +9,12 @@ msgstr ""
|
||||
"Language: de\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
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr "Buy"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
@@ -31,30 +23,14 @@ msgstr "Abbrechen"
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Spenden"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -67,18 +43,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Send"
|
||||
@@ -95,26 +59,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,10 +9,6 @@ msgstr ""
|
||||
"Language: es\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
|
||||
msgid ""
|
||||
"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"
|
||||
" 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
|
||||
msgid "Cancel"
|
||||
msgstr "Cancelar"
|
||||
@@ -33,30 +25,14 @@ msgstr "Cancelar"
|
||||
msgid "Clear List"
|
||||
msgstr "Limpiar lista"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Donar"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr "Informe de error"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "¿Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
@@ -69,18 +45,6 @@ msgstr "Aceptar"
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Enviar"
|
||||
@@ -97,26 +61,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr "En proceso, por favor, espere."
|
||||
|
||||
@@ -9,20 +9,12 @@ msgstr ""
|
||||
"Language: fr\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
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr "Acheter"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
@@ -31,30 +23,14 @@ msgstr "Annuler"
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Contribuer"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -67,18 +43,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Envoyer"
|
||||
@@ -94,26 +58,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,20 +9,12 @@ msgstr ""
|
||||
"Language: hy\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
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr "Գնել"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr "Չեղարկել"
|
||||
@@ -31,30 +23,14 @@ msgstr "Չեղարկել"
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Մասնակցել"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware է՞"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -67,18 +43,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Ուղարկել"
|
||||
@@ -94,26 +58,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,20 +9,12 @@ msgstr ""
|
||||
"Language: it\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
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr "Acquista"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr "Annulla"
|
||||
@@ -31,30 +23,14 @@ msgstr "Annulla"
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Contribuisci"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -67,18 +43,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Invia"
|
||||
@@ -95,26 +59,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,20 +9,12 @@ msgstr ""
|
||||
"Language: nl\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
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
@@ -31,30 +23,14 @@ msgstr ""
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -67,18 +43,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr ""
|
||||
@@ -93,24 +57,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,10 +9,6 @@ msgstr ""
|
||||
"Language: pt_BR\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
|
||||
msgid ""
|
||||
"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 "
|
||||
"instável. É recomendável reiniciá-lo."
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr "Comprar"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr "Cancelar"
|
||||
@@ -33,30 +25,14 @@ msgstr "Cancelar"
|
||||
msgid "Clear List"
|
||||
msgstr "Limpar Lista"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Contribuir"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
@@ -69,18 +45,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Enviar"
|
||||
@@ -96,26 +60,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,20 +9,12 @@ msgstr ""
|
||||
"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"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "%@ is Fairware"
|
||||
msgstr "%@ является Fairware"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr "Купить"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr "Отменить"
|
||||
@@ -31,30 +23,14 @@ msgstr "Отменить"
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Способствовайте"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -67,18 +43,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Отправить"
|
||||
@@ -94,26 +58,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,10 +9,6 @@ msgstr ""
|
||||
"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"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "%@ is Fairware"
|
||||
msgstr "%@ це Fairware"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid ""
|
||||
"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
|
||||
msgid "Cancel"
|
||||
msgstr "Відмінити"
|
||||
@@ -33,30 +25,14 @@ msgstr "Відмінити"
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "Зробити внесок"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -69,18 +45,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Надіслати"
|
||||
@@ -96,26 +60,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -10,20 +10,12 @@ msgstr ""
|
||||
"Language: vi\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "%@ is Fairware"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
@@ -32,30 +24,14 @@ msgstr ""
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Don't Send"
|
||||
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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -68,18 +44,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr ""
|
||||
@@ -94,24 +58,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -9,20 +9,12 @@ msgstr ""
|
||||
"Language: zh_CN\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
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Buy"
|
||||
msgstr "Buy"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
@@ -31,30 +23,14 @@ msgstr "取消"
|
||||
msgid "Clear List"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Contribute"
|
||||
msgstr "捐助"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "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
|
||||
msgid "Error Report"
|
||||
msgstr ""
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "Fairware?"
|
||||
msgstr "Fairware?"
|
||||
|
||||
#: cocoalib/en.lproj/cocoalib.strings:0
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
@@ -67,18 +43,6 @@ msgstr ""
|
||||
msgid "Please wait..."
|
||||
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
|
||||
msgid "Send"
|
||||
msgstr "Send"
|
||||
@@ -95,24 +59,6 @@ msgstr ""
|
||||
msgid "Status: Working..."
|
||||
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
|
||||
msgid "Work in progress, please wait."
|
||||
msgstr ""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
218
core/app.py
218
core/app.py
@@ -16,15 +16,14 @@ import shutil
|
||||
|
||||
from send2trash import send2trash
|
||||
from jobprogress import job
|
||||
from hscommon.reg import RegistrableApplication
|
||||
from hscommon.notify import Broadcaster
|
||||
from hscommon.path import Path
|
||||
from hscommon.conflict import smart_move, smart_copy
|
||||
from hscommon.gui.progress_window import ProgressWindow
|
||||
from hscommon.util import (delete_if_empty, first, escape, nonone, format_time_decimal, allsame,
|
||||
rem_file_ext)
|
||||
from hscommon.util import delete_if_empty, first, escape, nonone, format_time_decimal, allsame
|
||||
from hscommon.trans import tr
|
||||
from hscommon.plat import ISWINDOWS
|
||||
from hscommon import desktop
|
||||
|
||||
from . import directories, results, scanner, export, fs
|
||||
from .gui.deletion_options import DeletionOptions
|
||||
@@ -89,14 +88,62 @@ def format_dupe_count(c):
|
||||
return str(c) if c else '---'
|
||||
|
||||
def cmp_value(dupe, attrname):
|
||||
if attrname == 'name':
|
||||
value = rem_file_ext(dupe.name)
|
||||
else:
|
||||
value = getattr(dupe, attrname, '')
|
||||
return value.lower() if isinstance(value, str) else value
|
||||
|
||||
class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
def fix_surrogate_encoding(s, encoding='utf-8'):
|
||||
# ref #210. It's possible to end up with file paths that, while correct unicode strings, are
|
||||
# decoded with the 'surrogateescape' option, which make the string unencodable to utf-8. We fix
|
||||
# these strings here by trying to encode them and, if it fails, we do an encode/decode dance
|
||||
# to remove the problematic characters. This dance is *lossy* but there's not much we can do
|
||||
# because if we end up with this type of string, it means that we don't know the encoding of the
|
||||
# underlying filesystem that brought them. Don't use this for strings you're going to re-use in
|
||||
# fs-related functions because you're going to lose your path (it's going to change). Use this
|
||||
# if you need to export the path somewhere else, outside of the unicode realm.
|
||||
# See http://lucumr.pocoo.org/2013/7/2/the-updated-guide-to-unicode/
|
||||
try:
|
||||
s.encode(encoding)
|
||||
except UnicodeEncodeError:
|
||||
return s.encode(encoding, 'replace').decode(encoding)
|
||||
else:
|
||||
return s
|
||||
|
||||
class DupeGuru(Broadcaster):
|
||||
"""Holds everything together.
|
||||
|
||||
Instantiated once per running application, it holds a reference to every high-level object
|
||||
whose reference needs to be held: :class:`~core.results.Results`, :class:`Scanner`,
|
||||
:class:`~core.directories.Directories`, :mod:`core.gui` instances, etc..
|
||||
|
||||
It also hosts high level methods and acts as a coordinator for all those elements. This is why
|
||||
some of its methods seem a bit shallow, like for example :meth:`mark_all` and
|
||||
:meth:`remove_duplicates`. These methos are just proxies for a method in :attr:`results`, but
|
||||
they are also followed by a notification call which is very important if we want GUI elements
|
||||
to be correctly notified of a change in the data they're presenting.
|
||||
|
||||
.. attribute:: directories
|
||||
|
||||
Instance of :class:`~core.directories.Directories`. It holds the current folder selection.
|
||||
|
||||
.. attribute:: results
|
||||
|
||||
Instance of :class:`core.results.Results`. Holds the results of the latest scan.
|
||||
|
||||
.. attribute:: selected_dupes
|
||||
|
||||
List of currently selected dupes from our :attr:`results`. Whenever the user changes its
|
||||
selection at the UI level, :attr:`result_table` takes care of updating this attribute, so
|
||||
you can trust that it's always up-to-date.
|
||||
|
||||
.. attribute:: result_table
|
||||
|
||||
Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results`
|
||||
"""
|
||||
#--- View interface
|
||||
# get_default(key_name)
|
||||
# set_default(key_name, value)
|
||||
# show_message(msg)
|
||||
# open_url(url)
|
||||
# open_path(path)
|
||||
# reveal_path(path)
|
||||
# ask_yes_no(prompt) --> bool
|
||||
@@ -107,15 +154,14 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
|
||||
# in fairware prompts, we don't mention the edition, it's too long.
|
||||
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):
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logging.debug("Debug mode enabled")
|
||||
RegistrableApplication.__init__(self, view, appid=1)
|
||||
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):
|
||||
os.makedirs(self.appdata)
|
||||
self.directories = directories.Directories()
|
||||
@@ -153,19 +199,17 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
return self.results.is_marked(dupe)
|
||||
if key == 'percentage':
|
||||
m = get_group().get_match_of(dupe)
|
||||
result = m.percentage
|
||||
return m.percentage
|
||||
elif key == 'dupe_count':
|
||||
result = 0
|
||||
return 0
|
||||
else:
|
||||
result = cmp_value(dupe, key)
|
||||
if delta:
|
||||
refval = getattr(get_group().ref, key)
|
||||
refval = cmp_value(get_group().ref, key)
|
||||
if key in self.result_table.DELTA_COLUMNS:
|
||||
result -= refval
|
||||
else:
|
||||
# We use directly getattr() because cmp_value() does thing that we don't want to do
|
||||
# when we want to determine whether two values are exactly the same.
|
||||
same = getattr(dupe, key) == refval
|
||||
same = cmp_value(dupe, key) == refval
|
||||
result = (same, result)
|
||||
return result
|
||||
|
||||
@@ -203,7 +247,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
ref = group.ref
|
||||
linkfunc = os.link if use_hardlinks else os.symlink
|
||||
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):
|
||||
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
|
||||
@@ -228,7 +272,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
for group_id, group in enumerate(self.results.groups):
|
||||
for dupe in group:
|
||||
data = self.get_display_info(dupe, group)
|
||||
row = [data[col.name] for col in columns]
|
||||
row = [fix_surrogate_encoding(data[col.name]) for col in columns]
|
||||
row.insert(0, group_id)
|
||||
rows.append(row)
|
||||
return colnames, rows
|
||||
@@ -290,15 +334,14 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.selected_dupes = dupes
|
||||
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
|
||||
def add_directory(self, d):
|
||||
"""Adds folder ``d`` to :attr:`directories`.
|
||||
|
||||
Shows an error message dialog if something bad happens.
|
||||
|
||||
:param str d: path of folder to add
|
||||
"""
|
||||
try:
|
||||
self.directories.add_path(Path(d))
|
||||
self.notify('directories_changed')
|
||||
@@ -308,6 +351,8 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.view.show_message(tr("'{}' does not exist.").format(d))
|
||||
|
||||
def add_selected_to_ignore_list(self):
|
||||
"""Adds :attr:`selected_dupes` to :attr:`scanner`'s ignore list.
|
||||
"""
|
||||
dupes = self.without_ref(self.selected_dupes)
|
||||
if not dupes:
|
||||
self.view.show_message(MSG_NO_SELECTED_DUPES)
|
||||
@@ -324,6 +369,10 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.ignore_list_dialog.refresh()
|
||||
|
||||
def apply_filter(self, filter):
|
||||
"""Apply a filter ``filter`` to the results so that it shows only dupe groups that match it.
|
||||
|
||||
:param str filter: filter to apply
|
||||
"""
|
||||
self.results.apply_filter(None)
|
||||
if self.options['escape_filter_regexp']:
|
||||
filter = escape(filter, set('()[]\\.|+?^'))
|
||||
@@ -334,7 +383,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
def clean_empty_dirs(self, path):
|
||||
if self.options['clean_empty_dirs']:
|
||||
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):
|
||||
source_path = dupe.path
|
||||
@@ -342,23 +391,27 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
dest_path = Path(destination)
|
||||
if dest_type in {DestType.Relative, DestType.Absolute}:
|
||||
# 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:
|
||||
source_base = source_base[location_path:]
|
||||
dest_path = dest_path + source_base
|
||||
dest_path = dest_path[source_base]
|
||||
if not dest_path.exists():
|
||||
dest_path.makedirs()
|
||||
# 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)
|
||||
# Raises an EnvironmentError if there's a problem
|
||||
if copy:
|
||||
smart_copy(source_path, dest_path)
|
||||
else:
|
||||
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):
|
||||
"""Start an async move (or copy) job on marked duplicates.
|
||||
|
||||
:param bool copy: If True, duplicates will be copied instead of moved
|
||||
"""
|
||||
def do(j):
|
||||
def op(dupe):
|
||||
j.add_progress()
|
||||
@@ -367,8 +420,6 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
j.start_job(self.results.mark_count)
|
||||
self.results.perform_on_marked(op, not copy)
|
||||
|
||||
if not self._check_demo():
|
||||
return
|
||||
if not self.results.mark_count:
|
||||
self.view.show_message(MSG_NO_MARKED_DUPES)
|
||||
return
|
||||
@@ -381,8 +432,8 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self._start_job(jobid, do)
|
||||
|
||||
def delete_marked(self):
|
||||
if not self._check_demo():
|
||||
return
|
||||
"""Start an async job to send marked duplicates to the trash.
|
||||
"""
|
||||
if not self.results.mark_count:
|
||||
self.view.show_message(MSG_NO_MARKED_DUPES)
|
||||
return
|
||||
@@ -394,11 +445,22 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self._start_job(JobType.Delete, self._do_delete, args=args)
|
||||
|
||||
def export_to_xhtml(self):
|
||||
"""Export current results to XHTML.
|
||||
|
||||
The configuration of the :attr:`result_table` (columns order and visibility) is used to
|
||||
determine how the data is presented in the export. In other words, the exported table in
|
||||
the resulting XHTML will look just like the results table.
|
||||
"""
|
||||
colnames, rows = self._get_export_data()
|
||||
export_path = export.export_to_xhtml(colnames, rows)
|
||||
self.view.open_path(export_path)
|
||||
desktop.open_path(export_path)
|
||||
|
||||
def export_to_csv(self):
|
||||
"""Export current results to CSV.
|
||||
|
||||
The columns and their order in the resulting CSV file is determined in the same way as in
|
||||
:meth:`export_to_xhtml`.
|
||||
"""
|
||||
dest_file = self.view.select_dest_file(tr("Select a destination for your exported CSV"), 'csv')
|
||||
if dest_file:
|
||||
colnames, rows = self._get_export_data()
|
||||
@@ -416,11 +478,11 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
return empty_data()
|
||||
|
||||
def invoke_custom_command(self):
|
||||
"""Calls command in 'CustomCommand' pref with %d and %r placeholders replaced.
|
||||
"""Calls command in ``CustomCommand`` pref with ``%d`` and ``%r`` placeholders replaced.
|
||||
|
||||
Using the current selection, %d is replaced with the currently selected dupe and %r is
|
||||
replaced with that dupe's ref file. If there's no selection, the command is not invoked.
|
||||
If the dupe is a ref, %d and %r will be the same.
|
||||
Using the current selection, ``%d`` is replaced with the currently selected dupe and ``%r``
|
||||
is replaced with that dupe's ref file. If there's no selection, the command is not invoked.
|
||||
If the dupe is a ref, ``%d`` and ``%r`` will be the same.
|
||||
"""
|
||||
cmd = self.view.get_default('CustomCommand')
|
||||
if not cmd:
|
||||
@@ -446,6 +508,12 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
|
||||
def load(self):
|
||||
"""Load directory selection and ignore list from files in appdata.
|
||||
|
||||
This method is called during startup so that directory selection and ignore list, which
|
||||
is persistent data, is the same as when the last session was closed (when :meth:`save` was
|
||||
called).
|
||||
"""
|
||||
self.directories.load_from_file(op.join(self.appdata, 'last_directories.xml'))
|
||||
self.notify('directories_changed')
|
||||
p = op.join(self.appdata, 'ignore_list.xml')
|
||||
@@ -453,11 +521,21 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.ignore_list_dialog.refresh()
|
||||
|
||||
def load_from(self, filename):
|
||||
"""Start an async job to load results from ``filename``.
|
||||
|
||||
:param str filename: path of the XML file (created with :meth:`save_as`) to load
|
||||
"""
|
||||
def do(j):
|
||||
self.results.load_from_xml(filename, self._get_file, j)
|
||||
self._start_job(JobType.Load, do)
|
||||
|
||||
def make_selected_reference(self):
|
||||
"""Promote :attr:`selected_dupes` to reference position within their respective groups.
|
||||
|
||||
Each selected dupe will become the :attr:`~core.engine.Group.ref` of its group. If there's
|
||||
more than one dupe selected for the same group, only the first (in the order currently shown
|
||||
in :attr:`result_table`) dupe will be promoted.
|
||||
"""
|
||||
dupes = self.without_ref(self.selected_dupes)
|
||||
changed_groups = set()
|
||||
for dupe in dupes:
|
||||
@@ -484,18 +562,30 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.notify('results_changed_but_keep_selection')
|
||||
|
||||
def mark_all(self):
|
||||
"""Set all dupes in the results as marked.
|
||||
"""
|
||||
self.results.mark_all()
|
||||
self.notify('marking_changed')
|
||||
|
||||
def mark_none(self):
|
||||
"""Set all dupes in the results as unmarked.
|
||||
"""
|
||||
self.results.mark_none()
|
||||
self.notify('marking_changed')
|
||||
|
||||
def mark_invert(self):
|
||||
"""Invert the marked state of all dupes in the results.
|
||||
"""
|
||||
self.results.mark_invert()
|
||||
self.notify('marking_changed')
|
||||
|
||||
def mark_dupe(self, dupe, marked):
|
||||
"""Change marked status of ``dupe``.
|
||||
|
||||
:param dupe: dupe to mark/unmark
|
||||
:type dupe: :class:`~core.fs.File`
|
||||
:param bool marked: True = mark, False = unmark
|
||||
"""
|
||||
if marked:
|
||||
self.results.mark(dupe)
|
||||
else:
|
||||
@@ -503,17 +593,26 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.notify('marking_changed')
|
||||
|
||||
def open_selected(self):
|
||||
"""Open :attr:`selected_dupes` with their associated application.
|
||||
"""
|
||||
if len(self.selected_dupes) > 10:
|
||||
if not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN):
|
||||
return
|
||||
for dupe in self.selected_dupes:
|
||||
self.view.open_path(dupe.path)
|
||||
desktop.open_path(dupe.path)
|
||||
|
||||
def purge_ignore_list(self):
|
||||
"""Remove files that don't exist from :attr:`ignore_list`.
|
||||
"""
|
||||
self.scanner.ignore_list.Filter(lambda f,s:op.exists(f) and op.exists(s))
|
||||
self.ignore_list_dialog.refresh()
|
||||
|
||||
def remove_directories(self, indexes):
|
||||
"""Remove root directories at ``indexes`` from :attr:`directories`.
|
||||
|
||||
:param indexes: Indexes of the directories to remove.
|
||||
:type indexes: list of int
|
||||
"""
|
||||
try:
|
||||
indexes = sorted(indexes, reverse=True)
|
||||
for index in indexes:
|
||||
@@ -523,10 +622,19 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
pass
|
||||
|
||||
def remove_duplicates(self, duplicates):
|
||||
"""Remove ``duplicates`` from :attr:`results`.
|
||||
|
||||
Calls :meth:`~core.results.Results.remove_duplicates` and send appropriate notifications.
|
||||
|
||||
:param duplicates: duplicates to remove.
|
||||
:type duplicates: list of :class:`~core.fs.File`
|
||||
"""
|
||||
self.results.remove_duplicates(self.without_ref(duplicates))
|
||||
self.notify('results_changed_but_keep_selection')
|
||||
|
||||
def remove_marked(self):
|
||||
"""Removed marked duplicates from the results (without touching the files themselves).
|
||||
"""
|
||||
if not self.results.mark_count:
|
||||
self.view.show_message(MSG_NO_MARKED_DUPES)
|
||||
return
|
||||
@@ -537,6 +645,8 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self._results_changed()
|
||||
|
||||
def remove_selected(self):
|
||||
"""Removed :attr:`selected_dupes` from the results (without touching the files themselves).
|
||||
"""
|
||||
dupes = self.without_ref(self.selected_dupes)
|
||||
if not dupes:
|
||||
self.view.show_message(MSG_NO_SELECTED_DUPES)
|
||||
@@ -547,6 +657,12 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.remove_duplicates(dupes)
|
||||
|
||||
def rename_selected(self, newname):
|
||||
"""Renames the selected dupes's file to ``newname``.
|
||||
|
||||
If there's more than one selected dupes, the first one is used.
|
||||
|
||||
:param str newname: The filename to rename the dupe's file to.
|
||||
"""
|
||||
try:
|
||||
d = self.selected_dupes[0]
|
||||
d.rename(newname)
|
||||
@@ -556,6 +672,14 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
return False
|
||||
|
||||
def reprioritize_groups(self, sort_key):
|
||||
"""Sort dupes in each group (in :attr:`results`) according to ``sort_key``.
|
||||
|
||||
Called by the re-prioritize dialog. Calls :meth:`~core.engine.Group.prioritize` and, once
|
||||
the sorting is done, show a message that confirms the action.
|
||||
|
||||
:param sort_key: The key being sent to :meth:`~core.engine.Group.prioritize`
|
||||
:type sort_key: f(dupe)
|
||||
"""
|
||||
count = 0
|
||||
for group in self.results.groups:
|
||||
if group.prioritize(key_func=sort_key):
|
||||
@@ -566,7 +690,7 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
|
||||
def reveal_selected(self):
|
||||
if self.selected_dupes:
|
||||
self.view.reveal_path(self.selected_dupes[0].path)
|
||||
desktop.reveal_path(self.selected_dupes[0].path)
|
||||
|
||||
def save(self):
|
||||
if not op.exists(self.appdata):
|
||||
@@ -577,9 +701,17 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.notify('save_session')
|
||||
|
||||
def save_as(self, filename):
|
||||
"""Save results in ``filename``.
|
||||
|
||||
:param str filename: path of the file to save results (as XML) to.
|
||||
"""
|
||||
self.results.save_to_xml(filename)
|
||||
|
||||
def start_scanning(self):
|
||||
"""Starts an async job to scan for duplicates.
|
||||
|
||||
Scans folders selected in :attr:`directories` and put the results in :attr:`results`
|
||||
"""
|
||||
def do(j):
|
||||
j.set_progress(0, tr("Collecting files to scan"))
|
||||
if self.scanner.scan_type == scanner.ScanType.Folders:
|
||||
@@ -611,6 +743,8 @@ class DupeGuru(RegistrableApplication, Broadcaster):
|
||||
self.notify('marking_changed')
|
||||
|
||||
def without_ref(self, dupes):
|
||||
"""Returns ``dupes`` with all reference elements removed.
|
||||
"""
|
||||
return [dupe for dupe in dupes if self.results.get_group_of_duplicate(dupe).ref is not dupe]
|
||||
|
||||
def get_default(self, key, fallback_value=None):
|
||||
|
||||
@@ -15,7 +15,20 @@ from hscommon.util import FileOrPath
|
||||
|
||||
from . import fs
|
||||
|
||||
__all__ = [
|
||||
'Directories',
|
||||
'DirectoryState',
|
||||
'AlreadyThereError',
|
||||
'InvalidPathError',
|
||||
]
|
||||
|
||||
class DirectoryState:
|
||||
"""Enum describing how a folder should be considered.
|
||||
|
||||
* DirectoryState.Normal: Scan all files normally
|
||||
* DirectoryState.Reference: Scan files, but make sure never to delete any of them
|
||||
* DirectoryState.Excluded: Don't scan this folder
|
||||
"""
|
||||
Normal = 0
|
||||
Reference = 1
|
||||
Excluded = 2
|
||||
@@ -27,11 +40,20 @@ class InvalidPathError(Exception):
|
||||
"""The path being added is invalid"""
|
||||
|
||||
class Directories:
|
||||
"""Holds user folder selection.
|
||||
|
||||
Manages the selection that the user make through the folder selection dialog. It also manages
|
||||
folder states, and how recursion applies to them.
|
||||
|
||||
Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped
|
||||
in :mod:`core.fs`) that have to be scanned according to the chosen folders/states.
|
||||
"""
|
||||
#---Override
|
||||
def __init__(self, fileclasses=[fs.File]):
|
||||
self._dirs = []
|
||||
self.states = {}
|
||||
self.fileclasses = fileclasses
|
||||
self.folderclass = fs.Folder
|
||||
|
||||
def __contains__(self, path):
|
||||
for p in self._dirs:
|
||||
@@ -51,7 +73,7 @@ class Directories:
|
||||
#---Private
|
||||
def _default_state_for_path(self, path):
|
||||
# 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
|
||||
|
||||
def _get_files(self, from_path, j):
|
||||
@@ -72,9 +94,8 @@ class Directories:
|
||||
file.is_ref = state == DirectoryState.Reference
|
||||
filepaths.add(file.path)
|
||||
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
|
||||
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 file in self._get_files(subfolder, j):
|
||||
yield file
|
||||
@@ -97,11 +118,14 @@ class Directories:
|
||||
|
||||
#---Public
|
||||
def add_path(self, path):
|
||||
"""Adds 'path' to self, if not already there.
|
||||
"""Adds ``path`` to self, if not already there.
|
||||
|
||||
Raises AlreadyThereError if 'path' is already in self. If path is a directory containing
|
||||
some of the directories already present in self, 'path' will be added, but all directories
|
||||
under it will be removed. Can also raise InvalidPathError if 'path' does not exist.
|
||||
Raises :exc:`AlreadyThereError` if ``path`` is already in self. If path is a directory
|
||||
containing some of the directories already present in self, ``path`` will be added, but all
|
||||
directories under it will be removed. Can also raise :exc:`InvalidPathError` if ``path``
|
||||
does not exist.
|
||||
|
||||
:param Path path: path to add
|
||||
"""
|
||||
if path in self:
|
||||
raise AlreadyThereError()
|
||||
@@ -112,18 +136,22 @@ class Directories:
|
||||
|
||||
@staticmethod
|
||||
def get_subfolders(path):
|
||||
"""returns a sorted list of paths corresponding to subfolders in `path`"""
|
||||
"""Returns a sorted list of paths corresponding to subfolders in ``path``.
|
||||
|
||||
:param Path path: get subfolders from there
|
||||
:rtype: list of Path
|
||||
"""
|
||||
try:
|
||||
names = [name for name in path.listdir() if (path + name).isdir()]
|
||||
names.sort(key=lambda x:x.lower())
|
||||
return [path + name for name in names]
|
||||
subpaths = [p for p in path.listdir() if p.isdir()]
|
||||
subpaths.sort(key=lambda x:x.name.lower())
|
||||
return subpaths
|
||||
except EnvironmentError:
|
||||
return []
|
||||
|
||||
def get_files(self, j=job.nulljob):
|
||||
"""Returns a list of all files that are not excluded.
|
||||
|
||||
Returned files also have their 'is_ref' attr set.
|
||||
Returned files also have their ``is_ref`` attr set if applicable.
|
||||
"""
|
||||
for path in self._dirs:
|
||||
for file in self._get_files(path, j):
|
||||
@@ -132,28 +160,36 @@ class Directories:
|
||||
def get_folders(self, j=job.nulljob):
|
||||
"""Returns a list of all folders that are not excluded.
|
||||
|
||||
Returned folders also have their 'is_ref' attr set.
|
||||
Returned folders also have their ``is_ref`` attr set if applicable.
|
||||
"""
|
||||
for path in self._dirs:
|
||||
from_folder = fs.Folder(path)
|
||||
from_folder = self.folderclass(path)
|
||||
for folder in self._get_folders(from_folder, j):
|
||||
yield folder
|
||||
|
||||
def get_state(self, path):
|
||||
"""Returns the state of 'path' (One of the STATE_* const.)
|
||||
"""Returns the state of ``path``.
|
||||
|
||||
:rtype: :class:`DirectoryState`
|
||||
"""
|
||||
if path in self.states:
|
||||
return self.states[path]
|
||||
default_state = self._default_state_for_path(path)
|
||||
if default_state is not None:
|
||||
return default_state
|
||||
parent = path[:-1]
|
||||
parent = path.parent()
|
||||
if parent in self:
|
||||
return self.get_state(parent)
|
||||
else:
|
||||
return DirectoryState.Normal
|
||||
|
||||
def has_any_file(self):
|
||||
"""Returns whether selected folders contain any file.
|
||||
|
||||
Because it stops at the first file it finds, it's much faster than get_files().
|
||||
|
||||
:rtype: bool
|
||||
"""
|
||||
try:
|
||||
next(self.get_files())
|
||||
return True
|
||||
@@ -161,6 +197,10 @@ class Directories:
|
||||
return False
|
||||
|
||||
def load_from_file(self, infile):
|
||||
"""Load folder selection from ``infile``.
|
||||
|
||||
:param file infile: path or file pointer to XML generated through :meth:`save_to_file`
|
||||
"""
|
||||
try:
|
||||
root = ET.parse(infile).getroot()
|
||||
except Exception:
|
||||
@@ -183,6 +223,10 @@ class Directories:
|
||||
self.set_state(Path(path), int(state))
|
||||
|
||||
def save_to_file(self, outfile):
|
||||
"""Save folder selection as XML to ``outfile``.
|
||||
|
||||
:param file outfile: path or file pointer to XML file to save to.
|
||||
"""
|
||||
with FileOrPath(outfile, 'wb') as fp:
|
||||
root = ET.Element('directories')
|
||||
for root_path in self:
|
||||
@@ -196,6 +240,12 @@ class Directories:
|
||||
tree.write(fp, encoding='utf-8')
|
||||
|
||||
def set_state(self, path, state):
|
||||
"""Set the state of folder at ``path``.
|
||||
|
||||
:param Path path: path of the target folder
|
||||
:param state: state to set folder to
|
||||
:type state: :class:`DirectoryState`
|
||||
"""
|
||||
if self.get_state(path) == state:
|
||||
return
|
||||
# we don't want to needlessly fill self.states. if get_state returns the same thing
|
||||
|
||||
125
core/engine.py
125
core/engine.py
@@ -44,10 +44,10 @@ def unpack_fields(fields):
|
||||
return result
|
||||
|
||||
def compare(first, second, flags=()):
|
||||
"""Returns the % of words that match between first and second
|
||||
"""Returns the % of words that match between ``first`` and ``second``
|
||||
|
||||
The result is a int in the range 0..100.
|
||||
First and second can be either a string or a list.
|
||||
The result is a ``int`` in the range 0..100.
|
||||
``first`` and ``second`` can be either a string or a list (of words).
|
||||
"""
|
||||
if not (first and second):
|
||||
return 0
|
||||
@@ -76,9 +76,10 @@ def compare(first, second, flags=()):
|
||||
return result
|
||||
|
||||
def compare_fields(first, second, flags=()):
|
||||
"""Returns the score for the lowest matching fields.
|
||||
"""Returns the score for the lowest matching :ref:`fields`.
|
||||
|
||||
first and second must be lists of lists of string.
|
||||
``first`` and ``second`` must be lists of lists of string. Each sub-list is then compared with
|
||||
:func:`compare`.
|
||||
"""
|
||||
if len(first) != len(second):
|
||||
return 0
|
||||
@@ -98,13 +99,14 @@ def compare_fields(first, second, flags=()):
|
||||
if matched_field:
|
||||
second.remove(matched_field)
|
||||
else:
|
||||
results = [compare(word1, word2, flags) for word1, word2 in zip(first, second)]
|
||||
results = [compare(field1, field2, flags) for field1, field2 in zip(first, second)]
|
||||
return min(results) if results else 0
|
||||
|
||||
def build_word_dict(objects, j=job.nulljob):
|
||||
"""Returns a dict of objects mapped by their words.
|
||||
|
||||
objects must have a 'words' attribute being a list of strings or a list of lists of strings.
|
||||
objects must have a ``words`` attribute being a list of strings or a list of lists of strings
|
||||
(:ref:`fields`).
|
||||
|
||||
The result will be a dict with words as keys, lists of objects as values.
|
||||
"""
|
||||
@@ -115,7 +117,11 @@ def build_word_dict(objects, j=job.nulljob):
|
||||
return result
|
||||
|
||||
def merge_similar_words(word_dict):
|
||||
"""Take all keys in word_dict that are similar, and merge them together.
|
||||
"""Take all keys in ``word_dict`` that are similar, and merge them together.
|
||||
|
||||
``word_dict`` has been built with :func:`build_word_dict`. Similarity is computed with Python's
|
||||
``difflib.get_close_matches()``, which computes the number of edits that are necessary to make
|
||||
a word equal to the other.
|
||||
"""
|
||||
keys = list(word_dict.keys())
|
||||
keys.sort(key=len)# we want the shortest word to stay
|
||||
@@ -131,7 +137,9 @@ def merge_similar_words(word_dict):
|
||||
keys.remove(similar)
|
||||
|
||||
def reduce_common_words(word_dict, threshold):
|
||||
"""Remove all objects from word_dict values where the object count >= threshold
|
||||
"""Remove all objects from ``word_dict`` values where the object count >= ``threshold``
|
||||
|
||||
``word_dict`` has been built with :func:`build_word_dict`.
|
||||
|
||||
The exception to this removal are the objects where all the words of the object are common.
|
||||
Because if we remove them, we will miss some duplicates!
|
||||
@@ -149,14 +157,48 @@ def reduce_common_words(word_dict, threshold):
|
||||
else:
|
||||
del word_dict[word]
|
||||
|
||||
Match = namedtuple('Match', 'first second percentage')
|
||||
# Writing docstrings in a namedtuple is tricky. From Python 3.3, it's possible to set __doc__, but
|
||||
# some research allowed me to find a more elegant solution, which is what is done here. See
|
||||
# http://stackoverflow.com/questions/1606436/adding-docstrings-to-namedtuples-in-python
|
||||
|
||||
class Match(namedtuple('Match', 'first second percentage')):
|
||||
"""Represents a match between two :class:`~core.fs.File`.
|
||||
|
||||
Regarless of the matching method, when two files are determined to match, a Match pair is created,
|
||||
which holds, of course, the two matched files, but also their match "level".
|
||||
|
||||
.. attribute:: first
|
||||
|
||||
first file of the pair.
|
||||
|
||||
.. attribute:: second
|
||||
|
||||
second file of the pair.
|
||||
|
||||
.. attribute:: percentage
|
||||
|
||||
their match level according to the scan method which found the match. int from 1 to 100. For
|
||||
exact scan methods, such as Contents scans, this will always be 100.
|
||||
"""
|
||||
__slots__ = ()
|
||||
|
||||
def get_match(first, second, flags=()):
|
||||
#it is assumed here that first and second both have a "words" attribute
|
||||
percentage = compare(first.words, second.words, flags)
|
||||
return Match(first, second, percentage)
|
||||
|
||||
def getmatches(objects, min_match_percentage=0, match_similar_words=False, weight_words=False,
|
||||
def getmatches(
|
||||
objects, min_match_percentage=0, match_similar_words=False, weight_words=False,
|
||||
no_field_order=False, j=job.nulljob):
|
||||
"""Returns a list of :class:`Match` within ``objects`` after fuzzily matching their words.
|
||||
|
||||
:param objects: List of :class:`~core.fs.File` to match.
|
||||
:param int min_match_percentage: minimum % of words that have to match.
|
||||
:param bool match_similar_words: make similar words (see :func:`merge_similar_words`) match.
|
||||
:param bool weight_words: longer words are worth more in match % computations.
|
||||
:param bool no_field_order: match :ref:`fields` regardless of their order.
|
||||
:param j: A :ref:`job progress instance <jobs>`.
|
||||
"""
|
||||
COMMON_WORD_THRESHOLD = 50
|
||||
LIMIT = 5000000
|
||||
j = j.start_subjob(2)
|
||||
@@ -203,6 +245,14 @@ def getmatches(objects, min_match_percentage=0, match_similar_words=False, weigh
|
||||
return result
|
||||
|
||||
def getmatches_by_contents(files, sizeattr='size', partial=False, j=job.nulljob):
|
||||
"""Returns a list of :class:`Match` within ``files`` if their contents is the same.
|
||||
|
||||
:param str sizeattr: attibute name of the :class:`~core.fs.file` that returns the size of the
|
||||
file to use for comparison.
|
||||
:param bool partial: if true, will use the "md5partial" attribute instead of "md5" to compute
|
||||
contents hash.
|
||||
:param j: A :ref:`job progress instance <jobs>`.
|
||||
"""
|
||||
j = j.start_subjob([2, 8])
|
||||
size2files = defaultdict(set)
|
||||
for file in j.iter_with_progress(files, tr("Read size of %d/%d files")):
|
||||
@@ -224,6 +274,32 @@ def getmatches_by_contents(files, sizeattr='size', partial=False, j=job.nulljob)
|
||||
return result
|
||||
|
||||
class Group:
|
||||
"""A group of :class:`~core.fs.File` that match together.
|
||||
|
||||
This manages match pairs into groups and ensures that all files in the group match to each
|
||||
other.
|
||||
|
||||
.. attribute:: ref
|
||||
|
||||
The "reference" file, which is the file among the group that isn't going to be deleted.
|
||||
|
||||
.. attribute:: ordered
|
||||
|
||||
Ordered list of duplicates in the group (including the :attr:`ref`).
|
||||
|
||||
.. attribute:: unordered
|
||||
|
||||
Set duplicates in the group (including the :attr:`ref`).
|
||||
|
||||
.. attribute:: dupes
|
||||
|
||||
An ordered list of the group's duplicate, without :attr:`ref`. Equivalent to
|
||||
``ordered[1:]``
|
||||
|
||||
.. attribute:: percentage
|
||||
|
||||
Average match percentage of match pairs containing :attr:`ref`.
|
||||
"""
|
||||
#---Override
|
||||
def __init__(self):
|
||||
self._clear()
|
||||
@@ -257,6 +333,15 @@ class Group:
|
||||
|
||||
#---Public
|
||||
def add_match(self, match):
|
||||
"""Adds ``match`` to internal match list and possibly add duplicates to the group.
|
||||
|
||||
A duplicate can only be considered as such if it matches all other duplicates in the group.
|
||||
This method registers that pair (A, B) represented in ``match`` as possible candidates and,
|
||||
if A and/or B end up matching every other duplicates in the group, add these duplicates to
|
||||
the group.
|
||||
|
||||
:param tuple match: pair of :class:`~core.fs.File` to add
|
||||
"""
|
||||
def add_candidate(item, match):
|
||||
matches = self.candidates[item]
|
||||
matches.add(match)
|
||||
@@ -276,12 +361,18 @@ class Group:
|
||||
self._matches_for_ref = None
|
||||
|
||||
def discard_matches(self):
|
||||
"""Remove all recorded matches that didn't result in a duplicate being added to the group.
|
||||
|
||||
You can call this after the duplicate scanning process to free a bit of memory.
|
||||
"""
|
||||
discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second]))
|
||||
self.matches -= discarded
|
||||
self.candidates = defaultdict(set)
|
||||
return discarded
|
||||
|
||||
def get_match_of(self, item):
|
||||
"""Returns the match pair between ``item`` and :attr:`ref`.
|
||||
"""
|
||||
if item is self.ref:
|
||||
return
|
||||
for m in self._get_matches_for_ref():
|
||||
@@ -289,6 +380,12 @@ class Group:
|
||||
return m
|
||||
|
||||
def prioritize(self, key_func, tie_breaker=None):
|
||||
"""Reorders :attr:`ordered` according to ``key_func``.
|
||||
|
||||
:param key_func: Key (f(x)) to be used for sorting
|
||||
:param tie_breaker: function to be used to select the reference position in case the top
|
||||
duplicates have the same key_func() result.
|
||||
"""
|
||||
# tie_breaker(ref, dupe) --> True if dupe should be ref
|
||||
# Returns True if anything changed during prioritization.
|
||||
master_key_func = lambda x: (-x.is_ref, key_func(x))
|
||||
@@ -324,6 +421,8 @@ class Group:
|
||||
pass
|
||||
|
||||
def switch_ref(self, with_dupe):
|
||||
"""Make the :attr:`ref` dupe of the group switch position with ``with_dupe``.
|
||||
"""
|
||||
if self.ref.is_ref:
|
||||
return False
|
||||
try:
|
||||
@@ -354,6 +453,10 @@ class Group:
|
||||
|
||||
|
||||
def get_groups(matches, j=job.nulljob):
|
||||
"""Returns a list of :class:`Group` from ``matches``.
|
||||
|
||||
Create groups out of match pairs in the smartest way possible.
|
||||
"""
|
||||
matches.sort(key=lambda match: -match.percentage)
|
||||
dupe2group = {}
|
||||
groups = []
|
||||
|
||||
52
core/fs.py
52
core/fs.py
@@ -16,6 +16,18 @@ import logging
|
||||
|
||||
from hscommon.util import nonone, get_file_ext
|
||||
|
||||
__all__ = [
|
||||
'File',
|
||||
'Folder',
|
||||
'get_file',
|
||||
'get_files',
|
||||
'FSError',
|
||||
'AlreadyExistsError',
|
||||
'InvalidPath',
|
||||
'InvalidDestinationError',
|
||||
'OperationError',
|
||||
]
|
||||
|
||||
NOT_SET = object()
|
||||
|
||||
class FSError(Exception):
|
||||
@@ -50,6 +62,8 @@ class OperationError(FSError):
|
||||
cls_message = "Operation on '{name}' failed."
|
||||
|
||||
class File:
|
||||
"""Represents a file and holds metadata to be used for scanning.
|
||||
"""
|
||||
INITIAL_INFO = {
|
||||
'size': 0,
|
||||
'mtime': 0,
|
||||
@@ -129,14 +143,16 @@ class File:
|
||||
#--- Public
|
||||
@classmethod
|
||||
def can_handle(cls, path):
|
||||
"""Returns whether this file wrapper class can handle ``path``.
|
||||
"""
|
||||
return not path.islink() and path.isfile()
|
||||
|
||||
def rename(self, newname):
|
||||
if newname == self.name:
|
||||
return
|
||||
destpath = self.path[:-1] + newname
|
||||
destpath = self.path.parent()[newname]
|
||||
if destpath.exists():
|
||||
raise AlreadyExistsError(newname, self.path[:-1])
|
||||
raise AlreadyExistsError(newname, self.path.parent())
|
||||
try:
|
||||
self.path.rename(destpath)
|
||||
except EnvironmentError:
|
||||
@@ -157,11 +173,11 @@ class File:
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.path[-1]
|
||||
return self.path.name
|
||||
|
||||
@property
|
||||
def folder_path(self):
|
||||
return self.path[:-1]
|
||||
return self.path.parent()
|
||||
|
||||
|
||||
class Folder(File):
|
||||
@@ -203,9 +219,8 @@ class Folder(File):
|
||||
@property
|
||||
def subfolders(self):
|
||||
if self._subfolders is None:
|
||||
subpaths = [self.path + name for name in self.path.listdir()]
|
||||
subfolders = [p for p in subpaths if not p.islink() and p.isdir()]
|
||||
self._subfolders = [Folder(p) for p in subfolders]
|
||||
subfolders = [p for p in self.path.listdir() if not p.islink() and p.isdir()]
|
||||
self._subfolders = [self.__class__(p) for p in subfolders]
|
||||
return self._subfolders
|
||||
|
||||
@classmethod
|
||||
@@ -214,24 +229,27 @@ class Folder(File):
|
||||
|
||||
|
||||
def get_file(path, fileclasses=[File]):
|
||||
"""Wraps ``path`` around its appropriate :class:`File` class.
|
||||
|
||||
Whether a class is "appropriate" is decided by :meth:`File.can_handle`
|
||||
|
||||
:param Path path: path to wrap
|
||||
:param fileclasses: List of candidate :class:`File` classes
|
||||
"""
|
||||
for fileclass in fileclasses:
|
||||
if fileclass.can_handle(path):
|
||||
return fileclass(path)
|
||||
|
||||
def get_files(path, fileclasses=[File]):
|
||||
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
|
||||
"""Returns a list of :class:`File` for each file contained in ``path``.
|
||||
|
||||
:param Path path: path to scan
|
||||
:param fileclasses: List of candidate :class:`File` classes
|
||||
"""
|
||||
assert all(issubclass(fileclass, File) for fileclass in fileclasses)
|
||||
try:
|
||||
paths = [combine_paths(path, name) for name in path.listdir()]
|
||||
result = []
|
||||
for path in paths:
|
||||
for path in path.listdir():
|
||||
file = get_file(path, fileclasses=fileclasses)
|
||||
if file is not None:
|
||||
result.append(file)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Meta GUI elements in dupeGuru
|
||||
-----------------------------
|
||||
|
||||
dupeGuru is designed with a `cross-toolkit`_ approach in mind. It means that its core code
|
||||
(which doesn't depend on any GUI toolkit) has elements which preformat core information in a way
|
||||
that makes it easy for a UI layer to consume.
|
||||
|
||||
For example, we have :class:`~core.gui.ResultTable` which takes information from
|
||||
:class:`~core.results.Results` and mashes it in rows and columns which are ready to be fetched by
|
||||
either Cocoa's ``NSTableView`` or Qt's ``QTableView``. It tells them which cell is supposed to be
|
||||
blue, which is supposed to be orange, does the sorting logic, holds selection, etc..
|
||||
|
||||
.. _cross-toolkit: http://www.hardcoded.net/articles/cross-toolkit-software
|
||||
"""
|
||||
@@ -10,14 +10,60 @@ import os
|
||||
from hscommon.gui.base import GUIObject
|
||||
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):
|
||||
#--- View interface
|
||||
# update_msg(msg: str)
|
||||
# show()
|
||||
#
|
||||
"""Present the user with deletion options before proceeding.
|
||||
|
||||
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):
|
||||
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.direct = False
|
||||
msg = tr("You are sending {} file(s) to the Trash.").format(mark_count)
|
||||
@@ -25,6 +71,8 @@ class DeletionOptions(GUIObject):
|
||||
return self.view.show()
|
||||
|
||||
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
|
||||
# of arguments) raises NotImplementedError, which allows us to gracefully check for the
|
||||
# feature.
|
||||
@@ -40,3 +88,20 @@ class DeletionOptions(GUIObject):
|
||||
# wrong number of arguments
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class DirectoryNode(Node):
|
||||
self.clear()
|
||||
subpaths = self._tree.app.directories.get_subfolders(self._directory_path)
|
||||
for path in subpaths:
|
||||
self.append(DirectoryNode(self._tree, path, path[-1]))
|
||||
self.append(DirectoryNode(self._tree, path, path.name))
|
||||
self._loaded = True
|
||||
|
||||
def update_all_states(self):
|
||||
@@ -91,6 +91,10 @@ class DirectoryTree(Tree, DupeGuruGUIObject):
|
||||
for node in nodes:
|
||||
node.state = newstate
|
||||
|
||||
def select_all(self):
|
||||
self.selected_nodes = list(self)
|
||||
self.view.refresh()
|
||||
|
||||
def update_all_states(self):
|
||||
for node in self:
|
||||
node.update_all_states()
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.hardcoded.net/licenses/bsd_license
|
||||
|
||||
from hscommon import desktop
|
||||
|
||||
from .problem_table import ProblemTable
|
||||
|
||||
class ProblemDialog:
|
||||
@@ -20,7 +22,7 @@ class ProblemDialog:
|
||||
|
||||
def reveal_selected_dupe(self):
|
||||
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):
|
||||
self._selected_dupe = dupe
|
||||
|
||||
@@ -42,7 +42,7 @@ class DupeRow(Row):
|
||||
dupe_info = self.data
|
||||
ref_info = self._group.ref.get_display_info(group=self._group, delta=False)
|
||||
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)
|
||||
return column_name in self._delta_columns
|
||||
|
||||
|
||||
@@ -21,6 +21,19 @@ from . import engine
|
||||
from .markable import Markable
|
||||
|
||||
class Results(Markable):
|
||||
"""Manages a collection of duplicate :class:`~core.engine.Group`.
|
||||
|
||||
This class takes care or marking, sorting and filtering duplicate groups.
|
||||
|
||||
.. attribute:: groups
|
||||
|
||||
The list of :class:`~core.engine.Group` contained managed by this instance.
|
||||
|
||||
.. attribute:: dupes
|
||||
|
||||
A list of all duplicates (:class:`~core.fs.File` instances), without ref, contained in the
|
||||
currently managed :attr:`groups`.
|
||||
"""
|
||||
#---Override
|
||||
def __init__(self, app):
|
||||
Markable.__init__(self)
|
||||
@@ -145,17 +158,17 @@ class Results(Markable):
|
||||
|
||||
#---Public
|
||||
def apply_filter(self, filter_str):
|
||||
''' Applies a filter 'filter_str' to self.groups
|
||||
"""Applies a filter ``filter_str`` to :attr:`groups`
|
||||
|
||||
When you apply the filter, only dupes with the filename matching 'filter_str' will be in
|
||||
in the results. To cancel the filter, just call apply_filter with 'filter_str' to None,
|
||||
When you apply the filter, only dupes with the filename matching ``filter_str`` will be in
|
||||
in the results. To cancel the filter, just call apply_filter with ``filter_str`` to None,
|
||||
and the results will go back to normal.
|
||||
|
||||
If call apply_filter on a filtered results, the filter will be applied
|
||||
*on the filtered results*.
|
||||
|
||||
'filter_str' is a string containing a regexp to filter dupes with.
|
||||
'''
|
||||
:param str filter_str: a string containing a regexp to filter dupes with.
|
||||
"""
|
||||
if not filter_str:
|
||||
self.__filtered_dupes = None
|
||||
self.__filtered_groups = None
|
||||
@@ -182,6 +195,8 @@ class Results(Markable):
|
||||
self.__dupes = None
|
||||
|
||||
def get_group_of_duplicate(self, dupe):
|
||||
"""Returns :class:`~core.engine.Group` in which ``dupe`` belongs.
|
||||
"""
|
||||
try:
|
||||
return self.__group_of_duplicate[dupe]
|
||||
except (TypeError, KeyError):
|
||||
@@ -190,6 +205,12 @@ class Results(Markable):
|
||||
is_markable = _is_markable
|
||||
|
||||
def load_from_xml(self, infile, get_file, j=nulljob):
|
||||
"""Load results from ``infile``.
|
||||
|
||||
:param infile: a file or path pointing to an XML file created with :meth:`save_to_xml`.
|
||||
:param get_file: a function f(path) returning a :class:`~core.fs.File` wrapping the path.
|
||||
:param j: A :ref:`job progress instance <jobs>`.
|
||||
"""
|
||||
def do_match(ref_file, other_files, group):
|
||||
if not other_files:
|
||||
return
|
||||
@@ -242,6 +263,8 @@ class Results(Markable):
|
||||
self.is_modified = False
|
||||
|
||||
def make_ref(self, dupe):
|
||||
"""Make ``dupe`` take the :attr:`~core.engine.Group.ref` position of its group.
|
||||
"""
|
||||
g = self.get_group_of_duplicate(dupe)
|
||||
r = g.ref
|
||||
if not g.switch_ref(dupe):
|
||||
@@ -258,8 +281,14 @@ class Results(Markable):
|
||||
return True
|
||||
|
||||
def perform_on_marked(self, func, remove_from_results):
|
||||
# Performs `func` on all marked dupes. If an EnvironmentError is raised during the call,
|
||||
# the problematic dupe is added to self.problems.
|
||||
"""Performs ``func`` on all marked dupes.
|
||||
|
||||
If an ``EnvironmentError`` is raised during the call, the problematic dupe is added to
|
||||
self.problems.
|
||||
|
||||
:param bool remove_from_results: If true, dupes which had ``func`` applied and didn't cause
|
||||
any problem.
|
||||
"""
|
||||
self.problems = []
|
||||
to_remove = []
|
||||
marked = (dupe for dupe in self.dupes if self.is_marked(dupe))
|
||||
@@ -276,8 +305,10 @@ class Results(Markable):
|
||||
self.mark(dupe)
|
||||
|
||||
def remove_duplicates(self, dupes):
|
||||
'''Remove 'dupes' from their respective group, and remove the group is it ends up empty.
|
||||
'''
|
||||
"""Remove ``dupes`` from their respective :class:`~core.engine.Group`.
|
||||
|
||||
Also, remove the group from :attr:`groups` if it ends up empty.
|
||||
"""
|
||||
affected_groups = set()
|
||||
for dupe in dupes:
|
||||
group = self.get_group_of_duplicate(dupe)
|
||||
@@ -302,9 +333,12 @@ class Results(Markable):
|
||||
self.is_modified = bool(self.__groups)
|
||||
|
||||
def save_to_xml(self, outfile):
|
||||
"""Save results to ``outfile`` in XML.
|
||||
|
||||
:param outfile: file object or path.
|
||||
"""
|
||||
self.apply_filter(None)
|
||||
root = ET.Element('results')
|
||||
# writer = XMLGenerator(outfile, 'utf-8')
|
||||
for g in self.groups:
|
||||
group_elem = ET.SubElement(root, 'group')
|
||||
dupe2index = {}
|
||||
@@ -349,6 +383,12 @@ class Results(Markable):
|
||||
self.is_modified = False
|
||||
|
||||
def sort_dupes(self, key, asc=True, delta=False):
|
||||
"""Sort :attr:`dupes` according to ``key``.
|
||||
|
||||
:param str key: key attribute name to sort with.
|
||||
:param bool asc: If false, sorting is reversed.
|
||||
:param bool delta: If true, sorting occurs using :ref:`delta values <deltavalues>`.
|
||||
"""
|
||||
if not self.__dupes:
|
||||
self.__get_dupe_list()
|
||||
keyfunc = lambda d: self.app._get_dupe_sort_key(d, lambda: self.get_group_of_duplicate(d), key, delta)
|
||||
@@ -356,6 +396,13 @@ class Results(Markable):
|
||||
self.__dupes_sort_descriptor = (key,asc,delta)
|
||||
|
||||
def sort_groups(self, key, asc=True):
|
||||
"""Sort :attr:`groups` according to ``key``.
|
||||
|
||||
The :attr:`~core.engine.Group.ref` of each group is used to extract values for sorting.
|
||||
|
||||
:param str key: key attribute name to sort with.
|
||||
:param bool asc: If false, sorting is reversed.
|
||||
"""
|
||||
keyfunc = lambda g: self.app._get_group_sort_key(g, key)
|
||||
self.groups.sort(key=keyfunc, reverse=not asc)
|
||||
self.__groups_sort_descriptor = (key,asc)
|
||||
|
||||
@@ -11,7 +11,6 @@ import os.path as op
|
||||
import logging
|
||||
|
||||
from pytest import mark
|
||||
from hscommon import io
|
||||
from hscommon.path import Path
|
||||
import hscommon.conflict
|
||||
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
|
||||
# every change I want to make. The blowup was caused by a missing import.
|
||||
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))
|
||||
# XXX This monkeypatch is temporary. will be fixed in a better monkeypatcher.
|
||||
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):
|
||||
tmppath = Path(str(tmpdir))
|
||||
sourcepath = tmppath + 'source'
|
||||
io.mkdir(sourcepath)
|
||||
io.open(sourcepath + 'myfile', 'w')
|
||||
sourcepath = tmppath['source']
|
||||
sourcepath.mkdir()
|
||||
sourcepath['myfile'].open('w')
|
||||
app = TestApp().app
|
||||
app.directories.add_path(tmppath)
|
||||
[myfile] = app.directories.get_files()
|
||||
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
|
||||
eq_(1, len(calls))
|
||||
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
|
||||
# inode.
|
||||
tmppath = Path(str(tmpdir))
|
||||
io.open(tmppath + 'myfile', 'w').write('foo')
|
||||
os.link(str(tmppath + 'myfile'), str(tmppath + 'hardlink'))
|
||||
tmppath['myfile'].open('w').write('foo')
|
||||
os.link(str(tmppath['myfile']), str(tmppath['hardlink']))
|
||||
app = TestApp().app
|
||||
app.directories.add_path(tmppath)
|
||||
app.scanner.scan_type = ScanType.Contents
|
||||
@@ -171,8 +170,8 @@ class TestCaseDupeGuruWithResults:
|
||||
self.rtable.refresh()
|
||||
tmpdir = request.getfuncargvalue('tmpdir')
|
||||
tmppath = Path(str(tmpdir))
|
||||
io.mkdir(tmppath + 'foo')
|
||||
io.mkdir(tmppath + 'bar')
|
||||
tmppath['foo'].mkdir()
|
||||
tmppath['bar'].mkdir()
|
||||
self.app.directories.add_path(tmppath)
|
||||
|
||||
def test_GetObjects(self, do_setup):
|
||||
@@ -400,15 +399,28 @@ class TestCaseDupeGuruWithResults:
|
||||
eq_(len(self.rtable), 0)
|
||||
eq_(app.selected_dupes, [])
|
||||
|
||||
def test_dont_crash_on_delta_powermarker_dupecount_sort(self, do_setup):
|
||||
# Don't crash when sorting by dupe count or percentage while delta+powermarker are enabled.
|
||||
# Ref #238
|
||||
app = self.app
|
||||
objects = self.objects
|
||||
self.rtable.delta_values = True
|
||||
self.rtable.power_marker = True
|
||||
self.rtable.sort('dupe_count', False)
|
||||
# don't crash
|
||||
self.rtable.sort('percentage', False)
|
||||
# don't crash
|
||||
|
||||
|
||||
class TestCaseDupeGuru_renameSelected:
|
||||
def pytest_funcarg__do_setup(self, request):
|
||||
tmpdir = request.getfuncargvalue('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 = open(str(p + 'foo bar 2'),mode='w')
|
||||
fp = open(str(p['foo bar 2']),mode='w')
|
||||
fp.close()
|
||||
fp = open(str(p + 'foo bar 3'),mode='w')
|
||||
fp = open(str(p['foo bar 3']),mode='w')
|
||||
fp.close()
|
||||
files = fs.get_files(p)
|
||||
for f in files:
|
||||
@@ -431,7 +443,7 @@ class TestCaseDupeGuru_renameSelected:
|
||||
g = self.groups[0]
|
||||
self.rtable.select([1])
|
||||
assert app.rename_selected('renamed')
|
||||
names = io.listdir(self.p)
|
||||
names = [p.name for p in self.p.listdir()]
|
||||
assert 'renamed' in names
|
||||
assert 'foo bar 2' not in names
|
||||
eq_(g.dupes[0].name, 'renamed')
|
||||
@@ -444,7 +456,7 @@ class TestCaseDupeGuru_renameSelected:
|
||||
assert not app.rename_selected('renamed')
|
||||
msg = logging.warning.calls[0]['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 'foo bar 2' in names
|
||||
eq_(g.dupes[0].name, 'foo bar 2')
|
||||
@@ -457,7 +469,7 @@ class TestCaseDupeGuru_renameSelected:
|
||||
assert not app.rename_selected('foo bar 1')
|
||||
msg = logging.warning.calls[0]['msg']
|
||||
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 2' in names
|
||||
eq_(g.dupes[0].name, 'foo bar 2')
|
||||
@@ -467,9 +479,9 @@ class TestAppWithDirectoriesInTree:
|
||||
def pytest_funcarg__do_setup(self, request):
|
||||
tmpdir = request.getfuncargvalue('tmpdir')
|
||||
p = Path(str(tmpdir))
|
||||
io.mkdir(p + 'sub1')
|
||||
io.mkdir(p + 'sub2')
|
||||
io.mkdir(p + 'sub3')
|
||||
p['sub1'].mkdir()
|
||||
p['sub2'].mkdir()
|
||||
p['sub3'].mkdir()
|
||||
app = TestApp()
|
||||
self.app = app.app
|
||||
self.dtree = app.dtree
|
||||
|
||||
@@ -57,10 +57,12 @@ class ResultTable(ResultTableBase):
|
||||
DELTA_COLUMNS = {'size', }
|
||||
|
||||
class DupeGuru(DupeGuruBase):
|
||||
NAME = 'dupeGuru'
|
||||
METADATA_TO_READ = ['size']
|
||||
|
||||
def __init__(self):
|
||||
DupeGuruBase.__init__(self, DupeGuruView(), '/tmp')
|
||||
DupeGuruBase.__init__(self, DupeGuruView())
|
||||
self.appdata = '/tmp'
|
||||
|
||||
def _prioritization_categories(self):
|
||||
return prioritize.all_categories()
|
||||
@@ -100,11 +102,11 @@ class NamedObject:
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return self._folder + self.name
|
||||
return self._folder[self.name]
|
||||
|
||||
@property
|
||||
def folder_path(self):
|
||||
return self.path[:-1]
|
||||
return self.path.parent()
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
|
||||
@@ -12,7 +12,6 @@ import tempfile
|
||||
import shutil
|
||||
|
||||
from pytest import raises
|
||||
from hscommon import io
|
||||
from hscommon.path import Path
|
||||
from hscommon.testutil import eq_
|
||||
|
||||
@@ -20,27 +19,27 @@ from ..directories import *
|
||||
|
||||
def create_fake_fs(rootpath):
|
||||
# We have it as a separate function because other units are using it.
|
||||
rootpath = rootpath + 'fs'
|
||||
io.mkdir(rootpath)
|
||||
io.mkdir(rootpath + 'dir1')
|
||||
io.mkdir(rootpath + 'dir2')
|
||||
io.mkdir(rootpath + 'dir3')
|
||||
fp = io.open(rootpath + 'file1.test', 'w')
|
||||
rootpath = rootpath['fs']
|
||||
rootpath.mkdir()
|
||||
rootpath['dir1'].mkdir()
|
||||
rootpath['dir2'].mkdir()
|
||||
rootpath['dir3'].mkdir()
|
||||
fp = rootpath['file1.test'].open('w')
|
||||
fp.write('1')
|
||||
fp.close()
|
||||
fp = io.open(rootpath + 'file2.test', 'w')
|
||||
fp = rootpath['file2.test'].open('w')
|
||||
fp.write('12')
|
||||
fp.close()
|
||||
fp = io.open(rootpath + 'file3.test', 'w')
|
||||
fp = rootpath['file3.test'].open('w')
|
||||
fp.write('123')
|
||||
fp.close()
|
||||
fp = io.open(rootpath + ('dir1', 'file1.test'), 'w')
|
||||
fp = rootpath['dir1']['file1.test'].open('w')
|
||||
fp.write('1')
|
||||
fp.close()
|
||||
fp = io.open(rootpath + ('dir2', 'file2.test'), 'w')
|
||||
fp = rootpath['dir2']['file2.test'].open('w')
|
||||
fp.write('12')
|
||||
fp.close()
|
||||
fp = io.open(rootpath + ('dir3', 'file3.test'), 'w')
|
||||
fp = rootpath['dir3']['file3.test'].open('w')
|
||||
fp.write('123')
|
||||
fp.close()
|
||||
return rootpath
|
||||
@@ -50,9 +49,9 @@ def setup_module(module):
|
||||
# and another with a more complex structure.
|
||||
testpath = Path(tempfile.mkdtemp())
|
||||
module.testpath = testpath
|
||||
rootpath = testpath + 'onefile'
|
||||
io.mkdir(rootpath)
|
||||
fp = io.open(rootpath + 'test.txt', 'w')
|
||||
rootpath = testpath['onefile']
|
||||
rootpath.mkdir()
|
||||
fp = rootpath['test.txt'].open('w')
|
||||
fp.write('test_data')
|
||||
fp.close()
|
||||
create_fake_fs(testpath)
|
||||
@@ -67,30 +66,30 @@ def test_empty():
|
||||
|
||||
def test_add_path():
|
||||
d = Directories()
|
||||
p = testpath + 'onefile'
|
||||
p = testpath['onefile']
|
||||
d.add_path(p)
|
||||
eq_(1,len(d))
|
||||
assert p in d
|
||||
assert (p + 'foobar') in d
|
||||
assert p[:-1] not in d
|
||||
p = testpath + 'fs'
|
||||
assert (p['foobar']) in d
|
||||
assert p.parent() not in d
|
||||
p = testpath['fs']
|
||||
d.add_path(p)
|
||||
eq_(2,len(d))
|
||||
assert p in d
|
||||
|
||||
def test_AddPath_when_path_is_already_there():
|
||||
d = Directories()
|
||||
p = testpath + 'onefile'
|
||||
p = testpath['onefile']
|
||||
d.add_path(p)
|
||||
with raises(AlreadyThereError):
|
||||
d.add_path(p)
|
||||
with raises(AlreadyThereError):
|
||||
d.add_path(p + 'foobar')
|
||||
d.add_path(p['foobar'])
|
||||
eq_(1, len(d))
|
||||
|
||||
def test_add_path_containing_paths_already_there():
|
||||
d = Directories()
|
||||
d.add_path(testpath + 'onefile')
|
||||
d.add_path(testpath['onefile'])
|
||||
eq_(1, len(d))
|
||||
d.add_path(testpath)
|
||||
eq_(len(d), 1)
|
||||
@@ -98,7 +97,7 @@ def test_add_path_containing_paths_already_there():
|
||||
|
||||
def test_AddPath_non_latin(tmpdir):
|
||||
p = Path(str(tmpdir))
|
||||
to_add = p + 'unicode\u201a'
|
||||
to_add = p['unicode\u201a']
|
||||
os.mkdir(str(to_add))
|
||||
d = Directories()
|
||||
try:
|
||||
@@ -108,24 +107,24 @@ def test_AddPath_non_latin(tmpdir):
|
||||
|
||||
def test_del():
|
||||
d = Directories()
|
||||
d.add_path(testpath + 'onefile')
|
||||
d.add_path(testpath['onefile'])
|
||||
try:
|
||||
del d[1]
|
||||
assert False
|
||||
except IndexError:
|
||||
pass
|
||||
d.add_path(testpath + 'fs')
|
||||
d.add_path(testpath['fs'])
|
||||
del d[1]
|
||||
eq_(1, len(d))
|
||||
|
||||
def test_states():
|
||||
d = Directories()
|
||||
p = testpath + 'onefile'
|
||||
p = testpath['onefile']
|
||||
d.add_path(p)
|
||||
eq_(DirectoryState.Normal ,d.get_state(p))
|
||||
d.set_state(p, DirectoryState.Reference)
|
||||
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_(p,list(d.states.keys())[0])
|
||||
eq_(DirectoryState.Reference ,d.states[p])
|
||||
@@ -133,67 +132,67 @@ def test_states():
|
||||
def test_get_state_with_path_not_there():
|
||||
# When the path's not there, just return DirectoryState.Normal
|
||||
d = Directories()
|
||||
d.add_path(testpath + 'onefile')
|
||||
d.add_path(testpath['onefile'])
|
||||
eq_(d.get_state(testpath), DirectoryState.Normal)
|
||||
|
||||
def test_states_remain_when_larger_directory_eat_smaller_ones():
|
||||
d = Directories()
|
||||
p = testpath + 'onefile'
|
||||
p = testpath['onefile']
|
||||
d.add_path(p)
|
||||
d.set_state(p, DirectoryState.Excluded)
|
||||
d.add_path(testpath)
|
||||
d.set_state(testpath, DirectoryState.Reference)
|
||||
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))
|
||||
|
||||
def test_set_state_keep_state_dict_size_to_minimum():
|
||||
d = Directories()
|
||||
p = testpath + 'fs'
|
||||
p = testpath['fs']
|
||||
d.add_path(p)
|
||||
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_(DirectoryState.Reference ,d.get_state(p + 'dir1'))
|
||||
d.set_state(p + 'dir1', DirectoryState.Normal)
|
||||
eq_(DirectoryState.Reference ,d.get_state(p['dir1']))
|
||||
d.set_state(p['dir1'], DirectoryState.Normal)
|
||||
eq_(2,len(d.states))
|
||||
eq_(DirectoryState.Normal ,d.get_state(p + 'dir1'))
|
||||
d.set_state(p + 'dir1', DirectoryState.Reference)
|
||||
eq_(DirectoryState.Normal ,d.get_state(p['dir1']))
|
||||
d.set_state(p['dir1'], DirectoryState.Reference)
|
||||
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():
|
||||
d = Directories()
|
||||
p = testpath + 'fs'
|
||||
p = testpath['fs']
|
||||
d.add_path(p)
|
||||
d.set_state(p + 'dir1', DirectoryState.Reference)
|
||||
d.set_state(p + 'dir2', DirectoryState.Excluded)
|
||||
d.set_state(p['dir1'], DirectoryState.Reference)
|
||||
d.set_state(p['dir2'], DirectoryState.Excluded)
|
||||
files = list(d.get_files())
|
||||
eq_(5, len(files))
|
||||
for f in files:
|
||||
if f.path[:-1] == p + 'dir1':
|
||||
if f.path.parent() == p['dir1']:
|
||||
assert f.is_ref
|
||||
else:
|
||||
assert not f.is_ref
|
||||
|
||||
def test_get_folders():
|
||||
d = Directories()
|
||||
p = testpath + 'fs'
|
||||
p = testpath['fs']
|
||||
d.add_path(p)
|
||||
d.set_state(p + 'dir1', DirectoryState.Reference)
|
||||
d.set_state(p + 'dir2', DirectoryState.Excluded)
|
||||
d.set_state(p['dir1'], DirectoryState.Reference)
|
||||
d.set_state(p['dir2'], DirectoryState.Excluded)
|
||||
folders = list(d.get_folders())
|
||||
eq_(len(folders), 3)
|
||||
ref = [f for f in folders if f.is_ref]
|
||||
not_ref = [f for f in folders if not f.is_ref]
|
||||
eq_(len(ref), 1)
|
||||
eq_(ref[0].path, p + 'dir1')
|
||||
eq_(ref[0].path, p['dir1'])
|
||||
eq_(len(not_ref), 2)
|
||||
eq_(ref[0].size, 1)
|
||||
|
||||
def test_get_files_with_inherited_exclusion():
|
||||
d = Directories()
|
||||
p = testpath + 'onefile'
|
||||
p = testpath['onefile']
|
||||
d.add_path(p)
|
||||
d.set_state(p, DirectoryState.Excluded)
|
||||
eq_([], list(d.get_files()))
|
||||
@@ -202,19 +201,19 @@ def test_save_and_load(tmpdir):
|
||||
d1 = Directories()
|
||||
d2 = Directories()
|
||||
p1 = Path(str(tmpdir.join('p1')))
|
||||
io.mkdir(p1)
|
||||
p1.mkdir()
|
||||
p2 = Path(str(tmpdir.join('p2')))
|
||||
io.mkdir(p2)
|
||||
p2.mkdir()
|
||||
d1.add_path(p1)
|
||||
d1.add_path(p2)
|
||||
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'))
|
||||
d1.save_to_file(tmpxml)
|
||||
d2.load_from_file(tmpxml)
|
||||
eq_(2, len(d2))
|
||||
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():
|
||||
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
|
||||
#InvalidPath raise. Other directories must be loaded.
|
||||
d1 = Directories()
|
||||
d1.add_path(testpath + 'onefile')
|
||||
d1.add_path(testpath['onefile'])
|
||||
#Will raise InvalidPath upon loading
|
||||
p = Path(str(tmpdir.join('toremove')))
|
||||
io.mkdir(p)
|
||||
p.mkdir()
|
||||
d1.add_path(p)
|
||||
io.rmdir(p)
|
||||
p.rmdir()
|
||||
tmpxml = str(tmpdir.join('directories_testunit.xml'))
|
||||
d1.save_to_file(tmpxml)
|
||||
d2 = Directories()
|
||||
@@ -248,11 +247,11 @@ def test_load_from_file_with_invalid_path(tmpdir):
|
||||
|
||||
def test_unicode_save(tmpdir):
|
||||
d = Directories()
|
||||
p1 = Path(str(tmpdir)) + 'hello\xe9'
|
||||
io.mkdir(p1)
|
||||
io.mkdir(p1 + 'foo\xe9')
|
||||
p1 = Path(str(tmpdir))['hello\xe9']
|
||||
p1.mkdir()
|
||||
p1['foo\xe9'].mkdir()
|
||||
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'))
|
||||
try:
|
||||
d.save_to_file(tmpxml)
|
||||
@@ -261,12 +260,12 @@ def test_unicode_save(tmpdir):
|
||||
|
||||
def test_get_files_refreshes_its_directories():
|
||||
d = Directories()
|
||||
p = testpath + 'fs'
|
||||
p = testpath['fs']
|
||||
d.add_path(p)
|
||||
files = d.get_files()
|
||||
eq_(6, len(list(files)))
|
||||
time.sleep(1)
|
||||
os.remove(str(p + ('dir1','file1.test')))
|
||||
os.remove(str(p['dir1']['file1.test']))
|
||||
files = d.get_files()
|
||||
eq_(5, len(list(files)))
|
||||
|
||||
@@ -274,14 +273,14 @@ def test_get_files_does_not_choke_on_non_existing_directories(tmpdir):
|
||||
d = Directories()
|
||||
p = Path(str(tmpdir))
|
||||
d.add_path(p)
|
||||
io.rmtree(p)
|
||||
p.rmtree()
|
||||
eq_([], list(d.get_files()))
|
||||
|
||||
def test_get_state_returns_excluded_by_default_for_hidden_directories(tmpdir):
|
||||
d = Directories()
|
||||
p = Path(str(tmpdir))
|
||||
hidden_dir_path = p + '.foo'
|
||||
io.mkdir(p + '.foo')
|
||||
hidden_dir_path = p['.foo']
|
||||
p['.foo'].mkdir()
|
||||
d.add_path(p)
|
||||
eq_(d.get_state(hidden_dir_path), DirectoryState.Excluded)
|
||||
# But it can be overriden
|
||||
@@ -297,16 +296,16 @@ def test_default_path_state_override(tmpdir):
|
||||
|
||||
d = MyDirectories()
|
||||
p1 = Path(str(tmpdir))
|
||||
io.mkdir(p1 + 'foobar')
|
||||
io.open(p1 + 'foobar/somefile', 'w').close()
|
||||
io.mkdir(p1 + 'foobaz')
|
||||
io.open(p1 + 'foobaz/somefile', 'w').close()
|
||||
p1['foobar'].mkdir()
|
||||
p1['foobar/somefile'].open('w').close()
|
||||
p1['foobaz'].mkdir()
|
||||
p1['foobaz/somefile'].open('w').close()
|
||||
d.add_path(p1)
|
||||
eq_(d.get_state(p1 + 'foobaz'), DirectoryState.Normal)
|
||||
eq_(d.get_state(p1 + 'foobar'), DirectoryState.Excluded)
|
||||
eq_(d.get_state(p1['foobaz']), DirectoryState.Normal)
|
||||
eq_(d.get_state(p1['foobar']), DirectoryState.Excluded)
|
||||
eq_(len(list(d.get_files())), 1) # only the 'foobaz' file is there
|
||||
# However, the default state can be changed
|
||||
d.set_state(p1 + 'foobar', DirectoryState.Normal)
|
||||
eq_(d.get_state(p1 + 'foobar'), DirectoryState.Normal)
|
||||
d.set_state(p1['foobar'], DirectoryState.Normal)
|
||||
eq_(d.get_state(p1['foobar']), DirectoryState.Normal)
|
||||
eq_(len(list(d.get_files())), 2)
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ def test_md5_aggregate_subfiles_sorted(tmpdir):
|
||||
#same order everytime.
|
||||
p = create_fake_fs(Path(str(tmpdir)))
|
||||
b = fs.Folder(p)
|
||||
md51 = fs.File(p + ('dir1', 'file1.test')).md5
|
||||
md52 = fs.File(p + ('dir2', 'file2.test')).md5
|
||||
md53 = fs.File(p + ('dir3', 'file3.test')).md5
|
||||
md54 = fs.File(p + 'file1.test').md5
|
||||
md55 = fs.File(p + 'file2.test').md5
|
||||
md56 = fs.File(p + 'file3.test').md5
|
||||
md51 = fs.File(p['dir1']['file1.test']).md5
|
||||
md52 = fs.File(p['dir2']['file2.test']).md5
|
||||
md53 = fs.File(p['dir3']['file3.test']).md5
|
||||
md54 = fs.File(p['file1.test']).md5
|
||||
md55 = fs.File(p['file2.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
|
||||
folder_md51 = hashlib.md5(md51).digest()
|
||||
folder_md52 = hashlib.md5(md52).digest()
|
||||
|
||||
@@ -44,3 +44,13 @@ def test_delta_flags_delta_mode_on_non_delta_columns():
|
||||
assert not app.rtable[3].is_cell_delta('name')
|
||||
# "ibabtu" == "ibabtu", flag off
|
||||
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')
|
||||
|
||||
@@ -230,6 +230,23 @@ class TestCaseResultsWithSomeGroups:
|
||||
# also remove group ref
|
||||
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:
|
||||
def setup_method(self, method):
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
# http://www.hardcoded.net/licenses/bsd_license
|
||||
|
||||
from jobprogress import job
|
||||
from hscommon import io
|
||||
from hscommon.path import Path
|
||||
from hscommon.testutil import eq_
|
||||
|
||||
@@ -21,7 +20,7 @@ class NamedObject:
|
||||
if path is None:
|
||||
path = Path(name)
|
||||
else:
|
||||
path = Path(path) + name
|
||||
path = Path(path)[name]
|
||||
self.name = name
|
||||
self.size = size
|
||||
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
|
||||
# for file existence before doing the match grouping.
|
||||
monkeypatch = request.getfuncargvalue('monkeypatch')
|
||||
monkeypatch.setattr(io, 'exists', lambda _: True)
|
||||
monkeypatch.setattr(Path, 'exists', lambda _: True)
|
||||
|
||||
def test_empty(fake_fileexists):
|
||||
@@ -471,11 +469,11 @@ def test_dont_group_files_that_dont_exist(tmpdir):
|
||||
s = Scanner()
|
||||
s.scan_type = ScanType.Contents
|
||||
p = Path(str(tmpdir))
|
||||
io.open(p + 'file1', 'w').write('foo')
|
||||
io.open(p + 'file2', 'w').write('foo')
|
||||
p['file1'].open('w').write('foo')
|
||||
p['file2'].open('w').write('foo')
|
||||
file1, file2 = fs.get_files(p)
|
||||
def getmatches(*args, **kw):
|
||||
io.remove(file2.path)
|
||||
file2.path.remove()
|
||||
return [Match(file1, file2, 100)]
|
||||
s._getmatches = getmatches
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = '6.6.0'
|
||||
__version__ = '6.7.0'
|
||||
__appname__ = 'dupeGuru Music Edition'
|
||||
@@ -16,8 +16,8 @@ class DupeGuru(DupeGuruBase):
|
||||
METADATA_TO_READ = ['size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist',
|
||||
'album', 'genre', 'year', 'track', 'comment']
|
||||
|
||||
def __init__(self, view, appdata):
|
||||
DupeGuruBase.__init__(self, view, appdata)
|
||||
def __init__(self, view):
|
||||
DupeGuruBase.__init__(self, view)
|
||||
self.scanner = scanner.ScannerME()
|
||||
self.directories.fileclasses = [fs.MusicFile]
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class MusicFile(fs.File):
|
||||
def can_handle(cls, path):
|
||||
if not fs.File.can_handle(path):
|
||||
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):
|
||||
size = self.size
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = '2.7.1'
|
||||
__version__ = '2.9.0'
|
||||
__appname__ = 'dupeGuru Picture Edition'
|
||||
@@ -18,8 +18,8 @@ class DupeGuru(DupeGuruBase):
|
||||
NAME = __appname__
|
||||
METADATA_TO_READ = ['size', 'mtime', 'dimensions', 'exif_timestamp']
|
||||
|
||||
def __init__(self, view, appdata):
|
||||
DupeGuruBase.__init__(self, view, appdata)
|
||||
def __init__(self, view):
|
||||
DupeGuruBase.__init__(self, view)
|
||||
self.scanner = ScannerPE()
|
||||
self.scanner.cache_path = op.join(self.appdata, 'cached_pictures.db')
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ pystring2cfstring(PyObject *pystring)
|
||||
}
|
||||
|
||||
s = (UInt8*)PyBytes_AS_STRING(encoded);
|
||||
size = PyUnicode_GET_SIZE(encoded);
|
||||
size = PyBytes_GET_SIZE(encoded);
|
||||
result = CFStringCreateWithBytes(NULL, s, size, kCFStringEncodingUTF8, FALSE);
|
||||
Py_DECREF(encoded);
|
||||
return result;
|
||||
@@ -113,7 +113,7 @@ MyCreateBitmapContext(int width, int height)
|
||||
}
|
||||
|
||||
context = CGBitmapContextCreate(bitmapData, width, height, 8, bitmapBytesPerRow, colorSpace,
|
||||
kCGImageAlphaNoneSkipLast);
|
||||
(CGBitmapInfo)kCGImageAlphaNoneSkipLast);
|
||||
if (context== NULL) {
|
||||
free(bitmapData);
|
||||
fprintf(stderr, "Context not created!");
|
||||
|
||||
@@ -49,9 +49,18 @@ class Photo(fs.File):
|
||||
self._cached_orientation = 0
|
||||
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
|
||||
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):
|
||||
size = self.size
|
||||
@@ -89,12 +98,7 @@ class Photo(fs.File):
|
||||
if self._get_orientation() in {5, 6, 7, 8}:
|
||||
self.dimensions = (self.dimensions[1], self.dimensions[0])
|
||||
elif field == 'exif_timestamp':
|
||||
try:
|
||||
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)
|
||||
self.exif_timestamp = self._get_exif_timestamp()
|
||||
|
||||
def get_blocks(self, block_count_per_side):
|
||||
return self._plat_get_blocks(block_count_per_side, self._get_orientation())
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = '3.7.0'
|
||||
__version__ = '3.8.0'
|
||||
__appname__ = 'dupeGuru'
|
||||
|
||||
@@ -14,9 +14,10 @@ class DupeGuru(DupeGuruBase):
|
||||
NAME = __appname__
|
||||
METADATA_TO_READ = ['size', 'mtime']
|
||||
|
||||
def __init__(self, view, appdata):
|
||||
DupeGuruBase.__init__(self, view, appdata)
|
||||
def __init__(self, view):
|
||||
DupeGuruBase.__init__(self, view)
|
||||
self.directories.fileclasses = [fs.File]
|
||||
self.directories.folderclass = fs.Folder
|
||||
|
||||
def _prioritization_categories(self):
|
||||
return prioritize.all_categories()
|
||||
|
||||
@@ -11,11 +11,10 @@ from hscommon.util import format_size
|
||||
from core import fs
|
||||
from core.app import format_timestamp, format_perc, format_words, format_dupe_count
|
||||
|
||||
class File(fs.File):
|
||||
def get_display_info(self, group, delta):
|
||||
size = self.size
|
||||
mtime = self.mtime
|
||||
m = group.get_match_of(self)
|
||||
def get_display_info(dupe, group, delta):
|
||||
size = dupe.size
|
||||
mtime = dupe.mtime
|
||||
m = group.get_match_of(dupe)
|
||||
if m:
|
||||
percentage = m.percentage
|
||||
dupe_count = 0
|
||||
@@ -27,13 +26,22 @@ class File(fs.File):
|
||||
percentage = group.percentage
|
||||
dupe_count = len(group.dupes)
|
||||
return {
|
||||
'name': self.name,
|
||||
'folder_path': str(self.folder_path),
|
||||
'name': dupe.name,
|
||||
'folder_path': str(dupe.folder_path),
|
||||
'size': format_size(size, 0, 1, False),
|
||||
'extension': self.extension,
|
||||
'extension': dupe.extension,
|
||||
'mtime': format_timestamp(mtime, delta and m),
|
||||
'percentage': format_perc(percentage),
|
||||
'words': format_words(self.words) if hasattr(self, 'words') else '',
|
||||
'words': format_words(dupe.words) if hasattr(dupe, 'words') else '',
|
||||
'dupe_count': format_dupe_count(dupe_count),
|
||||
}
|
||||
|
||||
class File(fs.File):
|
||||
def get_display_info(self, group, delta):
|
||||
return get_display_info(self, group, delta)
|
||||
|
||||
|
||||
class Folder(fs.Folder):
|
||||
def get_display_info(self, group, delta):
|
||||
return get_display_info(self, group, delta)
|
||||
|
||||
|
||||
2
debian/control
vendored
2
debian/control
vendored
@@ -8,5 +8,5 @@ Homepage: http://www.hardcoded.net
|
||||
|
||||
Package: {pkgname}
|
||||
Architecture: {arch}
|
||||
Depends: python3 (>=3.2), python3-pyqt4
|
||||
Depends: python3 (>=3.3), python3-pyqt4
|
||||
Description: {longname}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
=== 6.7.0 (2013-12-08)
|
||||
|
||||
* 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.
|
||||
* Dropped Ubuntu 12.04 and 12.10 support.
|
||||
* Removed the fairware dialog ([More Info](http://www.hardcoded.net/articles/phasing-out-fairware)).
|
||||
|
||||
=== 6.6.0 (2013-08-18)
|
||||
|
||||
* Improved delta values to support non-numerical values. (#213)
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
=== 2.9.0 (2013-12-22)
|
||||
|
||||
* Read RAW pictures EXIF tags. [Mac] (#234)
|
||||
* 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.
|
||||
* Dropped Ubuntu 12.04 and 12.10 support.
|
||||
* Removed the fairware dialog ([More Info](http://www.hardcoded.net/articles/phasing-out-fairware)).
|
||||
|
||||
=== 2.8.0 (2013-08-25)
|
||||
|
||||
* Improved delta values to support non-numerical values. (#213)
|
||||
* Improved the Re-Prioritize dialog's UI. (#224)
|
||||
* Added hardlink/symlink support on Windows Vista+. (#220)
|
||||
* Added keybinding for the "Clear Picture Cache" action. [Linux, Windows] (#230)
|
||||
* Dropped 32bit support on Mac OS X.
|
||||
* Added Vietnamese localization by Phan Anh.
|
||||
|
||||
=== 2.7.1 (2013-05-05)
|
||||
|
||||
* Fixed false matching bug in EXIF matching. (#219)
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
=== 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.
|
||||
* Dropped Ubuntu 12.04 and 12.10 support.
|
||||
* Removed the fairware dialog ([More Info](http://www.hardcoded.net/articles/phasing-out-fairware)).
|
||||
|
||||
=== 3.7.1 (2013-08-19)
|
||||
|
||||
* Fixed folder scan type, which was broken in v3.7.0.
|
||||
|
||||
=== 3.7.0 (2013-08-17)
|
||||
|
||||
* Improved delta values to support non-numerical values. (#213)
|
||||
|
||||
@@ -12,11 +12,26 @@
|
||||
# serve to show the default.
|
||||
|
||||
import sys, os
|
||||
import re
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
# for autodocs
|
||||
sys.path.insert(0, os.path.abspath(os.path.join('..', '..')))
|
||||
|
||||
# -- Misc fixes for autodoc
|
||||
|
||||
def fix_nulljob_in_sig(app, what, name, obj, options, signature, return_annotation):
|
||||
if signature:
|
||||
signature = re.sub(r"<jobprogress.job.NullJob object at 0x[\da-f]+>", "nulljob", signature)
|
||||
return signature, return_annotation
|
||||
|
||||
def setup(app):
|
||||
app.connect('autodoc-process-signature', fix_nulljob_in_sig)
|
||||
|
||||
autodoc_member_order = 'groupwise'
|
||||
|
||||
# -- General configuration -----------------------------------------------------
|
||||
|
||||
@@ -25,7 +40,7 @@ import sys, os
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||
extensions = ['sphinx.ext.todo']
|
||||
extensions = ['sphinx.ext.todo', 'sphinx.ext.autodoc', 'sphinx.ext.autosummary']
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
@@ -145,10 +160,10 @@ html_theme = 'haiku'
|
||||
#html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
html_domain_indices = False
|
||||
# html_domain_indices = False
|
||||
|
||||
# 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.
|
||||
#html_split_index = False
|
||||
|
||||
5
help/en/developer/core/app.rst
Normal file
5
help/en/developer/core/app.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
core.app
|
||||
========
|
||||
|
||||
.. automodule:: core.app
|
||||
:members:
|
||||
5
help/en/developer/core/directories.rst
Normal file
5
help/en/developer/core/directories.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
core.directories
|
||||
================
|
||||
|
||||
.. automodule:: core.directories
|
||||
:members:
|
||||
36
help/en/developer/core/engine.rst
Normal file
36
help/en/developer/core/engine.rst
Normal file
@@ -0,0 +1,36 @@
|
||||
core.engine
|
||||
===========
|
||||
|
||||
.. automodule:: core.engine
|
||||
|
||||
.. autoclass:: Match
|
||||
|
||||
.. autoclass:: Group
|
||||
:members:
|
||||
|
||||
.. autofunction:: build_word_dict
|
||||
.. autofunction:: compare
|
||||
.. autofunction:: compare_fields
|
||||
.. autofunction:: getmatches
|
||||
.. autofunction:: getmatches_by_contents
|
||||
.. autofunction:: get_groups
|
||||
.. autofunction:: merge_similar_words
|
||||
.. autofunction:: reduce_common_words
|
||||
|
||||
.. _fields:
|
||||
|
||||
Fields
|
||||
------
|
||||
|
||||
Fields are groups of words which each represent a significant part of the whole name. This concept
|
||||
is sifnificant in music file names, where we often have names like "My Artist - a very long title
|
||||
with many many words".
|
||||
|
||||
This title has 10 words. If you run as scan with a bit of tolerance, let's say 90%, you'll be able
|
||||
to find a dupe that has only one "many" in the song title. However, you would also get false
|
||||
duplicates from a title like "My Giraffe - a very long title with many many words", which is of
|
||||
course a very different song and it doesn't make sense to match them.
|
||||
|
||||
When matching by fields, each field (separated by "-") is considered as a separate string to match
|
||||
independently. After all fields are matched, the lowest result is kept. In the "Giraffe" example we
|
||||
gave, the result would be 50% instead of 90% in normal mode.
|
||||
5
help/en/developer/core/fs.rst
Normal file
5
help/en/developer/core/fs.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
core.fs
|
||||
=======
|
||||
|
||||
.. automodule:: core.fs
|
||||
:members:
|
||||
5
help/en/developer/core/gui/deletion_options.rst
Normal file
5
help/en/developer/core/gui/deletion_options.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
core.gui.deletion_options
|
||||
=========================
|
||||
|
||||
.. automodule:: core.gui.deletion_options
|
||||
:members:
|
||||
10
help/en/developer/core/gui/index.rst
Normal file
10
help/en/developer/core/gui/index.rst
Normal file
@@ -0,0 +1,10 @@
|
||||
core.gui
|
||||
========
|
||||
|
||||
.. automodule:: core.gui
|
||||
:members:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
deletion_options
|
||||
12
help/en/developer/core/index.rst
Normal file
12
help/en/developer/core/index.rst
Normal file
@@ -0,0 +1,12 @@
|
||||
core
|
||||
====
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
app
|
||||
fs
|
||||
engine
|
||||
directories
|
||||
results
|
||||
gui/index
|
||||
5
help/en/developer/core/results.rst
Normal file
5
help/en/developer/core/results.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
core.results
|
||||
============
|
||||
|
||||
.. automodule:: core.results
|
||||
:members:
|
||||
5
help/en/developer/hscommon/build.rst
Normal file
5
help/en/developer/hscommon/build.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
hscommon.build
|
||||
==============
|
||||
|
||||
.. automodule:: hscommon.build
|
||||
:members:
|
||||
5
help/en/developer/hscommon/conflict.rst
Normal file
5
help/en/developer/hscommon/conflict.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
hscommon.conflict
|
||||
=================
|
||||
|
||||
.. automodule:: hscommon.conflict
|
||||
:members:
|
||||
5
help/en/developer/hscommon/desktop.rst
Normal file
5
help/en/developer/hscommon/desktop.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
hscommon.desktop
|
||||
================
|
||||
|
||||
.. automodule:: hscommon.desktop
|
||||
:members:
|
||||
12
help/en/developer/hscommon/gui/base.rst
Normal file
12
help/en/developer/hscommon/gui/base.rst
Normal file
@@ -0,0 +1,12 @@
|
||||
hscommon.gui.base
|
||||
=================
|
||||
|
||||
.. automodule:: hscommon.gui.base
|
||||
|
||||
.. autosummary::
|
||||
|
||||
GUIObject
|
||||
|
||||
.. autoclass:: GUIObject
|
||||
:members:
|
||||
:private-members:
|
||||
25
help/en/developer/hscommon/gui/column.rst
Normal file
25
help/en/developer/hscommon/gui/column.rst
Normal 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:
|
||||
18
help/en/developer/hscommon/gui/progress_window.rst
Normal file
18
help/en/developer/hscommon/gui/progress_window.rst
Normal 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:
|
||||
|
||||
26
help/en/developer/hscommon/gui/selectable_list.rst
Normal file
26
help/en/developer/hscommon/gui/selectable_list.rst
Normal 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:
|
||||
26
help/en/developer/hscommon/gui/table.rst
Normal file
26
help/en/developer/hscommon/gui/table.rst
Normal 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:
|
||||
16
help/en/developer/hscommon/gui/text_field.rst
Normal file
16
help/en/developer/hscommon/gui/text_field.rst
Normal 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:
|
||||
18
help/en/developer/hscommon/gui/tree.rst
Normal file
18
help/en/developer/hscommon/gui/tree.rst
Normal 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:
|
||||
|
||||
19
help/en/developer/hscommon/index.rst
Normal file
19
help/en/developer/hscommon/index.rst
Normal 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
|
||||
5
help/en/developer/hscommon/notify.rst
Normal file
5
help/en/developer/hscommon/notify.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
hscommon.notify
|
||||
===============
|
||||
|
||||
.. automodule:: hscommon.notify
|
||||
:members:
|
||||
5
help/en/developer/hscommon/path.rst
Normal file
5
help/en/developer/hscommon/path.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
hscommon.path
|
||||
=============
|
||||
|
||||
.. automodule:: hscommon.path
|
||||
:members:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user