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\Console\Commands;
7
8use App\Libraries\Elasticsearch\Es;
9use App\Libraries\Elasticsearch\Indexing;
10use App\Libraries\Elasticsearch\Search;
11use App\Libraries\Elasticsearch\Sort;
12use App\Libraries\OsuWiki;
13use App\Libraries\Search\BasicSearch;
14use App\Libraries\Wiki\WikiSitemap;
15use App\Models\Wiki\Page;
16use Illuminate\Console\Command;
17
18class EsIndexWiki extends Command
19{
20 /**
21 * The name and signature of the console command.
22 *
23 * @var string
24 */
25 protected $signature = 'es:index-wiki {--create-only} {--inplace} {--cleanup}';
26
27 /**
28 * The console command description.
29 *
30 * @var string
31 */
32 protected $description = 'Re-indexes wiki pages';
33
34 private $cleanup;
35 private $createOnly;
36 private $indexName;
37 private $indicesToRemove;
38 private $inplace;
39
40 public function handle()
41 {
42 $this->readOptions();
43
44 $alias = Page::esIndexName();
45 $oldIndices = Indexing::getOldIndices($alias);
46
47 if (!$this->inplace || empty($oldIndices)) {
48 $this->indexName = Page::esTimestampedIndexName();
49 } else {
50 $this->indexName = $oldIndices[0];
51 }
52
53 $this->indicesToRemove = array_filter($oldIndices, function ($index) {
54 // because removing the index we just wrote to would be silly.
55 return $this->indexName !== $index;
56 });
57
58 $continue = $this->starterMessage();
59 if (!$continue) {
60 $this->error('User aborted!');
61 return;
62 }
63
64 if (!Es::getClient()->indices()->exists(['index' => [$this->indexName]])) {
65 $this->info("Creating '{$this->indexName}'...");
66 Page::esCreateIndex($this->indexName);
67 }
68
69 $this->reindex();
70
71 Indexing::updateAlias($alias, $this->indexName);
72
73 $this->updateSitemap();
74
75 $this->finish();
76 }
77
78 private function finish()
79 {
80 if (!$this->cleanup) {
81 return;
82 }
83
84 foreach ($this->indicesToRemove as $index) {
85 $this->warn("Removing '{$index}'...");
86 Indexing::deleteIndex($index);
87 }
88 }
89
90 private function newBaseSearch(): Search
91 {
92 return (new BasicSearch($this->indexName))
93 ->query(['match_all' => new \stdClass()])
94 ->sort([new Sort('path.keyword', 'asc'), new Sort('locale.keyword', 'asc')])
95 ->source(false);
96 }
97
98 private function readOptions()
99 {
100 $this->createOnly = $this->option('create-only');
101 $this->inplace = $this->option('inplace');
102 $this->cleanup = $this->option('cleanup');
103 }
104
105 private function reindex()
106 {
107 if ($this->createOnly) {
108 return;
109 }
110 // for storing the paths as keys; the values don't matter in practise.
111 $paths = [];
112
113 $this->line('Fetching page list from Github...');
114 OsuWiki::getPageList()->each(function ($path) use (&$paths) {
115 $path = str_replace_first('wiki/', '', $path);
116 $paths[$path] = false;
117 });
118
119 $this->line(count($paths).' pages found');
120
121 if ($this->inplace) {
122 $this->line('Fetching existing list...');
123 $cursor = ['', '']; // number of params for initial cursor must match number of sorts used.
124 while ($cursor !== null) {
125 $search = $this->newBaseSearch()->searchAfter(array_values($cursor));
126 $response = $search->response();
127
128 foreach ($response as $hit) {
129 $paths[$hit['_id']] = true;
130 }
131
132 $cursor = $search->getSortCursor();
133 }
134 }
135
136 $total = count($paths);
137 $this->line("Total: {$total} documents");
138 $bar = $this->output->createProgressBar($total);
139
140 foreach ($paths as $path => $_inEs) {
141 $pagePath = Page::parsePagePath($path);
142 $page = new Page($pagePath['path'], $pagePath['locale']);
143 $page->sync(true, $this->indexName);
144
145 if (!$page->isVisible()) {
146 $this->warn("delete {$pagePath['locale']}: {$pagePath['path']}");
147 $page->esDeleteDocument(['index' => $this->indexName]);
148 }
149
150 $bar->advance();
151 }
152
153 $bar->finish();
154 $this->line('');
155 }
156
157 private function starterMessage()
158 {
159 if ($this->cleanup) {
160 $this->warn(
161 "The following indices will be deleted on completion!\n"
162 .implode("\n", $this->indicesToRemove)
163 );
164 }
165
166 return $this->option('no-interaction') || $this->confirm("This index to {$this->indexName}, begin indexing?", true);
167 }
168
169 private function updateSitemap()
170 {
171 $this->line('Updating wiki sitemap...');
172 WikiSitemap::expire();
173 WikiSitemap::get();
174 }
175}