mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
merge from trunk
This commit is contained in:
commit
533505a094
BIN
resources/images/news/20minutos.png
Normal file
BIN
resources/images/news/20minutos.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 800 B |
68
resources/recipes/20minutos.recipe
Normal file
68
resources/recipes/20minutos.recipe
Normal file
@ -0,0 +1,68 @@
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2011, Darko Miletic <darko.miletic at gmail.com>'
|
||||
'''
|
||||
www.20minutos.es
|
||||
'''
|
||||
|
||||
from calibre.web.feeds.news import BasicNewsRecipe
|
||||
|
||||
class t20Minutos(BasicNewsRecipe):
|
||||
title = '20 Minutos'
|
||||
__author__ = 'Darko Miletic'
|
||||
description = 'Diario de informacion general y local mas leido de Espania, noticias de ultima hora de Espania, el mundo, local, deportes, noticias curiosas y mas'
|
||||
publisher = '20 Minutos Online SL'
|
||||
category = 'news, politics, Spain'
|
||||
oldest_article = 2
|
||||
max_articles_per_feed = 200
|
||||
no_stylesheets = True
|
||||
encoding = 'utf8'
|
||||
use_embedded_content = True
|
||||
language = 'es'
|
||||
remove_empty_feeds = True
|
||||
publication_type = 'newspaper'
|
||||
masthead_url = 'http://estaticos.20minutos.es/css4/img/ui/logo-301x54.png'
|
||||
extra_css = """
|
||||
body{font-family: Arial,Helvetica,sans-serif }
|
||||
img{margin-bottom: 0.4em; display:block}
|
||||
"""
|
||||
|
||||
conversion_options = {
|
||||
'comment' : description
|
||||
, 'tags' : category
|
||||
, 'publisher' : publisher
|
||||
, 'language' : language
|
||||
}
|
||||
|
||||
remove_tags = [dict(attrs={'class':'mf-viral'})]
|
||||
remove_attributes=['border']
|
||||
|
||||
feeds = [
|
||||
(u'Principal' , u'http://20minutos.feedsportal.com/c/32489/f/478284/index.rss')
|
||||
,(u'Cine' , u'http://20minutos.feedsportal.com/c/32489/f/478285/index.rss')
|
||||
,(u'Internacional' , u'http://20minutos.feedsportal.com/c/32489/f/492689/index.rss')
|
||||
,(u'Deportes' , u'http://20minutos.feedsportal.com/c/32489/f/478286/index.rss')
|
||||
,(u'Nacional' , u'http://20minutos.feedsportal.com/c/32489/f/492688/index.rss')
|
||||
,(u'Economia' , u'http://20minutos.feedsportal.com/c/32489/f/492690/index.rss')
|
||||
,(u'Tecnologia' , u'http://20minutos.feedsportal.com/c/32489/f/478292/index.rss')
|
||||
]
|
||||
|
||||
def preprocess_html(self, soup):
|
||||
for item in soup.findAll(style=True):
|
||||
del item['style']
|
||||
for item in soup.findAll('a'):
|
||||
limg = item.find('img')
|
||||
if item.string is not None:
|
||||
str = item.string
|
||||
item.replaceWith(str)
|
||||
else:
|
||||
if limg:
|
||||
item.name = 'div'
|
||||
item.attrs = []
|
||||
else:
|
||||
str = self.tag_to_string(item)
|
||||
item.replaceWith(str)
|
||||
for item in soup.findAll('img'):
|
||||
if not item.has_key('alt'):
|
||||
item['alt'] = 'image'
|
||||
return soup
|
||||
|
@ -583,7 +583,7 @@ def main(args=sys.argv):
|
||||
if remove_plugin(opts.remove_plugin):
|
||||
print 'Plugin removed'
|
||||
else:
|
||||
print 'No custom pluginnamed', opts.remove_plugin
|
||||
print 'No custom plugin named', opts.remove_plugin
|
||||
if opts.customize_plugin is not None:
|
||||
name, custom = opts.customize_plugin.split(',')
|
||||
plugin = find_plugin(name.strip())
|
||||
|
@ -60,7 +60,12 @@ class ANDROID(USBMS):
|
||||
0x1004 : { 0x61cc : [0x100] },
|
||||
|
||||
# Archos
|
||||
0x0e79 : { 0x1419: [0x0216], 0x1420 : [0x0216], 0x1422 : [0x0216]},
|
||||
0x0e79 : {
|
||||
0x1400 : [0x0222, 0x0216],
|
||||
0x1419 : [0x0216],
|
||||
0x1420 : [0x0216],
|
||||
0x1422 : [0x0216]
|
||||
},
|
||||
|
||||
# Huawei
|
||||
# Disabled as this USB id is used by various USB flash drives
|
||||
@ -69,6 +74,9 @@ class ANDROID(USBMS):
|
||||
# T-Mobile
|
||||
0x0408 : { 0x03ba : [0x0109], },
|
||||
|
||||
# Xperia
|
||||
0x13d3 : { 0x3304 : [0x0001, 0x0002] },
|
||||
|
||||
}
|
||||
EBOOK_DIR_MAIN = ['eBooks/import', 'wordplayer/calibretransfer', 'Books']
|
||||
EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of directories to '
|
||||
@ -78,16 +86,16 @@ class ANDROID(USBMS):
|
||||
|
||||
VENDOR_NAME = ['HTC', 'MOTOROLA', 'GOOGLE_', 'ANDROID', 'ACER',
|
||||
'GT-I5700', 'SAMSUNG', 'DELL', 'LINUX', 'GOOGLE', 'ARCHOS',
|
||||
'TELECHIP', 'HUAWEI', 'T-MOBILE', ]
|
||||
'TELECHIP', 'HUAWEI', 'T-MOBILE', 'SEMC']
|
||||
WINDOWS_MAIN_MEM = ['ANDROID_PHONE', 'A855', 'A853', 'INC.NEXUS_ONE',
|
||||
'__UMS_COMPOSITE', '_MB200', 'MASS_STORAGE', '_-_CARD', 'SGH-I897',
|
||||
'GT-I9000', 'FILE-STOR_GADGET', 'SGH-T959', 'SAMSUNG_ANDROID',
|
||||
'SCH-I500_CARD', 'SPH-D700_CARD', 'MB810', 'GT-P1000', 'DESIRE',
|
||||
'SGH-T849', '_MB300', 'A70S', 'S_ANDROID', 'A101IT', 'A70H',
|
||||
'IDEOS_TABLET', 'MYTOUCH_4G', 'UMS_COMPOSITE', 'SCH-I800_CARD']
|
||||
'IDEOS_TABLET', 'MYTOUCH_4G', 'UMS_COMPOSITE', 'SCH-I800_CARD', '7']
|
||||
WINDOWS_CARD_A_MEM = ['ANDROID_PHONE', 'GT-I9000_CARD', 'SGH-I897',
|
||||
'FILE-STOR_GADGET', 'SGH-T959', 'SAMSUNG_ANDROID', 'GT-P1000_CARD',
|
||||
'A70S', 'A101IT']
|
||||
'A70S', 'A101IT', '7']
|
||||
|
||||
OSX_MAIN_MEM = 'Android Device Main Memory'
|
||||
|
||||
|
@ -83,6 +83,10 @@ CALIBRE_METADATA_FIELDS = frozenset([
|
||||
'application_id', # An application id, currently set to the db_id.
|
||||
'db_id', # the calibre primary key of the item.
|
||||
'formats', # list of formats (extensions) for this book
|
||||
# a dict of user category names, where the value is a list of item names
|
||||
# from the book that are in that category
|
||||
'user_categories',
|
||||
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -30,6 +30,7 @@ NULL_VALUES = {
|
||||
'author_sort_map': {},
|
||||
'authors' : [_('Unknown')],
|
||||
'title' : _('Unknown'),
|
||||
'user_categories' : {},
|
||||
'language' : 'und'
|
||||
}
|
||||
|
||||
|
@ -470,6 +470,13 @@ def serialize_user_metadata(metadata_elem, all_user_metadata, tail='\n'+(' '*8))
|
||||
metadata_elem.append(meta)
|
||||
|
||||
|
||||
def dump_user_categories(cats):
|
||||
if not cats:
|
||||
cats = {}
|
||||
from calibre.ebooks.metadata.book.json_codec import object_to_unicode
|
||||
return json.dumps(object_to_unicode(cats), ensure_ascii=False,
|
||||
skipkeys=True)
|
||||
|
||||
class OPF(object): # {{{
|
||||
|
||||
MIMETYPE = 'application/oebps-package+xml'
|
||||
@ -524,6 +531,9 @@ class OPF(object): # {{{
|
||||
publication_type = MetadataField('publication_type', is_dc=False)
|
||||
timestamp = MetadataField('timestamp', is_dc=False,
|
||||
formatter=parse_date, renderer=isoformat)
|
||||
user_categories = MetadataField('user_categories', is_dc=False,
|
||||
formatter=json.loads,
|
||||
renderer=dump_user_categories)
|
||||
|
||||
|
||||
def __init__(self, stream, basedir=os.getcwdu(), unquote_urls=True,
|
||||
@ -994,7 +1004,7 @@ class OPF(object): # {{{
|
||||
for attr in ('title', 'authors', 'author_sort', 'title_sort',
|
||||
'publisher', 'series', 'series_index', 'rating',
|
||||
'isbn', 'tags', 'category', 'comments',
|
||||
'pubdate'):
|
||||
'pubdate', 'user_categories'):
|
||||
val = getattr(mi, attr, None)
|
||||
if val is not None and val != [] and val != (None, None):
|
||||
setattr(self, attr, val)
|
||||
@ -1175,6 +1185,10 @@ class OPFCreator(Metadata):
|
||||
a(CAL_ELEM('calibre:timestamp', self.timestamp.isoformat()))
|
||||
if self.publication_type is not None:
|
||||
a(CAL_ELEM('calibre:publication_type', self.publication_type))
|
||||
if self.user_categories:
|
||||
from calibre.ebooks.metadata.book.json_codec import object_to_unicode
|
||||
a(CAL_ELEM('calibre:user_categories',
|
||||
json.dumps(object_to_unicode(self.user_categories))))
|
||||
manifest = E.manifest()
|
||||
if self.manifest is not None:
|
||||
for ref in self.manifest:
|
||||
@ -1299,6 +1313,8 @@ def metadata_to_opf(mi, as_string=True):
|
||||
meta('publication_type', mi.publication_type)
|
||||
if mi.title_sort:
|
||||
meta('title_sort', mi.title_sort)
|
||||
if mi.user_categories:
|
||||
meta('user_categories', dump_user_categories(mi.user_categories))
|
||||
|
||||
serialize_user_metadata(metadata, mi.get_all_user_metadata(False))
|
||||
|
||||
|
@ -7,7 +7,7 @@ __license__ = 'GPL v3'
|
||||
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import re
|
||||
import re, threading
|
||||
|
||||
from calibre.customize import Plugin
|
||||
from calibre.utils.logging import ThreadSafeLog, FileStream
|
||||
@ -30,7 +30,21 @@ class Source(Plugin):
|
||||
|
||||
touched_fields = frozenset()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
Plugin.__init__(self, *args, **kwargs)
|
||||
self._isbn_to_identifier_cache = {}
|
||||
self.cache_lock = threading.RLock()
|
||||
|
||||
# Utility functions {{{
|
||||
|
||||
def cache_isbn_to_identifier(self, isbn, identifier):
|
||||
with self.cache_lock:
|
||||
self._isbn_to_identifier_cache[isbn] = identifier
|
||||
|
||||
def cached_isbn_to_identifier(self, isbn):
|
||||
with self.cache_lock:
|
||||
return self._isbn_to_identifier_cache.get(isbn, None)
|
||||
|
||||
def get_author_tokens(self, authors, only_first_author=True):
|
||||
'''
|
||||
Take a list of authors and return a list of tokens useful for an
|
||||
|
@ -13,6 +13,7 @@ from functools import partial
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from calibre.ebooks.metadata import check_isbn
|
||||
from calibre.ebooks.metadata.sources.base import Source
|
||||
from calibre.ebooks.metadata.book.base import Metadata
|
||||
from calibre.ebooks.chardet import xml_to_unicode
|
||||
@ -69,6 +70,7 @@ def to_metadata(browser, log, entry_, timeout):
|
||||
|
||||
|
||||
id_url = entry_id(entry_)[0].text
|
||||
google_id = id_url.split('/')[-1]
|
||||
title_ = ': '.join([x.text for x in title(entry_)]).strip()
|
||||
authors = [x.text.strip() for x in creator(entry_) if x.text]
|
||||
if not authors:
|
||||
@ -78,6 +80,7 @@ def to_metadata(browser, log, entry_, timeout):
|
||||
return None
|
||||
|
||||
mi = Metadata(title_, authors)
|
||||
mi.identifiers = {'google':google_id}
|
||||
try:
|
||||
raw = get_details(browser, id_url, timeout)
|
||||
feed = etree.fromstring(xml_to_unicode(clean_ascii_chars(raw),
|
||||
@ -103,9 +106,12 @@ def to_metadata(browser, log, entry_, timeout):
|
||||
t = str(x.text).strip()
|
||||
if t[:5].upper() in ('ISBN:', 'LCCN:', 'OCLC:'):
|
||||
if t[:5].upper() == 'ISBN:':
|
||||
isbns.append(t[5:])
|
||||
t = check_isbn(t[5:])
|
||||
if t:
|
||||
isbns.append(t)
|
||||
if isbns:
|
||||
mi.isbn = sorted(isbns, key=len)[-1]
|
||||
mi.all_isbns = isbns
|
||||
|
||||
# Tags
|
||||
try:
|
||||
@ -133,20 +139,6 @@ def to_metadata(browser, log, entry_, timeout):
|
||||
return mi
|
||||
|
||||
|
||||
def get_all_details(br, log, entries, abort, result_queue, timeout):
|
||||
for i in entries:
|
||||
try:
|
||||
ans = to_metadata(br, log, i, timeout)
|
||||
if isinstance(ans, Metadata):
|
||||
result_queue.put(ans)
|
||||
except:
|
||||
log.exception(
|
||||
'Failed to get metadata for identify entry:',
|
||||
etree.tostring(i))
|
||||
if abort.is_set():
|
||||
break
|
||||
|
||||
|
||||
class GoogleBooks(Source):
|
||||
|
||||
name = 'Google Books'
|
||||
@ -185,6 +177,36 @@ class GoogleBooks(Source):
|
||||
'min-viewability':'none',
|
||||
})
|
||||
|
||||
def cover_url_from_identifiers(self, identifiers):
|
||||
goog = identifiers.get('google', None)
|
||||
if goog is None:
|
||||
isbn = identifiers.get('isbn', None)
|
||||
goog = self.cached_isbn_to_identifier(isbn)
|
||||
if goog is not None:
|
||||
return ('http://books.google.com/books?id=%s&printsec=frontcover&img=1' %
|
||||
goog)
|
||||
|
||||
def is_cover_image_valid(self, raw):
|
||||
# When no cover is present, returns a PNG saying image not available
|
||||
# Try for example google identifier llNqPwAACAAJ
|
||||
# I have yet to see an actual cover in PNG format
|
||||
return raw and len(raw) > 17000 and raw[1:4] != 'PNG'
|
||||
|
||||
def get_all_details(self, br, log, entries, abort, result_queue, timeout):
|
||||
for i in entries:
|
||||
try:
|
||||
ans = to_metadata(br, log, i, timeout)
|
||||
if isinstance(ans, Metadata):
|
||||
result_queue.put(ans)
|
||||
for isbn in ans.all_isbns:
|
||||
self.cache_isbn_to_identifier(isbn,
|
||||
ans.identifiers['google'])
|
||||
except:
|
||||
log.exception(
|
||||
'Failed to get metadata for identify entry:',
|
||||
etree.tostring(i))
|
||||
if abort.is_set():
|
||||
break
|
||||
|
||||
def identify(self, log, result_queue, abort, title=None, authors=None,
|
||||
identifiers={}, timeout=5):
|
||||
@ -207,8 +229,8 @@ class GoogleBooks(Source):
|
||||
return as_unicode(e)
|
||||
|
||||
# There is no point running these queries in threads as google
|
||||
# throttles requests returning Forbidden errors
|
||||
get_all_details(br, log, entries, abort, result_queue, timeout)
|
||||
# throttles requests returning 403 Forbidden errors
|
||||
self.get_all_details(br, log, entries, abort, result_queue, timeout)
|
||||
|
||||
return None
|
||||
|
||||
@ -218,8 +240,14 @@ if __name__ == '__main__':
|
||||
title_test)
|
||||
test_identify_plugin(GoogleBooks.name,
|
||||
[
|
||||
|
||||
(
|
||||
{'title': 'Great Expectations', 'authors':['Charles Dickens']},
|
||||
[title_test('Great Expectations', exact=True)]
|
||||
{'identifiers':{'isbn': '0743273567'}},
|
||||
[title_test('The great gatsby', exact=True)]
|
||||
),
|
||||
|
||||
#(
|
||||
# {'title': 'Great Expectations', 'authors':['Charles Dickens']},
|
||||
# [title_test('Great Expectations', exact=True)]
|
||||
#),
|
||||
])
|
||||
|
@ -242,9 +242,11 @@ class MobiReader(object):
|
||||
self.debug = debug
|
||||
self.embedded_mi = None
|
||||
self.base_css_rules = textwrap.dedent('''
|
||||
blockquote { margin: 0em 0em 0em 2em; text-align: justify }
|
||||
body { text-align: justify }
|
||||
|
||||
p { margin: 0em; text-align: justify; text-indent: 1.5em }
|
||||
blockquote { margin: 0em 0em 0em 2em; }
|
||||
|
||||
p { margin: 0em; text-indent: 1.5em }
|
||||
|
||||
.bold { font-weight: bold }
|
||||
|
||||
|
@ -515,7 +515,7 @@ class Metadata(object):
|
||||
'publisher', 'relation', 'rights', 'source',
|
||||
'subject', 'title', 'type'])
|
||||
CALIBRE_TERMS = set(['series', 'series_index', 'rating', 'timestamp',
|
||||
'publication_type'])
|
||||
'publication_type', 'title_sort'])
|
||||
OPF_ATTRS = {'role': OPF('role'), 'file-as': OPF('file-as'),
|
||||
'scheme': OPF('scheme'), 'event': OPF('event'),
|
||||
'type': XSI('type'), 'lang': XML('lang'), 'id': 'id'}
|
||||
|
@ -18,7 +18,8 @@ def meta_info_to_oeb_metadata(mi, m, log, override_input_metadata=False):
|
||||
if mi.title_sort:
|
||||
if not m.title:
|
||||
m.add('title', mi.title_sort)
|
||||
m.title[0].file_as = mi.title_sort
|
||||
m.clear('title_sort')
|
||||
m.add('title_sort', mi.title_sort)
|
||||
if not mi.is_null('authors'):
|
||||
m.filter('creator', lambda x : x.role.lower() in ['aut', ''])
|
||||
for a in mi.authors:
|
||||
|
@ -254,7 +254,8 @@ class EditorWidget(QWebView): # {{{
|
||||
f = QFontInfo(QApplication.font(self)).pixelSize()
|
||||
style = 'font-size: %dpx;' % (f,)
|
||||
|
||||
for body in self.page().mainFrame().documentElement().findAll('body'):
|
||||
# toList() is needed because PyQt on Debian is old/broken
|
||||
for body in self.page().mainFrame().documentElement().findAll('body').toList():
|
||||
body.setAttribute('style', style)
|
||||
self.page().setContentEditable(True)
|
||||
|
||||
|
@ -44,7 +44,8 @@
|
||||
<widget class="QLabel" name="msg">
|
||||
<property name="text">
|
||||
<string><p>This book is locked by <b>DRM</b>. To learn more about DRM and why you cannot read or convert this book in calibre,
|
||||
<a href="http://bugs.calibre-ebook.com/wiki/DRM">click here</a>.</string>
|
||||
<a href="http://drmfree.calibre-ebook.com/about#drm">click here</a>.<p>A large number of recent, DRM free releases are
|
||||
available at <a href="http://drmfree.calibre-ebook.com">Open Books</a>.</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
|
@ -73,16 +73,17 @@ class TagCategories(QDialog, Ui_TagCategories):
|
||||
if idx == 0:
|
||||
continue
|
||||
for n in category_values[idx]():
|
||||
t = Item(name=n, label=label, index=len(self.all_items),icon=category_icons[idx], exists=True)
|
||||
t = Item(name=n, label=label, index=len(self.all_items),
|
||||
icon=category_icons[idx], exists=True)
|
||||
self.all_items.append(t)
|
||||
self.all_items_dict[label+':'+n] = t
|
||||
self.all_items_dict[icu_lower(label+':'+n)] = t
|
||||
|
||||
self.categories = dict.copy(db.prefs.get('user_categories', {}))
|
||||
if self.categories is None:
|
||||
self.categories = {}
|
||||
for cat in self.categories:
|
||||
for item,l in enumerate(self.categories[cat]):
|
||||
key = ':'.join([l[1], l[0]])
|
||||
key = icu_lower(':'.join([l[1], l[0]]))
|
||||
t = self.all_items_dict.get(key, None)
|
||||
if l[1] in self.category_labels:
|
||||
if t is None:
|
||||
@ -231,6 +232,12 @@ class TagCategories(QDialog, Ui_TagCategories):
|
||||
|
||||
def accept(self):
|
||||
self.save_category()
|
||||
for cat in sorted(self.categories.keys(), key=sort_key):
|
||||
components = cat.split('.')
|
||||
for i in range(0,len(components)):
|
||||
c = '.'.join(components[0:i+1])
|
||||
if c not in self.categories:
|
||||
self.categories[c] = []
|
||||
QDialog.accept(self)
|
||||
|
||||
def save_category(self):
|
||||
|
@ -209,7 +209,6 @@ class EmailMixin(object): # {{{
|
||||
|
||||
def __init__(self):
|
||||
self.emailer = Emailer(self.job_manager)
|
||||
self.emailer.start()
|
||||
|
||||
def send_by_mail(self, to, fmts, delete_from_library, send_ids=None,
|
||||
do_auto_convert=True, specific_format=None):
|
||||
@ -255,6 +254,8 @@ class EmailMixin(object): # {{{
|
||||
|
||||
to_s = list(repeat(to, len(attachments)))
|
||||
if attachments:
|
||||
if not self.emailer.is_alive():
|
||||
self.emailer.start()
|
||||
self.emailer.send_mails(jobnames,
|
||||
Dispatcher(partial(self.email_sent, remove=remove)),
|
||||
attachments, to_s, subjects, texts, attachment_names)
|
||||
@ -325,6 +326,8 @@ class EmailMixin(object): # {{{
|
||||
files, auto = self.library_view.model().\
|
||||
get_preferred_formats_from_ids([id_], fmts)
|
||||
return files
|
||||
if not self.emailer.is_alive():
|
||||
self.emailer.start()
|
||||
sent_mails = self.emailer.email_news(mi, remove,
|
||||
get_fmts, self.email_sent)
|
||||
if sent_mails:
|
||||
|
@ -7,17 +7,19 @@ __docformat__ = 'restructuredtext en'
|
||||
|
||||
from PyQt4.Qt import QApplication, QFont, QFontInfo, QFontDialog
|
||||
|
||||
from calibre.gui2.preferences import ConfigWidgetBase, test_widget
|
||||
from calibre.gui2.preferences import ConfigWidgetBase, test_widget, CommaSeparatedList
|
||||
from calibre.gui2.preferences.look_feel_ui import Ui_Form
|
||||
from calibre.gui2 import config, gprefs, qt_app
|
||||
from calibre.utils.localization import available_translations, \
|
||||
get_language, get_lang
|
||||
from calibre.utils.config import prefs
|
||||
from calibre.utils.icu import sort_key
|
||||
|
||||
class ConfigWidget(ConfigWidgetBase, Ui_Form):
|
||||
|
||||
def genesis(self, gui):
|
||||
self.gui = gui
|
||||
db = gui.library_view.model().db
|
||||
|
||||
r = self.register
|
||||
|
||||
@ -61,6 +63,15 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
|
||||
r('tags_browser_partition_method', gprefs, choices=choices)
|
||||
r('tags_browser_collapse_at', gprefs)
|
||||
|
||||
choices = set([k for k in db.field_metadata.all_field_keys()
|
||||
if db.field_metadata[k]['is_category'] and
|
||||
db.field_metadata[k]['datatype'] in ['text', 'series', 'enumeration']])
|
||||
choices -= set(['authors', 'publisher', 'formats', 'news'])
|
||||
self.opt_categories_using_hierarchy.update_items_cache(choices)
|
||||
r('categories_using_hierarchy', db.prefs, setting=CommaSeparatedList,
|
||||
choices=sorted(list(choices), key=sort_key))
|
||||
|
||||
|
||||
self.current_font = None
|
||||
self.change_font_button.clicked.connect(self.change_font)
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>670</width>
|
||||
<height>392</height>
|
||||
<height>422</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@ -136,7 +136,7 @@
|
||||
<item>
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Tags browser category partitioning method:</string>
|
||||
<string>Tags browser category &partitioning method:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>opt_tags_browser_partition_method</cstring>
|
||||
@ -157,7 +157,7 @@ if you never want subcategories</string>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Collapse when more items than:</string>
|
||||
<string>&Collapse when more items than:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>opt_tags_browser_collapse_at</cstring>
|
||||
@ -190,6 +190,28 @@ up into sub-categories. If the partition method is set to disable, this value is
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<widget class="QLabel" name="label_81">
|
||||
<property name="text">
|
||||
<string>Categories with &hierarchical items:</string>
|
||||
</property>
|
||||
<property name="buddy">
|
||||
<cstring>opt_categories_using_hierarchy</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="1">
|
||||
<widget class="MultiCompleteLineEdit" name="opt_categories_using_hierarchy">
|
||||
<property name="toolTip">
|
||||
<string>A comma-separated list of columns in which items containing
|
||||
periods are displayed in the tag browser trees. For example, if
|
||||
this box contains 'tags' then tags of the form 'Mystery.English'
|
||||
and 'Mystery.Thriller' will be displayed with English and Thriller
|
||||
both under 'Mystery'. If 'tags' is not in this box,
|
||||
then the tags will be displayed each on their own line.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="15" column="0" colspan="2">
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
@ -275,6 +297,13 @@ up into sub-categories. If the partition method is set to disable, this value is
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>MultiCompleteLineEdit</class>
|
||||
<extends>QLineEdit</extends>
|
||||
<header>calibre/gui2/complete.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
|
@ -7,7 +7,7 @@ __docformat__ = 'restructuredtext en'
|
||||
Browsing book collection by tags.
|
||||
'''
|
||||
|
||||
import traceback
|
||||
import traceback, copy, cPickle
|
||||
|
||||
from itertools import izip
|
||||
from functools import partial
|
||||
@ -16,7 +16,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, QFont, QSize, \
|
||||
QIcon, QPoint, QVBoxLayout, QHBoxLayout, QComboBox, QTimer,\
|
||||
QAbstractItemModel, QVariant, QModelIndex, QMenu, QFrame,\
|
||||
QPushButton, QWidget, QItemDelegate, QString, QLabel, \
|
||||
QShortcut, QKeySequence, SIGNAL
|
||||
QShortcut, QKeySequence, SIGNAL, QMimeData
|
||||
|
||||
from calibre.ebooks.metadata import title_sort
|
||||
from calibre.gui2 import config, NONE, gprefs
|
||||
@ -73,6 +73,7 @@ class TagsView(QTreeView): # {{{
|
||||
refresh_required = pyqtSignal()
|
||||
tags_marked = pyqtSignal(object)
|
||||
user_category_edit = pyqtSignal(object)
|
||||
add_subcategory = pyqtSignal(object)
|
||||
tag_list_edit = pyqtSignal(object, object)
|
||||
saved_search_edit = pyqtSignal(object)
|
||||
author_sort_edit = pyqtSignal(object, object)
|
||||
@ -94,7 +95,8 @@ class TagsView(QTreeView): # {{{
|
||||
self.setItemDelegate(TagDelegate(self))
|
||||
self.made_connections = False
|
||||
self.setAcceptDrops(True)
|
||||
self.setDragDropMode(self.DropOnly)
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(self.DragDrop)
|
||||
self.setDropIndicatorShown(True)
|
||||
self.setAutoExpandDelay(500)
|
||||
self.pane_is_visible = False
|
||||
@ -218,6 +220,9 @@ class TagsView(QTreeView): # {{{
|
||||
if action == 'manage_categories':
|
||||
self.user_category_edit.emit(category)
|
||||
return
|
||||
if action == 'add_subcategory':
|
||||
self.add_subcategory.emit(category)
|
||||
return
|
||||
if action == 'search':
|
||||
self._toggle(index, set_to=search_state)
|
||||
return
|
||||
@ -258,11 +263,15 @@ class TagsView(QTreeView): # {{{
|
||||
|
||||
if item.type == TagTreeItem.TAG:
|
||||
tag_item = item
|
||||
tag_name = item.tag.name
|
||||
tag_id = item.tag.id
|
||||
t = item.tag
|
||||
tag_name = t.name
|
||||
tag_id = t.id
|
||||
can_edit = getattr(t, 'can_edit', True)
|
||||
while item.type != TagTreeItem.CATEGORY:
|
||||
item = item.parent
|
||||
|
||||
if item.type == TagTreeItem.CATEGORY:
|
||||
if not item.category_key.startswith('@'):
|
||||
while item.parent != self._model.root_item:
|
||||
item = item.parent
|
||||
category = unicode(item.name.toString())
|
||||
@ -275,7 +284,8 @@ class TagsView(QTreeView): # {{{
|
||||
if tag_name:
|
||||
# If the user right-clicked on an editable item, then offer
|
||||
# the possibility of renaming that item.
|
||||
if key in ['authors', 'tags', 'series', 'publisher', 'search'] or \
|
||||
if can_edit and \
|
||||
key in ['authors', 'tags', 'series', 'publisher', 'search'] or \
|
||||
(self.db.field_metadata[key]['is_custom'] and \
|
||||
self.db.field_metadata[key]['datatype'] != 'rating'):
|
||||
# Add the 'rename' items
|
||||
@ -298,9 +308,21 @@ class TagsView(QTreeView): # {{{
|
||||
search_state=TAG_SEARCH_STATES['mark_minus'],
|
||||
index=index))
|
||||
self.context_menu.addSeparator()
|
||||
elif key.startswith('@'):
|
||||
if item.can_edit:
|
||||
self.context_menu.addAction(_('Rename %s')%key[1:],
|
||||
partial(self.context_menu_handler, action='edit_item',
|
||||
category=key, index=index))
|
||||
self.context_menu.addAction(self.search_icon,
|
||||
_('Add sub-category to %s')%key[1:],
|
||||
partial(self.context_menu_handler,
|
||||
action='add_subcategory', category=key))
|
||||
self.context_menu.addSeparator()
|
||||
# Hide/Show/Restore categories
|
||||
if not key.startswith('@') or key.find('.') < 0:
|
||||
self.context_menu.addAction(_('Hide category %s') % category,
|
||||
partial(self.context_menu_handler, action='hide', category=category))
|
||||
partial(self.context_menu_handler, action='hide',
|
||||
category=category))
|
||||
if self.hidden_categories:
|
||||
m = self.context_menu.addMenu(_('Show category'))
|
||||
for col in sorted(self.hidden_categories, key=sort_key):
|
||||
@ -334,10 +356,11 @@ class TagsView(QTreeView): # {{{
|
||||
|
||||
# Always show the user categories editor
|
||||
self.context_menu.addSeparator()
|
||||
if category in self.db.prefs.get('user_categories', {}).keys():
|
||||
if key.startswith('@') and \
|
||||
key[1:] in self.db.prefs.get('user_categories', {}).keys():
|
||||
self.context_menu.addAction(_('Manage User Categories'),
|
||||
partial(self.context_menu_handler, action='manage_categories',
|
||||
category=category))
|
||||
category=key[1:]))
|
||||
else:
|
||||
self.context_menu.addAction(_('Manage User Categories'),
|
||||
partial(self.context_menu_handler, action='manage_categories',
|
||||
@ -376,15 +399,26 @@ class TagsView(QTreeView): # {{{
|
||||
index = self.indexAt(event.pos())
|
||||
if not index.isValid():
|
||||
return
|
||||
src_is_tb = event.mimeData().hasFormat('application/calibre+from_tag_browser')
|
||||
item = index.internalPointer()
|
||||
flags = self._model.flags(index)
|
||||
if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled:
|
||||
self.setDropIndicatorShown(True)
|
||||
else:
|
||||
self.setDropIndicatorShown(not src_is_tb)
|
||||
return
|
||||
if item.type == TagTreeItem.CATEGORY:
|
||||
fm_dest = self.db.metadata_for_field(item.category_key)
|
||||
if fm_dest['kind'] == 'user':
|
||||
if src_is_tb:
|
||||
if event.dropAction() == Qt.MoveAction:
|
||||
data = str(event.mimeData().data('application/calibre+from_tag_browser'))
|
||||
src = cPickle.loads(data)
|
||||
for s in src:
|
||||
if s[0] == TagTreeItem.TAG and not s[1].startswith('@'):
|
||||
return
|
||||
self.setDropIndicatorShown(True)
|
||||
return
|
||||
md = event.mimeData()
|
||||
if hasattr(md, 'column_name'):
|
||||
fm_src = self.db.metadata_for_field(md.column_name)
|
||||
if md.column_name in ['authors', 'publisher', 'series'] or \
|
||||
(fm_src['is_custom'] and
|
||||
@ -514,6 +548,12 @@ class TagTreeItem(object): # {{{
|
||||
tweaks['categories_use_field_for_author_name'] == 'author_sort':
|
||||
name = tag.sort
|
||||
tt_author = True
|
||||
else:
|
||||
p = self
|
||||
while p.parent.type != self.ROOT:
|
||||
p = p.parent
|
||||
if p.category_key.startswith('@'):
|
||||
name = getattr(tag, 'original_name', tag.name)
|
||||
else:
|
||||
name = tag.name
|
||||
tt_author = False
|
||||
@ -523,7 +563,7 @@ class TagTreeItem(object): # {{{
|
||||
else:
|
||||
return QVariant('[%d] %s'%(tag.count, name))
|
||||
if role == Qt.EditRole:
|
||||
return QVariant(tag.name)
|
||||
return QVariant(getattr(tag, 'original_name', tag.name))
|
||||
if role == Qt.DecorationRole:
|
||||
return self.icon_state_map[tag.state]
|
||||
if role == Qt.ToolTipRole:
|
||||
@ -550,12 +590,12 @@ class TagTreeItem(object): # {{{
|
||||
|
||||
def child_tags(self):
|
||||
res = []
|
||||
for t in self.children:
|
||||
if t.type == TagTreeItem.CATEGORY:
|
||||
for c in t.children:
|
||||
res.append(c)
|
||||
else:
|
||||
def recurse(nodes, res):
|
||||
for t in nodes:
|
||||
if t.type != TagTreeItem.CATEGORY:
|
||||
res.append(t)
|
||||
recurse(t.children, res)
|
||||
recurse(self.children, res)
|
||||
return res
|
||||
# }}}
|
||||
|
||||
@ -590,6 +630,10 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
data = self.get_node_tree(config['sort_tags_by'])
|
||||
gst = db.prefs.get('grouped_search_terms', {})
|
||||
self.root_item = TagTreeItem()
|
||||
self.category_nodes = []
|
||||
|
||||
last_category_node = None
|
||||
category_node_map = {}
|
||||
for i, r in enumerate(self.row_map):
|
||||
if self.hidden_categories and self.categories[i] in self.hidden_categories:
|
||||
continue
|
||||
@ -599,22 +643,132 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
tt = ''
|
||||
else:
|
||||
tt = _(u'The lookup/search name is "{0}"').format(r)
|
||||
TagTreeItem(parent=self.root_item,
|
||||
|
||||
if r.startswith('@'):
|
||||
path_parts = [p.strip() for p in r.split('.') if p.strip()]
|
||||
path = ''
|
||||
last_category_node = self.root_item
|
||||
for i,p in enumerate(path_parts):
|
||||
path += p
|
||||
if path not in category_node_map:
|
||||
node = TagTreeItem(parent=last_category_node,
|
||||
data=p[1:] if i == 0 else p,
|
||||
category_icon=self.category_icon_map[r],
|
||||
tooltip=tt if path == r else path,
|
||||
category_key=path)
|
||||
last_category_node = node
|
||||
category_node_map[path] = node
|
||||
self.category_nodes.append(node)
|
||||
node.can_edit = i == (len(path_parts) - 1)
|
||||
else:
|
||||
last_category_node = category_node_map[path]
|
||||
path += '.'
|
||||
else:
|
||||
node = TagTreeItem(parent=self.root_item,
|
||||
data=self.categories[i],
|
||||
category_icon=self.category_icon_map[r],
|
||||
tooltip=tt, category_key=r)
|
||||
category_node_map[r] = node
|
||||
last_category_node = node
|
||||
self.category_nodes.append(node)
|
||||
self.refresh(data=data)
|
||||
|
||||
def break_cycles(self):
|
||||
self.db = self.root_item = None
|
||||
|
||||
def mimeTypes(self):
|
||||
return ["application/calibre+from_library"]
|
||||
return ["application/calibre+from_library",
|
||||
'application/calibre+from_tag_browser']
|
||||
|
||||
def mimeData(self, indexes):
|
||||
data = []
|
||||
for idx in indexes:
|
||||
if idx.isValid():
|
||||
# get some useful serializable data
|
||||
node = idx.internalPointer()
|
||||
if node.type == TagTreeItem.CATEGORY:
|
||||
d = (node.type, node.py_name, node.category_key)
|
||||
else:
|
||||
t = node.tag
|
||||
p = node
|
||||
while p.type != TagTreeItem.CATEGORY:
|
||||
p = p.parent
|
||||
d = (node.type, p.category_key,
|
||||
getattr(t, 'original_name', t.name), t.category, t.id)
|
||||
data.append(d)
|
||||
else:
|
||||
data.append(None)
|
||||
raw = bytearray(cPickle.dumps(data, -1))
|
||||
ans = QMimeData()
|
||||
ans.setData('application/calibre+from_tag_browser', raw)
|
||||
return ans
|
||||
|
||||
def dropMimeData(self, md, action, row, column, parent):
|
||||
if not md.hasFormat("application/calibre+from_library") or \
|
||||
action != Qt.CopyAction:
|
||||
fmts = set([unicode(x) for x in md.formats()])
|
||||
if not fmts.intersection(set(self.mimeTypes())):
|
||||
return False
|
||||
if "application/calibre+from_library" in fmts:
|
||||
if action != Qt.CopyAction:
|
||||
return False
|
||||
return self.do_drop_from_library(md, action, row, column, parent)
|
||||
elif 'application/calibre+from_tag_browser' in fmts:
|
||||
return self.do_drop_from_tag_browser(md, action, row, column, parent)
|
||||
|
||||
def do_drop_from_tag_browser(self, md, action, row, column, parent):
|
||||
if not parent.isValid():
|
||||
return False
|
||||
dest = parent.internalPointer()
|
||||
if dest.type != TagTreeItem.CATEGORY:
|
||||
return False
|
||||
if not md.hasFormat('application/calibre+from_tag_browser'):
|
||||
return False
|
||||
data = str(md.data('application/calibre+from_tag_browser'))
|
||||
src = cPickle.loads(data)
|
||||
for s in src:
|
||||
if s[0] != TagTreeItem.TAG:
|
||||
return False
|
||||
user_cats = self.db.prefs.get('user_categories', {})
|
||||
parent_node = None
|
||||
for s in src:
|
||||
src_parent, src_name, src_cat = s[1:4]
|
||||
parent_node = src_parent
|
||||
if src_parent.startswith('@'):
|
||||
is_uc = True
|
||||
src_parent = src_parent[1:]
|
||||
else:
|
||||
is_uc = False
|
||||
dest_key = dest.category_key[1:]
|
||||
if dest_key not in user_cats:
|
||||
continue
|
||||
new_cat = []
|
||||
# delete the item if the source is a user category and action is move
|
||||
if is_uc and src_parent in user_cats and action == Qt.MoveAction:
|
||||
for tup in user_cats[src_parent]:
|
||||
if src_name == tup[0] and src_cat == tup[1]:
|
||||
continue
|
||||
new_cat.append(list(tup))
|
||||
user_cats[src_parent] = new_cat
|
||||
# Now add the item to the destination user category
|
||||
add_it = True
|
||||
if not is_uc and src_cat == 'news':
|
||||
src_cat = 'tags'
|
||||
for tup in user_cats[dest_key]:
|
||||
if src_name == tup[0] and src_cat == tup[1]:
|
||||
add_it = False
|
||||
if add_it:
|
||||
user_cats[dest_key].append([src_name, src_cat, 0])
|
||||
self.db.prefs.set('user_categories', user_cats)
|
||||
self.tags_view.set_new_model()
|
||||
if parent_node is not None:
|
||||
# Must work with the new model here
|
||||
m = self.tags_view.model()
|
||||
path = m.find_category_node(parent_node)
|
||||
idx = m.index_for_path(path)
|
||||
self.tags_view.setExpanded(idx, True)
|
||||
m.show_item_at_index(idx)
|
||||
return True
|
||||
|
||||
def do_drop_from_library(self, md, action, row, column, parent):
|
||||
idx = parent
|
||||
if idx.isValid():
|
||||
node = self.data(idx, Qt.UserRole)
|
||||
@ -754,10 +908,15 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
for user_cat in sorted(self.db.prefs.get('user_categories', {}).keys(),
|
||||
key=sort_key):
|
||||
cat_name = '@' + user_cat # add the '@' to avoid name collision
|
||||
while True:
|
||||
try:
|
||||
tb_cats.add_user_category(label=cat_name, name=user_cat)
|
||||
dot = cat_name.rfind('.')
|
||||
if dot < 0:
|
||||
break
|
||||
cat_name = cat_name[:dot]
|
||||
except ValueError:
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
for cat in sorted(self.db.prefs.get('grouped_search_terms', {}).keys(),
|
||||
key=sort_key):
|
||||
@ -794,7 +953,7 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
data = self.get_node_tree(sort_by) # get category data
|
||||
if data is None:
|
||||
return False
|
||||
row_index = -1
|
||||
|
||||
collapse = gprefs['tags_browser_collapse_at']
|
||||
collapse_model = self.collapse_model
|
||||
if collapse == 0:
|
||||
@ -810,53 +969,43 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
collapse_template = tweaks['categories_collapsed_popularity_template']
|
||||
collapse_letter = collapse_letter_sk = None
|
||||
|
||||
for i, r in enumerate(self.row_map):
|
||||
if self.hidden_categories and self.categories[i] in self.hidden_categories:
|
||||
continue
|
||||
row_index += 1
|
||||
category = self.root_item.children[row_index]
|
||||
names = []
|
||||
states = []
|
||||
children = category.child_tags()
|
||||
states = [t.tag.state for t in children]
|
||||
names = [t.tag.name for names in children]
|
||||
state_map = dict(izip(names, states))
|
||||
category_index = self.index(row_index, 0, QModelIndex())
|
||||
def process_one_node(category, state_map, collapse_letter, collapse_letter_sk):
|
||||
category_index = self.createIndex(category.row(), 0, category)
|
||||
category_node = category_index.internalPointer()
|
||||
if len(category.children) > 0:
|
||||
self.beginRemoveRows(category_index, 0,
|
||||
len(category.children)-1)
|
||||
category.children = []
|
||||
self.endRemoveRows()
|
||||
cat_len = len(data[r])
|
||||
key = category_node.category_key
|
||||
if key not in data:
|
||||
return ((collapse_letter, collapse_letter_sk))
|
||||
cat_len = len(data[key])
|
||||
if cat_len <= 0:
|
||||
continue
|
||||
return ((collapse_letter, collapse_letter_sk))
|
||||
|
||||
self.beginInsertRows(category_index, 0, len(data[r])-1)
|
||||
clear_rating = True if r not in self.categories_with_ratings and \
|
||||
not self.db.field_metadata[r]['is_custom'] and \
|
||||
not self.db.field_metadata[r]['kind'] == 'user' \
|
||||
fm = self.db.field_metadata[key]
|
||||
clear_rating = True if key not in self.categories_with_ratings and \
|
||||
not fm['is_custom'] and \
|
||||
not fm['kind'] == 'user' \
|
||||
else False
|
||||
tt = r if self.db.field_metadata[r]['kind'] == 'user' else None
|
||||
for idx,tag in enumerate(data[r]):
|
||||
tt = key if fm['kind'] == 'user' else None
|
||||
for idx,tag in enumerate(data[key]):
|
||||
if clear_rating:
|
||||
tag.avg_rating = None
|
||||
tag.state = state_map.get(tag.name, 0)
|
||||
tag.state = state_map.get((tag.name, tag.category), 0)
|
||||
|
||||
if collapse_model != 'disable' and cat_len > collapse:
|
||||
if collapse_model == 'partition':
|
||||
if (idx % collapse) == 0:
|
||||
d = {'first': tag}
|
||||
if cat_len > idx + collapse:
|
||||
d['last'] = data[r][idx+collapse-1]
|
||||
d['last'] = data[key][idx+collapse-1]
|
||||
else:
|
||||
d['last'] = data[r][cat_len-1]
|
||||
d['last'] = data[key][cat_len-1]
|
||||
name = eval_formatter.safe_format(collapse_template,
|
||||
d, 'TAG_VIEW', None)
|
||||
self.beginInsertRows(category_index, 999999, 1) #len(data[key])-1)
|
||||
sub_cat = TagTreeItem(parent=category,
|
||||
data = name, tooltip = None,
|
||||
category_icon = category_node.icon,
|
||||
category_key=category_node.category_key)
|
||||
self.endInsertRows()
|
||||
else:
|
||||
ts = tag.sort
|
||||
if not ts:
|
||||
@ -877,12 +1026,62 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
category_icon = category_node.icon,
|
||||
tooltip = None,
|
||||
category_key=category_node.category_key)
|
||||
t = TagTreeItem(parent=sub_cat, data=tag, tooltip=tt,
|
||||
icon_map=self.icon_state_map)
|
||||
node_parent = sub_cat
|
||||
else:
|
||||
t = TagTreeItem(parent=category, data=tag, tooltip=tt,
|
||||
node_parent = category
|
||||
|
||||
components = [t for t in tag.name.split('.')]
|
||||
if key in ['authors', 'publisher', 'news', 'formats'] or \
|
||||
key not in self.db.prefs.get('categories_using_hierarchy', []) or\
|
||||
len(components) == 1 or \
|
||||
fm['kind'] == 'user' or \
|
||||
fm['datatype'] not in ['text', 'series', 'enumeration']:
|
||||
self.beginInsertRows(category_index, 999999, 1)
|
||||
TagTreeItem(parent=node_parent, data=tag, tooltip=tt,
|
||||
icon_map=self.icon_state_map)
|
||||
self.endInsertRows()
|
||||
else:
|
||||
for i,comp in enumerate(components):
|
||||
child_map = dict([(t.tag.name, t) for t in node_parent.children
|
||||
if t.type != TagTreeItem.CATEGORY])
|
||||
if comp in child_map:
|
||||
node_parent = child_map[comp]
|
||||
node_parent.tag.count += tag.count
|
||||
else:
|
||||
if i < len(components)-1:
|
||||
t = copy.copy(tag)
|
||||
t.original_name = '.'.join(components[:i+1])
|
||||
t.can_edit = False
|
||||
else:
|
||||
t = tag
|
||||
t.original_name = t.name
|
||||
t.can_edit = True
|
||||
t.use_prefix = True
|
||||
t.name = comp
|
||||
self.beginInsertRows(category_index, 999999, 1)
|
||||
node_parent = TagTreeItem(parent=node_parent, data=t,
|
||||
tooltip=tt, icon_map=self.icon_state_map)
|
||||
self.endInsertRows()
|
||||
|
||||
return ((collapse_letter, collapse_letter_sk))
|
||||
|
||||
for category in self.category_nodes:
|
||||
if len(category.children) > 0:
|
||||
child_map = category.children
|
||||
states = [c.tag.state for c in category.child_tags()]
|
||||
names = [(c.tag.name, c.tag.category) for c in category.child_tags()]
|
||||
state_map = dict(izip(names, states))
|
||||
ctags = [c for c in child_map if c.type == TagTreeItem.CATEGORY]
|
||||
start = len(ctags)
|
||||
self.beginRemoveRows(self.createIndex(category.row(), 0, category),
|
||||
start, len(child_map)-1)
|
||||
category.children = ctags
|
||||
self.endRemoveRows()
|
||||
else:
|
||||
state_map = {}
|
||||
|
||||
collapse_letter, collapse_letter_sk = process_one_node(category,
|
||||
state_map, collapse_letter, collapse_letter_sk)
|
||||
return True
|
||||
|
||||
def columnCount(self, parent):
|
||||
@ -907,7 +1106,41 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
_('An item cannot be set to nothing. Delete it instead.')).exec_()
|
||||
return False
|
||||
item = index.internalPointer()
|
||||
key = item.parent.category_key
|
||||
if item.type == TagTreeItem.CATEGORY and item.category_key.startswith('@'):
|
||||
user_cats = self.db.prefs.get('user_categories', {})
|
||||
ckey = item.category_key[1:]
|
||||
dotpos = ckey.rfind('.')
|
||||
if dotpos < 0:
|
||||
nkey = val
|
||||
else:
|
||||
nkey = ckey[:dotpos+1] + val
|
||||
for c in user_cats:
|
||||
if c.startswith(ckey):
|
||||
if len(c) == len(ckey):
|
||||
if nkey in user_cats:
|
||||
error_dialog(self.tags_view, _('Rename user category'),
|
||||
_('The name %s is already used'%nkey), show=True)
|
||||
return False
|
||||
user_cats[nkey] = user_cats[ckey]
|
||||
del user_cats[ckey]
|
||||
elif c[len(ckey)] == '.':
|
||||
rest = c[len(ckey):]
|
||||
if (nkey + rest) in user_cats:
|
||||
error_dialog(self.tags_view, _('Rename user category'),
|
||||
_('The name %s is already used')%(nkey+rest), show=True)
|
||||
return False
|
||||
user_cats[nkey + rest] = user_cats[ckey + rest]
|
||||
del user_cats[ckey + rest]
|
||||
self.db.prefs.set('user_categories', user_cats)
|
||||
self.tags_view.set_new_model()
|
||||
# must not use 'self' below because the model has changed!
|
||||
p = self.tags_view.model().find_category_node('@' + nkey)
|
||||
self.tags_view.model().show_item_at_path(p)
|
||||
return True
|
||||
itm = item.parent
|
||||
while itm.type != TagTreeItem.CATEGORY:
|
||||
itm = itm.parent
|
||||
key = itm.category_key
|
||||
# make certain we know about the item's category
|
||||
if key not in self.db.field_metadata:
|
||||
return False
|
||||
@ -950,6 +1183,8 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
if index.isValid():
|
||||
node = self.data(index, Qt.UserRole)
|
||||
if node.type == TagTreeItem.TAG:
|
||||
if getattr(node.tag, 'can_edit', True):
|
||||
ans |= Qt.ItemIsDragEnabled
|
||||
fm = self.db.metadata_for_field(node.tag.category)
|
||||
if node.tag.category in \
|
||||
('tags', 'series', 'authors', 'rating', 'publisher') or \
|
||||
@ -961,7 +1196,7 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
return ans
|
||||
|
||||
def supportedDropActions(self):
|
||||
return Qt.CopyAction
|
||||
return Qt.CopyAction|Qt.MoveAction
|
||||
|
||||
def path_for_index(self, index):
|
||||
ans = []
|
||||
@ -1022,27 +1257,22 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
|
||||
def reset_all_states(self, except_=None):
|
||||
update_list = []
|
||||
def process_tag(tag_index, tag_item):
|
||||
def process_tag(tag_item):
|
||||
if tag_item.type != TagTreeItem.CATEGORY:
|
||||
tag = tag_item.tag
|
||||
if tag is except_:
|
||||
tag_index = self.createIndex(tag_item.row(), 0, tag_item)
|
||||
self.dataChanged.emit(tag_index, tag_index)
|
||||
return
|
||||
if tag.state != 0 or tag in update_list:
|
||||
elif tag.state != 0 or tag in update_list:
|
||||
tag_index = self.createIndex(tag_item.row(), 0, tag_item)
|
||||
tag.state = 0
|
||||
update_list.append(tag)
|
||||
self.dataChanged.emit(tag_index, tag_index)
|
||||
for t in tag_item.children:
|
||||
process_tag(t)
|
||||
|
||||
def process_level(category_index):
|
||||
for j in xrange(self.rowCount(category_index)):
|
||||
tag_index = self.index(j, 0, category_index)
|
||||
tag_item = tag_index.internalPointer()
|
||||
if tag_item.type == TagTreeItem.CATEGORY:
|
||||
process_level(tag_index)
|
||||
else:
|
||||
process_tag(tag_index, tag_item)
|
||||
|
||||
for i in xrange(self.rowCount(QModelIndex())):
|
||||
process_level(self.index(i, 0, QModelIndex()))
|
||||
for t in self.root_item.children:
|
||||
process_tag(t)
|
||||
|
||||
def clear_state(self):
|
||||
self.reset_all_states()
|
||||
@ -1073,14 +1303,10 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
# They will be 'checked' in both places, but we want to put the node
|
||||
# into the search string only once. The nodes_seen set helps us do that
|
||||
nodes_seen = set()
|
||||
row_index = -1
|
||||
|
||||
for i, key in enumerate(self.row_map):
|
||||
if self.hidden_categories and self.categories[i] in self.hidden_categories:
|
||||
continue
|
||||
row_index += 1
|
||||
category_item = self.root_item.children[row_index]
|
||||
for tag_item in category_item.child_tags():
|
||||
for node in self.category_nodes:
|
||||
key = node.category_key
|
||||
for tag_item in node.child_tags():
|
||||
tag = tag_item.tag
|
||||
if tag.state != TAG_SEARCH_STATES['clear']:
|
||||
prefix = ' not ' if tag.state == TAG_SEARCH_STATES['mark_minus'] \
|
||||
@ -1089,15 +1315,18 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
if tag.name and tag.name[0] == u'\u2605': # char is a star. Assume rating
|
||||
ans.append('%s%s:%s'%(prefix, category, len(tag.name)))
|
||||
else:
|
||||
name = getattr(tag, 'original_name', tag.name)
|
||||
use_prefix = getattr(tag, 'use_prefix', False)
|
||||
if category == 'tags':
|
||||
if tag.name in tags_seen:
|
||||
if name in tags_seen:
|
||||
continue
|
||||
tags_seen.add(tag.name)
|
||||
tags_seen.add(name)
|
||||
if tag in nodes_seen:
|
||||
continue
|
||||
nodes_seen.add(tag)
|
||||
ans.append('%s%s:"=%s"'%(prefix, category,
|
||||
tag.name.replace(r'"', r'\"')))
|
||||
ans.append('%s%s:"=%s%s"'%(prefix, category,
|
||||
'.' if use_prefix else '',
|
||||
name.replace(r'"', r'\"')))
|
||||
return ans
|
||||
|
||||
def find_item_node(self, key, txt, start_path):
|
||||
@ -1154,7 +1383,7 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
break
|
||||
return self.path_found
|
||||
|
||||
def find_category_node(self, key):
|
||||
def find_category_node(self, key, parent=QModelIndex()):
|
||||
'''
|
||||
Search for an category node (a top-level node) in the tags browser list
|
||||
that matches the key (exact case-insensitive match). Returns the path to
|
||||
@ -1163,11 +1392,17 @@ class TagsModel(QAbstractItemModel): # {{{
|
||||
if not key:
|
||||
return None
|
||||
|
||||
for i in xrange(self.rowCount(QModelIndex())):
|
||||
idx = self.index(i, 0, QModelIndex())
|
||||
ckey = idx.internalPointer().category_key
|
||||
for i in xrange(self.rowCount(parent)):
|
||||
idx = self.index(i, 0, parent)
|
||||
node = idx.internalPointer()
|
||||
if node.type == TagTreeItem.CATEGORY:
|
||||
ckey = node.category_key
|
||||
if strcmp(ckey, key) == 0:
|
||||
return self.path_for_index(idx)
|
||||
if len(node.children):
|
||||
v = self.find_category_node(key, idx)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
def show_item_at_path(self, path, box=False):
|
||||
@ -1222,6 +1457,7 @@ class TagBrowserMixin(object): # {{{
|
||||
self.tags_view.tags_marked.connect(self.search.set_search_string)
|
||||
self.tags_view.tag_list_edit.connect(self.do_tags_list_edit)
|
||||
self.tags_view.user_category_edit.connect(self.do_user_categories_edit)
|
||||
self.tags_view.add_subcategory.connect(self.do_add_subcategory)
|
||||
self.tags_view.saved_search_edit.connect(self.do_saved_search_edit)
|
||||
self.tags_view.author_sort_edit.connect(self.do_author_sort_edit)
|
||||
self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed)
|
||||
@ -1230,6 +1466,30 @@ class TagBrowserMixin(object): # {{{
|
||||
self.edit_categories.clicked.connect(lambda x:
|
||||
self.do_user_categories_edit())
|
||||
|
||||
def do_add_subcategory(self, on_category=None):
|
||||
db = self.library_view.model().db
|
||||
user_cats = db.prefs.get('user_categories', {})
|
||||
|
||||
# Ensure that the temporary name we will use is not already there
|
||||
i = 0
|
||||
new_name = _('New Category').replace('.', '')
|
||||
n = new_name
|
||||
while True:
|
||||
new_cat = on_category[1:] + '.' + n
|
||||
if new_cat not in user_cats:
|
||||
break
|
||||
i += 1
|
||||
n = new_name + unicode(i)
|
||||
# Add the new category
|
||||
user_cats[new_cat] = []
|
||||
db.prefs.set('user_categories', user_cats)
|
||||
self.tags_view.set_new_model()
|
||||
m = self.tags_view.model()
|
||||
idx = m.index_for_path(m.find_category_node('@' + new_cat))
|
||||
m.show_item_at_index(idx)
|
||||
# Open the editor on the new item to rename it
|
||||
self.tags_view.edit(idx)
|
||||
|
||||
def do_user_categories_edit(self, on_category=None):
|
||||
db = self.library_view.model().db
|
||||
d = TagCategories(self, db, on_category)
|
||||
|
@ -633,6 +633,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{
|
||||
mb.stop()
|
||||
|
||||
self.hide_windows()
|
||||
if self.emailer.is_alive():
|
||||
self.emailer.stop()
|
||||
try:
|
||||
try:
|
||||
|
@ -124,8 +124,14 @@ def _match(query, value, matchkind):
|
||||
for t in value:
|
||||
t = icu_lower(t)
|
||||
try: ### ignore regexp exceptions, required because search-ahead tries before typing is finished
|
||||
if ((matchkind == EQUALS_MATCH and query == t) or
|
||||
(matchkind == REGEXP_MATCH and re.search(query, t, re.I)) or ### search unanchored
|
||||
if (matchkind == EQUALS_MATCH):
|
||||
if query[0] == '.':
|
||||
if t.startswith(query[1:]):
|
||||
ql = len(query) - 1
|
||||
return (len(t) == ql) or (t[ql:ql+1] == '.')
|
||||
elif query == t:
|
||||
return True
|
||||
elif ((matchkind == REGEXP_MATCH and re.search(query, t, re.I)) or ### search unanchored
|
||||
(matchkind == CONTAINS_MATCH and query in t)):
|
||||
return True
|
||||
except re.error:
|
||||
@ -415,10 +421,22 @@ class ResultCache(SearchQueryParser): # {{{
|
||||
if self.db_prefs is None:
|
||||
return res
|
||||
user_cats = self.db_prefs.get('user_categories', [])
|
||||
if location not in user_cats:
|
||||
return res
|
||||
c = set(candidates)
|
||||
for (item, category, ign) in user_cats[location]:
|
||||
l = location.rfind('.')
|
||||
if l > 0:
|
||||
alt_loc = location[0:l]
|
||||
alt_item = location[l+1:]
|
||||
else:
|
||||
alt_loc = None
|
||||
for key in user_cats:
|
||||
if key == location or key.startswith(location + '.'):
|
||||
for (item, category, ign) in user_cats[key]:
|
||||
s = self.get_matches(category, '=' + item, candidates=c)
|
||||
c -= s
|
||||
res |= s
|
||||
elif key == alt_loc:
|
||||
for (item, category, ign) in user_cats[key]:
|
||||
if item == alt_item:
|
||||
s = self.get_matches(category, '=' + item, candidates=c)
|
||||
c -= s
|
||||
res |= s
|
||||
|
@ -174,6 +174,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
|
||||
self.prefs = DBPrefs(self)
|
||||
defs = self.prefs.defaults
|
||||
defs['gui_restriction'] = defs['cs_restriction'] = ''
|
||||
defs['categories_using_hierarchy'] = []
|
||||
|
||||
# Migrate saved search and user categories to db preference scheme
|
||||
def migrate_preference(key, default):
|
||||
@ -812,6 +813,21 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
|
||||
index_is_id=index_is_id),
|
||||
extra=self.get_custom_extra(idx, label=meta['label'],
|
||||
index_is_id=index_is_id))
|
||||
|
||||
user_cats = self.prefs['user_categories']
|
||||
user_cat_vals = {}
|
||||
for ucat in user_cats:
|
||||
res = []
|
||||
for name,cat,ign in user_cats[ucat]:
|
||||
v = mi.get(cat, None)
|
||||
if isinstance(v, list):
|
||||
if name in v:
|
||||
res.append([name,cat])
|
||||
elif name == v:
|
||||
res.append([name,cat])
|
||||
user_cat_vals[ucat] = res
|
||||
mi.user_categories = user_cat_vals
|
||||
|
||||
if get_cover:
|
||||
mi.cover = self.cover(id, index_is_id=True, as_path=True)
|
||||
return mi
|
||||
@ -1406,7 +1422,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
|
||||
# temporarily duplicating the categories lists.
|
||||
taglist = {}
|
||||
for c in categories.keys():
|
||||
taglist[c] = dict(map(lambda t:(t.name, t), categories[c]))
|
||||
taglist[c] = dict(map(lambda t:(icu_lower(t.name), t), categories[c]))
|
||||
|
||||
muc = self.prefs.get('grouped_search_make_user_categories', [])
|
||||
gst = self.prefs.get('grouped_search_terms', {})
|
||||
@ -1422,8 +1438,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
|
||||
for user_cat in sorted(user_categories.keys(), key=sort_key):
|
||||
items = []
|
||||
for (name,label,ign) in user_categories[user_cat]:
|
||||
if label in taglist and name in taglist[label]:
|
||||
items.append(taglist[label][name])
|
||||
n = icu_lower(name)
|
||||
if label in taglist and n in taglist[label]:
|
||||
items.append(taglist[label][n])
|
||||
# else: do nothing, to not include nodes w zero counts
|
||||
cat_name = '@' + user_cat # add the '@' to avoid name collision
|
||||
# Not a problem if we accumulate entries in the icon map
|
||||
|
@ -32,7 +32,7 @@ category_icon_map = {
|
||||
'news' : 'news.png',
|
||||
'tags' : 'tags.png',
|
||||
'custom:' : 'column.png',
|
||||
'user:' : 'drawer.png',
|
||||
'user:' : 'tb_folder.png',
|
||||
'search' : 'search.png'
|
||||
}
|
||||
|
||||
|
@ -413,6 +413,27 @@ The Book Details display shows you extra information and the cover for the curre
|
||||
|
||||
.. _jobs:
|
||||
|
||||
.. _tag_browser:
|
||||
|
||||
Tag Browser
|
||||
-------------
|
||||
.. image:: images/tag_browser.png
|
||||
|
||||
The Tag Browser allows you to easily browse your collection by Author/Tags/Series/etc. If you click on any Item in the Tag Browser, for example, the Author name, Isaac Asimov, then the list of books to the right is restricted to books by that author. Clicking once again on Isaac Asimov will restrict the list of books to books not by Isaac Asimov. A third click will remove the restriction. If you hold down the Ctrl or Shift keys and click on multiple items, then restrictions based on multiple items are created. For example you could Hold Ctrl and click on the tags History and Europe for find books on European history. The Tag Browser works by constructing search expressions that are automatically entered into the Search bar. It is a good way to learn how to construct basic search expressions.
|
||||
|
||||
There is a search bar at the top of the Tag Browser that allows you to easily find any item in the Tag Browser. In addition, you can right click on any item and choose to hide it or rename it or open a "Manage x" dialog that allows you to manage items of that kind. For example the "Manage Authors" dialog allows you to rename authors and control how their names are sorted.
|
||||
|
||||
For convenience, you can drag and drop books from the book list to items in the Tag Browser and that item will be automatically applied to the dropped books. For example, dragging a book to Isaac Asimov will set the author of that book to Isaac Asimov or dragging it to the tag History will add the tag History to its tags.
|
||||
|
||||
The outer-level items in the tag browser such as Authors and Series are called categories. You can create your own categories, called User Categories, which are useful for organizing items. For example, you can use the user categories editor (push the Manage User Categories button) to create a user category called Favorite Authors, then put the items for your favorites into the category. User categories act like built-in categories; you can click on items to search for them. You can search for all items in a category by right-clicking on the category name and choosing "Search for books in ...".
|
||||
|
||||
User categories can have sub-categories. For example, the user category Favorites.Authors is a sub-category of Favorites. You might also have Favorites.Series, in which case there will be two sub-categories under Favorites. Sub-categories can be created using Manage User Categories by entering names like the Favorites example. They can also be created by right-clicking on a user category, choosing "Add sub-category to ...", and entering the category name.
|
||||
|
||||
It is also possible to create hierarchies inside some of the built-in categories (the text categories). These hierarchies show with the small triangle permitting the sub-items to be hidden. To use hierarchies in a category, you must first go to Preferences / Look & Feel and enter the category name(s) into the "Categories with hierarchical items" box. Once this is done, items in that category that contain periods will be shown using the small triangle. For example, assume you create a custom column called "Genre" and indicate that it contains hierarchical items. Once done, items such as Mystery.Thriller and Mystery.English will display as Mystery with the small triangle next to it. Clicking on the triangle will show Thriller and English as sub-items.
|
||||
|
||||
You can drag and drop items in the Tag browser onto user categories to add them to that category.
|
||||
|
||||
|
||||
Jobs
|
||||
-----
|
||||
.. image:: images/jobs.png
|
||||
|
BIN
src/calibre/manual/images/tag_browser.png
Normal file
BIN
src/calibre/manual/images/tag_browser.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 41 KiB |
@ -396,6 +396,34 @@ class BuiltinListitem(BuiltinFormatterFunction):
|
||||
except:
|
||||
return ''
|
||||
|
||||
class BuiltinSublist(BuiltinFormatterFunction):
|
||||
name = 'sublist'
|
||||
arg_count = 4
|
||||
doc = _('sublist(val, start_index, end_index, separator) -- interpret the '
|
||||
' value as a list of items separated by `separator`, returning a '
|
||||
' new list made from the `start_index`th to the `end_index`th item. '
|
||||
'The first item is number zero. If an index is negative, then it '
|
||||
'counts from the end of the list. As a special case, an end_index '
|
||||
'of zero is assumed to be the length of the list. Examples using '
|
||||
'basic template mode and assuming a #genre value if A.B.C: '
|
||||
'{#genre:sublist(-1,0,.)} returns C<br/>'
|
||||
'{#genre:sublist(0,1,.)} returns A<br/>'
|
||||
'{#genre:sublist(0,-1,.)} returns A.B')
|
||||
|
||||
def evaluate(self, formatter, kwargs, mi, locals, val, start_index, end_index, sep):
|
||||
if not val:
|
||||
return ''
|
||||
si = int(start_index)
|
||||
ei = int(end_index)
|
||||
val = val.split(sep)
|
||||
try:
|
||||
if ei == 0:
|
||||
return sep.join(val[si:])
|
||||
else:
|
||||
return sep.join(val[si:ei])
|
||||
except:
|
||||
return ''
|
||||
|
||||
class BuiltinUppercase(BuiltinFormatterFunction):
|
||||
name = 'uppercase'
|
||||
arg_count = 1
|
||||
@ -447,6 +475,7 @@ builtin_re = BuiltinRe()
|
||||
builtin_shorten = BuiltinShorten()
|
||||
builtin_strcat = BuiltinStrcat()
|
||||
builtin_strcmp = BuiltinStrcmp()
|
||||
builtin_sublist = BuiltinSublist()
|
||||
builtin_substr = BuiltinSubstr()
|
||||
builtin_subtract = BuiltinSubtract()
|
||||
builtin_switch = BuiltinSwitch()
|
||||
|
Loading…
x
Reference in New Issue
Block a user