Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.65% covered (danger)
8.65%
9 / 104
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Converter
8.65% covered (danger)
8.65%
9 / 104
0.00% covered (danger)
0.00%
0 / 8
541.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 convertAll
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 convert
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 isAllowed
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 doConversion
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getPageMovedFrom
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 movePage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 createArchiveCleanupRevision
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace Flow\Import;
4
5use Flow\Exception\FlowException;
6use IDBAccessObject;
7use MediaWiki\MediaWikiServices;
8use MediaWiki\Revision\RevisionRecord;
9use MediaWiki\Revision\SlotRecord;
10use MediaWiki\Title\Title;
11use MediaWiki\User\User;
12use MWExceptionHandler;
13use Psr\Log\LoggerInterface;
14use Traversable;
15use Wikimedia\Rdbms\IDatabase;
16use WikitextContent;
17
18/**
19 * Converts provided titles to Flow. This converter is idempotent when
20 * used with an appropriate SourceStoreInterface, and may be run many times
21 * without worry for duplicate imports.
22 *
23 * Flow does not currently support viewing the history of its page prior
24 * to being flow enabled.  Because of this prior to conversion the current
25 * wikitext page will be moved to an archive location.
26 *
27 * Implementing classes must choose a name for their archive page and
28 * be able to create an IImportSource when provided a Title. On successful
29 * import of a page a 'cleanup archive' edit is optionally performed.
30 *
31 * Any content changes to the imported content should be provided as part
32 * of the IImportSource.
33 */
34class Converter {
35    /**
36     * @var IDatabase Primary database of the current wiki. Required
37     *  to lookup past page moves.
38     */
39    protected $dbw;
40
41    /**
42     * @var Importer Service capable of turning an IImportSource into
43     *  flow revisions.
44     */
45    protected $importer;
46
47    /**
48     * @var LoggerInterface
49     */
50    protected $logger;
51
52    /**
53     * @var User The user for performing maintenance actions like moving
54     *  pages or editing templates onto an archived page. This should be
55     *  a system account and not a normal user.
56     */
57    protected $user;
58
59    /**
60     * @var IConversionStrategy Interface between this converter and an
61     *  IImportSource implementation.
62     */
63    protected $strategy;
64
65    /**
66     * @param IDatabase $dbw Primary wiki database to read from
67     * @param Importer $importer
68     * @param LoggerInterface $logger
69     * @param User $user User for moves and edits related to the conversion process
70     * @param IConversionStrategy $strategy
71     * @throws ImportException When $user does not have an Id
72     */
73    public function __construct(
74        IDatabase $dbw,
75        Importer $importer,
76        LoggerInterface $logger,
77        User $user,
78        IConversionStrategy $strategy
79    ) {
80        if ( !$user->getId() ) {
81            throw new ImportException( 'User must have id' );
82        }
83        $this->dbw = $dbw;
84        $this->importer = $importer;
85        $this->logger = $logger;
86        $this->user = $user;
87        $this->strategy = $strategy;
88
89        $postprocessor = $strategy->getPostprocessor();
90        if ( $postprocessor !== null ) {
91            // @todo assert we cant cause duplicate postprocessors
92            $this->importer->addPostprocessor( $postprocessor );
93        }
94
95        // Force the importer to use our logger for consistent output.
96        $this->importer->setLogger( $logger );
97    }
98
99    /**
100     * Converts multiple pages into Flow boards
101     *
102     * @param Traversable<Title>|array $titles
103     */
104    public function convertAll( $titles ) {
105        /** @var Title $title */
106        foreach ( $titles as $title ) {
107            try {
108                $this->convert( $title );
109            } catch ( \Exception $e ) {
110                MWExceptionHandler::logException( $e );
111                $this->logger->error( "Exception while importing: {$title}" );
112                $this->logger->error( (string)$e );
113            }
114        }
115    }
116
117    /**
118     * Converts a page into a Flow board
119     *
120     * @param Title $title
121     * @throws FlowException
122     */
123    public function convert( Title $title ) {
124        /*
125         * $title is the title we're currently considering to import.
126         * It could be a page we need to import, but could also e.g.
127         * be an archive page of a previous import run (in which case
128         * $movedFrom will be the Title object of that original page)
129         */
130        $movedFrom = $this->getPageMovedFrom( $title );
131        if ( $this->strategy->isConversionFinished( $title, $movedFrom ) ) {
132            return;
133        }
134
135        if ( !$this->isAllowed( $title ) ) {
136            throw new FlowException( "Not allowed to convert: {$title}" );
137        }
138
139        $this->doConversion( $title, $movedFrom );
140    }
141
142    /**
143     * Returns a boolean indicating if we're allowed to import $title.
144     *
145     * @param Title $title
146     * @return bool
147     */
148    protected function isAllowed( Title $title ) {
149        // Only make changes to wikitext pages
150        if ( $title->getContentModel() !== CONTENT_MODEL_WIKITEXT ) {
151            $this->logger->warning( "WARNING: The title '" . $title->getPrefixedDBkey() .
152                "' is being skipped because it has content model '" . $title->getContentModel() . "''." );
153            return false;
154        }
155
156        if ( !$title->exists() ) {
157            $this->logger->warning( "WARNING: The title '" . $title->getPrefixedDBkey() .
158                "' is being skipped because it does not exist." );
159            return false;
160        }
161
162        // At some point we may want to handle these, but for now just
163        // let them be
164        if ( $title->isRedirect() ) {
165            $this->logger->warning( "WARNING: The title '" . $title->getPrefixedDBkey() .
166                "' is being skipped because it is a redirect." );
167            return false;
168        }
169
170        // Finally, check strategy-specific logic
171        return $this->strategy->shouldConvert( $title );
172    }
173
174    protected function doConversion( Title $title, Title $movedFrom = null ) {
175        if ( $movedFrom ) {
176            // If the page is moved but has not completed conversion that
177            // means the previous import failed to complete. Try again.
178            $archiveTitle = $title;
179            $title = $movedFrom;
180            $this->logger->info( "Page previously archived from $title to $archiveTitle" );
181        } else {
182            // The move needs to happen prior to the import because upon starting the
183            // import the top revision will be a flow-board revision.
184            $archiveTitle = $this->strategy->decideArchiveTitle( $title );
185            $this->logger->info( "Archiving page from $title to $archiveTitle" );
186            $this->movePage( $title, $archiveTitle );
187
188            $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
189            // Wait for replicas to pick up the page move
190            $lbFactory->waitForReplication();
191        }
192
193        $source = $this->strategy->createImportSource( $archiveTitle );
194        if ( $this->importer->import( $source, $title, $this->user, $this->strategy->getSourceStore() ) ) {
195            $this->createArchiveCleanupRevision( $title, $archiveTitle );
196            $this->logger->info( "Completed import to $title from $archiveTitle" );
197        } else {
198            $this->logger->error( "Failed to complete import to $title from $archiveTitle" );
199        }
200    }
201
202    /**
203     * Looks in the logging table to see if the provided title was last moved
204     * there by the user provided in the constructor. The provided user should
205     * be a system user for this task, as this assumes that user has never
206     * moved these pages outside the conversion process.
207     *
208     * This only considers the most recent move and not prior moves.  This allows
209     * for edge cases such as starting an import, canceling it, and manually
210     * reverting the move by a normal user.
211     *
212     * @param Title $title
213     * @return Title|null
214     */
215    protected function getPageMovedFrom( Title $title ) {
216        $row = $this->dbw->selectRow(
217            [ 'logging', 'page' ],
218            [ 'log_namespace', 'log_title' ],
219            [
220                'page_namespace' => $title->getNamespace(),
221                'page_title' => $title->getDBkey(),
222                'log_type' => 'move',
223                'log_actor' => $this->user->getActorId()
224            ],
225            __METHOD__,
226            [
227                'LIMIT' => 1,
228                'ORDER BY' => 'log_timestamp DESC'
229            ],
230            [ 'page' => [ 'JOIN', 'log_page = page_id' ] ]
231        );
232
233        // The page has never been moved or the most recent move was not by our user
234        if ( !$row ) {
235            return null;
236        }
237
238        return Title::makeTitle( $row->log_namespace, $row->log_title );
239    }
240
241    /**
242     * Moves the source page to the destination. Does not leave behind a
243     * redirect, intending that flow will place a revision there for its new
244     * board.
245     *
246     * @param Title $from
247     * @param Title $to
248     * @throws ImportException on failed import
249     */
250    protected function movePage( Title $from, Title $to ) {
251        $mp = MediaWikiServices::getInstance()
252            ->getMovePageFactory()
253            ->newMovePage( $from, $to );
254
255        $valid = $mp->isValidMove();
256        if ( !$valid->isOK() ) {
257            $this->logger->error( $valid->getMessage()->text() );
258            throw new ImportException( "It is not valid to move {$from} to {$to}" );
259        }
260
261        // Note that this comment must match the regex in self::getPageMovedFrom
262        $status = $mp->move(
263            /* user */ $this->user,
264            /* reason */ $this->strategy->getMoveComment( $from, $to ),
265            /* create redirect */ false
266        );
267
268        if ( !$status->isGood() ) {
269            $this->logger->error( $status->getMessage()->text() );
270            throw new ImportException( "Failed moving {$from} to {$to}" );
271        }
272    }
273
274    /**
275     * Creates a new revision of the archived page with strategy-specific changes.
276     *
277     * @param Title $title Previous location of the page, before moving
278     * @param Title $archiveTitle Current location of the page, after moving
279     * @throws ImportException
280     */
281    protected function createArchiveCleanupRevision( Title $title, Title $archiveTitle ) {
282        $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $archiveTitle );
283        // doUserEditContent will do this anyway, but we need to now for the revision.
284        $page->loadPageData( IDBAccessObject::READ_LATEST );
285        $revision = $page->getRevisionRecord();
286        if ( $revision === null ) {
287            throw new ImportException( "Expected a revision at {$archiveTitle}" );
288        }
289
290        // Do not create revisions based on rev_deleted revisions.
291        $content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC );
292        if ( !$content instanceof WikitextContent ) {
293            throw new ImportException( "Expected wikitext content at: {$archiveTitle}" );
294        }
295
296        $newContent = $this->strategy->createArchiveCleanupRevisionContent( $content, $title );
297        if ( $newContent === null ) {
298            return;
299        }
300
301        $status = $page->doUserEditContent(
302            $newContent,
303            $this->user,
304            $this->strategy->getCleanupComment( $title, $archiveTitle ),
305            EDIT_FORCE_BOT | EDIT_SUPPRESS_RC
306        );
307
308        if ( !$status->isGood() ) {
309            $this->logger->error( $status->getMessage()->text() );
310            throw new ImportException( "Failed creating archive cleanup revision at {$archiveTitle}" );
311        }
312    }
313}