From 4facdb06d555bab83292bef3da5265403f99ee7f Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 12 Jan 2011 21:01:38 +0000 Subject: [PATCH 01/30] First try at forward and backward movement through highlighted rows --- src/calibre/customize/builtins.py | 6 ++- src/calibre/gui2/actions/move_selection.py | 43 +++++++++++++++++++ src/calibre/gui2/library/models.py | 48 +++++++++++++++++----- src/calibre/gui2/library/views.py | 10 ++++- 4 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 src/calibre/gui2/actions/move_selection.py diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index d0f986209c..176d5e3901 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -705,13 +705,17 @@ class ActionTweakEpub(InterfaceActionBase): name = 'Tweak ePub' actual_plugin = 'calibre.gui2.actions.tweak_epub:TweakEpubAction' +class ActionMoveSelection(InterfaceActionBase): + name = 'Edit Metadata' + actual_plugin = 'calibre.gui2.actions.move_selection:MoveSelectionAction' + plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog, ActionConvert, ActionDelete, ActionEditMetadata, ActionView, ActionFetchNews, ActionSaveToDisk, ActionShowBookDetails, ActionRestart, ActionOpenFolder, ActionConnectShare, ActionSendToDevice, ActionHelp, ActionPreferences, ActionSimilarBooks, ActionAddToLibrary, ActionEditCollections, ActionChooseLibrary, - ActionCopyToLibrary, ActionTweakEpub] + ActionCopyToLibrary, ActionTweakEpub, ActionMoveSelection] # }}} diff --git a/src/calibre/gui2/actions/move_selection.py b/src/calibre/gui2/actions/move_selection.py new file mode 100644 index 0000000000..e49803fcd6 --- /dev/null +++ b/src/calibre/gui2/actions/move_selection.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +from calibre.gui2.actions import InterfaceAction + +class MoveSelectionAction(InterfaceAction): + name = 'Move selection in library' + action_spec = (_('Move to next item'), 'arrow-down.png', + _('Move to next highlighted item'), 'n') + dont_add_to = frozenset(['toolbar-device', 'context-menu-device']) + action_type = 'current' + + def genesis(self): + ''' + Setup this plugin. Only called once during initialization. self.gui is + available. The action secified by :attr:`action_spec` is available as + ``self.qaction``. + ''' + self.can_move = None + self.qaction.triggered.connect(self.move_forward) + self.create_action(spec=(_('Move to previous item'), 'arrow-up.png', + _('Move to previous highlighted item'), 'F3'), attr='p_action') + self.gui.addAction(self.p_action) + self.p_action.triggered.connect(self.move_backward) + + def location_selected(self, loc): + self.can_move = loc == 'library' + + def move_forward(self): + if self.can_move is None: + self.can_move = self.gui.current_view() is self.gui.library_view + if self.can_move: + self.gui.current_view().move_highlighted_row(forward=True) + + def move_backward(self): + if self.can_move is None: + self.can_move = self.gui.current_view() is self.gui.library_view + if self.can_move: + self.gui.current_view().move_highlighted_row(forward=False) \ No newline at end of file diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 38a4b28744..670a2d823e 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -93,8 +93,9 @@ class BooksModel(QAbstractTableModel): # {{{ self.bool_no_icon = QIcon(I('list_remove.png')) self.bool_blank_icon = QIcon(I('blank.png')) self.device_connected = False - self.rows_matching = set() - self.lowest_row_matching = None + self.rows_to_highlight = [] + self.rows_to_highlight_set = set() + self.current_highlighted_row = None self.highlight_only = False self.read_config() @@ -130,6 +131,9 @@ class BooksModel(QAbstractTableModel): # {{{ self.book_on_device = func def set_database(self, db): + self.rows_to_highlight = [] + self.rows_to_highlight_set = set() + self.current_highlighted_row = None self.db = db self.custom_columns = self.db.field_metadata.custom_field_metadata() self.column_map = list(self.orig_headers.keys()) + \ @@ -237,21 +241,43 @@ class BooksModel(QAbstractTableModel): # {{{ if self.last_search: self.research() + def get_current_highlighted_row(self): + if len(self.rows_to_highlight) == 0 or self.current_highlighted_row is None: + return None + try: + return self.rows_to_highlight[self.current_highlighted_row] + except: + return None + + def get_next_highlighted_row(self, forward): + if len(self.rows_to_highlight) == 0 or self.current_highlighted_row is None: + return None + self.current_highlighted_row += 1 if forward else -1 + if self.current_highlighted_row < 0: + self.current_highlighted_row = len(self.rows_to_highlight) - 1; + elif self.current_highlighted_row >= len(self.rows_to_highlight): + self.current_highlighted_row = 0 + return self.get_current_highlighted_row() + def search(self, text, reset=True): try: if self.highlight_only: self.db.search('') if not text: - self.rows_matching = set() - self.lowest_row_matching = None + self.rows_to_highlight = [] + self.rows_to_highlight_set = set() + self.current_highlighted_row = None else: - self.rows_matching = self.db.search(text, return_matches=True) - if self.rows_matching: - self.lowest_row_matching = self.db.row(self.rows_matching[0]) - self.rows_matching = set(self.rows_matching) + self.rows_to_highlight = self.db.search(text, return_matches=True) + self.rows_to_highlight_set = set(self.rows_to_highlight) + if self.rows_to_highlight: + self.current_highlighted_row = 0 + else: + self.current_highlighted_row = None else: - self.rows_matching = set() - self.lowest_row_matching = None + self.rows_to_highlight = [] + self.rows_to_highlight_set = set() + self.current_highlighted_row = None self.db.search(text) except ParseException as e: self.searched.emit(e.msg) @@ -673,7 +699,7 @@ class BooksModel(QAbstractTableModel): # {{{ if role in (Qt.DisplayRole, Qt.EditRole): return self.column_to_dc_map[col](index.row()) elif role == Qt.BackgroundColorRole: - if self.id(index) in self.rows_matching: + if self.id(index) in self.rows_to_highlight_set: return QColor('lightgreen') elif role == Qt.DecorationRole: if self.column_to_dc_decorator_map[col] is not None: diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index ea2e03fdad..e5cc259244 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -680,10 +680,16 @@ class BooksView(QTableView): # {{{ def set_editable(self, editable, supports_backloading): self._model.set_editable(editable) + def move_highlighted_row(self, forward): + id_to_select = self._model.get_next_highlighted_row(forward) + if id_to_select is not None: + self.select_rows([id_to_select], using_ids=True) + def search_proxy(self, txt): self._model.search(txt) - if self._model.lowest_row_matching is not None: - self.select_rows([self._model.lowest_row_matching], using_ids=False) + id_to_select = self._model.get_current_highlighted_row() + if id_to_select is not None: + self.select_rows([id_to_select], using_ids=True) self.setFocus(Qt.OtherFocusReason) def connect_to_search_box(self, sb, search_done): From f0881c3d26f5666dc2cd914ee5f55b737e166c8c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Jan 2011 18:59:25 -0700 Subject: [PATCH 02/30] News download: Convert various HTML 5 tags into
--- src/calibre/web/feeds/news.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/calibre/web/feeds/news.py b/src/calibre/web/feeds/news.py index 7bd5301dfb..ee5b11c5f6 100644 --- a/src/calibre/web/feeds/news.py +++ b/src/calibre/web/feeds/news.py @@ -700,10 +700,17 @@ class BasicNewsRecipe(Recipe): for attr in self.remove_attributes: for x in soup.findAll(attrs={attr:True}): del x[attr] - for base in list(soup.findAll(['base', 'iframe'])): + for base in list(soup.findAll(['base', 'iframe', 'canvas', 'embed', + 'command', 'datalist', 'video', 'audio'])): base.extract() ans = self.postprocess_html(soup, first_fetch) + + # Nuke HTML5 tags + for x in ans.findAll(['article', 'aside', 'header', 'footer', 'nav', + 'figcaption', 'figure', 'section']): + x.name = 'div' + if job_info: url, f, a, feed_len = job_info try: From d0f92778f8309aa3f8f4d765fe61031a23246b35 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Jan 2011 18:59:41 -0700 Subject: [PATCH 03/30] Fix Globe and Mail --- resources/recipes/globe_and_mail.recipe | 30 +++++++++++-------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/resources/recipes/globe_and_mail.recipe b/resources/recipes/globe_and_mail.recipe index 4cc76688c1..22cb6fa5bb 100644 --- a/resources/recipes/globe_and_mail.recipe +++ b/resources/recipes/globe_and_mail.recipe @@ -8,12 +8,13 @@ __docformat__ = 'restructuredtext en' globeandmail.com ''' +import re + from calibre.web.feeds.news import BasicNewsRecipe class AdvancedUserRecipe1287083651(BasicNewsRecipe): title = u'Globe & Mail' - __license__ = 'GPL v3' - __author__ = 'Szing' + __author__ = 'Kovid Goyal' oldest_article = 2 no_stylesheets = True max_articles_per_feed = 100 @@ -38,24 +39,19 @@ class AdvancedUserRecipe1287083651(BasicNewsRecipe): (u'Sports', u'http://www.theglobeandmail.com/auto/?service=rss') ] - keep_only_tags = [ - dict(name='h1'), - dict(name='h2', attrs={'id':'articletitle'}), - dict(name='p', attrs={'class':['leadText', 'meta', 'leadImage', 'redtext byline', 'bodyText']}), - dict(name='div', attrs={'class':['news','articlemeta','articlecopy']}), - dict(name='id', attrs={'class':'article'}), - dict(name='table', attrs={'class':'todays-market'}), - dict(name='header', attrs={'id':'leadheader'}) - ] + preprocess_regexps = [ + (re.compile(r'', re.DOTALL), lambda m: ''), + (re.compile(r'', re.DOTALL), lambda m: ''), + ] + remove_tags_before = dict(name='h1') remove_tags = [ - dict(name='div', attrs={'id':['tabInside', 'ShareArticles', 'topStories']}) - ] - - #this has to be here or the text in the article appears twice. - remove_tags_after = [dict(id='article')] + dict(name='div', attrs={'id':['ShareArticles', 'topStories']}), + dict(href=lambda x: x and 'tracking=' in x), + {'class':['articleTools', 'pagination', 'Ads', 'topad', + 'breadcrumbs', 'footerNav', 'footerUtil', 'downloadlinks']}] #Use the mobile version rather than the web version def print_version(self, url): - return url + '&service=mobile' + return url.rpartition('?')[0] + '?service=mobile' From bce5a1b4bc9ccb92112af849f64b020e6b4c5efb Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Jan 2011 23:29:02 -0700 Subject: [PATCH 04/30] Pure python implementation of WMF parser to extract bitmapped images stored in WMF files --- src/calibre/utils/wmf/parse.py | 269 +++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 src/calibre/utils/wmf/parse.py diff --git a/src/calibre/utils/wmf/parse.py b/src/calibre/utils/wmf/parse.py new file mode 100644 index 0000000000..c618884e33 --- /dev/null +++ b/src/calibre/utils/wmf/parse.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import sys, struct + + + +class WMFHeader(object): + + ''' + For header documentation, see + http://www.skynet.ie/~caolan/publink/libwmf/libwmf/doc/ora-wmf.html + ''' + + def __init__(self, data, log, verbose): + self.log, self.verbose = log, verbose + offset = 0 + file_type, header_size, windows_version = struct.unpack_from(' 0: + params = data[offset:offset+delta] + offset += delta + + func = self.function_map.get(func, func) + + if self.verbose > 3: + self.log.debug('WMF Record:', size, func) + self.records.append((func, params)) + + for rec in self.records: + f = getattr(self, rec[0], None) + if callable(f): + f(rec[1]) + elif self.verbose > 2: + self.log.debug('Ignoring record:', rec[0]) + + self.has_raster_image = len(self.bitmaps) > 0 + + + def SetMapMode(self, params): + if len(params) == 2: + self.map_mode = struct.unpack(' Date: Wed, 12 Jan 2011 23:29:29 -0700 Subject: [PATCH 05/30] RTF Input: Improved support for conversion of embedded WMF images --- src/calibre/ebooks/rtf/input.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/calibre/ebooks/rtf/input.py b/src/calibre/ebooks/rtf/input.py index ba13668eb7..92ac8a2519 100644 --- a/src/calibre/ebooks/rtf/input.py +++ b/src/calibre/ebooks/rtf/input.py @@ -190,12 +190,11 @@ class RTFInput(InputFormatPlugin): return name def rasterize_wmf(self, name): - raise ValueError('Conversion of WMF images not supported') - from calibre.utils.wmf import extract_raster_image + from calibre.utils.wmf.parse import wmf_unwrap with open(name, 'rb') as f: data = f.read() - data = extract_raster_image(data) - name = name.replace('.wmf', '.jpg') + data = wmf_unwrap(data) + name = name.replace('.wmf', '.png') with open(name, 'wb') as f: f.write(data) return name From 806b1d0a6bf5c5c299b24300fef9cba7fe7f4f1c Mon Sep 17 00:00:00 2001 From: GRiker Date: Thu, 13 Jan 2011 03:54:13 -0700 Subject: [PATCH 06/30] GwR fix for #8295, series title sorting failure (Kindle) --- src/calibre/library/catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/library/catalog.py b/src/calibre/library/catalog.py index 5cda9baa8c..0be2d7fc05 100644 --- a/src/calibre/library/catalog.py +++ b/src/calibre/library/catalog.py @@ -3250,7 +3250,7 @@ class EPUB_MOBI(CatalogPlugin): # Loop over the series titles, find start of each letter, add description_preview_count books # Special switch for using different title list title_list = self.booksBySeries - current_letter = self.letter_or_symbol(title_list[0]['series'][0]) + current_letter = self.letter_or_symbol(self.generateSortTitle(title_list[0]['series'])[0]) title_letters = [current_letter] current_series_list = [] current_series = "" From 969965f7c1927fa3414b5ce16398f00d3f1060f8 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 12:42:11 +0000 Subject: [PATCH 07/30] New plugin architecture for formatter --- resources/images/template_funcs.png | Bin 0 -> 16232 bytes src/calibre/customize/builtins.py | 13 +- .../gui2/preferences/template_functions.py | 147 ++++++ .../gui2/preferences/template_functions.ui | 191 +++++++ src/calibre/library/database2.py | 3 + src/calibre/utils/formatter.py | 213 +------- src/calibre/utils/formatter_functions.py | 468 ++++++++++++++++++ 7 files changed, 846 insertions(+), 189 deletions(-) create mode 100644 resources/images/template_funcs.png create mode 100644 src/calibre/gui2/preferences/template_functions.py create mode 100644 src/calibre/gui2/preferences/template_functions.ui create mode 100644 src/calibre/utils/formatter_functions.py diff --git a/resources/images/template_funcs.png b/resources/images/template_funcs.png new file mode 100644 index 0000000000000000000000000000000000000000..91cdfa9f63ff833dc824e78ff4143a1b1621f5d7 GIT binary patch literal 16232 zcmY*gbx<75(>^qKaCa`a1$PMU?ry;?xVr?mpoc>sxI@r`;O_43?*85T`|q2o*{Qv{ z+TEV+>FIv@nMh?tDO4mvBme+_DkCkf3K;|c>j?0WYmbo02xNe@{HE{?09b-uty_kM z+{2s7Nr^*7NP7}%3xEuWPSQFqkWu=7U2@1)=)WJ`oxWK@#=!q|)~&CZ0D#brjQBTo z&*ighuXJKdugw6BxreWQ@)M=JyNMjFzY`?~7wdLm%&=HTOsmkz87cIU*TX^+`r3N8 zhM+0JMgEy&_5EnC#pnkG@|wa3eKFfH1M1XqWQtLZ`Q6dHh zi5F-om3p_9AD2I`zdWu7k2n!9_k910lD5UBAO8PuF3F&aqWxe#6h-RnoE_M|HQQ~Q zZdkPtfl(|!?Pm?8Dzuz6t^8|qKU>C)6nBph%{RzbwkjO6KqlsQKLvLlDLA&`DMQ7| z#UR+>y}&d7sG-z@{7Y{vfSH!@%dq{!nun2|g2Gs%KRCdJ7JC^R1)jTg?fiS> zHG5E583IMk%2~9(UcJQ_X%{L_AV;{AlS~)*6U$~p!Rp1s1zJQ8sAua^gEamV*46<` zW^ukXVQW>PHDOuVe!z{VK=e;z3yW}{^Ol1Np_iozA%MIYdiNI!IR6D;7lA>IG3#Fj zY$W^ZCtneE zzJPrPli>T<`FyMuvsfd-MO;A08ey2olNbZWyj)UTg`BG-pVIS+wKA zmgsh9HmhGs`0h2;!Uc+zr&hafJz1#UfcXpXIp<|Y=1(=rTSwBL-UrZ8wV{io2mxw(bMxA#~a_^jr2uDeFMVTBGR z8BB<%ZEJBOd!j1Xz@t@pDxFjn>@Yj7wx59DZt?{~pR*OnQyqmtpa;f}ZwPtBb>Br= zKHgudkt2G1po6;hAvzzrc;HSSG-O^#fuRt`&;sG|VT-p6t?h|FJq4~g&?FCSI=4i1 zYGMCYM=df8X%VJsUxuwILUxCkLG#?~tp15BANRuCoIO%R4^f99i(&NxL@TAu3d}4x zd(@V>)qXVc6B>M$A_8k`sY4{cg+hf>0s<700UO6b`vP`})|&;OGYhe5MJLtXte zPMi`{URG9h?cMR{E7Wdy;e~3|*spM&3Z!H4S5Wa6!rb-k`bA$>mZ|~6z2-VICj2}V zAGf=IT+p*LIt@`q90~t&aQ9{o#8h~R(Tqrt!sva4$bv*R+a1QS_ln1v4}y8K745iI zcu&rdq1@ZDA@e0&2l;E0-O@Gg<_f+I`Iy8wDx(D8H?y|kuML+8K`%T;%*a!Sgs9!d z=(!Uf>-v_Iyr8jEw{H0kzovrr>cS8#5)we@@yXJU5hgrW)iZbgBUeI$1-vEvv9Yo8 z^_!fB3jfRQPnN7jT@o5F)cwPe&U;mD=Qa=vw0CIskrNZ^+5d0BP8?8SBQdc#kK@XZ zH&L!)?{R^cs+%WL|46pB5geTtR~N!`lp{B`f#CDt5dcasTdjP$%PTC4MVUF`ymZE8 zL7&KcFA?AzHw%JdMHBkmKi(cTj@{cT+n#ZA4Cu88|vQWKCF~3TkTuDGMkfJ2B)nH5Gujyn*qdV=zPGI~=5>TjEj! za9;qdMS<2ay4_n&HO_m(B>3xrs3BX-nS`v4UGE(iei!{~Z63nPMn(ba%;}}|mGdpU zbWsJnclS%J#xsS5#D2$g5TWu5VHO4v$^%J61}`G!HLd=H)?l@>HEogN`yrYVNLx-O z1tVPADU#?LAeG3FreeaZfKo%j%N+KJ`P;k;Ie zYtr4@hfQZrAG6?he9438=Z)+Izb(kNOXNPFm4K4;@=)0%qO&I@3dzZ9_0z^1T83e#LcHMNBn;uQ+J;wyTx)S8a}>7)Q0wl4kt0J7|$cUH|%@ zrfFFJ5PLNLINEpQCEGTysA#xhKzRy~&R6Prz&MfN>7b^EXaO{`v$H#V?oV~Z0cB-u z{QMw1ON+zVGxywNT46kdxdkj3U4UYz@z(k2(SyxZ`?G*@a^L}(Ure!QYETdcAOFkY zsX+IdM*v3rz}TWXl0@py(5!BaY_}gn6<^lMag_g@;(Vl09+5*9rWA@%-8*2~RMojhMQ^GWM) z-6=vu4-J{mX|0)luV-M4iLW`-)Y_UL4>x!7A-P%rsDHIDP}zWF#$VXgH4r>6;!KUa zl$AbF7TscG6yuwQ>?|-yH z2Rqp%rJ!nf3OHnfP5_6erz2zs56h*O%lPrXN2aFgy5-SQLKy#HsnB7su;hdhHUK{NH78{7wgWE<6Yb+b+MLx5(wx*_tn*Qrm zw=C%WU8VDB?B#JG;Egj5h@=idadJ2LhjJNTgDhgkqtmhTn{;ROVHTZ z9e`NaP#H!vP$BUTB0m90Hn(1I(}`yoGjPnpY^GSjqOV4W{ysBU1G*^CRZDB3bZKcx z;!`FrxDonZG^7-?P;J!r@xGC}A20m=X>vIVpCCx2$Mk%`m4gHqlNBTE-GpZzhJFe^sqUW01Q|)}EEtE4Rxs(`XvqZ3>g@xK^39QSMN_e~nI?18o zy$cUUqlDYR*F!;QY*w3DS9GyuSZHqEF)WZ> z`~@hc0Tld1pXYe-dOAeVT@Vw`ZOi=yuho84c%(`GjY#GiQZLO^xEyZk4bflh_=NH&#=ejSi&d{|ZwU+?W2wl{2 z99-&b7k>N-`9HkLJ~&Izgc=#40b)qMr~sg`{^Pmw6}m;JYHRgLdMNdnUh)0Qt)8Cd zb?=j5iBBtbqZ1QUsjPL(;>uQU&$#y&0g*ccFIEm!mOG6L*Y(YvDNBhM`>+1=dU`r5 z>*H>fm2%D=ywWt=;-6Xx&X38t7RS^bKcU)WHJV7@du*<(8Vi&FgINC-(eIl34l9}O z=s5{}5bZ~_%Ft}{2wIXZY&j*!Q;*CeaBy)owV&BL>b+g|e4e-@OpD5ZHD=Z`1WboO zT5@u-IAz$M8UkppdcVCS{yl^p-^hZ}A@s4gw{R=>m$&wpIR5dI%$EoM>~}w(tL5(R z>`kaAD~Oy=R}$pMHea>|x4udL%+ke$)&;@1-d6z|-Za~>9uqAX-DIRSw z!{;PG(Q(rrn{@XVZ+ybw8#95+qmw1@!^wzL@IZW9Q_54K!>sGLGh+=lhsRpA7T_Xvr7J)``%;XW=vqhdf zl!fISizv``6W+!*8SdZ1w}O%L)c@kS%d#+y;^Y_@;}-+x#7TS zHDZ-L>>vlT0GPc(^Tnt}$Ga!5(2f@zjOINP`!%0G_l(jZ%xmWeHRGkeU_M>A!aBc8 zL5Yhp_fs<*5@qf!=A#)3&B_FPNOxA{5%UnE?eFgor$$v8f&H~0xxih%ScHXVtSrR9 zL!jjI1$ATr`wAm<@?4-Xt@o^WipT5f2R5feYfLl2!3-Y5Po8Hr1)VrXCvJ~a z=oK(VUD5&QHXxm*T>)A$I4*_0%g9(cDJ_j{TINtA5G?qz2Vzz21giF z8UpaS0+B|^_}%TyO-(5q9oG03R58A>|M^js^>JN&?|i)Ro+>@4G_$N3%LP;z z&7vNaAbI@Cn=}}xgJbxXXk7NtML;&+J~7E(0AHOvv;F0NzkCGuu(z%w?SwNku+I{dG)W zD?;n{`goPR->cL7JBoN%;qmC!&vg?03M6z{(%_c|O3@OOsJW-Z&Jq1X)pwBcb#~OR z26IMxoj#ovc@;7$)g9 z#(&Fq8g_KJEgz9^>sWb}JiMgK3x?=4Z~>lN zXshr(>ffD3xQVx9o<>k6T`IJ6F-~5fx5mCPL7u)#7A%_j{18;y($W)_lz z23$U&BC>K$icuz-8XJf13@A-+2>-j8Pu z_X=b*r)YzR#(^dwkbz{mM1yQez(V98^x|agzn`Ggr3B=tW?dPq1J!dbMxT9o1eQh#pt_V(UhUS5udqY?#2D>Bky@Gl66G9>x$hcdaYZ1Ln2;NAKMNAgiOOEtYm@qY7lXShisC-q?90LXv7GH` zU?{qdPGLkV%mRS~jOaCW!wfVu_TDBVgl63@Iv#|E;2dbw6yefwL}kcbP8VyZh#cE% z8KD>%8ME%UM^rvRT+Q~*r*vLkrOJ#BK|MNha(w6s#&$0c^$_7vy81yN$ek!E|K4;1odeN*-^h?opOa0 z>yqubplNbF4w+lZSR{!L6!a$lApRGvS7m(i%t;$1N}n=3pgHPQT3TvyFrMuQepvTm z@#N2N)-1M0U3-C6}Uj}XTG^W>`<>cQ5Ds|m-(lZ2z`WJ1a$REXZYC)H+m%g zDsCj~m8U+j@VeH$kU>5|y-iY1EouLL|0E7c$xP&$#RNsKNK|nd!WyM_=ofKgL=2=n zw%z_>gxBBhaK4()*6ZeDpMvNB%VoS(lbm}t^LiM zHG!DL=T;Wr?d?6)n_*h6AGx@+3nicHTU-recUI!|F#0wMM7jHyh_}^3vz1Yj?})zr zSZb$VZ`JO0qALwl6)&85x=6|Zim8cZMdeUZvCV4EPh(qCZb#=0a#0bOdyxX^(t0sk zR2W=}nE4qg83c>(PI-P=18f{9>LECPeo=~Ijlyr^p^f0v&C2jX+h zot#kg`oF_||Md`jNV^&qsJ>b=x}%aF-#yAxq~4<~SBn1Elpm+E(oVZA`+5G1N3c_3 zhm&*8d9zC;j_ks0U_4fyrdY<-`ju2zqlz9Vvz-?Inlvo^WyRf&0qyyKKKQyBSR}A4 zkt2&V;XrEJ%)l}4iJzbuQbC@Ac>EnpXJ7JQakVMqYLQ2Y*gv?qj9PQ9s-USUwW+BI zbl!H+Y1tAul<#a9*fN0^bB6te4ldrOzCT+TXu7wL4n+&B7<_7|c`Cn4jJBfwltS@&>WE{C0Pim4M!N*! zRlVDMbY@!Nn|sD^8nq_X*Tff$@#4w+vzW&8tnBSM+oCcA!;S9#Y^vHeRl19`HaB~S zBh>x`jL)Il*>NyKGCnS$*DVjH-K$SWZ%-RjPnT+p=COU9bU30+E2TCo{lk(lM`+C# zS*v3>3{<|a>=)#iRlr?j3OQ$rnqO4GjVB9iUwKvDy{Xe!2wQu($N?`gcqbtp%A-bi3BAHJ(+WZM8yi`jbC)c3-Cd zS}Y+L+Ata%_U@Y%-C`@;#oj*L-wZ~_Uv_?OuSVZ#?&55#9>NOy(RV$_^!UM-hy7vb ztOGJAu3hda{g!ga#HAkxjkQqyM~dx3CalAtN$sLc>hp2?-RF(x&kWqvud8YAFKPFx z#`4|e;s|mCLB&YgfN7~X;-dz_U`=RN_K-bvH$Ez-vt@>#l9t!sGUv{6Pig@}Fn{8d0;^!Ryhb=E)m;@Chd;Gykvz{3ZC6v?o= z+!Q-!ykqj~zl0+^L(hCi?^fe)dNj)@2-!>p^Hm0jkSRUnrFeARQRuh$$<+JjQbQT< zk60o$EOh)p&!_iGnANwAHsrE$j)j_ZVi9R+QBh1J$;3ZlB1NL6e-N%(gk!*8G^Q$SHdqE82v7~#=#H_;N%gRjrbD1q&<{sdfE zZ;10~e?lq?nX|HHB6DE+5~ate72QqPSJV6bTyS6BV}MX4yrh1V!_3zpN0g^H=`Q*lZp3koRg}@u^S};;eIc| z@DepB3jt#&>G+r!6y3Q%#QlP)X})gupp~SYe}1Ssvg>EMI+EKWzOGE z5)T{R+E$oIORKa-{K_XF<+?ryoho*#%F50z)*w9W2CBMoT(5l^_&AgMIJ?WT%sX_& z?0Q{;Quh+bi8_J6zJ&GSqwOj>sTsMs6A#b)SlVPxPEIkMofJ*>dhrE|Uhhv(q&^i9 z0k7askJD?Po`xJt7KJH)uwrdLj*m?x zdZG%VoK3Hqa>{Ga__FGUfFeV#cz4Hl(ci(cUCx z50`5RnjbYL)b{)j9PJ+%n1SGIE}2-?!8+T!?rmvjC1e6Tqmv1}gFkcQ1N_PA|H0+6 zKE6Gka80hC7ylj~iJ~WSkI3c^aLo$IhKVi`*PLKW%b>;}Jij_XdY~`%UcJs}O$o1OI5NzQeaZ07%k{^4) zt;G3zzig|ON@^Y+Du>t;`t*bnfrjdGxyjnmgG?-t0sxcI+iVTrHO#lPbWTYrEJ~=w zer_J=UKB9rG?Ob+CU9bJC5PD4&JDq~+6?FR5szCC-)R%(5mnx7G2Zw0@3SaFu1SR< zQUvLQA|=6xxAd=dQGW{Lr^NyQyVV{d!zfe*7fF)QbK9(*U1n;2D zC>(~Wzfb!hyjm$r8%?>J0h#zY{MoQv?gSa!(%KC37KZXL3xK56@SAJgHOh!Vzwp1Fr6d!`;K ziNG*&2MfA^a>kU=gy%hBF-=kh3c=B8ffI|IZ>q7UO^`Qu^idriJP=-hp9)q;R52%z zX*%|Zd}@d(LoBv1{zgK1db3@_8`b-sQ3TyJe4G+99$RHJ*JN{?A(g`$(_J&7`Dug# z07u0{Pz7l#5bwy9JE7&8$2c-NHU`O!qA7!ufIlFb683b@oX@WGy^EiBUc1-TZE=dZS)FVy#RU;m#0&w zzgDPHvPk};lTs2ds1VSQ>(%tN@{A&1BOlqxn(jEnJ@cf)%PtM6iIQIgA)Fd-v3)wk z+=U!SAd!kh8Vsk7YJrK#w|T{vj-TDLSNYxE552@x3Ts&h10Z5nl$aQnxWIt6H?{LA zUg-y}&8EDUMbP(35l6?1A#KK{K~rvi-!#ouBnm6ra;e_p$Py0ow(OP$5iER=!;+p* zIW8q@bKnGOKKiH6Xobi0h)mypS7S!>bVFhwh~qd|0tbM%VztG=bMTX)Q^W4={;9d{ zufHNaGj)zMx1ZhXT0=5CleTKD>%NSAiT*3dBHc?FnO8=y;r~q&CtBbW6mXg~nN@T) z(?Q%-tFp}0m!o^L0fWMoe*DXC5#IEioc&3;h{bx7_<#kF`!&87mKPI)ar8UUiyp3&ixpC^0!|}(OZs@;?ny;j9v^16M5ZbIIlVaC(Rzu$K4p*a z+8f%yBqQ!WsSEXI8f3}aU=y2vnye2M8$^n#*mPZv3>73k z&9b1@S~N|bc!iFUr|yBE>HiAjg=A^5|CzX;qriK--mL|^QJ_S2FfLp|G@^#O2qlbe z%zGkV1t) zt@dgE^78gIz@=7nDR`K8HyAfb9EAsT?|3|z>r0~d`WS}%)V8HzQS+rQ9D_B zSlF=>?F2_rF~7i!@vnT()B0`ekqQ_R{YH{wPMf_@-csI6%Jopq6AFxka>AEgF!391 z3kFKiRQO4JBWwoUd!A6mG!R6(xTM4~JUrZ@BKbQf>{y^OT}9Q`1Ww-UQ5W#gmQ7^0 zC1G%BOuwgHP*w5{>@CKt1HWNfT38rdj^}pdwoOq|SG-7qGz|}%Q>Z#uU+jsazW-ev zD@t=I+|O|_4z%PS-Md-FY|KBI5Q_0!aaH+_Dokc6$@#E;3$H8nr<(Zfcpr#&(OtCUaj@#;?VK8dJVJ|`X`S}%!)ZCIs^n4Q_W#MnJx4$FiBP_J^49fKONbKZJ z6#klRZtfgZx&)kYp|jYQNssHk<@h)mUr^gB>oRevv7VvkvR_sYuHWubVoNqRwFu5H zrMRzH3Ng^hD$$NYsBp{if|Y^mVIdn&jN*V}pGFyT8&iKOzkU|d-vVOEZ7U0yyN*2V z%|aPB59v?gZ$9;?sh>q{CLAmU*a;7bC*8NYH^fLPX(`>lY<#@41l-r>lE4=SS~-BA zEtr8&z(9E;qYY&NQX?aqD^RrLNTcXVYa{7B*>{0dpel0Yul~|nuYNPSaOwks4E;b) z;%ygnkcC@p$if1Tl}`_nt|qq%iMrGBF!!GZYWGpF+7Wid^XDmihaw?uqtMft7Hx}2 zkljqQVTcW8P~ry*HA20J>&b8>kNr4ht$n@B;~FMqip1D59>&zRa31~kcXQvk0mB^e z5uvd%CoG&CBvZB8+xURj9ZT?iW3^yV_tEd4xg~kfsTv~QTrAgLZDD5w1qIjC)YKdq zzW`3kv`&^ideAFEkF5RO$@^V8Zp6(HVYX`VJ4c}xDz59j?E@?I^t3zpf+r3JspzTL z7PI9n38P2qU?b&&v36)C!^WHX`x@ajO@-HqYAc^_{!`=A;kUHh;}ci~|0YV9-8SvOLRVWg#Xu^BZxvCYEOtC+n$q}n})IVjQeP(!^TH)3%v%mS43B9&===Jmx64VRn9G-6OGO zM*4<`0!O?u9sZu9wZ%3vneZtooUjPp7t*{R@f-QmM_eKz=?h0woU37rzw6*cFfJgo zKIk~B=C!r?*uC7JHEN1~GpQ8eg+z~ytG+kOUoZ@0$}xd}c0F5zRK7oEM$Ac{jJJGcXM+gN8^itN7=5s^(82<> zOoXzC+bvM09g(u~l*d*qRasrY(Ajj;51#Kofs1ujN;}bFptkGa)yB*SM0(mK^#w zy=9<5b`@I68X8&}3NRG&)urYZLMxX+6aELo;FtPlq4@4Mc6|K+&FxNNMvtMyNKPV2 zYv(_ZkY{h%w%S0C5aSmxvZaOBS7<}0L&1*jpR&qTi(e?csNH&dMpP7%YALp1HD20? z2KJ+hTT0?0f>%^SX(1ul>IR*@SY9n8@$ z(+#sNxL+KBKv&mkT|$GyzgcWk+R$ku5|T4hVR4LdgVI*5%yml@#nze3dr?v561axV z+1UB1(miFCMo(u{9a&*10n|K1h~za5@dTu#LY#bjQQ)F)^*U5molLg8SZI3&Y%)pG zC)V`#DNh%We6qBvYP`)y^Fz1^-OSVhI(J5@h_ddqmMsHiW@aWPHg;-erk=HAzNW=5 zLaN~3SUYzl5)n$StJ1fh4ER!=D{nWW- zygXW)I+-FxmI4^Gt#z#fPFfnH@~7wRTYSYw+cFm7RI-;iv(`fIGyQ5>i*IIT24QL1 zy>iM!ikTwU}HyrROF0R-*aN|*Lubj zzDdD;X+$WyDd4o+*VX~|kRx59#Gz&E47F009{vrcHv8=&2l(zTMZ6P= zUF3RPyw_hr%R>)(V4Xo4`1!aTBuBGy; z6yWNq+V?&4#G$i_DaFPK{|zdJruo=c4fB^j*MSHYp$-!WnTAL|cdm|@OkjNW7W;19 zTVD|`gi7${W++85VkFtw`&Dsi80E`1N{Y}TT1=2G!(EyZkI<`8=Vg!I$E_ClgQHKb zkC_%q8dHwgn*SQs-hEzETW3 zpRlZ|!aknX;2=d8NC%ae&jq70TG6qQRN;&4#>eT2rmo3q}oRUKeD< z%gr7<+|Rs3!3PD<*DUFoSy_xLiu1lw|IslQ?kLaUadq~Y)5Cp?g2+938%=hz|(8I}! zhMgRZB*Dk$!HJc`YfRR_hSNzUAmN;UOW8S>z+;>6Auo`S&Ykt2un}|6Js2Kz{wIV=U7<(#;y&$XaxC{qh%QE?y= z9G3}r-%aX=b#A?CbT7#Qk^vQK;z|YqZ+8rGyq+!93EUH)%uGtcST;Vs;aal+YBW*- zo~zil&RXlz8(BQf0;2h*s-v$+ENGx^a?L96(etJK$4L`EO(Czx*nil3t@w0Rko zV#2Gd3z5gP2@Rp%Y`pGsllkpuSjwAnm_5w2w7?XO zDt;V)JXiZ@3IBBr897sD5N+&M?bQ>O4rC5(@N;q7*LsO)?!1CIC_x$r0no^Ve4(|y zsA2Uel84Y^B?$-!hV=ITyVBIAED@>G_AHS435vIeMIQvEVtf+?W30q z@JA%XF8IOu3ALaC9<#LmKYfG7usix6w;kXoaBj<6dG3`bQ(0H=Yj9h z@|8SkmT;aEkr1-8t@@}F_k^X#lvQ~Ja|n>5pKgG`H*x&uk57=IF+yLdZZbX|?IK07 zkJ%@Eu-Ekg%SHSC{@a291SH_J)fHzG6GG6CMG?hI0*d)HN;zi8KB^xbgSEj29RZVG zXl0xFh+X;&3A}E{vqM%KnKXf}eAx%*5H72Nhnt(i-tZ93^Ye4R^B@!CjK9`A;r+%ZgriUm_B|GgEVpaM#jrGNL zUE6}$RIU>EDV{^NPGlhW5r+D}Cl^2b1RfwO#6LCmjWEEy*WnElb%O)fuN>{={UD+yQ15#VjAsbN*hU(sf39#f;$msYtP7*pszl*vGQSVYHz@GJ&ebnLCGrIEe9{qFVvFyIiuqazbl3H|m>%j>Oy-)-bSM{;xo8uH5CMInZ#KD-@ zZ`HikdThM+_zCmLX*e-s0;1SMZkpHJS8~mqDy*+TtM(4X7Y0$6@(%33gQF-g*#jA} zWm%M={0#hxu&h-hv2nVQdTDzKH3+Rl#YQ@)2E{vz zt&3*p%*{{hQ1gFJapEN=cY=`6?;deD%#;zfC@RPjU z(xvy;%$;b$c+n&$Nt?NpBxSZ@IaN>LOey3jLt6pe=@r~6wTa2k8(yTAXLgD%LanbF zK`-mzkALxc^LA_6MtjLzmG(&4Q5N;3F%C?M^kr`% z6HsjWv&AzGV2aJSUb?!$&MW$rrk@^TdUxYJnFc^0mh`xO-PCM{JPW6(B~FhJ9^%* z1R=Np^9Ocj5D^XP`3299JToKC-};3Qa@bRP68$njOwCIQl=wVL1orv?XBtd*LX~{G zKfX+hE{q!XsV34;$^=r%h0>n1hNA% z^9l)lt+dvaO!`hSl9Uh&o4~;>$eFCIEe%WZ-YRvU`-o>gWJk+*tDma8fXtFT*qqe; zN)*aG8OAJ=WbU$vo$HlVg{+7wW!VG|lXDzsN|ZURu~x@3WrG4eE57>N3ZJHyl3I;4 zK8Kt@tDc=1nxLidCFJ<~xbV!Kn~2X=3tpVvQsdtIcZwHA?=z8Z^wAY`QC5O%V2V6N z`8RACh^zn8LuFR0&Xysb!AO2A@l)(oloEm{!8l`avxrTI`>pFGRD8M`{&XJ=kP&1n*VK2{<2WLPq@Cm zz6wlPVSg#yOnV(2U6CnPy~-$!{8Ls&pp)b#q2jtkeGWNWVclgv1$r9^w#AWDUEqgkWL*Q&r*(L%beV(BDCM7j)&X!;-^ zq70^FAT732RQ}Xy)8MiJi>siAw}w-v!5_J!%l0xg#)Sr$9IOtSUrOuuLwahm!-k4k zX)AaXFDL_+>^jA!YUT&^kFI2BL*F30<--}U;8#@*1G*J^Z^^{(2DNmpjB=t$U-Ad* zE+bATnVUD#JTQjZtdc~eMbp=SVOi2A^5YQOzAs_j)7ZGmbJIwK}g;jM{IqFJ8OFU2SAj zamF0#OQRI#fjZM)bmnnnFgum)Q)YG^aAbo+L(y@2iF_zrmtGYSDEjs_I$U^C2(rhm zbjE_6*3h3?;hrcXeCvk?Usre}(QoOZdZ=*(9oEY^1qe&+#eNee)Pp?U-aj7kB}puG z+&(hfbMJ`}$zh84axM;?ws#MM3SGg|5HOM7ejo-?pQlJLryQEy#n#pDseY<8*|{1Z z6TYnS=r}nid)@pr0gc54;x|z7BK31{-S+Nk|yp4afE{gvu zL!`E-_g;{u=jTeE($HXWQnZfmCLSSL>u0aJ#07F|f6I3K=!jZIIwm~+gP@*OTVlw2Rb9?^ zbLGW$L!e)V9I=}Rp@clLyl=CRw!WOVh<99n595fl-+|M_bgUVN#>nv&}&yb%5 zpmy%8lOkVzfwdwnn!9==`vMgB^dhoFZeKg{j#`<4C2K$Fa2s^@rI`wW->}pJSBWsVl+*?YhnsG6J|# znQ51Yq->?~&t&ggWTY(+bn{}l+0p2e6MZu(^iEVyMv`3;vs`$aR+Mj0LWREL?^Vj( zG+(1fQB3Ki$uML3Dw~eNqti!u&5=LhV5`07c0c3JifI(yqs<@EvhPaTY&?IN{G8Q59)ObG#Bg8ns~pkA8~R;hFyb0$S&92klzh6N#~Ff(4)Ru;a+Myz zOvUgC6_z1eD`0mNq>93EP=DQJ-fh@((vEt~qKA;BttU>(8UttEYPtQqYw7>aB9g-~ z`kvTsc#n>6Lnr@^5iyvN=~F8@m|nzk&-yn6Iq0%mB6L?8@OoP!{MG}ZPoEP{EA9X6 zlHVAIk_jr13moadfBLO&)b&RKPp^|91y*&-G)%o3h*(eTOHmmasiQ%XGj3str*1ID zIX&!yP>_yq=S&bcn6cwM4yY`@J_KbNjPG;ugBP9md~9nPKwx!50Mgg%Fg3rDel#+H z*8-SL*RDJgzd)ds4TF@0lRDGCEQ2gZ&2 zwTs1ix0U?`uQhi>l#Nb=l427r$p)54-{@boZW~dG?D6b3IOL_Ri;6qP3g8b4j< z2a$nwqH?ylg;glmaFXtFD!^kKf#=Ae+562*Tf;-2ZUppfTjtG1fNh!Uxux*?5<~7Q zwv&LJI%V5t};aa2fRZQ`MU$J$+a?&~hiZxW7C*)3?+h$4b%gBVE?pT>!z?(Z|5CSFDB%Z6? zKS{~4qJYQA&$(~YX`Qz~Igd%6wImx7Wqt@ZUdQnOi$-@-t2XeS*;dl z0Z?W$%ymW~9Uk83tP{RARK@dx(fi4Ve&dfhc$wr$Ov0jWhkBRC%PlOUr(v!4v%y3v z8ak(rjHH~MQ4$Vplr?-<8)rS*hqXTClxfV|F|Zu_pPI*v3NWbj1wVX^9dXp~7?#`w zpTQp`lmmP|ywGue{&L=FFf7ds7nYHc@wfnfzsnVVz3ho_`OGa-`!~0wCiUGln|*9E z`SYJHPpu^Ghqd3Pj4WZ*d!bunguSrvVx!Q=X{|`CA)*#*`lo#dMyKBAR|h$r$7SVJ zez&z!Mz5;}LJzhm4E{(kPsM+^wP5$OV6sgLM$ujWu@#3PKd!Gw11x^7RV+{Y(E#&;cY`BEas*$BR3fG>s0^yF$YLSyvET6BSX|j z7=(o4lZPqB9bz&yp~`q(DY?G%@5uE6j5jzj^1BMUf;+2Eq)8`uzev0K3thYMlkfD5 zY2?HQ^Oi^CKLrhATvdAP;TecHT5o&9AM0Blu>j**L#~nOO}6U~vF;1ZE5rYHl3exj a11cZh8+y{Ej}UV19Uvp2C|)IM9Q=PjW#8QZ literal 0 HcmV?d00001 diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index 176d5e3901..be7144fc0e 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -847,6 +847,17 @@ class Plugboard(PreferencesPlugin): config_widget = 'calibre.gui2.preferences.plugboard' description = _('Change metadata fields before saving/sending') +class TemplateFunctions(PreferencesPlugin): + name = 'TemplateFunctions' + icon = I('template_funcs.png') + gui_name = _('Template Functions') + category = 'Advanced' + gui_category = _('Advanced') + category_order = 5 + name_order = 4 + config_widget = 'calibre.gui2.preferences.template_functions' + description = _('Define and explore template functions') + class Email(PreferencesPlugin): name = 'Email' icon = I('mail.png') @@ -908,6 +919,6 @@ class Misc(PreferencesPlugin): plugins += [LookAndFeel, Behavior, Columns, Toolbar, InputOptions, CommonOptions, OutputOptions, Adding, Saving, Sending, Plugboard, - Email, Server, Plugins, Tweaks, Misc] + Email, Server, Plugins, Tweaks, Misc, TemplateFunctions] #}}} diff --git a/src/calibre/gui2/preferences/template_functions.py b/src/calibre/gui2/preferences/template_functions.py new file mode 100644 index 0000000000..d049f7dae4 --- /dev/null +++ b/src/calibre/gui2/preferences/template_functions.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import traceback + +from calibre.gui2 import error_dialog +from calibre.gui2.preferences import ConfigWidgetBase, test_widget +from calibre.gui2.preferences.template_functions_ui import Ui_Form +from calibre.utils.config import prefs +from calibre.utils.formatter_functions import formatter_functions, compile_user_function + + +class ConfigWidget(ConfigWidgetBase, Ui_Form): + + def genesis(self, gui): + self.gui = gui + self.db = gui.library_view.model().db + self.current_plugboards = self.db.prefs.get('plugboards',{}) + + def initialize(self): + self.funcs = formatter_functions.get_functions() + self.builtins = formatter_functions.get_builtins() + + self.build_function_names_box() + self.function_name.currentIndexChanged[str].connect(self.function_index_changed) + self.function_name.editTextChanged.connect(self.function_name_edited) + self.create_button.clicked.connect(self.create_button_clicked) + self.delete_button.clicked.connect(self.delete_button_clicked) + self.create_button.setEnabled(False) + self.delete_button.setEnabled(False) + self.clear_button.clicked.connect(self.clear_button_clicked) + self.program.setTabStopWidth(20) + + def clear_button_clicked(self): + self.build_function_names_box() + self.program.clear() + self.documentation.clear() + self.argument_count.clear() + self.create_button.setEnabled(False) + self.delete_button.setEnabled(False) + + def build_function_names_box(self, scroll_to='', set_to=''): + self.function_name.blockSignals(True) + func_names = sorted(self.funcs) + self.function_name.clear() + self.function_name.addItem('') + self.function_name.addItems(func_names) + self.function_name.setCurrentIndex(0) + if set_to: + self.function_name.setEditText(set_to) + self.create_button.setEnabled(True) + self.function_name.blockSignals(False) + if scroll_to: + idx = self.function_name.findText(scroll_to) + if idx >= 0: + self.function_name.setCurrentIndex(idx) + if scroll_to not in self.builtins: + self.delete_button.setEnabled(True) + + def delete_button_clicked(self): + name = unicode(self.function_name.currentText()) + if name in self.builtins: + error_dialog(self.gui, _('Template functions'), + _('You cannot delete a built-in function'), show=True) + if name in self.funcs: + del self.funcs[name] + self.changed_signal.emit() + self.create_button.setEnabled(True) + self.delete_button.setEnabled(False) + self.build_function_names_box(set_to=name) + else: + error_dialog(self.gui, _('Template functions'), + _('Function not defined'), show=True) + + def create_button_clicked(self): + self.changed_signal.emit() + name = unicode(self.function_name.currentText()) + if name in self.funcs: + error_dialog(self.gui, _('Template functions'), + _('Name already used'), show=True) + return + if self.argument_count.value() == 0: + error_dialog(self.gui, _('Template functions'), + _('Argument count must be -1 or greater than zero'), + show=True) + return + try: + prog = unicode(self.program.toPlainText()) + cls = compile_user_function(name, unicode(self.documentation.toPlainText()), + self.argument_count.value(), prog) + self.funcs[name] = cls + self.build_function_names_box(scroll_to=name) + except: + error_dialog(self.gui, _('Template functions'), + _('Exception while compiling function'), show=True, + det_msg=traceback.format_exc()) + + def function_name_edited(self, txt): + self.documentation.setReadOnly(False) + self.argument_count.setReadOnly(False) + self.create_button.setEnabled(True) + + def function_index_changed(self, txt): + txt = unicode(txt) + self.create_button.setEnabled(False) + if not txt: + self.argument_count.clear() + self.documentation.clear() + self.documentation.setReadOnly(False) + self.argument_count.setReadOnly(False) + return + func = self.funcs[txt] + self.argument_count.setValue(func.arg_count) + self.documentation.setText(func.doc) + if txt in self.builtins: + self.documentation.setReadOnly(True) + self.argument_count.setReadOnly(True) + self.program.clear() + self.delete_button.setEnabled(False) + else: + self.program.setPlainText(func.program_text) + self.delete_button.setEnabled(True) + + def refresh_gui(self, gui): + pass + + def commit(self): + formatter_functions.reset_to_builtins() + pref_value = [] + for f in self.funcs: + if f in self.builtins: + continue + func = self.funcs[f] + formatter_functions.register_function(func) + pref_value.append((func.name, func.doc, func.arg_count, func.program_text)) + self.db.prefs.set('user_template_functions', pref_value) + + +if __name__ == '__main__': + from PyQt4.Qt import QApplication + app = QApplication([]) + test_widget('Advanced', 'TemplateFunctions') + diff --git a/src/calibre/gui2/preferences/template_functions.ui b/src/calibre/gui2/preferences/template_functions.ui new file mode 100644 index 0000000000..8227c92607 --- /dev/null +++ b/src/calibre/gui2/preferences/template_functions.ui @@ -0,0 +1,191 @@ + + + Form + + + + 0 + 0 + 798 + 672 + + + + Form + + + + + + + 0 + 0 + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Here you can add and remove functions used in template processing. A template function is writen in python. It takes information from the book, processes it in some way, then returns a string result. Functions defined here are usable in templates in the same way that builtin functions are usable. The function must be named </span><span style=" font-size:8pt; font-weight:600;">evaluate</span><span style=" font-size:8pt;">, and must have the signature shown below. </span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt; font-weight:600;">evaluate(self, formatter, kwargs, mi, locals, your_arguments)</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The arguments to evaluate are:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">- formatter: the instance of the formatter being used to evaluate the current template. You can use this to do recursive template evaluation.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">- kwargs: a dictionary of metadata. Field values are in this dictionary.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">- mi: a Metadata instance. Used to get field information. This parameter can be None in some cases, such as when evaluating non-book templates.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">- locals: the local variables assigned to by the current template program.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">- Your_arguments must be one or more parameter (number matching the arg count box), or the value *args for a variable number of arguments. These are values passed into the function. One argument is required, and is usually the value of the field being operated upon. Note that when writing in basic template mode, the user does not provide this first argument. Instead it is the value of the field the function is operating upon.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The following example function looks for various values in the tags metadata field, returning those values that appear in tags.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">def evaluate(self, formatter, kwargs, mi, locals, val):</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;"> awards=['allbooks', 'PBook', 'ggff']</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;"> return ', '.join([t for t in kwargs.get('tags') if t in awards])</span></p></body></html> + + + Qt::RichText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + Qt::Horizontal + + + + + + + + + + + Function: + + + + + + + Enter the name of the function to create + + + true + + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p></body></html> + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Argument count:</span></p></body></html> + + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Set this to -1 if the function takes a variable number of arguments</span></p></body></html> + + + -1 + + + + + + + + + + Documentation: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + Clear + + + + + + + Delete + + + + + + + Create + + + + + + + + + + + + + Program Code: (be sure to follow python indenting rules) + + + + + + + + 400 + 0 + + + + + + + 30 + + + + + + + + + + + + diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index d2654577b9..8a72ec040c 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -38,6 +38,7 @@ from calibre.utils.search_query_parser import saved_searches, set_saved_searches from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format from calibre.utils.magick.draw import save_cover_data_to from calibre.utils.recycle_bin import delete_file, delete_tree +from calibre.utils.formatter_functions import load_user_template_functions copyfile = os.link if hasattr(os, 'link') else shutil.copyfile @@ -185,6 +186,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): migrate_preference('saved_searches', {}) set_saved_searches(self, 'saved_searches') + load_user_template_functions(self.prefs.get('user_template_functions', [])) + self.conn.executescript(''' DROP TRIGGER IF EXISTS author_insert_trg; CREATE TEMP TRIGGER author_insert_trg diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 0b5f1d1f52..2d74885942 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -4,12 +4,14 @@ Created on 23 Sep 2010 @author: charles ''' +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + import re, string, traceback -from functools import partial from calibre.constants import DEBUG -from calibre.utils.titlecase import titlecase -from calibre.utils.icu import capitalize, strcmp +from calibre.utils.formatter_functions import formatter_functions class _Parser(object): LEX_OP = 1 @@ -18,93 +20,6 @@ class _Parser(object): LEX_NUM = 4 LEX_EOF = 5 - def _python(self, func): - locals = {} - exec func in locals - if 'evaluate' not in locals: - self.error('no evaluate function in python') - try: - result = locals['evaluate'](self.parent.kwargs) - if isinstance(result, (float, int)): - result = unicode(result) - elif isinstance(result, list): - result = ','.join(result) - elif isinstance(result, str): - result = unicode(result) - return result - except Exception as e: - self.error('python function threw exception: ' + e.msg) - - - def _strcmp(self, x, y, lt, eq, gt): - v = strcmp(x, y) - if v < 0: - return lt - if v == 0: - return eq - return gt - - def _cmp(self, x, y, lt, eq, gt): - x = float(x if x else 0) - y = float(y if y else 0) - if x < y: - return lt - if x == y: - return eq - return gt - - def _assign(self, target, value): - self.variables[target] = value - return value - - def _concat(self, *args): - i = 0 - res = '' - for i in range(0, len(args)): - res += args[i] - return res - - def _math(self, x, y, op=None): - ops = { - '+': lambda x, y: x + y, - '-': lambda x, y: x - y, - '*': lambda x, y: x * y, - '/': lambda x, y: x / y, - } - x = float(x if x else 0) - y = float(y if y else 0) - return unicode(ops[op](x, y)) - - def _template(self, template): - template = template.replace('[[', '{').replace(']]', '}') - return self.parent.safe_format(template, self.parent.kwargs, 'TEMPLATE', - self.parent.book) - - def _eval(self, template): - template = template.replace('[[', '{').replace(']]', '}') - return eval_formatter.safe_format(template, self.variables, 'EVAL', None) - - def _print(self, *args): - print args - return None - - local_functions = { - 'add' : (2, partial(_math, op='+')), - 'assign' : (2, _assign), - 'cmp' : (5, _cmp), - 'divide' : (2, partial(_math, op='/')), - 'eval' : (1, _eval), - 'field' : (1, lambda s, x: s.parent.get_value(x, [], s.parent.kwargs)), - 'multiply' : (2, partial(_math, op='*')), - 'print' : (-1, _print), - 'python' : (1, _python), - 'strcat' : (-1, _concat), - 'strcmp' : (5, _strcmp), - 'substr' : (3, lambda s, x, y, z: x[int(y): len(x) if int(z) == 0 else int(z)]), - 'subtract' : (2, partial(_math, op='-')), - 'template' : (1, _template) - } - def __init__(self, val, prog, parent): self.lex_pos = 0 self.prog = prog[0] @@ -184,7 +99,9 @@ class _Parser(object): # We have a function. # Check if it is a known one. We do this here so error reporting is # better, as it can identify the tokens near the problem. - if id not in self.parent.functions and id not in self.local_functions: + funcs = formatter_functions.get_functions() + + if id not in funcs: self.error(_('unknown function {0}').format(id)) # Eat the paren self.consume() @@ -207,11 +124,12 @@ class _Parser(object): self.error(_('missing closing parenthesis')) # Evaluate the function - if id in self.local_functions: - f = self.local_functions[id] - if f[0] != -1 and len(args) != f[0]: + if id in funcs: + cls = funcs[id] + if cls.arg_count != -1 and len(args) != cls.arg_count: self.error('incorrect number of arguments for function {}'.format(id)) - return f[1](self, *args) + return cls.eval(self.parent, self.parent.kwargs, + self.parent.book, locals, *args) else: f = self.parent.functions[id] if f[0] != -1 and len(args) != f[0]+1: @@ -242,91 +160,6 @@ class TemplateFormatter(string.Formatter): self.kwargs = None self.program_cache = {} - def _lookup(self, val, *args): - if len(args) == 2: # here for backwards compatibility - if val: - return self.vformat('{'+args[0].strip()+'}', [], self.kwargs) - else: - return self.vformat('{'+args[1].strip()+'}', [], self.kwargs) - if (len(args) % 2) != 1: - raise ValueError(_('lookup requires either 2 or an odd number of arguments')) - i = 0 - while i < len(args): - if i + 1 >= len(args): - return self.vformat('{' + args[i].strip() + '}', [], self.kwargs) - if re.search(args[i], val): - return self.vformat('{'+args[i+1].strip() + '}', [], self.kwargs) - i += 2 - - def _test(self, val, value_if_set, value_not_set): - if val: - return value_if_set - else: - return value_not_set - - def _contains(self, val, test, value_if_present, value_if_not): - if re.search(test, val): - return value_if_present - else: - return value_if_not - - def _switch(self, val, *args): - if (len(args) % 2) != 1: - raise ValueError(_('switch requires an odd number of arguments')) - i = 0 - while i < len(args): - if i + 1 >= len(args): - return args[i] - if re.search(args[i], val): - return args[i+1] - i += 2 - - def _re(self, val, pattern, replacement): - return re.sub(pattern, replacement, val) - - def _ifempty(self, val, value_if_empty): - if val: - return val - else: - return value_if_empty - - def _shorten(self, val, leading, center_string, trailing): - l = max(0, int(leading)) - t = max(0, int(trailing)) - if len(val) > l + len(center_string) + t: - return val[0:l] + center_string + ('' if t == 0 else val[-t:]) - else: - return val - - def _count(self, val, sep): - return unicode(len(val.split(sep))) - - def _list_item(self, val, index, sep): - if not val: - return '' - index = int(index) - val = val.split(sep) - try: - return val[index] - except: - return '' - - functions = { - 'uppercase' : (0, lambda s,x: x.upper()), - 'lowercase' : (0, lambda s,x: x.lower()), - 'titlecase' : (0, lambda s,x: titlecase(x)), - 'capitalize' : (0, lambda s,x: capitalize(x)), - 'contains' : (3, _contains), - 'count' : (1, _count), - 'ifempty' : (1, _ifempty), - 'list_item' : (2, _list_item), - 'lookup' : (-1, _lookup), - 're' : (2, _re), - 'shorten' : (3, _shorten), - 'switch' : (-1, _switch), - 'test' : (2, _test) - } - def _do_format(self, val, fmt): if not fmt or not val: return val @@ -436,23 +269,27 @@ class TemplateFormatter(string.Formatter): else: dispfmt = fmt[0:colon] colon += 1 - if fmt[colon:p] in self.functions: + + funcs = formatter_functions.get_functions() + if fmt[colon:p] in funcs: field = fmt[colon:p] - func = self.functions[field] - if func[0] == 1: + func = funcs[field] + if func.arg_count == 2: # only one arg expected. Don't bother to scan. Avoids need # for escaping characters args = [fmt[p+1:-1]] else: args = self.arg_parser.scan(fmt[p+1:])[0] args = [self.backslash_comma_to_comma.sub(',', a) for a in args] - if (func[0] == 0 and (len(args) != 1 or args[0])) or \ - (func[0] > 0 and func[0] != len(args)): + if (func.arg_count == 1 and (len(args) != 0)) or \ + (func.arg_count > 1 and func.arg_count != len(args)+1): + print args raise ValueError('Incorrect number of arguments for function '+ fmt[0:p]) - if func[0] == 0: - val = func[1](self, val).strip() + if func.arg_count == 1: + val = func.eval(self, self.kwargs, self.book, locals, val).strip() else: - val = func[1](self, val, *args).strip() + val = func.eval(self, self.kwargs, self.book, locals, + val, *args).strip() if val: val = self._do_format(val, dispfmt) if not val: diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py new file mode 100644 index 0000000000..6b79e23b5e --- /dev/null +++ b/src/calibre/utils/formatter_functions.py @@ -0,0 +1,468 @@ +''' +Created on 13 Jan 2011 + +@author: charles +''' + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import re, traceback + +from calibre.utils.titlecase import titlecase +from calibre.utils.icu import capitalize, strcmp + + +class FormatterFunctions(object): + + def __init__(self): + self.builtins = {} + self.functions = {} + + def register_builtin(self, func_class): + if not isinstance(func_class, FormatterFunction): + raise ValueError('Class %s is not an instance of FormatterFunction'%( + func_class.__class__.__name__)) + name = func_class.name + if name in self.functions: + raise ValueError('Name %s already used'%name) + self.builtins[name] = func_class + self.functions[name] = func_class + + def register_function(self, func_class): + if not isinstance(func_class, FormatterFunction): + raise ValueError('Class %s is not an instance of FormatterFunction'%( + func_class.__class__.__name__)) + name = func_class.name + if name in self.functions: + raise ValueError('Name %s already used'%name) + self.functions[name] = func_class + + def get_builtins(self): + return self.builtins + + def get_functions(self): + return self.functions + + def reset_to_builtins(self): + self.functions = dict([t for t in self.builtins.items()]) + +formatter_functions = FormatterFunctions() + + + +class FormatterFunction(object): + + doc = _('No documentation provided') + name = 'no name provided' + arg_count = 0 + + def __init__(self): + formatter_functions.register_builtin(self) + + def evaluate(self, formatter, kwargs, mi, locals, *args): + raise NotImplementedError() + + def eval(self, formatter, kwargs, mi, locals, *args): + try: + ret = self.evaluate(formatter, kwargs, mi, locals, *args) + if isinstance(ret, (str, unicode)): + return ret + if isinstance(ret, (int, float, bool)): + return unicode(ret) + if isinstance(ret, list): + return ','.join(list) + except: + return _('Function threw exception' + traceback.format_exc()) + +class BuiltinStrcmp(FormatterFunction): + name = 'strcmp' + arg_count = 5 + doc = _('strcmp(x, y, lt, eq, gt) -- does a case-insensitive comparison of x ' + 'and y as strings. Returns lt if x < y. Returns eq if x == y. ' + 'Otherwise returns gt.') + + def evaluate(self, formatter, kwargs, mi, locals, x, y, lt, eq, gt): + v = strcmp(x, y) + if v < 0: + return lt + if v == 0: + return eq + return gt + +class BuiltinCmp(FormatterFunction): + name = 'cmp' + arg_count = 5 + doc = _('cmp(x, y, lt, eq, gt) -- compares x and y after converting both to ' + 'numbers. Returns lt if x < y. Returns eq if x == y. Otherwise returns gt.') + + def evaluate(self, formatter, kwargs, mi, locals, x, y, lt, eq, gt): + x = float(x if x else 0) + y = float(y if y else 0) + if x < y: + return lt + if x == y: + return eq + return gt + +class BuiltinStrcat(FormatterFunction): + name = 'strcat' + arg_count = -1 + doc = _('strcat(a, b, ...) -- can take any number of arguments. Returns a ' + 'string formed by concatenating all the arguments') + + def evaluate(self, formatter, kwargs, mi, locals, *args): + i = 0 + res = '' + for i in range(0, len(args)): + res += args[i] + return res + +class BuiltinAdd(FormatterFunction): + name = 'add' + arg_count = 2 + doc = _('add(x, y) -- returns x + y. Throws an exception if either x or y are not numbers.') + + def evaluate(self, formatter, kwargs, mi, locals, x, y): + x = float(x if x else 0) + y = float(y if y else 0) + return unicode(x + y) + +class BuiltinSubtract(FormatterFunction): + name = 'subtract' + arg_count = 2 + doc = _('subtract(x, y) -- returns x - y. Throws an exception if either x or y are not numbers.') + + def evaluate(self, formatter, kwargs, mi, locals, x, y): + x = float(x if x else 0) + y = float(y if y else 0) + return unicode(x - y) + +class BuiltinMultiply(FormatterFunction): + name = 'multiply' + arg_count = 2 + doc = _('multiply(x, y) -- returns x * y. Throws an exception if either x or y are not numbers.') + + def evaluate(self, formatter, kwargs, mi, locals, x, y): + x = float(x if x else 0) + y = float(y if y else 0) + return unicode(x * y) + +class BuiltinDivide(FormatterFunction): + name = 'divide' + arg_count = 2 + doc = _('divide(x, y) -- returns x / y. Throws an exception if either x or y are not numbers.') + + def evaluate(self, formatter, kwargs, mi, locals, x, y): + x = float(x if x else 0) + y = float(y if y else 0) + return unicode(x / y) + +class BuiltinTemplate(FormatterFunction): + name = 'template' + arg_count = 1 + doc = _('template(x) -- evaluates x as a template. The evaluation is done ' + 'in its own context, meaning that variables are not shared between ' + 'the caller and the template evaluation. Because the { and } ' + 'characters are special, you must use [[ for the { character and ' + ']] for the } character; they are converted automatically. ' + 'For example, ``template(\'[[title_sort]]\') will evaluate the ' + 'template {title_sort} and return its value.') + + def evaluate(self, formatter, kwargs, mi, locals, template): + template = template.replace('[[', '{').replace(']]', '}') + return formatter.safe_format(template, kwargs, 'TEMPLATE', mi) + +class BuiltinEval(FormatterFunction): + name = 'eval' + arg_count = 1 + doc = _('eval(template)`` -- evaluates the template, passing the local ' + 'variables (those \'assign\'ed to) instead of the book metadata. ' + ' This permits using the template processor to construct complex ' + 'results from local variables.') + + def evaluate(self, formatter, kwargs, mi, locals, template): + from formatter import eval_formatter + template = template.replace('[[', '{').replace(']]', '}') + return eval_formatter.safe_format(template, locals, 'EVAL', None) + +class BuiltinAssign(FormatterFunction): + name = 'assign' + arg_count = 2 + doc = _('assign(id, val) -- assigns val to id, then returns val. ' + 'id must be an identifier, not an expression') + + def evaluate(self, formatter, kwargs, mi, locals, target, value): + locals[target] = value + return value + +class BuiltinPrint(FormatterFunction): + name = 'print' + arg_count = -1 + doc = _('print(a, b, ...) -- prints the arguments to standard output. ' + 'Unless you start calibre from the command line (calibre-debug -g), ' + 'the output will go to a black hole.') + + def evaluate(self, formatter, kwargs, mi, locals, *args): + print args + return None + +class BuiltinField(FormatterFunction): + name = 'field' + arg_count = 1 + doc = _('field(name) -- returns the metadata field named by name') + + def evaluate(self, formatter, kwargs, mi, locals, name): + return formatter.get_value(name, [], kwargs) + +class BuiltinSubstr(FormatterFunction): + name = 'substr' + arg_count = 3 + doc = _('substr(str, start, end) -- returns the start\'th through the end\'th ' + 'characters of str. The first character in str is the zero\'th ' + 'character. If end is negative, then it indicates that many ' + 'characters counting from the right. If end is zero, then it ' + 'indicates the last character. For example, substr(\'12345\', 1, 0) ' + 'returns \'2345\', and substr(\'12345\', 1, -1) returns \'234\'.') + + def evaluate(self, formatter, kwargs, mi, locals, str_, start_, end_): + return str_[int(start_): len(str_) if int(end_) == 0 else int(end_)] + +class BuiltinLookup(FormatterFunction): + name = 'lookup' + arg_count = -1 + doc = _('lookup(val, pattern, field, pattern, field, ..., else_field) -- ' + 'like switch, except the arguments are field (metadata) names, not ' + 'text. The value of the appropriate field will be fetched and used. ' + 'Note that because composite columns are fields, you can use this ' + 'function in one composite field to use the value of some other ' + 'composite field. This is extremely useful when constructing ' + 'variable save paths') + + def evaluate(self, formatter, kwargs, mi, locals, val, *args): + if len(args) == 2: # here for backwards compatibility + if val: + return formatter.vformat('{'+args[0].strip()+'}', [], kwargs) + else: + return formatter.vformat('{'+args[1].strip()+'}', [], kwargs) + if (len(args) % 2) != 1: + raise ValueError(_('lookup requires either 2 or an odd number of arguments')) + i = 0 + while i < len(args): + if i + 1 >= len(args): + return formatter.vformat('{' + args[i].strip() + '}', [], kwargs) + if re.search(args[i], val): + return formatter.vformat('{'+args[i+1].strip() + '}', [], kwargs) + i += 2 + +class BuiltinTest(FormatterFunction): + name = 'test' + arg_count = 3 + doc = _('test(val, text if not empty, text if empty) -- return `text if not ' + 'empty` if the field is not empty, otherwise return `text if empty`') + + def evaluate(self, formatter, kwargs, mi, locals, val, value_if_set, value_not_set): + if val: + return value_if_set + else: + return value_not_set + +class BuiltinContains(FormatterFunction): + name = 'contains' + arg_count = 4 + doc = _('contains(val, pattern, text if match, text if not match) -- checks ' + 'if field contains matches for the regular expression `pattern`. ' + 'Returns `text if match` if matches are found, otherwise it returns ' + '`text if no match`') + + def evaluate(self, formatter, kwargs, mi, locals, + val, test, value_if_present, value_if_not): + if re.search(test, val): + return value_if_present + else: + return value_if_not + +class BuiltinSwitch(FormatterFunction): + name = 'switch' + arg_count = -1 + doc = _('switch(val, pattern, value, pattern, value, ..., else_value) -- ' + 'for each ``pattern, value`` pair, checks if the field matches ' + 'the regular expression ``pattern`` and if so, returns that ' + 'value. If no pattern matches, then else_value is returned. ' + 'You can have as many `pattern, value` pairs as you want') + + def evaluate(self, formatter, kwargs, mi, locals, val, *args): + if (len(args) % 2) != 1: + raise ValueError(_('switch requires an odd number of arguments')) + i = 0 + while i < len(args): + if i + 1 >= len(args): + return args[i] + if re.search(args[i], val): + return args[i+1] + i += 2 + +class BuiltinRe(FormatterFunction): + name = 're' + arg_count = 3 + doc = _('re(val, pattern, replacement) -- return the field after applying ' + 'the regular expression. All instances of `pattern` are replaced ' + 'with `replacement`. As in all of calibre, these are ' + 'python-compatible regular expressions') + + def evaluate(self, formatter, kwargs, mi, locals, val, pattern, replacement): + return re.sub(pattern, replacement, val) + +class BuiltinEvaluate(FormatterFunction): + name = 'evaluate' + arg_count = 2 + doc = _('evaluate(val, text if empty) -- return val if val is not empty, ' + 'otherwise return `text if empty`') + + def evaluate(self, formatter, kwargs, mi, locals, val, value_if_empty): + if val: + return val + else: + return value_if_empty + +class BuiltinShorten(FormatterFunction): + name = 'shorten ' + arg_count = 4 + doc = _('shorten(val, left chars, middle text, right chars) -- Return a ' + 'shortened version of the field, consisting of `left chars` ' + 'characters from the beginning of the field, followed by ' + '`middle text`, followed by `right chars` characters from ' + 'the end of the string. `Left chars` and `right chars` must be ' + 'integers. For example, assume the title of the book is ' + '`Ancient English Laws in the Times of Ivanhoe`, and you want ' + 'it to fit in a space of at most 15 characters. If you use ' + '{title:shorten(9,-,5)}, the result will be `Ancient E-nhoe`. ' + 'If the field\'s length is less than left chars + right chars + ' + 'the length of `middle text`, then the field will be used ' + 'intact. For example, the title `The Dome` would not be changed.') + + def evaluate(self, formatter, kwargs, mi, locals, + val, leading, center_string, trailing): + l = max(0, int(leading)) + t = max(0, int(trailing)) + if len(val) > l + len(center_string) + t: + return val[0:l] + center_string + ('' if t == 0 else val[-t:]) + else: + return val + +class BuiltinCount(FormatterFunction): + name = 'count' + arg_count = 2 + doc = _('count(val, separator) -- interprets the value as a list of items ' + 'separated by `separator`, returning the number of items in the ' + 'list. Most lists use a comma as the separator, but authors ' + 'uses an ampersand. Examples: {tags:count(,)}, {authors:count(&)}') + + def evaluate(self, formatter, kwargs, mi, locals, val, sep): + return unicode(len(val.split(sep))) + +class BuiltinListitem(FormatterFunction): + name = 'list_item' + arg_count = 3 + doc = _('list_item(val, index, separator) -- interpret the value as a list of ' + 'items separated by `separator`, returning the `index`th item. ' + 'The first item is number zero. The last item can be returned ' + 'using `list_item(-1,separator)`. If the item is not in the list, ' + 'then the empty value is returned. The separator has the same ' + 'meaning as in the count function.') + + def evaluate(self, formatter, kwargs, mi, locals, val, index, sep): + if not val: + return '' + index = int(index) + val = val.split(sep) + try: + return val[index] + except: + return '' + +class BuiltinUppercase(FormatterFunction): + name = 'uppercase' + arg_count = 1 + doc = _('uppercase(val) -- return value of the field in upper case') + + def evaluate(self, formatter, kwargs, mi, locals, val): + return val.upper() + +class BuiltinLowercase(FormatterFunction): + name = 'lowercase' + arg_count = 1 + doc = _('lowercase(val) -- return value of the field in lower case') + + def evaluate(self, formatter, kwargs, mi, locals, val): + return val.lower() + +class BuiltinTitlecase(FormatterFunction): + name = 'titlecase' + arg_count = 1 + doc = _('titlecase(val) -- return value of the field in title case') + + def evaluate(self, formatter, kwargs, mi, locals, val): + return titlecase(val) + +class BuiltinCapitalize(FormatterFunction): + name = 'capitalize' + arg_count = 1 + doc = _('capitalize(val) -- return value of the field capitalized') + + def evaluate(self, formatter, kwargs, mi, locals, val): + return capitalize(val) + +builtin_add = BuiltinAdd() +builtin_assign = BuiltinAssign() +builtin_capitalize = BuiltinCapitalize() +builtin_cmp = BuiltinCmp() +builtin_contains = BuiltinContains() +builtin_count = BuiltinCount() +builtin_divide = BuiltinDivide() +builtin_eval = BuiltinEval() +builtin_evaluate = BuiltinEvaluate() +builtin_field = BuiltinField() +builtin_list_item = BuiltinListitem() +builtin_lookup = BuiltinLookup() +builtin_lowercase = BuiltinLowercase() +builtin_multiply = BuiltinMultiply() +builtin_print = BuiltinPrint() +builtin_re = BuiltinRe() +builtin_shorten = BuiltinShorten() +builtin_strcat = BuiltinStrcat() +builtin_strcmp = BuiltinStrcmp() +builtin_substr = BuiltinSubstr() +builtin_subtract = BuiltinSubtract() +builtin_switch = BuiltinSwitch() +builtin_template = BuiltinTemplate() +builtin_test = BuiltinTest() +builtin_titlecase = BuiltinTitlecase() +builtin_uppercase = BuiltinUppercase() + +class FormatterUserFunction(FormatterFunction): + def __init__(self, name, doc, arg_count, program_text): + self.name = name + self.doc = doc + self.arg_count = arg_count + self.program_text = program_text + +def compile_user_function(name, doc, arg_count, eval_func): + func = '\t' + eval_func.replace('\n', '\n\t') + prog = ''' +from calibre.utils.formatter_functions import FormatterUserFunction +class UserFunction(FormatterUserFunction): +''' + func + locals = {} + exec prog in locals + cls = locals['UserFunction'](name, doc, arg_count, eval_func) + return cls + +def load_user_template_functions(funcs): + for func in funcs: + try: + cls = compile_user_function(*func) + formatter_functions.register_function(cls) + except: + traceback.print_exc() \ No newline at end of file From 13fd31dd87525ac8310bedfa1060d2b2a3ba9f96 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 13:05:11 +0000 Subject: [PATCH 08/30] Slight change to the dialog --- src/calibre/gui2/preferences/template_functions.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/preferences/template_functions.ui b/src/calibre/gui2/preferences/template_functions.ui index 8227c92607..2662d50dd7 100644 --- a/src/calibre/gui2/preferences/template_functions.ui +++ b/src/calibre/gui2/preferences/template_functions.ui @@ -29,7 +29,7 @@ p, li { white-space: pre-wrap; } </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Here you can add and remove functions used in template processing. A template function is writen in python. It takes information from the book, processes it in some way, then returns a string result. Functions defined here are usable in templates in the same way that builtin functions are usable. The function must be named </span><span style=" font-size:8pt; font-weight:600;">evaluate</span><span style=" font-size:8pt;">, and must have the signature shown below. </span></p> <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt; font-weight:600;">evaluate(self, formatter, kwargs, mi, locals, your_arguments)</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt; font-weight:600;">evaluate(self, formatter, kwargs, mi, locals, your_arguments) </span><span style=" font-size:8pt;">returning a</span><span style=" font-size:8pt; font-weight:600;"> unicode </span><span style=" font-size:8pt;">string</span></p> <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8pt;"></p> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The arguments to evaluate are:</span></p> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">- formatter: the instance of the formatter being used to evaluate the current template. You can use this to do recursive template evaluation.</span></p> From 59078f3b962cc978982b01086e5e17f3c4148aad Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 15:40:42 +0000 Subject: [PATCH 09/30] Clean away existing functions when loading preferences to avoid problems when changing libraries --- src/calibre/utils/formatter_functions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py index 6b79e23b5e..92ebe72706 100644 --- a/src/calibre/utils/formatter_functions.py +++ b/src/calibre/utils/formatter_functions.py @@ -460,6 +460,7 @@ class UserFunction(FormatterUserFunction): return cls def load_user_template_functions(funcs): + formatter_functions.reset_to_builtins() for func in funcs: try: cls = compile_user_function(*func) From 8bbc4ef1dee1ce93627f949c07036d97df7271b8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 08:53:27 -0700 Subject: [PATCH 10/30] Pressthink by DM. Fixes #8299 (New recipe for blog PressThink) --- resources/images/news/pressthink.png | Bin 0 -> 533 bytes resources/recipes/pressthink.recipe | 61 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 resources/images/news/pressthink.png create mode 100644 resources/recipes/pressthink.recipe diff --git a/resources/images/news/pressthink.png b/resources/images/news/pressthink.png new file mode 100644 index 0000000000000000000000000000000000000000..2a70cd5ca55e8c1b3d8e2dd16ac77d41d1af9532 GIT binary patch literal 533 zcmeAS@N?(olHy`uVBq!ia0vp^0w65F0wkYiy)rh#XMp-J+BE$MA_UNeQhotLbc`)Tub=jUrpBeK8RzW%$fyfRGt z_A~h)8Gg~o>)L0YKltjT@Ilcb@PLV;NYg=vTe&y#68BEqonHAaIzVK{-38a1bHt)_ zSA;0cZTl=~CA%Z$p2dgXi8gGp?Rny#Dm#uRTD7_`wK;ECS|_}%|m=e{-jtYh Date: Thu, 13 Jan 2011 09:45:49 -0700 Subject: [PATCH 11/30] Another fix for badly behaved plugins --- src/calibre/gui2/ui.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 01d3180778..b2d6d329f5 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -269,7 +269,13 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ self.set_window_title() for ac in self.iactions.values(): - ac.initialization_complete() + try: + ac.initialization_complete() + except: + import traceback + traceback.print_exc() + if ac.plugin_path is None: + raise if show_gui and self.gui_debug is not None: info_dialog(self, _('Debug mode'), '

