mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
This is a large commit.
DB changes: 1) Add link columns to "normalized" tables. All such in-memory tables have an attribute self.link_map. 2) Add API to set and get links for fields 3) get_metadata now includes an attribute giving the link maps for the book. Book link maps are cached. 4) ProxyMetadata can return link maps 5) Added a test for the API. URL Scheme: 1) Added a "book-details" URL that asks calibre to open a book info window on a book in some library Book Details: 1) You can now have multiple book info windows. 2) If an item as an associated link then that link is made available using "(item link)" link text. 3) Book info windows on books in other libraries have no links UI: 1) the Manage Category editor presents a fourth column for links. OPF: 1) The OPF used for backing up (metadata.opf) contains the link map for the book. Currently this isn't used, but it should be used in recover_database. I didn't do anything with OPF3.
This commit is contained in:
parent
b2aa59b484
commit
ff4ed26896
@ -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:
|
||||
|
||||
|
@ -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),
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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'))
|
||||
|
@ -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 "";')
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
||||
# }}}
|
||||
|
@ -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
|
||||
|
||||
|
@ -91,6 +91,24 @@ def mi_to_html(
|
||||
rating_font='Liberation Serif', rtl=False, comments_heading_pos='hide',
|
||||
for_qt=False, vertical_fields=()
|
||||
):
|
||||
|
||||
show_links = not hasattr(mi, '_bd_dbwref')
|
||||
|
||||
def get_link_map(column):
|
||||
if not show_links:
|
||||
return {}
|
||||
try:
|
||||
return mi.link_maps[column]
|
||||
except (KeyError, ValueError):
|
||||
return {column:{}}
|
||||
|
||||
def add_other_link(field, field_value):
|
||||
link = get_link_map(field).get(field_value, None)
|
||||
if link:
|
||||
return (' <a title="%s" href="%s">%s</a>'%(_('Click to open {}').format(link), link, _('(item link)')))
|
||||
else:
|
||||
return ''
|
||||
|
||||
if field_list is None:
|
||||
field_list = get_field_list(mi)
|
||||
ans = []
|
||||
@ -170,9 +188,12 @@ def mi_to_html(
|
||||
else:
|
||||
all_vals = [v.strip()
|
||||
for v in val.split(metadata['is_multiple']['cache_to_list']) if v.strip()]
|
||||
if show_links:
|
||||
links = ['<a href="{}" title="{}">{}</a>'.format(
|
||||
search_action(field, x), _('Click to see books with {0}: {1}').format(
|
||||
metadata['name'], a(x)), p(x)) for x in all_vals]
|
||||
else:
|
||||
links = all_vals
|
||||
val = value_list(metadata['is_multiple']['list_to_ui'], links)
|
||||
ans.append((field, row % (name, val)))
|
||||
elif field == 'path':
|
||||
@ -188,10 +209,15 @@ def mi_to_html(
|
||||
durl = ':::'.join((durl.split(':::'))[2:])
|
||||
extra = '<br><span style="font-size:smaller">%s</span>'%(
|
||||
prepare_string_for_xml(durl))
|
||||
if show_links:
|
||||
link = '<a href="{}" title="{}">{}</a>{}'.format(action(scheme, loc=loc),
|
||||
prepare_string_for_xml(path, True), pathstr, extra)
|
||||
else:
|
||||
link = prepare_string_for_xml(path, True)
|
||||
ans.append((field, row % (name, link)))
|
||||
elif field == 'formats':
|
||||
# Don't need show_links here because formats are removed from mi on
|
||||
# cross library displays.
|
||||
if isdevice:
|
||||
continue
|
||||
path = mi.path or ''
|
||||
@ -209,12 +235,15 @@ def mi_to_html(
|
||||
ans.append((field, row % (name, value_list(', ', fmts))))
|
||||
elif field == 'identifiers':
|
||||
urls = urls_from_identifiers(mi.identifiers, sort_results=True)
|
||||
if show_links:
|
||||
links = [
|
||||
'<a href="{}" title="{}:{}">{}</a>'.format(
|
||||
action('identifier', url=url, name=namel, id_type=id_typ, value=id_val, field='identifiers', book_id=book_id),
|
||||
a(id_typ), a(id_val), p(namel))
|
||||
for namel, id_typ, id_val, url in urls]
|
||||
links = value_list(', ', links)
|
||||
else:
|
||||
links = ', '.join(mi.identifiers)
|
||||
if links:
|
||||
ans.append((field, row % (_('Ids')+':', links)))
|
||||
elif field == 'authors':
|
||||
@ -222,9 +251,8 @@ 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 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)
|
||||
@ -235,26 +263,34 @@ def mi_to_html(
|
||||
except KeyError:
|
||||
vals['author_sort'] = qquote(aut)
|
||||
link = lt = formatter.safe_format(default_author_link, vals, '', vals)
|
||||
else:
|
||||
aut = p(aut)
|
||||
if link:
|
||||
authors.append('<a title="%s" href="%s">%s</a>'%(a(lt), action('author', url=link, name=aut, title=lt), aut))
|
||||
val = '<a title="%s" href="%s">%s</a>'%(a(lt), action('author', url=link, name=aut, title=lt), aut)
|
||||
else:
|
||||
authors.append(aut)
|
||||
val = aut
|
||||
val += add_other_link('authors', aut)
|
||||
authors.append(val)
|
||||
ans.append((field, row % (name, value_list(' & ', authors))))
|
||||
elif field == 'languages':
|
||||
if not mi.languages:
|
||||
continue
|
||||
names = filter(None, map(calibre_langcode_to_name, mi.languages))
|
||||
if show_links:
|
||||
names = ['<a href="{}" title="{}">{}</a>'.format(search_action_with_data('languages', n, book_id), _(
|
||||
'Search calibre for books with the language: {}').format(n), n) for n in names]
|
||||
ans.append((field, row % (name, value_list(', ', names))))
|
||||
elif field == 'publisher':
|
||||
if not mi.publisher:
|
||||
continue
|
||||
if show_links:
|
||||
val = '<a href="{}" title="{}">{}</a>'.format(
|
||||
search_action_with_data('publisher', mi.publisher, book_id),
|
||||
_('Click to see books with {0}: {1}').format(metadata['name'], a(mi.publisher)),
|
||||
p(mi.publisher))
|
||||
val += add_other_link('publisher', mi.publisher)
|
||||
else:
|
||||
val = p(mi.publisher)
|
||||
ans.append((field, row % (name, val)))
|
||||
elif field == 'title':
|
||||
# otherwise title gets metadata['datatype'] == 'text'
|
||||
@ -268,6 +304,7 @@ def mi_to_html(
|
||||
if val is None:
|
||||
continue
|
||||
val = p(val)
|
||||
if show_links:
|
||||
if metadata['datatype'] == 'series':
|
||||
sidx = mi.get(field+'_index')
|
||||
if sidx is None:
|
||||
@ -283,6 +320,7 @@ def mi_to_html(
|
||||
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):
|
||||
@ -306,10 +344,13 @@ def mi_to_html(
|
||||
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(
|
||||
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))
|
||||
for x in all_vals]
|
||||
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
|
||||
@ -317,9 +358,10 @@ def mi_to_html(
|
||||
st = metadata['search_terms'][0]
|
||||
except Exception:
|
||||
st = field
|
||||
val = '<a href="{}" title="{}">{}</a>'.format(
|
||||
v = '<a href="{}" title="{}">{}</a>'.format(
|
||||
search_action_with_data(st, unescaped_val, book_id, field), a(
|
||||
_('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(
|
||||
|
@ -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:
|
||||
|
@ -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():
|
||||
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)
|
||||
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
|
||||
|
@ -42,12 +42,15 @@ InternetSearch = namedtuple('InternetSearch', 'author where')
|
||||
|
||||
|
||||
def set_html(mi, html, text_browser):
|
||||
if hasattr(mi, '_bd_dbwref') and mi._bd_dbwref is not None:
|
||||
db = mi._bd_dbwref
|
||||
else:
|
||||
from calibre.gui2.ui import get_gui
|
||||
gui = 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'): # {{{
|
||||
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,7 +255,10 @@ 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'):
|
||||
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:
|
||||
@ -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(),
|
||||
|
@ -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,16 +116,18 @@ 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):
|
||||
if self.allow_context_menu:
|
||||
details_context_menu_event(self, ev, self.book_info, edit_metadata=self.edit_metadata)
|
||||
|
||||
|
||||
@ -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,23 +180,13 @@ class BookInfo(QDialog):
|
||||
hl.setContentsMargins(0, 0, 0, 0)
|
||||
l2.addLayout(hl, l2.rowCount(), 0, 1, -1)
|
||||
hl.addWidget(self.fit_cover), hl.addStretch()
|
||||
self.clabel = QLabel('<div style="text-align: right"><a href="calibre:conf" title="{}" style="text-decoration: none">{}</a>'.format(
|
||||
_('Configure this view'), _('Configure')))
|
||||
self.clabel.linkActivated.connect(self.configure)
|
||||
hl.addWidget(self.clabel)
|
||||
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.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)
|
||||
@ -198,11 +196,35 @@ class BookInfo(QDialog):
|
||||
self.previous_button.setToolTip(_('Previous [%s]')%
|
||||
str(self.ps.key().toString(QKeySequence.SequenceFormat.NativeText)))
|
||||
|
||||
self.restore_geometry(gprefs, 'book_info_dialog_geometry')
|
||||
try:
|
||||
self.splitter.restoreState(gprefs.get('book_info_dialog_splitter_state'))
|
||||
except Exception:
|
||||
pass
|
||||
self.path_to_book = None
|
||||
self.current_row = None
|
||||
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)
|
||||
|
||||
ema = get_gui().iactions['Edit Metadata'].menuless_qaction
|
||||
a = self.ema = QAction('edit metadata', self)
|
||||
a.setShortcut(ema.shortcut())
|
||||
@ -213,6 +235,21 @@ class BookInfo(QDialog):
|
||||
a.setShortcut(vb.shortcut())
|
||||
a.triggered.connect(self.view_book)
|
||||
self.addAction(a)
|
||||
self.clabel = QLabel('<div style="text-align: right"><a href="calibre:conf" title="{}" style="text-decoration: none">{}</a>'.format(
|
||||
_('Configure this view'), _('Configure')))
|
||||
self.clabel.linkActivated.connect(self.configure)
|
||||
hl.addWidget(self.clabel)
|
||||
self.fit_cover.stateChanged.connect(self.toggle_cover_fit)
|
||||
self.restore_geometry(gprefs, self.geometry_string('book_info_dialog_geometry'))
|
||||
try:
|
||||
self.splitter.restoreState(gprefs.get(self.geometry_string('book_info_dialog_splitter_state')))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def geometry_string(self, txt):
|
||||
if self.dialog_number is None or self.dialog_number == 0:
|
||||
return txt
|
||||
return txt + '_' + str(self.dialog_number)
|
||||
|
||||
def sizeHint(self):
|
||||
try:
|
||||
@ -248,9 +285,10 @@ 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)
|
||||
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)
|
||||
@ -342,11 +380,15 @@ class BookInfo(QDialog):
|
||||
# Indicates books was deleted from library, or row numbers have
|
||||
# changed
|
||||
return
|
||||
|
||||
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:
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user