From 08de895a672de10e5b0397bd93574f8b050eba95 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Feb 2011 10:20:13 -0700 Subject: [PATCH 01/14] Add tweak to remove yellow lines from edges of book list --- resources/default_tweaks.py | 70 ++++++++++++++++++++++++++----------- src/calibre/gui2/widgets.py | 4 +-- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py index 81088da520..ae96fc6a94 100644 --- a/resources/default_tweaks.py +++ b/resources/default_tweaks.py @@ -10,7 +10,7 @@ you know what you are doing. If you delete this file, it will be recreated from defaults. ''' - +#: Auto increment series index # The algorithm used to assign a new book in an existing series a series number. # New series numbers assigned using this tweak are always integer values, except # if a constant non-integer is specified. @@ -29,7 +29,7 @@ defaults. # series_index_auto_increment = 16.5 series_index_auto_increment = 'next' - +#: Add separator after completing an author name # Should the completion separator be append # to the end of the completed text to # automatically begin a new completion operation @@ -38,6 +38,7 @@ series_index_auto_increment = 'next' authors_completer_append_separator = False +#: Author sort name algorithm # The algorithm used to copy author to author_sort # Possible values are: # invert: use "fn ln" -> "ln, fn" (the default algorithm) @@ -49,6 +50,7 @@ authors_completer_append_separator = False # selecting 'manage authors', and pressing 'Recalculate all author sort values'. author_sort_copy_method = 'invert' +#: Use author sort in Tag Browser # Set which author field to display in the tags pane (the list of authors, # series, publishers etc on the left hand side). The choices are author and # author_sort. This tweak affects only what is displayed under the authors @@ -63,6 +65,7 @@ author_sort_copy_method = 'invert' # categories_use_field_for_author_name = 'author_sort' categories_use_field_for_author_name = 'author' +#: Control partitioning of Tag Browser # When partitioning the tags browser, the format of the subcategory label is # controlled by a template: categories_collapsed_name_template if sorting by # name, categories_collapsed_rating_template if sorting by average rating, and @@ -85,13 +88,14 @@ categories_collapsed_rating_template = r'{first.avg_rating:4.2f:ifempty(0)} - {l categories_collapsed_popularity_template = r'{first.count:d} - {last.count:d}' +#: Set boolean custom columns to be tristate # Set whether boolean custom columns are two- or three-valued. # Two-values for true booleans # three-values for yes/no/unknown # Set to 'yes' for three-values, 'no' for two-values bool_custom_columns_are_tristate = 'yes' - +#: Specify columns to sort the booklist by on startup # Provide a set of columns to be sorted on when calibre starts # The argument is None if saved sort history is to be used # otherwise it is a list of column,order pairs. Column is the @@ -101,6 +105,7 @@ bool_custom_columns_are_tristate = 'yes' # title within authors. sort_columns_at_startup = None +#; Control how dates are displayed # Format to be used for publication date and the timestamp (date). # A string controlling how the publication date is displayed in the GUI # d the day as number without a leading zero (1 to 31) @@ -121,6 +126,7 @@ sort_columns_at_startup = None gui_pubdate_display_format = 'MMM yyyy' gui_timestamp_display_format = 'dd MMM yyyy' +#: Control sorting of titles and series in the display # Control title and series sorting in the library view. # If set to 'library_order', Leading articles such as The and A will be ignored. # If set to 'strictly_alphabetic', the titles will be sorted without processing @@ -132,6 +138,7 @@ gui_timestamp_display_format = 'dd MMM yyyy' # without changing anything is sufficient to change the sort. title_series_sorting = 'library_order' +#: Control formatting of title and series when used in templates # Control how title and series names are formatted when saving to disk/sending # to device. If set to library_order, leading articles such as The and A will # be put at the end @@ -140,6 +147,7 @@ title_series_sorting = 'library_order' # strictly_alphabetic, it would remain "The Client". save_template_title_series_sorting = 'library_order' +#: Set the list of words considered to be "articles" for sort strings # Set the list of words that are to be considered 'articles' when computing the # title sort strings. The list is a regular expression, with the articles # separated by 'or' bars. Comparisons are case insensitive, and that cannot be @@ -149,7 +157,7 @@ save_template_title_series_sorting = 'library_order' # Default: '^(A|The|An)\s+' title_sort_articles=r'^(A|The|An)\s+' - +#: Specify a folder calibre should connect to at startup # Specify a folder that calibre should connect to at startup using # connect_to_folder. This must be a full path to the folder. If the folder does # not exist when calibre starts, it is ignored. If there are '\' characters in @@ -159,7 +167,7 @@ title_sort_articles=r'^(A|The|An)\s+' # auto_connect_to_folder = '/home/dropbox/My Dropbox/someone/library' auto_connect_to_folder = '' - +#: Specify renaming rules for SONY collections # Specify renaming rules for sony collections. This tweak is only applicable if # metadata management is set to automatic. Collections on Sonys are named # depending upon whether the field is standard or custom. A collection derived @@ -212,7 +220,7 @@ auto_connect_to_folder = '' sony_collection_renaming_rules={} sony_collection_name_template='{value}{category:| (|)}' - +#: Specify how SONY collections are sorted # Specify how sony collections are sorted. This tweak is only applicable if # metadata management is set to automatic. You can indicate which metadata is to # be used to sort on a collection-by-collection basis. The format of the tweak @@ -231,7 +239,7 @@ sony_collection_name_template='{value}{category:| (|)}' sony_collection_sorting_rules = [] -# Create search terms to apply a query across several built-in search terms. +#: Create search terms to apply a query across several built-in search terms. # Syntax: {'new term':['existing term 1', 'term 2', ...], 'new':['old'...] ...} # Example: create the term 'myseries' that when used as myseries:foo would # search all of the search categories 'series', '#myseries', and '#myseries2': @@ -244,15 +252,17 @@ sony_collection_sorting_rules = [] grouped_search_terms = {} -# Set this to True (not 'True') to ensure that tags in 'Tags to add when adding +#: Control how tags are applied when copying books to another library +# Set this to True to ensure that tags in 'Tags to add when adding # a book' are added when copying books to another library add_new_book_tags_when_importing_books = False -# Set the maximum number of tags to show per book in the content server +#: Set the maximum number of tags to show per book in the content server max_content_server_tags_shown=5 -# Set custom metadata fields that the content server will or will not display. + +#: Set custom metadata fields that the content server will or will not display. # content_server_will_display is a list of custom fields to be displayed. # content_server_wont_display is a list of custom fields not to be displayed. # wont_display has priority over will_display. @@ -270,13 +280,27 @@ max_content_server_tags_shown=5 content_server_will_display = ['*'] content_server_wont_display = [] -# Same as above (content server) but for the book details pane. Same syntax. +#: Set custom metadata fields that the book details panel will or will not display. +# book_details_will_display is a list of custom fields to be displayed. +# book_details_wont_display is a list of custom fields not to be displayed. +# wont_display has priority over will_display. +# The special value '*' means all custom fields. The value [] means no entries. +# Defaults: +# book_details_will_display = ['*'] +# book_details_wont_display = [] +# Examples: +# To display only the custom fields #mytags and #genre: +# book_details_will_display = ['#mytags', '#genre'] +# book_details_wont_display = [] +# To display all fields except #mycomments: +# book_details_will_display = ['*'] +# book_details_wont_display['#mycomments'] # As above, this tweak affects only display of custom fields. The standard # fields are not affected book_details_will_display = ['*'] book_details_wont_display = [] - +#: # Set the maximum number of sort 'levels' # Set the maximum number of sort 'levels' that calibre will use to resort the # library after certain operations such as searches or device insertion. Each # sort level adds a performance penalty. If the database is large (thousands of @@ -284,16 +308,14 @@ book_details_wont_display = [] # level sorts, and if you are seeing a slowdown, reduce the value of this tweak. maximum_resort_levels = 5 -# Absolute path to a TTF font file to use as the font for the title and author -# when generating a default cover. Useful if the default font (Liberation +#: Specify which font to use when generating a default cover +# Absolute path to .ttf font files to use as the fonts for the title, author +# and footer when generating a default cover. Useful if the default font (Liberation # Serif) does not contain glyphs for the language of the books in your library. generate_cover_title_font = None - -# Absolute path to a TTF font file to use as the font for the footer in the -# default cover generate_cover_foot_font = None - +#: Control behavior of double clicks on the book list # Behavior of doubleclick on the books list. Choices: open_viewer, do_nothing, # edit_cell, edit_metadata. Selecting edit_metadata has the side effect of # disabling editing a field using a single click. @@ -302,7 +324,8 @@ generate_cover_foot_font = None doubleclick_on_library_view = 'open_viewer' -# Language to use when sorting. Setting this tweak will force sorting to use the +#: Language to use when sorting. +# Setting this tweak will force sorting to use the # collating order for the specified language. This might be useful if you run # calibre in English but want sorting to work in the language where you live. # Set the tweak to the desired ISO 639-1 language code, in lower case. @@ -313,12 +336,13 @@ doubleclick_on_library_view = 'open_viewer' # Example: locale_for_sorting = 'nb' -- sort using Norwegian rules. locale_for_sorting = '' - +#: Use one or two columns for custom metadata fields in the edit metadata dialog # Set whether to use one or two columns for custom metadata when editing # metadata one book at a time. If True, then the fields are laid out using two # columns. If False, one column is used. metadata_single_use_2_cols_for_custom_fields = True +#: The number of seconds to wait before sending emails # The number of seconds to wait before sending emails when using a # public email server like gmail or hotmail. Default is: 5 minutes # Setting it to lower may cause the server's SPAM controls to kick in, @@ -326,3 +350,9 @@ metadata_single_use_2_cols_for_custom_fields = True # calibre. public_smtp_relay_delay = 301 +#: Remove the bright yellow lines at the edges of the book list +# Control whether the bright yellow lines at the edges of book list are drawn +# when a section of the user interface is hidden. Changes will take effect +# after a restart of calibre. +draw_hidden_section_indicators = True + diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py index 68e78cb6a6..f6c4cce3ef 100644 --- a/src/calibre/gui2/widgets.py +++ b/src/calibre/gui2/widgets.py @@ -20,7 +20,7 @@ from calibre.gui2.filename_pattern_ui import Ui_Form from calibre import fit_image from calibre.ebooks import BOOK_EXTENSIONS from calibre.ebooks.metadata.meta import metadata_from_filename -from calibre.utils.config import prefs, XMLConfig +from calibre.utils.config import prefs, XMLConfig, tweaks from calibre.gui2.progress_indicator import ProgressIndicator as _ProgressIndicator history = XMLConfig('history') @@ -932,7 +932,7 @@ class SplitterHandle(QSplitterHandle): def paintEvent(self, ev): QSplitterHandle.paintEvent(self, ev) - if self.highlight: + if self.highlight and tweaks['draw_hidden_section_indicators']: painter = QPainter(self) painter.setClipRect(ev.rect()) painter.fillRect(self.rect(), Qt.yellow) From aa19276219b8cdcbcf9a02b899c520461f102ad7 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Feb 2011 14:54:50 -0700 Subject: [PATCH 02/14] ... --- resources/default_tweaks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py index ae96fc6a94..55b8f82c48 100644 --- a/resources/default_tweaks.py +++ b/resources/default_tweaks.py @@ -77,13 +77,13 @@ categories_use_field_for_author_name = 'author' # author category will be the name of the author. The sub-values available are: # name: the printable name of the item # count: the number of books that references this item -# avg_rating: the averate rating of all the books referencing this item +# avg_rating: the average rating of all the books referencing this item # sort: the sort value. For authors, this is the author_sort for that author # category: the category (e.g., authors, series) that the item is in. # Note that the "r'" in front of the { is necessary if there are backslashes # (\ characters) in the template. It doesn't hurt anything to leave it there # even if there aren't any backslashes. -categories_collapsed_name_template = r'{first.sort:shorten(4,'',0)} - {last.sort:shorten(4,'',0)}' +categories_collapsed_name_template = r'{first.sort:shorten(4,"",0)} - {last.sort:shorten(4,"",0)}' categories_collapsed_rating_template = r'{first.avg_rating:4.2f:ifempty(0)} - {last.avg_rating:4.2f:ifempty(0)}' categories_collapsed_popularity_template = r'{first.count:d} - {last.count:d}' @@ -300,7 +300,7 @@ content_server_wont_display = [] book_details_will_display = ['*'] book_details_wont_display = [] -#: # Set the maximum number of sort 'levels' +#: Set the maximum number of sort 'levels' # Set the maximum number of sort 'levels' that calibre will use to resort the # library after certain operations such as searches or device insertion. Each # sort level adds a performance penalty. If the database is large (thousands of From 588e92348cf5153641180903c1dc0700d09a9fbe Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Feb 2011 15:13:13 -0700 Subject: [PATCH 03/14] Nicer interface for editing tweaks --- src/calibre/gui2/preferences/tweaks.py | 296 ++++++++++++++++++++++++- src/calibre/gui2/preferences/tweaks.ui | 109 +++++++-- 2 files changed, 372 insertions(+), 33 deletions(-) diff --git a/src/calibre/gui2/preferences/tweaks.py b/src/calibre/gui2/preferences/tweaks.py index 2bd765986d..fc3eb5a626 100644 --- a/src/calibre/gui2/preferences/tweaks.py +++ b/src/calibre/gui2/preferences/tweaks.py @@ -5,37 +5,311 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' +import textwrap + from calibre.gui2.preferences import ConfigWidgetBase, test_widget, AbortCommit from calibre.gui2.preferences.tweaks_ui import Ui_Form -from calibre.gui2 import error_dialog +from calibre.gui2 import error_dialog, NONE from calibre.utils.config import read_raw_tweaks, write_tweaks +from calibre.gui2.widgets import PythonHighlighter +from calibre import isbytestring +from PyQt4.Qt import QAbstractListModel, Qt, QStyledItemDelegate, QStyle, \ + QStyleOptionViewItem, QFont, QDialogButtonBox, QDialog, \ + QVBoxLayout, QPlainTextEdit, QLabel + +class Delegate(QStyledItemDelegate): # {{{ + def __init__(self, view): + QStyledItemDelegate.__init__(self, view) + self.view = view + + def paint(self, p, opt, idx): + copy = QStyleOptionViewItem(opt) + copy.showDecorationSelected = True + if self.view.currentIndex() == idx: + copy.state |= QStyle.State_HasFocus + QStyledItemDelegate.paint(self, p, copy, idx) + +# }}} + +class Tweak(object): # {{{ + + def __init__(self, name, doc, var_names, defaults, custom): + self.name = name + self.doc = doc.strip() + self.var_names = var_names + self.default_values = {} + for x in var_names: + self.default_values[x] = defaults[x] + self.custom_values = {} + for x in var_names: + if x in custom: + self.custom_values[x] = custom[x] + + def __str__(self): + ans = ['#: ' + self.name] + for line in self.doc.splitlines(): + if line: + ans.append('# ' + line) + for key, val in self.default_values.iteritems(): + val = self.custom_values.get(key, val) + ans.append('%s = %r'%(key, val)) + ans = '\n'.join(ans) + if isinstance(ans, unicode): + ans = ans.encode('utf-8') + return ans + + def __cmp__(self, other): + return cmp(self.is_customized, getattr(other, 'is_customized', False)) + + @property + def is_customized(self): + for x, val in self.default_values.iteritems(): + if self.custom_values.get(x, val) != val: + return True + return False + + @property + def edit_text(self): + ans = ['# %s'%self.name] + for x, val in self.default_values.iteritems(): + val = self.custom_values.get(x, val) + ans.append('%s = %r'%(x, val)) + return '\n\n'.join(ans) + + def restore_to_default(self): + self.custom_values.clear() + + def update(self, varmap): + self.custom_values.update(varmap) + +# }}} + +class Tweaks(QAbstractListModel): # {{{ + + def __init__(self, parent=None): + QAbstractListModel.__init__(self, parent) + raw_defaults, raw_custom = read_raw_tweaks() + + self.parse_tweaks(raw_defaults, raw_custom) + + def rowCount(self, *args): + return len(self.tweaks) + + def data(self, index, role): + row = index.row() + try: + tweak = self.tweaks[row] + except: + return NONE + if role == Qt.DisplayRole: + return textwrap.fill(tweak.name, 40) + if role == Qt.FontRole and tweak.is_customized: + ans = QFont() + ans.setBold(True) + return ans + if role == Qt.ToolTipRole: + tt = _('This tweak has it default value') + if tweak.is_customized: + tt = _('This tweak has been customized') + return tt + if role == Qt.UserRole: + return tweak + return NONE + + def parse_tweaks(self, defaults, custom): + l, g = {}, {} + try: + exec custom in g, l + except: + print 'Failed to load custom tweaks file' + import traceback + traceback.print_exc() + dl, dg = {}, {} + exec defaults in dg, dl + lines = defaults.splitlines() + pos = 0 + self.tweaks = [] + while pos < len(lines): + line = lines[pos] + if line.startswith('#:'): + pos = self.read_tweak(lines, pos, dl, l) + pos += 1 + + default_keys = set(dl.iterkeys()) + custom_keys = set(l.iterkeys()) + + self.plugin_tweaks = {} + for key in custom_keys - default_keys: + self.plugin_tweaks[key] = l[key] + + def read_tweak(self, lines, pos, defaults, custom): + name = lines[pos][2:].strip() + doc, var_names = [], [] + while True: + pos += 1 + line = lines[pos] + if not line.startswith('#'): + break + doc.append(line[1:].strip()) + doc = '\n'.join(doc) + while True: + line = lines[pos] + if not line.strip(): + break + spidx1 = line.find(' ') + spidx2 = line.find('=') + spidx = spidx1 if spidx1 > 0 and (spidx2 == 0 or spidx2 > spidx1) else spidx2 + if spidx > 0: + var = line[:spidx] + if var not in defaults: + raise ValueError('%r not in default tweaks dict'%var) + var_names.append(var) + pos += 1 + if not var_names: + raise ValueError('Failed to find any variables for %r'%name) + self.tweaks.append(Tweak(name, doc, var_names, defaults, custom)) + #print '\n\n', self.tweaks[-1] + return pos + + def restore_to_default(self, idx): + tweak = self.data(idx, Qt.UserRole) + if tweak is not NONE: + tweak.restore_to_default() + self.dataChanged.emit(idx, idx) + + def restore_to_defaults(self): + for r in range(self.rowCount()): + self.restore_to_default(self.index(r)) + + def update_tweak(self, idx, varmap): + tweak = self.data(idx, Qt.UserRole) + if tweak is not NONE: + tweak.update(varmap) + self.dataChanged.emit(idx, idx) + + def to_string(self): + ans = ['#!/usr/bin/env python', + '# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai', '', + '# This file was automatically generated by calibre, do not' + ' edit it unless you know what you are doing.', '', + ] + for tweak in self.tweaks: + ans.extend(['', str(tweak), '']) + + if self.plugin_tweaks: + ans.extend(['', '', + '# The following are tweaks for installed plugins', '']) + for key, val in self.plugin_tweaks.iteritems(): + ans.extend(['%s = %r'%(key, val), '', '']) + return '\n'.join(ans) + + @property + def plugin_tweaks_string(self): + ans = [] + for key, val in self.plugin_tweaks.iteritems(): + ans.extend(['%s = %r'%(key, val), '', '']) + ans = '\n'.join(ans) + if isbytestring(ans): + ans = ans.decode('utf-8') + return ans + + def set_plugin_tweaks(self, d): + self.plugin_tweaks = d + +# }}} + +class PluginTweaks(QDialog): # {{{ + + def __init__(self, raw, parent=None): + QDialog.__init__(self, parent) + self.edit = QPlainTextEdit(self) + self.highlighter = PythonHighlighter(self.edit.document()) + self.l = QVBoxLayout() + self.setLayout(self.l) + self.l.addWidget(QLabel( + _('Add/edit tweaks for any custom plugins you have installed.'))) + self.l.addWidget(self.edit) + self.edit.setPlainText(raw) + self.bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel, + Qt.Horizontal, self) + self.bb.accepted.connect(self.accept) + self.bb.rejected.connect(self.reject) + self.l.addWidget(self.bb) + +# }}} class ConfigWidget(ConfigWidgetBase, Ui_Form): def genesis(self, gui): self.gui = gui - self.current_tweaks.textChanged.connect(self.changed) + self.delegate = Delegate(self.tweaks_view) + self.tweaks_view.setItemDelegate(self.delegate) + self.tweaks_view.currentChanged = self.current_changed + self.highlighter = PythonHighlighter(self.edit_tweak.document()) + self.restore_default_button.clicked.connect(self.restore_to_default) + self.apply_button.clicked.connect(self.apply_tweak) + self.plugin_tweaks_button.clicked.connect(self.plugin_tweaks) + + def plugin_tweaks(self): + raw = self.tweaks.plugin_tweaks_string + d = PluginTweaks(raw, self) + if d.exec_() == d.Accepted: + g, l = {}, {} + try: + exec unicode(d.edit.toPlainText()) in g, l + except: + import traceback + return error_dialog(self, _('Failed'), + _('There was a syntax error in your tweak. Click ' + 'the show details button for details.'), show=True, + det_msg=traceback.format_exc()) + self.tweaks.set_plugin_tweaks(l) + self.changed() + + def current_changed(self, current, previous): + tweak = self.tweaks.data(current, Qt.UserRole) + self.help.setPlainText(tweak.doc) + self.edit_tweak.setPlainText(tweak.edit_text) def changed(self, *args): self.changed_signal.emit() def initialize(self): - deft, curt = read_raw_tweaks() - self.current_tweaks.blockSignals(True) - self.current_tweaks.setPlainText(curt.decode('utf-8')) - self.current_tweaks.blockSignals(False) + self.tweaks = Tweaks() + self.tweaks_view.setModel(self.tweaks) - self.default_tweaks.setPlainText(deft.decode('utf-8')) + def restore_to_default(self, *args): + idx = self.tweaks_view.currentIndex() + if idx.isValid(): + self.tweaks.restore_to_default(idx) + tweak = self.tweaks.data(idx, Qt.UserRole) + self.edit_tweak.setPlainText(tweak.edit_text) + self.changed() def restore_defaults(self): ConfigWidgetBase.restore_defaults(self) - deft, curt = read_raw_tweaks() - self.current_tweaks.setPlainText(deft.decode('utf-8')) + self.tweaks.restore_to_defaults() + self.changed() + def apply_tweak(self): + idx = self.tweaks_view.currentIndex() + if idx.isValid(): + l, g = {}, {} + try: + exec unicode(self.edit_tweak.toPlainText()) in g, l + except: + import traceback + error_dialog(self.gui, _('Failed'), + _('There was a syntax error in your tweak. Click ' + 'the show details button for details.'), + det_msg=traceback.format_exc(), show=True) + return + self.tweaks.update_tweak(idx, l) + self.changed() def commit(self): - raw = unicode(self.current_tweaks.toPlainText()).encode('utf-8') + raw = self.tweaks.to_string() try: exec raw except: @@ -54,5 +328,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): if __name__ == '__main__': from PyQt4.Qt import QApplication app = QApplication([]) + #Tweaks() + #test_widget test_widget('Advanced', 'Tweaks') diff --git a/src/calibre/gui2/preferences/tweaks.ui b/src/calibre/gui2/preferences/tweaks.ui index 8546873552..7194cce2f9 100644 --- a/src/calibre/gui2/preferences/tweaks.ui +++ b/src/calibre/gui2/preferences/tweaks.ui @@ -7,31 +7,70 @@ 0 0 660 - 351 + 531 Form - - - - - Values for the tweaks are shown below. Edit them to change the behavior of calibre. Your changes will only take effect after a restart of calibre. - - - true - - + + + + + + + Values for the tweaks are shown below. Edit them to change the behavior of calibre. Your changes will only take effect <b>after a restart</b> of calibre. + + + true + + + + + + + + 0 + 0 + + + + + 300 + 0 + + + + true + + + 5 + + + + + + + Edit tweaks for any custom plugins you have installed + + + &Plugin tweaks + + + + - - + + - All available tweaks + Help - - - + + + + + QPlainTextEdit::NoWrap + true @@ -40,14 +79,38 @@ - - + + - &Current tweaks + Edit tweak - - - + + + + + QPlainTextEdit::NoWrap + + + + + + + Restore this tweak to its default value + + + Restore &default + + + + + + + Apply any changes you made to this tweak + + + &Apply + + From 18adcba82f5f935ffc9259f7bd64bdcb9eecdb6f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Feb 2011 15:46:05 -0700 Subject: [PATCH 04/14] ... --- src/calibre/gui2/preferences/tweaks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/calibre/gui2/preferences/tweaks.py b/src/calibre/gui2/preferences/tweaks.py index fc3eb5a626..a0f9d1aab0 100644 --- a/src/calibre/gui2/preferences/tweaks.py +++ b/src/calibre/gui2/preferences/tweaks.py @@ -236,6 +236,7 @@ class PluginTweaks(QDialog): # {{{ self.bb.accepted.connect(self.accept) self.bb.rejected.connect(self.reject) self.l.addWidget(self.bb) + self.resize(550, 300) # }}} From a97ff28c343795b735d143571821d96074cf9e34 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Feb 2011 15:50:28 -0700 Subject: [PATCH 05/14] Fix #8822 (Calibre does not recognise my device) --- src/calibre/customize/builtins.py | 5 ++--- src/calibre/devices/teclast/driver.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index bd8e07fb58..ce964e0104 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -497,7 +497,7 @@ from calibre.devices.binatone.driver import README from calibre.devices.hanvon.driver import N516, EB511, ALEX, AZBOOKA, THEBOOK from calibre.devices.edge.driver import EDGE from calibre.devices.teclast.driver import TECLAST_K3, NEWSMY, IPAPYRUS, \ - SOVOS, PICO, SUNSTECH_EB700, ARCHOS7O + SOVOS, PICO, SUNSTECH_EB700, ARCHOS7O, STASH from calibre.devices.sne.driver import SNE from calibre.devices.misc import PALMPRE, AVANT, SWEEX, PDNOVEL, KOGAN, \ GEMEI, VELOCITYMICRO, PDNOVEL_KOBO, Q600, LUMIREAD, ALURATEK_COLOR, \ @@ -605,9 +605,8 @@ plugins += [ ELONEX, TECLAST_K3, NEWSMY, - PICO, SUNSTECH_EB700, ARCHOS7O, + PICO, SUNSTECH_EB700, ARCHOS7O, SOVOS, STASH, IPAPYRUS, - SOVOS, EDGE, SNE, ALEX, diff --git a/src/calibre/devices/teclast/driver.py b/src/calibre/devices/teclast/driver.py index 078e59da5b..2cca0085d7 100644 --- a/src/calibre/devices/teclast/driver.py +++ b/src/calibre/devices/teclast/driver.py @@ -92,3 +92,15 @@ class SUNSTECH_EB700(TECLAST_K3): VENDOR_NAME = 'SUNEB700' WINDOWS_MAIN_MEM = 'USB-MSC' +class STASH(TECLAST_K3): + + name = 'Stash device interface' + gui_name = 'Stash' + description = _('Communicate with the Stash W950 reader.') + + FORMATS = ['epub', 'fb2', 'lrc', 'pdb', 'html', 'fb2', 'wtxt', + 'txt', 'pdf'] + + VENDOR_NAME = 'STASH' + WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = 'W950' + From acba3505d93ef400c28da3035e6dd2d7aff28e07 Mon Sep 17 00:00:00 2001 From: John Schember Date: Wed, 9 Feb 2011 18:45:20 -0500 Subject: [PATCH 06/14] Add Amazon page mapping file specification. --- format_docs/pdb/apnx.txt | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 format_docs/pdb/apnx.txt diff --git a/format_docs/pdb/apnx.txt b/format_docs/pdb/apnx.txt new file mode 100644 index 0000000000..7ed43106a4 --- /dev/null +++ b/format_docs/pdb/apnx.txt @@ -0,0 +1,57 @@ +Amazon APNX file format + +bytes content comments + +4 00010001 Format identifier. +4 start of next The offset after ending location of the first header. + Starts a new sequence of header info +4 length Length of first header +N first header String containing content header +Starts next sequence +2 unknown Always 1 +2 length Length of second header +2 page count Total number of bytes after second header that + represent pages. This total includes bytes that + are ignored by the pageMap. +2 unknown Always 32 +N second header String containing the page mapping header +N padding The first number given in the page mapping header indicates the number of 0 bytes. +N page list + + +Content Header + +The content header is a string enclosed in {} containing key, value pairs. + +content comments + +contentGuid Guid +asin Amazon identifier for the Kindle version of the book +cdeType MOBI cdeType. Should always be EBOK for ebooks. +fileRevisionId Revision of this file + +Example: +{"contentGuid":"d8c14b0","asin":"B000JML5VM","cdeType":"EBOK","fileRevisionId":"1296874359405"} + + +Page Mapping Header + +The page mapping header is a string enclosed in {} containing key, value pairs. + +content comments + +asin The ISBN 10 for the paper book the pages correspond to +pageMap Three value tuple. + 1) Number of bytes after header that starts the page numbering sequence + 2) unknown + 3) unknown + +Example: +{"asin":"1906694184","pageMap":"(4,a,1)"} + + +Page List + +The page list is a sequence of offsets in the uncompressed HTML. Each +value is the beginning of a new page. Each entry is a 4 byte big endian +int. The list is ordered lowest to highest. \ No newline at end of file From bdf5e0c3b1dbb02a0fd0b2614fbf8c9ac42277a5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Feb 2011 17:15:18 -0700 Subject: [PATCH 07/14] Fix #8897 (Calibre does not recognize Nexus S) --- src/calibre/devices/android/driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/devices/android/driver.py b/src/calibre/devices/android/driver.py index e6ad786cac..dea9725894 100644 --- a/src/calibre/devices/android/driver.py +++ b/src/calibre/devices/android/driver.py @@ -83,7 +83,7 @@ class ANDROID(USBMS): 'GT-I9000', 'FILE-STOR_GADGET', 'SGH-T959', 'SAMSUNG_ANDROID', 'SCH-I500_CARD', 'SPH-D700_CARD', 'MB810', 'GT-P1000', 'DESIRE', 'SGH-T849', '_MB300', 'A70S', 'S_ANDROID', 'A101IT', 'A70H', - 'IDEOS_TABLET', 'MYTOUCH_4G'] + 'IDEOS_TABLET', 'MYTOUCH_4G', 'UMS_COMPOSITE'] WINDOWS_CARD_A_MEM = ['ANDROID_PHONE', 'GT-I9000_CARD', 'SGH-I897', 'FILE-STOR_GADGET', 'SGH-T959', 'SAMSUNG_ANDROID', 'GT-P1000_CARD', 'A70S', 'A101IT'] From dfcfc697d54ed6c38b8a0679af3f7d51c854bee3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Feb 2011 18:06:15 -0700 Subject: [PATCH 08/14] ... --- resources/default_tweaks.py | 2 +- src/calibre/gui2/preferences/tweaks.ui | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py index 55b8f82c48..01a6e8bd75 100644 --- a/resources/default_tweaks.py +++ b/resources/default_tweaks.py @@ -336,7 +336,7 @@ doubleclick_on_library_view = 'open_viewer' # Example: locale_for_sorting = 'nb' -- sort using Norwegian rules. locale_for_sorting = '' -#: Use one or two columns for custom metadata fields in the edit metadata dialog +#: Number of columns for custom metadata in the edit metadata dialog # Set whether to use one or two columns for custom metadata when editing # metadata one book at a time. If True, then the fields are laid out using two # columns. If False, one column is used. diff --git a/src/calibre/gui2/preferences/tweaks.ui b/src/calibre/gui2/preferences/tweaks.ui index 7194cce2f9..139f9563ad 100644 --- a/src/calibre/gui2/preferences/tweaks.ui +++ b/src/calibre/gui2/preferences/tweaks.ui @@ -46,6 +46,9 @@ 5 + + true + From a6246c52ad16f51a05981216de5dbe43ddabad15 Mon Sep 17 00:00:00 2001 From: John Schember Date: Wed, 9 Feb 2011 20:38:22 -0500 Subject: [PATCH 09/14] Kindle Interface: Move Bookmark class to separate file. Add APNX generating class. Generate APNX file using EPUB 1024 character page mapping. --- format_docs/pdb/apnx.txt | 42 ++-- src/calibre/devices/kindle/apnx.py | 68 +++++ src/calibre/devices/kindle/bookmark.py | 314 +++++++++++++++++++++++ src/calibre/devices/kindle/driver.py | 329 ++----------------------- 4 files changed, 428 insertions(+), 325 deletions(-) create mode 100644 src/calibre/devices/kindle/apnx.py create mode 100644 src/calibre/devices/kindle/bookmark.py diff --git a/format_docs/pdb/apnx.txt b/format_docs/pdb/apnx.txt index 7ed43106a4..f9feed1da1 100644 --- a/format_docs/pdb/apnx.txt +++ b/format_docs/pdb/apnx.txt @@ -1,47 +1,58 @@ -Amazon APNX file format +APNX +---- + +apnx files are used by the Amazon Kindle (firmware revision 3.1+) to +map pages from a print book to the Kindle version. Integers within +the file are big-endian. + + +Layout +------ bytes content comments -4 00010001 Format identifier. +4 00010001 Format identifier. Value of 65537 little-endian. 4 start of next The offset after ending location of the first header. - Starts a new sequence of header info + Starts a new sequence of header info 4 length Length of first header N first header String containing content header Starts next sequence -2 unknown Always 1 -2 length Length of second header -2 page count Total number of bytes after second header that - represent pages. This total includes bytes that - are ignored by the pageMap. -2 unknown Always 32 +2 unknown Always 1 +2 length Length of second header +2 page count Total number of bytes after second header that + represent pages. This total includes bytes that + are ignored by the pageMap. +2 unknown Always 32 N second header String containing the page mapping header -N padding The first number given in the page mapping header indicates the number of 0 bytes. -N page list +4*N padding The first number given in the page mapping header indicates the number of 0 bytes. +4*N page list Content Header +-------------- The content header is a string enclosed in {} containing key, value pairs. content comments -contentGuid Guid -asin Amazon identifier for the Kindle version of the book +contentGuid Guid. +asin Amazon identifier for the Kindle version of the book. cdeType MOBI cdeType. Should always be EBOK for ebooks. -fileRevisionId Revision of this file +fileRevisionId Revision of this file. Example: {"contentGuid":"d8c14b0","asin":"B000JML5VM","cdeType":"EBOK","fileRevisionId":"1296874359405"} Page Mapping Header +------------------- The page mapping header is a string enclosed in {} containing key, value pairs. content comments asin The ISBN 10 for the paper book the pages correspond to -pageMap Three value tuple. +pageMap Three value tuple. Looks like: "(N,N,N)" 1) Number of bytes after header that starts the page numbering sequence 2) unknown 3) unknown @@ -51,6 +62,7 @@ Example: Page List +--------- The page list is a sequence of offsets in the uncompressed HTML. Each value is the beginning of a new page. Each entry is a 4 byte big endian diff --git a/src/calibre/devices/kindle/apnx.py b/src/calibre/devices/kindle/apnx.py new file mode 100644 index 0000000000..e73511aafd --- /dev/null +++ b/src/calibre/devices/kindle/apnx.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +__license__ = 'GPL v3' +__copyright__ = '2011, John Schember ' +__docformat__ = 'restructuredtext en' + +''' +Generates and writes an APNX page mapping file. +''' + +import struct +import uuid + +from calibre.ebooks.pdb.header import PdbHeaderReader + +class APNXBuilder(object): + ''' + Currently uses the EPUB 1024 byte count equal one page formula. + ''' + + def write_apnx(self, mobi_file_path, apnx_path): + with open(mobi_file_path, 'rb') as mf: + phead = PdbHeaderReader(mf) + r0 = phead.section_data(0) + text_length = struct.unpack('>I', r0[4:8])[0] + + pages = self.get_pages(text_length) + apnx = self.generate_apnx(pages) + + with open(apnx_path, 'wb') as apnxf: + apnxf.write(apnx) + + def generate_apnx(self, pages): + apnx = '' + + content_vals = { + 'guid': str(uuid.uuid4()).replace('-', '')[:8], + 'isbn': '', + } + + content_header = '{"contentGuid":"%(guid)s","asin":"%(isbn)s","cdeType":"EBOK","fileRevisionId":"1"}' % content_vals + page_header = '{"asin":"%(isbn)s","pageMap":"(1,a,1)"}' % content_vals + + apnx += struct.pack('>I', 65537) + apnx += struct.pack('>I', 12 + len(content_header)) + apnx += struct.pack('>I', len(content_header)) + apnx += content_header + apnx += struct.pack('>H', 1) + apnx += struct.pack('>H', len(page_header)) + apnx += struct.pack('>H', len(pages)) + apnx += struct.pack('>H', 32) + apnx += page_header + + # write page values to apnx + for page in pages: + apnx += struct.pack('>L', page) + + return apnx + + def get_pages(self, text_length): + pages = [] + count = 0 + + while count < text_length: + pages.append(count) + count += 1024 + + return pages diff --git a/src/calibre/devices/kindle/bookmark.py b/src/calibre/devices/kindle/bookmark.py new file mode 100644 index 0000000000..a895d3263e --- /dev/null +++ b/src/calibre/devices/kindle/bookmark.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- + +__license__ = 'GPL v3' +__docformat__ = 'restructuredtext en' + +from cStringIO import StringIO +from struct import unpack + +class Bookmark(): # {{{ + ''' + A simple class fetching bookmark data + Kindle-specific + ''' + def __init__(self, path, id, book_format, bookmark_extension): + self.book_format = book_format + self.bookmark_extension = bookmark_extension + self.book_length = 0 + self.id = id + self.last_read = 0 + self.last_read_location = 0 + self.path = path + self.timestamp = 0 + self.user_notes = None + + self.get_bookmark_data() + self.get_book_length() + try: + self.percent_read = min(float(100*self.last_read / self.book_length),100) + except: + self.percent_read = 0 + + def record(self, n): + from calibre.ebooks.metadata.mobi import StreamSlicer + if n >= self.nrecs: + raise ValueError('non-existent record %r' % n) + offoff = 78 + (8 * n) + start, = unpack('>I', self.data[offoff + 0:offoff + 4]) + stop = None + if n < (self.nrecs - 1): + stop, = unpack('>I', self.data[offoff + 8:offoff + 12]) + return StreamSlicer(self.stream, start, stop) + + def get_bookmark_data(self): + ''' Return the timestamp and last_read_location ''' + from calibre.ebooks.metadata.mobi import StreamSlicer + user_notes = {} + if self.bookmark_extension == 'mbp': + MAGIC_MOBI_CONSTANT = 150 + with open(self.path,'rb') as f: + stream = StringIO(f.read()) + data = StreamSlicer(stream) + self.timestamp, = unpack('>I', data[0x24:0x28]) + bpar_offset, = unpack('>I', data[0x4e:0x52]) + lrlo = bpar_offset + 0x0c + self.last_read = int(unpack('>I', data[lrlo:lrlo+4])[0]) + self.last_read_location = self.last_read/MAGIC_MOBI_CONSTANT + 1 + entries, = unpack('>I', data[0x4a:0x4e]) + + # Store the annotations/locations + bpl = bpar_offset + 4 + bpar_len, = unpack('>I', data[bpl:bpl+4]) + bpar_len += 8 + #print "bpar_len: 0x%x" % bpar_len + eo = bpar_offset + bpar_len + + # Walk bookmark entries + #print " --- %s --- " % self.path + current_entry = 1 + sig = data[eo:eo+4] + previous_block = None + + while sig == 'DATA': + text = None + entry_type = None + rec_len, = unpack('>I', data[eo+4:eo+8]) + if rec_len == 0: + current_block = "empty_data" + elif data[eo+8:eo+12] == "EBAR": + current_block = "data_header" + #entry_type = "data_header" + location, = unpack('>I', data[eo+0x34:eo+0x38]) + #print "data_header location: %d" % location + else: + current_block = "text_block" + if previous_block == 'empty_data': + entry_type = 'Note' + elif previous_block == 'data_header': + entry_type = 'Highlight' + text = data[eo+8:eo+8+rec_len].decode('utf-16-be') + + if entry_type: + displayed_location = location/MAGIC_MOBI_CONSTANT + 1 + user_notes[location] = dict(id=self.id, + displayed_location=displayed_location, + type=entry_type, + text=text) + + eo += rec_len + 8 + current_entry += 1 + previous_block = current_block + sig = data[eo:eo+4] + + while sig == 'BKMK': + # Fix start location for Highlights using BKMK data + end_loc, = unpack('>I', data[eo+0x10:eo+0x14]) + + if end_loc in user_notes and \ + (user_notes[end_loc]['type'] == 'Highlight' or \ + user_notes[end_loc]['type'] == 'Note'): + # Switch location to start (0x08:0x0c) + start, = unpack('>I', data[eo+8:eo+12]) + user_notes[start] = user_notes[end_loc] + ''' + print " %s: swapping 0x%x (%d) to 0x%x (%d)" % (user_notes[end_loc]['type'], + end_loc, + end_loc/MAGIC_MOBI_CONSTANT + 1, + start, + start//MAGIC_MOBI_CONSTANT + 1) + ''' + user_notes[start]['displayed_location'] = start/MAGIC_MOBI_CONSTANT + 1 + user_notes.pop(end_loc) + else: + # If a bookmark coincides with a user annotation, the locs could + # be the same - cheat by nudging -1 + # Skip bookmark for last_read_location + if end_loc != self.last_read: + # print " adding Bookmark at 0x%x (%d)" % (end_loc, end_loc/MAGIC_MOBI_CONSTANT + 1) + displayed_location = end_loc/MAGIC_MOBI_CONSTANT + 1 + user_notes[end_loc - 1] = dict(id=self.id, + displayed_location=displayed_location, + type='Bookmark', + text=None) + rec_len, = unpack('>I', data[eo+4:eo+8]) + eo += rec_len + 8 + sig = data[eo:eo+4] + + elif self.bookmark_extension == 'tan': + from calibre.ebooks.metadata.topaz import get_metadata as get_topaz_metadata + + def get_topaz_highlight(displayed_location): + # Parse My Clippings.txt for a matching highlight + # Search looks for book title match, highlight match, and location match + # Author is not matched + # This will find the first instance of a clipping only + book_fs = self.path.replace('.%s' % self.bookmark_extension,'.%s' % self.book_format) + with open(book_fs,'rb') as f2: + stream = StringIO(f2.read()) + mi = get_topaz_metadata(stream) + my_clippings = self.path + split = my_clippings.find('documents') + len('documents/') + my_clippings = my_clippings[:split] + "My Clippings.txt" + try: + with open(my_clippings, 'r') as f2: + marker_found = 0 + text = '' + search_str1 = '%s' % (mi.title) + search_str2 = '- Highlight Loc. %d' % (displayed_location) + for line in f2: + if marker_found == 0: + if line.startswith(search_str1): + marker_found = 1 + elif marker_found == 1: + if line.startswith(search_str2): + marker_found = 2 + elif marker_found == 2: + if line.startswith('=========='): + break + text += line.strip() + else: + raise Exception('error') + except: + text = '(Unable to extract highlight text from My Clippings.txt)' + return text + + MAGIC_TOPAZ_CONSTANT = 33.33 + self.timestamp = os.path.getmtime(self.path) + with open(self.path,'rb') as f: + stream = StringIO(f.read()) + data = StreamSlicer(stream) + self.last_read = int(unpack('>I', data[5:9])[0]) + self.last_read_location = self.last_read/MAGIC_TOPAZ_CONSTANT + 1 + entries, = unpack('>I', data[9:13]) + current_entry = 0 + e_base = 0x0d + while current_entry < entries: + location, = unpack('>I', data[e_base+2:e_base+6]) + text = None + text_len, = unpack('>I', data[e_base+0xA:e_base+0xE]) + e_type, = unpack('>B', data[e_base+1]) + if e_type == 0: + e_type = 'Bookmark' + elif e_type == 1: + e_type = 'Highlight' + text = get_topaz_highlight(location/MAGIC_TOPAZ_CONSTANT + 1) + elif e_type == 2: + e_type = 'Note' + text = data[e_base+0x10:e_base+0x10+text_len] + else: + e_type = 'Unknown annotation type' + + displayed_location = location/MAGIC_TOPAZ_CONSTANT + 1 + user_notes[location] = dict(id=self.id, + displayed_location=displayed_location, + type=e_type, + text=text) + if text_len == 0xFFFFFFFF: + e_base = e_base + 14 + else: + e_base = e_base + 14 + 2 + text_len + current_entry += 1 + for location in user_notes: + if location == self.last_read: + user_notes.pop(location) + break + + elif self.bookmark_extension == 'pdr': + self.timestamp = os.path.getmtime(self.path) + with open(self.path,'rb') as f: + stream = StringIO(f.read()) + data = StreamSlicer(stream) + self.last_read = int(unpack('>I', data[5:9])[0]) + entries, = unpack('>I', data[9:13]) + current_entry = 0 + e_base = 0x0d + self.pdf_page_offset = 0 + while current_entry < entries: + ''' + location, = unpack('>I', data[e_base+2:e_base+6]) + text = None + text_len, = unpack('>I', data[e_base+0xA:e_base+0xE]) + e_type, = unpack('>B', data[e_base+1]) + if e_type == 0: + e_type = 'Bookmark' + elif e_type == 1: + e_type = 'Highlight' + text = get_topaz_highlight(location/MAGIC_TOPAZ_CONSTANT + 1) + elif e_type == 2: + e_type = 'Note' + text = data[e_base+0x10:e_base+0x10+text_len] + else: + e_type = 'Unknown annotation type' + + if self.book_format in ['tpz','azw1']: + displayed_location = location/MAGIC_TOPAZ_CONSTANT + 1 + elif self.book_format == 'pdf': + # *** This needs implementation + displayed_location = location + user_notes[location] = dict(id=self.id, + displayed_location=displayed_location, + type=e_type, + text=text) + if text_len == 0xFFFFFFFF: + e_base = e_base + 14 + else: + e_base = e_base + 14 + 2 + text_len + current_entry += 1 + ''' + # Use label as page number + pdf_location, = unpack('>I', data[e_base+1:e_base+5]) + label_len, = unpack('>H', data[e_base+5:e_base+7]) + location = int(data[e_base+7:e_base+7+label_len]) + displayed_location = location + e_type = 'Bookmark' + text = None + user_notes[location] = dict(id=self.id, + displayed_location=displayed_location, + type=e_type, + text=text) + self.pdf_page_offset = pdf_location - location + e_base += (7 + label_len) + current_entry += 1 + + self.last_read_location = self.last_read - self.pdf_page_offset + + else: + print "unsupported bookmark_extension: %s" % self.bookmark_extension + self.user_notes = user_notes + + def get_book_length(self): + from calibre.ebooks.metadata.mobi import StreamSlicer + book_fs = self.path.replace('.%s' % self.bookmark_extension,'.%s' % self.book_format) + + self.book_length = 0 + if self.bookmark_extension == 'mbp': + # Read the book len from the header + try: + with open(book_fs,'rb') as f: + self.stream = StringIO(f.read()) + self.data = StreamSlicer(self.stream) + self.nrecs, = unpack('>H', self.data[76:78]) + record0 = self.record(0) + self.book_length = int(unpack('>I', record0[0x04:0x08])[0]) + except: + pass + elif self.bookmark_extension == 'tan': + # Read bookLength from metadata + from calibre.ebooks.metadata.topaz import MetadataUpdater + try: + with open(book_fs,'rb') as f: + mu = MetadataUpdater(f) + self.book_length = mu.book_length + except: + pass + elif self.bookmark_extension == 'pdr': + from calibre import plugins + try: + self.book_length = plugins['pdfreflow'][0].get_numpages(open(book_fs).read()) + except: + pass + + else: + print "unsupported bookmark_extension: %s" % self.bookmark_extension + +# }}} diff --git a/src/calibre/devices/kindle/driver.py b/src/calibre/devices/kindle/driver.py index a369b04929..c33833cec4 100644 --- a/src/calibre/devices/kindle/driver.py +++ b/src/calibre/devices/kindle/driver.py @@ -7,11 +7,13 @@ __docformat__ = 'restructuredtext en' ''' Device driver for Amazon's Kindle ''' -import datetime, os, re, sys, json, hashlib -from cStringIO import StringIO -from struct import unpack +import datetime, os, re, sys, json, hashlib + +from calibre.devices.kindle.apnx import APNXBuilder +from calibre.devices.kindle.bookmark import Bookmark from calibre.devices.usbms.driver import USBMS +from calibre.ebooks.oeb.base import OPF ''' Notes on collections: @@ -170,6 +172,8 @@ class KINDLE2(KINDLE): description = _('Communicate with the Kindle 2/3 eBook reader.') FORMATS = KINDLE.FORMATS + ['pdf'] + DELETE_EXTS = KINDLE.DELETE_EXTS + ['.apnx'] + PRODUCT_ID = [0x0002, 0x0004] BCD = [0x0100] @@ -204,6 +208,18 @@ class KINDLE2(KINDLE): h = hashlib.sha1(path).hexdigest() if h in path_map: book.device_collections = list(sorted(path_map[h])) + + def upload_cover(self, path, filename, metadata, filepath): + ''' + Hijacking this function to write the apnx file. + ''' + if not filepath.lower().endswith('.mobi'): + return + + apnx_path = '%s.apnx' % os.path.join(path, filename) + apnx_builder = APNXBuilder() + apnx_builder.write_apnx(filepath, apnx_path) + class KINDLE_DX(KINDLE2): @@ -214,310 +230,3 @@ class KINDLE_DX(KINDLE2): PRODUCT_ID = [0x0003] BCD = [0x0100] -class Bookmark(): # {{{ - ''' - A simple class fetching bookmark data - Kindle-specific - ''' - def __init__(self, path, id, book_format, bookmark_extension): - self.book_format = book_format - self.bookmark_extension = bookmark_extension - self.book_length = 0 - self.id = id - self.last_read = 0 - self.last_read_location = 0 - self.path = path - self.timestamp = 0 - self.user_notes = None - - self.get_bookmark_data() - self.get_book_length() - try: - self.percent_read = min(float(100*self.last_read / self.book_length),100) - except: - self.percent_read = 0 - - def record(self, n): - from calibre.ebooks.metadata.mobi import StreamSlicer - if n >= self.nrecs: - raise ValueError('non-existent record %r' % n) - offoff = 78 + (8 * n) - start, = unpack('>I', self.data[offoff + 0:offoff + 4]) - stop = None - if n < (self.nrecs - 1): - stop, = unpack('>I', self.data[offoff + 8:offoff + 12]) - return StreamSlicer(self.stream, start, stop) - - def get_bookmark_data(self): - ''' Return the timestamp and last_read_location ''' - from calibre.ebooks.metadata.mobi import StreamSlicer - user_notes = {} - if self.bookmark_extension == 'mbp': - MAGIC_MOBI_CONSTANT = 150 - with open(self.path,'rb') as f: - stream = StringIO(f.read()) - data = StreamSlicer(stream) - self.timestamp, = unpack('>I', data[0x24:0x28]) - bpar_offset, = unpack('>I', data[0x4e:0x52]) - lrlo = bpar_offset + 0x0c - self.last_read = int(unpack('>I', data[lrlo:lrlo+4])[0]) - self.last_read_location = self.last_read/MAGIC_MOBI_CONSTANT + 1 - entries, = unpack('>I', data[0x4a:0x4e]) - - # Store the annotations/locations - bpl = bpar_offset + 4 - bpar_len, = unpack('>I', data[bpl:bpl+4]) - bpar_len += 8 - #print "bpar_len: 0x%x" % bpar_len - eo = bpar_offset + bpar_len - - # Walk bookmark entries - #print " --- %s --- " % self.path - current_entry = 1 - sig = data[eo:eo+4] - previous_block = None - - while sig == 'DATA': - text = None - entry_type = None - rec_len, = unpack('>I', data[eo+4:eo+8]) - if rec_len == 0: - current_block = "empty_data" - elif data[eo+8:eo+12] == "EBAR": - current_block = "data_header" - #entry_type = "data_header" - location, = unpack('>I', data[eo+0x34:eo+0x38]) - #print "data_header location: %d" % location - else: - current_block = "text_block" - if previous_block == 'empty_data': - entry_type = 'Note' - elif previous_block == 'data_header': - entry_type = 'Highlight' - text = data[eo+8:eo+8+rec_len].decode('utf-16-be') - - if entry_type: - displayed_location = location/MAGIC_MOBI_CONSTANT + 1 - user_notes[location] = dict(id=self.id, - displayed_location=displayed_location, - type=entry_type, - text=text) - - eo += rec_len + 8 - current_entry += 1 - previous_block = current_block - sig = data[eo:eo+4] - - while sig == 'BKMK': - # Fix start location for Highlights using BKMK data - end_loc, = unpack('>I', data[eo+0x10:eo+0x14]) - - if end_loc in user_notes and \ - (user_notes[end_loc]['type'] == 'Highlight' or \ - user_notes[end_loc]['type'] == 'Note'): - # Switch location to start (0x08:0x0c) - start, = unpack('>I', data[eo+8:eo+12]) - user_notes[start] = user_notes[end_loc] - ''' - print " %s: swapping 0x%x (%d) to 0x%x (%d)" % (user_notes[end_loc]['type'], - end_loc, - end_loc/MAGIC_MOBI_CONSTANT + 1, - start, - start//MAGIC_MOBI_CONSTANT + 1) - ''' - user_notes[start]['displayed_location'] = start/MAGIC_MOBI_CONSTANT + 1 - user_notes.pop(end_loc) - else: - # If a bookmark coincides with a user annotation, the locs could - # be the same - cheat by nudging -1 - # Skip bookmark for last_read_location - if end_loc != self.last_read: - # print " adding Bookmark at 0x%x (%d)" % (end_loc, end_loc/MAGIC_MOBI_CONSTANT + 1) - displayed_location = end_loc/MAGIC_MOBI_CONSTANT + 1 - user_notes[end_loc - 1] = dict(id=self.id, - displayed_location=displayed_location, - type='Bookmark', - text=None) - rec_len, = unpack('>I', data[eo+4:eo+8]) - eo += rec_len + 8 - sig = data[eo:eo+4] - - elif self.bookmark_extension == 'tan': - from calibre.ebooks.metadata.topaz import get_metadata as get_topaz_metadata - - def get_topaz_highlight(displayed_location): - # Parse My Clippings.txt for a matching highlight - # Search looks for book title match, highlight match, and location match - # Author is not matched - # This will find the first instance of a clipping only - book_fs = self.path.replace('.%s' % self.bookmark_extension,'.%s' % self.book_format) - with open(book_fs,'rb') as f2: - stream = StringIO(f2.read()) - mi = get_topaz_metadata(stream) - my_clippings = self.path - split = my_clippings.find('documents') + len('documents/') - my_clippings = my_clippings[:split] + "My Clippings.txt" - try: - with open(my_clippings, 'r') as f2: - marker_found = 0 - text = '' - search_str1 = '%s' % (mi.title) - search_str2 = '- Highlight Loc. %d' % (displayed_location) - for line in f2: - if marker_found == 0: - if line.startswith(search_str1): - marker_found = 1 - elif marker_found == 1: - if line.startswith(search_str2): - marker_found = 2 - elif marker_found == 2: - if line.startswith('=========='): - break - text += line.strip() - else: - raise Exception('error') - except: - text = '(Unable to extract highlight text from My Clippings.txt)' - return text - - MAGIC_TOPAZ_CONSTANT = 33.33 - self.timestamp = os.path.getmtime(self.path) - with open(self.path,'rb') as f: - stream = StringIO(f.read()) - data = StreamSlicer(stream) - self.last_read = int(unpack('>I', data[5:9])[0]) - self.last_read_location = self.last_read/MAGIC_TOPAZ_CONSTANT + 1 - entries, = unpack('>I', data[9:13]) - current_entry = 0 - e_base = 0x0d - while current_entry < entries: - location, = unpack('>I', data[e_base+2:e_base+6]) - text = None - text_len, = unpack('>I', data[e_base+0xA:e_base+0xE]) - e_type, = unpack('>B', data[e_base+1]) - if e_type == 0: - e_type = 'Bookmark' - elif e_type == 1: - e_type = 'Highlight' - text = get_topaz_highlight(location/MAGIC_TOPAZ_CONSTANT + 1) - elif e_type == 2: - e_type = 'Note' - text = data[e_base+0x10:e_base+0x10+text_len] - else: - e_type = 'Unknown annotation type' - - displayed_location = location/MAGIC_TOPAZ_CONSTANT + 1 - user_notes[location] = dict(id=self.id, - displayed_location=displayed_location, - type=e_type, - text=text) - if text_len == 0xFFFFFFFF: - e_base = e_base + 14 - else: - e_base = e_base + 14 + 2 + text_len - current_entry += 1 - for location in user_notes: - if location == self.last_read: - user_notes.pop(location) - break - - elif self.bookmark_extension == 'pdr': - self.timestamp = os.path.getmtime(self.path) - with open(self.path,'rb') as f: - stream = StringIO(f.read()) - data = StreamSlicer(stream) - self.last_read = int(unpack('>I', data[5:9])[0]) - entries, = unpack('>I', data[9:13]) - current_entry = 0 - e_base = 0x0d - self.pdf_page_offset = 0 - while current_entry < entries: - ''' - location, = unpack('>I', data[e_base+2:e_base+6]) - text = None - text_len, = unpack('>I', data[e_base+0xA:e_base+0xE]) - e_type, = unpack('>B', data[e_base+1]) - if e_type == 0: - e_type = 'Bookmark' - elif e_type == 1: - e_type = 'Highlight' - text = get_topaz_highlight(location/MAGIC_TOPAZ_CONSTANT + 1) - elif e_type == 2: - e_type = 'Note' - text = data[e_base+0x10:e_base+0x10+text_len] - else: - e_type = 'Unknown annotation type' - - if self.book_format in ['tpz','azw1']: - displayed_location = location/MAGIC_TOPAZ_CONSTANT + 1 - elif self.book_format == 'pdf': - # *** This needs implementation - displayed_location = location - user_notes[location] = dict(id=self.id, - displayed_location=displayed_location, - type=e_type, - text=text) - if text_len == 0xFFFFFFFF: - e_base = e_base + 14 - else: - e_base = e_base + 14 + 2 + text_len - current_entry += 1 - ''' - # Use label as page number - pdf_location, = unpack('>I', data[e_base+1:e_base+5]) - label_len, = unpack('>H', data[e_base+5:e_base+7]) - location = int(data[e_base+7:e_base+7+label_len]) - displayed_location = location - e_type = 'Bookmark' - text = None - user_notes[location] = dict(id=self.id, - displayed_location=displayed_location, - type=e_type, - text=text) - self.pdf_page_offset = pdf_location - location - e_base += (7 + label_len) - current_entry += 1 - - self.last_read_location = self.last_read - self.pdf_page_offset - - else: - print "unsupported bookmark_extension: %s" % self.bookmark_extension - self.user_notes = user_notes - - def get_book_length(self): - from calibre.ebooks.metadata.mobi import StreamSlicer - book_fs = self.path.replace('.%s' % self.bookmark_extension,'.%s' % self.book_format) - - self.book_length = 0 - if self.bookmark_extension == 'mbp': - # Read the book len from the header - try: - with open(book_fs,'rb') as f: - self.stream = StringIO(f.read()) - self.data = StreamSlicer(self.stream) - self.nrecs, = unpack('>H', self.data[76:78]) - record0 = self.record(0) - self.book_length = int(unpack('>I', record0[0x04:0x08])[0]) - except: - pass - elif self.bookmark_extension == 'tan': - # Read bookLength from metadata - from calibre.ebooks.metadata.topaz import MetadataUpdater - try: - with open(book_fs,'rb') as f: - mu = MetadataUpdater(f) - self.book_length = mu.book_length - except: - pass - elif self.bookmark_extension == 'pdr': - from calibre import plugins - try: - self.book_length = plugins['pdfreflow'][0].get_numpages(open(book_fs).read()) - except: - pass - - else: - print "unsupported bookmark_extension: %s" % self.bookmark_extension - -# }}} - From 7a6634d405739675d1dfd25cc55ed32912c701da Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Feb 2011 21:15:20 -0700 Subject: [PATCH 10/14] Kompas and Jakarta Post by Adrian Gunawan --- resources/recipes/jakarta_post.recipe | 67 +++++++++++++++++++++++ resources/recipes/kompas.recipe | 77 +++++++++++++++++++++++++++ src/calibre/utils/localization.py | 1 + 3 files changed, 145 insertions(+) create mode 100644 resources/recipes/jakarta_post.recipe create mode 100644 resources/recipes/kompas.recipe diff --git a/resources/recipes/jakarta_post.recipe b/resources/recipes/jakarta_post.recipe new file mode 100644 index 0000000000..d8d609469d --- /dev/null +++ b/resources/recipes/jakarta_post.recipe @@ -0,0 +1,67 @@ +#!/usr/bin/env python +__license__ = 'GPL v3' +__copyright__ = '2011, Adrian Gunawan ' +__author__ = 'Adrian Gunawan' +__version__ = 'v1.0' +__date__ = '02 February 2011' + +''' +http://www.thejakartapost.com/ +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class JakartaPost(BasicNewsRecipe): + title = u'Jakarta Post' + masthead_url = 'http://www.thejakartapost.com/images/jakartapost_logo.jpg' + cover_url = 'http://www.thejakartapost.com/images/jakartapost_logo.jpg' + + __author__ = u'Adrian Gunawan' + description = u'Indonesian Newspaper in English from Jakarta Post Online Edition' + category = 'breaking news, national, business, international, Indonesia' + language = 'en_ID' + oldest_article = 2 + max_articles_per_feed = 100 + + no_stylesheets = True + use_embedded_content = False + no_javascript = True + remove_empty_feeds = True + + timefmt = ' [%A, %d %B, %Y]' + encoding = 'utf-8' + + keep_only_tags = [dict(name='div', attrs ={'id':'news-main'})] + + extra_css = ''' + h1{font-family:Georgia,"Times New Roman",Times,serif; font-weight:bold; font-size:large;} + .cT-storyDetails{font-family:Arial,Helvetica,sans-serif; color:#666666;font-size:x-small;} + .articleBody{font-family:Arial,Helvetica,sans-serif; color:black;font-size:small;} + .cT-imageLandscape{font-family:Arial,Helvetica,sans-serif; color:#333333 ;font-size:x-small;} + .source{font-family:Arial,Helvetica,sans-serif; color:#333333 ;font-size:xx-small;} + #content{font-family:Arial,Helvetica,sans-serif;font-size:x-small;} + .pageprint{font-family:Arial,Helvetica,sans-serif;font-size:small;} + #bylineDetails{font-family:Arial,Helvetica,sans-serif; color:#666666;font-size:x-small;} + .featurePic-wide{font-family:Arial,Helvetica,sans-serif;font-size:x-small;} + #idfeaturepic{font-family:Arial,Helvetica,sans-serif;font-size:x-small;} + h3{font-family:Georgia,"Times New Roman",Times,serif; font-size:small;} + h2{font-family:Georgia,"Times New Roman",Times,serif; font-size:small;} + h4{font-family:Georgia,"Times New Roman",Times,serif; font-size:small;} + h5{font-family:Georgia,"Times New Roman",Times,serif; font-size:small;} + body{font-family:Arial,Helvetica,sans-serif; font-size:x-small;} + ''' + + remove_tags = [ + dict(name='div', attrs ={'class':['text-size']}), + ] + + feeds = [ + + (u'Breaking News', u'http://www.thejakartapost.com/breaking/feed'), + (u'National', u'http://www.thejakartapost.com/channel/national/feed'), + (u'Archipelago', u'http://www.thejakartapost.com/channel/archipelago/feed'), + (u'Business', u'http://www.thejakartapost.com/channel/business/feed'), + (u'Jakarta', u'http://www.thejakartapost.com/channel/jakarta/feed'), + (u'World', u'http://www.thejakartapost.com/channel/world/feed'), + (u'Sports', u'http://www.thejakartapost.com/channel/sports/feed'), + ] diff --git a/resources/recipes/kompas.recipe b/resources/recipes/kompas.recipe new file mode 100644 index 0000000000..2f2804d59a --- /dev/null +++ b/resources/recipes/kompas.recipe @@ -0,0 +1,77 @@ +#!/usr/bin/env python +__license__ = 'GPL v3' +__copyright__ = '2011, Adrian Gunawan ' +__author__ = 'Adrian Gunawan' +__version__ = 'v1.0' +__date__ = '02 February 2011' + +''' +http://www.kompas.com/ +''' + +import re +from calibre.web.feeds.news import BasicNewsRecipe + +class Kompas(BasicNewsRecipe): + title = u'Kompas' + masthead_url = 'http://stat.k.kidsklik.com/data/2k10/kompascom2011/images/logo_kompas.png' + cover_url = 'http://stat.k.kidsklik.com/data/2k10/kompascom2011/images/logo_kompas.png' + + __author__ = u'Adrian Gunawan' + description = u'Indonesian News from Kompas Online Edition' + category = 'local news, international, business, Indonesia' + language = 'id' + oldest_article = 5 + max_articles_per_feed = 100 + + no_stylesheets = True + use_embedded_content = False + no_javascript = True + remove_empty_feeds = True + + timefmt = ' [%A, %d %B, %Y]' + encoding = 'utf-8' + + keep_only_tags = [dict(name='div', attrs ={'class':'content_kiri_detail'})] + + extra_css = ''' + h1{font-family:Georgia,"Times New Roman",Times,serif; font-weight:bold; font-size:large;} + .cT-storyDetails{font-family:Arial,Helvetica,sans-serif; color:#666666;font-size:x-small;} + .articleBody{font-family:Arial,Helvetica,sans-serif; color:black;font-size:small;} + .cT-imageLandscape{font-family:Arial,Helvetica,sans-serif; color:#333333 ;font-size:x-small;} + .source{font-family:Arial,Helvetica,sans-serif; color:#333333 ;font-size:xx-small;} + #content{font-family:Arial,Helvetica,sans-serif;font-size:x-small;} + .pageprint{font-family:Arial,Helvetica,sans-serif;font-size:small;} + #bylineDetails{font-family:Arial,Helvetica,sans-serif; color:#666666;font-size:x-small;} + .featurePic-wide{font-family:Arial,Helvetica,sans-serif;font-size:x-small;} + #idfeaturepic{font-family:Arial,Helvetica,sans-serif;font-size:x-small;} + h3{font-family:Georgia,"Times New Roman",Times,serif; font-size:small;} + h2{font-family:Georgia,"Times New Roman",Times,serif; font-size:small;} + h4{font-family:Georgia,"Times New Roman",Times,serif; font-size:small;} + h5{font-family:Georgia,"Times New Roman",Times,serif; font-size:small;} + body{font-family:Arial,Helvetica,sans-serif; font-size:x-small;} + ''' + + remove_tags = [ + dict(name='div', attrs ={'class':['c_biru_kompas2011', 'c_abu01_kompas2011', 'c_abu_01_kompas2011', 'right', 'clearit']}), + dict(name='div', attrs ={'id':['comment_list', 'comment_paging', 'share']}), + dict(name='form'), + dict(name='ul'), + ] + + preprocess_regexps = [ + (re.compile(r'.*', re.DOTALL|re.IGNORECASE),lambda match: ''), + (re.compile(r'Sent Using.*', re.DOTALL|re.IGNORECASE),lambda match: ''), + (re.compile(r'Kirim Komentar Anda', re.DOTALL|re.IGNORECASE),lambda match: ''), + (re.compile(r']*>Kembali ke Index Topik Pilihan', re.DOTALL|re.IGNORECASE),lambda match: ''), + ] + + feeds = [ + (u'Nasional', u'http://www.kompas.com/getrss/nasional'), + (u'Regional', u'http://www.kompas.com/getrss/regional'), + (u'Internasional', u'http://www.kompas.com/getrss/internasional'), + (u'Megapolitan', u'http://www.kompas.com/getrss/megapolitan'), + (u'Bisnis Keuangan', u'http://www.kompas.com/getrss/bisniskeuangan'), + (u'Kesehatan', u'http://www.kompas.com/getrss/kesehatan'), + (u'Olahraga', u'http://www.kompas.com/getrss/olahraga'), + ] diff --git a/src/calibre/utils/localization.py b/src/calibre/utils/localization.py index 97356df081..1f869a6475 100644 --- a/src/calibre/utils/localization.py +++ b/src/calibre/utils/localization.py @@ -107,6 +107,7 @@ _extra_lang_codes = { 'en_CZ' : _('English (Czechoslovakia)'), 'en_PK' : _('English (Pakistan)'), 'en_HR' : _('English (Croatia)'), + 'en_ID' : _('English (Indonesia)'), 'en_IL' : _('English (Israel)'), 'en_SG' : _('English (Singapore)'), 'en_YE' : _('English (Yemen)'), From 3278370ed654ca95286935928c951019bb2b1b55 Mon Sep 17 00:00:00 2001 From: John Schember Date: Thu, 10 Feb 2011 07:09:28 -0500 Subject: [PATCH 11/14] Add try block around APNX writing. --- src/calibre/devices/kindle/driver.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/calibre/devices/kindle/driver.py b/src/calibre/devices/kindle/driver.py index c33833cec4..615d786adc 100644 --- a/src/calibre/devices/kindle/driver.py +++ b/src/calibre/devices/kindle/driver.py @@ -218,7 +218,10 @@ class KINDLE2(KINDLE): apnx_path = '%s.apnx' % os.path.join(path, filename) apnx_builder = APNXBuilder() - apnx_builder.write_apnx(filepath, apnx_path) + try: + apnx_builder.write_apnx(filepath, apnx_path) + except: + pass class KINDLE_DX(KINDLE2): From 740e8555478f0fe3d74a02d93ee786648eb1ba98 Mon Sep 17 00:00:00 2001 From: ldolse Date: Thu, 10 Feb 2011 22:32:39 +0800 Subject: [PATCH 12/14] convert entities for lit files going through txt processing --- src/calibre/ebooks/conversion/utils.py | 6 +++--- src/calibre/ebooks/lit/input.py | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/calibre/ebooks/conversion/utils.py b/src/calibre/ebooks/conversion/utils.py index a87392b54f..359915bdf0 100644 --- a/src/calibre/ebooks/conversion/utils.py +++ b/src/calibre/ebooks/conversion/utils.py @@ -338,11 +338,9 @@ class HeuristicProcessor(object): return content def txt_process(self, match): - from calibre.ebooks.txt.processor import convert_basic, preserve_spaces, \ - separate_paragraphs_single_line + from calibre.ebooks.txt.processor import convert_basic, separate_paragraphs_single_line content = match.group('text') content = separate_paragraphs_single_line(content) - content = preserve_spaces(content) content = convert_basic(content, epub_split_size_kb=0) return content @@ -352,6 +350,8 @@ class HeuristicProcessor(object): self.log.debug("Running Text Processing") outerhtml = re.compile(r'.*?(?<=
)(?P.*?)
', re.IGNORECASE|re.DOTALL) html = outerhtml.sub(self.txt_process, html) + from calibre.ebooks.conversion.preprocess import convert_entities + html = re.sub(r'&(\S+?);', convert_entities, html) else: # Add markup naively # TODO - find out if there are cases where there are more than one
 tag or
