Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 165 |
|
0.00% |
0 / 18 |
CRAP | |
0.00% |
0 / 1 |
Threads | |
0.00% |
0 / 165 |
|
0.00% |
0 / 18 |
2352 | |
0.00% |
0 / 1 |
createTalkpageIfNeeded | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
loadFromResult | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
where | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
databaseError | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
assertSingularity | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
withRoot | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
withId | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
withSummary | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
articleClause | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
topLevelClause | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
newThreadTitle | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
newSummaryTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
newReplyTitle | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
makeTitleValid | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
stripWikitext | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
stripHTML | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
incrementedTitle | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
synchroniseArticleData | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
132 |
1 | <?php |
2 | |
3 | use MediaWiki\Context\RequestContext; |
4 | use MediaWiki\MediaWikiServices; |
5 | use MediaWiki\Title\MediaWikiTitleCodec; |
6 | use MediaWiki\Title\Title; |
7 | use Wikimedia\Rdbms\DBQueryError; |
8 | |
9 | /** Module of factory methods. */ |
10 | class Threads { |
11 | public const TYPE_NORMAL = 0; |
12 | public const TYPE_MOVED = 1; |
13 | public const TYPE_DELETED = 2; |
14 | public const TYPE_HIDDEN = 4; |
15 | |
16 | public const CHANGE_NEW_THREAD = 0; |
17 | public const CHANGE_REPLY_CREATED = 1; |
18 | public const CHANGE_EDITED_ROOT = 2; |
19 | public const CHANGE_EDITED_SUMMARY = 3; |
20 | public const CHANGE_DELETED = 4; |
21 | public const CHANGE_UNDELETED = 5; |
22 | public const CHANGE_MOVED_TALKPAGE = 6; |
23 | public const CHANGE_SPLIT = 7; |
24 | public const CHANGE_EDITED_SUBJECT = 8; |
25 | public const CHANGE_PARENT_DELETED = 9; |
26 | public const CHANGE_MERGED_FROM = 10; |
27 | public const CHANGE_MERGED_TO = 11; |
28 | public const CHANGE_SPLIT_FROM = 12; |
29 | public const CHANGE_ROOT_BLANKED = 13; |
30 | public const CHANGE_ADJUSTED_SORTKEY = 14; |
31 | public const CHANGE_EDITED_SIGNATURE = 15; |
32 | |
33 | // Possible values of Thread->editedness. |
34 | public const EDITED_NEVER = 0; |
35 | public const EDITED_HAS_REPLY = 1; |
36 | public const EDITED_BY_AUTHOR = 2; |
37 | public const EDITED_BY_OTHERS = 3; |
38 | |
39 | /** @var Thread[] */ |
40 | public static $cache_by_root = []; |
41 | /** @var Thread[] */ |
42 | public static $cache_by_id = []; |
43 | /** @var string[] */ |
44 | public static $occupied_titles = []; |
45 | |
46 | /** |
47 | * Create the talkpage if it doesn't exist so that links to it |
48 | * will show up blue instead of red. For use upon new thread creation. |
49 | * |
50 | * @param WikiPage $talkpage |
51 | */ |
52 | public static function createTalkpageIfNeeded( WikiPage $talkpage ) { |
53 | if ( !$talkpage->exists() ) { |
54 | try { |
55 | // TODO figure out injecting the context user instead of |
56 | // using RequestContext::getMain() |
57 | $user = RequestContext::getMain()->getUser(); |
58 | $talkpage->doUserEditContent( |
59 | ContentHandler::makeContent( "", $talkpage->getTitle() ), |
60 | $user, |
61 | wfMessage( 'lqt_talkpage_autocreate_summary' )->inContentLanguage()->text(), |
62 | EDIT_NEW | EDIT_SUPPRESS_RC |
63 | ); |
64 | } catch ( DBQueryError $e ) { |
65 | // The page already existed by now. No need to do anything. |
66 | wfDebug( __METHOD__ . ": Page already exists." ); |
67 | } |
68 | } |
69 | } |
70 | |
71 | public static function loadFromResult( $res, $db, $bulkLoad = false ) { |
72 | $rows = []; |
73 | $threads = []; |
74 | |
75 | foreach ( $res as $row ) { |
76 | $rows[] = $row; |
77 | |
78 | if ( !$bulkLoad ) { |
79 | $threads[$row->thread_id] = Thread::newFromRow( $row ); |
80 | } |
81 | } |
82 | |
83 | if ( !$bulkLoad ) { |
84 | return $threads; |
85 | } |
86 | |
87 | return Thread::bulkLoad( $rows ); |
88 | } |
89 | |
90 | /** |
91 | * @param array $where |
92 | * @param array $options |
93 | * @param bool $bulkLoad |
94 | * @return Thread[] |
95 | */ |
96 | public static function where( $where, $options = [], $bulkLoad = true ) { |
97 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
98 | |
99 | $res = $dbr->newSelectQueryBuilder() |
100 | ->select( '*' ) |
101 | ->from( 'thread' ) |
102 | ->where( $where ) |
103 | ->caller( __METHOD__ ) |
104 | ->options( $options ) |
105 | ->fetchResultSet(); |
106 | $threads = self::loadFromResult( $res, $dbr, $bulkLoad ); |
107 | |
108 | foreach ( $threads as $thread ) { |
109 | if ( $thread->root() ) { |
110 | self::$cache_by_root[$thread->root()->getPage()->getId()] = $thread; |
111 | } |
112 | self::$cache_by_id[$thread->id()] = $thread; |
113 | } |
114 | |
115 | return $threads; |
116 | } |
117 | |
118 | /** |
119 | * @param string $msg |
120 | * @return never |
121 | */ |
122 | private static function databaseError( $msg ) { |
123 | // @todo Tie into MW's error reporting facilities. |
124 | throw new RuntimeException( "Corrupt LiquidThreads database: $msg" ); |
125 | } |
126 | |
127 | private static function assertSingularity( array $threads, $attribute, $value ) { |
128 | if ( count( $threads ) == 0 ) { |
129 | return null; |
130 | } |
131 | |
132 | if ( count( $threads ) == 1 ) { |
133 | return array_pop( $threads ); |
134 | } |
135 | |
136 | if ( count( $threads ) > 1 ) { |
137 | self::databaseError( "More than one thread with $attribute = $value." ); |
138 | } |
139 | |
140 | return null; |
141 | } |
142 | |
143 | /** |
144 | * @param WikiPage $post |
145 | * @param bool $bulkLoad |
146 | * @return Thread|null |
147 | */ |
148 | public static function withRoot( WikiPage $post, $bulkLoad = true ) { |
149 | if ( $post->getTitle()->getNamespace() != NS_LQT_THREAD ) { |
150 | // No articles outside the thread namespace have threads associated with them; |
151 | return null; |
152 | } |
153 | |
154 | if ( !$post->getId() ) { |
155 | // Page ID zero doesn't exist. |
156 | return null; |
157 | } |
158 | |
159 | if ( array_key_exists( $post->getId(), self::$cache_by_root ) ) { |
160 | return self::$cache_by_root[$post->getId()]; |
161 | } |
162 | |
163 | $ts = self::where( [ 'thread_root' => $post->getId() ], [], $bulkLoad ); |
164 | |
165 | return self::assertSingularity( $ts, 'thread_root', $post->getId() ); |
166 | } |
167 | |
168 | /** |
169 | * @param int $id |
170 | * @param bool $bulkLoad |
171 | * @return Thread |
172 | */ |
173 | public static function withId( $id, $bulkLoad = true ) { |
174 | if ( array_key_exists( $id, self::$cache_by_id ) ) { |
175 | return self::$cache_by_id[$id]; |
176 | } |
177 | |
178 | $ts = self::where( [ 'thread_id' => $id ], [], $bulkLoad ); |
179 | |
180 | return self::assertSingularity( $ts, 'thread_id', $id ); |
181 | } |
182 | |
183 | /** |
184 | * @param WikiPage $page |
185 | * @param bool $bulkLoad |
186 | * @return Thread |
187 | */ |
188 | public static function withSummary( WikiPage $page, $bulkLoad = true ) { |
189 | $ts = self::where( |
190 | [ 'thread_summary_page' => $page->getId() ], |
191 | [], |
192 | $bulkLoad |
193 | ); |
194 | return self::assertSingularity( |
195 | $ts, |
196 | 'thread_summary_page', |
197 | $page->getId() |
198 | ); |
199 | } |
200 | |
201 | /** |
202 | * @param WikiPage $page |
203 | * @return string |
204 | */ |
205 | public static function articleClause( WikiPage $page ) { |
206 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
207 | |
208 | $titleCond = [ 'thread_article_title' => $page->getTitle()->getDBKey(), |
209 | 'thread_article_namespace' => $page->getTitle()->getNamespace() ]; |
210 | $titleCond = $dbr->makeList( $titleCond, LIST_AND ); |
211 | |
212 | $conds = [ $titleCond ]; |
213 | |
214 | if ( $page->getId() ) { |
215 | $idCond = [ 'thread_article_id' => $page->getId() ]; |
216 | $conds[] = $dbr->makeList( $idCond, LIST_AND ); |
217 | } |
218 | |
219 | return $dbr->makeList( $conds, LIST_OR ); |
220 | } |
221 | |
222 | public static function topLevelClause() { |
223 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
224 | |
225 | $arr = [ 'thread_ancestor=thread_id', 'thread_parent' => null ]; |
226 | |
227 | return $dbr->makeList( $arr, LIST_OR ); |
228 | } |
229 | |
230 | public static function newThreadTitle( $subject, $article ) { |
231 | $base = $article->getTitle()->getPrefixedText() . "/$subject"; |
232 | |
233 | return self::incrementedTitle( $base, NS_LQT_THREAD ); |
234 | } |
235 | |
236 | public static function newSummaryTitle( Thread $t ) { |
237 | return self::incrementedTitle( $t->title()->getText(), NS_LQT_SUMMARY ); |
238 | } |
239 | |
240 | public static function newReplyTitle( Thread $thread, $user ) { |
241 | $topThread = $thread->topmostThread(); |
242 | |
243 | $base = $topThread->title()->getText() . '/' |
244 | . wfMessage( 'lqt-reply-subpage' )->inContentLanguage()->text(); |
245 | |
246 | return self::incrementedTitle( $base, NS_LQT_THREAD ); |
247 | } |
248 | |
249 | /** |
250 | * This will attempt to replace invalid characters and sequences in a title with a safe |
251 | * replacement (_, currently). Before doing this, it will parse any wikitext and strip the HTML, |
252 | * before converting HTML entities back into their corresponding characters. |
253 | * |
254 | * @param string $text |
255 | * @return string |
256 | */ |
257 | public static function makeTitleValid( $text ) { |
258 | $text = self::stripWikitext( $text ); |
259 | $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ); |
260 | |
261 | $rxTc = MediaWikiTitleCodec::getTitleInvalidRegex(); |
262 | |
263 | $text = preg_replace( $rxTc, '_', $text ); |
264 | |
265 | return $text; |
266 | } |
267 | |
268 | /** |
269 | * This will strip wikitext of its formatting. |
270 | * |
271 | * @param string $text |
272 | * @return string |
273 | */ |
274 | public static function stripWikitext( $text ) { |
275 | $out = RequestContext::getMain()->getOutput(); |
276 | # The $text may not actually be in the interface language, but we |
277 | # don't want to subject it to language conversion, so |
278 | # parseAsInterface() is better than parseAsContent() |
279 | $text = $out->parseInlineAsInterface( $text ); |
280 | |
281 | $text = StringUtils::delimiterReplace( '<', '>', '', $text ); |
282 | |
283 | return $text; |
284 | } |
285 | |
286 | public static function stripHTML( $text ) { |
287 | return StringUtils::delimiterReplace( '<', '>', '', $text ); |
288 | } |
289 | |
290 | /** |
291 | * Keep trying titles starting with $basename until one is unoccupied. |
292 | * @param string $basename |
293 | * @param int $namespace |
294 | * @return Title |
295 | */ |
296 | public static function incrementedTitle( $basename, $namespace ) { |
297 | $i = 2; |
298 | |
299 | // Try to make the title valid. |
300 | $basename = self::makeTitleValid( $basename ); |
301 | |
302 | $t = Title::makeTitleSafe( $namespace, $basename ); |
303 | $contLang = MediaWikiServices::getInstance()->getContentLanguage(); |
304 | while ( !$t || |
305 | in_array( $t->getPrefixedDBkey(), self::$occupied_titles ) || |
306 | $t->exists() || |
307 | $t->isDeletedQuick() |
308 | ) { |
309 | if ( !$t ) { |
310 | throw new LogicException( "Error in creating title for basename $basename" ); |
311 | } |
312 | |
313 | $n = $contLang->formatNum( $i ); |
314 | $t = Title::makeTitleSafe( $namespace, $basename . ' (' . $n . ')' ); |
315 | $i++; |
316 | } |
317 | // @phan-suppress-next-line PhanTypeMismatchReturnNullable |
318 | return $t; |
319 | } |
320 | |
321 | /** |
322 | * Called just before any function that might cause a loss of article association. |
323 | * by breaking either a NS-title reference (by moving the article), or a page-id |
324 | * reference (by deleting the article). |
325 | * Basically ensures that all subthreads have the two stores of article association |
326 | * synchronised. |
327 | * Can also be called with a "limit" parameter to slowly convert old threads. This |
328 | * is intended to be used by jobs created by move and create operations to slowly |
329 | * propagate the change through the data set without rushing the whole conversion |
330 | * when a second breaking change is made. If a limit is set and more rows require |
331 | * conversion, this function will return false. Otherwise, true will be returned. |
332 | * If the queueMore parameter is set and rows are left to update, a job queue item |
333 | * will then be added with the same limit, to finish the remainder of the update. |
334 | * |
335 | * @param WikiPage $page |
336 | * @param int|false $limit |
337 | * @param string|false $queueMore |
338 | * @return bool |
339 | */ |
340 | public static function synchroniseArticleData( WikiPage $page, $limit = false, $queueMore = false ) { |
341 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
342 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
343 | |
344 | $title = $page->getTitle(); |
345 | $id = $page->getId(); |
346 | |
347 | $titleCond = [ 'thread_article_namespace' => $title->getNamespace(), |
348 | 'thread_article_title' => $title->getDBkey() ]; |
349 | $titleCondText = $dbr->makeList( $titleCond, LIST_AND ); |
350 | |
351 | $idCond = [ 'thread_article_id' => $id ]; |
352 | $idCondText = $dbr->makeList( $idCond, LIST_AND ); |
353 | |
354 | $fixTitleCond = [ $idCondText, "NOT ($titleCondText)" ]; |
355 | $fixIdCond = [ $titleCondText, "NOT ($idCondText)" ]; |
356 | |
357 | // Try to hit the most recent threads first. |
358 | $options = [ 'LIMIT' => 500, 'ORDER BY' => 'thread_id DESC' ]; |
359 | |
360 | // Batch in 500s |
361 | if ( $limit ) { |
362 | $options['LIMIT'] = min( $limit, 500 ); |
363 | } |
364 | |
365 | $rowsAffected = 0; |
366 | $roundRowsAffected = 1; |
367 | |
368 | while ( ( !$limit || $rowsAffected < $limit ) && $roundRowsAffected > 0 ) { |
369 | $roundRowsAffected = 0; |
370 | |
371 | // Fix wrong title. |
372 | $fixTitleCount = $dbr->newSelectQueryBuilder() |
373 | ->select( 'COUNT(*)' ) |
374 | ->from( 'thread' ) |
375 | ->where( $fixTitleCond ) |
376 | ->caller( __METHOD__ ) |
377 | ->fetchField(); |
378 | if ( intval( $fixTitleCount ) ) { |
379 | $dbw->newUpdateQueryBuilder() |
380 | ->update( 'thread' ) |
381 | ->set( $titleCond ) |
382 | ->where( $fixTitleCond ) |
383 | ->options( $options ) |
384 | ->caller( __METHOD__ ) |
385 | ->execute(); |
386 | $roundRowsAffected += $dbw->affectedRows(); |
387 | } |
388 | |
389 | // Fix wrong ID |
390 | $fixIdCount = $dbr->newSelectQueryBuilder() |
391 | ->select( 'COUNT(*)' ) |
392 | ->from( 'thread' ) |
393 | ->where( $fixIdCond ) |
394 | ->caller( __METHOD__ ) |
395 | ->fetchField(); |
396 | if ( intval( $fixIdCount ) ) { |
397 | $dbw->newUpdateQueryBuilder() |
398 | ->update( 'thread' ) |
399 | ->set( $idCond ) |
400 | ->where( $fixIdCond ) |
401 | ->options( $options ) |
402 | ->caller( __METHOD__ ) |
403 | ->execute(); |
404 | $roundRowsAffected += $dbw->affectedRows(); |
405 | } |
406 | |
407 | $rowsAffected += $roundRowsAffected; |
408 | } |
409 | |
410 | if ( $limit && ( $rowsAffected >= $limit ) && $queueMore ) { |
411 | $jobParams = [ 'limit' => $limit, 'cascade' => true ]; |
412 | MediaWikiServices::getInstance()->getJobQueueGroup()->push( |
413 | new SynchroniseThreadArticleDataJob( |
414 | $page->getTitle(), |
415 | $jobParams |
416 | ) |
417 | ); |
418 | } |
419 | |
420 | return $limit ? ( $rowsAffected < $limit ) : true; |
421 | } |
422 | } |