' + From bef789b9df165e187e1e53a28391608dd3ff736e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 17:00:49 +0000 Subject: [PATCH 12/30] Fix next/prev highlight to work when selection is moved and when books are deleted --- src/calibre/gui2/library/models.py | 70 +++++++++++++++++------------- src/calibre/gui2/library/views.py | 9 +++- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 6fa23c2813..31b8cf46bf 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -93,9 +93,9 @@ class BooksModel(QAbstractTableModel): # {{{ self.bool_no_icon = QIcon(I('list_remove.png')) self.bool_blank_icon = QIcon(I('blank.png')) self.device_connected = False - self.rows_to_highlight = [] - self.rows_to_highlight_set = set() - self.current_highlighted_row = None + self.ids_to_highlight = [] + self.ids_to_highlight_set = set() + self.current_highlighted_idx = None self.highlight_only = False self.read_config() @@ -131,9 +131,9 @@ class BooksModel(QAbstractTableModel): # {{{ self.book_on_device = func def set_database(self, db): - self.rows_to_highlight = [] - self.rows_to_highlight_set = set() - self.current_highlighted_row = None + self.ids_to_highlight = [] + self.ids_to_highlight_set = set() + self.current_highlighted_idx = None self.db = db self.custom_columns = self.db.field_metadata.custom_field_metadata() self.column_map = list(self.orig_headers.keys()) + \ @@ -241,43 +241,55 @@ class BooksModel(QAbstractTableModel): # {{{ if self.last_search: self.research() - def get_current_highlighted_row(self): - if len(self.rows_to_highlight) == 0 or self.current_highlighted_row is None: + def get_current_highlighted_id(self): + if len(self.ids_to_highlight) == 0 or self.current_highlighted_idx is None: return None try: - return self.rows_to_highlight[self.current_highlighted_row] + return self.ids_to_highlight[self.current_highlighted_idx] except: return None - def get_next_highlighted_row(self, forward): - if len(self.rows_to_highlight) == 0 or self.current_highlighted_row is None: + def get_next_highlighted_id(self, current_row, forward): + if len(self.ids_to_highlight) == 0 or self.current_highlighted_idx is None: return None - self.current_highlighted_row += 1 if forward else -1 - if self.current_highlighted_row < 0: - self.current_highlighted_row = len(self.rows_to_highlight) - 1; - elif self.current_highlighted_row >= len(self.rows_to_highlight): - self.current_highlighted_row = 0 - return self.get_current_highlighted_row() + if current_row is None: + row_ = self.current_highlighted_idx + else: + row_ = current_row + while True: + row_ += 1 if forward else -1 + if row_ < 0: + row_ = self.count() - 1; + elif row_ >= self.count(): + row_ = 0 + if self.id(row_) in self.ids_to_highlight_set: + break + try: + self.current_highlighted_idx = self.ids_to_highlight.index(self.id(row_)) + except: + # This shouldn't happen ... + return None + return self.get_current_highlighted_id() def search(self, text, reset=True): try: if self.highlight_only: self.db.search('') if not text: - self.rows_to_highlight = [] - self.rows_to_highlight_set = set() - self.current_highlighted_row = None + self.ids_to_highlight = [] + self.ids_to_highlight_set = set() + self.current_highlighted_idx = None else: - self.rows_to_highlight = self.db.search(text, return_matches=True) - self.rows_to_highlight_set = set(self.rows_to_highlight) - if self.rows_to_highlight: - self.current_highlighted_row = 0 + self.ids_to_highlight = self.db.search(text, return_matches=True) + self.ids_to_highlight_set = set(self.ids_to_highlight) + if self.ids_to_highlight: + self.current_highlighted_idx = 0 else: - self.current_highlighted_row = None + self.current_highlighted_idx = None else: - self.rows_to_highlight = [] - self.rows_to_highlight_set = set() - self.current_highlighted_row = None + self.ids_to_highlight = [] + self.ids_to_highlight_set = set() + self.current_highlighted_idx = None self.db.search(text) except ParseException as e: self.searched.emit(e.msg) @@ -700,7 +712,7 @@ class BooksModel(QAbstractTableModel): # {{{ if role in (Qt.DisplayRole, Qt.EditRole): return self.column_to_dc_map[col](index.row()) elif role == Qt.BackgroundColorRole: - if self.id(index) in self.rows_to_highlight_set: + if self.id(index) in self.ids_to_highlight_set: return QColor('lightgreen') elif role == Qt.DecorationRole: if self.column_to_dc_decorator_map[col] is not None: diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index e5cc259244..3ff0fc3cd7 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -681,13 +681,18 @@ class BooksView(QTableView): # {{{ self._model.set_editable(editable) def move_highlighted_row(self, forward): - id_to_select = self._model.get_next_highlighted_row(forward) + rows = self.selectionModel().selectedRows() + if len(rows) > 0: + current_row = rows[0].row() + else: + current_row = None + id_to_select = self._model.get_next_highlighted_id(current_row, forward) if id_to_select is not None: self.select_rows([id_to_select], using_ids=True) def search_proxy(self, txt): self._model.search(txt) - id_to_select = self._model.get_current_highlighted_row() + id_to_select = self._model.get_current_highlighted_id() if id_to_select is not None: self.select_rows([id_to_select], using_ids=True) self.setFocus(Qt.OtherFocusReason) From b2ad17d7ffda27fc8a646ee7d20c093a6a0f64d0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 10:02:33 -0700 Subject: [PATCH 13/30] Add keyboard shorcuts to find the next highlighted match --- src/calibre/customize/builtins.py | 6 ++- src/calibre/gui2/actions/__init__.py | 13 +++++- src/calibre/gui2/actions/next_match.py | 56 ++++++++++++++++++++++++++ src/calibre/gui2/layout.py | 6 ++- src/calibre/gui2/library/models.py | 48 +++++++++++++++++----- src/calibre/gui2/library/views.py | 10 ++++- src/calibre/gui2/ui.py | 8 ++++ src/calibre/manual/gui.rst | 4 ++ 8 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 src/calibre/gui2/actions/next_match.py diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index d0f986209c..22e4900740 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -705,13 +705,17 @@ class ActionTweakEpub(InterfaceActionBase): name = 'Tweak ePub' actual_plugin = 'calibre.gui2.actions.tweak_epub:TweakEpubAction' +class ActionNextMatch(InterfaceActionBase): + name = 'Next Match' + actual_plugin = 'calibre.gui2.actions.next_match:NextMatchAction' + plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog, ActionConvert, ActionDelete, ActionEditMetadata, ActionView, ActionFetchNews, ActionSaveToDisk, ActionShowBookDetails, ActionRestart, ActionOpenFolder, ActionConnectShare, ActionSendToDevice, ActionHelp, ActionPreferences, ActionSimilarBooks, ActionAddToLibrary, ActionEditCollections, ActionChooseLibrary, - ActionCopyToLibrary, ActionTweakEpub] + ActionCopyToLibrary, ActionTweakEpub, ActionNextMatch] # }}} diff --git a/src/calibre/gui2/actions/__init__.py b/src/calibre/gui2/actions/__init__.py index b54d346904..8801777953 100644 --- a/src/calibre/gui2/actions/__init__.py +++ b/src/calibre/gui2/actions/__init__.py @@ -111,7 +111,10 @@ class InterfaceAction(QObject): action.setWhatsThis(text) action.setAutoRepeat(False) if shortcut: - action.setShortcut(shortcut) + if isinstance(shortcut, list): + action.setShortcuts(shortcut) + else: + action.setShortcut(shortcut) setattr(self, attr, action) return action @@ -170,6 +173,14 @@ class InterfaceAction(QObject): ''' pass + def gui_layout_complete(self): + ''' + Called once per action when the layout of the main GUI is + completed. If your action needs to make changes to the layout, they + should be done here, rather than in :meth:`initialization_complete`. + ''' + pass + def initialization_complete(self): ''' Called once per action when the initialization of the main GUI is diff --git a/src/calibre/gui2/actions/next_match.py b/src/calibre/gui2/actions/next_match.py new file mode 100644 index 0000000000..79de6a2d9b --- /dev/null +++ b/src/calibre/gui2/actions/next_match.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +from calibre.gui2.actions import InterfaceAction + +class NextMatchAction(InterfaceAction): + name = 'Move to next highlighted book' + action_spec = (_('Move to next match'), 'arrow-down.png', + _('Move to next highlighted match'), [_('N'), _('F3')]) + dont_add_to = frozenset(['toolbar-device', 'context-menu-device']) + action_type = 'current' + + def genesis(self): + ''' + Setup this plugin. Only called once during initialization. self.gui is + available. The action secified by :attr:`action_spec` is available as + ``self.qaction``. + ''' + self.can_move = None + self.qaction.triggered.connect(self.move_forward) + self.create_action(spec=(_('Move to previous item'), 'arrow-up.png', + _('Move to previous highlighted item'), [_('Shift+N'), + _('Shift+F3')]), attr='p_action') + self.gui.addAction(self.p_action) + self.p_action.triggered.connect(self.move_backward) + + def gui_layout_complete(self): + self.gui.search_highlight_only.setVisible(True) + + def location_selected(self, loc): + self.can_move = loc == 'library' + try: + self.gui.search_highlight_only.setVisible(self.can_move) + except: + import traceback + traceback.print_exc() + + def move_forward(self): + if self.can_move is None: + self.can_move = self.gui.current_view() is self.gui.library_view + self.gui.search_highlight_only.setVisible(self.can_move) + + if self.can_move: + self.gui.current_view().move_highlighted_row(forward=True) + + def move_backward(self): + if self.can_move is None: + self.can_move = self.gui.current_view() is self.gui.library_view + self.gui.search_highlight_only.setVisible(self.can_move) + + if self.can_move: + self.gui.current_view().move_highlighted_row(forward=False) diff --git a/src/calibre/gui2/layout.py b/src/calibre/gui2/layout.py index 2edf19d0c4..c1d9498075 100644 --- a/src/calibre/gui2/layout.py +++ b/src/calibre/gui2/layout.py @@ -196,9 +196,11 @@ class SearchBar(QWidget): # {{{ x = parent.search_highlight_only = QCheckBox() x.setText(_('&Highlight')) - x.setToolTip(_('Highlight matched books in the book list, instead ' - 'of restricting the book list to the matches.')) + x.setToolTip('

