1<?php
2/**
3 * Move Plugin Rewriting Handler
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Michael Hamann <michael@content-space.de>
7 */
8
9use dokuwiki\Extension\Plugin;
10use dokuwiki\File\MediaResolver;
11use dokuwiki\File\PageResolver;
12use dokuwiki\Logger;
13use dokuwiki\plugin\move\MoveResolver;
14
15/**
16 * Handler class for move. It does the actual rewriting of the content.
17 *
18 * It is not really a helper plugin.
19 *
20 * This should theoretically be a descendant of Doku_Handler. This would require to implement and overwrite
21 * all the mode handler methods, which would probably be the cleaner approach. For now we pretend to be a
22 * Doku_Handler, implement only the methods we need and use __call() to catch all the rest.
23 *
24 * Since Parser does not like that and expects a Doku_Handler in the constructor, we use reflection to
25 * inject our fake handler into the Parser in helper_plugin_move_rewrite:rewrite()
26 *
27 * This is not really nice, but it works. It has at leas the advantage that we would not need to update this
28 * class when a new handler is introduced - though that hasn't happened in literally decades.
29 *
30 * The other hack we're doing here is to directly append (rewritten) matches to $wikitext. This makes it easy
31 * to basically keep the syntax as is and only change the IDs. A more proper approach would be create
32 * calls, then reverse-render them in the end. This can lead to slight differences between the original syntax
33 * and the reverse-rendered one, e.g. where syntax is somewhat ambiguous
34 *
35 * @todo At least do the right thing an make this a proper Doku_Handler descendant even when we keep the wikitext
36 */
37class helper_plugin_move_handler extends Plugin {
38    public $calls = [];
39
40    /**
41     * @var string This handler does not create calls, but rather recreates wiki text in one go.
42     */
43    public $wikitext = '';
44
45    protected $id;
46    protected $ns;
47    protected $origID;
48    protected $origNS;
49    protected $page_moves;
50    protected $media_moves;
51    protected $handlers;
52
53
54    /**
55     * Initialize the move handler.
56     *
57     * @param string $id          The id of the text that is passed to the handler
58     * @param string $original    The name of the original ID of this page. Same as $id if this page wasn't moved
59     * @param array  $page_moves  Moves that shall be considered in the form [[$old,$new],...] ($old can be $original)
60     * @param array  $media_moves Moves of media files that shall be considered in the form $old => $new
61     * @param array  $handlers    Handlers for plugin content in the form $plugin_name => $callback
62     */
63    public function init($id, $original, $page_moves, $media_moves, $handlers) {
64        $this->id          = $id;
65        $this->ns          = getNS($id);
66        $this->origID      = $original;
67        $this->origNS      = getNS($original);
68        $this->page_moves  = $page_moves;
69        $this->media_moves = $media_moves;
70        $this->handlers    = $handlers;
71    }
72
73    /**
74     * Go through the list of moves and find the new value for the given old ID
75     *
76     * @param string $old  the old, full qualified ID
77     * @param string $type 'media' or 'page'
78     * @throws \Exception on bad argument
79     * @return string the new full qualified ID
80     */
81    public function resolveMoves($old, $type) {
82        global $conf;
83
84        if($type != 'media' && $type != 'page') throw new \Exception('Not a valid type');
85
86        $resolver = new MoveResolver($this->origID);
87        $old = $resolver->resolveId($old);
88
89        if($type == 'page') {
90            // FIXME this simply assumes that the link pointed to :$conf['start'], but it could also point to another page
91            // resolve_pageid does a lot more here, but we can't really assume this as the original pages might have been
92            // deleted already
93            if(substr($old, -1) === ':' || $old === '') $old .= $conf['start'];
94
95            $moves = $this->page_moves;
96        } else {
97            $moves = $this->media_moves;
98        }
99
100        $old = cleanID($old);
101
102        foreach($moves as $move) {
103            if($move[0] == $old) {
104                $old = $move[1];
105            }
106        }
107
108        return $old; // this is now new
109    }
110
111    /**
112     * if the old link ended with a colon and the new one is a start page, adjust
113     *
114     * @param $relold string the old, possibly relative ID
115     * @param $new    string the new, full qualified ID
116     * @param $type   'media' or 'page'
117     * @return string
118     */
119    protected function _nsStartCheck($relold, $new, $type) {
120        global $conf;
121        if($type == 'page' && substr($relold, -1) == ':') {
122            $len = strlen($conf['start']);
123            if($new == $conf['start']) {
124                $new = '.:';
125            } else if(substr($new, -1 * ($len + 1)) == ':' . $conf['start']) {
126                $new = substr($new, 0, -1 * $len);
127            }
128        }
129        return $new;
130    }
131
132    /**
133     * Construct a new ID relative to the current page's location
134     *
135     * Uses a relative link only if the original was relative, too. This function is for
136     * pages and media files.
137     *
138     * @param string $relold the old, possibly relative ID
139     * @param string $new    the new, full qualified ID
140     * @param string $type 'media' or 'page'
141     * @throws \Exception on bad argument
142     * @return string
143     */
144    public function relativeLink($relold, $new, $type) {
145        global $conf;
146        if($type != 'media' && $type != 'page') throw new \Exception('Not a valid type');
147
148        // first check if the old link still resolves
149        $old    = $relold;
150        if($type == 'page') {
151            $resolver = new PageResolver($this->id);
152        } else {
153            $resolver = new MediaResolver($this->id);
154        }
155        $old = $resolver->resolveId($old);
156        if($old == $new) {
157            return $relold; // old link still resolves, keep as is
158        }
159
160        if($conf['useslash']) $relold = str_replace('/', ':', $relold);
161
162        // check if the link was relative
163        if(strpos($relold, ':') === false ||$relold[0] == '.') {
164            $wasrel = true;
165        } else {
166            $wasrel = false;
167        }
168
169        // if it wasn't relative then, leave it absolute now, too
170        if(!$wasrel) {
171            if($this->ns && !getNS($new)) $new = ':' . $new;
172            $new = $this->_nsStartCheck($relold, $new, $type);
173            return $new;
174        }
175
176        // split the paths and see how much common parts there are
177        $selfpath = explode(':', $this->ns);
178        $goalpath = explode(':', getNS($new));
179        $min      = min(count($selfpath), count($goalpath));
180        for($common = 0; $common < $min; $common++) {
181            if($selfpath[$common] != $goalpath[$common]) break;
182        }
183
184        // we now have the non-common part and a number of uppers
185        $ups       = max(count($selfpath) - $common, 0);
186        $remainder = array_slice($goalpath, $common);
187        $upper     = $ups ? array_fill(0, $ups, '..:') : array();
188
189        // build the new relative path
190        $newrel = join(':', $upper);
191        if($remainder) $newrel .= join(':', $remainder) . ':';
192        $newrel .= noNS($new);
193        $newrel = str_replace('::', ':', trim($newrel, ':'));
194        if($newrel[0] != '.' && $this->ns && getNS($newrel)) $newrel = '.' . $newrel;
195
196        // if the old link ended with a colon and the new one is a start page, adjust
197        $newrel = $this->_nsStartCheck($relold,$newrel,$type);
198
199        // don't use relative paths if it is ridicoulus:
200        if(strlen($newrel) > strlen($new)) {
201            $newrel = $new;
202            if($this->ns && !getNS($new)) $newrel = ':' . $newrel;
203            $newrel = $this->_nsStartCheck($relold,$newrel,$type);
204        }
205
206        return $newrel;
207    }
208
209    /**
210     * Handle camelcase links
211     *
212     * @param string $match The text match
213     * @param string $state The starte of the parser
214     * @param int    $pos   The position in the input
215     * @return bool If parsing should be continued
216     */
217    public function camelcaselink($match, $state, $pos) {
218        $oldID = cleanID($this->origNS . ':' . $match);
219        $newID = $this->resolveMoves($oldID, 'page');
220        $newNS = getNS($newID);
221
222        if($oldID == $newID || $this->origNS == $newNS) {
223            // link is still valid as is
224            $this->wikitext .= $match;
225        } else {
226            if(noNS($oldID) == noNS($newID)) {
227                // only namespace changed, keep CamelCase in link
228                $this->wikitext .= "[[$newNS:$match]]";
229            } else {
230                // all new, keep CamelCase in title
231                $this->wikitext .= "[[$newID|$match]]";
232            }
233        }
234        return true;
235    }
236
237    /**
238     * Handle rewriting of internal links
239     *
240     * @param string $match The text match
241     * @param string $state The starte of the parser
242     * @param int    $pos   The position in the input
243     * @return bool If parsing should be continued
244     */
245    public function internallink($match, $state, $pos) {
246        // Strip the opening and closing markup
247        $link = preg_replace(array('/^\[\[/', '/\]\]$/u'), '', $match);
248
249        // Split title from URL
250        $link = explode('|', $link, 2);
251        if(!isset($link[1])) {
252            $link[1] = null;
253        } else if(preg_match('/^\{\{[^\}]+\}\}$/', $link[1])) {
254            // If the title is an image, rewrite it
255            $old_title = $link[1];
256            $link[1]   = $this->rewrite_media($link[1]);
257            // do a simple replace of the first match so really only the id is changed and not e.g. the alignment
258            $oldpos = strpos($match, $old_title);
259            $oldlen = strlen($old_title);
260            $match  = substr_replace($match, $link[1], $oldpos, $oldlen);
261        }
262        $link[0] = trim($link[0]);
263
264        //decide which kind of link it is
265
266        if(preg_match('/^[a-zA-Z0-9\.]+>{1}.*$/u', $link[0])) {
267            // Interwiki
268            $this->wikitext .= $match;
269        } elseif(preg_match('/^\\\\\\\\[^\\\\]+?\\\\/u', $link[0])) {
270            // Windows Share
271            $this->wikitext .= $match;
272        } elseif(preg_match('#^([a-z0-9\-\.+]+?)://#i', $link[0])) {
273            // external link (accepts all protocols)
274            $this->wikitext .= $match;
275        } elseif(preg_match('<' . PREG_PATTERN_VALID_EMAIL . '>', $link[0])) {
276            // E-Mail (pattern above is defined in inc/mail.php)
277            $this->wikitext .= $match;
278        } elseif(preg_match('!^#.+!', $link[0])) {
279            // local hash link
280            $this->wikitext .= $match;
281        } else {
282            $id = $link[0];
283
284            $hash  = '';
285            $parts = explode('#', $id, 2);
286            if(count($parts) === 2) {
287                $id   = $parts[0];
288                $hash = $parts[1];
289            }
290
291            $params = '';
292            $parts  = explode('?', $id, 2);
293            if(count($parts) === 2) {
294                $id     = $parts[0];
295                $params = $parts[1];
296            }
297
298            $new_id = $this->resolveMoves($id, 'page');
299            $new_id = $this->relativeLink($id, $new_id, 'page');
300
301            if($id == $new_id) {
302                $this->wikitext .= $match;
303            } else {
304                if($params !== '') {
305                    $new_id .= '?' . $params;
306                }
307
308                if($hash !== '') {
309                    $new_id .= '#' . $hash;
310                }
311
312                if($link[1] != null) {
313                    $new_id .= '|' . $link[1];
314                }
315
316                $this->wikitext .= '[[' . $new_id . ']]';
317            }
318
319        }
320
321        return true;
322    }
323
324    /**
325     * Handle rewriting of media links
326     *
327     * @param string $match The text match
328     * @param string $state The starte of the parser
329     * @param int    $pos   The position in the input
330     * @return bool If parsing should be continued
331     */
332    public function media($match, $state, $pos) {
333        $this->wikitext .= $this->rewrite_media($match);
334        return true;
335    }
336
337    /**
338     * Rewrite a media syntax
339     *
340     * @param string $match The text match of the media syntax
341     * @return string The rewritten syntax
342     */
343    protected function rewrite_media($match) {
344        $p = Doku_Handler_Parse_Media($match);
345        if($p['type'] == 'internalmedia') { // else: external media
346
347            $new_src = $this->resolveMoves($p['src'], 'media');
348            $new_src = $this->relativeLink($p['src'], $new_src, 'media');
349
350            if($new_src !== $p['src']) {
351                // do a simple replace of the first match so really only the id is changed and not e.g. the alignment
352                $srcpos = strpos($match, $p['src']);
353                $srclen = strlen($p['src']);
354                return substr_replace($match, $new_src, $srcpos, $srclen);
355            }
356        }
357        return $match;
358    }
359
360    /**
361     * Handle rewriting of plugin syntax, calls the registered handlers
362     *
363     * @param string $match      The text match
364     * @param string $state      The starte of the parser
365     * @param int    $pos        The position in the input
366     * @param string $pluginname The name of the plugin
367     * @return bool If parsing should be continued
368     */
369    public function plugin($match, $state, $pos, $pluginname) {
370        if(isset($this->handlers[$pluginname])) {
371            $this->wikitext .= call_user_func($this->handlers[$pluginname], $match, $state, $pos, $pluginname, $this);
372        } else {
373            $this->wikitext .= $match;
374        }
375        return true;
376    }
377
378    /**
379     * Catchall handler for the remaining syntax
380     *
381     * @param string $name   Function name that was called
382     * @param array  $params Original parameters
383     * @return bool If parsing should be continue
384     */
385    public function __call($name, $params) {
386        if(count($params) == 3) {
387            $this->wikitext .= $params[0];
388            return true;
389        } else {
390            Logger::error(
391                'Error, handler function ' . hsc($name) . ' with ' . count($params) .
392                ' parameters called which isn\'t implemented'
393            );
394            return false;
395        }
396    }
397
398    public function finalize() {
399        // remove padding that is added by the parser in parse()
400        $this->wikitext = substr($this->wikitext, 1, -1);
401    }
402
403    /**
404     * Get the rewritten wiki text
405     *
406     * @return string The rewritten wiki text
407     */
408    public function getWikiText()
409    {
410        return $this->wikitext;
411    }
412}
413