html_preview.py 16.2 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
gi.require_version('Gtk', '3.0')
33 34 35
gi.require_version('Ide', '1.0')
gi.require_version('WebKit2', '4.0')

36
from gi.repository import Dazzle
37 38 39 40 41 42 43 44
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

45 46 47 48 49
try:
    locale.setlocale(locale.LC_ALL, '')
except:
    pass

50
can_preview_rst = True
51 52
can_preview_sphinx = True
old_open = None
53 54 55 56 57 58

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

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 92 93 94
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

95 96
_ = Ide.gettext

97

98 99 100 101 102 103
class SphinxState():
    def __init__(self, builddir):
        self.builddir = builddir
        self.is_running = False
        self.need_build = False

104
class HtmlPreviewData(GObject.Object, Ide.ApplicationAddin):
105 106 107 108 109
    MARKDOWN_CSS = None
    MARKED_JS = None
    MARKDOWN_VIEW_JS = None

    def do_load(self, app):
110 111 112 113 114 115 116
        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
117

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

125
    def get_data(self, name):
126 127 128
        # Hold onto the GBytes to avoid copying the buffer
        path = os.path.join('/org/gnome/builder/plugins/html_preview', name)
        return Gio.resources_lookup_data(path, 0)
129

130 131 132
class HtmlWorkbenchAddin(GObject.Object, Ide.WorkbenchAddin):
    def do_load(self, workbench):
        self.workbench = workbench
133

134
        group = Gio.SimpleActionGroup()
135

136
        self.install_action = Gio.SimpleAction(name='install-docutils', enabled=True)
137 138
        self.install_action.connect('activate', lambda *_: self.install_docutils())
        group.insert(self.install_action)
139

140 141
        self.install_action = Gio.SimpleAction(name='install-sphinx', enabled=True)
        self.install_action.connect('activate', lambda *_: self.install_sphinx())
142 143 144 145
        group.insert(self.install_action)

        self.workbench.insert_action_group('html-preview', group)

146
    def do_unload(self, workbench):
147
        workbench.insert_action_group('html-preview', None)
148 149 150 151
        self.workbench = None

    def install_docutils(self):
        transfer = Ide.PkconTransfer(packages=['python3-docutils'])
152
        manager = Gio.Application.get_default().get_transfer_manager()
153 154 155 156 157

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

    def install_sphinx(self):
        transfer = Ide.PkconTransfer(packages=['python3-sphinx'])
158
        manager = Gio.Application.get_default().get_transfer_manager()
159 160 161 162 163 164 165 166 167 168

        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:
169
            Ide.warning("Failed to load docutils.core module")
170 171 172 173 174 175 176 177 178 179 180 181
            return

        can_preview_rst = True
        self.workbench.pop_message('org.gnome.builder.docutils.install')

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

        try:
            import sphinx
        except ImportError:
182
            Ide.warning("Failed to load sphinx module")
183 184 185 186 187 188 189 190 191 192
            return

        can_preview_sphinx = True
        self.workbench.pop_message('org.gnome.builder.sphinx.install')


class HtmlPreviewAddin(GObject.Object, Ide.EditorViewAddin):
    def do_load(self, view):
        self.workbench = view.get_ancestor(Ide.Workbench)
        self.view = view
193

194 195 196 197
        self.can_preview = False
        self.sphinx_basedir = None
        self.sphinx_builddir = None

198 199
        group = view.get_action_group('editor-view')

200
        self.action = Gio.SimpleAction(name='preview-as-html', enabled=True)
201
        self.activate_handler = self.action.connect('activate', self.preview_activated)
202
        group.add_action(self.action)
203

204
        document = view.get_buffer()
205 206 207 208 209
        language = document.get_language()
        language_id = language.get_id() if language else None

        self.do_language_changed(language_id)

210 211 212 213 214 215 216
        # 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,
                                      'editor-view.preview-as-html')

217
    def do_unload(self, view):
218 219
        self.action.disconnect(self.activate_handler)

220 221 222
        group = view.get_action_group('editor-view')
        group.remove_action('preview-as-html')

223
        self.action = None
224 225
        self.view = None
        self.workbench = None
226 227

    def do_language_changed(self, language_id):
228
        enabled = (language_id in ('html', 'markdown', 'rst'))
229
        self.action.set_enabled(enabled)
230 231 232 233 234
        self.lang_id = language_id
        self.can_preview = enabled

        if self.lang_id == 'rst':
            if not self.sphinx_basedir:
235
                document = self.view.get_buffer()
236 237 238 239 240
                path = document.get_file().get_file().get_path()
                self.sphinx_basedir = self.search_sphinx_base_dir(path)

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

242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258
        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

259
    def preview_activated(self, *args):
260 261
        global can_preview_rst

262 263
        view = self.view
        if view is None:
