1<?php
2
3// must be run within Dokuwiki
4if (!defined('DOKU_INC')) {
5    die();
6}
7
8class helper_plugin_sitemapnavi extends DokuWiki_Plugin {
9
10    public function getSiteMap($baseNS)
11    {
12        global $conf, $INFO;
13
14        $subdir = trim(str_replace(':', '/', $baseNS),'/');
15        $level = $this->getNumberOfSubnamespaces($baseNS) + 1;
16
17        $pages = array();
18        $currentNS = utf8_encodeFN(str_replace(':', '/', $INFO['namespace']));
19        search($pages, $conf['datadir'], 'search_index', array('ns' => $currentNS), $subdir, $level);
20        $media = array();
21        search($media, $conf['mediadir'], [$this, 'searchMediaIndex'], array('ns' => $currentNS, 'depth' => 1, 'showmsg'=>false), str_replace(':', '/', $baseNS));
22        $media = array_map(function($mediaFile) {
23            $cleanedNamespace = trim(getNS($mediaFile['id']), ':');
24            if ($cleanedNamespace === '') {
25                $mediaFile['level'] = 1;
26            } else {
27                $mediaFile['level'] = count(explode(':', $cleanedNamespace)) + 1;
28            }
29            return $mediaFile;
30        }, $media);
31        $items = $this->mergePagesAndMedia($pages, $media);
32        $items = $this->sortMediaAfterPages($items);
33
34        $html =  html_buildlist($items, 'idx', [$this, 'listItemCallback'], [$this, 'liCallback'], true);
35        return $html;
36    }
37
38    /**
39     * Calculate the number of subnamespaces, the given namespace is consisting of
40     *
41     * @param string $namespace
42     * @return int
43     */
44    protected function getNumberOfSubnamespaces($namespace) {
45        $cleanedNamespace = trim($namespace, ':');
46        if ($cleanedNamespace === '') {
47            return 0;
48        }
49        return substr_count($cleanedNamespace, ':') + 1;
50    }
51
52    /**
53     * A stable sort, that moves media entries after the pages in the same namespace
54     *
55     * @param array $items list of items to be sorted, consisting both of directories, pages and media
56     * @return array
57     */
58    protected function sortMediaAfterPages(array $items) {
59        $numberOfItems = count($items);
60
61        if (empty($items)) {
62            return $items;
63        }
64        $count = 0;
65        $hasChanged = false;
66        $isUnsorted = true;
67        while($isUnsorted) {
68            $item1 = $items[$count];
69            $item2 = $items[$count + 1];
70            if ($this->compareMediaPages($item1, $item2) === 1) {
71                $temp = $item1;
72                $items[$count] = $item2;
73                $items[$count + 1] = $temp;
74                $hasChanged = true;
75            }
76            $count++;
77            if ($count === $numberOfItems) {
78                if ($hasChanged) {
79                    $count = 0;
80                    $hasChanged = false;
81                    continue;
82                }
83                $isUnsorted = false;
84            }
85        }
86
87        return $items;
88    }
89
90    /**
91     * "compare" media items to pages and directories
92     *
93     * Considers media items to be "larger" than pages and directories if those are in the same namespace or a subnamespace
94     * Considers media items to be "larger" than other media items if those are in a subnamespace
95     *
96     * @param $item1
97     * @param $item2
98     * @return int
99     */
100    protected function compareMediaPages($item1, $item2) {
101        $item1IsMedia = !isset($item1['type']);
102        $item2IsMedia = !isset($item2['type']);
103        if ($item1IsMedia) {
104            $nameSpaceDifference = $this->namespaceDifference($item1['id'], $item2['id']);
105            if ($nameSpaceDifference > 0) {
106                return 1;
107            }
108            if ($nameSpaceDifference === 0 && !$item2IsMedia) {
109                return 1;
110            }
111        }
112        return -1;
113    }
114
115    /**
116     * Calculate how far $id2 is in the namespace of $id1
117     *
118     * If $id2 is not in the same namespace or a subnamespace of $id1 return false
119     * If they are in the same namespace return 0
120     * If $id2 is in a subnamespace to the namespace of $id1, return the relative number of subnamespaces
121     *
122     * @param $id1
123     * @param $id2
124     * @return bool|int
125     */
126    protected function namespaceDifference($id1, $id2) {
127        $nslist1 = explode(':', getNS($id1));
128        $nslist2 = explode(':', getNS($id2));
129        if (empty($nslist1)) {
130            return count($nslist2);
131        }
132        $NS1depth = count($nslist1);
133        for ($i = 0; $i < $NS1depth; $i += 1) {
134            if (empty($nslist2[$i]) || $nslist1[$i] !== $nslist2[$i]) {
135                // not in our namespace
136                return false;
137            }
138        }
139        return (count($nslist2) - count($nslist1));
140    }
141
142    /**
143     * Merge media items into an flat ordered list of index items, after their respecitve directories
144     *
145     * @param array $pages
146     * @param array $mediaFiles
147     * @return array
148     */
149    protected function mergePagesAndMedia(array $pages, array $mediaFiles) {
150        $items = [];
151        $unhandledMediaFiles = $mediaFiles;
152        foreach ($pages as $page) {
153            if ($page['type'] === 'f') {
154                $items[] = $page;
155                continue;
156            }
157            $items[] = $page;
158            $currentMediaFiles = $unhandledMediaFiles;
159            $unhandledMediaFiles = [];
160            foreach ($currentMediaFiles as $mediaFile) {
161                $mediafileNamespace = getNs($mediaFile['id']);
162                if ($page['id'] === $mediafileNamespace) {
163                    $items[] = $mediaFile;
164                    continue;
165                }
166                $unhandledMediaFiles[] = $mediaFile;
167            }
168        }
169        $items = array_merge($items, $unhandledMediaFiles);
170        return $items;
171    }
172
173    /**
174     * Wrapper for search_media, that descends only towards the current directory
175     *
176     * @see search_media
177     *
178     * @param $data
179     * @param $base
180     * @param $file
181     * @param $type
182     * @param $lvl
183     * @param $opts
184     * @return bool
185     */
186    public function searchMediaIndex(&$data,$base,$file,$type,$lvl,$opts) {
187        if($type === 'd') {
188            if (strpos($opts['ns'] . '/', trim($file,'/') . '/') === 0) {
189                return true;
190            }
191        }
192        return search_media($data,$base,$file,$type,$lvl,$opts);
193    }
194
195
196    public function listItemCallback($item)
197    {
198        $fullId = cleanID($item['id']);
199
200        $ret = '';
201        $fullId = ':' . $fullId;
202        $base = substr($fullId, strrpos($fullId, ':') + 1);
203
204        if ($item['type'] === 'd') {
205            // FS#2766, no need for search bots to follow namespace links in the index
206            $ret .= '<button title="' . $fullId . '" class="plugin__sitemapnavi__dir" ><strong>';
207            $ret .= $base;
208            $ret .= '</strong></button>';
209        } elseif ($item['type'] === 'f') {
210            // default is noNSorNS($id), but we want noNS($id) when useheading is off FS#2605
211            $ret .= html_wikilink($fullId, useHeading('navigation') ? null : noNS($fullId));
212        } else {
213            list($ext) = mimetype($item['file'],false);
214            $class = "mf_$ext media mediafile";
215            $ret .= '<a class="'.$class.'" href="'.ml($item['id']).'" target="_blank">' . $item['file'] . '</a>';
216        }
217        return $ret;
218    }
219
220    public function liCallback($item)
221    {
222        global $INFO;
223        $currentClass = '';
224        $adjustedItemID = str_replace('::', ':', ':' . $item['id']);
225        if (strpos(':' . $INFO['id'] . ':', $adjustedItemID . ':') === 0) {
226            $currentClass = 'current';
227        }
228
229        if (!isset($item['type'])) {
230            return '<li class="level' . $item['level'] . ' media">';
231        }
232        if ($item['type'] === 'f') {
233            return '<li class="level' . $item['level'] . ' ' . $currentClass . '">';
234        }
235        if ($item['open']) {
236            return '<li class="open ' . $currentClass . '">';
237        }
238
239        return '<li class="closed ' . $currentClass . '" data-ns="'.$adjustedItemID.'">';
240
241    }
242}
243