This commit is contained in:
Kovid Goyal 2023-03-30 07:27:11 +05:30
commit b3aafddf5a
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
18 changed files with 576 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,14 @@ 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.clear_link_map_cache(book_ids)
def clear_link_map_cache(self, book_ids=None):
if book_ids is None:
self.link_maps_cache = {}
else:
for book in book_ids:
self.link_maps_cache.pop(book, None)
@write_api @write_api
def reload_from_db(self, clear_caches=True): def reload_from_db(self, clear_caches=True):
@ -382,6 +391,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', {})
@ -1483,6 +1494,7 @@ class Cache:
if update_path and do_path_update: if update_path and do_path_update:
self._update_path(dirtied, mark_as_dirtied=False) self._update_path(dirtied, mark_as_dirtied=False)
self._mark_as_dirty(dirtied) self._mark_as_dirty(dirtied)
self.clear_link_map_cache(dirtied)
self.event_dispatcher(EventType.metadata_changed, name, dirtied) self.event_dispatcher(EventType.metadata_changed, name, dirtied)
return dirtied return dirtied
@ -1498,6 +1510,7 @@ class Cache:
self.format_metadata_cache.pop(book_id, None) self.format_metadata_cache.pop(book_id, None)
if mark_as_dirtied: if mark_as_dirtied:
self._mark_as_dirty(book_ids) self._mark_as_dirty(book_ids)
self.clear_link_map_cache(book_ids)
@read_api @read_api
def get_a_dirtied_book(self): def get_a_dirtied_book(self):
@ -2157,6 +2170,7 @@ class Cache:
for book_id in moved_books: for book_id in moved_books:
self._set_field(f.index_field.name, {book_id:self._get_next_series_num_for(self._fast_field_for(f, book_id), field=field)}) self._set_field(f.index_field.name, {book_id:self._get_next_series_num_for(self._fast_field_for(f, book_id), field=field)})
self._mark_as_dirty(affected_books) self._mark_as_dirty(affected_books)
self.clear_link_map_cache(affected_books)
self.event_dispatcher(EventType.items_renamed, field, affected_books, id_map) self.event_dispatcher(EventType.items_renamed, field, affected_books, id_map)
return affected_books, id_map return affected_books, id_map
@ -2176,6 +2190,7 @@ class Cache:
self._set_field(field.index_field.name, {bid:1.0 for bid in affected_books}) self._set_field(field.index_field.name, {bid:1.0 for bid in affected_books})
else: else:
self._mark_as_dirty(affected_books) self._mark_as_dirty(affected_books)
self.clear_link_map_cache(affected_books)
self.event_dispatcher(EventType.items_removed, field, affected_books, item_ids) self.event_dispatcher(EventType.items_removed, field, affected_books, item_ids)
return affected_books return affected_books
@ -2310,6 +2325,7 @@ class Cache:
self._set_field('author_sort', val_map) self._set_field('author_sort', val_map)
if changed_books: if changed_books:
self._mark_as_dirty(changed_books) self._mark_as_dirty(changed_books)
self.clear_link_map_cache(changed_books)
return changed_books return changed_books
@write_api @write_api
@ -2320,6 +2336,100 @@ class Cache:
changed_books |= self._books_for_field('authors', author_id) changed_books |= self._books_for_field('authors', author_id)
if changed_books: if changed_books:
self._mark_as_dirty(changed_books) self._mark_as_dirty(changed_books)
self.clear_link_map_cache(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
NB: this method doesn't change values not in the value_to_link_map
'''
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")
# Clear the links for book cache as we don't know what will be affected
self.link_maps_cache = {}
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)
self.clear_link_map_cache(changed_books)
return changed_books return changed_books
@read_api @read_api

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

@ -351,6 +351,10 @@ class ProxyMetadata(Metadata):
return custom_getter(field, ga(self, '_db'), ga(self, '_book_id'), ga(self, '_cache')) return custom_getter(field, ga(self, '_db'), ga(self, '_book_id'), ga(self, '_cache'))
return composite_getter(self, field, ga(self, '_db'), ga(self, '_book_id'), ga(self, '_cache'), ga(self, 'formatter'), ga(self, 'template_cache')) return composite_getter(self, field, ga(self, '_db'), ga(self, '_book_id'), ga(self, '_cache'), ga(self, 'formatter'), ga(self, 'template_cache'))
if field == 'link_maps':
db = ga(self, '_db')()
return db.get_all_link_maps_for_book(ga(self, '_book_id'))
try: try:
return ga(self, '_cache')[field] return ga(self, '_cache')[field]
except KeyError: except KeyError:

View File

@ -793,3 +793,31 @@ 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):
alters = []
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'])
alters.append(f'ALTER TABLE {tn} ADD COLUMN link TEXT NOT NULL DEFAULT "";')
alters.append('ALTER TABLE publishers ADD COLUMN link TEXT NOT NULL DEFAULT "";')
alters.append('ALTER TABLE series ADD COLUMN link TEXT NOT NULL DEFAULT "";')
alters.append('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
alters.append('ALTER TABLE languages ADD COLUMN link TEXT NOT NULL DEFAULT "";')
alters.append('ALTER TABLE ratings ADD COLUMN link TEXT NOT NULL DEFAULT "";')
self.db.execute('\n'.join(alters))

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,62 @@ 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()
# Add two tags
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')
# Check adding a link
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')
# Check getting links for a book and that links are correct
cache.set_field('publisher', {1:'random'})
cache.set_link_map('publisher', {'random': 'url2'})
links = cache.get_all_link_maps_for_book(1)
self.assertSetEqual({v for v in links.keys()}, {'tags', 'publisher'}, 'Wrong link keys')
self.assertSetEqual({v for v in links['tags'].keys()}, {'foo', }, 'Should be "foo"')
self.assertSetEqual({v for v in links['publisher'].keys()}, {'random', }, 'Should be "random"')
self.assertEqual('url', links['tags']['foo'], 'link for tag foo is wrong')
self.assertEqual('url2', links['publisher']['random'], 'link for publisher random is wrong')
# Check that renaming a tag keeps the link and clears the link map cache for the book
self.assertTrue(1 in cache.link_maps_cache, "book not in link_map_cache")
tag_id = cache.get_item_id('tags', 'foo')
cache.rename_items('tags', {tag_id: 'foobar'})
self.assertTrue(1 not in cache.link_maps_cache, "book still in link_map_cache")
links = cache.get_link_map('tags')
self.assertTrue('foobar' in links, "rename foo lost the link")
self.assertEqual(links['foobar'], 'url', "The link changed contents")
links = cache.get_all_link_maps_for_book(1)
self.assertTrue(1 in cache.link_maps_cache, "book not put back into link_map_cache")
self.assertDictEqual({'publisher': {'random': 'url2'}, 'tags': {'foobar': 'url'}},
links, "book links incorrect after tag rename")
# Check ProxyMetadata
mi = cache.get_proxy_metadata(1)
self.assertDictEqual({'publisher': {'random': 'url2'}, 'tags': {'foobar': 'url'}},
mi.link_maps, "ProxyMetadata didn't return the right link map")
# Now test deleting the links.
links = cache.get_link_map('tags')
to_del = {l:'' for l in links.keys()}
cache.set_link_map('tags', to_del)
self.assertEqual({}, cache.get_link_map('tags'), 'links on tags were not deleted')
links = cache.get_link_map('publisher')
to_del = {l:'' for l in links.keys()}
cache.set_link_map('publisher', to_del)
self.assertEqual({}, cache.get_link_map('publisher'), 'links on publisher were not deleted')
self.assertEqual({}, cache.get_all_link_maps_for_book(1), 'Not all links for book were deleted')
# }}} # }}}

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()]
links = ['<a href="{}" title="{}">{}</a>'.format( if show_links:
search_action(field, x), _('Click to see books with {0}: {1}').format( 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] 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))
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) 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)
links = [ if show_links:
'<a href="{}" title="{}:{}">{}</a>'.format( links = [
action('identifier', url=url, name=namel, id_type=id_typ, value=id_val, field='identifiers', book_id=book_id), '<a href="{}" title="{}:{}">{}</a>'.format(
a(id_typ), a(id_val), p(namel)) action('identifier', url=url, name=namel, id_type=id_typ, value=id_val, field='identifiers', book_id=book_id),
for namel, id_typ, id_val, url in urls] a(id_typ), a(id_val), p(namel))
links = value_list(', ', links) for namel, id_typ, id_val, url in urls]
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,39 +251,46 @@ 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) 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: else:
vals = {'author': qquote(aut), 'title': qquote(mi.title)} aut = p(aut)
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)
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))
names = ['<a href="{}" title="{}">{}</a>'.format(search_action_with_data('languages', n, book_id), _( if show_links:
'Search calibre for books with the language: {}').format(n), n) for n in names] 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)))) ans.append((field, row % (name, value_list(', ', names))))
elif field == 'publisher': elif field == 'publisher':
if not mi.publisher: if not mi.publisher:
continue continue
val = '<a href="{}" title="{}">{}</a>'.format( if show_links:
search_action_with_data('publisher', mi.publisher, book_id), val = '<a href="{}" title="{}">{}</a>'.format(
_('Click to see books with {0}: {1}').format(metadata['name'], a(mi.publisher)), search_action_with_data('publisher', mi.publisher, book_id),
p(mi.publisher)) _('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))) 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,79 +304,85 @@ def mi_to_html(
if val is None: if val is None:
continue continue
val = p(val) val = p(val)
if metadata['datatype'] == 'series': if show_links:
sidx = mi.get(field+'_index') if metadata['datatype'] == 'series':
if sidx is None: sidx = mi.get(field+'_index')
sidx = 1.0 if sidx is None:
try: sidx = 1.0
st = metadata['search_terms'][0] try:
except Exception: st = metadata['search_terms'][0]
st = field except Exception:
series = getattr(mi, field) st = field
val = _( series = getattr(mi, field)
'%(sidx)s of <a href="%(href)s" title="%(tt)s">' val = _(
'<span class="%(cls)s">%(series)s</span></a>') % dict( '%(sidx)s of <a href="%(href)s" title="%(tt)s">'
sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls="series_name", '<span class="%(cls)s">%(series)s</span></a>') % dict(
series=p(series), href=search_action_with_data(st, series, book_id, field), sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls="series_name",
tt=p(_('Click to see books in this series'))) series=p(series), href=search_action_with_data(st, series, book_id, field),
elif metadata['datatype'] == 'datetime': tt=p(_('Click to see books in this series')))
aval = getattr(mi, field) val += add_other_link('series', mi.series)
if is_date_undefined(aval): elif metadata['datatype'] == 'datetime':
continue aval = getattr(mi, field)
aval = format_date(aval, 'yyyy-MM-dd') if is_date_undefined(aval):
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:
continue continue
aval = format_date(aval, 'yyyy-MM-dd')
key = field if field != 'timestamp' else 'date'
if val == aval: if val == aval:
val = '<a href="{}" title="{}">{}</a>'.format( 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) _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
else: else:
val = '<a href="{}" title="{}">{}</a>'.format( 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( _('Click to see books with {0}: {1} (derived from {2})').format(
metadata['name'] or field, aval, val)), val) metadata['name'] or field, aval, val)), val)
except Exception: elif metadata['datatype'] == 'text' and metadata['is_multiple']:
import traceback try:
traceback.print_exc() 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))) ans.append((field, row % (name, val)))

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():
d = BookInfo(self.gui, self.gui.library_view, index, # Window #0 is slaved to changes in the book list. As such
self.gui.book_details.handle_click) # 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) 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):
from calibre.gui2.ui import get_gui if hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None:
gui = get_gui() db = mi._bd_dbwref
else:
from calibre.gui2.ui import 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,
from calibre.gui2.ui import get_gui 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, 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,9 +255,12 @@ 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):
from calibre.gui2.ui import get_gui if mi is not None and hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None:
db = get_gui().current_db db = mi._bd_dbwref
else:
from calibre.gui2.ui import get_gui
db = get_gui().current_db
if use_defaults: if use_defaults:
src = db.prefs.defaults src = db.prefs.defaults
else: 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', 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,17 +116,19 @@ 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):
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): class BookInfo(QDialog):
@ -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,47 +180,76 @@ 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.previous_button = QPushButton(QIcon.ic('previous.png'), _('&Previous'), self)
self.clabel.linkActivated.connect(self.configure) self.previous_button.clicked.connect(self.previous)
hl.addWidget(self.clabel) l2.addWidget(self.previous_button, l2.rowCount(), 0)
self.previous_button = QPushButton(QIcon.ic('previous.png'), _('&Previous'), self) self.next_button = QPushButton(QIcon.ic('next.png'), _('&Next'), self)
self.previous_button.clicked.connect(self.previous) self.next_button.clicked.connect(self.next)
l2.addWidget(self.previous_button, l2.rowCount(), 0) l2.addWidget(self.next_button, l2.rowCount() - 1, 1)
self.next_button = QPushButton(QIcon.ic('next.png'), _('&Next'), self) self.ns = QShortcut(QKeySequence('Alt+Right'), self)
self.next_button.clicked.connect(self.next) self.ns.activated.connect(self.next)
l2.addWidget(self.next_button, l2.rowCount() - 1, 1) 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.path_to_book = None
self.current_row = None self.current_row = None
self.refresh(row) self.slave_connected = False
self.view.model().new_bookdisplay_data.connect(self.slave) if library_path is not None:
self.fit_cover.stateChanged.connect(self.toggle_cover_fit) self.view = None
self.ns = QShortcut(QKeySequence('Alt+Right'), self) from calibre.db.legacy import LibraryDatabase
self.ns.activated.connect(self.next) db = LibraryDatabase(library_path, read_only=True, is_second_db=True)
self.ps = QShortcut(QKeySequence('Alt+Left'), self) if book_id is None:
self.ps.activated.connect(self.previous) ids = db.new_api.search(query)
self.next_button.setToolTip(_('Next [%s]')% if len(ids) == 0:
str(self.ns.key().toString(QKeySequence.SequenceFormat.NativeText))) raise ValueError(_('Query "{}" found no books').format(query))
self.previous_button.setToolTip(_('Previous [%s]')% book_id = sorted([i for i in ids])[0]
str(self.ps.key().toString(QKeySequence.SequenceFormat.NativeText))) 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: 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: except Exception:
pass pass
ema = get_gui().iactions['Edit Metadata'].menuless_qaction
a = self.ema = QAction('edit metadata', self) def geometry_string(self, txt):
a.setShortcut(ema.shortcut()) if self.dialog_number is None or self.dialog_number == 0:
self.addAction(a) return txt
a.triggered.connect(self.edit_metadata) return txt + '_' + str(self.dialog_number)
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 sizeHint(self): def sizeHint(self):
try: try:
@ -248,10 +285,11 @@ 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)
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.view = self.link_delegate = self.gui = None
self.closed.emit(self) self.closed.emit(self)
return ret return ret
@ -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':
@ -530,6 +546,8 @@ class TagListEditor(QDialog, Ui_TagListEditor):
self.table.blockSignals(False) self.table.blockSignals(False)
def finish_editing(self, edited_item): def finish_editing(self, edited_item):
if edited_item.column != 0:
return
if not edited_item.text(): if not edited_item.text():
error_dialog(self, _('Item is blank'), _( error_dialog(self, _('Item is blank'), _(
'An item cannot be set to nothing. Delete it instead.'), show=True) 'An item cannot be set to nothing. Delete it instead.'), show=True)
@ -662,4 +680,7 @@ 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()):
self.links[self.table.item(r, 0).text()] = self.table.item(r, 3).text()
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: