OSDN Git Service

htmlsc(): Just sugar for htmlspecialchars(), and a foundation
[pukiwiki/pukiwiki.git] / plugin / tracker.inc.php
index a38b836..10af617 100644 (file)
 <?php
 // PukiWiki - Yet another WikiWikiWeb clone
-// $Id: tracker.inc.php,v 1.40 2007/09/02 14:43:26 henoheno Exp $
+// $Id: tracker.inc.php,v 1.124 2011/01/25 15:01:01 henoheno Exp $
 // Copyright (C) 2003-2005, 2007 PukiWiki Developers Team
 // License: GPL v2 or (at your option) any later version
 //
 // Issue tracker plugin (See Also bugtrack plugin)
 
-// #tracker_list: Excluding pattern
+
+// Tracker_list: Excluding pattern
 define('PLUGIN_TRACKER_LIST_EXCLUDE_PATTERN','#^SubMenu$|/#'); // 'SubMenu' and using '/'
 //define('PLUGIN_TRACKER_LIST_EXCLUDE_PATTERN','#(?!)#');              // Nothing excluded
 
-// #tracker_list: Show error rows (can't capture columns properly)
+// Tracker_list: Show error rows (can't capture columns properly)
 define('PLUGIN_TRACKER_LIST_SHOW_ERROR_PAGE', 1);
 
