html_preview.py 18 KB
Newer Older
1 2 3
#!/usr/bin/env python3

#
4
# html_preview.py
5
#
6
# Copyright 2015 Christian Hergert <chris@dronelabs.com>
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

22
import builtins
23
import gi
24
import io
25
import json
26
import locale
27 28 29 30 31
import os
import shutil
import sys
import subprocess
import threading
32

33
from gi.repository import Dazzle
34 35 36 37 38 39 40 41
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Gtk
from gi.repository import GObject
from gi.repository import Ide
from gi.repository import WebKit2
from gi.repository import Peas

42 43 44 45 46
try:
    locale.setlocale(locale.LC_ALL, '')
except:
    pass

47
can_preview_rst = True
48 49
can_preview_sphinx = True
old_open = None
50 51 52 53 54 55

try:
    from docutils.core import publish_string
except ImportError:
    can_preview_rst = False

56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
try:
    import sphinx
except ImportError:
    can_preview_sphinx = False

sphinx_states = {}
sphinx_override = {}


def add_override_file(path, content):
    if path in sphinx_override:
        return False
    else:
        sphinx_override[path] = content.encode('utf-8')
        return True


def remove_override_file(path):
    try:
        del sphinx_override[path]
    except KeyError:
        return False

    return True


def new_open(*args, **kwargs):
    path = args[0]
    if path in sphinx_override:
        return io.BytesIO(sphinx_override[path])

    return old_open(*args, **kwargs)

old_open = builtins.open
builtins.open = new_open

92 93
_ = Ide.gettext

94

95 96 97 98 99 100
class SphinxState():
    def __init__(self, builddir):
        self.builddir = builddir
        self.is_running = False
        self.need_build = False

101
class HtmlPreviewData(GObject.Object, Ide.ApplicationAddin):
102 103 104 105 106
    MARKDOWN_CSS = None
    MARKED_JS = None
    MARKDOWN_VIEW_JS = None

    def do_load(self, app):
107 108 109 110 111 112 113
        HtmlPreviewData.MARKDOWN_CSS = self.get_data('css/markdown.css')
        HtmlPreviewData.MARKED_JS = self.get_data('js/marked.js')
        HtmlPreviewData.MARKDOWN_VIEW_JS = self.get_data('js/markdown-view.js')

        assert HtmlPreviewData.MARKDOWN_CSS
        assert HtmlPreviewData.MARKED_JS
        assert HtmlPreviewData.MARKDOWN_VIEW_JS
114

115
    def do_unload(self, app):
116
        for state in sphinx_states.values():
117 118 119 120 121
            # Be extra sure that we are in the tmp dir
            tmpdir = GLib.get_tmp_dir()
            if state.builddir.startswith(tmpdir):
                shutil.rmtree(state.builddir)

122
    def get_data(self, name):
123
        # Hold onto the GBytes to avoid copying the buffer
124
        path = os.path.join('/plugins/html_preview', name)
125
        return Gio.resources_lookup_data(path, 0)
126

127
class HtmlWorkbenchAddin(GObject.Object, Ide.WorkbenchAddin):
128 129
    workbench = None

130 131
    def do_load(self, workbench):
        self.workbench = workbench
132

133 134 135 136 137 138 139 140 141 142 143 144 145 146
    def do_unload(self, workbench):
        self.workbench = None

    def find_notif_by_id(self, id):
        notifs = self.workbench.get_context().get_child_typed(Ide.Notifications)
        return notifs.find_by_id(id)

    def withdraw_notification(self, id):
        notifs = self.workbench.get_context().get_child_typed(Ide.Notifications)
        notif = notifs.find_by_id(id)
        if notif is not None:
            notif.withdraw()

    def do_workspace_added(self, workspace):
147
        group = Gio.SimpleActionGroup()
148

149
        self.install_action = Gio.SimpleAction(name='install-docutils', enabled=True)
150 151
        self.install_action.connect('activate', lambda *_: self.install_docutils())
        group.insert(self.install_action)
152

153 154
        self.install_action = Gio.SimpleAction(name='install-sphinx', enabled=True)
        self.install_action.connect('activate', lambda *_: self.install_sphinx())
155 156
        group.insert(self.install_action)

157
        workspace.insert_action_group('html-preview', group)
158

159 160
    def do_workspace_removed(self, workspace):
        workspace.insert_action_group('html-preview', None)
161 162 163

    def install_docutils(self):
        transfer = Ide.PkconTransfer(packages=['python3-docutils'])
164 165 166 167
        manager = Ide.TransferManager.get_default()

        notif = transfer.create_notification()
        notif.attach(self.workbench.get_context())
