Sync to trunk.

This commit is contained in:
John Schember 2010-12-22 18:04:34 -05:00
commit c1afa4d36e
39 changed files with 999 additions and 295 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -1,64 +1,102 @@
__license__ = 'GPL v3'
__copyright__ = '2009, Darko Miletic <darko.miletic at gmail.com>'
__copyright__ = '2008 Kovid Goyal kovid@kovidgoyal.net, 2010 Darko Miletic <darko.miletic at gmail.com>'
'''
http://www.businessweek.com/magazine/news/articles/business_news.htm
www.businessweek.com
'''
from calibre import strftime
from calibre.web.feeds.news import BasicNewsRecipe
class BWmagazine(BasicNewsRecipe):
title = 'BusinessWeek Magazine'
__author__ = 'Darko Miletic'
description = 'Stay up to date with BusinessWeek magazine articles. Read news on international business, personal finances & the economy in the BusinessWeek online magazine.'
class BusinessWeek(BasicNewsRecipe):
title = 'Business Week'
__author__ = 'Kovid Goyal and Darko Miletic'
description = 'Read the latest international business news & stock market news. Get updated company profiles, financial advice, global economy and technology news.'
publisher = 'Bloomberg L.P.'
category = 'news, International Business News, current news in international business,international business articles, personal business, business week magazine, business week magazine articles, business week magazine online, business week online magazine'
oldest_article = 10
max_articles_per_feed = 100
category = 'Business, business news, stock market, stock market news, financial advice, company profiles, financial advice, global economy, technology news'
oldest_article = 7
max_articles_per_feed = 200
no_stylesheets = True
encoding = 'utf-8'
encoding = 'utf8'
use_embedded_content = False
language = 'en'
INDEX = 'http://www.businessweek.com/magazine/news/articles/business_news.htm'
remove_empty_feeds = True
publication_type = 'magazine'
cover_url = 'http://images.businessweek.com/mz/covers/current_120x160.jpg'
masthead_url = 'http://assets.businessweek.com/images/bw-logo.png'
extra_css = """
body{font-family: Helvetica,Arial,sans-serif }
img{margin-bottom: 0.4em; display:block}
.tagline{color: gray; font-style: italic}
.photoCredit{font-size: small; color: gray}
"""
conversion_options = {
'comment' : description
, 'tags' : category
, 'publisher' : publisher
, 'language' : language
'comment' : description
, 'tags' : category
, 'publisher' : publisher
, 'language' : language
}
remove_tags = [
dict(attrs={'class':'inStory'})
,dict(name=['meta','link','iframe','base','embed','object','table','th','tr','td'])
,dict(attrs={'id':['inset','videoDisplay']})
]
keep_only_tags = [dict(name='div', attrs={'id':['story-body','storyBody']})]
remove_attributes = ['lang']
match_regexps = [r'http://www.businessweek.com/.*_page_[1-9].*']
def parse_index(self):
articles = []
soup = self.index_to_soup(self.INDEX)
ditem = soup.find('div',attrs={'id':'column2'})
if ditem:
for item in ditem.findAll('h3'):
title_prefix = ''
description = ''
feed_link = item.find('a')
if feed_link and feed_link.has_key('href'):
url = 'http://www.businessweek.com/magazine/' + feed_link['href'].partition('../../')[2]
title = title_prefix + self.tag_to_string(feed_link)
date = strftime(self.timefmt)
articles.append({
'title' :title
,'date' :date
,'url' :url
,'description':description
})
return [(soup.head.title.string, articles)]
keep_only_tags = dict(name='div', attrs={'id':'storyBody'})
feeds = [
(u'Top Stories', u'http://www.businessweek.com/topStories/rss/topStories.rss'),
(u'Top News' , u'http://www.businessweek.com/rss/bwdaily.rss' ),
(u'Asia', u'http://www.businessweek.com/rss/asia.rss'),
(u'Autos', u'http://www.businessweek.com/rss/autos/index.rss'),
(u'Classic Cars', u'http://rss.businessweek.com/bw_rss/classiccars'),
(u'Hybrids', u'http://rss.businessweek.com/bw_rss/hybrids'),
(u'Europe', u'http://www.businessweek.com/rss/europe.rss'),
(u'Auto Reviews', u'http://rss.businessweek.com/bw_rss/autoreviews'),
(u'Innovation & Design', u'http://www.businessweek.com/rss/innovate.rss'),
(u'Architecture', u'http://www.businessweek.com/rss/architecture.rss'),
(u'Brand Equity', u'http://www.businessweek.com/rss/brandequity.rss'),
(u'Auto Design', u'http://www.businessweek.com/rss/carbuff.rss'),
(u'Game Room', u'http://rss.businessweek.com/bw_rss/gameroom'),
(u'Technology', u'http://www.businessweek.com/rss/technology.rss'),
(u'Investing', u'http://rss.businessweek.com/bw_rss/investor'),
(u'Small Business', u'http://www.businessweek.com/rss/smallbiz.rss'),
(u'Careers', u'http://rss.businessweek.com/bw_rss/careers'),
(u'B-Schools', u'http://www.businessweek.com/rss/bschools.rss'),
(u'Magazine Selections', u'http://www.businessweek.com/rss/magazine.rss'),
(u'CEO Guide to Tech', u'http://www.businessweek.com/rss/ceo_guide_tech.rss'),
]
def get_article_url(self, article):
url = article.get('guid', None)
if 'podcasts' in url:
return None
if 'surveys' in url:
return None
if 'images' in url:
return None
if 'feedroom' in url:
return None
if '/magazine/toc/' in url:
return None
rurl, sep, rest = url.rpartition('?')
if rurl:
return rurl
return rest
def print_version(self, url):
rurl = url.rpartition('?')[0]
if rurl == '':
rurl = url
return rurl.replace('.com/magazine/','.com/print/magazine/')
if '/news/' in url or '/blog/ in url':
return url
rurl = url.replace('http://www.businessweek.com/','http://www.businessweek.com/print/')
return rurl.replace('/investing/','/investor/')
def preprocess_html(self, soup):
for item in soup.findAll(style=True):
del item['style']
for alink in soup.findAll('a'):
if alink.string is not None:
tstr = alink.string
alink.replaceWith(tstr)
return soup

View File

@ -0,0 +1,67 @@
#!/usr/bin/env python
__license__ = 'GPL v3'
__copyright__ = '2010, Derek Liang <Derek.liang.ca @@@at@@@ gmail.com>'
'''
cnd.org
'''
import re
from calibre.web.feeds.news import BasicNewsRecipe
class TheCND(BasicNewsRecipe):
title = 'CND'
__author__ = 'Derek Liang'
description = ''
INDEX = 'http://cnd.org'
language = 'zh'
conversion_options = {'linearize_tables':True}
remove_tags_before = dict(name='div', id='articleHead')
remove_tags_after = dict(id='copyright')
remove_tags = [dict(name='table', attrs={'align':'right'}), dict(name='img', attrs={'src':'http://my.cnd.org/images/logo.gif'}), dict(name='hr', attrs={}), dict(name='small', attrs={})]
no_stylesheets = True
preprocess_regexps = [(re.compile(r'<!--.*?-->', re.DOTALL), lambda m: '')]
def print_version(self, url):
if url.find('news/article.php') >= 0:
return re.sub("^[^=]*", "http://my.cnd.org/modules/news/print.php?storyid", url)
else:
return re.sub("^[^=]*", "http://my.cnd.org/modules/wfsection/print.php?articleid", url)
def parse_index(self):
soup = self.index_to_soup(self.INDEX)
feeds = []
articles = {}
for a in soup.findAll('a', attrs={'target':'_cnd'}):
url = a['href']
if url.find('article.php') < 0 :
continue
if url.startswith('/'):
url = 'http://cnd.org'+url
title = self.tag_to_string(a)
self.log('\tFound article: ', title, 'at', url)
date = a.nextSibling
if (date is not None) and len(date)>2:
if not articles.has_key(date):
articles[date] = []
articles[date].append({'title':title, 'url':url, 'description': '', 'date':''})
self.log('\t\tAppend to : ', date)
self.log('log articles', articles)
mostCurrent = sorted(articles).pop()
self.title = 'CND ' + mostCurrent
feeds.append((self.title, articles[mostCurrent]))
return feeds
def populate_article_metadata(self, article, soup, first):
header = soup.find('h3')
self.log('header: ' + self.tag_to_string(header))
pass

