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