From 24a9ebd3af317d427a5c8b688039e858ef09f5b5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 6 Mar 2019 20:36:19 +0530 Subject: [PATCH] Content server: Allow specifying custom URLs for the "Search the internet" feature via Preferences->Sharing over the net->Search the internet. Fixes #1810923 [[Enhancement]: Search the internet on Content Server](https://bugs.launchpad.net/calibre/+bug/1810923) --- src/calibre/gui2/preferences/server.py | 218 +++++++++++++++++++++++-- src/calibre/srv/code.py | 1 + src/calibre/srv/embedded.py | 14 +- src/calibre/srv/standalone.py | 13 +- src/pyj/book_list/book_details.pyj | 35 ++-- src/pyj/session.pyj | 1 + 6 files changed, 257 insertions(+), 25 deletions(-) diff --git a/src/calibre/gui2/preferences/server.py b/src/calibre/gui2/preferences/server.py index 7dcfa07960..338a2b0d4d 100644 --- a/src/calibre/gui2/preferences/server.py +++ b/src/calibre/gui2/preferences/server.py @@ -24,7 +24,7 @@ from calibre.gui2 import ( ) from calibre.gui2.preferences import AbortCommit, ConfigWidgetBase, test_widget from calibre.srv.code import custom_list_template as default_custom_list_template -from calibre.srv.embedded import custom_list_template +from calibre.srv.embedded import custom_list_template, search_the_net_urls from calibre.srv.library_broker import load_gui_libraries from calibre.srv.opts import change_settings, options, server_config from calibre.srv.users import ( @@ -364,7 +364,9 @@ class MainTab(QWidget): # {{{ self.ip_info = QLabel(self) self.update_ip_info() from calibre.gui2.ui import get_gui - get_gui().iactions['Connect Share'].share_conn_menu.server_state_changed_signal.connect(self.update_ip_info) + gui = get_gui() + if gui is not None: + gui.iactions['Connect Share'].share_conn_menu.server_state_changed_signal.connect(self.update_ip_info) l.addSpacing(10) l.addWidget(self.ip_info) if set_run_at_startup is not None: @@ -394,9 +396,11 @@ class MainTab(QWidget): # {{{ def update_ip_info(self): from calibre.gui2.ui import get_gui - t = get_gui().iactions['Connect Share'].share_conn_menu.ip_text - t = t.strip().strip('[]') - self.ip_info.setText(_('Content server listening at: %s') % t) + gui = get_gui() + if gui is not None: + t = get_gui().iactions['Connect Share'].share_conn_menu.ip_text + t = t.strip().strip('[]') + self.ip_info.setText(_('Content server listening at: %s') % t) def genesis(self): opts = server_config() @@ -428,12 +432,13 @@ class MainTab(QWidget): # {{{ def update_button_state(self): from calibre.gui2.ui import get_gui gui = get_gui() - is_running = gui.content_server is not None and gui.content_server.is_running - self.ip_info.setVisible(is_running) - self.update_ip_info() - self.start_server_button.setEnabled(not is_running) - self.stop_server_button.setEnabled(is_running) - self.test_server_button.setEnabled(is_running) + if gui is not None: + is_running = gui.content_server is not None and gui.content_server.is_running + self.ip_info.setVisible(is_running) + self.update_ip_info() + self.start_server_button.setEnabled(not is_running) + self.stop_server_button.setEnabled(is_running) + self.test_server_button.setEnabled(is_running) @property def settings(self): @@ -1019,6 +1024,191 @@ class CustomList(QWidget): # {{{ # }}} +# Search the internet {{{ + +class URLItem(QWidget): + + changed_signal = pyqtSignal() + + def __init__(self, as_dict, parent=None): + QWidget.__init__(self, parent) + self.changed_signal.connect(parent.changed_signal) + self.l = l = QFormLayout(self) + self.type_widget = t = QComboBox(self) + l.setFieldGrowthPolicy(l.ExpandingFieldsGrow) + t.addItems([_('Book'), _('Author')]) + l.addRow(_('URL type:'), t) + self.name_widget = n = QLineEdit(self) + n.setClearButtonEnabled(True) + l.addRow(_('Name:'), n) + self.url_widget = w = QLineEdit(self) + w.setClearButtonEnabled(True) + l.addRow(_('URL:'), w) + if as_dict: + self.name = as_dict['name'] + self.url = as_dict['url'] + self.url_type = as_dict['type'] + self.type_widget.currentIndexChanged.connect(self.changed_signal) + self.name_widget.textChanged.connect(self.changed_signal) + self.url_widget.textChanged.connect(self.changed_signal) + + @property + def is_empty(self): + return not self.name or not self.url + + @property + def url_type(self): + return 'book' if self.type_widget.currentIndex() == 0 else 'author' + + @url_type.setter + def url_type(self, val): + self.type_widget.setCurrentIndex(1 if val == 'author' else 0) + + @property + def name(self): + return self.name_widget.text().strip() + + @name.setter + def name(self, val): + self.name_widget.setText((val or '').strip()) + + @property + def url(self): + return self.url_widget.text().strip() + + @url.setter + def url(self, val): + self.url_widget.setText((val or '').strip()) + + @property + def as_dict(self): + return {'name': self.name, 'url': self.url, 'type': self.url_type} + + def validate(self): + if self.is_empty: + return True + if '{author}' not in self.url: + error_dialog(self.parent(), _('Missing author placeholder'), _( + 'The URL {0} does not contain the {1} placeholder').format(self.url, '{author}'), show=True) + return False + if self.url_type == 'book' and '{title}' not in self.url: + error_dialog(self.parent(), _('Missing title placeholder'), _( + 'The URL {0} does not contain the {1} placeholder').format(self.url, '{title}'), show=True) + return False + return True + + +class SearchTheInternet(QWidget): + + changed_signal = pyqtSignal() + + def __init__(self, parent): + QWidget.__init__(self, parent) + self.sa = QScrollArea(self) + self.lw = QWidget(self) + self.l = QVBoxLayout(self.lw) + self.sa.setWidget(self.lw), self.sa.setWidgetResizable(True) + self.gl = gl = QVBoxLayout(self) + self.la = QLabel(_( + 'Add new locations to search for books or authors using the "Search the internet" feature' + ' of the Content server. The URLs should contain {author} which will be' + ' replaced by the author name and, for book URLs, {title} which will' + ' be replaced by the book title.')) + self.la.setWordWrap(True) + gl.addWidget(self.la) + + self.h = QHBoxLayout() + gl.addLayout(self.h) + self.add_url_button = b = QPushButton(QIcon(I('plus.png')), _('&Add URL')) + b.clicked.connect(self.add_url) + self.h.addWidget(b) + self.export_button = b = QPushButton(_('Export URLs')) + b.clicked.connect(self.export_urls) + self.h.addWidget(b) + self.import_button = b = QPushButton(_('Import URLs')) + b.clicked.connect(self.import_urls) + self.h.addWidget(b) + self.clear_button = b = QPushButton(_('Clear')) + b.clicked.connect(self.clear) + self.h.addWidget(b) + + self.h.addStretch(10) + gl.addWidget(self.sa, stretch=10) + self.items = [] + + def genesis(self): + self.current_urls = search_the_net_urls() or [] + + @property + def current_urls(self): + return [item.as_dict for item in self.items if not item.is_empty] + + def append_item(self, item_as_dict): + self.items.append(URLItem(item_as_dict, self)) + self.l.addWidget(self.items[-1]) + + def clear(self): + [(self.l.removeWidget(w), w.setParent(None), w.deleteLater()) for w in self.items] + self.items = [] + self.changed_signal.emit() + + @current_urls.setter + def current_urls(self, val): + self.clear() + for entry in val: + self.append_item(entry) + + def add_url(self): + self.items.append(URLItem(None, self)) + self.l.addWidget(self.items[-1]) + QTimer.singleShot(100, self.scroll_to_bottom) + + def scroll_to_bottom(self): + sb = self.sa.verticalScrollBar() + if sb: + sb.setValue(sb.maximum()) + self.items[-1].name_widget.setFocus(Qt.OtherFocusReason) + + @property + def serialized_urls(self): + return json.dumps(self.current_urls, indent=2) + + def commit(self): + for item in self.items: + if not item.validate(): + return False + cu = self.current_urls + if cu: + with lopen(search_the_net_urls.path, 'wb') as f: + f.write(self.serialized_urls) + else: + try: + os.remove(search_the_net_urls.path) + except EnvironmentError as err: + if err.errno != errno.ENOENT: + raise + return True + + def export_urls(self): + path = choose_save_file( + self, 'search-net-urls', _('Choose URLs file'), + filters=[(_('URL files'), ['json'])], initial_filename='search-urls.json') + if path: + with lopen(path, 'wb') as f: + f.write(self.serialized_urls) + + def import_urls(self): + paths = choose_files(self, 'search-net-urls', _('Choose URLs file'), + filters=[(_('URL files'), ['json'])], all_files=False, select_only_single_file=True) + if paths: + with lopen(paths[0], 'rb') as f: + items = json.loads(f.read()) + [self.append_item(x) for x in items] + self.changed_signal.emit() + +# }}} + + class ConfigWidget(ConfigWidgetBase): def __init__(self, *args, **kw): @@ -1044,6 +1234,9 @@ class ConfigWidget(ConfigWidgetBase): sa = QScrollArea(self) sa.setWidget(clt), sa.setWidgetResizable(True) t.addTab(sa, _('Book &list template')) + self.search_net_tab = SearchTheInternet(self) + t.addTab(self.search_net_tab, _('&Search the internet')) + for tab in self.tabs: if hasattr(tab, 'changed_signal'): tab.changed_signal.connect(self.changed_signal.emit) @@ -1201,6 +1394,8 @@ class ConfigWidget(ConfigWidgetBase): return False if not self.custom_list_tab.commit(): return False + if not self.search_net_tab.commit(): + return False ConfigWidgetBase.commit(self) change_settings(**settings) UserManager().user_data = users @@ -1222,6 +1417,7 @@ class ConfigWidget(ConfigWidgetBase): if self.server: self.server.user_manager.refresh() self.server.ctx.custom_list_template = custom_list_template() + self.server.ctx.search_the_net_urls = search_the_net_urls() if __name__ == '__main__': diff --git a/src/calibre/srv/code.py b/src/calibre/srv/code.py index 7c10db898a..d7cfe47219 100644 --- a/src/calibre/srv/code.py +++ b/src/calibre/srv/code.py @@ -148,6 +148,7 @@ def basic_interface_data(ctx, rd): 'icon_map': icon_map(), 'icon_path': ctx.url_for('/icon', which=''), 'custom_list_template': getattr(ctx, 'custom_list_template', None) or custom_list_template(), + 'search_the_net_urls': getattr(ctx, 'search_the_net_urls', None) or [], 'num_per_page': rd.opts.num_per_page, } ans['library_map'], ans['default_library_id'] = ctx.library_info(rd) diff --git a/src/calibre/srv/embedded.py b/src/calibre/srv/embedded.py index 2c75b9008c..b76e07ce5e 100644 --- a/src/calibre/srv/embedded.py +++ b/src/calibre/srv/embedded.py @@ -24,9 +24,9 @@ def log_paths(): ) -def custom_list_template(): +def read_json(path): try: - with lopen(custom_list_template.path, 'rb') as f: + with lopen(path, 'rb') as f: raw = f.read() except EnvironmentError as err: if err.errno != errno.ENOENT: @@ -35,7 +35,16 @@ def custom_list_template(): return json.loads(raw) +def custom_list_template(): + return read_json(custom_list_template.path) + + +def search_the_net_urls(): + return read_json(search_the_net_urls.path) + + custom_list_template.path = os.path.join(config_dir, 'server-custom-list-template.json') +search_the_net_urls.path = os.path.join(config_dir, 'server-search-the-net.json') class Server(object): @@ -62,6 +71,7 @@ class Server(object): self.log, self.access_log = log, access_log self.handler.set_log(self.log) self.handler.router.ctx.custom_list_template = custom_list_template() + self.handler.router.ctx.search_the_net_urls = search_the_net_urls() @property def ctx(self): diff --git a/src/calibre/srv/standalone.py b/src/calibre/srv/standalone.py index 87b67fca5f..9f585510cc 100644 --- a/src/calibre/srv/standalone.py +++ b/src/calibre/srv/standalone.py @@ -67,8 +67,11 @@ class Server(object): access_log = RotatingLog(opts.access_log, max_size=log_size) self.handler = Handler(libraries, opts) if opts.custom_list_template: - with lopen(opts.custom_list_template, 'rb') as f: + with lopen(os.path.expanduser(opts.custom_list_template), 'rb') as f: self.handler.router.ctx.custom_list_template = json.load(f) + if opts.search_the_net_urls: + with lopen(os.path.expanduser(opts.search_the_net_urls), 'rb') as f: + self.handler.router.ctx.search_the_net_urls = json.load(f) plugins = [] if opts.use_bonjour: plugins.append(BonJour()) @@ -117,6 +120,14 @@ libraries that the main calibre program knows about will be used. ' Sharing over the net-> Book list template in calibre, create the' ' template and export it.' )) + parser.add_option( + '--search-the-net-urls', help=_( + 'Path to a JSON file containing URLs for the "Search the internet" feature.' + ' The easiest way to create such a file is to go to Preferences->' + ' Sharing over the net->Search the internet in calibre, create the' + ' URLs and export them.' + )) + if not iswindows and not isosx: # Does not work on macOS because if we fork() we cannot connect to Core # Serives which is needed by the QApplication() constructor, which in diff --git a/src/pyj/book_list/book_details.pyj b/src/pyj/book_list/book_details.pyj index 72348a8c9f..ec63f7e455 100644 --- a/src/pyj/book_list/book_details.pyj +++ b/src/pyj/book_list/book_details.pyj @@ -665,25 +665,38 @@ def search_internet(container_id): create_top_bar(container, title=_('Search the internet'), action=back, icon='close') mi = book_metadata(render_book.book_id) data = {'title':mi.title, 'author':mi.authors[0] if mi.authors else _('Unknown')} + interface_data = get_interface_data() def link_for(name, template): return E.a(name, class_='simple-link', href=url_for(template, data), target="_blank") + author_links = E.ul() + book_links = E.ul() + + if interface_data.search_the_net_urls: + for entry in interface_data.search_the_net_urls: + links = book_links if entry.type is 'book' else author_links + links.appendChild(E.li(link_for(entry.name, entry.url))) + for name, url in [ + (_('Goodreads'), 'https://www.goodreads.com/book/author/{author}'), + (_('Wikipedia'), 'https://en.wikipedia.org/w/index.php?search={author}'), + (_('Google books'), 'https://www.google.com/search?tbm=bks&q=inauthor:%22{author}%22'), + ]: + author_links.appendChild(E.li(link_for(name, url))) + + for name, url in [ + (_('Goodreads'), 'https://www.goodreads.com/search?q={author}+{title}&search%5Bsource%5D=goodreads&search_type=books&tab=books'), + (_('Google books'), 'https://www.google.com/search?tbm=bks&q=inauthor:%22{author}%22+intitle:%22{title}%22'), + (_('Amazon'), 'https://www.amazon.com/s/ref=nb_sb_noss?url=search-alias%3Dstripbooks&field-keywords={author}+{title}'), + ]: + book_links.appendChild(E.li(link_for(name, url))) + container.appendChild(E.div(class_=SEARCH_INTERNET_CLASS, safe_set_inner_html(E.h2(), _('Search for the author {} at:').format(data.author)), - E.ul( - E.li(link_for(_('Goodreads'), 'https://www.goodreads.com/book/author/{author}')), - E.li(link_for(_('Wikipedia'), 'https://en.wikipedia.org/w/index.php?search={author}')), - E.li(link_for(_('Google books'), 'https://www.google.com/search?tbm=bks&q=inauthor:%22{author}%22')), - ), + author_links, E.hr(), safe_set_inner_html(E.h2(), _('Search for the book {} at:').format(data.title)), - E.ul( - E.li(link_for(_('Goodreads'), 'https://www.goodreads.com/search?q={author}+{title}&search%5Bsource%5D=goodreads&search_type=books&tab=books')), - E.li(link_for(_('Google books'), 'https://www.google.com/search?tbm=bks&q=inauthor:%22{author}%22+intitle:%22{title}%22')), - E.li(link_for(_('Amazon'), 'https://www.amazon.com/s/ref=nb_sb_noss?url=search-alias%3Dstripbooks&field-keywords={author}+{title}')), - ), - + book_links, )) diff --git a/src/pyj/session.pyj b/src/pyj/session.pyj index 5870d87cae..cc8c8cbd68 100644 --- a/src/pyj/session.pyj +++ b/src/pyj/session.pyj @@ -169,6 +169,7 @@ default_interface_data = { 'use_roman_numerals_for_series_number': True, 'default_library_id': None, 'library_map': None, + 'search_the_net_urls': [], 'icon_map': {}, 'icon_path': '', 'custom_list_template': None,