View File

@ -4,7 +4,7 @@ from calibre.web.feeds.recipes import BasicNewsRecipe
class LeMonde(BasicNewsRecipe):
title = 'Le Monde'
__author__ = 'veezh'
description = 'Actualités'
description = u'Actualit\xe9s'
oldest_article = 1
max_articles_per_feed = 100
no_stylesheets = True

View File

@ -0,0 +1,58 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#Based on Lars Jacob's Taz Digiabo recipe
__license__ = 'GPL v3'
__copyright__ = '2010, veezh'
'''
www.nrc.nl
'''
import os, urllib2, zipfile
import time
from calibre.web.feeds.news import BasicNewsRecipe
from calibre.ptempfile import PersistentTemporaryFile
class NRCHandelsblad(BasicNewsRecipe):
title = u'NRC Handelsblad'
description = u'De EPUB-versie van NRC'
language = 'nl'
lang = 'nl-NL'
__author__ = 'veezh'
conversion_options = {
'no_default_epub_cover' : True
}
def build_index(self):
today = time.strftime("%Y%m%d")
domain = "http://digitaleeditie.nrc.nl"
url = domain + "/digitaleeditie/helekrant/epub/nrc_" + today + ".epub"
# print url
try:
f = urllib2.urlopen(url)
except urllib2.HTTPError:
self.report_progress(0,_('Kan niet inloggen om editie te downloaden'))
raise ValueError('Krant van vandaag nog niet beschikbaar')
tmp = PersistentTemporaryFile(suffix='.epub')
self.report_progress(0,_('downloading epub'))
tmp.write(f.read())
tmp.close()
zfile = zipfile.ZipFile(tmp.name, 'r')
self.report_progress(0,_('extracting epub'))
zfile.extractall(self.output_dir)
tmp.close()
index = os.path.join(self.output_dir, 'content.opf')
self.report_progress(1,_('epub downloaded and extracted'))
return index

View File

@ -0,0 +1,62 @@
#!/usr/bin/env python
__license__ = 'GPL v3'
__copyright__ = '2010, Derek Liang <Derek.liang.ca @@@at@@@ gmail.com>'
'''
wenxuecity.com
'''
import re
from calibre.web.feeds.news import BasicNewsRecipe
class TheCND(BasicNewsRecipe):
title = 'wenxuecity - znjy'
__author__ = 'Derek Liang'
description = ''
INDEX = 'http://bbs.wenxuecity.com/znjy/?elite=1'
language = 'zh'
conversion_options = {'linearize_tables':True}
remove_tags_before = dict(name='div', id='message')
remove_tags_after = dict(name='div', id='message')
remove_tags = [dict(name='div', id='postmeta'), dict(name='div', id='footer')]
no_stylesheets = True
preprocess_regexps = [(re.compile(r'<!--.*?-->', re.DOTALL), lambda m: '')]
def print_version(self, url):
return url + '?print'
def parse_index(self):
soup = self.index_to_soup(self.INDEX)
feeds = []
articles = {}
for a in soup.findAll('a', attrs={'class':'post'}):
url = a['href']
if url.startswith('/'):
url = 'http://bbs.wenxuecity.com'+url
title = self.tag_to_string(a)
self.log('\tFound article: ', title, ' at:', url)
dateReg = re.search( '(\d\d?)/(\d\d?)/(\d\d)', self.tag_to_string(a.parent) )
date = '%(y)s/%(m)02d/%(d)02d' % {'y' : dateReg.group(3), 'm' : int(dateReg.group(1)), 'd' : int(dateReg.group(2)) }
if not articles.has_key(date):
articles[date] = []
articles[date].append({'title':title, 'url':url, 'description': '', 'date':''})
self.log('\t\tAppend to : ', date)
self.log('log articles', articles)
mostCurrent = sorted(articles).pop()
self.title = '文学城 - 子女教育 - ' + mostCurrent
feeds.append((self.title, articles[mostCurrent]))
return feeds
def populate_article_metadata(self, article, soup, first):
header = soup.find('h3')
self.log('header: ' + self.tag_to_string(header))
pass

View File

@ -46,7 +46,7 @@ class WallStreetJournal(BasicNewsRecipe):
br = BasicNewsRecipe.get_browser()
if self.username is not None and self.password is not None:
br.open('http://commerce.wsj.com/auth/login')
br.select_form(nr=0)
br.select_form(nr=1)
br['user'] = self.username
br['password'] = self.password
res = br.submit()

View File

