1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
4
5import codecs
6import json
7import os
8import re
9from functools import lru_cache, partial
10from qt.core import (
11    QAbstractItemView, QApplication, QCheckBox, QComboBox, QCursor, QDateTime,
12    QDialog, QDialogButtonBox, QFont, QFormLayout, QFrame, QHBoxLayout, QIcon,
13    QKeySequence, QLabel, QMenu, QPalette, QPlainTextEdit, QSize, QSplitter, Qt,
14    QTextBrowser, QTimer, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
15    QWidget, pyqtSignal
16)
17from urllib.parse import quote
18
19from calibre import prepare_string_for_xml
20from calibre.constants import (
21    builtin_colors_dark, builtin_colors_light, builtin_decorations
22)
23from calibre.db.backend import FTSQueryError
24from calibre.ebooks.metadata import authors_to_string, fmt_sidx
25from calibre.gui2 import (
26    Application, choose_save_file, config, error_dialog, gprefs, is_dark_theme,
27    safe_open_url
28)
29from calibre.gui2.dialogs.confirm_delete import confirm
30from calibre.gui2.viewer.widgets import ResultsDelegate, SearchBox
31from calibre.gui2.widgets2 import Dialog, RightClickButton
32
33
34# rendering {{{
35def render_highlight_as_text(hl, lines, as_markdown=False, link_prefix=None):
36    lines.append(hl['highlighted_text'])
37    date = QDateTime.fromString(hl['timestamp'], Qt.DateFormat.ISODate).toLocalTime().toString(Qt.DateFormat.SystemLocaleShortDate)
38    if as_markdown and link_prefix:
39        cfi = hl['start_cfi']
40        spine_index = (1 + hl['spine_index']) * 2
41        link = (link_prefix + quote(f'epubcfi(/{spine_index}{cfi})')).replace(')', '%29')
42        date = f'[{date}]({link})'
43    lines.append(date)
44    notes = hl.get('notes')
45    if notes:
46        lines.append('')
47        lines.append(notes)
48    lines.append('')
49    if as_markdown:
50        lines.append('-' * 20)
51    else:
52        lines.append('───')
53    lines.append('')
54
55
56def render_bookmark_as_text(b, lines, as_markdown=False, link_prefix=None):
57    lines.append(b['title'])
58    date = QDateTime.fromString(b['timestamp'], Qt.DateFormat.ISODate).toLocalTime().toString(Qt.DateFormat.SystemLocaleShortDate)
59    if as_markdown and link_prefix and b['pos_type'] == 'epubcfi':
60        link = (link_prefix + quote(b['pos'])).replace(')', '%29')
61        date = f'[{date}]({link})'
62    lines.append(date)
63    lines.append('')
64    if as_markdown:
65        lines.append('-' * 20)
66    else:
67        lines.append('───')
68    lines.append('')
69
70
71url_prefixes = 'http', 'https'
72url_delimiters = (
73    '\x00-\x09\x0b-\x20\x7f-\xa0\xad\u0600-\u0605\u061c\u06dd\u070f\u08e2\u1680\u180e\u2000-\u200f\u2028-\u202f'
74    '\u205f-\u2064\u2066-\u206f\u3000\ud800-\uf8ff\ufeff\ufff9-\ufffb\U000110bd\U000110cd\U00013430-\U00013438'
75    '\U0001bca0-\U0001bca3\U0001d173-\U0001d17a\U000e0001\U000e0020-\U000e007f\U000f0000-\U000ffffd\U00100000-\U0010fffd'
76)
77url_pattern = r'\b(?:{})://[^{}]{{3,}}'.format('|'.join(url_prefixes), url_delimiters)
78
79
80@lru_cache(maxsize=2)
81def url_pat():
82    return re.compile(url_pattern, flags=re.I)
83
84
85closing_bracket_map = {'(': ')', '[': ']', '{': '}', '<': '>', '*': '*', '"': '"', "'": "'"}
86
87
88def url(text: str, s: int, e: int):
89    while text[e - 1] in '.,?!' and e > 1:  # remove trailing punctuation
90        e -= 1
91    # truncate url at closing bracket/quote
92    if s > 0 and e <= len(text) and text[s-1] in closing_bracket_map:
93        q = closing_bracket_map[text[s-1]]
94        idx = text.find(q, s)
95        if idx > s:
96            e = idx
97    return s, e
98
99
100def render_note_line(line):
101    urls = []
102    for m in url_pat().finditer(line):
103        s, e = url(line, m.start(), m.end())
104        urls.append((s, e))
105    if not urls:
106        yield prepare_string_for_xml(line)
107        return
108    pos = 0
109    for (s, e) in urls:
110        if s > pos:
111            yield prepare_string_for_xml(line[pos:s])
112        yield '<a href="{0}">{0}</a>'.format(prepare_string_for_xml(line[s:e], True))
113    if urls[-1][1] < len(line):
114        yield prepare_string_for_xml(line[urls[-1][1]:])
115
116
117def render_notes(notes, tag='p'):
118    current_lines = []
119    for line in notes.splitlines():
120        if line:
121            current_lines.append(''.join(render_note_line(line)))
122        else:
123            if current_lines:
124                yield '<{0}>{1}</{0}>'.format(tag, '\n'.join(current_lines))
125                current_lines = []
126    if current_lines:
127        yield '<{0}>{1}</{0}>'.format(tag, '\n'.join(current_lines))
128
129
130def friendly_username(user_type, user):
131    key = user_type, user
132    if key == ('web', '*'):
133        return _('Anonymous Content server user')
134    if key == ('local', 'viewer'):
135        return _('Local E-book viewer user')
136    return user
137
138
139def annotation_title(atype, singular=False):
140    if singular:
141        return {'bookmark': _('Bookmark'), 'highlight': _('Highlight')}.get(atype, atype)
142    return {'bookmark': _('Bookmarks'), 'highlight': _('Highlights')}.get(atype, atype)
143
144
145class AnnotsResultsDelegate(ResultsDelegate):
146
147    add_ellipsis = False
148    emphasize_text = False
149
150    def result_data(self, result):
151        if not isinstance(result, dict):
152            return None, None, None, None, None
153        full_text = result['text'].replace('\x1f', ' ')
154        parts = full_text.split('\x1d', 2)
155        before = after = ''
156        if len(parts) > 2:
157            before, text = parts[:2]
158            after = parts[2].replace('\x1d', '')
159        elif len(parts) == 2:
160            before, text = parts
161        else:
162            text = parts[0]
163        return False, before, text, after, bool(result.get('annotation', {}).get('notes'))
164
165
166# }}}
167
168
169def sorted_items(items):
170    from calibre.ebooks.epub.cfi.parse import cfi_sort_key
171    def_spine = 999999999
172    defval = cfi_sort_key(f'/{def_spine}')
173
174    def sort_key(x):
175        x = x['annotation']
176        atype = x['type']
177        if atype == 'highlight':
178            cfi = x.get('start_cfi')
179            if cfi:
180                spine_idx = x.get('spine_index', def_spine)
181                cfi = f'/{spine_idx}{cfi}'
182                return cfi_sort_key(cfi)
183        elif atype == 'bookmark':
184            if x.get('pos_type') == 'epubcfi':
185                return cfi_sort_key(x['pos'], only_path=False)
186        return defval
187
188    return sorted(items, key=sort_key)
189
190
191def css_for_highlight_style(style):
192    is_dark = is_dark_theme()
193    kind = style.get('kind')
194    ans = ''
195    if kind == 'color':
196        key = 'dark' if is_dark else 'light'
197        val = style.get(key)
198        if val is None:
199            which = style.get('which')
200            val = (builtin_colors_dark if is_dark else builtin_colors_light).get(which)
201        if val is None:
202            val = style.get('background-color')
203        if val is not None:
204            ans = f'background-color: {val}'
205    elif 'background-color' in style:
206        ans = 'background-color: ' + style['background-color']
207        if 'color' in style:
208            ans += '; color: ' + style["color"]
209    elif kind == 'decoration':
210        which = style.get('which')
211        if which is not None:
212            q = builtin_decorations.get(which)
213            if q is not None:
214                ans = q
215        else:
216            ans = '; '.join(f'{k}: {v}' for k, v in style.items())
217    return ans
218
219
220class Export(Dialog):  # {{{
221
222    prefs = gprefs
223    pref_name = 'annots_export_format'
224
225    def __init__(self, annots, parent=None):
226        self.annotations = annots
227        super().__init__(name='export-annotations', title=_('Export {} annotations').format(len(annots)), parent=parent)
228
229    def file_type_data(self):
230        return _('calibre annotation collection'), 'calibre_annotation_collection'
231
232    def initial_filename(self):
233        return _('annotations')
234
235    def setup_ui(self):
236        self.l = l = QFormLayout(self)
237        self.export_format = ef = QComboBox(self)
238        ef.addItem(_('Plain text'), 'txt')
239        ef.addItem(_('Markdown'), 'md')
240        ef.addItem(*self.file_type_data())
241        idx = ef.findData(self.prefs[self.pref_name])
242        if idx > -1:
243            ef.setCurrentIndex(idx)
244        ef.currentIndexChanged.connect(self.save_format_pref)
245        l.addRow(_('Format to export in:'), ef)
246        l.addRow(self.bb)
247        self.bb.clear()
248        self.bb.addButton(QDialogButtonBox.StandardButton.Cancel)
249        b = self.bb.addButton(_('Copy to clipboard'), QDialogButtonBox.ButtonRole.ActionRole)
250        b.clicked.connect(self.copy_to_clipboard)
251        b.setIcon(QIcon(I('edit-copy.png')))
252        b = self.bb.addButton(_('Save to file'), QDialogButtonBox.ButtonRole.ActionRole)
253        b.clicked.connect(self.save_to_file)
254        b.setIcon(QIcon(I('save.png')))
255
256    def save_format_pref(self):
257        self.prefs[self.pref_name] = self.export_format.currentData()
258
259    def copy_to_clipboard(self):
260        QApplication.instance().clipboard().setText(self.exported_data())
261        self.accept()
262
263    def save_to_file(self):
264        filters = [(self.export_format.currentText(), [self.export_format.currentData()])]
265        path = choose_save_file(
266            self, 'annots-export-save', _('File for exports'), filters=filters,
267            initial_filename=self.initial_filename() + '.' + filters[0][1][0])
268        if path:
269            data = self.exported_data().encode('utf-8')
270            with open(path, 'wb') as f:
271                f.write(codecs.BOM_UTF8)
272                f.write(data)
273            self.accept()
274
275    def exported_data(self):
276        fmt = self.export_format.currentData()
277        if fmt == 'calibre_annotation_collection':
278            return json.dumps({
279                'version': 1,
280                'type': 'calibre_annotation_collection',
281                'annotations': self.annotations,
282            }, ensure_ascii=False, sort_keys=True, indent=2)
283        lines = []
284        db = current_db()
285        bid_groups = {}
286        as_markdown = fmt == 'md'
287        library_id = getattr(db, 'server_library_id', None)
288        if library_id:
289            library_id = '_hex_-' + library_id.encode('utf-8').hex()
290        for a in self.annotations:
291            bid_groups.setdefault(a['book_id'], []).append(a)
292        for book_id, group in bid_groups.items():
293            chapter_groups = {}
294            def_chap = (_('Unknown chapter'),)
295            for a in group:
296                toc_titles = a.get('toc_family_titles', def_chap)
297                chapter_groups.setdefault(toc_titles[0], []).append(a)
298
299            lines.append('## ' + db.field_for('title', book_id))
300            lines.append('')
301
302            for chapter, group in chapter_groups.items():
303                if len(chapter_groups) > 1:
304                    lines.append('### ' + chapter)
305                    lines.append('')
306                for a in group:
307                    atype = a['type']
308                    if library_id:
309                        link_prefix = f'calibre://view-book/{library_id}/{book_id}/{a["format"]}?open_at='
310                    else:
311                        link_prefix = None
312                    if atype == 'highlight':
313                        render_highlight_as_text(a, lines, as_markdown=as_markdown, link_prefix=link_prefix)
314                    elif atype == 'bookmark':
315                        render_bookmark_as_text(a, lines, as_markdown=as_markdown, link_prefix=link_prefix)
316            lines.append('')
317        return '\n'.join(lines).strip()
318# }}}
319
320
321def current_db():
322    from calibre.gui2.ui import get_gui
323    return (getattr(current_db, 'ans', None) or get_gui().current_db).new_api
324
325
326class BusyCursor:
327
328    def __enter__(self):
329        QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
330
331    def __exit__(self, *args):
332        QApplication.restoreOverrideCursor()
333
334
335class ResultsList(QTreeWidget):
336
337    current_result_changed = pyqtSignal(object)
338    open_annotation = pyqtSignal(object, object, object)
339    show_book = pyqtSignal(object, object)
340    delete_requested = pyqtSignal()
341    export_requested = pyqtSignal()
342    edit_annotation = pyqtSignal(object, object)
343
344    def __init__(self, parent):
345        QTreeWidget.__init__(self, parent)
346        self.setHeaderHidden(True)
347        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
348        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
349        self.customContextMenuRequested.connect(self.show_context_menu)
350        self.delegate = AnnotsResultsDelegate(self)
351        self.setItemDelegate(self.delegate)
352        self.section_font = QFont(self.font())
353        self.itemDoubleClicked.connect(self.item_activated)
354        self.section_font.setItalic(True)
355        self.currentItemChanged.connect(self.current_item_changed)
356        self.number_of_results = 0
357        self.item_map = []
358
359    def show_context_menu(self, pos):
360        item = self.itemAt(pos)
361        if item is not None:
362            result = item.data(0, Qt.ItemDataRole.UserRole)
363        else:
364            result = None
365        items = self.selectedItems()
366        m = QMenu(self)
367        if isinstance(result, dict):
368            m.addAction(_('Open in viewer'), partial(self.item_activated, item))
369            m.addAction(_('Show in calibre'), partial(self.show_in_calibre, item))
370            if result.get('annotation', {}).get('type') == 'highlight':
371                m.addAction(_('Edit notes'), partial(self.edit_notes, item))
372        if items:
373            m.addSeparator()
374            m.addAction(ngettext('Export selected item', 'Export {} selected items', len(items)).format(len(items)), self.export_requested.emit)
375            m.addAction(ngettext('Delete selected item', 'Delete {} selected items', len(items)).format(len(items)), self.delete_requested.emit)
376        m.addSeparator()
377        m.addAction(_('Expand all'), self.expandAll)
378        m.addAction(_('Collapse all'), self.collapseAll)
379        m.exec(self.mapToGlobal(pos))
380
381    def edit_notes(self, item):
382        r = item.data(0, Qt.ItemDataRole.UserRole)
383        if isinstance(r, dict):
384            self.edit_annotation.emit(r['id'], r['annotation'])
385
386    def show_in_calibre(self, item):
387        r = item.data(0, Qt.ItemDataRole.UserRole)
388        if isinstance(r, dict):
389            self.show_book.emit(r['book_id'], r['format'])
390
391    def item_activated(self, item):
392        r = item.data(0, Qt.ItemDataRole.UserRole)
393        if isinstance(r, dict):
394            self.open_annotation.emit(r['book_id'], r['format'], r['annotation'])
395
396    def set_results(self, results, emphasize_text):
397        self.clear()
398        self.delegate.emphasize_text = emphasize_text
399        self.number_of_results = 0
400        self.item_map = []
401        book_id_map = {}
402        db = current_db()
403        for result in results:
404            book_id = result['book_id']
405            if book_id not in book_id_map:
406                book_id_map[book_id] = {'title': db.field_for('title', book_id), 'matches': []}
407            book_id_map[book_id]['matches'].append(result)
408        for book_id, entry in book_id_map.items():
409            section = QTreeWidgetItem([entry['title']], 1)
410            section.setFlags(Qt.ItemFlag.ItemIsEnabled)
411            section.setFont(0, self.section_font)
412            section.setData(0, Qt.ItemDataRole.UserRole, book_id)
413            self.addTopLevelItem(section)
414            section.setExpanded(True)
415            for result in sorted_items(entry['matches']):
416                item = QTreeWidgetItem(section, [' '], 2)
417                self.item_map.append(item)
418                item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren)
419                item.setData(0, Qt.ItemDataRole.UserRole, result)
420                item.setData(0, Qt.ItemDataRole.UserRole + 1, self.number_of_results)
421                self.number_of_results += 1
422        if self.item_map:
423            self.setCurrentItem(self.item_map[0])
424
425    def current_item_changed(self, current, previous):
426        if current is not None:
427            r = current.data(0, Qt.ItemDataRole.UserRole)
428            if isinstance(r, dict):
429                self.current_result_changed.emit(r)
430        else:
431            self.current_result_changed.emit(None)
432
433    def show_next(self, backwards=False):
434        item = self.currentItem()
435        if item is None:
436            return
437        i = int(item.data(0, Qt.ItemDataRole.UserRole + 1))
438        i += -1 if backwards else 1
439        i %= self.number_of_results
440        self.setCurrentItem(self.item_map[i])
441
442    @property
443    def selected_annot_ids(self):
444        for item in self.selectedItems():
445            yield item.data(0, Qt.ItemDataRole.UserRole)['id']
446
447    @property
448    def selected_annotations(self):
449        for item in self.selectedItems():
450            x = item.data(0, Qt.ItemDataRole.UserRole)
451            ans = x['annotation'].copy()
452            for key in ('book_id', 'format'):
453                ans[key] = x[key]
454            yield ans
455
456    def keyPressEvent(self, ev):
457        if ev.matches(QKeySequence.StandardKey.Delete):
458            self.delete_requested.emit()
459            ev.accept()
460            return
461        if ev.key() == Qt.Key.Key_F2:
462            item = self.currentItem()
463            if item:
464                self.edit_notes(item)
465                ev.accept()
466                return
467        return QTreeWidget.keyPressEvent(self, ev)
468
469    @property
470    def tree_state(self):
471        ans = {'closed': set()}
472        item = self.currentItem()
473        if item is not None:
474            ans['current'] = item.data(0, Qt.ItemDataRole.UserRole)
475        for item in (self.topLevelItem(i) for i in range(self.topLevelItemCount())):
476            if not item.isExpanded():
477                ans['closed'].add(item.data(0, Qt.ItemDataRole.UserRole))
478        return ans
479
480    @tree_state.setter
481    def tree_state(self, state):
482        closed = state['closed']
483        for item in (self.topLevelItem(i) for i in range(self.topLevelItemCount())):
484            if item.data(0, Qt.ItemDataRole.UserRole) in closed:
485                item.setExpanded(False)
486
487        cur = state.get('current')
488        if cur is not None:
489            for item in self.item_map:
490                if item.data(0, Qt.ItemDataRole.UserRole) == cur:
491                    self.setCurrentItem(item)
492                    break
493
494
495class Restrictions(QWidget):
496
497    restrictions_changed = pyqtSignal()
498
499    def __init__(self, parent):
500        self.restrict_to_book_ids = frozenset()
501        QWidget.__init__(self, parent)
502        v = QVBoxLayout(self)
503        v.setContentsMargins(0, 0, 0, 0)
504        h = QHBoxLayout()
505        h.setContentsMargins(0, 0, 0, 0)
506        v.addLayout(h)
507        self.rla = QLabel(_('Restrict to') + ': ')
508        h.addWidget(self.rla)
509        la = QLabel(_('Type:'))
510        h.addWidget(la)
511        self.types_box = tb = QComboBox(self)
512        tb.la = la
513        tb.currentIndexChanged.connect(self.restrictions_changed)
514        connect_lambda(tb.currentIndexChanged, tb, lambda tb: gprefs.set('browse_annots_restrict_to_type', tb.currentData()))
515        la.setBuddy(tb)
516        tb.setToolTip(_('Show only annotations of the specified type'))
517        h.addWidget(tb)
518        la = QLabel(_('User:'))
519        h.addWidget(la)
520        self.user_box = ub = QComboBox(self)
521        ub.la = la
522        ub.currentIndexChanged.connect(self.restrictions_changed)
523        connect_lambda(ub.currentIndexChanged, ub, lambda ub: gprefs.set('browse_annots_restrict_to_user', ub.currentData()))
524        la.setBuddy(ub)
525        ub.setToolTip(_('Show only annotations created by the specified user'))
526        h.addWidget(ub)
527        h.addStretch(10)
528        h = QHBoxLayout()
529        self.restrict_to_books_cb = cb = QCheckBox('')
530        self.update_book_restrictions_text()
531        cb.setToolTip(_('Only show annotations from books that have been selected in the calibre library'))
532        cb.setChecked(bool(gprefs.get('show_annots_from_selected_books_only', False)))
533        cb.stateChanged.connect(self.show_only_selected_changed)
534        h.addWidget(cb)
535        v.addLayout(h)
536
537    def update_book_restrictions_text(self):
538        if not self.restrict_to_book_ids:
539            t = _('&Show results from only selected books')
540        else:
541            t = ngettext(
542                '&Show results from only the selected book',
543                '&Show results from only the {} selected books',
544                len(self.restrict_to_book_ids)).format(len(self.restrict_to_book_ids))
545        self.restrict_to_books_cb.setText(t)
546
547    def show_only_selected_changed(self):
548        self.restrictions_changed.emit()
549        gprefs['show_annots_from_selected_books_only'] = bool(self.restrict_to_books_cb.isChecked())
550
551    def selection_changed(self, restrict_to_book_ids):
552        self.restrict_to_book_ids = frozenset(restrict_to_book_ids or set())
553        self.update_book_restrictions_text()
554        if self.restrict_to_books_cb.isChecked():
555            self.restrictions_changed.emit()
556
557    @property
558    def effective_restrict_to_book_ids(self):
559        return (self.restrict_to_book_ids or None) if self.restrict_to_books_cb.isChecked() else None
560
561    def re_initialize(self, db, restrict_to_book_ids=None):
562        self.restrict_to_book_ids = frozenset(restrict_to_book_ids or set())
563        self.update_book_restrictions_text()
564        tb = self.types_box
565        before = tb.currentData()
566        if not before:
567            before = gprefs['browse_annots_restrict_to_type']
568        tb.blockSignals(True)
569        tb.clear()
570        tb.addItem(' ', ' ')
571        for atype in db.all_annotation_types():
572            tb.addItem(annotation_title(atype), atype)
573        if before:
574            row = tb.findData(before)
575            if row > -1:
576                tb.setCurrentIndex(row)
577        tb.blockSignals(False)
578        tb_is_visible = tb.count() > 2
579        tb.setVisible(tb_is_visible), tb.la.setVisible(tb_is_visible)
580        tb = self.user_box
581        before = tb.currentData()
582        if not before:
583            before = gprefs['browse_annots_restrict_to_user']
584        tb.blockSignals(True)
585        tb.clear()
586        tb.addItem(' ', ' ')
587        for user_type, user in db.all_annotation_users():
588            display_name = friendly_username(user_type, user)
589            tb.addItem(display_name, '{}:{}'.format(user_type, user))
590        if before:
591            row = tb.findData(before)
592            if row > -1:
593                tb.setCurrentIndex(row)
594        tb.blockSignals(False)
595        ub_is_visible = tb.count() > 2
596        tb.setVisible(ub_is_visible), tb.la.setVisible(ub_is_visible)
597        self.rla.setVisible(tb_is_visible or ub_is_visible)
598        self.setVisible(True)
599
600
601class BrowsePanel(QWidget):
602
603    current_result_changed = pyqtSignal(object)
604    open_annotation = pyqtSignal(object, object, object)
605    show_book = pyqtSignal(object, object)
606    delete_requested = pyqtSignal()
607    export_requested = pyqtSignal()
608    edit_annotation = pyqtSignal(object, object)
609
610    def __init__(self, parent):
611        QWidget.__init__(self, parent)
612        self.use_stemmer = parent.use_stemmer
613        self.current_query = None
614        l = QVBoxLayout(self)
615
616        h = QHBoxLayout()
617        l.addLayout(h)
618        self.search_box = sb = SearchBox(self)
619        sb.initialize('library-annotations-browser-search-box')
620        sb.cleared.connect(self.cleared, type=Qt.ConnectionType.QueuedConnection)
621        sb.lineEdit().returnPressed.connect(self.show_next)
622        sb.lineEdit().setPlaceholderText(_('Enter words to search for'))
623        h.addWidget(sb)
624
625        self.next_button = nb = QToolButton(self)
626        h.addWidget(nb)
627        nb.setFocusPolicy(Qt.FocusPolicy.NoFocus)
628        nb.setIcon(QIcon(I('arrow-down.png')))
629        nb.clicked.connect(self.show_next)
630        nb.setToolTip(_('Find next match'))
631
632        self.prev_button = nb = QToolButton(self)
633        h.addWidget(nb)
634        nb.setFocusPolicy(Qt.FocusPolicy.NoFocus)
635        nb.setIcon(QIcon(I('arrow-up.png')))
636        nb.clicked.connect(self.show_previous)
637        nb.setToolTip(_('Find previous match'))
638
639        self.restrictions = rs = Restrictions(self)
640        rs.restrictions_changed.connect(self.effective_query_changed)
641        self.use_stemmer.stateChanged.connect(self.effective_query_changed)
642        l.addWidget(rs)
643
644        self.results_list = rl = ResultsList(self)
645        rl.current_result_changed.connect(self.current_result_changed)
646        rl.open_annotation.connect(self.open_annotation)
647        rl.show_book.connect(self.show_book)
648        rl.edit_annotation.connect(self.edit_annotation)
649        rl.delete_requested.connect(self.delete_requested)
650        rl.export_requested.connect(self.export_requested)
651        l.addWidget(rl)
652
653    def re_initialize(self, restrict_to_book_ids=None):
654        db = current_db()
655        self.search_box.setFocus(Qt.FocusReason.OtherFocusReason)
656        self.restrictions.re_initialize(db, restrict_to_book_ids or set())
657        self.current_query = None
658        self.results_list.clear()
659
660    def selection_changed(self, restrict_to_book_ids):
661        self.restrictions.selection_changed(restrict_to_book_ids)
662
663    def sizeHint(self):
664        return QSize(450, 600)
665
666    @property
667    def restrict_to_user(self):
668        user = self.restrictions.user_box.currentData()
669        if user and ':' in user:
670            return user.split(':', 1)
671
672    @property
673    def effective_query(self):
674        text = self.search_box.lineEdit().text().strip()
675        atype = self.restrictions.types_box.currentData()
676        return {
677            'fts_engine_query': text,
678            'annotation_type': (atype or '').strip(),
679            'restrict_to_user': self.restrict_to_user,
680            'use_stemming': bool(self.use_stemmer.isChecked()),
681            'restrict_to_book_ids': self.restrictions.effective_restrict_to_book_ids,
682        }
683
684    def cleared(self):
685        self.current_query = None
686        self.effective_query_changed()
687
688    def do_find(self, backwards=False):
689        q = self.effective_query
690        if q == self.current_query:
691            self.results_list.show_next(backwards)
692            return
693        try:
694            with BusyCursor():
695                db = current_db()
696                if not q['fts_engine_query']:
697                    results = db.all_annotations(
698                        restrict_to_user=q['restrict_to_user'], limit=4096, annotation_type=q['annotation_type'],
699                        ignore_removed=True, restrict_to_book_ids=q['restrict_to_book_ids'] or None
700                    )
701                else:
702                    q2 = q.copy()
703                    q2['restrict_to_book_ids'] = q.get('restrict_to_book_ids') or None
704                    results = db.search_annotations(
705                        highlight_start='\x1d', highlight_end='\x1d', snippet_size=64,
706                        ignore_removed=True, **q2
707                    )
708                self.results_list.set_results(results, bool(q['fts_engine_query']))
709                self.current_query = q
710        except FTSQueryError as err:
711            return error_dialog(self, _('Invalid search expression'), '<p>' + _(
712                'The search expression: {0} is invalid. The search syntax used is the'
713                ' SQLite Full text Search Query syntax, <a href="{1}">described here</a>.').format(
714                    err.query, 'https://www.sqlite.org/fts5.html#full_text_query_syntax'),
715                det_msg=str(err), show=True)
716
717    def effective_query_changed(self):
718        self.do_find()
719
720    def refresh(self):
721        vbar = self.results_list.verticalScrollBar()
722        if vbar:
723            vpos = vbar.value()
724        self.current_query = None
725        self.do_find()
726        vbar = self.results_list.verticalScrollBar()
727        if vbar:
728            vbar.setValue(vpos)
729
730    def show_next(self):
731        self.do_find()
732
733    def show_previous(self):
734        self.do_find(backwards=True)
735
736    @property
737    def selected_annot_ids(self):
738        return self.results_list.selected_annot_ids
739
740    @property
741    def selected_annotations(self):
742        return self.results_list.selected_annotations
743
744    def save_tree_state(self):
745        return self.results_list.tree_state
746
747    def restore_tree_state(self, state):
748        self.results_list.tree_state = state
749
750
751class Details(QTextBrowser):
752
753    def __init__(self, parent):
754        QTextBrowser.__init__(self, parent)
755        self.setFrameShape(QFrame.Shape.NoFrame)
756        self.setOpenLinks(False)
757        self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False)
758        palette = self.palette()
759        palette.setBrush(QPalette.ColorRole.Base, Qt.GlobalColor.transparent)
760        self.setPalette(palette)
761        self.setAcceptDrops(False)
762
763
764class DetailsPanel(QWidget):
765
766    open_annotation = pyqtSignal(object, object, object)
767    show_book = pyqtSignal(object, object)
768    edit_annotation = pyqtSignal(object, object)
769    delete_annotation = pyqtSignal(object)
770
771    def __init__(self, parent):
772        QWidget.__init__(self, parent)
773        self.current_result = None
774        l = QVBoxLayout(self)
775        self.text_browser = tb = Details(self)
776        tb.anchorClicked.connect(self.link_clicked)
777        l.addWidget(tb)
778        self.show_result(None)
779
780    def link_clicked(self, qurl):
781        if qurl.scheme() == 'calibre':
782            getattr(self, qurl.host())()
783        else:
784            safe_open_url(qurl)
785
786    def open_result(self):
787        if self.current_result is not None:
788            r = self.current_result
789            self.open_annotation.emit(r['book_id'], r['format'], r['annotation'])
790
791    def delete_result(self):
792        if self.current_result is not None:
793            r = self.current_result
794            self.delete_annotation.emit(r['id'])
795
796    def edit_result(self):
797        if self.current_result is not None:
798            r = self.current_result
799            self.edit_annotation.emit(r['id'], r['annotation'])
800
801    def show_in_library(self):
802        if self.current_result is not None:
803            self.show_book.emit(self.current_result['book_id'], self.current_result['format'])
804
805    def sizeHint(self):
806        return QSize(450, 600)
807
808    def set_controls_visibility(self, visible):
809        self.text_browser.setVisible(visible)
810
811    def update_notes(self, annot):
812        if self.current_result:
813            self.current_result['annotation'] = annot
814            self.show_result(self.current_result)
815
816    def show_result(self, result_or_none):
817        self.current_result = r = result_or_none
818        if r is None:
819            self.set_controls_visibility(False)
820            return
821        self.set_controls_visibility(True)
822        db = current_db()
823        book_id = r['book_id']
824        title, authors = db.field_for('title', book_id), db.field_for('authors', book_id)
825        authors = authors_to_string(authors)
826        series, sidx = db.field_for('series', book_id), db.field_for('series_index', book_id)
827        series_text = ''
828        if series:
829            use_roman_numbers = config['use_roman_numerals_for_series_number']
830            series_text = '{} of {}'.format(fmt_sidx(sidx, use_roman=use_roman_numbers), series)
831        annot = r['annotation']
832        atype = annotation_title(annot['type'], singular=True)
833        book_format = r['format']
834        annot_text = ''
835        a = prepare_string_for_xml
836        highlight_css = ''
837
838        paras = []
839
840        def p(text, tag='p'):
841            paras.append('<{0}>{1}</{0}>'.format(tag, a(text)))
842
843        if annot['type'] == 'bookmark':
844            p(annot['title'])
845        elif annot['type'] == 'highlight':
846            for line in annot['highlighted_text'].splitlines():
847                p(line)
848            notes = annot.get('notes')
849            if notes:
850                paras.append('<h4>{} (<a title="{}" href="calibre://edit_result">{}</a>)</h4>'.format(
851                    _('Notes'), _('Edit the notes of this highlight'), _('Edit')))
852                paras.extend(render_notes(notes))
853            else:
854                paras.append('<p><a title="{}" href="calibre://edit_result">{}</a></p>'.format(
855                    _('Add notes to this highlight'), _('Add notes')))
856            if 'style' in annot:
857                highlight_css = css_for_highlight_style(annot['style'])
858
859        annot_text += '\n'.join(paras)
860        date = QDateTime.fromString(annot['timestamp'], Qt.DateFormat.ISODate).toLocalTime().toString(Qt.DateFormat.SystemLocaleShortDate)
861
862        text = '''
863        <style>a {{ text-decoration: none }}</style>
864        <h2 style="text-align: center">{title} [{book_format}]</h2>
865        <div style="text-align: center">{authors}</div>
866        <div style="text-align: center">{series}</div>
867        <div>&nbsp;</div>
868        <div>&nbsp;</div>
869
870        <div>{dt}: {date}</div>
871        <div>{ut}: {user}</div>
872        <div>
873            <a href="calibre://open_result" title="{ovtt}" style="margin-right: 20px">{ov}</a>
874            <span>\xa0\xa0\xa0</span>
875            <a title="{sictt}" href="calibre://show_in_library">{sic}</a>
876        </div>
877        <h3 style="text-align: left; {highlight_css}">{atype}</h3>
878        {text}
879        '''.format(
880            title=a(title), authors=a(authors), series=a(series_text), book_format=a(book_format),
881            atype=a(atype), text=annot_text, dt=_('Date'), date=a(date), ut=a(_('User')),
882            user=a(friendly_username(r['user_type'], r['user'])), highlight_css=highlight_css,
883            ov=a(_('Open in viewer')), sic=a(_('Show in calibre')),
884            ovtt=a(_('Open the book at this annotation in the calibre E-book viewer')),
885            sictt=(_('Show this book in the main calibre book list')),
886        )
887        self.text_browser.setHtml(text)
888
889
890class EditNotes(Dialog):
891
892    def __init__(self, notes, parent=None):
893        self.initial_notes = notes
894        Dialog.__init__(
895            self, _('Edit notes for highlight'), 'library-annotations-browser-edit-notes', parent=parent)
896
897    def setup_ui(self):
898        self.notes_edit = QPlainTextEdit(self)
899        if self.initial_notes:
900            self.notes_edit.setPlainText(self.initial_notes)
901        self.notes_edit.setMinimumWidth(400)
902        self.notes_edit.setMinimumHeight(300)
903        l = QVBoxLayout(self)
904        l.addWidget(self.notes_edit)
905        l.addWidget(self.bb)
906
907    @property
908    def notes(self):
909        return self.notes_edit.toPlainText()
910
911
912class AnnotationsBrowser(Dialog):
913
914    open_annotation = pyqtSignal(object, object, object)
915    show_book = pyqtSignal(object, object)
916
917    def __init__(self, parent=None):
918        self.current_restriction = None
919        Dialog.__init__(self, _('Annotations browser'), 'library-annotations-browser', parent=parent, default_buttons=QDialogButtonBox.StandardButton.Close)
920        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
921        self.setWindowIcon(QIcon(I('highlight.png')))
922
923    def do_open_annotation(self, book_id, fmt, annot):
924        atype = annot['type']
925        if atype == 'bookmark':
926            if annot['pos_type'] == 'epubcfi':
927                self.open_annotation.emit(book_id, fmt, annot['pos'])
928        elif atype == 'highlight':
929            x = 2 * (annot['spine_index'] + 1)
930            self.open_annotation.emit(book_id, fmt, 'epubcfi(/{}{})'.format(x, annot['start_cfi']))
931
932    def keyPressEvent(self, ev):
933        if ev.key() not in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
934            return Dialog.keyPressEvent(self, ev)
935
936    def setup_ui(self):
937        self.use_stemmer = us = QCheckBox(_('&Match on related words'))
938        us.setChecked(gprefs['browse_annots_use_stemmer'])
939        us.setToolTip('<p>' + _(
940            'With this option searching for words will also match on any related words (supported in several languages). For'
941            ' example, in the English language: <i>correction</i> matches <i>correcting</i> and <i>corrected</i> as well'))
942        us.stateChanged.connect(lambda state: gprefs.set('browse_annots_use_stemmer', state != Qt.CheckState.Unchecked))
943
944        l = QVBoxLayout(self)
945
946        self.splitter = s = QSplitter(self)
947        l.addWidget(s)
948        s.setChildrenCollapsible(False)
949
950        self.browse_panel = bp = BrowsePanel(self)
951        bp.open_annotation.connect(self.do_open_annotation)
952        bp.show_book.connect(self.show_book)
953        bp.delete_requested.connect(self.delete_selected)
954        bp.export_requested.connect(self.export_selected)
955        bp.edit_annotation.connect(self.edit_annotation)
956        s.addWidget(bp)
957
958        self.details_panel = dp = DetailsPanel(self)
959        s.addWidget(dp)
960        dp.open_annotation.connect(self.do_open_annotation)
961        dp.show_book.connect(self.show_book)
962        dp.delete_annotation.connect(self.delete_annotation)
963        dp.edit_annotation.connect(self.edit_annotation)
964        bp.current_result_changed.connect(dp.show_result)
965
966        h = QHBoxLayout()
967        l.addLayout(h)
968        h.addWidget(us), h.addStretch(10), h.addWidget(self.bb)
969        self.delete_button = b = self.bb.addButton(_('&Delete all selected'), QDialogButtonBox.ButtonRole.ActionRole)
970        b.setToolTip(_('Delete the selected annotations'))
971        b.setIcon(QIcon(I('trash.png')))
972        b.clicked.connect(self.delete_selected)
973        self.export_button = b = self.bb.addButton(_('&Export all selected'), QDialogButtonBox.ButtonRole.ActionRole)
974        b.setToolTip(_('Export the selected annotations'))
975        b.setIcon(QIcon(I('save.png')))
976        b.clicked.connect(self.export_selected)
977        self.refresh_button = b = RightClickButton(self.bb)
978        self.bb.addButton(b, QDialogButtonBox.ButtonRole.ActionRole)
979        b.setText(_('&Refresh'))
980        b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
981        self.refresh_menu = m = QMenu(self)
982        m.addAction(_('Rebuild search index')).triggered.connect(self.rebuild)
983        b.setMenu(m)
984        b.setToolTip(_('Refresh annotations in case they have been changed since this window was opened'))
985        b.setIcon(QIcon(I('restart.png')))
986        b.setPopupMode(QToolButton.ToolButtonPopupMode.DelayedPopup)
987        b.clicked.connect(self.refresh)
988
989    def delete_selected(self):
990        ids = frozenset(self.browse_panel.selected_annot_ids)
991        if not ids:
992            return error_dialog(self, _('No selected annotations'), _(
993                'No annotations have been selected'), show=True)
994        self.delete_annotations(ids)
995
996    def export_selected(self):
997        annots = tuple(self.browse_panel.selected_annotations)
998        if not annots:
999            return error_dialog(self, _('No selected annotations'), _(
1000                'No annotations have been selected'), show=True)
1001        Export(annots, self).exec()
1002
1003    def delete_annotations(self, ids):
1004        if confirm(ngettext(
1005            'Are you sure you want to <b>permanently</b> delete this annotation?',
1006            'Are you sure you want to <b>permanently</b> delete these {} annotations?',
1007            len(ids)).format(len(ids)), 'delete-annotation-from-browse', parent=self
1008        ):
1009            db = current_db()
1010            db.delete_annotations(ids)
1011            self.browse_panel.refresh()
1012
1013    def delete_annotation(self, annot_id):
1014        self.delete_annotations(frozenset({annot_id}))
1015
1016    def edit_annotation(self, annot_id, annot):
1017        if annot.get('type') != 'highlight':
1018            return error_dialog(self, _('Cannot edit'), _(
1019                'Editing is only supported for the notes associated with highlights'), show=True)
1020        notes = annot.get('notes')
1021        d = EditNotes(notes, self)
1022        if d.exec() == QDialog.DialogCode.Accepted:
1023            notes = d.notes
1024            if notes and notes.strip():
1025                annot['notes'] = notes.strip()
1026            else:
1027                annot.pop('notes', None)
1028            db = current_db()
1029            db.update_annotations({annot_id: annot})
1030            self.details_panel.update_notes(annot)
1031
1032    def show_dialog(self, restrict_to_book_ids=None):
1033        if self.parent() is None:
1034            self.browse_panel.effective_query_changed()
1035            self.exec()
1036        else:
1037            self.reinitialize(restrict_to_book_ids)
1038            self.show()
1039            self.raise_()
1040            QTimer.singleShot(80, self.browse_panel.effective_query_changed)
1041
1042    def selection_changed(self):
1043        if self.isVisible() and self.parent():
1044            gui = self.parent()
1045            self.browse_panel.selection_changed(gui.library_view.get_selected_ids(as_set=True))
1046
1047    def reinitialize(self, restrict_to_book_ids=None):
1048        self.current_restriction = restrict_to_book_ids
1049        self.browse_panel.re_initialize(restrict_to_book_ids or set())
1050
1051    def refresh(self):
1052        state = self.browse_panel.save_tree_state()
1053        self.browse_panel.re_initialize(self.current_restriction)
1054        self.browse_panel.effective_query_changed()
1055        self.browse_panel.restore_tree_state(state)
1056
1057    def rebuild(self):
1058        with BusyCursor():
1059            current_db().reindex_annotations()
1060        self.refresh()
1061
1062
1063if __name__ == '__main__':
1064    from calibre.library import db
1065    app = Application([])
1066    current_db.ans = db(os.path.expanduser('~/test library'))
1067    br = AnnotationsBrowser()
1068    br.reinitialize()
1069    br.show_dialog()
1070    del br
1071    del app
1072