From ee2db5549d3913fd0cc11d3622d71ac74f28bfea Mon Sep 17 00:00:00 2001 From: Jim Miller Date: Thu, 5 Jan 2012 22:08:25 -0600 Subject: [PATCH] Plugin 1.1.0--BG process (not thread) for reliability, true updates, menu. --- app.yaml | 2 +- calibre-plugin/__init__.py | 4 +- calibre-plugin/about.txt | 20 +- calibre-plugin/common_utils.py | 447 ++++++++ calibre-plugin/config.py | 77 +- calibre-plugin/dialogs.py | 561 +++++++--- calibre-plugin/ffdl_plugin.py | 1132 ++++++++++++++------ calibre-plugin/images/icon.png | Bin 23629 -> 24649 bytes calibre-plugin/images/icon.xcf | Bin 61221 -> 63927 bytes calibre-plugin/jobs.py | 191 ++++ fanficdownloader/adapters/adapter_test1.py | 5 +- fanficdownloader/story.py | 12 +- 12 files changed, 1931 insertions(+), 520 deletions(-) create mode 100644 calibre-plugin/common_utils.py create mode 100644 calibre-plugin/jobs.py diff --git a/app.yaml b/app.yaml index a21f6c6..d053fad 100644 --- a/app.yaml +++ b/app.yaml @@ -1,5 +1,5 @@ # ffd-retief-hrd fanfictiondownloader -application: fanfictiondownloader +application: ffd-retief-hrd version: 4-2-0 runtime: python27 api_version: 1 diff --git a/calibre-plugin/__init__.py b/calibre-plugin/__init__.py index a25fee9..d952652 100644 --- a/calibre-plugin/__init__.py +++ b/calibre-plugin/__init__.py @@ -25,11 +25,9 @@ class InterfacePluginDemo(InterfaceActionBase): description = 'UI plugin to download FanFiction stories from various sites.' supported_platforms = ['windows', 'osx', 'linux'] author = 'Jim Miller' - version = (1, 0, 4) + version = (1, 1, 0) minimum_calibre_version = (0, 8, 30) -# action_menu_clone_qaction = True - #: This field defines the GUI plugin class that contains all the code #: that actually does something. Its format is module_path:class_name #: The specified class must be defined in the specified module. diff --git a/calibre-plugin/about.txt b/calibre-plugin/about.txt index 1d11808..3ae79b0 100644 --- a/calibre-plugin/about.txt +++ b/calibre-plugin/about.txt @@ -1,11 +1,21 @@

FanFictionDownLoader Plugin


-

http://code.google.com/p/fanficdownloader/

+

Created by Jim Miller, borrowing heavily from Grant Drake's +'Reading List', +'Extract ISBN' and +'Count Pages' +plugins.

-

Created by Jim Miller, borrowing heavily from -Kovid Goyal's 'The InterfacePlugin Demo' and -Grant Drake's 'Count Pages' plugins.

+

+Calibre officially distributes plugins from the mobileread.com forum site. +The official distro channel for this plugin is there: FanFictionDownLoader +

-

Requires calibre >= 0.8.30

+

However, I monitor the +general users +group for the downloader more closely. That also covers the web application and CLI. +

