diff --git a/manual/url_scheme.rst b/manual/url_scheme.rst index ea5478500a..269941e198 100644 --- a/manual/url_scheme.rst +++ b/manual/url_scheme.rst @@ -116,6 +116,16 @@ If you perform a search in calibre and want to generate a link for it you can do so by right clicking the search bar and choosing :guilabel:`Copy search as URL`. +Open a book details window on a book in some library +------------------------------------------------------ + +The URL syntax is:: + + calibre://book-details/Library_Name/book_id + +This opens a book details window on the specified book from the specified library without changing the +current library or the selected book. + .. _hex_encoding: diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py index ffc13cfb30..f9b3c06208 100644 --- a/src/calibre/db/backend.py +++ b/src/calibre/db/backend.py @@ -1121,6 +1121,7 @@ class DB: CREATE TABLE %s( id INTEGER PRIMARY KEY AUTOINCREMENT, value %s NOT NULL %s, + link TEXT NOT NULL DEFAULT "", UNIQUE(value)); '''%(table, dt, collate), diff --git a/src/calibre/db/cache.py b/src/calibre/db/cache.py index 2ef2f5d449..ed29b05c31 100644 --- a/src/calibre/db/cache.py +++ b/src/calibre/db/cache.py @@ -153,6 +153,7 @@ class Cache: self.format_metadata_cache = defaultdict(dict) self.formatter_template_cache = {} self.dirtied_cache = {} + self.link_maps_cache = {} self.vls_for_books_cache = None self.vls_for_books_lib_in_process = None self.vls_cache_lock = Lock() @@ -290,6 +291,7 @@ class Cache: self.format_metadata_cache.clear() if search_cache: self._clear_search_caches(book_ids) + self.link_maps_cache = {} @write_api def reload_from_db(self, clear_caches=True): @@ -382,6 +384,8 @@ class Cache: for key in composites: mi.set(key, val=self._composite_for(key, book_id, mi)) + mi.link_maps = self.get_all_link_maps_for_book(book_id) + user_cat_vals = {} if get_user_categories: user_cats = self._pref('user_categories', {}) @@ -2322,6 +2326,93 @@ class Cache: self._mark_as_dirty(changed_books) return changed_books + @read_api + def has_link_map(self, field): + if field not in self.fields: + raise ValueError(f'Lookup name {field} is not a valid name') + table = self.fields[field].table + return hasattr(table, 'link_map') + + @read_api + def get_link_map(self, for_field): + ''' + Return a dict of links for the supplied field. + + field: the lookup name of the field for which the link map is desired + + returns {field_value:link_value, ...} for non-empty links + ''' + if for_field not in self.fields: + raise ValueError(f'Lookup name {for_field} is not a valid name') + table = self.fields[for_field].table + if not hasattr(table, 'link_map'): + raise ValueError(f"Lookup name {for_field} doesn't have a link map") + lm = table.link_map + vm = table.id_map + return dict({vm.get(fid, None):v for fid,v in lm.items() if v}) + + @read_api + def get_all_link_maps_for_book(self, book_id): + ''' + Returns all links for all fields referenced by book identified by book_id + + book_id: the book id in question. + + returns: + {field: {field_value, link_value}, ... + for all fields that have a non-empty link value for that book + + Example: Assume author A has link X, author B has link Y, tag S has link + F, and tag T has link G. IF book 1 has author A and + tag T, this method returns {'authors':{'A':'X'}, 'tags':{'T', 'G'}} + If book 2's author is neither A nor B and has no tags, this + method returns {} + ''' + if book_id in self.link_maps_cache: + return self.link_maps_cache[book_id] + links = {} + def add_links_for_field(f): + field_ids = frozenset(self.field_ids_for(f, book_id)) + table = self.fields[f].table + lm = table.link_map + vm = table.id_map + d = dict({vm.get(fid, None):v for fid,v in lm.items() if v and fid in field_ids}) + if d: + links[f] = d + for field in ('authors', 'publisher', 'series', 'tags'): + add_links_for_field(field) + for field in self.field_metadata.custom_field_keys(include_composites=False): + if self.has_link_map(field): + add_links_for_field(field) + self.link_maps_cache[book_id] = links + return links + + @write_api + def set_link_map(self, field, value_to_link_map): + ''' + Sets links for item values in field + + field: the lookup name + value_to_link_map: dict(field_value:link, ...). Note that these are + values, not field ids. + + returns books changed by setting the link + ''' + if field not in self.fields: + raise ValueError(f'Lookup name {field} is not a valid name') + table = self.fields[field].table + if not hasattr(table, 'link_map'): + raise ValueError(f"Lookup name {field} doesn't have a link map") + fids = {k: self.get_item_id(field, k) for k in value_to_link_map.keys()} + id_to_link_map = {fid:value_to_link_map[k] for k, fid in fids.items() if fid is not None} + result_map = table.set_links(id_to_link_map, self.backend) + changed_books = set() + for id_ in result_map: + changed_books |= self._books_for_field(field, id_) + if changed_books: + self._mark_as_dirty(changed_books) + return changed_books + @read_api def lookup_by_uuid(self, uuid): return self.fields['uuid'].table.lookup_by_uuid(uuid) diff --git a/src/calibre/db/fields.py b/src/calibre/db/fields.py index 49da62475d..a6a443efd9 100644 --- a/src/calibre/db/fields.py +++ b/src/calibre/db/fields.py @@ -642,7 +642,7 @@ class AuthorsField(ManyToManyField): return { 'name': self.table.id_map[author_id], 'sort': self.table.asort_map[author_id], - 'link': self.table.alink_map[author_id], + 'link': self.table.link_map[author_id], } def category_sort_value(self, item_id, book_ids, lang_map): diff --git a/src/calibre/db/lazy.py b/src/calibre/db/lazy.py index 617c590ecf..2ee0ded5c1 100644 --- a/src/calibre/db/lazy.py +++ b/src/calibre/db/lazy.py @@ -330,6 +330,9 @@ class ProxyMetadata(Metadata): sa(self, '_user_metadata', db.field_metadata) def __getattribute__(self, field): + if field == 'link_maps': + db = ga(self, '_db')() + return db.get_all_link_maps_for_book(ga(self, '_book_id')) getter = getters.get(field, None) if getter is not None: return getter(ga(self, '_db'), ga(self, '_book_id'), ga(self, '_cache')) diff --git a/src/calibre/db/schema_upgrades.py b/src/calibre/db/schema_upgrades.py index e5b4bf4419..f7e888f02d 100644 --- a/src/calibre/db/schema_upgrades.py +++ b/src/calibre/db/schema_upgrades.py @@ -793,3 +793,28 @@ CREATE TRIGGER fkc_annot_update def upgrade_version_24(self): self.db.reindex_annotations() + + def upgrade_version_25(self): + for record in self.db.execute( + 'SELECT label,name,datatype,editable,display,normalized,id,is_multiple FROM custom_columns'): + data = { + 'label':record[0], + 'name':record[1], + 'datatype':record[2], + 'editable':bool(record[3]), + 'display':record[4], + 'normalized':bool(record[5]), + 'num':record[6], + 'is_multiple':bool(record[7]), + } + if data['normalized']: + tn = 'custom_column_{}'.format(data['num']) + self.db.execute(f'ALTER TABLE {tn} ADD COLUMN link TEXT NOT NULL DEFAULT "";') + self.db.execute('ALTER TABLE publishers ADD COLUMN link TEXT NOT NULL DEFAULT "";') + self.db.execute('ALTER TABLE series ADD COLUMN link TEXT NOT NULL DEFAULT "";') + self.db.execute('ALTER TABLE tags ADD COLUMN link TEXT NOT NULL DEFAULT "";') + # These aren't necessary in that there is no UI to set links, but having them + # makes the code uniform + self.db.execute('ALTER TABLE languages ADD COLUMN link TEXT NOT NULL DEFAULT "";') + self.db.execute('ALTER TABLE ratings ADD COLUMN link TEXT NOT NULL DEFAULT "";') + diff --git a/src/calibre/db/tables.py b/src/calibre/db/tables.py index 4a2a4da706..18658a3401 100644 --- a/src/calibre/db/tables.py +++ b/src/calibre/db/tables.py @@ -199,19 +199,22 @@ class ManyToOneTable(Table): def read(self, db): self.id_map = {} + self.link_map = {} self.col_book_map = defaultdict(set) self.book_col_map = {} self.read_id_maps(db) self.read_maps(db) def read_id_maps(self, db): - query = db.execute('SELECT id, {} FROM {}'.format( + query = db.execute('SELECT id, {}, link FROM {}'.format( self.metadata['column'], self.metadata['table'])) if self.unserialize is None: - self.id_map = dict(query) + us = lambda x: x else: us = self.unserialize - self.id_map = {book_id:us(val) for book_id, val in query} + for id_, val, link in query: + self.id_map[id_] = us(val) + self.link_map[id_] = link def read_maps(self, db): cbm = self.col_book_map @@ -343,6 +346,14 @@ class ManyToOneTable(Table): self.link_table, lcol, table), (existing_item, item_id, item_id)) return affected_books, new_id + def set_links(self, link_map, db): + link_map = {id_:(l or '').strip() for id_, l in iteritems(link_map)} + link_map = {id_:l for id_, l in iteritems(link_map) if l != self.link_map.get(id_)} + self.link_map.update(link_map) + db.executemany(f'UPDATE {self.metadata["table"]} SET link=? WHERE id=?', + [(v, k) for k, v in iteritems(link_map)]) + return link_map + class RatingTable(ManyToOneTable): @@ -526,7 +537,7 @@ class ManyToManyTable(ManyToOneTable): class AuthorsTable(ManyToManyTable): def read_id_maps(self, db): - self.alink_map = lm = {} + self.link_map = lm = {} self.asort_map = sm = {} self.id_map = im = {} us = self.unserialize @@ -547,8 +558,8 @@ class AuthorsTable(ManyToManyTable): def set_links(self, link_map, db): link_map = {aid:(l or '').strip() for aid, l in iteritems(link_map)} - link_map = {aid:l for aid, l in iteritems(link_map) if l != self.alink_map.get(aid, None)} - self.alink_map.update(link_map) + link_map = {aid:l for aid, l in iteritems(link_map) if l != self.link_map.get(aid, None)} + self.link_map.update(link_map) db.executemany('UPDATE authors SET link=? WHERE id=?', [(v, k) for k, v in iteritems(link_map)]) return link_map @@ -556,14 +567,14 @@ class AuthorsTable(ManyToManyTable): def remove_books(self, book_ids, db): clean = ManyToManyTable.remove_books(self, book_ids, db) for item_id in clean: - self.alink_map.pop(item_id, None) + self.link_map.pop(item_id, None) self.asort_map.pop(item_id, None) return clean def rename_item(self, item_id, new_name, db): ret = ManyToManyTable.rename_item(self, item_id, new_name, db) if item_id not in self.id_map: - self.alink_map.pop(item_id, None) + self.link_map.pop(item_id, None) self.asort_map.pop(item_id, None) else: # Was a simple rename, update the author sort value diff --git a/src/calibre/db/tests/add_remove.py b/src/calibre/db/tests/add_remove.py index 34e627d5c3..2485836d35 100644 --- a/src/calibre/db/tests/add_remove.py +++ b/src/calibre/db/tests/add_remove.py @@ -225,7 +225,7 @@ class AddRemoveTest(BaseTest): self.assertNotIn(3, c.all_book_ids()) self.assertNotIn('Unknown', set(itervalues(table.id_map))) self.assertNotIn(item_id, table.asort_map) - self.assertNotIn(item_id, table.alink_map) + self.assertNotIn(item_id, table.link_map) ae(len(table.id_map), olen-1) # Check that files are removed diff --git a/src/calibre/db/tests/writing.py b/src/calibre/db/tests/writing.py index 5dbfe5c2ef..fc01e295ac 100644 --- a/src/calibre/db/tests/writing.py +++ b/src/calibre/db/tests/writing.py @@ -919,4 +919,28 @@ class WritingTest(BaseTest): ae(cache.field_for('series_index', 1), 2.0) ae(cache.field_for('series_index', 2), 3.5) + def test_link_maps(self): + cache = self.init_cache() + + cache.set_field('tags', {1:'foo'}) + self.assertEqual(('foo',), cache.field_for('tags', 1), 'Setting tag foo failed') + cache.set_field('tags', {1:'foo, bar'}) + self.assertEqual(('foo', 'bar'), cache.field_for('tags', 1), 'Adding second tag failed') + + links = cache.get_link_map('tags') + self.assertDictEqual(links, {}, 'Initial tags link dict is not empty') + links['foo'] = 'url' + cache.set_link_map('tags', links) + links2 = cache.get_link_map('tags') + self.assertDictEqual(links2, links, 'tags link dict mismatch') + + cache.set_field('publisher', {1:'random'}) + cache.set_link_map('publisher', {'random': 'url2'}) + + links = cache.get_all_link_maps_for_book(1) + self.assert_('foo' in links['tags'], 'foo not there') + self.assert_('bar' not in links['tags'], 'bar is there') + self.assert_('random' in links['publisher'], 'random is not there') + self.assertSetEqual({'tags', 'publisher'}, set(links.keys()), 'Link map has extra stuff') + # }}} diff --git a/src/calibre/db/write.py b/src/calibre/db/write.py index 8b240316ff..48ebd1fd03 100644 --- a/src/calibre/db/write.py +++ b/src/calibre/db/write.py @@ -295,7 +295,8 @@ def get_db_id(val, db, m, table, kmap, rid_map, allow_case_change, table.col_book_map[item_id] = set() if is_authors: table.asort_map[item_id] = aus - table.alink_map[item_id] = '' + if hasattr(table, 'link_map'): + table.link_map[item_id] = '' elif allow_case_change and val != table.id_map[item_id]: case_changes[item_id] = val val_map[val] = item_id @@ -491,7 +492,8 @@ def many_many(book_id_val_map, db, field, allow_case_change, *args): table.col_book_map.pop(item_id, None) if is_authors: table.asort_map.pop(item_id, None) - table.alink_map.pop(item_id, None) + if hasattr(table, 'link_map'): + table.link_map.pop(item_id, None) return dirtied diff --git a/src/calibre/ebooks/metadata/book/render.py b/src/calibre/ebooks/metadata/book/render.py index e8fd0e5ee8..432be82ad4 100644 --- a/src/calibre/ebooks/metadata/book/render.py +++ b/src/calibre/ebooks/metadata/book/render.py @@ -91,6 +91,24 @@ def mi_to_html( rating_font='Liberation Serif', rtl=False, comments_heading_pos='hide', for_qt=False, vertical_fields=() ): + + show_links = not hasattr(mi, '_bd_dbwref') + + def get_link_map(column): + if not show_links: + return {} + try: + return mi.link_maps[column] + except (KeyError, ValueError): + return {column:{}} + + def add_other_link(field, field_value): + link = get_link_map(field).get(field_value, None) + if link: + return (' %s'%(_('Click to open {}').format(link), link, _('(item link)'))) + else: + return '' + if field_list is None: field_list = get_field_list(mi) ans = [] @@ -170,9 +188,12 @@ def mi_to_html( else: all_vals = [v.strip() for v in val.split(metadata['is_multiple']['cache_to_list']) if v.strip()] - links = ['{}'.format( - search_action(field, x), _('Click to see books with {0}: {1}').format( + if show_links: + links = ['{}'.format( + search_action(field, x), _('Click to see books with {0}: {1}').format( metadata['name'], a(x)), p(x)) for x in all_vals] + else: + links = all_vals val = value_list(metadata['is_multiple']['list_to_ui'], links) ans.append((field, row % (name, val))) elif field == 'path': @@ -188,10 +209,15 @@ def mi_to_html( durl = ':::'.join((durl.split(':::'))[2:]) extra = '
%s'%( prepare_string_for_xml(durl)) - link = '{}{}'.format(action(scheme, loc=loc), + if show_links: + link = '{}{}'.format(action(scheme, loc=loc), prepare_string_for_xml(path, True), pathstr, extra) + else: + link = prepare_string_for_xml(path, True) ans.append((field, row % (name, link))) elif field == 'formats': + # Don't need show_links here because formats are removed from mi on + # cross library displays. if isdevice: continue path = mi.path or '' @@ -209,12 +235,15 @@ def mi_to_html( ans.append((field, row % (name, value_list(', ', fmts)))) elif field == 'identifiers': urls = urls_from_identifiers(mi.identifiers, sort_results=True) - links = [ - '{}'.format( - action('identifier', url=url, name=namel, id_type=id_typ, value=id_val, field='identifiers', book_id=book_id), - a(id_typ), a(id_val), p(namel)) - for namel, id_typ, id_val, url in urls] - links = value_list(', ', links) + if show_links: + links = [ + '{}'.format( + action('identifier', url=url, name=namel, id_type=id_typ, value=id_val, field='identifiers', book_id=book_id), + a(id_typ), a(id_val), p(namel)) + for namel, id_typ, id_val, url in urls] + links = value_list(', ', links) + else: + links = ', '.join(mi.identifiers) if links: ans.append((field, row % (_('Ids')+':', links))) elif field == 'authors': @@ -222,39 +251,46 @@ def mi_to_html( formatter = EvalFormatter() for aut in mi.authors: link = '' - if mi.author_link_map.get(aut): - link = lt = mi.author_link_map[aut] - elif default_author_link: - if default_author_link.startswith('search-'): - which_src = default_author_link.partition('-')[2] - link, lt = author_search_href(which_src, title=mi.title, author=aut) + if show_links: + if default_author_link: + if default_author_link.startswith('search-'): + which_src = default_author_link.partition('-')[2] + link, lt = author_search_href(which_src, title=mi.title, author=aut) + else: + vals = {'author': qquote(aut), 'title': qquote(mi.title)} + try: + vals['author_sort'] = qquote(mi.author_sort_map[aut]) + except KeyError: + vals['author_sort'] = qquote(aut) + link = lt = formatter.safe_format(default_author_link, vals, '', vals) else: - vals = {'author': qquote(aut), 'title': qquote(mi.title)} - try: - vals['author_sort'] = qquote(mi.author_sort_map[aut]) - except KeyError: - vals['author_sort'] = qquote(aut) - link = lt = formatter.safe_format(default_author_link, vals, '', vals) - aut = p(aut) + aut = p(aut) if link: - authors.append('%s'%(a(lt), action('author', url=link, name=aut, title=lt), aut)) + val = '%s'%(a(lt), action('author', url=link, name=aut, title=lt), aut) else: - authors.append(aut) + val = aut + val += add_other_link('authors', aut) + authors.append(val) ans.append((field, row % (name, value_list(' & ', authors)))) elif field == 'languages': if not mi.languages: continue names = filter(None, map(calibre_langcode_to_name, mi.languages)) - names = ['{}'.format(search_action_with_data('languages', n, book_id), _( - 'Search calibre for books with the language: {}').format(n), n) for n in names] + if show_links: + names = ['{}'.format(search_action_with_data('languages', n, book_id), _( + 'Search calibre for books with the language: {}').format(n), n) for n in names] ans.append((field, row % (name, value_list(', ', names)))) elif field == 'publisher': if not mi.publisher: continue - val = '{}'.format( - search_action_with_data('publisher', mi.publisher, book_id), - _('Click to see books with {0}: {1}').format(metadata['name'], a(mi.publisher)), - p(mi.publisher)) + if show_links: + val = '{}'.format( + search_action_with_data('publisher', mi.publisher, book_id), + _('Click to see books with {0}: {1}').format(metadata['name'], a(mi.publisher)), + p(mi.publisher)) + val += add_other_link('publisher', mi.publisher) + else: + val = p(mi.publisher) ans.append((field, row % (name, val))) elif field == 'title': # otherwise title gets metadata['datatype'] == 'text' @@ -268,79 +304,85 @@ def mi_to_html( if val is None: continue val = p(val) - if metadata['datatype'] == 'series': - sidx = mi.get(field+'_index') - if sidx is None: - sidx = 1.0 - try: - st = metadata['search_terms'][0] - except Exception: - st = field - series = getattr(mi, field) - val = _( - '%(sidx)s of ' - '%(series)s') % dict( - sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls="series_name", - series=p(series), href=search_action_with_data(st, series, book_id, field), - tt=p(_('Click to see books in this series'))) - elif metadata['datatype'] == 'datetime': - aval = getattr(mi, field) - if is_date_undefined(aval): - continue - aval = format_date(aval, 'yyyy-MM-dd') - key = field if field != 'timestamp' else 'date' - if val == aval: - val = '{}'.format( - search_action_with_data(key, str(aval), book_id, None, original_value=val), a( - _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val) - else: - val = '{}'.format( - search_action_with_data(key, str(aval), book_id, None, original_value=val), a( - _('Click to see books with {0}: {1} (derived from {2})').format( - metadata['name'] or field, aval, val)), val) - elif metadata['datatype'] == 'text' and metadata['is_multiple']: - try: - st = metadata['search_terms'][0] - except Exception: - st = field - all_vals = mi.get(field) - if not metadata.get('display', {}).get('is_names', False): - all_vals = sorted(all_vals, key=sort_key) - links = ['{}'.format( - search_action_with_data(st, x, book_id, field), _('Click to see books with {0}: {1}').format( - metadata['name'] or field, a(x)), p(x)) - for x in all_vals] - val = value_list(metadata['is_multiple']['list_to_ui'], links) - elif metadata['datatype'] == 'text' or metadata['datatype'] == 'enumeration': - # text/is_multiple handled above so no need to add the test to the if - try: - st = metadata['search_terms'][0] - except Exception: - st = field - val = '{}'.format( - search_action_with_data(st, unescaped_val, book_id, field), a( - _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val) - elif metadata['datatype'] == 'bool': - val = '{}'.format( - search_action_with_data(field, val, book_id, None), a( - _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val) - else: - try: - aval = str(getattr(mi, field)) - if not aval: + if show_links: + if metadata['datatype'] == 'series': + sidx = mi.get(field+'_index') + if sidx is None: + sidx = 1.0 + try: + st = metadata['search_terms'][0] + except Exception: + st = field + series = getattr(mi, field) + val = _( + '%(sidx)s of ' + '%(series)s') % dict( + sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls="series_name", + series=p(series), href=search_action_with_data(st, series, book_id, field), + tt=p(_('Click to see books in this series'))) + val += add_other_link('series', mi.series) + elif metadata['datatype'] == 'datetime': + aval = getattr(mi, field) + if is_date_undefined(aval): continue + aval = format_date(aval, 'yyyy-MM-dd') + key = field if field != 'timestamp' else 'date' if val == aval: val = '{}'.format( - search_action_with_data(field, str(aval), book_id, None, original_value=val), a( + search_action_with_data(key, str(aval), book_id, None, original_value=val), a( _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val) else: val = '{}'.format( - search_action_with_data(field, str(aval), book_id, None, original_value=val), a( + search_action_with_data(key, str(aval), book_id, None, original_value=val), a( _('Click to see books with {0}: {1} (derived from {2})').format( metadata['name'] or field, aval, val)), val) - except Exception: - import traceback - traceback.print_exc() + elif metadata['datatype'] == 'text' and metadata['is_multiple']: + try: + st = metadata['search_terms'][0] + except Exception: + st = field + all_vals = mi.get(field) + if not metadata.get('display', {}).get('is_names', False): + all_vals = sorted(all_vals, key=sort_key) + links = [] + for x in all_vals: + v = '{}'.format( + search_action_with_data(st, x, book_id, field), _('Click to see books with {0}: {1}').format( + metadata['name'] or field, a(x)), p(x)) + v += add_other_link(field, x) + links.append(v) + val = value_list(metadata['is_multiple']['list_to_ui'], links) + elif metadata['datatype'] == 'text' or metadata['datatype'] == 'enumeration': + # text/is_multiple handled above so no need to add the test to the if + try: + st = metadata['search_terms'][0] + except Exception: + st = field + v = '{}'.format( + search_action_with_data(st, unescaped_val, book_id, field), a( + _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val) + val = v + add_other_link(field, val) + elif metadata['datatype'] == 'bool': + val = '{}'.format( + search_action_with_data(field, val, book_id, None), a( + _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val) + else: + try: + aval = str(getattr(mi, field)) + if not aval: + continue + if val == aval: + val = '{}'.format( + search_action_with_data(field, str(aval), book_id, None, original_value=val), a( + _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val) + else: + val = '{}'.format( + search_action_with_data(field, str(aval), book_id, None, original_value=val), a( + _('Click to see books with {0}: {1} (derived from {2})').format( + metadata['name'] or field, aval, val)), val) + except Exception: + import traceback + traceback.print_exc() ans.append((field, row % (name, val))) diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py index 50dc3384a9..b56eb178f4 100644 --- a/src/calibre/ebooks/metadata/opf2.py +++ b/src/calibre/ebooks/metadata/opf2.py @@ -595,6 +595,8 @@ class OPF: # {{{ renderer=dump_dict) author_link_map = MetadataField('author_link_map', is_dc=False, formatter=json.loads, renderer=dump_dict) + link_map = MetadataField('link_maps', is_dc=False, + formatter=json.loads, renderer=dump_dict) def __init__(self, stream, basedir=os.getcwd(), unquote_urls=True, populate_spine=True, try_to_guess_cover=False, preparsed_opf=None, read_toc=True): @@ -1310,7 +1312,7 @@ class OPF: # {{{ for attr in ('title', 'authors', 'author_sort', 'title_sort', 'publisher', 'series', 'series_index', 'rating', 'isbn', 'tags', 'category', 'comments', 'book_producer', - 'pubdate', 'user_categories', 'author_link_map'): + 'pubdate', 'user_categories', 'author_link_map', 'link_map'): val = getattr(mi, attr, None) if attr == 'rating' and val: val = float(val) @@ -1673,6 +1675,8 @@ def metadata_to_opf(mi, as_string=True, default_lang=None): return factory('meta', name='calibre:' + n, content=c) if getattr(mi, 'author_link_map', None) is not None: meta('author_link_map', dump_dict(mi.author_link_map)) + if getattr(mi, 'link_maps', None) is not None: + meta('link_maps', dump_dict(mi.link_maps)) if mi.series: meta('series', mi.series) if mi.series_index is not None: diff --git a/src/calibre/gui2/actions/show_book_details.py b/src/calibre/gui2/actions/show_book_details.py index e74847885e..6a79e847a1 100644 --- a/src/calibre/gui2/actions/show_book_details.py +++ b/src/calibre/gui2/actions/show_book_details.py @@ -23,25 +23,61 @@ class ShowBookDetailsAction(InterfaceAction): def genesis(self): self.qaction.triggered.connect(self.show_book_info) self.memory = [] + self.dialogs = [None, ] - def show_book_info(self, *args): + def show_book_info(self, *args, **kwargs): if self.gui.current_view() is not self.gui.library_view: error_dialog(self.gui, _('No detailed info available'), _('No detailed information is available for books ' 'on the device.')).exec() return + library_path = kwargs.get('library_path', None) + book_id = kwargs.get('book_id', None) + library_id = kwargs.get('library_id', None) + query = kwargs.get('query', None) index = self.gui.library_view.currentIndex() - if index.isValid(): - d = BookInfo(self.gui, self.gui.library_view, index, - self.gui.book_details.handle_click) + if library_path or index.isValid(): + # Window #0 is slaved to changes in the book list. As such + # it must not be used for details from other libraries. + for dn,v in enumerate(self.dialogs): + if dn == 0 and library_path: + continue + if v is None: + break + else: + self.dialogs.append(None) + dn += 1 + + try: + d = BookInfo(self.gui, self.gui.library_view, index, + self.gui.book_details.handle_click, dialog_number=dn, + library_id=library_id, library_path=library_path, book_id=book_id, query=query) + except ValueError as e: + error_dialog(self.gui, _('Book not found'), str(e)).exec() + return + d.open_cover_with.connect(self.gui.bd_open_cover_with, type=Qt.ConnectionType.QueuedConnection) + self.dialogs[dn] = d self.memory.append(d) d.closed.connect(self.closed, type=Qt.ConnectionType.QueuedConnection) d.show() + def shutting_down(self): + for d in self.dialogs: + if d: + d.done(0) + + def library_about_to_change(self, *args): + for i,d in enumerate(self.dialogs): + if i == 0: + continue + if d: + d.done(0) + def closed(self, d): try: d.closed.disconnect(self.closed) + self.dialogs[d.dialog_number] = None self.memory.remove(d) except ValueError: pass diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py index fc38d86d41..f39fbf5b12 100644 --- a/src/calibre/gui2/book_details.py +++ b/src/calibre/gui2/book_details.py @@ -42,12 +42,15 @@ InternetSearch = namedtuple('InternetSearch', 'author where') def set_html(mi, html, text_browser): - from calibre.gui2.ui import get_gui - gui = get_gui() + if hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None: + db = mi._bd_dbwref + else: + from calibre.gui2.ui import get_gui + db = get_gui().current_db book_id = getattr(mi, 'id', None) search_paths = [] - if gui and book_id is not None: - path = gui.current_db.abspath(book_id, index_is_id=True) + if db and book_id is not None: + path = db.abspath(book_id, index_is_id=True) if path: search_paths = [path] text_browser.setSearchPaths(search_paths) @@ -203,10 +206,16 @@ def comments_pat(): return re.compile(r'', re.DOTALL) -def render_html(mi, vertical, widget, all_fields=False, render_data_func=None, pref_name='book_display_fields'): # {{{ - from calibre.gui2.ui import get_gui +def render_html(mi, vertical, widget, all_fields=False, render_data_func=None, + pref_name='book_display_fields', + pref_value=None): # {{{ + if hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None: + db = mi._bd_dbwref + else: + from calibre.gui2.ui import get_gui + db = get_gui().current_db func = render_data_func or partial(render_data, - vertical_fields=get_gui().current_db.prefs.get('book_details_vertical_categories') or ()) + vertical_fields=db.prefs.get('book_details_vertical_categories') or ()) try: table, comment_fields = func(mi, all_fields=all_fields, use_roman_numbers=config['use_roman_numerals_for_series_number'], pref_name=pref_name) @@ -246,9 +255,12 @@ def render_html(mi, vertical, widget, all_fields=False, render_data_func=None, p return ans -def get_field_list(fm, use_defaults=False, pref_name='book_display_fields'): - from calibre.gui2.ui import get_gui - db = get_gui().current_db +def get_field_list(fm, use_defaults=False, pref_name='book_display_fields', mi=None): + if mi is not None and hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None: + db = mi._bd_dbwref + else: + from calibre.gui2.ui import get_gui + db = get_gui().current_db if use_defaults: src = db.prefs.defaults else: @@ -267,7 +279,8 @@ def get_field_list(fm, use_defaults=False, pref_name='book_display_fields'): def render_data(mi, use_roman_numbers=True, all_fields=False, pref_name='book_display_fields', vertical_fields=()): - field_list = get_field_list(getattr(mi, 'field_metadata', field_metadata), pref_name=pref_name) + field_list = get_field_list(getattr(mi, 'field_metadata', field_metadata), + pref_name=pref_name, mi=mi) field_list = [(x, all_fields or display) for x, display in field_list] return mi_to_html( mi, field_list=field_list, use_roman_numbers=use_roman_numbers, rtl=is_rtl(), diff --git a/src/calibre/gui2/dialogs/book_info.py b/src/calibre/gui2/dialogs/book_info.py index 19ed838c92..e205a21119 100644 --- a/src/calibre/gui2/dialogs/book_info.py +++ b/src/calibre/gui2/dialogs/book_info.py @@ -3,6 +3,8 @@ import textwrap +import weakref + from qt.core import ( QAction, QApplication, QBrush, QCheckBox, QDialog, QDialogButtonBox, QGridLayout, QHBoxLayout, QIcon, QKeySequence, QLabel, QListView, QModelIndex, QPalette, QPixmap, @@ -114,17 +116,19 @@ class Configure(Dialog): class Details(HTMLDisplay): - def __init__(self, book_info, parent=None): + def __init__(self, book_info, parent=None, allow_context_menu=True): HTMLDisplay.__init__(self, parent) self.book_info = book_info self.edit_metadata = getattr(parent, 'edit_metadata', None) self.setDefaultStyleSheet(css()) + self.allow_context_menu = allow_context_menu def sizeHint(self): return QSize(350, 350) def contextMenuEvent(self, ev): - details_context_menu_event(self, ev, self.book_info, edit_metadata=self.edit_metadata) + if self.allow_context_menu: + details_context_menu_event(self, ev, self.book_info, edit_metadata=self.edit_metadata) class BookInfo(QDialog): @@ -132,8 +136,11 @@ class BookInfo(QDialog): closed = pyqtSignal(object) open_cover_with = pyqtSignal(object, object) - def __init__(self, parent, view, row, link_delegate): - QDialog.__init__(self, parent) + def __init__(self, parent, view, row, link_delegate, dialog_number=None, + library_id=None, library_path=None, book_id=None, query=None): + QDialog.__init__(self, None, flags=Qt.WindowType.Window) + self.dialog_number = dialog_number + self.library_id = library_id self.marked = None self.gui = parent self.splitter = QSplitter(self) @@ -150,7 +157,8 @@ class BookInfo(QDialog): self.cover.sizeHint = self.details_size_hint self.splitter.addWidget(self.cover) - self.details = Details(parent.book_details.book_info, self) + self.details = Details(parent.book_details.book_info, self, + allow_context_menu=library_path is None) self.details.anchor_clicked.connect(self.on_link_clicked) self.link_delegate = link_delegate self.details.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False) @@ -172,47 +180,76 @@ class BookInfo(QDialog): hl.setContentsMargins(0, 0, 0, 0) l2.addLayout(hl, l2.rowCount(), 0, 1, -1) hl.addWidget(self.fit_cover), hl.addStretch() - self.clabel = QLabel('
{}'.format( - _('Configure this view'), _('Configure'))) - self.clabel.linkActivated.connect(self.configure) - hl.addWidget(self.clabel) - self.previous_button = QPushButton(QIcon.ic('previous.png'), _('&Previous'), self) - self.previous_button.clicked.connect(self.previous) - l2.addWidget(self.previous_button, l2.rowCount(), 0) - self.next_button = QPushButton(QIcon.ic('next.png'), _('&Next'), self) - self.next_button.clicked.connect(self.next) - l2.addWidget(self.next_button, l2.rowCount() - 1, 1) + if self.dialog_number == 0: + self.previous_button = QPushButton(QIcon.ic('previous.png'), _('&Previous'), self) + self.previous_button.clicked.connect(self.previous) + l2.addWidget(self.previous_button, l2.rowCount(), 0) + self.next_button = QPushButton(QIcon.ic('next.png'), _('&Next'), self) + self.next_button.clicked.connect(self.next) + l2.addWidget(self.next_button, l2.rowCount() - 1, 1) + self.ns = QShortcut(QKeySequence('Alt+Right'), self) + self.ns.activated.connect(self.next) + self.ps = QShortcut(QKeySequence('Alt+Left'), self) + self.ps.activated.connect(self.previous) + self.next_button.setToolTip(_('Next [%s]')% + str(self.ns.key().toString(QKeySequence.SequenceFormat.NativeText))) + self.previous_button.setToolTip(_('Previous [%s]')% + str(self.ps.key().toString(QKeySequence.SequenceFormat.NativeText))) - self.view = view self.path_to_book = None self.current_row = None - self.refresh(row) - self.view.model().new_bookdisplay_data.connect(self.slave) - self.fit_cover.stateChanged.connect(self.toggle_cover_fit) - self.ns = QShortcut(QKeySequence('Alt+Right'), self) - self.ns.activated.connect(self.next) - self.ps = QShortcut(QKeySequence('Alt+Left'), self) - self.ps.activated.connect(self.previous) - self.next_button.setToolTip(_('Next [%s]')% - str(self.ns.key().toString(QKeySequence.SequenceFormat.NativeText))) - self.previous_button.setToolTip(_('Previous [%s]')% - str(self.ps.key().toString(QKeySequence.SequenceFormat.NativeText))) + self.slave_connected = False + if library_path is not None: + self.view = None + from calibre.db.legacy import LibraryDatabase + db = LibraryDatabase(library_path, read_only=True, is_second_db=True) + if book_id is None: + ids = db.new_api.search(query) + if len(ids) == 0: + raise ValueError(_('Query "{}" found no books').format(query)) + book_id = sorted([i for i in ids])[0] + if not db.new_api.has_id(book_id): + raise ValueError(_("Book {} doesn't exist").format(book_id)) + mi = db.new_api.get_metadata(book_id, get_cover=False) + mi.cover_data = [None, db.new_api.cover(book_id, as_image=True)] + mi.path = None + mi.format_files = dict() + mi.formats = list() + mi.marked = '' + mi._bd_dbwref = weakref.proxy(db) + self.refresh(row, mi) + else: + self.view = view + if dialog_number == 0: + self.slave_connected = True + self.view.model().new_bookdisplay_data.connect(self.slave) + self.refresh(row) - self.restore_geometry(gprefs, 'book_info_dialog_geometry') + ema = get_gui().iactions['Edit Metadata'].menuless_qaction + a = self.ema = QAction('edit metadata', self) + a.setShortcut(ema.shortcut()) + self.addAction(a) + a.triggered.connect(self.edit_metadata) + vb = get_gui().iactions['View'].menuless_qaction + a = self.vba = QAction('view book', self) + a.setShortcut(vb.shortcut()) + a.triggered.connect(self.view_book) + self.addAction(a) + self.clabel = QLabel('
{}'.format( + _('Configure this view'), _('Configure'))) + self.clabel.linkActivated.connect(self.configure) + hl.addWidget(self.clabel) + self.fit_cover.stateChanged.connect(self.toggle_cover_fit) + self.restore_geometry(gprefs, self.geometry_string('book_info_dialog_geometry')) try: - self.splitter.restoreState(gprefs.get('book_info_dialog_splitter_state')) + self.splitter.restoreState(gprefs.get(self.geometry_string('book_info_dialog_splitter_state'))) except Exception: pass - ema = get_gui().iactions['Edit Metadata'].menuless_qaction - a = self.ema = QAction('edit metadata', self) - a.setShortcut(ema.shortcut()) - self.addAction(a) - a.triggered.connect(self.edit_metadata) - vb = get_gui().iactions['View'].menuless_qaction - a = self.vba = QAction('view book', self) - a.setShortcut(vb.shortcut()) - a.triggered.connect(self.view_book) - self.addAction(a) + + def geometry_string(self, txt): + if self.dialog_number is None or self.dialog_number == 0: + return txt + return txt + '_' + str(self.dialog_number) def sizeHint(self): try: @@ -248,10 +285,11 @@ class BookInfo(QDialog): self.link_delegate(link) def done(self, r): - self.save_geometry(gprefs, 'book_info_dialog_geometry') - gprefs['book_info_dialog_splitter_state'] = bytearray(self.splitter.saveState()) + self.save_geometry(gprefs, self.geometry_string('book_info_dialog_geometry')) + gprefs[self.geometry_string('book_info_dialog_splitter_state')] = bytearray(self.splitter.saveState()) ret = QDialog.done(self, r) - self.view.model().new_bookdisplay_data.disconnect(self.slave) + if self.slave_connected: + self.view.model().new_bookdisplay_data.disconnect(self.slave) self.view = self.link_delegate = self.gui = None self.closed.emit(self) return ret @@ -342,11 +380,15 @@ class BookInfo(QDialog): # Indicates books was deleted from library, or row numbers have # changed return - - self.previous_button.setEnabled(False if row == 0 else True) - self.next_button.setEnabled(False if row == self.view.model().rowCount(QModelIndex())-1 else True) + if self.dialog_number == 0: + self.previous_button.setEnabled(False if row == 0 else True) + self.next_button.setEnabled(False if row == self.view.model().rowCount(QModelIndex())-1 else True) + self.setWindowTitle(mi.title + ' ' + _('(the current book)')) + elif self.library_id is not None: + self.setWindowTitle(mi.title + ' ' + _('(from {})').format(self.library_id)) + else: + self.setWindowTitle(mi.title + ' ' + _('(will not change)')) self.current_row = row - self.setWindowTitle(mi.title) self.cover_pixmap = QPixmap.fromImage(mi.cover_data[1]) self.path_to_book = getattr(mi, 'path', None) try: diff --git a/src/calibre/gui2/dialogs/tag_list_editor.py b/src/calibre/gui2/dialogs/tag_list_editor.py index a452b57391..355a7cf344 100644 --- a/src/calibre/gui2/dialogs/tag_list_editor.py +++ b/src/calibre/gui2/dialogs/tag_list_editor.py @@ -132,7 +132,9 @@ class EditColumnDelegate(QItemDelegate): else: editor = EnLineEdit(parent) return editor - return None + editor = EnLineEdit(parent) + editor.setClearButtonEnabled(True) + return editor def destroyEditor(self, editor, index): self.editing_finished.emit(index.row()) @@ -142,12 +144,13 @@ class EditColumnDelegate(QItemDelegate): class TagListEditor(QDialog, Ui_TagListEditor): def __init__(self, window, cat_name, tag_to_match, get_book_ids, sorter, - ttm_is_first_letter=False, category=None, fm=None): + ttm_is_first_letter=False, category=None, fm=None, link_map=None): QDialog.__init__(self, window) Ui_TagListEditor.__init__(self) self.setupUi(self) self.verticalLayout_2.setAlignment(Qt.AlignmentFlag.AlignCenter) self.search_box.setMinimumContentsLength(25) + self.link_map = link_map # Put the category name into the title bar t = self.windowTitle() @@ -171,6 +174,7 @@ class TagListEditor(QDialog, Ui_TagListEditor): self.to_delete = set() self.all_tags = {} self.original_names = {} + self.links = {} self.ordered_tags = [] self.sorter = sorter @@ -413,13 +417,15 @@ class TagListEditor(QDialog, Ui_TagListEditor): select_item = None self.table.blockSignals(True) self.table.clear() - self.table.setColumnCount(3) + self.table.setColumnCount(4) self.name_col = QTableWidgetItem(self.category_name) self.table.setHorizontalHeaderItem(0, self.name_col) self.count_col = QTableWidgetItem(_('Count')) self.table.setHorizontalHeaderItem(1, self.count_col) self.was_col = QTableWidgetItem(_('Was')) self.table.setHorizontalHeaderItem(2, self.was_col) + self.link_col = QTableWidgetItem(_('Link')) + self.table.setHorizontalHeaderItem(3, self.link_col) self.table.setRowCount(len(tags)) for row,tag in enumerate(tags): @@ -457,6 +463,16 @@ class TagListEditor(QDialog, Ui_TagListEditor): item.setData(Qt.ItemDataRole.DisplayRole, tag) self.table.setItem(row, 2, item) + item = QTableWidgetItem() + if self.link_map is None: + item.setFlags(item.flags() & ~(Qt.ItemFlag.ItemIsSelectable|Qt.ItemFlag.ItemIsEditable)) + item.setText(_('no links available')) + else: + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + item.setText(self.link_map.get(tag, '')) + self.table.setItem(row, 3, item) + if self.last_sorted_by == 'name': self.table.sortByColumn(0, Qt.SortOrder(self.name_order)) elif self.last_sorted_by == 'count': @@ -662,4 +678,9 @@ class TagListEditor(QDialog, Ui_TagListEditor): self.table.sortByColumn(2, Qt.SortOrder(self.was_order)) def accepted(self): + self.links = {} + for r in range(0, self.table.rowCount()): + l = self.table.item(r, 3).text() + if l: + self.links[self.table.item(r, 0).text()] = l self.save_geometry() diff --git a/src/calibre/gui2/tag_browser/ui.py b/src/calibre/gui2/tag_browser/ui.py index 34afb2951d..51d49f3bd6 100644 --- a/src/calibre/gui2/tag_browser/ui.py +++ b/src/calibre/gui2/tag_browser/ui.py @@ -58,6 +58,7 @@ class TagBrowserMixin: # {{{ partial(func, *args)) fm = db.new_api.field_metadata categories = [x[0] for x in find_categories(fm) if fm.is_custom_field(x[0])] + categories = [c for c in categories if fm[c]['datatype'] != 'composite'] if categories: if len(categories) > 5: m = m.addMenu(_('Custom columns')) @@ -281,7 +282,8 @@ class TagBrowserMixin: # {{{ tag_to_match=tag, get_book_ids=partial(self.get_book_ids, db=db, category=category), sorter=key, ttm_is_first_letter=is_first_letter, - fm=db.field_metadata[category]) + fm=db.field_metadata[category], + link_map=db.new_api.get_link_map(category)) d.exec() if d.result() == QDialog.DialogCode.Accepted: to_rename = d.to_rename # dict of old id to new name @@ -300,6 +302,9 @@ class TagBrowserMixin: # {{{ db.new_api.remove_items(category, to_delete) db.new_api.rename_items(category, to_rename, change_index=False) + # Must do this at the end so renames are accounted for + db.new_api.set_link_map(category, d.links) + # Clean up the library view self.do_tag_item_renamed() self.tags_view.recount() diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 71f4385dc9..2d6dafea63 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -690,6 +690,22 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ library_path = self.library_broker.path_for_library_id(library_id) if not db_matches(self.current_db, library_id, library_path): self.library_moved(library_path) + elif action == 'book-details': + parts = tuple(filter(None, path.split('/'))) + if len(parts) != 2: + return + library_id, book_id = parts + library_id = decode_library_id(library_id) + library_path = self.library_broker.path_for_library_id(library_id) + if library_path is None: + return + try: + book_id = int(book_id) + except Exception: + prints('Ignoring invalid book id', book_id, file=sys.stderr) + return + details = self.iactions['Show Book Details'] + details.show_book_info(library_id=library_id, library_path=library_path, book_id=book_id) elif action == 'show-book': parts = tuple(filter(None, path.split('/'))) if len(parts) != 2: