diff --git a/_test/ConfigParser.test.php b/_test/ConfigParser.test.php index 77b2a46403..8cc68a567a 100644 --- a/_test/ConfigParser.test.php +++ b/_test/ConfigParser.test.php @@ -78,6 +78,7 @@ public function test_simple() { ) ), 'csv' => true, + 'target' => '' ); $this->assertEquals($expected_config, $actual_config); diff --git a/_test/Type_Page.test.php b/_test/Type_Page.test.php index 4cf7157b79..22274e2b36 100644 --- a/_test/Type_Page.test.php +++ b/_test/Type_Page.test.php @@ -108,6 +108,16 @@ public function test_search() { $result[0][3]->getValue() ); + // if there is no title in the database display the pageid + $this->assertEquals( + array( + 'DokuWiki Overview', + 'DokuWiki Foobar Syntax', + 'wiki:welcome' + ), + $result[0][3]->getDisplayValue() + ); + // search single with title $single = clone $search; $single->addFilter('singletitle', 'Overview', '*~', 'AND'); diff --git a/lang/en/lang.php b/lang/en/lang.php index 40700820ac..2b0d89d5d3 100644 --- a/lang/en/lang.php +++ b/lang/en/lang.php @@ -80,6 +80,8 @@ $lang['Exception No data saved'] = 'No data saved'; $lang['Exception no sqlite'] = 'The struct plugin requires the sqlite plugin. Please install and enable it.'; +$lang['Warning: no filters for cloud'] = 'Filters are not supported for struct clouds.'; + $lang['sort'] = 'Sort by this column'; $lang['next'] = 'Next page'; $lang['prev'] = 'Previous page'; diff --git a/meta/AggregationCloud.php b/meta/AggregationCloud.php new file mode 100644 index 0000000000..8919b80700 --- /dev/null +++ b/meta/AggregationCloud.php @@ -0,0 +1,193 @@ +id = $id; + $this->mode = $mode; + $this->renderer = $renderer; + $this->searchConfig = $searchConfig; + $this->data = $searchConfig->getConf(); + $this->columns = $searchConfig->getColumns(); + $this->result = $this->searchConfig->execute(); + $this->resultCount = $this->searchConfig->getCount(); + + $this->max = $this->result[0]['count']; + $this->min = end($this->result)['count']; + } + + /** + * Create the cloud on the renderer + */ + public function render() { + + $this->sortResults(); + + $this->startScope(); + $this->startList(); + foreach ($this->result as $result) { + $this->renderTag($result); + } + $this->finishList(); + $this->finishScope(); + return; + } + + /** + * Adds additional info to document and renderer in XHTML mode + * + * @see finishScope() + */ + protected function startScope() { + // wrapping div + if($this->mode != 'xhtml') return; + $this->renderer->doc .= "
"; + } + + /** + * Closes the table and anything opened in startScope() + * + * @see startScope() + */ + protected function finishScope() { + // wrapping div + if($this->mode != 'xhtml') return; + $this->renderer->doc .= '
'; + } + + /** + * Render a tag of the cloud + * + * @param ['tag' => Value, 'count' => int] $result + */ + protected function renderTag($result) { + /** + * @var Value $value + */ + $value = $result['tag']; + $count = $result['count']; + if ($value->isEmpty()) { + return; + } + + $type = strtolower($value->getColumn()->getType()->getClass()); + $weight = $this->getWeight($count, $this->min, $this->max); + + if (!empty($this->data['target'])) { + $target = $this->data['target']; + } else { + global $INFO; + $target = $INFO['id']; + } + + $tagValue = $value->getDisplayValue(); + if (is_array($tagValue)) { + $tagValue = $tagValue[0]; + } + $key = $value->getColumn()->getFullQualifiedLabel() . '*~'; + $filter = SearchConfigParameters::$PARAM_FILTER . "[$key]=" . urlencode($tagValue); + + $this->renderer->listitem_open(1); + $this->renderer->listcontent_open(); + + if($this->mode == 'xhtml') { + $this->renderer->doc .= "
"; + } + + $value->renderAsTagCloudLink($this->renderer, $this->mode, $target, $filter, $weight); + + if($this->mode == 'xhtml') { + $this->renderer->doc .= '
'; + } + + $this->renderer->listcontent_close(); + $this->renderer->listitem_close(); + } + + /** + * This interpolates the weight between 70 and 150 based on $min, $max and $current + * + * @param int $current + * @param int $min + * @param int $max + * @return int + */ + protected function getWeight($current, $min, $max) { + if ($min == $max) { + return 100; + } + return round(($current - $min)/($max - $min) * 80 + 70); + } + + /** + * Sort the list of results + */ + protected function sortResults() { + usort($this->result, function ($a, $b) { + $asort = $a['tag']->getColumn()->getType()->getSortString($a['tag']); + $bsort = $b['tag']->getColumn()->getType()->getSortString($b['tag']); + if ($asort < $bsort) { + return -1; + } + if ($asort > $bsort) { + return 1; + } + return 0; + }); + } + + protected function startList() { + $this->renderer->listu_open(); + } + + protected function finishList() { + $this->renderer->listu_close(); + } +} diff --git a/meta/ConfigParser.php b/meta/ConfigParser.php index 596536c565..bddb99a3f7 100644 --- a/meta/ConfigParser.php +++ b/meta/ConfigParser.php @@ -29,6 +29,7 @@ public function __construct($lines) { 'summarize' => false, 'rownumbers' => false, 'sepbyheaders' => false, + 'target' => '', 'headers' => array(), 'widths' => array(), 'filter' => array(), @@ -108,6 +109,10 @@ public function __construct($lines) { case 'csv': $this->config['csv'] = (bool) $val; break; + case 'target': + case 'page': + $this->config['target'] = cleanID($val); + break; default: throw new StructException("unknown option '%s'", hsc($key)); } diff --git a/meta/SearchCloud.php b/meta/SearchCloud.php new file mode 100644 index 0000000000..1728519a63 --- /dev/null +++ b/meta/SearchCloud.php @@ -0,0 +1,108 @@ +columns) throw new StructException('nocolname'); + + $QB = new QueryBuilder(); + reset($this->schemas); + $schema = current($this->schemas); + $datatable = 'data_' . $schema->getTable(); + if(!$schema->isLookup()) { + $QB->addTable('schema_assignments'); + $QB->filters()->whereAnd("$datatable.pid = schema_assignments.pid"); + $QB->filters()->whereAnd("schema_assignments.tbl = '{$schema->getTable()}'"); + $QB->filters()->whereAnd("schema_assignments.assigned = 1"); + $QB->filters()->whereAnd("GETACCESSLEVEL($datatable.pid) > 0"); + $QB->filters()->whereAnd("PAGEEXISTS($datatable.pid) = 1"); + } + $QB->addTable($datatable); + $QB->filters()->whereAnd("$datatable.latest = 1"); + + $col = $this->columns[0]; + if($col->isMulti()) { + $multitable = "multi_{$col->getTable()}"; + $MN = $QB->generateTableAlias('M'); + + $QB->addLeftJoin( + $datatable, + $multitable, + $MN, + "$datatable.pid = $MN.pid AND + $datatable.rev = $MN.rev AND + $MN.colref = {$col->getColref()}" + ); + + $col->getType()->select($QB, $MN, 'value', 'tag'); + $colname = $MN . '.value'; + } else { + $col->getType()->select($QB, $datatable, $col->getColName(), 'tag'); + $colname = $datatable . '.' . $col->getColName(); + } + $QB->addSelectStatement("COUNT($colname)", 'count'); + $QB->addGroupByStatement('tag'); + $QB->addOrderBy('count DESC'); + + list($sql, $opts) = $QB->getSQL(); + return [$sql . $this->limit, $opts]; + } + + /** + * We do not have pagination in clouds, so we can work with a limit within SQL + * + * @param int $limit + */ + public function setLimit($limit) { + $this->limit = " LIMIT $limit"; + } + + /** + * Execute this search and return the result + * + * The result is a two dimensional array of Value()s. + * + * @return Value[][] + */ + public function execute() { + list($sql, $opts) = $this->getSQL(); + + /** @var \PDOStatement $res */ + $res = $this->sqlite->query($sql, $opts); + if($res === false) throw new StructException("SQL execution failed for\n\n$sql"); + + $result = []; + $rows = $this->sqlite->res2arr($res); + + foreach ($rows as $row) { + if (!empty($this->config['min']) && $this->config['min'] > $row['count']) { + break; + } + + $row['tag'] = new Value($this->columns[0], $row['tag']); + $result[] = $row; + } + + $this->sqlite->res_close($res); + $this->count = count($result); + return $result; + } +} diff --git a/meta/Value.php b/meta/Value.php index 59157e4892..3c9e10cd07 100644 --- a/meta/Value.php +++ b/meta/Value.php @@ -154,6 +154,19 @@ public function render(\Doku_Renderer $R, $mode) { return true; } + /** + * Render this value as a tag-link in a struct cloud + * + * @param \Doku_Renderer $R + * @param string $mode + * @param string $page + * @param string $filterQuery + * @param int $weight + */ + public function renderAsTagCloudLink(\Doku_Renderer $R, $mode, $page, $filterQuery, $weight) { + $this->column->getType()->renderTagCloudLink($this->value, $R, $mode, $page, $filterQuery, $weight); + } + /** * Return the value editor for this value field * diff --git a/style.less b/style.less index 2c69ccdf8c..9e876c2413 100644 --- a/style.less +++ b/style.less @@ -305,6 +305,29 @@ form.struct_newschema { background-position-x: 2px; } } + +.dokuwiki .structcloud { + li { + float: left; + list-style: none; + margin: 0 1em 0 0; + padding: 0; + + .struct_color, .struct_media { + a { + display: block; + height: 100%; + } + } + + .struct_media a { + background-size: cover; + background-position: center; + box-shadow: 1px 1px 5px 1px rgba(0,0,0,0.5) + } + } +} + /** * Lookup Aggregation Editor */ diff --git a/syntax/cloud.php b/syntax/cloud.php new file mode 100644 index 0000000000..c622868ef9 --- /dev/null +++ b/syntax/cloud.php @@ -0,0 +1,106 @@ + + */ + +// must be run within Dokuwiki +if (!defined('DOKU_INC')) die(); + +use dokuwiki\plugin\struct\meta\ConfigParser; +use dokuwiki\plugin\struct\meta\SearchCloud; +use dokuwiki\plugin\struct\meta\StructException; +use dokuwiki\plugin\struct\meta\AggregationCloud; + +class syntax_plugin_struct_cloud extends DokuWiki_Syntax_Plugin { + + /** + * @return string Syntax mode type + */ + public function getType() { + return 'substition'; + } + /** + * @return string Paragraph type + */ + public function getPType() { + return 'block'; + } + /** + * @return int Sort order - Low numbers go before high numbers + */ + public function getSort() { + return 151; + } + + /** + * Connect lookup pattern to lexer. + * + * @param string $mode Parser mode + */ + public function connectTo($mode) { + $this->Lexer->addSpecialPattern('----+ *struct cloud *-+\n.*?\n----+',$mode,'plugin_struct_cloud'); + } + + /** + * Handle matches of the struct syntax + * + * @param string $match The match of the syntax + * @param int $state The state of the handler + * @param int $pos The position in the document + * @param Doku_Handler $handler The handler + * @return array Data for the renderer + */ + public function handle($match, $state, $pos, Doku_Handler $handler){ + global $conf; + $lines = explode("\n", $match); + array_shift($lines); + array_pop($lines); + + try { + $parser = new ConfigParser($lines); + $config = $parser->getConfig(); + return $config; + } catch(StructException $e) { + msg($e->getMessage(), -1, $e->getLine(), $e->getFile()); + if($conf['allowdebug']) msg('
' . hsc($e->getTraceAsString()) . '
', -1); + return null; + } + + } + + /** + * Render xhtml output or metadata + * + * @param string $mode Renderer mode (supported modes: xhtml) + * @param Doku_Renderer $renderer The renderer + * @param array $data The data from the handler() function + * @return bool If rendering was successful. + */ + public function render($mode, Doku_Renderer $renderer, $data) { + if($mode != 'xhtml') return false; + if(!$data) return false; + if (!empty($data['filter'])) { + msg($this->getLang('Warning: no filters for cloud'), -1); + } + global $INFO, $conf; + try { + $search = new SearchCloud($data); + $cloud = new AggregationCloud($INFO['id'], $mode, $renderer, $search); + $cloud->render(); + if($mode == 'metadata') { + /** @var Doku_Renderer_metadata $renderer */ + $renderer->meta['plugin']['struct']['hasaggregation'] = $search->getCacheFlag(); + } + } catch(StructException $e) { + msg($e->getMessage(), -1, $e->getLine(), $e->getFile()); + if($conf['allowdebug']) msg('
' . hsc($e->getTraceAsString()) . '
', -1); + } + + return true; + } +} + +// vim:ts=4:sw=4:et: diff --git a/types/AbstractBaseType.php b/types/AbstractBaseType.php index fee1d14548..7a295c72d1 100644 --- a/types/AbstractBaseType.php +++ b/types/AbstractBaseType.php @@ -6,6 +6,7 @@ use dokuwiki\plugin\struct\meta\QueryBuilderWhere; use dokuwiki\plugin\struct\meta\StructException; use dokuwiki\plugin\struct\meta\ValidationException; +use dokuwiki\plugin\struct\meta\Value; /** * Class AbstractBaseType @@ -357,6 +358,20 @@ public function renderMultiValue($values, \Doku_Renderer $R, $mode) { return true; } + /** + * Render a link in a struct cloud. This should be good for most types, but can be overwritten if necessary. + * + * @param string|int $value the value stored in the database + * @param \Doku_Renderer $R the renderer currently used to render the data + * @param string $mode The mode the output is rendered in (eg. XHTML) + * @param string $page the target to which should be linked + * @param string $filter the filter to apply to the aggregations on $page + * @param int $weight the scaled weight of the item. Will already be implemented as css font-size on the outside container + */ + public function renderTagCloudLink($value, \Doku_Renderer $R, $mode, $page, $filter, $weight) { + $R->internallink("$page?$filter", $this->displayValue($value)); + } + /** * This function is used to modify an aggregation query to add a filter * for the given column matching the given value. A type should add at @@ -429,6 +444,27 @@ public function sort(QueryBuilder $QB, $tablealias, $colname, $order) { $QB->addOrderBy("$tablealias.$colname $order"); } + /** + * Get the string by which to sort values of this type + * + * This implementation is designed to work both as registered function in sqlite + * and to provide a string to be used in sorting values of this type in PHP. + * + * @param string|Value $string The string by which the types would usually be sorted + * + * @return string + */ + public function getSortString($value) { + if (is_string($value)) { + return $value; + } + $display = $value->getDisplayValue(); + if (is_array($display)) { + return blank($display[0]) ? "" : $display[0]; + } + return $display; + } + /** * This allows types to apply a transformation to the value read by select() * diff --git a/types/Color.php b/types/Color.php index 10dc08dcab..f183ac05df 100644 --- a/types/Color.php +++ b/types/Color.php @@ -68,4 +68,60 @@ public function valueEditor($name, $rawvalue) { return "$html"; } + /** + * @inheritDoc + */ + public function renderTagCloudLink($value, \Doku_Renderer $R, $mode, $page, $filter, $weight) { + $color = $this->displayValue($value); + if ($mode == 'xhtml') { + $url = wl($page, $filter); + $style = "background-color:$color;"; + $R->doc .= "$color"; + return; + } + $R->internallink("$page?$filter", $color); + } + + + /** + * Sort by the hue of a color, not by its hex-representation + */ + public function getSortString($value) { + $hue = $this->getHue(parent::getSortString($value)); + return $hue; + } + + /** + * Calculate the hue of a color to use it for sorting so we can sort similar colors together. + * + * @param string $color the color as #RRGGBB + * @return float|int + */ + protected function getHue($color) { + if (!preg_match('/^#[0-9A-F]{6}$/i', $color)) { + return 0; + } + + $red = hexdec(substr($color, 1, 2)); + $green = hexdec(substr($color, 3, 2)); + $blue = hexdec(substr($color, 5, 2)); + + $min = min([$red, $green, $blue]); + $max = max([$red, $green, $blue]); + + if ($max == $red) { + $hue = ($green-$blue)/($max-$min); + } + if ($max == $green) { + $hue = 2 + ($blue-$red)/($max-$min); + } + if ($max == $blue) { + $hue = 4 + ($red-$green)/($max-$min); + } + $hue = $hue * 60; + if ($hue < 0) { + $hue += 360; + } + return $hue; + } } diff --git a/types/Media.php b/types/Media.php index fa6c02550d..4ddda478fd 100644 --- a/types/Media.php +++ b/types/Media.php @@ -111,4 +111,25 @@ public function valueEditor($name, $rawvalue) { $html .= ""; return $html; } + + /** + * @inheritDoc + */ + public function renderTagCloudLink($value, \Doku_Renderer $R, $mode, $page, $filter, $weight) { + $media = $this->displayValue($value); + if ($mode == 'xhtml' && $this->getConfig()['mime'] == 'image/') { + $url = wl($page, $filter); + $image = ml($media, ['h' => $weight, 'w' => $weight]); + $media_escaped = hsc($media); + $R->doc .= "
"; + $R->doc .= ""; + $R->doc .= "$media_escaped"; + $R->doc .= ""; + $R->doc .= "
"; + return; + } + $R->internallink("$page?$filter", $media); + } + + } diff --git a/types/Page.php b/types/Page.php index f1b014fc67..f190d4fa8b 100644 --- a/types/Page.php +++ b/types/Page.php @@ -176,7 +176,10 @@ public function rawValue($value) { */ public function displayValue($value) { if($this->config['usetitles']) { - list(, $value) = json_decode($value); + list($pageid, $value) = json_decode($value); + if (blank($value)) { + $value = $pageid; + } } return $value; }