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

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

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

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

基本方針

  • 開発言語やフレームワークの勉強が目的なので、
    • 言語やフレームワークの機能を極力使う。パフォーマンスは二の次
    • DBなどバックエンドの障害エラーは考慮しない
    • セキュリティはなるべく考慮

開発環境

taskpadクローンの仕様

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

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

開発の流れ

    1. 一般ユーザでEthnaプロジェクトを作成
    2. データベース管理ユーザで対象データベースおよび操作ユーザを作成
    3. 一般ユーザでEthnaプロジェクトの作りこみ

プロジェクトの作成

Ethnaでのアプリケーションの単位は「プロジェクト」です。

今後Ethna以外でもtask*padクローンを作る予定なので、プロジェクト名を`taskpad-ethna'にしたいと思います。

$ ethna add-project taskpad-ethna
error occured w/ command [add-project]
  -> Only Numeric(0-9) and Alphabetical(A-Z) is allowed for Application Id

怒られました。ハイフン(`-')が使えません。
しょうがないですね、ディレクトリ`taskpad-ethna'を作成して、プロジェクト名を`taskpad'にします。

$ mkdir taskpad-ethna
$ ethna add-project taskpad taskpad-ethna
creating directory (taskpad-ethna/taskpad) [y/n]: y
proejct sub directory created [taskpad-ethna/taskpad/app]
proejct sub directory created [taskpad-ethna/taskpad/app/action]
proejct sub directory created [taskpad-ethna/taskpad/app/action_cli]
proejct sub directory created [taskpad-ethna/taskpad/app/action_xmlrpc]
proejct sub directory created [taskpad-ethna/taskpad/app/filter]
proejct sub directory created [taskpad-ethna/taskpad/app/view]
proejct sub directory created [taskpad-ethna/taskpad/bin]
proejct sub directory created [taskpad-ethna/taskpad/etc]
proejct sub directory created [taskpad-ethna/taskpad/lib]
proejct sub directory created [taskpad-ethna/taskpad/locale]
proejct sub directory created [taskpad-ethna/taskpad/locale/ja]
proejct sub directory created [taskpad-ethna/taskpad/locale/ja/LC_MESSAGES]
proejct sub directory created [taskpad-ethna/taskpad/log]
proejct sub directory created [taskpad-ethna/taskpad/schema]
proejct sub directory created [taskpad-ethna/taskpad/skel]
proejct sub directory created [taskpad-ethna/taskpad/template]
proejct sub directory created [taskpad-ethna/taskpad/template/ja]
proejct sub directory created [taskpad-ethna/taskpad/tmp]
proejct sub directory created [taskpad-ethna/taskpad/www]
proejct sub directory created [taskpad-ethna/taskpad/www/css]
proejct sub directory created [taskpad-ethna/taskpad/www/js]
file generated [www.index.php -> taskpad-ethna/taskpad/www/index.php]
file generated [www.info.php -> taskpad-ethna/taskpad/www/info.php]
file generated [www.unittest.php -> taskpad-ethna/taskpad/www/unittest.php]
file generated [www.xmlrpc.php -> taskpad-ethna/taskpad/www/xmlrpc.php]
file generated [www.css.ethna.css -> taskpad-ethna/taskpad/www/css/ethna.css]
file generated [dot.ethna -> taskpad-ethna/taskpad/.ethna]
file generated [app.controller.php -> taskpad-ethna/taskpad/app/Taskpad_Controller.php]
file generated [app.error.php -> taskpad-ethna/taskpad/app/Taskpad_Error.php]
file generated [app.action.default.php -> taskpad-ethna/taskpad/app/action/Index.php]
file generated [app.filter.default.php -> taskpad-ethna/taskpad/app/filter/Taskpad_Filter_ExecutionTime.php]
file generated [app.view.default.php -> taskpad-ethna/taskpad/app/view/Index.php]
file generated [app.unittest.php -> taskpad-ethna/taskpad/app/Taskpad_UnitTestManager.php]
file generated [etc.ini.php -> taskpad-ethna/taskpad/etc/taskpad-ini.php]
file generated [skel.action.php -> taskpad-ethna/taskpad/skel/skel.action.php]
file generated [skel.action_cli.php -> taskpad-ethna/taskpad/skel/skel.action_cli.php]
file generated [skel.action_test.php -> taskpad-ethna/taskpad/skel/skel.action_test.php]
file generated [skel.app_object.php -> taskpad-ethna/taskpad/skel/skel.app_object.php]
file generated [skel.cli.php -> taskpad-ethna/taskpad/skel/skel.cli.php]
file generated [skel.view.php -> taskpad-ethna/taskpad/skel/skel.view.php]
file generated [skel.template.tpl -> taskpad-ethna/taskpad/skel/skel.template.tpl]
file generated [skel.view_test.php -> taskpad-ethna/taskpad/skel/skel.view_test.php]
file generated [template.index.tpl -> taskpad-ethna/taskpad/template/ja/index.tpl]

