diff --git a/manual/url_scheme.rst b/manual/url_scheme.rst
index ea5478500a..269941e198 100644
--- a/manual/url_scheme.rst
+++ b/manual/url_scheme.rst
@@ -116,6 +116,16 @@ If you perform a search in calibre and want to generate a link for it you can
do so by right clicking the search bar and choosing :guilabel:`Copy search as
URL`.
+Open a book details window on a book in some library
+------------------------------------------------------
+
+The URL syntax is::
+
+ calibre://book-details/Library_Name/book_id
+
+This opens a book details window on the specified book from the specified library without changing the
+current library or the selected book.
+
.. _hex_encoding:
diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py
index ffc13cfb30..f9b3c06208 100644
--- a/src/calibre/db/backend.py
+++ b/src/calibre/db/backend.py
@@ -1121,6 +1121,7 @@ class DB:
CREATE TABLE %s(
id INTEGER PRIMARY KEY AUTOINCREMENT,
value %s NOT NULL %s,
+ link TEXT NOT NULL DEFAULT "",
UNIQUE(value));
'''%(table, dt, collate),
diff --git a/src/calibre/db/cache.py b/src/calibre/db/cache.py
index 2ef2f5d449..ed29b05c31 100644
--- a/src/calibre/db/cache.py
+++ b/src/calibre/db/cache.py
@@ -153,6 +153,7 @@ class Cache:
self.format_metadata_cache = defaultdict(dict)
self.formatter_template_cache = {}
self.dirtied_cache = {}
+ self.link_maps_cache = {}
self.vls_for_books_cache = None
self.vls_for_books_lib_in_process = None
self.vls_cache_lock = Lock()
@@ -290,6 +291,7 @@ class Cache:
self.format_metadata_cache.clear()
if search_cache:
self._clear_search_caches(book_ids)
+ self.link_maps_cache = {}
@write_api
def reload_from_db(self, clear_caches=True):
@@ -382,6 +384,8 @@ class Cache:
for key in composites:
mi.set(key, val=self._composite_for(key, book_id, mi))
+ mi.link_maps = self.get_all_link_maps_for_book(book_id)
+
user_cat_vals = {}
if get_user_categories:
user_cats = self._pref('user_categories', {})
@@ -2322,6 +2326,93 @@ class Cache:
self._mark_as_dirty(changed_books)
return changed_books
+ @read_api
+ def has_link_map(self, field):
+ if field not in self.fields:
+ raise ValueError(f'Lookup name {field} is not a valid name')
+ table = self.fields[field].table
+ return hasattr(table, 'link_map')
+
+ @read_api
+ def get_link_map(self, for_field):
+ '''
+ Return a dict of links for the supplied field.
+
+ field: the lookup name of the field for which the link map is desired
+
+ returns {field_value:link_value, ...} for non-empty links
+ '''
+ if for_field not in self.fields:
+ raise ValueError(f'Lookup name {for_field} is not a valid name')
+ table = self.fields[for_field].table
+ if not hasattr(table, 'link_map'):
+ raise ValueError(f"Lookup name {for_field} doesn't have a link map")
+ lm = table.link_map
+ vm = table.id_map
+ return dict({vm.get(fid, None):v for fid,v in lm.items() if v})
+
+ @read_api
+ def get_all_link_maps_for_book(self, book_id):
+ '''
+ Returns all links for all fields referenced by book identified by book_id
+
+ book_id: the book id in question.
+
+ returns:
+ {field: {field_value, link_value}, ...
+ for all fields that have a non-empty link value for that book
+
+ Example: Assume author A has link X, author B has link Y, tag S has link
+ F, and tag T has link G. IF book 1 has author A and
+ tag T, this method returns {'authors':{'A':'X'}, 'tags':{'T', 'G'}}
+ If book 2's author is neither A nor B and has no tags, this
+ method returns {}
+ '''
+ if book_id in self.link_maps_cache:
+ return self.link_maps_cache[book_id]
+ links = {}
+ def add_links_for_field(f):
+ field_ids = frozenset(self.field_ids_for(f, book_id))
+ table = self.fields[f].table
+ lm = table.link_map
+ vm = table.id_map
+ d = dict({vm.get(fid, None):v for fid,v in lm.items() if v and fid in field_ids})
+ if d:
+ links[f] = d
+ for field in ('authors', 'publisher', 'series', 'tags'):
+ add_links_for_field(field)
+ for field in self.field_metadata.custom_field_keys(include_composites=False):
+ if self.has_link_map(field):
+ add_links_for_field(field)
+ self.link_maps_cache[book_id] = links
+ return links
+
+ @write_api
+ def set_link_map(self, field, value_to_link_map):
+ '''
+ Sets links for item values in field
+
+ field: the lookup name
+ value_to_link_map: dict(field_value:link, ...). Note that these are
+ values, not field ids.
+
+ returns books changed by setting the link
+ '''
+ if field not in self.fields:
+ raise ValueError(f'Lookup name {field} is not a valid name')
+ table = self.fields[field].table
+ if not hasattr(table, 'link_map'):
+ raise ValueError(f"Lookup name {field} doesn't have a link map")
+ fids = {k: self.get_item_id(field, k) for k in value_to_link_map.keys()}
+ id_to_link_map = {fid:value_to_link_map[k] for k, fid in fids.items() if fid is not None}
+ result_map = table.set_links(id_to_link_map, self.backend)
+ changed_books = set()
+ for id_ in result_map:
+ changed_books |= self._books_for_field(field, id_)
+ if changed_books:
+ self._mark_as_dirty(changed_books)
+ return changed_books
+
@read_api
def lookup_by_uuid(self, uuid):
return self.fields['uuid'].table.lookup_by_uuid(uuid)
diff --git a/src/calibre/db/fields.py b/src/calibre/db/fields.py
index 49da62475d..a6a443efd9 100644
--- a/src/calibre/db/fields.py
+++ b/src/calibre/db/fields.py
@@ -642,7 +642,7 @@ class AuthorsField(ManyToManyField):
return {
'name': self.table.id_map[author_id],
'sort': self.table.asort_map[author_id],
- 'link': self.table.alink_map[author_id],
+ 'link': self.table.link_map[author_id],
}
def category_sort_value(self, item_id, book_ids, lang_map):
diff --git a/src/calibre/db/lazy.py b/src/calibre/db/lazy.py
index 617c590ecf..2ee0ded5c1 100644
--- a/src/calibre/db/lazy.py
+++ b/src/calibre/db/lazy.py
@@ -330,6 +330,9 @@ class ProxyMetadata(Metadata):
sa(self, '_user_metadata', db.field_metadata)
def __getattribute__(self, field):
+ if field == 'link_maps':
+ db = ga(self, '_db')()
+ return db.get_all_link_maps_for_book(ga(self, '_book_id'))
getter = getters.get(field, None)
if getter is not None:
return getter(ga(self, '_db'), ga(self, '_book_id'), ga(self, '_cache'))
diff --git a/src/calibre/db/schema_upgrades.py b/src/calibre/db/schema_upgrades.py
index e5b4bf4419..f7e888f02d 100644
--- a/src/calibre/db/schema_upgrades.py
+++ b/src/calibre/db/schema_upgrades.py
@@ -793,3 +793,28 @@ CREATE TRIGGER fkc_annot_update
def upgrade_version_24(self):
self.db.reindex_annotations()
+
+ def upgrade_version_25(self):
+ for record in self.db.execute(
+ 'SELECT label,name,datatype,editable,display,normalized,id,is_multiple FROM custom_columns'):
+ data = {
+ 'label':record[0],
+ 'name':record[1],
+ 'datatype':record[2],
+ 'editable':bool(record[3]),
+ 'display':record[4],
+ 'normalized':bool(record[5]),
+ 'num':record[6],
+ 'is_multiple':bool(record[7]),
+ }
+ if data['normalized']:
+ tn = 'custom_column_{}'.format(data['num'])
+ self.db.execute(f'ALTER TABLE {tn} ADD COLUMN link TEXT NOT NULL DEFAULT "";')
+ self.db.execute('ALTER TABLE publishers ADD COLUMN link TEXT NOT NULL DEFAULT "";')
+ self.db.execute('ALTER TABLE series ADD COLUMN link TEXT NOT NULL DEFAULT "";')
+ self.db.execute('ALTER TABLE tags ADD COLUMN link TEXT NOT NULL DEFAULT "";')
+ # These aren't necessary in that there is no UI to set links, but having them
+ # makes the code uniform
+ self.db.execute('ALTER TABLE languages ADD COLUMN link TEXT NOT NULL DEFAULT "";')
+ self.db.execute('ALTER TABLE ratings ADD COLUMN link TEXT NOT NULL DEFAULT "";')
+
diff --git a/src/calibre/db/tables.py b/src/calibre/db/tables.py
index 4a2a4da706..18658a3401 100644
--- a/src/calibre/db/tables.py
+++ b/src/calibre/db/tables.py
@@ -199,19 +199,22 @@ class ManyToOneTable(Table):
def read(self, db):
self.id_map = {}
+ self.link_map = {}
self.col_book_map = defaultdict(set)
self.book_col_map = {}
self.read_id_maps(db)
self.read_maps(db)
def read_id_maps(self, db):
- query = db.execute('SELECT id, {} FROM {}'.format(
+ query = db.execute('SELECT id, {}, link FROM {}'.format(
self.metadata['column'], self.metadata['table']))
if self.unserialize is None:
- self.id_map = dict(query)
+ us = lambda x: x
else:
us = self.unserialize
- self.id_map = {book_id:us(val) for book_id, val in query}
+ for id_, val, link in query:
+ self.id_map[id_] = us(val)
+ self.link_map[id_] = link
def read_maps(self, db):
cbm = self.col_book_map
@@ -343,6 +346,14 @@ class ManyToOneTable(Table):
self.link_table, lcol, table), (existing_item, item_id, item_id))
return affected_books, new_id
+ def set_links(self, link_map, db):
+ link_map = {id_:(l or '').strip() for id_, l in iteritems(link_map)}
+ link_map = {id_:l for id_, l in iteritems(link_map) if l != self.link_map.get(id_)}
+ self.link_map.update(link_map)
+ db.executemany(f'UPDATE {self.metadata["table"]} SET link=? WHERE id=?',
+ [(v, k) for k, v in iteritems(link_map)])
+ return link_map
+
class RatingTable(ManyToOneTable):
@@ -526,7 +537,7 @@ class ManyToManyTable(ManyToOneTable):
class AuthorsTable(ManyToManyTable):
def read_id_maps(self, db):
- self.alink_map = lm = {}
+ self.link_map = lm = {}
self.asort_map = sm = {}
self.id_map = im = {}
us = self.unserialize
@@ -547,8 +558,8 @@ class AuthorsTable(ManyToManyTable):
def set_links(self, link_map, db):
link_map = {aid:(l or '').strip() for aid, l in iteritems(link_map)}
- link_map = {aid:l for aid, l in iteritems(link_map) if l != self.alink_map.get(aid, None)}
- self.alink_map.update(link_map)
+ link_map = {aid:l for aid, l in iteritems(link_map) if l != self.link_map.get(aid, None)}
+ self.link_map.update(link_map)
db.executemany('UPDATE authors SET link=? WHERE id=?',
[(v, k) for k, v in iteritems(link_map)])
return link_map
@@ -556,14 +567,14 @@ class AuthorsTable(ManyToManyTable):
def remove_books(self, book_ids, db):
clean = ManyToManyTable.remove_books(self, book_ids, db)
for item_id in clean:
- self.alink_map.pop(item_id, None)
+ self.link_map.pop(item_id, None)
self.asort_map.pop(item_id, None)
return clean
def rename_item(self, item_id, new_name, db):
ret = ManyToManyTable.rename_item(self, item_id, new_name, db)
if item_id not in self.id_map:
- self.alink_map.pop(item_id, None)
+ self.link_map.pop(item_id, None)
self.asort_map.pop(item_id, None)
else:
# Was a simple rename, update the author sort value
diff --git a/src/calibre/db/tests/add_remove.py b/src/calibre/db/tests/add_remove.py
index 34e627d5c3..2485836d35 100644
--- a/src/calibre/db/tests/add_remove.py
+++ b/src/calibre/db/tests/add_remove.py
@@ -225,7 +225,7 @@ class AddRemoveTest(BaseTest):
self.assertNotIn(3, c.all_book_ids())
self.assertNotIn('Unknown', set(itervalues(table.id_map)))
self.assertNotIn(item_id, table.asort_map)
- self.assertNotIn(item_id, table.alink_map)
+ self.assertNotIn(item_id, table.link_map)
ae(len(table.id_map), olen-1)
# Check that files are removed
diff --git a/src/calibre/db/tests/writing.py b/src/calibre/db/tests/writing.py
index 5dbfe5c2ef..fc01e295ac 100644
--- a/src/calibre/db/tests/writing.py
+++ b/src/calibre/db/tests/writing.py
@@ -919,4 +919,28 @@ class WritingTest(BaseTest):
ae(cache.field_for('series_index', 1), 2.0)
ae(cache.field_for('series_index', 2), 3.5)
+ def test_link_maps(self):
+ cache = self.init_cache()
+
+ cache.set_field('tags', {1:'foo'})
+ self.assertEqual(('foo',), cache.field_for('tags', 1), 'Setting tag foo failed')
+ cache.set_field('tags', {1:'foo, bar'})
+ self.assertEqual(('foo', 'bar'), cache.field_for('tags', 1), 'Adding second tag failed')
+
+ links = cache.get_link_map('tags')
+ self.assertDictEqual(links, {}, 'Initial tags link dict is not empty')
+ links['foo'] = 'url'
+ cache.set_link_map('tags', links)
+ links2 = cache.get_link_map('tags')
+ self.assertDictEqual(links2, links, 'tags link dict mismatch')
+
+ cache.set_field('publisher', {1:'random'})
+ cache.set_link_map('publisher', {'random': 'url2'})
+
+ links = cache.get_all_link_maps_for_book(1)
+ self.assert_('foo' in links['tags'], 'foo not there')
+ self.assert_('bar' not in links['tags'], 'bar is there')
+ self.assert_('random' in links['publisher'], 'random is not there')
+ self.assertSetEqual({'tags', 'publisher'}, set(links.keys()), 'Link map has extra stuff')
+
# }}}
diff --git a/src/calibre/db/write.py b/src/calibre/db/write.py
index 8b240316ff..48ebd1fd03 100644
--- a/src/calibre/db/write.py
+++ b/src/calibre/db/write.py
@@ -295,7 +295,8 @@ def get_db_id(val, db, m, table, kmap, rid_map, allow_case_change,
table.col_book_map[item_id] = set()
if is_authors:
table.asort_map[item_id] = aus
- table.alink_map[item_id] = ''
+ if hasattr(table, 'link_map'):
+ table.link_map[item_id] = ''
elif allow_case_change and val != table.id_map[item_id]:
case_changes[item_id] = val
val_map[val] = item_id
@@ -491,7 +492,8 @@ def many_many(book_id_val_map, db, field, allow_case_change, *args):
table.col_book_map.pop(item_id, None)
if is_authors:
table.asort_map.pop(item_id, None)
- table.alink_map.pop(item_id, None)
+ if hasattr(table, 'link_map'):
+ table.link_map.pop(item_id, None)
return dirtied
diff --git a/src/calibre/ebooks/metadata/book/render.py b/src/calibre/ebooks/metadata/book/render.py
index e8fd0e5ee8..432be82ad4 100644
--- a/src/calibre/ebooks/metadata/book/render.py
+++ b/src/calibre/ebooks/metadata/book/render.py
@@ -91,6 +91,24 @@ def mi_to_html(
rating_font='Liberation Serif', rtl=False, comments_heading_pos='hide',
for_qt=False, vertical_fields=()
):
+
+ show_links = not hasattr(mi, '_bd_dbwref')
+
+ def get_link_map(column):
+ if not show_links:
+ return {}
+ try:
+ return mi.link_maps[column]
+ except (KeyError, ValueError):
+ return {column:{}}
+
+ def add_other_link(field, field_value):
+ link = get_link_map(field).get(field_value, None)
+ if link:
+ return (' %s'%(_('Click to open {}').format(link), link, _('(item link)')))
+ else:
+ return ''
+
if field_list is None:
field_list = get_field_list(mi)
ans = []
@@ -170,9 +188,12 @@ def mi_to_html(
else:
all_vals = [v.strip()
for v in val.split(metadata['is_multiple']['cache_to_list']) if v.strip()]
- links = ['{}'.format(
- search_action(field, x), _('Click to see books with {0}: {1}').format(
+ if show_links:
+ links = ['{}'.format(
+ search_action(field, x), _('Click to see books with {0}: {1}').format(
metadata['name'], a(x)), p(x)) for x in all_vals]
+ else:
+ links = all_vals
val = value_list(metadata['is_multiple']['list_to_ui'], links)
ans.append((field, row % (name, val)))
elif field == 'path':
@@ -188,10 +209,15 @@ def mi_to_html(
durl = ':::'.join((durl.split(':::'))[2:])
extra = '
%s'%(
prepare_string_for_xml(durl))
- link = '{}{}'.format(action(scheme, loc=loc),
+ if show_links:
+ link = '{}{}'.format(action(scheme, loc=loc),
prepare_string_for_xml(path, True), pathstr, extra)
+ else:
+ link = prepare_string_for_xml(path, True)
ans.append((field, row % (name, link)))
elif field == 'formats':
+ # Don't need show_links here because formats are removed from mi on
+ # cross library displays.
if isdevice:
continue
path = mi.path or ''
@@ -209,12 +235,15 @@ def mi_to_html(
ans.append((field, row % (name, value_list(', ', fmts))))
elif field == 'identifiers':
urls = urls_from_identifiers(mi.identifiers, sort_results=True)
- links = [
- '{}'.format(
- action('identifier', url=url, name=namel, id_type=id_typ, value=id_val, field='identifiers', book_id=book_id),
- a(id_typ), a(id_val), p(namel))
- for namel, id_typ, id_val, url in urls]
- links = value_list(', ', links)
+ if show_links:
+ links = [
+ '{}'.format(
+ action('identifier', url=url, name=namel, id_type=id_typ, value=id_val, field='identifiers', book_id=book_id),
+ a(id_typ), a(id_val), p(namel))
+ for namel, id_typ, id_val, url in urls]
+ links = value_list(', ', links)
+ else:
+ links = ', '.join(mi.identifiers)
if links:
ans.append((field, row % (_('Ids')+':', links)))
elif field == 'authors':
@@ -222,39 +251,46 @@ def mi_to_html(
formatter = EvalFormatter()
for aut in mi.authors:
link = ''
- if mi.author_link_map.get(aut):
- link = lt = mi.author_link_map[aut]
- elif default_author_link:
- if default_author_link.startswith('search-'):
- which_src = default_author_link.partition('-')[2]
- link, lt = author_search_href(which_src, title=mi.title, author=aut)
+ if show_links:
+ if default_author_link:
+ if default_author_link.startswith('search-'):
+ which_src = default_author_link.partition('-')[2]
+ link, lt = author_search_href(which_src, title=mi.title, author=aut)
+ else:
+ vals = {'author': qquote(aut), 'title': qquote(mi.title)}
+ try:
+ vals['author_sort'] = qquote(mi.author_sort_map[aut])
+ except KeyError:
+ vals['author_sort'] = qquote(aut)
+ link = lt = formatter.safe_format(default_author_link, vals, '', vals)
else:
- vals = {'author': qquote(aut), 'title': qquote(mi.title)}
- try:
- vals['author_sort'] = qquote(mi.author_sort_map[aut])
- except KeyError:
- vals['author_sort'] = qquote(aut)
- link = lt = formatter.safe_format(default_author_link, vals, '', vals)
- aut = p(aut)
+ aut = p(aut)
if link:
- authors.append('%s'%(a(lt), action('author', url=link, name=aut, title=lt), aut))
+ val = '%s'%(a(lt), action('author', url=link, name=aut, title=lt), aut)
else:
- authors.append(aut)
+ val = aut
+ val += add_other_link('authors', aut)
+ authors.append(val)
ans.append((field, row % (name, value_list(' & ', authors))))
elif field == 'languages':
if not mi.languages:
continue
names = filter(None, map(calibre_langcode_to_name, mi.languages))
- names = ['{}'.format(search_action_with_data('languages', n, book_id), _(
- 'Search calibre for books with the language: {}').format(n), n) for n in names]
+ if show_links:
+ names = ['{}'.format(search_action_with_data('languages', n, book_id), _(
+ 'Search calibre for books with the language: {}').format(n), n) for n in names]
ans.append((field, row % (name, value_list(', ', names))))
elif field == 'publisher':
if not mi.publisher:
continue
- val = '{}'.format(
- search_action_with_data('publisher', mi.publisher, book_id),
- _('Click to see books with {0}: {1}').format(metadata['name'], a(mi.publisher)),
- p(mi.publisher))
+ if show_links:
+ val = '{}'.format(
+ search_action_with_data('publisher', mi.publisher, book_id),
+ _('Click to see books with {0}: {1}').format(metadata['name'], a(mi.publisher)),
+ p(mi.publisher))
+ val += add_other_link('publisher', mi.publisher)
+ else:
+ val = p(mi.publisher)
ans.append((field, row % (name, val)))
elif field == 'title':
# otherwise title gets metadata['datatype'] == 'text'
@@ -268,79 +304,85 @@ def mi_to_html(
if val is None:
continue
val = p(val)
- if metadata['datatype'] == 'series':
- sidx = mi.get(field+'_index')
- if sidx is None:
- sidx = 1.0
- try:
- st = metadata['search_terms'][0]
- except Exception:
- st = field
- series = getattr(mi, field)
- val = _(
- '%(sidx)s of '
- '%(series)s') % dict(
- sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls="series_name",
- series=p(series), href=search_action_with_data(st, series, book_id, field),
- tt=p(_('Click to see books in this series')))
- elif metadata['datatype'] == 'datetime':
- aval = getattr(mi, field)
- if is_date_undefined(aval):
- continue
- aval = format_date(aval, 'yyyy-MM-dd')
- key = field if field != 'timestamp' else 'date'
- if val == aval:
- val = '{}'.format(
- search_action_with_data(key, str(aval), book_id, None, original_value=val), a(
- _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
- else:
- val = '{}'.format(
- search_action_with_data(key, str(aval), book_id, None, original_value=val), a(
- _('Click to see books with {0}: {1} (derived from {2})').format(
- metadata['name'] or field, aval, val)), val)
- elif metadata['datatype'] == 'text' and metadata['is_multiple']:
- try:
- st = metadata['search_terms'][0]
- except Exception:
- st = field
- all_vals = mi.get(field)
- if not metadata.get('display', {}).get('is_names', False):
- all_vals = sorted(all_vals, key=sort_key)
- links = ['{}'.format(
- search_action_with_data(st, x, book_id, field), _('Click to see books with {0}: {1}').format(
- metadata['name'] or field, a(x)), p(x))
- for x in all_vals]
- val = value_list(metadata['is_multiple']['list_to_ui'], links)
- elif metadata['datatype'] == 'text' or metadata['datatype'] == 'enumeration':
- # text/is_multiple handled above so no need to add the test to the if
- try:
- st = metadata['search_terms'][0]
- except Exception:
- st = field
- val = '{}'.format(
- search_action_with_data(st, unescaped_val, book_id, field), a(
- _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
- elif metadata['datatype'] == 'bool':
- val = '{}'.format(
- search_action_with_data(field, val, book_id, None), a(
- _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
- else:
- try:
- aval = str(getattr(mi, field))
- if not aval:
+ if show_links:
+ if metadata['datatype'] == 'series':
+ sidx = mi.get(field+'_index')
+ if sidx is None:
+ sidx = 1.0
+ try:
+ st = metadata['search_terms'][0]
+ except Exception:
+ st = field
+ series = getattr(mi, field)
+ val = _(
+ '%(sidx)s of '
+ '%(series)s') % dict(
+ sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls="series_name",
+ series=p(series), href=search_action_with_data(st, series, book_id, field),
+ tt=p(_('Click to see books in this series')))
+ val += add_other_link('series', mi.series)
+ elif metadata['datatype'] == 'datetime':
+ aval = getattr(mi, field)
+ if is_date_undefined(aval):
continue
+ aval = format_date(aval, 'yyyy-MM-dd')
+ key = field if field != 'timestamp' else 'date'
if val == aval:
val = '{}'.format(
- search_action_with_data(field, str(aval), book_id, None, original_value=val), a(
+ search_action_with_data(key, str(aval), book_id, None, original_value=val), a(
_('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
else:
val = '{}'.format(
- search_action_with_data(field, str(aval), book_id, None, original_value=val), a(
+ search_action_with_data(key, str(aval), book_id, None, original_value=val), a(
_('Click to see books with {0}: {1} (derived from {2})').format(
metadata['name'] or field, aval, val)), val)
- except Exception:
- import traceback
- traceback.print_exc()
+ elif metadata['datatype'] == 'text' and metadata['is_multiple']:
+ try:
+ st = metadata['search_terms'][0]
+ except Exception:
+ st = field
+ all_vals = mi.get(field)
+ if not metadata.get('display', {}).get('is_names', False):
+ all_vals = sorted(all_vals, key=sort_key)
+ links = []
+ for x in all_vals:
+ v = '{}'.format(
+ search_action_with_data(st, x, book_id, field), _('Click to see books with {0}: {1}').format(
+ metadata['name'] or field, a(x)), p(x))
+ v += add_other_link(field, x)
+ links.append(v)
+ val = value_list(metadata['is_multiple']['list_to_ui'], links)
+ elif metadata['datatype'] == 'text' or metadata['datatype'] == 'enumeration':
+ # text/is_multiple handled above so no need to add the test to the if
+ try:
+ st = metadata['search_terms'][0]
+ except Exception:
+ st = field
+ v = '{}'.format(
+ search_action_with_data(st, unescaped_val, book_id, field), a(
+ _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
+ val = v + add_other_link(field, val)
+ elif metadata['datatype'] == 'bool':
+ val = '{}'.format(
+ search_action_with_data(field, val, book_id, None), a(
+ _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
+ else:
+ try:
+ aval = str(getattr(mi, field))
+ if not aval:
+ continue
+ if val == aval:
+ val = '{}'.format(
+ search_action_with_data(field, str(aval), book_id, None, original_value=val), a(
+ _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
+ else:
+ val = '{}'.format(
+ search_action_with_data(field, str(aval), book_id, None, original_value=val), a(
+ _('Click to see books with {0}: {1} (derived from {2})').format(
+ metadata['name'] or field, aval, val)), val)
+ except Exception:
+ import traceback
+ traceback.print_exc()
ans.append((field, row % (name, val)))
diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py
index 50dc3384a9..b56eb178f4 100644
--- a/src/calibre/ebooks/metadata/opf2.py
+++ b/src/calibre/ebooks/metadata/opf2.py
@@ -595,6 +595,8 @@ class OPF: # {{{
renderer=dump_dict)
author_link_map = MetadataField('author_link_map', is_dc=False,
formatter=json.loads, renderer=dump_dict)
+ link_map = MetadataField('link_maps', is_dc=False,
+ formatter=json.loads, renderer=dump_dict)
def __init__(self, stream, basedir=os.getcwd(), unquote_urls=True,
populate_spine=True, try_to_guess_cover=False, preparsed_opf=None, read_toc=True):
@@ -1310,7 +1312,7 @@ class OPF: # {{{
for attr in ('title', 'authors', 'author_sort', 'title_sort',
'publisher', 'series', 'series_index', 'rating',
'isbn', 'tags', 'category', 'comments', 'book_producer',
- 'pubdate', 'user_categories', 'author_link_map'):
+ 'pubdate', 'user_categories', 'author_link_map', 'link_map'):
val = getattr(mi, attr, None)
if attr == 'rating' and val:
val = float(val)
@@ -1673,6 +1675,8 @@ def metadata_to_opf(mi, as_string=True, default_lang=None):
return factory('meta', name='calibre:' + n, content=c)
if getattr(mi, 'author_link_map', None) is not None:
meta('author_link_map', dump_dict(mi.author_link_map))
+ if getattr(mi, 'link_maps', None) is not None:
+ meta('link_maps', dump_dict(mi.link_maps))
if mi.series:
meta('series', mi.series)
if mi.series_index is not None:
diff --git a/src/calibre/gui2/actions/show_book_details.py b/src/calibre/gui2/actions/show_book_details.py
index e74847885e..6a79e847a1 100644
--- a/src/calibre/gui2/actions/show_book_details.py
+++ b/src/calibre/gui2/actions/show_book_details.py
@@ -23,25 +23,61 @@ class ShowBookDetailsAction(InterfaceAction):
def genesis(self):
self.qaction.triggered.connect(self.show_book_info)
self.memory = []
+ self.dialogs = [None, ]
- def show_book_info(self, *args):
+ def show_book_info(self, *args, **kwargs):
if self.gui.current_view() is not self.gui.library_view:
error_dialog(self.gui, _('No detailed info available'),
_('No detailed information is available for books '
'on the device.')).exec()
return
+ library_path = kwargs.get('library_path', None)
+ book_id = kwargs.get('book_id', None)
+ library_id = kwargs.get('library_id', None)
+ query = kwargs.get('query', None)
index = self.gui.library_view.currentIndex()
- if index.isValid():
- d = BookInfo(self.gui, self.gui.library_view, index,
- self.gui.book_details.handle_click)
+ if library_path or index.isValid():
+ # Window #0 is slaved to changes in the book list. As such
+ # it must not be used for details from other libraries.
+ for dn,v in enumerate(self.dialogs):
+ if dn == 0 and library_path:
+ continue
+ if v is None:
+ break
+ else:
+ self.dialogs.append(None)
+ dn += 1
+
+ try:
+ d = BookInfo(self.gui, self.gui.library_view, index,
+ self.gui.book_details.handle_click, dialog_number=dn,
+ library_id=library_id, library_path=library_path, book_id=book_id, query=query)
+ except ValueError as e:
+ error_dialog(self.gui, _('Book not found'), str(e)).exec()
+ return
+
d.open_cover_with.connect(self.gui.bd_open_cover_with, type=Qt.ConnectionType.QueuedConnection)
+ self.dialogs[dn] = d
self.memory.append(d)
d.closed.connect(self.closed, type=Qt.ConnectionType.QueuedConnection)
d.show()
+ def shutting_down(self):
+ for d in self.dialogs:
+ if d:
+ d.done(0)
+
+ def library_about_to_change(self, *args):
+ for i,d in enumerate(self.dialogs):
+ if i == 0:
+ continue
+ if d:
+ d.done(0)
+
def closed(self, d):
try:
d.closed.disconnect(self.closed)
+ self.dialogs[d.dialog_number] = None
self.memory.remove(d)
except ValueError:
pass
diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py
index fc38d86d41..f39fbf5b12 100644
--- a/src/calibre/gui2/book_details.py
+++ b/src/calibre/gui2/book_details.py
@@ -42,12 +42,15 @@ InternetSearch = namedtuple('InternetSearch', 'author where')
def set_html(mi, html, text_browser):
- from calibre.gui2.ui import get_gui
- gui = get_gui()
+ if hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None:
+ db = mi._bd_dbwref
+ else:
+ from calibre.gui2.ui import get_gui
+ db = get_gui().current_db
book_id = getattr(mi, 'id', None)
search_paths = []
- if gui and book_id is not None:
- path = gui.current_db.abspath(book_id, index_is_id=True)
+ if db and book_id is not None:
+ path = db.abspath(book_id, index_is_id=True)
if path:
search_paths = [path]
text_browser.setSearchPaths(search_paths)
@@ -203,10 +206,16 @@ def comments_pat():
return re.compile(r'', re.DOTALL)
-def render_html(mi, vertical, widget, all_fields=False, render_data_func=None, pref_name='book_display_fields'): # {{{
- from calibre.gui2.ui import get_gui
+def render_html(mi, vertical, widget, all_fields=False, render_data_func=None,
+ pref_name='book_display_fields',
+ pref_value=None): # {{{
+ if hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None:
+ db = mi._bd_dbwref
+ else:
+ from calibre.gui2.ui import get_gui
+ db = get_gui().current_db
func = render_data_func or partial(render_data,
- vertical_fields=get_gui().current_db.prefs.get('book_details_vertical_categories') or ())
+ vertical_fields=db.prefs.get('book_details_vertical_categories') or ())
try:
table, comment_fields = func(mi, all_fields=all_fields,
use_roman_numbers=config['use_roman_numerals_for_series_number'], pref_name=pref_name)
@@ -246,9 +255,12 @@ def render_html(mi, vertical, widget, all_fields=False, render_data_func=None, p
return ans
-def get_field_list(fm, use_defaults=False, pref_name='book_display_fields'):
- from calibre.gui2.ui import get_gui
- db = get_gui().current_db
+def get_field_list(fm, use_defaults=False, pref_name='book_display_fields', mi=None):
+ if mi is not None and hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None:
+ db = mi._bd_dbwref
+ else:
+ from calibre.gui2.ui import get_gui
+ db = get_gui().current_db
if use_defaults:
src = db.prefs.defaults
else:
@@ -267,7 +279,8 @@ def get_field_list(fm, use_defaults=False, pref_name='book_display_fields'):
def render_data(mi, use_roman_numbers=True, all_fields=False, pref_name='book_display_fields',
vertical_fields=()):
- field_list = get_field_list(getattr(mi, 'field_metadata', field_metadata), pref_name=pref_name)
+ field_list = get_field_list(getattr(mi, 'field_metadata', field_metadata),
+ pref_name=pref_name, mi=mi)
field_list = [(x, all_fields or display) for x, display in field_list]
return mi_to_html(
mi, field_list=field_list, use_roman_numbers=use_roman_numbers, rtl=is_rtl(),
diff --git a/src/calibre/gui2/dialogs/book_info.py b/src/calibre/gui2/dialogs/book_info.py
index 19ed838c92..e205a21119 100644
--- a/src/calibre/gui2/dialogs/book_info.py
+++ b/src/calibre/gui2/dialogs/book_info.py
@@ -3,6 +3,8 @@
import textwrap
+import weakref
+
from qt.core import (
QAction, QApplication, QBrush, QCheckBox, QDialog, QDialogButtonBox, QGridLayout,
QHBoxLayout, QIcon, QKeySequence, QLabel, QListView, QModelIndex, QPalette, QPixmap,
@@ -114,17 +116,19 @@ class Configure(Dialog):
class Details(HTMLDisplay):
- def __init__(self, book_info, parent=None):
+ def __init__(self, book_info, parent=None, allow_context_menu=True):
HTMLDisplay.__init__(self, parent)
self.book_info = book_info
self.edit_metadata = getattr(parent, 'edit_metadata', None)
self.setDefaultStyleSheet(css())
+ self.allow_context_menu = allow_context_menu
def sizeHint(self):
return QSize(350, 350)
def contextMenuEvent(self, ev):
- details_context_menu_event(self, ev, self.book_info, edit_metadata=self.edit_metadata)
+ if self.allow_context_menu:
+ details_context_menu_event(self, ev, self.book_info, edit_metadata=self.edit_metadata)
class BookInfo(QDialog):
@@ -132,8 +136,11 @@ class BookInfo(QDialog):
closed = pyqtSignal(object)
open_cover_with = pyqtSignal(object, object)
- def __init__(self, parent, view, row, link_delegate):
- QDialog.__init__(self, parent)
+ def __init__(self, parent, view, row, link_delegate, dialog_number=None,
+ library_id=None, library_path=None, book_id=None, query=None):
+ QDialog.__init__(self, None, flags=Qt.WindowType.Window)
+ self.dialog_number = dialog_number
+ self.library_id = library_id
self.marked = None
self.gui = parent
self.splitter = QSplitter(self)
@@ -150,7 +157,8 @@ class BookInfo(QDialog):
self.cover.sizeHint = self.details_size_hint
self.splitter.addWidget(self.cover)
- self.details = Details(parent.book_details.book_info, self)
+ self.details = Details(parent.book_details.book_info, self,
+ allow_context_menu=library_path is None)
self.details.anchor_clicked.connect(self.on_link_clicked)
self.link_delegate = link_delegate
self.details.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False)
@@ -172,47 +180,76 @@ class BookInfo(QDialog):
hl.setContentsMargins(0, 0, 0, 0)
l2.addLayout(hl, l2.rowCount(), 0, 1, -1)
hl.addWidget(self.fit_cover), hl.addStretch()
- self.clabel = QLabel('