html_preview.py 17.6 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 locale
26 27 28 29 30
import os
import shutil
import sys
import subprocess
import threading
31

32
from gi.repository import Dazzle
33 34 35 36 37 38 39 40
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

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

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

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

55 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
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

91 92
_ = Ide.gettext

93

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

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

    def do_load(self, app):
106 107 108 109 110 111 112
        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
113

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

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

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

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

132 133 134 135 136 137 138 139 140 141 142 143 144 145
    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):
146
        group = Gio.SimpleActionGroup()
147

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

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

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

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

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

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

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

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

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

        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:
186
            Ide.warning("Failed to load docutils.core module")
187 188 189
            return

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

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

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

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

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

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

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

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

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

        self.do_language_changed(language_id)

227 228 229 230 231
        # 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,
232
                                      'editor-page.preview-as-html')
233

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

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

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

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

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

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

259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275
        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

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

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

283 284 285 286 287 288 289 290 291
        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

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

298 299
        column = view.get_ancestor(Ide.GridColumn)
        grid = column.get_ancestor(Ide.Grid)
300 301 302 303 304 305 306
        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)
307

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

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

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

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

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

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

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

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

    def show_missing_docutils_message(self, view):
339 340
        notif = Ide.Notification(
            id='org.gnome.builder.html-preview.docutils',
341
            title=_('Your computer is missing python3-docutils'),
342 343 344 345 346
            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)
347

348
    def show_missing_sphinx_message(self, view):
349 350
        notif = Ide.Notification(
            id='org.gnome.builder.html-preview.sphinx',
351
            title=_('Your computer is missing python3-sphinx'),
352 353 354 355 356
            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)
357

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

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

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

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

        self.sphinx_basedir = sphinx_basedir
        self.sphinx_builddir = sphinx_builddir
373 374
        self.document = document

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

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

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

392 393 394
        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)
395

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

399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417
    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:
                decision.ignore()
                return True

        return False

418
    def on_title_changed(self, buffer):
419
        self.set_title("%s %s" % (buffer.dup_title(), _("(Preview)")))
420

421
    def web_view_destroyed(self, web_view):
422 423 424 425 426 427 428 429 430 431
        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
432

433 434
    def get_markdown(self, text):
        text = text.replace("\"", "\\\"").replace("\n", "\\n")
435
        params = (HtmlPreviewData.MARKDOWN_CSS.get_data().decode('UTF-8'),
436
                  text,
437 438
                  HtmlPreviewData.MARKED_JS.get_data().decode('UTF-8'),
                  HtmlPreviewData.MARKDOWN_VIEW_JS.get_data().decode('UTF-8'))
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454

        return """
<html>
 <head>
  <style>%s</style>
  <script>var str="%s";</script>
  <script>%s</script>
  <script>%s</script>
 </head>
 <body onload="preview()">
  <div class="markdown-body" id="preview">
  </div>
 </body>
</html>
""" % params

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

461 462 463 464 465 466 467
    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):
468
        path = document.get_file().get_path()
469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515
        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)

        rel_path = os.path.relpath(path, start=basedir)
        command = ['sphinx-build', '-Q', '-b', 'html', basedir, builddir, path]

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

        result = not sphinx.build_main(command)
        remove_override_file(path)

        if not result:
            task.builddir_path = None
            task.return_error(GLib.Error('\'sphinx-build\' command error for {}'.format(path)))
            return

        task.builddir_path = builddir_path
        task.return_boolean(True)

    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

516
    def reload(self):
517 518 519 520 521
        state = self.get_sphinx_state(self.sphinx_basedir)
        if state and state.is_running:
            state.need_build = True
            return

522
        gfile = self.document.get_file()
523
        base_uri = gfile.get_uri()
524 525 526 527 528 529

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

        if self.markdown:
            text = self.get_markdown(text)
530
        elif self.rst:
531 532 533 534 535 536 537 538 539 540 541 542 543 544
            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")
545 546 547

        self.webview.load_html(text, base_uri)

548 549 550 551 552 553 554 555 556 557 558 559
    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()

560 561
    def on_changed(self, document):
        self.reload()