project skelton for [taskpad] is successfully generated at [taskpad-ethna]

ファイルがたくさんできました。

この時点でapacheでページを表示できるようにしておきます。(ちょっと作っては試す、というスタイルで行きたいので。)

$ cd ~/public_html
$ mkdir -p taskpad/ethna
$ ln -s ~/src/taskpad-ethna/taskpad/www/index.php taskpad/ethna/

ブラウザでアクセスしてみてインデックスページが表示されれば次に進みます。



Taskpad


Index Page

hello, world!

Powered By Ethna.

データベース作成

データを格納するデータベースを作成します。キャラクタセットeuc-jpにしています。

$ mysql -p -u root
Enter password:

mysql> CREATE DATABASE taskpad_ethna DEFAULT CHARACTER SET ujis;
Query OK, 0 rows affected (0.02 sec)

次にテーブルを作成します。構成は、Perl+Catalyst版を参考にしつつ、次のようにしました。

CREATE TABLE IF NOT EXISTS users (
 uid INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
 session VARCHAR(64) NOT NULL
) ENGINE = InnoDB;
CREATE TABLE IF NOT EXISTS things (
 tid INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
 uid INT NOT NULL,
 deadline TEXT NOT NULL,
 body TEXT NOT NULL,
 completed ENUM('-', 'Y', 'N') NOT NULL,
 finished TIMESTAMP NOT NULL,
 FOREIGN KEY(uid) REFERENCES users(uid) ON DELETE CASCADE
) ENGINE = InnoDB;

これをファイルにしてデータベースに流し込みます。

mysql> use taskpad_ethna
Database changed
mysql> \. ~/src/taskpad-ethna/taskpad/schema/db.sql
Query OK, 0 rows affected (0.14 sec)
Query OK, 0 rows affected (0.07 sec)

アクセス用ユーザを作成します。

mysql> GRANT SELECT, INSERT, DELETE, UPDATE ON taskpad_ethna.* TO taskpad@localhost IDENTIFIED BY 'passwd';
Query OK, 0 rows affected (0.02 sec)

最後にプロジェクトの設定ファイルにデータベースと接続するための文字列を追加します。
(etc/taskpad-ini.php)

  'log_filter_ignore' => 'Undefined index.*%%.*tpl',
+
+ // database
+ 'dsn' => 'mysql://taskpad:passwd@localhost/taskpad_ethna',
 );

トップページの作成

さて、トップページですが、オリジナルを作成された百式の田口さんご厚意に甘えてオリジナルを取り込んでしまいます。

$ cd ~/src/taskpad-ethna/taskpad
$ wget -O template/ja/index.tpl http://taskpad.jp/jp/

スタイルシートも。

$ cat template/ja/index.tpl | grep -i stylesheet
<link rel="Stylesheet" href="/screen.css" type="text/css" media="screen" />
$ wget -O www/css/screen.css http://taskpad.jp/screen.css

Javascriptも使用されていますが、これは配布元から引っ張りました。(なんとなく気が引けたので)

