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