外部MantisサーバーとSVNの連携

外部にあるMantisサーバーと、WindowsマシンからアクセスできるSubversionサーバーを連動する仕組みを作ってみました。

概要

MantisにはSVNと連携する機能があります。ただし通常はSVNのコミットトリガーを利用して、SVNとMantisが同じサーバー上にあることを前提としています。

そこでローカルネットワークにあるSVNサーバーを定期的にポーリングして、外部のMantisサーバーに連携する仕組みを作ってみました。

SVNからコミット情報の取得

SVNには基本的にsvnコマンドのlogを利用して取得します。

http://d.hatena.ne.jp/akiraneko/20080211/
MantisとSubversionとの連携

この辺の記事をベースにしています。

Mantisへの連動

通常MantisサーバーからSVNにデータを取りにいく流れになりますが、インターネット上のMantisサーバーから、ローカルネットワーク上のSVNなのでこの手が利用できません。

今回はローカルネットワーク上の別マシンからSVNサーバーに情報を取得して、MantisサーバーにPOSTで送信するようにしました。

<?php

// 設定
$svn_url_top = 'http://192.168.0.100/svn/projectsA';
$svn_url_project = 'http://192.168.0.100/svn/projectsA/trunk';
$mantis_url = 'http://www.example.jp/mantis/core/svncheckin.php';
$trac_url = 'http://192.168.0.100/trac/projectsA/changeset/';
$svn_path = '"C:\Program Files\Subversion\bin\svn"';

// 処理リビジョン番号取得
$no = file_get_contents("cnt.txt") - 0;
echo "no = $no<br />\n";

// 最新リビジョン取得
$exec = "$svn_path log --incremental -v -r head $svn_url_top";
$out = exec( $exec,$output );
$last = substr( $output[1], 1, 10 ) - 0;
echo "最新リビジョン<br />\n";
var_dump($output);

// 処理と最新を比べる
if( $last < $no ){
	// すでに最新なので完了
	exit;
}

// 処理リビジョンのログ取得
$output = null;
$exec = "$svn_path log --incremental -v -r $no $svn_url_project";
$out = exec( $exec,$output );
echo "処理リビジョン<br />\n";
var_dump($output);

// TracのURLを付与
$output[] = $trac_url . $no;

// 配列の連結と、UTF-8に変換してPOST形式に変換
$output = implode( "\n", $output );
$output = mb_convert_encoding($output, "UTF-8", "sjis-win");
$post = "comment=" . urlencode( $output );

// データ送信
$res = do_post_request($mantis_url, $post );
echo "送信結果<br />\n";
var_dump($res);

// リビジョンカウントアップ
$no++;
$file = fopen("cnt.txt", "w");
fwrite($file, $no);
fclose( $file );

// POST送信関数
function do_post_request($url, $data, $optional_headers = null) 
{ 
   $params = array('http' => array(
                'method' => 'POST',
                'content' => $data
             ));
   if ($optional_headers !== null) {
      $params['http']['header'] = $optional_headers;
   }
   $ctx = stream_context_create($params);
   $fp = @fopen($url, 'rb', false, $ctx);
   if (!$fp) {
      throw new Exception("Problem with $url, $php_errormsg");
   }
   $response = @stream_get_contents($fp);
   if ($response === false) {
      throw new Exception("Problem reading data from $url,
      $php_errormsg");
   }
   return $response;
}

いきなりコードですが、SVNコマンドを利用して最新リビジョン番号を取得。最新が増えていたらコメントを取得。

最後にMantisへPOSTしてあげています。
また、コメントの最後にTRACのチェンジログのURLをつけてあげています。TRAC使っているんだったらMantisいらないじゃんって思うかもしれませんが、チケット管理はMantisでTRACSVNのログを見るビュアーとして利用しています、、、

Mantis側

上記のsvncheckin.phpが新規で作成したものになります。セキュリティーの関係でローカル以外からは実行できないようになっているので、もろもろはずします。

<?php
# Mantis - a php based bugtracking system

# Copyright (C) 2000 - 2002  Kenzaburo Ito - kenito@300baud.org
# Copyright (C) 2002 - 2007  Mantis Team   - mantisbt-dev@lists.sourceforge.net

# Mantis 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 2 of the License, or
# (at your option) any later version.
#
# Mantis 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 Mantis.  If not, see <http://www.gnu.org/licenses/>.
	# See the README and LICENSE files for details

	# --------------------------------------------------------
	# $Id: checkin.php,v 1.5.2.1 2007-10-13 22:35:16 giallu Exp $
	# --------------------------------------------------------
	global $g_bypass_headers;
	$g_bypass_headers = 1;
	require_once( dirname( dirname( __FILE__ ) ) . DIRECTORY_SEPARATOR . 'core.php' );


	# Make sure this script doesn't run via the webserver
	# @@@ This is a hack to detect php-cgi, there must be a better way.
	if ( isset( $_SERVER['SERVER_PORT'] ) ) {
//		echo "checkin.php is not allowed to run through the webserver.\n";
//		exit( 1 );
	}

	# Check that the username is set and exists
	$t_username = config_get( 'source_control_account' );
	if ( is_blank( $t_username ) || ( user_get_id_by_name( $t_username ) === false ) ) {
//		echo "Invalid source control account ('$t_username').\n";
//		exit( 1 );
	}

//	if ( !defined( "STDIN" ) ) {
//		define("STDIN", fopen('php://stdin','r'));
//	}

	# Detect references to issues + concat all lines to have the comment log.
	$t_commit_regexp = config_get( 'source_control_regexp' );
    $t_commit_fixed_regexp = config_get( 'source_control_fixed_regexp' );

	$t_comment = $_POST['comment'];
	$t_issues = array();
	$t_fixed_issues = array();
//	while ( ( $t_line = fgets( STDIN, 1024 ) ) ) {
//		$t_comment .= $t_line;

	if ( preg_match_all( $t_commit_regexp, $t_comment, $t_matches ) ) {
		for ( $i = 0; $i < count( $t_matches[0] ); ++$i ) {
			$t_issues[] = $t_matches[1][$i];
		}
	}

	if ( preg_match_all( $t_commit_fixed_regexp, $t_comment, $t_matches) ) {
		for ( $i = 0; $i < count( $t_matches[0] ); ++$i ) {
			$t_fixed_issues[] = $t_matches[1][$i];
		}
	}

	# If no issues found, then no work to do.
	if ( ( count( $t_issues ) == 0 ) && ( count( $t_fixed_issues ) == 0 ) ) {
//		echo "Comment does not reference any issues.\n";
//		exit(0);
	}

	# Login as source control user
	if ( !auth_attempt_script_login( $t_username ) ) {
//		echo "Unable to login\n";
//		exit( 1 );
	}

	# history parameters are reserved for future use.
	$t_history_old_value = '';
	$t_history_new_value = '';

	# add note to each bug only once
	$t_issues = array_unique( $t_issues );
	$t_fixed_issues = array_unique( $t_fixed_issues );

	# Call the custom function to register the checkin on each issue.
	foreach ( $t_issues as $t_issue_id ) {
		if ( !in_array( $t_issue_id, $t_fixed_issues ) ) {
			helper_call_custom_function( 'checkin', array( $t_issue_id, $t_comment, $t_history_old_value, $t_history_new_value, false ) );
		}
	}

	foreach ( $t_fixed_issues as $t_issue_id ) {
		helper_call_custom_function( 'checkin', array( $t_issue_id, $t_comment, $t_history_old_value, $t_history_new_value, true ) );
	}

	exit( 0 );
?>

とりあえず元のcheckin.phpからチェック部分をはずして、標準入力からの取得をPOSTからの取得に変更しているだけです。

このままだとセキュリティー的に厳しいですので、もう少し対策してください!
私の場合にはBASIC認証をかけて、IPアドレス制限をかけているのでここは適当に済ませています。

自動実行

上記まででコミットされた状態からチェック用のPHPを実行するとMantisに反映されるところまで完成しました。
定期的に実行されるように設定します。

今回はWindows上で実行します!
開発機は通常SVNにアクセスできる場所にありますもんね

実行は標準のタスクを利用します。

ほとんどの人がタスクは細かい設定ができないと思っていると思いますが、実は毎分も実行できます!
上記のように一日一回、0時0分にタスクが実行するように設定します。

詳細を選択

上記のような設定をします。
ポイントはタスクを繰り返し実行で継続時間を24時間にする。
間隔は1分にしていますが、本当は3分とか5分ぐらいで十分だと思います。

上記の設定によって、一日1回0時から24時間の間1分間隔で実行します。
ものすごくわかりにくいですねー

実行スクリプト

PHPを直接実行するのではなく、バッチファイルをかませて実行したいと思います。

check.bat

c:
cd c:\cron\svn

C:\xampp\php\php check.php > c:\cron\svn\out.txt

こんな感じで内部はそのままPHPで実行しています。

ウインドウを表示しないで実行

上記のままだと一瞬ダイアログが表示されますので、されないように一工夫。

SvnCheck.vbs

Dim WShell
Set WShell = WSCript.CreateObject("WScript.Shell")
WShell.Run "c:\cron\svn\check.bat",0

上記のようにVBスクリプトで実行するとウインドウが表示されなくなります。

感想

かなり便利です。チケットドリブンで開発しているので、Mantisでチケット登録。誰かが自分にアサインを変更。作業後にSVNをコミット。コミットログからMantisに連動。実装済みのチケットをチェックしてTRACSVNチェンジログをみて作業内容の確認+動作確認。確認後にチケットを完了に落とす!

上記のような感じで開発していけます

Mantisの日本語マニュアルから対訳データ取得

翻訳のための前準備として、1.0.0の日本語マニュアルと英語版のマニュアルの翻訳データを作成してみました。

htmlのパース試行

最初はSimpleXMLでDOMと取ってきてと考えていたのですが、どうもきれいにとれない。

<td class="description">
	<span class="description">Description</span>
	<br /><br />
	正しく動作するには以下のバージョンが必要です。<br />
</td>
<||

こんな構造だと「Description」は取れるけれど「正しく動作するには以下のバージョンが必要です。」は取れませんでした。

>|php|
<?php
$file_name = '1.2.3.html';
$file_data = file_get_contents( $file_name );

$doc = new DOMDocument();
$doc->loadHTML($file_data);

$xml = simplexml_load_string( $doc->saveXML() );

ちなみにこんな処理でxhtmlに変換してからじゃないとパースしてくれません。マニュアルはxhtmlで書かれているのですが、imgとかが閉じていないんですよね。

力技でのhtmlパース

文体で分けるので、こんな感じで分けました。

<?php
$file_data = file_get_contents( $filename );

$file_data = preg_replace('/<li>/', "\n</li>", $file_data );
$file_data = preg_replace('/<span/', "\n<span", $file_data );

$lines = mb_split( "\n", $file_data );

$list = array();
foreach( $lines as $item ){
	$line = trim( strip_tags( $item, '' ) );
	if( $line ){
		$list[] = $line;
	}
}

$string_list[$list[0]] = $list;

文が始まりそうなところに改行を入れて、strip_tagsで文章だけ抜き出し。最初の項目がページのタイトルなので、その名前で保存しておきます。

日本語ページと英語ページの対応チェック

日本語版は翻訳時に番号を並び替えちゃったみたいです。もしくは元にしたものがこの形式だったのかな?
ページのタイトルの翻訳表をがんばって作成して、同じページの組み合わせで項目の並び替えを行いました。

ページごとの利用文字列リスト出力

<?php
foreach( $string_list as $key => $item ){
	if( isset( $title_list[$key] ) ){
		$ja_key = $title_list[$key];
		$ct = count( $item );
		if( $ct < count( $string_list_ja[$ja_key] ) ){
			$ct = count( $string_list_ja[$ja_key] );
		}

		for( $i = 0 ; $i < $ct ; $i++ ){
			echo '"'.$item[$i] . '","' . $string_list_ja[$ja_key][$i] . '"'."\n";
		}
		echo ",\n";
		echo ",\n";
		echo ",\n";
		echo ",\n";
		echo ",\n";
	}
}

同じページは同じような文字が順番ででてくると期待してCSVに落とします。続けて処理をしますので最後に予備用の5行も一緒に出力します。

対訳を修正

ここが一番大変ですが、目視で英文と日本語で対応しているところをあわせていきます。今回はOOoのカルクを利用して、ちくちく処理を行いました。

TMXへの出力

CSVからTMXに出力します。

<?php

$filename = 'lang.csv';

$file = file_get_contents($filename);

$lines = mb_split( "\n", $file );

$glossary = array();
foreach( $lines as $lines => $line ){
	$item = mb_split( "\t", $line );
	if( trim( $item[0] ) && trim( $item[1] ) ){
		$glossary[trim( $item[0] )] = trim( $item[1] );
	}
}

$str = '';
$str .= '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$str .= '<!DOCTYPE tmx SYSTEM "tmx11.dtd">' . "\n";
$str .= '<tmx version="1.1">' . "\n";
$str .= '  <header' . "\n";
$str .= '    creationtool="OmegaT"' . "\n";
$str .= '    creationtoolversion="1.7.3_2"' . "\n";
$str .= '    segtype="sentence"' . "\n";
$str .= '    o-tmf="OmegaT TMX"' . "\n";
$str .= '    adminlang="EN-US"' . "\n";
$str .= '    srclang="EN-US"' . "\n";
$str .= '    datatype="plaintext"' . "\n";
$str .= '  >' . "\n";
$str .= '  </header>' . "\n";
$str .= '  <body>' . "\n";

foreach( $glossary as $key => $item ){
	$str .= '    <tu>' . "\n";
	$str .= '      <tuv lang="EN-US">' . "\n";
	$str .= '        <seg>' . htmlspecialchars($key) . '</seg>' . "\n";
	$str .= '      </tuv>' . "\n";
	$str .= '      <tuv lang="JA">' . "\n";
	$str .= '        <seg>' . htmlspecialchars($item) . '</seg>' . "\n";
	$str .= '      </tuv>' . "\n";
	$str .= '    </tu>' . "\n";
}

$str .= '  </body>' . "\n";
$str .= '</tmx>' . "\n";

echo $str;

とりあえず空白何もしないでそのまま出力です。

OmagaTに読み込ませる

いまいち出来上がったtmxが読み込めるか自信がなかったので読み込ませます。その後に保存して正しいフォーマットになった物で完了とします。

感想

下準備なので、このtmxだとかなりの不備があります。これを元に翻訳作業を行っていき、途中で本当に利用している単語だけにするクリーニング作業が必要になりそうですね。

とりあえず出来上がったファイルをアップしておきます。

http://akira.info/labs/mantis/project_save.tmx

あと一度編集中にページが戻ってしまい、消えてしまいました(涙) だけどバックアップ機能が最近搭載されたので、一命を取り留めました! やっぱりブログ系はある程度バックアップとか必要なんですね。自分で実装するのは面倒そうですが(笑)

Mantisの言語ファイルの翻訳手順

本体の翻訳ファイルも翻訳手順を作ってみました。

訳文の抜き出し

一般的には xgettext で抜き出しますが元に戻すことができないので、また po4a パッケージを利用させてもらいました。

ただ po4a は汎用的な読み込みってできないのでフォーマットごとに Perl のプログラムを書かないといけません。

po4a の構造

Ubuntuの場合 po4a は /usr/share/perl5/Locale/Po4a にセットアップされています。

root@ubuntu-vm:/usr/share/perl5/Locale/Po4a# ls
BibTeX.pm   Docbook.pm     LaTeX.pm  Pod.pm      Text.pm
Chooser.pm  Guide.pm       Man.pm    Sgml.pm     TransTractor.pm
Common.pm   Ini.pm         TeX.pm      Xhtml.pm  Dia.pm
KernelHelp.pm  Po.pm     Texinfo.pm  Xml.pm

こんな感じで入っています。本体と読み込みの pm が同じ場所にありますね。

po4a の読み込みフィルターの構造

http://po4a.alioth.debian.org/man/man3pm/Locale::Po4a::TransTractor.3pm.php

この辺を参考に parse() を実装すればよいのがわかります。

ベースのフィルタ選択

Text を最初に使おうかと思いましたが Ini が一番近いのでこれを利用します。といってもほぼそのまま利用できます。。。実は””で囲まれた文字を抜き出す処理なので、’’も追加してあげれば終了(笑)

フィルタ作成

基本的にはパッケージ名を変えて、if文の場所を””をコピーして’’を作ってあげるだけです。

/usr/share/perl5/Locale/Po4a/Php.pm

# Locale::Po4a::Ini -- Convert ini files to PO file, for translation.
# $Id: Ini.pm,v 1.2 2006/08/23 19:30:30 nekral-guest Exp $
#
# This program is free software; you may redistribute it and/or modify it
# under the terms of GPL (see COPYING).
#

############################################################################
# Modules and declarations
############################################################################

use Locale::Po4a::TransTractor qw(process new);
use Locale::Po4a::Common;

package Locale::Po4a::Php;

use 5.006;
use strict;
use warnings;

require Exporter;

use vars qw(@ISA @EXPORT $AUTOLOAD);
@ISA = qw(Locale::Po4a::TransTractor);
@EXPORT = qw();

my $debug=0;

sub initialize {}


sub parse {
	my $self=shift;
	my ($line,$ref);
	my $par;

	LINE:
	($line,$ref)=$self->shiftline();

	while (defined($line)) {
		chomp($line);
		print STDERR  "begin\n" if $debug;

		if ($line =~ /\"/) {
			print STDERR  "Start of line containing \".\n" if $debug;
			# Text before the first quote
			$line =~ m/(^[^"\r\n]*")/;
			my $pre_text = $1;
			print STDERR  "  PreText=".$pre_text."\n" if $debug;
			# The text for translation
			$line =~ m/("[^\r\n]*")/;
			my $quoted_text = $1;
			print STDERR  "  QuotedText=".$quoted_text."\n" if $debug;
			# Text after last quote
			$line =~ m/("[^"\n]*$)/;
			my $post_text = $1;
			print STDERR  "  PostText=".$post_text."\n" if $debug;
			# Remove starting and ending quotes from the translation text, if any
			$quoted_text =~ s/^"//g;
			$quoted_text =~ s/"$//g;
			# Translate the string it
			$par = $self->translate($quoted_text, $ref);
			# Escape the \n characters
			$par =~ s/\n/\\n/g;
			# Now push the result
			$self->pushline($pre_text.$par.$post_text."\n");
			print STDERR  "End of line containing \".\n" if $debug;
		} elsif ($line =~ /\'/) {
			print STDERR  "Start of line containing '.\n" if $debug;
			# Text before the first quote
			$line =~ m/(^[^'\r\n]*')/;
			my $pre_text = $1;
			print STDERR  "  PreText=".$pre_text."\n" if $debug;
			# The text for translation
			$line =~ m/('[^\r\n]*')/;
			my $quoted_text = $1;
			print STDERR  "  QuotedText=".$quoted_text."\n" if $debug;
			# Text after last quote
			$line =~ m/('[^'\n]*$)/;
			my $post_text = $1;
			print STDERR  "  PostText=".$post_text."\n" if $debug;
			# Remove starting and ending quotes from the translation text, if any
			$quoted_text =~ s/^'//g;
			$quoted_text =~ s/'$//g;
			# Translate the string it
			$par = $self->translate($quoted_text, $ref);
			# Escape the \n characters
			$par =~ s/\n/\\n/g;
			# Now push the result
			$self->pushline($pre_text.$par.$post_text."\n");
			print STDERR  "End of line containing '.\n" if $debug;
		}
		else
		{
			print STDERR "Other stuff\n" if $debug;
			$self->pushline("$line\n");
		}
		# Reinit the loop
		($line,$ref)=$self->shiftline();
	}
}

##############################################################################
# Module return value and documentation
##############################################################################

1;
__END__

=head1 NAME

Locale::Po4a::Ini - Convert ini files from/to PO files

=head1 DESCRIPTION

Locale::Po4a::Ini is a module to help the translation of INI files into other
[human] languages.

The module searches for lines of the following format and extracts the quoted
text:

identificator="text than can be translated"

NOTE: If the text is not quoted, it will be ignored.

=head1 SEE ALSO

L<po4a(7)|po4a.7>, L<Locale::Po4a::TransTractor(3pm)>.

=head1 AUTHORS

 Razvan Rusu <rrusu@bitdefender.com>
 Costin Stroie <cstroie@bitdefender.com>

=head1 COPYRIGHT AND LICENSE

Copyright 2006 by BitDefender

This program is free software; you may redistribute it and/or modify it
under the terms of GPL (see the COPYING file).

=cut

po ファイルの作成

po4a-gettextize -f php -m strings_english.txt > strings_japanese.po

こんな感じで php のフォーマットが利用できるようになります。

po の翻訳

OmegaTだと。。。””が付いている状態での訳文になりますね。ちょっと気持ち悪いけれど仕方ないです。がりがりと翻訳します。

po から php への翻訳文反映

po4a-translate -f php -m strings_english.txt -p strings_japanese.po >strings_japanese.txt

結構簡単にいきますね。

問題点

OmegaT 側の問題ですが””が付いている訳文になる。po の状態だと関係ないのでこれはいいとして、あとはライセンスの部分をどうするかですね。ここは手作業で書き換えるかなって思っています。sjisとeucjp版の作成もある程度手作業かな?

訳の対象が増えた場合には po をマージするとかいろいろな手順が本当はありますが、そこは翻訳メモリの機能を使って、毎回新しい po を作成。同じ翻訳文は翻訳メモリから自動設定がいいかなって思っています。

全般的にはPHPの変数で翻訳ファイルを処理しているOSSとかは同じ手順で翻訳できるかな? この他に翻訳メモリ側のノウハウとして、基準となる翻訳対応表のグロッサリーや、翻訳結果の中間方式である tmx ファイルなどを現在の翻訳ファイルからPHPで生成してあげていたりします。

あとは。。。1.1.2系から1.2.x系で結構訳文が増えています。が、、、実質的な翻訳はしていないのでカバー率は変わりません(笑)

Mantisの翻訳を考えている人がいましたら、連絡いただければお手伝いしますのでコメントしてもらえればと思います。

あと消えている可能性があるかもしれませんが、翻訳状況とコンフィグの日本語訳の対応表を作ったのであげておきます。

Mantisのドキュメントを翻訳してみる

MantisのドキュメントがDocBook形式になったので、今後は頻繁に更新されるかな? 今までは1.0.0のまま放置されてきたんですよね。

つーことで、翻訳できるか試してみました。

Windows上での変換

まずはDocBook形式からhtmlの形式に変換する環境を作ってみます。

えーっと、いろいろがんばったのですが無理でした(笑) DocBookにはSGML形式とXML形式があるのですが、XML版はまだツールがあるのですがSGML版はあまりツールないんですよね。。。

Linux上での変換

docbook* のパッケージを全部入れます。makeも使えるようにします。

cd docbook/adminguide/en
make html

Linux上だと普通に変換できます。今回はVMwareのUbuntu8.04を利用しています。ここまででCygwin上で変換しようとして失敗して、coLinux上とかいろいろ試してかなーーーーり時間をつぶしていますがWindows上でやるのであれば現状はVMware+Ubuntuが楽かな。CentOSFedoraの方が好みなのですが今風に行きます(笑)

翻訳ツールの選定

http://www.omegat.org/

OmegaTを今回利用します。昔から少し触っているのですが本格的に利用したことないかも。。。選定理由がDocBookも対応しているため!

翻訳ツールその後

DocBook/XMLは対応していたけれどDocBook/SGMLは対応していなかったよ(涙)

DocBook/XMLへの変換

http://www.jclark.com/sp/index.htm

いろいろ調べたところ上記のSGMLパーサーの中にsxという変換ツールがありました。Ubuntuで探してみると追加パッケージを入れろといわれて入れてみる。。。

結果というとXMODEMとかの転送モードがでてきた? これって他のパッケージだね。よくよく調べたところsgml2xmlという名前に変更されているようでした(笑)

sgml2xml administration_guide.sgml > administration_guide.xml

上記で変換できます。ただ結構吐き出されるxmlが汚い。。。あとDOCTYPEがなくなるので手で追加します。

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE book PUBLIC "-//OASIS//DTD DocBook XML V4.2//EN" "docbookx.dtd">

これでOmegaTに読み込める形式になりました。読み込んでみると。。。。。。。。。。。あれ?

docbookのタグ理解してなくて、かなりでかい単位で分割される。。。これでは使えませんね。

po形式に変換する

もう少し一般的な翻訳中間形式であるgettext形式に変換です。

cd docbook/developers
cp -r en ja
cd ja
po4a-gettextize -f sgml -m developers.sgml -p developers.po -o 'ignore=CODE' -o 'translate=COLOPHON'

po4aというパッケージをインストールします。

http://po4a.alioth.debian.org/man/man1/po4a-gettextize.1.php.ja

このページが非常に参考になりました。CODEとか何個かのタグが利用できなかったのでオプションで無視設定する必要があります。

翻訳

OmegaTにdevelopers.poを読み込ませてみるときれいに分割できました。これで大丈夫ですね。

po形式からsgml形式に戻す

cd docbook/developers/ja
po4a-translate -f sgml -m ../en/developers.sgml -p developers.po -o 'ignore=CODE' > developers.sgml

元のファイルを要求するのでこんな感じかな。

変換

makeをすれば出来上がるはず! ただ文字コードの指定を途中でしてなかったのでsjisでコードが吐き出されちゃいました。poファイルはEUCJPで作成してあげましょう。SGMLって日本語はEUCJPだけみたいなんですよね。。。

感想

書いていないのですが、Velocity DocBook Frameworkを試してみたりいろいろなツールを入れてみたり非常に大変でした。あまり日本語の情報ってないんですよね。

これとは別に本体の翻訳ファイル群があるのでそれから翻訳用のグロッサリーを作ってとか、1.0.0の翻訳したマニュアルもあるのでそこから翻訳を持ってくるなどいろいろやらないとだめみたいですね。ただし翻訳の流れは上記のような感じでできるかな。。。

問題は自分で翻訳自体までしている時間はないってことですね(苦笑)

Mantisのメールキューをまとめ送りに改造

Mantisのメールアラートは非常に便利なのですが、結構届きすぎるんですよね。
チケットを登録して、担当をアサインして、よくみたら間違っていたので修正してとどんどん同じチケットのメールが届きます(笑)

まとめ送りとは?

MLとかのまとめ送りを最初イメージしたのですが、さすがにチケットをすべて1本にまとめて一時間に一本送るのは乱暴かなって思って、同一のチケットをまとめて送るようにしました。今回は30分ごとにcronで起動する想定ですが、毎分チェックして送信後30分は送らないなどの処理の方がいいかもしれません。

処理の概要

まとめ送りようの処理をコピーして、複数のメールをまとめて送っています。内容的には結構シンプルに対応が可能です。send_emails.phpをベースにしています。1.1.2ではこのファイルがあるのですが、1.2.0aだとこのファイルなくなっていますね。。。でもコピーしたら動きました!

処理

email_send_all_one関数が新規で作成したものです。送信先と題名でグルーピングして本文をくっつけて送信しています。くっつけた分のメールはemail_queue_delete関数を使って消してあげます。

<?php
#!/usr/local/bin/php -q
# Mantis - a php based bugtracking system

# Copyright (C) 2000 - 2002  Kenzaburo Ito - kenito@300baud.org
# Copyright (C) 2002 - 2007  Mantis Team   - mantisbt-dev@lists.sourceforge.net

# Mantis 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 2 of the License, or
# (at your option) any later version.
#
# Mantis 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 Mantis.  If not, see <http://www.gnu.org/licenses/>.
	# See the README and LICENSE files for details

	# --------------------------------------------------------
	# $Id: send_emails.php,v 1.1.2.2 2007-10-26 13:36:15 giallu Exp $
	# --------------------------------------------------------

	global $g_bypass_headers;
	$g_bypass_headers = 1;

	require_once( dirname( dirname( __FILE__ ) ) . DIRECTORY_SEPARATOR . 'core.php' );

	$t_core_path = config_get( 'core_path' );

	require_once( $t_core_path . 'email_api.php' );

	# Make sure this script doesn't run via the webserver
	# @@@ This is a hack to detect php-cgi, there must be a better way.
	if ( isset( $_SERVER['SERVER_PORT'] ) ) {
		echo "send_emails.php is not allowed to run through the webserver.\n";
		exit( 1 );
	}

	echo "Sending emails...\n";
	email_send_all_one();
	echo "Done.\n";

	exit( 0 );

	function email_send_all_one() {
		$t_ids = email_queue_get_ids();

		$t_start = microtime_float();
		$t_email_list = array();
		foreach ( $t_ids as $t_id ) {

			$t_email_data = email_queue_get( $t_id );

			if( $t_email_data == null ){
				continue;
			}

			$email		= $t_email_data->email;
			$subject	= $t_email_data->subject;
			$email_id	= $t_email_data->email_id;

			$t_email_list[$subject][$email][] = $email_id;

		}

		foreach ( $t_email_list as $t_subject_list ) {

			foreach ( $t_subject_list as $t_email_list ) {

				if( count( $t_email_list ) == 1 ){
					// single send
					$t_email_data = email_queue_get( $t_email_list[0] );

					if ( !email_send( $t_email_data ) ) {
						echo "single send error ". $t_email_list[0] ."\n";

						if ( microtime_float() - $t_start > 5)
							break;
						else 
							continue;
					} else {
						echo "single send ". $t_email_list[0] ."\n";
					}

				} else {
					// multi send
					$t_email_body_list = array();
					foreach ( $t_email_list as $t_id ) {
						$t_email_body_list[] = email_queue_get( $t_id )->body;
					}
	
					$t_email_body = count($t_email_list) . "Mails Send.\n\n" . implode( "\n\n-------------------------------------------------\n\n", $t_email_body_list );

					$t_email_data = email_queue_get( $t_email_list[0] );
					$t_email_data->body = $t_email_body;

					if ( !email_send( $t_email_data ) ) {
						echo "multi send error ". implode( ", ", $t_email_list ) ."\n";
						if ( microtime_float() - $t_start > 5)
							break;
						else 
							continue;
					} else {
						echo "multi send ". implode( ", ", $t_email_list ) ."\n";

						while( array_shift($t_email_list) ){
							// mail delete(fast mail_id is excluded. )
							email_queue_delete( $t_email_list[0] );
						}
					}

				}
			}

		}

	}

事前設定

<?php
$g_email_send_using_cronjob = true;

上記の設定でまとめ送りをするようにしてから、コードをcoreの中にsend_emails_one.phpをコピーします。あとは通常の send_emails.php と同じように cron とかで定期的に呼び出してあげます。短くても10分から30分ぐらいの間隔にした方がいいと思います。

実験はWeb経由で実行していたのでコマンド以外の場合は exit( 1 ); で終了しているところをコメントアウトして、core の .htaccess を消して実験していました。

感想

いろいろ送り方は設定できると思いますが、今回はこんな感じにしてみました。本当は翻訳ファイルとかみて処理を作った方がきれいですがハードコーディングしちゃっています。(よく見たらメールの区切りとかも設定ファイルにあります。。。)

1.1.2と1.2.0a1で動作確認しています。

Mantis1.1.2 のグラフを JpGraph から Google Chart に変更

JpGraph は商用利用が無料じゃないのと日本語フォントを入れたりいろいろ設定が面倒なので Google Chat で描画するようにしてみました。

参照サイト

http://code.google.com/intl/ja/apis/chart/

昔は漢字が表示できませんでしたが、今はばりばり普通に利用できます。

棒グラフ

処理的には横の最高値を取って、最高からの61段階でデータを生成します。その後 Google Chart の URL を生成して file_get_contents で取得後ヘッダーを出力してから画像本体を出力します。

<?php
function graph_bar( $p_metrics, $p_title='', $p_graph_width = 350, $p_graph_height = 400 ){

	$max = 1;
	foreach( $p_metrics as $key => $item ){
		$item = floor( $item );
		$p_metrics[$key] = $item;
	
		if( $max < $item ){
			$max = $item;
		}
	}

	if( $max < 10 ){
		$max = 10;
	} elseif( $max < 50 ){
		$max = 50;
	} elseif( $max < 100 ){
		$max = 100;
	} elseif( $max < 1000 ){
		$max = ( floor( $max / 100 ) + 1 ) * 100;
	} else {
		$max = ( floor( $max / 1000 ) + 1 ) * 1000;
	}

	$step = array();
	for( $i = 0 ; $i < 6 ; $i++ ){
		$step[] = floor( ( $max / 5 ) * $i );
	}

	$data = "chd=s:";
	$name_data = "";
	foreach( $p_metrics as $key => $item ){
		$data .= substr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", $item/$max*61,1);
	}

	$keys = array();
	$i = 0;
	foreach( $p_metrics as $key => $item ){
		$keys[] = 't' . urlencode($key) . '(' . $p_metrics[$key] . ')' . ',000000,0,'.$i.',13,1';
		$i++;
	}
	$name_data .= implode( "|", $keys );

	$param = array();
	$param[] = 'cht=bhg';
	$param[] = 'chtt='.urlencode($p_title);
	$param[] = 'chts=0000FF,16';
	$param[] = 'chf=bg,s,fafafa';
	$param[] = 'chs='.$p_graph_width.'x'.floor($p_graph_height);
	$param[] = $data;
	$param[] = 'chm='.$name_data;
	$param[] = 'chxt=x';
	$param[] = 'chxl=0:|' . implode( '|', $step );
	$param[] = 'chg=20,0';

	$url = 'http://chart.apis.google.com/chart?' . implode( '&', $param );
	$chart = file_get_contents($url);

	header("Content-type: image/png");
	header("Cache-control: no-cache");

	echo $chart;

}

円グラフ

円グラフで注意しないといけないのが Google Chart の円グラフは横に長いことが前提になっています。キャプションが横に伸びるので横幅の半分以下の縦幅に縮小しています。

あとは合計を計算して、合計に占める割合でデータを作ってあげます。その後は棒グラフと一緒です。

<?php
function graph_pie( $p_metrics, $p_title='',
		$p_graph_width = 500, $p_graph_height = 350, $p_center = 0.4, $p_poshorizontal = 0.10, $p_posvertical = 0.09 ){

	if( $p_graph_width * 0.50 < $p_graph_height ){
		$p_graph_height = $p_graph_width * 0.50;
	}

	$sum = 0;
	foreach( $p_metrics as $key => $item ){
		$item = floor( $item );
		$p_metrics[$key] = $item;
	
		$sum += $item;
	}
	if( $sum == 0 ){
		$sum = 1;
	}

	$data = "chd=s:";
	$name_data = "";
	foreach( $p_metrics as $key => $item ){
		$data .= substr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", $item/$sum*61,1);
	}

	$keys = array();
	$i = 0;
	foreach( $p_metrics as $key => $item ){
		$keys[] = urlencode($key) . '(' . $p_metrics[$key] . ')';
	}
	$name_data .= implode( "|", $keys );

	$param = array();
	$param[] = 'cht=p';
	$param[] = 'chl='.$name_data;
	$param[] = 'chtt='.urlencode($p_title);
	$param[] = 'chts=0000FF,16';
	$param[] = 'chf=bg,s,fafafa';
	$param[] = 'chs='.$p_graph_width.'x'.floor($p_graph_height);
	$param[] = $data;

	$url = 'http://chart.apis.google.com/chart?' . implode( '&', $param );
	$chart = file_get_contents($url);

	header("Content-type: image/png");
	header("Cache-control: no-cache");

	echo $chart;
}

積層グラフ

本物は謎なグラフなのですが、普通の積層グラフにしてみました。処理はデータが複数追加しているぐらいで棒グラフと一緒ですね。

<?php
function graph_group( $p_metrics, $p_title='', $p_graph_width = 350, $p_graph_height = 400, $p_baseline = 100 ){
	# $p_metrics is an array of three arrays
	#   $p_metrics['open'] = array( 'enum' => value, ...)
	#   $p_metrics['resolved']
	#   $p_metrics['closed']

	$max = 1;
	$sum_list = array();
	$item_list = array();
	foreach( $p_metrics as $key => $item ){
		$sum = 0;
		foreach( $item as $key2 => $item2 ){
			$item = floor( $item2 );
			$sum_list[$key2] += $item;
			$item_list[$key2][$key] = $item;

			if( $max < $sum_list[$key2] ){
				$max = $sum_list[$key2];
			}
		}
	}

	if( $max < 10 ){
		$max = 10;
	} elseif( $max < 50 ){
		$max = 50;
	} elseif( $max < 100 ){
		$max = 100;
	} elseif( $max < 1000 ){
		$max = ( floor( $max / 100 ) + 1 ) * 100;
	} else {
		$max = ( floor( $max / 1000 ) + 1 ) * 1000;
	}

	$step = array();
	for( $i = 0 ; $i < 6 ; $i++ ){
		$step[] = floor( ( $max / 5 ) * $i );
	}

	$data = "chd=t:";
	$name_data = "";
	$items = array();;
	foreach( $p_metrics as $key => $item ){
		$item_str = array();
		foreach( $item as $key2 => $item2 ){
			$item_str[] = $item2/$max*100;
		}

		$items[] = implode( ',', $item_str );
	}
	$data .= implode( '|', $items );

	$keys = array();
	$i = 0;
	foreach( $item_list as $key => $item ){
		$keys[] = 't' . urlencode($key) . '(' . $sum_list[$key] . ')' . implode(':', $item_list[$key]) . ',000000,0,'.$i.',13,1';
		$i++;
	}
	$name_data .= implode( "|", $keys );

	$param = array();
	$param[] = 'cht=bhs';
	$param[] = 'chtt='.urlencode($p_title);
	$param[] = 'chts=0000FF,16';
	$param[] = 'chf=bg,s,fafafa';
	$param[] = 'chs='.$p_graph_width.'x'.floor($p_graph_height);
	$param[] = $data;
	$param[] = 'chm='.$name_data;
	$param[] = 'chxt=x';
	$param[] = 'chxl=0:|' . implode( '|', $step );
	$param[] = 'chg=20,0';
	$param[] = 'chco=4d89f9,c6d9fd,c6d900,c6d988';
	$param[] = 'chdl=' . lang_get( 'legend_opened' ) . '|' . lang_get( 'legend_closed' ) . '|' . lang_get( 'legend_resolved' );

	$url = 'http://chart.apis.google.com/chart?' . implode( '&', $param );
	$chart = file_get_contents($url);

	header("Content-type: image/png");
	header("Cache-control: no-cache");

	echo $chart;

}

折れ線グラフ その1

これは検索画面の折れ線と似ているけれどタイトルとかが直指定だったため、検索画面の折れ線用のデータ形式に変換してから検索画面の方を呼び出しています。

<?php
function graph_cumulative_bydate( $p_metrics, $p_graph_width = 300, $p_graph_height = 380 ){

	$key_list = array_keys($p_metrics);
	for( $date = $key_list[0] ; $date < $key_list[count($key_list)-2] ; $date += 60*60*24 ){
		if( !isset( $p_metrics[$date] ) ){
			$p_metrics[$date] = $p_metrics[$last];
		}
		$last = $date;
	}
	ksort($p_metrics);

	$p_title = lang_get( 'cumulative' );
        $p_labels = array( lang_get( 'legend_reported' ), lang_get( 'legend_resolved' ), lang_get( 'legend_still_open' ) );

	$data = array();
	$data[0] = array();
	foreach( $p_metrics as $key => $item ){
		$data[0][] = $key;

		foreach( $item as $key2 => $item2 ){
			$data[$key2+1][] = $item2;
		}
	}

	graph_bydate( $data, $p_labels, $p_title, $p_graph_width, $p_graph_height );
}

折れ線グラフ その2

先頭で画像の多きさチェックをいれています。600*500とか30000以内の画像しか Google Chart は作成できないのですが、検索画面は越えているんですよね。だから少し縦を縮小しています。この手のグラフは横は触ってはだめですが縦で調整ができる構造にした方がいいと思います。

元の描画部分がその1で作った物を流用して、その後統一したのでデータ形式をその1に変換かけています(苦笑) あと今週で検索を行うと一時間ごとのデータを取り出してくるので前回から4時間以上経過していないデータは無視するコードでデータ量を減らしています。この辺が Google Chart の限界だと思います。

あとは色の自動生成部分がいけてないのですがこの辺で妥協しています。。。

<?php
function graph_bydate( $p_metrics, $p_labels, $p_title, $p_graph_width = 300, $p_graph_height = 380 ){

	if( 300000 < $p_graph_width * $p_graph_height ){
		$p_graph_height = 300000 / $p_graph_width;
	}

	$p_metrics0 = $p_metrics;
	$p_metrics = array();
	foreach( $p_metrics0[0] as $key => $item ){
		$data = array();
		for( $i = 0 ; $i < count( $p_metrics0 ) - 1 ; $i++ ){
			$data[$i] = $p_metrics0[$i+1][$key];
		}
		$p_metrics[$item] = $data;
	}

	// skip
	$start = 0;
	foreach( $p_metrics as $key => $item ){
		if( ( $start + 60*60*4 ) <= $key ){
			$start = $key;
		} else {
			unset( $p_metrics[$key] );
		}
	}

	$max = 1;
	$sum_list = array();
	$item_list = array();
	$x_list = array();
	foreach( $p_metrics as $key => $item ){
		$sum = 0;
		foreach( $item as $key2 => $item2 ){
			$item = floor( $item2 );
			$sum_list[$key2] += $item;
			$item_list[$key2][$key] = $item;

			if( $max < $item ){
				$max = $item;
			}
		}

		$x_list[] = date("n/j", $key);
	}

	if( $max < 10 ){
		$max = 10;
	} elseif( $max < 50 ){
		$max = 50;
	} elseif( $max < 100 ){
		$max = 100;
	} elseif( $max < 1000 ){
		$max = ( floor( $max / 100 ) + 1 ) * 100;
	} else {
		$max = ( floor( $max / 1000 ) + 1 ) * 1000;
	}

	$step = array( '' );
	for( $i = 1 ; $i < 6 ; $i++ ){
		$step[] = floor( ( $max / 5 ) * $i );
	}

	$data = "chd=s:";
	$name_data = "";
	$items = array();;
	foreach( $item_list as $key => $item ){
		$item_str = "";
		foreach( $item as $key2 => $item2 ){
			$item_str .= substr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", $item2/$max*61,1);
		}

		$items[] = $item_str;
	}
	$data .= implode( ',', $items );

	$keys = array();
	$i = 0;
	foreach( $item_list as $key => $item ){
		$keys[] = 't' . urlencode($key) . '(' . $sum_list[$key] . ')' . implode(':', $item_list[$key]) . ',000000,0,'.$i.',13,1';
		$i++;
	}
	$name_data .= implode( "|", $keys );

	$count = count( $x_list );
	if( 15 < $count ){
		$cut = floor( ($count+5) / 10 );
		foreach( $x_list as $key => $item ){
			if( $key % $cut != 0 ){
				unset( $x_list[$key] );
			}
		}
	}

	$colors = array();
	$r = 0x80;
	$g = 0x80;
	$b = 0x40;
	for( $i = 0 ; $i < count( $p_labels ) ; $i++ ){
		$r += 0x80;
		if( 0xff < $r ){
			$r = 0x40;
			$g += 0x80;
		}
		if( 0xff < $g ){
			$g = 0x40;
			$b += 0x80;
		}
		if( 0xff < $b ){
			$b = 0x40;
		}

		$colors[] = sprintf( "%02x%02x%02x", $r, $g, $b );
	}

	$param = array();
	$param[] = 'cht=lc';
	$param[] = 'chtt='.urlencode($p_title . ' ' . lang_get( 'by_date' ));
	$param[] = 'chts=0000FF,16';
	$param[] = 'chf=bg,s,fafafa';
	$param[] = 'chs='.$p_graph_width.'x'.floor($p_graph_height);
	$param[] = $data;
	$param[] = 'chxt=y,x';
	$param[] = 'chxl=0:|' . implode( '|', $step ) . '|1:|' . implode( '|', $x_list );
	$param[] = 'chg='.floor(100/(count($x_list)-1)).',20';
	$param[] = 'chco=' . implode( ',', $colors );
	$param[] = 'chdl=' . urlencode( implode( "|", $p_labels ) );

	$url = 'http://chart.apis.google.com/chart?' . implode( '&', $param );
	$chart = file_get_contents($url);

//var_dump($url);echo "<br /><img src=$url><br />";

	header("Content-type: image/png");
	header("Cache-control: no-cache");

	echo $chart;

}

オリジナルのグラフ

下の文字が斜めになっていますが、Google Chart だとつらかったので棒グラフ自体を横に変更しています。

円グラフは凡例取っちゃいました。

積層はサンプル画像がわかりにくいのですが、合計数の高さでグラフがあって、各状態ごとに横に並んでいます。

棒グラフも元のデザインから結構変わっていますね。

事前設定

config_inc.php でグラフを有効にします。ここを設定しないとグラフ描画のリンクが表示されません。

<?php
$g_use_jpgraph  = ON;

全コード

/core/graph_api.php を上書きすることで更新ができます。ただし1.1.2用ですので1.2.xで利用するのは真ん中ぐらいにある utilities より上のグラフ描画部分のみ更新してください。

<?php
# Mantis - a php based bugtracking system

# Copyright (C) 2000 - 2002  Kenzaburo Ito - kenito@300baud.org
# Copyright (C) 2002 - 2007  Mantis Team   - mantisbt-dev@lists.sourceforge.net

# Mantis 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 2 of the License, or
# (at your option) any later version.
#
# Mantis 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 Mantis.  If not, see <http://www.gnu.org/licenses/>.

	# --------------------------------------------------------
	# $Id: graph_api.php,v 1.36.2.1 2007-10-13 22:35:29 giallu Exp $
	# --------------------------------------------------------

	if ( ON == config_get( 'use_jpgraph' ) ) {
	}

	function graph_get_font() {
	}

	### Graph API ###
	# --------------------
	# graphing routines
	# --------------------
	function graph_bar( $p_metrics, $p_title='', $p_graph_width = 350, $p_graph_height = 400 ){

		$max = 1;
		foreach( $p_metrics as $key => $item ){
			$item = floor( $item );
			$p_metrics[$key] = $item;
		
			if( $max < $item ){
				$max = $item;
			}
		}

		if( $max < 10 ){
			$max = 10;
		} elseif( $max < 50 ){
			$max = 50;
		} elseif( $max < 100 ){
			$max = 100;
		} elseif( $max < 1000 ){
			$max = ( floor( $max / 100 ) + 1 ) * 100;
		} else {
			$max = ( floor( $max / 1000 ) + 1 ) * 1000;
		}

		$step = array();
		for( $i = 0 ; $i < 6 ; $i++ ){
			$step[] = floor( ( $max / 5 ) * $i );
		}

		$data = "chd=s:";
		$name_data = "";
		foreach( $p_metrics as $key => $item ){
			$data .= substr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", $item/$max*61,1);
		}

		$keys = array();
		$i = 0;
		foreach( $p_metrics as $key => $item ){
			$keys[] = 't' . urlencode($key) . '(' . $p_metrics[$key] . ')' . ',000000,0,'.$i.',13,1';
			$i++;
		}
		$name_data .= implode( "|", $keys );

		$param = array();
		$param[] = 'cht=bhg';
		$param[] = 'chtt='.urlencode($p_title);
		$param[] = 'chts=0000FF,16';
		$param[] = 'chf=bg,s,fafafa';
		$param[] = 'chs='.$p_graph_width.'x'.floor($p_graph_height);
		$param[] = $data;
		$param[] = 'chm='.$name_data;
		$param[] = 'chxt=x';
		$param[] = 'chxl=0:|' . implode( '|', $step );
		$param[] = 'chg=20,0';

		$url = 'http://chart.apis.google.com/chart?' . implode( '&', $param );
		$chart = file_get_contents($url);

		header("Content-type: image/png");
		header("Cache-control: no-cache");

		echo $chart;

	}

	# Function which displays the charts using the absolute values according to the status (opened/closed/resolved)
	function graph_group( $p_metrics, $p_title='', $p_graph_width = 350, $p_graph_height = 400, $p_baseline = 100 ){
		# $p_metrics is an array of three arrays
		#   $p_metrics['open'] = array( 'enum' => value, ...)
		#   $p_metrics['resolved']
		#   $p_metrics['closed']

		$max = 1;
		$sum_list = array();
		$item_list = array();
		foreach( $p_metrics as $key => $item ){
			$sum = 0;
			foreach( $item as $key2 => $item2 ){
				$item = floor( $item2 );
				$sum_list[$key2] += $item;
				$item_list[$key2][$key] = $item;

				if( $max < $sum_list[$key2] ){
					$max = $sum_list[$key2];
				}
			}
		}

		if( $max < 10 ){
			$max = 10;
		} elseif( $max < 50 ){
			$max = 50;
		} elseif( $max < 100 ){
			$max = 100;
		} elseif( $max < 1000 ){
			$max = ( floor( $max / 100 ) + 1 ) * 100;
		} else {
			$max = ( floor( $max / 1000 ) + 1 ) * 1000;
		}

		$step = array();
		for( $i = 0 ; $i < 6 ; $i++ ){
			$step[] = floor( ( $max / 5 ) * $i );
		}

		$data = "chd=t:";
		$name_data = "";
		$items = array();;
		foreach( $p_metrics as $key => $item ){
			$item_str = array();
			foreach( $item as $key2 => $item2 ){
				$item_str[] = $item2/$max*100;
			}

			$items[] = implode( ',', $item_str );
		}
		$data .= implode( '|', $items );

		$keys = array();
		$i = 0;
		foreach( $item_list as $key => $item ){
			$keys[] = 't' . urlencode($key) . '(' . $sum_list[$key] . ')' . implode(':', $item_list[$key]) . ',000000,0,'.$i.',13,1';
			$i++;
		}
		$name_data .= implode( "|", $keys );

		$param = array();
		$param[] = 'cht=bhs';
		$param[] = 'chtt='.urlencode($p_title);
		$param[] = 'chts=0000FF,16';
		$param[] = 'chf=bg,s,fafafa';
		$param[] = 'chs='.$p_graph_width.'x'.floor($p_graph_height);
		$param[] = $data;
		$param[] = 'chm='.$name_data;
		$param[] = 'chxt=x';
		$param[] = 'chxl=0:|' . implode( '|', $step );
		$param[] = 'chg=20,0';
		$param[] = 'chco=4d89f9,c6d9fd,c6d900,c6d988';
		$param[] = 'chdl=' . lang_get( 'legend_opened' ) . '|' . lang_get( 'legend_closed' ) . '|' . lang_get( 'legend_resolved' );

		$url = 'http://chart.apis.google.com/chart?' . implode( '&', $param );
		$chart = file_get_contents($url);

		header("Content-type: image/png");
		header("Cache-control: no-cache");

		echo $chart;

	}

	# --------------------
	# Function that displays charts in % according to the status
	# @@@ this function is not used...
	function graph_group_pct( $p_title='', $p_graph_width = 350, $p_graph_height = 400 ){
	}

	# --------------------
	# Function that displays pie charts
	function graph_pie( $p_metrics, $p_title='',
			$p_graph_width = 500, $p_graph_height = 350, $p_center = 0.4, $p_poshorizontal = 0.10, $p_posvertical = 0.09 ){

		if( $p_graph_width * 0.50 < $p_graph_height ){
			$p_graph_height = $p_graph_width * 0.50;
		}

		$sum = 0;
		foreach( $p_metrics as $key => $item ){
			$item = floor( $item );
			$p_metrics[$key] = $item;
		
			$sum += $item;
		}
		if( $sum == 0 ){
			$sum = 1;
		}

		$data = "chd=s:";
		$name_data = "";
		foreach( $p_metrics as $key => $item ){
			$data .= substr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", $item/$sum*61,1);
		}

		$keys = array();
		$i = 0;
		foreach( $p_metrics as $key => $item ){
			$keys[] = urlencode($key) . '(' . $p_metrics[$key] . ')';
		}
		$name_data .= implode( "|", $keys );

		$param = array();
		$param[] = 'cht=p';
		$param[] = 'chl='.$name_data;
		$param[] = 'chtt='.urlencode($p_title);
		$param[] = 'chts=0000FF,16';
		$param[] = 'chf=bg,s,fafafa';
		$param[] = 'chs='.$p_graph_width.'x'.floor($p_graph_height);
		$param[] = $data;

		$url = 'http://chart.apis.google.com/chart?' . implode( '&', $param );
		$chart = file_get_contents($url);

		header("Content-type: image/png");
		header("Cache-control: no-cache");

		echo $chart;
	}

	# --------------------
	function graph_cumulative_bydate( $p_metrics, $p_graph_width = 300, $p_graph_height = 380 ){

		$key_list = array_keys($p_metrics);
		for( $date = $key_list[0] ; $date < $key_list[count($key_list)-2] ; $date += 60*60*24 ){
			if( !isset( $p_metrics[$date] ) ){
				$p_metrics[$date] = $p_metrics[$last];
			}
			$last = $date;
		}
		ksort($p_metrics);

		$p_title = lang_get( 'cumulative' );
		$p_labels = array( lang_get( 'legend_reported' ), lang_get( 'legend_resolved' ), lang_get( 'legend_still_open' ) );

		$data = array();
		$data[0] = array();
		foreach( $p_metrics as $key => $item ){
			$data[0][] = $key;

			foreach( $item as $key2 => $item2 ){
				$data[$key2+1][] = $item2;
			}
		}

		graph_bydate( $data, $p_labels, $p_title, $p_graph_width, $p_graph_height );
	}


	# --------------------
	function graph_bydate( $p_metrics, $p_labels, $p_title, $p_graph_width = 300, $p_graph_height = 380 ){

		if( 300000 < $p_graph_width * $p_graph_height ){
			$p_graph_height = 300000 / $p_graph_width;
		}

		$p_metrics0 = $p_metrics;
		$p_metrics = array();
		foreach( $p_metrics0[0] as $key => $item ){
			$data = array();
			for( $i = 0 ; $i < count( $p_metrics0 ) - 1 ; $i++ ){
				$data[$i] = $p_metrics0[$i+1][$key];
			}
			$p_metrics[$item] = $data;
		}

		// skip
		$start = 0;
		foreach( $p_metrics as $key => $item ){
			if( ( $start + 60*60*4 ) <= $key ){
				$start = $key;
			} else {
				unset( $p_metrics[$key] );
			}
		}

		$max = 1;
		$sum_list = array();
		$item_list = array();
		$x_list = array();
		foreach( $p_metrics as $key => $item ){
			$sum = 0;
			foreach( $item as $key2 => $item2 ){
				$item = floor( $item2 );
				$sum_list[$key2] += $item;
				$item_list[$key2][$key] = $item;

				if( $max < $item ){
					$max = $item;
				}
			}

			$x_list[] = date("n/j", $key);
		}

		if( $max < 10 ){
			$max = 10;
		} elseif( $max < 50 ){
			$max = 50;
		} elseif( $max < 100 ){
			$max = 100;
		} elseif( $max < 1000 ){
			$max = ( floor( $max / 100 ) + 1 ) * 100;
		} else {
			$max = ( floor( $max / 1000 ) + 1 ) * 1000;
		}

		$step = array( '' );
		for( $i = 1 ; $i < 6 ; $i++ ){
			$step[] = floor( ( $max / 5 ) * $i );
		}

		$data = "chd=s:";
		$name_data = "";
		$items = array();;
		foreach( $item_list as $key => $item ){
			$item_str = "";
			foreach( $item as $key2 => $item2 ){
				$item_str .= substr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", $item2/$max*61,1);
			}

			$items[] = $item_str;
		}
		$data .= implode( ',', $items );

		$keys = array();
		$i = 0;
		foreach( $item_list as $key => $item ){
			$keys[] = 't' . urlencode($key) . '(' . $sum_list[$key] . ')' . implode(':', $item_list[$key]) . ',000000,0,'.$i.',13,1';
			$i++;
		}
		$name_data .= implode( "|", $keys );

		$count = count( $x_list );
		if( 15 < $count ){
			$cut = floor( ($count+5) / 10 );
			foreach( $x_list as $key => $item ){
				if( $key % $cut != 0 ){
					unset( $x_list[$key] );
				}
			}
		}

		$colors = array();
		$r = 0x80;
		$g = 0x80;
		$b = 0x40;
		for( $i = 0 ; $i < count( $p_labels ) ; $i++ ){
			$r += 0x80;
			if( 0xff < $r ){
				$r = 0x40;
				$g += 0x80;
			}
			if( 0xff < $g ){
				$g = 0x40;
				$b += 0x80;
			}
			if( 0xff < $b ){
				$b = 0x40;
			}

			$colors[] = sprintf( "%02x%02x%02x", $r, $g, $b );
		}

		$param = array();
		$param[] = 'cht=lc';
		$param[] = 'chtt='.urlencode($p_title . ' ' . lang_get( 'by_date' ));
		$param[] = 'chts=0000FF,16';
		$param[] = 'chf=bg,s,fafafa';
		$param[] = 'chs='.$p_graph_width.'x'.floor($p_graph_height);
		$param[] = $data;
		$param[] = 'chxt=y,x';
		$param[] = 'chxl=0:|' . implode( '|', $step ) . '|1:|' . implode( '|', $x_list );
		$param[] = 'chg='.floor(100/(count($x_list)-1)).',20';
		$param[] = 'chco=' . implode( ',', $colors );
		$param[] = 'chdl=' . urlencode( implode( "|", $p_labels ) );

		$url = 'http://chart.apis.google.com/chart?' . implode( '&', $param );
		$chart = file_get_contents($url);

//var_dump($url);echo "<br /><img src=$url><br />";

		header("Content-type: image/png");
		header("Cache-control: no-cache");

		echo $chart;

	}
	
	
	# --------------------
	# utilities
	# --------------------
	function graph_total_metrics( $p_metrics ){
		foreach ( $p_metrics['open'] as $t_enum => $t_value ) {
			$total[$t_enum] = $t_value + $p_metrics['resolved'][$t_enum] + $p_metrics['closed'][$t_enum];
		}
		return $total;
	}



	# --------------------
	# Data Extractions
	# --------------------
	# --------------------
	# summarize metrics by a single field in the bug table
	function create_bug_enum_summary( $p_enum_string, $p_enum ) {
		$t_project_id = helper_get_current_project();
		$t_bug_table = config_get( 'mantis_bug_table' );
		$t_user_id = auth_get_current_user_id();
		$specific_where = " AND " . helper_project_specific_where( $t_project_id, $t_user_id );

		$t_arr = explode_enum_string( $p_enum_string );
		$enum_count = count( $t_arr );
		for ($i=0;$i<$enum_count;$i++) {
			$t_s = explode_enum_arr( $t_arr[$i] );
			$c_s[0] = addslashes($t_s[0]);
			$t_key = get_enum_to_string( $p_enum_string, $t_s[0] );

			$query = "SELECT COUNT(*)
					FROM $t_bug_table
					WHERE $p_enum='$c_s[0]' $specific_where";
			$result = db_query( $query );
			$t_metrics[$t_key] = db_result( $result, 0 );
		} # end for
		return $t_metrics;
	}

	# Function which gives the absolute values according to the status (opened/closed/resolved)
	function enum_bug_group( $p_enum_string, $p_enum ) {
		$t_bug_table = config_get( 'mantis_bug_table' );

		$t_project_id = helper_get_current_project();
		$t_bug_table = config_get( 'mantis_bug_table' );
		$t_user_id = auth_get_current_user_id();
		$t_res_val = config_get( 'bug_resolved_status_threshold' );
		$t_clo_val = CLOSED;
		$specific_where = " AND " . helper_project_specific_where( $t_project_id, $t_user_id );

		$t_arr = explode_enum_string( $p_enum_string );
		$enum_count = count( $t_arr );
		for ( $i=0; $i < $enum_count; $i++) {
			$t_s = explode( ':', $t_arr[$i] );
			$t_key = get_enum_to_string( $p_enum_string, $t_s[0] );

			# Calculates the number of bugs opened and puts the results in a table
			$query = "SELECT COUNT(*)
					FROM $t_bug_table
					WHERE $p_enum='$t_s[0]' AND
						status<'$t_res_val' $specific_where";
			$result2 = db_query( $query );
			$t_metrics['open'][$t_key] = db_result( $result2, 0, 0);

			# Calculates the number of bugs closed and puts the results in a table
			$query = "SELECT COUNT(*)
					FROM $t_bug_table
					WHERE $p_enum='$t_s[0]' AND
						status='$t_clo_val' $specific_where";
			$result2 = db_query( $query );
			$t_metrics['closed'][$t_key] = db_result( $result2, 0, 0);

			# Calculates the number of bugs resolved and puts the results in a table
			$query = "SELECT COUNT(*)
					FROM $t_bug_table
					WHERE $p_enum='$t_s[0]' AND
						status>='$t_res_val'  AND
						status<'$t_clo_val' $specific_where";
			$result2 = db_query( $query );
			$t_metrics['resolved'][$t_key] = db_result( $result2, 0, 0);
		} ### end for

		return $t_metrics;
	}

	# --------------------
	function create_developer_summary() {

		$t_project_id = helper_get_current_project();
		$t_user_table = config_get( 'mantis_user_table' );
		$t_bug_table = config_get( 'mantis_bug_table' );
		$t_user_id = auth_get_current_user_id();
		$specific_where = " AND " . helper_project_specific_where( $t_project_id, $t_user_id );

		$t_res_val = config_get( 'bug_resolved_status_threshold' );
		$t_clo_val = CLOSED;

		$query = "SELECT handler_id, status
				 FROM $t_bug_table
				 WHERE handler_id != '' $specific_where";
		$result = db_query( $query );
		$t_total_handled = db_num_rows( $result );

		$t_handler_arr = array();
		for ( $i = 0; $i < $t_total_handled; $i++ ) {
			$row = db_fetch_array( $result );
			if ( !isset( $t_handler_arr[$row['handler_id']] ) ) {
				$t_handler_arr[$row['handler_id']]['res'] = 0;
				$t_handler_arr[$row['handler_id']]['open'] = 0;
				$t_handler_arr[$row['handler_id']]['close'] = 0;
			}
			if ( $row['status'] >= $t_res_val ) {
				if ( $row['status'] >= $t_clo_val ) {
					$t_handler_arr[$row['handler_id']]['close']++;
				} else {
					$t_handler_arr[$row['handler_id']]['res']++;
				}
			} else {
				$t_handler_arr[$row['handler_id']]['open']++;
			}
		}

		if ( count( $t_handler_arr ) == 0 ) {
			return array( 'open' => array() );
		}

		$t_imploded_handlers = implode( ',', array_keys( $t_handler_arr ) );
		$query = "SELECT id, username
				FROM $t_user_table
				WHERE id IN ($t_imploded_handlers)
				ORDER BY username";
		$result = db_query( $query );
		$user_count = db_num_rows( $result );

		for ($i=0;$i<$user_count;$i++) {
			$row = db_fetch_array( $result );
			extract( $row, EXTR_PREFIX_ALL, 'v' );

			$t_metrics['open'][$v_username] = $t_handler_arr[$v_id]['open'];
			$t_metrics['resolved'][$v_username] = $t_handler_arr[$v_id]['res'];
			$t_metrics['closed'][$v_username] = $t_handler_arr[$v_id]['close'];
		} # end for
		return $t_metrics;
	}

	# --------------------
	function create_reporter_summary() {
		global $reporter_name, $reporter_count;


		$t_project_id = helper_get_current_project();
		$t_user_table = config_get( 'mantis_user_table' );
		$t_bug_table = config_get( 'mantis_bug_table' );
		$t_user_id = auth_get_current_user_id();
		$specific_where = " AND " . helper_project_specific_where( $t_project_id, $t_user_id );

		$query = "SELECT reporter_id
				 FROM $t_bug_table
				 WHERE id != '' $specific_where";
		$result = db_query( $query );
		$t_total_reported = db_num_rows( $result );

		$t_reporter_arr = array();
		for ( $i = 0; $i < $t_total_reported; $i++ ) {
			$row = db_fetch_array( $result );

			if ( isset( $t_reporter_arr[$row['reporter_id']] ) ) {
				$t_reporter_arr[$row['reporter_id']]++;
			} else {
				$t_reporter_arr[$row['reporter_id']] = 1;
			}
		}

		if ( count( $t_reporter_arr ) == 0 ) {
			return array();
		}

		$t_imploded_reporters = implode( ',', array_keys( $t_reporter_arr ) );
		$query = "SELECT id, username
				FROM $t_user_table
				WHERE id IN ($t_imploded_reporters)
				ORDER BY username";
		$result = db_query( $query );
		$user_count = db_num_rows( $result );

		for ($i=0;$i<$user_count;$i++) {
			$row = db_fetch_array( $result );
			extract( $row, EXTR_PREFIX_ALL, 'v' );

			$t_metrics[$v_username] = $t_reporter_arr[$v_id];
		} # end for
		return $t_metrics;
	}

	# --------------------
	function create_category_summary() {
		global $category_name, $category_bug_count;

		$t_project_id = helper_get_current_project();
		$t_cat_table = config_get( 'mantis_project_category_table' );
		$t_bug_table = config_get( 'mantis_bug_table' );
		$t_user_id = auth_get_current_user_id();
		$specific_where = helper_project_specific_where( $t_project_id, $t_user_id );

		$query = "SELECT DISTINCT category
				FROM $t_cat_table
				WHERE $specific_where
				ORDER BY category";
		$result = db_query( $query );
		$category_count = db_num_rows( $result );
		if ( 0 == $category_count ) {
			return array();
		}

		for ($i=0;$i<$category_count;$i++) {
			$row = db_fetch_array( $result );
			$t_cat_name = $row['category'];
			$c_category_name = addslashes($t_cat_name);
			$query = "SELECT COUNT(*)
					FROM $t_bug_table
					WHERE category='$c_category_name' AND $specific_where";
			$result2 = db_query( $query );
			$t_metrics[$t_cat_name] = db_result( $result2, 0, 0 );
		} # end for
		return $t_metrics;
	}

	# --------------------
	function cmp_dates($a, $b){
		if ($a[0] == $b[0]) {
			return 0;
		}
		return ( $a[0] < $b[0] ) ? -1 : 1;
	}

	# --------------------
	function find_date_in_metrics($aDate){
		global $metrics;
		$index = -1;
		for ($i=0;$i<count($metrics);$i++) {
			if ($aDate == $metrics[$i][0]){
				$index = $i;
				break;
			}
		}
		return $index;
	}

	# --------------------
	function create_cumulative_bydate(){

		$t_clo_val = CLOSED;
		$t_res_val = config_get( 'bug_resolved_status_threshold' );
		$t_bug_table = config_get( 'mantis_bug_table' );
		$t_history_table = config_get( 'mantis_bug_history_table' );

		$t_project_id = helper_get_current_project();
		$t_user_id = auth_get_current_user_id();
		$specific_where = helper_project_specific_where( $t_project_id, $t_user_id );

		# Get all the submitted dates
		$query = "SELECT date_submitted
				FROM $t_bug_table
				WHERE $specific_where
				ORDER BY date_submitted";
		$result = db_query( $query );
		$bug_count = db_num_rows( $result );

		for ($i=0;$i<$bug_count;$i++) {
			$row = db_fetch_array( $result );
			# rationalise the timestamp to a day to reduce the amount of data
 			$t_date = db_unixtimestamp( $row['date_submitted'] );
			$t_date = (int) ( $t_date / 86400 );

			if ( isset( $metrics[$t_date] ) ){
				$metrics[$t_date][0]++;
			} else {
				$metrics[$t_date] = array( 1, 0, 0 );
			}
		}

		### Get all the dates where a transition from not resolved to resolved may have happened
		#    also, get the last updated date for the bug as this may be all the information we have
		$query = "SELECT $t_bug_table.id, last_updated, date_modified, new_value, old_value
			FROM $t_bug_table LEFT JOIN $t_history_table
			ON $t_bug_table.id = $t_history_table.bug_id
			WHERE $specific_where
						AND $t_bug_table.status >= '$t_res_val'
						AND ( ( $t_history_table.new_value >= '$t_res_val'
								AND $t_history_table.field_name = 'status' )
						OR $t_history_table.id is NULL )
			ORDER BY $t_bug_table.id, date_modified ASC";
		$result = db_query( $query );
		$bug_count = db_num_rows( $result );

		$t_last_id = 0;
		for ($i=0;$i<$bug_count;$i++) {
			$row = db_fetch_array( $result );
			$t_id = $row['id'];
			# if h_last_updated is NULL, there were no appropriate history records
			#  (i.e. pre 0.18 data), use last_updated from bug table instead
			if (NULL == $row['date_modified']) {
				$t_date = db_unixtimestamp( $row['last_updated'] );
			} else {
				if ( $t_res_val > $row['old_value'] ) {
					$t_date = db_unixtimestamp( $row['date_modified'] );
				}
			}
			if ( $t_id <> $t_last_id ) {
				if ( 0 <> $t_last_id ) {
					# rationalise the timestamp to a day to reduce the amount of data
					$t_date_index = (int) ( $t_last_date / 86400 );

					if ( isset( $metrics[$t_date_index] ) ){
						$metrics[$t_date_index][1]++;
					} else {
						$metrics[$t_date_index] = array( 0, 1, 0 );
					}
				}
				$t_last_id = $t_id;
			}
			$t_last_date = $t_date;
		}

		ksort($metrics);

		$metrics_count = count($metrics);
		$t_last_opened = 0;
		$t_last_resolved = 0;
		foreach ($metrics as $i=>$vals) {
			$t_date = $i * 86400;
			$t_metrics[$t_date][0] = $t_last_opened = $metrics[$i][0] + $t_last_opened;
			$t_metrics[$t_date][1] = $t_last_resolved = $metrics[$i][1] + $t_last_resolved;
			$t_metrics[$t_date][2] = $t_metrics[$t_date][0] - $t_metrics[$t_date][1];
		}
		return $t_metrics;
	}

	function graph_date_format ($p_date) {
		return date( config_get( 'short_date_format' ), $p_date );
	}


	# ----------------------------------------------------
	#
	# Check that there is enough data to create graph
	#
	# ----------------------------------------------------
	function error_check( $bug_count, $title ) {

		if ( 0 == $bug_count ) {
			$t_graph_font = graph_get_font();

			$graph = new CanvasGraph(300,380);

			$txt = new Text( lang_get( 'not_enough_data' ), 150, 100);
			$txt->Align("center","center","center");
			$txt->SetFont( $t_graph_font, FS_BOLD );
			$graph->title->Set( $title );
			$graph->title->SetFont( $t_graph_font, FS_BOLD );
			$graph->AddText($txt);
			$graph->Stroke();
			die();
		}
	}
?>

感想

勢いで作ってみましたが、あまりこのグラフみないんだよね(苦笑)

便利だぜって人がいたらもう少しきれいに作って、本家にパッチ送って取り込んでもらえるようにしたいと思います。

Mantis1.1.2の作業履歴を一覧表示する

Mantisって作業履歴が残るって書いてありますけれど、個別のチケットを開いたときに一番下にだけでていますよね?
全体を取りまとめて見てみたかったので一覧ページを作成してみました。

history_page.php

<?php

require_once( 'core.php' );

html_page_top1('作業履歴');
html_page_top2();

// プロジェクトフィルター
$project_id = helper_get_current_project();
if( $project_id == 0 ){
  // すべて
  $project_str = " 1 = 1 ";
} else {
  $project_list = project_hierarchy_get_all_subprojects($project_id);
  $project_list[] = $project_id;
  $project_str = " bt.project_id in ( " . implode( ",", $project_list ) . " ) ";
}

$query = "
SELECT
    ht.date_modified,
    ht.user_id,
    ut.username,
    ht.bug_id,
    bt.project_id,
    pt.name,
    bt.category,
    bt.summary,
    ht.field_name,
    ht.old_value,
    ht.new_value,
    ht.type
FROM
    mantis_bug_history_table ht,
    mantis_bug_table bt,
    mantis_project_table pt,
    mantis_user_table ut
WHERE
    ht.bug_id = bt.id AND
    bt.project_id = pt.id AND
    ht.user_id = ut.id AND
    $project_str
ORDER BY
    ht.date_modified DESC
LIMIT
    100
";
$result = db_query( $query );

echo <<<__ECHO__
<br />
<table class="width100" cellspacing="1">
<tr class="row-category">
  <th>変更日</th>
  <th>変更者</th>
  <th>カテゴリ</th>
  <th width="50%">要約</th>
</tr>

__ECHO__;

while( $row = db_fetch_array( $result ) ){
  $data = history_localize_item( $row['field_name'], $row['type'], $row['old_value'], $row['new_value'] );
  if( $data['change'] ){
    $history_str = $data['note'] . '(' . $data['change'] .')';
  } else {
    $history_str = $data['note'];
  }
  if( $project_id != $row['project_id'] ){
    // プロジェクト名を表示
    $project_name = '<small>[<a href="set_project.php?project_id=' . $row['project_id'] . '">' . $row['name'] . "]</a></small><br />";
  } else {
    $project_name = "";
  }

  echo <<<__ECHO__
<tr bgcolor="#e8e8e8">
  <td>{$row['date_modified']}</td>
  <td>{$row['username']}</td>
  <td>{$project_name}{$row['category']}</td>
  <td><a href="view.php?id={$row['bug_id']}">{$row['summary']}</a><br />{$history_str}</td>
</tr>

__ECHO__;
}

echo <<<__ECHO__
</table>

__ECHO__;

html_page_bottom1( __FILE__ );

上記を保存して、Mantisのトップディレクトリに設置することで動作します。

個別解説 - 基本

<?php
require_once( 'core.php' );

html_page_top1('作業履歴');
html_page_top2();

coreを読み込めば必要なものが全て利用できるみたいです。top1の引数が画面のタイトルになるので指定しましょう。きれいに作る場合には日本語かかないで翻訳ファイル経由で作るべきです。

特定のプロジェクトのみ表示

<?php
// プロジェクトフィルター
$project_id = helper_get_current_project();
if( $project_id == 0 ){
  // すべて
  $project_str = " 1 = 1 ";
} else {
  $project_list = project_hierarchy_get_all_subprojects($project_id);
  $project_list[] = $project_id;
  $project_str = " bt.project_id in ( " . implode( ",", $project_list ) . " ) ";
}

現在選択されているプロジェクトと、その下位のプロジェクトを列挙してWHERE句を作成します。

一覧の取得

<?php
$query = "
SELECT
    ht.date_modified,
    ht.user_id,
    ut.username,
    ht.bug_id,
    bt.project_id,
    pt.name,
    bt.category,
    bt.summary,
    ht.field_name,
    ht.old_value,
    ht.new_value,
    ht.type
FROM
    mantis_bug_history_table ht,
    mantis_bug_table bt,
    mantis_project_table pt,
    mantis_user_table ut
WHERE
    ht.bug_id = bt.id AND
    bt.project_id = pt.id AND
    ht.user_id = ut.id AND
    $project_str
ORDER BY
    ht.date_modified DESC
LIMIT
    100
";
$result = db_query( $query );

データ取得の部分です。最新100件のみ取得しています。新しいバージョンはカテゴリーが別テーブルになっているので、次のバージョンでは修正が必要ですね。

テーブルのヘッダ出力

<?php
echo <<<__ECHO__
<br />
<table class="width100" cellspacing="1">
<tr class="row-category">
  <th>変更日</th>
  <th>変更者</th>
  <th>カテゴリ</th>
  <th width="50%">要約</th>
</tr>

__ECHO__;

要約の横幅を50%にしていますが、好みです!

テーブルの出力

<?php
while( $row = db_fetch_array( $result ) ){
  $data = history_localize_item( $row['field_name'], $row['type'], $row['old_value'], $row['new_value'] );
  if( $data['change'] ){
    $history_str = $data['note'] . '(' . $data['change'] .')';
  } else {
    $history_str = $data['note'];
  }
  if( $project_id != $row['project_id'] ){
    // プロジェクト名を表示
    $project_name = '<small>[<a href="set_project.php?project_id=' . $row['project_id'] . '">' . $row['name'] . "]</a></small><br />";
  } else {
    $project_name = "";
  }

  echo <<<__ECHO__
<tr bgcolor="#e8e8e8">
  <td>{$row['date_modified']}</td>
  <td>{$row['username']}</td>
  <td>{$project_name}{$row['category']}</td>
  <td><a href="view.php?id={$row['bug_id']}">{$row['summary']}</a><br />{$history_str}</td>
</tr>

__ECHO__;
}

history_localize_itemが履歴を文字列に変換するMantis本体の関数です。プロジェクト名は選択している物以外の場合には表示する設定がMantisの標準動作でしたので、合わせました。

最終処理

<?php
echo <<<__ECHO__
</table>

__ECHO__;

html_page_bottom1( __FILE__ );

テーブルを閉じてフッターを出力します。

メニューに追加(config_inc.php)

<?php
$g_main_menu_custom_options = array(
    array( "Pukiwiki", MANAGER, '/pukiwiki' ),
    array( "作業履歴", MANAGER, 'history_page.php' ),
);

TOPメニューに追加されます。

感想

結構単純な処理でページは追加できます。今後プラグインとかで拡張できるようになりますが、今のバージョンだと新規ページを作って置いた方がお手軽かな?

DBの構造を見た限り今後カテゴリーと同じ様にテキストで埋め込まれている物がテーブルに分離しそうな予感がありますので、まだまだDBの構造は変わっていきそうですね。

Mantisにはロードマップとか変更履歴がありますが、チケット単位での履歴なのでTRACみたいな操作履歴が欲しいって要望はないのかな?