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