264 265
            return

266 267 268 269 270 271 272 273 274
        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

275
        document = view.get_buffer()
276 277 278 279 280
        web_view = HtmlPreviewView(document,
                                   self.sphinx_basedir,
                                   self.sphinx_builddir,
                                   visible=True)

281 282 283 284 285 286 287 288 289
        column = view.get_ancestor(Ide.LayoutGridColumn)
        grid = column.get_ancestor(Ide.LayoutGrid)
        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)
290

291
        self.action.set_enabled(False)
292
        web_view.connect('destroy', self.web_view_destroyed)
293

294
    def web_view_destroyed(self, web_view, *args):
295 296
        self.action.set_enabled(True)

297 298 299 300
    def search_sphinx_base_dir(self, path):
        context = self.workbench.get_context()
        vcs = context.get_vcs()
        working_dir = vcs.get_working_directory().get_path()
301

302 303 304 305 306
        try:
            if os.path.commonpath([working_dir, path]) != working_dir:
                working_dir = '/'
        except:
            working_dir = '/'
307

308 309
        folder = os.path.dirname(path)
        level = 10
310

311 312 313 314 315
        while level > 0:
            files = os.scandir(folder)
            for file in files:
                if file.name == 'conf.py':
                    return folder
316

317 318 319 320 321 322 323
            if folder == working_dir:
                return None

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

    def show_missing_docutils_message(self, view):
324 325 326 327 328 329 330 331 332
        message = Ide.WorkbenchMessage(
            id='org.gnome.builder.docutils.install',
            title=_('Your computer is missing python3-docutils'),
            show_close_button=True,
            visible=True)

        message.add_action(_('Install'), 'html-preview.install-docutils')
        self.workbench.push_message(message)

333 334 335 336 337 338
    def show_missing_sphinx_message(self, view):
        message = Ide.WorkbenchMessage(
            id='org.gnome.builder.sphinx.install',
            title=_('Your computer is missing python3-sphinx'),
            show_close_button=True,
            visible=True)
339

340 341
        message.add_action(_('Install'), 'html-preview.install-sphinx')
        self.workbench.push_message(message)
342

343

344
class HtmlPreviewView(Ide.LayoutView):
345
    markdown = False
346
    rst = False
347

348 349 350 351
    title_handler = 0
    changed_handler = 0
    destroy_handler = 0

352 353 354
    def __init__(self, document, sphinx_basedir, sphinx_builddir, *args, **kwargs):
        global old_open

355
        super().__init__(*args, **kwargs)
356 357 358

        self.sphinx_basedir = sphinx_basedir
        self.sphinx_builddir = sphinx_builddir
359 360 361 362 363
        self.document = document

        self.webview = WebKit2.WebView(visible=True, expand=True)
        self.add(self.webview)

364 365 366
        settings = self.webview.get_settings()
        settings.enable_html5_local_storage = False

367
        language = document.get_language()
368 369 370 371 372 373
        if language:
            id = language.get_id()
            if id == 'markdown':
                self.markdown = True
            elif id == 'rst':
                self.rst = True
374

375 376 377
        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)
378

379
        self.on_changed(document)
380 381 382 383
        self.on_title_changed(document)

    def on_title_changed(self, buffer):
        self.set_title("%s %s" % (buffer.get_title(), _("(Preview)")))
384

385
    def web_view_destroyed(self, web_view):
386 387 388 389 390 391 392 393 394 395
        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
396

397 398
    def get_markdown(self, text):
        text = text.replace("\"", "\\\"").replace("\n", "\\n")
399
        params = (HtmlPreviewData.MARKDOWN_CSS.get_data().decode('UTF-8'),
400
                  text,
401 402
                  HtmlPreviewData.MARKED_JS.get_data().decode('UTF-8'),
                  HtmlPreviewData.MARKDOWN_VIEW_JS.get_data().decode('UTF-8'))
403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418

        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

419 420 421 422 423
    def get_rst(self, text, path):
        return publish_string(text,
                              writer_name='html5',
                              source_path=path,
                              destination_path=path)
424

425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479
    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):
        path = document.get_file().get_file().get_path()
        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

480
    def reload(self):
481 482 483 484 485 486 487
        state = self.get_sphinx_state(self.sphinx_basedir)
        if state and state.is_running:
            state.need_build = True
            return

        gfile = self.document.get_file().get_file()
        base_uri = gfile.get_uri()
488 489 490 491 492 493

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

        if self.markdown:
            text = self.get_markdown(text)
494
        elif self.rst:
495 496 497 498 499 500 501 502 503 504 505 506 507 508
            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")
509 510 511

        self.webview.load_html(text, base_uri)

512 513 514 515 516 517 518 519 520 521 522 523
    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()

524 525
    def on_changed(self, document):
        self.reload()