くりーむわーかー

プログラムとか。作ってて ・試しててハマった事など。誰かのお役に立てば幸いかと。 その他、いろいろエトセトラ。。。

Redmine

GitLabとRedmineの連携

別サーバで社内に立ててるGitLabとRedmineを連携したくなった。

ちなみに片一方はAWSでもう一方はAzure。

GitLabへのPush通知をRedmine側に出して、RedmineからGitLabのリポジトリをpullして

Redmine側のリポジトリに反映させるみたいなやつ。

やるときの流れ。

  1. Redmineに「redmine_github_hook」のプラグインをインストール
  2. Redmineのサーバで適当な場所に該当リポジトリをbareでClone
  3. Redmineでパスワード無しでfetch出来るようにする
  4. Redmineのリポジトリに上でCloneしたフルパスを設定する
  5. GitLabのWebhookを設定する

redmine_github_hookのインストール

https://github.com/koppen/redmine_github_hook

redimne/htdocs/Gemfileの一番最後に↓を記載してbundle

gem "redmine_github_hook"

あと再起動。

リポジトリをbareでClone

こんな感じ

git clone --bare https://gitlab.hoge.com/fuga/myrepo.git myrepo.git

ベアリポジトリのディレクトリ名は「***.git」にした方がいいらしい。

パスワード無しでfetch出来るようにする

sshが通る場合はそれで。

通らない場合はhttpsでcloneすると思いますが、

パスワードキャッシュだとうまくいかなかった。

configにID/PASS書かないとだめっぽ。微妙ーーーー。

myrepo.git/config

[remote "origin"]
	url = https://[userid]:[password]@gitlab.hoge.com/fuga/myrepo.git

Redmineのリポジトリの設定

これは普通に。。。

GitLabのWebhookを設定する

GitLab側は該当リポジトリの「設定⇒インテグレーション」の

WebHookで下の感じのURLを設定してPushにチェックつけて追加。

https://hoge.com/redmine/github_hook?project_id=[プロジェクトの識別子]&repository_id=[リポジトリの識別子]

こんな感じ
https://hoge.com/redmine/github_hook?project_id=myproject&repository_id=myrepo

とりあえずこれで通った。

上手くいかない場合の調べ方。

まずはとりあえず、サーバ間の疎通が取れてるか確認。pingでも何でもいい。

そしたらGitLabに設定したWebHookの編集のリンク。

TestクリックしてPushを選択。

そうすると通知履歴が出来るので、それの編集をクリック。

そしたらResponseが見れるので、一番下にエラーの内容が返ってきてるはず。

Redmine側のログは「log\production.log」あたり。

apacheならPostリクエストのログがapacheのログにも出てるはず。

そのあたり見て、問題の切り分けをするしかなかね。

とりあえず、bareリポジトリじゃないとfetch後のマージとか失敗してるみたいですよ。

Redmine エクセルの表をWikiの記法(textile)に変換

以前、タイトルの内容のものを書いたけど、あれは.net側に変換のモジュールを持たせてたので、ちゃんとRedmineのプラグインとして動くようにした。

ソースは↓に置いてあるので、使いたい方はご自由にお使いください。公開できるようなソースじゃないのですが、個人的に割と便利だったので上げておきます。テストとかはほぼしてないので、好きに直してください。

ソース

多分、大丈夫だと思うのですが、いちを「nokogiri」と「css_parser」が必要です。

動かすときは↓の感じです。

Wikiとかチケットとかの入力のところで、「E2T」ってボタンが出る。クリックでペースト用のエリアが出る。
sample01

エクセルの表をコピー。↓は国勢調査の何か。
sample02

エリアにペーストするとTextile形式に変換する。
sample03

そしたらそのままコピペでWikiとかの入力欄に貼り付け。
sample04

↓の感じになる。
sample05

完璧に復元はしてないけど、十分かなと。

ボタンのつけ方とか絶対いけてない。変換のロジックも書き殴ってそのままあげちゃってるのでご注意をば。。。

Redmineのログの設定

Redmineのログにはかれる量を減らしたくなったので、ログのレベルをINFOにする。

と思ってみてたら、出てくる情報が古い古い。そのまま書くと落ちる。

最近のバージョン(とりあえず3.3)では↓の感じで書く。

apps\redmine\htdocs\config\additional_environment.rb
↑に↓を追記

config.log_level = :info

ログレベルとしてはdebug/info/warnあたり

RedmineでLDAP認証

タイトル通り。LDAP認証やる。設定は↓。Redmineの環境としてはWindowsServer2012R2上にBitnamiRedmineでRedmine構築。バージョンは3.3。

管理者でログインして、管理→LDAP認証→新しい認証方式。 sample01

そしたら↓の感じで設定。 sample02

検索範囲は「DC=domain,DC=hoge,DC=co,DC=jp」みたいにする。これはつなぎ先のLDAP次第。

で、アカウント使う場合は「user@domain.hoge.co.jp」みたいに@の後ろもつけないと上手くいかなかった。

アカウントをLDAPをもとに作る場合は属性を指定しておく。空欄にしてると、新規ユーザ来たときは入力画面に飛ばされる。

属性はココのサイトが見やすい。

Redmine ナレッジベースのエラー

Redmineのプラグインで「ナレッジベース(Knowledgebase)」を使ってる際に、稀にエラー(internal error)になる事がある。

これの理由が記事のカテゴリがなくなった場合。こうなると裏でデータ補正するしかない。

bitnamiのRedmineを使ってる場合は「use_redmine.bat」でコンソール上げる。そしたらMySQLにつなげる。

MySQLにつなげる
mysql -u root -p

redmineのDBに切り替え
use bitnami_redmine;

カテゴリーのテーブルに存在しないIDを使ってる記事がないかチェック
select * from kb_articles where project_id = 1
and category_id not in (select id from kb_categories where project_id = 1);

↑で出てきたらarticlesのcategory_idを存在するidに適当にupdateしてあげる

記事を消す前にカテゴリー消すとダメになるかも。

エラーチェックかかってたような気もするけど。。。

エクセルの表をRedmineのWikiテーブルにしたい

--追記2017/5/21
こっちにRedmineのプラグイン的な何かを置きました。

RedmineのWikiで表的なまとめを作ろうとするとtextile形式で作ることになるんだけど、ちょっと見た目にこりだしたりすると、とたんにえらいもの書かないといけなくなる。なので、エクセルでまとめた表をぱっと貼り付けられると便利。なんかいいプラグインないかなーと探してみたけど、グッとくるものがいないので、とりあえず作った。

まずはエクセルの表をコピーしてテキストエリアあたりに貼り付け⇒セルのフォーマット情報も含めて内容を取得する。Webサイトで。これにはClipboradApiを使用。下の感じ。

document.querySelector('#pasteArea').addEventListener('paste', function (e) {
    console.log(e.clipboardData.getData('text/html'));
});

クリップボードのデータを取得するとき、だいたい”text/plain”で指定するんだけど、htmlで指定すると、セルのフォーマット情報も含んだHTML形式で取得できる。↓の感じ。

sample01

で、あとはこのHTMLを解析してTextile形式に変換すれば良いという寸法です。ホントはこの流れをRedmineのプラグインで作りたかったんだけど、Ruby触るの久しぶり過ぎて、すごい時間かかりそうだったので、.netに逃げました。.netでやる場合、HTMLの解析は「HtmlAgilityPack」を使用する。Nugetから取得。

とりあえずで作ったのは、TextAreaにエクセルの表をコピペすると、サーバ側にそのHTMLをテキストで投げて、サーバ側で解析して結果を返す的なAPI。

で困ったことにHTMLをテキストで投げると、.netのセキュリティのチェックにデフォルトで引っかかる。なので、アノテーションでそのチェックを外してあげる。↓の感じ。

[HttpPost]
[ValidateInput(false)]//コレ
public ActionResult Excel2Textile(int id , FormCollection cols)
{
    string resultStr = Conv(cols);
    return Json(resultStr, JsonRequestBehavior.AllowGet);
}

そしたら、HTMLを解析する。まず、HTMLのテキストをAgilityPackに食わせる。

    StringBuilder wkSb = new StringBuilder();
    HtmlDocument htmlDoc = new HtmlDocument();
    htmlDoc.LoadHtml(excelTableHTML);

で、あとはTableタグ拾って⇒TRタグ拾って⇒TDタグ拾ってを繰り返し。

    foreach (var rootnode in htmlDoc.DocumentNode.SelectNodes(@"//table"))
    {
        //テーブルのTRで回す
        foreach (var tmpnode in rootnode.SelectNodes(@"tr"))
        {
            //TDそれぞれで定義を付ける
            foreach (var tmpTdNode in tmpnode.SelectNodes(@"td"))
            {

"//table/tr"って書けば外側のループはいらないんだけど理由は後で。これでとりえあず、中身の内容は拾えるんだけど、スタイルはAgilityPackでは解析できないっぽい?なので別途、スタイルシートを解析するライブラリを使う。今回使ったのは「ExCss」。取得はNugetから。これを使ってClass定義とstyle属性の内容を解析する。

今回はそこまで厳密にテーブルを再現したいわけではないので、背景色・文字色・文字寄せ・セル幅くらいを再現する。スタイルの解析は下の感じで書く。

var parser = new Parser();
var stylesheet = parser.Parse(htmlDoc.DocumentNode.SelectSingleNode(@"//style").InnerHtml);

//Class名は.付きにしないと当たらない。ex: .HogeClass
var styleElm = stylesheet.StyleRules.Where(n => n.Value == wkclass).FirstOrDefault();
if (styleElm != null)
{
    foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "background"))
    {
        if (!wkDic.ContainsKey("background")) wkDic.Add("background", sTmp.Term.ToString());
        break;
    }
}

Class定義はこれでいいんだけど、タグ内のStyle属性やる場合、ExCssはクラス定義の形式じゃないと解析できない模様なので、ちょっと文字列足してあげる。↓の感じ。

//クラス名はなんでもよい
var tmppparse = parser.Parse(".hoge{" + tmpTdNode.Attributes["style"].Value.Trim() + "}");

これでだいたい出来上がりなんだけど、問題が一つ。画像とか図形が表にかかってると、マイクロソフトのオレオレ定義が入り込んでくる。↓の感じ。

sample02

あと、この定義が入ると、通常のtdタグの中にさらにtableタグが入り込んでくる。てんやわんや。なので、一番表のTableだけで処理したいので、最初のループで最初のtableタグのみ処理するように仕込んでる。

超絶やっつけで作ったやつの全体像は下の感じ。見せられたもんではないのですが、とりあえず。同じロジック2回書いてるし・・・あとで整理しないと。


//ExcelをRedmineのWiki用に変換する
public string ExcelTable2WikiTable(string excelTableDef)
{
    StringBuilder wkSb = new StringBuilder();
    HtmlDocument htmlDoc = new HtmlDocument();
    htmlDoc.LoadHtml(excelTableDef);
    string wkInnerText = "";

    var parser = new Parser();
    var stylesheet = parser.Parse(htmlDoc.DocumentNode.SelectSingleNode(@"//style").InnerHtml);

    foreach (var rootnode in htmlDoc.DocumentNode.SelectNodes(@"//table"))
    {
        //テーブルのTRで回す
        foreach (var tmpnode in rootnode.SelectNodes(@"tr"))
        {
            //TDそれぞれで定義を付ける
            foreach (var tmpTdNode in tmpnode.SelectNodes(@"td"))
            {
                if (tmpTdNode.InnerText.Contains("<!--[if gte vml 1]>"))
                {
                    HtmlDocument innerHtmlDoc = new HtmlDocument();
                    innerHtmlDoc.LoadHtml(tmpTdNode.OuterHtml);
                    foreach (var innerNode in innerHtmlDoc.DocumentNode.SelectNodes(@"//table/tr/td"))
                    {
                        wkInnerText = innerNode.InnerText;
                        break;
                    }
                }
                else
                {
                    wkInnerText = tmpTdNode.InnerText;
                }

                Dictionary<string, string> wkDic = new Dictionary<string, string>();
                string classsep = "";
                wkSb.AppendFormat("|");
                //列結合
                if (tmpTdNode.Attributes.Where(n => n.Name == "colspan").Count() > 0)
                {
                    wkSb.AppendFormat("\\{0}", tmpTdNode.Attributes["colspan"].Value.Trim());
                    classsep = ".";
                }
                //行結合
                if (tmpTdNode.Attributes.Where(n => n.Name == "rowspan").Count() > 0)
                {
                    wkSb.AppendFormat("/{0}", tmpTdNode.Attributes["rowspan"].Value.Trim());
                    classsep = ".";
                }
                //最初にスタイル属性に入ってる奴を処理
                if (tmpTdNode.Attributes.Where(n => n.Name == "style").Count() > 0)
                {
                    var tmppparse = parser.Parse(".hoge{" + tmpTdNode.Attributes["style"].Value.Trim() + "}");
                    var styleElm = tmppparse.StyleRules.Where(n => n.Value == ".hoge").FirstOrDefault();
                    if (styleElm != null)
                    {
                        foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "background"))
                        {
                            if (!wkDic.ContainsKey("background")) wkDic.Add("background", sTmp.Term.ToString());
                            classsep = ".";
                            break;
                        }
                        foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "color"))
                        {
                            if (!wkDic.ContainsKey("color")) wkDic.Add("color", sTmp.Term.ToString());
                            classsep = ".";
                            break;
                        }
                        foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "width"))
                        {
                            if (!wkDic.ContainsKey("width")) wkDic.Add("width", sTmp.Term.ToString());
                            classsep = ".";
                            break;
                        }
                        foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "text-align"))
                        {
                            if (!wkDic.ContainsKey("text-align")) wkDic.Add("text-align", sTmp.Term.ToString());
                            classsep = ".";
                            break;
                        }
                    }
                }
                //CLASSで指定されてる内容を処理
                if (tmpTdNode.Attributes.Where(n => n.Name == "class").Count() > 0)
                {
                    string wkclass = "." + tmpTdNode.Attributes["class"].Value.Trim();
                    var styleElm = stylesheet.StyleRules.Where(n => n.Value == wkclass).FirstOrDefault();
                    if (styleElm != null)
                    {
                        foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "background"))
                        {
                            if (!wkDic.ContainsKey("background")) wkDic.Add("background", sTmp.Term.ToString());
                            classsep = ".";
                            break;
                        }
                        foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "color"))
                        {
                            if (!wkDic.ContainsKey("color")) wkDic.Add("color", sTmp.Term.ToString());
                            classsep = ".";
                            break;
                        }
                        foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "width"))
                        {
                            if (!wkDic.ContainsKey("width")) wkDic.Add("width", sTmp.Term.ToString());
                            classsep = ".";
                            break;
                        }
                        foreach (var sTmp in styleElm.Declarations.Properties.Where(n => n.Name == "text-align"))
                        {
                            if (!wkDic.ContainsKey("text-align")) wkDic.Add("text-align", sTmp.Term.ToString());
                            classsep = ".";
                            break;
                        }
                    }
                }
                StringBuilder wkStyleStr = new StringBuilder();
                foreach (var tmpkey in wkDic.Keys)
                {
                    wkStyleStr.Append(tmpkey + ":" + wkDic[tmpkey] + ";");
                }
                if (wkDic.Count > 0) wkSb.Append("{" + wkStyleStr.ToString() + "}");
                wkSb.AppendFormat("{0}{1}", classsep, wkInnerText);
            }//td
            wkSb.Append("|" + Environment.NewLine);
        }//tr
        break;
    }//table
    
    return wkSb.ToString();
}

ちなみに、変換した結果をWikiに貼った結果は下。

sample03

けっこーちゃんと出来てる気がする。あー、ちゃんと列・行の結合にも対応出来てたりする。

Redmineの全文検索をFESSでやる

タイトル通り。

Redmine使ってると全文検索をしたくなる。もちろん添付ファイルの中身も含めて。チケットとかWikiとかとか。

で、今回はチケットは検索対象には入れずにやる。その代り、ナレッジベースDMSFとWikiの検索をやる。

Redmine単体ではちょっときつそうなので、プラグインを作るかどうにかするしかない。色々見てたら、FESSがよさそうなので、これを使ってやってみる。Fessが何かはインターネットにおまかせ。

まず、準備。必要なものはFess本体とJDK。JDKはてきとーにオラクルから落としてきてインストール。Fessは「Fess」でググれば配布元のページ出てくるからそこから落とす。

そしたら、下の感じでJavaのPath通しと環境変数を設定。あー、環境はWindows7っす。

sample01
sample02
sample03

JAVA_HOMEっていうシステム変数が必要らしい。ElasticSearchっていう、検索エンジンが使うっぽ。っていうかこのエンジン使って色々やりたくなるよね。。。今度いじってみよう。

そしたら、FessのZipを展開して好きなところに置いておく。

で、Fessの「bin\fess.bat」で起動する。そしたらプロンプト上がって動き出すので、ブラウザで「http://localhost:8080」につなげる。つなぐと下の感じ。

sample04
タイトルはFessのロゴが出ると思うけど、ちょっと変えてるのでモザイク。。。

とりあえず、これで動きました。なんだけど、ポートが8080で割とアレ。ポート変える。

ポート変えるの見てたら、公式のドキュメントちょっと間違ってて少しはまった。とゆーか10.2あたりから、起動の仕方全然変えてるっぽいのね。ドキュメント間に合ってなさそう。えらいこっちゃ。

ポート変えるときは「fess.in.bat」を修正。下感じでやる。

set FESS_JAVA_OPTS=%FESS_JAVA_OPTS% -Dfess.port=8080
↑のポート番号を変える。

あと、はまったのがElasticSearchのポート番号が全然わかんなかった。いちをデフォルトだと9200っぽいんだけど9200だとつながらない。

「netstat /oan」でListenしてるポート確認したら、「9201」っぽい。なる。。。

なんで、知りたいかとゆーと、クロールした結果をチャラにしたい場合、Fess側の管理メニューにそれらしきモノがないので、ElasticSearch側のインデックスを消す必要があるので。

ただ、試しに全消しやってみたら、Fessのダッシュボード動かなくなった。なんか設定でも入ってたのかしら。 要調査。

まーとりあえず、これでFessが動くようになったので、あとはクロールの設定をしていくけど、それはまた今度。眠くなってまいりましたとさ。。。

2016/12/18追記
試した中でちょっと危険な部分があったので、ちゃんと書く前に追記。

RedmineみたいなMVC構成のアプリをクロールする場合、アクションが実行される可能性があるので、 クロールの対象から除外するURLは色々やっておかないとマジで危険。

Redmine丸ごとクロールとかすると、DMSFのファイル丸ごと消えるから注意した方が吉。

問合せ