$ cat index.tpl | grep -i javascript
<script language="JavaScript" type="text/javascript" src="/js/prototype.js"></script>
<script language="JavaScript" type="text/javascript" src="/js/scriptaculous.js?effects"></script>
$ cd ~/src
$ curl http://script.aculo.us/dist/scriptaculous-js-1.6.1.tar.bz2 | tar jfx -
$ cd ~/src/taskpad-ethna/taskpad/www/js
$ cp -p ~/src/scriptaculous-js-1.6.1/src/* .
$ cp -p ~/src/scriptaculous-js-1.6.1/lib/* .
$ ls
builder.js  controls.js  dragdrop.js  effects.js  prototype.js  scriptaculous.js  slider.js  unittest.js

次にトップページをクローンらしく修正します。またCSSのリンクパスはEthnaのwwwディレクトリに合わせます。
(template/ja/index.tpl)

 <head>
 <meta http-equiv="Content-Type" content="text/html; charset=EUC-JP">
+<meta name="ROBOTS" content="NONE">
-<title>目標管理プチツール - taskpad.jp</title>
-<meta name="keywords" content="taskpad,目標,管理,ToDo,生産性向上,コーチング">
-<meta name="description" content="今自分が何の作業をしているか管理できるツールです。">
-<script language="JavaScript" type="text/javascript" src="/js/prototype.js"></script>
-<script language="JavaScript" type="text/javascript" src="/js/scriptaculous.js?effects"></script>
-<link rel="Stylesheet" href="/screen.css" type="text/css" media="screen" />
+<title>taskpadクローン&nbsp;(PHP+Ethna)</title>
+<meta name="description" content="今自分が何の作業をしているか管理できるツールです。">
+<script language="JavaScript" type="text/javascript" src="js/prototype.js"></script>
+<script language="JavaScript" type="text/javascript" src="js/scriptaculous.js?effects"></script>
+<link rel="Stylesheet" href="css/screen.css" type="text/css" media="screen" />

-  <div id="header"><a href="/jp/?">...
+  <div id="header"><a href="http://taskpad.jp/jp">task*pad</a>クローン(PHP+Ethna)</div>

-<a href="http://www.ideaxidea.com/archives/2005/08/taskpad.html" target="_blank">task*padについて</a>...
+<a href="http://www.ideaxidea.com/archives/2005/08/taskpad.html" target="_blank">オリジナルのtask*padについて</a><br />

-Copyright 2006 taskpad.jp.  All rights reserved.
+task*pad, Copyright 2006 taskpad.jp.

apacheの公開用ディレクトリからcssとjsがアクセスできるようにします。

$ cd ~/public_html/taskpad/ethna
$ mkdir css
$ ln -s ~/src/taskpad-ethna/taskpad/www/css/screen.css css/
$ mkdir js
$ ln -s ~/src/taskpad-ethna/taskpad/www/js/*.js js/

そして実際にブラウザで表示できるか確認します。本家にそっくりなページが表示されたらOKです。

タスク開始アクションの追加

Ethnaではブラウザなどクライアントからのリクエストを、対応する「アクション」で処理します。(動作概要ページ)
本家task*padではタスク開始などの処理を、URLクエリ文字列"mode=xxx"の形式で指示するようになっているようです。
Ethnaのデフォルトでは"action_xxx=true"の形式でアクションが起動されるので、この動作を修正します。
(app/Taskpad_Controller.php)

+  function _getActionName_Form()
+  {
+    if (array_key_exists('mode', $_REQUEST)) {
+      return $_REQUEST['mode'];
+    } else {
+      return null;
+    }
+  }

タスク開始アクションを追加します。ethnaコマンドでアクションコードのスケルトンを出力し、

$ cd ~/src/taskpad-ethna/taskpad
$ ethna add-action start

生成されたソースに、タスクの「いつまで」と「なにを」欄の入力値が妥当かどうかを判定するコードを追加します。
(app/action/Start.php)

  var $form = array(
+   'task0' => array(
+     'name' => '期限',
+     'required' => true,
+     'type' => VAR_TYPE_STRING),
+   'task1' => array(
+     'name' => '内容',
+     'required' => true,
+     'type' => VAR_TYPE_STRING),
  );

  function prepare()
  {
-   return null;
+   return ($this->af->validate() == 0) ? null : false;
  }

`af'は`ActionForm'の略で、フォームの入力値を管理するEthnaフレームワークのオブジェクトです。
validate()は妥当な場合は0を返します。
prepare()メソッドの戻り値は、処理続行ならnull、中断ならfalseを返すことになっているそうです。
本家のように入力値が妥当でない場合は何も言わずにトップページに戻りたいのですが、とりあえず今は上記のように返しておきます。

妥当性判定を通り抜けたらDBテーブルにタスクを追加するよう、コードを追加します。

...とその前に。

ORマッパとテーブルアクセスロジックの作成

DBテーブルへのアクセス方法はいくつもあるようですが、今回はEthnaで用意されているORマッパを利用してみたいと思います。
DBの各テーブル用にORマッパ派生クラスを作ります。

$ ethna add-app-object users
$ ethna add-app-object things

作成されたファイルにはORマッパ'Ethna_AppObject'の他、アプリケーションマネージャ`Ethna_AppManager'の派生クラスも作られています。
後者にはテーブルアクセスロジックをまとめようと思います。
そうするとテーブルアクセスロジックがプロジェクト全体に散らばることなく、一ヶ所にまとまるので保守性が高まるように思うからです。

作成したAppManager系クラスオブジェクトをアクションなどの中から呼べるようにします。
(app/Taskpad_Controller.php)

include_once('Taskpad_Error.php');
+include_once('Taskpad_Users.php');
+include_once('Taskpad_Things.php');

  var $manager = array(
+   'users' => 'Users',
+   'things' => 'Things',
  );

これによりアクションクラスの中から

  $this->users->xxx();
  $this->things->xxx();

という呼び方ができるようになります。

ユーザの追加・取得をusersテーブルに対して行うロジックを作りこみます。
(app/Taskpad_Users.php)

class Taskpad_UsersManager extends Ethna_AppManager
{
+  protected function activate_session()
+  {
+    if (!$this->session->isStart()) {
+      $this->session->Start(365 * 24 * 60 * 60);
+    }
+  }
+
+  public function get_uid($create_if_not_exists = false)
+  {
+    if ($this->session->isStart()) {
+      $result =& $this->getObjectProp('users', null, array('session' => session_id()));
+      if (Ethna::isError($result)) {
+        //mada
+        return null;
+      }
+      if ($result) {
+        return $result['uid'];
+      }
+    }
+
+    if ($create_if_not_exists && $this->create_user()) {
+      return $this->get_uid(false);
+    } else {
+      return null;
+    }
+  }
+
+  protected function create_user()
+  {
+    $this->activate_session();
+    $users = new Taskpad_Users($this->backend);
+    $users->set('session', session_id());
+    if (Ethna::isError($users->add())) {
+      //mada
+      return false;
+    }
+    return true;
+  }
}

thingsテーブルに対してタスクを追加する処理を作りこみます。
(app/Taskpad_Things.php)

class Taskpad_ThingsManager extends Ethna_AppManager
{
+  public function add($deadline, $body)
+  {
+    $uid = $this->users->get_uid(true);
+    if (!$uid) {
+      //mada
+      return;
+    }
+    $things = new Taskpad_Things($this->backend);
+    $things->set('uid', $uid);
+    $things->set('deadline', $deadline);
+    $things->set('body', $body);
+    $things->set('completed', '-');
+    $things->add();
  }

タスク開始アクションの追加(2)

ようやくStartアクションからテーブルデータ追加ができるようになりました。
(app/action/Start.php)

  function prepare()
  {
-   return ($this->af->validate() == 0) ? null : false;
+   return null;
  }

  function perform()
  {
-   return 'start';
+   if ($this->af->validate() == 0) {
+     $this->things->add($this->af->get('task0'), $this->af->get('task1'));
+   }
+   return 'index';
  }

ブラウザからタスクを入力して「開始」ボタンを押してみます。エラーなくテーブルに値が入力されればOKです。

$ mysql -D taskpad_ethna -u taskpad -p
Enter password:
mysql> select * from users;
+-----+------------------------------------------------------------------+
| uid | session                                                          |
+-----+------------------------------------------------------------------+
|   5 | 5d223ee2d43c9f292a38a1b83078ae5e71a3f49fd2b99bccb88a3db598960c48 |
+-----+------------------------------------------------------------------+

mysql> select * from things;
+-----+-----+----------+------+-----------+---------------------+
| tid | uid | deadline | body | completed | finished            |
+-----+-----+----------+------+-----------+---------------------+
|   1 |   1 | ????     | ???? | -         | 2006-06-20 18:59:58 |
+-----+-----+----------+------+-----------+---------------------+

...タスクに日本語を入力したら文字化けしました。
これは私のサーバにあるmysqlのデフォルト接続キャラクタセットUTF-8になっているためでした。

mysql接続キャラクタセットの設定

EUC-JPを使うよう、テーブルデータにアクセスする前に接続キャラクタセット設定クエリを発行するよう修正します。
やり方はいろいろあると思いますが、ここではプロジェクト内部で使用するDBクラスを`Ethna_DB_Pear'から独自のクラス'Taskpad_DB_Mysql'に変更し、その初期化処理でクエリを発行します。

まずTaskpad_DB_Mysqlクラスを作成します。
(app/Taskpad_DB_Mysql.php)

+class Taskpad_DB_Mysql extends Ethna_DB_PEAR
+<?php
+{
+  function connect()
+  {
+    $ret = parent::connect();
+    if ($ret == 0) {
+      $this->query("SET CHARACTER SET ujis");
+    }
+    return $ret;
+  }
+}
+?>

DBクラスを置き換えます。
(app/Taskpad_Controller.php)

 include_once('Taskpad_Things.php');
+include_once('Taskpad_DB_Mysql.php');

-         'db'          => 'Ethna_DB_PEAR',
+         'db'          => 'Taskpad_DB_Mysql',

再度ブラウザからタスクを追加し、テーブルを見てみます。

mysql> select * from things;
+-----+-----+----------+--------------+-----------+---------------------+
| tid | uid | deadline | body         | completed | finished            |
+-----+-----+----------+--------------+-----------+---------------------+
|   2 |   1 | 21:00    | ご飯食べたい | -         | 2006-06-20 19:29:28 |
+-----+-----+----------+--------------+-----------+---------------------+

お腹空きました。

タスク達成・未達成ボタンの表示

今度はタスクを開始している場合の表示部分を作ります。本家task*padではタスク入力欄が変化しますので、こちらもそのようにします。
Ethnaの表示用テンプレートはSmartyが利用されていまして、これも勉強しながらコードを書いていきます。

+{if $app.deadline eq ""}
  <table>
  <tr valign="top">
  <td><input type="text" style="font-size:12px" size="10" name="task0" ...
  <td><input type="text" style="font-size:12px" size="45" name="task1" ...
  <td><input type="submit" value="開始" style="font-size:12px" />
  <input type="hidden" name="mode" value="start" /></td>
  </tr>
  </table>  </form>
+{else}
+<input type="text" size="50" value="{$app.deadline}までに{$app.body}" disabled style="font-size:12px" />
+<input type="button" value="達成!" style="font-size:12px" onclick="location.href='?mode=finish'" />
+<input type="button" value="未達成" style="font-size:12px" onclick="location.href='?mode=notfinish'" />
+</form>
+{/if}

Indexアクションを修正します。
(app/action/Index.php)

  function perform()
  {
+   $tasks = $this->things->listup();
+
+   if (count($tasks) && $tasks[0]['completed'] == '-') {
+     $task = $tasks[0];
+     $this->af->setApp('deadline', $task['deadline']);
+     $this->af->setApp('body', $task['body']);
+   }
    return 'index';
  }

ブラウザからトップページを見てみます。開始中のタスクが表示されて「達成」「未達成」ボタンが現れたらOKです。

達成・未達成アクションの追加

これまでと同じように追加していきます。

$ ethna add-action finish
$ ethna add-action notfinish

(app/action/Finish.php)

  function perform()
  {
-   return 'finish';
+   $this->things->mark_finish(true);
+   return 'index';
  }

(app/action/Notinish.php)

  function perform()
  {
-   return 'notfinish';
+   $this->things->mark_finish(false);
+   return 'index';
  }

テーブルロジックも追加します。
(app/Taskpad_Things.php)

+ function mark_finish($finished)
+ {
+   $uid = $this->users->get_uid(true);
+   if (!$uid) {
+     return;
+   }
+
+   $res = $this->getObjectPropList("things", null,
+                   array('uid' => $uid, 'completed' => '-'),
+                   array('tid' => OBJECT_SORT_DESC),
+                   null, 1);
+   if (!$res || $res[0] == 0) {
+     return;
+   }
+
+   $things = new Taskpad_Things($this->backend, 'tid', $res[1][0]['tid']);
+   $things->set('completed', $finished ? 'Y' : 'N');
+   $things->set('finished', strftime("%y-%m-%d %T"));
+   $things->update();
+ }

なお、タスク登録数が表示件数を超えても削除しないようにしています。(cronでバッチ削除する運用を想定)
さて、これでブラウザから「達成」「未達成」ボタンを押してみて、テーブルの内容がきちんと変更されればひとまずOK、なのですが...。

ページリダイレクト処理の追加

今の状態ではブラウザから新しいタスクを追加しても「達成」「未達成」ボタンが表示されません。
Startアクションでは次のようになっていて、

(app/action/Start.php)

  function perform()
  {
    if ($this->af->validate() == 0) {
      $this->things->add($this->af->get('task0'), $this->af->get('task1'));
    }
    return 'index';
  }

indexページを表示するようにはしているのですが、これは(結果的には)テンプレートページ(template/ja/index.tpl)をSmarty経由で出力するだけで、ボタン表示切り替え処理を実装したIndexアクションが呼ばれていないためです。
Ethnaのソースを見ると、どうやらアクションから別のアクションを呼ぶにはEthna_Backend.perform()を呼べばよいようです。

-   return 'index';
+   return $this->backend->perform('index');

ただ、本家task*padではブラウザのURL欄にパラメータ文字列が表示されないよう、リダイレクトを使って常にトップページに飛ぶようになっているので、クローンとしても同じように動作させたいと思います。
本家のリダイレクトはHTTPエラー302の出力で実現しているようです。
PHPのheader関数を利用すればよいようですが、リダイレクトは複数のアクションで行いますので、リダイレクトロジックはどこか一ヶ所にまとめたいと思います。
今回は、Redirectアクションを追加して対応します。

$ ethna add-action redirect

(app/action/Redirect.php)

  function perform()
  {
-   return "redirect";
+   $uri = $this->_parseTopPageUrl($_SERVER['REQUEST_URI']);
+   header("Location: $uri");
+   return null;
  }
+
+ private function _parseTopPageUrl($uri)
+ {
+   $pos = strrpos($uri, '/');
+   return (0 <= $pos) ? substr($uri, 0, $pos + 1) : $uri;
+ }

各アクションを修正します。
(app/action/Start.php)

  function perform()
  {
    $this->things->add($this->af->get('task0'), $this->af->get('task1'));
-   return 'index';
+   return $this->backend->perform('redirect');
  }

(app/action/Finish.php)

  function perform()
  {
    $this->things->mark_finish(true);
-   return 'index';
+   return $this->backend->perform('redirect');
  }

(app/action/Notfinish.php)

  function perform()
  {
    $this->things->mark_finish(false);
-   return 'index';
+   return $this->backend->perform('redirect');
  }

ブラウザで動作確認します。

さらに、クライアントから未定義のアクションが呼ばれた場合もトップページにリダイレクトするよう修正します。
(www/index.php)

-Taskpad_Controller::main('Taskpad_Controller', 'index');
+Taskpad_Controller::main('Taskpad_Controller', 'index', 'redirect');

タスク一覧表示

タスク一覧表示アクションを作り込みます。
(app/action/Index.php)

  function perform()
  {
-   $tasks = $this->things->listup();
-
-   if (count($tasks) && $tasks[0]['completed'] == '-') {
-     $task = $tasks[0];
-     $this->af->setApp('deadline', $task['deadline']);
-     $this->af->setApp('body', $task['body']);
-   }
+   $list = $this->things->listup();
+   if (!$list) $list = array();
+
+   $suc = 0;
+   $fail = 0;
+   $tasks = array();
+   $row = 0;
+   foreach ($list as $task) {
+     if ($task['completed'] == '-') {
+       $this->af->setApp('deadline', $task['deadline']);
+       $this->af->setApp('body', $task['body']);
+       continue;
+     }

+     $task['bg'] = ($row & 1 ? 'odd' : 'even');
+     switch ($task['completed']) {
+     case 'Y':
+       $task['fg'] = 'suc';
+       $suc++;
+       break;
+     case 'N':
+       $task['fg'] = 'fail';
+       $fail++;
+       break;
+     case '-':
+       $task['fg'] = 'none';
+       break;
+     }
+     array_push($tasks, $task);
+     if (++$row == 10) {
+       break;
+     }
+   }
+
+   if ($row < 10) {
+     $task = array('completed' => '-',
+             'deadline' => '',
+             'body' => '',
+             'ago' => '-1',
+             'fg' => 'none',
+             );
+     for (; $row < 10; $row++) {
+       $task['bg'] = ($row & 1 ? 'odd' : 'even');
+       array_push($tasks, $task);
+     }
+   }

+   $this->af->setApp('tasks', $tasks);
+   $this->af->setApp('count_suc', $suc);
+   $this->af->setApp('count_fail', $fail);

    return 'index';
 }
}

表示用テンプレートとCSSを修正します。
(template/ja/index.tpl)

   <td align="center" width="15%">時期</td>
   </tr>

-    <tr style="background-color:#EDF3FE;height:15px">
-    <td align="center"><span style='color:#ccc'>未登録</span></td>
-    <td><span style='color:#ccc'>------</span></td>
...
+ {foreach from = $app.tasks item = task}
+  <tr class="tr_{$task.bg}">
+   <td align="center"><span class="status_{$task.fg}">
+   {if $task.completed eq 'Y'}
+   達成
+   {elseif $task.completed eq 'N'}
+   未達成
+   {else}
+   未登録
+   {/if}
+   </span></td>
+   <td><span class="text_{$task.fg}">
+   {if $task.completed ne '-'}
+   {$task.deadline}までに{$task.body}
+   {else}
+   ------
+   {/if}
+   </span></td>
+   <td align="center"><span class="text_{$task.fg}">
+   {if $task.ago < 0}
+   未登録
+   {elseif $task.ago lt 3600}
+   {$task.ago/60|round}分前
+   {elseif $task.ago lt 86400}
+   {$task.ago/3600|round}時間前
+   {else}
+   {$task.ago/86400|round}日前
+   {/if}
+  </span></td></tr>
+ {/foreach}

(www/css/screen.css)

+.tr_even { background-color: #EDF3FE; height: 15px; }
+.tr_odd { background-color: #fff; height: 15px; }
+.status_none { color: #ccc; }
+.status_suc { color: red; font-weight: bold; }
+.status_fail { color: #000; }
+.text_none { color: #ccc; }
+.text_suc { color: #000; }
+.text_fail { color: #000; }

ブラウザで動作確認します。

タスク削除アクションの追加

$ ethna add-action crear

(app/action/Clear.php)

  function perform()
  {
-   return 'clear';
+   $this->things->clear();
+   return $this->backend->perform('redirect');
  }

(app/Taskpad_Things.php)

+ function clear()
+ {
+   $uid = $this->users->get_uid(true);
+   if ($uid) {
+     $this->db->query("DELETE FROM things where uid = $uid");
+   }
+ }

(template/ja/index.tpl)

-  <p class="title">直近の履歴 <span class="s12"><a href="/jp/?mode=clear" ...
+  <p class="title">直近の履歴 <span class="s12"><a href="?mode=clear" ...

メール送信アクションの追加

Ethnaにはメール送信用処理も用意されているのでそれを利用します。
まずメール送信用テンプレートファイルを用意します。
テンプレートはtemplate/*/mail/以下に置くことになっているようなのでそれにあわせます。

