Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.45% covered (warning)
78.45%
222 / 283
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
TalkpageImportOperation
78.45% covered (warning)
78.45%
222 / 283
36.36% covered (danger)
36.36%
4 / 11
48.98
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
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
 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\MediaWikiServices;
14use MediaWiki\Title\Title;
15use MediaWiki\User\User;
16use MediaWiki\Utils\MWTimestamp;
17
18class 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}