168 169 170 171 172

        manager.execute_async(transfer, None, self.docutils_installed, None)

    def install_sphinx(self):
        transfer = Ide.PkconTransfer(packages=['python3-sphinx'])
173 174 175 176
        manager = Ide.TransferManager.get_default()

        notif = transfer.create_notification()
        notif.attach(self.workbench.get_context())
177 178 179 180 181 182 183 184 185 186

        manager.execute_async(transfer, None, self.sphinx_installed, None)

    def docutils_installed(self, object, result, data):
        global can_preview_rst
        global publish_string

        try:
            from docutils.core import publish_string
        except ImportError:
187
            Ide.warning("Failed to load docutils.core module")
188 189 190
            return

        can_preview_rst = True
191
        self.withdraw_notification('org.gnome.builder.html-preview.docutils')
192 193 194 195 196 197 198 199

    def sphinx_installed(self, object, result, data):
        global can_preview_sphinx
        global sphinx

        try:
            import sphinx
        except ImportError:
200
            Ide.warning("Failed to load sphinx module")
201 202 203
            return

        can_preview_sphinx = True
204 205
        self.withdraw_notification('org.gnome.builder.html-preview.docutils')
        self.withdraw_notification('org.gnome.builder.html-preview.sphinx')
206

207
class HtmlPreviewAddin(GObject.Object, Ide.EditorPageAddin):
208
    def do_load(self, view):
209
        self.context = Ide.widget_get_context(view)
210
        self.view = view
211

212 213 214 215
        self.can_preview = False
        self.sphinx_basedir = None
        self.sphinx_builddir = None

216
        group = view.get_action_group('editor-page')
217

218
        self.action = Gio.SimpleAction(name='preview-as-html', enabled=True)
219
        self.activate_handler = self.action.connect('activate', self.preview_activated)
220
        group.add_action(self.action)
221

222
        document = view.get_buffer()
223 224 225 226 227
        language = document.get_language()
        language_id = language.get_id() if language else None

        self.do_language_changed(language_id)

228 229 230 231 232
        # Add a shortcut for activation inside the editor
        controller = Dazzle.ShortcutController.find(view)
        controller.add_command_action('org.gnome.builder.html-preview.preview',
                                      '<Control><Alt>p',
                                      Dazzle.ShortcutPhase.CAPTURE,
233
                                      'editor-page.preview-as-html')
234

235
    def do_unload(self, view):
236 237
        self.action.disconnect(self.activate_handler)

238
        group = view.get_action_group('editor-page')
239 240
        group.remove_action('preview-as-html')

241
        self.action = None
242
        self.view = None
243
        self.context = None
244 245

    def do_language_changed(self, language_id):
246
        enabled = (language_id in ('html', 'markdown', 'rst'))
247
        self.action.set_enabled(enabled)
248 249 250 251 252
        self.lang_id = language_id
        self.can_preview = enabled

        if self.lang_id == 'rst':
            if not self.sphinx_basedir:
253
                document = self.view.get_buffer()
254
                path = document.get_file().get_path()
255 256 257 258
                self.sphinx_basedir = self.search_sphinx_base_dir(path)

            if self.sphinx_basedir:
                self.sphinx_builddir = self.setup_sphinx_states(self.sphinx_basedir)
259

260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
        if not enabled:
            self.sphinx_basedir = None
            self.sphinx_builddir = None

    def setup_sphinx_states(self, basedir):
        global sphinx_states

        if basedir in sphinx_states:
            state = sphinx_states[basedir]
            sphinx_builddir = state.builddir
        else:
            sphinx_builddir = GLib.Dir.make_tmp('gnome-builder-sphinx-build-XXXXXX')
            state = SphinxState(sphinx_builddir)
            sphinx_states[basedir] = state

        return sphinx_builddir

277
    def preview_activated(self, *args):
278 279
        global can_preview_rst

280 281
        view = self.view
        if view is None:
282 283
            return

284 285 286 287 288 289 290 291 292
        if self.lang_id == 'rst':
            if self.sphinx_basedir:
                if not can_preview_sphinx:
                    self.show_missing_sphinx_message(view)
                    return
            elif not can_preview_rst:
                self.show_missing_docutils_message(view)
                return

293
        document = view.get_buffer()
294
        web_view = HtmlPreviewPage(document,
295 296 297 298
                                   self.sphinx_basedir,
                                   self.sphinx_builddir,
                                   visible=True)

299 300
        column = view.get_ancestor(Ide.GridColumn)
        grid = column.get_ancestor(Ide.Grid)
301 302 303 304 305 306 307
        index = grid.child_get_property(column, 'index')

        # If we are past first stack, use the 0 column stack
        # otherwise create or reuse a stack to the right.
        index += -1 if index > 0 else 1
        column = grid.get_nth_column(index)
        column.add(web_view)
