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