Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 101 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
Converter | |
0.00% |
0 / 101 |
|
0.00% |
0 / 8 |
702 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
convertAll | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
convert | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
isAllowed | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
doConversion | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
getPageMovedFrom | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
movePage | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
createArchiveCleanupRevision | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | namespace Flow\Import; |
4 | |
5 | use Flow\Exception\FlowException; |
6 | use MediaWiki\Content\WikitextContent; |
7 | use MediaWiki\Exception\MWExceptionHandler; |
8 | use MediaWiki\MediaWikiServices; |
9 | use MediaWiki\Revision\RevisionRecord; |
10 | use MediaWiki\Revision\SlotRecord; |
11 | use MediaWiki\Title\Title; |
12 | use MediaWiki\User\User; |
13 | use Psr\Log\LoggerInterface; |
14 | use Traversable; |
15 | use Wikimedia\Rdbms\IDBAccessObject; |
16 | use Wikimedia\Rdbms\IReadableDatabase; |
17 | use 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 | */ |
35 | class 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 | } |