1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2019, Kovid Goyal <kovid at kovidgoyal.net> 4 5 6import os 7import sys 8import textwrap 9from qt.core import ( 10 QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QAbstractItemView, 11 QHBoxLayout, QIcon, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, 12 QSize, Qt, QTimer, QUrl, QVBoxLayout, QWidget, pyqtSignal 13) 14from qt.webengine import ( 15 QWebEnginePage, QWebEngineProfile, QWebEngineScript, QWebEngineView 16) 17 18from calibre import prints, random_user_agent 19from calibre.constants import cache_dir 20from calibre.gui2 import error_dialog 21from calibre.gui2.viewer.web_view import apply_font_settings, vprefs 22from calibre.gui2.webengine import create_script, insert_scripts, secure_webengine 23from calibre.gui2.widgets2 import Dialog 24 25vprefs.defaults['lookup_locations'] = [ 26 { 27 'name': 'Google dictionary', 28 'url': 'https://www.google.com/search?q=define:{word}', 29 'langs': [], 30 }, 31 32 { 33 'name': 'Google search', 34 'url': 'https://www.google.com/search?q={word}', 35 'langs': [], 36 }, 37 38 { 39 'name': 'Wordnik', 40 'url': 'https://www.wordnik.com/words/{word}', 41 'langs': ['eng'], 42 }, 43] 44vprefs.defaults['lookup_location'] = 'Google dictionary' 45 46 47class SourceEditor(Dialog): 48 49 def __init__(self, parent, source_to_edit=None): 50 self.all_names = {x['name'] for x in parent.all_entries} 51 self.initial_name = self.initial_url = None 52 self.langs = [] 53 if source_to_edit is not None: 54 self.langs = source_to_edit['langs'] 55 self.initial_name = source_to_edit['name'] 56 self.initial_url = source_to_edit['url'] 57 Dialog.__init__(self, _('Edit lookup source'), 'viewer-edit-lookup-location', parent=parent) 58 self.resize(self.sizeHint()) 59 60 def setup_ui(self): 61 self.l = l = QFormLayout(self) 62 self.name_edit = n = QLineEdit(self) 63 n.setPlaceholderText(_('The name of the source')) 64 n.setMinimumWidth(450) 65 l.addRow(_('&Name:'), n) 66 if self.initial_name: 67 n.setText(self.initial_name) 68 n.setReadOnly(True) 69 self.url_edit = u = QLineEdit(self) 70 u.setPlaceholderText(_('The URL template of the source')) 71 u.setMinimumWidth(n.minimumWidth()) 72 l.addRow(_('&URL:'), u) 73 if self.initial_url: 74 u.setText(self.initial_url) 75 la = QLabel(_( 76 'The URL template must starts with https:// and have {word} in it which will be replaced by the actual query')) 77 la.setWordWrap(True) 78 l.addRow(la) 79 l.addRow(self.bb) 80 if self.initial_name: 81 u.setFocus(Qt.FocusReason.OtherFocusReason) 82 83 @property 84 def source_name(self): 85 return self.name_edit.text().strip() 86 87 @property 88 def url(self): 89 return self.url_edit.text().strip() 90 91 def accept(self): 92 q = self.source_name 93 if not q: 94 return error_dialog(self, _('No name'), _( 95 'You must specify a name'), show=True) 96 if not self.initial_name and q in self.all_names: 97 return error_dialog(self, _('Name already exists'), _( 98 'A lookup source with the name {} already exists').format(q), show=True) 99 if not self.url: 100 return error_dialog(self, _('No name'), _( 101 'You must specify a URL'), show=True) 102 if not self.url.startswith('http://') and not self.url.startswith('https://'): 103 return error_dialog(self, _('Invalid URL'), _( 104 'The URL must start with https://'), show=True) 105 if '{word}' not in self.url: 106 return error_dialog(self, _('Invalid URL'), _( 107 'The URL must contain the placeholder {word}'), show=True) 108 return Dialog.accept(self) 109 110 @property 111 def entry(self): 112 return {'name': self.source_name, 'url': self.url, 'langs': self.langs} 113 114 115class SourcesEditor(Dialog): 116 117 def __init__(self, parent): 118 Dialog.__init__(self, _('Edit lookup sources'), 'viewer-edit-lookup-locations', parent=parent) 119 120 def setup_ui(self): 121 self.l = l = QVBoxLayout(self) 122 self.la = la = QLabel(_('Double-click to edit an entry')) 123 la.setWordWrap(True) 124 l.addWidget(la) 125 self.entries = e = QListWidget(self) 126 e.setDragEnabled(True) 127 e.itemDoubleClicked.connect(self.edit_source) 128 e.viewport().setAcceptDrops(True) 129 e.setDropIndicatorShown(True) 130 e.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) 131 e.setDefaultDropAction(Qt.DropAction.MoveAction) 132 l.addWidget(e) 133 l.addWidget(self.bb) 134 self.build_entries(vprefs['lookup_locations']) 135 136 self.add_button = b = self.bb.addButton(_('Add'), QDialogButtonBox.ButtonRole.ActionRole) 137 b.setIcon(QIcon(I('plus.png'))) 138 b.clicked.connect(self.add_source) 139 self.remove_button = b = self.bb.addButton(_('Remove'), QDialogButtonBox.ButtonRole.ActionRole) 140 b.setIcon(QIcon(I('minus.png'))) 141 b.clicked.connect(self.remove_source) 142 self.restore_defaults_button = b = self.bb.addButton(_('Restore defaults'), QDialogButtonBox.ButtonRole.ActionRole) 143 b.clicked.connect(self.restore_defaults) 144 145 def add_entry(self, entry, prepend=False): 146 i = QListWidgetItem(entry['name']) 147 i.setData(Qt.ItemDataRole.UserRole, entry.copy()) 148 self.entries.insertItem(0, i) if prepend else self.entries.addItem(i) 149 150 def build_entries(self, entries): 151 self.entries.clear() 152 for entry in entries: 153 self.add_entry(entry) 154 155 def restore_defaults(self): 156 self.build_entries(vprefs.defaults['lookup_locations']) 157 158 def add_source(self): 159 d = SourceEditor(self) 160 if d.exec() == QDialog.DialogCode.Accepted: 161 self.add_entry(d.entry, prepend=True) 162 163 def remove_source(self): 164 idx = self.entries.currentRow() 165 if idx > -1: 166 self.entries.takeItem(idx) 167 168 def edit_source(self, source_item): 169 d = SourceEditor(self, source_item.data(Qt.ItemDataRole.UserRole)) 170 if d.exec() == QDialog.DialogCode.Accepted: 171 source_item.setData(Qt.ItemDataRole.UserRole, d.entry) 172 source_item.setData(Qt.ItemDataRole.DisplayRole, d.name) 173 174 @property 175 def all_entries(self): 176 return [self.entries.item(r).data(Qt.ItemDataRole.UserRole) for r in range(self.entries.count())] 177 178 def accept(self): 179 entries = self.all_entries 180 if not entries: 181 return error_dialog(self, _('No sources'), _( 182 'You must specify at least one lookup source'), show=True) 183 if entries == vprefs.defaults['lookup_locations']: 184 del vprefs['lookup_locations'] 185 else: 186 vprefs['lookup_locations'] = entries 187 return Dialog.accept(self) 188 189 190def create_profile(): 191 ans = getattr(create_profile, 'ans', None) 192 if ans is None: 193 ans = QWebEngineProfile('viewer-lookup', QApplication.instance()) 194 ans.setHttpUserAgent(random_user_agent(allow_ie=False)) 195 ans.setCachePath(os.path.join(cache_dir(), 'ev2vl')) 196 js = P('lookup.js', data=True, allow_user_override=False) 197 insert_scripts(ans, create_script('lookup.js', js, injection_point=QWebEngineScript.InjectionPoint.DocumentCreation)) 198 s = ans.settings() 199 s.setDefaultTextEncoding('utf-8') 200 create_profile.ans = ans 201 return ans 202 203 204class Page(QWebEnginePage): 205 206 def javaScriptConsoleMessage(self, level, msg, linenumber, source_id): 207 prefix = { 208 QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel: 'INFO', 209 QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: 'WARNING' 210 }.get(level, 'ERROR') 211 if source_id == 'userscript:lookup.js': 212 prints('%s: %s:%s: %s' % (prefix, source_id, linenumber, msg), file=sys.stderr) 213 sys.stderr.flush() 214 215 def zoom_in(self): 216 self.setZoomFactor(min(self.zoomFactor() + 0.2, 5)) 217 218 def zoom_out(self): 219 self.setZoomFactor(max(0.25, self.zoomFactor() - 0.2)) 220 221 def default_zoom(self): 222 self.setZoomFactor(1) 223 224 225class View(QWebEngineView): 226 227 inspect_element = pyqtSignal() 228 229 def contextMenuEvent(self, ev): 230 menu = self.page().createStandardContextMenu() 231 menu.addSeparator() 232 menu.addAction(_('Zoom in'), self.page().zoom_in) 233 menu.addAction(_('Zoom out'), self.page().zoom_out) 234 menu.addAction(_('Default zoom'), self.page().default_zoom) 235 menu.addAction(_('Inspect'), self.do_inspect_element) 236 menu.exec(ev.globalPos()) 237 238 def do_inspect_element(self): 239 self.inspect_element.emit() 240 241 242class Lookup(QWidget): 243 244 def __init__(self, parent): 245 QWidget.__init__(self, parent) 246 self.is_visible = False 247 self.selected_text = '' 248 self.current_query = '' 249 self.current_source = '' 250 self.l = l = QVBoxLayout(self) 251 self.h = h = QHBoxLayout() 252 l.addLayout(h) 253 self.debounce_timer = t = QTimer(self) 254 t.setInterval(150), t.timeout.connect(self.update_query) 255 self.source_box = sb = QComboBox(self) 256 self.label = la = QLabel(_('Lookup &in:')) 257 h.addWidget(la), h.addWidget(sb), la.setBuddy(sb) 258 self.view = View(self) 259 self.view.inspect_element.connect(self.show_devtools) 260 self._page = Page(create_profile(), self.view) 261 apply_font_settings(self._page) 262 secure_webengine(self._page, for_viewer=True) 263 self.view.setPage(self._page) 264 l.addWidget(self.view) 265 self.populate_sources() 266 self.source_box.currentIndexChanged.connect(self.source_changed) 267 self.view.setHtml('<p>' + _('Double click on a word in the book\'s text' 268 ' to look it up.')) 269 self.add_button = b = QPushButton(QIcon(I('plus.png')), _('Add sources')) 270 b.setToolTip(_('Add more sources at which to lookup words')) 271 b.clicked.connect(self.add_sources) 272 self.refresh_button = rb = QPushButton(QIcon(I('view-refresh.png')), _('Refresh')) 273 rb.setToolTip(_('Refresh the result to match the currently selected text')) 274 rb.clicked.connect(self.update_query) 275 h = QHBoxLayout() 276 l.addLayout(h) 277 h.addWidget(b), h.addWidget(rb) 278 self.auto_update_query = a = QCheckBox(_('Update on selection change'), self) 279 a.setToolTip(textwrap.fill( 280 _('Automatically update the displayed result when selected text in the book changes. With this disabled' 281 ' the lookup is changed only when clicking the Refresh button.'))) 282 a.setChecked(vprefs['auto_update_lookup']) 283 a.stateChanged.connect(self.auto_update_state_changed) 284 l.addWidget(a) 285 self.update_refresh_button_status() 286 287 def auto_update_state_changed(self, state): 288 vprefs['auto_update_lookup'] = self.auto_update_query.isChecked() 289 self.update_refresh_button_status() 290 291 def show_devtools(self): 292 if not hasattr(self, '_devtools_page'): 293 self._devtools_page = QWebEnginePage() 294 self._devtools_view = QWebEngineView(self) 295 self._devtools_view.setPage(self._devtools_page) 296 self._page.setDevToolsPage(self._devtools_page) 297 self._devtools_dialog = d = QDialog(self) 298 d.setWindowTitle('Inspect Lookup page') 299 v = QVBoxLayout(d) 300 v.addWidget(self._devtools_view) 301 d.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) 302 d.bb.rejected.connect(d.reject) 303 v.addWidget(d.bb) 304 d.resize(QSize(800, 600)) 305 d.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) 306 self._devtools_dialog.show() 307 self._page.triggerAction(QWebEnginePage.WebAction.InspectElement) 308 309 def add_sources(self): 310 if SourcesEditor(self).exec() == QDialog.DialogCode.Accepted: 311 self.populate_sources() 312 self.source_box.setCurrentIndex(0) 313 self.update_query() 314 315 def source_changed(self): 316 s = self.source 317 if s is not None: 318 vprefs['lookup_location'] = s['name'] 319 self.update_query() 320 321 def populate_sources(self): 322 sb = self.source_box 323 sb.clear() 324 sb.blockSignals(True) 325 for item in vprefs['lookup_locations']: 326 sb.addItem(item['name'], item) 327 idx = sb.findText(vprefs['lookup_location'], Qt.MatchFlag.MatchExactly) 328 if idx > -1: 329 sb.setCurrentIndex(idx) 330 sb.blockSignals(False) 331 332 def visibility_changed(self, is_visible): 333 self.is_visible = is_visible 334 self.update_query() 335 336 @property 337 def source(self): 338 idx = self.source_box.currentIndex() 339 if idx > -1: 340 return self.source_box.itemData(idx) 341 342 @property 343 def url_template(self): 344 idx = self.source_box.currentIndex() 345 if idx > -1: 346 return self.source_box.itemData(idx)['url'] 347 348 @property 349 def query_is_up_to_date(self): 350 query = self.selected_text or self.current_query 351 return self.current_query == query and self.current_source == self.url_template 352 353 def update_refresh_button_status(self): 354 b = self.refresh_button 355 b.setVisible(not self.auto_update_query.isChecked()) 356 b.setEnabled(not self.query_is_up_to_date) 357 358 def update_query(self): 359 self.debounce_timer.stop() 360 query = self.selected_text or self.current_query 361 if self.query_is_up_to_date: 362 return 363 if not self.is_visible or not query: 364 return 365 self.current_source = self.url_template 366 url = self.current_source.format(word=query) 367 self.view.load(QUrl(url)) 368 self.current_query = query 369 self.update_refresh_button_status() 370 371 def selected_text_changed(self, text, annot_id): 372 already_has_text = bool(self.current_query) 373 self.selected_text = text or '' 374 if self.auto_update_query.isChecked() or not already_has_text: 375 self.debounce_timer.start() 376 self.update_refresh_button_status() 377 378 def on_forced_show(self): 379 self.update_query() 380