1<?php
2
3// lightmenu utilities class
4
5class lightmenu
6{
7	protected static $syntax = [
8		'|^-head$|' => 'head',
9		'|^-min=(\d+)$|' => 'min',
10		'|^-sort=([\w,]+)$|' => 'sort'
11	];
12	protected static $options;
13	protected static $collator;
14
15	protected static function _log(string $format, string ...$params)
16	{
17		file_put_contents(__DIR__.'/lightmenu.log',date('Y/m/d H:i:s').':'.vsprintf($format,$params),FILE_APPEND);
18	}
19
20	protected static function _options(string $match) : array
21	{
22		self::$options = [];
23
24		$list = preg_split('|\s+|',$match);
25		foreach ($list as $option)
26		{
27			foreach (self::$syntax as $regex => $name)
28			{
29				if (preg_match($regex,$option,$matches))
30				{
31					self::$options[$name] = (count($matches) === 2)?$matches[1]:true;
32					break;
33				}
34			}
35		}
36
37		return self::$options;
38	}
39
40	// function give lightmenu meta data file path from page id and environment
41	// $id : dokuwiki page id
42	// return : lightmenu meta data file path
43	protected static function _meta_path(string $id) : string
44	{
45		global $conf;
46
47		$_id = ':'.$id;
48		$pos = strrpos($_id,':');
49		$path = strtr(substr($_id,0,$pos),':','/');
50		$name = substr($_id,$pos + 1);
51		if (($name === $conf['start'] && ($path !== '')) || (basename($path) === $name))
52			return $path;
53		if (is_dir(sprintf('%s%s/%s',$conf['datadir'],$path,$name)))
54			return sprintf('%s/%s',$path,$name);
55		return sprintf('%s/%s.txt',$path,$name);
56	}
57
58	// function give lightmenu meta data file path from page id and environment
59	// $id : dokuwiki page id
60	// return : lightmenu meta data file path
61	protected static function _subpath(string $id) : string
62	{
63		global $conf;
64
65		$parts = explode(':',$id);
66		$path = implode('/',array_map(function ($e) {
67			return rawurlencode($e);
68		}, array_slice($parts, 0, -1)));
69		$name = rawurlencode($parts[count($parts) - 1]);
70		if (($name === $conf['start'] && ($path !== '')) || (basename($path) === $name))
71			return $path;
72		if (is_dir(sprintf('%s/%s/%s',$conf['datadir'],$path,$name)))
73			return sprintf('/%s/%s',$path,$name);
74		return sprintf('/%s/%s.txt',$path,$name);
75	}
76
77
78	// return data about wiki path identified by file subpath and name
79	// subpath : the sub path to the file from data page root directory
80	// name : the file name of the page or directory (ex : start.txt)
81	// returned values : array
82	//   (boolean) true if the $name is a page, false if a directory
83	//   (string) the dokuwiki id of the page
84	//   (array) the lightmenu meta data
85	protected static function _get_page_data(string $subpath, string $name) : array
86	{
87		global $conf;
88
89		$path = sprintf('%s%s/%s.lightmenu.json',$conf['metadir'],$subpath,$name);
90		$data = [];
91		if (is_file($path) && is_readable($path))
92			$data = json_decode(file_get_contents($path),true,2,JSON_THROW_ON_ERROR);
93		return [
94			$is_page = substr($name,-4) === '.txt',
95			rawurldecode(($is_page)?substr($name,0,-4):$name),
96			$data
97		];
98	}
99
100	protected static function _set_page_data(string $subpath, array &$data)
101	{
102		global $conf;
103
104		$path = sprintf('%s%s.lightmenu.json',$conf['metadir'],'/'.ltrim($subpath,'/'));
105		if (is_file($path) && (! is_writable($path)))
106			throw new Exception(sprintf('Lightmenu : meta data file "%s" not writable.',$path));
107		if (is_file($path) && (count($data) === 0))
108			unlink($path);
109		else
110		{
111			if (! is_dir(dirname($path)))
112				mkdir(dirname($path),0755,true);
113			if (file_put_contents($path,json_encode($data,JSON_THROW_ON_ERROR)) === false)
114				throw new Exception('Lightmenu unable to write meta data.');
115		}
116	}
117
118	protected static function _touch_sidebar()
119	{
120		global $conf;
121
122		if (is_writeable($path = $conf['datadir'].'/'.$conf['sidebar'].'.txt'))
123			touch($path);
124	}
125
126	public static function update(string $id, string &$contents)
127	{
128		$data = [];
129		if (preg_match('|<lm:([^>]+)>|',$contents,$matches))
130			$data = json_decode(trim($matches[1]),true,2,JSON_THROW_ON_ERROR);
131		if (preg_match('/(?:^|[^=])======([^=\n]+)======(?:$|[^=])/',$contents,$matches))
132			$data['head'] = trim($matches[1]);
133		self::_set_page_data(self::_subpath($id),$data);
134		self::_touch_sidebar();
135	}
136
137	protected static function _str_compare(string $a, string $b, bool $use_locale = false) : int
138	{
139		if (self::$collator === null)
140			return strcmp($a,$b) * (($a === '')?-1:1) * (($b === '')?-1:1);
141		else
142			return self::$collator->compare($a,$b);
143	}
144
145	protected static function _browse(string $subpath = '', string $sort_criteria = '') : array
146	{
147		global $conf;
148
149		$tree = [];
150		$times = [];
151		$path = $conf['datadir'].$subpath;
152		$list = scandir($path);
153
154		foreach ($list as $name)
155		{
156			if (($name === '.') || ($name === '..'))
157				continue;
158			$filepath = $path.'/'.$name;
159			[$is_page,$id,$data] = self::_get_page_data($subpath,$name);
160			if (is_dir($filepath))
161				$tree[] = [$id,$data,self::_browse($subpath.'/'.$name,$data['_sort'] ?? $sort_criteria)];
162			elseif ($is_page)
163			{
164				$short = substr($name,0,strrpos($name,'.'));
165				if (($id === $conf['start']) || is_dir($path.'/'.$short) || ($short === basename($path)))
166					continue;
167				$tree[] = [$id,$data];
168			}
169			$times[$id] = filemtime($filepath);
170		}
171		if ($sort_criteria === '')
172			$sort_criteria = self::$options['sort'] ?? 'type_asc,id_asc';
173		$sort = function ($a,$b) use (&$times,$sort_criteria) {
174			$orders = explode(',',$sort_criteria);
175			foreach ($orders as $order)
176			{
177				if (! preg_match('/^([^_]+)_(asc|desc)$/',$order,$matches))
178					continue;
179				[,$type,$dir] = $matches;
180				if ($type === 'type')
181					$diff = count($b) - count($a); // Count: 2 file, 3 folder. Folder come before file
182				elseif ($type === 'id')
183					$diff = self::_str_compare($a[0],$b[0]);
184				elseif ($type === 'date')
185					$diff = $times[$a[0]] - $times[$b[0]];
186				elseif ($type === 'label')
187					$diff = self::_str_compare($a[1]['label'] ?? ($a[1]['head'] ?? ''),$b[1]['label'] ?? ($b[1]['head'] ?? ''));
188				elseif (is_int($a[1][$key = '_oc_'.$type] ?? '') || is_int($b[1][$key] ?? ''))
189					$diff = ($a[1][$key] ?? ($b[1][$key] + 1)) - ($b[1][$key] ?? ($a[1][$key] + 1));
190				else
191					$diff = self::_str_compare($a[1]['_oc_'.$type] ?? '',$b[1]['_oc_'.$type] ?? '');
192				if ($diff !== 0)
193					return ($dir === 'asc')?$diff:-$diff;
194			}
195			return self::_str_compare($a[0],$b[0]);
196		};
197		usort($tree,$sort);
198		return $tree;
199	}
200
201	/* browse the wiki hierarchy to get data needed for Lightmenu tree.
202		$options : string of options after "<lightmenu" tag.
203		return : array with start page data, wiki hierachy tree data and options
204	*/
205	public static function get_data(string $options) : array
206	{
207		global $conf;
208
209		if (extension_loaded('intl'))
210			self::$collator = new Collator($conf['lang']);
211		else
212			self::$collator = null;
213		lightmenu::_options($options);
214		[$is_page,$id,$data] = self::_get_page_data('',$conf['start'].'.txt');
215		return [[$id,$data],self::_browse(),self::$options];
216	}
217
218	protected static function _get_label(string $id,array &$data) : string
219	{
220		global $conf;
221
222		if (isset($data['label.'.$conf['lang']]))
223			return $data['label.'.$conf['lang']];
224		if (isset($data['label']))
225			return $data['label'];
226		if (self::$options['head'] && isset($data['head']))
227			return $data['head'];
228		return $id;
229	}
230
231	protected static function _format_attributes(array &$metas) : string
232	{
233		$html = '';
234
235		foreach ($metas as $name => $value)
236		{
237			if ((strncmp($name,'label',5) === 0) || ($name === 'head') || ($name === 'title') || ($name === 'href') || (strncmp($name,'_',1) === 0))
238				continue;
239			$html .= sprintf(' %s="%s"',$name,$value);
240		}
241
242		return $html;
243	}
244
245	protected static function _get_page(string $prefix, string $id, array &$metas) : string
246	{
247		$label = self::_get_label($id,$metas);
248		$html = '<div class="child">'.PHP_EOL;
249		$html .= sprintf('<span class="label" id="lm-%s%s"><a%s title="%s" href="%s">%s</a></span>'.PHP_EOL,
250			$prefix,$id,self::_format_attributes($metas),isset($metas['title'])?$metas['title']:$label,wl($prefix.$id),trim($label));
251		$html .= '</div>'.PHP_EOL;
252		return $html;
253	}
254
255	protected static function _get_html(array &$data, string $prefix = '', int $level = 0) : string
256	{
257		global $conf;
258
259		$html = '';
260
261		foreach ($data as $values)
262		{
263			$n = count($values);
264			if ($n === 3)
265			{
266				[$id,$metas,$children] = $values;
267				$label = self::_get_label($id,$metas);
268				$html .= '<div class="child">'.PHP_EOL;
269				$html .= sprintf('<input type="checkbox" id="checkbox-%s%s" />',$prefix,$id);
270				$html .= sprintf('<label class="label" id="lm-%s%s" for="checkbox-%s%s"><a%s title="%s" href="%s">%s</a></label>'.PHP_EOL,$prefix,$id,$prefix,$id,
271				self::_format_attributes($metas),isset($metas['title'])?$metas['title']:$label,wl($prefix.$id.':'),trim($label));
272				if (count($children) > 0)
273					$html .= '<div class="tree">'.PHP_EOL.self::_get_html($children,$prefix.$id.':').'</div>'.PHP_EOL;
274				$html .= '</div>'.PHP_EOL;
275			}
276			elseif ($n === 2)
277			{
278				[$id,$metas] = $values;
279				if (($prefix === '') && ($id === $conf['sidebar']))
280					continue;
281				$html .= self::_get_page($prefix,$id,$metas);
282			}
283			else
284				throw new Exception('lightmenu error : wrong entry data count');
285		}
286
287		return $html;
288	}
289
290	public static function render(array &$data) : string
291	{
292		self::$options = $data[2];
293		$html = '<div class="lm">';
294		$html .= self::_get_page('',$data[0][0],$data[0][1]);
295		$html .= self::_get_html($data[1]);
296		$html .= '</div>';
297
298		return $html;
299	}
300
301	protected static function _rescan(string $subpath = '')
302	{
303		global $conf;
304
305		$path = $conf['datadir'].$subpath;
306		$list = scandir($path);
307		foreach ($list as $name)
308		{
309			if (($name === '.') || ($name === '..'))
310				continue;
311			if (($subpath === '') && ($name === $conf['sidebar'].'.txt'))
312				continue;
313			$filepath = $path.'/'.$name;
314			[$is_page,$id,$data] = self::_get_page_data($subpath,$name);
315			if (is_dir($filepath))
316				self::_rescan($subpath.'/'.$name);
317			elseif ($is_page)
318			{
319				$contents = file_get_contents($filepath);
320				if (preg_match('/(?:^|[^=])======([^=\n]+)======(?:$|[^=])/',$contents,$matches))
321				{
322					$data['head'] = trim($matches[1]);
323					if ((($id === $conf['start']) && ($subpath !== '')) || ($id === basename($path)))
324						self::_set_page_data($subpath,$data);
325					elseif (is_dir($path.'/'.$id))
326						self::_set_page_data($subpath.'/'.$id,$data);
327					else
328						self::_set_page_data($subpath.'/'.$name,$data);
329				}
330			}
331		}
332	}
333
334	public static function rescan()
335	{
336		global $conf;
337
338		self::_rescan();
339		self::_touch_sidebar();
340	}
341}