'+_('When searching, highlight matched books, instead ' + 'of restricting the book list to the matches.

You can use the ' + 'N or F3 keys to go to the next match.')) l.addWidget(x) + x.setVisible(False) x = parent.saved_search = SavedSearchBox(self) x.setMaximumSize(QSize(150, 16777215)) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index eea452c238..6fa23c2813 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -93,8 +93,9 @@ class BooksModel(QAbstractTableModel): # {{{ self.bool_no_icon = QIcon(I('list_remove.png')) self.bool_blank_icon = QIcon(I('blank.png')) self.device_connected = False - self.rows_matching = set() - self.lowest_row_matching = None + self.rows_to_highlight = [] + self.rows_to_highlight_set = set() + self.current_highlighted_row = None self.highlight_only = False self.read_config() @@ -130,6 +131,9 @@ class BooksModel(QAbstractTableModel): # {{{ self.book_on_device = func def set_database(self, db): + self.rows_to_highlight = [] + self.rows_to_highlight_set = set() + self.current_highlighted_row = None self.db = db self.custom_columns = self.db.field_metadata.custom_field_metadata() self.column_map = list(self.orig_headers.keys()) + \ @@ -237,21 +241,43 @@ class BooksModel(QAbstractTableModel): # {{{ if self.last_search: self.research() + def get_current_highlighted_row(self): + if len(self.rows_to_highlight) == 0 or self.current_highlighted_row is None: + return None + try: + return self.rows_to_highlight[self.current_highlighted_row] + except: + return None + + def get_next_highlighted_row(self, forward): + if len(self.rows_to_highlight) == 0 or self.current_highlighted_row is None: + return None + self.current_highlighted_row += 1 if forward else -1 + if self.current_highlighted_row < 0: + self.current_highlighted_row = len(self.rows_to_highlight) - 1; + elif self.current_highlighted_row >= len(self.rows_to_highlight): + self.current_highlighted_row = 0 + return self.get_current_highlighted_row() + def search(self, text, reset=True): try: if self.highlight_only: self.db.search('') if not text: - self.rows_matching = set() - self.lowest_row_matching = None + self.rows_to_highlight = [] + self.rows_to_highlight_set = set() + self.current_highlighted_row = None else: - self.rows_matching = self.db.search(text, return_matches=True) - if self.rows_matching: - self.lowest_row_matching = self.db.row(self.rows_matching[0]) - self.rows_matching = set(self.rows_matching) + self.rows_to_highlight = self.db.search(text, return_matches=True) + self.rows_to_highlight_set = set(self.rows_to_highlight) + if self.rows_to_highlight: + self.current_highlighted_row = 0 + else: + self.current_highlighted_row = None else: - self.rows_matching = set() - self.lowest_row_matching = None + self.rows_to_highlight = [] + self.rows_to_highlight_set = set() + self.current_highlighted_row = None self.db.search(text) except ParseException as e: self.searched.emit(e.msg) @@ -674,7 +700,7 @@ class BooksModel(QAbstractTableModel): # {{{ if role in (Qt.DisplayRole, Qt.EditRole): return self.column_to_dc_map[col](index.row()) elif role == Qt.BackgroundColorRole: - if self.id(index) in self.rows_matching: + if self.id(index) in self.rows_to_highlight_set: return QColor('lightgreen') elif role == Qt.DecorationRole: if self.column_to_dc_decorator_map[col] is not None: diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index ea2e03fdad..e5cc259244 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -680,10 +680,16 @@ class BooksView(QTableView): # {{{ def set_editable(self, editable, supports_backloading): self._model.set_editable(editable) + def move_highlighted_row(self, forward): + id_to_select = self._model.get_next_highlighted_row(forward) + if id_to_select is not None: + self.select_rows([id_to_select], using_ids=True) + def search_proxy(self, txt): self._model.search(txt) - if self._model.lowest_row_matching is not None: - self.select_rows([self._model.lowest_row_matching], using_ids=False) + id_to_select = self._model.get_current_highlighted_row() + if id_to_select is not None: + self.select_rows([id_to_select], using_ids=True) self.setFocus(Qt.OtherFocusReason) def connect_to_search_box(self, sb, search_done): diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index b2d6d329f5..79bd1decc5 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -256,6 +256,14 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ self.height()) self.resize(self.width(), self._calculated_available_height) + for ac in self.iactions.values(): + try: + ac.gui_layout_complete() + except: + import traceback + traceback.print_exc() + if ac.plugin_path is None: + raise if config['autolaunch_server']: self.start_content_server() diff --git a/src/calibre/manual/gui.rst b/src/calibre/manual/gui.rst index 28fd0307d3..9a65d80384 100644 --- a/src/calibre/manual/gui.rst +++ b/src/calibre/manual/gui.rst @@ -478,6 +478,10 @@ Calibre has several keyboard shortcuts to save you time and mouse movement. Thes - Focus the search bar * - :kbd:`Shift+Ctrl+F` - Open the advanced search dialog + * - :kbd:`N or F3` + - Find the next book that matches the current search (only works if the highlight checkbox next to the search bar is checked) + * - :kbd:`Shift+N or Shift+F3` + - Find the next book that matches the current search (only works if the highlight checkbox next to the search bar is checked) * - :kbd:`Ctrl+D` - Download metadata and shortcuts * - :kbd:`Ctrl+R` From 9c53adfbb9c13870d9ff9634c59991a0ed07fd3a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 10:08:35 -0700 Subject: [PATCH 14/30] Add an --ignore-plugins option to calibre.exe --- src/calibre/gui2/main.py | 3 +++ src/calibre/gui2/ui.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/calibre/gui2/main.py b/src/calibre/gui2/main.py index c8b5cb001e..aaca398e44 100644 --- a/src/calibre/gui2/main.py +++ b/src/calibre/gui2/main.py @@ -34,6 +34,9 @@ path_to_ebook to the database. help=_('Log debugging information to console')) parser.add_option('--no-update-check', default=False, action='store_true', help=_('Do not check for updates')) + parser.add_option('--ignore-plugins', default=False, action='store_true', + help=_('Ignore custom plugins, useful if you installed a plugin' + ' that is preventing calibre from starting')) return parser def init_qt(args): diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 79bd1decc5..9eb202d761 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -103,6 +103,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ self.gui_debug = gui_debug acmap = OrderedDict() for action in interface_actions(): + if opts.ignore_plugins and action.plugin_path is not None: + continue try: ac = action.load_actual_plugin(self) except: From c73d1a4f0d146a4a9ac8a2b8f3d8f50b7337cba5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 10:12:03 -0700 Subject: [PATCH 15/30] ... --- src/calibre/gui2/library/models.py | 70 +++++++++++++++++------------- src/calibre/gui2/library/views.py | 9 +++- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 6fa23c2813..31b8cf46bf 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -93,9 +93,9 @@ class BooksModel(QAbstractTableModel): # {{{ self.bool_no_icon = QIcon(I('list_remove.png')) self.bool_blank_icon = QIcon(I('blank.png')) self.device_connected = False - self.rows_to_highlight = [] - self.rows_to_highlight_set = set() - self.current_highlighted_row = None + self.ids_to_highlight = [] + self.ids_to_highlight_set = set() + self.current_highlighted_idx = None self.highlight_only = False self.read_config() @@ -131,9 +131,9 @@ class BooksModel(QAbstractTableModel): # {{{ self.book_on_device = func def set_database(self, db): - self.rows_to_highlight = [] - self.rows_to_highlight_set = set() - self.current_highlighted_row = None + self.ids_to_highlight = [] + self.ids_to_highlight_set = set() + self.current_highlighted_idx = None self.db = db self.custom_columns = self.db.field_metadata.custom_field_metadata() self.column_map = list(self.orig_headers.keys()) + \ @@ -241,43 +241,55 @@ class BooksModel(QAbstractTableModel): # {{{ if self.last_search: self.research() - def get_current_highlighted_row(self): - if len(self.rows_to_highlight) == 0 or self.current_highlighted_row is None: + def get_current_highlighted_id(self): + if len(self.ids_to_highlight) == 0 or self.current_highlighted_idx is None: return None try: - return self.rows_to_highlight[self.current_highlighted_row] + return self.ids_to_highlight[self.current_highlighted_idx] except: return None - def get_next_highlighted_row(self, forward): - if len(self.rows_to_highlight) == 0 or self.current_highlighted_row is None: + def get_next_highlighted_id(self, current_row, forward): + if len(self.ids_to_highlight) == 0 or self.current_highlighted_idx is None: return None - self.current_highlighted_row += 1 if forward else -1 - if self.current_highlighted_row < 0: - self.current_highlighted_row = len(self.rows_to_highlight) - 1; - elif self.current_highlighted_row >= len(self.rows_to_highlight): - self.current_highlighted_row = 0 - return self.get_current_highlighted_row() + if current_row is None: + row_ = self.current_highlighted_idx + else: + row_ = current_row + while True: + row_ += 1 if forward else -1 + if row_ < 0: + row_ = self.count() - 1; + elif row_ >= self.count(): + row_ = 0 + if self.id(row_) in self.ids_to_highlight_set: + break + try: + self.current_highlighted_idx = self.ids_to_highlight.index(self.id(row_)) + except: + # This shouldn't happen ... + return None + return self.get_current_highlighted_id() def search(self, text, reset=True): try: if self.highlight_only: self.db.search('') if not text: - self.rows_to_highlight = [] - self.rows_to_highlight_set = set() - self.current_highlighted_row = None + self.ids_to_highlight = [] + self.ids_to_highlight_set = set() + self.current_highlighted_idx = None else: - self.rows_to_highlight = self.db.search(text, return_matches=True) - self.rows_to_highlight_set = set(self.rows_to_highlight) - if self.rows_to_highlight: - self.current_highlighted_row = 0 + self.ids_to_highlight = self.db.search(text, return_matches=True) + self.ids_to_highlight_set = set(self.ids_to_highlight) + if self.ids_to_highlight: + self.current_highlighted_idx = 0 else: - self.current_highlighted_row = None + self.current_highlighted_idx = None else: - self.rows_to_highlight = [] - self.rows_to_highlight_set = set() - self.current_highlighted_row = None + self.ids_to_highlight = [] + self.ids_to_highlight_set = set() + self.current_highlighted_idx = None self.db.search(text) except ParseException as e: self.searched.emit(e.msg) @@ -700,7 +712,7 @@ class BooksModel(QAbstractTableModel): # {{{ if role in (Qt.DisplayRole, Qt.EditRole): return self.column_to_dc_map[col](index.row()) elif role == Qt.BackgroundColorRole: - if self.id(index) in self.rows_to_highlight_set: + if self.id(index) in self.ids_to_highlight_set: return QColor('lightgreen') elif role == Qt.DecorationRole: if self.column_to_dc_decorator_map[col] is not None: diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index e5cc259244..3ff0fc3cd7 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -681,13 +681,18 @@ class BooksView(QTableView): # {{{ self._model.set_editable(editable) def move_highlighted_row(self, forward): - id_to_select = self._model.get_next_highlighted_row(forward) + rows = self.selectionModel().selectedRows() + if len(rows) > 0: + current_row = rows[0].row() + else: + current_row = None + id_to_select = self._model.get_next_highlighted_id(current_row, forward) if id_to_select is not None: self.select_rows([id_to_select], using_ids=True) def search_proxy(self, txt): self._model.search(txt) - id_to_select = self._model.get_current_highlighted_row() + id_to_select = self._model.get_current_highlighted_id() if id_to_select is not None: self.select_rows([id_to_select], using_ids=True) self.setFocus(Qt.OtherFocusReason) From d05b92a92b5ae8f826d1228f0334ff63008ee454 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 17:32:08 +0000 Subject: [PATCH 16/30] Get current source -- some strange merge artefacts. --- src/calibre/gui2/ui.py | 8 +++++++- src/calibre/library/catalog.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 8c92fec0d7..9eb202d761 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -279,7 +279,13 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ self.set_window_title() for ac in self.iactions.values(): - ac.initialization_complete() + try: + ac.initialization_complete() + except: + import traceback + traceback.print_exc() + if ac.plugin_path is None: + raise if show_gui and self.gui_debug is not None: info_dialog(self, _('Debug mode'), '

