*/
/**
* Class syntax_plugin_data_table
*/
class syntax_plugin_data_table extends SyntaxPlugin
{
/**
* will hold the data helper plugin
*
* @var $dthlp helper_plugin_data
*/
public $dthlp;
public $sums = [];
/**
* Constructor. Load helper plugin
*/
public function __construct()
{
$this->dthlp = plugin_load('helper', 'data');
}
/**
* What kind of syntax are we?
*/
public function getType()
{
return 'substition';
}
/**
* What about paragraphs?
*/
public function getPType()
{
return 'block';
}
/**
* Where to sort in?
*/
public function getSort()
{
return 155;
}
/**
* Connect pattern to lexer
*/
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern(
'----+ *datatable(?: [ a-zA-Z0-9_]*)?-+\n.*?\n----+',
$mode,
'plugin_data_table'
);
}
/**
* Handle the match - parse the data
*
* This parsing is shared between the multiple different output/control
* syntaxes
*
* @param string $match The text matched by the patterns
* @param int $state The lexer state for the match
* @param int $pos The character position of the matched text
* @param Doku_Handler $handler The Doku_Handler object
* @return bool|array Return an array with all data you want to use in render, false don't add an instruction
*/
public function handle($match, $state, $pos, Doku_Handler $handler)
{
if (!$this->dthlp->ready()) return null;
// get lines and additional class
$lines = explode("\n", $match);
array_pop($lines);
$class = array_shift($lines);
$class = preg_replace('/^----+ *data[a-z]+/', '', $class);
$class = trim($class, '- ');
$data = [
'classes' => $class,
'limit' => 0,
'dynfilters' => false,
'summarize' => false,
'rownumbers' => (bool)$this->getConf('rownumbers'),
'sepbyheaders' => false,
'headers' => [],
'widths' => [],
'filter' => []
];
// parse info
foreach ($lines as $line) {
// ignore comments
$line = preg_replace('/(?dthlp->column($col);
$data['cols'][$column['key']] = $column;
}
break;
case 'title':
$data['title'] = $line[1];
break;
case 'head':
case 'header':
case 'headers':
$cols = $this->parseValues($line[1]);
$data['headers'] = array_merge($data['headers'], $cols);
break;
case 'align':
$cols = explode(',', $line[1]);
foreach ($cols as $col) {
$col = trim(strtolower($col));
if ($col[0] == 'c') {
$col = 'center';
} elseif ($col[0] == 'r') {
$col = 'right';
} else {
$col = 'left';
}
$data['align'][] = $col;
}
break;
case 'widths':
$cols = explode(',', $line[1]);
foreach ($cols as $col) {
$col = trim($col);
$data['widths'][] = $col;
}
break;
case 'min':
$data['min'] = abs((int)$line[1]);
break;
case 'limit':
case 'max':
$data['limit'] = abs((int)$line[1]);
break;
case 'order':
case 'sort':
$column = $this->dthlp->column($line[1]);
$sort = $column['key'];
if (substr($sort, 0, 1) == '^') {
$data['sort'] = [substr($sort, 1), 'DESC'];
} else {
$data['sort'] = [$sort, 'ASC'];
}
break;
case 'where':
case 'filter':
case 'filterand':
case 'and': // phpcs:ignore PSR2.ControlStructures.SwitchDeclaration.TerminatingComment
$logic = 'AND';
case 'filteror':
case 'or':
if (!$logic) {
$logic = 'OR';
}
$flt = $this->dthlp->parseFilter($line[1]);
if (is_array($flt)) {
$flt['logic'] = $logic;
$data['filter'][] = $flt;
}
break;
case 'page':
case 'target':
$data['page'] = cleanID($line[1]);
break;
case 'dynfilters':
$data['dynfilters'] = (bool)$line[1];
break;
case 'rownumbers':
$data['rownumbers'] = (bool)$line[1];
break;
case 'summarize':
$data['summarize'] = (bool)$line[1];
break;
case 'sepbyheaders':
$data['sepbyheaders'] = (bool)$line[1];
break;
default:
msg("data plugin: unknown option '" . hsc($line[0]) . "'", -1);
}
}
// we need at least one column to display
if (!is_array($data['cols']) || $data['cols'] === []) {
msg('data plugin: no columns selected', -1);
return null;
}
// fill up headers with field names if necessary
$data['headers'] = (array)$data['headers'];
$cnth = count($data['headers']);
$cntf = count($data['cols']);
for ($i = $cnth; $i < $cntf; $i++) {
$column = array_slice($data['cols'], $i, 1);
$columnprops = array_pop($column);
$data['headers'][] = $columnprops['title'];
}
$data['sql'] = $this->buildSQL($data);
// Save current request params for comparison in updateSQL
$data['cur_param'] = $this->dthlp->getPurrentParam(false);
return $data;
}
protected $before_item = '
';
protected $after_item = '
';
protected $before_val = '';
protected $after_val = ' | ';
/**
* Handles the actual output creation.
*
* @param string $format output format being rendered
* @param Doku_Renderer $renderer the current renderer object
* @param array $data data created by handler()
* @return boolean rendered correctly? (however, returned value is not used at the moment)
*/
public function render($format, Doku_Renderer $renderer, $data)
{
if ($format != 'xhtml') return false;
if (is_null($data)) return false;
if (!$this->dthlp->ready()) return false;
$sqlite = $this->dthlp->getDB();
if (!$sqlite) return false;
$renderer->info['cache'] = false;
//reset counters
$this->sums = [];
if ($this->hasRequestFilter() || isset($_REQUEST['dataofs'])) {
$this->updateSQLwithQuery($data); // handles request params
}
$this->dthlp->replacePlaceholdersInSQL($data);
// run query
$clist = array_keys($data['cols']);
$rows = $sqlite->queryAll($data['sql']);
$cnt = count($rows);
if ($cnt === 0) {
$this->nullList($data, $clist, $renderer);
return true;
}
if ($data['limit'] && $cnt > $data['limit']) {
$rows = array_slice($rows, 0, $data['limit']);
}
//build classnames per column
$classes = [];
$class_names_cache = [];
$offset = 0;
if ($data['rownumbers']) {
$offset = 1; //rownumbers are in first column
$classes[] = $data['align'][0] . 'align rownumbers';
}
foreach ($clist as $index => $col) {
$class = '';
if (isset($data['align'])) {
$class .= $data['align'][$index + $offset];
}
$class .= 'align ' . hsc(sectionID($col, $class_names_cache));
$classes[] = $class;
}
//start table/list
$renderer->doc .= $this->preList($clist, $data);
foreach ($rows as $rownum => $row) {
// build data rows
$renderer->doc .= $this->before_item;
if ($data['rownumbers']) {
$renderer->doc .= sprintf($this->before_val, 'class="' . $classes[0] . '"');
$renderer->doc .= $rownum + 1;
$renderer->doc .= $this->after_val;
}
foreach (array_values($row) as $num => $cval) {
$num_rn = $num + $offset;
$renderer->doc .= sprintf($this->beforeVal($data, $num_rn), 'class="' . $classes[$num_rn] . '"');
$renderer->doc .= $this->dthlp->formatData(
$data['cols'][$clist[$num]],
$cval,
$renderer
);
$renderer->doc .= $this->afterVal($data, $num_rn);
// clean currency symbols
$nval = str_replace('$€₤', '', $cval);
$nval = str_replace('/ [A-Z]{0,3}$/', '', $nval);
$nval = str_replace(',', '.', $nval);
$nval = trim($nval);
// summarize
if ($data['summarize'] && is_numeric($nval)) {
if (!isset($this->sums[$num])) {
$this->sums[$num] = 0;
}
$this->sums[$num] += $nval;
}
}
$renderer->doc .= $this->after_item;
}
$renderer->doc .= $this->postList($data, $cnt);
return true;
}
/**
* Before value in table cell
*
* @param array $data instructions by handler
* @param int $colno column number
* @return string
*/
protected function beforeVal(&$data, $colno)
{
return $this->before_val;
}
/**
* After value in table cell
*
* @param array $data
* @param int $colno
* @return string
*/
protected function afterVal(&$data, $colno)
{
return $this->after_val;
}
/**
* Create table header
*
* @param array $clist keys of the columns
* @param array $data instruction by handler
* @return string html of table header
*/
public function preList($clist, $data)
{
global $ID;
global $conf;
// Save current request params to not loose them
$cur_params = $this->dthlp->getPurrentParam();
//show active filters
$text = '';
if (isset($_REQUEST['dataflt'])) {
$filters = $this->dthlp->getFilters();
$fltrs = [];
foreach ($filters as $filter) {
if (strpos($filter['compare'], 'LIKE') !== false) {
if (strpos($filter['compare'], 'NOT') !== false) {
$comparator_value = '!~' . str_replace('%', '*', $filter['value']);
} else {
$comparator_value = '*~' . str_replace('%', '', $filter['value']);
}
$fltrs[] = $filter['key'] . $comparator_value;
} else {
$fltrs[] = $filter['key'] . $filter['compare'] . $filter['value'];
}
}
$text .= '
';
$text .= '
' . sprintf($this->getLang('tablefilteredby'), hsc(implode(' & ', $fltrs))) . '
';
$text .= '
';
$text .= '
';
}
// build table
$text .= '
';
// build column headers
$text .= '';
if ($data['rownumbers']) {
$text .= '# | ';
}
foreach ($data['headers'] as $num => $head) {
$ckey = $clist[$num];
$width = '';
if (isset($data['widths'][$num]) && $data['widths'][$num] != '-') {
$width = ' style="width: ' . $data['widths'][$num] . ';"';
}
$text .= '';
// add sort arrow
if (isset($data['sort']) && $ckey == $data['sort'][0]) {
if ($data['sort'][1] == 'ASC') {
$text .= '↓ ';
$ckey = '^' . $ckey;
} else {
$text .= '↑ ';
}
}
// Clickable header for dynamic sorting
$text .= '' . hsc($head) . '';
$text .= ' | ';
}
$text .= '
';
// Dynamic filters
if ($data['dynfilters']) {
$text .= '';
if ($data['rownumbers']) {
$text .= ' | ';
}
foreach ($data['headers'] as $num => $head) {
$text .= '';
$form = new Doku_Form(['method' => 'GET']);
$form->_hidden = [];
if (!$conf['userewrite']) {
$form->addHidden('id', $ID);
}
$key = 'dataflt[' . $data['cols'][$clist[$num]]['colname'] . '*~' . ']';
$val = $cur_params[$key] ?? '';
// Add current request params
foreach ($cur_params as $c_key => $c_val) {
if ($c_val !== '' && $c_key !== $key) {
$form->addHidden($c_key, $c_val);
}
}
$form->addElement(form_makeField('text', $key, $val, ''));
$text .= $form->getForm();
$text .= ' | ';
}
$text .= '
';
}
return $text;
}
/**
* Create an empty table
*
* @param array $data instruction by handler()
* @param array $clist keys of the columns
* @param Doku_Renderer $R
*/
public function nullList($data, $clist, $R)
{
$R->doc .= $this->preList($clist, $data);
$R->tablerow_open();
$R->tablecell_open(count($clist), 'center');
$R->cdata($this->getLang('none'));
$R->tablecell_close();
$R->tablerow_close();
$R->doc .= '
';
}
/**
* Create table footer
*
* @param array $data instruction by handler()
* @param int $rowcnt number of rows
* @return string html of table footer
*/
public function postList($data, $rowcnt)
{
global $ID;
$text = '';
// if summarize was set, add sums
if ($data['summarize']) {
$text .= '';
$len = count($data['cols']);
if ($data['rownumbers']) $text .= ' | ';
for ($i = 0; $i < $len; $i++) {
$text .= '';
if (!empty($this->sums[$i])) {
$text .= '∑ ' . $this->sums[$i];
} else {
$text .= ' ';
}
$text .= ' | ';
}
$text .= '
';
}
// if limit was set, add control
if ($data['limit']) {
$text .= '
';
$offset = (int)$_REQUEST['dataofs'];
if ($offset) {
$prev = $offset - $data['limit'];
if ($prev < 0) {
$prev = 0;
}
// keep url params
$params = $this->dthlp->a2ua('dataflt', $_REQUEST['dataflt']);
if (isset($_REQUEST['datasrt'])) {
$params['datasrt'] = $_REQUEST['datasrt'];
}
$params['dataofs'] = $prev;
$text .= '' . $this->getLang('prev') . '';
}
$text .= ' ';
if ($rowcnt > $data['limit']) {
$next = $offset + $data['limit'];
// keep url params
$params = $this->dthlp->a2ua('dataflt', $_REQUEST['dataflt']);
if (isset($_REQUEST['datasrt'])) {
$params['datasrt'] = $_REQUEST['datasrt'];
}
$params['dataofs'] = $next;
$text .= '' . $this->getLang('next') . '';
}
$text .= ' |
';
}
$text .= '';
return $text;
}
/**
* Builds the SQL query from the given data
*
* @param array &$data instruction by handler
* @return bool|string SQL query or false
*/
public function buildSQL(&$data)
{
$cnt = 0;
$tables = [];
$select = [];
$from = '';
$from2 = '';
$where2 = '1 = 1';
$sqlite = $this->dthlp->getDB();
if (!$sqlite) return false;
// prepare the columns to show
foreach ($data['cols'] as &$col) {
$key = $col['key'];
if ($key == '%pageid%') {
// Prevent stripping of trailing zeros by forcing a CAST
$select[] = '" " || pages.page';
} elseif ($key == '%class%') {
// Prevent stripping of trailing zeros by forcing a CAST
$select[] = '" " || pages.class';
} elseif ($key == '%lastmod%') {
$select[] = 'pages.lastmod';
} elseif ($key == '%title%') {
$select[] = "pages.page || '|' || pages.title";
} else {
if (!isset($tables[$key])) {
$tables[$key] = 'T' . (++$cnt);
$from .= ' LEFT JOIN data AS ' . $tables[$key] . ' ON ' . $tables[$key] . '.pid = W1.pid';
$from .= ' AND ' . $tables[$key] . ".key = " . $sqlite->getPdo()->quote($key);
}
$type = $col['type'];
if (is_array($type)) {
$type = $type['type'];
}
switch ($type) {
case 'pageid':
case 'wiki':
//note in multivalued case: adds pageid only to first value
$select[] = "pages.page || '|' || GROUP_CONCAT_DISTINCT(" . $tables[$key] . ".value,'\n')";
break;
default:
// Prevent stripping of trailing zeros by forcing a CAST
$select[] = 'GROUP_CONCAT_DISTINCT(" " || ' . $tables[$key] . ".value,'\n')";
}
}
}
unset($col);
// prepare sorting
if (isset($data['sort'])) {
$col = $data['sort'][0];
if ($col == '%pageid%') {
$order = 'ORDER BY pages.page ' . $data['sort'][1];
} elseif ($col == '%class%') {
$order = 'ORDER BY pages.class ' . $data['sort'][1];
} elseif ($col == '%title%') {
$order = 'ORDER BY pages.title ' . $data['sort'][1];
} elseif ($col == '%lastmod%') {
$order = 'ORDER BY pages.lastmod ' . $data['sort'][1];
} else {
// sort by hidden column?
if (!$tables[$col]) {
$tables[$col] = 'T' . (++$cnt);
$from .= ' LEFT JOIN data AS ' . $tables[$col] . ' ON ' . $tables[$col] . '.pid = W1.pid';
$from .= ' AND ' . $tables[$col] . ".key = " . $sqlite->getPdo()->quote($col);
}
$order = 'ORDER BY ' . $tables[$col] . '.value ' . $data['sort'][1];
}
} else {
$order = 'ORDER BY 1 ASC';
}
// may be disabled from config. as it decreases performance a lot
$use_dataresolve = $this->getConf('use_dataresolve');
// prepare filters
$cnt = 0;
if (is_array($data['filter']) && count($data['filter'])) {
foreach ($data['filter'] as $filter) {
$col = $filter['key'];
$closecompare = ($filter['compare'] == 'IN(' ? ')' : '');
if ($col == '%pageid%') {
$where2 .= " " . $filter['logic'] . " pages.page " .
$filter['compare'] . " " . $filter['value'] . $closecompare;
} elseif ($col == '%class%') {
$where2 .= " " . $filter['logic'] . " pages.class " .
$filter['compare'] . " " . $filter['value'] . $closecompare;
} elseif ($col == '%title%') {
$where2 .= " " . $filter['logic'] . " pages.title " .
$filter['compare'] . " " . $filter['value'] . $closecompare;
} elseif ($col == '%lastmod%') {
# parse value to int?
$filter['value'] = (int)strtotime($filter['value']);
$where2 .= " " . $filter['logic'] . " pages.lastmod " .
$filter['compare'] . " " . $filter['value'] . $closecompare;
} else {
// filter by hidden column?
$table = 'T' . (++$cnt);
$from2 .= ' LEFT JOIN data AS ' . $table . ' ON ' . $table . '.pid = pages.pid';
$from2 .= ' AND ' . $table . ".key = " . $sqlite->getPdo()->quote($col);
// apply data resolving?
if ($use_dataresolve && $filter['colname'] && (substr($filter['compare'], -4) == 'LIKE')) {
$where2 .= ' ' . $filter['logic'] .
' DATARESOLVE(' . $table . '.value,' . $sqlite->getPdo()->quote($filter['colname']) . ') ' .
$filter['compare'] .
" " . $filter['value']; //value is already escaped
} else {
$where2 .= ' ' . $filter['logic'] . ' ' . $table . '.value ' . $filter['compare'] .
" " . $filter['value'] . $closecompare; //value is already escaped
}
}
}
}
// build the query
$sql = "SELECT " . implode(', ', $select) . "
FROM (
SELECT DISTINCT pages.pid AS pid
FROM pages $from2
WHERE $where2
) AS W1
$from
LEFT JOIN pages ON W1.pid=pages.pid
GROUP BY W1.pid
$order";
// offset and limit
if ($data['limit']) {
$sql .= ' LIMIT ' . ($data['limit'] + 1);
// offset is added from REQUEST params in updateSQLwithQuery
}
return $sql;
}
/**
* Handle request paramaters, rebuild sql when needed
*
* @param array $data instruction by handler()
*/
public function updateSQLwithQuery(&$data)
{
if ($this->hasRequestFilter()) {
if (isset($_REQUEST['datasrt'])) {
if ($_REQUEST['datasrt'][0] == '^') {
$data['sort'] = [substr($_REQUEST['datasrt'], 1), 'DESC'];
} else {
$data['sort'] = [$_REQUEST['datasrt'], 'ASC'];
}
}
// add request filters
$data['filter'] = array_merge($data['filter'], $this->dthlp->getFilters());
// Rebuild SQL FIXME do this smarter & faster
$data['sql'] = $this->buildSQL($data);
}
if ($data['limit'] && (int)$_REQUEST['dataofs']) {
$data['sql'] .= ' OFFSET ' . ((int)$_REQUEST['dataofs']);
}
}
/**
* Check whether a sort or filter request parameters are available
*
* @return bool
*/
public function hasRequestFilter()
{
return isset($_REQUEST['datasrt']) || isset($_REQUEST['dataflt']);
}
/**
* Split values at the commas,
* - Wrap with quotes to escape comma, quotes escaped by two quotes
* - Within quotes spaces are stored.
*
* @param string $line
* @return array
*/
protected function parseValues($line)
{
$values = [];
$inQuote = false;
$escapedQuote = false;
$value = '';
$len = strlen($line);
for ($i = 0; $i < $len; $i++) {
if ($line[$i] == '"') {
if ($inQuote) {
if ($escapedQuote) {
$value .= '"';
$escapedQuote = false;
continue;
}
if ($line[$i + 1] == '"') {
$escapedQuote = true;
continue;
}
$values[] = $value;
$inQuote = false;
$value = '';
continue;
} else {
$inQuote = true;
$value = ''; //don't store stuff before the opening quote
continue;
}
} elseif ($line[$i] == ',') {
if ($inQuote) {
$value .= ',';
continue;
} else {
if (strlen($value) < 1) {
continue;
}
$values[] = trim($value);
$value = '';
continue;
}
}
$value .= $line[$i];
}
if (strlen($value) > 0) {
$values[] = trim($value);
}
return $values;
}
}