Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
8.65% |
9 / 104 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
Converter | |
8.65% |
9 / 104 |
|
0.00% |
0 / 8 |
541.25 | |
0.00% |
0 / 1 |
__construct | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
3.05 | |||
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 / 19 |
|
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 IDBAccessObject; |
7 | use MediaWiki\MediaWikiServices; |
8 | use MediaWiki\Revision\RevisionRecord; |
9 | use MediaWiki\Revision\SlotRecord; |
10 | use MediaWiki\Title\Title; |
11 | use MediaWiki\User\User; |
12 | use MWExceptionHandler; |
13 | use Psr\Log\LoggerInterface; |
14 | use Traversable; |
15 | use Wikimedia\Rdbms\IDatabase; |
16 | use 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 | */ |
34 | class 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 | } |