Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.07% covered (warning)
79.07%
102 / 129
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageChangeEventSerializer
79.07% covered (warning)
79.07%
102 / 129
70.00% covered (warning)
70.00%
7 / 10
25.04
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 toEvent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getChangelogKind
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 toCommonAttrs
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 toCreateEvent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 toEditEvent
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 toMoveEvent
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 toDeleteEvent
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 toUndeleteEvent
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 toVisibilityChangeEvent
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
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 */
21namespace MediaWiki\Extension\EventBus\Serializers\MediaWiki;
22
23use MediaWiki\Extension\EventBus\Redirects\RedirectTarget;
24use MediaWiki\Extension\EventBus\Serializers\EventSerializer;
25use MediaWiki\Linker\LinkTarget;
26use MediaWiki\Revision\RevisionRecord;
27use MediaWiki\User\User;
28use MediaWiki\WikiMap\WikiMap;
29use Wikimedia\Assert\Assert;
30use WikiPage;
31
32/**
33 * Methods to convert from incoming page state changes (via Hooks)
34 * to a mediawiki/page/change event.
35 */
36class 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}