diff --git a/src/calibre/ebooks/lit/input.py b/src/calibre/ebooks/lit/input.py
index 9ccbba543f..4008f15d53 100644
--- a/src/calibre/ebooks/lit/input.py
+++ b/src/calibre/ebooks/lit/input.py
@@ -37,13 +37,12 @@ class LITInput(InputFormatPlugin):
                 body = body[0]
                 if len(body) == 1 and body[0].tag == XHTML('pre'):
                     pre = body[0]
-                    from calibre.ebooks.txt.processor import convert_basic, preserve_spaces, \
+                    from calibre.ebooks.txt.processor import convert_basic, \
                         separate_paragraphs_single_line
                     from calibre.ebooks.chardet import xml_to_unicode
                     from lxml import etree
                     import copy
                     html = separate_paragraphs_single_line(pre.text)
-                    html = preserve_spaces(html)
                     html = convert_basic(html).replace('',
                             ''%XHTML_NS)
                     html = xml_to_unicode(html, strip_encoding_pats=True,

From 0ac0fe5bcd02cfc1923b02ad2de459a378d169a7 Mon Sep 17 00:00:00 2001
From: Kovid Goyal 
Date: Thu, 10 Feb 2011 10:28:15 -0700
Subject: [PATCH 13/14] Fix regression that caused a spurious error message
 after moving a library. Also ensure that the entries in the Copy to Library
 menu are upadted after a library is moved/renamed/deleted. Fixes #8905 (After
 moving Library, the "Open in Containing Folder" option points to the old
 location)

