1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Models\Wiki;
7
8use App\Exceptions\GitHubNotFoundException;
9use App\Libraries\Elasticsearch\BoolQuery;
10use App\Libraries\Elasticsearch\Es;
11use App\Libraries\Elasticsearch\QueryHelper;
12use App\Libraries\Elasticsearch\Sort;
13use App\Libraries\LocaleMeta;
14use App\Libraries\Markdown\OsuMarkdown;
15use App\Libraries\OsuWiki;
16use App\Libraries\Search\BasicSearch;
17use App\Libraries\Wiki\MainPageRenderer;
18use App\Libraries\Wiki\MarkdownRenderer;
19use App\Models\Traits;
20use App\Traits\Memoizes;
21use Carbon\Carbon;
22use Ds\Set;
23use Exception;
24use Log;
25
26class Page implements WikiObject
27{
28 use Memoizes, Traits\Es\WikiPageSearch;
29
30 const CACHE_DURATION = 5 * 60 * 60;
31 const VERSION = 9;
32
33 const TEMPLATES = [
34 'markdown_page' => 'wiki.show',
35 'main_page' => 'wiki.main',
36 ];
37
38 const RENDERERS = [
39 'markdown_page' => MarkdownRenderer::class,
40 'main_page' => MainPageRenderer::class,
41 ];
42
43 public $locale;
44 public $path;
45 public $requestedLocale;
46
47 private $defaultSubtitle;
48 private $defaultTitle;
49 private $source;
50 private $page;
51 private $parent = false;
52
53 public static function cleanupPath($path)
54 {
55 return strtolower(str_replace(['-', '/', '_'], ' ', $path));
56 }
57
58 public static function fromEs($hit)
59 {
60 $source = $hit->source();
61 $path = $source['path'];
62 $locale = $source['locale'];
63
64 if ($path === null || $locale === null) {
65 $pagePath = static::parsePagePath($hit['_id']);
66
67 $path = $pagePath['path'];
68 $locale = $pagePath['locale'];
69 }
70
71 $page = new static($path, $locale);
72 $page->setSource($source);
73
74 return $page;
75 }
76
77 public static function lookup($path, $locale, $requestedLocale = null)
78 {
79 $page = new static($path, $locale, $requestedLocale);
80 $page->esFetch();
81
82 return $page;
83 }
84
85 public static function lookupForController($path, $locale)
86 {
87 $page = static::lookup($path, $locale)->sync();
88
89 if (!$page->isVisible() && $page->isTranslation()) {
90 $page = static::lookup($path, $GLOBALS['cfg']['app']['fallback_locale'], $locale)->sync();
91 }
92
93 return $page;
94 }
95
96 public static function parsePagePath($pagePath)
97 {
98 $matches = null;
99 preg_match('#^(?<path>.+)/(?<locale>[^/]+)\.md$#', $pagePath, $matches);
100
101 return [
102 'path' => $matches['path'] ?? null,
103 'locale' => $matches['locale'] ?? null,
104 ];
105 }
106
107 public static function searchPath($path, $locale)
108 {
109 $searchPath = static::cleanupPath($path);
110
111 $currentLocaleQuery =
112 ['constant_score' => [
113 'boost' => 1000,
114 'filter' => [
115 'match' => [
116 'locale' => $locale ?? app()->getLocale(),
117 ],
118 ],
119 ]];
120
121 $fallbackLocaleQuery =
122 ['constant_score' => [
123 'filter' => [
124 'match' => [
125 'locale' => $GLOBALS['cfg']['app']['fallback_locale'],
126 ],
127 ],
128 ]];
129
130 $query = (new BoolQuery())
131 ->must(QueryHelper::queryString($searchPath, ['path_clean'], 'and'))
132 ->must(['exists' => ['field' => 'page']])
133 ->should($currentLocaleQuery)
134 ->should($fallbackLocaleQuery)
135 ->shouldMatch(1);
136
137 $search = (new BasicSearch(static::esIndexName(), 'wiki_searchpath'))
138 ->source('path')
139 ->query($query);
140
141 $response = $search->response();
142
143 if ($response->total() === 0) {
144 return;
145 }
146
147 foreach ($response as $hit) {
148 $resultPath = static::cleanupPath($hit->source('path'));
149
150 if ($resultPath === $searchPath) {
151 return $hit->source('path');
152 }
153 }
154 }
155
156 public function __construct($path, $locale, $requestedLocale = null)
157 {
158 $this->path = OsuWiki::cleanPath($path);
159 $this->locale = $locale;
160 $this->requestedLocale = $requestedLocale ?? $locale;
161
162 $defaultTitles = explode('/', str_replace('_', ' ', $this->path));
163 $this->defaultTitle = array_pop($defaultTitles);
164 $this->defaultSubtitle = array_pop($defaultTitles);
165 }
166
167 public function availableLocales(): Set
168 {
169 return $this->memoize(__FUNCTION__, function () {
170 $locales = new Set();
171
172 if (!$this->isVisible()) {
173 return $locales;
174 }
175
176 $query = (new BoolQuery())
177 ->must(['term' => ['path.keyword' => $this->path]])
178 ->must(['exists' => ['field' => 'page']]);
179 $search = (new BasicSearch(static::esIndexName(), 'wiki_searchlocales'))
180 ->source('locale')
181 ->sort(new Sort('locale.keyword', 'asc'))
182 ->query($query);
183 $response = $search->response();
184
185 foreach ($response->hits() as $hit) {
186 $locale = $hit['_source']['locale'] ?? null;
187 if (LocaleMeta::isValid($locale)) {
188 $locales[] = $locale;
189 }
190 }
191 $locales->sort();
192
193 return $locales;
194 });
195 }
196
197 public function editUrl()
198 {
199 return 'https://github.com/'.OsuWiki::user().'/'.OsuWiki::repository().'/tree/'.OsuWiki::branch().'/wiki/'.$this->pagePath();
200 }
201
202 public function esDeleteDocument(array $options = [])
203 {
204 $this->log('delete document');
205
206 return Es::getClient()->delete([
207 'client' => ['ignore' => 404],
208 'id' => $this->pagePath(),
209 'index' => $options['index'] ?? static::esIndexName(),
210 ]);
211 }
212
213 public function esIndexDocument(array $options = [])
214 {
215 if ($this->page === null) {
216 $this->log('index document empty');
217 } else {
218 $this->log('index document');
219 }
220
221 return Es::getClient()->index([
222 'body' => $this->source,
223 'id' => $this->pagePath(),
224 'index' => $options['index'] ?? static::esIndexName(),
225 ]);
226 }
227
228 public function esFetch()
229 {
230 $response = (new BasicSearch(static::esIndexName(), 'wiki_page_lookup'))
231 ->source(['markdown', 'page', 'page_text', 'indexed_at', 'version'])
232 ->query([
233 'term' => [
234 '_id' => $this->pagePath(),
235 ],
236 ])
237 ->response();
238
239 if ($response->total() > 0) {
240 $this->setSource($response[0]->source());
241 }
242 }
243
244 public function get()
245 {
246 return $this->page;
247 }
248
249 public function getMarkdown()
250 {
251 return $this->source['markdown'] ?? null;
252 }
253
254 public function getTextPreview()
255 {
256 return html_excerpt($this->source['page_text']);
257 }
258
259 public function hasParent()
260 {
261 return $this->parent() !== null;
262 }
263
264 public function needsCleanup(): bool
265 {
266 return $this->page['header']['needs_cleanup'] ?? false;
267 }
268
269 public function parent()
270 {
271 if ($this->parent === false) {
272 $parentPath = $this->parentPath();
273
274 if ($parentPath === null) {
275 $parent = null;
276 } else {
277 $parent = static::lookup($this->parentPath(), $this->requestedLocale);
278
279 if (!$parent->isVisible()) {
280 $parent = null;
281 }
282 }
283
284 $this->parent = $parent;
285 }
286
287 return $this->parent;
288 }
289
290 public function isLegalTranslation(): bool
291 {
292 return $this->isTranslation()
293 && ($this->page['header']['legal'] ?? false);
294 }
295
296 public function isOutdated(): bool
297 {
298 return $this->page['header']['outdated'] ?? false;
299 }
300
301 public function isOutdatedTranslation(): bool
302 {
303 return $this->isTranslation()
304 && ($this->page['header']['outdated_translation'] ?? false);
305 }
306
307 public function isStub(): bool
308 {
309 return $this->page['header']['stub'] ?? false;
310 }
311
312 public function isTranslation(): bool
313 {
314 return $this->locale !== $GLOBALS['cfg']['app']['fallback_locale'];
315 }
316
317 public function isVisible()
318 {
319 return $this->page !== null;
320 }
321
322 public function layout($layout = null)
323 {
324 $layout = presence($layout)
325 ?? presence($this->page['header']['layout'] ?? null)
326 ?? 'markdown_page';
327
328 if (!array_key_exists($layout, static::RENDERERS)) {
329 throw new Exception('Invalid wiki page type');
330 }
331
332 return $layout;
333 }
334
335 public function needsSync()
336 {
337 return $this->source === null
338 || Carbon::parse($this->source['indexed_at'])
339 ->addSeconds(static::CACHE_DURATION)
340 ->isPast()
341 || $this->source['version'] !== static::VERSION;
342 }
343
344 public function pagePath()
345 {
346 return $this->path.'/'.$this->locale.'.md';
347 }
348
349 public function parentPath()
350 {
351 if (($pos = strrpos($this->path, '/')) !== false) {
352 return substr($this->path, 0, $pos);
353 }
354 }
355
356 public function setSource($source)
357 {
358 $this->source = $source;
359 $page = $source['page'] ?? null;
360
361 if ($page !== null) {
362 $this->page = json_decode($source['page'], true);
363 }
364 }
365
366 public function subtitle()
367 {
368 if ($this->page === null) {
369 return;
370 }
371
372 if ($this->parent() !== null) {
373 return $this->parent()->title();
374 }
375
376 return presence($this->page['header']['subtitle'] ?? null) ?? $this->defaultSubtitle;
377 }
378
379 public function sync($force = false, $indexName = null)
380 {
381 if (!$force && !$this->needsSync()) {
382 return $this;
383 }
384
385 try {
386 $this->log('fetch');
387
388 $content = OsuWiki::fetchContent('wiki/'.$this->pagePath());
389 } catch (GitHubNotFoundException $e) {
390 $this->log('not found');
391 } catch (Exception $e) {
392 // log and do nothing
393 log_error($e);
394
395 return $this;
396 }
397
398 $source = [
399 'locale' => $this->locale,
400 'page' => null,
401 'page_text' => null,
402 'path' => $this->path,
403 'path_clean' => static::cleanupPath($this->path),
404 'tags' => [],
405 'title' => null,
406 'indexed_at' => json_time(now()),
407 'version' => static::VERSION,
408 ];
409
410 if (isset($content)) {
411 $layout = OsuMarkdown::parseYamlHeader($content)['header']['layout'] ?? null;
412 $layout = $this->layout($layout);
413 $rendererClass = static::RENDERERS[$layout];
414 $contentRenderer = (new $rendererClass($this, $content));
415
416 $this->page = $contentRenderer->render();
417 $pageIndex = $contentRenderer->renderIndexable();
418
419 $source['markdown'] = $content;
420 $source['page'] = json_encode($this->page);
421 $source['page_text'] = $pageIndex;
422 $source['title'] = strip_tags($this->title());
423 $source['tags'] = $this->tags();
424 $source['layout'] = $layout;
425 }
426
427 $this->source = $source;
428 $this->esIndexDocument(['index' => $indexName]);
429
430 return $this;
431 }
432
433 public function tags()
434 {
435 return $this->page['header']['tags'] ?? [];
436 }
437
438 public function template()
439 {
440 return $this->page === null
441 ? static::TEMPLATES['markdown_page']
442 : static::TEMPLATES[$this->layout()];
443 }
444
445 public function title($withSubtitle = false)
446 {
447 if ($this->page === null) {
448 return osu_trans('wiki.show.missing_title');
449 }
450
451 $title = presence($this->page['header']['title'] ?? null) ?? $this->defaultTitle;
452
453 if ($withSubtitle && present($this->subtitle())) {
454 $title = $this->subtitle().' / '.$title;
455 }
456
457 return $title;
458 }
459
460 private function log($action)
461 {
462 Log::info("wiki ({$action}): {$this->pagePath()}");
463 }
464}