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