' + diff --git a/src/calibre/library/catalog.py b/src/calibre/library/catalog.py index 5cda9baa8c..0be2d7fc05 100644 --- a/src/calibre/library/catalog.py +++ b/src/calibre/library/catalog.py @@ -3250,7 +3250,7 @@ class EPUB_MOBI(CatalogPlugin): # Loop over the series titles, find start of each letter, add description_preview_count books # Special switch for using different title list title_list = self.booksBySeries - current_letter = self.letter_or_symbol(title_list[0]['series'][0]) + current_letter = self.letter_or_symbol(self.generateSortTitle(title_list[0]['series'])[0]) title_letters = [current_letter] current_series_list = [] current_series = "" From 1db0863ab51da644f7c971254e7092ae4a921fea Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 10:32:28 -0700 Subject: [PATCH 17/30] Fix selecting Tablet output profile would actually select the Samsung Galaxy S profile --- src/calibre/customize/profiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/customize/profiles.py b/src/calibre/customize/profiles.py index 0c27069df3..763460d2ef 100644 --- a/src/calibre/customize/profiles.py +++ b/src/calibre/customize/profiles.py @@ -441,7 +441,7 @@ class TabletOutput(iPadOutput): class SamsungGalaxy(TabletOutput): name = 'Samsung Galaxy' - shortname = 'galaxy' + short_name = 'galaxy' description = _('Intended for the Samsung Galaxy and similar tablet devices with ' 'a resolution of 600x1280') screen_size = comic_screen_size = (600, 1280) From cd66ed4bc6ff8010ccd695db808d4426e2fda3f5 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 17:58:25 +0000 Subject: [PATCH 18/30] Fix problem with local variable assignment and typo in function name --- src/calibre/utils/formatter.py | 18 +++++++++++------- src/calibre/utils/formatter_functions.py | 3 ++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 2d74885942..abcb3021be 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -26,7 +26,7 @@ class _Parser(object): if prog[1] != '': self.error(_('failed to scan program. Invalid input {0}').format(prog[1])) self.parent = parent - self.variables = {'$':val} + self.parent.locals = {'$':val} def error(self, message): m = 'Formatter: ' + message + _(' near ') @@ -88,18 +88,20 @@ class _Parser(object): def expr(self): if self.token_is_id(): + funcs = formatter_functions.get_functions() # We have an identifier. Determine if it is a function id = self.token() if not self.token_op_is_a('('): if self.token_op_is_a('='): # classic assignment statement self.consume() - return self._assign(id, self.expr()) - return self.variables.get(id, _('unknown id ') + id) + cls = funcs['assign'] + return cls.eval(self.parent, self.parent.kwargs, + self.parent.book, self.parent.locals, id, self.expr()) + return self.parent.locals.get(id, _('unknown id ') + id) # We have a function. # Check if it is a known one. We do this here so error reporting is # better, as it can identify the tokens near the problem. - funcs = formatter_functions.get_functions() if id not in funcs: self.error(_('unknown function {0}').format(id)) @@ -129,7 +131,7 @@ class _Parser(object): if cls.arg_count != -1 and len(args) != cls.arg_count: self.error('incorrect number of arguments for function {}'.format(id)) return cls.eval(self.parent, self.parent.kwargs, - self.parent.book, locals, *args) + self.parent.book, self.parent.locals, *args) else: f = self.parent.functions[id] if f[0] != -1 and len(args) != f[0]+1: @@ -159,6 +161,7 @@ class TemplateFormatter(string.Formatter): self.book = None self.kwargs = None self.program_cache = {} + self.locals = {} def _do_format(self, val, fmt): if not fmt or not val: @@ -286,9 +289,9 @@ class TemplateFormatter(string.Formatter): print args raise ValueError('Incorrect number of arguments for function '+ fmt[0:p]) if func.arg_count == 1: - val = func.eval(self, self.kwargs, self.book, locals, val).strip() + val = func.eval(self, self.kwargs, self.book, self.locals, val).strip() else: - val = func.eval(self, self.kwargs, self.book, locals, + val = func.eval(self, self.kwargs, self.book, self.locals, val, *args).strip() if val: val = self._do_format(val, dispfmt) @@ -309,6 +312,7 @@ class TemplateFormatter(string.Formatter): self.kwargs = kwargs self.book = book self.composite_values = {} + self.locals = {} try: ans = self.vformat(fmt, [], kwargs).strip() except Exception, e: diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py index 92ebe72706..ae95b9ad07 100644 --- a/src/calibre/utils/formatter_functions.py +++ b/src/calibre/utils/formatter_functions.py @@ -74,6 +74,7 @@ class FormatterFunction(object): if isinstance(ret, list): return ','.join(list) except: + traceback.print_exc() return _('Function threw exception' + traceback.format_exc()) class BuiltinStrcmp(FormatterFunction): @@ -327,7 +328,7 @@ class BuiltinEvaluate(FormatterFunction): return value_if_empty class BuiltinShorten(FormatterFunction): - name = 'shorten ' + name = 'shorten' arg_count = 4 doc = _('shorten(val, left chars, middle text, right chars) -- Return a ' 'shortened version of the field, consisting of `left chars` ' From 97a0ae8a3e74c7e1b222b07af33ade8baf7563f0 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 18:30:27 +0000 Subject: [PATCH 19/30] Add a paragraph to template_lang.rst. Change slightly the template_dialog upper text. --- src/calibre/gui2/preferences/template_functions.py | 3 ++- src/calibre/manual/template_lang.rst | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/preferences/template_functions.py b/src/calibre/gui2/preferences/template_functions.py index 8416c5a581..fa15c0a973 100644 --- a/src/calibre/gui2/preferences/template_functions.py +++ b/src/calibre/gui2/preferences/template_functions.py @@ -38,7 +38,8 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): This parameter can be None in some cases, such as when evaluating non-book templates.

  • locals: the local variables assigned to by the current - template program. Your_arguments must be one or more parameter (number + template program.
  • +
  • Your_arguments must be one or more parameter (number matching the arg count box), or the value *args for a variable number of arguments. These are values passed into the function. One argument is required, and is usually the value of the field being operated upon. diff --git a/src/calibre/manual/template_lang.rst b/src/calibre/manual/template_lang.rst index f64a413d3e..f63a7c4e95 100644 --- a/src/calibre/manual/template_lang.rst +++ b/src/calibre/manual/template_lang.rst @@ -308,6 +308,12 @@ The following program produces the same results as the original recipe, using on It would be possible to do the above with no custom columns by putting the program into the template box of the plugboard. However, to do so, all comments must be removed because the plugboard text box does not support multi-line editing. It is debatable whether the gain of not having the custom column is worth the vast increase in difficulty caused by the program being one giant line. + +User-defined Template Functions +------------------------------- + +You can add your own functions to the template processor. Such functions are written in python, and can be used in any of the three template programming modes. The functions are added by going to Preferences -> Advanced -> Template Functions. Instructions are shown in that dialog. + Special notes for save/send templates ------------------------------------- From 293a50af985d3894d123ef9573df06124406c648 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 11:45:24 -0700 Subject: [PATCH 20/30] Wichita Eagle by Jason Cameron. Fixes #405 (New news feed) --- resources/recipes/wichita_eagle.recipe | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 resources/recipes/wichita_eagle.recipe diff --git a/resources/recipes/wichita_eagle.recipe b/resources/recipes/wichita_eagle.recipe new file mode 100644 index 0000000000..9e0314ca0e --- /dev/null +++ b/resources/recipes/wichita_eagle.recipe @@ -0,0 +1,29 @@ +from calibre.web.feeds.news import BasicNewsRecipe + +class AdvancedUserRecipe1294938721(BasicNewsRecipe): + title = u'Wichita Eagle' + language = 'en' + __author__ = 'Jason Cameron' + description = 'Daily news from the Wichita Eagle' + oldest_article = 1 + max_articles_per_feed = 30 + keep_only_tags = [dict(name='div', attrs={'id':'wide'})] + feeds = [ + (u'Local News', + u'http://www.kansas.com/news/local/index.rss'), + (u'National News', + u'http://www.kansas.com/news/nation-world/index.rss'), + (u'Sports', + u'http://www.kansas.com/sports/index.rss'), + (u'Opinion', + u'http://www.kansas.com/opinion/index.rss'), + (u'Life', + u'http://www.kansas.com/living/index.rss'), + (u'Entertainment', + u'http://www.kansas.com/entertainment/index.rss') + ] + + def print_version(self, url): + urlparts = url.split('/') + newadd = urlparts[5]+'/v-print' + return url.replace(url, newadd.join(url.split(urlparts[5]))) From 1a9762aed9d85fd93ed79b1102b46a43649dd551 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 12:07:31 -0700 Subject: [PATCH 21/30] ... --- src/calibre/gui2/dialogs/drm_error.ui | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/calibre/gui2/dialogs/drm_error.ui b/src/calibre/gui2/dialogs/drm_error.ui index 842807c9bc..ff28ef5a48 100644 --- a/src/calibre/gui2/dialogs/drm_error.ui +++ b/src/calibre/gui2/dialogs/drm_error.ui @@ -13,6 +13,10 @@ This book is DRMed + + + :/images/document-encrypt.png:/images/document-encrypt.png + From 5d00117f449b4431602e9685757cef9b92f78e21 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 19:09:03 +0000 Subject: [PATCH 22/30] Convert leading tabs to 4 spaces --- src/calibre/utils/formatter_functions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py index a17903004f..b0895ce1b3 100644 --- a/src/calibre/utils/formatter_functions.py +++ b/src/calibre/utils/formatter_functions.py @@ -449,8 +449,13 @@ class FormatterUserFunction(FormatterFunction): self.arg_count = arg_count self.program_text = program_text +tabs = re.compile(r'^\t*') def compile_user_function(name, doc, arg_count, eval_func): - func = '\t' + eval_func.replace('\n', '\n\t') + def replace_func(mo): + return mo.group().replace('\t', ' ') + + func = ' ' + '\n '.join([tabs.sub(replace_func, line ) + for line in eval_func.splitlines()]) prog = ''' from calibre.utils.formatter_functions import FormatterUserFunction class UserFunction(FormatterUserFunction): From 9aa26db64022d6d02e209b09f56133453e92eeb5 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 13 Jan 2011 19:26:18 +0000 Subject: [PATCH 23/30] Add syntax highlighter --- src/calibre/gui2/preferences/template_functions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/calibre/gui2/preferences/template_functions.py b/src/calibre/gui2/preferences/template_functions.py index fa15c0a973..efcf9e6379 100644 --- a/src/calibre/gui2/preferences/template_functions.py +++ b/src/calibre/gui2/preferences/template_functions.py @@ -10,6 +10,7 @@ import traceback from calibre.gui2 import error_dialog from calibre.gui2.preferences import ConfigWidgetBase, test_widget from calibre.gui2.preferences.template_functions_ui import Ui_Form +from calibre.gui2.widgets import PythonHighlighter from calibre.utils.formatter_functions import formatter_functions, compile_user_function @@ -72,6 +73,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.delete_button.setEnabled(False) self.clear_button.clicked.connect(self.clear_button_clicked) self.program.setTabStopWidth(20) + self.highlighter = PythonHighlighter(self.program.document()) def clear_button_clicked(self): self.build_function_names_box() From a294138ff738ea3bc5de36075586290b061ac344 Mon Sep 17 00:00:00 2001 From: GRiker Date: Thu, 13 Jan 2011 12:26:34 -0700 Subject: [PATCH 24/30] GwR refactor epub_mobi class init, remove special case handling of Description notes --- src/calibre/gui2/catalog/catalog_epub_mobi.py | 116 ++++++++++-------- src/calibre/library/catalog.py | 13 +- 2 files changed, 66 insertions(+), 63 deletions(-) diff --git a/src/calibre/gui2/catalog/catalog_epub_mobi.py b/src/calibre/gui2/catalog/catalog_epub_mobi.py index 7a35fdb3c2..a4da4a0b6d 100644 --- a/src/calibre/gui2/catalog/catalog_epub_mobi.py +++ b/src/calibre/gui2/catalog/catalog_epub_mobi.py @@ -6,67 +6,20 @@ __license__ = 'GPL v3' __copyright__ = '2009, Kovid Goyal ' __docformat__ = 'restructuredtext en' +import traceback from calibre.ebooks.conversion.config import load_defaults from calibre.gui2 import gprefs from catalog_epub_mobi_ui import Ui_Form -from PyQt4.Qt import QWidget, QLineEdit +from PyQt4.Qt import QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QGroupBox, QHBoxLayout, QLineEdit, \ + QRadioButton, QRegExp, QSound, QTextEdit, QWidget, SIGNAL, SLOT class PluginWidget(QWidget,Ui_Form): TITLE = _('E-book options') HELP = _('Options specific to')+' EPUB/MOBI '+_('output') - CheckBoxControls = [ - 'generate_titles', - 'generate_series', - 'generate_genres', - 'generate_recently_added', - 'generate_descriptions', - 'include_hr' - ] - ComboBoxControls = [ - 'read_source_field', - 'exclude_source_field', - 'header_note_source_field', - 'merge_source_field' - ] - LineEditControls = [ - 'exclude_genre', - 'exclude_pattern', - 'exclude_tags', - 'read_pattern', - 'wishlist_tag' - ] - RadioButtonControls = [ - 'merge_before', - 'merge_after' - ] - SpinBoxControls = [ - 'thumb_width' - ] - - OPTION_FIELDS = zip(CheckBoxControls, - [True for i in CheckBoxControls], - ['check_box' for i in CheckBoxControls]) - OPTION_FIELDS += zip(ComboBoxControls, - [None for i in ComboBoxControls], - ['combo_box' for i in ComboBoxControls]) - OPTION_FIELDS += zip(RadioButtonControls, - [None for i in RadioButtonControls], - ['radio_button' for i in RadioButtonControls]) - - # LineEditControls - OPTION_FIELDS += zip(['exclude_genre'],['\[.+\]'],['line_edit']) - OPTION_FIELDS += zip(['exclude_pattern'],[None],['line_edit']) - OPTION_FIELDS += zip(['exclude_tags'],['~,'+_('Catalog')],['line_edit']) - OPTION_FIELDS += zip(['read_pattern'],['+'],['line_edit']) - OPTION_FIELDS += zip(['wishlist_tag'],['Wishlist'],['line_edit']) - - # SpinBoxControls - OPTION_FIELDS += zip(['thumb_width'],[1.00],['spin_box']) - # Output synced to the connected device? sync_enabled = True @@ -76,8 +29,69 @@ class PluginWidget(QWidget,Ui_Form): def __init__(self, parent=None): QWidget.__init__(self, parent) self.setupUi(self) + self._initControlArrays() + + def _initControlArrays(self): + + CheckBoxControls = [] + ComboBoxControls = [] + DoubleSpinBoxControls = [] + LineEditControls = [] + RadioButtonControls = [] + + for item in self.__dict__: + if type(self.__dict__[item]) is QCheckBox: + CheckBoxControls.append(str(self.__dict__[item].objectName())) + elif type(self.__dict__[item]) is QComboBox: + ComboBoxControls.append(str(self.__dict__[item].objectName())) + elif type(self.__dict__[item]) is QDoubleSpinBox: + DoubleSpinBoxControls.append(str(self.__dict__[item].objectName())) + elif type(self.__dict__[item]) is QLineEdit: + LineEditControls.append(str(self.__dict__[item].objectName())) + elif type(self.__dict__[item]) is QRadioButton: + RadioButtonControls.append(str(self.__dict__[item].objectName())) + + option_fields = zip(CheckBoxControls, + [True for i in CheckBoxControls], + ['check_box' for i in CheckBoxControls]) + option_fields += zip(ComboBoxControls, + [None for i in ComboBoxControls], + ['combo_box' for i in ComboBoxControls]) + option_fields += zip(RadioButtonControls, + [None for i in RadioButtonControls], + ['radio_button' for i in RadioButtonControls]) + + # LineEditControls + option_fields += zip(['exclude_genre'],['\[.+\]'],['line_edit']) + option_fields += zip(['exclude_pattern'],[None],['line_edit']) + option_fields += zip(['exclude_tags'],['~,'+_('Catalog')],['line_edit']) + option_fields += zip(['read_pattern'],['+'],['line_edit']) + option_fields += zip(['wishlist_tag'],['Wishlist'],['line_edit']) + + # SpinBoxControls + option_fields += zip(['thumb_width'],[1.00],['spin_box']) + + self.OPTION_FIELDS = option_fields def initialize(self, name, db): + ''' + + CheckBoxControls (c_type: check_box): + ['generate_titles','generate_series','generate_genres', + 'generate_recently_added','generate_descriptions','include_hr'] + ComboBoxControls (c_type: combo_box): + ['read_source_field','exclude_source_field','header_note_source_field', + 'merge_source_field'] + LineEditControls (c_type: line_edit): + ['exclude_genre','exclude_pattern','exclude_tags','read_pattern', + 'wishlist_tag'] + RadioButtonControls (c_type: radio_button): + ['merge_before','merge_after'] + SpinBoxControls (c_type: spin_box): + ['thumb_width'] + + ''' + self.name = name self.db = db self.populateComboBoxes() @@ -135,7 +149,7 @@ class PluginWidget(QWidget,Ui_Form): def options(self): # Save/return the current options # exclude_genre stores literally - # generate_titles, generate_recently_added, numbers_as_text stores as True/False + # generate_titles, generate_recently_added store as True/False # others store as lists opts_dict = {} diff --git a/src/calibre/library/catalog.py b/src/calibre/library/catalog.py index 0be2d7fc05..a9a9caa8db 100644 --- a/src/calibre/library/catalog.py +++ b/src/calibre/library/catalog.py @@ -1536,18 +1536,6 @@ class EPUB_MOBI(CatalogPlugin): notes = ' · '.join(notes) elif field_md['datatype'] == 'datetime': notes = format_date(notes,'dd MMM yyyy') - elif field_md['datatype'] == 'composite': - m = re.match(r'\[(.+)\]$', notes) - if m is not None: - # Sniff for special pseudo-list string "[]" - bracketed_content = m.group(1) - if ',' in bracketed_content: - # Recast the comma-separated items as a list - items = bracketed_content.split(',') - items = [i.strip() for i in items] - notes = ' · '.join(items) - else: - notes = bracketed_content this_title['notes'] = {'source':field_md['name'], 'content':notes} @@ -4381,6 +4369,7 @@ class EPUB_MOBI(CatalogPlugin): formats.append(format.rpartition('.')[2].upper()) formats = ' · '.join(formats) + # Date of publication pubdate = book['date'] pubmonth, pubyear = pubdate.split(' ') From 58c5b94e74aceb9e59394b36db0d4b4793cda853 Mon Sep 17 00:00:00 2001 From: GRiker Date: Thu, 13 Jan 2011 12:34:51 -0700 Subject: [PATCH 25/30] GwR remove unneeded imports --- src/calibre/gui2/catalog/catalog_epub_mobi.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/calibre/gui2/catalog/catalog_epub_mobi.py b/src/calibre/gui2/catalog/catalog_epub_mobi.py index a4da4a0b6d..e0c878c9e8 100644 --- a/src/calibre/gui2/catalog/catalog_epub_mobi.py +++ b/src/calibre/gui2/catalog/catalog_epub_mobi.py @@ -6,14 +6,12 @@ __license__ = 'GPL v3' __copyright__ = '2009, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import traceback - from calibre.ebooks.conversion.config import load_defaults from calibre.gui2 import gprefs from catalog_epub_mobi_ui import Ui_Form -from PyQt4.Qt import QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QGroupBox, QHBoxLayout, QLineEdit, \ - QRadioButton, QRegExp, QSound, QTextEdit, QWidget, SIGNAL, SLOT +from PyQt4.Qt import QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QGroupBox, QLineEdit, \ + QRadioButton, QSound, QTextEdit, QWidget, SIGNAL, SLOT class PluginWidget(QWidget,Ui_Form): From f2219c400b625b8ed8c6d5c86487e08d95357a4a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 15:16:08 -0700 Subject: [PATCH 26/30] MOBI Input: Fix regression that caused images placed inside svg tags to be discarded --- src/calibre/ebooks/mobi/reader.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/calibre/ebooks/mobi/reader.py b/src/calibre/ebooks/mobi/reader.py index 57f32e7131..e07418f41c 100644 --- a/src/calibre/ebooks/mobi/reader.py +++ b/src/calibre/ebooks/mobi/reader.py @@ -632,9 +632,18 @@ class MobiReader(object): attrib['class'] = cls for tag in svg_tags: - p = tag.getparent() - if hasattr(p, 'remove'): - p.remove(tag) + images = tag.xpath('descendant::img[@src]') + parent = tag.getparent() + + if images and hasattr(parent, 'find'): + index = parent.index(tag) + for img in images: + img.getparent().remove(img) + img.tail = img.text = None + parent.insert(index, img) + + if hasattr(parent, 'remove'): + parent.remove(tag) def create_opf(self, htmlfile, guide=None, root=None): mi = getattr(self.book_header.exth, 'mi', self.embedded_mi) From 84c159472d81bfcf92129c0c8cbe224fa50bba83 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 16:57:45 -0700 Subject: [PATCH 27/30] Yakima Herald and Tri-City Herald by Laura Gjovaag --- resources/recipes/tri_city_herald.recipe | 25 ++++++++++++++++++++++++ resources/recipes/yakima_herald.recipe | 21 ++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 resources/recipes/tri_city_herald.recipe create mode 100644 resources/recipes/yakima_herald.recipe diff --git a/resources/recipes/tri_city_herald.recipe b/resources/recipes/tri_city_herald.recipe new file mode 100644 index 0000000000..5deb5abd5b --- /dev/null +++ b/resources/recipes/tri_city_herald.recipe @@ -0,0 +1,25 @@ +from calibre.web.feeds.news import BasicNewsRecipe + +class TriCityHeraldRecipe(BasicNewsRecipe): + title = u'Tri-City Herald' + description = 'The Tri-City Herald Mid-Columbia.' + language = 'en' + __author__ = 'Laura Gjovaag' + oldest_article = 1.5 + max_articles_per_feed = 100 + no_stylesheets = True + remove_javascript = True + keep_only_tags = [ + dict(name='div', attrs={'id':'story_header'}), + dict(name='img', attrs={'class':'imageCycle'}), + dict(name='div', attrs={'id':['cycleImageCaption', 'story_body']}) + ] + remove_tags = [ + dict(name='div', attrs={'id':'story_mlt'}), + dict(name='a', attrs={'id':'commentCount'}), + dict(name=['script', 'noscript', 'style'])] + extra_css = 'h1{font: bold 140%;} #cycleImageCaption{font: monospace 60%}' + + feeds = [ + (u'Tri-City Herald Mid-Columbia', u'http://www.tri-cityherald.com/901/index.rss') + ] diff --git a/resources/recipes/yakima_herald.recipe b/resources/recipes/yakima_herald.recipe new file mode 100644 index 0000000000..d98f48c199 --- /dev/null +++ b/resources/recipes/yakima_herald.recipe @@ -0,0 +1,21 @@ +from calibre.web.feeds.news import BasicNewsRecipe + +class YakimaHeraldRepublicRecipe(BasicNewsRecipe): + title = u'Yakima Herald-Republic' + description = 'The Yakima Herald-Republic.' + language = 'en' + __author__ = 'Laura Gjovaag' + oldest_article = 1.5 + max_articles_per_feed = 100 + no_stylesheets = True + remove_javascript = True + keep_only_tags = [ + dict(name='div', attrs={'id':['searchleft', 'headline_credit']}), + dict(name='div', attrs={'class':['photo', 'cauthor', 'photocredit']}), + dict(name='div', attrs={'id':['content_body', 'footerleft']}) + ] + extra_css = '.cauthor {font: monospace 60%;} .photocredit {font: monospace 60%}' + + feeds = [ + (u'Yakima Herald Online', u'http://feeds.feedburner.com/yhronlinenews'), + ] From 64f1e9c2c3f072dcc15088b43591d7b806738765 Mon Sep 17 00:00:00 2001 From: GRiker Date: Thu, 13 Jan 2011 17:02:36 -0700 Subject: [PATCH 28/30] GwR fix graceful exit for author_sort metadata mismatch --- src/calibre/gui2/actions/catalog.py | 2 +- src/calibre/library/catalog.py | 25 +++++++++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/calibre/gui2/actions/catalog.py b/src/calibre/gui2/actions/catalog.py index 0eba0406a1..d75b0dfa5a 100644 --- a/src/calibre/gui2/actions/catalog.py +++ b/src/calibre/gui2/actions/catalog.py @@ -57,7 +57,7 @@ class GenerateCatalogAction(InterfaceAction): if job.result: # Search terms nulled catalog results return error_dialog(self.gui, _('No books found'), - _("No books to catalog\nCheck exclusion criteria"), + _("No books to catalog\nCheck job details"), show=True) if job.failed: return self.gui.job_exception(job) diff --git a/src/calibre/library/catalog.py b/src/calibre/library/catalog.py index a9a9caa8db..62d172d6e2 100644 --- a/src/calibre/library/catalog.py +++ b/src/calibre/library/catalog.py @@ -1338,7 +1338,8 @@ class EPUB_MOBI(CatalogPlugin): if self.booksByTitle is None: if not self.fetchBooksByTitle(): return False - self.fetchBooksByAuthor() + if not self.fetchBooksByAuthor(): + return False self.fetchBookmarks() if self.opts.generate_descriptions: self.generateHTMLDescriptions() @@ -1556,7 +1557,10 @@ class EPUB_MOBI(CatalogPlugin): return False def fetchBooksByAuthor(self): - # Generate a list of titles sorted by author from the database + ''' + Generate a list of titles sorted by author from the database + return = Success + ''' self.updateProgressFullStep("Sorting database") @@ -1596,10 +1600,16 @@ class EPUB_MOBI(CatalogPlugin): multiple_authors = True if author != current_author and i: - # Warn if friendly matches previous, but sort doesn't + # Warn, exit if friendly matches previous, but sort doesn't if author[0] == current_author[0]: - self.opts.log.warn("Warning: multiple entries for Author '%s' with differing Author Sort metadata:" % author[0]) - self.opts.log.warn(" '%s' != '%s'" % (author[1], current_author[1])) + error_msg = _("\nWarning: inconsistent Author Sort values for Author '%s', ") % author[0] + error_msg += _("unable to continue building catalog.\n") + error_msg += _("Select all books by '%s', apply same Author Sort value in Edit Metadata dialog, ") % author[0] + error_msg += _("then rebuild the catalog.\n") + error_msg += _("Terminating catalog generation.\n") + + self.opts.log.warn(error_msg) + return False # New author, save the previous author/sort/count unique_authors.append((current_author[0], icu_title(current_author[1]), @@ -1625,6 +1635,7 @@ class EPUB_MOBI(CatalogPlugin): author[2])).encode('utf-8')) self.authors = unique_authors + return True def fetchBookmarks(self): ''' @@ -1739,8 +1750,6 @@ class EPUB_MOBI(CatalogPlugin): # Generate the header from user-customizable template soup = self.generateHTMLDescriptionHeader(title) - - # Write the book entry to contentdir outfile = open("%s/book_%d.html" % (self.contentDir, int(title['id'])), 'w') outfile.write(soup.prettify()) @@ -4350,7 +4359,7 @@ class EPUB_MOBI(CatalogPlugin): _soup = BeautifulSoup('') genresTag = Tag(_soup,'p') gtc = 0 - for (i, tag) in enumerate(book.get('tags', [])): + for (i, tag) in enumerate(sorted(book.get('tags', []))): aTag = Tag(_soup,'a') if self.opts.generate_genres: aTag['href'] = "Genre_%s.html" % re.sub("\W","",tag.lower()) From 00ee7975ef72aa88edea5399843f7510539b6e08 Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Thu, 13 Jan 2011 20:51:54 -0400 Subject: [PATCH 29/30] Kobo no longer sets a the DateCreated on new book purchases? --- src/calibre/devices/kobo/books.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/devices/kobo/books.py b/src/calibre/devices/kobo/books.py index 24ec272bb1..8d58dde892 100644 --- a/src/calibre/devices/kobo/books.py +++ b/src/calibre/devices/kobo/books.py @@ -27,7 +27,7 @@ class Book(Book_): self.size = size # will be set later if None - if ContentType == '6': + if ContentType == '6' and date is not None: self.datetime = time.strptime(date, "%Y-%m-%dT%H:%M:%S.%f") else: try: From 4e9070019acf13909ad373f19e56548650ef3c15 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 13 Jan 2011 19:01:48 -0700 Subject: [PATCH 30/30] Refactor the downloading social metadata message box to allow canceling. Fixes #8234 (Calibre crashes while editing metadata) --- src/calibre/gui2/dialogs/metadata_single.py | 8 ++++- src/calibre/gui2/preferences/social.py | 37 ++++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py index a4e8bb6972..ede605343b 100644 --- a/src/calibre/gui2/dialogs/metadata_single.py +++ b/src/calibre/gui2/dialogs/metadata_single.py @@ -790,7 +790,13 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): if d.opt_get_social_metadata.isChecked(): d2 = SocialMetadata(book, self) d2.exec_() - if d2.exceptions: + if d2.timed_out: + warning_dialog(self, _('Timed out'), + _('The download of social' + ' metadata timed out, the servers are' + ' probably busy. Try again later.'), + show=True) + elif d2.exceptions: det = '\n'.join([x[0]+'\n\n'+x[-1]+'\n\n\n' for x in d2.exceptions]) warning_dialog(self, _('There were errors'), diff --git a/src/calibre/gui2/preferences/social.py b/src/calibre/gui2/preferences/social.py index ad14ea05b0..5f66f12326 100644 --- a/src/calibre/gui2/preferences/social.py +++ b/src/calibre/gui2/preferences/social.py @@ -6,16 +6,19 @@ __license__ = 'GPL v3' __copyright__ = '2009, Kovid Goyal ' __docformat__ = 'restructuredtext en' +import time +from threading import Thread from PyQt4.Qt import QDialog, QDialogButtonBox, Qt, QLabel, QVBoxLayout, \ - SIGNAL, QThread + QTimer from calibre.ebooks.metadata import MetaInformation -class Worker(QThread): +class Worker(Thread): - def __init__(self, mi, parent): - QThread.__init__(self, parent) + def __init__(self, mi): + Thread.__init__(self) + self.daemon = True self.mi = MetaInformation(mi) self.exceptions = [] @@ -25,10 +28,12 @@ class Worker(QThread): class SocialMetadata(QDialog): + TIMEOUT = 300 # seconds + def __init__(self, mi, parent): QDialog.__init__(self, parent) - self.bbox = QDialogButtonBox(QDialogButtonBox.Ok, Qt.Horizontal, self) + self.bbox = QDialogButtonBox(QDialogButtonBox.Cancel, Qt.Horizontal, self) self.mi = mi self.layout = QVBoxLayout(self) self.label = QLabel(_('Downloading social metadata, please wait...'), self) @@ -36,15 +41,29 @@ class SocialMetadata(QDialog): self.layout.addWidget(self.label) self.layout.addWidget(self.bbox) - self.worker = Worker(mi, self) - self.connect(self.worker, SIGNAL('finished()'), self.accept) - self.connect(self.bbox, SIGNAL('rejected()'), self.reject) + self.worker = Worker(mi) + self.bbox.rejected.connect(self.reject) self.worker.start() + self.start_time = time.time() + self.timed_out = False + self.rejected = False + QTimer.singleShot(50, self.update) def reject(self): - self.disconnect(self.worker, SIGNAL('finished()'), self.accept) + self.rejected = True QDialog.reject(self) + def update(self): + if self.rejected: + return + if time.time() - self.start_time > self.TIMEOUT: + self.timed_out = True + self.reject() + return + if not self.worker.is_alive(): + self.accept() + QTimer.singleShot(50, self.update) + def accept(self): self.mi.tags = self.worker.mi.tags self.mi.rating = self.worker.mi.rating