---
 src/calibre/gui2/actions/choose_library.py | 16 +++++++++++++++-
 src/calibre/gui2/dialogs/choose_library.py |  2 ++
 2 files changed, 17 insertions(+), 1 deletion(-)

diff --git a/src/calibre/gui2/actions/choose_library.py b/src/calibre/gui2/actions/choose_library.py
index 7034380a56..f3a7f1742d 100644
--- a/src/calibre/gui2/actions/choose_library.py
+++ b/src/calibre/gui2/actions/choose_library.py
@@ -237,6 +237,7 @@ class ChooseLibraryAction(InterfaceAction):
             return
         self.stats.rename(location, newloc)
         self.build_menus()
+        self.gui.iactions['Copy To Library'].build_menus()
 
     def delete_requested(self, name, location):
         loc = location.replace('/', os.sep)
@@ -253,6 +254,7 @@ class ChooseLibraryAction(InterfaceAction):
                 pass
         self.stats.remove(location)
         self.build_menus()
+        self.gui.iactions['Copy To Library'].build_menus()
 
     def backup_status(self, location):
         dirty_text = 'no'
@@ -329,6 +331,7 @@ class ChooseLibraryAction(InterfaceAction):
                     ' libraries.')%loc, show=True)
             self.stats.remove(location)
             self.build_menus()
