Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
78.45% |
222 / 283 |
|
36.36% |
4 / 11 |
CRAP | |
0.00% |
0 / 1 |
TalkpageImportOperation | |
78.45% |
222 / 283 |
|
36.36% |
4 / 11 |
48.98 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
import | |
69.74% |
53 / 76 |
|
0.00% |
0 / 1 |
11.25 | |||
importHeader | |
88.24% |
30 / 34 |
|
0.00% |
0 / 1 |
4.03 | |||
importTopic | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
getTopicState | |
37.50% |
3 / 8 |
|
0.00% |
0 / 1 |
2.98 | |||
getFirstRevision | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
createTopicState | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
1 | |||
getExistingTopicState | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
6.99 | |||
importSummary | |
86.11% |
31 / 36 |
|
0.00% |
0 / 1 |
3.02 | |||
importPost | |
95.24% |
40 / 42 |
|
0.00% |
0 / 1 |
4 | |||
importObjectWithHistory | |
41.94% |
13 / 31 |
|
0.00% |
0 / 1 |
7.13 |
1 | <?php |
2 | |
3 | namespace Flow\Import; |
4 | |
5 | use Flow\Import\SourceStore\Exception as ImportSourceStoreException; |
6 | use Flow\Model\AbstractRevision; |
7 | use Flow\Model\Header; |
8 | use Flow\Model\PostRevision; |
9 | use Flow\Model\PostSummary; |
10 | use Flow\Model\TopicListEntry; |
11 | use Flow\Model\Workflow; |
12 | use Flow\OccupationController; |
13 | use MediaWiki\MediaWikiServices; |
14 | use MediaWiki\Title\Title; |
15 | use MediaWiki\User\User; |
16 | use MediaWiki\Utils\MWTimestamp; |
17 | |
18 | class TalkpageImportOperation { |
19 | /** |
20 | * @var IImportSource |
21 | */ |
22 | protected $importSource; |
23 | |
24 | /** @var User User doing the conversion actions (e.g. initial description, wikitext |
25 | * archive edit). However, actions will be attributed to the original user when |
26 | * possible (e.g. the user who did the original LQT reply) |
27 | */ |
28 | protected $user; |
29 | |
30 | /** @var OccupationController */ |
31 | protected $occupationController; |
32 | |
33 | /** |
34 | * @param IImportSource $source |
35 | * @param User $user The import user; this will only be used when there is no |
36 | * 'original' user |
37 | * @param OccupationController $occupationController |
38 | */ |
39 | public function __construct( IImportSource $source, User $user, OccupationController $occupationController ) { |
40 | $this->importSource = $source; |
41 | $this->user = $user; |
42 | $this->occupationController = $occupationController; |
43 | } |
44 | |
45 | /** |
46 | * @param PageImportState $state |
47 | * @return bool True if import completed successfully |
48 | * @throws ImportSourceStoreException |
49 | * @throws \Exception |
50 | */ |
51 | public function import( PageImportState $state ) { |
52 | $destinationTitle = $state->boardWorkflow->getArticleTitle(); |
53 | $state->logger->info( 'Importing to ' . $destinationTitle->getPrefixedText() ); |
54 | $isNew = $state->boardWorkflow->isNew(); |
55 | $state->logger->debug( 'Workflow isNew: ' . var_export( $isNew, true ) ); |
56 | if ( $isNew ) { |
57 | // Explicitly allow creation of board |
58 | $creationStatus = $this->occupationController->safeAllowCreation( |
59 | $destinationTitle, |
60 | $this->user, |
61 | /* $mustNotExist = */ true |
62 | ); |
63 | if ( !$creationStatus->isGood() ) { |
64 | throw new ImportException( |
65 | "safeAllowCreation failed to allow the import destination, with the following error:\n" . |
66 | $creationStatus->getWikiText() |
67 | ); |
68 | } |
69 | |
70 | // Makes sure the page exists and a Flow-specific revision has been inserted |
71 | $status = $this->occupationController->ensureFlowRevision( |
72 | MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $destinationTitle ), |
73 | $state->boardWorkflow |
74 | ); |
75 | $state->logger->debug( |
76 | 'ensureFlowRevision status isOK: ' . var_export( $status->isOK(), true ) |
77 | ); |
78 | $state->logger->debug( |
79 | 'ensureFlowRevision status isGood: ' . var_export( $status->isGood(), true ) |
80 | ); |
81 | |
82 | if ( $status->isOK() ) { |
83 | $ensureValue = $status->getValue(); |
84 | $revisionRecord = $ensureValue['revision-record']; |
85 | $state->logger->debug( |
86 | 'ensureFlowRevision already-existed: ' . var_export( |
87 | $ensureValue['already-existed'], |
88 | true |
89 | ) |
90 | ); |
91 | $revisionId = $revisionRecord->getId(); |
92 | $pageId = $revisionRecord->getPageId(); |
93 | $state->logger->debug( |
94 | "ensureFlowRevision revision ID: $revisionId, page ID: $pageId" |
95 | ); |
96 | |
97 | $state->put( $state->boardWorkflow, [] ); |
98 | } else { |
99 | throw new ImportException( "ensureFlowRevision failed to create the Flow board" ); |
100 | } |
101 | } |
102 | |
103 | $imported = $failed = 0; |
104 | $header = $this->importSource->getHeader(); |
105 | try { |
106 | $state->begin(); |
107 | $this->importHeader( $state, $header ); |
108 | $state->commit(); |
109 | $state->postprocessor->afterHeaderImported( $state, $header ); |
110 | $imported++; |
111 | } catch ( ImportSourceStoreException $e ) { |
112 | // errors from the source store are more serious and should |
113 | // not just be logged and swallowed. This may indicate that |
114 | // we are not properly recording progress. |
115 | $state->rollback(); |
116 | throw $e; |
117 | } catch ( \Exception $e ) { |
118 | $state->rollback(); |
119 | \MWExceptionHandler::logException( $e ); |
120 | $state->logger->error( 'Failed importing header: ' . $header->getObjectKey() ); |
121 | $state->logger->error( (string)$e ); |
122 | $failed++; |
123 | } |
124 | |
125 | foreach ( $this->importSource->getTopics() as $topic ) { |
126 | try { |
127 | // @todo this may be too large of a chunk for one commit, unsure |
128 | $state->begin(); |
129 | $topicState = $this->getTopicState( $state, $topic ); |
130 | $this->importTopic( $topicState, $topic ); |
131 | $state->commit(); |
132 | $state->postprocessor->afterTopicImported( $topicState, $topic ); |
133 | $state->clearManagerGroup(); |
134 | |
135 | $imported++; |
136 | } catch ( ImportSourceStoreException $e ) { |
137 | // errors from the source store are more serious and shuld |
138 | // not juts be logged and swallowed. This may indicate that |
139 | // we are not properly recording progress. |
140 | $state->rollback(); |
141 | throw $e; |
142 | } catch ( \Exception $e ) { |
143 | $state->rollback(); |
144 | \MWExceptionHandler::logException( $e ); |
145 | $state->logger->error( 'Failed importing topic: ' . $topic->getObjectKey() ); |
146 | $state->logger->error( (string)$e ); |
147 | $failed++; |
148 | } |
149 | } |
150 | $state->logger->info( "Imported $imported items, failed $failed" ); |
151 | |
152 | return $failed === 0; |
153 | } |
154 | |
155 | /** |
156 | * @param PageImportState $pageState |
157 | * @param IImportHeader $importHeader |
158 | */ |
159 | public function importHeader( PageImportState $pageState, IImportHeader $importHeader ) { |
160 | $pageState->logger->info( 'Importing header' ); |
161 | if ( !$importHeader->getRevisions()->valid() ) { |
162 | $pageState->logger->info( 'no revisions located for header' ); |
163 | |
164 | // No revisions |
165 | return; |
166 | } |
167 | |
168 | /* |
169 | * We don't need $pageState->getImportedId( $importHeader ) here, there |
170 | * can only be 1 header per workflow and we already know the workflow, |
171 | * might as well query it from the workflow instead of using the id from |
172 | * the source store. |
173 | * reason I prefer not to use source store is that a header import is |
174 | * incomplete (it doesn't import full history, just the last revision. |
175 | */ |
176 | $existingId = $pageState->boardWorkflow->getId(); |
177 | if ( $existingId && $pageState->getTopRevision( 'Header', $existingId ) ) { |
178 | $pageState->logger->info( 'header previously imported' ); |
179 | |
180 | return; |
181 | } |
182 | |
183 | $revisions = $this->importObjectWithHistory( |
184 | $importHeader, |
185 | static function ( IObjectRevision $rev ) use ( $pageState ) { |
186 | return Header::create( |
187 | $pageState->boardWorkflow, |
188 | $pageState->createUser( $rev->getAuthor() ), |
189 | $rev->getText(), |
190 | 'wikitext', |
191 | 'create-header' |
192 | ); |
193 | }, |
194 | 'edit-header', |
195 | $pageState, |
196 | $pageState->boardWorkflow->getArticleTitle() |
197 | ); |
198 | |
199 | $pageState->put( |
200 | $revisions, |
201 | [ |
202 | 'workflow' => $pageState->boardWorkflow, |
203 | ] |
204 | ); |
205 | $pageState->recordAssociation( |
206 | reset( $revisions )->getCollectionId(), |
207 | $importHeader |
208 | ); |
209 | |
210 | $pageState->logger->info( 'Imported ' . count( $revisions ) . ' revisions for header' ); |
211 | } |
212 | |
213 | /** |
214 | * @param TopicImportState $topicState |
215 | * @param IImportTopic $importTopic |
216 | */ |
217 | public function importTopic( TopicImportState $topicState, IImportTopic $importTopic ) { |
218 | $summary = $importTopic->getTopicSummary(); |
219 | if ( $summary ) { |
220 | $this->importSummary( $topicState, $summary ); |
221 | } |
222 | |
223 | foreach ( $importTopic->getReplies() as $post ) { |
224 | $this->importPost( $topicState, $post, $topicState->topicTitle ); |
225 | } |
226 | |
227 | $topicState->commitLastUpdated(); |
228 | $topicState->parent->logger->info( "Finished importing topic" ); |
229 | } |
230 | |
231 | /** |
232 | * @param PageImportState $state |
233 | * @param IImportTopic $importTopic |
234 | * @return TopicImportState |
235 | */ |
236 | protected function getTopicState( PageImportState $state, IImportTopic $importTopic ) { |
237 | // Check if it's already been imported |
238 | $topicState = $this->getExistingTopicState( $state, $importTopic ); |
239 | if ( $topicState ) { |
240 | $state->logger->info( |
241 | 'Continuing import to ' . $topicState->topicWorkflow->getArticleTitle( |
242 | )->getPrefixedText() |
243 | ); |
244 | |
245 | return $topicState; |
246 | } else { |
247 | return $this->createTopicState( $state, $importTopic ); |
248 | } |
249 | } |
250 | |
251 | protected function getFirstRevision( IRevisionableObject $obj ) { |
252 | $iterator = $obj->getRevisions(); |
253 | $iterator->rewind(); |
254 | |
255 | return $iterator->current(); |
256 | } |
257 | |
258 | /** |
259 | * @param PageImportState $state |
260 | * @param IImportTopic $importTopic |
261 | * @return TopicImportState |
262 | */ |
263 | protected function createTopicState( PageImportState $state, IImportTopic $importTopic ) { |
264 | $state->logger->info( 'Importing new topic' ); |
265 | $topicWorkflow = Workflow::create( |
266 | 'topic', |
267 | $state->boardWorkflow->getArticleTitle() |
268 | ); |
269 | $state->setWorkflowTimestamp( |
270 | $topicWorkflow, |
271 | $this->getFirstRevision( $importTopic )->getTimestamp() |
272 | ); |
273 | |
274 | $topicListEntry = TopicListEntry::create( |
275 | $state->boardWorkflow, |
276 | $topicWorkflow |
277 | ); |
278 | |
279 | $titleRevisions = $this->importObjectWithHistory( |
280 | $importTopic, |
281 | static function ( IObjectRevision $rev ) use ( $state, $topicWorkflow ) { |
282 | return PostRevision::createTopicPost( |
283 | $topicWorkflow, |
284 | $state->createUser( $rev->getAuthor() ), |
285 | $rev->getText() |
286 | ); |
287 | }, |
288 | 'edit-title', |
289 | $state, |
290 | $topicWorkflow->getArticleTitle() |
291 | ); |
292 | |
293 | // @phan-suppress-next-line PhanTypeMismatchArgumentSuperType |
294 | $topicState = new TopicImportState( $state, $topicWorkflow, end( $titleRevisions ) ); |
295 | $topicMetadata = $topicState->getMetadata(); |
296 | |
297 | // This should all match the order in TopicListBlock->commit (board/ |
298 | // discussion workflow is inserted before this method is called). |
299 | |
300 | $state->put( $topicWorkflow, $topicMetadata ); |
301 | // TLE must be before topic title, otherwise you get an error importing the Topic Title |
302 | // Flow/includes/Data/Index/BoardHistoryIndex.php: |
303 | // No topic list contains topic XXX, called for revision YYY |
304 | $state->put( $topicListEntry, $topicMetadata ); |
305 | $state->put( $titleRevisions, $topicMetadata ); |
306 | |
307 | $state->recordAssociation( $topicWorkflow->getId(), $importTopic ); |
308 | |
309 | $state->logger->info( |
310 | 'Finished importing topic title with ' . count( $titleRevisions ) . ' revisions' |
311 | ); |
312 | |
313 | return $topicState; |
314 | } |
315 | |
316 | /** |
317 | * @param PageImportState $state |
318 | * @param IImportTopic $importTopic |
319 | * @return TopicImportState|null |
320 | */ |
321 | protected function getExistingTopicState( PageImportState $state, IImportTopic $importTopic ) { |
322 | $topicId = $state->getImportedId( $importTopic ); |
323 | if ( $topicId ) { |
324 | $topicWorkflow = $state->get( 'Workflow', $topicId ); |
325 | $topicTitle = $state->getTopRevision( 'PostRevision', $topicId ); |
326 | if ( $topicWorkflow instanceof Workflow && $topicTitle instanceof PostRevision ) { |
327 | return new TopicImportState( $state, $topicWorkflow, $topicTitle ); |
328 | } |
329 | } |
330 | |
331 | return null; |
332 | } |
333 | |
334 | /** |
335 | * @param TopicImportState $state |
336 | * @param IImportSummary $importSummary |
337 | */ |
338 | public function importSummary( TopicImportState $state, IImportSummary $importSummary ) { |
339 | $state->parent->logger->info( "Importing summary" ); |
340 | $existingId = $state->parent->getImportedId( $importSummary ); |
341 | if ( $existingId ) { |
342 | $summary = $state->parent->getTopRevision( 'PostSummary', $existingId ); |
343 | if ( $summary ) { |
344 | $state->recordUpdateTime( $summary->getRevisionId() ); |
345 | $state->parent->logger->info( "Summary previously imported" ); |
346 | |
347 | return; |
348 | } |
349 | } |
350 | |
351 | $revisions = $this->importObjectWithHistory( |
352 | $importSummary, |
353 | static function ( IObjectRevision $rev ) use ( $state ) { |
354 | return PostSummary::create( |
355 | $state->topicWorkflow->getArticleTitle(), |
356 | $state->topicTitle, |
357 | $state->parent->createUser( $rev->getAuthor() ), |
358 | $rev->getText(), |
359 | 'wikitext', |
360 | 'create-topic-summary' |
361 | ); |
362 | }, |
363 | 'edit-topic-summary', |
364 | $state->parent, |
365 | $state->topicWorkflow->getArticleTitle() |
366 | ); |
367 | |
368 | $metadata = [ |
369 | 'workflow' => $state->topicWorkflow, |
370 | ]; |
371 | $state->parent->put( $revisions, $metadata ); |
372 | $state->parent->recordAssociation( |
373 | reset( $revisions )->getCollectionId(), // Summary ID |
374 | $importSummary |
375 | ); |
376 | |
377 | $state->recordUpdateTime( end( $revisions )->getRevisionId() ); |
378 | $state->parent->logger->info( |
379 | "Finished importing summary with " . count( $revisions ) . " revisions" |
380 | ); |
381 | } |
382 | |
383 | /** |
384 | * @param TopicImportState $state |
385 | * @param IImportPost $post |
386 | * @param PostRevision $replyTo |
387 | * @param string $logPrefix |
388 | * @suppress PhanTypeMismatchArgument,PhanUndeclaredMethod |
389 | */ |
390 | public function importPost( TopicImportState $state, IImportPost $post, PostRevision $replyTo, $logPrefix = '' ) { |
391 | $state->parent->logger->info( $logPrefix . "Importing post" ); |
392 | $postId = $state->parent->getImportedId( $post ); |
393 | $topRevision = false; |
394 | if ( $postId ) { |
395 | $topRevision = $state->parent->getTopRevision( 'PostRevision', $postId ); |
396 | } |
397 | |
398 | if ( $topRevision ) { |
399 | $state->parent->logger->info( $logPrefix . "Post previously imported" ); |
400 | } else { |
401 | $replyRevisions = $this->importObjectWithHistory( |
402 | $post, |
403 | static function ( IObjectRevision $rev ) use ( $replyTo, $state ) { |
404 | return $replyTo->reply( |
405 | $state->topicWorkflow, |
406 | $state->parent->createUser( $rev->getAuthor() ), |
407 | $rev->getText(), |
408 | 'wikitext' |
409 | ); |
410 | }, |
411 | 'edit-post', |
412 | $state->parent, |
413 | $state->topicWorkflow->getArticleTitle() |
414 | ); |
415 | |
416 | $topRevision = end( $replyRevisions ); |
417 | |
418 | $metadata = [ |
419 | 'workflow' => $state->topicWorkflow, |
420 | 'board-workflow' => $state->parent->boardWorkflow, |
421 | 'topic-title' => $state->topicTitle, |
422 | 'reply-to' => $replyTo, |
423 | ]; |
424 | |
425 | $state->parent->put( $replyRevisions, $metadata ); |
426 | $state->parent->recordAssociation( |
427 | $topRevision->getPostId(), |
428 | $post |
429 | ); |
430 | $state->parent->logger->info( |
431 | $logPrefix . "Finished importing post with " . count( |
432 | $replyRevisions |
433 | ) . " revisions" |
434 | ); |
435 | $state->parent->postprocessor->afterPostImported( $state, $post, $topRevision ); |
436 | } |
437 | |
438 | $state->recordUpdateTime( $topRevision->getRevisionId() ); |
439 | |
440 | foreach ( $post->getReplies() as $subReply ) { |
441 | $this->importPost( $state, $subReply, $topRevision, $logPrefix . ' ' ); |
442 | } |
443 | } |
444 | |
445 | /** |
446 | * Imports an object with all its revisions |
447 | * |
448 | * @param IRevisionableObject $object Object to import. |
449 | * @param callable $importFirstRevision Function which, given the appropriate import revision, |
450 | * creates the Flow revision. |
451 | * @param string $editChangeType The Flow change type (from FlowActions.php) for each new operation. |
452 | * @param PageImportState $state State of the import operation. |
453 | * @param Title $title Title content is rendered against |
454 | * @return AbstractRevision[] Objects to insert into the database. |
455 | * @throws ImportException |
456 | */ |
457 | public function importObjectWithHistory( |
458 | IRevisionableObject $object, |
459 | $importFirstRevision, |
460 | $editChangeType, |
461 | PageImportState $state, |
462 | Title $title |
463 | ) { |
464 | $insertObjects = []; |
465 | $revisions = $object->getRevisions(); |
466 | $revisions->rewind(); |
467 | |
468 | if ( !$revisions->valid() ) { |
469 | throw new ImportException( "Attempted to import empty history" ); |
470 | } |
471 | |
472 | $importRevision = $revisions->current(); |
473 | /** @var AbstractRevision $lastRevision */ |
474 | $insertObjects[] = $lastRevision = $importFirstRevision( $importRevision ); |
475 | $lastTimestamp = $importRevision->getTimestamp(); |
476 | |
477 | $state->setRevisionTimestamp( $lastRevision, $lastTimestamp ); |
478 | $state->recordAssociation( $lastRevision->getRevisionId(), $importRevision ); |
479 | $state->recordAssociation( $lastRevision->getCollectionId(), $importRevision ); |
480 | |
481 | $revisions->next(); |
482 | while ( $revisions->valid() ) { |
483 | $importRevision = $revisions->current(); |
484 | $insertObjects[] = $lastRevision = $lastRevision->newNextRevision( |
485 | $state->createUser( $importRevision->getAuthor() ), |
486 | $importRevision->getText(), |
487 | 'wikitext', |
488 | $editChangeType, |
489 | $title |
490 | ); |
491 | |
492 | $importTimestampObj = new MWTimestamp( $importRevision->getTimestamp() ); |
493 | $lastTimestampObj = new MWTimestamp( $lastTimestamp ); |
494 | $timeDiff = $lastTimestampObj->diff( $importTimestampObj ); |
495 | // If $import - last < 0 |
496 | if ( $timeDiff->invert ) { |
497 | throw new ImportException( "Revision listing is not sorted from oldest to newest" ); |
498 | } |
499 | |
500 | $lastTimestamp = $importRevision->getTimestamp(); |
501 | $state->setRevisionTimestamp( $lastRevision, $lastTimestamp ); |
502 | $state->recordAssociation( $lastRevision->getRevisionId(), $importRevision ); |
503 | $revisions->next(); |
504 | } |
505 | |
506 | return $insertObjects; |
507 | } |
508 | } |