From 3b78209a8d6fbb2e20c55d2163f025bd89cc43fd Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 16 Apr 2011 21:24:04 -0600 Subject: [PATCH 01/13] Edit metadata dialog: Add an action to generate author sort from author --- src/calibre/gui2/metadata/basic_widgets.py | 12 +++++++++++- src/calibre/gui2/metadata/single.py | 14 ++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/calibre/gui2/metadata/basic_widgets.py b/src/calibre/gui2/metadata/basic_widgets.py index 73913ba58f..25034bbfbd 100644 --- a/src/calibre/gui2/metadata/basic_widgets.py +++ b/src/calibre/gui2/metadata/basic_widgets.py @@ -222,7 +222,8 @@ class AuthorSortEdit(EnLineEdit): 'red, then the authors and this text do not match.') LABEL = _('Author s&ort:') - def __init__(self, parent, authors_edit, autogen_button, db): + def __init__(self, parent, authors_edit, autogen_button, db, + copy_as_to_a_action): EnLineEdit.__init__(self, parent) self.authors_edit = authors_edit self.db = db @@ -241,6 +242,7 @@ class AuthorSortEdit(EnLineEdit): self.textChanged.connect(self.update_state) autogen_button.clicked.connect(self.auto_generate) + copy_as_to_a_action.triggered.connect(self.copy_to_authors) self.update_state() @dynamic_property @@ -273,6 +275,14 @@ class AuthorSortEdit(EnLineEdit): self.setToolTip(tt) self.setWhatsThis(tt) + def copy_to_authors(self): + aus = self.current_val + if aus: + ln, _, rest = aus.partition(',') + if rest: + au = rest.strip() + ' ' + ln.strip() + self.authors_edit.current_val = [au] + def auto_generate(self, *args): au = unicode(self.authors_edit.text()) au = re.sub(r'\s+et al\.$', '', au) diff --git a/src/calibre/gui2/metadata/single.py b/src/calibre/gui2/metadata/single.py index 2e5b43ceba..215a41a68d 100644 --- a/src/calibre/gui2/metadata/single.py +++ b/src/calibre/gui2/metadata/single.py @@ -13,7 +13,7 @@ from functools import partial from PyQt4.Qt import (Qt, QVBoxLayout, QHBoxLayout, QWidget, QPushButton, QGridLayout, pyqtSignal, QDialogButtonBox, QScrollArea, QFont, QTabWidget, QIcon, QToolButton, QSplitter, QGroupBox, QSpacerItem, - QSizePolicy, QPalette, QFrame, QSize, QKeySequence) + QSizePolicy, QPalette, QFrame, QSize, QKeySequence, QMenu) from calibre.ebooks.metadata import authors_to_string, string_to_authors from calibre.gui2 import ResizableDialog, error_dialog, gprefs, pixmap_to_data @@ -102,15 +102,17 @@ class MetadataSingleDialogBase(ResizableDialog): self.deduce_title_sort_button) self.basic_metadata_widgets.extend([self.title, self.title_sort]) - self.authors = AuthorsEdit(self) - self.deduce_author_sort_button = QToolButton(self) - self.deduce_author_sort_button.setToolTip(_( + self.deduce_author_sort_button = b = QToolButton(self) + b.setToolTip(_( 'Automatically create the author sort entry based on the current' ' author entry.\n' 'Using this button to create author sort will change author sort from' ' red to green.')) - self.author_sort = AuthorSortEdit(self, self.authors, - self.deduce_author_sort_button, self.db) + b.m = m = QMenu() + ac = m.addAction(QIcon(I('back.png')), _('Set author from author sort')) + b.setMenu(m) + self.authors = AuthorsEdit(self) + self.author_sort = AuthorSortEdit(self, self.authors, b, self.db, ac) self.basic_metadata_widgets.extend([self.authors, self.author_sort]) self.swap_title_author_button = QToolButton(self) From 48b7133f64920e78ad734e33e087c0893f715cc1 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 17 Apr 2011 09:14:58 +0100 Subject: [PATCH 02/13] Slight changes to the new 'manage' button --- src/calibre/gui2/tag_view.py | 40 +++++++++++++++++------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 188fd3367d..7b68229da0 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -15,7 +15,7 @@ from functools import partial from PyQt4.Qt import (Qt, QTreeView, QApplication, pyqtSignal, QFont, QSize, QIcon, QPoint, QVBoxLayout, QHBoxLayout, QComboBox, QTimer, QAbstractItemModel, QVariant, QModelIndex, QMenu, QFrame, - QWidget, QItemDelegate, QString, QLabel, + QWidget, QItemDelegate, QString, QLabel, QPushButton, QShortcut, QKeySequence, SIGNAL, QMimeData, QToolButton) from calibre.ebooks.metadata import title_sort @@ -1809,9 +1809,6 @@ class TagsModel(QAbstractItemModel): # {{{ # }}} -category_managers = ( - ) - class TagBrowserMixin(object): # {{{ def __init__(self, db): @@ -1833,20 +1830,23 @@ class TagBrowserMixin(object): # {{{ self.tags_view.restriction_error.connect(self.do_restriction_error, type=Qt.QueuedConnection) - for text, func, args in ( - (_('Manage Authors'), self.do_author_sort_edit, (self, - None)), - (_('Manage Series'), self.do_tags_list_edit, (None, - 'series')), - (_('Manage Publishers'), self.do_tags_list_edit, (None, - 'publisher')), - (_('Manage Tags'), self.do_tags_list_edit, (None, 'tags')), - (_('Manage User Categories'), - self.do_edit_user_categories, (None,)), - (_('Manage Saved Searches'), self.do_saved_search_edit, - (None,)) + for text, func, args, cat_name in ( + (_('Manage Authors'), + self.do_author_sort_edit, (self, None), 'authors'), + (_('Manage Series'), + self.do_tags_list_edit, (None, 'series'), 'series'), + (_('Manage Publishers'), + self.do_tags_list_edit, (None, 'publisher'), 'publisher'), + (_('Manage Tags'), + self.do_tags_list_edit, (None, 'tags'), 'tags'), + (_('Manage User Categories'), + self.do_edit_user_categories, (None,), 'user:'), + (_('Manage Saved Searches'), + self.do_saved_search_edit, (None,), 'search') ): - self.manage_items_button.menu().addAction(text, partial(func, *args)) + self.manage_items_button.menu().addAction( + QIcon(I(category_icon_map[cat_name])), + text, partial(func, *args)) def do_restriction_error(self): error_dialog(self.tags_view, _('Invalid search restriction'), @@ -2166,11 +2166,9 @@ class TagBrowserWidget(QWidget): # {{{ parent.tag_match.setStatusTip(parent.tag_match.toolTip()) - l = parent.manage_items_button = QToolButton(self) - l.setIcon(QIcon(I('tags.png'))) + l = parent.manage_items_button = QPushButton(self) + l.setStyleSheet('QPushButton {text-align: left; }') l.setText(_('Manage authors, tags, etc')) - l.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - l.setPopupMode(l.InstantPopup) l.setToolTip(_('All of these category_managers are available by right-clicking ' 'on items in the tag browser above')) l.m = QMenu() From b4d098bc252dc16e7fb2392b76053aca618ba024 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 17 Apr 2011 10:14:34 +0100 Subject: [PATCH 03/13] Back out change to add 'size' to the mi returned by get_metadata. It breaks managing device metadata badly. --- src/calibre/library/database2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index b5155368c7..bdcefd13a2 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -854,7 +854,6 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): mi.uuid = row[fm['uuid']] mi.title_sort = row[fm['sort']] mi.last_modified = row[fm['last_modified']] - mi.size = row[fm['size']] formats = row[fm['formats']] if not formats: formats = None From 1af2d395408a674a103ab197030f292c897bc19e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 17 Apr 2011 10:40:49 +0100 Subject: [PATCH 04/13] Make the toggle search highlighting re-run the last search. --- src/calibre/gui2/search_box.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py index f5b3649e27..40c9c34587 100644 --- a/src/calibre/gui2/search_box.py +++ b/src/calibre/gui2/search_box.py @@ -374,6 +374,8 @@ class SearchBoxMixin(object): # {{{ def highlight_only_clicked(self, state): config['highlight_search_matches'] = not config['highlight_search_matches'] self.set_highlight_only_button_icon() + self.search.do_search() + self.focus_to_library() def set_highlight_only_button_icon(self): if config['highlight_search_matches']: From 375d64e118eb8d22d58bee0c7e1cfe7b6ac498a1 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 17 Apr 2011 11:51:49 +0100 Subject: [PATCH 05/13] Completely remove the search options button. Enable and disable the highlight_only_button when switching between library and device views. --- src/calibre/gui2/layout.py | 7 ------- src/calibre/gui2/search_box.py | 5 ----- src/calibre/gui2/ui.py | 4 ++-- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/calibre/gui2/layout.py b/src/calibre/gui2/layout.py index 246fa168a0..e5ec5a9131 100644 --- a/src/calibre/gui2/layout.py +++ b/src/calibre/gui2/layout.py @@ -200,13 +200,6 @@ class SearchBar(QWidget): # {{{ x.setIcon(QIcon(I('arrow-down.png'))) l.addWidget(x) - x = parent.search_options_button = QToolButton(self) - x.setIcon(QIcon(I('config.png'))) - x.setObjectName("search_option_button") - l.addWidget(x) - x.setToolTip(_("Change the way searching for books works")) - x.setVisible(False) - x = parent.saved_search = SavedSearchBox(self) x.setMaximumSize(QSize(150, 16777215)) x.setMinimumContentsLength(15) diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py index 40c9c34587..c349d84a68 100644 --- a/src/calibre/gui2/search_box.py +++ b/src/calibre/gui2/search_box.py @@ -364,7 +364,6 @@ class SearchBoxMixin(object): # {{{ unicode(self.search.toolTip()))) self.advanced_search_button.setStatusTip(self.advanced_search_button.toolTip()) self.clear_button.setStatusTip(self.clear_button.toolTip()) - self.search_options_button.clicked.connect(self.search_options_button_clicked) self.set_highlight_only_button_icon() self.highlight_only_button.clicked.connect(self.highlight_only_clicked) tt = _('Enable or disable search highlighting.') + '

' @@ -406,10 +405,6 @@ class SearchBoxMixin(object): # {{{ self.search.do_search() self.focus_to_library() - def search_options_button_clicked(self): - self.iactions['Preferences'].do_config(initial_plugin=('Interface', - 'Search'), close_after_initial=True) - def focus_to_library(self): self.current_view().setFocus(Qt.OtherFocusReason) diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 8d31d9da32..f234d48739 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -529,10 +529,10 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ action.location_selected(location) if location == 'library': self.search_restriction.setEnabled(True) - self.search_options_button.setEnabled(True) + self.highlight_only_button.setEnabled(True) else: self.search_restriction.setEnabled(False) - self.search_options_button.setEnabled(False) + self.highlight_only_button.setEnabled(False) # Reset the view in case something changed while it was invisible self.current_view().reset() self.set_number_of_books_shown() From f49d96871b332b4325457038c37e028dc9ce6036 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 17 Apr 2011 13:36:02 +0100 Subject: [PATCH 06/13] If the search option highlight_only is set, then only select a book after a search if the search returned results. --- src/calibre/gui2/library/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index 48fbfb7291..ce5e0d9877 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -743,6 +743,8 @@ class BooksView(QTableView): # {{{ id_to_select = self._model.get_current_highlighted_id() if id_to_select is not None: self.select_rows([id_to_select], using_ids=True) + elif self._model.highlight_only: + self.clearSelection() self.setFocus(Qt.OtherFocusReason) def connect_to_search_box(self, sb, search_done): From 48d431f85fcd458211099e9f5d9ea69b6ead6dcf Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 17 Apr 2011 07:38:12 -0600 Subject: [PATCH 07/13] Comic Input: Fix handling of some CBZ files that have wrongly encoded non ASCII filenames on windows. Fixes #763280 (Conversion from CBR/CBZ fails if files not in root) --- src/calibre/ebooks/comic/input.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/calibre/ebooks/comic/input.py b/src/calibre/ebooks/comic/input.py index 56fa123249..56f7683c57 100755 --- a/src/calibre/ebooks/comic/input.py +++ b/src/calibre/ebooks/comic/input.py @@ -12,6 +12,7 @@ from Queue import Empty from calibre.customize.conversion import InputFormatPlugin, OptionRecommendation from calibre import extract, CurrentDir, prints +from calibre.constants import filesystem_encoding from calibre.ptempfile import PersistentTemporaryDirectory from calibre.utils.ipc.server import Server from calibre.utils.ipc.job import ParallelJob @@ -21,6 +22,10 @@ def extract_comic(path_to_comic_file): Un-archive the comic file. ''' tdir = PersistentTemporaryDirectory(suffix='_comic_extract') + if not isinstance(tdir, unicode): + # Needed in case the zip file has wrongly encoded unicode file/dir + # names + tdir = tdir.decode(filesystem_encoding) extract(path_to_comic_file, tdir) return tdir From 48f0079d0605906071bc6e7b24fb7449497d3d8d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 17 Apr 2011 07:49:38 -0600 Subject: [PATCH 08/13] Remember windows size of download metadata preferences dialog --- src/calibre/gui2/preferences/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/calibre/gui2/preferences/__init__.py b/src/calibre/gui2/preferences/__init__.py index 1669e24059..649a58448d 100644 --- a/src/calibre/gui2/preferences/__init__.py +++ b/src/calibre/gui2/preferences/__init__.py @@ -319,9 +319,12 @@ def show_config_widget(category, name, gui=None, show_restart_msg=False, :return: True iff a restart is required for the changes made by the user to take effect ''' + from calibre.gui2 import gprefs pl = get_plugin(category, name) d = ConfigDialog(parent) d.resize(750, 550) + conf_name = 'config_widget_dialog_geometry_%s_%s'%(category, name) + geom = gprefs.get(conf_name, None) d.setWindowTitle(_('Configure ') + name) d.setWindowIcon(QIcon(I('config.png'))) bb = QDialogButtonBox(d) @@ -345,7 +348,11 @@ def show_config_widget(category, name, gui=None, show_restart_msg=False, mygui = True w.genesis(gui) w.initialize() + if geom is not None: + d.restoreGeometry(geom) d.exec_() + geom = bytearray(d.saveGeometry()) + gprefs[conf_name] = geom rr = getattr(d, 'restart_required', False) if show_restart_msg and rr: from calibre.gui2 import warning_dialog From 799f818e7d073ee8ce1ffe93e40bb604bd55b051 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 17 Apr 2011 08:14:00 -0600 Subject: [PATCH 09/13] MOBI Input: Fix detection of Table of Contents for MOBI files that have a page break between the location designated as the Table of Contents and the actual table of contents. Fixes #763504 (Private bug) --- src/calibre/ebooks/mobi/reader.py | 4 +++- src/calibre/library/cli.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/calibre/ebooks/mobi/reader.py b/src/calibre/ebooks/mobi/reader.py index 8877ecdd0b..a65649dfd2 100644 --- a/src/calibre/ebooks/mobi/reader.py +++ b/src/calibre/ebooks/mobi/reader.py @@ -716,6 +716,7 @@ class MobiReader(object): ent_pat = re.compile(r'&(\S+?);') if elems: tocobj = TOC() + found = False reached = False for x in root.iter(): if x == elems[-1]: @@ -732,7 +733,8 @@ class MobiReader(object): text = ent_pat.sub(entity_to_unicode, text) tocobj.add_item(toc.partition('#')[0], href[1:], text) - if reached and x.get('class', None) == 'mbp_pagebreak': + found = True + if reached and found and x.get('class', None) == 'mbp_pagebreak': break if tocobj is not None: opf.set_toc(tocobj) diff --git a/src/calibre/library/cli.py b/src/calibre/library/cli.py index f062aecc26..b1a8236151 100644 --- a/src/calibre/library/cli.py +++ b/src/calibre/library/cli.py @@ -426,7 +426,7 @@ def do_show_metadata(db, id, as_opf): mi = OPFCreator(os.getcwd(), mi) mi.render(sys.stdout) else: - print unicode(mi).encode(preferred_encoding) + prints(unicode(mi)) def show_metadata_option_parser(): parser = get_parser(_( From ba5285ea5b1088a605e105a722b7f7bdec8c78c3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 17 Apr 2011 08:19:48 -0600 Subject: [PATCH 10/13] For the slow fingered among us --- src/calibre/gui2/metadata/basic_widgets.py | 3 ++- src/calibre/gui2/metadata/single.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/calibre/gui2/metadata/basic_widgets.py b/src/calibre/gui2/metadata/basic_widgets.py index 25034bbfbd..9502fcb205 100644 --- a/src/calibre/gui2/metadata/basic_widgets.py +++ b/src/calibre/gui2/metadata/basic_widgets.py @@ -223,7 +223,7 @@ class AuthorSortEdit(EnLineEdit): LABEL = _('Author s&ort:') def __init__(self, parent, authors_edit, autogen_button, db, - copy_as_to_a_action): + copy_a_to_as_action, copy_as_to_a_action): EnLineEdit.__init__(self, parent) self.authors_edit = authors_edit self.db = db @@ -242,6 +242,7 @@ class AuthorSortEdit(EnLineEdit): self.textChanged.connect(self.update_state) autogen_button.clicked.connect(self.auto_generate) + copy_a_to_as_action.triggered.connect(self.auto_generate) copy_as_to_a_action.triggered.connect(self.copy_to_authors) self.update_state() diff --git a/src/calibre/gui2/metadata/single.py b/src/calibre/gui2/metadata/single.py index 215a41a68d..52b9e99872 100644 --- a/src/calibre/gui2/metadata/single.py +++ b/src/calibre/gui2/metadata/single.py @@ -109,10 +109,12 @@ class MetadataSingleDialogBase(ResizableDialog): 'Using this button to create author sort will change author sort from' ' red to green.')) b.m = m = QMenu() - ac = m.addAction(QIcon(I('back.png')), _('Set author from author sort')) + ac = m.addAction(QIcon(I('forward.png')), _('Set author sort from author')) + ac2 = m.addAction(QIcon(I('back.png')), _('Set author from author sort')) b.setMenu(m) self.authors = AuthorsEdit(self) - self.author_sort = AuthorSortEdit(self, self.authors, b, self.db, ac) + self.author_sort = AuthorSortEdit(self, self.authors, b, self.db, ac, + ac2) self.basic_metadata_widgets.extend([self.authors, self.author_sort]) self.swap_title_author_button = QToolButton(self) From 03cb146ad01f2914beba6fef14349da679d8d420 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 17 Apr 2011 08:28:43 -0600 Subject: [PATCH 11/13] Fix #763443 (Fetch News - set the default back to 6 am) --- src/calibre/gui2/dialogs/scheduler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/calibre/gui2/dialogs/scheduler.py b/src/calibre/gui2/dialogs/scheduler.py index 9b1fe67f29..b25d66979d 100644 --- a/src/calibre/gui2/dialogs/scheduler.py +++ b/src/calibre/gui2/dialogs/scheduler.py @@ -68,7 +68,7 @@ class DaysOfWeek(Base): def initialize(self, typ=None, val=None): if typ is None: typ = 'day/time' - val = (-1, 9, 0) + val = (-1, 6, 0) if typ == 'day/time': val = convert_day_time_schedule(val) @@ -118,7 +118,7 @@ class DaysOfMonth(Base): def initialize(self, typ=None, val=None): if val is None: - val = ((1,), 9, 0) + val = ((1,), 6, 0) days_of_month, hour, minute = val self.days.setText(', '.join(map(str, map(int, days_of_month)))) self.time.setTime(QTime(hour, minute)) @@ -380,7 +380,7 @@ class SchedulerDialog(QDialog, Ui_Dialog): if d < timedelta(days=366): ld_text = tm else: - typ, sch = 'day/time', (-1, 9, 0) + typ, sch = 'day/time', (-1, 6, 0) sch_widget = {'day/time': 0, 'days_of_week': 0, 'days_of_month':1, 'interval':2}[typ] rb = getattr(self, list(self.SCHEDULE_TYPES)[sch_widget]) From 94c4b74e53767186d45605c02d118d03bbc82c2b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 17 Apr 2011 08:40:10 -0600 Subject: [PATCH 12/13] ... --- src/calibre/gui2/actions/store.py | 12 ++-- src/calibre/gui2/store/__init__.py | 48 ++++++------- src/calibre/gui2/store/amazon_plugin.py | 48 ++++++------- src/calibre/gui2/store/bewrite_plugin.py | 19 +++--- src/calibre/gui2/store/search.py | 85 ++++++++++++------------ src/calibre/gui2/store/web_control.py | 34 +++++----- 6 files changed, 122 insertions(+), 124 deletions(-) diff --git a/src/calibre/gui2/actions/store.py b/src/calibre/gui2/actions/store.py index f00497ad64..4e96960243 100644 --- a/src/calibre/gui2/actions/store.py +++ b/src/calibre/gui2/actions/store.py @@ -8,20 +8,20 @@ __docformat__ = 'restructuredtext en' from functools import partial -from PyQt4.Qt import Qt, QMenu, QToolButton, QDialog, QVBoxLayout +from PyQt4.Qt import QMenu from calibre.gui2.actions import InterfaceAction class StoreAction(InterfaceAction): name = 'Store' - action_spec = (_('Store'), 'store.png', None, None) - + action_spec = (_('Get books'), 'store.png', None, None) + def genesis(self): self.qaction.triggered.connect(self.search) self.store_menu = QMenu() self.load_menu() - + def load_menu(self): self.store_menu.clear() self.store_menu.addAction(_('Search'), self.search) @@ -29,11 +29,11 @@ class StoreAction(InterfaceAction): for n, p in self.gui.istores.items(): self.store_menu.addAction(n, partial(self.open_store, p)) self.qaction.setMenu(self.store_menu) - + def search(self): from calibre.gui2.store.search import SearchDialog sd = SearchDialog(self.gui.istores, self.gui) sd.exec_() - + def open_store(self, store_plugin): store_plugin.open(self.gui) diff --git a/src/calibre/gui2/store/__init__.py b/src/calibre/gui2/store/__init__.py index 73d0d0a8d4..26bafd2c95 100644 --- a/src/calibre/gui2/store/__init__.py +++ b/src/calibre/gui2/store/__init__.py @@ -19,27 +19,27 @@ class StorePlugin(object): # {{{ If two :class:`StorePlugin` objects have the same name, the one with higher priority takes precedence. - + Sub-classes must implement :meth:`open`, and :meth:`search`. - + Regarding :meth:`open`. Most stores only make themselves available though a web site thus most store plugins will open using :class:`calibre.gui2.store.web_store_dialog.WebStoreDialog`. This will open a modal window and display the store website in a QWebView. - + Sub-classes should implement and use the :meth:`genesis` if they require plugin specific initialization. They should not override or otherwise reimplement :meth:`__init__`. - + Once initialized, this plugin has access to the main calibre GUI via the :attr:`gui` member. You can access other plugins by name, for example:: self.gui.istores['Amazon Kindle'] - + Plugin authors can use affiliate programs within their plugin. The distribution of money earned from a store plugin is 70/30. 70% going to the pluin author / maintainer and 30% going to the calibre project. - + The easiest way to handle affiliate money payouts is to randomly select between the author's affiliate id and calibre's affiliate id so that 70% of the time the author's id is used. @@ -49,61 +49,61 @@ class StorePlugin(object): # {{{ self.gui = gui self.name = name self.base_plugin = None - + def open(self, gui, parent=None, detail_item=None, external=False): ''' Open the store. - + :param gui: The main GUI. This will be used to have the job system start downloading an item from the store. - + :param parent: The parent of the store dialog. This is used to create modal dialogs. - + :param detail_item: A plugin specific reference to an item in the store that the user should be shown. - + :param external: When False open an internal dialog with the store. When True open the users default browser to the store's web site. :param:`detail_item` should still be respected when external is True. ''' raise NotImplementedError() - + def search(self, query, max_results=10, timeout=60): ''' Searches the store for items matching query. This should return items as a generator. - + Don't be lazy with the search! Load as much data as possible in the :class:`calibre.gui2.store.search_result.SearchResult` object. If you have to parse multiple pages to get all of the data then do so. However, if data (such as cover_url) isn't available because the store does not display cover images then it's okay to ignore it. - + Also, by default search results can only include ebooks. A plugin can offer users an option to include physical books in the search results but this must be disabled by default. - + If a store doesn't provide search on it's own use something like a site specific google search to get search results for this funtion. - + :param query: The string query search with. :param max_results: The maximum number of results to return. :param timeout: The maximum amount of time in seconds to spend download the search results. - + :return: :class:`calibre.gui2.store.search_result.SearchResult` objects - item_data is plugin specific and is used in :meth:`open` to open to a specifc place in the store. + item_data is plugin specific and is used in :meth:`open` to open to a specifc place in the store. ''' raise NotImplementedError() - + def get_settings(self): ''' This is only useful for plugins that implement :attr:`config_widget` that is the only way to save settings. This is used by plugins to get the saved settings and apply when necessary. - + :return: A dictionary filled with the settings used by this plugin. ''' @@ -117,23 +117,23 @@ class StorePlugin(object): # {{{ Plugin specific initialization. ''' pass - + def config_widget(self): ''' See :class:`calibre.customize.Plugin` for details. ''' raise NotImplementedError() - + def save_settings(self, config_widget): ''' See :class:`calibre.customize.Plugin` for details. ''' raise NotImplementedError() - + def customization_help(self, gui=False): ''' See :class:`calibre.customize.Plugin` for details. ''' raise NotImplementedError() -# }}} \ No newline at end of file +# }}} diff --git a/src/calibre/gui2/store/amazon_plugin.py b/src/calibre/gui2/store/amazon_plugin.py index 0b42ee1308..51986ee4df 100644 --- a/src/calibre/gui2/store/amazon_plugin.py +++ b/src/calibre/gui2/store/amazon_plugin.py @@ -21,14 +21,14 @@ from calibre.gui2.store import StorePlugin from calibre.gui2.store.search_result import SearchResult class AmazonKindleStore(StorePlugin): - + def open(self, parent=None, detail_item=None, external=False): ''' Amazon comes with a number of difficulties. - + QWebView has major issues with Amazon.com. The largest of issues is it simply doesn't work on a number of pages. - + When connecting to a number parts of Amazon.com (Kindle library for instance) QNetworkAccessManager fails to connect with a NetworkError of 399 - ProtocolFailure. The strange thing is, @@ -37,19 +37,19 @@ class AmazonKindleStore(StorePlugin): the QNetworkAccessManager decides there was a NetworkError it does not download the page from Amazon. So I can't even set the HTML in the QWebView myself. - + There is http://bugreports.qt.nokia.com/browse/QTWEBKIT-259 an open bug about the issue but it is not correct. We can set the useragent (Arora does) to something else and the above issue will persist. This http://developer.qt.nokia.com/forums/viewthread/793 gives a bit more information about the issue but as of now (27/Feb/2011) there is no solution or work around. - + We cannot change the The linkDelegationPolicy to allow us to avoid QNetworkAccessManager because it only works links. Forms aren't included so the same issue persists on any part of the site (login) that use a form to load a new page. - + Using an aStore was evaluated but I've decided against using it. There are three major issues with an aStore. Because checkout is handled by sending the user to Amazon we can't put it in a QWebView. @@ -57,7 +57,7 @@ class AmazonKindleStore(StorePlugin): nicer. Also, we cannot put the aStore in a QWebView and let it open the redirection the users default browser because the cookies with the shopping cart won't transfer. - + Another issue with the aStore is how it handles the referral. It only counts the referral for the items in the shopping card / the item that directed the user to Amazon. Kindle books do not use the shopping @@ -65,44 +65,44 @@ class AmazonKindleStore(StorePlugin): instance we would only get referral credit for the one book that the aStore directs to Amazon that the user buys. Any other purchases we won't get credit for. - + The last issue with the aStore is performance. Even though it's an Amazon site it's alow. So much slower than Amazon.com that it makes me not want to browse books using it. The look and feel are lesser issues. So is the fact that it almost seems like the purchase is with calibre. This can cause some support issues because we can't do much for issues with Amazon.com purchase hiccups. - + Another option that was evaluated was the Product Advertising API. The reasons against this are complexity. It would take a lot of work to basically re-create Amazon.com within calibre. The Product Advertising API is also designed with being run on a server not in an app. The signing keys would have to be made avaliable to ever calibre user which means bad things could be done with our account. - + The Product Advertising API also assumes the same browser for easy shopping cart transfer to Amazon. With QWebView not working and there not being an easy way to transfer cookies between a QWebView and the users default browser this won't work well. - + We could create our own website on the calibre server and create an Amazon Product Advertising API store. However, this goes back to the complexity argument. Why spend the time recreating Amazon.com - + The final and largest issue against using the Product Advertising API is the Efficiency Guidelines: - + "Each account used to access the Product Advertising API will be allowed an initial usage limit of 2,000 requests per hour. Each account will receive an additional 500 requests per hour (up to a maximum of 25,000 requests per hour) for every $1 of shipped item revenue driven per hour in a trailing 30-day period. Usage thresholds are recalculated daily based - on revenue performance." - + on revenue performance." + With over two million users a limit of 2,000 request per hour could render our store unusable for no other reason than Amazon rate limiting our traffic. - + The best (I use the term lightly here) solution is to open Amazon.com in the users default browser and set the affiliate id as part of the url. ''' @@ -119,14 +119,14 @@ class AmazonKindleStore(StorePlugin): def search(self, query, max_results=10, timeout=60): url = 'http://www.amazon.com/s/url=search-alias%3Ddigital-text&field-keywords=' + urllib2.quote(query) br = browser() - + counter = max_results with closing(br.open(url, timeout=timeout)) as f: doc = html.fromstring(f.read()) for data in doc.xpath('//div[@class="productData"]'): if counter <= 0: break - + # Even though we are searching digital-text only Amazon will still # put in results for non Kindle books (author pages). Se we need # to explicitly check if the item is a Kindle book and ignore it @@ -134,7 +134,7 @@ class AmazonKindleStore(StorePlugin): type = ''.join(data.xpath('//span[@class="format"]/text()')) if 'kindle' not in type.lower(): continue - + # We must have an asin otherwise we can't easily reference the # book later. asin_href = None @@ -148,25 +148,25 @@ class AmazonKindleStore(StorePlugin): continue else: continue - + cover_url = '' if asin_href: cover_img = data.xpath('//div[@class="productImage"]/a[@href="%s"]/img/@src' % asin_href) if cover_img: cover_url = cover_img[0] - + title = ''.join(data.xpath('div[@class="productTitle"]/a/text()')) author = ''.join(data.xpath('div[@class="productTitle"]/span[@class="ptBrand"]/text()')) author = author.split('by')[-1] price = ''.join(data.xpath('div[@class="newPrice"]/span/text()')) - + counter -= 1 - + s = SearchResult() s.cover_url = cover_url s.title = title.strip() s.author = author.strip() s.price = price.strip() s.detail_item = asin.strip() - + yield s diff --git a/src/calibre/gui2/store/bewrite_plugin.py b/src/calibre/gui2/store/bewrite_plugin.py index ffdb3cd4a2..37bd9cf9a5 100644 --- a/src/calibre/gui2/store/bewrite_plugin.py +++ b/src/calibre/gui2/store/bewrite_plugin.py @@ -6,7 +6,6 @@ __license__ = 'GPL 3' __copyright__ = '2011, John Schember ' __docformat__ = 'restructuredtext en' -import re import urllib2 from contextlib import closing @@ -22,7 +21,7 @@ from calibre.gui2.store.search_result import SearchResult from calibre.gui2.store.web_store_dialog import WebStoreDialog class BeWriteStore(BasicStoreConfig, StorePlugin): - + def open(self, parent=None, detail_item=None, external=False): settings = self.get_settings() url = 'http://www.bewrite.net/mm5/merchant.mvc?Screen=SFNT' @@ -42,9 +41,9 @@ class BeWriteStore(BasicStoreConfig, StorePlugin): def search(self, query, max_results=10, timeout=60): url = 'http://www.bewrite.net/mm5/merchant.mvc?Search_Code=B&Screen=SRCH&Search=' + urllib2.quote(query) - + br = browser() - + counter = max_results with closing(br.open(url, timeout=timeout)) as f: doc = html.fromstring(f.read()) @@ -55,12 +54,12 @@ class BeWriteStore(BasicStoreConfig, StorePlugin): id = ''.join(data.xpath('.//a/@href')) if not id: continue - - heading = ''.join(data.xpath('./td[2]//text()')) + + heading = ''.join(data.xpath('./td[2]//text()')) title, q, author = heading.partition('by ') cover_url = '' price = '' - + with closing(br.open(id.strip(), timeout=timeout/4)) as nf: idata = html.fromstring(nf.read()) price = ''.join(idata.xpath('//div[@id="content"]//td[contains(text(), "ePub")]/text()')) @@ -68,14 +67,14 @@ class BeWriteStore(BasicStoreConfig, StorePlugin): cover_img = idata.xpath('//div[@id="content"]//img[1]/@src') if cover_img: cover_url = 'http://www.bewrite.net/mm5/' + cover_img[0] - + counter -= 1 - + s = SearchResult() s.cover_url = cover_url.strip() s.title = title.strip() s.author = author.strip() s.price = price.strip() s.detail_item = id.strip() - + yield s diff --git a/src/calibre/gui2/store/search.py b/src/calibre/gui2/store/search.py index 970aaf61d2..1d263959ef 100644 --- a/src/calibre/gui2/store/search.py +++ b/src/calibre/gui2/store/search.py @@ -13,9 +13,8 @@ from random import shuffle from threading import Thread from Queue import Queue -from PyQt4.Qt import Qt, QAbstractItemModel, QDialog, QTimer, QVariant, \ - QModelIndex, QPixmap, QSize, QCheckBox, QVBoxLayout, QHBoxLayout, \ - QPushButton, QString, QByteArray +from PyQt4.Qt import (Qt, QAbstractItemModel, QDialog, QTimer, QVariant, + QModelIndex, QPixmap, QSize, QCheckBox, QVBoxLayout) from calibre import browser from calibre.gui2 import NONE @@ -35,7 +34,7 @@ class SearchDialog(QDialog, Ui_Dialog): def __init__(self, istores, *args): QDialog.__init__(self, *args) self.setupUi(self) - + self.config = DynamicConfig('store_search') # We keep a cache of store plugins and reference them by name. @@ -44,7 +43,7 @@ class SearchDialog(QDialog, Ui_Dialog): # Check for results and hung threads. self.checker = QTimer() self.hang_check = 0 - + self.model = Matches() self.results_view.setModel(self.model) @@ -59,7 +58,7 @@ class SearchDialog(QDialog, Ui_Dialog): stores_group_layout.addWidget(cbox) setattr(self, 'store_check_' + x, cbox) stores_group_layout.addStretch() - + # Create and add the progress indicator self.pi = ProgressIndicator(self, 24) self.bottom_layout.insertWidget(0, self.pi) @@ -71,9 +70,9 @@ class SearchDialog(QDialog, Ui_Dialog): self.select_invert_stores.clicked.connect(self.stores_select_invert) self.select_none_stores.clicked.connect(self.stores_select_none) self.finished.connect(self.dialog_closed) - + self.restore_state() - + def resize_columns(self): total = 600 # Cover @@ -87,19 +86,19 @@ class SearchDialog(QDialog, Ui_Dialog): self.results_view.setColumnWidth(3, int(total*.10)) # Store self.results_view.setColumnWidth(4, int(total*.20)) - + def do_search(self, checked=False): # Stop all running threads. self.checker.stop() self.search_pool.abort() # Clear the visible results. self.results_view.model().clear_results() - + # Don't start a search if there is nothing to search for. query = unicode(self.search_edit.text()) if not query.strip(): return - + # Plugins are in alphebetic order. Randomize the # order of plugin names. This way plugins closer # to a don't have an unfair advantage over @@ -117,12 +116,12 @@ class SearchDialog(QDialog, Ui_Dialog): self.checker.start(100) self.search_pool.start_threads() self.pi.startAnimation() - + def save_state(self): self.config['store_search_geometry'] = self.saveGeometry() self.config['store_search_store_splitter_state'] = self.store_splitter.saveState() self.config['store_search_results_view_column_width'] = [self.results_view.columnWidth(i) for i in range(self.model.columnCount())] - + store_check = {} for n in self.store_plugins: store_check[n] = getattr(self, 'store_check_' + n).isChecked() @@ -132,11 +131,11 @@ class SearchDialog(QDialog, Ui_Dialog): geometry = self.config['store_search_geometry'] if geometry: self.restoreGeometry(geometry) - + splitter_state = self.config['store_search_store_splitter_state'] if splitter_state: self.store_splitter.restoreState(splitter_state) - + results_cwidth = self.config['store_search_results_view_column_width'] if results_cwidth: for i, x in enumerate(results_cwidth): @@ -145,7 +144,7 @@ class SearchDialog(QDialog, Ui_Dialog): self.results_view.setColumnWidth(i, x) else: self.resize_columns() - + store_check = self.config['store_search_store_checked'] if store_check: for n in store_check: @@ -165,7 +164,7 @@ class SearchDialog(QDialog, Ui_Dialog): if not self.search_pool.threads_running() and not self.search_pool.has_tasks(): self.checker.stop() self.pi.stopAnimation() - + while self.search_pool.has_results(): res = self.search_pool.get_result() if res: @@ -189,15 +188,15 @@ class SearchDialog(QDialog, Ui_Dialog): def stores_select_all(self): for check in self.get_store_checks(): check.setChecked(True) - + def stores_select_invert(self): for check in self.get_store_checks(): check.setChecked(not check.isChecked()) - + def stores_select_none(self): for check in self.get_store_checks(): check.setChecked(False) - + def dialog_closed(self, result): self.model.closing() self.search_pool.abort() @@ -208,46 +207,46 @@ class GenericDownloadThreadPool(object): ''' add_task must be implemented in a subclass. ''' - + def __init__(self, thread_type, thread_count): self.thread_type = thread_type self.thread_count = thread_count - + self.tasks = Queue() self.results = Queue() self.threads = [] - + def add_task(self): raise NotImplementedError() - + def start_threads(self): for i in range(self.thread_count): t = self.thread_type(self.tasks, self.results) self.threads.append(t) t.start() - + def abort(self): self.tasks = Queue() self.results = Queue() for t in self.threads: t.abort() self.threads = [] - + def has_tasks(self): return not self.tasks.empty() - + def get_result(self): return self.results.get() - + def get_result_no_wait(self): return self.results.get_nowait() - + def result_count(self): return len(self.results) - + def has_results(self): return not self.results.empty() - + def threads_running(self): for t in self.threads: if t.is_alive(): @@ -260,7 +259,7 @@ class SearchThreadPool(GenericDownloadThreadPool): Threads will run until there is no work or abort is called. Create and start new threads using start_threads(). Reset by calling abort(). - + Example: sp = SearchThreadPool(SearchThread, 3) add tasks using add_task(...) @@ -270,13 +269,13 @@ class SearchThreadPool(GenericDownloadThreadPool): add tasks using add_task(...) sp.start_threads() ''' - + def add_task(self, query, store_name, store_plugin, timeout): self.tasks.put((query, store_name, store_plugin, timeout)) class SearchThread(Thread): - + def __init__(self, tasks, results): Thread.__init__(self) self.daemon = True @@ -286,7 +285,7 @@ class SearchThread(Thread): def abort(self): self._run = False - + def run(self): while self._run and not self.tasks.empty(): try: @@ -305,7 +304,7 @@ class CoverThreadPool(GenericDownloadThreadPool): ''' Once started all threads run until abort is called. ''' - + def add_task(self, search_result, update_callback, timeout=5): self.tasks.put((search_result, update_callback, timeout)) @@ -318,12 +317,12 @@ class CoverThread(Thread): self.tasks = tasks self.results = results self._run = True - + self.br = browser() def abort(self): self._run = False - + def run(self): while self._run: try: @@ -354,13 +353,13 @@ class Matches(QAbstractItemModel): def closing(self): self.cover_pool.abort() - + def clear_results(self): self.matches = [] self.cover_pool.abort() self.cover_pool.start_threads() self.reset() - + def add_result(self, result): self.layoutAboutToBeChanged.emit() self.matches.append(result) @@ -391,7 +390,7 @@ class Matches(QAbstractItemModel): def columnCount(self, *args): return len(self.HEADERS) - + def headerData(self, section, orientation, role): if role != Qt.DisplayRole: return NONE @@ -434,7 +433,7 @@ class Matches(QAbstractItemModel): elif col == 3: text = result.price if len(text) < 3 or text[-3] not in ('.', ','): - text += '00' + text += '00' text = re.sub(r'\D', '', text) text = text.rjust(6, '0') elif col == 4: @@ -444,7 +443,7 @@ class Matches(QAbstractItemModel): def sort(self, col, order, reset=True): if not self.matches: return - descending = order == Qt.DescendingOrder + descending = order == Qt.DescendingOrder self.matches.sort(None, lambda x: sort_key(unicode(self.data_as_text(x, col))), descending) diff --git a/src/calibre/gui2/store/web_control.py b/src/calibre/gui2/store/web_control.py index b7ab75975d..492f17c719 100644 --- a/src/calibre/gui2/store/web_control.py +++ b/src/calibre/gui2/store/web_control.py @@ -9,8 +9,8 @@ __docformat__ = 'restructuredtext en' import os from urlparse import urlparse -from PyQt4.Qt import QWebView, QWebPage, QNetworkCookieJar, QNetworkRequest, QString, \ - QFileDialog, QNetworkProxy +from PyQt4.Qt import (QWebView, QWebPage, QNetworkCookieJar, + QFileDialog, QNetworkProxy) from calibre import USER_AGENT, get_proxies, get_download_filename from calibre.ebooks import BOOK_EXTENSIONS @@ -35,13 +35,13 @@ class NPWebView(QWebView): proxy.setPassword(proxy_parts.password) proxy.setHostName(proxy_parts.hostname) proxy.setPort(proxy_parts.port) - self.page().networkAccessManager().setProxy(proxy) - + self.page().networkAccessManager().setProxy(proxy) + self.page().setForwardUnsupportedContent(True) self.page().unsupportedContent.connect(self.start_download) self.page().downloadRequested.connect(self.start_download) self.page().networkAccessManager().sslErrors.connect(self.ignore_ssl_errors) - + def createWindow(self, type): if type == QWebPage.WebBrowserWindow: return self @@ -50,17 +50,17 @@ class NPWebView(QWebView): def set_gui(self, gui): self.gui = gui - + def set_tags(self, tags): self.tags = tags - + def start_download(self, request): if not self.gui: return - + url = unicode(request.url().toString()) cf = self.get_cookies() - + filename = get_download_filename(url, cf) ext = os.path.splitext(filename)[1][1:].lower() if ext not in BOOK_EXTENSIONS: @@ -76,21 +76,21 @@ class NPWebView(QWebView): def ignore_ssl_errors(self, reply, errors): reply.ignoreSslErrors(errors) - + def get_cookies(self): ''' Writes QNetworkCookies to Mozilla cookie .txt file. - + :return: The file path to the cookie file. ''' cf = PersistentTemporaryFile(suffix='.txt') - + cf.write('# Netscape HTTP Cookie File\n\n') - + for c in self.page().networkAccessManager().cookieJar().allCookies(): cookie = [] domain = unicode(c.domain()) - + cookie.append(domain) cookie.append('TRUE' if domain.startswith('.') else 'FALSE') cookie.append(unicode(c.path())) @@ -98,15 +98,15 @@ class NPWebView(QWebView): cookie.append(unicode(c.expirationDate().toTime_t())) cookie.append(unicode(c.name())) cookie.append(unicode(c.value())) - + cf.write('\t'.join(cookie)) cf.write('\n') - + cf.close() return cf.name class NPWebPage(QWebPage): - + def userAgentForUrl(self, url): return USER_AGENT From 37798ef22578a8e2cfa5f9709f3ddb7d8bc2883f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 17 Apr 2011 09:02:07 -0600 Subject: [PATCH 13/13] ... --- src/calibre/gui2/layout.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/layout.py b/src/calibre/gui2/layout.py index e5ec5a9131..93bb8ce331 100644 --- a/src/calibre/gui2/layout.py +++ b/src/calibre/gui2/layout.py @@ -317,6 +317,8 @@ class BaseToolBar(QToolBar): # {{{ QToolBar.resizeEvent(self, ev) style = self.get_text_style() self.setToolButtonStyle(style) + if hasattr(self, 'd_widget'): + self.d_widget.filler.setVisible(style != Qt.ToolButtonIconOnly) def get_text_style(self): style = Qt.ToolButtonTextUnderIcon @@ -399,7 +401,8 @@ class ToolBar(BaseToolBar): # {{{ self.d_widget.layout().addWidget(self.donate_button) if isosx: self.d_widget.setStyleSheet('QWidget, QToolButton {background-color: none; border: none; }') - self.d_widget.layout().addWidget(QLabel(u'\u00a0')) + self.d_widget.filler = QLabel(u'\u00a0') + self.d_widget.layout().addWidget(self.d_widget.filler) bar.addWidget(self.d_widget) self.showing_donate = True elif what in self.gui.iactions: