Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
79.07% |
102 / 129 |
|
70.00% |
7 / 10 |
CRAP | |
0.00% |
0 / 1 |
PageChangeEventSerializer | |
79.07% |
102 / 129 |
|
70.00% |
7 / 10 |
25.04 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
toEvent | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getChangelogKind | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
toCommonAttrs | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
20 | |||
toCreateEvent | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
toEditEvent | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
toMoveEvent | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
2 | |||
toDeleteEvent | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
3 | |||
toUndeleteEvent | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
3 | |||
toVisibilityChangeEvent | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | * @author Andrew Otto <otto@wikimedia.org> |
20 | */ |
21 | namespace MediaWiki\Extension\EventBus\Serializers\MediaWiki; |
22 | |
23 | use MediaWiki\Extension\EventBus\Redirects\RedirectTarget; |
24 | use MediaWiki\Extension\EventBus\Serializers\EventSerializer; |
25 | use MediaWiki\Linker\LinkTarget; |
26 | use MediaWiki\Revision\RevisionRecord; |
27 | use MediaWiki\User\User; |
28 | use MediaWiki\WikiMap\WikiMap; |
29 | use Wikimedia\Assert\Assert; |
30 | use WikiPage; |
31 | |
32 | /** |
33 | * Methods to convert from incoming page state changes (via Hooks) |
34 | * to a mediawiki/page/change event. |
35 | */ |
36 | class PageChangeEventSerializer { |
37 | |
38 | /** |
39 | * All page change events will have their $schema URI set to this. |
40 | * https://phabricator.wikimedia.org/T308017 |
41 | */ |
42 | public const PAGE_CHANGE_SCHEMA_URI = '/mediawiki/page/change/1.2.0'; |
43 | |
44 | /** |
45 | * There are many kinds of changes that can happen to a MediaWiki pages, |
46 | * but only a few kinds of changes in a 'changelog' stream. |
47 | * This maps from a MediaWiki page change kind to a changelog kind. |
48 | */ |
49 | private const PAGE_CHANGE_KIND_TO_CHANGELOG_KIND_MAP = [ |
50 | 'create' => 'insert', |
51 | 'edit' => 'update', |
52 | 'move' => 'update', |
53 | 'visibility_change' => 'update', |
54 | 'delete' => 'delete', |
55 | 'undelete' => 'insert', |
56 | ]; |
57 | |
58 | /** |
59 | * @var EventSerializer |
60 | */ |
61 | private EventSerializer $eventSerializer; |
62 | |
63 | /** |
64 | * @var PageEntitySerializer |
65 | */ |
66 | private PageEntitySerializer $pageEntitySerializer; |
67 | |
68 | /** |
69 | * @var UserEntitySerializer |
70 | */ |
71 | private UserEntitySerializer $userEntitySerializer; |
72 | |
73 | /** |
74 | * @var RevisionEntitySerializer |
75 | */ |
76 | private RevisionEntitySerializer $revisionEntitySerializer; |
77 | |
78 | /** |
79 | * @param EventSerializer $eventSerializer |
80 | * @param PageEntitySerializer $pageEntitySerializer |
81 | * @param UserEntitySerializer $userEntitySerializer |
82 | * @param RevisionEntitySerializer $revisionEntitySerializer |
83 | */ |
84 | public function __construct( |
85 | EventSerializer $eventSerializer, |
86 | PageEntitySerializer $pageEntitySerializer, |
87 | UserEntitySerializer $userEntitySerializer, |
88 | RevisionEntitySerializer $revisionEntitySerializer |
89 | ) { |
90 | $this->eventSerializer = $eventSerializer; |
91 | $this->pageEntitySerializer = $pageEntitySerializer; |
92 | $this->userEntitySerializer = $userEntitySerializer; |
93 | $this->revisionEntitySerializer = $revisionEntitySerializer; |
94 | } |
95 | |
96 | /** |
97 | * Uses EventSerializer to create the mediawiki/page/change event for the given $eventAttrs |
98 | * @param string $stream |
99 | * @param WikiPage $wikiPage |
100 | * @param array $eventAttrs |
101 | * @return array |
102 | */ |
103 | private function toEvent( string $stream, WikiPage $wikiPage, array $eventAttrs ): array { |
104 | return $this->eventSerializer->createEvent( |
105 | self::PAGE_CHANGE_SCHEMA_URI, |
106 | $stream, |
107 | $this->pageEntitySerializer->canonicalPageURL( $wikiPage ), |
108 | $eventAttrs |
109 | ); |
110 | } |
111 | |
112 | /** |
113 | * Returns the appropriate changelog kind given a pageChangeKind. |
114 | * @param string $pageChangeKind |
115 | * @return string |
116 | */ |
117 | private static function getChangelogKind( string $pageChangeKind ): string { |
118 | Assert::parameter( |
119 | array_key_exists( $pageChangeKind, self::PAGE_CHANGE_KIND_TO_CHANGELOG_KIND_MAP ), |
120 | '$pageChangeKind', |
121 | "Unsupported pageChangeKind '$pageChangeKind'. Must be one of " . |
122 | implode( ',', array_keys( self::PAGE_CHANGE_KIND_TO_CHANGELOG_KIND_MAP ) ) |
123 | ); |
124 | |
125 | return self::PAGE_CHANGE_KIND_TO_CHANGELOG_KIND_MAP[$pageChangeKind]; |
126 | } |
127 | |
128 | /** |
129 | * DRY helper to set event fields common to all page change events. |
130 | * @param string $page_change_kind |
131 | * @param string $dt |
132 | * @param WikiPage $wikiPage |
133 | * @param User|null $performer |
134 | * @param RevisionRecord|null $currentRevision |
135 | * @param RedirectTarget|null $redirectTarget |
136 | * @param string|null $comment |
137 | * @return array |
138 | */ |
139 | private function toCommonAttrs( |
140 | string $page_change_kind, |
141 | string $dt, |
142 | WikiPage $wikiPage, |
143 | ?User $performer, |
144 | ?RevisionRecord $currentRevision = null, |
145 | ?RedirectTarget $redirectTarget = null, |
146 | ?string $comment = null |
147 | ): array { |
148 | $eventAttrs = [ |
149 | 'changelog_kind' => self::getChangelogKind( $page_change_kind ), |
150 | 'page_change_kind' => $page_change_kind, |
151 | 'dt' => $dt, |
152 | # Ideally, wiki_id would come from a dependency injected MediaWikiService, |
153 | # But for now, the best place to get it is from WikiMap, which ultimately uses globals. |
154 | 'wiki_id' => WikiMap::getCurrentWikiId(), |
155 | 'page' => $this->pageEntitySerializer->toArray( $wikiPage, $redirectTarget ), |
156 | ]; |
157 | |
158 | if ( isset( $performer ) ) { |
159 | $eventAttrs['performer'] = $this->userEntitySerializer->toArray( $performer ); |
160 | } |
161 | |
162 | if ( $comment !== null ) { |
163 | $eventAttrs['comment'] = $comment; |
164 | } |
165 | |
166 | if ( $currentRevision !== null ) { |
167 | $eventAttrs['revision'] = $this->revisionEntitySerializer->toArray( $currentRevision ); |
168 | } |
169 | |
170 | return $eventAttrs; |
171 | } |
172 | |
173 | /** |
174 | * Converts from the given WikiPage and RevisionRecord to a page_change_kind: create event. |
175 | * |
176 | * @param string $stream |
177 | * @param WikiPage $wikiPage |
178 | * @param User $performer |
179 | * @param RevisionRecord $currentRevision |
180 | * @param RedirectTarget|null $redirectTarget |
181 | * @return array |
182 | */ |
183 | public function toCreateEvent( |
184 | string $stream, |
185 | WikiPage $wikiPage, |
186 | User $performer, |
187 | RevisionRecord $currentRevision, |
188 | ?RedirectTarget $redirectTarget = null |
189 | ): array { |
190 | $eventAttrs = $this->toCommonAttrs( |
191 | 'create', |
192 | $this->eventSerializer->timestampToDt( $currentRevision->getTimestamp() ), |
193 | $wikiPage, |
194 | $performer, |
195 | $currentRevision, |
196 | $redirectTarget, |
197 | null |
198 | ); |
199 | |
200 | return $this->toEvent( $stream, $wikiPage, $eventAttrs ); |
201 | } |
202 | |
203 | /** |
204 | * Converts from the given WikiPage and RevisionRecord to a page_change_kind: edit event. |
205 | * |
206 | * @param string $stream |
207 | * @param WikiPage $wikiPage |
208 | * @param User $performer |
209 | * @param RevisionRecord $currentRevision |
210 | * @param RedirectTarget|null $redirectTarget |
211 | * @param RevisionRecord|null $parentRevision |
212 | * @return array |
213 | */ |
214 | public function toEditEvent( |
215 | string $stream, |
216 | WikiPage $wikiPage, |
217 | User $performer, |
218 | RevisionRecord $currentRevision, |
219 | ?RedirectTarget $redirectTarget = null, |
220 | ?RevisionRecord $parentRevision = null |
221 | ): array { |
222 | $eventAttrs = $this->toCommonAttrs( |
223 | 'edit', |
224 | $this->eventSerializer->timestampToDt( $currentRevision->getTimestamp() ), |
225 | $wikiPage, |
226 | $performer, |
227 | $currentRevision, |
228 | $redirectTarget, |
229 | null |
230 | ); |
231 | |
232 | // On edit, the prior state is all about the previous revision. |
233 | if ( $parentRevision !== null ) { |
234 | $priorStateAttrs = []; |
235 | $priorStateAttrs['revision'] = $this->revisionEntitySerializer->toArray( $parentRevision ); |
236 | $eventAttrs['prior_state'] = $priorStateAttrs; |
237 | } |
238 | |
239 | return $this->toEvent( $stream, $wikiPage, $eventAttrs ); |
240 | } |
241 | |
242 | /** |
243 | * Converts from the given WikiPage, RevisionRecord |
244 | * and old title LinkTarget to a page_change_kind: move event. |
245 | * |
246 | * @param string $stream |
247 | * @param WikiPage $wikiPage |
248 | * @param User $performer |
249 | * @param RevisionRecord $currentRevision |
250 | * @param RevisionRecord $parentRevision |
251 | * @param LinkTarget $oldTitle |
252 | * @param string $reason |
253 | * @param WikiPage|null $createdRedirectWikiPage |
254 | * @param RedirectTarget|null $redirectTarget |
255 | * @return array |
256 | */ |
257 | public function toMoveEvent( |
258 | string $stream, |
259 | WikiPage $wikiPage, |
260 | User $performer, |
261 | RevisionRecord $currentRevision, |
262 | RevisionRecord $parentRevision, |
263 | LinkTarget $oldTitle, |
264 | string $reason, |
265 | ?WikiPage $createdRedirectWikiPage = null, |
266 | ?RedirectTarget $redirectTarget = null |
267 | ): array { |
268 | $eventAttrs = $this->toCommonAttrs( |
269 | 'move', |
270 | // NOTE: This uses the newly created revision's timestamp as the page move event time, |
271 | // for lack of a better 'move time'. |
272 | $this->eventSerializer->timestampToDt( $currentRevision->getTimestamp() ), |
273 | $wikiPage, |
274 | $performer, |
275 | $currentRevision, |
276 | $redirectTarget, |
277 | // NOTE: the reason for the page move is used to generate the comment |
278 | // on the revision created by the page move, but it is not the same! |
279 | $reason |
280 | ); |
281 | |
282 | // If a new redirect page was created during this move, then include |
283 | // some information about it. |
284 | if ( $createdRedirectWikiPage ) { |
285 | $eventAttrs['created_redirect_page'] = $this->pageEntitySerializer->toArray( $createdRedirectWikiPage ); |
286 | } |
287 | |
288 | // On move, the prior state is about page title, namespace, and also the previous revision. |
289 | // (Page moves create a new revision of a page). |
290 | $priorStateAttrs = []; |
291 | |
292 | // Include old page_title and namespace to prior_state, these are primary |
293 | // arguments to the move. Skip page_id as it could not have changed. |
294 | $priorStateAttrs['page'] = [ |
295 | 'page_title' => $this->pageEntitySerializer->formatLinkTarget( $oldTitle ), |
296 | 'namespace_id' => $oldTitle->getNamespace(), |
297 | ]; |
298 | |
299 | // add parent revision info in prior_state, since a page move creates a new revision. |
300 | $priorStateAttrs['revision'] = $this->revisionEntitySerializer->toArray( $parentRevision ); |
301 | |
302 | $eventAttrs['prior_state'] = $priorStateAttrs; |
303 | |
304 | return $this->toEvent( $stream, $wikiPage, $eventAttrs ); |
305 | } |
306 | |
307 | /** |
308 | * Converts from the given WikiPage, RevisionRecord to a page_change_kind: delete event. |
309 | * |
310 | * NOTE: If $isSuppression is true, the current revision info emitted by this even will have |
311 | * all of its visibility settings set to false. |
312 | * A consumer of this event probably doesn't care, because they should delete the page |
313 | * and revision in response to this event anyway. |
314 | * |
315 | * @param string $stream |
316 | * @param WikiPage $wikiPage |
317 | * @param User|null $performer |
318 | * @param RevisionRecord $currentRevision |
319 | * @param string $reason |
320 | * @param string|null $eventTimestamp |
321 | * @param int|null $archivedRevisionCount |
322 | * @param RedirectTarget|null $redirectTarget |
323 | * @param bool $isSuppression |
324 | * If true, the current revision info emitted by this even will have |
325 | * all of its visibility settings set to false. |
326 | * A consumer of this event probably doesn't care, because they should delete the page |
327 | * and revision in response to this event anyway. |
328 | * @return array |
329 | */ |
330 | public function toDeleteEvent( |
331 | string $stream, |
332 | WikiPage $wikiPage, |
333 | ?User $performer, |
334 | RevisionRecord $currentRevision, |
335 | string $reason, |
336 | ?string $eventTimestamp = null, |
337 | ?int $archivedRevisionCount = null, |
338 | ?RedirectTarget $redirectTarget = null, |
339 | bool $isSuppression = false |
340 | ): array { |
341 | $eventAttrs = $this->toCommonAttrs( |
342 | 'delete', |
343 | $this->eventSerializer->timestampToDt( $eventTimestamp ), |
344 | $wikiPage, |
345 | $performer, |
346 | $currentRevision, |
347 | $redirectTarget, |
348 | $reason |
349 | ); |
350 | |
351 | // page delete specific fields: |
352 | if ( $archivedRevisionCount !== null ) { |
353 | $eventAttrs['page']['revision_count'] = $archivedRevisionCount; |
354 | } |
355 | |
356 | // If this is a full page suppression, then we need to represent that fact that |
357 | // the current revision (and also all revisions of this page) is having its visibility changed |
358 | // to fully hidden, AKA SUPPRESSED_ALL, and delete any fields that might contain information |
359 | // that has been suppressed. |
360 | // NOTE: It would be better if $currentRevision itself had its visibility settings |
361 | // set to the same as the 'deleted/archived' revision, but it is not because |
362 | // MediaWiki is pretty weird with archived revisions. |
363 | // See: https://phabricator.wikimedia.org/T308017#8339347 |
364 | if ( $isSuppression ) { |
365 | $eventAttrs['revision'] = array_merge( |
366 | $eventAttrs['revision'], |
367 | $this->revisionEntitySerializer->bitsToVisibilityAttrs( RevisionRecord::SUPPRESSED_ALL ) |
368 | ); |
369 | |
370 | unset( $eventAttrs['revision']['rev_size'] ); |
371 | unset( $eventAttrs['revision']['rev_sha1'] ); |
372 | unset( $eventAttrs['revision']['comment'] ); |
373 | unset( $eventAttrs['revision']['editor'] ); |
374 | unset( $eventAttrs['revision']['content_slots'] ); |
375 | |
376 | // $currentRevision actually has the prior revision visibility info in the case of page suppression. |
377 | $eventAttrs['prior_state']['revision'] = $this->revisionEntitySerializer->bitsToVisibilityAttrs( |
378 | $currentRevision->getVisibility() |
379 | ); |
380 | } |
381 | |
382 | return $this->toEvent( $stream, $wikiPage, $eventAttrs ); |
383 | } |
384 | |
385 | /** |
386 | * Converts from the given WikiPage, RevisionRecord to a page_change_kind: undelete event. |
387 | * |
388 | * @param string $stream |
389 | * @param WikiPage $wikiPage |
390 | * @param User $performer |
391 | * @param RevisionRecord $currentRevision |
392 | * @param string $reason |
393 | * @param RedirectTarget|null $redirectTarget |
394 | * @param string|null $eventTimestamp |
395 | * @param int|null $oldPageID |
396 | * @return array |
397 | */ |
398 | public function toUndeleteEvent( |
399 | string $stream, |
400 | WikiPage $wikiPage, |
401 | User $performer, |
402 | RevisionRecord $currentRevision, |
403 | string $reason, |
404 | ?RedirectTarget $redirectTarget = null, |
405 | ?string $eventTimestamp = null, |
406 | ?int $oldPageID = null |
407 | ): array { |
408 | $eventAttrs = $this->toCommonAttrs( |
409 | 'undelete', |
410 | $this->eventSerializer->timestampToDt( $eventTimestamp ), |
411 | $wikiPage, |
412 | $performer, |
413 | $currentRevision, |
414 | $redirectTarget, |
415 | $reason |
416 | ); |
417 | |
418 | // If this page had a different id in the archive table, |
419 | // then save it as the prior_state page_id. This will |
420 | // be the page_id that the page had before it was deleted, |
421 | // which is the same as the page_id that it had while it was |
422 | // in the archive table. |
423 | // Usually page_id will be the same, but there are some historical |
424 | // edge cases where a new page_id is created as part of an undelete. |
425 | if ( $oldPageID && $oldPageID != $wikiPage->getId() ) { |
426 | $eventAttrs['prior_state'] = [ |
427 | 'page' => [ |
428 | 'page_id' => $oldPageID |
429 | ] |
430 | ]; |
431 | } |
432 | |
433 | return $this->toEvent( $stream, $wikiPage, $eventAttrs ); |
434 | } |
435 | |
436 | /** |
437 | * Converts from the given WikiPage, RevisionRecord and previous RevisionRecord's visibility (deleted) |
438 | * bitfield to a page_change_kind: visibility_change event. |
439 | * |
440 | * @param string $stream |
441 | * @param WikiPage $wikiPage |
442 | * @param User|null $performer |
443 | * @param RevisionRecord $currentRevision |
444 | * @param int $priorVisibilityBitfield |
445 | * @param string|null $eventTimestamp |
446 | * @return array |
447 | */ |
448 | public function toVisibilityChangeEvent( |
449 | string $stream, |
450 | WikiPage $wikiPage, |
451 | ?User $performer, |
452 | RevisionRecord $currentRevision, |
453 | int $priorVisibilityBitfield, |
454 | ?string $eventTimestamp = null |
455 | ) { |
456 | $eventAttrs = $this->toCommonAttrs( |
457 | 'visibility_change', |
458 | $this->eventSerializer->timestampToDt( $eventTimestamp ), |
459 | $wikiPage, |
460 | $performer, |
461 | $currentRevision, |
462 | # NOTE: ArticleRevisionVisibilitySet does not give us the 'reason' (comment) |
463 | # the visibility has been changed. This info is provided in the UI by the user, |
464 | # where does it go? |
465 | # https://phabricator.wikimedia.org/T321411 |
466 | null |
467 | ); |
468 | |
469 | // During a visibility change, we are only representing the change to the revision's |
470 | // visibility. The rev_id that is being modified is at revision.rev_id. |
471 | // The rev_id has not changed. The prior_state.revision object will not contain |
472 | // any duplicate information about this revision. It will only contain the |
473 | // prior visibility fields for this revision that have been changed |
474 | $priorVisibilityFields = $this->revisionEntitySerializer->bitsToVisibilityAttrs( $priorVisibilityBitfield ); |
475 | $eventAttrs['prior_state']['revision'] = []; |
476 | foreach ( $priorVisibilityFields as $key => $value ) { |
477 | // Only set the old visibility field in prior state if it has changed. |
478 | if ( $eventAttrs['revision'][$key] !== $value ) { |
479 | $eventAttrs['prior_state']['revision'][$key] = $value; |
480 | } |
481 | } |
482 | |
483 | return $this->toEvent( $stream, $wikiPage, $eventAttrs ); |
484 | } |
485 | } |