1# -*- coding: utf-8 -*- 2# 3# gPodder - A media aggregator and podcast client 4# Copyright (c) 2005-2018 The gPodder Team 5# 6# gPodder is free software; you can redistribute it and/or modify 7# it under the terms of the GNU General Public License as published by 8# the Free Software Foundation; either version 3 of the License, or 9# (at your option) any later version. 10# 11# gPodder is distributed in the hope that it will be useful, 12# but WITHOUT ANY WARRANTY; without even the implied warranty of 13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14# GNU General Public License for more details. 15# 16# You should have received a copy of the GNU General Public License 17# along with this program. If not, see <http://www.gnu.org/licenses/>. 18# 19import html 20import logging 21from urllib.parse import urlparse 22 23import gpodder 24from gpodder import util 25from gpodder.gtkui.draw import (draw_text_box_centered, get_background_color, 26 get_foreground_color) 27 28# from gpodder.gtkui.draw import investigate_widget_colors 29 30import gi # isort:skip 31gi.require_version('Gdk', '3.0') # isort:skip 32gi.require_version('Gtk', '3.0') # isort:skip 33from gi.repository import Gdk, Gtk, Pango # isort:skip 34 35 36_ = gpodder.gettext 37 38logger = logging.getLogger(__name__) 39 40has_webkit2 = False 41try: 42 gi.require_version('WebKit2', '4.0') 43 from gi.repository import WebKit2 44 has_webkit2 = True 45except (ImportError, ValueError): 46 logger.info('No WebKit2 gobject bindings, so no HTML shownotes') 47 48 49def get_shownotes(enable_html, pane): 50 if enable_html and has_webkit2: 51 return gPodderShownotesHTML(pane) 52 else: 53 return gPodderShownotesText(pane) 54 55 56class gPodderShownotes: 57 def __init__(self, shownotes_pane): 58 self.shownotes_pane = shownotes_pane 59 60 self.text_view = Gtk.TextView() 61 self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) 62 self.text_view.set_border_width(10) 63 self.text_view.set_editable(False) 64 self.text_buffer = Gtk.TextBuffer() 65 self.text_buffer.create_tag('heading', scale=1.2, weight=Pango.Weight.BOLD) 66 self.text_buffer.create_tag('subheading', scale=1.0) 67 self.text_view.set_buffer(self.text_buffer) 68 69 self.status = Gtk.Label.new() 70 self.status.set_halign(Gtk.Align.START) 71 self.status.set_valign(Gtk.Align.END) 72 self.status.set_property('ellipsize', Pango.EllipsizeMode.END) 73 self.set_status(None) 74 self.status_bg = None 75 self.color_set = False 76 self.background_color = None 77 self.foreground_color = None 78 self.link_color = None 79 self.visited_color = None 80 81 self.scrolled_window = Gtk.ScrolledWindow() 82 # main_component is the scrolled_window, except for gPodderShownotesText 83 # where it's an overlay, to show hyperlink targets 84 self.main_component = self.scrolled_window 85 self.scrolled_window.set_shadow_type(Gtk.ShadowType.IN) 86 self.scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) 87 self.scrolled_window.add(self.init()) 88 self.main_component.show_all() 89 90 self.da_message = Gtk.DrawingArea() 91 self.da_message.set_property('expand', True) 92 self.da_message.connect('draw', self.on_shownotes_message_expose_event) 93 self.shownotes_pane.add(self.da_message) 94 self.shownotes_pane.add(self.main_component) 95 96 self.set_complain_about_selection(True) 97 self.hide_pane() 98 99 # Either show the shownotes *or* a message, 'Please select an episode' 100 def set_complain_about_selection(self, message=True): 101 if message: 102 self.scrolled_window.hide() 103 self.da_message.show() 104 else: 105 self.da_message.hide() 106 self.scrolled_window.show() 107 108 def set_episodes(self, selected_episodes): 109 if self.pane_is_visible: 110 if len(selected_episodes) == 1: 111 episode = selected_episodes[0] 112 heading = episode.title 113 subheading = _('from %s') % (episode.channel.title) 114 self.update(heading, subheading, episode) 115 self.set_complain_about_selection(False) 116 else: 117 self.set_complain_about_selection(True) 118 119 def show_pane(self, selected_episodes): 120 self.pane_is_visible = True 121 self.set_episodes(selected_episodes) 122 self.shownotes_pane.show() 123 124 def hide_pane(self): 125 self.pane_is_visible = False 126 self.shownotes_pane.hide() 127 128 def toggle_pane_visibility(self, selected_episodes): 129 if self.pane_is_visible: 130 self.hide_pane() 131 else: 132 self.show_pane(selected_episodes) 133 134 def on_shownotes_message_expose_event(self, drawingarea, ctx): 135 background = get_background_color() 136 if background is None: 137 background = Gdk.RGBA(1, 1, 1, 1) 138 ctx.set_source_rgba(background.red, background.green, background.blue, 1) 139 x1, y1, x2, y2 = ctx.clip_extents() 140 ctx.rectangle(x1, y1, x2 - x1, y2 - y1) 141 ctx.fill() 142 143 width, height = drawingarea.get_allocated_width(), drawingarea.get_allocated_height(), 144 text = _('Please select an episode') 145 draw_text_box_centered(ctx, drawingarea, width, height, text, None, None) 146 return False 147 148 def set_status(self, text): 149 self.status.set_label(text or " ") 150 151 def define_colors(self): 152 if not self.color_set: 153 self.color_set = True 154 # investigate_widget_colors([ 155 # ([(Gtk.Window, 'background', '')], self.status.get_toplevel()), 156 # ([(Gtk.Window, 'background', ''), (Gtk.Label, '', '')], self.status), 157 # ([(Gtk.Window, 'background', ''), (Gtk.TextView, 'view', '')], self.text_view), 158 # ([(Gtk.Window, 'background', ''), (Gtk.TextView, 'view', 'text')], self.text_view), 159 # ]) 160 self.background_color = get_background_color(Gtk.StateFlags.NORMAL, widget=self.text_view) or Gdk.RGBA() 161 self.foreground_color = get_foreground_color(Gtk.StateFlags.NORMAL, widget=self.text_view) or Gdk.RGBA(0, 0, 0) 162 self.link_color = (get_foreground_color(state=Gtk.StateFlags.LINK, widget=self.text_view) or Gdk.RGBA(0, 0, 0)) 163 self.visited_color = (get_foreground_color(state=Gtk.StateFlags.VISITED, widget=self.text_view) or self.link_color) 164 self.status_bg.override_background_color(Gtk.StateFlags.NORMAL, self.background_color) 165 self.text_buffer.create_tag('hyperlink', 166 foreground=self.link_color.to_string(), 167 underline=Pango.Underline.SINGLE) 168 169 170class gPodderShownotesText(gPodderShownotes): 171 def init(self): 172 self.text_view.set_property('expand', True) 173 self.text_view.connect('button-release-event', self.on_button_release) 174 self.text_view.connect('key-press-event', self.on_key_press) 175 self.text_view.connect('motion-notify-event', self.on_hover_hyperlink) 176 self.overlay = Gtk.Overlay() 177 self.overlay.add(self.scrolled_window) 178 # need an EventBox for an opaque background behind the label 179 box = Gtk.EventBox() 180 self.status_bg = box 181 box.add(self.status) 182 box.set_hexpand(False) 183 box.set_vexpand(False) 184 box.set_valign(Gtk.Align.END) 185 box.set_halign(Gtk.Align.START) 186 self.overlay.add_overlay(box) 187 self.overlay.set_overlay_pass_through(box, True) 188 self.main_component = self.overlay 189 return self.text_view 190 191 def update(self, heading, subheading, episode): 192 self.define_colors() 193 hyperlinks = [(0, None)] 194 self.text_buffer.set_text('') 195 self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(), heading, 'heading') 196 self.text_buffer.insert_at_cursor('\n') 197 self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(), subheading, 'subheading') 198 self.text_buffer.insert_at_cursor('\n\n') 199 for target, text in util.extract_hyperlinked_text(episode.description_html or episode.description): 200 hyperlinks.append((self.text_buffer.get_char_count(), target)) 201 if target: 202 self.text_buffer.insert_with_tags_by_name( 203 self.text_buffer.get_end_iter(), text, 'hyperlink') 204 else: 205 self.text_buffer.insert( 206 self.text_buffer.get_end_iter(), text) 207 hyperlinks.append((self.text_buffer.get_char_count(), None)) 208 self.hyperlinks = [(start, end, url) for (start, url), (end, _) in zip(hyperlinks, hyperlinks[1:]) if url] 209 self.text_buffer.place_cursor(self.text_buffer.get_start_iter()) 210 211 def on_button_release(self, widget, event): 212 if event.button == 1: 213 self.activate_links() 214 215 def on_key_press(self, widget, event): 216 if event.keyval == Gdk.KEY_Return: 217 self.activate_links() 218 return True 219 220 return False 221 222 def hyperlink_at_pos(self, pos): 223 """ 224 :param int pos: offset in text buffer 225 :return str: hyperlink target at pos if any or None 226 """ 227 return next((url for start, end, url in self.hyperlinks if start < pos < end), None) 228 229 def activate_links(self): 230 if self.text_buffer.get_selection_bounds() == (): 231 pos = self.text_buffer.props.cursor_position 232 target = self.hyperlink_at_pos(pos) 233 if target is not None: 234 util.open_website(target) 235 236 def on_hover_hyperlink(self, textview, e): 237 x, y = textview.window_to_buffer_coords(Gtk.TextWindowType.TEXT, e.x, e.y) 238 w = self.text_view.get_window(Gtk.TextWindowType.TEXT) 239 success, it = textview.get_iter_at_location(x, y) 240 if success: 241 pos = it.get_offset() 242 target = self.hyperlink_at_pos(pos) 243 if target: 244 self.set_status(target) 245 w.set_cursor(Gdk.Cursor.new_from_name(w.get_display(), 'pointer')) 246 return 247 self.set_status('') 248 w.set_cursor(None) 249 250 251class gPodderShownotesHTML(gPodderShownotes): 252 def init(self): 253 self.episode = None 254 self._base_uri = None 255 # basic restrictions 256 self.stylesheet = None 257 self.manager = WebKit2.UserContentManager() 258 self.html_view = WebKit2.WebView.new_with_user_content_manager(self.manager) 259 settings = self.html_view.get_settings() 260 settings.set_enable_java(False) 261 settings.set_enable_plugins(False) 262 settings.set_enable_javascript(False) 263 # uncomment to show web inspector 264 # settings.set_enable_developer_extras(True) 265 self.html_view.set_property('expand', True) 266 self.html_view.connect('mouse-target-changed', self.on_mouse_over) 267 self.html_view.connect('context-menu', self.on_context_menu) 268 self.html_view.connect('decide-policy', self.on_decide_policy) 269 self.html_view.connect('authenticate', self.on_authenticate) 270 # give the vertical space to the html view! 271 self.text_view.set_property('hexpand', True) 272 grid = Gtk.Grid() 273 self.status_bg = grid 274 grid.attach(self.text_view, 0, 0, 1, 1) 275 grid.attach(self.html_view, 0, 1, 1, 1) 276 grid.attach(self.status, 0, 2, 1, 1) 277 return grid 278 279 def update(self, heading, subheading, episode): 280 self.define_colors() 281 282 self.text_buffer.set_text('') 283 self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(), heading, 'heading') 284 self.text_buffer.insert_at_cursor('\n') 285 self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(), subheading, 'subheading') 286 287 if episode.has_website_link(): 288 self._base_uri = episode.link 289 else: 290 self._base_uri = episode.channel.url 291 292 # for incomplete base URI (e.g. http://919.noagendanotes.com) 293 baseURI = urlparse(self._base_uri) 294 if baseURI.path == '': 295 self._base_uri += '/' 296 self._loaded = False 297 298 stylesheet = self.get_stylesheet() 299 if stylesheet: 300 self.manager.add_style_sheet(stylesheet) 301 description_html = episode.description_html 302 if description_html: 303 # uncomment to prevent background override in html shownotes 304 # self.manager.remove_all_style_sheets () 305 logger.debug("base uri: %s (chan:%s)", self._base_uri, episode.channel.url) 306 self.html_view.load_html(description_html, self._base_uri) 307 else: 308 self.html_view.load_plain_text(episode.description) 309 # uncomment to show web inspector 310 # self.html_view.get_inspector().show() 311 self.episode = episode 312 313 def on_mouse_over(self, webview, hit_test_result, modifiers): 314 if hit_test_result.context_is_link(): 315 self.set_status(hit_test_result.get_link_uri()) 316 else: 317 self.set_status(None) 318 319 def on_context_menu(self, webview, context_menu, event, hit_test_result): 320 whitelist_actions = [ 321 WebKit2.ContextMenuAction.NO_ACTION, 322 WebKit2.ContextMenuAction.STOP, 323 WebKit2.ContextMenuAction.RELOAD, 324 WebKit2.ContextMenuAction.COPY, 325 WebKit2.ContextMenuAction.CUT, 326 WebKit2.ContextMenuAction.PASTE, 327 WebKit2.ContextMenuAction.DELETE, 328 WebKit2.ContextMenuAction.SELECT_ALL, 329 WebKit2.ContextMenuAction.INPUT_METHODS, 330 WebKit2.ContextMenuAction.COPY_VIDEO_LINK_TO_CLIPBOARD, 331 WebKit2.ContextMenuAction.COPY_AUDIO_LINK_TO_CLIPBOARD, 332 WebKit2.ContextMenuAction.COPY_LINK_TO_CLIPBOARD, 333 WebKit2.ContextMenuAction.COPY_IMAGE_TO_CLIPBOARD, 334 WebKit2.ContextMenuAction.COPY_IMAGE_URL_TO_CLIPBOARD 335 ] 336 items = context_menu.get_items() 337 for item in items: 338 if item.get_stock_action() not in whitelist_actions: 339 context_menu.remove(item) 340 if hit_test_result.get_context() == WebKit2.HitTestResultContext.DOCUMENT: 341 item = self.create_open_item( 342 'shownotes-in-browser', 343 _('Open shownotes in web browser'), 344 self._base_uri) 345 context_menu.insert(item, -1) 346 elif hit_test_result.context_is_link(): 347 item = self.create_open_item( 348 'link-in-browser', 349 _('Open link in web browser'), 350 hit_test_result.get_link_uri()) 351 context_menu.insert(item, -1) 352 return False 353 354 def on_decide_policy(self, webview, decision, decision_type): 355 if decision_type == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION: 356 decision.ignore() 357 return False 358 elif decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION: 359 req = decision.get_request() 360 # about:blank is for plain text shownotes 361 if req.get_uri() in (self._base_uri, 'about:blank'): 362 decision.use() 363 else: 364 logger.debug("refusing to go to %s (base URI=%s)", req.get_uri(), self._base_uri) 365 decision.ignore() 366 return False 367 else: 368 decision.use() 369 return False 370 371 def on_open_in_browser(self, action): 372 util.open_website(action.url) 373 374 def on_authenticate(self, view, request): 375 if request.is_retry(): 376 return False 377 if not self.episode or not self.episode.channel.auth_username: 378 return False 379 chan = self.episode.channel 380 u = urlparse(chan.url) 381 host = u.hostname 382 if u.port: 383 port = u.port 384 elif u.scheme == 'https': 385 port = 443 386 else: 387 port = 80 388 logger.debug("on_authenticate(chan=%s:%s req=%s:%s (scheme=%s))", 389 host, port, request.get_host(), request.get_port(), 390 request.get_scheme()) 391 if host == request.get_host() and port == request.get_port() \ 392 and request.get_scheme() == WebKit2.AuthenticationScheme.HTTP_BASIC: 393 persistence = WebKit2.CredentialPersistence.FOR_SESSION 394 request.authenticate(WebKit2.Credential(chan.auth_username, 395 chan.auth_password, 396 persistence)) 397 return True 398 else: 399 return False 400 401 def create_open_item(self, name, label, url): 402 action = Gtk.Action.new(name, label, None, Gtk.STOCK_OPEN) 403 action.url = url 404 action.connect('activate', self.on_open_in_browser) 405 return WebKit2.ContextMenuItem.new(action) 406 407 def get_stylesheet(self): 408 if self.stylesheet is None: 409 style = ("html { background: %s; color: %s;}" 410 " a { color: %s; }" 411 " a:visited { color: %s; }") % \ 412 (self.background_color.to_string(), self.foreground_color.to_string(), 413 self.link_color.to_string(), self.visited_color.to_string()) 414 self.stylesheet = WebKit2.UserStyleSheet(style, 0, 1, None, None) 415 return self.stylesheet 416