1<?php
2
3/**
4 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
5 * @author     Andreas Gohr <andi@splitbrain.org>
6 */
7
8use dokuwiki\Extension\Plugin;
9use dokuwiki\ErrorHandler;
10use dokuwiki\plugin\sqlite\SQLiteDB;
11use dokuwiki\Utf8\PhpString;
12
13/**
14 * This is the base class for all syntax classes, providing some general stuff
15 */
16class helper_plugin_data extends Plugin
17{
18    /**
19     * @var SQLiteDB initialized via _getDb()
20     */
21    protected $db;
22
23    /**
24     * @var array stores the alias definitions
25     */
26    protected $aliases;
27
28    /**
29     * @var array stores custom key localizations
30     */
31    protected $locs = [];
32
33    /**
34     * Constructor
35     *
36     * Loads custom translations
37     */
38    public function __construct()
39    {
40        $this->loadLocalizedLabels();
41    }
42
43    private function loadLocalizedLabels()
44    {
45        $lang = [];
46        $path = DOKU_CONF . '/lang/en/data-plugin.php';
47        if (file_exists($path)) include($path);
48        $path = DOKU_CONF . '/lang/' . $this->determineLang() . '/data-plugin.php';
49        if (file_exists($path)) include($path);
50        foreach ($lang as $key => $val) {
51            $lang[PhpString::strtolower($key)] = $val;
52        }
53        $this->locs = $lang;
54    }
55
56    /**
57     * Return language code
58     *
59     * @return mixed
60     */
61    protected function determineLang()
62    {
63        /** @var helper_plugin_translation $trans */
64        $trans = plugin_load('helper', 'translation');
65        if ($trans) {
66            $value = $trans->getLangPart(getID());
67            if ($value) return $value;
68        }
69        global $conf;
70        return $conf['lang'];
71    }
72
73    /**
74     * Simple function to check if the database is ready to use
75     *
76     * @return bool
77     */
78    public function ready()
79    {
80        return (bool)$this->getDB();
81    }
82
83    /**
84     * load the sqlite helper
85     *
86     * @return SQLiteDB|null SQLite class plugin or null if failed
87     */
88    public function getDB()
89    {
90        if ($this->db === null) {
91            try {
92                $this->db = new SQLiteDB('data', __DIR__ . '/db/');
93                $this->db->getPdo()->sqliteCreateFunction('DATARESOLVE', [$this, 'resolveData'], 2);
94            } catch (\Exception $exception) {
95                if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception);
96                ErrorHandler::logException($exception);
97                msg('Couldn\'t load sqlite.', -1);
98                return null;
99            }
100        }
101        return $this->db;
102    }
103
104    /**
105     * Makes sure the given data fits with the given type
106     *
107     * @param string $value
108     * @param string|array $type
109     * @return string
110     */
111    public function cleanData($value, $type)
112    {
113        $value = trim((string) $value);
114        if (!$value && $value !== '0') {
115            return '';
116        }
117        if (is_array($type)) {
118            if (isset($type['enum']) && !preg_match('/(^|,\s*)' . preg_quote_cb($value) . '($|\s*,)/', $type['enum'])) {
119                return '';
120            }
121            $type = $type['type'];
122        }
123        switch ($type) {
124            case 'dt':
125                if (preg_match('/^(\d\d\d\d)-(\d\d?)-(\d\d?)$/', $value, $m)) {
126                    return sprintf('%d-%02d-%02d', $m[1], $m[2], $m[3]);
127                }
128                if ($value === '%now%') {
129                    return $value;
130                }
131                return '';
132            case 'url':
133                if (!preg_match('!^[a-z]+://!i', $value)) {
134                    $value = 'http://' . $value;
135                }
136                return $value;
137            case 'mail':
138                $email = '';
139                $name = '';
140                $parts = preg_split('/\s+/', $value);
141                do {
142                    $part = array_shift($parts);
143                    if (!$email && mail_isvalid($part)) {
144                        $email = strtolower($part);
145                        continue;
146                    }
147                    $name .= $part . ' ';
148                } while ($part);
149
150                return trim($email . ' ' . $name);
151            case 'page':
152            case 'nspage':
153                return cleanID($value);
154            default:
155                return $value;
156        }
157    }
158
159    /**
160     * Add pre and postfixs to the given value
161     *
162     * $type may be an column array with pre and postfixes
163     *
164     * @param string|array $type
165     * @param string $val
166     * @param string $pre
167     * @param string $post
168     * @return string
169     */
170    public function addPrePostFixes($type, $val, $pre = '', $post = '')
171    {
172        if (is_array($type)) {
173            if (isset($type['prefix'])) {
174                $pre = $type['prefix'];
175            }
176            if (isset($type['postfix'])) {
177                $post = $type['postfix'];
178            }
179        }
180        $val = $pre . $val . $post;
181        $val = $this->replacePlaceholders($val);
182        return $val;
183    }
184
185    /**
186     * Resolve a value according to its column settings
187     *
188     * This function is registered as a SQL function named DATARESOLVE
189     *
190     * @param string $value
191     * @param string $colname
192     * @return string
193     */
194    public function resolveData($value, $colname)
195    {
196        // resolve pre and postfixes
197        $column = $this->column($colname);
198        $value = $this->addPrePostFixes($column['type'], $value);
199
200        // for pages, resolve title
201        $type = $column['type'];
202        if (is_array($type)) {
203            $type = $type['type'];
204        }
205        if ($type == 'title' || ($type == 'page' && useHeading('content'))) {
206            $id = $value;
207            if ($type == 'title') {
208                [$id, ] = explode('|', $value, 2);
209            }
210            //DATARESOLVE is only used with the 'LIKE' comparator, so concatenate the different strings is fine.
211            $value .= ' ' . p_get_first_heading($id);
212        }
213        return $value;
214    }
215
216    public function ensureAbsoluteId($id)
217    {
218        if (substr($id, 0, 1) !== ':') {
219            $id = ':' . $id;
220        }
221        return $id;
222    }
223
224    /**
225     * Return XHTML formated data, depending on column type
226     *
227     * @param array $column
228     * @param string $value
229     * @param Doku_Renderer_xhtml $R
230     * @return string
231     */
232    public function formatData($column, $value, Doku_Renderer_xhtml $R)
233    {
234        global $conf;
235        $vals = explode("\n", $value);
236        $outs = [];
237
238        //multivalued line from db result for pageid and wiki has only in first value the ID
239        $storedID = '';
240
241        foreach ($vals as $val) {
242            $val = trim($val);
243            if ($val == '') continue;
244
245            $type = $column['type'];
246            if (is_array($type)) {
247                $type = $type['type'];
248            }
249            switch ($type) {
250                case 'page':
251                    $val = $this->addPrePostFixes($column['type'], $val);
252                    $val = $this->ensureAbsoluteId($val);
253                    $outs[] = $R->internallink($val, null, null, true);
254                    break;
255                case 'title':
256                    [$id, $title] = array_pad(explode('|', $val, 2), 2, null);
257                    $id = $this->addPrePostFixes($column['type'], $id);
258                    $id = $this->ensureAbsoluteId($id);
259                    $outs[] = $R->internallink($id, $title, null, true);
260                    break;
261                case 'pageid':
262                    [$id, $title] = array_pad(explode('|', $val, 2), 2, null);
263
264                    //use ID from first value of the multivalued line
265                    if ($title == null) {
266                        $title = $id;
267                        if (!empty($storedID)) {
268                            $id = $storedID;
269                        }
270                    } else {
271                        $storedID = $id;
272                    }
273
274                    $id = $this->addPrePostFixes($column['type'], $id);
275
276                    $outs[] = $R->internallink($id, $title, null, true);
277                    break;
278                case 'nspage':
279                    // no prefix/postfix here
280                    $val = ':' . $column['key'] . ":$val";
281
282                    $outs[] = $R->internallink($val, null, null, true);
283                    break;
284                case 'mail':
285                    [$id, $title] = array_pad(explode(' ', $val, 2), 2, null);
286                    $id = $this->addPrePostFixes($column['type'], $id);
287                    $id = obfuscate(hsc($id));
288                    if (!$title) {
289                        $title = $id;
290                    } else {
291                        $title = hsc($title);
292                    }
293                    if ($conf['mailguard'] == 'visible') {
294                        $id = rawurlencode($id);
295                    }
296                    $outs[] = '<a href="mailto:' . $id . '" class="mail" title="' . $id . '">' . $title . '</a>';
297                    break;
298                case 'url':
299                    $val = $this->addPrePostFixes($column['type'], $val);
300                    $outs[] = $this->external_link($val, false, 'urlextern');
301                    break;
302                case 'tag':
303                    // per default use keyname as target page, but prefix on aliases
304                    if (!is_array($column['type'])) {
305                        $target = $column['key'] . ':';
306                    } else {
307                        $target = $this->addPrePostFixes($column['type'], '');
308                    }
309
310                    $outs[] = '<a href="'
311                        . wl(str_replace('/', ':', cleanID($target)), $this->getTagUrlparam($column, $val))
312                        . '" title="' . sprintf($this->getLang('tagfilter'), hsc($val))
313                        . '" class="wikilink1">' . hsc($val) . '</a>';
314                    break;
315                case 'timestamp':
316                    $outs[] = dformat($val);
317                    break;
318                case 'wiki':
319                    global $ID;
320                    $oldid = $ID;
321                    [$ID, $data] = explode('|', $val, 2);
322
323                    //use ID from first value of the multivalued line
324                    if ($data == null) {
325                        $data = $ID;
326                        $ID = $storedID;
327                    } else {
328                        $storedID = $ID;
329                    }
330                    $data = $this->addPrePostFixes($column['type'], $data);
331
332                    // Trim document_{start,end}, p_{open,close} from instructions
333                    $allinstructions = p_get_instructions($data);
334                    $wraps = 1;
335                    if (isset($allinstructions[1]) && $allinstructions[1][0] == 'p_open') {
336                        $wraps++;
337                    }
338                    $instructions = array_slice($allinstructions, $wraps, -$wraps);
339
340                    $outs[] = p_render('xhtml', $instructions, $byref_ignore);
341                    $ID = $oldid;
342                    break;
343                default:
344                    $val = $this->addPrePostFixes($column['type'], $val);
345                    //type '_img' or '_img<width>'
346                    if (substr($type, 0, 3) == 'img') {
347                        $width = (int)substr($type, 3);
348                        if (!$width) {
349                            $width = $this->getConf('image_width');
350                        }
351
352                        [$mediaid, $title] = array_pad(explode('|', $val, 2), 2, null);
353                        if ($title === null) {
354                            $title = $column['key'] . ': ' . basename(str_replace(':', '/', $mediaid));
355                        } else {
356                            $title = trim($title);
357                        }
358
359                        if (media_isexternal($val)) {
360                            $html = $R->externalmedia(
361                                $mediaid,
362                                $title,
363                                $align = null,
364                                $width,
365                                $height = null,
366                                $cache = null,
367                                $linking = 'direct',
368                                true
369                            );
370                        } else {
371                            $html = $R->internalmedia(
372                                $mediaid,
373                                $title,
374                                $align = null,
375                                $width,
376                                $height = null,
377                                $cache = null,
378                                $linking = 'direct',
379                                true
380                            );
381                        }
382                        if (strpos($html, 'mediafile') === false) {
383                            $html = str_replace('href', 'rel="lightbox" href', $html);
384                        }
385
386                        $outs[] = $html;
387                    } else {
388                        $outs[] = hsc($val);
389                    }
390            }
391        }
392        return implode(', ', $outs);
393    }
394
395    /**
396     * Split a column name into its parts
397     *
398     * @param string $col column name
399     * @return array with key, type, ismulti, title, opt
400     */
401    public function column($col)
402    {
403        preg_match('/^([^_]*)(?:_(.*))?((?<!s)|s)$/', $col, $matches);
404        $column = [
405            'colname' => $col,
406            'multi' => ($matches[3] === 's'),
407            'key' => PhpString::strtolower($matches[1]),
408            'origkey' => $matches[1], //similar to key, but stores upper case
409            'title' => $matches[1],
410            'type' => PhpString::strtolower($matches[2]),
411        ];
412
413        // fix title for special columns
414        static $specials = [
415            '%title%' => ['page', 'title'],
416            '%pageid%' => ['title', 'page'],
417            '%class%' => ['class'],
418            '%lastmod%' => ['lastmod', 'timestamp']
419        ];
420        if (isset($specials[$column['title']])) {
421            $s = $specials[$column['title']];
422            $column['title'] = $this->getLang($s[0]);
423            if ($column['type'] === '' && isset($s[1])) {
424                $column['type'] = $s[1];
425            }
426        }
427
428        // check if the type is some alias
429        $aliases = $this->aliases();
430        if (isset($aliases[$column['type']])) {
431            $column['origtype'] = $column['type'];
432            $column['type'] = $aliases[$column['type']];
433        }
434
435        // use custom localization for keys
436        if (isset($this->locs[$column['key']])) {
437            $column['title'] = $this->locs[$column['key']];
438        }
439
440        return $column;
441    }
442
443    /**
444     * Load defined type aliases
445     *
446     * @return array
447     */
448    public function aliases()
449    {
450        if (!is_null($this->aliases)) return $this->aliases;
451
452        $sqlite = $this->getDB();
453        if (!$sqlite) return [];
454
455        $this->aliases = [];
456        $rows = $sqlite->queryAll("SELECT * FROM aliases");
457        foreach ($rows as $row) {
458            $name = $row['name'];
459            unset($row['name']);
460            $this->aliases[$name] = array_filter(array_map('trim', $row));
461            if (!isset($this->aliases[$name]['type'])) {
462                $this->aliases[$name]['type'] = '';
463            }
464        }
465        return $this->aliases;
466    }
467
468    /**
469     * Parse a filter line into an array
470     *
471     * @param $filterline
472     * @return array|bool - array on success, false on error
473     */
474    public function parseFilter($filterline)
475    {
476        //split filterline on comparator
477        if (preg_match('/^(.*?)([\*=<>!~]{1,2})(.*)$/', $filterline, $matches)) {
478            $column = $this->column(trim($matches[1]));
479
480            $com = $matches[2];
481            $aliasses = ['<>' => '!=', '=!' => '!=', '~!' => '!~', '==' => '=', '~=' => '~', '=~' => '~'];
482
483            if (isset($aliasses[$com])) {
484                $com = $aliasses[$com];
485            } elseif (!preg_match('/(!?[=~])|([<>]=?)|(\*~)/', $com)) {
486                msg('Failed to parse comparison "' . hsc($com) . '"', -1);
487                return false;
488            }
489
490            $val = trim($matches[3]);
491
492            if ($com == '~~') {
493                $com = 'IN(';
494            }
495            if (strpos($com, '~') !== false) {
496                if ($com === '*~') {
497                    $val = '*' . $val . '*';
498                    $com = '~';
499                }
500                $val = str_replace('*', '%', $val);
501                if ($com == '!~') {
502                    $com = 'NOT LIKE';
503                } else {
504                    $com = 'LIKE';
505                }
506            } else {
507                // Clean if there are no asterisks I could kill
508                $val = $this->cleanData($val, $column['type']);
509            }
510            $sqlite = $this->getDB();
511            if (!$sqlite) return false;
512
513            if ($com == 'IN(') {
514                $val = explode(',', $val);
515                $val = array_map('trim', $val);
516                $val = array_map([$sqlite->getPdo(), 'quote'], $val);
517                $val = implode(",", $val);
518            } else {
519                $val = $sqlite->getPdo()->quote($val);
520            }
521
522            return [
523                'key' => $column['key'],
524                'value' => $val,
525                'compare' => $com,
526                'colname' => $column['colname'],
527                'type' => $column['type']
528            ];
529        }
530        msg('Failed to parse filter "' . hsc($filterline) . '"', -1);
531        return false;
532    }
533
534    /**
535     * Replace placeholders in sql
536     *
537     * @param $data
538     */
539    public function replacePlaceholdersInSQL(&$data)
540    {
541        global $USERINFO;
542        global $INPUT;
543        // allow current user name in filter:
544        $data['sql'] = str_replace('%user%', $INPUT->server->str('REMOTE_USER'), $data['sql']);
545        $data['sql'] = str_replace('%groups%', implode("','", $USERINFO['grps'] ?? []), $data['sql']);
546        // allow current date in filter:
547        $data['sql'] = str_replace('%now%', dformat(null, '%Y-%m-%d'), $data['sql']);
548
549        // language filter
550        $data['sql'] = $this->makeTranslationReplacement($data['sql']);
551    }
552
553    /**
554     * Replace translation related placeholders in given string
555     *
556     * @param string $data
557     * @return string
558     */
559    public function makeTranslationReplacement($data)
560    {
561        global $conf;
562        global $ID;
563
564        $patterns[] = '%lang%';
565        if (isset($conf['lang_before_translation'])) {
566            $values[] = $conf['lang_before_translation'];
567        } else {
568            $values[] = $conf['lang'];
569        }
570
571        // if translation plugin available, get current translation (empty for default lang)
572        $patterns[] = '%trans%';
573        /** @var helper_plugin_translation $trans */
574        $trans = plugin_load('helper', 'translation');
575        if ($trans) {
576            $local = $trans->getLangPart($ID);
577            if ($local === '') {
578                $local = $conf['lang'];
579            }
580            $values[] = $local;
581        } else {
582            $values[] = '';
583        }
584        return str_replace($patterns, $values, $data);
585    }
586
587    /**
588     * Get filters given in the request via GET or POST
589     *
590     * @return array
591     */
592    public function getFilters()
593    {
594        $filters = [];
595
596        if (!isset($_REQUEST['dataflt'])) {
597            $flt = [];
598        } elseif (!is_array($_REQUEST['dataflt'])) {
599            $flt = (array)$_REQUEST['dataflt'];
600        } else {
601            $flt = $_REQUEST['dataflt'];
602        }
603        foreach ($flt as $key => $line) {
604            // we also take the column and filtertype in the key:
605            if (!is_numeric($key)) {
606                $line = $key . $line;
607            }
608            $f = $this->parseFilter($line);
609            if (is_array($f)) {
610                $f['logic'] = 'AND';
611                $filters[] = $f;
612            }
613        }
614        return $filters;
615    }
616
617    /**
618     * prepare an array to be passed through buildURLparams()
619     *
620     * @param string $name keyname
621     * @param string|array $array value or key-value pairs
622     * @return array
623     */
624    public function a2ua($name, $array)
625    {
626        $urlarray = [];
627        foreach ((array)$array as $key => $val) {
628            $urlarray[$name . '[' . $key . ']'] = $val;
629        }
630        return $urlarray;
631    }
632
633    /**
634     * get current URL parameters
635     *
636     * @param bool $returnURLparams
637     * @return array with dataflt, datasrt and dataofs parameters
638     */
639    public function getPurrentParam($returnURLparams = true)
640    {
641        $cur_params = [];
642        if (isset($_REQUEST['dataflt'])) {
643            $cur_params = $this->a2ua('dataflt', $_REQUEST['dataflt']);
644        }
645        if (isset($_REQUEST['datasrt'])) {
646            $cur_params['datasrt'] = $_REQUEST['datasrt'];
647        }
648        if (isset($_REQUEST['dataofs'])) {
649            $cur_params['dataofs'] = $_REQUEST['dataofs'];
650        }
651
652        //combine key and value
653        if (!$returnURLparams) {
654            $flat_param = [];
655            foreach ($cur_params as $key => $val) {
656                $flat_param[] = $key . $val;
657            }
658            $cur_params = $flat_param;
659        }
660        return $cur_params;
661    }
662
663    /**
664     * Get url parameters, remove all filters for given column and add filter for desired tag
665     *
666     * @param array $column
667     * @param string $tag
668     * @return array of url parameters
669     */
670    public function getTagUrlparam($column, $tag)
671    {
672        $param = [];
673
674        if (isset($_REQUEST['dataflt'])) {
675            $param = (array)$_REQUEST['dataflt'];
676
677            //remove all filters equal to column
678            foreach ($param as $key => $flt) {
679                if (!is_numeric($key)) {
680                    $flt = $key . $flt;
681                }
682                $filter = $this->parseFilter($flt);
683                if ($filter['key'] == $column['key']) {
684                    unset($param[$key]);
685                }
686            }
687        }
688        $param[] = $column['key'] . "_=$tag";
689        $param = $this->a2ua('dataflt', $param);
690
691        if (isset($_REQUEST['datasrt'])) {
692            $param['datasrt'] = $_REQUEST['datasrt'];
693        }
694        if (isset($_REQUEST['dataofs'])) {
695            $param['dataofs'] = $_REQUEST['dataofs'];
696        }
697
698        return $param;
699    }
700
701    /**
702     * Perform replacements on the output values
703     *
704     * @param string $value
705     * @return string
706     */
707    private function replacePlaceholders($value)
708    {
709        return $this->makeTranslationReplacement($value);
710    }
711}
712