@ -474,10 +474,10 @@ from calibre.devices.binatone.driver import README
from calibre.devices.hanvon.driver import N516, EB511, ALEX, AZBOOKA, THEBOOK
from calibre.devices.edge.driver import EDGE
from calibre.devices.teclast.driver import TECLAST_K3, NEWSMY, IPAPYRUS, \
SOVOS, PICO
SOVOS, PICO, SUNSTECH_EB700
from calibre.devices.sne.driver import SNE
from calibre.devices.misc import PALMPRE, AVANT, SWEEX, PDNOVEL, KOGAN, \
GEMEI, VELOCITYMICRO, PDNOVEL_KOBO, Q600, LUMIREAD
GEMEI, VELOCITYMICRO, PDNOVEL_KOBO, Q600, LUMIREAD, ALURATEK_COLOR
from calibre.devices.folder_device.driver import FOLDER_DEVICE_FOR_CONFIG
from calibre.devices.kobo.driver import KOBO
@ -579,7 +579,7 @@ plugins += [
ELONEX,
TECLAST_K3,
NEWSMY,
PICO,
PICO, SUNSTECH_EB700,
IPAPYRUS,
SOVOS,
EDGE,
@ -600,6 +600,7 @@ plugins += [
VELOCITYMICRO,
PDNOVEL_KOBO,
LUMIREAD,
ALURATEK_COLOR,
ITUNES,
]
plugins += [x for x in list(locals().values()) if isinstance(x, type) and \

View File

@ -204,3 +204,23 @@ class LUMIREAD(USBMS):
with open(cfilepath+'.jpg', 'wb') as f:
f.write(metadata.thumbnail[-1])
class ALURATEK_COLOR(USBMS):
name = 'Aluratek Color Device Interface'
gui_name = 'Aluratek Color'
description = _('Communicate with the Aluratek Color')
author = 'Kovid Goyal'
supported_platforms = ['windows', 'osx', 'linux']
# Ordered list of supported formats
FORMATS = ['epub', 'fb2', 'txt', 'pdf']
VENDOR_ID = [0x1f3a]
PRODUCT_ID = [0x1000]
BCD = [0x0002]
EBOOK_DIR_MAIN = EBOOK_DIR_CARD_A = 'books'
VENDOR_NAME = 'USB_2.0'
WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = 'USB_FLASH_DRIVER'

View File

@ -58,9 +58,16 @@ class PRS505(USBMS):
SUPPORTS_USE_AUTHOR_SORT = True
EBOOK_DIR_MAIN = 'database/media/books'
ALL_BY_TITLE = _('All by title')
ALL_BY_AUTHOR = _('All by author')
EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of metadata fields '
'to turn into collections on the device. Possibilities include: ')+\
'series, tags, authors'
'series, tags, authors' +\
_('. Two special collections are available: %s:%s and %s:%s. Add '
'these values to the list to enable them. The collections will be '
'given the name provided after the ":" character.')%(
'abt', ALL_BY_TITLE, 'aba', ALL_BY_AUTHOR)
EXTRA_CUSTOMIZATION_DEFAULT = ', '.join(['series', 'tags'])
plugboard = None
@ -151,7 +158,7 @@ class PRS505(USBMS):
blists[i] = booklists[i]
opts = self.settings()
if opts.extra_customization:
collections = [x.lower().strip() for x in
collections = [x.strip() for x in
opts.extra_customization.split(',')]
else:
collections = []
@ -179,6 +186,8 @@ class PRS505(USBMS):
self.plugboard_func = pb_func
def upload_cover(self, path, filename, metadata, filepath):
return # Disabled as the SONY's don't need this thumbnail anyway and
# older models don't auto delete it
if metadata.thumbnail and metadata.thumbnail[-1]:
path = path.replace('/', os.sep)
is_main = path.startswith(self._main_prefix)

View File

@ -410,6 +410,9 @@ class XMLCache(object):
newmi = book.deepcopy_metadata()
newmi.template_to_attribute(book, plugboard)
newmi.set('_new_book', getattr(book, '_new_book', False))
book.set('_pb_title_sort',
newmi.get('title_sort', newmi.get('title', None)))
book.set('_pb_author_sort', newmi.get('author_sort', ''))
else:
newmi = book
(gtz_count, ltz_count, use_tz_var) = \

View File

@ -72,3 +72,13 @@ class SOVOS(TECLAST_K3):
VENDOR_NAME = 'RK28XX'
WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = 'USB-MSC'
class SUNSTECH_EB700(TECLAST_K3):
name = 'Sunstech EB700 device interface'
gui_name = 'EB700'
description = _('Communicate with the Sunstech EB700 reader.')
FORMATS = ['epub', 'fb2', 'pdf', 'pdb', 'txt']
VENDOR_NAME = 'SUNEB700'
WINDOWS_MAIN_MEM = 'USB-MSC'

View File

@ -132,9 +132,24 @@ class CollectionsBookList(BookList):
use_renaming_rules = prefs['manage_device_metadata'] == 'on_connect'
collections = {}
# This map of sets is used to avoid linear searches when testing for
# book equality
# get the special collection names
all_by_author = ''
all_by_title = ''
ca = []
for c in collection_attributes:
if c.startswith('aba:') and c[4:]:
all_by_author = c[4:]
elif c.startswith('abt:') and c[4:]:
all_by_title = c[4:]
else:
ca.append(c.lower())
collection_attributes = ca
for book in self:
tsval = book.get('_pb_title_sort',
book.get('title_sort', book.get('title', 'zzzz')))
asval = book.get('_pb_author_sort', book.get('author_sort', ''))
# Make sure we can identify this book via the lpath
lpath = getattr(book, 'lpath', None)
if lpath is None:
@ -211,22 +226,29 @@ class CollectionsBookList(BookList):
collections[cat_name] = {}
if use_renaming_rules and sort_attr:
sort_val = book.get(sort_attr, None)
collections[cat_name][lpath] = \
(book, sort_val, book.get('title_sort', 'zzzz'))
collections[cat_name][lpath] = (book, sort_val, tsval)
elif is_series:
if doing_dc:
collections[cat_name][lpath] = \
(book, book.get('series_index', sys.maxint),
book.get('title_sort', 'zzzz'))
(book, book.get('series_index', sys.maxint), tsval)
else:
collections[cat_name][lpath] = \
(book, book.get(attr+'_index', sys.maxint),
book.get('title_sort', 'zzzz'))
(book, book.get(attr+'_index', sys.maxint), tsval)
else:
if lpath not in collections[cat_name]:
collections[cat_name][lpath] = \
(book, book.get('title_sort', 'zzzz'),
book.get('title_sort', 'zzzz'))
collections[cat_name][lpath] = (book, tsval, tsval)
# All books by author
if all_by_author:
if all_by_author not in collections:
collections[all_by_author] = {}
collections[all_by_author][lpath] = (book, asval, tsval)
# All books by title
if all_by_title:
if all_by_title not in collections:
collections[all_by_title] = {}
collections[all_by_title][lpath] = (book, tsval, asval)
# Sort collections
result = {}

View File

@ -55,9 +55,11 @@ except:
_ignore_starts = u'\'"'+u''.join(unichr(x) for x in range(0x2018, 0x201e)+[0x2032, 0x2033])
def title_sort(title):
def title_sort(title, order=None):
if order is None:
order = tweaks['title_series_sorting']
title = title.strip()
if tweaks['title_series_sorting'] == 'strictly_alphabetic':
if order == 'strictly_alphabetic':
return title
if title and title[0] in _ignore_starts:
title = title[1:]

View File

@ -11,12 +11,11 @@ import os, re, uuid, logging
from mimetypes import types_map
from collections import defaultdict
from itertools import count
from urlparse import urldefrag, urlparse, urlunparse
from urlparse import urldefrag, urlparse, urlunparse, urljoin
from urllib import unquote as urlunquote
from urlparse import urljoin
from lxml import etree, html
from cssutils import CSSParser
from cssutils import CSSParser, parseString, parseStyle, replaceUrls
from cssutils.css import CSSRule
import calibre
@ -88,11 +87,11 @@ def XLINK(name):
def CALIBRE(name):
return '{%s}%s' % (CALIBRE_NS, name)
_css_url_re = re.compile(r'url\((.*?)\)', re.I)
_css_url_re = re.compile(r'url\s*\((.*?)\)', re.I)
_css_import_re = re.compile(r'@import "(.*?)"')
_archive_re = re.compile(r'[^ ]+')
def iterlinks(root):
def iterlinks(root, find_links_in_css=True):
'''
Iterate over all links in a OEB Document.
@ -134,6 +133,8 @@ def iterlinks(root):
yield (el, attr, attribs[attr], 0)
if not find_links_in_css:
continue
if tag == XHTML('style') and el.text:
for match in _css_url_re.finditer(el.text):
yield (el, None, match.group(1), match.start(1))
@ -180,7 +181,7 @@ def rewrite_links(root, link_repl_func, resolve_base_href=False):
'''
if resolve_base_href:
resolve_base_href(root)
for el, attrib, link, pos in iterlinks(root):
for el, attrib, link, pos in iterlinks(root, find_links_in_css=False):
new_link = link_repl_func(link.strip())
if new_link == link:
continue
@ -203,6 +204,40 @@ def rewrite_links(root, link_repl_func, resolve_base_href=False):
new = cur[:pos] + new_link + cur[pos+len(link):]
el.attrib[attrib] = new
def set_property(v):
if v.CSS_PRIMITIVE_VALUE == v.cssValueType and \
v.CSS_URI == v.primitiveType:
v.setStringValue(v.CSS_URI,
link_repl_func(v.getStringValue()))
for el in root.iter():
try:
tag = el.tag
except UnicodeDecodeError:
continue
if tag == XHTML('style') and el.text and \
(_css_url_re.search(el.text) is not None or '@import' in
el.text):
stylesheet = parseString(el.text)
replaceUrls(stylesheet, link_repl_func)
el.text = '\n'+stylesheet.cssText + '\n'
if 'style' in el.attrib:
text = el.attrib['style']
if _css_url_re.search(text) is not None:
stext = parseStyle(text)
for p in stext.getProperties(all=True):
v = p.cssValue
if v.CSS_VALUE_LIST == v.cssValueType:
for item in v:
set_property(item)
elif v.CSS_PRIMITIVE_VALUE == v.cssValueType:
set_property(v)
el.attrib['style'] = stext.cssText.replace('\n', ' ').replace('\r',
' ')
EPUB_MIME = types_map['.epub']
XHTML_MIME = types_map['.xhtml']
@ -622,7 +657,10 @@ class Metadata(object):
attrib[key] = prefixname(value, nsrmap)
if namespace(self.term) == DC11_NS:
elem = element(parent, self.term, attrib=attrib)
elem.text = self.value
try:
elem.text = self.value
except:
elem.text = repr(self.value)
else:
elem = element(parent, OPF('meta'), attrib=attrib)
elem.attrib['name'] = prefixname(self.term, nsrmap)

View File

@ -6,7 +6,7 @@ __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import posixpath
from urlparse import urldefrag
from urlparse import urldefrag, urlparse
from lxml import etree
import cssutils
@ -67,6 +67,10 @@ class RenameFiles(object): # {{{
def url_replacer(self, orig_url):
url = urlnormalize(orig_url)
parts = urlparse(url)
if parts.scheme:
# Only rewrite local URLs
return orig_url
path, frag = urldefrag(url)
if self.renamed_items_map:
orig_item = self.renamed_items_map.get(self.current_item.href, self.current_item)

View File

@ -138,6 +138,10 @@ class CheckIntegrity(QProgressDialog):
'You should check them manually. This can '
'happen if you manipulate the files in the '
'library folder directly.'), det_msg=det_msg, show=True)
else:
info_dialog(self, _('No errors found'),
_('The integrity check completed with no uncorrectable errors found.'),
show=True)
self.reset()
# }}}
@ -162,6 +166,7 @@ class ChooseLibraryAction(InterfaceAction):
self.choose_menu = QMenu(self.gui)
self.qaction.setMenu(self.choose_menu)
if not os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
self.choose_menu.addAction(self.action_choose)
@ -172,6 +177,11 @@ class ChooseLibraryAction(InterfaceAction):
self.delete_menu = QMenu(_('Delete library'))
self.delete_menu_action = self.choose_menu.addMenu(self.delete_menu)
ac = self.create_action(spec=(_('Pick a random book'), 'catalog.png',
None, None), attr='action_pick_random')
ac.triggered.connect(self.pick_random)
self.choose_menu.addAction(ac)
self.rename_separator = self.choose_menu.addSeparator()
self.switch_actions = []
@ -209,6 +219,12 @@ class ChooseLibraryAction(InterfaceAction):
self.maintenance_menu.addAction(ac)
self.choose_menu.addMenu(self.maintenance_menu)
def pick_random(self, *args):
import random
pick = random.randint(0, self.gui.library_view.model().rowCount(None))
self.gui.library_view.set_current_row(pick)
self.gui.library_view.scroll_to_row(pick)
def library_name(self):
db = self.gui.library_view.model().db
path = db.library_path

