Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 984 |
|
0.00% |
0 / 85 |
CRAP | |
0.00% |
0 / 1 |
Thread | |
0.00% |
0 / 984 |
|
0.00% |
0 / 85 |
75900 | |
0.00% |
0 / 1 |
isHistorical | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
create | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
56 | |||
insert | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
setRoot | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
setRootId | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
commitRevision | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
42 | |||
logChange | |
0.00% |
0 / 61 |
|
0.00% |
0 / 1 |
90 | |||
updateEditedness | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
72 | |||
save | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
getRow | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
30 | |||
author | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
delete | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
undelete | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
moveToPage | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
20 | |||
leaveTrace | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
2 | |||
replyCount | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
incrementReplyCount | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
decrementReplyCount | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
newFromRow | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
__construct | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
132 | |||
bulkLoad | |
0.00% |
0 / 121 |
|
0.00% |
0 / 1 |
702 | |||
loadOriginalAuthorFromRevision | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
recursiveGetReplyCount | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
doLazyUpdates | |
0.00% |
0 / 85 |
|
0.00% |
0 / 1 |
650 | |||
addReply | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
removeReply | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
checkReplies | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
replies | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
30 | |||
setSuperthread | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
superthread | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
hasSuperthread | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
topmostThread | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
setAncestor | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
fixMissingAncestor | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
isTopmostThread | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
setArticle | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
touch | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
article | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
72 | |||
id | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
ancestorId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
root | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
90 | |||
editedness | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
summary | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
hasSummary | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setSummary | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
title | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
splitIncrementFromSubject | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
subject | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
formattedSubject | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setSubject | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
hasDistinctSubject | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
subthreads | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
modified | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
created | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
type | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAnchorName | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
updateHistory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setAuthor | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
loadAllData | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
__sleep | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
__wakeup | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
dieIfHistorical | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
rootRevision | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
20 | |||
sortkey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setSortKey | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
replyWithId | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
createdSortCallback | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
split | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
moveToParent | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
recursiveSet | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
validateSubject | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
56 | |||
canUserReply | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
canUserPost | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
canUserCreateThreads | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
signature | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setSignature | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
editors | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
30 | |||
setEditors | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
addEditor | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getReactions | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
42 | |||
addReaction | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
6 | |||
deleteReaction | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
getRevisionQueryInfo | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | use MediaWiki\Context\RequestContext; |
4 | use MediaWiki\MediaWikiServices; |
5 | use MediaWiki\Parser\Sanitizer; |
6 | use MediaWiki\Permissions\PermissionManager; |
7 | use MediaWiki\Title\Title; |
8 | use MediaWiki\User\User; |
9 | use MediaWiki\User\UserIdentity; |
10 | use Wikimedia\Rdbms\SelectQueryBuilder; |
11 | |
12 | class Thread { |
13 | /* SCHEMA changes must be reflected here. */ |
14 | |
15 | /* ID references to other objects that are loaded on demand: */ |
16 | /** @var int|null */ |
17 | protected $rootId; |
18 | /** @var int|null */ |
19 | protected $articleId; |
20 | /** @var int|null */ |
21 | protected $summaryId; |
22 | /** @var int|null */ |
23 | protected $ancestorId; |
24 | /** @var int|null */ |
25 | protected $parentId; |
26 | |
27 | /* Actual objects loaded on demand from the above when accessors are called: */ |
28 | /** @var Article|null */ |
29 | protected $root; |
30 | /** @var Article|null */ |
31 | protected $article; |
32 | /** @var Article|null */ |
33 | protected $summary; |
34 | /** @var self|null */ |
35 | protected $superthread; |
36 | /** @var self|null */ |
37 | protected $ancestor; |
38 | |
39 | /** @var int|null namespace of Subject page of the talkpage we're attached to */ |
40 | protected $articleNamespace; |
41 | /** @var string|null Subject page of the talkpage we're attached to: */ |
42 | protected $articleTitle; |
43 | |
44 | /** @var string Timestamp */ |
45 | protected $modified; |
46 | /** @var string Timestamp */ |
47 | protected $created; |
48 | /** @var string Timestamp */ |
49 | protected $sortkey; |
50 | |
51 | /** @var int */ |
52 | protected $id; |
53 | /** @var int|null */ |
54 | protected $type; |
55 | /** @var string|null */ |
56 | protected $subject; |
57 | /** @var int */ |
58 | protected $authorId; |
59 | /** @var string|null */ |
60 | protected $authorName; |
61 | /** @var string|null */ |
62 | protected $signature; |
63 | /** @var int */ |
64 | protected $replyCount; |
65 | |
66 | /** @var bool|null */ |
67 | protected $allDataLoaded; |
68 | |
69 | /** @var bool */ |
70 | protected $isHistorical = false; |
71 | |
72 | /** @var int|null */ |
73 | protected $rootRevision; |
74 | |
75 | /** @var int Flag about who has edited or replied to this thread. */ |
76 | public $editedness; |
77 | /** @var string[]|null */ |
78 | protected $editors = null; |
79 | |
80 | /** @var Thread[]|null */ |
81 | protected $replies; |
82 | /** @var array[]|null */ |
83 | protected $reactions; |
84 | |
85 | /** @var self|null */ |
86 | public $dbVersion; // A copy of the thread as it exists in the database. |
87 | /** @var ThreadRevision */ |
88 | public $threadRevision; |
89 | |
90 | /** @var Title[] */ |
91 | public static $titleCacheById = []; |
92 | /** @var self[][] */ |
93 | public static $replyCacheById = []; |
94 | /** @var Article[] */ |
95 | public static $articleCacheById = []; |
96 | /** @var array[][] */ |
97 | public static $reactionCacheById = []; |
98 | |
99 | /** @var int[] */ |
100 | public static $VALID_TYPES = [ |
101 | Threads::TYPE_NORMAL, Threads::TYPE_MOVED, Threads::TYPE_DELETED ]; |
102 | |
103 | public function isHistorical() { |
104 | return $this->isHistorical; |
105 | } |
106 | |
107 | public static function create( |
108 | $root, |
109 | Article $article, |
110 | User $user, |
111 | ?self $superthread, |
112 | $type = Threads::TYPE_NORMAL, |
113 | $subject = '', |
114 | $summary = '', |
115 | $bump = null, |
116 | $signature = null |
117 | ) { |
118 | $thread = new self( null ); |
119 | |
120 | if ( !in_array( $type, self::$VALID_TYPES ) ) { |
121 | throw new UnexpectedValueException( __METHOD__ . ": invalid change type $type." ); |
122 | } |
123 | |
124 | if ( $superthread ) { |
125 | $change_type = Threads::CHANGE_REPLY_CREATED; |
126 | } else { |
127 | $change_type = Threads::CHANGE_NEW_THREAD; |
128 | } |
129 | |
130 | $thread->setAuthor( $user ); |
131 | |
132 | if ( is_object( $root ) ) { |
133 | $thread->setRoot( $root ); |
134 | } else { |
135 | $thread->setRootId( $root ); |
136 | } |
137 | |
138 | $thread->setSuperthread( $superthread ); |
139 | $thread->setArticle( $article ); |
140 | $thread->setSubject( $subject ); |
141 | $thread->setType( $type ); |
142 | |
143 | if ( $signature !== null ) { |
144 | $thread->setSignature( $signature ); |
145 | } |
146 | |
147 | $thread->insert(); |
148 | |
149 | if ( $superthread ) { |
150 | $superthread->addReply( $thread ); |
151 | |
152 | $superthread->commitRevision( $change_type, $user, $thread, $summary, $bump ); |
153 | } else { |
154 | ThreadRevision::create( $thread, $change_type, $user ); |
155 | } |
156 | |
157 | // Create talk page |
158 | Threads::createTalkpageIfNeeded( $article->getPage() ); |
159 | |
160 | // Notifications |
161 | NewMessages::writeMessageStateForUpdatedThread( $thread, $change_type, $user ); |
162 | |
163 | $services = MediaWikiServices::getInstance(); |
164 | $userOptionsLookup = $services->getUserOptionsLookup(); |
165 | $watchlistManager = $services->getWatchlistManager(); |
166 | if ( $userOptionsLookup->getOption( $user, 'lqt-watch-threads', false ) ) { |
167 | $watchlistManager->addWatch( $user, $thread->topmostThread()->root()->getTitle() ); |
168 | } |
169 | |
170 | return $thread; |
171 | } |
172 | |
173 | public function insert() { |
174 | $this->dieIfHistorical(); |
175 | |
176 | if ( $this->id() ) { |
177 | throw new LogicException( "Attempt to insert a thread that already exists." ); |
178 | } |
179 | |
180 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
181 | |
182 | $row = $this->getRow(); |
183 | |
184 | $dbw->newInsertQueryBuilder() |
185 | ->insertInto( 'thread' ) |
186 | ->row( $row ) |
187 | ->caller( __METHOD__ ) |
188 | ->execute(); |
189 | $this->id = $dbw->insertId(); |
190 | |
191 | // Touch the root |
192 | if ( $this->root() ) { |
193 | $this->root()->getTitle()->invalidateCache(); |
194 | } |
195 | |
196 | // Touch the talk page, too. |
197 | $this->getTitle()->invalidateCache(); |
198 | |
199 | $this->dbVersion = clone $this; |
200 | $this->dbVersion->dbVersion = null; |
201 | } |
202 | |
203 | /** |
204 | * @param Article $article |
205 | */ |
206 | public function setRoot( $article ) { |
207 | $this->rootId = $article->getPage()->getId(); |
208 | $this->root = $article; |
209 | |
210 | if ( $article->getTitle()->getNamespace() != NS_LQT_THREAD ) { |
211 | throw new LogicException( "Attempt to set thread root to a non-Thread page" ); |
212 | } |
213 | } |
214 | |
215 | public function setRootId( $article ) { |
216 | $this->rootId = $article; |
217 | $this->root = null; |
218 | } |
219 | |
220 | /** |
221 | * @param int $change_type |
222 | * @param User $user |
223 | * @param self|null $change_object |
224 | * @param string $reason |
225 | * @param bool|null $bump |
226 | * |
227 | * @throws Exception |
228 | */ |
229 | public function commitRevision( |
230 | $change_type, |
231 | User $user, |
232 | $change_object = null, |
233 | $reason = "", |
234 | $bump = null |
235 | ) { |
236 | $this->dieIfHistorical(); |
237 | |
238 | global $wgThreadActionsNoBump; |
239 | if ( $bump === null ) { |
240 | $bump = !in_array( $change_type, $wgThreadActionsNoBump ); |
241 | } |
242 | if ( $bump ) { |
243 | $this->sortkey = wfTimestamp( TS_MW ); |
244 | } |
245 | |
246 | $original = $this->dbVersion; |
247 | if ( $original->signature() != $this->signature() ) { |
248 | $this->logChange( |
249 | Threads::CHANGE_EDITED_SIGNATURE, |
250 | $original, |
251 | null, |
252 | $reason |
253 | ); |
254 | } |
255 | |
256 | $this->modified = wfTimestampNow(); |
257 | $this->updateEditedness( $change_type, $user ); |
258 | $this->save( __METHOD__ . "/" . wfGetCaller() ); |
259 | |
260 | $topmost = $this->topmostThread(); |
261 | $topmost->modified = wfTimestampNow(); |
262 | if ( $bump ) { |
263 | $topmost->setSortKey( wfTimestamp( TS_MW ) ); |
264 | } |
265 | $topmost->save(); |
266 | |
267 | ThreadRevision::create( $this, $change_type, $user, $change_object, $reason ); |
268 | $this->logChange( $change_type, $original, $change_object, $reason ); |
269 | |
270 | if ( $change_type == Threads::CHANGE_EDITED_ROOT ) { |
271 | NewMessages::writeMessageStateForUpdatedThread( $this, $change_type, $user ); |
272 | } |
273 | } |
274 | |
275 | /** |
276 | * @param int $change_type |
277 | * @param self $original |
278 | * @param self|null $change_object |
279 | * @param string|null $reason |
280 | */ |
281 | public function logChange( |
282 | $change_type, |
283 | $original, |
284 | $change_object = null, |
285 | $reason = '' |
286 | ) { |
287 | $log = new LogPage( 'liquidthreads' ); |
288 | $user = $this->author(); |
289 | |
290 | if ( $reason === null ) { |
291 | $reason = ''; |
292 | } |
293 | |
294 | switch ( $change_type ) { |
295 | case Threads::CHANGE_MOVED_TALKPAGE: |
296 | $log->addEntry( |
297 | 'move', |
298 | $this->title(), |
299 | $reason, |
300 | [ $original->getTitle(), $this->getTitle() ], |
301 | $user |
302 | ); |
303 | break; |
304 | case Threads::CHANGE_SPLIT: |
305 | $log->addEntry( |
306 | 'split', |
307 | $this->title(), |
308 | $reason, |
309 | [ $this->subject(), $original->superthread()->title() ], |
310 | $user |
311 | ); |
312 | break; |
313 | case Threads::CHANGE_EDITED_SUBJECT: |
314 | $log->addEntry( |
315 | 'subjectedit', |
316 | $this->title(), |
317 | $reason, |
318 | [ $original->subject(), $this->subject() ], |
319 | $user |
320 | ); |
321 | break; |
322 | case Threads::CHANGE_MERGED_TO: |
323 | $oldParent = $change_object->dbVersion->isTopmostThread() |
324 | ? '' |
325 | : $change_object->dbVersion->superthread()->title(); |
326 | |
327 | $log->addEntry( |
328 | 'merge', |
329 | $this->title(), |
330 | $reason, |
331 | [ $oldParent, $change_object->superthread()->title() ], |
332 | $user |
333 | ); |
334 | break; |
335 | case Threads::CHANGE_ADJUSTED_SORTKEY: |
336 | $log->addEntry( |
337 | 'resort', |
338 | $this->title(), |
339 | $reason, |
340 | [ $original->sortkey(), $this->sortkey() ], |
341 | $user |
342 | ); |
343 | break; |
344 | case Threads::CHANGE_EDITED_SIGNATURE: |
345 | $log->addEntry( |
346 | 'signatureedit', |
347 | $this->title(), |
348 | $reason, |
349 | [ $original->signature(), $this->signature() ], |
350 | $user |
351 | ); |
352 | break; |
353 | } |
354 | } |
355 | |
356 | private function updateEditedness( $change_type, User $user ) { |
357 | if ( $change_type == Threads::CHANGE_REPLY_CREATED |
358 | && $this->editedness == Threads::EDITED_NEVER ) { |
359 | $this->editedness = Threads::EDITED_HAS_REPLY; |
360 | } elseif ( $change_type == Threads::CHANGE_EDITED_ROOT ) { |
361 | $originalAuthor = $this->author(); |
362 | |
363 | if ( ( $user->getId() == 0 && $originalAuthor->getName() != $user->getName() ) |
364 | || $user->getId() != $originalAuthor->getId() ) { |
365 | $this->editedness = Threads::EDITED_BY_OTHERS; |
366 | } elseif ( $this->editedness == Threads::EDITED_HAS_REPLY ) { |
367 | $this->editedness = Threads::EDITED_BY_AUTHOR; |
368 | } |
369 | } |
370 | } |
371 | |
372 | /** |
373 | * Unless you know what you're doing, you want commitRevision |
374 | * @param string|null $fname |
375 | */ |
376 | public function save( $fname = null ) { |
377 | $this->dieIfHistorical(); |
378 | |
379 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
380 | |
381 | if ( !$fname ) { |
382 | $fname = __METHOD__ . "/" . wfGetCaller(); |
383 | } else { |
384 | $fname = __METHOD__ . "/" . $fname; |
385 | } |
386 | |
387 | $dbw->newUpdateQueryBuilder() |
388 | ->update( 'thread' ) |
389 | ->set( $this->getRow() ) |
390 | ->where( [ 'thread_id' => $this->id, ] ) |
391 | ->caller( $fname ) |
392 | ->execute(); |
393 | |
394 | // Touch the root |
395 | if ( $this->root() ) { |
396 | $this->root()->getTitle()->invalidateCache(); |
397 | } |
398 | |
399 | // Touch the talk page, too. |
400 | $this->getTitle()->invalidateCache(); |
401 | |
402 | $this->dbVersion = clone $this; |
403 | $this->dbVersion->dbVersion = null; |
404 | } |
405 | |
406 | public function getRow() { |
407 | $id = $this->id(); |
408 | |
409 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
410 | |
411 | // If there's no root, bail out with an error message |
412 | if ( !$this->rootId && !( $this->type & Threads::TYPE_DELETED ) ) { |
413 | throw new LogicException( "Non-deleted thread saved with empty root ID" ); |
414 | } |
415 | |
416 | if ( $this->replyCount < -1 ) { |
417 | wfWarn( |
418 | "Saving thread $id with negative reply count {$this->replyCount} " . |
419 | wfGetAllCallers() |
420 | ); |
421 | $this->replyCount = -1; |
422 | } |
423 | |
424 | $contLang = MediaWikiServices::getInstance()->getContentLanguage(); |
425 | // Reflect schema changes here. |
426 | $row = [ |
427 | 'thread_root' => $this->rootId, |
428 | 'thread_parent' => $this->parentId, |
429 | 'thread_article_namespace' => $this->articleNamespace, |
430 | 'thread_article_title' => $this->articleTitle, |
431 | 'thread_article_id' => $this->articleId, |
432 | 'thread_modified' => $dbw->timestamp( $this->modified ), |
433 | 'thread_created' => $dbw->timestamp( $this->created ), |
434 | 'thread_ancestor' => $this->ancestorId, |
435 | 'thread_type' => $this->type, |
436 | 'thread_subject' => $this->subject, |
437 | 'thread_author_id' => $this->authorId, |
438 | 'thread_author_name' => $this->authorName, |
439 | 'thread_summary_page' => $this->summaryId, |
440 | 'thread_editedness' => $this->editedness, |
441 | 'thread_sortkey' => $this->sortkey, |
442 | 'thread_replies' => $this->replyCount, |
443 | 'thread_signature' => $contLang->truncateForDatabase( $this->signature, 255, '' ), |
444 | ]; |
445 | if ( $id ) { |
446 | $row['thread_id'] = $id; |
447 | } |
448 | |
449 | return $row; |
450 | } |
451 | |
452 | public function author() { |
453 | if ( $this->authorId ) { |
454 | return User::newFromId( $this->authorId ); |
455 | } else { |
456 | // Do NOT validate username. If the user did it, they did it. |
457 | return User::newFromName( $this->authorName, false /* no validation */ ); |
458 | } |
459 | } |
460 | |
461 | public function delete( $reason, $commit = true ) { |
462 | if ( $this->type == Threads::TYPE_DELETED ) { |
463 | return; |
464 | } |
465 | |
466 | $this->type = Threads::TYPE_DELETED; |
467 | $user = RequestContext::getMain()->getUser(); // Need to inject |
468 | |
469 | if ( $commit ) { |
470 | $this->commitRevision( Threads::CHANGE_DELETED, $user, $this, $reason ); |
471 | } else { |
472 | $this->save( __METHOD__ ); |
473 | } |
474 | /* Mark thread as read by all users, or we get blank thingies in New Messages. */ |
475 | |
476 | $this->dieIfHistorical(); |
477 | |
478 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
479 | |
480 | $dbw->newDeleteQueryBuilder() |
481 | ->deleteFrom( 'user_message_state' ) |
482 | ->where( [ 'ums_thread' => $this->id() ] ) |
483 | ->caller( __METHOD__ ) |
484 | ->execute(); |
485 | |
486 | // Fix reply count. |
487 | $t = $this->superthread(); |
488 | |
489 | if ( $t ) { |
490 | $t->decrementReplyCount( 1 + $this->replyCount() ); |
491 | $t->save(); |
492 | } |
493 | } |
494 | |
495 | public function undelete( $reason ) { |
496 | $this->type = Threads::TYPE_NORMAL; |
497 | $user = RequestContext::getMain()->getUser(); // Need to inject |
498 | $this->commitRevision( Threads::CHANGE_UNDELETED, $user, $this, $reason ); |
499 | |
500 | // Fix reply count. |
501 | $t = $this->superthread(); |
502 | if ( $t ) { |
503 | $t->incrementReplyCount( 1 ); |
504 | $t->save(); |
505 | } |
506 | } |
507 | |
508 | public function moveToPage( $title, $reason, $leave_trace, User $user ) { |
509 | if ( !$this->isTopmostThread() ) { |
510 | throw new LogicException( "Attempt to move non-toplevel thread to another page" ); |
511 | } |
512 | |
513 | $this->dieIfHistorical(); |
514 | |
515 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
516 | |
517 | $oldTitle = $this->getTitle(); |
518 | $newTitle = $title; |
519 | |
520 | $new_articleNamespace = $title->getNamespace(); |
521 | $new_articleTitle = $title->getDBkey(); |
522 | $new_articleID = $title->getArticleID(); |
523 | |
524 | if ( !$new_articleID ) { |
525 | $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $newTitle ); |
526 | Threads::createTalkpageIfNeeded( $page ); |
527 | $new_articleID = $page->getId(); |
528 | } |
529 | |
530 | // Update on *all* subthreads. |
531 | $dbw->newUpdateQueryBuilder() |
532 | ->update( 'thread' ) |
533 | ->set( [ |
534 | 'thread_article_namespace' => $new_articleNamespace, |
535 | 'thread_article_title' => $new_articleTitle, |
536 | 'thread_article_id' => $new_articleID, |
537 | ] ) |
538 | ->where( [ 'thread_ancestor' => $this->id() ] ) |
539 | ->caller( __METHOD__ ) |
540 | ->execute(); |
541 | |
542 | $this->articleNamespace = $new_articleNamespace; |
543 | $this->articleTitle = $new_articleTitle; |
544 | $this->articleId = $new_articleID; |
545 | $this->article = null; |
546 | |
547 | $this->commitRevision( Threads::CHANGE_MOVED_TALKPAGE, $user, null, $reason ); |
548 | |
549 | // Notifications |
550 | NewMessages::writeMessageStateForUpdatedThread( $this, $this->type, $user ); |
551 | |
552 | if ( $leave_trace ) { |
553 | $this->leaveTrace( $reason, $oldTitle, $newTitle, $user ); |
554 | } |
555 | } |
556 | |
557 | /** |
558 | * Drop a note at the source location of a move, noting that a thread was moved from there. |
559 | * |
560 | * @param string $reason |
561 | * @param Title $oldTitle |
562 | * @param Title $newTitle |
563 | * @param User $user |
564 | */ |
565 | public function leaveTrace( $reason, $oldTitle, $newTitle, User $user ) { |
566 | $this->dieIfHistorical(); |
567 | |
568 | // Create redirect text |
569 | $mwRedir = \MediaWiki\MediaWikiServices::getInstance()->getMagicWordFactory()->get( 'redirect' ); |
570 | $redirectText = $mwRedir->getSynonym( 0 ) . |
571 | ' [[' . $this->title()->getPrefixedText() . "]]\n"; |
572 | |
573 | // Make the article edit. |
574 | $traceTitle = Threads::newThreadTitle( $this->subject(), new Article( $oldTitle, 0 ) ); |
575 | $redirectArticle = new Article( $traceTitle, 0 ); |
576 | |
577 | $redirectArticle->getPage()->doUserEditContent( |
578 | ContentHandler::makeContent( $redirectText, $traceTitle ), |
579 | $user, |
580 | $reason, |
581 | EDIT_NEW | EDIT_SUPPRESS_RC |
582 | ); |
583 | |
584 | // Add the trace thread to the tracking table. |
585 | $thread = self::create( |
586 | $redirectArticle, |
587 | new Article( $oldTitle, 0 ), |
588 | $user, |
589 | null, |
590 | Threads::TYPE_MOVED, |
591 | $this->subject() |
592 | ); |
593 | |
594 | $thread->setSortKey( $this->sortkey() ); |
595 | $thread->save(); |
596 | } |
597 | |
598 | /** |
599 | * Lists total reply count, including replies to replies and such |
600 | * |
601 | * @return int |
602 | */ |
603 | public function replyCount() { |
604 | // Populate reply count |
605 | if ( $this->replyCount == -1 ) { |
606 | if ( $this->isTopmostThread() ) { |
607 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
608 | |
609 | $count = $dbr->newSelectQueryBuilder() |
610 | ->select( 'COUNT(*)' ) |
611 | ->from( 'thread' ) |
612 | ->where( [ 'thread_ancestor' => $this->id() ] ) |
613 | ->caller( __METHOD__ ) |
614 | ->fetchField(); |
615 | } else { |
616 | $count = self::recursiveGetReplyCount( $this ); |
617 | } |
618 | |
619 | $this->replyCount = $count; |
620 | $this->save(); |
621 | } |
622 | |
623 | return $this->replyCount; |
624 | } |
625 | |
626 | public function incrementReplyCount( $val = 1 ) { |
627 | $this->replyCount += $val; |
628 | |
629 | wfDebug( "Incremented reply count for thread " . $this->id() . " to " . |
630 | $this->replyCount . "\n" ); |
631 | |
632 | $thread = $this->superthread(); |
633 | |
634 | if ( $thread ) { |
635 | $thread->incrementReplyCount( $val ); |
636 | wfDebug( "Saving Incremented thread " . $thread->id() . |
637 | " with reply count " . $thread->replyCount . "\n" ); |
638 | $thread->save(); |
639 | } |
640 | } |
641 | |
642 | public function decrementReplyCount( $val = 1 ) { |
643 | $this->incrementReplyCount( -$val ); |
644 | } |
645 | |
646 | /** |
647 | * @param stdClass $row |
648 | * @return self |
649 | */ |
650 | public static function newFromRow( $row ) { |
651 | $id = $row->thread_id; |
652 | |
653 | if ( isset( Threads::$cache_by_id[$id] ) ) { |
654 | return Threads::$cache_by_id[$id]; |
655 | } |
656 | |
657 | return new self( $row ); |
658 | } |
659 | |
660 | /** |
661 | * @param stdClass|null $line |
662 | * @param null $unused |
663 | */ |
664 | protected function __construct( $line, $unused = null ) { |
665 | /* SCHEMA changes must be reflected here. */ |
666 | |
667 | if ( $line === null ) { // For Thread::create(). |
668 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
669 | $this->modified = $dbr->timestamp( wfTimestampNow() ); |
670 | $this->created = $dbr->timestamp( wfTimestampNow() ); |
671 | $this->sortkey = wfTimestamp( TS_MW ); |
672 | $this->editedness = Threads::EDITED_NEVER; |
673 | $this->replyCount = 0; |
674 | return; |
675 | } |
676 | |
677 | $dataLoads = [ |
678 | 'thread_id' => 'id', |
679 | 'thread_root' => 'rootId', |
680 | 'thread_article_namespace' => 'articleNamespace', |
681 | 'thread_article_title' => 'articleTitle', |
682 | 'thread_article_id' => 'articleId', |
683 | 'thread_summary_page' => 'summaryId', |
684 | 'thread_ancestor' => 'ancestorId', |
685 | 'thread_parent' => 'parentId', |
686 | 'thread_modified' => 'modified', |
687 | 'thread_created' => 'created', |
688 | 'thread_type' => 'type', |
689 | 'thread_editedness' => 'editedness', |
690 | 'thread_subject' => 'subject', |
691 | 'thread_author_id' => 'authorId', |
692 | 'thread_author_name' => 'authorName', |
693 | 'thread_sortkey' => 'sortkey', |
694 | 'thread_replies' => 'replyCount', |
695 | 'thread_signature' => 'signature', |
696 | ]; |
697 | |
698 | foreach ( $dataLoads as $db_field => $member_field ) { |
699 | if ( isset( $line->$db_field ) ) { |
700 | $this->$member_field = $line->$db_field; |
701 | } |
702 | } |
703 | |
704 | if ( isset( $line->page_namespace ) && isset( $line->page_title ) ) { |
705 | $root_title = Title::makeTitle( $line->page_namespace, $line->page_title ); |
706 | $this->root = new Article( $root_title, 0 ); |
707 | $this->root->getPage()->loadPageData( $line ); |
708 | } else { |
709 | if ( isset( self::$titleCacheById[$this->rootId] ) ) { |
710 | $root_title = self::$titleCacheById[$this->rootId]; |
711 | } else { |
712 | $root_title = Title::newFromID( $this->rootId ); |
713 | } |
714 | |
715 | if ( $root_title ) { |
716 | $this->root = new Article( $root_title, 0 ); |
717 | } |
718 | } |
719 | |
720 | Threads::$cache_by_id[$line->thread_id] = $this; |
721 | if ( $line->thread_parent ) { |
722 | if ( !isset( self::$replyCacheById[$line->thread_parent] ) ) { |
723 | self::$replyCacheById[$line->thread_parent] = []; |
724 | } |
725 | self::$replyCacheById[$line->thread_parent][$line->thread_id] = $this; |
726 | } |
727 | |
728 | try { |
729 | $this->doLazyUpdates(); |
730 | } catch ( Exception $excep ) { |
731 | trigger_error( "Exception doing lazy updates: " . $excep->__toString() ); |
732 | } |
733 | |
734 | $this->dbVersion = clone $this; |
735 | $this->dbVersion->dbVersion = null; |
736 | } |
737 | |
738 | /** |
739 | * Load a list of threads in bulk, including all subthreads. |
740 | * |
741 | * @param stdClass[] $rows |
742 | * @return self[] |
743 | */ |
744 | public static function bulkLoad( $rows ) { |
745 | // Preload subthreads |
746 | $top_thread_ids = []; |
747 | $all_thread_rows = $rows; |
748 | $pageIds = []; |
749 | $linkBatch = MediaWikiServices::getInstance()->getLinkBatchFactory()->newLinkBatch(); |
750 | $userIds = []; |
751 | $loadEditorsFor = []; |
752 | |
753 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
754 | |
755 | if ( !is_array( self::$replyCacheById ) ) { |
756 | self::$replyCacheById = []; |
757 | } |
758 | |
759 | // Build a list of threads for which to pull replies, and page IDs to pull data for. |
760 | // Also, pre-initialise the reply cache. |
761 | foreach ( $rows as $row ) { |
762 | if ( $row->thread_ancestor ) { |
763 | $top_thread_ids[] = $row->thread_ancestor; |
764 | } else { |
765 | $top_thread_ids[] = $row->thread_id; |
766 | } |
767 | |
768 | // Grab page data while we're here. |
769 | if ( $row->thread_root ) { |
770 | $pageIds[] = $row->thread_root; |
771 | } |
772 | if ( $row->thread_summary_page ) { |
773 | $pageIds[] = $row->thread_summary_page; |
774 | } |
775 | if ( !isset( self::$replyCacheById[$row->thread_id] ) ) { |
776 | self::$replyCacheById[$row->thread_id] = []; |
777 | } |
778 | } |
779 | |
780 | $all_thread_ids = $top_thread_ids; |
781 | |
782 | // Pull replies to the threads provided, and as above, pull page IDs to pull data for, |
783 | // pre-initialise the reply cache, and stash the row object for later use. |
784 | if ( count( $top_thread_ids ) ) { |
785 | $res = $dbr->newSelectQueryBuilder() |
786 | ->select( '*' ) |
787 | ->from( 'thread' ) |
788 | ->where( [ |
789 | 'thread_ancestor' => $top_thread_ids, |
790 | $dbr->expr( 'thread_type', '!=', Threads::TYPE_DELETED ), |
791 | ] ) |
792 | ->caller( __METHOD__ ) |
793 | ->fetchResultSet(); |
794 | |
795 | foreach ( $res as $row ) { |
796 | // Grab page data while we're here. |
797 | if ( $row->thread_root ) { |
798 | $pageIds[] = $row->thread_root; |
799 | } |
800 | if ( $row->thread_summary_page ) { |
801 | $pageIds[] = $row->thread_summary_page; |
802 | } |
803 | $all_thread_rows[] = $row; |
804 | $all_thread_ids[$row->thread_id] = $row->thread_id; |
805 | } |
806 | } |
807 | |
808 | // Pull thread reactions |
809 | if ( count( $all_thread_ids ) ) { |
810 | $res = $dbr->newSelectQueryBuilder() |
811 | ->select( '*' ) |
812 | ->from( 'thread_reaction' ) |
813 | ->where( [ 'tr_thread' => $all_thread_ids ] ) |
814 | ->caller( __METHOD__ ) |
815 | ->fetchResultSet(); |
816 | |
817 | foreach ( $res as $row ) { |
818 | $thread_id = $row->tr_thread; |
819 | $info = [ |
820 | 'type' => $row->tr_type, |
821 | 'user-id' => $row->tr_user, |
822 | 'user-name' => $row->tr_user_text, |
823 | 'value' => $row->tr_value, |
824 | ]; |
825 | |
826 | $type = $info['type']; |
827 | $user = $info['user-name']; |
828 | |
829 | if ( !isset( self::$reactionCacheById[$thread_id] ) ) { |
830 | self::$reactionCacheById[$thread_id] = []; |
831 | } |
832 | |
833 | if ( !isset( self::$reactionCacheById[$thread_id][$type] ) ) { |
834 | self::$reactionCacheById[$thread_id][$type] = []; |
835 | } |
836 | |
837 | self::$reactionCacheById[$thread_id][$type][$user] = $info; |
838 | } |
839 | } |
840 | |
841 | // Preload page data (restrictions, and preload Article object with everything from |
842 | // the page table. Also, precache the title and article objects for pulling later. |
843 | $articlesById = []; |
844 | if ( count( $pageIds ) ) { |
845 | // Pull restriction info. Needs to come first because otherwise it's done per |
846 | // page by loadPageData. |
847 | $restrictionRows = array_fill_keys( $pageIds, [] ); |
848 | $res = $dbr->newSelectQueryBuilder() |
849 | ->select( '*' ) |
850 | ->from( 'page_restrictions' ) |
851 | ->where( [ 'pr_page' => $pageIds ] ) |
852 | ->caller( __METHOD__ ) |
853 | ->fetchResultSet(); |
854 | foreach ( $res as $row ) { |
855 | $restrictionRows[$row->pr_page][] = $row; |
856 | } |
857 | |
858 | $res = $dbr->newSelectQueryBuilder() |
859 | ->select( '*' ) |
860 | ->from( 'page' ) |
861 | ->where( [ 'page_id' => $pageIds ] ) |
862 | ->caller( __METHOD__ ) |
863 | ->fetchResultSet(); |
864 | |
865 | $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore(); |
866 | foreach ( $res as $row ) { |
867 | $t = Title::newFromRow( $row ); |
868 | |
869 | if ( isset( $restrictionRows[$t->getArticleID()] ) ) { |
870 | $restrictionStore->loadRestrictionsFromRows( $t, $restrictionRows[$t->getArticleID()] ); |
871 | } |
872 | |
873 | $article = new Article( $t, 0 ); |
874 | $article->getPage()->loadPageData( $row ); |
875 | |
876 | self::$titleCacheById[$t->getArticleID()] = $t; |
877 | $articlesById[$article->getPage()->getId()] = $article; |
878 | |
879 | if ( count( self::$titleCacheById ) > 10000 ) { |
880 | self::$titleCacheById = []; |
881 | } |
882 | } |
883 | } |
884 | |
885 | // For every thread we have a row object for, load a Thread object, add the user and |
886 | // user talk pages to a link batch, cache the relevant user id/name pair, and |
887 | // populate the reply cache. |
888 | foreach ( $all_thread_rows as $row ) { |
889 | $thread = self::newFromRow( $row ); |
890 | |
891 | if ( isset( $articlesById[$thread->rootId] ) ) { |
892 | $thread->root = $articlesById[$thread->rootId]; |
893 | } |
894 | |
895 | // User cache data |
896 | $t = Title::makeTitleSafe( NS_USER, $row->thread_author_name ); |
897 | $linkBatch->addObj( $t ); |
898 | $t = Title::makeTitleSafe( NS_USER_TALK, $row->thread_author_name ); |
899 | $linkBatch->addObj( $t ); |
900 | |
901 | $userIds[$row->thread_author_id] = true; |
902 | |
903 | if ( $row->thread_editedness > Threads::EDITED_BY_AUTHOR ) { |
904 | $loadEditorsFor[$row->thread_root] = $thread; |
905 | $thread->setEditors( [] ); |
906 | } |
907 | } |
908 | |
909 | // Pull list of users who have edited |
910 | if ( count( $loadEditorsFor ) ) { |
911 | $revQuery = self::getRevisionQueryInfo(); |
912 | $res = $dbr->newSelectQueryBuilder() |
913 | ->select( [ 'rev_user_text' => $revQuery['fields']['rev_user_text'], 'rev_page' ] ) |
914 | ->tables( $revQuery['tables'] ) |
915 | ->where( [ |
916 | 'rev_page' => array_keys( $loadEditorsFor ), |
917 | $dbr->expr( 'rev_parent_id', '!=', 0 ), |
918 | ] ) |
919 | ->caller( __METHOD__ ) |
920 | ->joinConds( $revQuery['joins'] ) |
921 | ->fetchResultSet(); |
922 | foreach ( $res as $row ) { |
923 | $pageid = $row->rev_page; |
924 | $editor = $row->rev_user_text; |
925 | $t = $loadEditorsFor[$pageid]; |
926 | |
927 | $t->addEditor( $editor ); |
928 | } |
929 | } |
930 | |
931 | // Pull link batch data. |
932 | $linkBatch->execute(); |
933 | |
934 | $threads = []; |
935 | |
936 | // Fill and return an array with the threads that were actually requested. |
937 | foreach ( $rows as $row ) { |
938 | $threads[$row->thread_id] = Threads::$cache_by_id[$row->thread_id]; |
939 | } |
940 | |
941 | return $threads; |
942 | } |
943 | |
944 | /** |
945 | * @return User|null the User object representing the author of the first revision |
946 | * (or null, if the database is screwed up). |
947 | */ |
948 | public function loadOriginalAuthorFromRevision() { |
949 | $this->dieIfHistorical(); |
950 | |
951 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
952 | |
953 | $article = $this->root(); |
954 | |
955 | $revQuery = self::getRevisionQueryInfo(); |
956 | $line = $dbr->newSelectQueryBuilder() |
957 | ->select( [ 'rev_user_text' => $revQuery['fields']['rev_user_text'] ] ) |
958 | ->tables( $revQuery['tables'] ) |
959 | ->where( [ 'rev_page' => $article->getPage()->getId() ] ) |
960 | ->caller( __METHOD__ ) |
961 | ->orderBy( 'rev_timestamp' ) |
962 | ->joinConds( $revQuery['joins'] ) |
963 | ->fetchRow(); |
964 | if ( $line ) { |
965 | return User::newFromName( $line->rev_user_text, false ); |
966 | } else { |
967 | return null; |
968 | } |
969 | } |
970 | |
971 | public static function recursiveGetReplyCount( self $thread, $level = 1 ) { |
972 | if ( $level > 80 ) { |
973 | return 1; |
974 | } |
975 | |
976 | $count = 0; |
977 | |
978 | foreach ( $thread->replies() as $reply ) { |
979 | if ( $thread->type != Threads::TYPE_DELETED ) { |
980 | $count++; |
981 | $count += self::recursiveGetReplyCount( $reply, $level + 1 ); |
982 | } |
983 | } |
984 | |
985 | return $count; |
986 | } |
987 | |
988 | /** |
989 | * Lazy updates done whenever a thread is loaded. |
990 | * Much easier than running a long-running maintenance script. |
991 | */ |
992 | public function doLazyUpdates() { |
993 | if ( $this->isHistorical() ) { |
994 | return; // Don't do lazy updates on stored historical threads. |
995 | } |
996 | |
997 | // This is an invocation guard to avoid infinite recursion when fixing a |
998 | // missing ancestor. |
999 | static $doingUpdates = false; |
1000 | if ( $doingUpdates ) { |
1001 | return; |
1002 | } |
1003 | $doingUpdates = true; |
1004 | |
1005 | // Fix missing ancestry information. |
1006 | // (there was a bug where this was not saved properly) |
1007 | if ( $this->parentId && !$this->ancestorId ) { |
1008 | $this->fixMissingAncestor(); |
1009 | } |
1010 | |
1011 | $ancestor = $this->topmostThread(); |
1012 | |
1013 | $set = []; |
1014 | |
1015 | // Fix missing subject information |
1016 | // (this information only started to be added later) |
1017 | if ( !$this->subject && $this->root() ) { |
1018 | $detectedSubject = $this->root()->getTitle()->getText(); |
1019 | $parts = self::splitIncrementFromSubject( $detectedSubject ); |
1020 | |
1021 | $this->subject = $detectedSubject = $parts[1]; |
1022 | |
1023 | // Update in the DB |
1024 | $set['thread_subject'] = $detectedSubject; |
1025 | } |
1026 | |
1027 | // Fix inconsistent subject information |
1028 | // (in some intermediate versions this was not updated when the subject was changed) |
1029 | if ( $this->subject() != $ancestor->subject() ) { |
1030 | $set['thread_subject'] = $ancestor->subject(); |
1031 | |
1032 | $this->subject = $ancestor->subject(); |
1033 | } |
1034 | |
1035 | // Fix missing authorship information |
1036 | // (this information only started to be added later) |
1037 | if ( !$this->authorName ) { |
1038 | $author = $this->loadOriginalAuthorFromRevision(); |
1039 | |
1040 | $this->authorId = $author->getId(); |
1041 | $this->authorName = $author->getName(); |
1042 | |
1043 | $set['thread_author_name'] = $this->authorName; |
1044 | $set['thread_author_id'] = $this->authorId; |
1045 | } |
1046 | |
1047 | // Check for article being in subject, not talk namespace. |
1048 | // If the page is non-LiquidThreads and it's in subject-space, we'll assume it's meant |
1049 | // to be on the corresponding talk page, but only if the talk-page is a LQT page. |
1050 | // (Previous versions stored the subject page, for some totally bizarre reason) |
1051 | // Old versions also sometimes store the thread page for trace threads as the |
1052 | // article, not as the root. |
1053 | // Trying not to exacerbate this by moving it to be the 'Thread talk' page. |
1054 | $articleTitle = $this->getTitle(); |
1055 | global $wgLiquidThreadsMigrate; |
1056 | if ( !LqtDispatch::isLqtPage( $articleTitle ) && !$articleTitle->isTalkPage() && |
1057 | LqtDispatch::isLqtPage( $articleTitle->getTalkPage() ) && |
1058 | $articleTitle->getNamespace() != NS_LQT_THREAD && |
1059 | $wgLiquidThreadsMigrate |
1060 | ) { |
1061 | $newTitle = $articleTitle->getTalkPage(); |
1062 | $newArticle = new Article( $newTitle, 0 ); |
1063 | |
1064 | $set['thread_article_namespace'] = $newTitle->getNamespace(); |
1065 | $set['thread_article_title'] = $newTitle->getDBkey(); |
1066 | |
1067 | $this->articleNamespace = $newTitle->getNamespace(); |
1068 | $this->articleTitle = $newTitle->getDBkey(); |
1069 | $this->articleId = $newTitle->getArticleID(); |
1070 | |
1071 | $this->article = $newArticle; |
1072 | } |
1073 | |
1074 | // Check for article corruption from incomplete thread moves. |
1075 | // (thread moves only updated this on immediate replies, not replies to replies etc) |
1076 | if ( !$ancestor->getTitle()->equals( $this->getTitle() ) ) { |
1077 | $title = $ancestor->getTitle(); |
1078 | $set['thread_article_namespace'] = $title->getNamespace(); |
1079 | $set['thread_article_title'] = $title->getDBkey(); |
1080 | |
1081 | $this->articleNamespace = $title->getNamespace(); |
1082 | $this->articleTitle = $title->getDBkey(); |
1083 | $this->articleId = $title->getArticleID(); |
1084 | |
1085 | $this->article = $ancestor->article(); |
1086 | } |
1087 | |
1088 | // Check for invalid/missing articleId |
1089 | $articleTitle = null; |
1090 | $dbTitle = Title::makeTitleSafe( $this->articleNamespace, $this->articleTitle ); |
1091 | if ( $this->articleId && isset( self::$titleCacheById[$this->articleId] ) ) { |
1092 | // If it corresponds to a title, the article obviously exists. |
1093 | $articleTitle = self::$titleCacheById[$this->articleId]; |
1094 | $this->article = new Article( $articleTitle, 0 ); |
1095 | } elseif ( $this->articleId ) { |
1096 | $articleTitle = Title::newFromID( $this->articleId ); |
1097 | } |
1098 | |
1099 | // If still unfilled, the article ID referred to is no longer valid. Re-fill it |
1100 | // from the namespace/title pair if an article ID is provided |
1101 | if ( !$articleTitle && ( $this->articleId != 0 || $dbTitle->getArticleID() != 0 ) ) { |
1102 | $articleTitle = $dbTitle; |
1103 | $this->articleId = $articleTitle->getArticleID(); |
1104 | $this->article = new Article( $dbTitle, 0 ); |
1105 | |
1106 | $set['thread_article_id'] = $this->articleId; |
1107 | wfDebug( |
1108 | "Unfilled or non-existent thread_article_id, refilling to {$this->articleId}\n" |
1109 | ); |
1110 | |
1111 | // There are probably problems on the rest of the article, trigger a small update |
1112 | Threads::synchroniseArticleData( $this->article->getPage(), 100, 'cascade' ); |
1113 | } elseif ( $articleTitle && !$articleTitle->equals( $dbTitle ) ) { |
1114 | // The page was probably moved and this was probably not updated. |
1115 | wfDebug( |
1116 | "Article ID/Title discrepancy, resetting NS/Title to article provided by ID\n" |
1117 | ); |
1118 | $this->articleNamespace = $articleTitle->getNamespace(); |
1119 | $this->articleTitle = $articleTitle->getDBkey(); |
1120 | $this->article = new Article( $articleTitle, 0 ); |
1121 | |
1122 | $set['thread_article_namespace'] = $articleTitle->getNamespace(); |
1123 | $set['thread_article_title'] = $articleTitle->getDBkey(); |
1124 | |
1125 | // There are probably problems on the rest of the article, trigger a small update |
1126 | Threads::synchroniseArticleData( $this->article->getPage(), 100, 'cascade' ); |
1127 | } |
1128 | |
1129 | // Check for unfilled signature field. This field hasn't existed until |
1130 | // recently. |
1131 | if ( $this->signature === null ) { |
1132 | // Grab our signature. |
1133 | $sig = LqtView::getUserSignature( $this->author() ); |
1134 | $contLang = MediaWikiServices::getInstance()->getContentLanguage(); |
1135 | $set['thread_signature'] = $contLang->truncateForDatabase( $sig, 255, '' ); |
1136 | $this->setSignature( $sig ); |
1137 | } |
1138 | |
1139 | if ( count( $set ) ) { |
1140 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
1141 | |
1142 | $dbw->newUpdateQueryBuilder() |
1143 | ->update( 'thread' ) |
1144 | ->set( $set ) |
1145 | ->where( [ 'thread_id' => $this->id() ] ) |
1146 | ->caller( __METHOD__ ) |
1147 | ->execute(); |
1148 | } |
1149 | |
1150 | // Done |
1151 | $doingUpdates = false; |
1152 | } |
1153 | |
1154 | public function addReply( self $thread ) { |
1155 | $thread->setSuperThread( $this ); |
1156 | |
1157 | if ( is_array( $this->replies ) ) { |
1158 | $this->replies[$thread->id()] = $thread; |
1159 | } else { |
1160 | $this->replies(); |
1161 | // @phan-suppress-next-line PhanTypeArraySuspicious $replies set by replies() |
1162 | $this->replies[$thread->id()] = $thread; |
1163 | } |
1164 | |
1165 | // Increment reply count. |
1166 | $this->incrementReplyCount( $thread->replyCount() + 1 ); |
1167 | } |
1168 | |
1169 | private function removeReply( self $thread ) { |
1170 | $thread = $thread->id(); |
1171 | |
1172 | $this->replies(); |
1173 | |
1174 | unset( $this->replies[$thread] ); |
1175 | |
1176 | // Also, decrement the reply count. |
1177 | $threadObj = Threads::withId( $thread ); |
1178 | $this->decrementReplyCount( 1 + $threadObj->replyCount() ); |
1179 | } |
1180 | |
1181 | private function checkReplies( $replies ) { |
1182 | // Fixes a bug where some history pages were not working, before |
1183 | // superthread was properly instance-cached. |
1184 | if ( $this->isHistorical() ) { |
1185 | return; |
1186 | } |
1187 | foreach ( $replies as $reply ) { |
1188 | if ( !$reply->hasSuperthread() ) { |
1189 | throw new RuntimeException( "Post " . $this->id() . |
1190 | " has contaminated reply " . $reply->id() . |
1191 | ". Found no superthread." ); |
1192 | } |
1193 | |
1194 | if ( $reply->superthread()->id() != $this->id() ) { |
1195 | throw new RuntimeException( "Post " . $this->id() . |
1196 | " has contaminated reply " . $reply->id() . |
1197 | ". Expected " . $this->id() . ", got " . |
1198 | $reply->superthread()->id() ); |
1199 | } |
1200 | } |
1201 | } |
1202 | |
1203 | /** |
1204 | * @return self[] |
1205 | */ |
1206 | public function replies() { |
1207 | if ( !$this->id() ) { |
1208 | return []; |
1209 | } |
1210 | |
1211 | if ( $this->replies !== null ) { |
1212 | $this->checkReplies( $this->replies ); |
1213 | return $this->replies; |
1214 | } |
1215 | |
1216 | $this->dieIfHistorical(); |
1217 | |
1218 | // Check cache |
1219 | if ( isset( self::$replyCacheById[$this->id()] ) ) { |
1220 | $this->replies = self::$replyCacheById[$this->id()]; |
1221 | return $this->replies; |
1222 | } |
1223 | |
1224 | $this->replies = []; |
1225 | |
1226 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
1227 | |
1228 | $res = $dbr->newSelectQueryBuilder() |
1229 | ->select( '*' ) |
1230 | ->from( 'thread' ) |
1231 | ->where( [ |
1232 | 'thread_parent' => $this->id(), |
1233 | $dbr->expr( 'thread_type', '!=', Threads::TYPE_DELETED ), |
1234 | ] ) |
1235 | ->caller( __METHOD__ ) |
1236 | ->fetchResultSet(); |
1237 | |
1238 | $rows = []; |
1239 | foreach ( $res as $row ) { |
1240 | $rows[] = $row; |
1241 | } |
1242 | |
1243 | $this->replies = self::bulkLoad( $rows ); |
1244 | |
1245 | $this->checkReplies( $this->replies ); |
1246 | |
1247 | return $this->replies; |
1248 | } |
1249 | |
1250 | public function setSuperthread( ?self $thread ) { |
1251 | if ( $thread == null ) { |
1252 | $this->parentId = null; |
1253 | $this->ancestorId = 0; |
1254 | return; |
1255 | } |
1256 | |
1257 | $this->parentId = $thread->id(); |
1258 | $this->superthread = $thread; |
1259 | |
1260 | if ( $thread->isTopmostThread() ) { |
1261 | $this->ancestorId = $thread->id(); |
1262 | $this->ancestor = $thread; |
1263 | } else { |
1264 | $this->ancestorId = $thread->ancestorId(); |
1265 | $this->ancestor = $thread->topmostThread(); |
1266 | } |
1267 | } |
1268 | |
1269 | public function superthread() { |
1270 | if ( !$this->hasSuperthread() ) { |
1271 | return null; |
1272 | } elseif ( $this->superthread ) { |
1273 | return $this->superthread; |
1274 | } else { |
1275 | $this->dieIfHistorical(); |
1276 | $this->superthread = Threads::withId( $this->parentId ); |
1277 | return $this->superthread; |
1278 | } |
1279 | } |
1280 | |
1281 | public function hasSuperthread() { |
1282 | return !$this->isTopmostThread(); |
1283 | } |
1284 | |
1285 | /** |
1286 | * @return self |
1287 | */ |
1288 | public function topmostThread() { |
1289 | if ( $this->isTopmostThread() ) { |
1290 | $this->ancestor = $this; |
1291 | return $this->ancestor; |
1292 | } elseif ( $this->ancestor ) { |
1293 | return $this->ancestor; |
1294 | } else { |
1295 | $this->dieIfHistorical(); |
1296 | |
1297 | $thread = Threads::withId( $this->ancestorId ); |
1298 | |
1299 | if ( !$thread ) { |
1300 | $thread = $this->fixMissingAncestor(); |
1301 | } |
1302 | |
1303 | $this->ancestor = $thread; |
1304 | |
1305 | return $thread; |
1306 | } |
1307 | } |
1308 | |
1309 | /** |
1310 | * @param self|int $newAncestor |
1311 | */ |
1312 | public function setAncestor( $newAncestor ) { |
1313 | if ( is_object( $newAncestor ) ) { |
1314 | $this->ancestorId = $newAncestor->id(); |
1315 | } else { |
1316 | $this->ancestorId = $newAncestor; |
1317 | } |
1318 | } |
1319 | |
1320 | /** |
1321 | * Due to a bug in earlier versions, the topmost thread sometimes isn't there. |
1322 | * Fix the corruption by repeatedly grabbing the parent until we hit the topmost thread. |
1323 | * |
1324 | * @return self |
1325 | */ |
1326 | public function fixMissingAncestor() { |
1327 | $thread = $this; |
1328 | |
1329 | $this->dieIfHistorical(); |
1330 | |
1331 | while ( !$thread->isTopmostThread() ) { |
1332 | $thread = $thread->superthread(); |
1333 | } |
1334 | |
1335 | $this->ancestorId = $thread->id(); |
1336 | |
1337 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
1338 | $dbw->newUpdateQueryBuilder() |
1339 | ->update( 'thread' ) |
1340 | ->set( [ 'thread_ancestor' => $thread->id() ] ) |
1341 | ->where( [ 'thread_id' => $this->id() ] ) |
1342 | ->caller( __METHOD__ ) |
1343 | ->execute(); |
1344 | |
1345 | // @phan-suppress-next-line PhanTypeMismatchReturnNullable Would crash above if null |
1346 | return $thread; |
1347 | } |
1348 | |
1349 | public function isTopmostThread() { |
1350 | return $this->ancestorId == $this->id || |
1351 | $this->parentId == 0; |
1352 | } |
1353 | |
1354 | public function setArticle( Article $a ) { |
1355 | $this->articleId = $a->getPage()->getId(); |
1356 | $this->articleNamespace = $a->getTitle()->getNamespace(); |
1357 | $this->articleTitle = $a->getTitle()->getDBkey(); |
1358 | $this->touch(); |
1359 | } |
1360 | |
1361 | public function touch() { |
1362 | // Nothing here yet |
1363 | } |
1364 | |
1365 | /** |
1366 | * @return Article |
1367 | */ |
1368 | public function article() { |
1369 | if ( $this->article ) { |
1370 | return $this->article; |
1371 | } |
1372 | |
1373 | if ( $this->articleId !== null ) { |
1374 | if ( isset( self::$articleCacheById[$this->articleId] ) ) { |
1375 | return self::$articleCacheById[$this->articleId]; |
1376 | } |
1377 | |
1378 | if ( isset( self::$titleCacheById[$this->articleId] ) ) { |
1379 | $title = self::$titleCacheById[$this->articleId]; |
1380 | } else { |
1381 | $title = Title::newFromID( $this->articleId ); |
1382 | } |
1383 | |
1384 | if ( $title ) { |
1385 | $article = new Article( $title, 0 ); |
1386 | self::$articleCacheById[$this->articleId] = $article; |
1387 | } |
1388 | } |
1389 | |
1390 | if ( isset( $article ) && $article->getPage()->exists() ) { |
1391 | $this->article = $article; |
1392 | return $article; |
1393 | } else { |
1394 | $title = Title::makeTitle( $this->articleNamespace, $this->articleTitle ); |
1395 | return new Article( $title, 0 ); |
1396 | } |
1397 | } |
1398 | |
1399 | /** |
1400 | * @return int |
1401 | */ |
1402 | public function id() { |
1403 | return $this->id; |
1404 | } |
1405 | |
1406 | /** |
1407 | * @return int|null |
1408 | */ |
1409 | public function ancestorId() { |
1410 | return $this->ancestorId; |
1411 | } |
1412 | |
1413 | /** |
1414 | * The 'root' is the page in the Thread namespace corresponding to this thread. |
1415 | * |
1416 | * @return Article|null |
1417 | */ |
1418 | public function root() { |
1419 | if ( !$this->rootId ) { |
1420 | return null; |
1421 | } |
1422 | if ( !$this->root ) { |
1423 | if ( isset( self::$articleCacheById[$this->rootId] ) ) { |
1424 | $this->root = self::$articleCacheById[$this->rootId]; |
1425 | return $this->root; |
1426 | } |
1427 | |
1428 | if ( isset( self::$titleCacheById[$this->rootId] ) ) { |
1429 | $title = self::$titleCacheById[$this->rootId]; |
1430 | } else { |
1431 | $title = Title::newFromID( $this->rootId ); |
1432 | } |
1433 | |
1434 | if ( !$title && $this->type() != Threads::TYPE_DELETED ) { |
1435 | if ( !$this->isHistorical() ) { |
1436 | $this->delete( '', false /* !commit */ ); |
1437 | } else { |
1438 | $this->type = Threads::TYPE_DELETED; |
1439 | } |
1440 | } |
1441 | |
1442 | if ( !$title ) { |
1443 | return null; |
1444 | } |
1445 | |
1446 | $this->root = new Article( $title, 0 ); |
1447 | } |
1448 | return $this->root; |
1449 | } |
1450 | |
1451 | /** |
1452 | * @return int |
1453 | */ |
1454 | public function editedness() { |
1455 | return $this->editedness; |
1456 | } |
1457 | |
1458 | /** |
1459 | * @return Article|null |
1460 | */ |
1461 | public function summary() { |
1462 | if ( !$this->summaryId ) { |
1463 | return null; |
1464 | } |
1465 | |
1466 | if ( !$this->summary ) { |
1467 | $title = Title::newFromID( $this->summaryId ); |
1468 | |
1469 | if ( !$title ) { |
1470 | wfDebug( __METHOD__ . ": supposed summary doesn't exist" ); |
1471 | $this->summaryId = null; |
1472 | return null; |
1473 | } |
1474 | |
1475 | $this->summary = new Article( $title, 0 ); |
1476 | } |
1477 | |
1478 | return $this->summary; |
1479 | } |
1480 | |
1481 | public function hasSummary() { |
1482 | return $this->summaryId != null; |
1483 | } |
1484 | |
1485 | /** |
1486 | * @param Article $post |
1487 | */ |
1488 | public function setSummary( $post ) { |
1489 | // Weird -- this was setting $this->summary to NULL before I changed it. |
1490 | // If there was some reason why, please tell me! -- Andrew |
1491 | $this->summary = $post; |
1492 | $this->summaryId = $post->getPage()->getId(); |
1493 | } |
1494 | |
1495 | /** |
1496 | * @return Title|null |
1497 | */ |
1498 | public function title() { |
1499 | if ( is_object( $this->root() ) ) { |
1500 | return $this->root()->getTitle(); |
1501 | } else { |
1502 | // wfWarn( "Thread ".$this->id()." has no title." ); |
1503 | return null; |
1504 | } |
1505 | } |
1506 | |
1507 | public static function splitIncrementFromSubject( $subject_string ) { |
1508 | preg_match( '/^(.*) \((\d+)\)$/', $subject_string, $matches ); |
1509 | if ( count( $matches ) != 3 ) { |
1510 | throw new LogicException( |
1511 | __METHOD__ . ": thread subject has no increment: " . $subject_string |
1512 | ); |
1513 | } else { |
1514 | return $matches; |
1515 | } |
1516 | } |
1517 | |
1518 | public function subject() { |
1519 | return $this->subject; |
1520 | } |
1521 | |
1522 | public function formattedSubject() { |
1523 | return LqtView::formatSubject( $this->subject() ); |
1524 | } |
1525 | |
1526 | public function setSubject( $subject ) { |
1527 | $this->subject = $subject; |
1528 | |
1529 | foreach ( $this->replies() as $reply ) { |
1530 | $reply->setSubject( $subject ); |
1531 | } |
1532 | } |
1533 | |
1534 | /** |
1535 | * Currently equivalent to isTopmostThread. |
1536 | * |
1537 | * @return bool |
1538 | */ |
1539 | public function hasDistinctSubject() { |
1540 | return $this->isTopmostThread(); |
1541 | } |
1542 | |
1543 | /** |
1544 | * Synonym for replies() |
1545 | * |
1546 | * @return self[] |
1547 | */ |
1548 | public function subthreads() { |
1549 | return $this->replies(); |
1550 | } |
1551 | |
1552 | public function modified() { |
1553 | return $this->modified; |
1554 | } |
1555 | |
1556 | public function created() { |
1557 | return $this->created; |
1558 | } |
1559 | |
1560 | public function type() { |
1561 | return $this->type; |
1562 | } |
1563 | |
1564 | public function setType( $t ) { |
1565 | $this->type = $t; |
1566 | } |
1567 | |
1568 | public function getAnchorName() { |
1569 | $wantedId = $this->subject() . "_{$this->id()}"; |
1570 | return Sanitizer::escapeIdForLink( $wantedId ); |
1571 | } |
1572 | |
1573 | public function updateHistory() { |
1574 | } |
1575 | |
1576 | public function setAuthor( UserIdentity $user ) { |
1577 | $this->authorId = $user->getId(); |
1578 | $this->authorName = $user->getName(); |
1579 | } |
1580 | |
1581 | /** |
1582 | * Load all lazy-loaded data in prep for (e.g.) serialization. |
1583 | */ |
1584 | public function loadAllData() { |
1585 | // Make sure superthread and topmost thread are loaded. |
1586 | $this->superthread(); |
1587 | $this->topmostThread(); |
1588 | |
1589 | // Make sure replies, and all the data therein, is loaded. |
1590 | foreach ( $this->replies() as $reply ) { |
1591 | $reply->loadAllData(); |
1592 | } |
1593 | } |
1594 | |
1595 | /** |
1596 | * On serialization, load all data because it will be different in the DB when we wake up. |
1597 | * |
1598 | * @return string[] |
1599 | */ |
1600 | public function __sleep() { |
1601 | $this->loadAllData(); |
1602 | |
1603 | $fields = array_keys( get_object_vars( $this ) ); |
1604 | |
1605 | // Filter out article objects, there be dragons (or unserialization problems) |
1606 | $fields = array_diff( $fields, [ 'root', 'article', 'summary', 'sleeping', |
1607 | 'dbVersion' ] ); |
1608 | |
1609 | return $fields; |
1610 | } |
1611 | |
1612 | public function __wakeup() { |
1613 | // Mark as historical. |
1614 | $this->isHistorical = true; |
1615 | } |
1616 | |
1617 | /** |
1618 | * This is a safety valve that makes sure that the DB is NEVER touched by a historical thread |
1619 | * (even for reading, because the data will be out of date). |
1620 | * |
1621 | * @throws Exception |
1622 | */ |
1623 | public function dieIfHistorical() { |
1624 | if ( $this->isHistorical() ) { |
1625 | throw new LogicException( "Attempted write or DB operation on historical thread" ); |
1626 | } |
1627 | } |
1628 | |
1629 | /** |
1630 | * @return int|null |
1631 | */ |
1632 | public function rootRevision() { |
1633 | if ( !$this->isHistorical() || |
1634 | !isset( $this->topmostThread()->threadRevision ) || |
1635 | !$this->root() |
1636 | ) { |
1637 | return null; |
1638 | } |
1639 | |
1640 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
1641 | |
1642 | $revision = $this->topmostThread()->threadRevision; |
1643 | $timestamp = $dbr->timestamp( $revision->getTimestamp() ); |
1644 | |
1645 | $row = $dbr->newSelectQueryBuilder() |
1646 | ->select( '*' ) |
1647 | ->from( 'revision' ) |
1648 | ->join( 'page', null, 'rev_page=page_id' ) |
1649 | ->where( [ |
1650 | $dbr->expr( 'rev_timestamp', '<=', $timestamp ), |
1651 | 'page_namespace' => $this->root()->getTitle()->getNamespace(), |
1652 | 'page_title' => $this->root()->getTitle()->getDBkey(), |
1653 | ] ) |
1654 | ->caller( __METHOD__ ) |
1655 | ->orderBy( 'rev_timestamp', SelectQueryBuilder::SORT_DESC ) |
1656 | ->fetchRow(); |
1657 | |
1658 | return $row->rev_id; |
1659 | } |
1660 | |
1661 | public function sortkey() { |
1662 | return $this->sortkey; |
1663 | } |
1664 | |
1665 | public function setSortKey( $k = null ) { |
1666 | if ( $k === null ) { |
1667 | $k = wfTimestamp( TS_MW ); |
1668 | } |
1669 | |
1670 | $this->sortkey = $k; |
1671 | } |
1672 | |
1673 | public function replyWithId( $id ) { |
1674 | if ( $this->id() == $id ) { |
1675 | return $this; |
1676 | } |
1677 | |
1678 | foreach ( $this->replies() as $reply ) { |
1679 | $obj = $reply->replyWithId( $id ); |
1680 | if ( $obj ) { |
1681 | return $obj; |
1682 | } |
1683 | } |
1684 | |
1685 | return null; |
1686 | } |
1687 | |
1688 | public static function createdSortCallback( self $a, self $b ) { |
1689 | $a = $a->created(); |
1690 | $b = $b->created(); |
1691 | |
1692 | if ( $a == $b ) { |
1693 | return 0; |
1694 | } elseif ( $a > $b ) { |
1695 | return 1; |
1696 | } else { |
1697 | return -1; |
1698 | } |
1699 | } |
1700 | |
1701 | public function split( $newSubject, $reason = '', $newSortkey = null ) { |
1702 | $oldTopThread = $this->topmostThread(); |
1703 | $oldParent = $this->superthread(); |
1704 | |
1705 | $original = $this->dbVersion; |
1706 | |
1707 | self::recursiveSet( $this, $newSubject, $this, null ); |
1708 | |
1709 | $oldParent->removeReply( $this ); |
1710 | |
1711 | $bump = null; |
1712 | if ( $newSortkey !== null ) { |
1713 | $this->setSortKey( $newSortkey ); |
1714 | $bump = false; |
1715 | } |
1716 | |
1717 | // For logging purposes, will be reset by the time this call returns. |
1718 | $this->dbVersion = $original; |
1719 | $user = RequestContext::getMain()->getUser(); // Need to inject |
1720 | |
1721 | $this->commitRevision( Threads::CHANGE_SPLIT, $user, null, $reason, $bump ); |
1722 | $oldTopThread->commitRevision( Threads::CHANGE_SPLIT_FROM, $user, $this, $reason ); |
1723 | } |
1724 | |
1725 | public function moveToParent( self $newParent, $reason = '' ) { |
1726 | $newSubject = $newParent->subject(); |
1727 | |
1728 | $original = $this->dbVersion; |
1729 | |
1730 | $oldTopThread = $newParent->topmostThread(); |
1731 | $oldParent = $this->superthread(); |
1732 | $newTopThread = $newParent->topmostThread(); |
1733 | |
1734 | self::recursiveSet( $this, $newSubject, $newTopThread, $newParent ); |
1735 | |
1736 | $newParent->addReply( $this ); |
1737 | |
1738 | if ( $oldParent ) { |
1739 | $oldParent->removeReply( $this ); |
1740 | } |
1741 | |
1742 | $this->dbVersion = $original; |
1743 | $user = RequestContext::getMain()->getUser(); // Need to inject |
1744 | |
1745 | $oldTopThread->commitRevision( Threads::CHANGE_MERGED_FROM, $user, $this, $reason ); |
1746 | $newParent->commitRevision( Threads::CHANGE_MERGED_TO, $user, $this, $reason ); |
1747 | } |
1748 | |
1749 | /** |
1750 | * @param self $thread |
1751 | * @param string $subject |
1752 | * @param self $ancestor |
1753 | * @param self|null $superthread |
1754 | */ |
1755 | public static function recursiveSet( |
1756 | self $thread, |
1757 | $subject, |
1758 | self $ancestor, |
1759 | $superthread = null |
1760 | ) { |
1761 | $thread->setSubject( $subject ); |
1762 | $thread->setAncestor( $ancestor->id() ); |
1763 | |
1764 | if ( $superthread ) { |
1765 | $thread->setSuperThread( $superthread ); |
1766 | } |
1767 | |
1768 | $thread->save(); |
1769 | |
1770 | foreach ( $thread->replies() as $subThread ) { |
1771 | self::recursiveSet( $subThread, $subject, $ancestor ); |
1772 | } |
1773 | } |
1774 | |
1775 | public static function validateSubject( $subject, User $user, &$title, $replyTo, $article ) { |
1776 | $t = null; |
1777 | $ok = true; |
1778 | |
1779 | while ( !$t ) { |
1780 | try { |
1781 | if ( !$replyTo && $subject ) { |
1782 | $t = Threads::newThreadTitle( $subject, $article ); |
1783 | } elseif ( $replyTo ) { |
1784 | $t = Threads::newReplyTitle( $replyTo, $user ); |
1785 | } |
1786 | |
1787 | if ( $t ) { |
1788 | break; |
1789 | } |
1790 | } catch ( Exception $e ) { |
1791 | } |
1792 | |
1793 | $subject = md5( (string)mt_rand() ); // Just a random title |
1794 | $ok = false; |
1795 | } |
1796 | |
1797 | $title = $t; |
1798 | |
1799 | return $ok; |
1800 | } |
1801 | |
1802 | /** |
1803 | * Returns true, or a string with either thread or talkpage, noting which is protected |
1804 | * |
1805 | * @param User $user |
1806 | * @param string $rigor |
1807 | * @return bool|string |
1808 | */ |
1809 | public function canUserReply( User $user, $rigor = PermissionManager::RIGOR_SECURE ) { |
1810 | $rootTitle = $this->topmostThread()->title(); |
1811 | $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore(); |
1812 | $threadRestrictions = $rootTitle ? $restrictionStore->getRestrictions( $rootTitle, 'reply' ) : []; |
1813 | $talkpageRestrictions = $restrictionStore->getRestrictions( $this->getTitle(), 'reply' ); |
1814 | |
1815 | $threadRestrictions = array_fill_keys( $threadRestrictions, 'thread' ); |
1816 | $talkpageRestrictions = array_fill_keys( $talkpageRestrictions, 'talkpage' ); |
1817 | |
1818 | $restrictions = array_merge( $threadRestrictions, $talkpageRestrictions ); |
1819 | |
1820 | foreach ( $restrictions as $right => $source ) { |
1821 | if ( $right == 'sysop' ) { |
1822 | $right = 'protect'; |
1823 | } |
1824 | if ( !$user->isAllowed( $right ) ) { |
1825 | return $source; |
1826 | } |
1827 | } |
1828 | |
1829 | return self::canUserCreateThreads( $user, $rigor ); |
1830 | } |
1831 | |
1832 | /** |
1833 | * @param User $user |
1834 | * @param Article $talkpage |
1835 | * @param string $rigor |
1836 | * @return bool |
1837 | */ |
1838 | public static function canUserPost( $user, $talkpage, $rigor = PermissionManager::RIGOR_SECURE ) { |
1839 | $restrictions = MediaWikiServices::getInstance()->getRestrictionStore() |
1840 | ->getRestrictions( $talkpage->getTitle(), 'newthread' ); |
1841 | |
1842 | foreach ( $restrictions as $right ) { |
1843 | if ( !$user->isAllowed( $right ) ) { |
1844 | return false; |
1845 | } |
1846 | } |
1847 | |
1848 | return self::canUserCreateThreads( $user, $rigor ); |
1849 | } |
1850 | |
1851 | /** |
1852 | * Generally, not some specific page |
1853 | * |
1854 | * @param User $user |
1855 | * @param string $rigor |
1856 | * @return bool |
1857 | */ |
1858 | public static function canUserCreateThreads( $user, $rigor = PermissionManager::RIGOR_SECURE ) { |
1859 | $userText = $user->getName(); |
1860 | $pm = MediaWikiServices::getInstance()->getPermissionManager(); |
1861 | |
1862 | static $canCreateNew = []; |
1863 | if ( !isset( $canCreateNew[$userText] ) ) { |
1864 | $title = Title::makeTitleSafe( |
1865 | NS_LQT_THREAD, 'Test title for LQT thread creation check' ); |
1866 | $canCreateNew[$userText] = $pm->userCan( 'edit', $user, $title, $rigor ); |
1867 | } |
1868 | |
1869 | return $canCreateNew[$userText]; |
1870 | } |
1871 | |
1872 | /** |
1873 | * @return string|null Signature wikitext, may be null for very old unserialized comments (T365495) |
1874 | */ |
1875 | public function signature() { |
1876 | return $this->signature; |
1877 | } |
1878 | |
1879 | public function setSignature( $sig ) { |
1880 | $sig = LqtView::signaturePST( $sig, $this->author() ); |
1881 | $this->signature = $sig; |
1882 | } |
1883 | |
1884 | public function editors() { |
1885 | if ( $this->editors === null ) { |
1886 | if ( $this->editedness() < Threads::EDITED_BY_AUTHOR ) { |
1887 | return []; |
1888 | } elseif ( $this->editedness == Threads::EDITED_BY_AUTHOR ) { |
1889 | return [ $this->author()->getName() ]; |
1890 | } |
1891 | |
1892 | // Load editors |
1893 | $this->editors = []; |
1894 | |
1895 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
1896 | $revQuery = self::getRevisionQueryInfo(); |
1897 | $res = $dbr->newSelectQueryBuilder() |
1898 | ->select( [ 'rev_user_text' => $revQuery['fields']['rev_user_text'] ] ) |
1899 | ->tables( $revQuery['tables'] ) |
1900 | ->where( [ |
1901 | 'rev_page' => $this->root()->getPage()->getId(), |
1902 | $dbr->expr( 'rev_parent_id', '!=', 0 ), |
1903 | ] ) |
1904 | ->caller( __METHOD__ ) |
1905 | ->joinConds( $revQuery['joins'] ) |
1906 | ->fetchResultSet(); |
1907 | |
1908 | $editors = []; |
1909 | foreach ( $res as $row ) { |
1910 | $editors[$row->rev_user_text] = 1; |
1911 | } |
1912 | |
1913 | $this->editors = array_keys( $editors ); |
1914 | } |
1915 | |
1916 | return $this->editors; |
1917 | } |
1918 | |
1919 | public function setEditors( $e ) { |
1920 | $this->editors = $e; |
1921 | } |
1922 | |
1923 | public function addEditor( $e ) { |
1924 | $this->editors[] = $e; |
1925 | $this->editors = array_unique( $this->editors ); |
1926 | } |
1927 | |
1928 | /** |
1929 | * @return Title the Title object for the article this thread is attached to. |
1930 | */ |
1931 | public function getTitle() { |
1932 | return $this->article()->getTitle(); |
1933 | } |
1934 | |
1935 | public function getReactions( $requestedType = null ) { |
1936 | if ( $this->reactions === null ) { |
1937 | if ( isset( self::$reactionCacheById[$this->id()] ) ) { |
1938 | $this->reactions = self::$reactionCacheById[$this->id()]; |
1939 | } else { |
1940 | $reactions = []; |
1941 | |
1942 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
1943 | |
1944 | $res = $dbr->newSelectQueryBuilder() |
1945 | ->select( [ 'tr_user', 'tr_user_text', 'tr_type', 'tr_value' ] ) |
1946 | ->from( 'thread_reaction' ) |
1947 | ->where( [ 'tr_thread' => $this->id() ] ) |
1948 | ->caller( __METHOD__ ) |
1949 | ->fetchResultSet(); |
1950 | |
1951 | foreach ( $res as $row ) { |
1952 | $user = $row->tr_user_text; |
1953 | $type = $row->tr_type; |
1954 | $info = [ |
1955 | 'type' => $type, |
1956 | 'user-id' => $row->tr_user, |
1957 | 'user-name' => $row->tr_user_text, |
1958 | 'value' => $row->tr_value, |
1959 | ]; |
1960 | |
1961 | if ( !isset( $reactions[$type] ) ) { |
1962 | $reactions[$type] = []; |
1963 | } |
1964 | |
1965 | $reactions[$type][$user] = $info; |
1966 | } |
1967 | |
1968 | $this->reactions = $reactions; |
1969 | } |
1970 | } |
1971 | |
1972 | if ( $requestedType === null ) { |
1973 | return $this->reactions; |
1974 | } else { |
1975 | return $this->reactions[$requestedType]; |
1976 | } |
1977 | } |
1978 | |
1979 | public function addReaction( $user, $type, $value ) { |
1980 | $info = [ |
1981 | 'type' => $type, |
1982 | 'user-id' => $user->getId(), |
1983 | 'user-name' => $user->getName(), |
1984 | 'value' => $value, |
1985 | ]; |
1986 | |
1987 | if ( !isset( $this->reactions[$type] ) ) { |
1988 | $this->reactions[$type] = []; |
1989 | } |
1990 | |
1991 | $this->reactions[$type][$user->getName()] = $info; |
1992 | |
1993 | $row = [ |
1994 | 'tr_type' => $type, |
1995 | 'tr_thread' => $this->id(), |
1996 | 'tr_user' => $user->getId(), |
1997 | 'tr_user_text' => $user->getName(), |
1998 | 'tr_value' => $value, |
1999 | ]; |
2000 | |
2001 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
2002 | |
2003 | $dbw->newInsertQueryBuilder() |
2004 | ->insertInto( 'thread_reaction' ) |
2005 | ->row( $row ) |
2006 | ->caller( __METHOD__ ) |
2007 | ->execute(); |
2008 | } |
2009 | |
2010 | public function deleteReaction( $user, $type ) { |
2011 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
2012 | |
2013 | if ( isset( $this->reactions[$type][$user->getName()] ) ) { |
2014 | unset( $this->reactions[$type][$user->getName()] ); |
2015 | } |
2016 | |
2017 | $dbw->newDeleteQueryBuilder() |
2018 | ->deleteFrom( 'thread_reaction' ) |
2019 | ->where( [ |
2020 | 'tr_thread' => $this->id(), |
2021 | 'tr_user' => $user->getId(), |
2022 | 'tr_type' => $type |
2023 | ] ) |
2024 | ->caller( __METHOD__ ) |
2025 | ->execute(); |
2026 | } |
2027 | |
2028 | /** |
2029 | * @return array[] |
2030 | */ |
2031 | private static function getRevisionQueryInfo() { |
2032 | $info = MediaWikiServices::getInstance()->getRevisionStore()->getQueryInfo(); |
2033 | if ( !isset( $info['fields']['rev_user_text'] ) ) { |
2034 | $info['fields']['rev_user_text'] = 'rev_user_text'; |
2035 | } |
2036 | |
2037 | return $info; |
2038 | } |
2039 | } |