page: rename page method to file
[minimedit.git] / article.inc.php
1 <?php
2 class ArchiveArticle
3 {
4         public $raw, $title, $body; # file contents
5         public $meta = [];  # head metadata properties
6         public $place = []; # template variables replaced in render()
7
8         function __construct($path)
9         {
10                 $this->file = preg_replace('{^\.(?:/|$)}', '', $path);
11                 $this->link = preg_replace('{(?:(?:/|^)index)?\.html$}', '', $this->file);
12                 $this->raw($this->file);
13         }
14
15         function raw($page)
16         {
17                 if (!file_exists($page)) {
18                         return;
19                 }
20                 $this->raw = file_get_contents($page);
21
22                 if (preg_match_all('{
23                         \G <meta \s+ property="( [^"]+ )" \s+ content="( [^"]* )" > \s*
24                 }x', $this->raw, $meta)) {
25                         $matchlen = array_sum(array_map('strlen', $meta[0]));
26                         $this->raw = substr($this->raw, $matchlen); # delete matched contents
27                         $this->meta = array_combine($meta[1], $meta[2]); # [property => content]
28                 }
29
30                 // find significant contents
31                 $this->body = preg_replace('{<aside\b.*?</aside>}s', '', $this->raw);
32                 if (preg_match('{<h2>(.*?)</h2>\s*(.*)}s', $this->body, $titlematch)) {
33                         list (, $this->title, $this->body) = $titlematch;
34                 }
35
36                 return $this->raw;
37         }
38
39         function __get($col)
40         {
41                 return $this->$col = $this->$col();  # run method and cache
42         }
43
44         function handler()
45         {
46                 $path = $this->link;
47                 $this->path = '';
48                 $this->restricted = FALSE;
49                 while (TRUE) {
50                         if (file_exists("$path/.private")) {
51                                 $this->restricted = $path;
52                         }
53
54                         if (file_exists("$path/index.php")) {
55                                 return $path;
56                         }
57
58                         $up = strrpos($path, '/');
59                         $this->path = substr($path, $up) . $this->path;
60                         $path = substr($path, 0, $up);
61                         if ($up === FALSE) {
62                                 break;
63                         }
64                 }
65                 return;
66         }
67
68         function index()
69         {
70                 $this->handler;
71                 if (empty($this->handler)) {
72                         return;
73                 }
74                 $User = NULL;
75                 $Page = $this;
76                 $res = include "./{$this->handler}/index.php";
77                 return $res;
78         }
79
80         function restricted()
81         {
82                 $this->handler;
83                 return $this->restricted;
84         }
85
86         function safetitle()
87         {
88                 return trim($this->meta['og:title'] ?? strip_tags($this->title));
89         }
90         function name()
91         {
92                 return $this->safetitle ?: htmlspecialchars($this->link);
93         }
94
95         function last()
96         {
97                 return filemtime($this->file);
98         }
99         function lastiso()
100         {
101                 return date(DATE_ATOM, $this->last);
102         }
103
104         function dateparts()
105         {
106                 preg_match('< / (\d{4}) [/-] (\d{2}) (?:- (\d{2}) )? - >x', $this->file, $ymd);
107                 array_shift($ymd);
108                 return $ymd;
109         }
110         function dateiso()
111         {
112                 return implode('-', $this->dateparts()) . 'T12:00:00+02:00';
113         }
114
115         function story()
116         {
117                 if ( preg_match('{
118                         (?: < (?: p | figure [^>]* ) >\s* )+ (<img\ [^>]*>) | \n <hr\ />
119                 }x', $this->body, $img, PREG_OFFSET_CAPTURE) ) {
120                         # strip part after matching divider (image)
121                         if (isset($img[1])) {
122                                 $this->img = $img[1][0];
123                         }
124                         return substr($this->body, 0, $img[0][1]);
125                 }
126                 return $this->body;
127         }
128
129         function teaser()
130         {
131                 if ($override = @$this->meta['og:description']) {
132                         # prefer specific page description if found in metadata
133                         return $override;
134                 }
135
136                 # paragraph contents following the page header if any
137                 if (preg_match('{
138                         \G (?> \s+ | <div [^>]*> | \[\[[^]]*\]\] )* <p> \s* (.*?) </p>
139                 }sx', $this->body, $bodyp, 0)) {
140                         return $bodyp[1];
141                 }
142         }
143
144         function img()
145         {
146                 $this->img = NULL;
147                 $this->story;
148                 return $this->img;
149         }
150         function image()
151         {
152                 if ($override = @$this->meta['og:image']) {
153                         # prefer specific page image if found in metadata
154                         return $override;
155                 }
156
157                 if ( preg_match('/\bsrc="([^"]*)"/', $this->img, $src) ) {
158                         return $src[1];
159                 }
160         }
161         function thumb($size = '300x')
162         {
163                 if (!$this->image or $this->image[0] !== '/') return;
164                 if (preg_match('{^/thumb/\D}', $this->image)) {
165                         return ltrim($this->image, '/');
166                 }
167                 return preg_replace(
168                         ['{^(?:/thumb/[^/]*)?}', '/\.groot(?=\.\w+$)/'], ["thumb/$size", ''],
169                         $this->image
170                 );
171         }
172
173         function widget($name, $params = [])
174         {
175                 $path = stream_resolve_include_path("widget/$name.php");
176                 if (!file_exists($path)) {
177                         return '<strong class="warn"><em>'.$name.'</em> ontbreekt</strong>';
178                 }
179
180                 ob_start();
181                 $Page = clone $this;
182                 if (is_array($params)) {
183                         $Page->place += $params;
184                 }
185                 else {
186                         foreach (explode(' ', $params) as $param) {
187                                 if ($set = strpos($param, '=')) {
188                                         $Page->place[ substr($param, 0, $set) ] = substr($param, $set + 1);
189                                 }
190                                 elseif (!empty($param)) {
191                                         $Page->place[] = $param;
192                                 }
193                         }
194                 }
195
196                 try {
197                         include "widget/$name.php";
198                         return ob_get_clean();
199                 }
200                 catch (Exception $e) {
201                         return sprintf('<strong class="warn">%s</strong>',
202                                 "fout in <em>$name</em>: {$e->getMessage()}"
203                         );
204                 }
205         }
206
207         function render()
208         {
209                 $doc = $this->raw;
210
211                 if (!empty($this->place['warn'])) {
212                         $warn = '<p class="warn">[[warn]]</p>';
213                         if ($offset = strpos($doc, '</h2>')) {
214                                 $doc = substr_replace($doc, "\n\n".$warn, $offset + 5, 0);
215                         }
216                         else {
217                                 $doc = $warn . "\n\n" . $doc;
218                         }
219                 }
220
221                 # keep either login or logout parts depending on user level
222                 global $User;
223                 $userexists = $User && property_exists($User, 'login') && $User->login;
224                 if (! ($userexists and $User->admin("edit {$this->link}")) ) {
225                         # remove matching elements until first corresponding closing tag
226                         $hideclass = $userexists ? 'logout' : 'login';
227                         $tagmatch = '<([a-z]+) class="'.$hideclass.'"[^>]*>';
228                         $doc = preg_replace("{\s*{$tagmatch}.*?</\\1>}s", '', $doc);
229                 }
230
231                 return preg_replace_callback(
232                         '{ \[\[ ([^] ]+) ([^]]*) \]\] }x',
233                         function ($sub) {
234                                 list ($placeholder, $name, $params) = $sub;
235                                 $html = $this->place[$name] ??
236                                         $this->widget($name, $params);
237                                 if (empty($html) or $html[0] != '<') {
238                                         $html = "<span>$html</span>";
239                                 }
240                                 $attr = sprintf(' data-dyn="%s"', is_numeric($name) ? '' : $name.$params);
241                                 # contents with identifier in first tag
242                                 return preg_replace( '/(?=>)/', $attr, $html, 1);
243                         },
244                         $doc
245                 );
246         }
247 }
248
249 class PageSearch
250 {
251         public $handlers = [];
252
253         function __construct($path = '.')
254         {
255                 $this->iterator = new RecursiveCallbackFilterIterator(
256                         new RecursiveDirectoryIterator($path),
257                         function ($current) {
258                                 if ($current->getFilename()[0] === '.') {
259                                         # skip hidden files and directories
260                                         return FALSE;
261                                 }
262                                 if (file_exists($current->getFilename() . '/index.php')) {
263                                         # contents better provided by handler code
264                                         $this->handlers[ $current->getPathname() ] = $current;
265                                         return FALSE;
266                                 }
267                                 if ($current->isLink()) {
268                                         # ignore symlinks, original contents only
269                                         return FALSE;
270                                 }
271                                 if ($current->isDir()) {
272                                         # traverse subdirectories unless untracked in any amount
273                                         return !file_exists("$current/.gitignore");
274                                 }
275                                 # match **/*.html
276                                 return preg_match('/(?<!\.inc)\.html$/', $current->getFilename());
277                         }
278                 );
279         }
280
281         function files()
282         {
283                 # order alphabetically by link
284                 $dir = [];
285                 foreach (new RecursiveIteratorIterator($this->iterator) as $name) {
286                         $article = new ArchiveArticle($name);
287                         $dir[$article->link] = $article;
288                 }
289                 ksort($dir);
290                 return $dir;
291         }
292 }