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