Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 178
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Importer
0.00% covered (danger)
0.00%
0 / 178
0.00% covered (danger)
0.00%
0 / 13
1806
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 setStorage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 put
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 handleBoard
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
 handleHeader
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 handleTopic
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
12
 handlePost
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 handleSummary
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getRevisions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getRevision
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
110
 mapId
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 checkTransWikiMode
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 createLocalUser
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace Flow\Dump;
4
5use Flow\Container;
6use Flow\Data\ManagerGroup;
7use Flow\DbFactory;
8use Flow\Import\HistoricalUIDGenerator;
9use Flow\Import\ImportException;
10use Flow\Model\AbstractRevision;
11use Flow\Model\Header;
12use Flow\Model\PostRevision;
13use Flow\Model\PostSummary;
14use Flow\Model\TopicListEntry;
15use Flow\Model\UUID;
16use Flow\Model\Workflow;
17use Flow\OccupationController;
18use MediaWiki\Deferred\SiteStatsUpdate;
19use MediaWiki\Extension\CentralAuth\CentralAuthServices;
20use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
21use MediaWiki\MediaWikiServices;
22use MediaWiki\Title\Title;
23use MediaWiki\User\CentralId\CentralIdLookup;
24use MediaWiki\User\User;
25use MediaWiki\WikiMap\WikiMap;
26use RuntimeException;
27use WikiImporter;
28use XMLReader;
29
30class Importer {
31    /**
32     * @var WikiImporter
33     */
34    protected $importer;
35
36    /**
37     * @var ManagerGroup|null
38     */
39    protected $storage;
40
41    /**
42     * The most recently imported board workflow (if any).
43     *
44     * @var Workflow|null
45     */
46    protected $boardWorkflow;
47
48    /**
49     * The most recently imported topic workflow (if any).
50     *
51     * @var Workflow|null
52     */
53    protected $topicWorkflow;
54
55    /**
56     * @var array Map of old to new IDs
57     */
58    protected $idMap = [];
59
60    /**
61     * To convert between global and local user ids
62     *
63     * @var CentralIdLookup|null
64     */
65    protected $lookup;
66
67    /**
68     * Whether the current board is being imported in trans-wiki mode
69     *
70     * @var bool
71     */
72    protected $transWikiMode = false;
73
74    /**
75     * @param WikiImporter $importer
76     */
77    public function __construct( WikiImporter $importer ) {
78        $this->importer = $importer;
79        try {
80            $this->lookup = MediaWikiServices::getInstance()
81                ->getCentralIdLookupFactory()
82                ->getLookup( 'CentralAuth' );
83        } catch ( \Throwable $unused ) {
84            $this->lookup = null;
85        }
86    }
87
88    /**
89     * @param ManagerGroup $storage
90     */
91    public function setStorage( ManagerGroup $storage ) {
92        $this->storage = $storage;
93    }
94
95    /**
96     * @param object $object
97     * @param array $metadata
98     */
99    protected function put( $object, array $metadata = [] ) {
100        if ( $this->storage ) {
101            $this->storage->put( $object, [ 'imported' => true ] + $metadata );
102
103            // prevent memory from being filled up
104            $this->storage->clear();
105
106            // keep workflow objects around, so follow-up `put`s (e.g. to update
107            // last_update_timestamp) don't confuse it for a new object
108            foreach ( [ $this->boardWorkflow, $this->topicWorkflow ] as $object ) {
109                if ( $object ) {
110                    $this->storage->getStorage( get_class( $object ) )->merge( $object );
111                }
112            }
113        }
114    }
115
116    public function handleBoard() {
117        $this->checkTransWikiMode(
118            $this->importer->nodeAttribute( 'id' ),
119            $this->importer->nodeAttribute( 'title' )
120        );
121
122        $id = $this->mapId( $this->importer->nodeAttribute( 'id' ) );
123        $this->importer->debug( 'Enter board handler for ' . $id );
124
125        $uuid = UUID::create( $id );
126        $title = Title::newFromDBkey( $this->importer->nodeAttribute( 'title' ) );
127
128        $this->boardWorkflow = Workflow::fromStorageRow( [
129            'workflow_id' => $uuid->getAlphadecimal(),
130            'workflow_type' => 'discussion',
131            'workflow_wiki' => WikiMap::getCurrentWikiId(),
132            'workflow_page_id' => $title->getArticleID(),
133            'workflow_namespace' => $title->getNamespace(),
134            'workflow_title_text' => $title->getDBkey(),
135            'workflow_last_update_timestamp' => $uuid->getTimestamp( TS_MW ),
136        ] );
137
138        // create page if it does not yet exist
139        /** @var OccupationController $occupationController */
140        $occupationController = Container::get( 'occupation_controller' );
141        $creationStatus = $occupationController->safeAllowCreation( $title, $occupationController->getTalkpageManager() );
142        if ( !$creationStatus->isOK() ) {
143            throw new RuntimeException( $creationStatus->__toString() );
144        }
145
146        $ensureStatus = $occupationController->ensureFlowRevision(
147            MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ),
148            $this->boardWorkflow
149        );
150        if ( !$ensureStatus->isOK() ) {
151            throw new RuntimeException( $ensureStatus->__toString() );
152        }
153
154        $this->put( $this->boardWorkflow, [] );
155    }
156
157    public function handleHeader() {
158        $id = $this->mapId( $this->importer->nodeAttribute( 'id' ) );
159        $this->importer->debug( 'Enter description handler for ' . $id );
160
161        $metadata = [ 'workflow' => $this->boardWorkflow ];
162
163        $revisions = $this->getRevisions( [ Header::class, 'fromStorageRow' ] );
164        foreach ( $revisions as $revision ) {
165            $this->put( $revision, $metadata );
166        }
167
168        /** @var Header $revision */
169        $revision = end( $revisions );
170        $this->boardWorkflow->updateLastUpdated( $revision->getRevisionId() );
171        $this->put( $this->boardWorkflow, [] );
172    }
173
174    public function handleTopic() {
175        $id = $this->mapId( $this->importer->nodeAttribute( 'id' ) );
176        $this->importer->debug( 'Enter topic handler for ' . $id );
177
178        $uuid = UUID::create( $id );
179        $title = $this->boardWorkflow->getArticleTitle();
180
181        $this->topicWorkflow = Workflow::fromStorageRow( [
182            'workflow_id' => $uuid->getAlphadecimal(),
183            'workflow_type' => 'topic',
184            'workflow_wiki' => WikiMap::getCurrentWikiId(),
185            'workflow_page_id' => $title->getArticleID(),
186            'workflow_namespace' => $title->getNamespace(),
187            'workflow_title_text' => $title->getDBkey(),
188            'workflow_last_update_timestamp' => $uuid->getTimestamp( TS_MW ),
189        ] );
190        $topicListEntry = TopicListEntry::create( $this->boardWorkflow, $this->topicWorkflow );
191
192        $metadata = [
193            'board-workflow' => $this->boardWorkflow,
194            'workflow' => $this->topicWorkflow,
195            // @todo: topic-title & first-post? (used only in NotificationListener)
196        ];
197
198        // create page if it does not yet exist
199        /** @var OccupationController $occupationController */
200        $occupationController = Container::get( 'occupation_controller' );
201        $creationStatus = $occupationController->safeAllowCreation(
202            $this->topicWorkflow->getArticleTitle(),
203            $occupationController->getTalkpageManager()
204        );
205        if ( !$creationStatus->isOK() ) {
206            throw new RuntimeException( $creationStatus->__toString() );
207        }
208
209        $ensureStatus = $occupationController->ensureFlowRevision(
210            MediaWikiServices::getInstance()->getWikiPageFactory()
211                ->newFromTitle( $this->topicWorkflow->getArticleTitle() ),
212            $this->topicWorkflow
213        );
214        if ( !$ensureStatus->isOK() ) {
215            throw new RuntimeException( $ensureStatus->__toString() );
216        }
217
218        $this->put( $this->topicWorkflow, $metadata );
219        $this->put( $topicListEntry, $metadata );
220    }
221
222    public function handlePost() {
223        $id = $this->mapId( $this->importer->nodeAttribute( 'id' ) );
224        $this->importer->debug( 'Enter post handler for ' . $id );
225
226        $metadata = [
227            'workflow' => $this->topicWorkflow
228            // @todo: topic-title? (used only in NotificationListener)
229        ];
230
231        $revisions = $this->getRevisions( [ PostRevision::class, 'fromStorageRow' ] );
232        foreach ( $revisions as $revision ) {
233            $this->put( $revision, $metadata );
234        }
235
236        /** @var PostRevision $revision */
237        $revision = end( $revisions );
238        $this->topicWorkflow->updateLastUpdated( $revision->getRevisionId() );
239        $this->put( $this->topicWorkflow, $metadata );
240    }
241
242    public function handleSummary() {
243        $id = $this->mapId( $this->importer->nodeAttribute( 'id' ) );
244        $this->importer->debug( 'Enter summary handler for ' . $id );
245
246        $metadata = [ 'workflow' => $this->topicWorkflow ];
247
248        $revisions = $this->getRevisions( [ PostSummary::class, 'fromStorageRow' ] );
249        foreach ( $revisions as $revision ) {
250            $this->put( $revision, $metadata );
251        }
252
253        /** @var PostSummary $revision */
254        $revision = end( $revisions );
255        $this->topicWorkflow->updateLastUpdated( $revision->getRevisionId() );
256        $this->put( $this->topicWorkflow, $metadata );
257    }
258
259    /**
260     * @param callable $callback The relevant fromStorageRow callback
261     * @return AbstractRevision[]
262     */
263    protected function getRevisions( $callback ) {
264        $revisions = [];
265
266        // keep processing <revision> nodes until </revisions>
267        while ( $this->importer->getReader()->localName !== 'revisions' ||
268            $this->importer->getReader()->nodeType !== XMLReader::END_ELEMENT
269        ) {
270            if ( $this->importer->getReader()->localName === 'revision' ) {
271                $revisions[] = $this->getRevision( $callback );
272            }
273            $this->importer->getReader()->read();
274        }
275
276        return $revisions;
277    }
278
279    /**
280     * @param callable $callback The relevant fromStorageRow callback
281     * @return AbstractRevision
282     */
283    protected function getRevision( $callback ) {
284        $id = $this->mapId( $this->importer->nodeAttribute( 'id' ) );
285        $this->importer->debug( 'Enter revision handler for ' . $id );
286
287        // isEmptyElement will no longer be valid after we've started iterating
288        // the attributes
289        $empty = $this->importer->getReader()->isEmptyElement;
290
291        $attribs = [];
292
293        $this->importer->getReader()->moveToFirstAttribute();
294        do {
295            $attribs[$this->importer->getReader()->name] = $this->importer->getReader()->value;
296        } while ( $this->importer->getReader()->moveToNextAttribute() );
297
298        $idFields = [ 'id', 'typeid', 'treedescendantid', 'treerevid', 'parentid', 'treeparentid', 'lasteditid' ];
299        foreach ( $idFields as $idField ) {
300            if ( isset( $attribs[ $idField ] ) ) {
301                $attribs[ $idField ] = $this->mapId( $attribs[ $idField ] );
302            }
303        }
304
305        if ( $this->transWikiMode && $this->lookup ) {
306            $userFields = [ 'user', 'treeoriguser', 'moduser', 'edituser' ];
307            foreach ( $userFields as $userField ) {
308                $globalUserIdField = 'global' . $userField . 'id';
309                if ( isset( $attribs[ $globalUserIdField ] ) ) {
310                    $localUser = $this->lookup->localUserFromCentralId(
311                        (int)$attribs[ $globalUserIdField ],
312                        CentralIdLookup::AUDIENCE_RAW
313                    );
314                    if ( !$localUser ) {
315                        $localUser = $this->createLocalUser( (int)$attribs[ $globalUserIdField ] );
316                    }
317                    $attribs[ $userField . 'id' ] = $localUser->getId();
318                    $attribs[ $userField . 'wiki' ] = WikiMap::getCurrentWikiId();
319                } elseif ( isset( $attribs[ $userField . 'ip' ] ) ) {
320                    // make anons local users
321                    $attribs[ $userField . 'wiki' ] = WikiMap::getCurrentWikiId();
322                }
323            }
324        }
325
326        // now that we've moved inside the node (to fetch attributes),
327        // nodeContents() is no longer reliable: is uses isEmptyContent (which
328        // will now no longer respond with 'true') to see if the node should be
329        // skipped - use the value we've fetched earlier!
330        $attribs['content'] = $empty ? '' : $this->importer->nodeContents();
331
332        // make sure there are no leftover key columns (unknown to $attribs)
333        $keys = array_intersect_key( array_flip( Exporter::$map ), $attribs );
334        // now make sure $values columns are in the same order as $keys are
335        // (array_merge) and there are no leftover columns (array_intersect_key)
336        $values = array_intersect_key( array_merge( $keys, $attribs ), $keys );
337        // combine them
338        $attribs = array_combine( $keys, $values );
339
340        // now fill in missing attributes
341        $keys = array_fill_keys( array_keys( Exporter::$map ), null );
342        $attribs += $keys;
343
344        return $callback( $attribs );
345    }
346
347    /**
348     * When in trans-wiki mode, return a new id based on the same timestamp
349     *
350     * @param string $id
351     * @return string
352     */
353    private function mapId( $id ) {
354        if ( !$this->transWikiMode ) {
355            return $id;
356        }
357
358        if ( !isset( $this->idMap[ $id ] ) ) {
359            $this->idMap[ $id ] = UUID::create( HistoricalUIDGenerator::historicalTimestampedUID88(
360                UUID::hex2timestamp( UUID::create( $id )->getHex() )
361            ) )->getAlphadecimal();
362        }
363        return $this->idMap[ $id ];
364    }
365
366    /**
367     * Check if a board already exist and should be imported in trans-wiki mode
368     *
369     * @param string $boardWorkflowId
370     * @param string $title
371     */
372    private function checkTransWikiMode( $boardWorkflowId, $title ) {
373        /** @var DbFactory $dbFactory */
374        $dbFactory = Container::get( 'db.factory' );
375        $workflowExist = (bool)$dbFactory->getDB( DB_PRIMARY )->selectField(
376            'flow_workflow',
377            'workflow_id',
378            [ 'workflow_id' => UUID::create( $boardWorkflowId )->getBinary() ],
379            __METHOD__
380        );
381
382        if ( $workflowExist ) {
383            $this->importer->debug( "$title will be imported in trans-wiki mode" );
384        }
385        $this->transWikiMode = $workflowExist;
386    }
387
388    /**
389     * Create a local user corresponding to a global id
390     *
391     * @param int $globalUserId
392     * @return User Local user
393     * @throws ImportException
394     */
395    private function createLocalUser( $globalUserId ) {
396        if ( !$this->lookup ) {
397            throw new ImportException( 'Creating local users is not supported without central id provider' );
398        }
399
400        $globalUser = CentralAuthUser::newFromId( $globalUserId );
401        $localUser = User::newFromName( $globalUser->getName() );
402
403        if ( $localUser->getId() ) {
404            throw new ImportException( "User '{$localUser->getName()}' already exists" );
405        }
406
407        $status = CentralAuthServices::getUtilityService()->autoCreateUser( $localUser );
408        if ( !$status->isGood() ) {
409            throw new ImportException(
410                "autoCreateUser failed for {$localUser->getName()}" . print_r( $status->getErrors(), true )
411            );
412        }
413
414        # Update user count
415        $ssUpdate = SiteStatsUpdate::factory( [ 'users' => 1 ] );
416        $ssUpdate->doUpdate();
417
418        return $localUser;
419    }
420}