__init__.py 14.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
#!/usr/bin/env python3

#
# html_preview_plugin.py
#
# Copyright (C) 2015 Christian Hergert <chris@dronelabs.com>
#
# 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 36 37 38 39 40 41 42 43
gi.require_version('Ide', '1.0')
gi.require_version('WebKit2', '4.0')

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

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

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

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

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

94 95
_ = Ide.gettext

96

97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
def is_sphinx_installed():
    with open(os.devnull, 'w') as devnull:
        try:
            if subprocess.call(['sphinx-build', '--version'],
                               stdout=devnull, stderr=devnull) == 0:
                return True
        except FileNotFoundError:
            pass

        return False


class SphinxState():
    def __init__(self, builddir):
        self.builddir = builddir
        self.is_running = False
        self.need_build = False


116
class HtmlPreviewData(GObject.Object, Ide.ApplicationAddin):
117 118 119 120 121 122 123 124 125
    MARKDOWN_CSS = None
    MARKED_JS = None
    MARKDOWN_VIEW_JS = None

    def do_load(self, app):
        HtmlPreviewData.MARKDOWN_CSS = self.get_data('markdown.css')
        HtmlPreviewData.MARKED_JS = self.get_data('marked.js')
        HtmlPreviewData.MARKDOWN_VIEW_JS = self.get_data('markdown-view.js')

126 127 128 129 130 131 132
    def do_unload(self, app):
        for state in sphinx_states.items():
            # Be extra sure that we are in the tmp dir
            tmpdir = GLib.get_tmp_dir()
            if state.builddir.startswith(tmpdir):
                shutil.rmtree(state.builddir)

133 134 135 136 137 138 139 140
    def get_data(self, name):
        engine = Peas.Engine.get_default()
        info = engine.get_plugin_info('html_preview_plugin')
        datadir = info.get_data_dir()
        path = os.path.join(datadir, name)
        return open(path, 'r').read()


141 142 143
class HtmlWorkbenchAddin(GObject.Object, Ide.WorkbenchAddin):
    def do_load(self, workbench):
        self.workbench = workbench
144

145
        group = Gio.SimpleActionGroup()
146

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

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

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

157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220
    def do_unload(self, workbench):
        self.workbench = None

    def install_docutils(self):
        transfer = Ide.PkconTransfer(packages=['python3-docutils'])
        context = self.workbench.get_context()
        manager = context.get_transfer_manager()

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

    def install_sphinx(self):
        transfer = Ide.PkconTransfer(packages=['python3-sphinx'])
        context = self.workbench.get_context()
        manager = context.get_transfer_manager()

        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:
            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:
            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
        self.can_preview = False
        self.sphinx_basedir = None
        self.sphinx_builddir = None

        self.action = Gio.SimpleAction(name='preview-as-html', enabled=True)
        self.action.connect('activate', lambda *_: self.preview_activated(view))

        actions = view.get_action_group('view')
        actions.add_action(self.action)

        document = view.get_document()
        language = document.get_language()
        language_id = language.get_id() if language else None

        self.do_language_changed(language_id)

    def do_unload(self, view):
        actions = view.get_action_group('view')
221
        actions.remove_action('preview-as-html')
222 223

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

        if self.lang_id == 'rst':
            if not self.sphinx_basedir:
                document = self.view.get_document()
                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)
237

238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255
        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

    def preview_activated(self, view):
256 257
        global can_preview_rst

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

        document = view.get_document()
        web_view = HtmlPreviewView(document,
                                   self.sphinx_basedir,
                                   self.sphinx_builddir,
                                   visible=True)

        stack = view.get_ancestor(Ide.LayoutStack)
        stack.add(web_view)

276 277 278 279 280 281
        self.action.set_enabled(False)
        web_view.connect('destroy', lambda *_: self.web_view_destroyed(web_view))

    def web_view_destroyed(self, web_view):
        self.action.set_enabled(True)

282 283 284 285
    def search_sphinx_base_dir(self, path):
        context = self.workbench.get_context()
        vcs = context.get_vcs()
        working_dir = vcs.get_working_directory().get_path()
286

287 288 289 290 291
        try:
            if os.path.commonpath([working_dir, path]) != working_dir:
                working_dir = '/'
        except:
            working_dir = '/'
292

293 294
        folder = os.path.dirname(path)
        level = 10
295

296 297 298 299 300
        while level > 0:
            files = os.scandir(folder)
            for file in files:
                if file.name == 'conf.py':
                    return folder
301

302 303 304 305 306 307 308
            if folder == working_dir:
                return None

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

    def show_missing_docutils_message(self, view):
309 310 311 312 313 314 315 316 317
        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)

318 319 320 321 322 323
    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)
324

325 326
        message.add_action(_('Install'), 'html-preview.install-sphinx')
        self.workbench.push_message(message)
327

328

329
class HtmlPreviewView(Ide.LayoutView):
330
    markdown = False
331
    rst = False
332

333 334 335
    def __init__(self, document, sphinx_basedir, sphinx_builddir, *args, **kwargs):
        global old_open

336
        super().__init__(*args, **kwargs)
337 338 339

        self.sphinx_basedir = sphinx_basedir
        self.sphinx_builddir = sphinx_builddir
340 341 342 343 344
        self.document = document

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

345 346 347
        settings = self.webview.get_settings()
        settings.enable_html5_local_storage = False

348
        language = document.get_language()
349 350 351 352 353 354
        if language:
            id = language.get_id()
            if id == 'markdown':
                self.markdown = True
            elif id == 'rst':
                self.rst = True
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387

        document.connect('changed', self.on_changed)
        self.on_changed(document)

    def do_get_title(self):
        title = self.document.get_title()
        return '%s (Preview)' % title

    def do_get_document(self):
        return self.document

    def get_markdown(self, text):
        text = text.replace("\"", "\\\"").replace("\n", "\\n")
        params = (HtmlPreviewData.MARKDOWN_CSS,
                  text,
                  HtmlPreviewData.MARKED_JS,
                  HtmlPreviewData.MARKDOWN_VIEW_JS)

        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

388 389 390 391 392
    def get_rst(self, text, path):
        return publish_string(text,
                              writer_name='html5',
                              source_path=path,
                              destination_path=path)
393

394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 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
    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

449
    def reload(self):
450 451 452 453 454 455 456
        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()
457 458 459 460 461 462

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

        if self.markdown:
            text = self.get_markdown(text)
463
        elif self.rst:
464 465 466 467 468 469 470 471 472 473 474 475 476 477
            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")
478 479 480

        self.webview.load_html(text, base_uri)

481 482 483 484 485 486 487 488 489 490 491 492
    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()

493 494
    def on_changed(self, document):
        self.reload()