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