diff --git a/src/calibre/srv/code.py b/src/calibre/srv/code.py index 16fc659999..f7d89b5c9d 100644 --- a/src/calibre/srv/code.py +++ b/src/calibre/srv/code.py @@ -216,6 +216,19 @@ def get_books(ctx, rd): mdata[book_id] = data return ans +@endpoint('/interface-data/book-metadata/{book_id}', postprocess=json, types={'book_id': int}) +def book_metadata(ctx, rd, book_id): + ''' + Get metadata for the specified book + + Optional: ?library_id= + ''' + library_id, db = get_basic_query_data(ctx, rd.query)[:2] + data = book_as_json(db, book_id) + if data is None: + raise HTTPNotFound('No book with id: %d in library' % book_id) + return data + @endpoint('/interface-data/tag-browser') def tag_browser(ctx, rd): ''' diff --git a/src/calibre/srv/routes.py b/src/calibre/srv/routes.py index 4c2624f1b9..3229b6303c 100644 --- a/src/calibre/srv/routes.py +++ b/src/calibre/srv/routes.py @@ -135,7 +135,7 @@ class Route(object): if argspec.args[2:len(self.names)+2] != self.names: raise route_error('Function\'s argument names do not match the variable names in the route') if not frozenset(self.type_checkers).issubset(frozenset(self.names)): - raise route_error('There exist type checkers that do not correspond to route variables') + raise route_error('There exist type checkers that do not correspond to route variables: %r' % (set(self.type_checkers) - set(self.names))) self.min_size = found_optional_part if found_optional_part is not False else len(matchers) self.max_size = sys.maxsize if self.soak_up_extra else len(matchers) diff --git a/src/pyj/book_list/book_details.pyj b/src/pyj/book_list/book_details.pyj new file mode 100644 index 0000000000..d91a26fff9 --- /dev/null +++ b/src/pyj/book_list/book_details.pyj @@ -0,0 +1,111 @@ +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + +from ajax import ajax +from book_list.globals import get_current_query +from dom import clear +from elementmaker import E +from gettext import gettext as _ +from book_list.globals import get_boss +from modals import error_dialog +from widgets import create_spinner + +bd_counter = 0 + +class BookDetailsPanel: + + def __init__(self, interface_data, book_list_container): + nonlocal bd_counter + bd_counter += 1 + self.container_id = 'book-details-panel-' + bd_counter + style = '' + div = E.div( + id=self.container_id, style='display:none', + E.style(style, type='text/css') + ) + book_list_container.appendChild(div) + self.interface_data = interface_data + + @property + def container(self): + return document.getElementById(self.container_id) + + @property + def is_visible(self): + self.container.style.display == 'block' + + @is_visible.setter + def is_visible(self, val): + self.container.style.display = 'block' if val else 'none' + + def init(self, data): + c = self.container + clear(c) + book_id = get_current_query()['book-id'] + if book_id is undefined or book_id is None: + self.no_book() + elif book_id in self.interface_data.metadata: + self.render_book(book_id) + else: + self.fetch_metadata(book_id) + + def no_book(self, book_id): + self.container.appendChild(E.div( + style='margin: 1ex 1em', + _('No book found') + )) + + def fetch_metadata(self, book_id): + if self.is_fetching: + self.is_fetching.abort() + def fetched(end_type, xhr, ev): + self.metadata_fetched(book_id, end_type, xhr, ev) + self.is_fetching = ajax('interface-data/book-metadata/' + book_id, fetched, + query={'library_id':self.interface_data.library_id}) + self.container.appendChild(E.div( + style='margin: 1ex 1em', + create_spinner(), '\xa0' + _('Fetching metadata for the book, please wait') + '…', + )) + + def metadata_fetched(self, book_id, end_type, xhr, event): + if self.is_fetching is None or self.is_fetching is not xhr: + return # Fetching was aborted + self.is_fetching = None + c = self.container + if end_type == 'load': + try: + data = JSON.parse(xhr.responseText) + except Exception as err: + error_dialog(_('Could not fetch metadata for book'), _('Server returned an invalid response'), err.stack or err.toString()) + return + clear(c) + self.interface_data.metadata[book_id] = data + self.render_book(book_id) + elif end_type != 'abort': + clear(c) + c.appendChild(E.div( + style='margin: 1ex 1em', + _('Could not fetch metadata for book'), + E.div(style='margin: 1ex 1em') + )) + c.lastChild.lastChild.innerHTML = xhr.error_html + + def render_book(self, book_id): + metadata = self.interface_data.metadata[book_id] + get_boss().ui.set_title(metadata.title) + cover_url = str.format('get/cover/{}/{}', book_id, self.interface_data['library_id']) + alt = str.format(_('{} by {}'), metadata['title'], metadata['authors'].join(' & ')) + img = E.img( + src=cover_url, alt=alt, title=alt, data_title=metadata['title'], data_authors=metadata['authors'].join(' & '), + style='margin-left: 1em; max-width: 45vw; max-height: 95vh; display: block; width:auto; height:auto' + ) + img.onerror = self.on_img_err.bind(self) + c = self.container + c.appendChild(E.div( + E.div(), + img + )) + + def on_img_err(self, err): + img = err.target + img.style.display = 'none' diff --git a/src/pyj/book_list/boss.pyj b/src/pyj/book_list/boss.pyj index 8ab85c5099..fc06a7ec3d 100644 --- a/src/pyj/book_list/boss.pyj +++ b/src/pyj/book_list/boss.pyj @@ -82,8 +82,11 @@ class Boss: self.interface_data.search_result = data.search_result self.ui.refresh_books_view() - def push_state(self, replace=False): + def push_state(self, replace=False, extra_query_data=None): query = {} + if extra_query_data: + for k in extra_query_data: + query[k] = extra_query_data[k] if self.current_mode == 'book_list': if self.ui.current_panel != self.ui.ROOT_PANEL: query.panel = self.ui.current_panel @@ -95,8 +98,8 @@ class Boss: sq = idata.search_result.query if sq: query.search = sq - query = encode_query(query) or '?' set_current_query(query) + query = encode_query(query) or '?' if replace: window.history.replaceState(None, '', query) else: diff --git a/src/pyj/book_list/top_bar.pyj b/src/pyj/book_list/top_bar.pyj index 5760a317f3..f139e2a85c 100644 --- a/src/pyj/book_list/top_bar.pyj +++ b/src/pyj/book_list/top_bar.pyj @@ -114,3 +114,7 @@ class TopBar: clear(right) for button in buttons: self.add_button(**button) + + def set_title(self, text): + for bar in self.bar, self.dummy_bar: + bar.firstChild.firstChild.nextSibling.textContent = text diff --git a/src/pyj/book_list/ui.pyj b/src/pyj/book_list/ui.pyj index c05f0868e8..603e79c45b 100644 --- a/src/pyj/book_list/ui.pyj +++ b/src/pyj/book_list/ui.pyj @@ -7,6 +7,7 @@ from book_list.top_bar import TopBar from book_list.views import BooksView from book_list.item_list import ItemsView, create_item from book_list.prefs import PrefsPanel +from book_list.book_details import BookDetailsPanel from gettext import gettext as _ from utils import debounce @@ -76,7 +77,8 @@ class UI: self.items_view = ItemsView(interface_data, book_list_container) self.prefs_panel = PrefsPanel(interface_data, book_list_container) self.search_panel = SearchPanel(interface_data, book_list_container) - self.panels = [self.books_view, self.items_view, self.search_panel, self.prefs_panel] + self.book_details_panel = BookDetailsPanel(interface_data, book_list_container) + self.panels = [self.books_view, self.items_view, self.search_panel, self.prefs_panel, self.book_details_panel] self.panel_map = {self.ROOT_PANEL: UIState(create_book_view_top_bar_state(self.books_view), main_panel=self.books_view)} self.current_panel = self.ROOT_PANEL window.addEventListener('resize', debounce(self.on_resize.bind(self), 250)) @@ -98,11 +100,16 @@ class UI: bss.add_button(icon_name='cogs', tooltip=_('Configure Tag Browser'), action=show_panel_action('booklist-config-tb')) self.panel_map['booklist-search'] = UIState(bss, main_panel=self.search_panel) + self.panel_map['book-details'] = UIState(ClosePanelBar(_('Book Details')), main_panel=self.book_details_panel) + def create_prefences_panel(self, title, close_callback=None, panel_data=None): ans = UIState(ClosePanelBar(title), close_callback=close_callback, main_panel=self.prefs_panel, panel_data=panel_data) ans.add_button(icon_name='refresh', tooltip=_('Restore default settings'), action=self.prefs_panel.reset_to_defaults.bind(self.prefs_panel)) return ans + def set_title(self, text): + self.top_bar.set_title(text) + def on_resize(self): pass @@ -126,16 +133,20 @@ class UI: self.show_panel(self.ROOT_PANEL) def replace_panel(self, panel_name, force=False): - get_boss().push_state(replace=True) - if force or panel_name != self.current_panel: + action_needed = force or panel_name != self.current_panel + if action_needed: self.current_panel = panel_name or self.ROOT_PANEL + get_boss().push_state(replace=True) + if action_needed: self.apply_state() - def show_panel(self, panel_name, push_state=True, force=False): - if push_state: - get_boss().push_state() - if force or panel_name != self.current_panel: + def show_panel(self, panel_name, push_state=True, force=False, extra_query_data=None): + action_needed = force or panel_name != self.current_panel + if action_needed: self.current_panel = panel_name or self.ROOT_PANEL + if push_state: + get_boss().push_state(extra_query_data=extra_query_data) + if action_needed: self.apply_state() def refresh_books_view(self): diff --git a/src/pyj/book_list/views.pyj b/src/pyj/book_list/views.pyj index 2d79dd7b6a..95de5997c3 100644 --- a/src/pyj/book_list/views.pyj +++ b/src/pyj/book_list/views.pyj @@ -157,33 +157,37 @@ class BooksView: set_css(div, display='flex', flex_wrap='wrap', justify_content='flex-start', align_items='flex-end', align_content='flex-start', user_select='none') div.setAttribute('class', 'cover_grid') + def on_cover_grid_img_err(self, err): + img = err.target + div = img.parentNode + if not div: + return + clear(div) + div.appendChild(E.div( + style='position:relative; top:-50%; transform: translateY(50%)', + E.h2(img.getAttribute('data-title'), style='text-align:center; font-size:larger; font-weight: bold'), + E.div(_('by'), style='text-align: center'), + E.h2(img.getAttribute('data-authors'), style='text-align:center; font-size:larger; font-weight: bold') + )) + set_css(div, border='dashed 1px currentColor', border_radius='10px') + def cover_grid_item(self, book_id): cover_url = str.format('get/thumb/{}/{}?sz={}x{}', book_id, self.interface_data['library_id'], THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT) metadata = self.interface_data['metadata'][book_id] alt = str.format(_('{} by {}'), metadata['title'], metadata['authors'].join(' & ')) img = E.img(src=cover_url, alt=alt, title=alt, data_title=metadata['title'], data_authors=metadata['authors'].join(' & '), style='max-width: 100%; max-height: 100%; display: block; width:auto; height:auto') - img.onerror = def(err): - img = err.target - div = img.parentNode - if not div: - return - clear(div) - div.appendChild(E.div( - style='position:relative; top:-50%; transform: translateY(50%)', - E.h2(img.getAttribute('data-title'), style='text-align:center; font-size:larger; font-weight: bold'), - E.div(_('by'), style='text-align: center'), - E.h2(img.getAttribute('data-authors'), style='text-align:center; font-size:larger; font-weight: bold') - )) - set_css(div, border='dashed 1px currentColor', border_radius='10px') + img.onerror = self.on_cover_grid_img_err.bind(self) - return E.div( + ans = E.div( style=str.format(('margin: 10px; display: flex; align-content: flex-end; align-items: flex-end; justify-content: space-around;' 'width: 21vw; height: 28vw; max-width: {}px; max-height: {}px; min-width: {}px; min-height: {}px; cursor:pointer'), THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT, THUMBNAIL_MAX_WIDTH // 2, THUMBNAIL_MAX_HEIGHT // 2), data_book_id=str(book_id), img ) + ans.addEventListener('click', def(ev): self.show_book_details(book_id);) + return ans # }}} @@ -261,3 +265,6 @@ class BooksView: def refresh(self): self.clear() self.render_ids() + + def show_book_details(self, book_id): + get_boss().ui.show_panel('book-details', extra_query_data={'book-id':'' + book_id})