Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 165
0.00% covered (danger)
0.00%
0 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Threads
0.00% covered (danger)
0.00%
0 / 165
0.00% covered (danger)
0.00%
0 / 18
2352
0.00% covered (danger)
0.00%
0 / 1
 createTalkpageIfNeeded
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 loadFromResult
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 where
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 databaseError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 assertSingularity
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 withRoot
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 withId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 withSummary
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 articleClause
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 topLevelClause
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 newThreadTitle
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 newSummaryTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newReplyTitle
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeTitleValid
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 stripWikitext
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 stripHTML
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 incrementedTitle
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 synchroniseArticleData
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3use MediaWiki\Context\RequestContext;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Title\MediaWikiTitleCodec;
6use MediaWiki\Title\Title;
7use Wikimedia\Rdbms\DBQueryError;
8
9/** Module of factory methods. */
10class 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}