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 json
6import math
7from collections import defaultdict
8from functools import lru_cache
9from itertools import chain
10from qt.core import (
11    QAbstractItemView, QColor, QDialog, QFont, QHBoxLayout, QIcon, QImage,
12    QItemSelectionModel, QKeySequence, QLabel, QMenu, QPainter, QPainterPath,
13    QPalette, QPixmap, QPushButton, QRect, QSizePolicy, QStyle, Qt, QTextCursor,
14    QTextEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, pyqtSignal
15)
16
17from calibre.constants import (
18    builtin_colors_dark, builtin_colors_light, builtin_decorations
19)
20from calibre.ebooks.epub.cfi.parse import cfi_sort_key
21from calibre.gui2 import error_dialog, is_dark_theme, safe_open_url
22from calibre.gui2.dialogs.confirm_delete import confirm
23from calibre.gui2.library.annotations import (
24    Details, Export as ExportBase, render_highlight_as_text, render_notes
25)
26from calibre.gui2.viewer import link_prefix_for_location_links
27from calibre.gui2.viewer.config import vprefs
28from calibre.gui2.viewer.search import SearchInput
29from calibre.gui2.viewer.shortcuts import get_shortcut_for, index_to_key_sequence
30from calibre.gui2.widgets2 import Dialog
31from calibre_extensions.progress_indicator import set_no_activate_on_click
32
33decoration_cache = {}
34
35
36@lru_cache(maxsize=8)
37def wavy_path(width, height, y_origin):
38    half_height = height / 2
39    path = QPainterPath()
40    pi2 = math.pi * 2
41    num = 100
42    num_waves = 4
43    wav_limit = num // num_waves
44    sin = math.sin
45    path.reserve(num)
46    for i in range(num):
47        x = width * i / num
48        rads = pi2 * (i % wav_limit) / wav_limit
49        factor = sin(rads)
50        y = y_origin + factor * half_height
51        path.lineTo(x, y) if i else path.moveTo(x, y)
52    return path
53
54
55def decoration_for_style(palette, style, icon_size, device_pixel_ratio, is_dark):
56    style_key = (is_dark, icon_size, device_pixel_ratio, tuple((k, style[k]) for k in sorted(style)))
57    sentinel = object()
58    ans = decoration_cache.get(style_key, sentinel)
59    if ans is not sentinel:
60        return ans
61    ans = None
62    kind = style.get('kind')
63    if kind == 'color':
64        key = 'dark' if is_dark else 'light'
65        val = style.get(key)
66        if val is None:
67            which = style.get('which')
68            val = (builtin_colors_dark if is_dark else builtin_colors_light).get(which)
69        if val is None:
70            val = style.get('background-color')
71        if val is not None:
72            ans = QColor(val)
73    elif kind == 'decoration':
74        which = style.get('which')
75        if which is not None:
76            q = builtin_decorations.get(which)
77            if q is not None:
78                style = q
79        sz = int(math.ceil(icon_size * device_pixel_ratio))
80        canvas = QImage(sz, sz, QImage.Format.Format_ARGB32)
81        canvas.fill(Qt.GlobalColor.transparent)
82        canvas.setDevicePixelRatio(device_pixel_ratio)
83        p = QPainter(canvas)
84        p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
85        p.setPen(palette.color(QPalette.ColorRole.WindowText))
86        irect = QRect(0, 0, icon_size, icon_size)
87        adjust = -2
88        text_rect = p.drawText(irect.adjusted(0, adjust, 0, adjust), Qt.AlignmentFlag.AlignHCenter| Qt.AlignmentFlag.AlignTop, 'a')
89        p.drawRect(irect)
90        fm = p.fontMetrics()
91        pen = p.pen()
92        if 'text-decoration-color' in style:
93            pen.setColor(QColor(style['text-decoration-color']))
94        lstyle = style.get('text-decoration-style') or 'solid'
95        q = {'dotted': Qt.PenStyle.DotLine, 'dashed': Qt.PenStyle.DashLine, }.get(lstyle)
96        if q is not None:
97            pen.setStyle(q)
98        lw = fm.lineWidth()
99        if lstyle == 'double':
100            lw * 2
101        pen.setWidth(fm.lineWidth())
102        q = style.get('text-decoration-line') or 'underline'
103        pos = text_rect.bottom()
104        height = irect.bottom() - pos
105        if q == 'overline':
106            pos = height
107        elif q == 'line-through':
108            pos = text_rect.center().y() - adjust - lw // 2
109        p.setPen(pen)
110        if lstyle == 'wavy':
111            p.drawPath(wavy_path(icon_size, height, pos))
112        else:
113            p.drawLine(0, pos, irect.right(), pos)
114        p.end()
115        ans = QPixmap.fromImage(canvas)
116    elif 'background-color' in style:
117        ans = QColor(style['background-color'])
118    decoration_cache[style_key] = ans
119    return ans
120
121
122class Export(ExportBase):
123    prefs = vprefs
124    pref_name = 'highlight_export_format'
125
126    def file_type_data(self):
127        return _('calibre highlights'), 'calibre_highlights'
128
129    def initial_filename(self):
130        return _('highlights')
131
132    def exported_data(self):
133        fmt = self.export_format.currentData()
134        if fmt == 'calibre_highlights':
135            return json.dumps({
136                'version': 1,
137                'type': 'calibre_highlights',
138                'highlights': self.annotations,
139            }, ensure_ascii=False, sort_keys=True, indent=2)
140        lines = []
141        as_markdown = fmt == 'md'
142        link_prefix = link_prefix_for_location_links()
143        chapter_groups = {}
144        def_chap = (_('Unknown chapter'),)
145        for a in self.annotations:
146            toc_titles = a.get('toc_family_titles', def_chap)
147            chapter_groups.setdefault(toc_titles[0], []).append(a)
148        for chapter, group in chapter_groups.items():
149            if len(chapter_groups) > 1:
150                lines.append('### ' + chapter)
151                lines.append('')
152            for hl in group:
153                render_highlight_as_text(hl, lines, as_markdown=as_markdown, link_prefix=link_prefix)
154        return '\n'.join(lines).strip()
155
156
157class Highlights(QTreeWidget):
158
159    jump_to_highlight = pyqtSignal(object)
160    current_highlight_changed = pyqtSignal(object)
161    delete_requested = pyqtSignal()
162    edit_requested = pyqtSignal()
163    edit_notes_requested = pyqtSignal()
164
165    def __init__(self, parent=None):
166        QTreeWidget.__init__(self, parent)
167        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
168        self.customContextMenuRequested.connect(self.show_context_menu)
169        self.default_decoration = QIcon(I('blank.png'))
170        self.setHeaderHidden(True)
171        self.num_of_items = 0
172        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
173        set_no_activate_on_click(self)
174        self.itemActivated.connect(self.item_activated)
175        self.currentItemChanged.connect(self.current_item_changed)
176        self.uuid_map = {}
177        self.section_font = QFont(self.font())
178        self.section_font.setItalic(True)
179
180    def show_context_menu(self, point):
181        index = self.indexAt(point)
182        h = index.data(Qt.ItemDataRole.UserRole)
183        self.context_menu = m = QMenu(self)
184        if h is not None:
185            m.addAction(QIcon(I('edit_input.png')), _('Modify this highlight'), self.edit_requested.emit)
186            m.addAction(QIcon(I('modified.png')), _('Edit notes for this highlight'), self.edit_notes_requested.emit)
187            m.addAction(QIcon(I('trash.png')), ngettext(
188                'Delete this highlight', 'Delete selected highlights', len(self.selectedItems())
189            ), self.delete_requested.emit)
190        m.addSeparator()
191        m.addAction(_('Expand all'), self.expandAll)
192        m.addAction(_('Collapse all'), self.collapseAll)
193        self.context_menu.popup(self.mapToGlobal(point))
194        return True
195
196    def current_item_changed(self, current, previous):
197        self.current_highlight_changed.emit(current.data(0, Qt.ItemDataRole.UserRole) if current is not None else None)
198
199    def load(self, highlights, preserve_state=False):
200        s = self.style()
201        expanded_chapters = set()
202        if preserve_state:
203            root = self.invisibleRootItem()
204            for i in range(root.childCount()):
205                chapter = root.child(i)
206                if chapter.isExpanded():
207                    expanded_chapters.add(chapter.data(0, Qt.ItemDataRole.DisplayRole))
208        icon_size = s.pixelMetric(QStyle.PixelMetric.PM_SmallIconSize, None, self)
209        dpr = self.devicePixelRatioF()
210        is_dark = is_dark_theme()
211        self.clear()
212        self.uuid_map = {}
213        highlights = (h for h in highlights if not h.get('removed') and h.get('highlighted_text'))
214        section_map = defaultdict(list)
215        section_tt_map = {}
216        for h in self.sorted_highlights(highlights):
217            tfam = h.get('toc_family_titles') or ()
218            if tfam:
219                tsec = tfam[0]
220                lsec = tfam[-1]
221            else:
222                tsec = h.get('top_level_section_title')
223                lsec = h.get('lowest_level_section_title')
224            sec = lsec or tsec or _('Unknown')
225            if len(tfam) > 1:
226                lines = []
227                for i, node in enumerate(tfam):
228                    lines.append('\xa0\xa0' * i + '➤ ' + node)
229                tt = ngettext('Table of Contents section:', 'Table of Contents sections:', len(lines))
230                tt += '\n' + '\n'.join(lines)
231                section_tt_map[sec] = tt
232            section_map[sec].append(h)
233        for secnum, (sec, items) in enumerate(section_map.items()):
234            section = QTreeWidgetItem([sec], 1)
235            section.setFlags(Qt.ItemFlag.ItemIsEnabled)
236            section.setFont(0, self.section_font)
237            tt = section_tt_map.get(sec)
238            if tt:
239                section.setToolTip(0, tt)
240            self.addTopLevelItem(section)
241            section.setExpanded(not preserve_state or sec in expanded_chapters)
242            for itemnum, h in enumerate(items):
243                txt = h.get('highlighted_text')
244                txt = txt.replace('\n', ' ')
245                if h.get('notes'):
246                    txt = '•' + txt
247                if len(txt) > 100:
248                    txt = txt[:100] + '…'
249                item = QTreeWidgetItem(section, [txt], 2)
250                item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren)
251                item.setData(0, Qt.ItemDataRole.UserRole, h)
252                try:
253                    dec = decoration_for_style(self.palette(), h.get('style') or {}, icon_size, dpr, is_dark)
254                except Exception:
255                    import traceback
256                    traceback.print_exc()
257                    dec = None
258                if dec is None:
259                    dec = self.default_decoration
260                item.setData(0, Qt.ItemDataRole.DecorationRole, dec)
261                self.uuid_map[h['uuid']] = secnum, itemnum
262                self.num_of_items += 1
263
264    def sorted_highlights(self, highlights):
265        defval = 999999999999999, cfi_sort_key('/99999999')
266
267        def cfi_key(h):
268            cfi = h.get('start_cfi')
269            return (h.get('spine_index') or defval[0], cfi_sort_key(cfi)) if cfi else defval
270
271        return sorted(highlights, key=cfi_key)
272
273    def refresh(self, highlights):
274        h = self.current_highlight
275        self.load(highlights, preserve_state=True)
276        if h is not None:
277            idx = self.uuid_map.get(h['uuid'])
278            if idx is not None:
279                sec_idx, item_idx = idx
280                self.set_current_row(sec_idx, item_idx)
281
282    def iteritems(self):
283        root = self.invisibleRootItem()
284        for i in range(root.childCount()):
285            sec = root.child(i)
286            for k in range(sec.childCount()):
287                yield sec.child(k)
288
289    def count(self):
290        return self.num_of_items
291
292    def find_query(self, query):
293        pat = query.regex
294        items = tuple(self.iteritems())
295        count = len(items)
296        cr = -1
297        ch = self.current_highlight
298        if ch:
299            q = ch['uuid']
300            for i, item in enumerate(items):
301                h = item.data(0, Qt.ItemDataRole.UserRole)
302                if h['uuid'] == q:
303                    cr = i
304        if query.backwards:
305            if cr < 0:
306                cr = count
307            indices = chain(range(cr - 1, -1, -1), range(count - 1, cr, -1))
308        else:
309            if cr < 0:
310                cr = -1
311            indices = chain(range(cr + 1, count), range(0, cr + 1))
312        for i in indices:
313            h = items[i].data(0, Qt.ItemDataRole.UserRole)
314            if pat.search(h['highlighted_text']) is not None or pat.search(h.get('notes') or '') is not None:
315                self.set_current_row(*self.uuid_map[h['uuid']])
316                return True
317        return False
318
319    def find_annot_id(self, annot_id):
320        q = self.uuid_map.get(annot_id)
321        if q is not None:
322            self.set_current_row(*q)
323            return True
324        return False
325
326    def set_current_row(self, sec_idx, item_idx):
327        sec = self.topLevelItem(sec_idx)
328        if sec is not None:
329            item = sec.child(item_idx)
330            if item is not None:
331                self.setCurrentItem(item, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect)
332                return True
333        return False
334
335    def item_activated(self, item):
336        h = item.data(0, Qt.ItemDataRole.UserRole)
337        if h is not None:
338            self.jump_to_highlight.emit(h)
339
340    @property
341    def current_highlight(self):
342        i = self.currentItem()
343        if i is not None:
344            return i.data(0, Qt.ItemDataRole.UserRole)
345
346    @property
347    def all_highlights(self):
348        for item in self.iteritems():
349            yield item.data(0, Qt.ItemDataRole.UserRole)
350
351    @property
352    def selected_highlights(self):
353        for item in self.selectedItems():
354            yield item.data(0, Qt.ItemDataRole.UserRole)
355
356    def keyPressEvent(self, ev):
357        if ev.matches(QKeySequence.StandardKey.Delete):
358            self.delete_requested.emit()
359            ev.accept()
360            return
361        if ev.key() == Qt.Key.Key_F2:
362            self.edit_requested.emit()
363            ev.accept()
364            return
365        return super().keyPressEvent(ev)
366
367
368class NotesEditDialog(Dialog):
369
370    def __init__(self, notes, parent=None):
371        self.initial_notes = notes
372        Dialog.__init__(self, name='edit-notes-highlight', title=_('Edit notes'), parent=parent)
373
374    def setup_ui(self):
375        l = QVBoxLayout(self)
376        self.qte = qte = QTextEdit(self)
377        qte.setMinimumHeight(400)
378        qte.setMinimumWidth(600)
379        if self.initial_notes:
380            qte.setPlainText(self.initial_notes)
381            qte.moveCursor(QTextCursor.MoveOperation.End)
382        l.addWidget(qte)
383        l.addWidget(self.bb)
384
385    @property
386    def notes(self):
387        return self.qte.toPlainText().rstrip()
388
389
390class NotesDisplay(Details):
391
392    notes_edited = pyqtSignal(object)
393
394    def __init__(self, parent=None):
395        Details.__init__(self, parent)
396        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
397        self.anchorClicked.connect(self.anchor_clicked)
398        self.current_notes = ''
399
400    def show_notes(self, text=''):
401        text = (text or '').strip()
402        self.setVisible(bool(text))
403        self.current_notes = text
404        html = '\n'.join(render_notes(text))
405        self.setHtml('<div><a href="edit://moo">{}</a></div>{}'.format(_('Edit notes'), html))
406        self.document().setDefaultStyleSheet('a[href] { text-decoration: none }')
407        h = self.document().size().height() + 2
408        self.setMaximumHeight(h)
409
410    def anchor_clicked(self, qurl):
411        if qurl.scheme() == 'edit':
412            self.edit_notes()
413        else:
414            safe_open_url(qurl)
415
416    def edit_notes(self):
417        current_text = self.current_notes
418        d = NotesEditDialog(current_text, self)
419        if d.exec() == QDialog.DialogCode.Accepted and d.notes != current_text:
420            self.notes_edited.emit(d.notes)
421
422
423class HighlightsPanel(QWidget):
424
425    jump_to_cfi = pyqtSignal(object)
426    request_highlight_action = pyqtSignal(object, object)
427    web_action = pyqtSignal(object, object)
428    toggle_requested = pyqtSignal()
429    notes_edited_signal = pyqtSignal(object, object)
430
431    def __init__(self, parent=None):
432        QWidget.__init__(self, parent)
433        self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
434        self.l = l = QVBoxLayout(self)
435        l.setContentsMargins(0, 0, 0, 0)
436        self.search_input = si = SearchInput(self, 'highlights-search')
437        si.do_search.connect(self.search_requested)
438        l.addWidget(si)
439
440        la = QLabel(_('Double click to jump to an entry'))
441        la.setWordWrap(True)
442        l.addWidget(la)
443
444        self.highlights = h = Highlights(self)
445        l.addWidget(h)
446        h.jump_to_highlight.connect(self.jump_to_highlight)
447        h.delete_requested.connect(self.remove_highlight)
448        h.edit_requested.connect(self.edit_highlight)
449        h.edit_notes_requested.connect(self.edit_notes)
450        h.current_highlight_changed.connect(self.current_highlight_changed)
451        self.load = h.load
452        self.refresh = h.refresh
453
454        self.h = h = QHBoxLayout()
455
456        def button(icon, text, tt, target):
457            b = QPushButton(QIcon(I(icon)), text, self)
458            b.setToolTip(tt)
459            b.setFocusPolicy(Qt.FocusPolicy.NoFocus)
460            b.clicked.connect(target)
461            return b
462
463        self.edit_button = button('edit_input.png', _('Modify'), _('Modify the selected highlight'), self.edit_highlight)
464        self.remove_button = button('trash.png', _('Delete'), _('Delete the selected highlights'), self.remove_highlight)
465        self.export_button = button('save.png', _('Export'), _('Export all highlights'), self.export)
466        h.addWidget(self.edit_button), h.addWidget(self.remove_button), h.addWidget(self.export_button)
467
468        self.notes_display = nd = NotesDisplay(self)
469        nd.notes_edited.connect(self.notes_edited)
470        l.addWidget(nd)
471        nd.setVisible(False)
472        l.addLayout(h)
473
474    def notes_edited(self, text):
475        h = self.highlights.current_highlight
476        if h is not None:
477            h['notes'] = text
478            self.web_action.emit('set-notes-in-highlight', h)
479            self.notes_edited_signal.emit(h['uuid'], text)
480
481    def set_tooltips(self, rmap):
482        a = rmap.get('create_annotation')
483        if a:
484
485            def as_text(idx):
486                return index_to_key_sequence(idx).toString(QKeySequence.SequenceFormat.NativeText)
487
488            tt = self.add_button.toolTip().partition('[')[0].strip()
489            keys = sorted(filter(None, map(as_text, a)))
490            if keys:
491                self.add_button.setToolTip('{} [{}]'.format(tt, ', '.join(keys)))
492
493    def search_requested(self, query):
494        if not self.highlights.find_query(query):
495            error_dialog(self, _('No matches'), _(
496                'No highlights match the search: {}').format(query.text), show=True)
497
498    def focus(self):
499        self.highlights.setFocus(Qt.FocusReason.OtherFocusReason)
500
501    def jump_to_highlight(self, highlight):
502        self.request_highlight_action.emit(highlight['uuid'], 'goto')
503
504    def current_highlight_changed(self, highlight):
505        nd = self.notes_display
506        if highlight is None or not highlight.get('notes'):
507            nd.show_notes()
508        else:
509            nd.show_notes(highlight['notes'])
510
511    def no_selected_highlight(self):
512        error_dialog(self, _('No selected highlight'), _(
513            'No highlight is currently selected'), show=True)
514
515    def edit_highlight(self):
516        h = self.highlights.current_highlight
517        if h is None:
518            return self.no_selected_highlight()
519        self.request_highlight_action.emit(h['uuid'], 'edit')
520
521    def edit_notes(self):
522        self.notes_display.edit_notes()
523
524    def remove_highlight(self):
525        highlights = tuple(self.highlights.selected_highlights)
526        if not highlights:
527            return self.no_selected_highlight()
528        if confirm(
529            ngettext(
530            'Are you sure you want to delete this highlight permanently?',
531            'Are you sure you want to delete all {} highlights permanently?',
532            len(highlights)).format(len(highlights)),
533            'delete-highlight-from-viewer', parent=self, config_set=vprefs
534        ):
535            for h in highlights:
536                self.request_highlight_action.emit(h['uuid'], 'delete')
537
538    def export(self):
539        hl = list(self.highlights.all_highlights)
540        if not hl:
541            return error_dialog(self, _('No highlights'), _('This book has no highlights to export'), show=True)
542        Export(hl, self).exec()
543
544    def selected_text_changed(self, text, annot_id):
545        if annot_id:
546            self.highlights.find_annot_id(annot_id)
547
548    def keyPressEvent(self, ev):
549        sc = get_shortcut_for(self, ev)
550        if sc == 'toggle_highlights' or ev.key() == Qt.Key.Key_Escape:
551            self.toggle_requested.emit()
552        return super().keyPressEvent(ev)
553