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    /**
157     * @param PageImportState $pageState
158     * @param IImportHeader $importHeader
159     */
160    public function importHeader( PageImportState $pageState, IImportHeader $importHeader ) {
161        $pageState->logger->info( 'Importing header' );
162        if ( !$importHeader->getRevisions()->valid() ) {
163            $pageState->logger->info( 'no revisions located for header' );
164
165            // No revisions
166            return;
167        }
168
169        /*
170         * We don't need $pageState->getImportedId( $importHeader ) here, there
171         * can only be 1 header per workflow and we already know the workflow,
172         * might as well query it from the workflow instead of using the id from
173         * the source store.
174         * reason I prefer not to use source store is that a header import is
175         * incomplete (it doesn't import full history, just the last revision.
176         */
177        $existingId = $pageState->boardWorkflow->getId();
178        if ( $existingId && $pageState->getTopRevision( 'Header', $existingId ) ) {
179            $pageState->logger->info( 'header previously imported' );
180
181            return;
182        }
183
184        $revisions = $this->importObjectWithHistory(
185            $importHeader,
186            static function ( IObjectRevision $rev ) use ( $pageState ) {
187                return Header::create(
188                    $pageState->boardWorkflow,
189                    $pageState->createUser( $rev->getAuthor() ),
190                    $rev->getText(),
191                    'wikitext',
192                    'create-header'
193                );
194            },
195            'edit-header',
196            $pageState,
197            $pageState->boardWorkflow->getArticleTitle()
198        );
199
200        $pageState->put(
201            $revisions,
202            [
203                'workflow' => $pageState->boardWorkflow,
204            ]
205        );
206        $pageState->recordAssociation(
207            reset( $revisions )->getCollectionId(),
208            $importHeader
209        );
210
211        $pageState->logger->info( 'Imported ' . count( $revisions ) . ' revisions for header' );
212    }
213
214    /**
215     * @param TopicImportState $topicState
216     * @param IImportTopic $importTopic
217     */
218    public function importTopic( TopicImportState $topicState, IImportTopic $importTopic ) {
219        $summary = $importTopic->getTopicSummary();
220        if ( $summary ) {
221            $this->importSummary( $topicState, $summary );
222        }
223
224        foreach ( $importTopic->getReplies() as $post ) {
225            $this->importPost( $topicState, $post, $topicState->topicTitle );
226        }
227
228        $topicState->commitLastUpdated();
229        $topicState->parent->logger->info( "Finished importing topic" );
230    }
231
232    /**
233     * @param PageImportState $state
234     * @param IImportTopic $importTopic
235     * @return TopicImportState
236     */
237    protected function getTopicState( PageImportState $state, IImportTopic $importTopic ) {
238        // Check if it's already been imported
239        $topicState = $this->getExistingTopicState( $state, $importTopic );
240        if ( $topicState ) {
241            $state->logger->info(
242                'Continuing import to ' . $topicState->topicWorkflow->getArticleTitle()->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}