+// Tracker_list: Allow N columns sorted at a time
+define('PLUGIN_TRACKER_LIST_SORT_LIMIT', 3);
+
+
+// ----
+// Basic interface and strategy
+
+define('PLUGIN_TRACKER_USAGE',      '#tracker([config[/form][,basepage]])');
+define('PLUGIN_TRACKER_LIST_USAGE', '#tracker_list([config[/list]][[,base][,field:sort[;field:sort ...][,limit]]])');
+
+// $refer  : Where the plugin had been set / Where to return back to
+//           If ($refer == '') $refer = $base;
+// $base   : "$base/nnn" will be added by plugin_tracker_action(), or will be shown by Tracker_list
+//           Compat: If ($base  == '') $base  = $refer;
+// $config : ":config/plugin/tracker/$config" will be load to the Config
+// $form   : ":config/plugin/tracker/$config/$form" will be load as template for XHTML form by Tracker_form
+// $page   : ":config/plugin/tracker/$config/$page" will be load as template for a new page written by Tracker_form
+// $list   : ":config/plugin/tracker/$config/$list" will be load as template of Tracker_list
+// $order  : "field:sort" ... i.e. "Severity:desc" means sorting the field "Severity" descendant order.
+// $limit  : Show top N rows at a time
+
+define('PLUGIN_TRACKER_DEFAULT_CONFIG', 'default');
+define('PLUGIN_TRACKER_DEFAULT_FORM',   'form');
+define('PLUGIN_TRACKER_DEFAULT_PAGE',   'page');
+define('PLUGIN_TRACKER_DEFAULT_LIST',   'list');
+define('PLUGIN_TRACKER_DEFAULT_ORDER',  '');
+define('PLUGIN_TRACKER_DEFAULT_LIMIT',  0 );   // 0 = Unlimited
+
+// Sort type
+define('PLUGIN_TRACKER_SORT_TYPE_REGULAR',       0);
+define('PLUGIN_TRACKER_SORT_TYPE_NUMERIC',       1);
+define('PLUGIN_TRACKER_SORT_TYPE_STRING',        2);
+define('PLUGIN_TRACKER_SORT_TYPE_NATURAL',       6);
+if (! defined('SORT_NATURAL')) define('SORT_NATURAL', PLUGIN_TRACKER_SORT_TYPE_NATURAL);
+
+// Sort order
+define('PLUGIN_TRACKER_SORT_ORDER_DESC',    3);
+define('PLUGIN_TRACKER_SORT_ORDER_ASC',     4);
+define('PLUGIN_TRACKER_SORT_ORDER_DEFAULT', PLUGIN_TRACKER_SORT_ORDER_ASC);
+
+// ----
+
+// Show a form
 function plugin_tracker_convert()
 {
-       global $script, $vars;
+       global $vars;
 
        if (PKWK_READONLY) return ''; // Show nothing
 
-       $base = $refer = $vars['page'];
-
-       $config_name = 'default';
-       $form = 'form';
-       $options = array();
-       if (func_num_args()) {
-               $args = func_get_args();
-               switch (count($args))
-               {
-                       case 3:
-                               $options = array_splice($args, 2);
-                       case 2:
-                               $args[1] = get_fullname($args[1], $base);
-                               $base    = is_pagename($args[1]) ? $args[1] : $base;
-                       case 1:
-                               $config_name = ($args[0] != '') ? $args[0] : $config_name;
-                               list($config_name, $form) = array_pad(explode('/', $config_name, 2), 2, $form);
+       $args = func_get_args();
+       $argc = count($args);
+       if ($argc > 2) return PLUGIN_TRACKER_USAGE . '<br />';
+
+       $base   = isset($vars['page']) ? $vars['page'] : '';
+       $refer  = '';
+       $config = '';
+       $form   = '';
+       $rel    = '';
+       switch ($argc) {
+       case 2:
+               $rel = $args[1];
+               /*FALLTHROUGH*/
+       case 1:
+               // Set "$config/$form"
+               if ($args[0] != '') {
+                       $arg = explode('/', trim($args[0]), 2);
+                       if ($arg[0] != '' ) $config = trim($arg[0]);
+                       if (isset($arg[1])) $form   = trim($arg[1]);
                }
        }
+       unset($args, $argc, $arg);
 
-       $config = new Config('plugin/tracker/' . $config_name);
-
-       if (! $config->read()) {
-               return '<p>config file \'' . htmlspecialchars($config_name) . '\' not found.</p>';
+       $tracker_form = & new Tracker_form();
+       if (! $tracker_form->init($base, $refer, $config, $rel)) {
+               return '#tracker: ' . htmlsc($tracker_form->error) . '<br />';
        }
 
-       $config->config_name = $config_name;
-
-       $fields = plugin_tracker_get_fields($base, $refer, $config);
-
-       $form = $config->page . '/' . $form;
-       if (! is_page($form)) {
-               return '<p>config file \'' . make_pagelink($form) . '\' not found.</p>';
+       // Load $template
+       $form = ($form != '') ? $form : PLUGIN_TRACKER_DEFAULT_FORM;
+       $form = $tracker_form->config->page . '/' . $form;
+       $template = plugin_tracker_get_source($form, TRUE);
+       if ($template === FALSE || empty($template)) {
+               return '#tracker: Form not found: ' . $form . '<br />';
        }
 
-       $retval  = convert_html(plugin_tracker_get_source($form));
-       $hiddens = '';
-
-       foreach (array_keys($fields) as $name) {
-               $replace = $fields[$name]->get_tag();
-               if (is_a($fields[$name], 'Tracker_field_hidden')) {
-                       $hiddens .= $replace;
-                       $replace  = '';
+       if (! $tracker_form->initFields(plugin_tracker_field_pickup($template)) ||
+               ! $tracker_form->initHiddenFields()) {
+               return '#tracker: ' . htmlsc($tracker_form->error);
+       }
+       $fields = $tracker_form->fields;
+       unset($tracker_form);
+
+       $from = $to = $hidden = array();
+       foreach (array_keys($fields) as $fieldname) {
+               $from[] = '[' . $fieldname . ']';
+               $_to    = $fields[$fieldname]->get_tag();
+               if (is_a($fields[$fieldname], 'Tracker_field_hidden')) {
+                       $to[]     = '';
+                       $hidden[] = $_to;
+               } else {
+                       $to[]     = $_to;
                }
-               $retval = str_replace('[' . $name . ']', $replace, $retval);
+               unset($fields[$fieldname]);
        }
 
+       $script   = get_script_uri();
+       $template = str_replace($from, $to, convert_html($template));
+       $hidden   = implode('<br />' . "\n", $hidden);
        return <<<EOD
 <form enctype="multipart/form-data" action="$script" method="post">
 <div>
-$retval
-$hiddens
+$template
+$hidden
 </div>
 </form>
 EOD;
@@ -83,20 +139,14 @@ function plugin_tracker_action()
 
        if (PKWK_READONLY) die_message('PKWK_READONLY prohibits editing');
 
-       $base  = isset($post['_base'])   ? $post['_base']  : '';
-       $refer = isset($post['_refer'])  ? $post['_refer'] : $base;
-       if (! is_pagename($refer)) {
-               return array(
-                       'msg'  => 'cannot write',
-                       'body' => 'page name (' . htmlspecialchars($refer) . ') is not valid.'
-               );
-       }
+       $base  = isset($post['_base'])  ? $post['_base']  : '';
+       $refer = isset($post['_refer']) ? $post['_refer'] : '';
 
        // $page name to add will be decided here
-       $name = isset($post['_name']) ? $post['_name'] : '';
        $num  = 0;
+       $name = isset($post['_name']) ? $post['_name'] : '';
        if (isset($post['_page'])) {
-               $page = $real = $post['_page'];
+               $real = $page = $post['_page'];
        } else {
                $real = is_pagename($name) ? $name : ++$num;
                $page = get_fullname('./' . $real, $base);
@@ -107,21 +157,9 @@ function plugin_tracker_action()
                $page = $base . '/' . $real;
        }
 
-       // Loading configuration
-       $config_name = isset($post['_config']) ? $post['_config'] : '';
-       $config = new Config('plugin/tracker/' . $config_name);
-       if (! $config->read()) {
-               return '<p>config file \'' . htmlspecialchars($config_name) . '\' not found.</p>';
-       }
-       $config->config_name = $config_name;
-       $template_page = $config->page . '/page';
-       if (! is_page($template_page)) {
-               return array(
-                       'msg'  => 'cannot write',
-                       'body' => 'page template (' . htmlspecialchars($template_page) . ') is not exist.'
-               );
-       }
+       $config = isset($post['_config']) ? $post['_config'] : '';
 
+       // TODO: Why here
        // Default
        $_post = array_merge($post, $_FILES);
        $_post['_date'] = $now;
@@ -130,51 +168,75 @@ function plugin_tracker_action()
        $_post['_real'] = $real;
        // $_post['_refer'] = $_post['refer'];
 
+       // TODO: Why here => See BugTrack/662
        // Creating an empty page, before attaching files
        pkwk_touch_file(get_filename($page));
 
-       // Load $fields
-       $fields = plugin_tracker_get_fields($page, $refer, $config);
        $from = $to = array();
+
+       $tracker_form = & new Tracker_form();
+       if (! $tracker_form->init($base, $refer, $config)) {
+               return array(
+                       'msg'  => 'Cannot write',
+                       'body' => htmlsc($tracker_form->error)
+               );
+       }
+
+       // Load $template
+       $template_page = $tracker_form->config->page . '/' . PLUGIN_TRACKER_DEFAULT_PAGE;
+       $template = plugin_tracker_get_source($template_page);
+       if ($template === FALSE || empty($template)) {
+               return array(
+                       'msg'  => 'Cannot write',
+                       'body' => 'Page template (' . htmlsc($template_page) . ') not found'
+               );
+       }
+
+       if (! $tracker_form->initFields(plugin_tracker_field_pickup(implode('', $template)))) {
+               return array(
+                       'msg'  => 'Cannot write',
+                       'body' => htmlsc($tracker_form->error)
+               );
+       }
+       $fields = $tracker_form->fields;
+       unset($tracker_form);
+
        foreach (array_keys($fields) as $field) {
                $from[] = '[' . $field . ']';
                $to[]   = isset($_post[$field]) ? $fields[$field]->format_value($_post[$field]) : '';
+               unset($fields[$field]);
        }
-       unset($fields);
 
-       // Load $template
-       $template = plugin_tracker_get_source($template_page);
+       // Repalace every [$field]s (found inside $template) to real values
+       $subject = $escape = array();
+       foreach (array_keys($template) as $linenum) {
+               if (trim($template[$linenum]) == '') continue;
 
-       // Repalace every [$field]s to real values in the $template
-       $replace = $replace_e = array();
-       foreach (array_keys($template) as $num) {
-               if (trim($template[$num]) == '') continue;
-               $letter = $template[$num]{0};
+               // Escape some TextFormattingRules
+               $letter = $template[$linenum][0];
                if ($letter == '|' || $letter == ':') {
-                       // Escape for some TextFormattingRules: <table> and <dr>
-                       $replace_e[$num] = $template[$num];
+                       $escape['|'][$linenum] = $template[$linenum];
+               } else if ($letter == ',') {
+                       $escape[','][$linenum] = $template[$linenum];
                } else {
-                       $replace[$num]   = $template[$num];
+                       // TODO: Escape "\n" except multiline-allowed fields
+                       $subject[$linenum]     = $template[$linenum];
                }
        }
-       foreach (str_replace($from,   $to,   $replace  ) as $num => $line) {
-               $template[$num] = $line;
+       foreach (str_replace($from, $to, $subject) as $linenum => $line) {
+               $template[$linenum] = $line;
        }
-       // Escape for some TextFormattingRules: <table> and <dr>
-       if ($replace_e) {
-               $to_e = array();
-               foreach($to as $value) {
-                       if (strpos($value, '|') !== FALSE) {
-                               // Escape for some TextFormattingRules: <table> and <dr>
-                               $to_e[] = str_replace('|', '&#x7c;', $value);
-                       } else{
-                               $to_e[] = $value;       
+       if ($escape) {
+               // Escape for some TextFormattingRules
+               foreach(array_keys($escape) as $hint) {
+                       $to_e = plugin_tracker_escape($to, $hint);
+                       foreach (str_replace($from, $to_e, $escape[$hint]) as $linenum => $line) {
+                               $template[$linenum] = $line;
                        }
                }
-               foreach (str_replace($from, $to_e, $replace_e) as $num => $line) {
-                       $template[$num] = $line;
-               }
+               unset($to_e);
        }
+       unset($from, $to);
 
        // Write $template, without touch
        page_write($page, join('', $template));
@@ -184,269 +246,432 @@ function plugin_tracker_action()
        exit;
 }
 
-/*
-function plugin_tracker_inline()
+// Data set of XHTML form or something
+class Tracker_form
 {
-       global $vars;
+       var $base;
+       var $refer;
+       var $config_name;
 
-       if (PKWK_READONLY) return ''; // Show nothing
+       var $config;    // class Config
 
-       $args = func_get_args();
-       if (count($args) < 3) return FALSE;
+       var $raw_fields;
+       var $fields = array();
+
+       var $error  = '';       // Error message
+
+       function init($base, $refer = '', $config = NULL, $relative = '')
+       {
+               $base     = trim($base);
+               $refer    = trim($refer);
+               $relative = trim($relative);
+
+               if ($refer  == '') $refer  = $base;
+               if ($base   == '') $base   = $refer;    // Compat
+
+               if ($base  == '') {
+                       $this->error = 'Base not specified';
+                       return FALSE;
+               } else if (! is_pagename($refer)) {
+                       $this->error = 'Invalid page name: ' . $refer;
+                       return FALSE;
+               }
 
-       $body = array_pop($args);
-       list($config_name, $field) = $args;
+               $absolute = get_fullname($relative, $base);
+               if (is_pagename($absolute)) $base = $absolute;
 
-       $config = new Config('plugin/tracker/' . $config_name);
+               $this->base  = $base;
+               $this->refer = $refer;
 
-       if (! $config->read()) {
-               return 'config file \'' . htmlspecialchars($config_name) . '\' not found.';
+               if ($config !== NULL && ! $this->loadConfig($config)) {
+                       return FALSE;
+               }
+
+               return TRUE;
        }
 
-       $config->config_name           = $config_name;
-       $fields                        = plugin_tracker_get_fields($vars['page'], $vars['page'], $config);
-       $fields[$field]->default_value = $body;
+       function loadConfig($config = '')
+       {
+               if (isset($this->config)) return TRUE;
 
-       return $fields[$field]->get_tag();
-}
-*/
+               $config = trim($config);
+               if ($config == '') $config = PLUGIN_TRACKER_DEFAULT_CONFIG;
 
-// Construct field objects
-function plugin_tracker_get_fields($base, $refer, & $config)
-{
-       global $now, $_tracker_messages;
+               $obj_config  = new Config('plugin/tracker/' . $config);
 
-       $fields = array();
+               if ($obj_config->read()) {
+                       $this->config      = $obj_config;
+                       $this->config_name = $config;
+                       return TRUE;
+               } else {
+                       $this->error = "Config not found: " . $obj_config->page;
+                       return FALSE;
+               }
+       }
 
-       foreach (
-               array(
-                       // Reserved words
-                       '_date'  =>'text',      // Post date
-                       '_update'=>'date',      // Last modified date
-                       '_past'  =>'past',      // Elapsed time (passage)
-                       '_page'  =>'page',      // Page name
-                       '_name'  =>'text',      // Page name specified by poster
-                       '_real'  =>'real',      // Page name (Real)
-                       '_refer' =>'page',      // Page name refer from this (Page who has forms)
-                       '_base'  =>'page',
-                       '_submit'=>'submit'
-               ) as $field => $class)
-       {
-               $class = 'Tracker_field_' . $class;
-               $fields[$field] = & new $class(
-                       array($field, $_tracker_messages['btn' . $field], '', '20', ''),
-                       $base,
-                       $refer,
-                       $config
-               );
+       // Init $this->raw_fields and $this->fields
+       function initFields($requests = NULL)
+       {
+               // No argument
+               if (func_num_args() == 0 && $requests === NULL) {
+                       return $this->initFields(NULL);
+               }
+
+               if (! isset($this->raw_fields)) {
+                       $raw_fields = array();
+                       // From config
+                       foreach ($this->config->get('fields') as $field) {
+                               $fieldname = isset($field[0]) ? $field[0] : '';
+                               $raw_fields[$fieldname] = array(
+                                       'display' => isset($field[1]) ? $field[1] : '',
+                                       'type'    => isset($field[2]) ? $field[2] : '',
+                                       'options' => isset($field[3]) ? $field[3] : '',
+                                       'default' => isset($field[4]) ? $field[4] : '',
+                               );
+                       }
+                       // From reserved
+                       $default = array('options' => '20', 'default' => '');
+                       foreach (array(
+                               '_date'   => 'text',    // Post date
+                               '_update' => 'date',    // Last modified date
+                               '_past'   => 'past',    // Elapsed time (passage)
+                               '_page'   => 'page',    // Page name
+                               '_name'   => 'text',    // Page name specified by poster
+                               '_real'   => 'real',    // Page name (Real)
+                               '_refer'  => 'page',    // Page name refer from this (Page who has forms)
+                               '_base'   => 'page',
+                               '_submit' => 'submit'
+                       ) as $fieldname => $type) {
+                               if (isset($raw_fields[$fieldname])) continue;
+                               $raw_fields[$fieldname] = array(
+                                       'display' => plugin_tracker_message('btn' . $fieldname),
+                                       'type'    => $type,
+                               ) + $default;
+                       }
+                       $this->raw_fields = & $raw_fields;
+               } else {
+                       $raw_fields = & $this->raw_fields;
+               }
+
+               foreach(func_get_args() as $requests) {
+                       if (empty($raw_fields)) return TRUE;
+
+                       if (! is_array($requests)) {
+                               if ($requests === NULL) {
+                                       $requests = array_keys($raw_fields);    // (The rest of) All, defined order
+                               } else {
+                                       $requests = array($requests);   // Just one
+                               }
+                       }
+                       foreach ($requests as $fieldname) {
+                               if (! isset($raw_fields[$fieldname])) continue;
+                               $field = $raw_fields[$fieldname];
+                               $err = $this->addField(
+                                       $fieldname,
+                                       $field['display'],
+                                       $field['type'],
+                                       $field['options'],
+                                       $field['default']
+                               );
+                               unset($raw_fields[$fieldname]);
+                               if (! $err) return FALSE;
+                       }
+               }
+
+               return TRUE;
        }
 
-       foreach ($config->get('fields') as $field) {
-               // $field[0]: Field name
-               // $field[1]: Field name (for display)
-               // $field[2]: Field type
-               // $field[3]: Option
-               // $field[4]: Default value
-               $class = 'Tracker_field_' . $field[2];
+       function initHiddenFields()
+       {
+               // Make sure to init $this->raw_fields
+               if (! $this->initFields(array())) return FALSE;
+
+               $fields = array();
+               foreach ($this->raw_fields as $fieldname => $field) {
+                       if (isset($field['type']) && $field['type'] == 'hidden') {
+                               $fields[] = $fieldname;
+                       }
+               }
+
+               return $this->initFields($fields);
+       }
+
+       // Add $this->fields
+       function addField($fieldname, $displayname, $type = 'text', $options = '20', $default = '')
+       {
+               if (isset($this->fields[$fieldname])) return TRUE;      // Already
+
+               $class = 'Tracker_field_' . $type;
                if (! class_exists($class)) {
-                       // Default
-                       $class    = 'Tracker_field_text';
-                       $field[2] = 'text';
-                       $field[3] = '20';
+                       $this->error = "No such type: " . $type;
+                       return FALSE;
                }
-               $fields[$field[0]] = & new $class($field, $base, $refer, $config);
+
+               $this->fields[$fieldname] = & new $class(
+                       $this,                  // Reference
+                       array(
+                               $fieldname,
+                               $displayname,
+                               NULL,           // $type
+                               $options,
+                               $default
+                       )
+               );
+
+               return TRUE;
        }
-       return $fields;
 }
 
-// Field classes
+// TODO: Why a filter sometimes created so many?
+// Field classes within a form
 class Tracker_field
 {
+       var $id;        // Unique id per instance, and per class(extended-class)
+       var $form;      // Parent (class Tracker_form)
+
        var $name;
        var $title;
-       var $values;
+       var $options;
        var $default_value;
-       var $page;
-       var $refer;
-       var $config;
+
        var $data;
-       var $sort_type = SORT_REGULAR;
-       var $id        = 0;
 
-       function Tracker_field($field, $page, $refer, & $config)
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_REGULAR;
+
+       function Tracker_field(& $tracker_form, $field)
        {
                global $post;
                static $id = 0;
 
-               $this->id     = ++$id;
-               $this->name   = $field[0];
-               $this->title  = $field[1];
-               $this->values = explode(',', $field[3]);
-               $this->default_value = $field[4];
-               $this->page   = $page;
-               $this->refer  = $refer;
-               $this->config = & $config;
-               $this->data   = isset($post[$this->name]) ? $post[$this->name] : '';
+               $this->id = ++$id;
+
+               $this->form          = & $tracker_form;
+               $this->name          = isset($field[0]) ? $field[0] : '';
+               $this->title         = isset($field[1]) ? $field[1] : '';
+               $this->options       = isset($field[3]) ? explode(',', $field[3]) : array();
+               $this->default_value = isset($field[4]) ? $field[4] : '';
+
+               $this->data = isset($post[$this->name]) ? $post[$this->name] : '';
        }
 
+       // Output a part of XHTML form for the field
        function get_tag()
        {
+               return '';
        }
 
-       function get_style($str)
+       // Format user input before write
+       function format_value($value)
        {
-               return '%s';
+               return $value;
        }
 
-       function format_value($value)
+       // Compare key for Tracker_list->sort()
+       function get_value($value)
        {
                return $value;
        }
 
-       function format_cell($str)
+       // Get $this->formats[$key] for format_value()), or
+       // Get $this->styles[$key]  for get_style()
+       // from cell contents
+       function get_key($value)
        {
-               return $str;
+               return $value;
        }
 
-       function get_value($value)
+       // Format table cell data before output the wiki text
+       function format_cell($value)
        {
                return $value;
        }
+
+       // Format-string for sprintf() before output the wiki text
+       function get_style($value)
+       {
+               return '%s';
+       }
 }
 
 class Tracker_field_text extends Tracker_field
 {
-       var $sort_type = SORT_STRING;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_STRING;
 
        function get_tag()
        {
+               $s_name  = htmlsc($this->name);
+               $s_size  = isset($this->options[0]) ? htmlsc($this->options[0]) : '';
+               $s_value = htmlsc($this->default_value);
+
                return '<input type="text"' .
-                               ' name="'  . htmlspecialchars($this->name)          . '"' .
-                               ' size="'  . htmlspecialchars($this->values[0])     . '"' .
-                               ' value="' . htmlspecialchars($this->default_value) . '" />';
+                               ' name="'  . $s_name  . '"' .
+                               ' size="'  . $s_size  . '"' .
+                               ' value="' . $s_value . '" />';
        }
 }
 
+// Special type: Page name with link syntax
 class Tracker_field_page extends Tracker_field_text
 {
-       var $sort_type = SORT_STRING;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_STRING;
+
+       function _format($page)
+       {
+               $page = strip_bracket($page);
+               if (is_pagename($page)) $page = '[[' . $page . ']]';
+               return $page;
+       }
 
        function format_value($value)
        {
-               $value = strip_bracket($value);
-               if (is_pagename($value)) $value = '[[' . $value . ']]';
-               return parent::format_value($value);
+               return $this->_format($value);
+       }
+
+       function format_cell($value)
+       {
+               return $this->_format($value);
        }
 }
 
+// Special type: Page name minus 'base'
+// e.g.
+//  page name: Tracker/sales/100
+//  base     : Tracker/sales
+//  _real    : 100
+//
+// NOTE:
+//   Don't consider using within ":config/plugin/tracker/*/page".
+//   This value comes from _the_page_name_ itself.
 class Tracker_field_real extends Tracker_field_text
 {
-       var $sort_type = SORT_REGULAR;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_NATURAL;
+
+       function format_cell($value)
+       {
+               // basename(): Rough but work with this
+               // (PLUGIN_TRACKER_LIST_EXCLUDE_PATTERN prohibits '/') situation
+               return basename($value);
+       }
 }
 
+// Special type: For headings cleaning
 class Tracker_field_title extends Tracker_field_text
 {
-       var $sort_type = SORT_STRING;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_STRING;
 
-       function format_cell($str)
+       function format_cell($value)
        {
-               make_heading($str);
-               return $str;
+               make_heading($value);
+               return $value;
        }
 }
 
 class Tracker_field_textarea extends Tracker_field
 {
-       var $sort_type = SORT_STRING;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_STRING;
 
        function get_tag()
        {
+               $s_name    = htmlsc($this->name);
+               $s_cols    = isset($this->options[0]) ? htmlsc($this->options[0]) : '';
+               $s_rows    = isset($this->options[1]) ? htmlsc($this->options[1]) : '';
+               $s_default = htmlsc($this->default_value);
+
                return '<textarea' .
-                       ' name="' . htmlspecialchars($this->name)      . '"' .
-                       ' cols="' . htmlspecialchars($this->values[0]) . '"' .
-                       ' rows="' . htmlspecialchars($this->values[1]) . '">' .
-                                               htmlspecialchars($this->default_value) .
+                               ' name="' . $s_name . '"' .
+                               ' cols="' . $s_cols . '"' .
+                               ' rows="' . $s_rows . '">' .
+                               $s_default .
                        '</textarea>';
        }
 
-       function format_cell($str)
+       function format_cell($value)
        {
-               $str = preg_replace('/[\r\n]+/', '', $str);
-               if (! empty($this->values[2]) && strlen($str) > ($this->values[2] + 3)) {
-                       $str = mb_substr($str, 0, $this->values[2]) . '...';
+               // Cut too long ones
+               // TODO: Why store all of them to the memory?
+               if (isset($this->options[2])) {
+                       $limit = max(0, $this->options[2]);
+                       $len = mb_strlen($value);
+                       if ($len > ($limit + 3)) {      // 3 = mb_strlen('...')
+                               $value = mb_substr($value, 0, $limit) . '...';
+                       }
                }
-               return $str;
+               return $value;
        }
 }
 
+// Writing text with formatting if trim($cell) != ''
+// See also: http://home.arino.jp/?tracker.inc.php%2F41
 class Tracker_field_format extends Tracker_field
 {
-       var $sort_type = SORT_STRING;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_STRING;
+
        var $styles    = array();
        var $formats   = array();
 
-       function Tracker_field_format($field, $page, $refer, & $config)
+       function Tracker_field_format(& $tracker_form, $field)
        {
-               parent::Tracker_field($field, $page, $refer, $config);
+               parent::Tracker_field($tracker_form, $field);
 
-               foreach ($this->config->get($this->name) as $option) {
-                       list($key, $style, $format) =
-                               array_pad(array_map(create_function('$a', 'return trim($a);'), $option), 3, '');
+               foreach ($this->form->config->get($this->name) as $option) {
+                       list($key, $style, $format) = array_pad(array_map('trim', $option), 3, '');
                        if ($style  != '') $this->styles[$key]  = $style;
                        if ($format != '') $this->formats[$key] = $format;
                }
        }
 
-       function get_tag()
+       function get_key($value)
        {
-               return '<input type="text"' .
-                       ' name="' . htmlspecialchars($this->name)      . '"' .
-                       ' size="' . htmlspecialchars($this->values[0]) . '" />';
+               return ($value == '') ? 'IS NULL' : 'IS NOT NULL';
        }
 
-       function get_key($str)
+       function get_tag()
        {
-               return ($str == '') ? 'IS NULL' : 'IS NOT NULL';
+               $s_name = htmlsc($this->name);
+               $s_size = isset($this->options[0]) ? htmlsc($this->options[0]) : '';
+
+               return '<input type="text" name="' . $s_name . '" size="' . $s_size . '" />';
        }
 
-       function format_value($str)
+       function format_value($value)
        {
-               if (is_array($str)) {
-                       return join(', ', array_map(array($this, 'format_value'), $str));
+               if (is_array($value)) {
+                       return join(', ', array_map(array($this, 'format_value'), $value));
                }
 
-               $key = $this->get_key($str);
-               return isset($this->formats[$key]) ? str_replace('%s', $str, $this->formats[$key]) : $str;
+               $key = $this->get_key($value);
+               return isset($this->formats[$key]) ? str_replace('%s', $value, $this->formats[$key]) : $value;
        }
 
-       function get_style($str)
+       function get_style($value)
        {
-               $key = $this->get_key($str);
+               $key = $this->get_key($value);
                return isset($this->styles[$key]) ? $this->styles[$key] : '%s';
        }
 }
 
 class Tracker_field_file extends Tracker_field_format
 {
-       var $sort_type = SORT_STRING;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_STRING;
 
        function get_tag()
        {
-               return '<input type="file"' .
-                       ' name="' . htmlspecialchars($this->name)      . '"' .
-                       ' size="' . htmlspecialchars($this->values[0]) . '" />';
+               $s_name = htmlsc($this->name);
+               $s_size = isset($this->options[0]) ? htmlsc($this->options[0]) : '';
+
+               return '<input type="file" name="' . $s_name . '" size="' . $s_size . '" />';
        }
 
-       function format_value($str)
+       function format_value()
        {
                if (isset($_FILES[$this->name])) {
-
                        require_once(PLUGIN_DIR . 'attach.inc.php');
 
-                       $result = attach_upload($_FILES[$this->name], $this->page);
-                       if ($result['result']) {
+                       $base = $this->form->base;
+                       $result = attach_upload($_FILES[$this->name], $base);
+                       if (isset($result['result']) && $result['result']) {
                                // Upload success
-                               return parent::format_value($this->page . '/' . $_FILES[$this->name]['name']);
+                               return parent::format_value($base . '/' . $_FILES[$this->name]['name']);
                        }
                }
 
@@ -457,19 +682,20 @@ class Tracker_field_file extends Tracker_field_format
 
 class Tracker_field_radio extends Tracker_field_format
 {
-       var $sort_type = SORT_NUMERIC;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_NUMERIC;
+       var $_options  = array();
 
        function get_tag()
        {
                $retval = '';
 
                $id = 0;
-               $s_name = htmlspecialchars($this->name);
-               foreach ($this->config->get($this->name) as $option) {
+               $s_name = htmlsc($this->name);
+               foreach ($this->form->config->get($this->name) as $option) {
                        ++$id;
                        $s_id = '_p_tracker_' . $s_name . '_' . $this->id . '_' . $id;
-                       $s_option = htmlspecialchars($option[0]);
-                       $checked  = trim($option[0]) == trim($this->default_value) ? ' checked="checked"' : '';
+                       $s_option = htmlsc($option[0]);
+                       $checked  = trim($option[0]) === trim($this->default_value) ? ' checked="checked"' : '';
 
                        $retval .= '<input type="radio"' .
                                ' name="'  . $s_name   . '"' .
@@ -482,74 +708,86 @@ class Tracker_field_radio extends Tracker_field_format
                return $retval;
        }
 
-       function get_key($str)
+       function get_value($value)
        {
-               return $str;
+               $options = & $this->_options;
+               $name    = $this->name;
+
+               if (! isset($options[$name])) {
+                       $values = array_map('reset', $this->form->config->get($name));
+                       $options[$name] = array_flip($values);  // array('value0' => 0, 'value1' => 1, ...)
+               }
+
+               return isset($options[$name][$value]) ? $options[$name][$value] : $value;
        }
 
-       function get_value($value)
+       // Revert(re-overload) Tracker_field_format's specific code
+       function get_key($value)
        {
-               static $options = array();
-               if (! isset($options[$this->name])) {
-                       $options[$this->name] = array_flip(array_map(create_function('$arr', 'return $arr[0];'), $this->config->get($this->name)));
-               }
-               return isset($options[$this->name][$value]) ? $options[$this->name][$value] : $value;
+               return $value;
        }
 }
 
 class Tracker_field_select extends Tracker_field_radio
 {
-       var $sort_type = SORT_NUMERIC;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_NUMERIC;
+
+       var $_defaults;
 
        function get_tag($empty = FALSE)
        {
-               $s_name = htmlspecialchars($this->name);
-               $s_size = (isset($this->values[0]) && is_numeric($this->values[0])) ?
-                       ' size="' . htmlspecialchars($this->values[0]) . '"' :
-                       '';
-               $s_multiple = (isset($this->values[1]) && strtolower($this->values[1]) == 'multiple') ?
-                       ' multiple="multiple"' :
-                       '';
-
-               $retval = '<select name="' . $s_name . '[]"' . $s_size . $s_multiple . '>' . "\n";
-               if ($empty){
-                       $retval .= ' <option value=""></option>' . "\n";
+               if (! isset($this->_defaults)) {
+                       $this->_defaults = array_flip(preg_split('/\s*,\s*/', $this->default_value, -1, PREG_SPLIT_NO_EMPTY));
                }
-               $defaults = array_flip(preg_split('/\s*,\s*/', $this->default_value, -1, PREG_SPLIT_NO_EMPTY));
-               foreach ($this->config->get($this->name) as $option) {
-                       $s_option = htmlspecialchars($option[0]);
-                       $selected = isset($defaults[trim($option[0])]) ? ' selected="selected"' : '';
-                       $retval  .= ' <option value="' . $s_option . '"' . $selected . '>' . $s_option . '</option>' . "\n";
+               $defaults = $this->_defaults;
+
+               $retval = array();
+
+               $s_name = htmlsc($this->name);
+               $s_size = (isset($this->options[0]) && is_numeric($this->options[0])) ?
+                       ' size="' . htmlsc($this->options[0]) . '"' : '';
+               $s_multiple = (isset($this->options[1]) && strtolower($this->options[1]) == 'multiple') ?
+                       ' multiple="multiple"' : '';
+               $retval[] = '<select name="' . $s_name . '[]"' . $s_size . $s_multiple . '>';
+
+               if ($empty) $retval[] = ' <option value=""></option>';
+
+               foreach ($this->form->config->get($this->name) as $option) {
+                       $option   = reset($option);
+                       $s_option = htmlsc($option);
+                       $selected = isset($defaults[trim($option)]) ? ' selected="selected"' : '';
+                       $retval[] = ' <option value="' . $s_option . '"' . $selected . '>' . $s_option . '</option>';
                }
-               $retval .= '</select>';
 
-               return $retval;
+               $retval[] = '</select>';
+
+               return implode("\n", $retval);
        }
 }
 
 class Tracker_field_checkbox extends Tracker_field_radio
 {
-       var $sort_type = SORT_NUMERIC;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_NUMERIC;
 
-       function get_tag($empty=FALSE)
+       function get_tag()
        {
-               $retval = '';
+               $config   = $this->form->config;
 
-               $id = 0;
-               $s_name   = htmlspecialchars($this->name);
+               $s_name   = htmlsc($this->name);
+               $s_fid    = htmlsc($this->id);
                $defaults = array_flip(preg_split('/\s*,\s*/', $this->default_value, -1, PREG_SPLIT_NO_EMPTY));
-               foreach ($this->config->get($this->name) as $option)
-               {
+
+               $id     = 0;
+               $retval = '';
+               foreach ($config->get($this->name) as $option) {
                        ++$id;
-                       $s_id     = '_p_tracker_' . $s_name . '_' . $this->id . '_' . $id;
-                       $s_option = htmlspecialchars($option[0]);
+                       $s_id     = '_p_tracker_' . $s_name . '_' . $s_fid . '_' . $id;
+                       $s_option = htmlsc($option[0]);
                        $checked  = isset($defaults[trim($option[0])]) ? ' checked="checked"' : '';
 
                        $retval .= '<input type="checkbox"' .
-                               ' name="' . $s_name . '[]"' .
-                               ' id="' . $s_id . '"' .
-                               ' value="' . $s_option . '"' .
-                               $checked . ' />' .
+                               ' name="' . $s_name . '[]" id="' . $s_id . '"' .
+                               ' value="' . $s_option . '"' . $checked . ' />' .
                                '<label for="' . $s_id . '">' . $s_option . '</label>' . "\n";
                }
 
@@ -559,13 +797,16 @@ class Tracker_field_checkbox extends Tracker_field_radio
 
 class Tracker_field_hidden extends Tracker_field_radio
 {
-       var $sort_type = SORT_NUMERIC;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_NUMERIC;
 
-       function get_tag($empty=FALSE)
+       function get_tag()
        {
+               $s_name    = htmlsc($this->name);
+               $s_default = htmlsc($this->default_value);
+
                return '<input type="hidden"' .
-                       ' name="'  . htmlspecialchars($this->name)          . '"' .
-                       ' value="' . htmlspecialchars($this->default_value) . '" />' . "\n";
+                       ' name="'  . $s_name    . '"' .
+                       ' value="' . $s_default . '" />' . "\n";
        }
 }
 
@@ -573,16 +814,18 @@ class Tracker_field_submit extends Tracker_field
 {
        function get_tag()
        {
-               $s_title  = htmlspecialchars($this->title);
-               $s_page   = htmlspecialchars($this->page);
-               $s_refer  = htmlspecialchars($this->refer);
-               $s_config = htmlspecialchars($this->config->config_name);
+               $form = $this->form;
+
+               $s_title  = htmlsc($this->title);
+               $s_base   = htmlsc($form->base);
+               $s_refer  = htmlsc($form->refer);
+               $s_config = htmlsc($form->config_name);
 
                return <<<EOD
 <input type="submit" value="$s_title" />
-<input type="hidden" name="plugin" value="tracker" />
-<input type="hidden" name="_refer" value="$s_refer" />
-<input type="hidden" name="_base" value="$s_page" />
+<input type="hidden" name="plugin"  value="tracker" />
+<input type="hidden" name="_refer"  value="$s_refer" />
+<input type="hidden" name="_base"   value="$s_base" />
 <input type="hidden" name="_config" value="$s_config" />
 EOD;
        }
@@ -590,7 +833,7 @@ EOD;
 
 class Tracker_field_date extends Tracker_field
 {
-       var $sort_type = SORT_NUMERIC;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_NUMERIC;
 
        function format_cell($timestamp)
        {
@@ -600,345 +843,683 @@ class Tracker_field_date extends Tracker_field
 
 class Tracker_field_past extends Tracker_field
 {
-       var $sort_type = SORT_NUMERIC;
+       var $sort_type = PLUGIN_TRACKER_SORT_TYPE_NUMERIC;
 
-       function format_cell($timestamp)
+       function get_value($timestamp)
        {
-               return get_passage($timestamp, FALSE);
+               return UTIME - $timestamp;
        }
 
-       function get_value($value)
+       function format_cell($timestamp)
        {
-               return UTIME - $value;
+               return get_passage($timestamp, FALSE);
        }
 }
 
 ///////////////////////////////////////////////////////////////////////////
+// tracker_list plugin
 
 function plugin_tracker_list_convert()
 {
        global $vars;
 
-       $config = 'default';
-       $page   = $refer = $vars['page'];
-       $field  = '_page';
+       $args = func_get_args();
+       $argc = count($args);
+       if ($argc > 4) {
+               return PLUGIN_TRACKER_LIST_USAGE . '<br />';
+       }
+
+       $base   = isset($vars['page']) ? $vars['page'] : '';
+       $refer  = '';
+       $rel    = '';
+       $config = '';
        $order  = '';
-       $list   = 'list';
+       $list   = '';
        $limit  = NULL;
-       if (func_num_args()) {
-               $args = func_get_args();
-               switch (count($args)) {
-               case 4:
-                       $limit = is_numeric($args[3]) ? $args[3] : $limit;
-               case 3:
-                       $order = $args[2];
-               case 2:
-                       $args[1] = get_fullname($args[1], $page);
-                       $page    = is_pagename($args[1]) ? $args[1] : $page;
-               case 1:
-                       $config = ($args[0] != '') ? $args[0] : $config;
-                       list($config, $list) = array_pad(explode('/', $config, 2), 2, $list);
-               }
-       }
-       return plugin_tracker_getlist($page, $refer, $config, $list, $order, $limit);
+       switch ($argc) {
+       case 4: $limit = $args[3];      /*FALLTHROUGH*/
+       case 3: $order = $args[2];      /*FALLTHROUGH*/
+       case 2: $rel   = $args[1];      /*FALLTHROUGH*/
+       case 1:
+               // Set "$config/$list"
+               if ($args[0] != '') {
+                       $arg = explode('/', $args[0], 2);
+                       if ($arg[0] != '' ) $config = $arg[0];
+                       if (isset($arg[1])) $list   = $arg[1];
+               }
+       }
+
+       unset($args, $argc, $arg);
+
+       return plugin_tracker_list_render($base, $refer, $rel, $config, $order, $list, $limit);
 }
 
 function plugin_tracker_list_action()
 {
-       global $script, $vars, $_tracker_messages;
+       global $get;
+
+       $base   = isset($get['base'])   ? $get['base']   : '';
+       $refer  = isset($get['refer'])  ? $get['refer']  : '';
+       $rel    = '';
+       $config = isset($get['config']) ? $get['config'] : '';
+       $order  = isset($get['order'])  ? $get['order']  : '';
+       $list   = isset($get['list'])   ? $get['list']   : '';
+       $limit  = isset($get['limit'])  ? $get['limit']  : NULL;
 
-       $page   = $refer = $vars['refer'];
-       $s_page = make_pagelink($page);
-       $config = $vars['config'];
-       $list   = isset($vars['list'])  ? $vars['list']  : 'list';
-       $order  = isset($vars['order']) ? $vars['order'] : '_real:SORT_DESC';
+       $s_refer = make_pagelink($refer);
 
        return array(
-               'msg' => $_tracker_messages['msg_list'],
-               'body'=> str_replace('$1', $s_page, $_tracker_messages['msg_back']).
-                       plugin_tracker_getlist($page, $refer, $config, $list, $order)
+               'msg' => plugin_tracker_message('msg_list'),
+               'body'=>
+                       str_replace('$1', $s_refer, plugin_tracker_message('msg_back')) .
+                       plugin_tracker_list_render($base, $refer, $rel, $config, $order, $list, $limit)
        );
 }
 
-function plugin_tracker_getlist($page, $refer, $config_name, $list, $order = '', $limit = NULL)
+function plugin_tracker_list_render($base, $refer, $rel = '', $config = '', $order = '', $list = '', $limit = NULL)
 {
-       $config = new Config('plugin/tracker/' . $config_name);
+       $tracker_list = & new Tracker_list();
 
-       if (! $config->read()) {
-               return '<p>config file \'' . htmlspecialchars($config_name) . '\' is not exist.</p>';
+       if (! $tracker_list->init($base, $refer, $config, $rel)  ||
+               ! $tracker_list->setSortOrder($order)) {
+               return '#tracker_list: ' . htmlsc($tracker_list->error) . '<br />';
        }
 
-       $config->config_name = $config_name;
+       if (! is_page($tracker_list->form->refer)) {
+               return '#tracker_list: Refer page not found: ' . htmlsc($refer) . '<br />';
+       }
 
-       if (! is_page($config->page . '/' . $list)) {
-               return '<p>config file \'' . make_pagelink($config->page . '/' . $list) . '\' not found.</p>';
+       $result = $tracker_list->toString($list, $limit);
+       if ($result === FALSE) {
+               return '#tracker_list: ' . htmlsc($tracker_list->error) . '<br />';
        }
+       unset($tracker_list);
 
-       $list = & new Tracker_list($page, $refer, $config, $list);
-       $list->sort($order);
-       return $list->toString($limit);
+       return convert_html($result);
 }
 
 // Listing class
 class Tracker_list
 {
-       var $page;
-       var $config;
-       var $list;
-       var $fields;
+       var $form;      // class Tracker_form
+
+       var $rows   = array();
+       var $orders;
+       var $error  = '';       // Error message
+
+       // _generate_regex()
        var $pattern;
        var $pattern_fields;
-       var $rows;
-       var $order;
-
-       function Tracker_list($page, $refer, & $config, $list)
-       {
-               $this->page    = $page;
-               $this->config  = & $config;
-               $this->list    = $list;
-               $this->fields  = plugin_tracker_get_fields($page, $refer, $config);
-               $this->pattern = '';
-               $this->pattern_fields = array();
-               $this->rows    = array();
-               $this->order   = array();
-
-               $pattern = plugin_tracker_get_source($config->page . '/page', TRUE);
-
-               // Convert block-plugins to fields
-               // Incleasing and decreasing around #comment etc, will be covererd with [_block_xxx]
-               $pattern = preg_replace('/^\#([^\(\s]+)(?:\((.*)\))?\s*$/m', '[_block_$1]', $pattern);
-
-               // Generate regexes
-               $pattern = preg_split('/\\\\\[(\w+)\\\\\]/', preg_quote($pattern, '/'), -1, PREG_SPLIT_DELIM_CAPTURE);
-               while (! empty($pattern)) {
-                       $this->pattern .= preg_replace('/\s+/', '\\s*', '(?>\\s*' . trim(array_shift($pattern)) . '\\s*)');
-                       if (! empty($pattern)) {
-                               $field = array_shift($pattern);
-                               $this->pattern_fields[] = $field;
-                               $this->pattern         .= '(.*)';
-                       }
+
+       // add()
+       var $_added = array();
+
+       // toString()
+       var $_list;
+       var $_row;
+       var $_the_first_character_of_the_line;
+
+       function init($base, $refer, $config = NULL, $relative = '')
+       {
+               $this->form = & new Tracker_form();
+               return $this->form->init($base, $refer, $config, $relative);
+       }
+
+       // Generate/Regenerate regex to load one page
+       function _generate_regex()
+       {
+               if (isset($this->pattern) && isset($this->pattern_fields)) return TRUE;
+
+               $template_page = $this->form->config->page . '/' . 'page';
+               $fields        = $this->form->fields;
+               
+               $pattern        = array();
+               $pattern_fields = array();
+
+               $template = plugin_tracker_get_source($template_page, TRUE);
+               if ($template === FALSE || empty($template)) {
+                       $this->error = 'Page not found or seems empty: ' . $template_page;
+                       return FALSE;
                }
 
-               // Listing
-               $pattern     = $page . '/';
-               $pattern_len = strlen($pattern);
-               foreach (get_existpages() as $_page) {
-                       if (strpos($_page, $pattern) === 0) {
-                               $name = substr($_page, $pattern_len);
-                               if (preg_match(PLUGIN_TRACKER_LIST_EXCLUDE_PATTERN, $name)) continue;
-                               $this->add($_page, $name);
+               // Block-plugins to pseudo fields (#convert => [_block_convert])
+               $template = preg_replace('/^\#([^\(\s]+)(?:\((.*)\))?\s*$/m', '[_block_$1]', $template);
+
+               // Now, $template = array('*someting*', 'fieldname', '*someting*', 'fieldname', ...)
+               $template = preg_split('/\\\\\[(\w+)\\\\\]/', preg_quote($template, '/'), -1, PREG_SPLIT_DELIM_CAPTURE);
+
+               // NOTE: if the page has garbages between [field]s, it will fail to be load
+               while (! empty($template)) {
+                       // Just ignore these _fixed_ data
+                       $pattern[] = preg_replace('/\s+/', '\\s*', '(?>\\s*' . trim(array_shift($template)) . '\\s*)');
+                       if (empty($template)) continue;
+
+                       $fieldname = array_shift($template);
+                       if (isset($fields[$fieldname])) {
+                               $pattern[] = '(.*?)';   // Just capture it
+                               $pattern_fields[] = $fieldname; // Capture it as this $filedname
+                       } else {
+                               $pattern[] = '.*?';     // Just ignore pseudo fields etc
                        }
                }
+
+               $this->pattern        = '/' . implode('', $pattern) . '/sS';
+               $this->pattern_fields = $pattern_fields;
+
+               return TRUE;
        }
 
-       function add($page, $name)
+       // Adding $this->rows
+       // Add multiple pages at a time
+       function loadRows()
        {
-               static $done = array();
+               $base  = $this->form->base . '/';
+               $len   = strlen($base);
+               $regex = '#^' . preg_quote($base, '#') . '#';
 
-               if (isset($done[$page])) return;
+               foreach (preg_grep($regex, array_values(get_existpages())) as $pagename) {
+                       if (preg_match(PLUGIN_TRACKER_LIST_EXCLUDE_PATTERN, substr($pagename, $len))) {
+                               continue;
+                       }
+                       if ($this->addRow($pagename) === FALSE) return FALSE;
+               }
+               if (empty($this->rows)) {
+                       $this->error = 'Pages not found under: ' . $base;
+                       return FALSE;
+               }
+
+               return TRUE;
+       }
+
+       // Add one pages
+       function addRow($pagename, $rescan = FALSE)
+       {
+               // Generate/Regenerate regex if needed
+               if ($this->_generate_regex() === FALSE) return FALSE;
 
-               $done[$page] = TRUE;
+               if (isset($this->_added[$pagename])) return TRUE;
+               $this->_added[$pagename] = TRUE;
 
-               $source  = plugin_tracker_get_source($page);
+               $source = plugin_tracker_get_source($pagename, TRUE);
+               if ($source === FALSE) $source = '';
 
-               // Compat: 'move to [[page]]' (bugtrack plugin)
+               // Compat: 'move to [[page]]' (like bugtrack plugin)
                $matches = array();
-               if (! empty($source) && preg_match('/move\sto\s(.+)/', $source[0], $matches)) {
+               if (! $rescan && ! empty($source) && preg_match('/move\sto\s(.+)/', $source, $matches)) {
                        $to_page = strip_bracket(trim($matches[1]));
-                       if (! is_page($to_page)) {
-                               return; // Invalid
-                       } else {
-                               return $this->add($to_page, $name);     // Rescan
+                       if (is_page($to_page)) {
+                               unset($source, $matches);       // Release
+                               return $this->addRow($to_page, TRUE);   // Recurse(Rescan) once
                        }
                }
 
-               // Default
-               $this->rows[$name] = array(
-                       '_page'   => '[[' . $page . ']]',
-                       '_refer'  => $this->page,
-                       '_real'   => $name,
-                       '_update' => get_filetime($page),
-                       '_past'   => get_filetime($page),
-                       '_match'  => FALSE,
+               // Default column
+               $filetime = get_filetime($pagename);
+               $row = array(
+                       // column => default data of the cell
+                       '_page'   => $pagename, // TODO: Redudant column pair [1]
+                       '_real'   => $pagename, // TODO: Redudant column pair [1]
+                       '_update' => $filetime, // TODO: Redudant column pair [2]
+                       '_past'   => $filetime, // TODO: Redudant column pair [2]
                );
 
-               // Redefine
+               // Load / Redefine cell
                $matches = array();
-               $this->rows[$name]['_match'] =
-                       preg_match('/' . $this->pattern . '/s', implode('', $source), $matches);
-               unset($source);
-
-               if ($this->rows[$name]['_match']) {
-                       array_shift($matches);
-                       foreach ($this->pattern_fields as $key => $field) {
-                               $this->rows[$name][$field] = trim($matches[$key]);
+               if (preg_match($this->pattern, $source, $matches)) {
+                       array_shift($matches);  // $matches[0] = all of the captured string
+                       foreach ($this->pattern_fields as $key => $fieldname) {
+                               $row[$fieldname] = trim($matches[$key]);
+                               unset($matches[$key]);
                        }
+                       $this->rows[] = $row;
+               } else if (PLUGIN_TRACKER_LIST_SHOW_ERROR_PAGE) {
+                       $this->rows[] = $row;   // Error
                }
+
+               return TRUE;
        }
 
-       function sort($order)
+       // setSortOrder()
+       function _order_commands2orders($order_commands = '')
        {
-               if ($order == '') return;
+               $order_commands = trim($order_commands);
+               if ($order_commands == '') $order_commands = PLUGIN_TRACKER_DEFAULT_ORDER;
+               if ($order_commands == '') return array();
+
+               $orders = array();
+
+               $i = 0;
+               foreach (explode(';', $order_commands) as $command) {
+                       $command = trim($command);
+                       if ($command == '') continue;
+
+                       $arg = explode(':', $command, 2);
+                       $fieldname = isset($arg[0]) ? trim($arg[0]) : '';
+                       $order     = isset($arg[1]) ? trim($arg[1]) : '';
+
+                       $_order = $this->_sortkey_string2define($order);
+                       if ($_order === FALSE) {
+                               $this->error =  'Invalid sort key: ' . $order;
+                               return FALSE;
+                       } else if (isset($orders[$fieldname])) {
+                               $this->error =  'Sort key already set for: ' . $fieldname;
+                               return FALSE;
+                       }
 
-               $names       = array_flip(array_keys($this->fields));
-               $this->order = array();
+                       if (PLUGIN_TRACKER_LIST_SORT_LIMIT <= $i) continue;
+                       ++$i;
 
-               foreach (explode(';', $order) as $item) {
-                       list($key, $dir) = array_pad(explode(':', $item), 1, 'ASC');
-                       if (! isset($names[$key])) continue;
+                       $orders[$fieldname] = $_order;
+               }
 
-                       switch (strtoupper($dir)) {
-                       case 'SORT_ASC':
-                       case 'ASC':
-                       case SORT_ASC:
-                               $dir = SORT_ASC;
-                               break;
-                       case 'SORT_DESC':
-                       case 'DESC':
-                       case SORT_DESC:
-                               $dir = SORT_DESC;
-                               break;
-                       default:
-                               continue;
-                       }
-                       $this->order[$key] = $dir;
+               return $orders;
+       }
+
+       // Set commands for sort()
+       function setSortOrder($order_commands = '')
+       {
+               $orders = $this->_order_commands2orders($order_commands);
+               if ($orders === FALSE) {
+                       unset($this->orders);
+                       return FALSE;
+               } else {
+                       $this->orders = $orders;
+                       return TRUE;
                }
-               $keys   = array();
-               $params = array();
-               foreach ($this->order as $field => $order) {
-                       if (! isset($names[$field])) continue;
+       }
 
-                       foreach ($this->rows as $row) {
-                               $keys[$field][] = isset($row[$field])?
-                                       $this->fields[$field]->get_value($row[$field]) :
-                                       '';
-                       }
-                       $params[] = $keys[$field];
-                       $params[] = $this->fields[$field]->sort_type;
-                       $params[] = $order;
+       // sortRows(): Internal sort type => PHP sort define
+       function _sort_type_dropout($order)
+       {
+               switch ($order) {
+               case PLUGIN_TRACKER_SORT_TYPE_REGULAR: return SORT_REGULAR;
+               case PLUGIN_TRACKER_SORT_TYPE_NUMERIC: return SORT_NUMERIC;
+               case PLUGIN_TRACKER_SORT_TYPE_STRING:  return SORT_STRING;
+               case PLUGIN_TRACKER_SORT_TYPE_NATURAL: return SORT_NATURAL;
+               default:
+                       $this->error = 'Invalid sort type';
+                       return FALSE;
                }
-               $params[] = & $this->rows;
+       }
 
-               call_user_func_array('array_multisort', $params);
+       // sortRows(): Internal sort order => PHP sort define
+       function _sort_order_dropout($order)
+       {
+               switch ($order) {
+               case PLUGIN_TRACKER_SORT_ORDER_ASC:  return SORT_ASC;
+               case PLUGIN_TRACKER_SORT_ORDER_DESC: return SORT_DESC;
+               default:
+                       $this->error = 'Invalid sort order';
+                       return FALSE;
+               }
        }
 
-       // Used with preg_replace_callback()  at toString()
-       function replace_item($arr)
+       // Sort $this->rows by $this->orders
+       function sortRows()
        {
-               $params = explode(',', $arr[1]);
-               $name   = array_shift($params);
-               if ($name == '') {
-                       $str = '';
-               } else if (isset($this->items[$name])) {
-                       $str = $this->items[$name];
-                       if (isset($this->fields[$name])) {
-                               $str = $this->fields[$name]->format_cell($str);
+               if (! isset($this->orders)) {
+                       $this->error = "Sort order seems not set";
+                       return FALSE;
+               }
+
+               $fields = $this->form->fields;
+               $orders = $this->orders;
+               $types  = array();
+
+               $fieldnames = array_keys($orders);      // Field names to sort
+
+               foreach ($fieldnames as $fieldname) {
+                       if (! isset($fields[$fieldname])) {
+                               $this->error =  'No such field: ' . $fieldname;
+                               return FALSE;
                        }
-               } else {
-                       return $this->pipe ? str_replace('|', '&#x7c;', $arr[0]) : $arr[0];
+                       $types[$fieldname]  = $this->_sort_type_dropout($fields[$fieldname]->sort_type);
+                       $orders[$fieldname] = $this->_sort_order_dropout($orders[$fieldname]);
+                       if ($types[$fieldname] === FALSE || $orders[$fieldname] === FALSE) return FALSE;
                }
 
-               $style = empty($params) ? $name : $params[0];
-               if (isset($this->items[$style]) && isset($this->fields[$style])) {
-                       $str = sprintf($this->fields[$style]->get_style($this->items[$style]), $str);
+               $columns = array();
+               foreach ($this->rows as $row) {
+                       foreach ($fieldnames as $fieldname) {
+                               if (isset($row[$fieldname])) {
+                                       $columns[$fieldname][] = $fields[$fieldname]->get_value($row[$fieldname]);
+                               } else {
+                                       $columns[$fieldname][] = '';
+                               }
+                       }
                }
 
-               return $this->pipe ? str_replace('|', '&#x7c;', $str) : $str;
+               $params = array();
+               foreach ($fieldnames as $fieldname) {
+
+                       if ($types[$fieldname] == SORT_NATURAL) {
+                               $column = & $columns[$fieldname];
+                               natcasesort($column);
+                               $i = 0;
+                               $last = NULL;
+                               foreach (array_keys($column) as $key) {
+                                       // Consider the same values there, for array_multisort()
+                                       if ($last !== $column[$key]) ++$i;
+                                       $last = strtolower($column[$key]);      // natCASEsort()
+                                       $column[$key] = $i;
+                               }
+                               ksort($column, SORT_NUMERIC);   // Revert the order
+                               $types[$fieldname] = SORT_NUMERIC;
+                       }
+
+                       // One column set (one-dimensional array, sort type, and sort order)
+                       // for array_multisort()
+                       $params[] = $columns[$fieldname];
+                       $params[] = $types[$fieldname];
+                       $params[] = $orders[$fieldname];
+               }
+               if (! empty($orders) && ! empty($this->rows)) {
+                       $params[] = & $this->rows;      // The target
+                       call_user_func_array('array_multisort', $params);
+               }
+
+               return TRUE; 
+       }
+
+       // toString(): Sort key: Define to string (internal var => string)
+       function _sortkey_define2string($sortkey)
+       {
+               switch ($sortkey) {
+               case PLUGIN_TRACKER_SORT_ORDER_ASC:     return 'asc';
+               case PLUGIN_TRACKER_SORT_ORDER_DESC:    return 'desc';
+               default:
+                       $this->error =  'No such define: ' . $sortkey;
+                       return FALSE;
+               }
        }
 
-       // Used with preg_replace_callback() at toString()
-       function replace_title($arr)
+       // toString(): Sort key: String to define (string => internal var)
+       function _sortkey_string2define($sortkey)
        {
-               global $script;
+               switch (strtoupper(trim($sortkey))) {
+               case '':          return PLUGIN_TRACKER_SORT_ORDER_DEFAULT; break;
+
+               case SORT_ASC:    /*FALLTHROUGH*/ // Compat, will be removed at 1.4.9 or later
+               case 'SORT_ASC':  /*FALLTHROUGH*/
+               case 'ASC':       return PLUGIN_TRACKER_SORT_ORDER_ASC;
+
+               case SORT_DESC:   /*FALLTHROUGH*/ // Compat, will be removed at 1.4.9 or later
+               case 'SORT_DESC': /*FALLTHROUGH*/
+               case 'DESC':      return PLUGIN_TRACKER_SORT_ORDER_DESC;
+
+               default:
+                       $this->error =  'Invalid sort key: ' . $sortkey;
+                       return FALSE;
+               }
+       }
+
+       // toString(): Called within preg_replace_callback()
+       function _replace_title($matches = array())
+       {
+               $form   = $this->form;
+               $base   = $form->base;
+               $refer  = $form->refer;
+               $fields = $form->fields;
+               $config = $form->config_name;
+
+               $orders = $this->orders;
+               $list   = $this->_list;
+
+               $fieldname = isset($matches[1]) ? $matches[1] : '';
+               if (! isset($fields[$fieldname])) {
+                       // Invalid $fieldname or user's own string or something. Nothing to do
+                       return isset($matches[0]) ? $matches[0] : '';
+               }
 
-               $field = $sort = $arr[1];
-               if (! isset($this->fields[$field])) return $arr[0];
+               // This column seems sorted or not
+               if (isset($orders[$fieldname])) {
+                       $is_asc = ($orders[$fieldname] == PLUGIN_TRACKER_SORT_ORDER_ASC);
 
-               if ($sort == '_name' || $sort == '_page') $sort = '_real';
+                       $indexes = array_flip(array_keys($orders));
+                       $index   = $indexes[$fieldname] + 1;
+                       unset($indexes);
 
-               $dir   = SORT_ASC;
-               $arrow = '';
-               $order = $this->order;
-               if (is_array($order) && isset($order[$sort])) {
-                       // BugTrack2/106: Only variables can be passed by reference from PHP 5.0.5
-                       $order_keys = array_keys($order); // with array_shift();
+                       $arrow = '&br;' . ($is_asc ? '&uarr;' : '&darr;') . '(' . $index . ')';
+                       // Allow flip, if this is the first column
+                       if (($index == 1) xor $is_asc) {
+                               $order = PLUGIN_TRACKER_SORT_ORDER_ASC;
+                       } else {
+                               $order = PLUGIN_TRACKER_SORT_ORDER_DESC;
+                       }
+               } else {
+                       $arrow = '';
+                       $order = PLUGIN_TRACKER_SORT_ORDER_DEFAULT;
+               }
 
-                       $index   = array_flip($order_keys);
-                       $pos     = 1 + $index[$sort];
-                       $b_end   = ($sort == array_shift($order_keys));
-                       $b_order = ($order[$sort] == SORT_ASC);
-                       $dir     = ($b_end xor $b_order) ? SORT_ASC : SORT_DESC;
-                       $arrow   = '&br;' . ($b_order ? '&uarr;' : '&darr;') . '(' . $pos . ')';
+               // This column will be the first position , if you click
+               $orders = array($fieldname => $order) + $orders;
 
-                       unset($order[$sort], $order_keys);
+               $_orders = array();
+               foreach ($orders as $_fieldname => $_order) {
+                       if ($_order == PLUGIN_TRACKER_SORT_ORDER_DEFAULT) {
+                               $_orders[] = $_fieldname;
+                       } else {
+                               $_orders[] = $_fieldname . ':' . $this->_sortkey_define2string($_order);
+                       }
                }
-               $title  = $this->fields[$field]->title;
-               $r_page = rawurlencode($this->page);
-               $r_config = rawurlencode($this->config->config_name);
-               $r_list = rawurlencode($this->list);
-               $_order = array($sort . ':' . $dir);
-               if (is_array($order)) {
-                       foreach ($order as $key => $value) {
-                               $_order[] = $key . ':' . $value;
+
+               $script = get_script_uri();
+               $r_base   = ($refer  != $base) ? '&base='  . rawurlencode($base) : '';
+               $r_config = ($config != PLUGIN_TRACKER_DEFAULT_CONFIG) ? '&config=' . rawurlencode($config) : '';
+               $r_list   = ($list   != PLUGIN_TRACKER_DEFAULT_LIST  ) ? '&list='   . rawurlencode($list)   : '';
+               $r_order  = ! empty($_orders) ? '&order=' . rawurlencode(join(';', $_orders)) : '';
+
+               return
+                        '[[' .
+                               $fields[$fieldname]->title . $arrow .
+                       '>' .
+                               $script . '?plugin=tracker_list' .
+                               '&refer=' . rawurlencode($refer) .      // Try to show 'page title' properly
+                               $r_base . $r_config . $r_list . $r_order  .
+                       ']]';
+       }
+
+       // toString(): Called within preg_replace_callback()
+       function _replace_item($matches = array())
+       {
+               $fields = $this->form->fields;
+               $row    = $this->_row;
+               $tfc    = $this->_the_first_character_of_the_line ;
+
+               $params    = isset($matches[1]) ? explode(',', $matches[1]) : array();
+               $fieldname = isset($params[0])  ? $params[0] : '';
+               $stylename = isset($params[1])  ? $params[1] : $fieldname;
+
+               $str = '';
+
+               if ($fieldname != '') {
+                       if (! isset($row[$fieldname])) {
+                               // Maybe load miss of the page
+                               if (isset($fields[$fieldname])) {
+                                       $str = '[match_err]';   // Exactlly
+                               } else {
+                                       $str = isset($matches[0]) ? $matches[0] : '';   // Nothing to do
+                               }
+                       } else {
+                               $str = $row[$fieldname];
+                               if (isset($fields[$fieldname])) {
+                                       $str = $fields[$fieldname]->format_cell($str);
+                               }
                        }
+                       $str = plugin_tracker_escape($str, $tfc);
+               }
+
+               if (isset($fields[$stylename]) && isset($row[$stylename])) {
+                       $_style = $fields[$stylename]->get_style($row[$stylename]);
+                       $str    = sprintf($_style, $str);
                }
-               $r_order = rawurlencode(join(';', $_order));
 
-               return '[[' . $title . $arrow . '>' .
-                               $script . '?plugin=tracker_list&refer=' . $r_page .
-                               '&config=' . $r_config .
-                               '&list=' . $r_list . '&order=' . $r_order . ']]';
+               return $str;
        }
 
-       function toString($limit = NULL)
+       // Output a part of Wiki text
+       function toString($list = PLUGIN_TRACKER_DEFAULT_LIST, $limit = NULL)
        {
-               global $_tracker_messages;
+               $list = trim($list);
+               if ($list == '') $list = PLUGIN_TRACKER_DEFAULT_LIST;
+
+               if ($limit == NULL) $limit = PLUGIN_TRACKER_DEFAULT_LIMIT;
+               if (! is_numeric($limit)) {
+                       $this->error = "Limit seems not numeric: " . $limit;
+                       return FALSE;
+               }
+       
+               $form   = & $this->form;
+
+               $this->_list = $list;   // For _replace_title() only
+               $list = $form->config->page . '/' . $list;
 
                $source = array();
+               $regex  = '/\[([^\[\]]+)\]/';
+
+               // Loading template
+               $template = plugin_tracker_get_source($list, TRUE);
+               if ($template === FALSE || empty($template)) {
+                       $this->error = 'List not found: ' . $list;
+                       return FALSE;
+               }
+
+               // Try to create $form->fields just you need
+               if ($form->initFields('_real', plugin_tracker_field_pickup($template),
+                   array_keys($this->orders)) === FALSE) {
+                       $this->error = $form->error;
+                       return FALSE;
+               }
+
+               // Load and sort $this->rows
+               if ($this->loadRows() === FALSE || $this->sortRows() === FALSE) return FALSE;
+               $rows = $this->rows;
+
+               // toString()
                $count = count($this->rows);
-               if ($limit !== NULL && $count > $limit) {
+               $limit = intval($limit);
+               if ($limit != 0) $limit = max(1, $limit);
+               if ($limit != 0 && $count > $limit) {
                        $source[] = str_replace(
-                               array('$1',  '$2'),
+                               array('$1',   '$2'  ),
                                array($count, $limit),
-                               $_tracker_messages['msg_limit']) . "\n";
-                       $this->rows = array_splice($this->rows, 0, $limit);
+                               plugin_tracker_message('msg_limit')
+                       ) . "\n";
+                       $rows  = array_slice($this->rows, 0, $limit);
                }
-               if (empty($this->rows)) return '';
 
-               $body   = array();
-               foreach (plugin_tracker_get_source($this->config->page . '/' . $this->list) as $line) {
-                       if (preg_match('/^\|(.+)\|[hHfFcC]$/', $line)) {
-                               $source[] = preg_replace_callback('/\[([^\[\]]+)\]/', array(& $this, 'replace_title'), $line);
+               // Loading template
+               // TODO: How do you feel single/multiple table rows with 'c'(decolation)?
+               $matches = $t_header = $t_body = $t_footer = array();
+               $template = plugin_tracker_get_source($list);
+               if ($template === FALSE) {
+                       $this->error = 'Page not found or seems empty: ' . $list;
+                       return FALSE;
+               }
+               foreach ($template as $line) {
+                       if (preg_match('/^\|.+\|([hfc])$/i', $line, $matches)) {
+                               if (strtolower($matches[1]) == 'f') {
+                                       $t_footer[] = $line;    // Table footer
+                               } else {
+                                       $t_header[] = $line;    // Table header, or decoration
+                               }
                        } else {
-                               $body[] = $line;
+                               $t_body[]   = $line;
                        }
                }
-               foreach ($this->rows as $row) {
-                       if (! PLUGIN_TRACKER_LIST_SHOW_ERROR_PAGE && ! $row['_match']) continue;
+               unset($template);
 
-                       $this->items = $row;
-                       foreach ($body as $line) {
-                               if (ltrim($line) == '') {
-                                       $source[] = $line;
-                               } else {
-                                       $this->pipe = ($line{0} == '|' || $line{0} == ':');
-                                       $source[] = preg_replace_callback('/\[([^\[\]]+)\]/', array(& $this, 'replace_item'), $line);
+               // Header and decolation
+               foreach($t_header as $line) {
+                       $source[] = preg_replace_callback($regex, array(& $this, '_replace_title'), $line);
+               }
+               unset($t_header);
+               // Repeat
+               foreach ($rows as $row) {
+                       $this->_row = $row;
+                       // Body
+                       foreach ($t_body as $line) {
+                               if (ltrim($line) != '') {
+                                       $this->_the_first_character_of_the_line = $line[0];
+                                       $line = preg_replace_callback($regex, array(& $this, '_replace_item'), $line);
                                }
+                               $source[] = $line;
                        }
                }
+               unset($t_body);
+               // Footer
+               foreach($t_footer as $line) {
+                       $source[] = preg_replace_callback($regex, array(& $this, '_replace_title'), $line);
+               }
+               unset($t_footer);
 
-               return convert_html(implode('', $source));
+               return implode('', $source);
        }
 }
 
+// Roughly checking listed fields from template
+// " [field1] [field2,style1] " => array('fielld', 'field2')
+function plugin_tracker_field_pickup($string = '')
+{
+       if (! is_string($string) || empty($string)) return array();
+
+       $fieldnames = array();
+
+       $matches = array();
+       preg_match_all('/\[([^\[\]]+)\]/', $string, $matches);
+       unset($matches[0]);
+
+       foreach ($matches[1] as $match) {
+               $params = explode(',', $match, 2);
+               if (isset($params[0])) {
+                       $fieldnames[$params[0]] = TRUE;
+               }
+       }
+
+       return array_keys($fieldnames);
+}
+
 function plugin_tracker_get_source($page, $join = FALSE)
 {
        $source = get_source($page, TRUE, $join);
+       if ($source === FALSE) return FALSE;
 
-       // Remove fixed-heading anchors
-       $source = preg_replace('/^(\*{1,3}.*)\[#[A-Za-z][\w-]+\](.*)$/m', '$1$2', $source);
+       return preg_replace(
+                array(
+                       '/^#freeze\s*$/im',
+                       '/^(\*{1,3}.*)\[#[A-Za-z][\w-]+\](.*)$/m',      // Remove fixed-heading anchors
+               ),
+               array(
+                       '',
+                       '$1$2',
+               ),
+               $source
+       );
+}
 
-       // Remove #freeze-es
-       return preg_replace('/^#freeze\s*$/im', '', $source);
+// Escape special characters not to break Wiki syntax
+function plugin_tracker_escape($string, $syntax_hint = '')
+{
+       // Default: line-oriented
+       $from = array("\n",   "\r"  );
+       $to   = array('&br;', '&br;');
+
+       if ($syntax_hint == '|' || $syntax_hint == ':') {
+               // <table> or <dl> Wiki syntax: Excape '|'
+               $from[] = '|';
+               $to[]   = '&#x7c;';
+       } else if ($syntax_hint == ',') {
+               // <table> by comma
+               $from[] = ',';
+               $to[]   = '&#x2c;';
+       }
+       return str_replace($from, $to, $string);
 }
+
+function plugin_tracker_message($key)
+{
+       global $_tracker_messages;
+       return isset($_tracker_messages[$key]) ? $_tracker_messages[$key] : 'NOMESSAGE';
+}
+
 ?>