+The source project for this plugin is +also available. diff --git a/calibre-plugin/common_utils.py b/calibre-plugin/common_utils.py new file mode 100644 index 0000000..19e8697 --- /dev/null +++ b/calibre-plugin/common_utils.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Grant Drake ' +__docformat__ = 'restructuredtext en' + +import os +from PyQt4 import QtGui +from PyQt4.Qt import (Qt, QIcon, QPixmap, QLabel, QDialog, QHBoxLayout, + QTableWidgetItem, QFont, QLineEdit, QComboBox, + QVBoxLayout, QDialogButtonBox, QStyledItemDelegate, QDateTime) +from calibre.constants import iswindows +from calibre.gui2 import gprefs, error_dialog, UNDEFINED_QDATETIME +from calibre.gui2.actions import menu_action_unique_name +from calibre.gui2.keyboard import ShortcutConfig +from calibre.utils.config import config_dir +from calibre.utils.date import now, format_date, qt_to_dt, UNDEFINED_DATE + +# Global definition of our plugin name. Used for common functions that require this. +plugin_name = None +# Global definition of our plugin resources. Used to share between the xxxAction and xxxBase +# classes if you need any zip images to be displayed on the configuration dialog. +plugin_icon_resources = {} + + +def set_plugin_icon_resources(name, resources): + ''' + Set our global store of plugin name and icon resources for sharing between + the InterfaceAction class which reads them and the ConfigWidget + if needed for use on the customization dialog for this plugin. + ''' + global plugin_icon_resources, plugin_name + plugin_name = name + plugin_icon_resources = resources + + +def get_icon(icon_name): + ''' + Retrieve a QIcon for the named image from the zip file if it exists, + or if not then from Calibre's image cache. + ''' + if icon_name: + pixmap = get_pixmap(icon_name) + if pixmap is None: + # Look in Calibre's cache for the icon + return QIcon(I(icon_name)) + else: + return QIcon(pixmap) + return QIcon() + + +def get_pixmap(icon_name): + ''' + Retrieve a QPixmap for the named image + Any icons belonging to the plugin must be prefixed with 'images/' + ''' + global plugin_icon_resources, plugin_name + + if not icon_name.startswith('images/'): + # We know this is definitely not an icon belonging to this plugin + pixmap = QPixmap() + pixmap.load(I(icon_name)) + return pixmap + + # Check to see whether the icon exists as a Calibre resource + # This will enable skinning if the user stores icons within a folder like: + # ...\AppData\Roaming\calibre\resources\images\Plugin Name\ + if plugin_name: + local_images_dir = get_local_images_dir(plugin_name) + local_image_path = os.path.join(local_images_dir, icon_name.replace('images/', '')) + if os.path.exists(local_image_path): + pixmap = QPixmap() + pixmap.load(local_image_path) + return pixmap + + # As we did not find an icon elsewhere, look within our zip resources + if icon_name in plugin_icon_resources: + pixmap = QPixmap() + pixmap.loadFromData(plugin_icon_resources[icon_name]) + return pixmap + return None + + +def get_local_images_dir(subfolder=None): + ''' + Returns a path to the user's local resources/images folder + If a subfolder name parameter is specified, appends this to the path + ''' + images_dir = os.path.join(config_dir, 'resources/images') + if subfolder: + images_dir = os.path.join(images_dir, subfolder) + if iswindows: + images_dir = os.path.normpath(images_dir) + return images_dir + + +def create_menu_item(ia, parent_menu, menu_text, image=None, tooltip=None, + shortcut=(), triggered=None, is_checked=None): + ''' + Create a menu action with the specified criteria and action + Note that if no shortcut is specified, will not appear in Preferences->Keyboard + This method should only be used for actions which either have no shortcuts, + or register their menus only once. Use create_menu_action_unique for all else. + ''' + if shortcut is not None: + if len(shortcut) == 0: + shortcut = () + else: + shortcut = _(shortcut) + ac = ia.create_action(spec=(menu_text, None, tooltip, shortcut), + attr=menu_text) + if image: + ac.setIcon(get_icon(image)) + if triggered is not None: + ac.triggered.connect(triggered) + if is_checked is not None: + ac.setCheckable(True) + if is_checked: + ac.setChecked(True) + + parent_menu.addAction(ac) + return ac + + +def create_menu_action_unique(ia, parent_menu, menu_text, image=None, tooltip=None, + shortcut=None, triggered=None, is_checked=None, shortcut_name=None, + unique_name=None): + ''' + Create a menu action with the specified criteria and action, using the new + InterfaceAction.create_menu_action() function which ensures that regardless of + whether a shortcut is specified it will appear in Preferences->Keyboard + ''' + orig_shortcut = shortcut + kb = ia.gui.keyboard + if unique_name is None: + unique_name = menu_text + if not shortcut == False: + full_unique_name = menu_action_unique_name(ia, unique_name) + if full_unique_name in kb.shortcuts: + shortcut = False + else: + if shortcut is not None and not shortcut == False: + if len(shortcut) == 0: + shortcut = None + else: + shortcut = _(shortcut) + + if shortcut_name is None: + shortcut_name = menu_text.replace('&','') + + ac = ia.create_menu_action(parent_menu, unique_name, menu_text, icon=None, shortcut=shortcut, + description=tooltip, triggered=triggered, shortcut_name=shortcut_name) + if shortcut == False and not orig_shortcut == False: + if ac.calibre_shortcut_unique_name in ia.gui.keyboard.shortcuts: + kb.replace_action(ac.calibre_shortcut_unique_name, ac) + if image: + ac.setIcon(get_icon(image)) + if is_checked is not None: + ac.setCheckable(True) + if is_checked: + ac.setChecked(True) + return ac + + +def swap_author_names(author): + if author.find(',') == -1: + return author + name_parts = author.strip().partition(',') + return name_parts[2].strip() + ' ' + name_parts[0] + + +def get_library_uuid(db): + try: + library_uuid = db.library_id + except: + library_uuid = '' + return library_uuid + + +class ImageLabel(QLabel): + + def __init__(self, parent, icon_name, size=16): + QLabel.__init__(self, parent) + pixmap = get_pixmap(icon_name) + self.setPixmap(pixmap) + self.setMaximumSize(size, size) + self.setScaledContents(True) + + +class ImageTitleLayout(QHBoxLayout): + ''' + A reusable layout widget displaying an image followed by a title + ''' + def __init__(self, parent, icon_name, title): + QHBoxLayout.__init__(self) + title_image_label = QLabel(parent) + pixmap = get_pixmap(icon_name) + if pixmap is None: + pixmap = get_pixmap('library.png') + # error_dialog(parent, _('Restart required'), + # _('You must restart Calibre before using this plugin!'), show=True) + else: + title_image_label.setPixmap(pixmap) + title_image_label.setMaximumSize(32, 32) + title_image_label.setScaledContents(True) + self.addWidget(title_image_label) + + title_font = QFont() + title_font.setPointSize(16) + shelf_label = QLabel(title, parent) + shelf_label.setFont(title_font) + self.addWidget(shelf_label) + self.insertStretch(-1) + + +class SizePersistedDialog(QDialog): + ''' + This dialog is a base class for any dialogs that want their size/position + restored when they are next opened. + ''' + def __init__(self, parent, unique_pref_name): + QDialog.__init__(self, parent) + self.unique_pref_name = unique_pref_name + self.geom = gprefs.get(unique_pref_name, None) + self.finished.connect(self.dialog_closing) + + def resize_dialog(self): + if self.geom is None: + self.resize(self.sizeHint()) + else: + self.restoreGeometry(self.geom) + + def dialog_closing(self, result): + geom = bytearray(self.saveGeometry()) + gprefs[self.unique_pref_name] = geom + + +class ReadOnlyTableWidgetItem(QTableWidgetItem): + + def __init__(self, text): + if text is None: + text = '' + QTableWidgetItem.__init__(self, text, QtGui.QTableWidgetItem.UserType) + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + + +class RatingTableWidgetItem(QTableWidgetItem): + + def __init__(self, rating, is_read_only=False): + QTableWidgetItem.__init__(self, '', QtGui.QTableWidgetItem.UserType) + self.setData(Qt.DisplayRole, rating) + if is_read_only: + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + + +class DateTableWidgetItem(QTableWidgetItem): + + def __init__(self, date_read, is_read_only=False, default_to_today=False): + if date_read == UNDEFINED_DATE and default_to_today: + date_read = now() + if is_read_only: + QTableWidgetItem.__init__(self, format_date(date_read, None), QtGui.QTableWidgetItem.UserType) + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + else: + QTableWidgetItem.__init__(self, '', QtGui.QTableWidgetItem.UserType) + self.setData(Qt.DisplayRole, QDateTime(date_read)) + + +class NoWheelComboBox(QComboBox): + + def wheelEvent (self, event): + # Disable the mouse wheel on top of the combo box changing selection as plays havoc in a grid + event.ignore() + + +class CheckableTableWidgetItem(QTableWidgetItem): + + def __init__(self, checked=False, is_tristate=False): + QTableWidgetItem.__init__(self, '') + self.setFlags(Qt.ItemFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled )) + if is_tristate: + self.setFlags(self.flags() | Qt.ItemIsTristate) + if checked: + self.setCheckState(Qt.Checked) + else: + if is_tristate and checked is None: + self.setCheckState(Qt.PartiallyChecked) + else: + self.setCheckState(Qt.Unchecked) + + def get_boolean_value(self): + ''' + Return a boolean value indicating whether checkbox is checked + If this is a tristate checkbox, a partially checked value is returned as None + ''' + if self.checkState() == Qt.PartiallyChecked: + return None + else: + return self.checkState() == Qt.Checked + + +class TextIconWidgetItem(QTableWidgetItem): + + def __init__(self, text, icon): + QTableWidgetItem.__init__(self, text) + if icon: + self.setIcon(icon) + + +class ReadOnlyTextIconWidgetItem(ReadOnlyTableWidgetItem): + + def __init__(self, text, icon): + ReadOnlyTableWidgetItem.__init__(self, text) + if icon: + self.setIcon(icon) + + +class ReadOnlyLineEdit(QLineEdit): + + def __init__(self, text, parent): + if text is None: + text = '' + QLineEdit.__init__(self, text, parent) + self.setEnabled(False) + + +class KeyValueComboBox(QComboBox): + + def __init__(self, parent, values, selected_key): + QComboBox.__init__(self, parent) + self.values = values + self.populate_combo(selected_key) + + def populate_combo(self, selected_key): + self.clear() + selected_idx = idx = -1 + for key, value in self.values.iteritems(): + idx = idx + 1 + self.addItem(value) + if key == selected_key: + selected_idx = idx + self.setCurrentIndex(selected_idx) + + def selected_key(self): + for key, value in self.values.iteritems(): + if value == unicode(self.currentText()).strip(): + return key + + +class CustomColumnComboBox(QComboBox): + + def __init__(self, parent, custom_columns, selected_column, initial_items=['']): + QComboBox.__init__(self, parent) + self.populate_combo(custom_columns, selected_column, initial_items) + + def populate_combo(self, custom_columns, selected_column, initial_items=['']): + self.clear() + self.column_names = initial_items + if len(initial_items) > 0: + self.addItems(initial_items) + selected_idx = 0 + for idx, value in enumerate(initial_items): + if value == selected_column: + selected_idx = idx + for key in sorted(custom_columns.keys()): + self.column_names.append(key) + self.addItem('%s (%s)'%(key, custom_columns[key]['name'])) + if key == selected_column: + selected_idx = len(self.column_names) - 1 + self.setCurrentIndex(selected_idx) + + def get_selected_column(self): + return self.column_names[self.currentIndex()] + + +class KeyboardConfigDialog(SizePersistedDialog): + ''' + This dialog is used to allow editing of keyboard shortcuts. + ''' + def __init__(self, gui, group_name): + SizePersistedDialog.__init__(self, gui, 'Keyboard shortcut dialog') + self.gui = gui + self.setWindowTitle('Keyboard shortcuts') + layout = QVBoxLayout(self) + self.setLayout(layout) + + self.keyboard_widget = ShortcutConfig(self) + layout.addWidget(self.keyboard_widget) + self.group_name = group_name + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.commit) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + # Cause our dialog size to be restored from prefs or created on first usage + self.resize_dialog() + self.initialize() + + def initialize(self): + self.keyboard_widget.initialize(self.gui.keyboard) + self.keyboard_widget.highlight_group(self.group_name) + + def commit(self): + self.keyboard_widget.commit() + self.accept() + + +class DateDelegate(QStyledItemDelegate): + ''' + Delegate for dates. Because this delegate stores the + format as an instance variable, a new instance must be created for each + column. This differs from all the other delegates. + ''' + def __init__(self, parent): + QStyledItemDelegate.__init__(self, parent) + self.format = 'dd MMM yyyy' + + def displayText(self, val, locale): + d = val.toDateTime() + if d <= UNDEFINED_QDATETIME: + return '' + return format_date(qt_to_dt(d, as_utc=False), self.format) + + def createEditor(self, parent, option, index): + qde = QStyledItemDelegate.createEditor(self, parent, option, index) + qde.setDisplayFormat(self.format) + qde.setMinimumDateTime(UNDEFINED_QDATETIME) + qde.setSpecialValueText(_('Undefined')) + qde.setCalendarPopup(True) + return qde + + def setEditorData(self, editor, index): + val = index.model().data(index, Qt.DisplayRole).toDateTime() + if val is None or val == UNDEFINED_QDATETIME: + val = now() + editor.setDateTime(val) + + def setModelData(self, editor, model, index): + val = editor.dateTime() + if val <= UNDEFINED_QDATETIME: + model.setData(index, UNDEFINED_QDATETIME, Qt.EditRole) + else: + model.setData(index, QDateTime(val), Qt.EditRole) diff --git a/calibre-plugin/config.py b/calibre-plugin/config.py index 8fdb9bd..a91f731 100644 --- a/calibre-plugin/config.py +++ b/calibre-plugin/config.py @@ -10,9 +10,12 @@ __docformat__ = 'restructuredtext en' from PyQt4.Qt import (QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QComboBox, QCheckBox, QPushButton) +from calibre.gui2 import dynamic, info_dialog from calibre.utils.config import JSONConfig -from calibre_plugins.fanfictiondownloader_plugin.dialogs import (OVERWRITE, ADDNEW, SKIP,CALIBREONLY,UPDATE) +from calibre_plugins.fanfictiondownloader_plugin.dialogs \ + import (SKIP, ADDNEW, UPDATE, UPDATEALWAYS, OVERWRITE, OVERWRITEALWAYS, + CALIBREONLY,collision_order) # This is where all preferences for this plugin will be stored # Remember that this name (i.e. plugins/fanfictiondownloader_plugin) is also @@ -21,17 +24,12 @@ from calibre_plugins.fanfictiondownloader_plugin.dialogs import (OVERWRITE, ADDN # so as to ensure you dont accidentally clobber a calibre config file prefs = JSONConfig('plugins/fanfictiondownloader_plugin') -# urlsfrompriority values -CLIP = 'Clipboard' -SELECTED = 'Selected Stories' - # Set defaults prefs.defaults['personal.ini'] = get_resources('example.ini') prefs.defaults['updatemeta'] = True -prefs.defaults['onlyoverwriteifnewer'] = False +#prefs.defaults['onlyoverwriteifnewer'] = False prefs.defaults['urlsfromclip'] = True -prefs.defaults['urlsfromselected'] = True -prefs.defaults['urlsfrompriority'] = SELECTED +prefs.defaults['updatedefault'] = True prefs.defaults['fileform'] = 'epub' prefs.defaults['collision'] = OVERWRITE prefs.defaults['deleteotherforms'] = False @@ -62,13 +60,10 @@ class ConfigWidget(QWidget): label.setToolTip("What to do if there's already an existing story with the same title and author.") horz.addWidget(label) self.collision = QComboBox(self) - self.collision.addItem(OVERWRITE) - self.collision.addItem(UPDATE) - self.collision.addItem(ADDNEW) - self.collision.addItem(SKIP) - self.collision.addItem(CALIBREONLY) + for o in collision_order: + self.collision.addItem(o) self.collision.setCurrentIndex(self.collision.findText(prefs['collision'])) - self.collision.setToolTip('Overwrite will replace the existing story. Add New will create a new story with the same title and author.') + # self.collision.setToolTip('Overwrite will replace the existing story. Add New will create a new story with the same title and author.') label.setBuddy(self.collision) horz.addWidget(self.collision) self.l.addLayout(horz) @@ -78,33 +73,21 @@ class ConfigWidget(QWidget): self.updatemeta.setChecked(prefs['updatemeta']) self.l.addWidget(self.updatemeta) - self.onlyoverwriteifnewer = QCheckBox('Default Only Overwrite Story if Newer',self) - self.onlyoverwriteifnewer.setToolTip("Don't overwrite existing book unless the story on the web site is newer or from the same day.") - self.onlyoverwriteifnewer.setChecked(prefs['onlyoverwriteifnewer']) - self.l.addWidget(self.onlyoverwriteifnewer) + # self.onlyoverwriteifnewer = QCheckBox('Default Only Overwrite Story if Newer',self) + # self.onlyoverwriteifnewer.setToolTip("Don't overwrite existing book unless the story on the web site is newer or from the same day.") + # self.onlyoverwriteifnewer.setChecked(prefs['onlyoverwriteifnewer']) + # self.l.addWidget(self.onlyoverwriteifnewer) self.urlsfromclip = QCheckBox('Take URLs from Clipboard?',self) - self.urlsfromclip.setToolTip('Prefill URLs from valid URLs in Clipboard when starting plugin?') + self.urlsfromclip.setToolTip('Prefill URLs from valid URLs in Clipboard when Adding New?') self.urlsfromclip.setChecked(prefs['urlsfromclip']) self.l.addWidget(self.urlsfromclip) - self.urlsfromselected = QCheckBox('Take URLs from Selected Stories?',self) - self.urlsfromselected.setToolTip('Prefill URLs from valid URLs in selected stories when starting plugin?') - self.urlsfromselected.setChecked(prefs['urlsfromselected']) - self.l.addWidget(self.urlsfromselected) - - horz = QHBoxLayout() - label = QLabel('Take URLs from which first:') - label.setToolTip("If both clipbaord and selected enabled and populated, which is used?") - horz.addWidget(label) - self.urlsfrompriority = QComboBox(self) - self.urlsfrompriority.addItem(SELECTED) - self.urlsfrompriority.addItem(CLIP) - self.urlsfrompriority.setCurrentIndex(self.urlsfrompriority.findText(prefs['urlsfrompriority'])) - self.urlsfrompriority.setToolTip('If both clipbaord and selected enabled and populated, which is used?') - label.setBuddy(self.urlsfrompriority) - horz.addWidget(self.urlsfrompriority) - self.l.addLayout(horz) + self.updatedefault = QCheckBox('Default to Update when books selected?',self) + self.updatedefault.setToolTip('The top FanFictionDownLoader plugin button will start Update if\n'+ + 'books are selected. If unchecked, it will always bring up \'Add New\'.') + self.updatedefault.setChecked(prefs['updatedefault']) + self.l.addWidget(self.updatedefault) self.deleteotherforms = QCheckBox('Delete other existing formats?',self) self.deleteotherforms.setToolTip('Check this to automatically delete all other ebook formats when updating an existing book.\nHandy if you have both a Nook(epub) and Kindle(mobi), for example.') @@ -124,14 +107,19 @@ class ConfigWidget(QWidget): self.defaults.clicked.connect(self.show_defaults) self.l.addWidget(self.defaults) + reset_confirmation_button = QPushButton(_('Reset disabled &confirmation dialogs'), self) + reset_confirmation_button.setToolTip(_( + 'Reset all show me again dialogs for the FanFictionDownLoader plugin')) + reset_confirmation_button.clicked.connect(self.reset_dialogs) + self.l.addWidget(reset_confirmation_button) + def save_settings(self): prefs['fileform'] = unicode(self.fileform.currentText()) prefs['collision'] = unicode(self.collision.currentText()) prefs['updatemeta'] = self.updatemeta.isChecked() - prefs['urlsfrompriority'] = unicode(self.urlsfrompriority.currentText()) prefs['urlsfromclip'] = self.urlsfromclip.isChecked() - prefs['urlsfromselected'] = self.urlsfromselected.isChecked() - prefs['onlyoverwriteifnewer'] = self.onlyoverwriteifnewer.isChecked() + prefs['updatedefault'] = self.updatedefault.isChecked() +# prefs['onlyoverwriteifnewer'] = self.onlyoverwriteifnewer.isChecked() prefs['deleteotherforms'] = self.deleteotherforms.isChecked() ini = unicode(self.ini.toPlainText()) @@ -145,7 +133,16 @@ class ConfigWidget(QWidget): def show_defaults(self): text = get_resources('defaults.ini') ShowDefaultsIniDialog(self.windowIcon(),text,self).exec_() - + + def reset_dialogs(self): + for key in dynamic.keys(): + if key.startswith('fanfictiondownloader_') and key.endswith('_again') \ + and dynamic[key] is False: + dynamic[key] = True + info_dialog(self, _('Done'), + _('Confirmation dialogs have all been reset'), show=True) + + class ShowDefaultsIniDialog(QDialog): def __init__(self, icon, text, parent=None): diff --git a/calibre-plugin/dialogs.py b/calibre-plugin/dialogs.py index 534ef4c..a14c53c 100644 --- a/calibre-plugin/dialogs.py +++ b/calibre-plugin/dialogs.py @@ -9,28 +9,48 @@ __docformat__ = 'restructuredtext en' import traceback -from PyQt4.Qt import (QDialog, QMessageBox, QVBoxLayout, QHBoxLayout, QGridLayout, - QPushButton, QProgressDialog, QString, QLabel, QCheckBox, - QTextEdit, QLineEdit, QInputDialog, QComboBox, QClipboard, - QProgressDialog, QTimer, QDialogButtonBox, QPixmap, Qt ) +from PyQt4 import QtGui +from PyQt4.Qt import (QDialog, QTableWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QGridLayout, + QPushButton, QProgressDialog, QString, QLabel, QCheckBox, QIcon, + QTextEdit, QLineEdit, QInputDialog, QComboBox, QClipboard, QVariant, + QProgressDialog, QTimer, QDialogButtonBox, QPixmap, Qt,QAbstractItemView ) from calibre.gui2 import error_dialog, warning_dialog, question_dialog, info_dialog +from calibre.gui2.dialogs.confirm_delete import confirm from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters,writers,exceptions +from calibre_plugins.fanfictiondownloader_plugin.common_utils \ + import (ReadOnlyTableWidgetItem, ReadOnlyTextIconWidgetItem, SizePersistedDialog, + ImageTitleLayout, get_icon) -OVERWRITE='Overwrite' -UPDATE='Update EPUB' -ADDNEW='Add New' SKIP='Skip' +ADDNEW='Add New Book' +UPDATE='Update EPUB if New Chapters' +UPDATEALWAYS='Update EPUB Always' +OVERWRITE='Overwrite if Newer' +OVERWRITEALWAYS='Overwrite Always' CALIBREONLY='Update Calibre Metadata Only' +collision_order=[SKIP, + ADDNEW, + UPDATE, + UPDATEALWAYS, + OVERWRITE, + OVERWRITEALWAYS, + CALIBREONLY,] + +class NotGoingToDownload(Exception): + def __init__(self,error,icon='dialog_error.png'): + self.error=error + self.icon=icon -class DownloadDialog(QDialog): + def __str__(self): + return self.error + +class AddNewDialog(SizePersistedDialog): - def __init__(self, gui, prefs, icon, url_list_text, do_user_config, start_downloads): - QDialog.__init__(self, gui) + def __init__(self, gui, prefs, icon, url_list_text): + SizePersistedDialog.__init__(self, gui, 'FanFictionDownLoader plugin:add new dialog') self.gui = gui - self.do_user_config = do_user_config - self.start_downloads = start_downloads self.setMinimumWidth(300) self.l = QVBoxLayout() @@ -41,19 +61,19 @@ class DownloadDialog(QDialog): self.l.addWidget(QLabel('Story URL(s), one per line:')) self.url = QTextEdit(self) - self.url.setToolTip('URLs for stories, one per line.\nWill take URLs from selected books or clipboard, but only valid URLs.') + self.url.setToolTip('URLs for stories, one per line.\nWill take URLs from clipboard, but only valid URLs.') self.url.setLineWrapMode(QTextEdit.NoWrap) self.url.setText(url_list_text) self.l.addWidget(self.url) - self.ffdl_button = QPushButton( - 'Download Stories', self) - self.ffdl_button.setToolTip('Start download(s).') - self.ffdl_button.clicked.connect(self.ffdl) - # if there's already URL(s), focus 'go' button - if url_list_text: - self.ffdl_button.setFocus() - self.l.addWidget(self.ffdl_button) + # self.ffdl_button = QPushButton( + # 'Download Stories', self) + # self.ffdl_button.setToolTip('Start download(s).') + # self.ffdl_button.clicked.connect(self.ffdl) + # # if there's already URL(s), focus 'go' button + # if url_list_text: + # self.ffdl_button.setFocus() + # self.l.addWidget(self.ffdl_button) horz = QHBoxLayout() label = QLabel('Output &Format:') @@ -74,17 +94,16 @@ class DownloadDialog(QDialog): label.setToolTip("What to do if there's already an existing story with the same title and author.") horz.addWidget(label) self.collision = QComboBox(self) - self.collision.addItem(OVERWRITE) - self.collision.addItem(UPDATE) - self.collision.addItem(ADDNEW) - self.collision.addItem(SKIP) - self.collision.addItem(CALIBREONLY) + # add collision options + for o in collision_order: + self.collision.addItem(o) + self.collision.setCurrentIndex(self.collision.findText(prefs['collision'])) - self.collision.setToolTip(OVERWRITE+' will replace the existing story.\n'+ - UPDATE+' will download new chapters only and add to existing EPUB.\n'+ - ADDNEW+' will create a new story with the same title and author.\n'+ - SKIP+' will not download existing stories.\n'+ - CALIBREONLY+' will not download stories, but will update Calibre metadata.') + # self.collision.setToolTip(OVERWRITE+' will replace the existing story.\n'+ + # UPDATE+' will download new chapters only and add to existing EPUB.\n'+ + # ADDNEW+' will create a new story with the same title and author.\n'+ + # SKIP+' will not download existing stories.\n'+ + # CALIBREONLY+' will not download stories, but will update Calibre metadata.') label.setBuddy(self.collision) horz.addWidget(self.collision) self.l.addLayout(horz) @@ -94,50 +113,42 @@ class DownloadDialog(QDialog): self.updatemeta.setChecked(prefs['updatemeta']) self.l.addWidget(self.updatemeta) - self.onlyoverwriteifnewer = QCheckBox('Only Overwrite Story if Newer',self) - self.onlyoverwriteifnewer.setToolTip("Don't overwrite existing book unless the story on the web site is newer.\n"+ - "From the same day counts as 'newer' because the sites don't give update time.") - self.onlyoverwriteifnewer.setChecked(prefs['onlyoverwriteifnewer']) - self.l.addWidget(self.onlyoverwriteifnewer) + # self.onlyoverwriteifnewer = QCheckBox('Only Overwrite Story if Newer',self) + # self.onlyoverwriteifnewer.setToolTip("Don't overwrite existing book unless the story on the web site is newer.\n"+ + # "From the same day counts as 'newer' because the sites don't give update time.") + # self.onlyoverwriteifnewer.setChecked(prefs['onlyoverwriteifnewer']) + # self.l.addWidget(self.onlyoverwriteifnewer) - horz = QHBoxLayout() - self.about_button = QPushButton('About', self) - self.about_button.clicked.connect(self.about) - horz.addWidget(self.about_button) - self.conf_button = QPushButton( - 'Configure this plugin', self) - self.conf_button.clicked.connect(self.config) - horz.addWidget(self.conf_button) - self.l.addLayout(horz) + # horz = QHBoxLayout() + # self.about_button = QPushButton('About', self) + # self.about_button.clicked.connect(self.about) + # horz.addWidget(self.about_button) + # self.conf_button = QPushButton( + # 'Configure this plugin', self) + # self.conf_button.clicked.connect(self.config) + # horz.addWidget(self.conf_button) + # self.l.addLayout(horz) - self.resize(self.sizeHint()) + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + self.l.addWidget(button_box) + + if url_list_text: + button_box.button(QDialogButtonBox.Ok).setFocus() + + self.resize_dialog() + #self.resize(self.sizeHint()) - def about(self): - # Get the about text from a file inside the plugin zip file - # The get_resources function is a builtin function defined for all your - # plugin code. It loads files from the plugin zip file. It returns - # the bytes from the specified file. - # - # Note that if you are loading more than one file, for performance, you - # should pass a list of names to get_resources. In this case, - # get_resources will return a dictionary mapping names to bytes. Names that - # are not found in the zip file will not be in the returned dictionary. - text = get_resources('about.txt') -# QMessageBox.about(self, 'About the FanFictionDownLoader Plugin', - # text.decode('utf-8')) - AboutDialog(self.windowIcon(),text,self).exec_() - - def ffdl(self): - self.start_downloads(unicode(self.url.toPlainText()), - unicode(self.fileform.currentText()), - unicode(self.collision.currentText()), - self.updatemeta.isChecked(), - self.onlyoverwriteifnewer.isChecked()) - self.hide() - - def config(self): - self.do_user_config(parent=self) + def get_ffdl_options(self): + return { + 'fileform': unicode(self.fileform.currentText()), + 'collision': unicode(self.collision.currentText()), + 'updatemeta': unicode(self.updatemeta.isChecked()), + } + def get_urlstext(self): + return unicode(self.url.toPlainText()) class UserPassDialog(QDialog): ''' @@ -184,19 +195,22 @@ class MetadataProgressDialog(QProgressDialog): ''' ProgressDialog displayed while fetching metadata for each story. ''' - def __init__(self, gui, loop_list, fileform, getadapter_function, download_list_function, db): + def __init__(self, gui, + book_list, + options, + metadata_function, + startdownload_function): QProgressDialog.__init__(self, "Fetching metadata for stories...", - QString(), 0, len(loop_list), gui) + QString(), 0, len(book_list), gui) self.setWindowTitle("Downloading metadata for stories") self.setMinimumWidth(500) self.gui = gui - self.db = db - self.loop_list = loop_list - self.fileform = fileform - self.getadapter_function = getadapter_function - self.download_list_function = download_list_function - self.i, self.loop_bad, self.loop_good = 0, [], [] + self.book_list = book_list + self.options = options + self.metadata_function = metadata_function + self.startdownload_function = startdownload_function + self.i = 0 ## self.do_loop does QTimer.singleShot on self.do_loop also. ## A weird way to do a loop, but that was the example I had. @@ -204,7 +218,7 @@ class MetadataProgressDialog(QProgressDialog): self.exec_() def updateStatus(self): - self.setLabelText("Fetched metadata for %d of %d"%(self.i+1,len(self.loop_list))) + self.setLabelText("Fetched metadata for %d of %d"%(self.i+1,len(self.book_list))) self.setValue(self.i+1) print(self.labelText()) @@ -214,49 +228,41 @@ class MetadataProgressDialog(QProgressDialog): if self.i == 0: self.setValue(0) - if self.i >= len(self.loop_list) or self.wasCanceled(): - return self.do_when_finished() - - else: - current = self.loop_list[self.i] - try: - ## collision spec passed into getadapter by partial from ffdl_plugin - ## no retval only if it exists, but collision is SKIP - retval = self.getadapter_function(current,self.fileform) - self.loop_good.append((current,retval)) - except Exception as e: - print("%s:%s"%(current,e)) - self.loop_bad.append((current,e)) - traceback.print_exc() + book = self.book_list[self.i] + try: + ## collision spec passed into getadapter by partial from ffdl_plugin + ## no retval only if it exists, but collision is SKIP + self.metadata_function(book) - self.updateStatus() - self.i += 1 + except NotGoingToDownload as d: + book['good']=False + book['comment']=unicode(d) + book['icon'] = d.icon + + except Exception as e: + book['good']=False + book['comment']=unicode(e) + print("%s:%s"%(book,unicode(e))) + # XXX trace for not-expected exceptions + #traceback.print_exc() + + self.updateStatus() + self.i += 1 + + if self.i >= len(self.book_list) or self.wasCanceled(): + return self.do_when_finished() + else: QTimer.singleShot(0, self.do_loop) def do_when_finished(self): self.hide() - - # Queues a job to process these ePub/Mobi books in the background. - self.download_list_function(self.loop_good,self.fileform) - - if self.loop_bad != []: - res = [] - for j in self.loop_bad: - res.append('%s : %s'%j) - msg = '%s' % '\n'.join(res) - warning_dialog(self.gui, _('Not going to download some stories'), - _('Not going to download %d of %d stories.') % - (len(self.loop_bad), len(self.loop_list)), - msg).exec_() - # else: - # info_dialog(self.gui, "Starting Downloads", - # "Got metadata and started download for %d stories."%len(self.loop_good), - # show_copy_button=False).exec_() - self.gui = None + self.gui = None + # Queues a job to process these books in the background. + self.startdownload_function(self.book_list) class AboutDialog(QDialog): - def __init__(self, icon, text, parent=None): + def __init__(self, parent, icon, text): QDialog.__init__(self, parent) self.resize(400, 250) self.l = QGridLayout() @@ -267,7 +273,7 @@ class AboutDialog(QDialog): self.label = QLabel(text) self.label.setOpenExternalLinks(True) self.label.setWordWrap(True) - self.setWindowTitle(_('Update available!')) + self.setWindowTitle(_('About FanFictionDownLoader')) self.setWindowIcon(icon) self.l.addWidget(self.logo, 0, 0) self.l.addWidget(self.label, 0, 1) @@ -277,10 +283,321 @@ class AboutDialog(QDialog): self.l.addWidget(self.bb, 2, 0, 1, -1) self.bb.accepted.connect(self.accept) - -class NotGoingToDownload(Exception): - def __init__(self,error): - self.error=error +class IconWidgetItem(ReadOnlyTextIconWidgetItem): + def __init__(self, text, icon, sort_key): + ReadOnlyTextIconWidgetItem.__init__(self, text, icon) + self.sort_key = sort_key - def __str__(self): - return self.error + #Qt uses a simple < check for sorting items, override this to use the sortKey + def __lt__(self, other): + return self.sort_key < other.sort_key + +class AuthorTableWidgetItem(ReadOnlyTableWidgetItem): + def __init__(self, text, sort_key): + ReadOnlyTableWidgetItem.__init__(self, text) + self.sort_key = sort_key + + #Qt uses a simple < check for sorting items, override this to use the sortKey + def __lt__(self, other): + return self.sort_key < other.sort_key + +class UpdateExistingDialog(SizePersistedDialog): + def __init__(self, gui, header, prefs, icon, books, + save_size_name='FanFictionDownLoader plugin:update list dialog'): + SizePersistedDialog.__init__(self, gui, save_size_name) + self.gui = gui + + self.setWindowTitle(header) + self.setWindowIcon(icon) + + layout = QVBoxLayout(self) + self.setLayout(layout) + title_layout = ImageTitleLayout(self, 'images/icon.png', + header) + layout.addLayout(title_layout) + books_layout = QHBoxLayout() + layout.addLayout(books_layout) + + self.books_table = StoryListTableWidget(self) + books_layout.addWidget(self.books_table) + + button_layout = QVBoxLayout() + books_layout.addLayout(button_layout) + # self.move_up_button = QtGui.QToolButton(self) + # self.move_up_button.setToolTip('Move selected books up the list') + # self.move_up_button.setIcon(QIcon(I('arrow-up.png'))) + # self.move_up_button.clicked.connect(self.books_table.move_rows_up) + # button_layout.addWidget(self.move_up_button) + spacerItem = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + button_layout.addItem(spacerItem) + self.remove_button = QtGui.QToolButton(self) + self.remove_button.setToolTip('Remove selected books from the list') + self.remove_button.setIcon(get_icon('list_remove.png')) + self.remove_button.clicked.connect(self.remove_from_list) + button_layout.addWidget(self.remove_button) + spacerItem1 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + button_layout.addItem(spacerItem1) + # self.move_down_button = QtGui.QToolButton(self) + # self.move_down_button.setToolTip('Move selected books down the list') + # self.move_down_button.setIcon(QIcon(I('arrow-down.png'))) + # self.move_down_button.clicked.connect(self.books_table.move_rows_down) + # button_layout.addWidget(self.move_down_button) + + options_layout = QHBoxLayout() + + label = QLabel('Output &Format:') + options_layout.addWidget(label) + self.fileform = QComboBox(self) + self.fileform.addItem('epub') + self.fileform.addItem('mobi') + self.fileform.addItem('html') + self.fileform.addItem('txt') + self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform'])) + self.fileform.setToolTip('Choose output format to create. May set default from plugin configuration.') + label.setBuddy(self.fileform) + options_layout.addWidget(self.fileform) + + label = QLabel('Update Mode:') + label.setToolTip("What sort of update to perform. May set default from plugin configuration.") + options_layout.addWidget(label) + self.collision = QComboBox(self) + for o in collision_order: + if o not in [ADDNEW,SKIP]: + self.collision.addItem(o) + self.collision.setCurrentIndex(self.collision.findText(prefs['collision'])) + # self.collision.setToolTip('Overwrite will replace the existing story. Add New will create a new story with the same title and author.') + label.setBuddy(self.collision) + options_layout.addWidget(self.collision) + + self.updatemeta = QCheckBox('Update Calibre &Metadata?',self) + self.updatemeta.setToolTip('Update metadata for story in Calibre from web site? May set default from plugin configuration.') + self.updatemeta.setChecked(prefs['updatemeta']) + options_layout.addWidget(self.updatemeta) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + options_layout.addWidget(button_box) + + layout.addLayout(options_layout) + + # Cause our dialog size to be restored from prefs or created on first usage + self.resize_dialog() + self.books_table.populate_table(books) + + def remove_from_list(self): + self.books_table.remove_selected_rows() + + def get_books(self): + return self.books_table.get_books() + + def get_ffdl_options(self): + return { + 'fileform': unicode(self.fileform.currentText()), + 'collision': unicode(self.collision.currentText()), + 'updatemeta': unicode(self.updatemeta.isChecked()), + } + +class DisplayStoryListDialog(SizePersistedDialog): + def __init__(self, gui, header, prefs, icon, books, + label_text='', + save_size_name='FanFictionDownLoader plugin:display list dialog'): + SizePersistedDialog.__init__(self, gui, save_size_name) + # UpdateExistingDialog.__init__(self, gui, header, prefs, icon, books, + # save_size_name='FanFictionDownLoader plugin:display list dialog') + + self.gui = gui + + self.setWindowTitle(header) + self.setWindowIcon(icon) + + layout = QVBoxLayout(self) + self.setLayout(layout) + title_layout = ImageTitleLayout(self, 'images/icon.png', + header) + layout.addLayout(title_layout) + + self.books_table = StoryListTableWidget(self) + layout.addWidget(self.books_table) + + options_layout = QHBoxLayout() + self.label = QLabel(label_text) + #self.label.setOpenExternalLinks(True) + #self.label.setWordWrap(True) + options_layout.addWidget(self.label) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + options_layout.addWidget(button_box) + + layout.addLayout(options_layout) + + # Cause our dialog size to be restored from prefs or created on first usage + self.resize_dialog() + self.books_table.populate_table(books) + + def get_books(self): + return self.books_table.get_books() + +class StoryListTableWidget(QTableWidget): + + def __init__(self, parent): + QTableWidget.__init__(self, parent) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + + def populate_table(self, books): + self.clear() + self.setAlternatingRowColors(True) + self.setRowCount(len(books)) + header_labels = ['','Title', 'Author', 'URL', 'Comment'] + self.setColumnCount(len(header_labels)) + self.setHorizontalHeaderLabels(header_labels) + self.horizontalHeader().setStretchLastSection(True) + #self.verticalHeader().setDefaultSectionSize(24) + self.verticalHeader().hide() + + self.books={} + for row, book in enumerate(books): + self.populate_table_row(row, book) + self.books[row] = book + + # turning True breaks up/down. Do we need either sorting or up/down? + self.setSortingEnabled(True) + self.resizeColumnsToContents() + self.setMinimumColumnWidth(1, 100) + self.setMinimumColumnWidth(2, 100) + self.setMinimumColumnWidth(3, 100) + self.setMinimumSize(300, 0) + # if len(books) > 0: + # self.selectRow(0) + self.sortItems(1) + self.sortItems(0) + + def setMinimumColumnWidth(self, col, minimum): + if self.columnWidth(col) < minimum: + self.setColumnWidth(col, minimum) + + def populate_table_row(self, row, book): + if book['good']: + icon = get_icon('ok.png') + val = 0 + else: + icon = get_icon('minus.png') + val = 1 + if 'icon' in book: + icon = get_icon(book['icon']) + + status_cell = IconWidgetItem(None,icon,val) + status_cell.setData(Qt.UserRole, QVariant(val)) + self.setItem(row, 0, status_cell) + + title_cell = ReadOnlyTableWidgetItem(book['title']) + title_cell.setData(Qt.UserRole, QVariant(row)) + self.setItem(row, 1, title_cell) + + self.setItem(row, 2, AuthorTableWidgetItem(book['author'], book['author_sort'])) + + url_cell = ReadOnlyTableWidgetItem(book['url']) + #url_cell.setData(Qt.UserRole, QVariant(book['url'])) + self.setItem(row, 3, url_cell) + + comment_cell = ReadOnlyTableWidgetItem(book['comment']) + #comment_cell.setData(Qt.UserRole, QVariant(book)) + self.setItem(row, 4, comment_cell) + + # def get_calibre_ids(self): + # ids = [] + # for row in range(self.rowCount()): + # ids.append(self.item(row, 1).data(Qt.UserRole).toPyObject()) + # return ids + + # def get_urls(self): + # urls = [] + # for row in range(self.rowCount()): + # urls.append(self.item(row, 2).data(Qt.UserRole).toPyObject()) + # return urls + + def get_books(self): + books = [] + #print("=========================\nbooks:%s"%self.books) + for row in range(self.rowCount()): + rnum = self.item(row, 1).data(Qt.UserRole).toPyObject() + #print("get_books rnum:%s"%rnum) + book = self.books[rnum] + #if book['good']: + books.append(book) + return books + + def remove_selected_rows(self): + self.setFocus() + rows = self.selectionModel().selectedRows() + if len(rows) == 0: + return + message = '

