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> </div> 868 <div> </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