View File

@ -5,18 +5,19 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import re, os
from lxml import html
from lxml.html import soupparser
from PyQt4.Qt import QApplication, QFontInfo, QSize, QWidget, QPlainTextEdit, \
QToolBar, QVBoxLayout, QAction, QIcon, QWebPage, Qt, QTabWidget, \
QSyntaxHighlighter, QColor, QChar
from PyQt4.QtWebKit import QWebView
QToolBar, QVBoxLayout, QAction, QIcon, Qt, QTabWidget, QUrl, \
QSyntaxHighlighter, QColor, QChar, QColorDialog, QMenu, QInputDialog
from PyQt4.QtWebKit import QWebView, QWebPage
from calibre.ebooks.chardet import xml_to_unicode
from calibre import xml_replace_entities
from calibre.gui2 import open_url
class PageAction(QAction): # {{{
@ -44,6 +45,18 @@ class PageAction(QAction): # {{{
# }}}
class BlockStyleAction(QAction): # {{{
def __init__(self, text, name, view):
QAction.__init__(self, text, view)
self._name = name
self.triggered.connect(self.apply_style)
def apply_style(self, *args):
self.parent().exec_command('formatBlock', self._name)
# }}}
class EditorWidget(QWebView): # {{{
def __init__(self, parent=None):
@ -90,14 +103,109 @@ class EditorWidget(QWebView): # {{{
ac = PageAction(wac, icon, text, checkable, self)
setattr(self, 'action_'+name, ac)
self.action_color = QAction(QIcon(I('format-text-color')), _('Foreground color'),
self)
self.action_color.triggered.connect(self.foreground_color)
self.action_background = QAction(QIcon(I('format-fill-color')),
_('Background color'), self)
self.action_background.triggered.connect(self.background_color)
self.action_block_style = QAction(QIcon(I('format-text-heading')),
_('Style text block'), self)
self.action_block_style.setToolTip(
_('Style the selected text block'))
self.block_style_menu = QMenu(self)
self.action_block_style.setMenu(self.block_style_menu)
self.block_style_actions = []
for text, name in [
(_('Normal'), 'p'),
(_('Heading') +' 1', 'h1'),
(_('Heading') +' 2', 'h2'),
(_('Heading') +' 3', 'h3'),
(_('Heading') +' 4', 'h4'),
(_('Heading') +' 5', 'h5'),
(_('Heading') +' 6', 'h6'),
(_('Pre-formatted'), 'pre'),
(_('Blockquote'), 'blockquote'),
(_('Address'), 'address'),
]:
ac = BlockStyleAction(text, name, self)
self.block_style_menu.addAction(ac)
self.block_style_actions.append(ac)
self.action_insert_link = QAction(QIcon(I('insert-link.png')),
_('Insert link'), self)
self.action_insert_link.triggered.connect(self.insert_link)
self.page().setLinkDelegationPolicy(QWebPage.DelegateAllLinks)
self.page().linkClicked.connect(self.link_clicked)
def link_clicked(self, url):
open_url(url)
def foreground_color(self):
col = QColorDialog.getColor(Qt.black, self,
_('Choose foreground color'), QColorDialog.ShowAlphaChannel)
if col.isValid():
self.exec_command('foreColor', unicode(col.name()))
def background_color(self):
col = QColorDialog.getColor(Qt.white, self,
_('Choose background color'), QColorDialog.ShowAlphaChannel)
if col.isValid():
self.exec_command('hiliteColor', unicode(col.name()))
def insert_link(self, *args):
link, ok = QInputDialog.getText(self, _('Create link'),
_('Enter URL'))
if not ok:
return
url = self.parse_link(unicode(link))
if url.isValid():
url = unicode(url.toString())
self.exec_command('createLink', url)
def parse_link(self, link):
link = link.strip()
has_schema = re.match(r'^[a-zA-Z]+:', link)
if has_schema is not None:
url = QUrl(link, QUrl.TolerantMode)
if url.isValid():
return url
if os.path.exists(link):
return QUrl.fromLocalFile(link)
if has_schema is None:
first, _, rest = link.partition('.')
prefix = 'http'
if first == 'ftp':
prefix = 'ftp'
url = QUrl(prefix +'://'+link, QUrl.TolerantMode)
if url.isValid():
return url
return QUrl(link, QUrl.TolerantMode)
def sizeHint(self):
return QSize(150, 150)
def exec_command(self, cmd, arg=None):
frame = self.page().mainFrame()
if arg is not None:
js = 'document.execCommand("%s", false, "%s");' % (cmd, arg)
else:
js = 'document.execCommand("%s", false, null);' % cmd
frame.evaluateJavaScript(js)
@dynamic_property
def html(self):
def fget(self):
ans = u''
check = unicode(self.page().mainFrame().toPlainText()).strip()
if not check:
return ans
try:
raw = unicode(self.page().mainFrame().toHtml())
raw = xml_to_unicode(raw, strip_encoding_pats=True,
@ -348,7 +456,7 @@ class Highlighter(QSyntaxHighlighter):
# }}}
class Editor(QWidget):
class Editor(QWidget): # {{{
def __init__(self, parent=None):
QWidget.__init__(self, parent)
@ -404,6 +512,15 @@ class Editor(QWidget):
self.toolbar1.addAction(ac)
self.toolbar1.addSeparator()
self.toolbar1.addAction(self.editor.action_color)
self.toolbar1.addAction(self.editor.action_background)
self.toolbar1.addSeparator()
self.toolbar1.addAction(self.editor.action_block_style)
w = self.toolbar1.widgetForAction(self.editor.action_block_style)
w.setPopupMode(w.InstantPopup)
self.toolbar1.addAction(self.editor.action_insert_link)
self.code_edit.textChanged.connect(self.code_dirtied)
self.editor.page().contentsChanged.connect(self.wyswyg_dirtied)
@ -411,18 +528,21 @@ class Editor(QWidget):
def html(self):
def fset(self, v):
self.editor.html = v
return property(fget=lambda self:self.editor.html, fset=fset)
def fget(self):
self.tabs.setCurrentIndex(0)
return self.editor.html
return property(fget=fget, fset=fset)
def change_tab(self, index):
#print 'reloading:', (index and self.wyswyg_dirty) or (not index and
# self.source_dirty)
if index == 1: # changing to code view
if self.wyswyg_dirty:
self.code_edit.setPlainText(self.html)
self.code_edit.setPlainText(self.editor.html)
self.wyswyg_dirty = False
elif index == 0: #changing to wyswyg
if self.source_dirty:
self.html = unicode(self.code_edit.toPlainText())
self.editor.html = unicode(self.code_edit.toPlainText())
self.source_dirty = False
def wyswyg_dirtied(self, *args):
@ -431,6 +551,8 @@ class Editor(QWidget):
def code_dirtied(self, *args):
self.source_dirty = True
# }}}
if __name__ == '__main__':
app = QApplication([])
w = Editor()

View File

@ -587,8 +587,6 @@ class BulkSeries(BulkBase):
else:
s_index = self.db.get_custom_extra(book_id, num=self.col_id,
index_is_id=True)
if s_index is None:
s_index = 1.0
extras.append(s_index)
self.db.set_custom_bulk(book_ids, val, extras=extras,
num=self.col_id, notify=notify)

View File

@ -16,6 +16,7 @@ from calibre.gui2 import error_dialog, NONE, info_dialog, config
from calibre.gui2.widgets import ProgressIndicator
from calibre import strftime, force_unicode
from calibre.customize.ui import get_isbndb_key, set_isbndb_key
from calibre.utils.icu import sort_key
_hung_fetchers = set([])
@ -72,27 +73,33 @@ class Matches(QAbstractTableModel):
def summary(self, row):
return self.matches[row].comments
def data_as_text(self, book, col):
if col == 0 and book.title is not None:
return book.title
elif col == 1:
return ', '.join(book.authors)
elif col == 2 and book.author_sort is not None:
return book.author_sort
elif col == 3 and book.publisher is not None:
return book.publisher
elif col == 4 and book.isbn is not None:
return book.isbn
elif col == 5 and hasattr(book.pubdate, 'timetuple'):
return strftime('%b %Y', book.pubdate.timetuple())
elif col == 6 and book.has_cover:
return 'y'
elif col == 7 and book.comments:
return 'y'
return ''
def data(self, index, role):
row, col = index.row(), index.column()
book = self.matches[row]
if role == Qt.DisplayRole:
res = None
if col == 0:
res = book.title
elif col == 1:
res = ', '.join(book.authors)
elif col == 2:
res = book.author_sort
elif col == 3:
res = book.publisher
elif col == 4:
res = book.isbn
elif col == 5:
if hasattr(book.pubdate, 'timetuple'):
res = strftime('%b %Y', book.pubdate.timetuple())
if not res:
return NONE
return QVariant(res)
res = self.data_as_text(book, col)
if col <= 5 and res:
return QVariant(res)
return NONE
elif role == Qt.DecorationRole:
if col == 6 and book.has_cover:
return self.yes_icon
@ -100,6 +107,16 @@ class Matches(QAbstractTableModel):
return self.yes_icon
return NONE
def sort(self, col, order, reset=True):
if not self.matches:
return
descending = order == Qt.DescendingOrder
self.matches.sort(None,
lambda x: sort_key(unicode(force_unicode(self.data_as_text(x, col)))),
descending)
if reset:
self.reset()
class FetchMetadata(QDialog, Ui_FetchMetadata):
HANG_TIME = 75 #seconds
@ -136,6 +153,11 @@ class FetchMetadata(QDialog, Ui_FetchMetadata):
self.connect(self.matches, SIGNAL('entered(QModelIndex)'),
self.show_summary)
self.matches.setMouseTracking(True)
# Enabling sorting and setting a sort column will not change the initial
# order of the results, as they are filled in later
self.matches.setSortingEnabled(True)
self.matches.horizontalHeader().sectionClicked.connect(self.show_sort_indicator)
self.matches.horizontalHeader().setSortIndicatorShown(False)
self.fetch_metadata()
self.opt_get_social_metadata.setChecked(config['get_social_metadata'])
self.opt_overwrite_author_title_metadata.setChecked(config['overwrite_author_title_metadata'])
@ -243,3 +265,7 @@ class FetchMetadata(QDialog, Ui_FetchMetadata):
def chosen(self, index):
self.matches.setCurrentIndex(index)
self.accept()
def show_sort_indicator(self, *args):
self.matches.horizontalHeader().setSortIndicatorShown(True)

View File

@ -12,6 +12,7 @@ from PyQt4 import QtGui
from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog
from calibre.gui2.dialogs.tag_editor import TagEditor
from calibre.ebooks.metadata import string_to_authors, authors_to_string
from calibre.ebooks.metadata.book.base import composite_formatter
from calibre.ebooks.metadata.meta import get_metadata
from calibre.gui2.custom_column_widgets import populate_metadata_page
from calibre.gui2 import error_dialog
@ -311,6 +312,7 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
def prepare_search_and_replace(self):
self.search_for.initialize('bulk_edit_search_for')
self.replace_with.initialize('bulk_edit_replace_with')
self.s_r_template.initialize('bulk_edit_template')
self.test_text.initialize('bulk_edit_test_test')
self.all_fields = ['']
self.writable_fields = ['']
@ -325,9 +327,10 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
if f in ['sort'] or fm[f]['datatype'] == 'composite':
self.all_fields.append(f)
self.all_fields.sort()
self.all_fields.insert(1, '{template}')
self.writable_fields.sort()
self.search_field.setMaxVisibleItems(20)
self.destination_field.setMaxVisibleItems(20)
self.search_field.setMaxVisibleItems(25)
self.destination_field.setMaxVisibleItems(25)
offset = 10
self.s_r_number_of_books = min(10, len(self.ids))
for i in range(1,self.s_r_number_of_books+1):
@ -403,22 +406,28 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.test_text.editTextChanged[str].connect(self.s_r_paint_results)
self.comma_separated.stateChanged.connect(self.s_r_paint_results)
self.case_sensitive.stateChanged.connect(self.s_r_paint_results)
self.s_r_template.lost_focus.connect(self.s_r_template_changed)
self.central_widget.setCurrentIndex(0)
self.search_for.completer().setCaseSensitivity(Qt.CaseSensitive)
self.replace_with.completer().setCaseSensitivity(Qt.CaseSensitive)
self.s_r_template.completer().setCaseSensitivity(Qt.CaseSensitive)
self.s_r_search_mode_changed(self.search_mode.currentIndex())
def s_r_get_field(self, mi, field):
if field:
if field == '{template}':
v = composite_formatter.safe_format\
(unicode(self.s_r_template.text()), mi, _('S/R TEMPLATE ERROR'), mi)
return [v]
fm = self.db.metadata_for_field(field)
if field == 'sort':
val = mi.get('title_sort', None)
else:
val = mi.get(field, None)
if val is None:
val = []
val = [] if fm['is_multiple'] else ['']
elif not fm['is_multiple']:
val = [val]
elif field == 'authors':
@ -427,7 +436,16 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
val = []
return val
def s_r_template_changed(self):
self.s_r_search_field_changed(self.search_field.currentIndex())
def s_r_search_field_changed(self, idx):
if self.search_mode.currentIndex() != 0 and idx == 1: # Template
self.s_r_template.setVisible(True)
self.template_label.setVisible(True)
else:
self.s_r_template.setVisible(False)
self.template_label.setVisible(False)
for i in range(0, self.s_r_number_of_books):
w = getattr(self, 'book_%d_text'%(i+1))
mi = self.db.get_metadata(self.ids[i], index_is_id=True)
@ -590,11 +608,7 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
if not dest:
dest = source
dfm = self.db.field_metadata[dest]
mi = self.db.get_metadata(id, index_is_id=True,)
val = mi.get(source)
if val is None:
return
val = self.s_r_do_regexp(mi)
val = self.s_r_do_destination(mi, val)
if dfm['is_multiple']:

View File

@ -508,6 +508,29 @@ Future conversion of these books will use the default settings.</string>
</layout>
</item>
<item row="4" column="0">
<widget class="QLabel" name="template_label">
<property name="text">
<string>Te&amp;mplate:</string>
</property>
<property name="buddy">
<cstring>s_r_template</cstring>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="HistoryLineEdit" name="s_r_template">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>Enter a template to be used as the source for the search/replace</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="xlabel_2">
<property name="text">
<string>&amp;Search for:</string>
@ -517,7 +540,7 @@ Future conversion of these books will use the default settings.</string>
</property>
</widget>
</item>
<item row="4" column="1">
<item row="5" column="1">
<widget class="HistoryLineEdit" name="search_for">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
@ -530,7 +553,7 @@ Future conversion of these books will use the default settings.</string>
</property>
</widget>
</item>
<item row="4" column="2">
<item row="5" column="2">
<widget class="QCheckBox" name="case_sensitive">
<property name="toolTip">
<string>Check this box if the search string must match exactly upper and lower case. Uncheck it if case is to be ignored</string>
@ -543,7 +566,7 @@ Future conversion of these books will use the default settings.</string>
</property>
</widget>
</item>
<item row="5" column="0">
<item row="6" column="0">
<widget class="QLabel" name="xlabel_4">
<property name="text">
<string>&amp;Replace with:</string>
@ -553,14 +576,14 @@ Future conversion of these books will use the default settings.</string>
</property>
</widget>
</item>
<item row="5" column="1">
<item row="6" column="1">
<widget class="HistoryLineEdit" name="replace_with">
<property name="toolTip">
<string>The replacement text. The matched search text will be replaced with this string</string>
</property>
</widget>
</item>
<item row="5" column="2">
<item row="6" column="2">
<layout class="QHBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label_41">
@ -595,7 +618,7 @@ field is processed. In regular expression mode, only the matched text is process
</item>
</layout>
</item>
<item row="6" column="0">
<item row="7" column="0">
<widget class="QLabel" name="destination_field_label">
<property name="text">
<string>&amp;Destination field:</string>
@ -605,14 +628,15 @@ field is processed. In regular expression mode, only the matched text is process
</property>
</widget>
</item>
<item row="6" column="1">
<item row="7" column="1">
<widget class="QComboBox" name="destination_field">
<property name="toolTip">
<string>The field that the text will be put into after all replacements. If blank, the source field is used.</string>
<string>The field that the text will be put into after all replacements.
If blank, the source field is used if the field is modifiable</string>
</property>
</widget>
</item>
<item row="6" column="2">
<item row="7" column="2">
<layout class="QHBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="replace_mode_label">
@ -660,7 +684,7 @@ nothing should be put between the original text and the inserted text</string>
</item>
</layout>
</item>
<item row="7" column="1">
<item row="8" column="1">
<widget class="QLabel" name="xlabel_3">
<property name="text">
<string>Test &amp;text</string>
@ -670,7 +694,7 @@ nothing should be put between the original text and the inserted text</string>
</property>
</widget>
</item>
<item row="7" column="2">
<item row="8" column="2">
<widget class="QLabel" name="label_51">
<property name="text">
<string>Test re&amp;sult</string>
@ -791,6 +815,7 @@ nothing should be put between the original text and the inserted text</string>
<tabstop>central_widget</tabstop>
<tabstop>search_field</tabstop>
<tabstop>search_mode</tabstop>
<tabstop>s_r_template</tabstop>
<tabstop>search_for</tabstop>
<tabstop>case_sensitive</tabstop>
<tabstop>replace_with</tabstop>

View File

@ -34,6 +34,7 @@ from calibre.customize.ui import run_plugins_on_import, get_isbndb_key
from calibre.gui2.preferences.social import SocialMetadata
from calibre.gui2.custom_column_widgets import populate_metadata_page
from calibre import strftime
from calibre.library.comments import comments_to_html
class CoverFetcher(Thread): # {{{
@ -195,7 +196,6 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
_file + _(" is not a valid picture"))
d.exec_()
else:
self.cover_path.setText(_file)
self.cover.setPixmap(pix)
self.update_cover_tooltip()
self.cover_changed = True
@ -409,7 +409,8 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
if mi.series_index is not None:
self.series_index.setValue(float(mi.series_index))
if mi.comments and mi.comments.strip():
self.comments.setPlainText(mi.comments)
comments = comments_to_html(mi.comments)
self.comments.html = comments
def sync_formats(self):
@ -556,7 +557,9 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
if rating > 0:
self.rating.setValue(int(rating/2.))
comments = self.db.comments(row)
self.comments.setPlainText(comments if comments else '')
if comments and comments.strip():
comments = comments_to_html(comments)
self.comments.html = comments
cover = self.db.cover(row)
pubdate = db.pubdate(self.id, index_is_id=True)
self.pubdate.setDate(QDate(pubdate.year, pubdate.month,
@ -806,10 +809,10 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.pubdate.setDate(QDate(dt.year, dt.month, dt.day))
summ = book.comments
if summ:
prefix = unicode(self.comments.toPlainText())
prefix = self.comments.html
if prefix:
prefix += '\n'
self.comments.setPlainText(prefix + summ)
self.comments.html = prefix + comments_to_html(summ)
if book.rating is not None:
self.rating.setValue(int(book.rating))
if book.tags:
@ -899,7 +902,7 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.db.set_series_index(self.id, self.series_index.value(),
notify=False, commit=False)
self.db.set_comment(self.id,
unicode(self.comments.toPlainText()).strip(),
self.comments.html,
notify=False, commit=False)
d = self.pubdate.date()
d = qt_to_dt(d)
@ -936,16 +939,16 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
QDialog.reject(self, *args)
def read_state(self):
wg = dynamic.get('metasingle_window_geometry', None)
ss = dynamic.get('metasingle_splitter_state', None)
wg = dynamic.get('metasingle_window_geometry2', None)
ss = dynamic.get('metasingle_splitter_state2', None)
if wg is not None:
self.restoreGeometry(wg)
if ss is not None:
self.splitter.restoreState(ss)
def save_state(self):
dynamic.set('metasingle_window_geometry', bytes(self.saveGeometry()))
dynamic.set('metasingle_splitter_state',
dynamic.set('metasingle_window_geometry2', bytes(self.saveGeometry()))
dynamic.set('metasingle_splitter_state2',
bytes(self.splitter.saveState()))
def break_cycles(self):

View File

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>887</width>
<height>750</height>
<width>994</width>
<height>716</height>
</rect>
</property>
<property name="sizePolicy">
@ -43,8 +43,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>879</width>
<height>711</height>
<width>986</width>
<height>677</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
@ -66,8 +66,8 @@
<attribute name="title">
<string>&amp;Basic metadata</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QGridLayout" name="gridLayout_5">
<item row="0" column="0">
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
@ -496,28 +496,147 @@ Using this button to create author sort will change author sort from red to gree
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>&amp;Comments</string>
<spacer name="verticalSpacer_5">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="0" column="0">
<widget class="QTextEdit" name="comments">
<property name="tabChangesFocus">
<bool>true</bool>
</property>
<property name="acceptRichText">
<bool>false</bool>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QWidget" name="layoutWidget_2">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="bc_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Book Cover</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="ImageView" name="cover" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="_4">
<property name="spacing">
<number>6</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetMaximumSize</enum>
</property>
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>Change &amp;cover image:</string>
</property>
<property name="buddy">
<cstring>cover_button</cstring>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="_5">
<property name="spacing">
<number>6</number>
</property>
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QPushButton" name="cover_button">
<property name="text">
<string>&amp;Browse</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/document_open.png</normaloff>:/images/document_open.png</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="trim_cover_button">
<property name="toolTip">
<string>Remove border (if any) from cover</string>
</property>
<property name="text">
<string>T&amp;rim</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/trim.png</normaloff>:/images/trim.png</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="reset_cover">
<property name="toolTip">
<string>Reset cover to default</string>
</property>
<property name="text">
<string>&amp;Remove</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/trash.png</normaloff>:/images/trash.png</iconset>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="_6">
<item>
<widget class="QPushButton" name="fetch_cover_button">
<property name="text">
<string>Download co&amp;ver</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="generate_cover_button">
<property name="toolTip">
<string>Generate a default cover based on the title and author</string>
</property>
<property name="text">
<string>&amp;Generate cover</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="layoutWidget_2">
<layout class="QVBoxLayout" name="verticalLayout_2">
<widget class="QWidget" name="layoutWidget">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QGroupBox" name="af_group_box">
<property name="sizePolicy">
@ -546,6 +665,12 @@ Using this button to create author sort will change author sort from red to gree
<height>140</height>
</size>
</property>
<property name="baseSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DropOnly</enum>
</property>
@ -644,129 +769,22 @@ Using this button to create author sort will change author sort from red to gree
</widget>
</item>
<item>
<widget class="QGroupBox" name="bc_box">
<widget class="QGroupBox" name="groupBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Book Cover</string>
<string>&amp;Comments</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<layout class="QVBoxLayout" name="verticalLayout_8">
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="ImageView" name="cover" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="_4">
<property name="spacing">
<number>6</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetMaximumSize</enum>
</property>
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>Change &amp;cover image:</string>
</property>
<property name="buddy">
<cstring>cover_path</cstring>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="_5">
<property name="spacing">
<number>6</number>
</property>
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QLineEdit" name="cover_path">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cover_button">
<property name="text">
<string>&amp;Browse</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/document_open.png</normaloff>:/images/document_open.png</iconset>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="trim_cover_button">
<property name="toolTip">
<string>Remove border (if any) from cover</string>
</property>
<property name="text">
<string>T&amp;rim</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/trim.png</normaloff>:/images/trim.png</iconset>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonTextBesideIcon</enum>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="reset_cover">
<property name="toolTip">
<string>Reset cover to default</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/trash.png</normaloff>:/images/trash.png</iconset>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="_6">
<item>
<widget class="QPushButton" name="fetch_cover_button">
<property name="text">
<string>Download co&amp;ver</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="generate_cover_button">
<property name="toolTip">
<string>Generate a default cover based on the title and author</string>
</property>
<property name="text">
<string>&amp;Generate cover</string>
</property>
</widget>
</item>
</layout>
<widget class="Editor" name="comments" native="true"/>
</item>
</layout>
</widget>
@ -828,6 +846,12 @@ Using this button to create author sort will change author sort from red to gree
<header>calibre/gui2/widgets.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>Editor</class>
<extends>QWidget</extends>
<header location="global">calibre/gui2/comments_editor.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>title</tabstop>
@ -848,13 +872,11 @@ Using this button to create author sort will change author sort from red to gree
<tabstop>date</tabstop>
<tabstop>pubdate</tabstop>
<tabstop>fetch_metadata_button</tabstop>
<tabstop>comments</tabstop>
<tabstop>button_set_cover</tabstop>
<tabstop>button_set_metadata</tabstop>
<tabstop>formats</tabstop>
<tabstop>add_format_button</tabstop>
<tabstop>remove_format_button</tabstop>
<tabstop>cover_path</tabstop>
<tabstop>cover_button</tabstop>
<tabstop>trim_cover_button</tabstop>
<tabstop>reset_cover</tabstop>

View File

@ -23,7 +23,6 @@ from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
from calibre.utils.search_query_parser import SearchQueryParser
from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, \
REGEXP_MATCH, MetadataBackup
from calibre.library.cli import parse_series_string
from calibre import strftime, isbytestring, prepare_string_for_xml
from calibre.constants import filesystem_encoding, DEBUG
from calibre.gui2.library import DEFAULT_SORT
@ -725,9 +724,7 @@ class BooksModel(QAbstractTableModel): # {{{
return False
val = qt_to_dt(val, as_utc=False)
elif typ == 'series':
val, s_index = parse_series_string(self.db, label, value.toString())
if not val:
val = s_index = None
val = unicode(value.toString()).strip()
elif typ == 'composite':
tmpl = unicode(value.toString()).strip()
disp = cc['display']

View File

@ -695,8 +695,10 @@ class TagsModel(QAbstractItemModel): # {{{
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid():
return NONE
# set up to position at the category label
path = self.path_for_index(self.parent(index))
# set up to reposition at the same item. We can do this except if
# working with the last item and that item is deleted, in which case
# we position at the parent label
path = index.model().path_for_index(index)
val = unicode(value.toString())
if not val:
error_dialog(self.tags_view, _('Item is blank'),
@ -947,18 +949,22 @@ class TagBrowserMixin(object): # {{{
for old_id in to_rename[text]:
rename_func(old_id, new_name=unicode(text))
# Clean up everything, as information could have changed for many books.
self.library_view.model().refresh()
self.tags_view.set_new_model()
self.tags_view.recount()
self.saved_search.clear()
self.search.clear()
# Clean up the library view
self.do_tag_item_renamed()
self.tags_view.set_new_model() # does a refresh for free
def do_tag_item_renamed(self):
# Clean up library view and search
self.library_view.model().refresh()
self.saved_search.clear()
self.search.clear()
# get information to redo the selection
rows = [r.row() for r in \
self.library_view.selectionModel().selectedRows()]
m = self.library_view.model()
ids = [m.id(r) for r in rows]
m.refresh(reset=False)
m.research()
self.library_view.select_rows(ids)
# refreshing the tags view happens at the emit()/call() site
def do_author_sort_edit(self, parent, id):
db = self.library_view.model().db

View File

@ -3,8 +3,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
'''
'''
# Imports {{{
import os, math, re, glob, sys
from base64 import b64encode
from functools import partial
@ -19,11 +18,14 @@ from calibre.utils.config import Config, StringConfig
from calibre.utils.localization import get_language
from calibre.gui2.viewer.config_ui import Ui_Dialog
from calibre.gui2.viewer.flip import SlideFlip
from calibre.gui2.viewer.gestures import Gestures
from calibre.gui2.shortcuts import Shortcuts, ShortcutConfig
from calibre.constants import iswindows
from calibre import prints, guess_type
from calibre.gui2.viewer.keys import SHORTCUTS
# }}}
bookmarks = referencing = hyphenation = jquery = jquery_scrollTo = \
hyphenator = images = hyphen_pats = None
@ -33,6 +35,7 @@ def load_builtin_fonts():
QFontDatabase.addApplicationFont(f)
return 'Liberation Serif', 'Liberation Sans', 'Liberation Mono'
# Config {{{
def config(defaults=None):
desc = _('Options to customize the ebook viewer')
if defaults is None:
@ -137,8 +140,9 @@ class ConfigDialog(QDialog, Ui_Dialog):
str(self.hyphenate_default_lang.itemData(idx).toString()))
return QDialog.accept(self, *args)
# }}}
class Document(QWebPage):
class Document(QWebPage): # {{{
def set_font_settings(self):
opts = config().parse()
@ -449,7 +453,9 @@ class Document(QWebPage):
self.height+amount)
self.setPreferredContentsSize(s)
class EntityDeclarationProcessor(object):
# }}}
class EntityDeclarationProcessor(object): # {{{
def __init__(self, html):
self.declared_entities = {}
@ -460,14 +466,16 @@ class EntityDeclarationProcessor(object):
self.processed_html = html
for key, val in self.declared_entities.iteritems():
self.processed_html = self.processed_html.replace('&%s;'%key, val)
# }}}
class DocumentView(QWebView):
class DocumentView(QWebView): # {{{
DISABLED_BRUSH = QBrush(Qt.lightGray, Qt.Dense5Pattern)
def __init__(self, *args):
QWebView.__init__(self, *args)
self.flipper = SlideFlip(self)
self.gestures = Gestures()
self.is_auto_repeat_event = False
self.debug_javascript = False
self.shortcuts = Shortcuts(SHORTCUTS, 'shortcuts/viewer')
@ -953,6 +961,29 @@ class DocumentView(QWebView):
self.manager.viewport_resized(self.scroll_fraction)
return ret
def event(self, ev):
typ = ev.type()
if typ == ev.TouchBegin:
try:
self.gestures.start_gesture('touch', ev)
except:
import traceback
traceback.print_exc()
elif typ == ev.TouchEnd:
try:
gesture = self.gestures.end_gesture('touch', ev, self.rect())
except:
import traceback
traceback.print_exc()
if gesture is not None:
ev.accept()
if gesture == 'lineleft':
self.next_page()
elif gesture == 'lineright':
self.previous_page()
return True
return QWebView.event(self, ev)
def mouseReleaseEvent(self, ev):
opos = self.document.ypos
ret = QWebView.mouseReleaseEvent(self, ev)
@ -961,4 +992,5 @@ class DocumentView(QWebView):
self.manager.scrolled(self.scroll_fraction)
return ret
# }}}

View File

@ -0,0 +1,61 @@
#!/usr/bin/env python
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
__license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import time
class Gestures(object):
def __init__(self):
self.in_progress = {}
def get_boundary_point(self, event):
t = time.time()
id_ = None
if hasattr(event, 'touchPoints'):
tps = list(event.touchPoints())
tp = None
for t in tps:
if t.isPrimary():
tp = t
break
if tp is None:
tp = tps[0]
gp, p = tp.screenPos(), tp.pos()
id_ = tp.id()
else:
gp, p = event.globalPos(), event.pos()
return (t, gp, p, id_)
def start_gesture(self, typ, event):
self.in_progress[typ] = self.get_boundary_point(event)
def is_in_progress(self, typ):
return typ in self.in_progress
def end_gesture(self, typ, event, widget_rect):
if not self.is_in_progress(typ):
return
start = self.in_progress[typ]
end = self.get_boundary_point(event)
if start[3] != end[3]:
return
timespan = end[0] - start[0]
start_pos, end_pos = start[1], end[1]
xspan = end_pos.x() - start_pos.x()
yspan = end_pos.y() - start_pos.y()
width = widget_rect.width()
if timespan < 1.1 and abs(xspan) >= width/5. and \
abs(yspan) < abs(xspan)/5.:
# Quick horizontal gesture
return 'line'+('left' if xspan < 0 else 'right')
return None

View File

@ -524,6 +524,8 @@ class EnComboBox(QComboBox):
class HistoryLineEdit(QComboBox):
lost_focus = pyqtSignal()
def __init__(self, *args):
QComboBox.__init__(self, *args)
self.setEditable(True)
@ -559,6 +561,10 @@ class HistoryLineEdit(QComboBox):
def text(self):
return self.currentText()
def focusOutEvent(self, e):
QComboBox.focusOutEvent(self, e)
self.lost_focus.emit()
class ComboBoxWithHelp(QComboBox):
'''
A combobox where item 0 is help text. CurrentText will return '' for item 0.

View File

@ -445,6 +445,9 @@ class CustomColumns(object):
index_is_id=True)
val = self.custom_data_adapters[data['datatype']](val, data)
if data['datatype'] == 'series' and extra is None:
(val, extra) = self._get_series_values(val)
if data['normalized']:
if data['datatype'] == 'enumeration' and (
val and val not in data['display']['enum_values']):

View File

@ -2133,9 +2133,27 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.conn.execute('DELETE FROM tags WHERE id=?', (id,))
self.conn.commit()
series_index_pat = re.compile(r'(.*)\s+\[([.0-9]+)\]$')
def _get_series_values(self, val):
if not val:
return (val, None)
match = self.series_index_pat.match(val.strip())
if match is not None:
idx = match.group(2)
try:
idx = float(idx)
return (match.group(1).strip(), idx)
except:
pass
return (val, None)
def set_series(self, id, series, notify=True, commit=True):
self.conn.execute('DELETE FROM books_series_link WHERE book=?',(id,))
self.conn.execute('DELETE FROM series WHERE (SELECT COUNT(id) FROM books_series_link WHERE series=series.id) < 1')
self.conn.execute('''DELETE FROM series
WHERE (SELECT COUNT(id) FROM books_series_link
WHERE series=series.id) < 1''')
(series, idx) = self._get_series_values(series)
if series:
if not isinstance(series, unicode):
series = series.decode(preferred_encoding, 'replace')
@ -2147,6 +2165,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
aid = self.conn.execute('INSERT INTO series(name) VALUES (?)', (series,)).lastrowid
self.conn.execute('INSERT INTO books_series_link(book, series) VALUES (?,?)', (id, aid))
if idx:
self.set_series_index(id, idx, notify=notify, commit=commit)
self.dirtied([id], commit=False)
if commit:
self.conn.commit()

View File

@ -7,6 +7,7 @@ __copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import os, traceback, cStringIO, re, shutil
from functools import partial
from calibre.constants import DEBUG
from calibre.utils.config import Config, StringConfig, tweaks
@ -139,8 +140,7 @@ class SafeFormat(TemplateFormatter):
def get_components(template, mi, id, timefmt='%b %Y', length=250,
sanitize_func=ascii_filename, replace_whitespace=False,
to_lowercase=False):
library_order = tweaks['save_template_title_series_sorting'] == 'library_order'
tsfmt = title_sort if library_order else lambda x: x
tsfmt = partial(title_sort, order=tweaks['save_template_title_series_sorting'])
format_args = FORMAT_ARGS.copy()
format_args.update(mi.all_non_none_fields())
if mi.title:

View File

@ -16,7 +16,6 @@ from datetime import datetime
from functools import partial
from calibre.ebooks.metadata import title_sort, author_to_author_sort
from calibre.utils.config import tweaks
from calibre.utils.date import parse_date, isoformat
from calibre import isbytestring, force_unicode
from calibre.constants import iswindows, DEBUG

View File

@ -181,16 +181,17 @@ How do I use |app| with my iPad/iPhone/iTouch?
Over the air
^^^^^^^^^^^^^^
The easiest way to browse your |app| collection on your Apple device (iPad/iPhone/iPod) is by using the *free* Stanza app, available from the Apple app store. You need at least Stanza version 3.0. Stanza allows you to access your |app| collection wirelessly, over the air.
First perform the following steps in |app|
The easiest way to browse your |app| collection on your Apple device (iPad/iPhone/iPod) is by using the calibre sontent server, which makes your collection available over the net. First perform the following steps in |app|
* Set the Preferred Output Format in |app| to EPUB (The output format can be set under :guilabel:`Preferences->Interface->Behavior`)
* Set the output profile to iPad (this will work for iPhone/iPods as well), under :guilabel:`Preferences->Conversion->Common Options->Page Setup`
* Convert the books you want to read on your iPhone to EPUB format by selecting them and clicking the Convert button.
* Turn on the Content Server in |app|'s preferences and leave |app| running.
Install the free Stanza reader app on your iPad/iPhone/iTouch using iTunes.
Now on your iPad/iPhone you have two choices, use either iBooks (version 1.2 and later) or Stanza (version 3.0 and later). Both are available free from the app store.
Using Stanza
***************
Now you should be able to access your books on your iPhone by opening Stanza. Go to "Get Books" and then click the "Shared" tab. Under Shared you will see an entry "Books in calibre". If you don't, make sure your iPad/iPhone is connected using the WiFi network in your house, not 3G. If the |app| catalog is still not detected in Stanza, you can add it manually in Stanza. To do this, click the "Shared" tab, then click the "Edit" button and then click "Add book source" to add a new book source. In the Add Book Source screen enter whatever name you like and in the URL field, enter the following::
@ -200,6 +201,18 @@ Replace ``192.168.1.2`` with the local IP address of the computer running |app|.
If you get timeout errors while browsing the calibre catalog in Stanza, try increasing the connection timeout value in the stanza settings. Go to Info->Settings and increase the value of Download Timeout.
Using iBooks
**************
Start the Safari browser and type in the IP address and port of the computer running the calibre server, like this::
http://192.168.1.2:8080/
Replace ``192.168.1.2`` with the local IP address of the computer running |app|. If you have changed the port the |app| content server is running on, you will have to change ``8080`` as well to the new port. The local IP address is the IP address you computer is assigned on your home network. A quick Google search will tell you how to find out your local IP address.
You wills ee a list of books in Safari, just click on the epub link for whichever book you want to read, Safari will then prompt you to open it with iBooks.
With the USB cable
^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -203,16 +203,17 @@ All the functions listed under single-function mode can be used in program mode,
The following functions are available in addition to those described in single-function mode. With the exception of the ``id`` parameter of assign, all parameters can be statements (sequences of expressions):
* ``add(x, y)`` -- returns x + y. Throws an exception if either x or y are not numbers.
* ``add(x, y)`` -- returns x + y. Throws an exception if either x or y are not numbers.
* ``assign(id, val)`` -- assigns val to id, then returns val. id must be an identifier, not an expression
* ``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``.
* ``divide(x, y)`` -- returns x / y. Throws an exception if either x or y are not numbers.
* ``divide(x, y)`` -- returns x / y. Throws an exception if either x or y are not numbers.
* ``field(name)`` -- returns the metadata field named by ``name``.
* ``multiply`` -- returns x * y. Throws an exception if either x or y are not numbers.
* ``multiply(x, y)`` -- returns x * y. Throws an exception if either x or y are not numbers.
* ``strcat(a, b, ...)`` -- can take any number of arguments. Returns a string formed by concatenating all the arguments.
* ``strcmp(x, y, lt, eq, gt)`` -- does a case-insensitive comparison x and y as strings. Returns ``lt`` if x < y. Returns ``eq`` if x == y. Otherwise returns ``gt``.
* ``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'``.
* ``subtract`` -- returns x - y. Throws an exception if either x or y are not numbers.
* ``subtract(x, y)`` -- returns x - y. Throws an exception if either x or y are not numbers.
* ``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.
Special notes for save/send templates
-------------------------------------

View File

@ -57,6 +57,11 @@ class _Parser(object):
y = float(y if y else 0)
return 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)
local_functions = {
'add' : (2, partial(_math, op='+')),
'assign' : (2, _assign),
@ -68,6 +73,7 @@ class _Parser(object):
'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):