This is a large commit.

DB changes:
1) Add link columns to "normalized" tables. All such in-memory tables have an attribute self.link_map.
2) Add API to set and get links for fields
3) get_metadata now includes an attribute giving the link maps for the book. Book link maps are cached.
4) ProxyMetadata can return link maps
5) Added a test for the API.

URL Scheme:
1) Added a "book-details" URL that asks calibre to open a book info window on a book in some library

Book Details:
1) You can now have multiple book info windows.
2) If an item as an associated link then that link is made available using "(item link)" link text.
3) Book info windows on books in other libraries have no links

UI:
1) the Manage Category editor presents a fourth column for links.

OPF:
1) The OPF used for backing up (metadata.opf) contains the link map for the book. Currently this isn't used, but it should be used in recover_database. I didn't do anything with OPF3.
This commit is contained in:
Charles Haley 2023-03-25 16:46:14 +00:00
parent b2aa59b484
commit ff4ed26896
18 changed files with 519 additions and 173 deletions

View File

@ -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:

View File

@ -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),

View File

@ -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)

View File

@ -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):

View File

@ -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'))

View File

@ -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 "";')

View File

@ -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

View File

@ -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

View File

@ -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')
# }}}

View File

@ -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

View File

@ -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 (' <a title="%s" href="%s">%s</a>'%(_('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 = ['<a href="{}" title="{}">{}</a>'.format(
search_action(field, x), _('Click to see books with {0}: {1}').format(
if show_links:
links = ['<a href="{}" title="{}">{}</a>'.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 = '<br><span style="font-size:smaller">%s</span>'%(
prepare_string_for_xml(durl))
link = '<a href="{}" title="{}">{}</a>{}'.format(action(scheme, loc=loc),
if show_links:
link = '<a href="{}" title="{}">{}</a>{}'.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 = [
'<a href="{}" title="{}:{}">{}</a>'.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 = [
'<a href="{}" title="{}:{}">{}</a>'.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('<a title="%s" href="%s">%s</a>'%(a(lt), action('author', url=link, name=aut, title=lt), aut))
val = '<a title="%s" href="%s">%s</a>'%(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 = ['<a href="{}" title="{}">{}</a>'.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 = ['<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 <a href="%(href)s" title="%(tt)s">'
'<span class="%(cls)s">%(series)s</span></a>') % 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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 = ['<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 <a href="%(href)s" title="%(tt)s">'
'<span class="%(cls)s">%(series)s</span></a>') % 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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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 = '<a href="{}" title="{}">{}</a>'.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)))

View File

@ -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:

View File

@ -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

View File

@ -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(),

View File

@ -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('<div style="text-align: right"><a href="calibre:conf" title="{}" style="text-decoration: none">{}</a>'.format(
_('Configure this view'), _('Configure')))
self.clabel.linkActivated.connect(self.configure)
hl.addWidget(self.clabel)
self.previous_button = QPushButton(QIcon.ic('previous.png'), _('&Previous'), self)
self.previous_button.clicked.connect(self.previous)
l2.addWidget(self.previous_button, l2.rowCount(), 0)
self.next_button = QPushButton(QIcon.ic('next.png'), _('&Next'), self)
self.next_button.clicked.connect(self.next)
l2.addWidget(self.next_button, l2.rowCount() - 1, 1)
if self.dialog_number == 0:
self.previous_button = QPushButton(QIcon.ic('previous.png'), _('&Previous'), self)
self.previous_button.clicked.connect(self.previous)
l2.addWidget(self.previous_button, l2.rowCount(), 0)
self.next_button = QPushButton(QIcon.ic('next.png'), _('&Next'), self)
self.next_button.clicked.connect(self.next)
l2.addWidget(self.next_button, l2.rowCount() - 1, 1)
self.ns = QShortcut(QKeySequence('Alt+Right'), self)
self.ns.activated.connect(self.next)
self.ps = QShortcut(QKeySequence('Alt+Left'), self)
self.ps.activated.connect(self.previous)
self.next_button.setToolTip(_('Next [%s]')%
str(self.ns.key().toString(QKeySequence.SequenceFormat.NativeText)))
self.previous_button.setToolTip(_('Previous [%s]')%
str(self.ps.key().toString(QKeySequence.SequenceFormat.NativeText)))
self.view = view
self.path_to_book = None
self.current_row = None
self.refresh(row)
self.view.model().new_bookdisplay_data.connect(self.slave)
self.fit_cover.stateChanged.connect(self.toggle_cover_fit)
self.ns = QShortcut(QKeySequence('Alt+Right'), self)
self.ns.activated.connect(self.next)
self.ps = QShortcut(QKeySequence('Alt+Left'), self)
self.ps.activated.connect(self.previous)
self.next_button.setToolTip(_('Next [%s]')%
str(self.ns.key().toString(QKeySequence.SequenceFormat.NativeText)))
self.previous_button.setToolTip(_('Previous [%s]')%
str(self.ps.key().toString(QKeySequence.SequenceFormat.NativeText)))
self.slave_connected = False
if library_path is not None:
self.view = None
from calibre.db.legacy import LibraryDatabase
db = LibraryDatabase(library_path, read_only=True, is_second_db=True)
if book_id is None:
ids = db.new_api.search(query)
if len(ids) == 0:
raise ValueError(_('Query "{}" found no books').format(query))
book_id = sorted([i for i in ids])[0]
if not db.new_api.has_id(book_id):
raise ValueError(_("Book {} doesn't exist").format(book_id))
mi = db.new_api.get_metadata(book_id, get_cover=False)
mi.cover_data = [None, db.new_api.cover(book_id, as_image=True)]
mi.path = None
mi.format_files = dict()
mi.formats = list()
mi.marked = ''
mi._bd_dbwref = weakref.proxy(db)
self.refresh(row, mi)
else:
self.view = view
if dialog_number == 0:
self.slave_connected = True
self.view.model().new_bookdisplay_data.connect(self.slave)
self.refresh(row)
self.restore_geometry(gprefs, 'book_info_dialog_geometry')
ema = get_gui().iactions['Edit Metadata'].menuless_qaction
a = self.ema = QAction('edit metadata', self)
a.setShortcut(ema.shortcut())
self.addAction(a)
a.triggered.connect(self.edit_metadata)
vb = get_gui().iactions['View'].menuless_qaction
a = self.vba = QAction('view book', self)
a.setShortcut(vb.shortcut())
a.triggered.connect(self.view_book)
self.addAction(a)
self.clabel = QLabel('<div style="text-align: right"><a href="calibre:conf" title="{}" style="text-decoration: none">{}</a>'.format(
_('Configure this view'), _('Configure')))
self.clabel.linkActivated.connect(self.configure)
hl.addWidget(self.clabel)
self.fit_cover.stateChanged.connect(self.toggle_cover_fit)
self.restore_geometry(gprefs, self.geometry_string('book_info_dialog_geometry'))
try:
self.splitter.restoreState(gprefs.get('book_info_dialog_splitter_state'))
self.splitter.restoreState(gprefs.get(self.geometry_string('book_info_dialog_splitter_state')))
except Exception:
pass
ema = get_gui().iactions['Edit Metadata'].menuless_qaction
a = self.ema = QAction('edit metadata', self)
a.setShortcut(ema.shortcut())
self.addAction(a)
a.triggered.connect(self.edit_metadata)
vb = get_gui().iactions['View'].menuless_qaction
a = self.vba = QAction('view book', self)
a.setShortcut(vb.shortcut())
a.triggered.connect(self.view_book)
self.addAction(a)
def geometry_string(self, txt):
if self.dialog_number is None or self.dialog_number == 0:
return txt
return txt + '_' + str(self.dialog_number)
def sizeHint(self):
try:
@ -248,10 +285,11 @@ class BookInfo(QDialog):
self.link_delegate(link)
def done(self, r):
self.save_geometry(gprefs, 'book_info_dialog_geometry')
gprefs['book_info_dialog_splitter_state'] = bytearray(self.splitter.saveState())
self.save_geometry(gprefs, self.geometry_string('book_info_dialog_geometry'))
gprefs[self.geometry_string('book_info_dialog_splitter_state')] = bytearray(self.splitter.saveState())
ret = QDialog.done(self, r)
self.view.model().new_bookdisplay_data.disconnect(self.slave)
if self.slave_connected:
self.view.model().new_bookdisplay_data.disconnect(self.slave)
self.view = self.link_delegate = self.gui = None
self.closed.emit(self)
return ret
@ -342,11 +380,15 @@ class BookInfo(QDialog):
# Indicates books was deleted from library, or row numbers have
# changed
return
self.previous_button.setEnabled(False if row == 0 else True)
self.next_button.setEnabled(False if row == self.view.model().rowCount(QModelIndex())-1 else True)
if self.dialog_number == 0:
self.previous_button.setEnabled(False if row == 0 else True)
self.next_button.setEnabled(False if row == self.view.model().rowCount(QModelIndex())-1 else True)
self.setWindowTitle(mi.title + ' ' + _('(the current book)'))
elif self.library_id is not None:
self.setWindowTitle(mi.title + ' ' + _('(from {})').format(self.library_id))
else:
self.setWindowTitle(mi.title + ' ' + _('(will not change)'))
self.current_row = row
self.setWindowTitle(mi.title)
self.cover_pixmap = QPixmap.fromImage(mi.cover_data[1])
self.path_to_book = getattr(mi, 'path', None)
try:

View File

@ -132,7 +132,9 @@ class EditColumnDelegate(QItemDelegate):
else:
editor = EnLineEdit(parent)
return editor
return None
editor = EnLineEdit(parent)
editor.setClearButtonEnabled(True)
return editor
def destroyEditor(self, editor, index):
self.editing_finished.emit(index.row())
@ -142,12 +144,13 @@ class EditColumnDelegate(QItemDelegate):
class TagListEditor(QDialog, Ui_TagListEditor):
def __init__(self, window, cat_name, tag_to_match, get_book_ids, sorter,
ttm_is_first_letter=False, category=None, fm=None):
ttm_is_first_letter=False, category=None, fm=None, link_map=None):
QDialog.__init__(self, window)
Ui_TagListEditor.__init__(self)
self.setupUi(self)
self.verticalLayout_2.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.search_box.setMinimumContentsLength(25)
self.link_map = link_map
# Put the category name into the title bar
t = self.windowTitle()
@ -171,6 +174,7 @@ class TagListEditor(QDialog, Ui_TagListEditor):
self.to_delete = set()
self.all_tags = {}
self.original_names = {}
self.links = {}
self.ordered_tags = []
self.sorter = sorter
@ -413,13 +417,15 @@ class TagListEditor(QDialog, Ui_TagListEditor):
select_item = None
self.table.blockSignals(True)
self.table.clear()
self.table.setColumnCount(3)
self.table.setColumnCount(4)
self.name_col = QTableWidgetItem(self.category_name)
self.table.setHorizontalHeaderItem(0, self.name_col)
self.count_col = QTableWidgetItem(_('Count'))
self.table.setHorizontalHeaderItem(1, self.count_col)
self.was_col = QTableWidgetItem(_('Was'))
self.table.setHorizontalHeaderItem(2, self.was_col)
self.link_col = QTableWidgetItem(_('Link'))
self.table.setHorizontalHeaderItem(3, self.link_col)
self.table.setRowCount(len(tags))
for row,tag in enumerate(tags):
@ -457,6 +463,16 @@ class TagListEditor(QDialog, Ui_TagListEditor):
item.setData(Qt.ItemDataRole.DisplayRole, tag)
self.table.setItem(row, 2, item)
item = QTableWidgetItem()
if self.link_map is None:
item.setFlags(item.flags() & ~(Qt.ItemFlag.ItemIsSelectable|Qt.ItemFlag.ItemIsEditable))
item.setText(_('no links available'))
else:
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
item.setText(self.link_map.get(tag, ''))
self.table.setItem(row, 3, item)
if self.last_sorted_by == 'name':
self.table.sortByColumn(0, Qt.SortOrder(self.name_order))
elif self.last_sorted_by == 'count':
@ -662,4 +678,9 @@ class TagListEditor(QDialog, Ui_TagListEditor):
self.table.sortByColumn(2, Qt.SortOrder(self.was_order))
def accepted(self):
self.links = {}
for r in range(0, self.table.rowCount()):
l = self.table.item(r, 3).text()
if l:
self.links[self.table.item(r, 0).text()] = l
self.save_geometry()

View File

@ -58,6 +58,7 @@ class TagBrowserMixin: # {{{
partial(func, *args))
fm = db.new_api.field_metadata
categories = [x[0] for x in find_categories(fm) if fm.is_custom_field(x[0])]
categories = [c for c in categories if fm[c]['datatype'] != 'composite']
if categories:
if len(categories) > 5:
m = m.addMenu(_('Custom columns'))
@ -281,7 +282,8 @@ class TagBrowserMixin: # {{{
tag_to_match=tag,
get_book_ids=partial(self.get_book_ids, db=db, category=category),
sorter=key, ttm_is_first_letter=is_first_letter,
fm=db.field_metadata[category])
fm=db.field_metadata[category],
link_map=db.new_api.get_link_map(category))
d.exec()
if d.result() == QDialog.DialogCode.Accepted:
to_rename = d.to_rename # dict of old id to new name
@ -300,6 +302,9 @@ class TagBrowserMixin: # {{{
db.new_api.remove_items(category, to_delete)
db.new_api.rename_items(category, to_rename, change_index=False)
# Must do this at the end so renames are accounted for
db.new_api.set_link_map(category, d.links)
# Clean up the library view
self.do_tag_item_renamed()
self.tags_view.recount()

View File

@ -690,6 +690,22 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{
library_path = self.library_broker.path_for_library_id(library_id)
if not db_matches(self.current_db, library_id, library_path):
self.library_moved(library_path)
elif action == 'book-details':
parts = tuple(filter(None, path.split('/')))
if len(parts) != 2:
return
library_id, book_id = parts
library_id = decode_library_id(library_id)
library_path = self.library_broker.path_for_library_id(library_id)
if library_path is None:
return
try:
book_id = int(book_id)
except Exception:
prints('Ignoring invalid book id', book_id, file=sys.stderr)
return
details = self.iactions['Show Book Details']
details.show_book_info(library_id=library_id, library_path=library_path, book_id=book_id)
elif action == 'show-book':
parts = tuple(filter(None, path.split('/')))
if len(parts) != 2: