TracのWiki編集画面でプレビューを表示するTrac Plugin

TracWiki編集画面で、「プレビュー」を押して画面遷移するのが面倒じゃないですか?*1


これ。
Wiki全体の変更内容を見るより、自分が変更した部分だけサクッと見たい。
という事で、TracPluginを作ってみました。

参考にしたプラグイン

とても参考にさせて頂きました。
こういう参考になるコードが簡単に見られるっていい時代ですよね。

WikiPreviewOverrayPlugin

入力したテキストを選択して、右クリックを押すことでWiki編集画面から画面遷移せずにWiki変更後のイメージを表示出来ます。*2
こんな感じ。

内部の話ですが、技術的には

辺りを使用しています。
Python初心者+Javascript未経験だったため、良い勉強になりました。

インストール方法

WindowsXP+TracLightning2.4.0での確認しかしていませんので、動かなかったらスミマセン。

まずは、後述しているソースを下記フォルダ構成で配置する。

  • フォルダ構成
■WikiPreviewOverrayPlugin
 setup.py
 ■wikipreviewoverray
  __init__.py
  wikipreviewoverray.py
  ■htdocs
   wikipreviewoverray.css
  ■templates
   wikipreviewoverray.js

後は、下記のどれかで動かしてみることができます。

  • リンクだけ作って確認してみる方法
python setup.py develop
  • 普通にinstallする方法
python setup.py install
  • eggを作って、Tracのweb画面からinstallする方法
python setup.py bdist_egg

作成されたeggを管理画面からインストールする。


インストール後、もしかしたらサーバの再起動が必要かもしれません。

ソース

外部に置くリポジトリがないため、ひとまずブログ上に公開。

  • setup.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from setuptools import find_packages, setup

setup(
    name='WikiPreviewOverrayPlugin',
    version='0.1',
    packages=find_packages(exclude=['*.tests*']),
    package_data={'wikipreviewoverray': [ 'htdocs/*', 'templates/*']},
    entry_points = """
        [trac.plugins]
        WikiPreviewOverray = wikipreviewoverray
    """,
    url='http://d.hatena.ne.jp/sinsoku/',
    author = 'sinsoku',
    author_email = "sinsoku.listy@gmail.com",
    description = u"Wiki Preview",
    license ="New BSD",
)
  • /wikipreviewoverray/__init__.py
# -*- coding: utf-8 -*-
from wikipreviewoverray import *
  • /wikipreviewoverray/wikipreviewoverray.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import re

from trac.core import *
from trac.web.chrome import ITemplateProvider, add_stylesheet, add_script
from trac.web.api import IRequestFilter, IRequestHandler
from trac.util import escape, Markup
from trac.perm import IPermissionRequestor
from trac.wiki.formatter import wiki_to_html
from pkg_resources import resource_filename

class WikiPreviewOverrayPlugin(Component):
    implements(IRequestHandler, ITemplateProvider, IRequestFilter)

    # ITemplateProvider methods
    def get_templates_dirs(self):
        yield resource_filename(__name__, 'templates')

    def get_htdocs_dirs(self):
        yield 'wikipreviewoverray', resource_filename(__name__, 'htdocs')

    # IRequestHandler methods
    def match_request(self, req):
        return re.match(r'^/WikiPreviewOverray(?:(.*))', req.path_info) is not None


    def process_request(self, req):
        if re.match(r'^/WikiPreviewOverray/wikipreviewoverray.js',req.path_info) :
            if 'WIKI_CREATE' in req.perm('wiki') or 'WIKI_ADMIN' in req.perm('wiki'):
                return 'wikipreviewoverray.js',{},'text/plain'

    # IRequestFilter methods
    def post_process_request(self, req, template, data, content_type) :
        if re.match(r'/wiki/', req.path_info) and req.method =='GET' and req.args.get('action') == "edit" :
            add_script(req, '/WikiPreviewOverray/wikipreviewoverray.js')
        if not re.match(r'^wikipreviewoverray/wikipreviewoverray.css',req.path_info) :
            add_stylesheet(req, 'wikipreviewoverray/wikipreviewoverray.css')

        if re.match(r'^/WikiPreviewOverray/post',req.path_info) :
            if req.method == 'POST' :
                text = req.args.get('tohtml')
                html = unicode(wiki_to_html(text, self.env, req, absurls=1))
                
                req.send_response(200)
                req.send_header('Content-Type', 'applicatiion/x-www-form-urlencoded')
                req.end_headers()
                req.write(html)

        return template, data, content_type

    def pre_process_request(self, req, handler):
        return handler
  • /wikipreviewoverray/htdocs/wikipreviewoverray.css
div#WikiPreviewOverray_Container{
  position:absolute;
  padding:5px;
  background-color: #FFFFFF;
  z-index: 999;
  color: #000000;
  border: 1px solid #000000;
  -moz-border-radius: 10px; /* for Fx */
  -webkit-border-radius: 10px; /* for Safari */
  -border-radius:10px;
}
div#WikiPreviewOverray_Container span#WikiPreviewOverray_Button{
  width:5px;
  height:5px;
  padding: 0px;
  display: block;
  float: right;
  border: 1px solid #000000;
  -moz-border-radius: 9px; /* for Fx */
  -webkit-border-radius: 9px; /* for Safari */
  -border-radius:9px;
}
div#WikiPreviewOverray_Container span#WikiPreviewOverray_Button:hover{
  background-color:#FF0000;
}
  • /wikipreviewoverray/templates/wikipreviewoverray.js
$( function() {
    $(document).ready(function(){
        $("div#footer").after("<div id='WikiPreviewOverray_Container'/>")
        $("div#WikiPreviewOverray_Container")
            .css( "max-width", 380)
            .css( "max-height", 480)
            .css( "top" ,  0)
            .css( "left",  0);
        $("#WikiPreviewOverray_Container").append("<span id='WikiPreviewOverray_Button'></span>")
        $("span#WikiPreviewOverray_Button").click( function(){
            $("#WikiPreviewOverray_Container").hide("slow");
        });
        $("#WikiPreviewOverray_Container").hide();
        $("#WikiPreviewOverray_Container").append("<div id='WikiPreviewOverray_Html'/>")
        $("div#WikiPreviewOverray_Html")
            .css( "overflow", "auto")
            .css( "max-width", 370)
            .css( "max-height", 470)
    });

    $(document).bind("contextmenu",function(event){ 
        if(document.selection) {
            // IE
            var range = document.selection.createRange();
            var selected_value = range.text;
        } else {
            // IE 以外
            var org = document.getElementById("text");
            var start = org.selectionStart;
            var end = org.selectionEnd;
            var selected_value = org.value.substring(start, end);
        }

        if(selected_value.length > 0) {
            var url = location.pathname.split("/");
            var req_url = "";
            // /wiki/WikiStartの分をurlから削って、req_urlを構築する
            for(var i = 1; i < url.length - 2; i++)
                req_url += "/" + url[i];
            req_url += "/WikiPreviewOverray/post";
            
            $.post(
                req_url,
                {"tohtml" : selected_value, "__FORM_TOKEN" : $('[name=__FORM_TOKEN]').attr('value')},
                function(data, status) {
                    $("div#WikiPreviewOverray_Html").html(data);
                    $("div#WikiPreviewOverray_Container")
                        .css( "top" ,  event.pageY)
                        .css( "left",  event.pageX);
                    $("#WikiPreviewOverray_Container").hide();
                    $("#WikiPreviewOverray_Container").show("slow");
                },
                "html"
            );
            selected_value = "";
        }
        
        // 右クリックメニュー(コンテキストメニュー)を非表示にする。
        return false;
    });
})

Plugin開発者向け

今回のPlugin作成で、POSTを使用する所でハマったので、備忘録を残しておきます。

Tracの本体側ではCSRF対策として、フォームにハッシュのような文字列を付加しています。
本体(/TracLight/python-lib/trac/trac/web/main.py)のソースだと、190行目付近。

  • main.py
# Protect against CSRF attacks: we validate the form token for
# all POST requests with a content-type corresponding to form
# submissions
if req.method == 'POST':
    ctype = req.get_header('Content-Type')
    if ctype:
        ctype, options = cgi.parse_header(ctype)
    if ctype in ('application/x-www-form-urlencoded',
                 'multipart/form-data') and \
            req.args.get('__FORM_TOKEN') != req.form_token:
        raise HTTPBadRequest('Missing or invalid form token. '
                             'Do you have cookies enabled?')

上記を見ていただければわかるように、

  • 'Content-Type'が'application/x-www-form-urlencoded'もしくは'multipart/form-data'でない
  • '__FORM_TOKEN'がない

と400 Bad Requestが発生してしまいます。

これを回避するためには、Tracのページから'__FORM_TOKEN'を取得し、POSTに含めてやります。

$.post(
  req_url,
  {"__FORM_TOKEN" : $('[name=__FORM_TOKEN]').attr('value')
  },
  function(data, status) {
    //何かの処理
  },
  "html"
);

気づけば簡単に修正出来ますが、中々Web上に情報がなく、苦戦しました。

追記

ソースコード一式をzipにしてDropboxに置きました。
WikiPreviewOverrayPlugin-0.1.zip

*1:wysiwyg使えば解決しそうですが、慣れるとテキスト入力の方が早いのですよね・・・

*2:デメリットとして、wiki編集画面で右クリックが使用不可になってしまいます。代用の方法を考えて修正したいですが・・・