+            self.gui.iactions['Copy To Library'].build_menus()
             return
 
         prefs['library_path'] = loc
@@ -371,9 +374,20 @@ class ChooseLibraryAction(InterfaceAction):
         if not self.change_library_allowed():
             return
         from calibre.gui2.dialogs.choose_library import ChooseLibrary
+        self.gui.library_view.save_state()
         db = self.gui.library_view.model().db
-        c = ChooseLibrary(db, self.gui.library_moved, self.gui)
+        location = self.stats.canonicalize_path(db.library_path)
+        self.pre_choose_dialog_location = location
+        c = ChooseLibrary(db, self.choose_library_callback, self.gui)
         c.exec_()
+        self.choose_dialog_library_renamed = getattr(c, 'library_renamed', False)
+
+    def choose_library_callback(self, newloc, copy_structure=False):
+        self.gui.library_moved(newloc, copy_structure=copy_structure)
+        if getattr(self, 'choose_dialog_library_renamed', False):
+            self.stats.rename(self.pre_choose_dialog_location, prefs['library_path'])
+        self.build_menus()
+        self.gui.iactions['Copy To Library'].build_menus()
 
     def change_library_allowed(self):
         if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
diff --git a/src/calibre/gui2/dialogs/choose_library.py b/src/calibre/gui2/dialogs/choose_library.py
index 033b038b65..24bd6591c6 100644
--- a/src/calibre/gui2/dialogs/choose_library.py
+++ b/src/calibre/gui2/dialogs/choose_library.py
@@ -71,6 +71,8 @@ class ChooseLibrary(QDialog, Ui_Dialog):
             prefs['library_path'] = loc
             self.callback(loc, copy_structure=self.copy_structure.isChecked())
         else:
+            self.db.prefs.disable_setting = True
+            self.library_renamed = True
             move_library(self.db.library_path, loc, self.parent(),
                     self.callback)
 

From ad8d2f8889d47ce2717d9c4b2028791846c5b64c Mon Sep 17 00:00:00 2001
From: Kovid Goyal 
Date: Thu, 10 Feb 2011 10:55:30 -0700
Subject: [PATCH 14/14] EPUB Output: Cleanup zipfile code

---
 src/calibre/ebooks/epub/output.py | 21 +++++++++++----------
 1 file changed, 11 insertions(+), 10 deletions(-)

diff --git a/src/calibre/ebooks/epub/output.py b/src/calibre/ebooks/epub/output.py
index 2e254e99cc..0ed6d7e222 100644
--- a/src/calibre/ebooks/epub/output.py
+++ b/src/calibre/ebooks/epub/output.py
@@ -216,21 +216,22 @@ class EPUBOutput(OutputFormatPlugin):
                 encryption = self.encrypt_fonts(encrypted_fonts, tdir, uuid)
 
             from calibre.ebooks.epub import initialize_container
-            epub = initialize_container(output_path, os.path.basename(opf),
-                    extra_entries=extra_entries)
-            epub.add_dir(tdir)
-            if encryption is not None:
-                epub.writestr('META-INF/encryption.xml', encryption)
-            if metadata_xml is not None:
-                epub.writestr('META-INF/metadata.xml',
-                        metadata_xml.encode('utf-8'))
+            with initialize_container(output_path, os.path.basename(opf),
+                    extra_entries=extra_entries) as epub:
+                epub.add_dir(tdir)
+                if encryption is not None:
+                    epub.writestr('META-INF/encryption.xml', encryption)
+                if metadata_xml is not None:
+                    epub.writestr('META-INF/metadata.xml',
+                            metadata_xml.encode('utf-8'))
             if opts.extract_to is not None:
+                from calibre.utils.zipfile import ZipFile
                 if os.path.exists(opts.extract_to):
                     shutil.rmtree(opts.extract_to)
                 os.mkdir(opts.extract_to)
-                epub.extractall(path=opts.extract_to)
+                with ZipFile(output_path) as zf:
+                    zf.extractall(path=opts.extract_to)
                 self.log.info('EPUB extracted to', opts.extract_to)
-            epub.close()
 
     def encrypt_fonts(self, uris, tdir, uuid): # {{{
         from binascii import unhexlify