task*padクローンをPHP+Mapleで

というわけでtask*padクローンをPHP+Mapleで作ってみます。

作成したクローンはこちら。
できたソースはこちら。
Subversionリポジトリアクセスできます。

$ svn co http://svn.nakarika.com/repos/taskpad/taskpad-maple taskpad-maple

はじめに

現状のMaple(3.1.1)は非常にクセの強いフレームワークです。コミュニティのメーリングリストでやりとりしていた時に

言い方はよくないですが素のMapleは、あくまで開発者のkunitさんが使いやすいフレームワークであり、
サンプル実装になっています。

との発言が飛び出しましたが、これには至極納得。
どうやらMapleユーザは標準添付の機能もろとも自己流に置き換え、拡張しながら使っているようです。
ということで今回は、デフォルト仕様を好き勝手にいじりつつやっていきたいと思います。

基本方針

  • 開発言語やフレームワークの勉強が目的なので、
    • パフォーマンスは二の次
    • DBなどバックエンドの障害エラーは考慮しない
    • セキュリティはちょっとだけ考慮
  • Maple-3.1.1なので
    • Mapleの流儀で機能は置き換え・拡張する。標準添付ソースコードはなるべく書き換えない
    • Mapleのデフォルト設定による命名のうち、理解しにくい名前は自分に合うよう変更する

開発環境

taskpadクローンの仕様

オリジナル説明書と動作を参考に以下を仕様とします。

    • ユーザごとにタスクを10件まで表示
    • ユーザ登録は不要。
    • ユーザの識別はクッキーによるセッション管理。(おおよそ1ブラウザ=1ユーザ)
    • タスク一件は「いつまで」「なにを」「タスク状態」「完了時間」の組み合わせ
      • 「いつまで」「なにを」は任意の文字列
      • 「タスク状態」は開始/達成/未達成の3状態
      • 「完了時間」は達成か未達成が設定された時刻
    • 他にタスクの削除、タスクリストの送信が行える

開発の流れ

  1. Mapleフレームワークを用意
  2. 対象データベースおよび操作ユーザを作成
  3. webトップページ生成
  4. フレームワークのデフォルト設定の変更、他
  5. アプリケーションの作りこみ

Mapleフレームワークの用意

フレームワークのインストール

現状のMaple(3.1.1)は、私が知っている他のフレームワークとは異なり、フレームワークディレクトリの「中に」ユーザアプリケーションファイルを追加していくようになっています(少なくともデフォルト設定では)。
今回はデフォルト設定のまま、一般ユーザのディレクトリの中にフレームワークを準備します。

$ cd src
$ tar xfz maple-3.1.1.tar.gz
$ mv maple-3.1.1 taskpad-maple
$ cd taskpad-maple

ディレクトリの構成はこちら。
※ 以降、taskpad-maple/. ディレクトリを BASE_DIR と表記します。

次にインストールの手順の通り、ディレクトリのアクセス権限を設定します。

$ chmod 777 -R webapp/templates_c
$ chmod 777 -R webapp/logs

BASE_DIR/webapp/maple.ini.php は今のところ変更する必要がないのでそのままにしました。

関連コンポーネントの入手およびインストール

MapleにはDB O/Rマッパは付属していませんが、Maple作者kunitさんによるO/Rマッパが公開されています。→ ActiveGateway
Ruby on RailsActiveRecordがベースとのこと、おもしろそうなので試してみます。

Wikiページ
には

現在Linux+PHP4.4.0+PostgreSQLという環境でしかチェックしていません。
もしかしたら、MySQLとかで使用すると不具合がでるかもしれません。

とありますが、Mysqlで添付されたテストケースを走らせたところ2件を除きパスしました。
2件はどうもテストケースの記述ミスのようなので、見なかったことにしてこのままMysqlでいきます。
このファイルの置き場所に悩みましたが、今回はMapleのためだけの導入なので、BASE_DIR/maple の下に db ディレクトリを作成し、そこに置きました。

MapleではHTML表示用に素のPHPSmartyFlexyをサポートしています。
Ethna版ではSmartyを使いましたが、せっかくなので今回はFlexyを試してみます。→ HTML_Template_Flexy
インストールは管理者権限で

# pear install -a HTML_Template_Flexy

としました。

データベース作成

データを格納するデータベースを作成します。キャラクタセットeuc-jpです。
テーブルレイアウトは、ActiveGatewayから扱いやすいようにEthna版からちょっと変えて、

    • プライマリキーの属性名を id に(規約)
    • thingsテーブルの外部参照キーの属性名を users_id に(規約:参照先テーブル名 + _id)
    • 更新日付属性の属性名を updated_on に(規約:行更新時にこの日時が自動で変わる)

としました。

(BASE_DIR/webapp/schema/mysql.sql)

CREATE DATABASE IF NOT EXISTS taskpad_maple;

use taskpad_maple;

GRANT SELECT, INSERT, DELETE, UPDATE ON taskpad_maple.* TO taskpad@localhost IDENTIFIED BY 'passwd';

CREATE TABLE IF NOT EXISTS users (
 id INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
 session VARCHAR(64) NOT NULL
) ENGINE = InnoDB;

CREATE TABLE IF NOT EXISTS things (
 id INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
 users_id INT NOT NULL,
 deadline TEXT NOT NULL,
 body TEXT NOT NULL,
 completed ENUM('-', 'Y', 'N') NOT NULL,
 updated_on TIMESTAMP NOT NULL,
 FOREIGN KEY(users_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE = InnoDB;

実際にデータベースを作成します。

$ mysql -p -u root < webapp/schema/mysql.sql

taskpadトップページの作成

まずトップページを作ります。
Mapleにはソースコードジェネレータが付属しています。
引数なしで呼び出すとヘルプが表示されます。

$ php script/generate.php
Usage: /path/to/php script/generate.php [generator_name] [args]

Installed Generators:
component, action, converter, filter, validator, simple

引数にジェネレータ名を渡すとさらにヘルプが出ます。
action ジェネレータで試すと、

$ php script/generate.php action
Usage: /path/to/php script/generate.php action [action_name] [ [template_type] [template_name] [entry_name] ]

と出力されます。


...と、毎回 php script/generate.php と入力するのは面倒なので、呼び出しスクリプトを書きます。
(BASE_DIR/generate.sh)

#!/bin/bash
php script/generate.php $1 $2 $3 $4 $5 $6 $7 $8 $9
$ chmod 755 generate.sh
$ ./generate action
Usage: /path/to/php script/generate.php action [action_name] [ [template_type] [template_name] [entry_name] ]


さて、実際に action ジェネレータでトップページを生成してみます。

$ ./generate action mode_list flexy flexy_top index
  [create]  ~/src/taskpad-maple/webapp/modules/mode/list/List.class.php
  [create]  ~/src/taskpad-maple/webapp/modules/mode/list/maple.ini
  [create]  ~/src/taskpad-maple/webapp/templates/flexy/top.html
  [create]  ~/src/taskpad-maple/htdocs/index.php


作成されたファイルはそれぞれ

    • BASE_DIR/webapp/modules/mode/list/List.class.php
      アクションクラス
    • BASE_DIR/webapp/modules/mode/list/maple.ini
      フィルタ設定
    • BASE_DIR/webapp/templates/flexy/top.html
      flexy用テンプレート
    • BASE_DIR/htdocs/index.php
      エントリポイント

となっています。

「エントリポイント」は、Ethnaと同様、webサーバで公開するファイルです。役割も同じで、Mapleフレームワークを呼び出す基点となります。
(BASE_DIR/htdocs/index.html)

<?php
error_reporting(E_ALL);
//error_reporting(0);

/**
 * MapleのBaseディレクトリの設定
 */
define('BASE_DIR', dirname(dirname(__FILE__)));

/**
 * Debugフィルターを発動させるかどうかの設定
 */
define('DEBUG_MODE', 0);

/**
 * Mapleの設定ファイルの読込み
 */
require_once BASE_DIR . "/webapp/config/maple.inc.php";

/**
 * このアプリの独自設定
 */
define('ACTION_KEY',     'action');
define('CONFIG_FILE',    'maple.ini');
define('DEFAULT_ACTION', 'mode_list');
define('LOG_LEVEL',      LEVEL_TRACE);

/**
 * フレームワーク起動
 */
$controller =& new Controller();
$controller->execute();
?>

ジェネレータで生成したアクション mode_list が "DEFAULT_ACTION" として指定されています。起動すべきアクションが見つからなかった場合にデフォルトで呼ばれるアクションになります。
同様にジェネレータで生成した maple.ini も "CONFIG_FILE" として定義されています。
...えーと、これなんですが、このファイルの用途はフィルタ動作を設定するためにしか使いません(少なくとも今回のアプリケーションでは)。
なので名前は filter.ini の方が実際の役割を表すように思います。
今回はエントリポイントの定義を変更するだけで済みますが、もしジェネレータのデフォルト出力を変えたい場合は、フレームワーク全体を置換することになりそうです。

$ find . -name .svn -prune -o -type f -exec sed -i -e "s/maple.ini/filter.ini/" {} \;
$ for f in `find . -name .svn -prune -o -name maple.ini -print`; do
   mv $f `dirname $f`/filter.ini
  done


さて、生成されたアクションの中身を確認します。
(BASE_DIR/webapp/modules/mode/list/List.class.php)

<?php
/**
 * [[機能説明]]
 *
 * @package     [[package名]]
 * @author      [[あなたの名前]]
 * @copyright   [[copyright]]
 * @license     [[license]]
 * @access      public
 */
class Mode_List
{
    /**
     * [[機能説明]]
     *
     * @access  public
     */
    function execute()
    {
        return 'success';
    }
}
?>

唯一の関数 execute() が 'success' という文字列を返しています。

このアクションと同じディレクトリに生成された旧 maple.ini、現 filter.ini を見ていきます。
(BASE_DIR/webapp/modules/mode/list/filter.ini)

;[Convert]
;*.trim =

[FlexyView]
success = "flexy/top.html"

.INIファイル形式で書かれています。
ここに書かれている内容は、アクション処理の前後に実行される「フィルタ」に渡されます。
各セクション([FlexyView]など)は、どのフィルタを実行するかを示し、セクションに続く項目がフィルタへのパラメータとなります。
FlexyViewフィルタは今回のアプリケーションで試すことにしたflexyテンプレートを処理します。
FlexyViewフィルタには「アクションから返される文字列がxxx なら」「テンプレートファイル yyy を出力する」というパラメータを指定します(複数可能)。
ここでは、'success' という文字列が返ると flexy/top.html テンプレートファイルを出力するように設定されています。
今回ジェネレータで生成したテンプレートファイルです。
(BASE_DIR/webapp/templates/flexy/top.html)

<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=EUC-JP">
<title>Hello, Maple!</title>
</head>
<body>
<h1>Hello, Maple!</h1>
</body>
</html>


ということで、apacheでページを表示できるようにして、

$ cd ~/public_html
$ mkdir -p taskpad/maple
$ ln -s ~/src/taskpad-maple/htdocs/index.php taskpad/maple/

ブラウザからアクセスしてみると、


Hello, Maple!

生成された4つのファイルを経由して、テンプレートの出力結果が表示されます。

ケルトンの修正

ジェネレータは、生成ファイルの雛形を持っています。(下記抜粋)

/**
 * [[機能説明]]
 *
 * @package     [[package名]]
 * @author      [[あなたの名前]]
 * @copyright   [[copyright]]
 * @license     [[license]]
 * @access      public
 */

いまさらですが、アプリケーション用にコメント内容を修正しておきます。

アプリケーションの作りこみ

処理の流れはEthna版と変わりません。

エントリポイント
     |
     +--> 一覧(list)アクション    -> DBから取得 -> HTML出力
     |
     +--> タスク開始(start)       -> DB更新   --+
     |                                          |
     +--> タスク達成(finish)      -> DB更新   --+
     |                                          |
     +--> タスク未達成(notfinish) -> DB更新   --+--> 一覧アクションへリダイレクト
     |                                          |            ^
     +--> タスククリア(clear)     -> DB更新   --+            |
     |                                                       |
     +--> メール送信(mail)        -> DBから取得 -> SMTP送信--+
flexyテンプレートの置き換え

前回、オリジナルHTMLコードを取り込んでSmary用に転用しましたので(許可してくださった百式の田口さんのご厚意に改めて感謝)、今回はそれをFlexy用に直して使います。

まずアクション呼び出し方式をMapleに合わせます。

Ethna版ではこんな感じだったところを、

  <input type="hidden" name="mode" value="start" />

Maple版ではこうします。

  <input type="hidden" name="action" value="mode_start" />

またはこうしてもいいようです。

  <input type="hidden" name="dispatch_mode_action" />


Mapleでは2通りの指定方法があって、

  1. <input name="(ACTION_KEY定数の値)" value="(アクション名)" ...>
  2. <input name="dispatch_(アクション名)" ...>

なのだそうです。
ACTION_KEY 定数はジェネレータで生成されたエントリポイントファイル(今回は BASE_DIR/htdocs/index.php)で定義されており、初期値は"ACTION"になっています。

考えてみると "dispath_xx" の形式ならvalue属性は無視されることから、<hidden> を省き、

   <input type="submit" value="開始" name="dispatch_mode_start">

でもいけそうです。

次はアクションから値を受け取る場合の書式を変更します。
Ethnaでは app 変数経由で受け取りました。

  1. <input type="text" size="50" value="{$app.deadline}までに{$app.body}" ...

Mapleでは action 変数です。

さら書式をSmartyからFlexyに変更します。
Smartyでは

{$app.deadline}

だったところが、Flexyでは $ が要らなくなり、

{app.deadline}

となります。

と、小さな差異だけならよかったのですが、Flexyでは {elseif} が使えないのが困りました。
Smarty版では一応、日本語文字列を極力テンプレート内に閉じ込めようとしていて、そのためテンプレート内で比較演算が必要だったのですが。
仕方がないので、Flexy版では日本語リソースを閉じ込めたPHPクラスを別途作り、テンプレートから関数呼び出しで取得するようにしました。
すべての変更を終えた(差分はこちら)
テンプレートの他、必要なcssとjsをEthna版と同様に用意します。

アクション生成

この時点でジェネレータですべて生成しておきます。

$ ./generate.sh action mode_start
  [create]  ~/src/taskpad-maple/webapp/modules/mode/start/Start.class.php
$ ./generate.sh action mode_finish
  [create]  ~/src/taskpad-maple/webapp/modules/mode/finish/Finish.class.php
$ ./generate.sh action mode_notfinish
  [create]  ~/src/taskpad-maple/webapp/modules/mode/notfinish/Notfinish.class.php
$ ./generate.sh action mode_clear
  [create]  ~/src/taskpad-maple/webapp/modules/mode/clear/Clear.class.php
$ ./generate.sh action mode_mail
  [create]  ~/src/taskpad-maple/webapp/modules/mode/mail/Mail.class.php
データベース処理クラス生成

ActiveGatewayを使ってデータベーステーブルの更新・取得を行うクラスを作ります。
Mapleではこうしたロジック系処理クラスを「コンポーネント」と呼びます。コンポーネントはジェネレータで生成できます。

$ ./generate.sh component
Usage: /path/to/php script/generate.php component [component_name]

ユーザテーブル、タスクテーブル処理用コンポーネントを生成します。

$ ./generate.sh component manage.users
  [create]  ~/src/taskpad-maple/webapp/components/manage/Users.class.php
$ ./generate.sh component manage.things
  [create]  ~/src/taskpad-maple/webapp/components/manage/Things.class.php

なお、アクションの名前では mode_listコンポーネントでは manage.users というように名前のデリミタが異なっています(アンダーバーとピリオド)。
どういった経緯でこうなっているのか解りませんが、流儀に従っておきます。
これまで書きませんでしたが、このデリミタは階層構造を表現するもので、Mapleディレクトリ階層やクラス名にマップします。

データベース処理クラス実装

Ethna版を移植します。
(BASE_DIR/webapp/components/manage/Users.class.php)

class Manage_Users
{
  var $gw;          // ActiveGateway

  /*
   * ユーザIDの取得
   * @param boolean create_if_not_exists ユーザ情報がない場合は作成する
   * @return integer uid, or null
   */
  function getUID($create_if_not_exists = false)
  {
    $sid = session_id();

    // SELECT FROM users WHERE session = $sid
    $row =& $this->gw->find_by('users', 'session', $sid);
    if ($row) {
      return $row->id;
    }

    if ($create_if_not_exists) {
      $this->gw->create('users', array('session' => $sid));
      $row =& $this->gw->find_by('users', 'session', $sid);
    }
    return $row ? $row->id : null;
  }
}
?>

ActiveGatewayは高機能なのでコードは短くて済みます。
なお、クラスのインスタンス変数 $gw を初期化せずに使っているのですが、これはMaple付属の機能(DI:Dependency Injection)を利用して、「実行時」に「インスタンスの外部から」初期化・設定します。DI利用設定はあとで行います。
thingsテーブルアクセスクラスでは、$gw に加え、$users もDI対象としています。
(BASE_DIR/webapp/components/manage/Things.class.php)

class Manage_Things
{
  var $gw;          // ActiveGateway
  var $users;       // usersテーブル管理

  
  // タスクを追加する
  function add($deadline, $body)
  {
    $uid = $this->users->getUID(true);
    if (!$uid) {
      // mada
      return null;
    }
    $this->gw->create('things',
      array('users_id' => $uid,
            'deadline' => $deadline,
            'body' => $body,
            'completed' => '-'));
    return true;
  }

  // 達成/未達成をマークする
  function markFinish($finished)
  {
    $uid = $this->users->getUID(false);
    if (!$uid) {
      return true;
    }

    $row =& $this->gw->find_by('things',
                               'completed', '-',
                               array('order' => 'id desc',
                               'limit' => 1));
    if ($row) {
      $row->completed = $finished ? 'Y' : 'N';
      return $this->gw->save($row);
    }
    return false;
  }

   // タスク一覧取得
  function listup(&$crnt_task, &$done_list)
  {
    $crnt_task = null;
    $done_list = array();

    $uid = $this->users->getUID(false);
    if (!$uid) {
      return true;
    }

    $rows =& $this->gw->find_all_by('things',
                                    'users_id', $uid,
                                    array('order' => 'id desc',
                                    'limit' => 11));
    while ($row =& $this->gw->each($rows)) {
      if ($row->completed == '-') {
        $crnt_task = $row;
        continue;
      }

      $row->ago = time() - strtotime($row->updated_on);
      array_push($done_list, $row);
    }

    return true;
  }

  // タスクの全削除
  function clear()
  {
    $uid = $this->users->getUID(false);
    if ($uid) {
      $this->gw->delete_all('things', "users_id = $uid");
    }
  }

  // タスクリストのサイズを修正
  function resizeList(&$list, $max_count)
  {
    $empty_task = $this->createEmptyTask();

    $count = count($list);
    for ($i = $count; $i < $max_count; ++$i) {
      $list[$i] = clone $empty_task;
    }
    for ($i = $count; $max_count < $i; --$i) {
      array_pop($list);
    }
  }

  // タスクリストのサイズを修正
  function resizeList(&$list, $max_count)
  {
    $empty_task = $this->createEmptyTask();

    $count = count($list);
    for ($i = $count; $i < $max_count; ++$i) {
      $list[$i] = clone $empty_task;
    }
    for ($i = $count; $max_count < $i; --$i) {
      array_pop($list);
    }
  }

  // 空のタスクを生成(resizeList()用)
  function createEmptyTask()
  {
    $task->ago = -1;
    $task->completed = '-';
    return $task;
  }
「タスク開始」アクションの実装

flexyテンプレートでの「タスク開始」アクションを呼び出すフォームはおおよそ次の構成になっており、

  <form name="gbtw" action="?" method="post">
   <input type="text" name="task0">
   <input type="text" name="task1"
   <input type="submit" value="開始" name="dispatch_mode_start">
  </form>

呼び出されるタスク開始アクションでは、

  1. 入力値変換:task0, task1 の両方に文字列が安全な文字列にする
  2. 妥当性判定:task0, task1 の両方に文字列が入っていること
  3. DB登録処理

の流れで処理を行うようにしていきます。

上の1,2についてはMapleのフィルタが利用できます。
ジェネレータで生成したアクションと同じディレクトリにフィルタ設定ファイル filter.ini を新規作成し(Mapleデフォルトでは maple.ini)、
(BASE_DIR/webapp/modules/mode/start/filter.ini")

[Convert]
task0,task1.trim =

[Validate]
task0.required = 1,いつまでに達成しましょうか
task1.required = 1,なにをやりましょうか

とします。
Convert フィルタでは、task0task1 にtrim処理(前後空白文字列を取り除く)を行うよう指定してます。

Validate フィルタでは、task0task1required(必須項目)だとしています。
右辺はカンマ区切りで、最初の数字は 010だと次の妥当性判定も行う、1ならそこで停止。次の文字列は妥当性エラー時に使用するための文字列になっている、とのことです。

さて、開始アクションクラスを実装します。
(BASE_DIR/webapp/modules/mode/start/Start.class.php)

class Mode_Start
{
+ var $task0;
+ var $task1;
+ var $things;

  function execute()
  {
-   return 'success';
+   $this->things->add($this->task0, $this->task1);
+   return 'redirect_to_top';
  }
}

フォームクエリパラメータと同じ名前のプロパティを用意しておくと($task0, $task1)、Mapleが(Actionフィルタが)値を設定してくれます。
$things はDI(Dependency Injection)経由でThingsコンポーネントを設定するつもりでいます。
execute()メソッドでは、タスクをテーブルに新規追加し、リダイレクトをする、というつもりのコードを書きました。


つもりで書いたコードを動くようにします。
Thingsコンポーネントやリダイレクト処理は他のアクションでも使うので、フィルタ設定ファイルを他のアクションから見える場所に用意します。
(BASE_DIR/webapp/modules/mode/filter.ini)

[GlobalFilter]
Session =
DIContainer =
FlexyView =
Action =

[Session]
mode = start
path = /
name = TASKPADMAPLE
cacheLimiter = "private, must-revalidate"
lifetime = 31536000

[DIContainer]
filename_global = /modules/mode/di.ini

[FlexyView]
redirect_to_top = "location: ."

[Action]
things = ref:Things

共通のフィルタ設定を有効にするには、設定ファイルの先頭に [GlobalFilter] というセクション名で「どのフィルタを」「どの順序で」ということを書いておく必要があるらしいので、そうします。(右辺が空というのがなんとも...)
Sessionフィルタは思い出したので追加しました。全アクションの開始前にセッションを開始・再開させます。 DIContainerフィルタには、DI設定内容を書いたファイルのパスを指定します。(dicon.ini という名前が通例のようですが、「DIを設定する」と意味であれば di.ini の方がふさわしいでしょう)
Viewフィルタ系は特別な構文を備えていて、

    • "location: (url)"
      リダイレクト
    • "action: (action-name)"
      指定アクション実行

なのだそうです。


DI設定ファイルです。
(BASE_DIR/webapp/modules/mode/di.ini)

[Users:manage.users]
[Things:manage.things]

書式がこれまた独特です。

で、ブラウザからまずは妥当性判定処理を試すために、タスク欄を空白にしたまま「開始」ボタンを押します...。
何も表示されず。
設定を忘れていました。
妥当性判定がエラーになると、どうやらActionには処理が移らずに、input というキー名で指定されたテンプレートが表示されるとのこと。

[FlexyView]
input = "path/to/template/for/validation-error

input という名前は解りにくい気がします。
BASE_DIR/config/maple.ini.php の定義を変更し、
(BASE_DIR/webapp/config/maple.ini.php)

-define('VALIDATE_ERROR_TYPE', 'input');
+define('VALIDATE_ERROR_TYPE', 'validateError');
define('TOKEN_ERROR_TYPE', 'invalidToken');
-define('UPLOAD_ERROR_TYPE', 'upload');
+define('UPLOAD_ERROR_TYPE', 'uploadError');

として、
(BASE_DIR/webapp/modules/mode/filter.ini)

[FlexyView]
redirect_to_top = "location: ."
  1. validateError = "location: ."

とします。

解決したので次に行きます。

ブラウザからタスクを埋めて「開始」ボタンを押して...。
エラー発生。

Thingsコンポーネントの$gwプロパティが未設定とのこと。
忘れてました。DIでコンポーネントにActiveGatewayのインスタンスを注入(inject)するつもりでした。
ActiveGatewayは BASE_DIR/maple/db の下に置いたのですが、どうも DIContainerBASE_DIR/webapp/components より下にあるファイルしか読まないようです。親ディレクトリ指定 ".." を使えば何とかならなくもないですが、美しくないので、Maple wikiで公開されているDIContainer2を導入します。

インストールの後、フィルタ設定ファイルを書き換え、
(BASE_DIR/webapp/modules/mode/filter.ini)

-[DIContainer]
+[DIContainer2]

DI設定ファイルも書き換えます。
(BASE_DIR/webapp/modules/mode/di.ini)

  • [Users:manage.users]
  • [Things:manage.things]
  1. [DIContainer]
  2. Gateway = maple://constructor@ActiveGateway/db/
  3. Users = manage.users
  4. Things = manage.things
+
  1. [Gateway]
  2. ; DIContainerセクションで inject_typeconstructor と指定したので、
  3. ; 以下はコンストラクタの引数になる(書かれた順番で)
  4. param1 = "mysql://taskpad:passwd@localhost/taskpad_maple"
+
  1. [Users]
  2. gw = dicon://Gateway
+
  1. [Things]
  2. gw = dicon://Gateway
  3. users = dicon://Users

書式が変わりましたが、こちらの方が好みです。ただし複雑ですが。
文法はこのような感じのようです。

 [DIContainer] section for DIContainer2
   alias ::= inst_spec
   inst_spec ::= component_spec | pear_spec | maple_spec | dicon_spec

   component_spec ::= ["component://"] [inject_type[":" init_option] "@"] component_path
   pear_spec      ::= "pear://" [inject_type[":" init_option] "@"] class_name
   maple_spec     ::= "maple://" [injec_ttype[":" init_option] "@"] class_name ["/" class_path]
   dicon_spec     ::= "dicon://" alias ["/" method_name]

   inject_type    ::= "setter" | "constructor" | "factory"
   component_path ::= filespec_string ["." component_path]
   class_path     ::= path file_name
   path           ::= filespec_string [ "/" path ]
   file_name      ::= filespec_string [ ".class.php" ]

 Injection sections for DIContainer2
   section     ::= "[" alias "]"
   entry       ::= target "=" value
 
   target_line ::= ['"'] target ['"']
   target      ::= variable | setter_method
 
   variable    ::= identifier | array
   array       ::= identifier "[" array_key "]"
   array_key   ::= ['"'] string ['"']
 
   value       ::= string | instance
   instance    ::= "dicon://" alias

再度ブラウザからタスクを埋めて「開始」ボタンを押して...。
また同じエラー発生。

どうも [Action] セクションは個々のアクション用のフィルタ設定ファイルに書かなければならないようです。

共通の設定ファイルから [Action] セクションを削除し、

(BASE_DIR/webapp/modules/mode/filter.ini)

[GlobalFilter]
Session =
DIContainer =
FlexyView =
-Action =

-[Action]
-things = ref:Things

開始アクション用の filter.ini の終わりに追加します。
(BASE_DIR/webapp/modules/mode/start/filter.ini)

+[Action]
+things = ref:Things

もう一度ブラウザから試してみて、トップページにリダイレクトされること、users テーブルと things テーブルが更新されることがようやく確認できました。

実行中タスクの表示

タスクを開始したら、トップページでは実行中のタスクを「xxxまでにyyy」と表示し、「開始」ボタンの代わりに「達成」「未達成」ボタンを表示します。
表示の切り替えは、flexyテンプレートからactionの $crnt_task プロパティが null かどうかの判定結果で行います。
また、「xxxまでにyyy」という、日本語リテラル「までに」を含む文字列の生成は、Ethna-Smarty版ではテンプレート内に埋め込んでいましたが、Maple-Flexy版では日本語リソースをカプセル化したクラスを用意し、アクションオブジェクト経由でメソッド呼び出しをするようにしました。

{if:!action.crnt_task}
  <table>
  <tr valign="top">
  <td><input type="text" name="task0" value="" /></td>
  <td><input type="text" name="task1" value="" /></td>
  <td><input type="submit" value="開始" name="dispatch_mode_start" /></td></tr>
  </table>
{else:}
  <input type="text" disabled value="{action.intl.getDesc(action.crnt_task)}" />
  <input type="button" value="達成!" onclick="location.href='?action=mode_finish'" />
  <input type="button" value="未達成" onclick="location.href='?action=mode_notfinish'" />
{end:}

一覧アクションを実装します。
(BASE_DIR/webapp/modules/mode/list/List.class.php)

class Mode_List
{
+ var $things;
+ var $intl;
+ var $crnt_task;
+ var $done_list;

  function execute()
  {
+   $this->things->listup($this->crnt_task, $this->done_list);
    return 'success';
  }
}

一覧アクション用のfilter.ini を修正します。
(BASE_DIR/webapp/modules/mode/list/filter.ini)

[FlexyView]
success = "flexy/top.html"

+[Action]
+things = ref:Things
+intl = ref:Intl

アクション共通のDI設定ファイルを修正します。
(BASE_DIR/webapp/modules/mode/di.ini)

[DIContainer]
Gateway = maple://constructor@ActiveGateway/db/
Users = manage.users
Things = manage.things
+Intl = intl.ja

日本語リソースコンポーネントを作成します。

$ ./generate.sh component intl.ja
  [create]  ~/src/taskpad-maple/webapp/components/intl/Ja.class.php

実装します。
(BASE_DIR/webapp/components/intl/Ja.class.php)

class Intl_Ja
{
  function getDesc($task)
  {
    return sprintf("%sまでに%s", $task->deadline, $task->body);
  }
}

ブラウザでトップページへアクセスしてみて、タスクや「達成」「未達成」ボタンが正しく表示されたらOKです。

「タスク達成」「タスク未達成」アクションの実装

流れ作業です。
実装します。
(BASE_DIR/webapp/modules/mode/finish/Finish.class.php)

class Mode_Finish
{
+ var $things;

  function execute()
  {
-   return 'success';
+   $this->things->markFinish(true);
+   return 'redirect_to_top';
  }
}

フィルタ設定ファイルを新規作成します。
(BASE_DIR/webapp/modules/mode/finish/filter.ini)

[Action]
things = ref:Things


同様に未達成アクション。
(BASE_DIR/webapp/modules/mode/notfinish/Notfinish.class.php)

class Mode_Notfinish
{
+ var $things;

  function execute()
  {
-   return 'success';
+   $this->things->markFinish(false);
+   return 'redirect_to_top';
  }
}

フィルタ設定ファイル。
(BASE_DIR/webapp/modules/mode/notfinish/filter.ini)

[Action]
things = ref:Things

ブラウザから達成ボタンを押してみて、thingsテーブルが正しく更新されたらOKです。

データベース文字化け対応

手元のサーバ環境では、タスク欄に日本語を入力すると文字化けを起こしました。Ethna版と同じく mysql の通信用エンコードEUC-JP にセットする必要があります。
えーと、どう実現しましょうか。
ActiveGateway が connect() した直後に SET CHARACTER SET ujis を投げたいのですが、connect()成功フックのようなものは無いですし、派生クラスを作るのは「やり過ぎ」のような気がしますし。
PEAR::MDB2付属のmysqlドライバであれば、DSNエンコードを設定できるのですが、ActiveGatewayはPEAR::DBを使っていますし、置き換えるのは骨が折れそうですし。

安直に行きます。
汎用クエリ発行クラスを作り、

$ ./generate.sh component gateway.query
  [create]  ~/src/taskpad-maple/webapp/components/gateway/Query.class.php

実装して、

class Gateway_Query
{
+ var $gw;
+
+ function setQuery($sql)
+ {
+   if ($this->gw->connect()) {
+     $this->gw->_connection->query($sql);
+   }
+ }
}

アクション共通DI設定にて

[DIContainer]
Gateway = maple://constructor@ActiveGateway/db/
+InitMysql = gateway.query
Users = manage.users

+[InitMysql]
+gw = dicon://Gateway
+query = "SET CHARACTER SET ujis"

とすると、設定ファイル最後の queryGateway_Queryクラスの setQuery 呼び出しになって、無事目的を果たします。
非常にBADなやり方のような気がしますが、ここまでで少し疲れているので (てへっ)、
このまま先に進みます。

一覧表示と履歴情報クリアアクションの実装

もう特に目新しいことはないので説明を省きます。

メール送信アクションの実装

困ったことに、Mapleには日本語メール送信処理が実装されてません。
手元の「超極める!PHP」なる本にメール送信クラスの記事がありましたので、それをMapleに持ってきて使います。(BASE_DIR/maple/mail/Mail_I18n.phpに配置)

アクションから使いやすいよう、コンポーネント化します。

$ ./generate.sh component mail.send
  [create]  ~/src/taskpad-maple/webapp/components/mail/Send.class.php

実装します。
(BASE_DIR/webapp/components/mail/send.class.php)

require_once(MAPLE_DIR . '/mail/Mail_I18n.php');

class Mail_Send
{
+ function send($from, $from_name, $to, $subject, $body)
+ {
+   $send_from = str_replace( "\x00", "", $from);
+   $send_from_name = str_replace( "\x00", "", $from_name);
+   $send_body = str_replace( "\x00", "", $body);
+   $send_to = str_replace( "\x00", "", $to);
+   $send_subject = str_replace( "\x00", "", $subject);

+   $send_from_line = "";
+   if ($send_name != "") {
+     $send_name = Mail_I18n::escapeMailComment($send_name);
+     $send_from_line = "From: $send_name <$send_from>";
+   } else {
+     $send_from_line = "From: $send_from";
+   }

+   $sender =& new Mail_I18n($this->smtp_config);
+   $sender->Send($send_to, $send_subject, $send_body, $send_from_line, "-f$send_from");
+ }

+ function sendText($mail_text, $config = null)
+ {
+   list($header_line, $body) = preg_split('/\r?\n\r?\n/', $mail_text, 2);
+   $sender =& new Mail_I18n($config);
+   return $sender->send("", "", $body, $header_line);
+ }
}

せっかくflexyがあるので、メールにもflexyテンプレートを利用できるようにします。

$ ./generate.sh component mail.send.flexy
  [create]  ~/src/taskpad-maple/webapp/components/mail/send/Flexy.class.php

実装します。
(BASE_DIR/webapp/components/mail/send/Flexy.class.php)

require_once(MAPLE_DIR .'/flexy/Flexy_Flexy4Maple.class.php');
require_once(MAPLE_DIR .'/flexy/Flexy_ViewBase.class.php');
require_once(MAPLE_DIR .'/flexy/Flexy_FormElementFilter.class.php');
require_once(MAPLE_DIR .'/flexy/Flexy_ComponentElementFilter.class.php');
require_once(dirname(dirname(__FILE__)) . '/Send.class.php');

class Mail_Send_Flexy
{
+ var $template;
+ var $smtp_config_path;

+ function send($template_arg = "")
+ {
+   $tmpl = ($template_arg != "") ? $template_arg : $this->template;
+   if ($tmpl == "") {
+     $log =& logFactory::getLog();
+     $log->error("テンプレートファイルが指定されていません", get_class($this) . "#_renderByFlexy");
+     return false;
+   }

+   $mail = $this->_renderByFlexy($tmpl);
+   if ($mail == "") {
+     return false;
+   }

+   if(TEMPLATE_CODE != INTERNAL_CODE) {
+     $mail = mb_convert_encoding($result, INTERNAL_CODE_CODE, TEMPLATE_CODE);
+   }

+   return Mail_Send::sendText($mail, $this->_getSmtpConfig());
+ }

+ function _getSmtpConfig()
+ {
+   if ($this->smtp_config_path) {
+     if (file_exists($this->smtp_config_path)) {
+       return parse_ini_file($this->smtp_config_path);
+     } else {
+       $log =& logFactory::getLog();
+       $log->warn(sprintf("SMTP設定ファイルを読み込めません(%s)", $this->smtp_config_path),
+             "Mail_Send_Flexy#_getSmtpConfig");
+     }
+   }
+   return null;
+ }

+ function _renderByFlexy($template)
+ {
+   $container =& DIContainerFactory::getContainer();
+   $actionChain =& $container->getComponent("ActionChain");
+   $action =& $actionChain->getCurAction();
+   $errorList =& $actionChain->getCurErrorList();
+   $token =& $container->getComponent("Token");
+   $session =& $container->getComponent("Session");

+   $flexy =& new Flexy_Flexy4Maple();
+   $obj =& new Flexy_ViewBase();

+   $ret = $flexy->compile($template);
+   if (is_a($ret, 'PEAR_Error')) {
+     $log =& logFactory::getLog();
+     $log->error("テンプレートファイルのコンパイルに失敗しました", get_class($this) . "#_renderByFlexy");
+     return "";
+   }

+   $obj->setAction($action);
+   $obj->setErrorList($errorList);
+   if(is_object($token)) {
+     $obj->setToken($token);
+   }
+   if(is_object($session)) {
+     $obj->setSession($session);
+   }
+   $obj->prepare();

+   return $flexy->bufferedOutputObject($obj);
+ }

メールテンプレートを適当な場所に作成し、
(BASE_DIR/webapp/templates/flexy/mail/history.txt)

Subject: [taskpad] 作業履歴を送信します!
To: {action.email:h} 
From: taskpad.support@example.com

こんにちは!taskpadの作業履歴をお送りします。

---

{foreach:action.done_list,key,task}
{action.intl.getCompleteMail(task):h}   {action.intl.getDesc(task):h} 
{end:}

---
このメールは {action.url:h} から送られています。

※ 行末にflexyの文字挿入がある場合(上記例では To: {action.email}など)、行末(括弧の直後)に空白文字を入れておきます。
  もし空白文字がないと、flexyが続く改行コードを取り除いてしまいます。(上記例ではTo:ヘッダとFrom:ヘッダが一行になります)

メール送信アクションを実装します。
(BASE_DIR/webapp/modules/mode/mail/Mail.class.php)

class Mode_Mail
{
+ var $things;
+ var $done_list;
+ var $mailer;
+ var $url;
+ var $smtp;
+ var $email;         // フォームの<INPUT name="email"...>の入力値

+ function execute()
+ {
+   $this->things->listup($this->crnt_task, $this->done_list);
+   $this->things->resizeList($this->done_list, 10);
+   $this->url = 'http://' . $_SERVER['HTTP_HOST'] .
+     $this->_parseTopPageUrl($_SERVER['REQUEST_URI']);
+   $this->mailer->send();
+   return 'redirect_to_top';
+ }

+ private function _parseTopPageUrl($uri)
+ {
+   $pos = strrpos($uri, '/');
+   return (0 <= $pos) ? substr($uri, 0, $pos + 1) : $uri;
+ }
}

filter.ini を用意して
(BASE_DIR/webapp/modules/mode/mail/filter.ini)

[DIContainer2]
filename = di.ini

[Validate]
email.required = "1,メールアドレス未入力"
email.mail = "1,メールアドレスぢゃない"

[Action]
things = ref:Things
mailer = ref:Mailer

対応する di.ini を用意して
(BASE_DIR/webapp/modules/mode/mail/di.ini)

[DIContainer]
Mailer = mail.send.flexy

[Mailer]
template = flexy/mail/history.txt
smtp_config_path = /home/taskpad/etc/smtp.conf

必要に応じて SMTP AUTH 用の設定ファイルを用意して
(/home/taskpad/etc/smtp.conf)

host = smtp.example.com
port = 25
auth = LOGIN
username = taskpad.support
password = password
persist = FALSE

ブラウザからメール送信してみます。
入手したMail_I18nクラスに不具合があるので修正して、もう一度。
Ethna版と同様に文字化けしました。mb_internal_encoding()呼び出しが必要です。
対策します、お手軽に。
(/home/taskpad/etc/smtp.conf)

define('SCRIPT_CODE',   'EUC-JP');
define('SKELETON_CODE', 'EUC-JP');
+
+mb_internal_encoding('EUC-JP');

解決したので、送信後のメッセージ出力など、こまごました実装をして完成です。

終わりに

やることが多くて、ソースと首っ引きで調べることが多くて、ほんとにもう、大変でした。