308

309
        self.action.set_enabled(False)
310
        web_view.connect('destroy', self.web_view_destroyed)
311

312
    def web_view_destroyed(self, web_view, *args):
313 314
        self.action.set_enabled(True)

315
    def search_sphinx_base_dir(self, path):
316
        working_dir = self.context.ref_workdir()
317

318 319 320 321 322
        try:
            if os.path.commonpath([working_dir, path]) != working_dir:
                working_dir = '/'
        except:
            working_dir = '/'
323

324 325
        folder = os.path.dirname(path)
        level = 10
326

327 328 329 330 331
        while level > 0:
            files = os.scandir(folder)
            for file in files:
                if file.name == 'conf.py':
                    return folder
332

333 334 335 336 337 338 339
            if folder == working_dir:
                return None

            level -= 1
            folder = os.path.dirname(folder)

    def show_missing_docutils_message(self, view):
340 341
        notif = Ide.Notification(
            id='org.gnome.builder.html-preview.docutils',
342
            title=_('Your computer is missing python3-docutils'),
343 344 345 346 347
            body=_('This package is necessary to provide previews of markup-based documents.'),
            icon_name='dialog-warning-symbolic',
            urgent=True)
        notif.add_button(_('Install Package'), None, 'html-preview.install-docutils')
        notif.attach(self.context)
348

349
    def show_missing_sphinx_message(self, view):
350 351
        notif = Ide.Notification(
            id='org.gnome.builder.html-preview.sphinx',
352
            title=_('Your computer is missing python3-sphinx'),
353 354 355 356 357
            body=_('This package is necessary to provide previews of markup-based documents.'),
            icon_name='dialog-warning-symbolic',
            urgent=True)
        notif.add_button(_('Install Package'), None, 'html-preview.install-sphinx')
        notif.attach(self.context)
358

359
class HtmlPreviewPage(Ide.Page):
360
    markdown = False
361
    rst = False
362

363 364 365 366
    title_handler = 0
    changed_handler = 0
    destroy_handler = 0

367 368 369
    def __init__(self, document, sphinx_basedir, sphinx_builddir, *args, **kwargs):
        global old_open

370
        super().__init__(*args, **kwargs)
371 372 373

        self.sphinx_basedir = sphinx_basedir
        self.sphinx_builddir = sphinx_builddir
374 375
        self.document = document

376
        self.webview = WebKit2.WebView()
377
        self.webview.props.expand = True
378
        self.webview.connect('decide-policy', self.on_decide_policy_cb)
379
        self.add(self.webview)
380
        self.webview.show()
381

382 383 384
        settings = self.webview.get_settings()
        settings.enable_html5_local_storage = False

385
        language = document.get_language()
386 387 388 389 390 391
        if language:
            id = language.get_id()
            if id == 'markdown':
                self.markdown = True
            elif id == 'rst':
                self.rst = True
392

393 394 395
        self.title_handler = document.connect('notify::title', self.on_title_changed)
        self.changed_handler = document.connect('changed', self.on_changed)
        self.destroy_handler = self.webview.connect('destroy', self.web_view_destroyed)
396

397
        self.on_changed(document)
398 399
        self.on_title_changed(document)

400 401 402 403 404 405 406 407 408 409 410 411 412 413
    def on_decide_policy_cb(self, webview, decision, decision_type):
        """Handle policy decisions from webview"""

        if decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION:
            # Don't allow navigating away from the current page

            action = decision.get_navigation_action()
            request = action.get_request()
            uri = request.get_uri()

            # print(">>> URI = ", uri)

            if uri != self.document.get_file().get_uri() \
            and 'gnome-builder-sphinx' not in uri:
414 415
                toplevel = self.webview.get_toplevel()
                Ide.gtk_show_uri_on_window(toplevel, uri, GLib.get_monotonic_time())
416 417 418 419 420
                decision.ignore()
                return True

        return False

421
    def on_title_changed(self, buffer):
422
        self.set_title("%s %s" % (buffer.dup_title(), _("(Preview)")))
423

424
    def web_view_destroyed(self, web_view):
425 426 427 428 429 430 431 432 433 434
        self.document.disconnect(self.title_handler)
        self.document.disconnect(self.changed_handler)
        web_view.disconnect(self.destroy_handler)

        self.title_handler = 0
        self.changed_handler = 0
        self.destroy_handler = 0

        self.document = None
        self.webview = None
435

436
    def get_markdown(self, text):
