%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/library/ |
Current File : //lib/calibre/calibre/gui2/library/annotations.py |
#!/usr/bin/env python3 # License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net> import codecs import json import os import re from functools import lru_cache, partial from qt.core import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, QCursor, QDateTime, QDialog, QDialogButtonBox, QFont, QFormLayout, QFrame, QHBoxLayout, QIcon, QKeySequence, QLabel, QMenu, QPalette, QPlainTextEdit, QSize, QSplitter, Qt, QTextBrowser, QTimer, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, pyqtSignal ) from urllib.parse import quote from calibre import prepare_string_for_xml from calibre.constants import ( builtin_colors_dark, builtin_colors_light, builtin_decorations ) from calibre.db.backend import FTSQueryError from calibre.ebooks.metadata import authors_to_string, fmt_sidx from calibre.gui2 import ( Application, choose_save_file, config, error_dialog, gprefs, is_dark_theme, safe_open_url ) from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.viewer.widgets import ResultsDelegate, SearchBox from calibre.gui2.widgets2 import Dialog, RightClickButton # rendering {{{ def render_highlight_as_text(hl, lines, as_markdown=False, link_prefix=None): lines.append(hl['highlighted_text']) date = QDateTime.fromString(hl['timestamp'], Qt.DateFormat.ISODate).toLocalTime().toString(Qt.DateFormat.SystemLocaleShortDate) if as_markdown and link_prefix: cfi = hl['start_cfi'] spine_index = (1 + hl['spine_index']) * 2 link = (link_prefix + quote(f'epubcfi(/{spine_index}{cfi})')).replace(')', '%29') date = f'[{date}]({link})' lines.append(date) notes = hl.get('notes') if notes: lines.append('') lines.append(notes) lines.append('') if as_markdown: lines.append('-' * 20) else: lines.append('───') lines.append('') def render_bookmark_as_text(b, lines, as_markdown=False, link_prefix=None): lines.append(b['title']) date = QDateTime.fromString(b['timestamp'], Qt.DateFormat.ISODate).toLocalTime().toString(Qt.DateFormat.SystemLocaleShortDate) if as_markdown and link_prefix and b['pos_type'] == 'epubcfi': link = (link_prefix + quote(b['pos'])).replace(')', '%29') date = f'[{date}]({link})' lines.append(date) lines.append('') if as_markdown: lines.append('-' * 20) else: lines.append('───') lines.append('') url_prefixes = 'http', 'https' url_delimiters = ( '\x00-\x09\x0b-\x20\x7f-\xa0\xad\u0600-\u0605\u061c\u06dd\u070f\u08e2\u1680\u180e\u2000-\u200f\u2028-\u202f' '\u205f-\u2064\u2066-\u206f\u3000\ud800-\uf8ff\ufeff\ufff9-\ufffb\U000110bd\U000110cd\U00013430-\U00013438' '\U0001bca0-\U0001bca3\U0001d173-\U0001d17a\U000e0001\U000e0020-\U000e007f\U000f0000-\U000ffffd\U00100000-\U0010fffd' ) url_pattern = r'\b(?:{})://[^{}]{{3,}}'.format('|'.join(url_prefixes), url_delimiters) @lru_cache(maxsize=2) def url_pat(): return re.compile(url_pattern, flags=re.I) closing_bracket_map = {'(': ')', '[': ']', '{': '}', '<': '>', '*': '*', '"': '"', "'": "'"} def url(text: str, s: int, e: int): while text[e - 1] in '.,?!' and e > 1: # remove trailing punctuation e -= 1 # truncate url at closing bracket/quote if s > 0 and e <= len(text) and text[s-1] in closing_bracket_map: q = closing_bracket_map[text[s-1]] idx = text.find(q, s) if idx > s: e = idx return s, e def render_note_line(line): urls = [] for m in url_pat().finditer(line): s, e = url(line, m.start(), m.end()) urls.append((s, e)) if not urls: yield prepare_string_for_xml(line) return pos = 0 for (s, e) in urls: if s > pos: yield prepare_string_for_xml(line[pos:s]) yield '<a href="{0}">{0}</a>'.format(prepare_string_for_xml(line[s:e], True)) if urls[-1][1] < len(line): yield prepare_string_for_xml(line[urls[-1][1]:]) def render_notes(notes, tag='p'): current_lines = [] for line in notes.splitlines(): if line: current_lines.append(''.join(render_note_line(line))) else: if current_lines: yield '<{0}>{1}</{0}>'.format(tag, '\n'.join(current_lines)) current_lines = [] if current_lines: yield '<{0}>{1}</{0}>'.format(tag, '\n'.join(current_lines)) def friendly_username(user_type, user): key = user_type, user if key == ('web', '*'): return _('Anonymous Content server user') if key == ('local', 'viewer'): return _('Local E-book viewer user') return user def annotation_title(atype, singular=False): if singular: return {'bookmark': _('Bookmark'), 'highlight': _('Highlight')}.get(atype, atype) return {'bookmark': _('Bookmarks'), 'highlight': _('Highlights')}.get(atype, atype) class AnnotsResultsDelegate(ResultsDelegate): add_ellipsis = False emphasize_text = False def result_data(self, result): if not isinstance(result, dict): return None, None, None, None, None full_text = result['text'].replace('\x1f', ' ') parts = full_text.split('\x1d', 2) before = after = '' if len(parts) > 2: before, text = parts[:2] after = parts[2].replace('\x1d', '') elif len(parts) == 2: before, text = parts else: text = parts[0] return False, before, text, after, bool(result.get('annotation', {}).get('notes')) # }}} def sorted_items(items): from calibre.ebooks.epub.cfi.parse import cfi_sort_key def_spine = 999999999 defval = cfi_sort_key(f'/{def_spine}') def sort_key(x): x = x['annotation'] atype = x['type'] if atype == 'highlight': cfi = x.get('start_cfi') if cfi: spine_idx = x.get('spine_index', def_spine) cfi = f'/{spine_idx}{cfi}' return cfi_sort_key(cfi) elif atype == 'bookmark': if x.get('pos_type') == 'epubcfi': return cfi_sort_key(x['pos'], only_path=False) return defval return sorted(items, key=sort_key) def css_for_highlight_style(style): is_dark = is_dark_theme() kind = style.get('kind') ans = '' if kind == 'color': key = 'dark' if is_dark else 'light' val = style.get(key) if val is None: which = style.get('which') val = (builtin_colors_dark if is_dark else builtin_colors_light).get(which) if val is None: val = style.get('background-color') if val is not None: ans = f'background-color: {val}' elif 'background-color' in style: ans = 'background-color: ' + style['background-color'] if 'color' in style: ans += '; color: ' + style["color"] elif kind == 'decoration': which = style.get('which') if which is not None: q = builtin_decorations.get(which) if q is not None: ans = q else: ans = '; '.join(f'{k}: {v}' for k, v in style.items()) return ans class Export(Dialog): # {{{ prefs = gprefs pref_name = 'annots_export_format' def __init__(self, annots, parent=None): self.annotations = annots super().__init__(name='export-annotations', title=_('Export {} annotations').format(len(annots)), parent=parent) def file_type_data(self): return _('calibre annotation collection'), 'calibre_annotation_collection' def initial_filename(self): return _('annotations') def setup_ui(self): self.l = l = QFormLayout(self) self.export_format = ef = QComboBox(self) ef.addItem(_('Plain text'), 'txt') ef.addItem(_('Markdown'), 'md') ef.addItem(*self.file_type_data()) idx = ef.findData(self.prefs[self.pref_name]) if idx > -1: ef.setCurrentIndex(idx) ef.currentIndexChanged.connect(self.save_format_pref) l.addRow(_('Format to export in:'), ef) l.addRow(self.bb) self.bb.clear() self.bb.addButton(QDialogButtonBox.StandardButton.Cancel) b = self.bb.addButton(_('Copy to clipboard'), QDialogButtonBox.ButtonRole.ActionRole) b.clicked.connect(self.copy_to_clipboard) b.setIcon(QIcon(I('edit-copy.png'))) b = self.bb.addButton(_('Save to file'), QDialogButtonBox.ButtonRole.ActionRole) b.clicked.connect(self.save_to_file) b.setIcon(QIcon(I('save.png'))) def save_format_pref(self): self.prefs[self.pref_name] = self.export_format.currentData() def copy_to_clipboard(self): QApplication.instance().clipboard().setText(self.exported_data()) self.accept() def save_to_file(self): filters = [(self.export_format.currentText(), [self.export_format.currentData()])] path = choose_save_file( self, 'annots-export-save', _('File for exports'), filters=filters, initial_filename=self.initial_filename() + '.' + filters[0][1][0]) if path: data = self.exported_data().encode('utf-8') with open(path, 'wb') as f: f.write(codecs.BOM_UTF8) f.write(data) self.accept() def exported_data(self): fmt = self.export_format.currentData() if fmt == 'calibre_annotation_collection': return json.dumps({ 'version': 1, 'type': 'calibre_annotation_collection', 'annotations': self.annotations, }, ensure_ascii=False, sort_keys=True, indent=2) lines = [] db = current_db() bid_groups = {} as_markdown = fmt == 'md' library_id = getattr(db, 'server_library_id', None) if library_id: library_id = '_hex_-' + library_id.encode('utf-8').hex() for a in self.annotations: bid_groups.setdefault(a['book_id'], []).append(a) for book_id, group in bid_groups.items(): chapter_groups = {} def_chap = (_('Unknown chapter'),) for a in group: toc_titles = a.get('toc_family_titles', def_chap) chapter_groups.setdefault(toc_titles[0], []).append(a) lines.append('## ' + db.field_for('title', book_id)) lines.append('') for chapter, group in chapter_groups.items(): if len(chapter_groups) > 1: lines.append('### ' + chapter) lines.append('') for a in group: atype = a['type'] if library_id: link_prefix = f'calibre://view-book/{library_id}/{book_id}/{a["format"]}?open_at=' else: link_prefix = None if atype == 'highlight': render_highlight_as_text(a, lines, as_markdown=as_markdown, link_prefix=link_prefix) elif atype == 'bookmark': render_bookmark_as_text(a, lines, as_markdown=as_markdown, link_prefix=link_prefix) lines.append('') return '\n'.join(lines).strip() # }}} def current_db(): from calibre.gui2.ui import get_gui return (getattr(current_db, 'ans', None) or get_gui().current_db).new_api class BusyCursor: def __enter__(self): QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) def __exit__(self, *args): QApplication.restoreOverrideCursor() class ResultsList(QTreeWidget): current_result_changed = pyqtSignal(object) open_annotation = pyqtSignal(object, object, object) show_book = pyqtSignal(object, object) delete_requested = pyqtSignal() export_requested = pyqtSignal() edit_annotation = pyqtSignal(object, object) def __init__(self, parent): QTreeWidget.__init__(self, parent) self.setHeaderHidden(True) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) self.delegate = AnnotsResultsDelegate(self) self.setItemDelegate(self.delegate) self.section_font = QFont(self.font()) self.itemDoubleClicked.connect(self.item_activated) self.section_font.setItalic(True) self.currentItemChanged.connect(self.current_item_changed) self.number_of_results = 0 self.item_map = [] def show_context_menu(self, pos): item = self.itemAt(pos) if item is not None: result = item.data(0, Qt.ItemDataRole.UserRole) else: result = None items = self.selectedItems() m = QMenu(self) if isinstance(result, dict): m.addAction(QIcon.ic('viewer.png'), _('Open in viewer'), partial(self.item_activated, item)) m.addAction(QIcon.ic('lt.png'), _('Show in calibre'), partial(self.show_in_calibre, item)) if result.get('annotation', {}).get('type') == 'highlight': m.addAction(QIcon.ic('modified.png'), _('Edit notes'), partial(self.edit_notes, item)) if items: m.addSeparator() m.addAction(QIcon.ic('save.png'), ngettext('Export selected item', 'Export {} selected items', len(items)).format(len(items)), self.export_requested.emit) m.addAction(QIcon.ic('trash.png'), ngettext('Delete selected item', 'Delete {} selected items', len(items)).format(len(items)), self.delete_requested.emit) m.addSeparator() m.addAction(QIcon.ic('plus.png'), _('Expand all'), self.expandAll) m.addAction(QIcon.ic('minus.png'), _('Collapse all'), self.collapseAll) m.exec(self.mapToGlobal(pos)) def edit_notes(self, item): r = item.data(0, Qt.ItemDataRole.UserRole) if isinstance(r, dict): self.edit_annotation.emit(r['id'], r['annotation']) def show_in_calibre(self, item): r = item.data(0, Qt.ItemDataRole.UserRole) if isinstance(r, dict): self.show_book.emit(r['book_id'], r['format']) def item_activated(self, item): r = item.data(0, Qt.ItemDataRole.UserRole) if isinstance(r, dict): self.open_annotation.emit(r['book_id'], r['format'], r['annotation']) def set_results(self, results, emphasize_text): self.clear() self.delegate.emphasize_text = emphasize_text self.number_of_results = 0 self.item_map = [] book_id_map = {} db = current_db() for result in results: book_id = result['book_id'] if book_id not in book_id_map: book_id_map[book_id] = {'title': db.field_for('title', book_id), 'matches': []} book_id_map[book_id]['matches'].append(result) for book_id, entry in book_id_map.items(): section = QTreeWidgetItem([entry['title']], 1) section.setFlags(Qt.ItemFlag.ItemIsEnabled) section.setFont(0, self.section_font) section.setData(0, Qt.ItemDataRole.UserRole, book_id) self.addTopLevelItem(section) section.setExpanded(True) for result in sorted_items(entry['matches']): item = QTreeWidgetItem(section, [' '], 2) self.item_map.append(item) item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) item.setData(0, Qt.ItemDataRole.UserRole, result) item.setData(0, Qt.ItemDataRole.UserRole + 1, self.number_of_results) self.number_of_results += 1 if self.item_map: self.setCurrentItem(self.item_map[0]) def current_item_changed(self, current, previous): if current is not None: r = current.data(0, Qt.ItemDataRole.UserRole) if isinstance(r, dict): self.current_result_changed.emit(r) else: self.current_result_changed.emit(None) def show_next(self, backwards=False): item = self.currentItem() if item is None: return i = int(item.data(0, Qt.ItemDataRole.UserRole + 1)) i += -1 if backwards else 1 i %= self.number_of_results self.setCurrentItem(self.item_map[i]) @property def selected_annot_ids(self): for item in self.selectedItems(): yield item.data(0, Qt.ItemDataRole.UserRole)['id'] @property def selected_annotations(self): for item in self.selectedItems(): x = item.data(0, Qt.ItemDataRole.UserRole) ans = x['annotation'].copy() for key in ('book_id', 'format'): ans[key] = x[key] yield ans def keyPressEvent(self, ev): if ev.matches(QKeySequence.StandardKey.Delete): self.delete_requested.emit() ev.accept() return if ev.key() == Qt.Key.Key_F2: item = self.currentItem() if item: self.edit_notes(item) ev.accept() return return QTreeWidget.keyPressEvent(self, ev) @property def tree_state(self): ans = {'closed': set()} item = self.currentItem() if item is not None: ans['current'] = item.data(0, Qt.ItemDataRole.UserRole) for item in (self.topLevelItem(i) for i in range(self.topLevelItemCount())): if not item.isExpanded(): ans['closed'].add(item.data(0, Qt.ItemDataRole.UserRole)) return ans @tree_state.setter def tree_state(self, state): closed = state['closed'] for item in (self.topLevelItem(i) for i in range(self.topLevelItemCount())): if item.data(0, Qt.ItemDataRole.UserRole) in closed: item.setExpanded(False) cur = state.get('current') if cur is not None: for item in self.item_map: if item.data(0, Qt.ItemDataRole.UserRole) == cur: self.setCurrentItem(item) break class Restrictions(QWidget): restrictions_changed = pyqtSignal() def __init__(self, parent): self.restrict_to_book_ids = frozenset() QWidget.__init__(self, parent) v = QVBoxLayout(self) v.setContentsMargins(0, 0, 0, 0) h = QHBoxLayout() h.setContentsMargins(0, 0, 0, 0) v.addLayout(h) self.rla = QLabel(_('Restrict to') + ': ') h.addWidget(self.rla) la = QLabel(_('Type:')) h.addWidget(la) self.types_box = tb = QComboBox(self) tb.la = la tb.currentIndexChanged.connect(self.restrictions_changed) connect_lambda(tb.currentIndexChanged, tb, lambda tb: gprefs.set('browse_annots_restrict_to_type', tb.currentData())) la.setBuddy(tb) tb.setToolTip(_('Show only annotations of the specified type')) h.addWidget(tb) la = QLabel(_('User:')) h.addWidget(la) self.user_box = ub = QComboBox(self) ub.la = la ub.currentIndexChanged.connect(self.restrictions_changed) connect_lambda(ub.currentIndexChanged, ub, lambda ub: gprefs.set('browse_annots_restrict_to_user', ub.currentData())) la.setBuddy(ub) ub.setToolTip(_('Show only annotations created by the specified user')) h.addWidget(ub) h.addStretch(10) h = QHBoxLayout() self.restrict_to_books_cb = cb = QCheckBox('') self.update_book_restrictions_text() cb.setToolTip(_('Only show annotations from books that have been selected in the calibre library')) cb.setChecked(bool(gprefs.get('show_annots_from_selected_books_only', False))) cb.stateChanged.connect(self.show_only_selected_changed) h.addWidget(cb) v.addLayout(h) def update_book_restrictions_text(self): if not self.restrict_to_book_ids: t = _('&Show results from only selected books') else: t = ngettext( '&Show results from only the selected book', '&Show results from only the {} selected books', len(self.restrict_to_book_ids)).format(len(self.restrict_to_book_ids)) self.restrict_to_books_cb.setText(t) def show_only_selected_changed(self): self.restrictions_changed.emit() gprefs['show_annots_from_selected_books_only'] = bool(self.restrict_to_books_cb.isChecked()) def selection_changed(self, restrict_to_book_ids): self.restrict_to_book_ids = frozenset(restrict_to_book_ids or set()) self.update_book_restrictions_text() if self.restrict_to_books_cb.isChecked(): self.restrictions_changed.emit() @property def effective_restrict_to_book_ids(self): return (self.restrict_to_book_ids or None) if self.restrict_to_books_cb.isChecked() else None def re_initialize(self, db, restrict_to_book_ids=None): self.restrict_to_book_ids = frozenset(restrict_to_book_ids or set()) self.update_book_restrictions_text() tb = self.types_box before = tb.currentData() if not before: before = gprefs['browse_annots_restrict_to_type'] tb.blockSignals(True) tb.clear() tb.addItem(' ', ' ') for atype in db.all_annotation_types(): tb.addItem(annotation_title(atype), atype) if before: row = tb.findData(before) if row > -1: tb.setCurrentIndex(row) tb.blockSignals(False) tb_is_visible = tb.count() > 2 tb.setVisible(tb_is_visible), tb.la.setVisible(tb_is_visible) tb = self.user_box before = tb.currentData() if not before: before = gprefs['browse_annots_restrict_to_user'] tb.blockSignals(True) tb.clear() tb.addItem(' ', ' ') for user_type, user in db.all_annotation_users(): display_name = friendly_username(user_type, user) tb.addItem(display_name, f'{user_type}:{user}') if before: row = tb.findData(before) if row > -1: tb.setCurrentIndex(row) tb.blockSignals(False) ub_is_visible = tb.count() > 2 tb.setVisible(ub_is_visible), tb.la.setVisible(ub_is_visible) self.rla.setVisible(tb_is_visible or ub_is_visible) self.setVisible(True) class BrowsePanel(QWidget): current_result_changed = pyqtSignal(object) open_annotation = pyqtSignal(object, object, object) show_book = pyqtSignal(object, object) delete_requested = pyqtSignal() export_requested = pyqtSignal() edit_annotation = pyqtSignal(object, object) def __init__(self, parent): QWidget.__init__(self, parent) self.use_stemmer = parent.use_stemmer self.current_query = None l = QVBoxLayout(self) h = QHBoxLayout() l.addLayout(h) self.search_box = sb = SearchBox(self) sb.initialize('library-annotations-browser-search-box') sb.cleared.connect(self.cleared, type=Qt.ConnectionType.QueuedConnection) sb.lineEdit().returnPressed.connect(self.show_next) sb.lineEdit().setPlaceholderText(_('Enter words to search for')) h.addWidget(sb) self.next_button = nb = QToolButton(self) h.addWidget(nb) nb.setFocusPolicy(Qt.FocusPolicy.NoFocus) nb.setIcon(QIcon(I('arrow-down.png'))) nb.clicked.connect(self.show_next) nb.setToolTip(_('Find next match')) self.prev_button = nb = QToolButton(self) h.addWidget(nb) nb.setFocusPolicy(Qt.FocusPolicy.NoFocus) nb.setIcon(QIcon(I('arrow-up.png'))) nb.clicked.connect(self.show_previous) nb.setToolTip(_('Find previous match')) self.restrictions = rs = Restrictions(self) rs.restrictions_changed.connect(self.effective_query_changed) self.use_stemmer.stateChanged.connect(self.effective_query_changed) l.addWidget(rs) self.results_list = rl = ResultsList(self) rl.current_result_changed.connect(self.current_result_changed) rl.open_annotation.connect(self.open_annotation) rl.show_book.connect(self.show_book) rl.edit_annotation.connect(self.edit_annotation) rl.delete_requested.connect(self.delete_requested) rl.export_requested.connect(self.export_requested) l.addWidget(rl) def re_initialize(self, restrict_to_book_ids=None): db = current_db() self.search_box.setFocus(Qt.FocusReason.OtherFocusReason) self.restrictions.re_initialize(db, restrict_to_book_ids or set()) self.current_query = None self.results_list.clear() def selection_changed(self, restrict_to_book_ids): self.restrictions.selection_changed(restrict_to_book_ids) def sizeHint(self): return QSize(450, 600) @property def restrict_to_user(self): user = self.restrictions.user_box.currentData() if user and ':' in user: return user.split(':', 1) @property def effective_query(self): text = self.search_box.lineEdit().text().strip() atype = self.restrictions.types_box.currentData() return { 'fts_engine_query': text, 'annotation_type': (atype or '').strip(), 'restrict_to_user': self.restrict_to_user, 'use_stemming': bool(self.use_stemmer.isChecked()), 'restrict_to_book_ids': self.restrictions.effective_restrict_to_book_ids, } def cleared(self): self.current_query = None self.effective_query_changed() def do_find(self, backwards=False): q = self.effective_query if q == self.current_query: self.results_list.show_next(backwards) return try: with BusyCursor(): db = current_db() if not q['fts_engine_query']: results = db.all_annotations( restrict_to_user=q['restrict_to_user'], limit=4096, annotation_type=q['annotation_type'], ignore_removed=True, restrict_to_book_ids=q['restrict_to_book_ids'] or None ) else: q2 = q.copy() q2['restrict_to_book_ids'] = q.get('restrict_to_book_ids') or None results = db.search_annotations( highlight_start='\x1d', highlight_end='\x1d', snippet_size=64, ignore_removed=True, **q2 ) self.results_list.set_results(results, bool(q['fts_engine_query'])) self.current_query = q except FTSQueryError as err: return error_dialog(self, _('Invalid search expression'), '<p>' + _( 'The search expression: {0} is invalid. The search syntax used is the' ' SQLite Full text Search Query syntax, <a href="{1}">described here</a>.').format( err.query, 'https://www.sqlite.org/fts5.html#full_text_query_syntax'), det_msg=str(err), show=True) def effective_query_changed(self): self.do_find() def refresh(self): vbar = self.results_list.verticalScrollBar() if vbar: vpos = vbar.value() self.current_query = None self.do_find() vbar = self.results_list.verticalScrollBar() if vbar: vbar.setValue(vpos) def show_next(self): self.do_find() def show_previous(self): self.do_find(backwards=True) @property def selected_annot_ids(self): return self.results_list.selected_annot_ids @property def selected_annotations(self): return self.results_list.selected_annotations def save_tree_state(self): return self.results_list.tree_state def restore_tree_state(self, state): self.results_list.tree_state = state class Details(QTextBrowser): def __init__(self, parent): QTextBrowser.__init__(self, parent) self.setFrameShape(QFrame.Shape.NoFrame) self.setOpenLinks(False) self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False) palette = self.palette() palette.setBrush(QPalette.ColorRole.Base, Qt.GlobalColor.transparent) self.setPalette(palette) self.setAcceptDrops(False) class DetailsPanel(QWidget): open_annotation = pyqtSignal(object, object, object) show_book = pyqtSignal(object, object) edit_annotation = pyqtSignal(object, object) delete_annotation = pyqtSignal(object) def __init__(self, parent): QWidget.__init__(self, parent) self.current_result = None l = QVBoxLayout(self) self.text_browser = tb = Details(self) tb.anchorClicked.connect(self.link_clicked) l.addWidget(tb) self.show_result(None) def link_clicked(self, qurl): if qurl.scheme() == 'calibre': getattr(self, qurl.host())() else: safe_open_url(qurl) def open_result(self): if self.current_result is not None: r = self.current_result self.open_annotation.emit(r['book_id'], r['format'], r['annotation']) def delete_result(self): if self.current_result is not None: r = self.current_result self.delete_annotation.emit(r['id']) def edit_result(self): if self.current_result is not None: r = self.current_result self.edit_annotation.emit(r['id'], r['annotation']) def show_in_library(self): if self.current_result is not None: self.show_book.emit(self.current_result['book_id'], self.current_result['format']) def sizeHint(self): return QSize(450, 600) def set_controls_visibility(self, visible): self.text_browser.setVisible(visible) def update_notes(self, annot): if self.current_result: self.current_result['annotation'] = annot self.show_result(self.current_result) def show_result(self, result_or_none): self.current_result = r = result_or_none if r is None: self.set_controls_visibility(False) return self.set_controls_visibility(True) db = current_db() book_id = r['book_id'] title, authors = db.field_for('title', book_id), db.field_for('authors', book_id) authors = authors_to_string(authors) series, sidx = db.field_for('series', book_id), db.field_for('series_index', book_id) series_text = '' if series: use_roman_numbers = config['use_roman_numerals_for_series_number'] series_text = f'{fmt_sidx(sidx, use_roman=use_roman_numbers)} of {series}' annot = r['annotation'] atype = annotation_title(annot['type'], singular=True) book_format = r['format'] annot_text = '' a = prepare_string_for_xml highlight_css = '' paras = [] def p(text, tag='p'): paras.append('<{0}>{1}</{0}>'.format(tag, a(text))) if annot['type'] == 'bookmark': p(annot['title']) elif annot['type'] == 'highlight': for line in annot['highlighted_text'].splitlines(): p(line) notes = annot.get('notes') if notes: paras.append('<h4>{} (<a title="{}" href="calibre://edit_result">{}</a>)</h4>'.format( _('Notes'), _('Edit the notes of this highlight'), _('Edit'))) paras.extend(render_notes(notes)) else: paras.append('<p><a title="{}" href="calibre://edit_result">{}</a></p>'.format( _('Add notes to this highlight'), _('Add notes'))) if 'style' in annot: highlight_css = css_for_highlight_style(annot['style']) annot_text += '\n'.join(paras) date = QDateTime.fromString(annot['timestamp'], Qt.DateFormat.ISODate).toLocalTime().toString(Qt.DateFormat.SystemLocaleShortDate) text = ''' <style>a {{ text-decoration: none }}</style> <h2 style="text-align: center">{title} [{book_format}]</h2> <div style="text-align: center">{authors}</div> <div style="text-align: center">{series}</div> <div> </div> <div> </div> <div>{dt}: {date}</div> <div>{ut}: {user}</div> <div> <a href="calibre://open_result" title="{ovtt}" style="margin-right: 20px">{ov}</a> <span>\xa0\xa0\xa0</span> <a title="{sictt}" href="calibre://show_in_library">{sic}</a> </div> <h3 style="text-align: left; {highlight_css}">{atype}</h3> {text} '''.format( title=a(title), authors=a(authors), series=a(series_text), book_format=a(book_format), atype=a(atype), text=annot_text, dt=_('Date'), date=a(date), ut=a(_('User')), user=a(friendly_username(r['user_type'], r['user'])), highlight_css=highlight_css, ov=a(_('Open in viewer')), sic=a(_('Show in calibre')), ovtt=a(_('Open the book at this annotation in the calibre E-book viewer')), sictt=(_('Show this book in the main calibre book list')), ) self.text_browser.setHtml(text) class EditNotes(Dialog): def __init__(self, notes, parent=None): self.initial_notes = notes Dialog.__init__( self, _('Edit notes for highlight'), 'library-annotations-browser-edit-notes', parent=parent) def setup_ui(self): self.notes_edit = QPlainTextEdit(self) if self.initial_notes: self.notes_edit.setPlainText(self.initial_notes) self.notes_edit.setMinimumWidth(400) self.notes_edit.setMinimumHeight(300) l = QVBoxLayout(self) l.addWidget(self.notes_edit) l.addWidget(self.bb) @property def notes(self): return self.notes_edit.toPlainText() class AnnotationsBrowser(Dialog): open_annotation = pyqtSignal(object, object, object) show_book = pyqtSignal(object, object) def __init__(self, parent=None): self.current_restriction = None Dialog.__init__(self, _('Annotations browser'), 'library-annotations-browser', parent=parent, default_buttons=QDialogButtonBox.StandardButton.Close) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) self.setWindowIcon(QIcon(I('highlight.png'))) def do_open_annotation(self, book_id, fmt, annot): atype = annot['type'] if atype == 'bookmark': if annot['pos_type'] == 'epubcfi': self.open_annotation.emit(book_id, fmt, annot['pos']) elif atype == 'highlight': x = 2 * (annot['spine_index'] + 1) self.open_annotation.emit(book_id, fmt, 'epubcfi(/{}{})'.format(x, annot['start_cfi'])) def keyPressEvent(self, ev): if ev.key() not in (Qt.Key.Key_Enter, Qt.Key.Key_Return): return Dialog.keyPressEvent(self, ev) def setup_ui(self): self.use_stemmer = us = QCheckBox(_('&Match on related words')) us.setChecked(gprefs['browse_annots_use_stemmer']) us.setToolTip('<p>' + _( 'With this option searching for words will also match on any related words (supported in several languages). For' ' example, in the English language: <i>correction</i> matches <i>correcting</i> and <i>corrected</i> as well')) us.stateChanged.connect(lambda state: gprefs.set('browse_annots_use_stemmer', state != Qt.CheckState.Unchecked)) l = QVBoxLayout(self) self.splitter = s = QSplitter(self) l.addWidget(s) s.setChildrenCollapsible(False) self.browse_panel = bp = BrowsePanel(self) bp.open_annotation.connect(self.do_open_annotation) bp.show_book.connect(self.show_book) bp.delete_requested.connect(self.delete_selected) bp.export_requested.connect(self.export_selected) bp.edit_annotation.connect(self.edit_annotation) s.addWidget(bp) self.details_panel = dp = DetailsPanel(self) s.addWidget(dp) dp.open_annotation.connect(self.do_open_annotation) dp.show_book.connect(self.show_book) dp.delete_annotation.connect(self.delete_annotation) dp.edit_annotation.connect(self.edit_annotation) bp.current_result_changed.connect(dp.show_result) h = QHBoxLayout() l.addLayout(h) h.addWidget(us), h.addStretch(10), h.addWidget(self.bb) self.delete_button = b = self.bb.addButton(_('&Delete all selected'), QDialogButtonBox.ButtonRole.ActionRole) b.setToolTip(_('Delete the selected annotations')) b.setIcon(QIcon(I('trash.png'))) b.clicked.connect(self.delete_selected) self.export_button = b = self.bb.addButton(_('&Export all selected'), QDialogButtonBox.ButtonRole.ActionRole) b.setToolTip(_('Export the selected annotations')) b.setIcon(QIcon(I('save.png'))) b.clicked.connect(self.export_selected) self.refresh_button = b = RightClickButton(self.bb) self.bb.addButton(b, QDialogButtonBox.ButtonRole.ActionRole) b.setText(_('&Refresh')) b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) self.refresh_menu = m = QMenu(self) m.addAction(_('Rebuild search index')).triggered.connect(self.rebuild) b.setMenu(m) b.setToolTip(_('Refresh annotations in case they have been changed since this window was opened')) b.setIcon(QIcon(I('restart.png'))) b.setPopupMode(QToolButton.ToolButtonPopupMode.DelayedPopup) b.clicked.connect(self.refresh) def delete_selected(self): ids = frozenset(self.browse_panel.selected_annot_ids) if not ids: return error_dialog(self, _('No selected annotations'), _( 'No annotations have been selected'), show=True) self.delete_annotations(ids) def export_selected(self): annots = tuple(self.browse_panel.selected_annotations) if not annots: return error_dialog(self, _('No selected annotations'), _( 'No annotations have been selected'), show=True) Export(annots, self).exec() def delete_annotations(self, ids): if confirm(ngettext( 'Are you sure you want to <b>permanently</b> delete this annotation?', 'Are you sure you want to <b>permanently</b> delete these {} annotations?', len(ids)).format(len(ids)), 'delete-annotation-from-browse', parent=self ): db = current_db() db.delete_annotations(ids) self.browse_panel.refresh() def delete_annotation(self, annot_id): self.delete_annotations(frozenset({annot_id})) def edit_annotation(self, annot_id, annot): if annot.get('type') != 'highlight': return error_dialog(self, _('Cannot edit'), _( 'Editing is only supported for the notes associated with highlights'), show=True) notes = annot.get('notes') d = EditNotes(notes, self) if d.exec() == QDialog.DialogCode.Accepted: notes = d.notes if notes and notes.strip(): annot['notes'] = notes.strip() else: annot.pop('notes', None) db = current_db() db.update_annotations({annot_id: annot}) self.details_panel.update_notes(annot) def show_dialog(self, restrict_to_book_ids=None): if self.parent() is None: self.browse_panel.effective_query_changed() self.exec() else: self.reinitialize(restrict_to_book_ids) self.show() self.raise_() QTimer.singleShot(80, self.browse_panel.effective_query_changed) def selection_changed(self): if self.isVisible() and self.parent(): gui = self.parent() self.browse_panel.selection_changed(gui.library_view.get_selected_ids(as_set=True)) def reinitialize(self, restrict_to_book_ids=None): self.current_restriction = restrict_to_book_ids self.browse_panel.re_initialize(restrict_to_book_ids or set()) def refresh(self): state = self.browse_panel.save_tree_state() self.browse_panel.re_initialize(self.current_restriction) self.browse_panel.effective_query_changed() self.browse_panel.restore_tree_state(state) def rebuild(self): with BusyCursor(): current_db().reindex_annotations() self.refresh() if __name__ == '__main__': from calibre.library import db app = Application([]) current_db.ans = db(os.path.expanduser('~/test library')) br = AnnotationsBrowser() br.reinitialize() br.show_dialog() del br del app