1<?php
2
3use dokuwiki\ErrorHandler;
4use dokuwiki\plugin\struct\meta\SearchConfig;
5use dokuwiki\plugin\struct\meta\StructException;
6
7/**
8 * DokuWiki Plugin structautolink (Renderer Component)
9 *
10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
11 * @author  Andreas Gohr <gohr@cosmocode.de>
12 */
13class renderer_plugin_structautolink extends Doku_Renderer_xhtml
14{
15    /** @var array[] The glossary terms per page */
16    protected $glossary = [];
17
18    /** @var string The compound regex to match all terms */
19    protected $regex;
20
21    // region renderer methods
22
23    /**
24     * Make this renderer available as alternative default renderer
25     *
26     * @param string $format
27     * @return bool
28     */
29    public function canRender($format)
30    {
31        if ($format == 'xhtml') return true;
32        return false;
33    }
34
35    /** @inheritdoc */
36    public function document_start()
37    {
38        parent::document_start();
39        $this->setGlossary($this->loadGlossary());
40    }
41
42    /** @inheritDoc */
43    public function cdata($text)
44    {
45        global $ID;
46
47        // only auto-link if wanted
48        if ($this->getConf('match') && !preg_match('/' . $this->getConf('match') . '/i', ":$ID")) {
49            parent::cdata($text);
50            return;
51        }
52
53        $tokens = $this->findMatchingTokens($text);
54        if (!$tokens) {
55            parent::cdata($text);
56            return;
57        }
58
59        $start = 0;
60        foreach ($tokens as $token) {
61            if ($token['pos'] > $start) {
62                parent::cdata(substr($text, $start, $token['pos'] - $start));
63            }
64            $this->internallink($this->getConf('ns') . ':' . $token['id'], $token['term']);
65            $start = $token['pos'] + $token['len'];
66        }
67        if ($start < strlen($text)) {
68            parent::cdata(substr($text, $start));
69        }
70
71    }
72
73    // endregion
74    // region logic methods
75
76    /**
77     * Load the defined glossary terms from struct
78     *
79     * @return array[] [pageid => [terms, ...], ...]
80     */
81    public function loadGlossary()
82    {
83        $schema = $this->getConf('schema');
84        $field = $this->getConf('field');
85        if (!$schema || !$field) return [];
86
87        try {
88            $search = new SearchConfig([
89                'schemas' => [[$schema, 'glossary']],
90                'cols' => ['%pageid%', $field],
91            ]);
92            $data = $search->execute();
93        } catch (StructException $e) {
94            ErrorHandler::logException($e);
95            return [];
96        }
97
98        $glossary = [];
99        foreach ($data as $row) {
100            $glossary[$row[0]->getValue()] = $row[1]->getValue();
101        }
102
103        return $glossary;
104    }
105
106    /**
107     * Set the given glossary and rebuild the regex
108     *
109     * @param array[] $glossary [pageid => [terms, ...], ...]
110     */
111    public function setGlossary($glossary)
112    {
113        $this->glossary = $glossary;
114        $this->buildPatterns();
115    }
116
117
118    /**
119     * initializes the regex to match terms
120     */
121    public function buildPatterns()
122    {
123        if (!$this->glossary) {
124            $this->regex = null;
125            return;
126        }
127
128        $patterns = [];
129        $num = 0; // term number
130        foreach ($this->glossary as $terms) {
131            $terms = array_map('preg_quote_cb', $terms);
132            $patterns[] = '(?P<p' . ($num++) . '>' . join('|', $terms) . ')';
133        }
134
135        $this->regex = '/\b(?:' . implode('|', $patterns) . ')\b/';
136    }
137
138    /**
139     * Find all matching glossary tokens in the given text
140     *
141     * @param string $text
142     * @return array|false Either an array of tokens or false if no matches were found
143     */
144    public function findMatchingTokens($text)
145    {
146        global $ID;
147
148        if (!$this->regex) return false;
149
150        if (!preg_match_all($this->regex, $text, $matches, PREG_OFFSET_CAPTURE)) {
151            return false;
152        }
153
154        $tokens = [];
155        foreach (array_keys($this->glossary) as $num => $id) {
156            if (!$this->glossary[$id]) continue; // this page has been linked before
157            if ($id === $ID) continue; // don't link to the current page
158
159            foreach ($matches["p$num"] as $match) {
160                if ($match[0] === '') continue;
161                $tokens[] = [
162                    'id' => $id,
163                    'term' => $match[0],
164                    'pos' => $match[1],
165                    'len' => strlen($match[0]),
166                ];
167                $this->glossary[$id] = false; // don't link this page again
168                break; // don't link any other term of this page
169            }
170        }
171
172        // sort by position
173        usort($tokens, function ($a, $b) {
174            return $a['pos'] - $b['pos'];
175        });
176
177        return $tokens;
178    }
179
180
181}
182
183