Are you sure you want to remove this book from the list?' + if len(rows) > 1: + message = '

Are you sure you want to remove the selected %d books from the list?'%len(rows) + if not confirm(message,'fanfictiondownloader_delete_item', self): + return + first_sel_row = self.currentRow() + for selrow in reversed(rows): + self.removeRow(selrow.row()) + if first_sel_row < self.rowCount(): + self.select_and_scroll_to_row(first_sel_row) + elif self.rowCount() > 0: + self.select_and_scroll_to_row(first_sel_row - 1) + + def select_and_scroll_to_row(self, row): + self.selectRow(row) + self.scrollToItem(self.currentItem()) + + def move_rows_up(self): + self.setFocus() + rows = self.selectionModel().selectedRows() + if len(rows) == 0: + return + first_sel_row = rows[0].row() + if first_sel_row <= 0: + return + # Workaround for strange selection bug in Qt which "alters" the selection + # in certain circumstances which meant move down only worked properly "once" + selrows = [] + for row in rows: + selrows.append(row.row()) + selrows.sort() + for selrow in selrows: + self.swap_row_widgets(selrow - 1, selrow + 1) + scroll_to_row = first_sel_row - 1 + if scroll_to_row > 0: + scroll_to_row = scroll_to_row - 1 + self.scrollToItem(self.item(scroll_to_row, 0)) + + def move_rows_down(self): + self.setFocus() + rows = self.selectionModel().selectedRows() + if len(rows) == 0: + return + last_sel_row = rows[-1].row() + if last_sel_row == self.rowCount() - 1: + return + # Workaround for strange selection bug in Qt which "alters" the selection + # in certain circumstances which meant move down only worked properly "once" + selrows = [] + for row in rows: + selrows.append(row.row()) + selrows.sort() + for selrow in reversed(selrows): + self.swap_row_widgets(selrow + 2, selrow) + scroll_to_row = last_sel_row + 1 + if scroll_to_row < self.rowCount() - 1: + scroll_to_row = scroll_to_row + 1 + self.scrollToItem(self.item(scroll_to_row, 0)) + + def swap_row_widgets(self, src_row, dest_row): + self.blockSignals(True) + self.insertRow(dest_row) + for col in range(0, self.columnCount()): + self.setItem(dest_row, col, self.takeItem(src_row, col)) + self.removeRow(src_row) + self.blockSignals(False) diff --git a/calibre-plugin/ffdl_plugin.py b/calibre-plugin/ffdl_plugin.py index 4adbea7..b6b1f65 100644 --- a/calibre-plugin/ffdl_plugin.py +++ b/calibre-plugin/ffdl_plugin.py @@ -4,31 +4,37 @@ from __future__ import (unicode_literals, division, absolute_import, print_function) __license__ = 'GPL v3' -__copyright__ = '2011, Jim Miller' +__copyright__ = '2012, Jim Miller' __docformat__ = 'restructuredtext en' -import ConfigParser, os +import time, os +from ConfigParser import SafeConfigParser from StringIO import StringIO from functools import partial from datetime import datetime -from PyQt4.Qt import (QApplication) +from PyQt4.Qt import (QApplication, QMenu, QToolButton) -# The class that all interface action plugins must inherit from -from calibre.ptempfile import PersistentTemporaryFile -from calibre.ebooks.metadata import MetaInformation +from calibre.ptempfile import PersistentTemporaryFile, PersistentTemporaryDirectory, remove_dir +from calibre.ebooks.metadata import MetaInformation, authors_to_string from calibre.ebooks.metadata.meta import get_metadata from calibre.gui2 import error_dialog, warning_dialog, question_dialog, info_dialog +# The class that all interface action plugins must inherit from from calibre.gui2.actions import InterfaceAction from calibre.gui2.threaded_jobs import ThreadedJob +from calibre_plugins.fanfictiondownloader_plugin.common_utils import (set_plugin_icon_resources, get_icon, + create_menu_action_unique, get_library_uuid) + from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters, writers, exceptions from calibre_plugins.fanfictiondownloader_plugin.epubmerge import doMerge -from calibre_plugins.fanfictiondownloader_plugin.config import (prefs, CLIP, SELECTED) +from calibre_plugins.fanfictiondownloader_plugin.config import (prefs) from calibre_plugins.fanfictiondownloader_plugin.dialogs import ( - DownloadDialog, MetadataProgressDialog, UserPassDialog, - OVERWRITE, UPDATE, ADDNEW, SKIP, CALIBREONLY, NotGoingToDownload ) + AddNewDialog, UpdateExistingDialog, DisplayStoryListDialog, + MetadataProgressDialog, UserPassDialog, AboutDialog, + OVERWRITE, OVERWRITEALWAYS, UPDATE, UPDATEALWAYS, ADDNEW, SKIP, CALIBREONLY, + NotGoingToDownload ) # because calibre immediately transforms html into zip and don't want # to have an 'if html'. db.has_format is cool with the case mismatch, @@ -40,6 +46,8 @@ formmapping = { 'txt':'TXT' } +PLUGIN_ICONS = ['images/icon.png'] + class FanFictionDownLoaderPlugin(InterfaceAction): name = 'FanFictionDownLoader' @@ -50,172 +58,236 @@ class FanFictionDownLoaderPlugin(InterfaceAction): # keyboard shortcuts, so try to use an unusual/unused shortcut. # (text, icon_path, tooltip, keyboard shortcut) # icon_path isn't in the zip--icon loaded below. - action_spec = ('FanFictionDownLoader', None, + action_spec = (name, None, 'Download FanFiction stories from various web sites', None) action_type = 'global' - + # make button menu drop down only + #popup_type = QToolButton.InstantPopup + def genesis(self): # This method is called once per plugin, do initial setup here + # Read the plugin icons and store for potential sharing with the config widget + icon_resources = self.load_resources(PLUGIN_ICONS) + set_plugin_icon_resources(self.name, icon_resources) + + # Show the config dialog + # The config dialog can also be shown from within + # Preferences->Plugins, which is why the do_user_config + # method is defined on the base plugin class + do_user_config = self.interface_action_base_plugin.do_user_config + # Set the icon for this interface action # The get_icons function is a builtin function defined for all your # plugin code. It loads icons from the plugin zip file. It returns # QIcon objects, if you want the actual data, use the analogous # get_resources builtin function. - # + # Note that if you are loading more than one icon, for performance, you # should pass a list of names to get_icons. In this case, get_icons # will return a dictionary mapping names to QIcons. Names that # are not found in the zip file will result in null QIcons. - icon = get_icons('images/icon.png') - + icon = get_icon('images/icon.png') + # The qaction is automatically created from the action_spec defined # above self.qaction.setIcon(icon) - # Call function when plugin triggered. - self.qaction.triggered.connect(self.show_dialog) - def show_dialog(self): - # The base plugin object defined in __init__.py - base_plugin_object = self.interface_action_base_plugin + # Call function when plugin triggered. + self.qaction.triggered.connect(self.plugin_button) + + # Assign our menu to this action + self.menu = QMenu(self.gui) + self.qaction.setMenu(self.menu) + + self.menu.aboutToShow.connect(self.about_to_show_menu) + + self.actions_unique_map = {} + + self.add_action = self.create_menu_item_ex(self.menu, '&Add New from URL(s)', image='plus.png', + unique_name='Add New FanFiction Book(s) from URL(s)', + shortcut_name='Add New FanFiction Book(s) from URL(s)', + triggered=self.add_dialog ) + + self.update_action = self.create_menu_item_ex(self.menu, '&Update Existing FanFiction Book(s)', image='plusplus.png', + unique_name='Update Existing FanFiction Book(s)', + shortcut_name='Update Existing FanFiction Book(s)', + triggered=self.update_existing) #partial(self._update_existing,'qwerty')) + + self.menu.addSeparator() + self.config_action = create_menu_action_unique(self, self.menu, '&Configure Plugin', shortcut=False, + image= 'config.png', + unique_name='Configure FanFictionDownLoader', + shortcut_name='Configure FanFictionDownLoader', + triggered=partial(do_user_config,parent=self.gui)) + + self.config_action = create_menu_action_unique(self, self.menu, '&About Plugin', shortcut=False, + image= 'images/icon.png', + unique_name='About FanFictionDownLoader', + shortcut_name='About FanFictionDownLoader', + triggered=self.about) + + + def about_to_show_menu(self): + self.update_action.setEnabled( len(self.gui.library_view.get_selected_ids()) > 0 ) + + def about(self): + # Get the about text from a file inside the plugin zip file + # The get_resources function is a builtin function defined for all your + # plugin code. It loads files from the plugin zip file. It returns + # the bytes from the specified file. + # + # Note that if you are loading more than one file, for performance, you + # should pass a list of names to get_resources. In this case, + # get_resources will return a dictionary mapping names to bytes. Names that + # are not found in the zip file will not be in the returned dictionary. + text = get_resources('about.txt') +# QMessageBox.about(self, 'About the FanFictionDownLoader Plugin', + # text.decode('utf-8')) + AboutDialog(self.gui,self.qaction.icon(),text).exec_() + + def create_menu_item_ex(self, parent_menu, menu_text, image=None, tooltip=None, + shortcut=None, triggered=None, is_checked=None, shortcut_name=None, + unique_name=None): + ac = create_menu_action_unique(self, parent_menu, menu_text, image, tooltip, + shortcut, triggered, is_checked, shortcut_name, unique_name) + self.actions_unique_map[ac.calibre_shortcut_unique_name] = ac.calibre_shortcut_unique_name + return ac + + def plugin_button(self): + if len(self.gui.library_view.get_selected_ids()) > 0 and prefs['updatedefault']: + self.update_existing() + else: + self.add_dialog() + + def add_dialog(self): + + #print("add_dialog()") + # Show the config dialog # The config dialog can also be shown from within # Preferences->Plugins, which is why the do_user_config # method is defined on the base plugin class - do_user_config = base_plugin_object.do_user_config - - # The current database shown in the GUI - # db is an instance of the class LibraryDatabase2 from database.py - # This class has many, many methods that allow you to do a lot of - # things. - self.db = self.gui.current_db - - # pre-pop urls from selected stories or clipboard. but with - # configurable priority and on/off option on each - if prefs['urlsfrompriority'] == SELECTED: - from_first_func = self.get_urls_select - from_second_func = self.get_urls_clip - elif prefs['urlsfrompriority'] == CLIP: - from_first_func = self.get_urls_clip - from_second_func = self.get_urls_select - - url_list_text = from_first_func() - if not url_list_text: - url_list_text = from_second_func() + do_user_config = self.interface_action_base_plugin.do_user_config + url_list = self.get_urls_clip() + url_list_text = "\n".join(url_list) + # self.gui is the main calibre GUI. It acts as the gateway to access # all the elements of the calibre user interface, it should also be the # parent of the dialog - # DownloadDialog just collects URLs, format and presents buttons. - d = DownloadDialog(self.gui, - prefs, - self.qaction.icon(), - url_list_text, - do_user_config, # method for config button - self.start_downloads, # method to start downloads - ) - d.show() + # AddNewDialog just collects URLs, format and presents buttons. + d = AddNewDialog(self.gui, + prefs, + self.qaction.icon(), + url_list_text, + ) + d.exec_() + if d.result() != d.Accepted: + return + + url_list = get_url_list(d.get_urlstext()) + add_books = self._convert_urls_to_books(url_list) + #print("add_books:%s"%add_books) + #print("options:%s"%d.get_ffdl_options()) - ## if there's rows selected, try to find a source URL from - ## either identifier in the metadata, or from the epub - ## metadata. - def get_urls_select(self): - url_list = [] - rows = self.gui.library_view.selectionModel().selectedRows() - if rows and prefs['urlsfromselected']: - book_ids = self.gui.library_view.get_selected_ids() - #print("book_ids: %s"%book_ids) - for book_id in book_ids: - #print("book_on_device:%s"%self.db.book_on_device(book_id)) - identifiers = self.db.get_identifiers(book_id,index_is_id=True) - if 'url' in identifiers: - # identifiers have :->| in url. - #print("url from book:"+identifiers['url'].replace('|',':')) - url_list.append(identifiers['url'].replace('|',':')) - else: - ## only epub has that in it. - if self.db.has_format(book_id,'EPUB',index_is_id=True): - existingepub = self.db.format(book_id,'EPUB',index_is_id=True, as_file=True) - mi = get_metadata(existingepub,'EPUB') - #print("mi:%s"%mi) - identifiers = mi.get_identifiers() - if 'url' in identifiers: - #print("url from epub:"+identifiers['url'].replace('|',':')) - url_list.append(identifiers['url'].replace('|',':')) - return self.get_valid_urls(url_list) + self.start_downloads( d.get_ffdl_options(),add_books ) + + def update_existing(self): + #print("update_existing()") + previous = self.gui.library_view.currentIndex() + db = self.gui.current_db + book_ids = self.gui.library_view.get_selected_ids() + books = self._convert_calibre_ids_to_books(db, book_ids) + #print("update books:%s"%books) + + d = UpdateExistingDialog(self.gui, + 'Update Existing List', + prefs, + self.qaction.icon(), + books, + ) + d.exec_() + if d.result() != d.Accepted: + return + + update_books = d.get_books() + + #print("update_books:%s"%update_books) + #print("options:%s"%d.get_ffdl_options()) + # only if there's some good ones. + if 0 < len(filter(lambda x : x['good'], update_books)): + self.start_downloads( d.get_ffdl_options(), update_books ) def get_urls_clip(self): url_list = [] if prefs['urlsfromclip']: - # no rows selected, check for valid URLs in the clipboard. - cliptext = unicode(QApplication.instance().clipboard().text()) - url_list.extend(cliptext.split()) - return self.get_valid_urls(url_list) + for url in unicode(QApplication.instance().clipboard().text()).split(): + if( self._is_good_downloader_url(url) ): + url_list.append(url) + return url_list - def get_valid_urls(self,url_list): - url_list_text = "" - - # Check and make sure the URLs are valid ffdl URLs. - if url_list: - # this is the accepted way to 'check for existance'? really? - try: - self.dummyconfig - except AttributeError: - self.dummyconfig = ConfigParser.SafeConfigParser() - - alreadyin=[] - for url in url_list: - if url in alreadyin: - continue - alreadyin.append(url) - # pulling up an adapter is pretty low over-head. If - # it fails, it's a bad url. - try: - adapters.getAdapter(self.dummyconfig,url) - except: - pass - else: - if url_list_text: - url_list_text += "\n" - url_list_text += url - - return url_list_text - def apply_settings(self): # No need to do anything with perfs here, but we could. prefs - def start_downloads(self,urls,fileform, - collision,updatemeta,onlyoverwriteifnewer): + def start_downloads(self, options, books): - url_list = get_url_list(urls) + #print("start_downloads:%s"%books) - self.fetchmeta_qpd = \ - MetadataProgressDialog(self.gui, - url_list, - fileform, - partial(self.get_adapter_for_story, collision=collision,onlyoverwriteifnewer=onlyoverwriteifnewer), - partial(self.download_list,collision=collision,updatemeta=updatemeta,onlyoverwriteifnewer=onlyoverwriteifnewer), - self.db) - - def get_adapter_for_story(self,url,fileform, - collision=SKIP, - onlyoverwriteifnewer=False): + # create and pass temp dir. + tdir = PersistentTemporaryDirectory(prefix='fanfictiondownloader_') + options['tdir']=tdir + + self.gui.status_bar.show_message(_('Started fetching metadata for %s stories.'%len(books)), 3000) + + MetadataProgressDialog(self.gui, + books, + options, + partial(self.get_metadata_for_book, options = options), + partial(self.start_download_list, options = options)) + # MetadataProgressDialog calls get_adapter_for_story for each 'good' story, + # get_adapter_for_story updates book for each, + # MetadataProgressDialog calls download_list at the end which goes into the BG. + # Not sure yet how I get back to display the 'do updates?' list. + + def get_metadata_for_book(self,book, + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True}): ''' - Returns adapter object for story at URL. To be called from - MetadataProgressDialog 'loop' to build up list of adapters. Also - pops dialogs for is adult, user/pass, duplicate + Update passed in book dict with metadata from website and + necessary data. To be called from MetadataProgressDialog + 'loop'. Also pops dialogs for is adult, user/pass. ''' - print("URL:"+url) + # The current database shown in the GUI + # db is an instance of the class LibraryDatabase2 from database.py + # This class has many, many methods that allow you to do a lot of + # things. + db = self.gui.current_db + + fileform = options['fileform'] + collision = options['collision'] + updatemeta= options['updatemeta'] + + if not book['good']: + # book has already been flagged bad for whatever reason. + return + + url = book['url'] + ## was self.ffdlconfig, but we need to be able to change it ## when doing epub update. - ffdlconfig = ConfigParser.SafeConfigParser() + ffdlconfig = SafeConfigParser() ffdlconfig.readfp(StringIO(get_resources("defaults.ini"))) ffdlconfig.readfp(StringIO(prefs['personal.ini'])) adapter = adapters.getAdapter(ffdlconfig,url) + #options['defaults.ini'] = get_resources("defaults.ini") + options['personal.ini'] = prefs['personal.ini'] + try: adapter.getStoryMetadataOnly() except exceptions.FailedToLogin: @@ -225,240 +297,612 @@ class FanFictionDownLoaderPlugin(InterfaceAction): if userpass.status: adapter.username = userpass.user.text() adapter.password = userpass.passwd.text() - # else: - # del adapter - # return + except exceptions.AdultCheckRequired: if question_dialog(self.gui, 'Are You Adult?', '

