MediaWikiにpcommentプラグイン風のコメント機能をつける

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102207/ebcc0e48-7da3-716b-59c6-9be811ef3f32.png)
PukiWikiのpcommentプラグイン風のコメント機能を提供します。
使用したページの議論ページからコメントを読み込んで表示します。

## 基本仕様
* テンプレートなどでpcomment-widgetクラスをつけたDIV枠を追加すると、Javascriptがレンダリング処理を行います。
* オプションでコメントツリーの最大個数を制限できます。
* 議論ページが複数のセクションで分かれている場合にはセクションを個別に指定することができます。
* オプションで非ログインユーザーの場合は書き込みできないようにすることができます。
* 箇条書きのコメントにはラジオボタンをつけて表示。
* チェックしてコメントを書き込むと吊り下げて返信ができます。
* 書き込み後はテンプレート部分のみを再読み込みします。
* 読み込んだリビジョンより更新されていた場合でも書き込めます。（レスしたコメントが削除されていた場合は動作不可）

## ソースコード
```javascript:Gadget-pcomment.js
/***********************************************
 * PukiWiki pcommentプラグイン風コメントボックス
 * 
 * by pneuma01
 ***********************************************/
var i18n = {
    summary_edit_with_pcomment: "PCommentでコメント",
    send_button_caption: "コメントを追加"
};

$(function () {

    const intersectionObserver = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
            //画面内に入った
            if (entry.isIntersecting) {

                //コメント読み込み
                loadComment(entry);

                //監視対象の解除
                intersectionObserver.unobserve(entry.target);
            }
        });
    });

    //監視開始
    var observe_target = document.querySelectorAll(".pcomment-widget");
    observe_target.forEach(function (element) {
        intersectionObserver.observe(element);
    });

});

//コメント読み込み
function loadComment(entry) {
    
    //トークページ名を取得(存在しない場合でも)
    var targetPage = entry.target.getAttribute("data-page") || mw.config.get('wgPageName');
    var talkPage = new mw.Title( targetPage ).getTalkPage().getPrefixedText();

    //トークページのwikitextを取得
    $.when(new mw.Api().get({
            action: 'query',
            format: 'json',
            titles: talkPage,
            prop: 'revisions',
            meta: 'tokens',
            type: 'csrf',
            rvprop: 'content|ids',
            indexpageids: 1,
            formatversion: '2',
            rvslots: 'main'
        }),
        mw.user.getGroups()
    ).done(function (result, userGroups) {
        result = result[0].query;
        var page = result.pages[0];

        //衝突防止のためにリビジョンを記録
        if(page.revisions){
            entry.target.dataset.revid = page.revisions[0].revid;
        }
        entry.target.dataset.csrftoken = result.tokens.csrftoken;

        //非ログインユーザーにもコメントできるか？
        var GuestUser = false;
        if(entry.target.getAttribute("data-deny-guest")){
            GuestUser = (userGroups.indexOf("user") == -1);
        }

        var wikitext = page.revisions && page.revisions[0].slots.main.content || "";
        var lines = wikitext.split(/\r\n|\n|\r/);
        var sections = [];
        var lists = {};
        var lists_count = 0;
        var tree = {children: [], level: 0, text: "root"};
        var curtree = tree;
        var last_lvl = 1;
        for (var i = 0; i < lines.length; i++) {
            //セクションの位置を取得
            if (lines[i].match(/^=+[^=]+=+$/)) {
                sections.push({ index: sections.length, line: i, title: lines[i].match(/^=+([^=]+)=+$/)[1].trim() });
                continue;
            }

            //*(<li>)の行番号を取得
            if (lines[i].match(/^\*+/)) {
                lists[i] = { index: lists_count++, line: i, section: sections.length - 1};

                var m = lines[i].match(/^(\*+)/);
                var astarisk = m[1];
                
                //ツリーの始まりを記憶
                if(astarisk.length == 1){
                    lists[i].toplevel = true;
                }

                //ツリーを記憶
                {
                    var treedata = {line: i, level: astarisk.length, children: []};

                    if(astarisk.length < last_lvl){
                        do{
                            curtree = curtree.parent;
                        } while (curtree && curtree.level >= astarisk.length);
                        treedata.parent = curtree;
                    }

                    if(astarisk.length > last_lvl){
                        curtree = curtree.children[curtree.children.length-1];
                    }

                    treedata.parent = curtree;

                    curtree.children.push( treedata );
                    
                    lists[i].tree = treedata;
                }
                
                last_lvl = astarisk.length;
            }
        }

        //セクションがまだ存在しないならデフォルト
        if (sections.length == 0) {
            sections.push(
                { index: 0, line: -1, title: "PComment" }
            );
        }

        //引数を取得
        var sectionName = entry.target.getAttribute("data-section") || sections[sections.length - 1].title;
        var sectionId = sections.length - 1;
        var max = entry.target.getAttribute("data-max") || 10;

        //引数の名前からセクションを特定する
        for (i = 0; i < sections.length; i++) {
            if (sections[i].title == sectionName) {
                sectionId = sections[i].index;
                break;
            }
        }

        //次のセクションの先頭行を取得(セクション範囲識別に使用)
        var nextSectionLine = lines.length;
        if (sectionId > -1 && sections.length - 1 > sectionId) {
            nextSectionLine = sections[sectionId + 1].line;
        }

        //コメントツリーの最後の行を取得するための関数
        function getLast(obj){
            if(obj.children.length){
                return getLast(obj.children[obj.children.length -1]);
            }
            return obj.line;
        }

        //wikitextを指定レス分で抽出する
        var new_lines = [];
        var count = 1;
        for (i = lines.length - 1; i >= 0; i--) {
            //関係ないセクションはスキップ
            if (i <= sections[sectionId].line || i >= nextSectionLine) {
                continue;
            }

            var line = lines[i];

            if (!GuestUser && lists[i]) {
                //リスト構文の先頭にラジオボタン(予定地)を挿入する
                var m = line.match(/^(\*+)([^\*].+)$/);
                var astarisk = m[1];
                var comment = m[2];

                //最後のレスの行を取得
                var lastlinenum = i;
                if(lists[i].tree){
                    lastlinenum = getLast(lists[i].tree);
                }

                line = astarisk + "<span class='pcomment-radio' data-group='sec" + lists[i].section + "' data-line='" + lastlinenum + "' data-level='" + astarisk + "'></span>" + comment;

                if (lists[i].toplevel) {
                    count++;
                }
            }
            new_lines.push(line);

            //指定のレス数に達したのでループを抜ける
            if (count > max) break;
        }

        //抜き取った行をwikitextに変換する
        wikitext = new_lines.reverse().join("\n");

        // APIでWikitextをパースする
        new mw.Api().parse(wikitext).done(function (data) {
            //htmlを生成
            var $html = $(data).removeClass('noscript');

            //OOUIのラジオボタンを生成して挿入
            $html.find("span.pcomment-radio").each(function () {

                var val = {
                    line: $(this).attr('data-line'),
                    level: $(this).attr('data-level')
                };

                $(this).replaceWith(
                    new OO.ui.RadioInputWidget({ name: $(this).attr('data-group'), value: JSON.stringify(val) }).$element
                );
            });

            //テキストボックスと送信ボタンを追加
            if(!GuestUser){
                var $text = new OO.ui.TextInputWidget();
                var $button = new OO.ui.ButtonWidget({ label: i18n.send_button_caption, flags: ["primary", "progressive"] });
                $html.append(
                    $("<div>", { class: "pcomment-toolbar", "data-section-end": nextSectionLine}).append(
                        $text.$element.addClass('pcomment-text').css("display", "inline-block"),
                        $button.$element.on('click', function(){

                            var selected = $(this).parents(".pcomment-widget").find("input:checked");
                            var section_end = $(this).parents(".pcomment-toolbar").attr("data-section-end");
                            var text = $(this).parent().find('.pcomment-text input').val();

                            if(text.trim().length == 0) {
                                //空送信を防止する
                                return;
                            }
                            $text.setDisabled(true);
                            $button.setDisabled(true);

                            $(this).parents(".pcomment-toolbar").prepend(
                                new OO.ui.ProgressBarWidget({ progress:false }).$element.addClass("oo-ui-pendingElement-pending")
                            );

                            var comment = {
                                targetPage: talkPage,
                                section_end: section_end,
                                wikitext: "* " + text + "--~" + "~" + "~" + "~",
                                entry: entry
                            };

                            if(selected.length){
                                var opt = JSON.parse(selected.val());
                                comment.line = opt.line;
                                comment.level = opt.level;
                            }

                            postComment(comment);
                        })
                    )
                );
            }

            // できたHTMLの差し込み
            $(entry.target).html("").append($html);

            // 他のスクリプトへ読み込み完了通知
            mw.hook('wikipage.content').fire($(entry.target));
        });

    });
}

function getRevIndex(revisions, revid){
    for(var i=0; i<revisions.length; i++) {
        if(revisions[i].revid == revid){
            return i;
        }
    }
    return -1;
}

//コメント追加用
function postComment(option) {

    var query = {
        action: 'query',
        format: 'json',
        prop: 'revisions',
        titles: option.targetPage,
        rvprop: 'content|ids',
        rvslots: 'main',
        indexpageids: 1,
        formatversion: '2'
    };

    var oldrevid = option.entry.target.dataset.revid;

    //元の版で取得
    if(oldrevid) {
        query.rvendid = oldrevid;
    }

    //最後のページ内容を取得
    new mw.Api().get(query).done(function (result) {
        result = result.query;
        var page = result.pages[0];
        var oldText = "";
        var currevid = page.revisions && page.revisions[0].revid;
        var linenum = option.line && option.line*1;
        var section_end = option.section_end && option.section_end*1;

        if(page.revisions){
            oldText = page.revisions[0].slots.main.content;
        }

        //行で分割する
        var new_lines = oldText.split(/\r\n|\n|\r/);

        //リビジョンが更新されていた場合、該当する行番号へ更新
        if(currevid != oldrevid) {
            var index = getRevIndex(oldrevid);
            oldText = page.revisions[ index ].slots.main.content;
            var old_lines = oldText.split(/\r\n|\n|\r/);
            var target_line;

            if(option.line) {
                target_line = old_lines[ linenum ];
                linenum = new_lines.indexOf( target_line );
            }

            if(option.section_end){
                if(section_end < old_lines.length - 2){
                    target_line = old_lines[ section_end + 1 ];
                    section_end = new_lines.indexOf( target_line ) - 1;
                } else {
                    section_end = old_lines.length - 1;
                }
            }
        }
        
        if(linenum){
            //レスする行が指定されているならコメントを挿入
            new_lines.splice(linenum + 1, 0, option.level + option.wikitext);
        } else if(section_end){
            //レス行未指定
            new_lines.splice(section_end, 0, option.wikitext);
        } else {
            new_lines.push(option.wikitext);
        }

        var newText = new_lines.join("\n");

        //あたらしい内容を送信する
        new mw.Api().post({
            action: 'edit',
            title: option.targetPage,
            text: newText,
            summary: i18n.summary_edit_with_pcomment,
            token: option.entry.target.dataset.csrftoken
        }).always(function () {
            loadComment(option.entry);
        });
    });
}

```

Extension:Gadgetsで使用する場合、``mediawiki.api``、``mediawiki.user``、``oojs-ui-core``が依存要件です。
```
*pcomment[ResourceLoader|default|targets=desktop|type=general|dependencies=jquery,mediawiki.api,mediawiki.user,oojs-ui-core]|pcomment.js
```

## 読み込み方法
以下のタグを追加します。
```html
<div class="pcomment-widget" data-page="ページ名" data-max="10" data-section="セクション名" data-deny-guest="1"></div>
```
* ``data-page`` - 対象ページ（デフォルトは現在ページ）
* ``data-max`` - コメントツリーの最大個数（省略時は10）
* ``data-section`` - 対象のセクション名（省略時は末尾のセクションから読み込み）
* ``data-deny-guest`` - 非ログインユーザーの書き込みを禁止したい場合に指定

テンプレートにする場合は以下のようにすると良いと思います。
```html
<includeonly><div class="pcomment-widget" {{#if:{{{page|}}}|data-page="{{{page|}}}"}} {{#if:{{{max|}}}|data-max="{{{max|}}}"}} {{#if:{{{section|}}}|data-section="{{{section|}}}"}} {{#if:{{{denyguest|}}}|data-deny-guest="1"}}></div></includeonly><noinclude>
{{documentation}}
</noinclude>
```