437
        params = (HtmlPreviewData.MARKDOWN_CSS.get_data().decode('UTF-8'),
438
                  json.dumps(text),
439 440
                  HtmlPreviewData.MARKED_JS.get_data().decode('UTF-8'),
                  HtmlPreviewData.MARKDOWN_VIEW_JS.get_data().decode('UTF-8'))
441 442 443 444 445

        return """
<html>
 <head>
  <style>%s</style>
446
  <script>var str=%s;</script>
447 448 449 450 451 452 453 454 455 456
  <script>%s</script>
  <script>%s</script>
 </head>
 <body onload="preview()">
  <div class="markdown-body" id="preview">
  </div>
 </body>
</html>
""" % params

457 458 459 460 461
    def get_rst(self, text, path):
        return publish_string(text,
                              writer_name='html5',
                              source_path=path,
                              destination_path=path)
462

463 464 465 466 467 468 469
    def get_sphinx_rst_async(self, text, path, basedir, builddir, cancellable, callback):
        task = Gio.Task.new(self, cancellable, callback)
        threading.Thread(target=self.get_sphinx_rst_worker,
                         args=[task, text, path, basedir, builddir],
                         name='sphinx-rst-thread').start()

    def purge_cache(self, basedir, builddir, document):
470
        path = document.get_file().get_path()
471 472 473 474 475 476 477 478 479 480 481 482 483 484
        rel_path = os.path.relpath(path, start=basedir)
        rel_path_doctree = os.path.splitext(rel_path)[0] + '.doctree'
        doctree_path = os.path.join(builddir, '.doctrees', rel_path_doctree)

        tmpdir = GLib.get_tmp_dir()
        if doctree_path.startswith(tmpdir):
            try:
                os.remove(doctree_path)
            except:
                pass

    def get_sphinx_rst_worker(self, task, text, path, basedir, builddir):
        add_override_file(path, text)

485 486 487 488 489
        if GLib.find_program_in_path('sphinx-build-3'):
            program = 'sphinx-build-3'
        else:
            program = 'sphinx-build'

490
        rel_path = os.path.relpath(path, start=basedir)
491
        command = [program, '-Q', '-b', 'html', basedir, builddir, path]
492 493 494 495

        rel_path_html = os.path.splitext(rel_path)[0] + '.html'
        builddir_path = os.path.join(builddir, rel_path_html)

496
        try:
497 498 499 500 501 502 503 504 505 506 507 508
            launcher = Ide.SubprocessLauncher.new(0) # Gio.SubprocessFlags.STDOUT_SILENCE |
                                                     # Gio.SubprocessFlags.STDERR_SILENCE)
            launcher.push_args(command)
            subprocess = launcher.spawn()
            subprocess.wait_check()

            task.builddir_path = builddir_path
            task.return_boolean(True)
        except Exception as ex:
            task.return_error(GLib.Error(ex))
        finally:
            remove_override_file(path)
509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526


    def get_sphinx_rst_finish(self, result):
        succes = result.propagate_boolean()
        builddir_path = result.builddir_path

        return builddir_path

    def get_sphinx_state(self, basedir):
        global sphinx_states

        try:
            state = sphinx_states[basedir]
        except KeyError:
            return None

        return state

527
    def reload(self):
528 529 530 531 532
        state = self.get_sphinx_state(self.sphinx_basedir)
        if state and state.is_running:
            state.need_build = True
            return

533
        gfile = self.document.get_file()
534
        base_uri = gfile.get_uri()
535 536 537 538 539 540

        begin, end = self.document.get_bounds()
        text = self.document.get_text(begin, end, True)

        if self.markdown:
            text = self.get_markdown(text)
541
        elif self.rst:
542 543 544 545 546 547 548 549 550 551 552 553 554 555
            if self.sphinx_basedir:
                self.purge_cache(self.sphinx_basedir, self.sphinx_builddir, self.document)
                state.is_running = True

                self.get_sphinx_rst_async(text,
                                          gfile.get_path(),
                                          self.sphinx_basedir,
                                          self.sphinx_builddir,
                                          None,
                                          self.get_sphinx_rst_cb)

                return
            else:
                text = self.get_rst(text, gfile.get_path()).decode("utf-8")
556 557 558

        self.webview.load_html(text, base_uri)

559 560 561 562 563 564 565 566 567 568 569 570
    def get_sphinx_rst_cb(self, obj, result):
        builddir_path = self.get_sphinx_rst_finish(result)
        if builddir_path:
            uri = 'file:///' + builddir_path
            self.webview.load_uri(uri)

        state = self.get_sphinx_state(self.sphinx_basedir)
        state.is_running = False
        if state.need_build:
            state.need_build = False
            self.reload()

571 572
    def on_changed(self, document):
        self.reload()