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 do so by right clicking the search bar and choosing :guilabel:`Copy search as
URL`. 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: .. _hex_encoding:

View File

@ -1121,6 +1121,7 @@ class DB:
CREATE TABLE %s( CREATE TABLE %s(
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
value %s NOT NULL %s, value %s NOT NULL %s,
link TEXT NOT NULL DEFAULT "",
UNIQUE(value)); UNIQUE(value));
'''%(table, dt, collate), '''%(table, dt, collate),

View File

@ -153,6 +153,7 @@ class Cache:
self.format_metadata_cache = defaultdict(dict) self.format_metadata_cache = defaultdict(dict)
self.formatter_template_cache = {} self.formatter_template_cache = {}
self.dirtied_cache = {} self.dirtied_cache = {}
self.link_maps_cache = {}
self.vls_for_books_cache = None self.vls_for_books_cache = None
self.vls_for_books_lib_in_process = None self.vls_for_books_lib_in_process = None
self.vls_cache_lock = Lock() self.vls_cache_lock = Lock()
@ -290,6 +291,7 @@ class Cache:
self.format_metadata_cache.clear() self.format_metadata_cache.clear()
if search_cache: if search_cache:
self._clear_search_caches(book_ids) self._clear_search_caches(book_ids)
self.link_maps_cache = {}
@write_api @write_api
def reload_from_db(self, clear_caches=True): def reload_from_db(self, clear_caches=True):
@ -382,6 +384,8 @@ class Cache:
for key in composites: for key in composites:
mi.set(key, val=self._composite_for(key, book_id, mi)) 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 = {} user_cat_vals = {}
if get_user_categories: if get_user_categories:
user_cats = self._pref('user_categories', {}) user_cats = self._pref('user_categories', {})
@ -2322,6 +2326,93 @@ class Cache:
self._mark_as_dirty(changed_books) self._mark_as_dirty(changed_books)
return 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 @read_api
def lookup_by_uuid(self, uuid): def lookup_by_uuid(self, uuid):
return self.fields['uuid'].table.lookup_by_uuid(uuid) return self.fields['uuid'].table.lookup_by_uuid(uuid)

View File

@ -642,7 +642,7 @@ class AuthorsField(ManyToManyField):
return { return {
'name': self.table.id_map[author_id], 'name': self.table.id_map[author_id],
'sort': self.table.asort_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): 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) sa(self, '_user_metadata', db.field_metadata)
def __getattribute__(self, field): 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) getter = getters.get(field, None)
if getter is not None: if getter is not None:
return getter(ga(self, '_db'), ga(self, '_book_id'), ga(self, '_cache')) 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): def upgrade_version_24(self):
self.db.reindex_annotations() 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): def read(self, db):
self.id_map = {} self.id_map = {}
self.link_map = {}
self.col_book_map = defaultdict(set) self.col_book_map = defaultdict(set)
self.book_col_map = {} self.book_col_map = {}
self.read_id_maps(db) self.read_id_maps(db)
self.read_maps(db) self.read_maps(db)
def read_id_maps(self, 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'])) self.metadata['column'], self.metadata['table']))
if self.unserialize is None: if self.unserialize is None:
self.id_map = dict(query) us = lambda x: x
else: else:
us = self.unserialize 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): def read_maps(self, db):
cbm = self.col_book_map cbm = self.col_book_map
@ -343,6 +346,14 @@ class ManyToOneTable(Table):
self.link_table, lcol, table), (existing_item, item_id, item_id)) self.link_table, lcol, table), (existing_item, item_id, item_id))
return affected_books, new_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): class RatingTable(ManyToOneTable):
@ -526,7 +537,7 @@ class ManyToManyTable(ManyToOneTable):
class AuthorsTable(ManyToManyTable): class AuthorsTable(ManyToManyTable):
def read_id_maps(self, db): def read_id_maps(self, db):
self.alink_map = lm = {} self.link_map = lm = {}
self.asort_map = sm = {} self.asort_map = sm = {}
self.id_map = im = {} self.id_map = im = {}
us = self.unserialize us = self.unserialize
@ -547,8 +558,8 @@ class AuthorsTable(ManyToManyTable):
def set_links(self, link_map, db): def set_links(self, link_map, db):
link_map = {aid:(l or '').strip() for aid, l in iteritems(link_map)} 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)} link_map = {aid:l for aid, l in iteritems(link_map) if l != self.link_map.get(aid, None)}
self.alink_map.update(link_map) self.link_map.update(link_map)
db.executemany('UPDATE authors SET link=? WHERE id=?', db.executemany('UPDATE authors SET link=? WHERE id=?',
[(v, k) for k, v in iteritems(link_map)]) [(v, k) for k, v in iteritems(link_map)])
return link_map return link_map
@ -556,14 +567,14 @@ class AuthorsTable(ManyToManyTable):
def remove_books(self, book_ids, db): def remove_books(self, book_ids, db):
clean = ManyToManyTable.remove_books(self, book_ids, db) clean = ManyToManyTable.remove_books(self, book_ids, db)
for item_id in clean: 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) self.asort_map.pop(item_id, None)
return clean return clean
def rename_item(self, item_id, new_name, db): def rename_item(self, item_id, new_name, db):
ret = ManyToManyTable.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: 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) self.asort_map.pop(item_id, None)
else: else:
# Was a simple rename, update the author sort value # 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(3, c.all_book_ids())
self.assertNotIn('Unknown', set(itervalues(table.id_map))) self.assertNotIn('Unknown', set(itervalues(table.id_map)))
self.assertNotIn(item_id, table.asort_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) ae(len(table.id_map), olen-1)
# Check that files are removed # 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', 1), 2.0)
ae(cache.field_for('series_index', 2), 3.5) 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() table.col_book_map[item_id] = set()
if is_authors: if is_authors:
table.asort_map[item_id] = aus 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]: elif allow_case_change and val != table.id_map[item_id]:
case_changes[item_id] = val case_changes[item_id] = val
val_map[val] = item_id 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) table.col_book_map.pop(item_id, None)
if is_authors: if is_authors:
table.asort_map.pop(item_id, None) 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 return dirtied

View File

@ -91,6 +91,24 @@ def mi_to_html(
rating_font='Liberation Serif', rtl=False, comments_heading_pos='hide', rating_font='Liberation Serif', rtl=False, comments_heading_pos='hide',
for_qt=False, vertical_fields=() 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: if field_list is None:
field_list = get_field_list(mi) field_list = get_field_list(mi)
ans = [] ans = []
@ -170,9 +188,12 @@ def mi_to_html(
else: else:
all_vals = [v.strip() all_vals = [v.strip()
for v in val.split(metadata['is_multiple']['cache_to_list']) if v.strip()] for v in val.split(metadata['is_multiple']['cache_to_list']) if v.strip()]
if show_links:
links = ['<a href="{}" title="{}">{}</a>'.format( links = ['<a href="{}" title="{}">{}</a>'.format(
search_action(field, x), _('Click to see books with {0}: {1}').format( search_action(field, x), _('Click to see books with {0}: {1}').format(
metadata['name'], a(x)), p(x)) for x in all_vals] 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) val = value_list(metadata['is_multiple']['list_to_ui'], links)
ans.append((field, row % (name, val))) ans.append((field, row % (name, val)))
elif field == 'path': elif field == 'path':
@ -188,10 +209,15 @@ def mi_to_html(
durl = ':::'.join((durl.split(':::'))[2:]) durl = ':::'.join((durl.split(':::'))[2:])
extra = '<br><span style="font-size:smaller">%s</span>'%( extra = '<br><span style="font-size:smaller">%s</span>'%(
prepare_string_for_xml(durl)) prepare_string_for_xml(durl))
if show_links:
link = '<a href="{}" title="{}">{}</a>{}'.format(action(scheme, loc=loc), link = '<a href="{}" title="{}">{}</a>{}'.format(action(scheme, loc=loc),
prepare_string_for_xml(path, True), pathstr, extra) prepare_string_for_xml(path, True), pathstr, extra)
else:
link = prepare_string_for_xml(path, True)
ans.append((field, row % (name, link))) ans.append((field, row % (name, link)))
elif field == 'formats': elif field == 'formats':
# Don't need show_links here because formats are removed from mi on
# cross library displays.
if isdevice: if isdevice:
continue continue
path = mi.path or '' path = mi.path or ''
@ -209,12 +235,15 @@ def mi_to_html(
ans.append((field, row % (name, value_list(', ', fmts)))) ans.append((field, row % (name, value_list(', ', fmts))))
elif field == 'identifiers': elif field == 'identifiers':
urls = urls_from_identifiers(mi.identifiers, sort_results=True) urls = urls_from_identifiers(mi.identifiers, sort_results=True)
if show_links:
links = [ links = [
'<a href="{}" title="{}:{}">{}</a>'.format( '<a href="{}" title="{}:{}">{}</a>'.format(
action('identifier', url=url, name=namel, id_type=id_typ, value=id_val, field='identifiers', book_id=book_id), 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)) a(id_typ), a(id_val), p(namel))
for namel, id_typ, id_val, url in urls] for namel, id_typ, id_val, url in urls]
links = value_list(', ', links) links = value_list(', ', links)
else:
links = ', '.join(mi.identifiers)
if links: if links:
ans.append((field, row % (_('Ids')+':', links))) ans.append((field, row % (_('Ids')+':', links)))
elif field == 'authors': elif field == 'authors':
@ -222,9 +251,8 @@ def mi_to_html(
formatter = EvalFormatter() formatter = EvalFormatter()
for aut in mi.authors: for aut in mi.authors:
link = '' link = ''
if mi.author_link_map.get(aut): if show_links:
link = lt = mi.author_link_map[aut] if default_author_link:
elif default_author_link:
if default_author_link.startswith('search-'): if default_author_link.startswith('search-'):
which_src = default_author_link.partition('-')[2] which_src = default_author_link.partition('-')[2]
link, lt = author_search_href(which_src, title=mi.title, author=aut) link, lt = author_search_href(which_src, title=mi.title, author=aut)
@ -235,26 +263,34 @@ def mi_to_html(
except KeyError: except KeyError:
vals['author_sort'] = qquote(aut) vals['author_sort'] = qquote(aut)
link = lt = formatter.safe_format(default_author_link, vals, '', vals) link = lt = formatter.safe_format(default_author_link, vals, '', vals)
else:
aut = p(aut) aut = p(aut)
if link: 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: else:
authors.append(aut) val = aut
val += add_other_link('authors', aut)
authors.append(val)
ans.append((field, row % (name, value_list(' & ', authors)))) ans.append((field, row % (name, value_list(' & ', authors))))
elif field == 'languages': elif field == 'languages':
if not mi.languages: if not mi.languages:
continue continue
names = filter(None, map(calibre_langcode_to_name, mi.languages)) names = filter(None, map(calibre_langcode_to_name, mi.languages))
if show_links:
names = ['<a href="{}" title="{}">{}</a>'.format(search_action_with_data('languages', n, book_id), _( 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] 'Search calibre for books with the language: {}').format(n), n) for n in names]
ans.append((field, row % (name, value_list(', ', names)))) ans.append((field, row % (name, value_list(', ', names))))
elif field == 'publisher': elif field == 'publisher':
if not mi.publisher: if not mi.publisher:
continue continue
if show_links:
val = '<a href="{}" title="{}">{}</a>'.format( val = '<a href="{}" title="{}">{}</a>'.format(
search_action_with_data('publisher', mi.publisher, book_id), search_action_with_data('publisher', mi.publisher, book_id),
_('Click to see books with {0}: {1}').format(metadata['name'], a(mi.publisher)), _('Click to see books with {0}: {1}').format(metadata['name'], a(mi.publisher)),
p(mi.publisher)) p(mi.publisher))
val += add_other_link('publisher', mi.publisher)
else:
val = p(mi.publisher)
ans.append((field, row % (name, val))) ans.append((field, row % (name, val)))
elif field == 'title': elif field == 'title':
# otherwise title gets metadata['datatype'] == 'text' # otherwise title gets metadata['datatype'] == 'text'
@ -268,6 +304,7 @@ def mi_to_html(
if val is None: if val is None:
continue continue
val = p(val) val = p(val)
if show_links:
if metadata['datatype'] == 'series': if metadata['datatype'] == 'series':
sidx = mi.get(field+'_index') sidx = mi.get(field+'_index')
if sidx is None: if sidx is None:
@ -283,6 +320,7 @@ def mi_to_html(
sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls="series_name", 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), series=p(series), href=search_action_with_data(st, series, book_id, field),
tt=p(_('Click to see books in this series'))) tt=p(_('Click to see books in this series')))
val += add_other_link('series', mi.series)
elif metadata['datatype'] == 'datetime': elif metadata['datatype'] == 'datetime':
aval = getattr(mi, field) aval = getattr(mi, field)
if is_date_undefined(aval): if is_date_undefined(aval):
@ -306,10 +344,13 @@ def mi_to_html(
all_vals = mi.get(field) all_vals = mi.get(field)
if not metadata.get('display', {}).get('is_names', False): if not metadata.get('display', {}).get('is_names', False):
all_vals = sorted(all_vals, key=sort_key) all_vals = sorted(all_vals, key=sort_key)
links = ['<a href="{}" title="{}">{}</a>'.format( 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( 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)) metadata['name'] or field, a(x)), p(x))
for x in all_vals] v += add_other_link(field, x)
links.append(v)
val = value_list(metadata['is_multiple']['list_to_ui'], links) val = value_list(metadata['is_multiple']['list_to_ui'], links)
elif metadata['datatype'] == 'text' or metadata['datatype'] == 'enumeration': elif metadata['datatype'] == 'text' or metadata['datatype'] == 'enumeration':
# text/is_multiple handled above so no need to add the test to the if # text/is_multiple handled above so no need to add the test to the if
@ -317,9 +358,10 @@ def mi_to_html(
st = metadata['search_terms'][0] st = metadata['search_terms'][0]
except Exception: except Exception:
st = field st = field
val = '<a href="{}" title="{}">{}</a>'.format( v = '<a href="{}" title="{}">{}</a>'.format(
search_action_with_data(st, unescaped_val, book_id, field), a( 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) _('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': elif metadata['datatype'] == 'bool':
val = '<a href="{}" title="{}">{}</a>'.format( val = '<a href="{}" title="{}">{}</a>'.format(
search_action_with_data(field, val, book_id, None), a( search_action_with_data(field, val, book_id, None), a(

View File

@ -595,6 +595,8 @@ class OPF: # {{{
renderer=dump_dict) renderer=dump_dict)
author_link_map = MetadataField('author_link_map', is_dc=False, author_link_map = MetadataField('author_link_map', is_dc=False,
formatter=json.loads, renderer=dump_dict) 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, def __init__(self, stream, basedir=os.getcwd(), unquote_urls=True,
populate_spine=True, try_to_guess_cover=False, preparsed_opf=None, read_toc=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', for attr in ('title', 'authors', 'author_sort', 'title_sort',
'publisher', 'series', 'series_index', 'rating', 'publisher', 'series', 'series_index', 'rating',
'isbn', 'tags', 'category', 'comments', 'book_producer', '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) val = getattr(mi, attr, None)
if attr == 'rating' and val: if attr == 'rating' and val:
val = float(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) return factory('meta', name='calibre:' + n, content=c)
if getattr(mi, 'author_link_map', None) is not None: if getattr(mi, 'author_link_map', None) is not None:
meta('author_link_map', dump_dict(mi.author_link_map)) 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: if mi.series:
meta('series', mi.series) meta('series', mi.series)
if mi.series_index is not None: if mi.series_index is not None:

View File

@ -23,25 +23,61 @@ class ShowBookDetailsAction(InterfaceAction):
def genesis(self): def genesis(self):
self.qaction.triggered.connect(self.show_book_info) self.qaction.triggered.connect(self.show_book_info)
self.memory = [] 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: if self.gui.current_view() is not self.gui.library_view:
error_dialog(self.gui, _('No detailed info available'), error_dialog(self.gui, _('No detailed info available'),
_('No detailed information is available for books ' _('No detailed information is available for books '
'on the device.')).exec() 'on the device.')).exec()
return 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() index = self.gui.library_view.currentIndex()
if index.isValid(): 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, d = BookInfo(self.gui, self.gui.library_view, index,
self.gui.book_details.handle_click) 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) d.open_cover_with.connect(self.gui.bd_open_cover_with, type=Qt.ConnectionType.QueuedConnection)
self.dialogs[dn] = d
self.memory.append(d) self.memory.append(d)
d.closed.connect(self.closed, type=Qt.ConnectionType.QueuedConnection) d.closed.connect(self.closed, type=Qt.ConnectionType.QueuedConnection)
d.show() 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): def closed(self, d):
try: try:
d.closed.disconnect(self.closed) d.closed.disconnect(self.closed)
self.dialogs[d.dialog_number] = None
self.memory.remove(d) self.memory.remove(d)
except ValueError: except ValueError:
pass pass

View File

@ -42,12 +42,15 @@ InternetSearch = namedtuple('InternetSearch', 'author where')
def set_html(mi, html, text_browser): def set_html(mi, html, text_browser):
if hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None:
db = mi._bd_dbwref
else:
from calibre.gui2.ui import get_gui from calibre.gui2.ui import get_gui
gui = get_gui() db = get_gui().current_db
book_id = getattr(mi, 'id', None) book_id = getattr(mi, 'id', None)
search_paths = [] search_paths = []
if gui and book_id is not None: if db and book_id is not None:
path = gui.current_db.abspath(book_id, index_is_id=True) path = db.abspath(book_id, index_is_id=True)
if path: if path:
search_paths = [path] search_paths = [path]
text_browser.setSearchPaths(search_paths) text_browser.setSearchPaths(search_paths)
@ -203,10 +206,16 @@ def comments_pat():
return re.compile(r'<!--.*?-->', re.DOTALL) return re.compile(r'<!--.*?-->', re.DOTALL)
def render_html(mi, vertical, widget, all_fields=False, render_data_func=None, pref_name='book_display_fields'): # {{{ 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 from calibre.gui2.ui import get_gui
db = get_gui().current_db
func = render_data_func or partial(render_data, 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: try:
table, comment_fields = func(mi, all_fields=all_fields, table, comment_fields = func(mi, all_fields=all_fields,
use_roman_numbers=config['use_roman_numerals_for_series_number'], pref_name=pref_name) use_roman_numbers=config['use_roman_numerals_for_series_number'], pref_name=pref_name)
@ -246,7 +255,10 @@ def render_html(mi, vertical, widget, all_fields=False, render_data_func=None, p
return ans return ans
def get_field_list(fm, use_defaults=False, pref_name='book_display_fields'): 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 from calibre.gui2.ui import get_gui
db = get_gui().current_db db = get_gui().current_db
if use_defaults: if use_defaults:
@ -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', def render_data(mi, use_roman_numbers=True, all_fields=False, pref_name='book_display_fields',
vertical_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] field_list = [(x, all_fields or display) for x, display in field_list]
return mi_to_html( return mi_to_html(
mi, field_list=field_list, use_roman_numbers=use_roman_numbers, rtl=is_rtl(), mi, field_list=field_list, use_roman_numbers=use_roman_numbers, rtl=is_rtl(),

View File

@ -3,6 +3,8 @@
import textwrap import textwrap
import weakref
from qt.core import ( from qt.core import (
QAction, QApplication, QBrush, QCheckBox, QDialog, QDialogButtonBox, QGridLayout, QAction, QApplication, QBrush, QCheckBox, QDialog, QDialogButtonBox, QGridLayout,
QHBoxLayout, QIcon, QKeySequence, QLabel, QListView, QModelIndex, QPalette, QPixmap, QHBoxLayout, QIcon, QKeySequence, QLabel, QListView, QModelIndex, QPalette, QPixmap,
@ -114,16 +116,18 @@ class Configure(Dialog):
class Details(HTMLDisplay): 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) HTMLDisplay.__init__(self, parent)
self.book_info = book_info self.book_info = book_info
self.edit_metadata = getattr(parent, 'edit_metadata', None) self.edit_metadata = getattr(parent, 'edit_metadata', None)
self.setDefaultStyleSheet(css()) self.setDefaultStyleSheet(css())
self.allow_context_menu = allow_context_menu
def sizeHint(self): def sizeHint(self):
return QSize(350, 350) return QSize(350, 350)
def contextMenuEvent(self, ev): def contextMenuEvent(self, ev):
if self.allow_context_menu:
details_context_menu_event(self, ev, self.book_info, edit_metadata=self.edit_metadata) details_context_menu_event(self, ev, self.book_info, edit_metadata=self.edit_metadata)
@ -132,8 +136,11 @@ class BookInfo(QDialog):
closed = pyqtSignal(object) closed = pyqtSignal(object)
open_cover_with = pyqtSignal(object, object) open_cover_with = pyqtSignal(object, object)
def __init__(self, parent, view, row, link_delegate): def __init__(self, parent, view, row, link_delegate, dialog_number=None,
QDialog.__init__(self, parent) 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.marked = None
self.gui = parent self.gui = parent
self.splitter = QSplitter(self) self.splitter = QSplitter(self)
@ -150,7 +157,8 @@ class BookInfo(QDialog):
self.cover.sizeHint = self.details_size_hint self.cover.sizeHint = self.details_size_hint
self.splitter.addWidget(self.cover) 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.details.anchor_clicked.connect(self.on_link_clicked)
self.link_delegate = link_delegate self.link_delegate = link_delegate
self.details.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False) self.details.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False)
@ -172,23 +180,13 @@ class BookInfo(QDialog):
hl.setContentsMargins(0, 0, 0, 0) hl.setContentsMargins(0, 0, 0, 0)
l2.addLayout(hl, l2.rowCount(), 0, 1, -1) l2.addLayout(hl, l2.rowCount(), 0, 1, -1)
hl.addWidget(self.fit_cover), hl.addStretch() 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( if self.dialog_number == 0:
_('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 = QPushButton(QIcon.ic('previous.png'), _('&Previous'), self)
self.previous_button.clicked.connect(self.previous) self.previous_button.clicked.connect(self.previous)
l2.addWidget(self.previous_button, l2.rowCount(), 0) l2.addWidget(self.previous_button, l2.rowCount(), 0)
self.next_button = QPushButton(QIcon.ic('next.png'), _('&Next'), self) self.next_button = QPushButton(QIcon.ic('next.png'), _('&Next'), self)
self.next_button.clicked.connect(self.next) self.next_button.clicked.connect(self.next)
l2.addWidget(self.next_button, l2.rowCount() - 1, 1) l2.addWidget(self.next_button, l2.rowCount() - 1, 1)
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 = QShortcut(QKeySequence('Alt+Right'), self)
self.ns.activated.connect(self.next) self.ns.activated.connect(self.next)
self.ps = QShortcut(QKeySequence('Alt+Left'), self) self.ps = QShortcut(QKeySequence('Alt+Left'), self)
@ -198,11 +196,35 @@ class BookInfo(QDialog):
self.previous_button.setToolTip(_('Previous [%s]')% self.previous_button.setToolTip(_('Previous [%s]')%
str(self.ps.key().toString(QKeySequence.SequenceFormat.NativeText))) str(self.ps.key().toString(QKeySequence.SequenceFormat.NativeText)))
self.restore_geometry(gprefs, 'book_info_dialog_geometry') self.path_to_book = None
try: self.current_row = None
self.splitter.restoreState(gprefs.get('book_info_dialog_splitter_state')) self.slave_connected = False
except Exception: if library_path is not None:
pass 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)
ema = get_gui().iactions['Edit Metadata'].menuless_qaction ema = get_gui().iactions['Edit Metadata'].menuless_qaction
a = self.ema = QAction('edit metadata', self) a = self.ema = QAction('edit metadata', self)
a.setShortcut(ema.shortcut()) a.setShortcut(ema.shortcut())
@ -213,6 +235,21 @@ class BookInfo(QDialog):
a.setShortcut(vb.shortcut()) a.setShortcut(vb.shortcut())
a.triggered.connect(self.view_book) a.triggered.connect(self.view_book)
self.addAction(a) 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(self.geometry_string('book_info_dialog_splitter_state')))
except Exception:
pass
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): def sizeHint(self):
try: try:
@ -248,9 +285,10 @@ class BookInfo(QDialog):
self.link_delegate(link) self.link_delegate(link)
def done(self, r): def done(self, r):
self.save_geometry(gprefs, 'book_info_dialog_geometry') self.save_geometry(gprefs, self.geometry_string('book_info_dialog_geometry'))
gprefs['book_info_dialog_splitter_state'] = bytearray(self.splitter.saveState()) gprefs[self.geometry_string('book_info_dialog_splitter_state')] = bytearray(self.splitter.saveState())
ret = QDialog.done(self, r) ret = QDialog.done(self, r)
if self.slave_connected:
self.view.model().new_bookdisplay_data.disconnect(self.slave) self.view.model().new_bookdisplay_data.disconnect(self.slave)
self.view = self.link_delegate = self.gui = None self.view = self.link_delegate = self.gui = None
self.closed.emit(self) self.closed.emit(self)
@ -342,11 +380,15 @@ class BookInfo(QDialog):
# Indicates books was deleted from library, or row numbers have # Indicates books was deleted from library, or row numbers have
# changed # changed
return return
if self.dialog_number == 0:
self.previous_button.setEnabled(False if row == 0 else True) 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.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.current_row = row
self.setWindowTitle(mi.title)
self.cover_pixmap = QPixmap.fromImage(mi.cover_data[1]) self.cover_pixmap = QPixmap.fromImage(mi.cover_data[1])
self.path_to_book = getattr(mi, 'path', None) self.path_to_book = getattr(mi, 'path', None)
try: try:

View File

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

View File

@ -58,6 +58,7 @@ class TagBrowserMixin: # {{{
partial(func, *args)) partial(func, *args))
fm = db.new_api.field_metadata fm = db.new_api.field_metadata
categories = [x[0] for x in find_categories(fm) if fm.is_custom_field(x[0])] 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 categories:
if len(categories) > 5: if len(categories) > 5:
m = m.addMenu(_('Custom columns')) m = m.addMenu(_('Custom columns'))
@ -281,7 +282,8 @@ class TagBrowserMixin: # {{{
tag_to_match=tag, tag_to_match=tag,
get_book_ids=partial(self.get_book_ids, db=db, category=category), get_book_ids=partial(self.get_book_ids, db=db, category=category),
sorter=key, ttm_is_first_letter=is_first_letter, 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() d.exec()
if d.result() == QDialog.DialogCode.Accepted: if d.result() == QDialog.DialogCode.Accepted:
to_rename = d.to_rename # dict of old id to new name 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.remove_items(category, to_delete)
db.new_api.rename_items(category, to_rename, change_index=False) 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 # Clean up the library view
self.do_tag_item_renamed() self.do_tag_item_renamed()
self.tags_view.recount() 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) library_path = self.library_broker.path_for_library_id(library_id)
if not db_matches(self.current_db, library_id, library_path): if not db_matches(self.current_db, library_id, library_path):
self.library_moved(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': elif action == 'show-book':
parts = tuple(filter(None, path.split('/'))) parts = tuple(filter(None, path.split('/')))
if len(parts) != 2: if len(parts) != 2: