Elgg メールの文字コード

Elgg から送信されるメールには、

  • 自分または友達のコンテンツに更新があったことを知らせる通知メール
  • プラグイン “User Validation by Email” による登録確認メール
  • プラグイン “Invite Friends” による招待メール
があります。この他にも導入しているプラグインによってメールが送信される場合があります。
これらのメールの文字コードはUTF-8となっているので、UTF-8を扱えるメールソフトやWEBMAILであれば日本語でも問題ありません。

ユーザーによっては、UTF-8を扱えない環境であることも考えられるので、日本語メールの一般的な文字コードJISに変換して送信するようにします。

Elgg メール送信処理

伝言板(messageboard)に書き込みがあった場合の通知を例にメール送信処理を見ていきます。
function messageboard_add($poster, $owner, $message, $access_id = ACCESS_PUBLIC) {
	$result = $owner->annotate('messageboard', $message, $access_id, $poster->guid);

	if (!$result) {
		return false;
	}

	add_to_river('river/object/messageboard/create',
				'messageboard',
				$poster->guid,
				$owner->guid,
				$access_id,
				0,
				$result);

	// only send notification if not self
	if ($poster->guid != $owner->guid) {
		$subject = elgg_echo('messageboard:email:subject');
		$body = elgg_echo('messageboard:email:body', array(
						$poster->name,
						$message,
						elgg_get_site_url() . "messageboard/" . $owner->username,
						$poster->name,
						$poster->getURL()
						));

		notify_user($owner->guid, $poster->guid, $subject, $body);
	}

	return $result;
}
129行目でnotify_user() を呼び出して通知処理を行います。
notify_user() は engine/lib/notification.php にあり、ユーザーの通知設定を確認して適切な通知処理を行います。今回はメール通知の設定をしてあるので、email_notify_handler() が呼び出されます。
function email_notify_handler(ElggEntity $from, ElggUser $to, $subject, $message,
array $params = NULL) {

	global $CONFIG;

	if (!$from) {
		$msg = elgg_echo('NotificationException:MissingParameter', array('from'));
		throw new NotificationException($msg);
	}

	if (!$to) {
		$msg = elgg_echo('NotificationException:MissingParameter', array('to'));
		throw new NotificationException($msg);
	}

	if ($to->email == "") {
		$msg = elgg_echo('NotificationException:NoEmailAddress', array($to->guid));
		throw new NotificationException($msg);
	}

	// To
	$to = $to->email;

	// From
	$site = get_entity($CONFIG->site_guid);
	// If there's an email address, use it - but only if its not from a user.
	if (!($from instanceof ElggUser) && $from->email) {
		$from = $from->email;
	} else if ($site && $site->email) {
		// Use email address of current site if we cannot use sender's email
		$from = $site->email;
	} else {
		// If all else fails, use the domain of the site.
		$from = 'noreply@' . get_site_domain($CONFIG->site_guid);
	}

	return elgg_send_email($from, $to, $subject, $message);
}
from、to の確認を行った後、最後に elgg_send_email() を呼び出します。
function elgg_send_email($from, $to, $subject, $body, array $params = NULL) {
	global $CONFIG;

	if (!$from) {
		$msg = elgg_echo('NotificationException:MissingParameter', array('from'));
		throw new NotificationException($msg);
	}

	if (!$to) {
		$msg = elgg_echo('NotificationException:MissingParameter', array('to'));
		throw new NotificationException($msg);
	}

	// return TRUE/FALSE to stop elgg_send_email() from sending
	$mail_params = array(
							'to' => $to,
							'from' => $from,
							'subject' => $subject,
							'body' => $body,
							'params' => $params
					);

	$result = elgg_trigger_plugin_hook('email', 'system', $mail_params, NULL);
	if ($result !== NULL) {
		return $result;
	}

	$header_eol = "\r\n";
	if (isset($CONFIG->broken_mta) && $CONFIG->broken_mta) {
		// Allow non-RFC 2822 mail headers to support some broken MTAs
		$header_eol = "\n";
	}

	// Windows is somewhat broken, so we use just address for to and from
	if (strtolower(substr(PHP_OS, 0, 3)) == 'win') {
		// strip name from to and from
		if (strpos($to, '<')) {
			preg_match('/<(.*)>/', $to, $matches);
			$to = $matches[1];
		}
		if (strpos($from, '<')) {
			preg_match('/<(.*)>/', $from, $matches);
			$from = $matches[1];
		}
	}

	$headers = "From: $from{$header_eol}"
		. "Content-Type: text/plain; charset=UTF-8; format=flowed{$header_eol}"
		. "MIME-Version: 1.0{$header_eol}"
		. "Content-Transfer-Encoding: 8bit{$header_eol}";


	// Sanitise subject by stripping line endings
	$subject = preg_replace("/(\r\n|\r|\n)/", " ", $subject);
	if (is_callable('mb_encode_mimeheader')) {
		$subject = mb_encode_mimeheader($subject, "UTF-8", "B");
	}

	// Format message
	$body = html_entity_decode($body, ENT_COMPAT, 'UTF-8'); // Decode any html entities
	$body = elgg_strip_tags($body); // Strip tags from message
	$body = preg_replace("/(\r\n|\r)/", "\n", $body); // Convert to unix line endings in body
	$body = preg_replace("/^From/", ">From", $body); // Change lines starting with From to >From

	return mail($to, $subject, wordwrap($body), $headers);
}
339行目にメールヘッダの設定があり、
text/plain; charset=UTF-8;
Content-Transfer-Encoding: 8bit;
が設定されています。ここをiso-2022-jp/7bitとなるようにします。
348行目のmime変換も”iso-2022-jp”に変更します。
$body を”iso-2022-jp”に変換してから mail() 関数を呼び出せば、”iso-2022-jp”のメールが送信されます。

315行目でプラグインフックを起動しているので、プラグイン側でこれらの修正を行うことにします。

日本語メール送信プラグイン

プラグインをsendmail_jaとして、mod/sendmail_ja/start.php を作成します。
<?php
/*
 *
 * Send Japanese(charset is iso-2022-jp) mail
 *
 * @author oo-foo
 * @copyright Copyright (c) 2013, oo-foo
 *
 */
 
elgg_register_event_handler('init','system','sednmail_ja_init');

function sednmail_ja_init() {

	elgg_register_plugin_hook_handler('email', 'system', 'sendmail_ja_hook');
}

/**
 * Send email hook
 * 
 * @param string $hook
 * @param string $type
 * @param array  $return
 * @param array  $params
 */
function sendmail_ja_hook($hook, $type, $return, $params) {

	global $CONFIG;

	// 言語が日本語('ja')でなければ何もしない
	if (get_current_language() != 'ja') {
		return;
	}

	// mb_languageが使えなければ何もしない
	if (!is_callable('mb_language')) {
		return;
	}
	mb_language('Japanese');
	mb_internal_encoding('utf-8');

	extract($params);

	$header_eol = "\r\n";
	if (isset($CONFIG->broken_mta) && $CONFIG->broken_mta) {
		// Allow non-RFC 2822 mail headers to support some broken MTAs
		$header_eol = "\n";
	}

	// Windows is somewhat broken, so we use just address for to and from
	if (strtolower(substr(PHP_OS, 0, 3)) == 'win') {
		// strip name from to and from
		if (strpos($to, '<')) {
			preg_match('/<(.*)>/', $to, $matches);
			$to = $matches[1];
		}
		if (strpos($from, '<')) {
			preg_match('/<(.*)>/', $from, $matches);
			$from = $matches[1];
		}
	}

	$headers = "From: $from{$header_eol}"
		. "Content-Type: text/plain; charset=iso-2022-jp; format=flowed{$header_eol}"
		. "MIME-Version: 1.0{$header_eol}"
		. "Content-Transfer-Encoding: 7bit{$header_eol}";


	// Sanitise subject by stripping line endings
	$subject = preg_replace("/(\r\n|\r|\n)/", " ", $subject);
	if (is_callable('mb_encode_mimeheader')) {
		$subject = mb_encode_mimeheader($subject, "iso-2022-jp", "B");
	}

	// Format message
	$body = html_entity_decode($body, ENT_COMPAT, 'UTF-8'); // Decode any html entities
	$body = elgg_strip_tags($body); // Strip tags from message
	$body = preg_replace("/(\r\n|\r)/", "\n", $body); // Convert to unix line endings in body
	$body = preg_replace("/^From/", ">From", $body); // Change lines starting with From to >From

	// convert to iso-2022-jp
	$body = mb_convert_encoding($body, 'iso-2022-jp');
	
	return mail($to, $subject, wordwrap($body), $headers);
}
31行目で言語設定が日本語になっているか確認しています。このプラグインを有効にしている場合は、常に”iso-2022-jp”で送信するという事にしてチェックを行わなくても良いかもしれません。
36行目では mb_language() が呼べるかどうかをチェックして、日本語環境があるかを確認しています。
後は、
  • メールヘッダを”iso-2022-jp”メールに設定
  • “iso-2022-jp”でMIMEエンコード
  • $bodyを”iso-2022-jp”に変換
という処理になっています。

今回のプラグインファイルは、このstart.phpとmanifest.xmlのみです。


Elgg cron設定

Elgg のcron については、http://docs.elgg.org/wiki/Cron(日本語) に日本語の説明があります。

Elgg cronハンドラーは特定のページを読み込むと実行されます。例えば、http://example.com/pg/cron/hourly/ がWebブラウザ等で読み込まれると1時間単位のフックが実行されます。この一連の流れを自動化するには、cronジョブを設定して適当なタイミングでこのページを読み込むようにします。このcronジョブはcrontabにて設定します。

CLIからの起動ではなく、HTTPアクセスによりcron処理が起動されます。

標準で入っているプラグイン、”Garbage Collector”や”Log Rotate”、はプラグインの設定を行っても、ブラウザからアクセスすることにより実行はされますが、cronの設定を行わないと定期的には実行されません。

cronの設定方法は上記ページに例があります。

 # GETコマンドのパス
 GET='/usr/bin/GET'
 
 # サイトのURL(最後のスラッシュをお忘れなく!)
 ELGG='http://www.example.com/'
 
 # The crontab
 @reboot $GET ${ELGG}pg/cron/reboot/
 @hourly $GET ${ELGG}pg/cron/hourly/
 @daily $GET ${ELGG}pg/cron/daily/
 @weekly $GET ${ELGG}pg/cron/weekly/
 @monthly $GET ${ELGG}pg/cron/monthly/
 @yearly $GET ${ELGG}pg/cron/yearly/

ソースを見るとこれ以外に、’minute’(1分毎), ‘fiveminute’(5分毎), ‘fifteenmin’(15分毎), ‘halfhour’(30分毎)、が定義されています。
この設定については、https://github.com/melvincarvalho/elgg/blob/master/crontab.example に例が載っています。これを基にcurlを使って設定した例が以下になります。
# Elgg cron
GET='/usr/bin/curl -s -S'

ELGG='http://elgg.oo-foo.com/'

@reboot $GET ${ELGG}cron/reboot/
* * * * * $GET ${ELGG}cron/minute/
*/5 * * * * $GET ${ELGG}cron/fiveminute/
15,30,45,59 * * * * $GET ${ELGG}cron/fifteenmin/
30,59 * * * * $GET ${ELGG}cron/halfhour/
@hourly $GET ${ELGG}cron/hourly/
@daily $GET ${ELGG}cron/daily/
@weekly $GET ${ELGG}cron/weekly/
@monthly $GET ${ELGG}cron/monthly/
@yearly $GET ${ELGG}cron/yearly/
ドキュメントでは、${ELGG}pg/cron/reboot/、の様になっていますが、”pg”は後方互換の記述の様なので必要ありません。

HTTPアクセスによる起動なので、Elggをインストールしたサーバーとは異なるサーバー上のcronから呼び出すことも可能です。

cronのアクセス制限

ElggのcronはHTTPアクセスにより起動するため、”/cron/hourly/”にアクセスされると誰にでもhourlyに設定された処理が実行出来てしまいます。誰にでもcron処理が実行可能なのは問題がありそうなので、これを制限する方法を考えます。

“/cron”へのアクセスは”engine/lib/cron.php”のcron_page_handler()で処理されます。
function cron_page_handler($page) {
	global $CONFIG;

	if (!isset($page[0])) {
		forward();
	}

	$period = strtolower($page[0]);

	$allowed_periods = array(
		'minute', 'fiveminute', 'fifteenmin', 'halfhour', 'hourly',
		'daily', 'weekly', 'monthly', 'yearly', 'reboot'
	);

	if (!in_array($period, $allowed_periods)) {
		throw new CronException(elgg_echo('CronException:unknownperiod', array($period)));
	}

	// Get a list of parameters
	$params = array();
	$params['time'] = time();

	// Data to return to
	$std_out = "";
	$old_stdout = "";
	ob_start();

	$old_stdout = elgg_trigger_plugin_hook('cron', $period, $params, $old_stdout);
	$std_out = ob_get_clean();

	echo $std_out . $old_stdout;
	return true;
}
$pageに”/cron/”以下のURLが配列形式で入り、$page[0]に’hourly’や’daily’などのperiodが入ってきます。これをパラメータとして58行目で’cron’をフックしているプラグイン関数を呼び出しています。
34行目でperiod指定がない場合、cron処理を行わないで”/”にリダイレクトするようになっているので、不正なアクセスの場合もこれと同じようにします。

アクセスを制限する方法は、”/cron/hourly/{secretkey}”の様にURLに特定のパラメータがある時のみ、cron処理を実行するようにします。

$pageにURLが配列で入ってくるので、{secretkey}は$page[1]に入ってきます。これが指定した値と一致しないときは、forward()してcron処理を行わないようにします。
	if (!isset($page[0]) || !isset($page[1]) || $page[1] != '{secretkey}') {
		forward();
	}
独自のサイト設定を行っている箇所が見当たらなかったので、ソースに直接’{secretkey}’を書き込んでいます。

cron 設定もこれに合わせて書き換えます。
# Elgg cron
GET='/usr/bin/curl -s -S'

ELGG='http://elgg.oo-foo.com/'
SECRETKEY='{secretkey}'

@reboot $GET ${ELGG}cron/reboot/${SECRETKEY}
* * * * * $GET ${ELGG}cron/minute/${SECRETKEY}
*/5 * * * * $GET ${ELGG}cron/fiveminute/${SECRETKEY}
15,30,45,59 * * * * $GET ${ELGG}cron/fifteenmin/${SECRETKEY}
30,59 * * * * $GET ${ELGG}cron/halfhour/${SECRETKEY}
@hourly $GET ${ELGG}cron/hourly/${SECRETKEY}
@daily $GET ${ELGG}cron/daily/${SECRETKEY}
@weekly $GET ${ELGG}cron/weekly/${SECRETKEY}
@monthly $GET ${ELGG}cron/monthly/${SECRETKEY}
@yearly $GET ${ELGG}cron/yearly/${SECRETKEY}
これで{SECRETKEY}を知らない第三者からcron処理を実行される事はなくなります。

プラグインでの制限

engine/lib/cron.php を直接書き換えるのではなく、プラグインで上の制限を行うようにします。

プラグインをsafe_cronとして、mod/safe_cron/start.php を作成します。
elgg_register_event_handler('init','system','safecron_init');

function safecron_init() {

	elgg_unregister_page_handler('cron');
	elgg_register_page_handler('cron', 'safecron_page_handler');
}

function safecron_page_handler($page) {
	global $CONFIG;

	if (!isset($page[0]) || !isset($page[1]) || $page[1] != '{secret}') {
		forward();
	}

	$period = strtolower($page[0]);

	$allowed_periods = array(
		'minute', 'fiveminute', 'fifteenmin', 'halfhour', 'hourly',
		'daily', 'weekly', 'monthly', 'yearly', 'reboot'
	);

	if (!in_array($period, $allowed_periods)) {
		throw new CronException(elgg_echo('CronException:unknownperiod', array($period)));
	}

	// Get a list of parameters
	$params = array();
	$params['time'] = time();

	// Data to return to
	$std_out = "";
	$old_stdout = "";
	ob_start();

	$old_stdout = elgg_trigger_plugin_hook('cron', $period, $params, $old_stdout);
	$std_out = ob_get_clean();

	echo $std_out . $old_stdout;
	return true;
}
5行目でcoreのcronページハンドラを削除して、6行目で新たなcronページハンドラ”safecron_page_handler”を設定します。
12行目で{secret}のチェックを行っている以外は、engine/lib/cron.php の cron_page_handler() と同じです。

今回のプラグインファイルは、このstart.phpとmanifest.xmlのみです。


Elgg プロフィール項目の変更

Elgg のユーザープロフィール項目は、デフォルトで以下の項目になっています。

  • 氏名
  • 自己紹介
  • ちょっと一言
  • 住所・地域
  • 趣味
  • 特技
  • 電子メール
  • 電話番号
  • 携帯番号
  • Website
  • Twitterユーザ名
プロフィール項目は、管理ページの「見た目 > プロフィール項目を編集」で変更する事が出来、プロフィール項目を編集した場合は、氏名以外のデフォルトのプロフィール項目はすべて削除されて編集で追加した項目のみになります。

プロフィール項目の編集は、追加したいプロフィール項目のラベルと項目のタイプを選択して追加します。
Elgg:プロフィール項目編集

Elgg:プロフィール項目編集



複数URL指定

プロフィールに複数のURLを登録したいとき、最大3つまで、の様に数が決まっている場合はラベルを”URL 1″,”URL 2″,”URL 3″の様にしてタイプをすべてWebアドレスとすれば、最大3つまでのURLの登録が可能です。
が、固定数ではなく登録数を可変にできる様にします。

プロフィール保存処理

プロフィール編集ページのform actionは、/action/profile/edit となっているので、mod/profile/start.php を確認します。start.php には action ハンドラの設定がないので、coreの actions/profile/edit.php でプロフィールの保存処理が行われます。
入力値のチェック等の後、データを保存しているのは以下の部分です。
// go through custom fields
if (sizeof($input) > 0) {
	foreach ($input as $shortname => $value) {
		$options = array(
			'guid' => $owner->guid,
			'metadata_name' => $shortname
		);
		elgg_delete_metadata($options);
		if (isset($accesslevel[$shortname])) {
			$access_id = (int) $accesslevel[$shortname];
		} else {
			// this should never be executed since the access level should always be set
			$access_id = ACCESS_DEFAULT;
		}
		if (is_array($value)) {
			$i = 0;
			foreach ($value as $interval) {
				$i++;
				$multiple = ($i > 1) ? TRUE : FALSE;
				create_metadata($owner->guid, $shortname, $interval, 'text', $owner->guid, $access_id, $multiple);
			}
		} else {
			create_metadata($owner->getGUID(), $shortname, $value, 'text', $owner->getGUID(), $access_id);
		}
	}

	$owner->save();

	// Notify of profile update
	elgg_trigger_event('profileupdate', $owner->type, $owner);

	system_message(elgg_echo("profile:saved"));
}
metadataとして登録されるので、76行目で既存データを削除、$value(入力値)が配列であるか否かを判定、88,91行目で入力された値を保存しています。
$valueが配列であるか判定して保存処理を行っているので、可変数の項目を保存するための処理は入っていることになります。

つまり、プロフィールの入力フォームで「name=”website”」となっているところを「name=”website[]“」とすれば複数URLの登録が可能になります。

プロフィール登録フォーム

ユーザーのプロフィール編集ページは、/profile/{username}/edit となっていて、mod/profile/start.php では以下のようになっています。
function profile_page_handler($page) {

	if (isset($page[0])) {
		$username = $page[0];
		$user = get_user_by_username($username);
		elgg_set_page_owner_guid($user->guid);
	}

	// short circuit if invalid or banned username
	if (!$user || ($user->isBanned() && !elgg_is_admin_logged_in())) {
		register_error(elgg_echo('profile:notfound'));
		forward();
	}

	$action = NULL;
	if (isset($page[1])) {
		$action = $page[1];
	}

	if ($action == 'edit') {
		// use the core profile edit page
		$base_dir = elgg_get_root_path();
		require "{$base_dir}pages/profile/edit.php";
		return true;
	}
こちらもcoreのページ、pages/profile/edit.php から views/default/forms/profile/edit.php が呼び出されてフォーム表示されます。
<?php

$profile_fields = elgg_get_config('profile_fields');
if (is_array($profile_fields) && count($profile_fields) > 0) {
	foreach ($profile_fields as $shortname => $valtype) {
		$metadata = elgg_get_metadata(array(
			'guid' => $vars['entity']->guid,
			'metadata_name' => $shortname
		));
		if ($metadata) {
			if (is_array($metadata)) {
				$value = '';
				foreach ($metadata as $md) {
					if (!empty($value)) {
						$value .= ', ';
					}
					$value .= $md->value;
					$access_id = $md->access_id;
				}
			} else {
				$value = $metadata->value;
				$access_id = $metadata->access_id;
			}
		} else {
			$value = '';
			$access_id = ACCESS_DEFAULT;
		}

?>
<div>
	<label><?php echo elgg_echo("profile:{$shortname}") ?></label>
	<?php
		$params = array(
			'name' => $shortname,
			'value' => $value,
		);
		echo elgg_view("input/{$valtype}", $params);
		$params = array(
			'name' => "accesslevel[$shortname]",
			'value' => $access_id,
		);
		echo elgg_view('input/access', $params);
	?>
</div>
<?php
	}
}
?>
実際のフォーム表示は43行目からで、その前にvalueとアクセスレベルの処理を行っています。
ここでも24行目で配列確認の処理が入っています。配列の場合は”,”区切りに変換して、ひとつのテキストボックスとして表示しています。

“,”区切りは複数データとして扱われている様なので、再度保存処理を確認してみます。
	if ($valuetype == 'tags') {
		$value = string_to_tag_array($value);
	}
プロフィール項目のタイプが「タグ」であれば、”,”区切りを配列にする処理が入っています。

ということで、複数URLを登録するには、
・プロフィール項目のタイプを「タグ」として
・複数ある場合は、”,”区切りで入力
とすれば、一つの「Website」という項目に対して複数のURLが登録可能となります。

一つのテキストボックスに”,”区切りで入力する、という仕様にするのであれば、これで終わりです。
複数のテキストボックスを表示して入力するには、入力フォームを変更する必要があります。

プロフィール項目のタイプ追加

actions/profile/edit.php 19行目の elgg_get_metadata() で配列(複数データ)が返ってきた場合、25行目からの処理で無条件に”, “区切りの一つの文字列とされてしまいます。
複数のテキストボックスを表示する場合は、一つの結合された文字列ではなく配列データとして分割されている必要があるので、「タグ」と見分けが付くようにプロフィール項目のタイプを追加します。

プロフィール項目のタイプは、views/default/forms/profile/fields/add.php で定義されています。
$type_control = elgg_view('input/dropdown', array('name' => 'type', 'options_values' => array(
	'text' => elgg_echo('profile:field:text'),
	'longtext' => elgg_echo('profile:field:longtext'),
	'tags' => elgg_echo('profile:field:tags'),
	'url' => elgg_echo('profile:field:url'),
	'email' => elgg_echo('profile:field:email'),
	'location' => elgg_echo('profile:field:location'),
	'date' => elgg_echo('profile:field:date'),
)));
ここを書き換えれば良いのですが、このファイルを書き換えるのではなくプラグインとして処理する様にします。

プラグインをcustom_profileとして、mod/custom_profile/views/default/forms/profile/fields/add.php を作成します。
<?php
/**
 * Add a new field to the set of custom profile fields
 */

$label_text = elgg_echo('profile:label');
$type_text = elgg_echo('profile:type');

$label_control = elgg_view('input/text', array('name' => 'label'));
$type_control = elgg_view('input/dropdown', array('name' => 'type', 'options_values' => array(
	'text' => elgg_echo('profile:field:text'),
	'longtext' => elgg_echo('profile:field:longtext'),
	'tags' => elgg_echo('profile:field:tags'),
	'url' => elgg_echo('profile:field:url'),
	'email' => elgg_echo('profile:field:email'),
	'location' => elgg_echo('profile:field:location'),
	'date' => elgg_echo('profile:field:date'),
	'multiple' => '複数項目',
)));

$submit_control = elgg_view('input/submit', array('name' => elgg_echo('add'), 'value' => elgg_echo('add')));

$formbody = <<< END
		<div>$label_text: $label_control</div>
		<div class="elgg-foot">$type_text: $type_control
		$submit_control</div>
END;

echo autop(elgg_echo('profile:explainchangefields'));
echo $formbody;
18行目で ‘multiple’ => ‘複数項目’ として、プロフィール項目に「複数項目」を追加しています。それ以外は、views/default/forms/profile/fields/add.php と同じです。
‘複数項目’は本来であれば言語ファイルに記述して、elgg_echo(‘profile:field:multiple’)、とすべきですが、とりあえずここではそのまま’複数項目’ としています。

これで、views/default/forms/profile/edit.php のelgg_get_metadata()で複数データが帰ってきたときに「タグ」として”,”区切りとするか、「複数項目」としてそのまま配列データとするかの判断が可能になりました。

プロフィール編集フォームの変更

フォーム表示もプラグインにして、views/default/forms/profile/edit.php を置き換えます。
<?php

$profile_fields = elgg_get_config('profile_fields');
if (is_array($profile_fields) && count($profile_fields) > 0) {
	foreach ($profile_fields as $shortname => $valtype) {
		$metadata = elgg_get_metadata(array(
			'guid' => $vars['entity']->guid,
			'metadata_name' => $shortname
		));
		if ($metadata) {
			if (is_array($metadata)) {
				if ($valtype == 'multi') {
					$value = array();
					foreach ($metadata as $md) {
						$value[] = $md->value;
						$access_id = $md->access_id;
					}
				} else {
					$value = '';
					foreach ($metadata as $md) {
						if (!empty($value)) {
							$value .= ', ';
						}
						$value .= $md->value;
						$access_id = $md->access_id;
					}
				}
			} else {
				$value = $metadata->value;
				$access_id = $metadata->access_id;
			}
		} else {
			$value = '';
			$access_id = ACCESS_DEFAULT;
		}

?>
<div>
	<label><?php echo elgg_echo("profile:{$shortname}") ?></label>
	<?php
		$params = array(
			'name' => $shortname,
			'value' => $value,
		);
		echo elgg_view("input/{$valtype}", $params);
		$params = array(
			'name' => "accesslevel[$shortname]",
			'value' => $access_id,
		);
		echo elgg_view('input/access', $params);
	?>
</div>
<?php
	}
}
?>
25行目から30行目で、プロフィール項目のタイプが「複数項目(multi)」であれば、配列形式で値を渡すようにしています。アクセスレベルはすべて同じなので最後の値が設定されます。

<input>等の実際のHTMLタグは58行目の”input/{$valtype}”、複数項目の場合は”input/multi”、なので views/default/input/multi.php が呼び出されて出力されますが、multiは今回追加したものなのでこのファイルは存在しません。プラグインで mod/custom_profile/views/default/input/multi.php を作成します。
<?php

if (isset($vars['class'])) {
	$vars['class'] = "elgg-input-url {$vars['class']}";
} else {
	$vars['class'] = "elgg-input-url";
}

$defaults = array(
	'value' => '',
	'disabled' => false,
);

$vars = array_merge($defaults, $vars);

$shortname = $vars['name'];
$multivalue = $vars['value'];
?>

<?php if (is_array($multivalue)): ?>
<?php foreach ($multivalue as $i => $value):
$vars['internalname'] = $shortname.'['.$i.']';
$vars['value'] = $value;
?>
<input type="text" <?php echo elgg_format_attributes($vars); ?> />
<?php endforeach; ?>
<?php if (reset($multivalue)):
$vars['internalname'] = $shortname.'[new]';
$vars['value'] = '';
?>
<input type="text" <?php echo elgg_format_attributes($vars); ?> />
<?php endif; ?>

<?php else:
$vars['internalname'] = $shortname.'[0]';
$vars['value'] = '';
?>
<input type="text" <?php echo elgg_format_attributes($vars); ?> />
<?php endif; ?>
今回は複数URLの登録という事なので、views/default/input/url.php をコピーして書き換えています。
20行目の配列判定は、プロフィールを一度も更新していない場合の対応です。
21行目から25行目で登録済みのURL分のテキストボックスを出力、28行目から31行目で空以外のURLが登録されていれば追加用のテキストボックスを一つ出力しています。
「追加」ボタンを作成してJavascriptでテキストボックスを追加した方が良さそうですが、複数URLの登録が主眼なので端折ります。
35行目から38行目でデータが登録されていない場合に、テキストボックスを一つ出力しています。
プロフィール 複数URL

空白入力の除去

追加用の空白テキストボックスを設置したため、このまま保存すると空白のURLが登録されて、プロフィールの編集のたびにテキストボックスが増えていってしまいます。また、登録済みのurlを削除するにはurl欄を空白にする、という仕様にするため、空白の場合は登録されないようにする必要があります。

プロフィールの保存前に入力値から空白の項目を除去するために、プラグインフックを使用します。
elgg_register_event_handler('init','system','custom_profile_init');
 
function custom_profile_init() {

	$plugin = elgg_get_plugin_from_id('custom_profile');
	
	elgg_register_plugin_hook_handler('action', 'profile/edit', 'profile_edit_action_hook');
}

function profile_edit_action_hook() {

	$profile_fields = elgg_get_config('profile_fields');
	if (is_array($profile_fields) && count($profile_fields) > 0) {
		foreach ($profile_fields as $shortname => $valtype) {
			if ($valtype == 'multi') {
				$multival = $_REQUEST[$shortname];
				foreach (array_keys($multival) as $i) {
					if ($multival[$i] == '')
						unset($multival[$i]);
				}
				if (!$multival)
					$_REQUEST[$shortname] = array('');
				else
					$_REQUEST[$shortname] = $multival;
			}
		}
	}
	return true;
}
7行目のelgg_register_plugin_hook_handler()でプロフィール編集のプラグインフックを登録して、profile_edit_action_hook()で処理します。

profile_edit_action_hook()の処理内容は、
  • 12行目でプロフィール項目を取得
  • 15行目でプロフィール項目のタイプが複数項目であるか判定
  • 空であればunset
  • 空でない入力が一つもなければ一つの空文字列の配列とする
  • trueをreturn
となっています。

プラグインファイルの構成

今回作成したcustom_profile プラグインは、以下の様になります。
mod/custom_profile
│  manifest.xml
│  start.php
│
└─views
    └─default
        ├─forms
        │  └─profile
        │      │  edit.php
        │      │
        │      └─fields
        │              add.php
        │
        └─input
               multi.php


Elgg デザイン変更

Elggのデザインを変更してみます。

http://community.elgg.org/plugins/category/themes にテーマファイルがアップロードされています。この中からFacebook Theme for Elgg 1.8 を試してみます。

ダウンロードしてzipを展開すると、以下のようなディレクトリ構造になっています。

facebook_theme
│  CONTRIBUTORS.md
│  manifest.xml
│  README.md
│  start.php
│
├─languages
│      en.php
│      fr.php
│
├─pages
│  │  dashboard.php
│  │
│  ├─groups
│  │      info.php
│  │      wall.php
│  │
│  └─profile
│          info.php
│          wall.php
│
├─screenshots
│      dashboard.png
│      group-wall.png
│      index.png
│      user-wall.png
│
├─tests
│      FacebookThemeTest.php
│
└─views
    └─default
        ├─annotation
        │      generic_comment.php
        │
        ├─blog
        │      composer.php
        ... 以下省略
これらのファイルをmod ディレクトリに配置、管理ページのプラグイン管理で「Facebook Theme 1.4」を起動するとデザインが変更されます。

start.php でviewを切り替えるための処理をいくつか行っていますが、基本的には”mod/facebook_theme/views”以下のファイルで、デフォルトのビューであるElggルートの”views”以下のファイルを置き換えることにより、表示の切り替えを行っています。

実際に使用する場合は、languages/en.php を翻訳してlanguages/ja.php を用意する必要があります。

メニューの変更

メニュー項目は、管理ページの「見た目 > メニュー項目」で変更する事が出来ます。
見た目 : メニュー項目

「使用しない項目は、メニューリストの最後の”More”以下に追加されます。」とありますが、使用しない項目は”More”以下にも表示したくないので、プラグインを作成して表示しないようにしてみます。
Moreありメニュー

Moreを表示しないプラグイン

メニューの表示は、views/default/navigation/menu/site.php で行われているので、このファイルをプラグインで置き換えて”More”を表示しないようにします。

views/default/navigation/menu/site.php の確認

<?php
/**
 * Site navigation menu
 *
 * @uses $vars['menu']['default']
 * @uses $vars['menu']['more']
 */

$default_items = elgg_extract('default', $vars['menu'], array());
$more_items = elgg_extract('more', $vars['menu'], array());

echo '<ul class="elgg-menu elgg-menu-site elgg-menu-site-default clearfix">';
foreach ($default_items as $menu_item) {
	echo elgg_view('navigation/menu/elements/item', array('item' => $menu_item));
}

if ($more_items) {
	echo '<li class="elgg-more">';

	$more = elgg_echo('more');
	echo "<a href=\"#\">$more</a>";
	
	echo elgg_view('navigation/menu/elements/section', array(
		'class' => 'elgg-menu elgg-menu-site elgg-menu-site-more', 
		'items' => $more_items,
	));
	
	echo '</li>';
}
echo '</ul>';
10行目でmore項目を取得して、17-29行目でその表示を行っています。
この処理を削除したものをプラグインとして作成します。

プラグインのファイル構成

作成するプラグインのファイル構成は以下の様になります。
mod/mycustom
│  manifest.xml
│  start.php
│
└─views
    └─default
        └─navigation
            └─menu
                    site.php

start.php

このプラグインでは、start.php で何もしません。プラグインの管理でstart.phpがないとエラーになるため、中身が空(0バイト)のファイルを設置しておきます。

site.php

site.phpは、上で書いたように”More”に関する処理を削除しただけのものです。
<?php
/**
 * Site navigation menu
 *
 * @uses $vars['menu']['default']
 * @uses $vars['menu']['more']
 */

$default_items = elgg_extract('default', $vars['menu'], array());

echo '<ul class="elgg-menu elgg-menu-site elgg-menu-site-default clearfix">';
foreach ($default_items as $menu_item) {
	echo elgg_view('navigation/menu/elements/item', array('item' => $menu_item));
}
echo '</ul>';

これでメニューから”More”がなくなります。
Moreなしメニュー


Elgg ブックマークレットからの重複登録チェック

Elgg でブックマークレットからブックマーク登録しようとすると、既にブックマーク登録されているURLであってもそのまま登録処理されてしまい、同じURLがいくつも登録されてしまいます。
これを修正して、URLがブックマークに登録済みの場合は編集ページに飛ばすようにします。

ページハンドラの確認

ブックマークレットからの登録URLは、
{siteurl}/bookmarks/add/{guid}?address={page_url}&title={page_title}
となり、{guid}はブックマークレット登録したユーザーまたはグループのguid、{page_url}・{page_title}はブックマーク登録しているページのURL・タイトル、になります。

/bookmarks/add/ がどこで処理されているか、mod/bookmarks/start.php でページハンドラを確認。
	elgg_register_page_handler('bookmarks', 'bookmarks_page_handler');

function bookmarks_page_handler($page) {
	elgg_load_library('elgg:bookmarks');

	elgg_push_breadcrumb(elgg_echo('bookmarks'), 'bookmarks/all');

	// old group usernames
	if (substr_count($page[0], 'group:')) {
		preg_match('/group\:([0-9]+)/i', $page[0], $matches);
		$guid = $matches[1];
		if ($entity = get_entity($guid)) {
			bookmarks_url_forwarder($page);
		}
	}

	// user usernames
	$user = get_user_by_username($page[0]);
	if ($user) {
		bookmarks_url_forwarder($page);
	}

	$pages = dirname(__FILE__) . '/pages/bookmarks';

	switch ($page[0]) {
		case "all":
			include "$pages/all.php";
			break;

		case "owner":
			include "$pages/owner.php";
			break;

		case "friends":
			include "$pages/friends.php";
			break;

		case "read":
		case "view":
			set_input('guid', $page[1]);
			include "$pages/view.php";
			break;

		case "add":
			gatekeeper();
			include "$pages/add.php";
			break;

		case "edit":
			gatekeeper();
			set_input('guid', $page[1]);
			include "$pages/edit.php";
			break;

		case 'group':
			group_gatekeeper();
			include "$pages/owner.php";
			break;

		case "bookmarklet":
			set_input('container_guid', $page[1]);
			include "$pages/bookmarklet.php";
			break;

		default:
			return false;
	}

	elgg_pop_context();
	return true;
}
$pages/add.php、つまり、mod/bookmarks/pages/bookmarks/add.php でブックマークレットからの登録を処理していることになります。

「新規ブックマーク」からの登録URLは、{siteurl}/bookmarks/add/{guid}、なのでブックマークレットからの登録と同じスクリプトで処理されます。

既存ブックマークの検索

ElggドキュメントEngine/DataModel/Entitiesの中程にLoading an objectという項があり、そこには

By metadata

Currently two functions are available to retrieve entities by metadata, get_entities_from_metadata() and get_entities_from_metadata_multi(). The former can be used to get entities based on a single piece of metadata, the latter can handle multiple metedata values.

となっているのですが、get_entities_from_metadata関数は
* @deprecated 1.7 use elgg_get_entities_from_metadata().
とあるので、elgg_get_entities_from_metadata()を使用します。

elgg_get_entities_from_metadata()のインタフェースを確認。

/**
 * ElggEntities interfaces
 */

/**
 * Returns entities based upon metadata.  Also accepts all
 * options available to elgg_get_entities().  Supports
 * the singular option shortcut.
 *
 * @note Using metadata_names and metadata_values results in a
 * "names IN (...) AND values IN (...)" clause.  This is subtly
 * differently than default multiple metadata_name_value_pairs, which use
 * "(name = value) AND (name = value)" clauses.
 *
 * When in doubt, use name_value_pairs.
 *
 * To ask for entities that do not have a metadata value, use a custom
 * where clause like this:
 *
 * 	$options['wheres'][] = "NOT EXISTS (
 *			SELECT 1 FROM {$dbprefix}metadata md
 *			WHERE md.entity_guid = e.guid
 *				AND md.name_id = $name_metastring_id
 *				AND md.value_id = $value_metastring_id)";
 *
 * Note the metadata name and value has been denormalized in the above example.
 *
 * @see elgg_get_entities
 *
 * @param array $options Array in format:
 *
 * 	metadata_names => NULL|ARR metadata names
 *
 * 	metadata_values => NULL|ARR metadata values
 *
 * 	metadata_name_value_pairs => NULL|ARR (
 *                                         name => 'name',
 *                                         value => 'value',
 *                                         'operand' => '=',
 *                                         'case_sensitive' => TRUE
 *                                        )
 * 	                             Currently if multiple values are sent via
 *                               an array (value => array('value1', 'value2')
 *                               the pair's operand will be forced to "IN".
 *
 * 	metadata_name_value_pairs_operator => NULL|STR The operator to use for combining
 *                                        (name = value) OPERATOR (name = value); default AND
 *
 * 	metadata_case_sensitive => BOOL Overall Case sensitive
 *
 *  order_by_metadata => NULL|ARR array(
 *                                      'name' => 'metadata_text1',
 *                                      'direction' => ASC|DESC,
 *                                      'as' => text|integer
 *                                     )
 *                                Also supports array('name' => 'metadata_text1')
 *
 *  metadata_owner_guids => NULL|ARR guids for metadata owners
 *
 * @return mixed If count, int. If not count, array. false on errors.
 * @since 1.7.0
 */

bookmarksモジュール内でelgg_get_entities_from_metadata()を使用しているところがあったので、使用方法を確認。
$options = array(
	'type' => 'object',
	'subtype' => 'bookmarks',
	'container_guid' => elgg_get_page_owner_guid(),
	'limit' => 6,
	'full_view' => false,
	'pagination' => false,
);
$content = elgg_list_entities($options);

これを参考に、mod/bookmarks/pages/bookmarks/add.php の先頭にコードを追加します。
<?php
/**
 * Add bookmark page
 *
 * @package Bookmarks
 */

// duplicate checks from bookmarklet
$input_title = get_input('title', '');
$input_address = get_input('address', '');
if ($input_title && $input_address) {
	$options = array(
		'type' => 'object',
		'subtype' => 'bookmarks',
		'container_guid' => elgg_get_page_owner_guid(),
		'limit' => 1,
		'metadata_names' => array('address'),
		'metadata_values' => array($input_address),
	);
	$entities = elgg_get_entities_from_metadata($options);
	if ($entities) {
		forward("bookmarks/edit/{$entities[0]->guid}");
	}
}

$page_owner = elgg_get_page_owner_entity();

$title = elgg_echo('bookmarks:add');
elgg_push_breadcrumb($title);

$vars = bookmarks_prepare_form_vars();
$content = elgg_view_form('bookmarks/save', array(), $vars);

$body = elgg_view_layout('content', array(
	'filter' => '',
	'content' => $content,
	'title' => $title,
));

echo elgg_view_page($title, $body);

入力に’title’と’address’があるか、ブックマークレットからの登録か、を確認して、入力があれば’address’が登録済みであるかを確認します。
他者あるいは他グループで同じURLが登録されていても関係ないので、container_guidを指定しています。
既に同一URLで複数登録されている可能性がありますが、一番最初に見つかったものだけ対象にするため、’limit’ => 1、を指定しています。
同じURLが見つかれば、forward(“bookmarks/edit/{$entities[0]->guid}”)、で見つかったブックマークレットの編集ページにリダイレクトします。

見つからなければ、そのまま新規ブックマーク登録ページが表示されます。


Elgg ブックマーク検索

Elgg でブックマークに登録したURLが検索できないので、調べてみました。
結果、ブックマークのURLは検索対象になっていないので、URLを検索できるようにします。

検索SQL

検索時のSQLがどのようになっているか確認。
SELECT count(DISTINCT e.guid) as total
 FROM entities e
  JOIN objects_entity oe ON e.guid = oe.guid
  WHERE  (oe.title LIKE '%{検索URL}%' OR oe.description LIKE '%{検索URL}%')
 AND  ((e.type = 'object' AND e.subtype IN (5)))
 AND  (e.site_guid IN (1)) AND ( (1 = 1)
  and e.enabled='yes')
...
SELECT count(DISTINCT e.guid) as total
 FROM entities e
  JOIN metadata md on e.guid = md.entity_guid
  JOIN metastrings msn on md.name_id = msn.id
  JOIN metastrings msv on md.value_id = msv.id
  WHERE  (msn.string IN ("tags","location","interests","skills")
      AND msv.string = '{検索URL}'
      AND ( (1 = 1)  and md.enabled='yes'))
 AND  (e.site_guid IN (1)) AND ( (1 = 1)  and e.enabled='yes')

前半のSQLは、以前Elgg 検索の日本語対応でLIKE検索に変更した部分です。
今回問題となるのは、metadata、metastringsをJOINしている後半のSQLです。

このSQL文の意味を理解するために、elggのデータ構造を確認します。

elggのデータ構造

elggは、ブログやブックマーク等それぞれのデータに対して独自のテーブルを持ちません。
すべてentityとして登録され、titleとdescriptionを持つobjects_entity、タグ等それ以外のデータを持つmetadataに紐付けられます。
metadataは、さらに属性名と値を持つmetastringsに紐付けられます。

あるブックマークデータは、データベース上で以下のように格納されています。
elgg_entity

elgg entity構造


このブックマークは、”php”,”oo-foo”,”sample”という3つのtagsを持ち、”http://www.oo-foo.com/”というaddressを持つブックマークです。

上のSQL文で
  WHERE  (msn.string IN ("tags","location","interests","skills","address")
とすれば、URLも検索される事になります。

検索タグの追加

検索タグの追加は、elgg_register_tag_metadata_name()で行います。

elgg_register_tag_metadata_name()を使用している箇所を見てみます。
ユーザー管理でも使用していますが本体組み込み部分なので、モジュールgroupsのソースを確認します。
elgg_register_event_handler('init', 'system', 'groups_init');

// Ensure this runs after other plugins
elgg_register_event_handler('init', 'system', 'groups_fields_setup', 10000);


function groups_fields_setup() {

	$profile_defaults = array(
		'description' => 'longtext',
		'briefdescription' => 'text',
		'interests' => 'tags',
		//'website' => 'url',
	);

	$profile_defaults = elgg_trigger_plugin_hook('profile:fields', 'group', NULL, $profile_defaults);

	elgg_set_config('group', $profile_defaults);

	// register any tag metadata names
	foreach ($profile_defaults as $name => $type) {
		if ($type == 'tags') {
			elgg_register_tag_metadata_name($name);

			// only shows up in search but why not just set this in en.php as doing it here
			// means you cannot override it in a plugin
			add_translation(get_current_language(), array("tag_names:$name" => elgg_echo("groups:$name")));
		}
	}
}

groupsデータのフィールド定義を行って検索用のタグを登録している、と読み取れます。
これと同様の処理をbookmarksモジュールにも追加します。

初期化ハンドラの追加
elgg_register_event_handler('init', 'system', 'bookmarks_init');

// Ensure this runs after other plugins
elgg_register_event_handler('init', 'system', 'bookmarks_fields_setup', 10000);


mod/bookmarks/start.php の最後に追加
function bookmarks_fields_setup() {

	elgg_register_tag_metadata_name('tags');
	elgg_register_tag_metadata_name('address');
}

“tags”は既に他のモジュール等で登録されているはずですが、ブックマークの検索タグとして必要なので一応登録しておきます。

これで
  WHERE  (msn.string IN ("tags","location","interests","skills","address")
      AND msv.string = '{検索URL}'
というSQL文が生成されるようになります。

しかし、このSQL文だと”msv.string = ‘{検索URL}’”なので、完全一致でない場合は検索されません。
部分一致でも検索されるように変更します。

SQL文の変更

タグ検索のSQL文は、searchモジュールのsearch_tags_hook()で処理されます。

	foreach ($search_tag_names as $tag) {
		$sanitised_tags[] = '"' . sanitise_string($tag) . '"';
	}

	$tags_in = implode(',', $sanitised_tags);

	$params['wheres'][] = "(msn.string IN ($tags_in) AND msv.string = '$query' AND $access)";


ここを以下の様に書き換えます。
	foreach ($search_tag_names as $tag) {
		$sanitised_tags[] = '"' . sanitise_string($tag) . '"';
	}

	$tags_in = implode(',', $sanitised_tags);

	$params['wheres'][] = "(msn.string IN ($tags_in) AND msv.string LIKE '%$query%' AND $access)";


これで
  WHERE  (msn.string IN ("tags","location","interests","skills","address")
      AND msv.string LIKE '%{検索URL}%'
というSQL文が生成され、ブックマークのURLが部分一致で検索できるようになります。


Elgg 検索の日本語対応

Elgg で検索に日本語を使用すると、稀に検索できてほぼ失敗、という結果になるので調査してみました。

まず、どういうSQL文が実行されているか確認してみます。「検索語」を検索してみたときの SQL の一部です。

SELECT count(DISTINCT e.guid) as total 
FROM entities e  JOIN objects_entity oe ON e.guid = oe.guid  
WHERE  (MATCH (oe.title,oe.description) AGAINST ('+検索語' IN BOOLEAN MODE))
  AND  ((e.type = 'object' AND e.subtype IN (4)))
  AND  (e.site_guid IN (1))
  AND ( (e.access_id = -2
			AND e.owner_guid IN (
				SELECT guid_one FROM entity_relationships
				WHERE relationship='friend' AND guid_two=41
			)) OR  (e.access_id IN (2,1,3)
			OR (e.owner_guid = 41)
			OR (
				e.access_id = 0
				AND e.owner_guid = 41
			)
		) and e.enabled='yes')
全文検索が行われているため、偶々空白で区切られていたものだけ検索でヒットしていたようです。

このSQL分を組み立てている箇所を探します。検索機能もモジュール化されていて、/mod/search/ にソースがあります。このディレクトリ内を探してみると /mod/search/start.php にあります。
/**
 * Returns a where clause for a search query.
 *
 * @param str $table Prefix for table to search on
 * @param array $fields Fields to match against
 * @param array $params Original search params
 * @return str
 */
function search_get_where_sql($table, $fields, $params, $use_fulltext = TRUE) {
	global $CONFIG;
	$query = $params['query'];

	// add the table prefix to the fields
	foreach ($fields as $i => $field) {
		if ($table) {
			$fields[$i] = "$table.$field";
		}
	}
	
	$where = '';

	// if query is shorter than the min for fts words
	// it's likely a single acronym or similar
	// switch to literal mode
	if (elgg_strlen($query) < $CONFIG->search_info['min_chars']) {
		$likes = array();
		$query = sanitise_string($query);
		foreach ($fields as $field) {
			$likes[] = "$field LIKE '%$query%'";
		}
		$likes_str = implode(' OR ', $likes);
		$where = "($likes_str)";
	} else {
		// if we're not using full text, rewrite the query for bool mode.
		// exploiting a feature(ish) of bool mode where +-word is the same as -word
		if (!$use_fulltext) {
			$query = '+' . str_replace(' ', ' +', $query);
		}
		
		// if using advanced, boolean operators, or paired "s, switch into boolean mode
		$booleans_used = preg_match("/([\-\+~])([\w]+)/i", $query);
		$advanced_search = (isset($params['advanced_search']) && $params['advanced_search']);
		$quotes_used = (elgg_substr_count($query, '"') >= 2); 
		
		if (!$use_fulltext || $booleans_used || $advanced_search || $quotes_used) {
			$options = 'IN BOOLEAN MODE';
		} else {
			// natural language mode is default and this keyword isn't supported in < 5.1
			//$options = 'IN NATURAL LANGUAGE MODE';
			$options = '';
		}
		
		// if short query, use query expansion.
		// @todo doesn't seem to be working well.
//		if (elgg_strlen($query) < 5) {
//			$options .= ' WITH QUERY EXPANSION';
//		}
		$query = sanitise_string($query);

		$fields_str = implode(',', $fields);
		$where = "(MATCH ($fields_str) AGAINST ('$query' $options))";
	}

	return $where;
}

$use_fulltext というパラメータがありますが、BOOLEAN MODE で検索をするか否かのフラグで全文検索するか否かの指定ではありません。/mod/search 内では全て $use_fulltext = FALSE として search_get_where_sql() が呼び出されています。
409行目の if 文で、検索文字列が $CONFIG->search_info['min_chars'] より小さい場合は LIKE 検索をするようなので、この処理を強制的に有効にしてみます。
410 ~ 416 行目までを有効にして、その他の 409 ~ 446 行目までをコメントアウトしてしまっても良いですが、$CONFIG->search_info['min_chars'] を設定している箇所を調べてみます。
これも、start.php にあります。
/**
 * Initialize search plugin
 */
function search_init() {
	global $CONFIG;
	require_once 'search_hooks.php';

	// page handler for search actions and results
	elgg_register_page_handler('search', 'search_page_handler');

	// register some default search hooks
	elgg_register_plugin_hook_handler('search', 'object', 'search_objects_hook');
	elgg_register_plugin_hook_handler('search', 'user', 'search_users_hook');
	elgg_register_plugin_hook_handler('search', 'group', 'search_groups_hook');

	// tags and comments are a bit different.
	// register a search types and a hooks for them.
	elgg_register_plugin_hook_handler('search_types', 'get_types', 'search_custom_types_tags_hook');
	elgg_register_plugin_hook_handler('search', 'tags', 'search_tags_hook');

	elgg_register_plugin_hook_handler('search_types', 'get_types', 'search_custom_types_comments_hook');
	elgg_register_plugin_hook_handler('search', 'comments', 'search_comments_hook');

	// get server min and max allowed chars for ft searching
	$CONFIG->search_info = array();

	// can't use get_data() here because some servers don't have these globals set,
	// which throws a db exception.
	$dblink = get_db_link('read');
	$r = mysql_query('SELECT @@ft_min_word_len as min, @@ft_max_word_len as max', $dblink);
	if ($r && ($word_lens = mysql_fetch_assoc($r))) {
		$CONFIG->search_info['min_chars'] = $word_lens['min'];
		$CONFIG->search_info['max_chars'] = $word_lens['max'];
	} else {
		// uhhh these are good numbers.
		$CONFIG->search_info['min_chars'] = 4;
		$CONFIG->search_info['max_chars'] = 90;
	}

	// add in CSS for search elements
	elgg_extend_view('css/elgg', 'search/css');

	// extend view for elgg topbar search box
	elgg_extend_view('page/elements/header', 'search/header');
}
全文検索インデックスの最小文字数を $CONFIG->search_info['min_chars'] に設定していて、検索文字列がこれよりも短い場合は、LIKE 検索を行うという仕様になっている様です。
今回は全文検索にはしないので、ここの処理自体が不要ですので以下のように変更します。
	// can't use get_data() here because some servers don't have these globals set,
	// which throws a db exception.
/*
	$dblink = get_db_link('read');
	$r = mysql_query('SELECT @@ft_min_word_len as min, @@ft_max_word_len as max', $dblink);
	if ($r && ($word_lens = mysql_fetch_assoc($r))) {
		$CONFIG->search_info['min_chars'] = $word_lens['min'];
		$CONFIG->search_info['max_chars'] = $word_lens['max'];
	} else {
		// uhhh these are good numbers.
		$CONFIG->search_info['min_chars'] = 4;
		$CONFIG->search_info['max_chars'] = 90;
	}
*/
	$CONFIG->search_info['min_chars'] = PHP_INT_MAX;
	$CONFIG->search_info['max_chars'] = PHP_INT_MAX;
最小文字数をとりあえずINTの最大値にしておいて、常に LIKE 検索するようにします。
SQL文を確認。
SELECT count(DISTINCT e.guid) as total 
FROM entities e  JOIN objects_entity oe ON e.guid = oe.guid  
WHERE  (oe.title LIKE '%検索語%' OR oe.description LIKE '%検索語%')
 AND  ((e.type = 'object' AND e.subtype IN (4)))
 AND  (e.site_guid IN (1))
 AND ( (e.access_id = -2
                        AND e.owner_guid IN (
                                SELECT guid_one FROM entity_relationships
                                WHERE relationship='friend' AND guid_two=41
                        )) OR  (e.access_id IN (2,1,3)
                        OR (e.owner_guid = 41)
                        OR (
                                e.access_id = 0
                                AND e.owner_guid = 41
                        )
                ) and e.enabled='yes')
これで日本語での検索が出来るようになりました。

テーブルの方も当然全文検索インデックスが作成されるようになっています。
CREATE TABLE `groups_entity` (
  `guid` bigint(20) unsigned NOT NULL,
  `name` text NOT NULL,
  `description` text NOT NULL,
  PRIMARY KEY (`guid`),
  KEY `name` (`name`(50)),
  KEY `description` (`description`(50)),
  FULLTEXT KEY `name_2` (`name`,`description`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE `objects_entity` (
  `guid` bigint(20) unsigned NOT NULL,
  `title` text NOT NULL,
  `description` text NOT NULL,
  PRIMARY KEY (`guid`),
  FULLTEXT KEY `title` (`title`,`description`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE `sites_entity` (
  `guid` bigint(20) unsigned NOT NULL,
  `name` text NOT NULL,
  `description` text NOT NULL,
  `url` varchar(255) NOT NULL,
  PRIMARY KEY (`guid`),
  UNIQUE KEY `url` (`url`),
  FULLTEXT KEY `name` (`name`,`description`,`url`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE `users_entity` (
  `guid` bigint(20) unsigned NOT NULL,
  `name` text NOT NULL,
  `username` varchar(128) NOT NULL DEFAULT '',
  `password` varchar(32) NOT NULL DEFAULT '',
  `salt` varchar(8) NOT NULL DEFAULT '',
  `email` text NOT NULL,
  `language` varchar(6) NOT NULL DEFAULT '',
  `code` varchar(32) NOT NULL DEFAULT '',
  `banned` enum('yes','no') NOT NULL DEFAULT 'no',
  `admin` enum('yes','no') NOT NULL DEFAULT 'no',
  `last_action` int(11) NOT NULL DEFAULT '0',
  `prev_last_action` int(11) NOT NULL DEFAULT '0',
  `last_login` int(11) NOT NULL DEFAULT '0',
  `prev_last_login` int(11) NOT NULL DEFAULT '0',
  PRIMARY KEY (`guid`),
  UNIQUE KEY `username` (`username`),
  KEY `password` (`password`),
  KEY `email` (`email`(50)),
  KEY `code` (`code`),
  KEY `last_action` (`last_action`),
  KEY `last_login` (`last_login`),
  KEY `admin` (`admin`),
  FULLTEXT KEY `name` (`name`),
  FULLTEXT KEY `name_2` (`name`,`username`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
全文検索を使用しないのであれば、これらのインデックスも削除しておけば余分なインデックス処理がなくなります。


Elggプラグイン livedoorクリップインポートプラグインの作成

Elgg のプラグイン開発に関する日本語ドキュメントが、プラグイン開発 – Elgg Documentation にあります。が、Elgg の内部動作がわかってないと理解し難い内容になっています。

Elgg は、ブックマーク機能等もプラグインになってるので、これらのプラグインを参考にして内部動作の理解も兼ねて、プラグインを作成してみます。作成する機能は、サービス終了となる「livedoorクリップ」のインポートです。

作成するプラグインのファイル構成は以下の様になります。

livedoorclip
│  manifest.xml
│  start.php
│
├─actions
│  └─livedoorclip
│          import.php
│
├─languages
│      en.php
│      ja.php
│
├─pages
│  └─livedoorclip
│          edit.php
│
└─views
    └─default
        ├─forms
        │  └─livedoorclip
        │          import.php
        │
        └─livedoorclip
                bookmark_sidebar.php

manifest.xml と start.php

manifest.xml と start.php は、プラグインのお決まりのファイルです。manifest.xml は、プラグインの説明用なので省略して、start.php から見ていきます。

<?php

elgg_register_event_handler('init', 'system', 'livedoorclip_init');

function livedoorclip_init() {
	$root = dirname(__FILE__);

	$action_path = "$root/actions/livedoorclip";
	elgg_register_action('livedoorclip/import', "$action_path/import.php");

	elgg_register_page_handler('livedoorclip', 'livedoorclip_page_handler');
	elgg_extend_view('bookmarks/sidebar', 'livedoorclip/bookmark_sidebar');
}

function livedoorclip_page_handler($page) {
	$pages = dirname(__FILE__) . '/pages/livedoorclip';
	include "$pages/edit.php";
	elgg_pop_context();
	return true;
}
elgg_register_event_handler() で、プラグインの初期化関数を設定します。
elgg_register_event_handler($event, $object_type, $callback, $priority = 500)
‘system’の’init’で、’livedoorclip_init’が呼び出されるようにします。

プラグインの初期化

プラグインの初期化関数 livedoorclip_init() では、以下の関数を使用します。
elgg_register_action($action, $filename = "", $access = 'logged_in') 
elgg_register_page_handler($handler, $function)
elgg_extend_view($view, $view_extension, $priority = 501, $viewtype = )
elgg_register_action() で、actionを登録します。
‘livedoorclip/import’ は、インポート処理を行うactionで、http://{ドメイン}/action/livedoorclip/import というURLになります。このURLに対応する処理が、”$action_path/import.php” となる様にします。

elgg_register_page_handler() で、ページハンドラを登録します。
http://{ドメイン}/livedoorclip[/...] というURLでアクセスされた時に、livedoorclip_page_handler() が呼び出される様にします。
パラメータには、ハンドラ(ここでは’livedoorclip’)以下のURLを ‘/’ で区切った配列が渡されます。Elgg Documentation の例で言うと、http://yoururl/blog/username/friends/ というURLの場合、array(‘username’,'friends’)というパラメータになります。
プラグインで複数のURLに対応する場合はこのパラメータによる分岐が必要になりますが、今回はファイルをアップロードして処理するだけなので、表示するページはアップロードフォームのみになります。
livedoorclip_page_handler() で、”/pages/livedoorclip/edit.php” が呼び出される様にします。

最後は elgg_extend_view() で、ブックマークページのサイドバーに「livedoorクリップのインポート」へのリンクを追加します。
ブックマークサイドバーのビューが’bookmarks/sidebar’となっているので、これを’livedoorclip/bookmark_sidebar’で拡張します。ビューのデフォルトの $priority が500で(‘bookmarks/sidebar’の $priority は500)、拡張する側ビュー(‘livedoorclip/bookmark_sidebar’)の $priority が拡張される側のビュー(‘bookmarks/sidebar’)よりも $priority が大きい場合、拡張される側のビューの後に表示されます。

アップロードフォームの表示

<?php
gatekeeper();
$user = get_loggedin_user();

$title = elgg_echo('livedoorclip:add');
elgg_push_breadcrumb($title);

$form_vars = array('enctype' => 'multipart/form-data');
$content = elgg_view_form('livedoorclip/import', $form_vars);

$body = elgg_view_layout('content', array(
	'filter' => '',
	'content' => $content,
	'title' => $title,
));

echo elgg_view_page($title, $body);
gatekeeper() は、ログインユーザーであるかの確認です。ログインしていないユーザーからのアクセスがあった場合は、ログインページに飛ばされます。

elgg_view_form() で、フォームを作成します。
‘livedoorclip/import’で参照されるファイルは views/default/forms/livedoorclip/import.php で、フォームのaction が /action/livedoorclip/import になります。
ファイルのアップロードを行うので、array(‘enctype’ => ‘multipart/form-data’) を $form_vars として渡しています。

elgg_view_layout() でページボディを作成します。
パラメータに指定している’content’ はプラグインでは作成していません。
elgg_view_layout ($layout_name, $vars=array())
は、page/layouts/$layout_name を探しに行くので、この場合は “views/default/page/layouts/content/” を探しに行きます。プラグインディレクトリにない場合は、”{elgg-root}/views/default/page/layouts/content/” を探しに行きます。レイアウトは標準のものを使うので、プラグイン側で定義していません。

elgg_view_page()を使ってページを表示します。

<div>
	<label>livedoor クリップ エクスポートファイル</label><br />
	<?php echo elgg_view('input/file', array('name' => 'upload')); ?>
</div>

<div class="elgg-foot">
<?php
echo elgg_view('input/submit', array('value' => elgg_echo("livedoorclip:add")));
?>
</div>
アップロードフォームは、ファイル選択ボックスとsubmitボタンで構成されます。
elgg_view('input/file', array('name' => 'upload'));
でファイル選択ボックスを表示します。’input/file’は作成しないで、標準の/views/default/input/file.php を使います。
submit ボタンも同様に標準のビューを使用します。

view の default

views/default/forms/livedoorclip/import.php、views/default/page/layouts/content/、等 default というディレクトリがありますが、この default はビュータイプを表します。
Engine/Views – Elgg Documentation によると、モバイルデバイス用に表示を変更したりも出来るようです。

action

<?php
gatekeeper();

if (isset($_FILES['upload']['name']) && !empty($_FILES['upload']['name']))
{
	$rss_data = file_get_contents($_FILES['upload']['tmp_name']);
	$rss_data =  str_replace('dc:subject', 'tag', $rss_data); 
	$rss_data =  str_replace('lc:scope', 'scope', $rss_data); 
	$rss = simplexml_load_string($rss_data);
	foreach ($rss->channel->item as $item)
	{
		$bookmark = new ElggObject;
		$bookmark->subtype = "bookmarks";
		$bookmark->container_guid = get_loggedin_userid();
		
		$bookmark->title = $item->title;
		$bookmark->address = $item->link;
		$bookmark->description = $item->description;
		
		$bookmark->access_id = ($item->scope == 'public')?2:0;
		$tagarray = array();
		foreach ($item->tag as $tag)
		{
			$tagarray[] = (string)$tag;
		}
		$bookmark->tags = $tagarray;
		
		$bookmark->time_created = strtotime($item->pubDate);
		
		if ( ! $bookmark->save()) {
			register_error(elgg_echo('livedoorclip:save:failed'));
			forward("livedoorclip");
		}
	}

	forward('bookmarks/owner/' . get_loggedin_user()->username);
}

register_error(elgg_echo("livedoorclip:none"));
forward('livedoorclip');
gatekeeper() でログイン状態を確認。
アップロードファイルがなければ、register_error() でエラーメッセージを登録して、/livedoorclip にリダイレクト。
livedoorクリップのエクスポートファイルはRSS形式になっているので、simplexml_load_string() で読み込みます。
SimpleXML は、名前空間の扱いが上手くないので、’dc:subject’と’lc:scope’を適当に置き換えておきます。
ブックマークプラグインのソース、mod/bookmarks/actions/bookmarks/save.php を参考にブックマークを登録していきます。
登録日をlivedoorクリップの登録日にあわせたかったため、
$bookmark->time_created = strtotime($item->pubDate);
としていますが、ElggObject の create では、time_created が設定されていても無視されるので、time_created はインポートした日時になってしまいます。
処理が完了したら、「自分の」ブックマークページに移動します。

ブックマークサイドバーにリンク表示

<?php
$owner = elgg_get_page_owner_entity();
if ( !$owner) return;
if ($owner instanceof ElggGroup) return;

echo '<a href="/livedoorclip">livedoorクリップのインポート</a>';
elgg_get_page_owner_entity() でページオーナーを確認して、「自分の」ブックマークページの時にのみ、ブックマークサイドバーにリンクを表示するようにします。
最後の echo ‘…’ は、Elgg 流ではありませんが、とりあえずリンクが表示されれば良いので。

言語ファイル

<?php
$japanese = array(
	
	 'livedoorclip' => "livedoor clip インポート",
	 'livedoorclip:add' => "インポート",
	 'livedoorclip:none' => "ファイルがありません。",
	 'livedoorclip:save:failed' => "ブックマークに登録できませんでした。",
	
	);
					
add_translation("ja",$japanese);
ここまでで何度か出てきた、elgg_echo() で表示される文字列の定義です。
英訳版も languages/en.php として用意しておきます。

デモサイト

http://elgg.oo-foo.com/ に今回作成したプラグインを設置しています。
しばらく elgg のデモサイトとして動かしておきますので、ご利用ください。


Elgg インストール

オープンソースのソーシャルネットワーキングエンジン「Elgg」をインストールしました。

Elgg は、主要機能であるブログ・ブックマーク・つぶやき(wire)もプラグインとして実装されており、拡張性に優れたソーシャルネットワーキングエンジンです。

パッケージを http://elgg.org/download.php からダウンロードして展開しておきます。
日本語言語パッケージが、http://community.elgg.org/plugins/899244/1.8.8.0/japanese-language-pack にあるので、こちらもダウンロードして展開しておきます。

elggのパッケージをアップロードしてアクセスするとインストーラが起動します。
「Welcome」画面が表示されるので「Next」。
Requirements check

赤枠で囲まれた内容を修正します。
「Web server」は、ドキュメントルートに.htaccessを作成しようとしてファイルを作成できない、「setting file」は、engine/settings.phpを作成しようとしてファイルを作成できない、というエラーなので、それぞれのディレクトリのパーミッションを変更します。

chmod 777 public_html
chmod 777 public_html/engine/
「Refresh」して、再度チェック。
Requirements check ok
グリーン表示に変わったので、「Next」。

Database installation
データベースの接続情報を入力して、「Next」。
‘Database Table Prefix’は、必須のようです。


‘Site Name’: サイト名称
‘Site Email Address’: サイトからのメール送信時のメールアドレス
‘Site URL’: URL
‘Elgg Install Directory’: elggをインストールしたディレクトリ
‘Data Directory’: elggのデータ用ディレクトリ
‘Default Site Access’: ブログ等作成データのデフォルトアクセス権

‘Data Directory’は、ドキュメントルートの外に書き込み可能なディレクトリを用意しておく必要があります。
サイト情報を設定して、「Next」。

Create admin account
管理者情報を入力して、「Next」。


以上で、インストール完了です。
「Go to site」で管理ページに移動します。

インストールが完了したので、パーミッションを元に戻しておきます。
chmod 755 public_html
chmod 755 public_html/engine/

elggをインストールしたディレクトリに、日本語言語パッケージを展開したファイルをコピーすると日本語が選択できるようになります。