$ ethna add-template mail_tasklist

テンプレートを書き換えます。内容はもちろん、本家のメール内容とそっくりにします。
(template/ja/mail/tasklist.tpl)

-<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
-<html>
- <head>
-  <meta http-equiv="Content-Type" content="text/html; charset=EUC-JP">
- </head>
- <body>
-  <h1>Taskpad</h1>
- </body>
-</html>
+from: taskpad.support@example.com
+subject: [taskpad] 作業履歴を送信します!
+
+こんにちは!taskpadの作業履歴をお送りします。
+
+---
+
+{foreach from = $tasks item = task}
+{if $task.completed eq 'Y'}達成!{elseif $task.completed eq 'N'}未達成{else}------{/if}   {
+if $task.completed ne '-'}{$task.deadline}までに{$task.body}{else}------{/if}
+
+{/foreach}
+
+---
+このメールは {$uri} から送られています。

トップページのテンプレートを修正し、
(template/ja/index.tpl)

-  <form action="/jp/?mode=mail" method="post" style="padding:10px 7px">
+  <form action="?mode=mail" method="post" style="padding:10px 7px">

メール送信アクションを作りこみます。

$ ethna add-action mail

(app/action/Mail.php)

 var $form = array(
+  'email' => array(
+   'required' => true,
+   'type' => VAR_TYPE_STRING),

  function perform()
  {
-   return 'mail';
+   if ($this->af->validate() == 0) {
+     $mail = new Ethna_MailSender($this->backend);
+     $mail->def = array('1' => 'tasklist.tpl');
+     $topPage = 'http://' . $_SERVER['HTTP_HOST'] .
+       $this->_parseTopPageUrl($_SERVER['REQUEST_URI']);
+     $mail->send($this->af->get('email'), '1',
+           array('tasks' => $this->_getTasklist(),
+               'uri' => $topPage));
+   }
+   return $this->backend->perform('redirect');
  }
+
+ function _getTasklist()
+ {
+   $list = $this->things->listup();
+   if (!$list) $list = array();
+
+   $tasks = array();
+   foreach ($list as $task) {
+     if ($task['completed'] != '-') {
+       array_push($tasks, $task);
+     }
+   }
+
+   while (count($tasks) < 10) {
+     array_push($tasks, array('completed' => '-'));
+   }
+
+   return $tasks;
+ }
+
+ private function _parseTopPageUrl($uri)
+ {
+   $pos = strrpos($uri, '/');
+   return (0 <= $pos) ? substr($uri, 0, $pos + 1) : $uri;
+ }
}

ブラウザからメールアドレス欄を適当に入力して送信してみます。
受信したメールを見てみると...文字化けしています。

メール送信時の文字化けの対処

どうやらPHPの内部エンコードを適切に設定することで対処できるようです。
(app/Taskpad_Controller.php)

define('BASE', dirname(dirname(__FILE__)));
+mb_internal_encoding('eucjp');

再度ブラウザからメールアドレス欄を適当に入力して送信してみます。
きちんと読めるメールが届いたらOKです。

メール送信通知メッセージ表示

さらに本家ではメール送信後に画面にちょっとしたメッセージを表示していますので、これも実装します。
動作としては、メール送信後のリダイレクトでメッセージをクッキーにセット、リダイレクト先のトップページでは該当クッキーがある場合にのみメッセージを表示する、というものです。

まずクッキー処理をひとまとめにしたクラスを作成します。
(lib/Taskpad_SysMsg.php)

+<?php
+class Taskpad_SysMsg
+{
+ public function handleMsg($backend)
+ {
+   if (isset($_COOKIE['sys-msg'])) {
+     $backend->af->setApp('sysMsg', $_COOKIE['sys-msg']);
+     setcookie('sys-msg');
+   }
+ }
+
+ public function setMsg($backend, $msg)
+ {
+   setcookie('sys-msg', $msg);
+  }
+}
?>

作ったクラスをアクションから呼べるようにして、
(app/Taskpad_Controller.php)

 include_once('Taskpad_DB_Mysql.php');
+include_once('Taskpad_SysMsg.php');

メッセージを設定ファイルに追加し、
(etc/taskpad-ini.php)

+ 'msg_mail_sent' => '作業履歴が送信されました!',

メール送信後にメッセージを設定するようにして、
(app/action/Mail.php)

      $mail->send($this->af->get('email'), '1',
            array('tasks' => $this->_getTasklist(),
                'uri' => $topPage));
+     Taskpad_SysMsg::setMsg($this->backend, $this->backend->config->get('msg_mail_sent'));

トップページでメッセージを表示できるようにします。
(app/action/Index.php)

  function perform()
  {
+   Taskpad_SysMsg::handleMsg($this->backend);
ブラウザでメール送信後にきちんとメッセージが表示されたら、ひとまず完成です。

終わりに

エラー処理を実装せずにここまで書いてきてしまいました。正常系で手一杯で...。今後のアップデートで対応したいと思います。
またEthnaがせっかくサポートしているユニットテストも触ることがありませんでした。これもまた一緒に使っていきたいです。