'+ "%s requires that you be an adult. Please confirm you are an adult in your locale:"%url, show_copy_button=False): adapter.is_adult=True - # else: - # del adapter - # return - # let exceptions percolate up. + # let other exceptions percolate up. story = adapter.getStoryMetadataOnly() + writer = writers.getWriter(options['fileform'],adapter.config,adapter) - if collision != ADDNEW: - mi = MetaInformation(story.getMetadata("title"), - (story.getMetadata("author"),)) # author is a list. + book['title'] = story.getMetadata("title", removeallentities=True) + book['author_sort'] = book['author'] = story.getMetadata("author", removeallentities=True) + book['publisher'] = story.getMetadata("site") + book['tags'] = writer.getTags() + book['pubdate'] = story.getMetadataRaw('datePublished') + book['timestamp'] = story.getMetadataRaw('dateCreated') + book['comments'] = story.getMetadata("description") #, removeallentities=True) comments handles entities better. + + # adapter.opener is the element with a threadlock. But del + # adapter.opener doesn't work--subproc fails when it tries + # to pull in the adapter object that hasn't been imported yet. + # book['adapter'] = adapter + + book['is_adult'] = adapter.is_adult + book['username'] = adapter.username + book['password'] = adapter.password - identicalbooks = self.db.find_identical_books(mi) - print(identicalbooks) - ## more than one match will need to be handled differently. - if identicalbooks: + book['icon'] = 'plus.png' + + if collision in (CALIBREONLY): + book['icon'] = 'metadata.png' + + # XXX should really do a 'you can't do that' dialog when the + # hit 'OK' for this case. + if collision in (UPDATE,UPDATEALWAYS) and fileform != 'epub': + raise NotGoingToDownload("Cannot update non-epub format.") + + book_id = None + + if book['calibre_id'] != None: + # updating an existing book. Update mode applies. + print("update id:%s"%book['calibre_id']) + book_id = book['calibre_id'] + # No handling needed: OVERWRITEALWAYS,CALIBREONLY + + # only care about collisions when not ADDNEW + elif collision != ADDNEW: + # 'new' book from URL. collision handling applies. + print("from URL") + + # find dups + mi = MetaInformation(story.getMetadata("title", removeallentities=True), + (story.getMetadata("author", removeallentities=True),)) # author is a list. + identicalbooks = db.find_identical_books(mi) + ## removed for being overkill. + # for ib in db.find_identical_books(mi): + # # only *really* identical if URL matches, too. + # # XXX make an option? + # if self._get_story_url(db,ib) == url: + # identicalbooks.append(ib) + print("identicalbooks:%s"%identicalbooks) + + if collision == SKIP and identicalbooks: + raise NotGoingToDownload("Skipping duplicate story.","list_remove.png") + + if len(identicalbooks) > 1: + raise NotGoingToDownload("More than one identical book--can't tell which to update/overwrite.","minusminus.png") + + if collision == CALIBREONLY and not identicalbooks: + raise NotGoingToDownload("Not updating Calibre Metadata, no existing book to update.","search_delete_saved.png") + + if len(identicalbooks)>0: book_id = identicalbooks.pop() - if collision == SKIP: - raise NotGoingToDownload("Skipping duplicate story.") + book['calibre_id'] = book_id + book['icon'] = 'edit-redo.png' - # print("Attempting to add to list.") - # rl_plugin = self.gui.iactions['Reading List'] - # rl_plugin.remove_books_from_list('Send',[book_id],refresh_screen=False) - # rl_plugin.add_books_to_list('Send',[book_id],refresh_screen=False) - #rl_plugin.view_list('Send') - - if collision == OVERWRITE and len(identicalbooks) > 1: - raise NotGoingToDownload("More than one identical books--can't tell which to overwrite.") - - if collision == OVERWRITE and \ - onlyoverwriteifnewer and \ - self.db.has_format(book_id,fileform,index_is_id=True): - # check make sure incoming is newer. - lastupdated=story.getMetadataRaw('dateUpdated').date() - fileupdated=datetime.fromtimestamp(os.stat(self.db.format_abspath(book_id, fileform, index_is_id=True))[8]).date() - if fileupdated > lastupdated: - raise NotGoingToDownload("Not Overwriting, story is not newer.") - - if collision == UPDATE: - if fileform != 'epub': - raise NotGoingToDownload("Not updating non-epub format.") - # 'book' can exist without epub. If there's no existing epub, - # let it go and it will download it. - if self.db.has_format(book_id,fileform,index_is_id=True): - toupdateio = StringIO() - (epuburl,chaptercount) = doMerge(toupdateio, - [StringIO(self.db.format(book_id,'EPUB', - index_is_id=True))], - titlenavpoints=False, - striptitletoc=True, - forceunique=False) - - urlchaptercount = int(story.getMetadata('numChapters')) - if chaptercount == urlchaptercount: # and not onlyoverwriteifnewer: - raise NotGoingToDownload("%s already contains %d chapters." % (url,chaptercount)) - elif chaptercount > urlchaptercount: - raise NotGoingToDownload("%s contains %d chapters, more than epub." % (url,chaptercount)) - else: - print("Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount)) - - else: # not identicalbooks - if collision == CALIBREONLY: - raise NotGoingToDownload("Not updating Calibre Metadata, no existing book to update.") - - return adapter - - def download_list(self,adaptertuple_list,fileform, - collision=ADDNEW, - updatemeta=True, - onlyoverwriteifnewer=True): - ''' - Called by MetadataProgressDialog to start story downloads BG processing. - adapter_list is a list of tuples of (url,adapter) - ''' - print("download_list") - - job = ThreadedJob('FanFictionDownload', - 'Downloading FanFiction Stories', - func=self.do_story_downloads, - args=(adaptertuple_list, fileform, self.db), - kwargs={'collision':collision,'updatemeta':updatemeta, - 'onlyoverwriteifnewer':onlyoverwriteifnewer}, - callback=self._get_stories_completed) - - - self.gui.job_manager.run_threaded_job(job) - - self.gui.status_bar.show_message('Downloading %d stories'%len(adaptertuple_list)) - - def _get_stories_completed(self, job): - print("_get_stories_completed") - - def do_story_downloads(self, adaptertuple_list, fileform, db, - **kwargs): - ''' - Master job, loop to download this list of stories - ''' - print("do_story_downloads") - abort = kwargs['abort'] - notifications=kwargs['notifications'] - log = kwargs['log'] - notifications.put((0.01, 'Start Downloading Stories')) - count = 0.01 - total = len(adaptertuple_list) - # Queue all the jobs - for (url,adapter) in adaptertuple_list: - if abort.is_set(): - notifications.put(1.0,'Aborting...') + if book_id != None and collision != ADDNEW: + if options['collision'] in (CALIBREONLY): + book['comment'] = 'Metadata collected.' + # don't need temp file created below. return - notifications.put((float(count)/total, - 'Downloading %s'%adapter.getStoryMetadataOnly().getMetadata("title"))) - log.prints(log.INFO,'Downloading %s'%adapter.getStoryMetadataOnly().getMetadata("title")) - try: - self.do_story_download(adapter,fileform,db, - kwargs['collision'],kwargs['updatemeta'],kwargs['onlyoverwriteifnewer']) - except Exception as e: - log.prints(log.ERROR,'Failed Downloading %s: %s'% - (adapter.getStoryMetadataOnly().getMetadata("title"),e)) - - count = count + 1 - return - - def do_story_download(self,adapter,fileform,db,collision, - updatemeta,onlyoverwriteifnewer): - print("do_story_download") - - story = adapter.getStoryMetadataOnly() - - mi = MetaInformation(story.getMetadata("title"), - (story.getMetadata("author"),)) # author is a list. - - writer = writers.getWriter(fileform,adapter.config,adapter) - tmp = PersistentTemporaryFile("."+fileform) - titleauth = "%s by %s"%(story.getMetadata("title"), story.getMetadata("author")) - url = story.getMetadata("storyUrl") - print(titleauth) - print("tmp: "+tmp.name) - - mi.set_identifiers({'url':story.getMetadata("storyUrl")}) - mi.publisher = story.getMetadata("site") - mi.tags = writer.getTags() - mi.languages = ['en'] - mi.pubdate = story.getMetadataRaw('datePublished') - mi.timestamp = story.getMetadataRaw('dateCreated') - mi.comments = story.getMetadata("description") - - identicalbooks = self.db.find_identical_books(mi) - #print(identicalbooks) - addedcount=0 - if identicalbooks and collision != ADDNEW: - ## more than one match? add to first off the list. - ## Shouldn't happen--we checked above. - book_id = identicalbooks.pop() - + + ## newer/chaptercount checks are the same for both: + # Update epub, but only if more chapters. if collision == UPDATE: - if self.db.has_format(book_id,fileform,index_is_id=True): - urlchaptercount = int(story.getMetadata('numChapters')) - ## First, get existing epub with titlepage and tocpage stripped. - updateio = StringIO() - (epuburl,chaptercount) = doMerge(updateio, - [StringIO(self.db.format(book_id,'EPUB', + # 'book' can exist without epub. If there's no existing epub, + # let it go and it will download it. + if db.has_format(book_id,fileform,index_is_id=True): + toupdateio = StringIO() + (epuburl,chaptercount) = doMerge(toupdateio, + [StringIO(db.format(book_id,'EPUB', index_is_id=True))], titlenavpoints=False, striptitletoc=True, forceunique=False) - print("Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount)) - - ## Get updated title page/metadata by itself in an epub. - ## Even if the title page isn't included, this carries the metadata. - titleio = StringIO() - writer.writeStory(outstream=titleio,metaonly=True) - - newchaptersio = None - if urlchaptercount > chaptercount : - ## Go get the new chapters only in another epub. - newchaptersio = StringIO() - adapter.setChaptersRange(chaptercount+1,urlchaptercount) - - adapter.config.set("overrides",'include_tocpage','false') - adapter.config.set("overrides",'include_titlepage','false') - writer.writeStory(outstream=newchaptersio) - - ## Merge the three epubs together. - doMerge(tmp, - [titleio,updateio,newchaptersio], - fromfirst=True, - titlenavpoints=False, - striptitletoc=False, - forceunique=False) - - - else: # update, but there's no epub extant, so do overwrite. - collision = OVERWRITE - - if collision == OVERWRITE: - writer.writeStory(tmp) - - db.add_format_with_hooks(book_id, fileform, tmp, index_is_id=True) - - # get all formats. - if prefs['deleteotherforms'] and collision in (OVERWRITE, UPDATE): - fmts = set([x.lower() for x in db.formats(book_id, index_is_id=True).split(',')]) - for fmt in fmts: - if fmt != fileform: - print("remove f:"+fmt) - db.remove_format(book_id, fmt,index_is_id=True)#, notify=False - - if updatemeta or collision == CALIBREONLY: - db.set_metadata(book_id,mi) - - else: # no matching, adding new. - writer.writeStory(tmp) - (notadded,addedcount)=db.add_books([tmp],[fileform],[mi], add_duplicates=True) - - - # Otherwise list of books doesn't update right away. - if addedcount: - self.gui.library_view.model().books_added(addedcount) - - self.gui.library_view.model().refresh() - del adapter - del writer - -def f(x): - if x.strip(): return True - else: return False + urlchaptercount = int(story.getMetadata('numChapters')) + if chaptercount == urlchaptercount: # and not onlyoverwriteifnewer: + raise NotGoingToDownload("Already contains %d chapters."%chaptercount,'edit-undo.png') + elif chaptercount > urlchaptercount: + raise NotGoingToDownload("Existing epub contains %d chapters, web site only has %d." % (chaptercount,urlchaptercount),'dialog_error.png') + if collision == OVERWRITE and \ + db.has_format(book_id,formmapping[fileform],index_is_id=True): + # check make sure incoming is newer. + lastupdated=story.getMetadataRaw('dateUpdated').date() + fileupdated=datetime.fromtimestamp(os.stat(db.format_abspath(book_id, formmapping[fileform], index_is_id=True))[8]).date() + if fileupdated > lastupdated: + raise NotGoingToDownload("Not Overwriting, web site is not newer.",'edit-undo.png') + + # For update, provide a tmp file copy of the existing epub so + # it can't change underneath us. + if collision in (UPDATE,UPDATEALWAYS) and \ + db.has_format(book['calibre_id'],'EPUB',index_is_id=True): + tmp = PersistentTemporaryFile(prefix='old-%s-'%book['calibre_id'], + suffix='.epub', + dir=options['tdir']) + db.copy_format_to(book_id,fileform,tmp,index_is_id=True) + print("existing epub tmp:"+tmp.name) + book['epub_for_update'] = tmp.name + + if book['good']: # there shouldn't be any !'good' books at this point. + # if still 'good', make a temp file to write the output to. + tmp = PersistentTemporaryFile(prefix='new-%s-'%book['calibre_id'], + suffix='.'+options['fileform'], + dir=options['tdir']) + print("outfile:"+tmp.name) + book['outfile'] = tmp.name + + return + + # if collision != ADDNEW: + # mi = MetaInformation(story.getMetadata("title"), + # (story.getMetadata("author"),)) # author is a list. + + # identicalbooks = self.db.find_identical_books(mi) + # ## more than one match will need to be handled differently. + # if identicalbooks: + # book_id = identicalbooks.pop() + # if collision == SKIP: + # raise NotGoingToDownload("Skipping duplicate story.") + + # if collision == OVERWRITE and len(identicalbooks) > 1: + # raise NotGoingToDownload("More than one identical books--can't tell which to overwrite.") + + # if collision == OVERWRITE and \ + # onlyoverwriteifnewer and \ + # self.db.has_format(book_id,fileform,index_is_id=True): + # # check make sure incoming is newer. + # lastupdated=story.getMetadataRaw('dateUpdated').date() + # fileupdated=datetime.fromtimestamp(os.stat(self.db.format_abspath(book_id, fileform, index_is_id=True))[8]).date() + # if fileupdated > lastupdated: + # raise NotGoingToDownload("Not Overwriting, story is not newer.") + + # if collision == UPDATE: + # if fileform != 'epub': + # raise NotGoingToDownload("Not updating non-epub format.") + # # 'book' can exist without epub. If there's no existing epub, + # # let it go and it will download it. + # if self.db.has_format(book_id,fileform,index_is_id=True): + # toupdateio = StringIO() + # (epuburl,chaptercount) = doMerge(toupdateio, + # [StringIO(self.db.format(book_id,'EPUB', + # index_is_id=True))], + # titlenavpoints=False, + # striptitletoc=True, + # forceunique=False) + + # urlchaptercount = int(story.getMetadata('numChapters')) + # if chaptercount == urlchaptercount: # and not onlyoverwriteifnewer: + # raise NotGoingToDownload("%s already contains %d chapters." % (url,chaptercount)) + # elif chaptercount > urlchaptercount: + # raise NotGoingToDownload("%s contains %d chapters, more than epub." % (url,chaptercount)) + # else: + # print("Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount)) + + # else: # not identicalbooks + # if collision == CALIBREONLY: + # raise NotGoingToDownload("Not updating Calibre Metadata, no existing book to update.") + + # return adapter + + def start_download_list(self,book_list, + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True}): + ''' + Called by MetadataProgressDialog to start story downloads BG processing. + adapter_list is a list of tuples of (url,adapter) + ''' + #print("start_download_list:book_list:%s"%book_list) + + ## No need to BG process when CALIBREONLY! Fake it. + if options['collision'] in (CALIBREONLY): + class NotJob(object): + def __init__(self,result): + self.failed=False + self.result=result + notjob = NotJob(book_list) + self.download_list_completed(notjob,options=options) + return + + for book in book_list: + if book['good']: + break + else: + ## No good stories to try to download, go straight to + ## list. + d = DisplayStoryListDialog(self.gui, + 'Nothing to Download', + prefs, + self.qaction.icon(), + book_list, + label_text='None of the URLs/stories given can be/need to be downloaded.' + ) + d.exec_() + return + + func = 'arbitrary_n' + cpus = self.gui.job_manager.server.pool_size + #print("cpus:%s <<<======================================"%cpus) + # XXX - option for parallel downloads? + args = ['calibre_plugins.fanfictiondownloader_plugin.jobs', 'do_download_worker', + (book_list, options, cpus)] + desc = 'Download FanFiction Book' + job = self.gui.job_manager.run_job( + self.Dispatcher(partial(self.download_list_completed,options=options)), + func, args=args, + description=desc) + + self.gui.status_bar.show_message('Starting %d FanFictionDownLoads'%len(book_list),3000) + + def download_list_completed(self, job, options={}): + if job.failed: + self.gui.job_exception(job, dialog_title='Failed to Download Stories') + return + #print("download_list_completed job:%s"%job.result) + # for b in job.result: + # print("job.result: %s"%b['title']) + + db = self.gui.current_db + + d = DisplayStoryListDialog(self.gui, + 'Downloads finished, confirm to update Calibre', + prefs, + self.qaction.icon(), + job.result, + label_text='Stories will not be added or updated in Calibre without confirmation.' + ) + d.exec_() + if d.result() == d.Accepted: + + ## in case the user removed any from the list. + book_list = d.get_books() + # for b in book_list: + # print("d list: %s"%b['title']) + + update_list = filter(lambda x : x['good'] and x['calibre_id'] != None, + book_list) + + add_list = filter(lambda x : x['good'] and x['calibre_id'] == None, + book_list) + + total_good = len(update_list)+len(add_list) + + self.gui.status_bar.show_message(_('Adding/Updating %s books.'%total_good), 3000) + + print("==================================================") + addfiles,addfileforms,addmis=[],[],[] + + added=0 + for book in add_list: + print("adding %s %s"%(book['title'],book['url'])) + addfiles.append(book['outfile']) + addfileforms.append(options['fileform']) + addmis.append(self._make_mi_from_book(book)) + + (notadded,added)=db.add_books(addfiles,addfileforms,addmis, + add_duplicates=True) + + print("==================================================") + updated=0 + for book in update_list: + print("updating (%s)%s %s"%(book['calibre_id'],book['title'],book['url'])) + + if options['collision'] == CALIBREONLY: + updated += 1 + else: + if not db.add_format_with_hooks(book['calibre_id'], options['fileform'], book['outfile'], index_is_id=True): + book['comment'] = "Adding format to book failed for some reason..." + book['good']=False + book['icon']='dialog_error.png' + else: + updated += 1 + # get all formats. + if prefs['deleteotherforms']: + fmts = set([x.lower() for x in db.formats(book['calibre_id'], index_is_id=True).split(',')]) + for fmt in fmts: + if fmt != options['fileform']: + print("remove f:"+fmt) + db.remove_format(book['calibre_id'], fmt, index_is_id=True)#, notify=False + + if options['collision'] == CALIBREONLY or \ + (options['updatemeta'] or book['good']) : + db.set_metadata(book['calibre_id'], + self._make_mi_from_book(book)) + + if added: + self.gui.library_view.model().books_added(added) + + # the refresh causes problems sometimes? Switching libraries + # and back cleared it? + self.gui.library_view.model().refresh() + + self.gui.status_bar.show_message(_('Finished Adding/Updating %d books.'%(updated + added)), 3000) + + if updated + added != total_good: + d = DisplayStoryListDialog(self.gui, + 'Updates completed, final status', + prefs, + self.qaction.icon(), + book_list, + label_text='Stories have be added or updated in Calibre, some had additional problems.' + ) + d.exec_() + + print("all done, remove temp dir.") + remove_dir(options['tdir']) + + + def _make_mi_from_book(self,book): + mi = MetaInformation(book['title'],(book['author'],)) # author is a list. + mi.set_identifiers({'url':book['url']}) + mi.publisher = book['publisher'] + mi.tags = book['tags'] + #mi.languages = ['en'] + mi.pubdate = book['pubdate'] + mi.timestamp = book['timestamp'] + mi.comments = book['comments'] + return mi + + + def _convert_urls_to_books(self, urls): + books = [] + for url in urls: + book = {} + book['good'] = True + book['calibre_id'] = None + book['title'] = 'Unknown' + book['author'] = 'Unknown' + book['author_sort'] = 'Unknown' + + book['comment'] = '' + book['url'] = '' + + self._set_book_url_and_comment(book,url) + + books.append(book) + return books + + def _convert_calibre_ids_to_books(self, db, ids): + books = [] + for book_id in ids: + mi = db.get_metadata(book_id, index_is_id=True) + book = {} + book['good'] = True + book['calibre_id'] = mi.id + book['title'] = mi.title + book['author'] = authors_to_string(mi.authors) + book['author_sort'] = mi.author_sort + # book['series'] = mi.series + # if mi.series: + # book['series_index'] = mi.series_index + # else: + # book['series_index'] = 0 + + book['comment'] = '' + book['url'] = "" + + url = self._get_story_url(db,book_id) + self._set_book_url_and_comment(book,url) + + books.append(book) + return books + + def _set_book_url_and_comment(self,book,url): + if not url: + book['comment'] = "No story URL found." + book['good'] = False + book['icon'] = 'search_delete_saved.png' + else: + # get normalized url or None. + book['url'] = self._is_good_downloader_url(url) + if book['url'] == None: + book['url'] = url + book['comment'] = "URL is not a valid story URL." + book['good'] = False + book['icon']='dialog_error.png' + + def _get_story_url(self, db, book_id): + identifiers = db.get_identifiers(book_id,index_is_id=True) + if 'url' in identifiers: + # identifiers have :->| in url. + #print("url from book:"+identifiers['url'].replace('|',':')) + return identifiers['url'].replace('|',':') + else: + ## only epub has URL in it. + if db.has_format(book_id,'EPUB',index_is_id=True): + existingepub = db.format(book_id,'EPUB',index_is_id=True, as_file=True) + mi = get_metadata(existingepub,'EPUB') + #print("mi:%s"%mi) + identifiers = mi.get_identifiers() + if 'url' in identifiers: + #print("url from epub:"+identifiers['url'].replace('|',':')) + return identifiers['url'].replace('|',':') + return None + + def _is_good_downloader_url(self,url): + # this is the accepted way to 'check for existance'? really? + try: + self.dummyconfig + except AttributeError: + self.dummyconfig = SafeConfigParser() + # pulling up an adapter is pretty low over-head. If + # it fails, it's a bad url. + try: + adapter = adapters.getAdapter(self.dummyconfig,url) + url = adapter.url + del adapter + return url + except: + return None; + + + +def get_job_details(job): + ''' + Convert the job result into a set of parameters including a detail message + summarising the success of the extraction operation. + This is used by both the threaded and worker approaches to extraction + ''' + extracted_ids, same_isbn_ids, failed_ids, no_format_ids = job.result + if not hasattr(job, 'html_details'): + job.html_details = job.details + det_msg = [] + for i, title in failed_ids: + if i in no_format_ids: + msg = title + ' (No formats)' + else: + msg = title + ' (ISBN not found)' + det_msg.append(msg) + if same_isbn_ids: + if det_msg: + det_msg.append('----------------------------------') + for i, title in same_isbn_ids: + msg = title + ' (Same ISBN)' + det_msg.append(msg) + if len(extracted_ids) > 0: + if det_msg: + det_msg.append('----------------------------------') + for i, title, last_modified, isbn in extracted_ids: + msg = '%s (Extracted %s)'%(title, isbn) + det_msg.append(msg) + + det_msg = '\n'.join(det_msg) + return extracted_ids, same_isbn_ids, failed_ids, det_msg + def get_url_list(urls): - return filter(f,urls.strip().splitlines()) + def f(x): + if x.strip(): return True + else: return False + # set removes dups. + return set(filter(f,urls.strip().splitlines())) + +############################################################################3 + + # def do_story_downloads(self, adaptertuple_list, fileform, db, + # **kwargs): + # ''' + # Master job, loop to download this list of stories + # ''' + # print("do_story_downloads") + # abort = kwargs['abort'] + # notifications=kwargs['notifications'] + # log = kwargs['log'] + # notifications.put((0.01, 'Start Downloading Stories')) + # count = 0.01 + # total = len(adaptertuple_list) + # # Queue all the jobs + # for (url,adapter) in adaptertuple_list: + # if abort.is_set(): + # notifications.put(1.0,'Aborting...') + # return + # notifications.put((float(count)/total, + # 'Downloading %s'%adapter.getStoryMetadataOnly().getMetadata("title"))) + # log.prints(log.INFO,'Downloading %s'%adapter.getStoryMetadataOnly().getMetadata("title")) + # try: + # self.do_story_download(adapter,fileform,db, + # kwargs['collision'],kwargs['updatemeta'],kwargs['onlyoverwriteifnewer']) + # except Exception as e: + # log.prints(log.ERROR,'Failed Downloading %s: %s'% + # (adapter.getStoryMetadataOnly().getMetadata("title"),e)) + + # count = count + 1 + # return + + # def do_story_download(self,adapter,fileform,db,collision, + # updatemeta,onlyoverwriteifnewer): + # print("do_story_download") + + # story = adapter.getStoryMetadataOnly() + + # mi = MetaInformation(story.getMetadata("title"), + # (story.getMetadata("author"),)) # author is a list. + + # writer = writers.getWriter(fileform,adapter.config,adapter) + # tmp = PersistentTemporaryFile("."+fileform) + # titleauth = "%s by %s"%(story.getMetadata("title"), story.getMetadata("author")) + # url = story.getMetadata("storyUrl") + # print(titleauth) + # print("tmp: "+tmp.name) + + # mi.set_identifiers({'url':story.getMetadata("storyUrl")}) + # mi.publisher = story.getMetadata("site") + # mi.tags = writer.getTags() + # mi.languages = ['en'] + # mi.pubdate = story.getMetadataRaw('datePublished') + # mi.timestamp = story.getMetadataRaw('dateCreated') + # mi.comments = story.getMetadata("description") + + # identicalbooks = self.db.find_identical_books(mi) + # #print(identicalbooks) + # added=0 + # if identicalbooks and collision != ADDNEW: + # ## more than one match? add to first off the list. + # ## Shouldn't happen--we checked above. + # book_id = identicalbooks.pop() + + # if collision == UPDATE: + # if self.db.has_format(book_id,fileform,index_is_id=True): + # urlchaptercount = int(story.getMetadata('numChapters')) + # ## First, get existing epub with titlepage and tocpage stripped. + # updateio = StringIO() + # (epuburl,chaptercount) = doMerge(updateio, + # [StringIO(self.db.format(book_id,'EPUB', + # index_is_id=True))], + # titlenavpoints=False, + # striptitletoc=True, + # forceunique=False) + # print("Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount)) + + # ## Get updated title page/metadata by itself in an epub. + # ## Even if the title page isn't included, this carries the metadata. + # titleio = StringIO() + # writer.writeStory(outstream=titleio,metaonly=True) + + # newchaptersio = None + # if urlchaptercount > chaptercount : + # ## Go get the new chapters only in another epub. + # newchaptersio = StringIO() + # adapter.setChaptersRange(chaptercount+1,urlchaptercount) + + # adapter.config.set("overrides",'include_tocpage','false') + # adapter.config.set("overrides",'include_titlepage','false') + # writer.writeStory(outstream=newchaptersio) + + # ## Merge the three epubs together. + # doMerge(tmp, + # [titleio,updateio,newchaptersio], + # fromfirst=True, + # titlenavpoints=False, + # striptitletoc=False, + # forceunique=False) + + + # else: # update, but there's no epub extant, so do overwrite. + # collision = OVERWRITE + + # if collision == OVERWRITE: + # writer.writeStory(tmp) + + # db.add_format_with_hooks(book_id, fileform, tmp, index_is_id=True) + + # # get all formats. + # if prefs['deleteotherforms'] and collision in (OVERWRITE, UPDATE): + # fmts = set([x.lower() for x in db.formats(book_id, index_is_id=True).split(',')]) + # for fmt in fmts: + # if fmt != fileform: + # print("remove f:"+fmt) + # db.remove_format(book_id, fmt,index_is_id=True)#, notify=False + + # if updatemeta or collision == CALIBREONLY: + # db.set_metadata(book_id,mi) + + # else: # no matching, adding new. + # writer.writeStory(tmp) + # (notadded,added)=db.add_books([tmp],[fileform],[mi], add_duplicates=True) + + # # Otherwise list of books doesn't update right away. + # if added: + # self.gui.library_view.model().books_added(added) + + # # the refresh causes problems sometimes? Switching libraries + # # and back cleared it? + # self.gui.library_view.model().refresh() + + # del adapter + # del writer diff --git a/calibre-plugin/images/icon.png b/calibre-plugin/images/icon.png index 75b2806e15e2c1363d2c69bf9fb7cdfa43ba0c1f..e9715307dd4fe35c686b222262b796828856ff22 100644 GIT binary patch literal 24649 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 literal 23629 zcmZU*1z1~8@F;o`EV#P`Ew06-NGMj^odSj8?iQ?Qad$0R+=~P&TBOCjMO(aR(Z}!q zzI)%h-~E#8IlIa3&deFvotaIvx~e=j1_cHH0N9EOFwN)d2mpZIKv19W;&INmpD!SH zO?erhc9QD%a|P8xSsn&F{r4*DE>C%GL3dFwaDVQ;@ZSd{!;<6k+=%9(s3M29f<;V9 z#A@k$#Q^|NKoKUb?X!H??VHM^=lkO3&dI@fG_bmAq9#y7u`IbNd>WMi`qt~cWmtrS zAga*xu+4JozPt6V&+p&vtBzlX^F^kcj~e;}7cH@lon$HB@=Wtt6GPBBE2ed;G>??6 zck)lV^^YHR6WZ~)*VF&GvFdPLj}z{72XH$L#zx(J*?0_u7YQi(_wqa-*14yGW>=%_ zm93ommXXoETxbB$;)L#-^hw;DFBX7B1rF73Sw1K)z>9qN@cjY_FpSor^*?i5LuKUV zM|a0em-p`xo_XL4MFMCR@5*dTQpn&nfug6IgBk?$LlU^E4@1Y?sx-dj2-i8O#`D;e zz&o1U8}f7Qk5Mhz)eq%BS6$~=g|pJ9SE7n*W}9tET}qq!02R-{X3}$hil$lrC%`(9sWi@$V)6PEJ6eA>QL6{cWtx#&^#hwh+rDXveYr52UvW zCrrDzU}631%V3{pXnXDR>p>$*9m6L)=fTESZOL-?Y`~E!my}C$Lu_0C`88(snW7pstC>+U==HN4T&*!`GT}4VbN9>ij@TEKO=#({J z20k4I9Dt{VDoGeN!7qZNM@^SH)FCx)1gGK5lknM9zIXNuN1%~o`Dt!6@|~1-&CteI z*pt*mByecd_qLb^%RB#HR^R!q?XcBEm*e-4;B4Ve-9G#Siu*cU2o6wBJ?z`-yNn7| z{=`#=hD;=QZ(WT$OVo!pIC0$JEq%ltC**aFynz z!Pf>tfUSI&+s%UDPJ|fG*zIf52Cs*$8|j9Zb>7_^Jz>$07C*RfHqA0(3JlVVxogf+ zoR0%7x;K#gWt-&bf~XO7T;Af&f9JG9C2<+l6iUb)bPi*o@mh^U>3_g-G;I9F`M&g{^_}&(0=AT) z2{k`AIbP0N8Iw!?6RDf?nU3kg{Zp(9@gTsxUNRUVd zq)AX-gE1OVMbJK@0<1W&iO?4y75;j%LI)Q(4}ai(bXIyw9QFdhX~F%86+-R(0O zV{?7fk9IN6kT92T4FKo1PJE}m2pMlZJy%%hn_j!^z-x-|Fd&h&RM5ip9pO9tUXB0-U=yqN~vX~$AU66QOhriXwz==J?%3};!~$|z^}0F77Cm1UW7+PK;C ztEEAW^9xuy6WPjmM8kW!Kq;7rEO~2+7=+IcLuFxYuSm*qU`})(D?dA)%AYWJ$U8wj zM+-Jt~I#y{YYt^1N%Z*B~e61i^)Y+^^o;0NJN&Y^hp&>YLje z>?Cbk##4XtS-oHluugnpR~yP^BAnAgT^M;WbQ2P)f^0Mkj^Xo*dS9f#otE=b>qE5N zkjM<|$yQXlBR$MYuFA?n89PeBJvoZJD5j5|7>^BV_2K)5VlDBP(nlv&XyrO>5Hh1= z{>rPuidsCLDQ~3Wmg_E>%iKhXmp; zo_ONJB+{AFOUEQoA&uTwODlUk+&);4A4>H^Nesj_?a~nWB?|=MOPyGdrtZ+BlN3=& zyP)*pM@dT~Pl1iG@fPTXr;Wo|h({Ztwx1$j-e{2EwN+`72vH3ci^hM4UG0g&T?p~n ztHTD1b;L)Rkm&5=i-^Fe0oZX;yx{o0?dnWRWWTsnLgKec4D%p0Dt1Q*w0&`x<)s8d!*7e7jUJFBQl_P(Rd> z6@kIT?rBo7dVKh8hJd>Wqj5li?5)q#e8|(|;x{4+I7iskWu%Fgs$BUy$sWaH~1et)p)2Qz5^hG2eY{SoO5xx8j{U&&^&^ju1S}1%~S7* z=_KsIz&!l*bON_0^7Jng6rCo|#)DpQL%8j3yRq7muXJARu1pw*cO%_qG6qc58$BJlh*|II60`jefX z?IcvA6)d4p6gtAEK#C}^rWEjDVz8QJY+s!nZB!8FlosWu_f^2ouU?6>-Os^Jz<1_6 zp8>?rf*>D@tKV7g4I+R>SH0-PwEY)l4T?7}(AP($dANJ;$hgnM=rPMe6JjkWAVi`M zB8Q^>1VZ`#=+u1SHJEeyliSLkFPM4YO8H(xQ0YTu4*hI~>REM5zq?ye zMOTAJ&uY34%L1hj3lqTPWMf4E$;jK{=-T2O z&k`@{as3=yM2Sn=<#jor-X9^QYYy#uW3C+52TqNVrq>C;G|*uP*JBqv!^QPnzYyHT z#Y@6(SkB*rP4S+$1zX!6=l622-H7$=LE*4dMe}tp zsCIZ)s<6+l+(dyy$P4RFu7X&un>|8c5^3I$KJ~)I31vkeC93>^33X9=(dw|8@K^^| zh%?m%74WcW0u{%@N{6 zgFCm$UA}8!{81PU(TLLT78Om(7R5}&Qi{sFw=Z08{W6E!;_w5*>H4WI%WwqNz-%4< z9b=X0`D`2$Q|d|B-X21W5Kr%G;ujB)ks9sr^gWiSENznv6beE?$siXBdZH9IsoeCD zM|w=rn#Yvtoge>7_B3}$QUqYk}4dKk)gIM zBS7qvk10uyLfB66({}MyR2amAN%m-hvG}KG2N3_X_Vcnrk?}AvW3we;y)v@$AiF=oQI3A!pL50J2aeFI z?}Xwp1N!2z>e-w>N99csX?ve?ZYDj2m18$(mvIhjoeozUka{eL7|VlVq{JS2tuQF=U1=6O9EtzN?t~W;Yvhh;H+Uej|=*A zKR^ti1QEZr zMV`E+&sspHrdWlUNC_zX?QA1F9UMguwnyGmd6ff4O`5El@ZDEw6M*yPWbBjnD zjdDWJug95y!+Jw2tt`FKxG3@@3z*W-nL^AKTmm2_sle+)qm!yZ+*;8=i=Nsx2SV$5 zF~94{Ktd9c>FMzLLm5G=94vHrtWt|tF~=_nrIVxz0dyHa27z3F{ti{?4GU|C@1#Qv z&r~?ZuZ4dKZ|4)#;~7)yT~Fi1ey-i2b~W!oQ=?j{8TCr_6g{Y)&{ZY=2ExjG+leCg zOGWr|U1l@ym?@Win7_4NJ8}bvy{PvSfYr{$^&Q65;Ae>=fjpfoXb+4wnoK>$bCX81 zOek^msUd*&kB+E1mKiIu5}i5q#Bdz^GH8@H6}1S5SkD%fmYag6H#3+4}8`90wG3__*KX)UB zP02itS#Ja-T;FAj6GaBdE1DwfozsK!Ug6WBYJxc`uVeMgMip#}@m~_l2jFDWHs8qG`~bH^_s0-YzJDm4~>1LRJbEZ?s{TD$+^G8N7%17144Hu_D7a1)u3she!d+-zuO_znz0PFmQaDaxnc=&m9u3->7u8xy; z^(&!}8Cg6mpE2*H3dZ-%Xd>B0nbPEKdd_0j51P4ukl4;C_WbRWq+c+V`3c!b_d6mw z{?VYLp%XDAVwthsXciT>HCm;<+*9a~FIA5rD~sagCf*hBuH0`3vABqjw>bP%l6ibo z_Xm5zC7Q=$htGN{_ru@7JkLtPNvTRjv88lyO!!}tpQNub?9=^gCVm2PZ}i zeyx2|ik)XaKqsZEBB6&?^?X&!>?6u)hs^nSvRh6PMUDDWenAj#;qJaZbse~Ar1>C~xN_dE zbW=Izd=b>cUL2%5TttYV>ApQFiS)=I0R%Efk6VL6{l{y1D~2DC?A6^{?%amnJ^njH z3ihyb^466$_OMrHNeiO&4@W^F5zarfm5yA@*nDpmDog)O;etcyTv@!ku`{&l-%+$o zHDR4Di`DNgmr{E3TrNuYZgm~c6f5OGCfgvi=5?@RZWK0=YEe{Poe$F>Vt(;=W){&P zmgGNhDCSPOkLX$-CMLj9lwf}3Fg0PK%8}wAeSF&tr~1^ZMN$asYCut7S2fl2YUHJ-OcaE2 zLL1@J@z=2&)FS4*G8QnI$V5CSiYx)c{>d<`p-C6^E@J&Gw!WO%?t2E|-uqSz%sHI^ z0|H|z@%j@=dqT#mPrs1F(3MC*tDT-G+NZcs%%81ANxTe5Eq`DJHM*pr55@!LOt`gy@cNdG+;`i2l1dsfkU=qfnUA+EyF8tW} z6PFThjO0>^%dO=vq=GrK8di+A(?PC%kKvYHF+VCZ@vhNTE85`4q_%bQk1fp9 zZ<}qc^GBtfW^<c0hrey~mJpxRKhO z!T7+&No$9p;oMFG6-yUOmu;OftW4=i4VM4siS5CRRYxp+%MyJBRm92ug1eAqW)5i_ z>5fL!U*)sWl{JdI&dW+&nz()ll|J5V5%HfnqcO_@ukhvOPc5?j1n@cWvkVT*+Jk=Q zFn`mCnn(LF#d#vBXWDrY#cdb`LTAf28gwIz8-7H&!w}I z*XMxeuMx8G@TkwS=RL82t%K~Gv8Tol>kzvd{i%RGqFq%vDaDs>D$9oMzJHg3eM}d$ z@~DT=rleuCYmSbrYd@H@nxtJNa>jRSn>ZMJB}$Lk@aIG{Fw51D9@BLE{>i%WAT(TC zSmV>eQ=~U!^?i`_6NiPG8!TJ_;u9#bc(*L}Gvp_F(F&b*9(+mZEVNN|!0s?jrF1~^ z7u9usab)b?*RzK4;7Gy@u25y=r-yGJu~wTgiRXwl6o|fMe>XQMFL;!hoC@2urktdP z7m0Io@v{N%hBUrao-n=r#^yBJls)8!0)Mr-p?tDv=)(jG{_;D`!~W;1bCtwS=SGB1O9C=s~m2-?hvoc?jd4R2XcW?bg|5`>d7AWc?3Q<_6gl4H*2n9>yIQDS%4qK z-n#0_8~<(9G^n>pE9V2h>Zs^l=&!kg%;5H3(r3^BxS8dyk&px$U2)UIy%&JHzO+Rw z=_~t6(#HXdDR^gCH;X9^5)~!Q2t~$=5}wVI>x$r$d5_)>J8eqp9tm%@ky-h1>I$Y; zmcl?8I>TZl$Yi}s)HpB3I*QRteRRcMsQ3WKi}J|#L4C8N07~kJXT0J-8NCtyyOm+z zo=)FywE=Dag2o3fj-r$O5J#p#WHhDSSh={}SJm1y=jLMh1wngwqKu#eb!9}wMU_ea zokCY|I=RFs5PwM)275cS8lGh9UUSZr%p&$$ylIhskqU`Lsk-0&lO1cHMZDg-czc+E zc{^Z?$H<-+O!q5#1WU2=uY7D7-#-&f6!t&5w97_$Kbd2oQEv^FPt(yyROnOX2oh~U zt#U3Lh{i2LbJ?|j$Nd; z-s{s>XA+jyM#u?Dvw&8tg`?z>(~GP|s|wm%jUL_`eDtIrg`%qu)-(Q68B)|y=)kn! z0v#}NBU;c~trm%{6&iGmKV4k>&OmHgx_BpK?Xthu;Uu09lBVhN-?W$+^dbDxIe@z-IF8z;=`^gzwl44Xx+cafg-;4oWM_q- zuK75HWll9YxVVW-A}=y|yM7yau1ifw`Pzmky9uukJ78LfR_5&2zwGlgkTDsV% znf7Paueq(Ux>+PYY~hT~R4z4`a1P{TrkzszJ15yBejQG(au0fq(y9e@xMcR z5-zXFW5sbXyFT?bJo^l>7@a9NYGS@~g=Bky@e`Rz2Iy)mqZ$SD`TC&UmeGDjzRDeOk%j;99L z#M2J{I|;%x8s_dR5q4GjeVXz&-BG=+gS1h9wujyX6RRR!=6m;#jFMmo`0>>IDI0Y- zTlc$BNO;zUAYoPTeR+ds5Yw1wi{^Tc@OD+PtZ|AStI0=`!2)!8m;zh8dRM1m=h>T_ zMiK6SwfZC%?APIoMOj1_4T^fP6U{aXHO7i9#0fwo@!!_8tH^@K4Clf<+D9>k*JuA& zC2La&>{eL3)-LUYsq->Z&=+PoNrjs#Tv`>@3GOwqj7j%aYYbd!*m06kMkC`tKI!;I z%(SkC=eRUxKW~cOwUtiRS+JD-n$xv@%!qFPr0L3OMA4035PE#kTJQ>66GJnM zt(-_T;kbYGoBXqjx>Uq!f~ky4gvjlCM!0?U4TnzL{R*agS9)K=u`mR9ju`ql+)hyH z9{4PcEa+O0y}Kt4V8#+JTez78e^|_r`;RivQx;`}TYmgCBMuO{`@3PTA3BXyoa~{^k@gei4m9 z(iut1!iivRp=nG^&Zj9Ex>nN1YyBuQDJRGKHy)*kEZtm~eB9{4hh589^tXxIuN&9g zyWL*I05wZuiH20pmFHNJ2!-@oB<*NBI-vU$@u5x*&P5K?4DxB=nj z)#W|yNE|W7cM5ZKr+-< zMd`=m=~Jz(U9JvA^gF0_*`h_%${%iEe5k}MgBl&EVN7yk0?jm`n75dQXg5V?D~+OI{u zUJNf|m0~tm}$?Ax#jez>G()Llds9W?Rp_1!&|> zFfYJx6-C*iMZ0DB7?NFg`qjne==1K@4)vAVUM5z}D2xG}W9Nm>7+-WWnGzrfnOCB8`8)kxH+sbfJggS=T33{TxxCm2Nhi|5L1KlwcJa4}_J?hzT!8GH9J zm2igb2MImMy(-r-qCsEjIp$VJ39o^B1^>3fUAm9e%d>Iek$7~pi|Ah+U z;G=4ZK<`$$Ejuz3KM&6J9lmM=jqr`KeMbYf>vo(+uGULBaks9%pV?RBoZQ6xWHz-y zhqOwiQ=hyWvh~qG)-IKSG99wW(8T4wE2FkdftbeX7>#>$W)p(aiNz!%h%?wx7>K~q zwQ!{#__;r>`E8na@s_}KvGWNC)*ALty%@Y7#E|_i-xa>9udm=w6nbRei2@H^C%KR--4P`16^o^}^!9UgCVg4q8UnRN17R*qMx z2o_N!rZLx=gCjSAuSFN7qeHnCjQeMPojs54TMfl(2;YV7fb;FaUY9v;TsG|-he)c zXCHlKUYO?HE28w{YyEK3mXj~#&f0%U{pbm#wkYV-9taRzTWz!P_*}AQ zsuKkP2qgR8B0mh#hVvs;S2>K&1`WGxU0bg0%J6ddOv*hxQ*xt993(8ZkvC=Jw+YN+ z+gU$x!i-B|JR7;%oT^gta}^z%IT@9-f0@-#MyPt=N_llo+`*tRQyzv1~xE64WSAt$-l|8Ee&i|CmUzY`ow}wOJF7Q227m zoHUe#GlwgX$w0i&tF+Sb8yKpY1Tp=vz}9Q3qQ^WC8$Mb0Yy&CWZMKt3u~~dXAt>nP ziJdaZ?DN4hafbtQs_lvfg&YbFNuan#<5FzN%2s==Pi+d0_V22GGS_TduNsBO~Qg&J)eZQjT#cwKfg*#U3K;FOe#J2oCXg0{?O#Nxu=?HzwILI+wHHLTpsRS!CamK8cL>R|&=PaL-+UHIkKcZc3jT`?t444i|T>awFPsiJm7hX)jt-5B(?hUnGnec*hPmLph}bjG8u{ z$rtP%gjztIGo&rjX${5Ca)2odJx;Xdy}o)nToio~`@SS5Knw2DRI9bLDWmF{0?V;S zBV$JyR>>1teYG0hhEuvquk0Rma1s<6MZ*smh^UJLC-cTT^L}RseeCBqTl^`)M(F8Y zt5LcrjIb8JaHfjZz^q!bjR{SiAF5gDZfX=S8ppIL6+KAPP^4eWkH*+yZ`Nm^eZ*P$ zDuycN@}&L(ha`vOFqjDO0(8%b|Lt>&Z>tkX6D?irrFsyF)UxsEtK9a`DU;mJKY%SN z!7Qk(N^SOv)a-pgcjg?I6bV6eW8uj0_=WWG%nM0eKF3IOtWVorH(YLP{wJx>|BBN zW(%hAnPcBr3Xn0Sv)>c+NY!Pwumjz^TZ^pf62ji(H*E89>;A?Xf~^4C zCY&j+3;#PY&UIYm*}V+A%IG>x51-_=Q3j(QB#_Uw@;gHWjrXs)BV~q&fnKnAX4K=t zB_tkuSb9FybT*WbgYm06;X8kUpaHw1HX=4f0a;jq;mMS%CDNdwn3U}>TmQnTH#r}b zm5OV6***^{7AjQFhVOOPpYC%ka(E{DODlyGo;E}x9TJ}G2P`Ry5kB0-sMcLR5=M)X zGD<$G!T||?_%GhSf1VHgL}kz+kPWlD>i@T=l3h&5;}(azlGJH>_2{9%lX^6@#XWd`29Di z1+O~@-Zt?w5D5BvJ8b5r_V4GXEWGa42lxDIH;SCkEOq+0|G`R#b4*GN5%iO1ZxBIDGsw z=-l8Qp+%I)^nYP!Mn}^*M8g;Ph*c>iv#q$xVUK zN#Ai-n{{0u(ty1t`U=t(X`~;#9WD019(6SvYg+ef8VjwQ^j-e-l&{35KZbY~cj5Zs zNCv+rmGt(-2=@2IAocXFE%k5W1u1eK^&B+nm*e6)Cw<_V|^9}!gG z`|TE&kr1f@aK#-Gy7v6#E_8_aM*Z(OI+-bN4>VF-2mst5AAE!}cu^I0LSI~mpJdVC z_1Rd772RA6zJZ!3u=6N^Z^Dx>AEjSiT*H5E59H36UR_?@4TA#0v!3t}V)#PTx9z@- zsL*PWQB`NSJp_N)C@Yg43C~^rTZ*9QvuDcM#DH|r{zr@yc8XuAECGv=M9_cUth3s!FSf@OKcOG6$Z;&*j}A_J+R6E!8l3Do z2ND>B{P-~8=)V+|xll8_3_GWQHmP8$DI{w4^-vsVbJy z>mtj>S@iD~cGJFNdYbu0LVggOL$FMsjw|pmA_q6vM^+If=l5^&L^2mLrfziJa~A%m zM@)HK$3Qi;4OwfhI5Em0gW>AwGAxpgxV5o>K-2ks6o zr0o^y;@qKsHU=C>1hP3GsvI0BDkCZt>i<@UIAP47p2+SBGn{O(L2ww3{*1orZg_H%N0g^?wFPew^ zoL06O7>0Cn{rS(= zxHvzBBpE%P(`QtI)CJ(?1<$W#<+Mxfs-OJ6q8phxQz#x3^xwP7XZ&N+~SmM{a z4Cu8OWJ5~4#7$1oi*}FOY->9a{NH_-PNDx2Y*cHK^#$TK)_WHIzX7yDwz>77P7eot zj{l)TNy}K!>ESh+yKUx7{@<+s@7%o5XOEgUDSlhfyZ2;I>;Jp^p!&*xuvtm<-vjoy z^Y=ap2=vx-D&SP- zNK?L9zovb4Zq$1o{MeBC_W)qN;2ql)q`{k0e!qSPx9a;-d>(`#9d%DL=$bCpryT2?41yCS}vGEw3$Z;BvFCogY zs>hV;r}K{`@@BrV3p zx=y)Zvwq})i*Q;$%Rjt^(kUM$923#KlWrvH+4FWIe`dG5=(>I}jn}>UYQmOo2`>eV0~)kP=&J<=cT($CcI{v!_o& zM}9jqdR=zclSy}-nCp^LvI*5nC~A3a=h~lCAFg&Z`7B6WA4p39Okq4RR^{wfhPUn!O;UsVy z_=+JH^J$aWHB@$wek`;5!TGiKI6myb?BTd63}}U3ML%m3xG}LrKPd6w;NbkYb1&af zh)UdJr3h)|-hsbFq2Fg?n6JS1ZwA2T{qCg55Q8kOH2S-QWs72hYR*D896KEFs{nX6#9(UKf1ZG#ZJiST9%bOU`hA%Cu z3GBDntK}jHbP3K0%d_=Md?I`dmTuNc0=tdSM8&w`r)Z=cjs=p|a>$?tL(8un);*m4 zFRRh6PFCG-ru(|R{g4fR{ zG$;w7;Fh9I(_K$I1PxFJb}20>vE10)+)NJ%0X5jSV+t9!nXpe`5u;Rh+D(~>pzGS{ z5FJ$WKJJDMthnXp)ZO%P4epxKer@V00xWE66T`f|(|H9Q0WN6x)NglH7d#U#uC6+f zkdXF|k6+8nqfrg_{g~rr`Eu`E2G=n+ttZQRUZC;jmVQbNa&3SCY`1^Oe35nrZBaC+aY}1 zl8}sI-zZslK#9I;g1%;go?{A%v1_l=y`Y|JC(-%uM$CCxL@9hPlNY>M3>y9lL}6|+ z1HAzk`!ix*N6Ts8{_8*p9W-3(nIw}1uM!>~gWjmB(#I4NvAiYgZ{+Cm`9qI-=T9o0 zrAgQS_ZfIy_h`)@OQYOdgQhb#SdQfT9LLnu`=UYC_kMsHgX(~Q>7^%5qBI~f?CN!O zb@d5evEkN1RcqW4Iy0jYmyo~^Lm&P&iU*6XEPP8?*y~>0sdwkrPzv^Jl{_Zs*!UBr zeMeJLGCmP;_*!ts_g#I(lx{!Rug%}G`HBl3VYsTwVXS(dpg4&0Ur#}d#?r?qDHuz} z`2Nw6id;1Z+L<`6+N)rkBD2s7eo>fyPgV8RFSem^vBx=iFXy`hZpr03(tLIvMePF` zC?zDn?_`x3o2Nvh(#V~G9- zaU4=~9B=IESKEk&P$biG{@D11#+JF2<}9F0Q@Tu3Q921lzBn@ODAP>UbYD*VZp}Sw>1AF2bB$8+)@-WbcPpj$ zg)=J^fLNkShDk~r|S#8$vg|^|T z0#m2F8oyy)Oo{-909hL8S^+^IhaWRUpQ?$yyUv5<>a{u?z$!&|(wu4-UjK9Z%P-Ca z^kH_ewvl%o->c}qhd0?R`uGqfRyai%5N|5PXJ;_4R+?(y?);pB+5_S9(LCrER5241yXYF$d|4!qR>HMPna?^GfvWAWYMh!<~IAag)#X{I$3?QWrV^!kI8e;YsdLDjLVWCKw z6R&MiG&(BW+y^5mA<(eKm$K+4J(UL2-#%c0e_gKkN0Ds_!2aB>DK2x7(;7G&4{g>2 zW59(TGuh9a@q}|6%s|J z)GS)4hC|*N)H{j=uFabvYV#PB32eriwlZ^sSEytiV`Nw7BgZMZIWUzfiLkKk#FFNf z$@v^hC`yMsFza9Udln3J`Hl|-z5bjQAaam8H^Z7h)|y>4TgQW=H2^VM7<2bEY* z5J=08(7h@-vm@YFc9fP5ew|mffZRb&nbnUo7(BUUxaa)zif&NxU0#&ConT95OFs7_ zAN&AxKXUzGb`@AaP`KE-wkga=k1P#;UbU53=;-1mY5w}XWeH((cX6lY)H`IUfU>AD zuuLJiRfd>kGHMNkjyt}eeH%ogE3}%`7I{Gj4B6@jH4Z&TQS(_vQ%L{fO(O%LwWIfVxvE7LtozjFDM<qfTef_s(M0EPM&wQGVg+ z8h3Z=23P<|+SoP>F)BS(TzZmXk0YK&@=+DBS!j5q?u*Sdo(+@1PwyUXt;jySwK&EO zE&QsHi4v{(EVFbwT$f|FXH8lxcN$s|?JX8-)`eT4F`f^@N3?a4tz(TG6)fN;2jRMU znB6yffu7zk>Wf0l3niXigS3sTcLufIVr?*Ea&>U~nrrU*vuT_<|3x54j?cc7NiG9P z9e=05`gX;u(;yvXDDI~usjNiDm9C>{mc``Jiq8rKH*#MiB_4U#{ND>nI(uMb|N@$BQO$o)s#00zOL{q7~Hdf2B zub916^C-tcFEF5g)UNB{%xAn2|AWRr1Hl>F%ustsY$b)N&q`6!XTyLasl%76sk^2P zpjx`KNb&q{%4%JIzKI`5;}up{vvaN|3S5H`4Jj!fE%1RC3lSC@UXT?>zK#r$hMC8k zdHQT6StaBmROC@>Bf(;x_n^hE=O?Sn{Id&b2jF_l9N|MN2IGs&UFfSM&Gazn$ctx_ zQ}@mxeM`9B@k?cgw?k`O^b(#?8y157;=lK(cSyLDuQk=oO{I=zjh=Vc&N75ysSs`? zBh?ViW>SUiY-Q{T5Iw&W5#A))-KRgxvt2@FV5AQe6@pjCGsvZWzTS z?NL<`zdE@BoW)SRsHFE0TFsh8S0cMrrWeA`Vg^A4rw+V$xc~$RGjglaPM8(7?Wj4|@T}(({1s!ux zgc1y^(qD{^=P!O9%$g%BKz=(P>}&wmloKswBpIfFHFv5&!2DYuOpq8_-+2~MTw6cK zCY{|+O5TzQ&dL6nu->~;kc*xSS zEIp1Zko!VAwkY%sck^vI7`}3^I zGbncK+5LXdc2&}v8TNc0`H=-?tT&J5!UuX@l~qP|8SqB(VA;1T%x~w6@0|Xs!~~Ge zQ%ECd3M$SLVDJVMfZv#*1?%GsIY)Xqy_b0|i? z;qluq3Ne3%R2n?~&eG>{toM#Zw_XtYe?SxS!@JgA@=(;aKr$I;dbrKhjA@cg(zVbf z`>_A#PZhe@XpG@2kszFmDe;Bx!P8FbukR4=^Sv9V2fe6q-)=6Hq9UdKG59R=*6`CF zfwXl{9TTLDhU)l-g)UA`jrII%@JJj<0~gSq`f+@WS!2$BE=^OwAWFcOUh`*kd4bW) zU~R$b_>7sMiUWP%cANIZwl8>Xxh~jJ^K~@I;d%E8N(NiFwz@bh?AGr@qBJoy zi27O`#og`vY;T7N2D8%Gu4)g83AF9Tqx^8#(v;4BVLQ6;(N*+O?24odcX_`D5Okc@ z(b$6Cf+$jrkBQ{~3^U8W=`qomE2C%XR;Kj-2^JgaiLBbP?2mp{`$Rx+li>3nL7{{mD?0aR2z+hNNw&_(Bbd(dPp;WNTP3K{UHel$>;NU z?1jm;{S?hsZMDFvy`1p?!F9bFQ3@bfmW{yomwkQ`0#PKJj_)qIg*_wPeE<$%6D3h? zL^*OSG4}#M5_*z|SpXV<;`=`O`}>;)E)o%5x-e5`s%q z?vZR!6u}P}7p|1rp4hxXU@SJ?^*mJkN;;kZIF_B(1#6i%vQSbM7?y3t3VMrjt2gZ* z+y-DLsx7x|1CaGcN%;FbfHPSRlJ%@yzL&wwl>AU$wdAs=Zi&?D~oAxE`V?M3m%r z0C2q=Dy8xQoM`ca5JDR(RFevj>A7vfP5TBDbML0;M72?E*=hJYfD|09yz1F zLIFQ`cKoU;=8GxTUOtB~j6u#S)|o1m%7W>?O*vVU^T#;wMc5?E<{IV45b`|r*CU!noK)GDy79GV7%+Jj% zUBqi5BHFfHo44L#JeYCQfuVi?H)9hdQElXSnnVxz5dcm&NEn8xf1`d&6!OHoUcb$i zCFaUuTa$ZfU25AH4rCsA!=fFxlj5r7l*)~r@yRv3op?(S~dL!?kB z;OUdoE4Ns+sH(Wos_o@+ah#Q6xQ+`cW4&rM+PXQm-K1Pl>+u4>;eGwt=-v%$?%l`$ z$)@2x4ggs(D_dMGTgV|X_cjx;m>K7;l$uzV+6X0A3w4iL+>%wu7t&CY?(QB;PE9Ro z{6gn<+`771ZH&KpU+)$G*J<%;!zNK}^qBr64fja^GHdS5a*-@hk{VOCZPzI3sa1;w zE?lX+tX*nj^KK5M`8n9O4G19cE30;X$91bA^4uc5x9)HIP;t26+5L#S;<^SP>ywi3 z_i+H{6R&y#lzI-5Y>8R7S4>I*td&4q}hf+eV_;?wxy%DPV#3q_2NkE5rz50jIV`fy7~zgpl!*NrPO z);IHPXZ(TDj_pEVn^LK{cf%!7ZHBz!q<_dU0Dji1o`jvW;v}u~5ZiX}#PR7RD8`F- zj%qdD5*EB%oIO}56fiqGvqHtZR>bn$T#W^*q4U)O<2&+pVb`{fApqO4L6fL9LwzF& zV5`2?+W_=gmIcRg;5ZIE&#S4Mo4h8vNV!~2<@Cg%<~RUTl14 zR~Nio9#Tn^O7p0c%ZqcNC7gd?Z~*hAd6YB9ZQVz-uHPYm`I4M__Vn~6054zzcB0x0 z97G`wUSt5z)SG*=UiB=6VTg{7jv5Yjt>++0#c8kK`tg+oYVNMQEt_T8$mMcy zb2(fYA6xa#ySZE*%yIp&R&CuD+bjVb+TFVq)#%<06pmd_IqIxw7IuCWm%)4Iz&1 zb+Eybs5VOp$t3)}AF)^cP`1@U7>2MctHF{Lh9UC#d=2Nwf@OL(sP_503jGR;9UWk1 zT)K2|(SC+Y?srIpLZOJs$tyLL-IqhVwiHJkM{Es4mCKlWH-yP|vv3fgS`O%k066Pa zZzX0Wo@U2!nmP?396Wn^c18UP7iz24+1Z7Oi7O4AqgJD>o9nu;EP+b7vf{RIo-5q_ zBb{4N?TEW!lBhNlKqmF7c3!_sc`?3lz`u@W`{1D+0zsJqL670xn;=d{x@I z6$=G`fO5IC;u}i<4(;yVf;d-wLnTpdCV(W>-$TT?>NO%(D=w1M?yU!hQVOB2ip}%g?|kQ+du})y#WW3yrNY7GDK9aCp|G|0qP@w^%Z@h(qWxH#?08O7 zH0vw@#JLQ20@&9<@gRVR`YUnY?P_Tas2M6vK{01}NvkR*vxsdTKL+t(533n;!WAjByt zJ2`6r!J$Jw7i=Ne;IMG7C~$NN?Phk}lv&<7p4s@^xgi=xom#zdYy$czfJEaMhJkj> zpRag5o_Eei2C%}tr(zM+Sp;XK~COqmcLI6 zc5s5^AO`XId`*|j$t1e2qt#4wKp;pmm7r27BY7l>g;xWRSS*etLGhUNha`DO z_KxsiWxEEHZQIg0;TiR_2oQKu{{EShAq0c8%3C-~G+HGM@pu`VdvP~wXn2^-jZIrX zL;h6>^jI7uiPABIkY(9NFq)!k@J$kfm$)*qWR6d-+y)-o6VZuZJmo#30Lj|_yaju< z3%Z-cITug~kO&rW*G7r+6AyOz(%Z=wV2=P@UEO?s<2sivzel-JK|g@WUS#yXGjwcd;L_UAS zyp1HG`ojdH9Yo^YR2n9$>lsF`&$Cx36HcTUx$q8`J{aWY)n8H5N+iRc;oXuRJuQZE zRskY*p-KUhSW8CtF^K5X>vXZia$C{NL7Wq-4oyjBJzJqK6*%&R_*IqOzCOlod`2Rf zz#~cI3wy`^7faC7(}U!ZC>)}1ylQ|@BuOOEgX9a7&gPhS@QC?^aU_pS&)^0A?0cUu zICQ~U%%v#Yy8dgz(Kz{hj%dI<2lWeBZI-27(w zPj8rM7zRi(pG{}^{#OQ$&K~DS*valtDwP;GcMc?pLjL&mM?9Xin&BBHxm@mvj_@i3 z!f~RhJ_6xRa>Wu04y>L`1K0;$ek-v8oP`PL8rt_cWnh{>X(tDA5niqEHD=z4EZ z@kGltV-PE(Uc=fqvJH?EHk{?}c_8ZSpeVYSxQqEj6|bggP4*CnEX&NU96kD^sw({h z1Kb)P2Oyc~uqt?8L5WFkZx1FC>CFuke}rUD57AUFT3zDt>IT=Z&ak?=hCdkL;#=?Y zqo4j0kw^?xRrdkH=kww9dhz*u`2Bu-J|EpZeS{-%JRTWIlBDy49bcG!m|g&)SfxFl zlPcEFGC)N6y8aN&x~}hYv-WLxX}AArRmQG049p|OHiw4KQ!EzA<#th3m2f!3 z?(WX9RoDt05s9UU#=EE*e&pm^c#SMKXXkk^H;NAQ4qo7ozxh}6pBq6{Ra8}N0zsB# z6h*=7^)`Xx^ZA-JYNlxt2!`?cyi~O^sg7>e=F?I37b>3EQ14j=h$w&W!CGbbu5hyi zNMu=lszhD%%#vlf84?yuVtuHG+w;4;e=%|Bmn%LWgF{2yy)$XCM=Axt-rmdW51$$& zoaiFaIY=LshWCAx!2%v~V6mtsq)9dw; z*;;4n)(!4W-lU_mmqOJvGWp7^&B`afKh{|Wh|nR}0QoWCaW`jZ8h(ove^2)3aO6xR z4>xAA&jiTu`4Os`MrO;pgk-V<$>U)!_x!mOuTLQu?IhlLj(A5uTHR!6`hYE_MUlVeQYyu$S482Q`|p-7Bq zvWMlzoBHxv`c|cCY-9Bxt2u%qZGfD>AGDnVp%z!~$D)Pmd*xc5~0XXpcuG7)=uE7$ng(ghvUpmCi7BXNr5XzrxTBy8B<_ zOCSA!?+l#Zrz2XqO#&FIs_v^K5K+73#rxd;{0jGOea6c2Ji$-|L-LZ%=Ge;Q^h|a! zU#pv$V%4~#)r<+OKFC$9vYrO7Y}r>EApc)FB!ty7DPa?`vknk!9Wn@vV)gEA z)>k>jxHH@gAMgEwJpZijDwsjmflco})^H5L~ZnjO^#hlY@l$Y!^nXcT{l zNTQci-w3ge0jf2FM@vgw`R{8iE-s?@LtJ?4J-++bKWphW2~b$wOXPhF;%>ES7&H*7*H=wbs}!*UiO}rr)vwk#R6W*#<+= z27=~rtE%>4o^<1A1H}4=7O-OV89S=gQir(3x|oSuB^1lmrzcL2EHm=j1y&y~qw5Bd zL^l`Tx`bu~FqIg|ktEYI)BNJUeoiKxCLHhJ?LYW3fBxg|5C{Yahr!9@Aug?k_{Zk#Vb9cJ7b<78%Dwc~nN1fR zP)2(E{QXb9N$222Qr#oSUYYgHG-KoA+?l+CR?&!e_3`HAuaNBMCJ+b^3g@ z-Q9gnu`O6afJ3-+0Nd$JZhihKvr{)&Uc65z5=Hj<$!zbiw7hDpuW#hET5YFXW2va= zlQtkWZG*=KfdE9=;VOx8k%ogM&ctXlh&3|yKmERK42p2Ggz>q>x-+XTqFv4~ZX^;x ziKjSs`5R14P4aL5@vm%dZr~5a7{2%(fAsYqHGv?2;rILT`~8GMp(ZOh8A3#z1SqOn z#S+&O*LW~<2SaZViKWQpi>ze|tW3<7^7-OUwPCIowZ>FkXW0hDp6v$J9e^k~^n(C| zlO@cvXc%n2qCw7?kU$FKxY`RynEbmJg3d0o(tQ18d8jTW(M9_7ed_Iq+X(Xa5 z7yDy$4~#JU#s?^>+604Y-xo(H6hcvy=3_-s@caGEvU~CU%ZvB9b>mYW-oHy`YmIoS zn_69Gb#0BsrImU*o!+U|$&{=5{YuRgZd2Aadh-CcNr0l}Ukv`?e$L4h zk|Ys{L;xrji|D#eG#Vuqi=ikA`Fx&yK96ad3=R%*`SN9YdwZKq;P?CYxlF36HlK3> zM1VxFgvaAyZ#T=G@lmGljIq2pM>rD0tA3V|))05o3d5z`y8ETCxu|y|(dj%Gk zmwEW`Q6-nlZ`bPPMnP-bv)v|Dy#8 zngBNa-0EKpdxYo~-xNZaF_=o7a z=)-C{@VeJ)^|0OE-sbY<%f#dHCJ5ZXa2~3vHbr+l9&hF_gxIdvs@(qkQ|{jSjE%J= z{DC0;K$PvB9J33L&87dnx>qV!v(=ihT&U>Nh6RW%hq%_*pRIPfOs6&{(FVxxc9=Fm zda$}Kz8;B0$Ye5n{PD+4;Vg>!1WN=00en7R^Km2+Argr+^A{pSySg;bz1uffU7Dw= zm5HUgDHcmCt!0>h@KDQUvzc1m+{$alOkFobv`K8&&~QbY+-}pE$Vs#T^1B~fz!$LU zvVOR?w@d12w##bT+FsWyz|d_|u%3^sU@lQ3+f zcP8lsJG65WZGik<>5yHlmYMf~p{=bgdU|@AE|U=6e!ristu3xyyT<6~D5ImJ?q<_@ z(==EA{TIK?)f(o*THSbTv&6P-@VWyKMF$YtIf*tvey>LwtMueL@Qu57?@H-(nh!tx zkXo(A+}s>DZ{Fm}l`Cv)Y&hR%VD;GAv~O2{jbcU5*Z^=Q>B=s4sJZ|l#Io=6lHi$!<$_ksfy>pV%)?b$9--PR8U z8x#!}LkPD?11o2evE4EuuFLjOvH{=(L(Rbs9_*U0=LCWI3+C;)}Mi*KI(EMMHHLAQ~=)c+r{vAfv{i zQOsjLjF)&_8z8?2Lv|hB7U$exX$2g)1&btsH|$wna{X2U3}qJ(oPf|<817MK`{yxs zx1t9|k5TSiC?Ot41rrlTd8cp)VQ^x5-)X=QjA4G2)}1 zH;(Bwui~>$^bCBGhvAxW4~yVPE^$r^{o!!|$mB`+hBM;i1c`Bk@8AHfaoFSM0n>39 zx$&IuH#h^X)@h4kIkkw_#T19RUx&F~P7q1YFn;p@!F?RU*x=QZj7^(mnVu`L76V1ZCMzDz>Rwp2r0nA;T&SAou2IQRqbC1uy6UfBqGkNLTl6poTkfA^+Mh1p= z<_ru!Cj;?(AYKj94Uq%Mzq<^?*BKbzgYa!2zR$q$0fZj|@pA@-k0AUSh~G0Xd;;Om zK>VG7;WG&T2IBt=3}1lwD>D$YGcbGuVQz+chHv}~4BtUm7>LCg7=D1TG!V-J9Rb41 zK&%eL;XoV>#PLAf4{}<)0Laz0|0%)slxU`c-g=5$4{`u76u_ACzbOOhxSkwMRMb;X zmaDNlpdOgCNKF$%KmM^LLgLfJaEgCysh?1q7@qM@e3}^U@lPmCjGXw#o+j!ksXVY3 z!`Olxrx-QMf1H-!sblJ4ng|sX*n*D=VwBhalsTdvv-w1PghVHY!LB jVxYeNAdGAl4mN5K)-y~1HEfqbF^C-u#UQpO6f*z-qcFCz delta 374 zcmdn~nR)3wW})=V+yaG)MBQVY4jLb0t7n#VO0V0&VaecXWt2AVmL8*>D!Weh7$}7j6OgqMh1p= z<_rw)f%JP`AeIE_1_G%3yTw5HyW@ns;s z4#c;C_&yLn2IA*H{2GYg1Mz1d{?1U(@a{K|@gHPUy#T|1IA%g(gT%?= total: + # All done! + break + + server.close() + + # return the book list as the job result + return book_list + +def do_download_for_worker(book,options): + ''' + Child job, to extract isbn from formats for this specific book, + when run as a worker job + ''' + try: + print("do_download_for_worker") + # print("is_adult:%s"%book['is_adult']) + # print("personal.ini len:%s"%len(options['personal.ini'])) + # print("defaults.ini len:%s"%len(get_resources("defaults.ini"))) + #time.sleep(2.0) + book['comment'] = 'Download started...' + + ffdlconfig = SafeConfigParser() + ffdlconfig.readfp(StringIO(get_resources("defaults.ini"))) + ffdlconfig.readfp(StringIO(options['personal.ini'])) + + adapter = adapters.getAdapter(ffdlconfig,book['url']) + adapter.is_adult = book['is_adult'] + adapter.username = book['username'] + adapter.password = book['password'] + + story = adapter.getStoryMetadataOnly() + writer = writers.getWriter(options['fileform'],adapter.config,adapter) + + outfile = book['outfile'] + + ## No need to download at all. Shouldn't ever get down here. + if options['collision'] in (CALIBREONLY): + print("Skipping CALIBREONLY 'update' down inside worker--this shouldn't be happening...") + book['comment'] = 'Metadata collected.' + + ## checks were done earlier, it's new or not dup or newer--just write it. + elif options['collision'] in (ADDNEW, SKIP, OVERWRITE, OVERWRITEALWAYS) or \ + ('epub_for_update' not in book and options['collision'] in (UPDATE, UPDATEALWAYS)): + print("write to %s"%outfile) + writer.writeStory(outfilename=outfile) + book['comment'] = 'Download %s completed, %s chapters.'%(options['fileform'],story.getMetadata("numChapters")) + + ## checks were done earlier, just update it. + elif 'epub_for_update' in book and options['collision'] in (UPDATE, UPDATEALWAYS): + + urlchaptercount = int(story.getMetadata('numChapters')) + ## First, get existing epub with titlepage and tocpage stripped. + updateio = StringIO() + (epuburl,chaptercount) = doMerge(updateio, + [book['epub_for_update']], + titlenavpoints=False, + striptitletoc=True, + forceunique=False) + print("Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount)) + + ## Get updated title page/metadata by itself in an epub. + ## Even if the title page isn't included, this carries the metadata. + titleio = StringIO() + writer.writeStory(outstream=titleio,metaonly=True) + + newchaptersio = None + if urlchaptercount > chaptercount : + ## Go get the new chapters + newchaptersio = StringIO() + adapter.setChaptersRange(chaptercount+1,urlchaptercount) + + adapter.config.set("overrides",'include_tocpage','false') + adapter.config.set("overrides",'include_titlepage','false') + writer.writeStory(outstream=newchaptersio) + + ## Merge the three epubs together. + doMerge(outfile, + [titleio,updateio,newchaptersio], + fromfirst=True, + titlenavpoints=False, + striptitletoc=False, + forceunique=False) + + book['comment'] = 'Update %s completed, added %s chapters for %s total.'%\ + (options['fileform'],(urlchaptercount-chaptercount),urlchaptercount) + + except NotGoingToDownload as d: + book['good']=False + book['comment']=unicode(d) + book['icon'] = d.icon + + except Exception as e: + book['good']=False + book['comment']=unicode(e) + print("%s:%s"%(book,unicode(e))) + # XXX trace for not-expected exceptions + traceback.print_exc() + + #time.sleep(10) + return book diff --git a/fanficdownloader/adapters/adapter_test1.py b/fanficdownloader/adapters/adapter_test1.py index f00b963..a17c7a6 100644 --- a/fanficdownloader/adapters/adapter_test1.py +++ b/fanficdownloader/adapters/adapter_test1.py @@ -67,9 +67,11 @@ class TestSiteAdapter(BaseSiteAdapter): raise exceptions.FailedToLogin(self.url,self.username) if self.story.getMetadata('storyId') == '664': - self.story.setMetadata(u'title',"Test Story Title "+self.crazystring) + self.story.setMetadata(u'title',"Test Story Title "+self.story.getMetadata('storyId')+self.crazystring) + self.story.setMetadata('author','Test Author aa bare amp(&) quote(') amp(&)') else: self.story.setMetadata(u'title',"Test Story Title "+self.story.getMetadata('storyId')) + self.story.setMetadata('author','Test Author aa') self.story.setMetadata('storyUrl',self.url) self.story.setMetadata('description',u'Description '+self.crazystring+u''' Done @@ -85,7 +87,6 @@ Some more longer description. "I suck at summaries!" "Better than it sounds!" self.story.setMetadata('status','In-Completed') self.story.setMetadata('rating','Tweenie') - self.story.setMetadata('author','Test Author aa') self.story.setMetadata('authorId','98765') self.story.setMetadata('authorUrl','http://author/url') diff --git a/fanficdownloader/story.py b/fanficdownloader/story.py index 7241a90..53f2d66 100644 --- a/fanficdownloader/story.py +++ b/fanficdownloader/story.py @@ -17,7 +17,7 @@ import os -from htmlcleanup import conditionalRemoveEntities +from htmlcleanup import conditionalRemoveEntities, removeAllEntities class Story: @@ -30,15 +30,17 @@ class Story: self.listables = {} # some items (extratags, category, warnings & genres) are also kept as lists. def setMetadata(self, key, value): + ## still keeps < < and & self.metadata[key]=conditionalRemoveEntities(value) def getMetadataRaw(self,key): if self.metadata.has_key(key): return self.metadata[key] - def getMetadata(self, key): + def getMetadata(self, key, removeallentities=False): + value = None if self.getLists().has_key(key): - return ', '.join(self.getList(key)) + value = ', '.join(self.getList(key)) if self.metadata.has_key(key): value = self.metadata[key] if value: @@ -48,6 +50,10 @@ class Story: value = value.strftime("%Y-%m-%d %H:%M:%S") if key == "datePublished" or key == "dateUpdated": value = value.strftime("%Y-%m-%d") + + if removeallentities and value != None: + return removeAllEntities(value) + else: return value def getAllMetadata(self):