From 76f595274b4c66a537f1656d16e4f5e11f1c1962 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 2 Jun 2021 20:40:10 +0530 Subject: [PATCH] E-book viewer: Highlights: Make URLs in the notes for highlights clickable --- src/calibre/gui2/library/annotations.py | 63 ++++++++++++++++++++++--- src/calibre/gui2/viewer/highlights.py | 13 +++-- src/pyj/read_book/highlights.pyj | 57 ++++++++++++++++++---- src/pyj/read_book/selection_bar.pyj | 2 +- 4 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/calibre/gui2/library/annotations.py b/src/calibre/gui2/library/annotations.py index a5a29890fd..0eb0de00be 100644 --- a/src/calibre/gui2/library/annotations.py +++ b/src/calibre/gui2/library/annotations.py @@ -5,7 +5,8 @@ import codecs import json import os -from functools import partial +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, @@ -16,16 +17,17 @@ from qt.core import ( from urllib.parse import quote from calibre import prepare_string_for_xml -from calibre.ebooks.metadata import authors_to_string, fmt_sidx from calibre.db.backend import FTSQueryError -from calibre.gui2 import Application, choose_save_file, config, error_dialog, gprefs +from calibre.ebooks.metadata import authors_to_string, fmt_sidx +from calibre.gui2 import ( + Application, choose_save_file, config, error_dialog, gprefs, 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 # 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) @@ -62,11 +64,57 @@ def render_bookmark_as_text(b, lines, as_markdown=False, link_prefix=None): 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 '{0}'.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(line) + current_lines.append(''.join(render_note_line(line))) else: if current_lines: yield '<{0}>{1}'.format(tag, '\n'.join(current_lines)) @@ -697,7 +745,10 @@ class DetailsPanel(QWidget): self.show_result(None) def link_clicked(self, qurl): - getattr(self, qurl.host())() + if qurl.scheme() == 'calibre': + getattr(self, qurl.host())() + else: + safe_open_url(qurl) def open_result(self): if self.current_result is not None: diff --git a/src/calibre/gui2/viewer/highlights.py b/src/calibre/gui2/viewer/highlights.py index 10eb6cb5c6..336442d35b 100644 --- a/src/calibre/gui2/viewer/highlights.py +++ b/src/calibre/gui2/viewer/highlights.py @@ -18,7 +18,7 @@ from calibre.constants import ( builtin_colors_dark, builtin_colors_light, builtin_decorations ) from calibre.ebooks.epub.cfi.parse import cfi_sort_key -from calibre.gui2 import error_dialog, is_dark_theme +from calibre.gui2 import error_dialog, is_dark_theme, safe_open_url from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.library.annotations import ( Details, Export as ExportBase, render_highlight_as_text, render_notes @@ -397,7 +397,7 @@ class NotesDisplay(Details): def __init__(self, parent=None): Details.__init__(self, parent) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) - self.anchorClicked.connect(self.edit_notes) + self.anchorClicked.connect(self.anchor_clicked) self.current_notes = '' def show_notes(self, text=''): @@ -405,10 +405,17 @@ class NotesDisplay(Details): self.setVisible(bool(text)) self.current_notes = text html = '\n'.join(render_notes(text)) - self.setHtml('
{}
{}'.format(_('Edit notes'), html)) + self.setHtml('
{}
{}'.format(_('Edit notes'), html)) + self.document().setDefaultStyleSheet('a[href] { text-decoration: none }') h = self.document().size().height() + 2 self.setMaximumHeight(h) + def anchor_clicked(self, qurl): + if qurl.scheme() == 'edit': + self.edit_notes() + else: + safe_open_url(qurl) + def edit_notes(self): current_text = self.current_notes d = NotesEditDialog(current_text, self) diff --git a/src/pyj/read_book/highlights.pyj b/src/pyj/read_book/highlights.pyj index 33f7bd7ccb..4e984cfe25 100644 --- a/src/pyj/read_book/highlights.pyj +++ b/src/pyj/read_book/highlights.pyj @@ -14,7 +14,7 @@ from modals import ( create_custom_dialog, error_dialog, get_text_dialog, question_dialog, warning_dialog ) -from read_book.globals import current_book, is_dark_theme, runtime +from read_book.globals import current_book, is_dark_theme, runtime, ui_operations from widgets import create_button ICON_SIZE_VAL = 3 @@ -712,23 +712,60 @@ add_extra_css(def(): return ans ) +url_pat = /\bhttps?:\/\/\S{3,}/ig +closing_bracket_map = {'(': ')', '[': ']', '{': '}', '<': '>', '*': '*', '"': '"', "'": "'"} +opening_brackets = Object.keys(closing_bracket_map).join('') + + +def url(text: str, s: int, e: int): + while '.,?!'.indexOf(text[e-1]) > -1 and e > 1: # remove trailing punctuation + e -= 1 + # truncate url at closing bracket/quote + if s > 0 and e <= text.length and closing_bracket_map[text[s-1]]: + q = closing_bracket_map[text[s-1]] + idx = text.indexOf(q, s) + if idx > s: + e = idx + return s, e + + +def render_notes(notes, container, make_urls_clickable): + current_para = E.p() -def render_notes(notes, container): - current_block = '' def add_para(): - nonlocal current_block - container.appendChild(E.p(current_block)) + nonlocal current_para + container.appendChild(current_para) if container.childNodes.length > 1: container.lastChild.style.marginTop = '2ex' - current_block = '' + current_para = E.p() + + def add_line(line): + url_pat.lastIndex = 0 + urls = v'[]' + + while make_urls_clickable: + m = url_pat.exec(line) + if not m: + break + urls.push(url(line, m.index, url_pat.lastIndex)) + if not urls.length: + current_para.appendChild(document.createTextNode(line)) + return + pos = 0 + for (s, e) in urls: + if s > pos: + current_para.appendChild(document.createTextNode(line[pos:s])) + current_para.appendChild(E.a(line[s:e], href='javascript: void(0)', class_='blue-link', onclick=ui_operations.open_url.bind(None, line[s:e]))) + if urls[-1][1] < line.length: + current_para.appendChild(document.createTextNode(line[urls[-1][1]:])) for line in notes.splitlines(): if not line or not line.strip(): - if current_block: + if current_para.childNodes.length: add_para() continue - current_block += line + '\n' - if current_block: + add_line(line) + if current_para.childNodes.length: add_para() return container @@ -847,7 +884,7 @@ def highlight_entry(h, onclick, view, hide_panel): E.div(class_='notes') ) if h.notes: - render_notes(h.notes, ans.querySelector('.notes')) + render_notes(h.notes, ans.querySelector('.notes'), True) ans.dataset.notes = h.notes or '' ans.dataset.title = h.highlighted_text or '' return ans diff --git a/src/pyj/read_book/selection_bar.pyj b/src/pyj/read_book/selection_bar.pyj index 77c8497a1c..833c23f082 100644 --- a/src/pyj/read_book/selection_bar.pyj +++ b/src/pyj/read_book/selection_bar.pyj @@ -366,7 +366,7 @@ class SelectionBar: c.style.maxHeight = 'min(20ex, 40vh)' else: c.style.maxHeight = '20ex' - render_notes(notes, c) + render_notes(notes, c, True) # }}} # accessors {{{