Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
StoriesEventIngress
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 7
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handlePageRevisionUpdatedEvent
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
72
 getPatrolled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 handlePageDeletedEvent
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 purgeStories
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 deleteStories
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 makeRecentChangesEntry
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\Wikistories;
4
5use MediaWiki\Config\Config;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\DomainEvent\DomainEventIngress;
8use MediaWiki\Extension\Wikistories\Hooks\RecentChangesPropagationHooks;
9use MediaWiki\MainConfigNames;
10use MediaWiki\Page\DeletePageFactory;
11use MediaWiki\Page\Event\PageDeletedEvent;
12use MediaWiki\Page\Event\PageDeletedListener;
13use MediaWiki\Page\Event\PageRevisionUpdatedEvent;
14use MediaWiki\Page\Event\PageRevisionUpdatedListener;
15use MediaWiki\Page\PageIdentity;
16use MediaWiki\Page\ProperPageIdentity;
17use MediaWiki\Page\WikiPageFactory;
18use MediaWiki\Permissions\Authority;
19use MediaWiki\RecentChanges\RecentChange;
20use MediaWiki\Revision\RevisionRecord;
21use MediaWiki\Storage\EditResult;
22use MediaWiki\Title\Title;
23use MediaWiki\Title\TitleFormatter;
24use MediaWiki\User\UserIdentity;
25
26/**
27 * Event subscriber acting as an ingress for relevant events emitted
28 * by MediaWiki core.
29 */
30class StoriesEventIngress
31    extends DomainEventIngress
32    implements PageRevisionUpdatedListener, PageDeletedListener
33{
34
35    private readonly bool $useRCPatrol;
36
37    public function __construct(
38        private readonly StoriesCache $storiesCache,
39        private readonly PageLinksSearch $linksSearch,
40        private readonly WikiPageFactory $wikiPageFactory,
41        private readonly DeletePageFactory $deletePageFactory,
42        private readonly TitleFormatter $titleFormatter,
43        Config $config,
44    ) {
45        $this->useRCPatrol = $config->get( MainConfigNames::UseRCPatrol );
46    }
47
48    /**
49     * When editing a story with the form, it is possible to change the 'Related article'
50     * to change which article the story will be shown on. The link to the new article will
51     * be done automatically with the page links but it will still show on the previous
52     * article because of the long-live stories cache.
53     *
54     * This listener invalidates the stories cache for the old article and
55     * purges stories when article is undeleted.
56     *
57     * Also, when a story is saved (created or edited), it creates a recent changes
58     * entry for the related article so that watchers of that article can
59     * be aware of the story change.
60     *
61     * @noinspection PhpUnused
62     */
63    public function handlePageRevisionUpdatedEvent( PageRevisionUpdatedEvent $event ): void {
64        $page = $event->getPage();
65        $revisionRecord = $event->getLatestRevisionAfter();
66
67        // Undeletion in the main namespace
68        if ( $page->getNamespace() === NS_MAIN &&
69            $event->isCreation() &&
70            $event->hasCause( PageRevisionUpdatedEvent::CAUSE_UNDELETE )
71        ) {
72            $this->purgeStories( $page );
73        }
74
75        // Story created or edited
76        if ( $page->getNamespace() !== NS_STORY ||
77            $revisionRecord->getMainContentModel() !== 'story'
78        ) {
79            return;
80        }
81
82        $story = $revisionRecord->getMainContentRaw();
83
84        if ( !( $story instanceof StoryContent ) ) {
85            // not the story content format
86            return;
87        }
88
89        // Invalidate caches
90        $articleTitle = $story->getArticleTitle();
91
92        if ( $articleTitle ) {
93            $this->storiesCache->invalidateForArticle( $articleTitle->getId() );
94        }
95
96        $this->storiesCache->invalidateStory( $page->getId() );
97
98        // Inject RecentChanged entry
99        $article = Title::newFromText( $story->getFromArticle() );
100        $context = RequestContext::getMain();
101        $requestIP = $context->getRequest()->getIP();
102        $patrolled = $this->getPatrolled( $article, $context->getAuthority() );
103
104        $rc = $this->makeRecentChangesEntry(
105            $article,
106            $revisionRecord,
107            $event->getPerformer(),
108            $revisionRecord->getComment( RevisionRecord::RAW )->text,
109            $requestIP,
110            $revisionRecord->isMinor(),
111            $event->isBotUpdate(),
112            $patrolled,
113            $event->getEditResult()
114        );
115
116        $rc->save();
117    }
118
119    private function getPatrolled( Title $title, Authority $performer ): int {
120        return $this->useRCPatrol && $performer->definitelyCan( 'autopatrol', $title ) ?
121            RecentChange::PRC_AUTOPATROLLED :
122            RecentChange::PRC_UNPATROLLED;
123    }
124
125    /**
126     * Do purge stories when article is deleted.
127     * Invalidate stories cache for the related article.
128     *
129     * @noinspection PhpUnused
130     */
131    public function handlePageDeletedEvent( PageDeletedEvent $event ): void {
132        $page = $event->getDeletedPage();
133        $deletedRev = $event->getLatestRevisionBefore();
134
135        // NS_MAIN deletion
136        if ( $page->getNamespace() === NS_MAIN ) {
137            $request = RequestContext::getMain()->getRequest();
138            $authority = RequestContext::getMain()->getAuthority();
139
140            $deleteStories = $request->getBool( 'wpDeleteStory' );
141            if ( $deleteStories ) {
142                self::deleteStories(
143                    $request->getText( 'wpReason' ),
144                    $page,
145                    $authority,
146                    $request->getBool( 'wpSuppress' )
147                );
148            } else {
149                self::purgeStories( $page );
150            }
151
152            return;
153        }
154
155        // NS_STORY deletion
156        if ( $page->getNamespace() !== NS_STORY ) {
157            return;
158        }
159
160        $story = $deletedRev->getMainContentRaw();
161        if ( !( $story instanceof StoryContent ) ) {
162            return;
163        }
164
165        $articleTitle = $story->getArticleTitle();
166        if ( $articleTitle === null ) {
167            return;
168        }
169
170        $articlePageId = $articleTitle->getId();
171        if ( $articlePageId === 0 ) {
172            return;
173        }
174
175        $this->storiesCache->invalidateForArticle( $articlePageId );
176    }
177
178    private function purgeStories( ProperPageIdentity $page ): void {
179        $storiesId = $this->linksSearch->getPageLinks( $page->getDBkey(), 99 );
180        foreach ( $storiesId as $storyId ) {
181            $page = $this->wikiPageFactory->newFromID( $storyId );
182            $page->doPurge();
183        }
184    }
185
186    private function deleteStories(
187        string $reason,
188        ProperPageIdentity $page,
189        Authority $authority,
190        bool $suppress,
191    ): void {
192        $storiesId = $this->linksSearch->getPageLinks( $page->getDBkey(), 99 );
193        foreach ( $storiesId as $storyId ) {
194            $page = $this->wikiPageFactory->newFromID( $storyId );
195            $deletePage = $this->deletePageFactory->newDeletePage(
196                $page,
197                $authority
198            );
199            $deletePage
200                ->setSuppress( $suppress )
201                ->deleteIfAllowed( $reason );
202        }
203    }
204
205    /**
206     * When a story is saved (created or edited), we create a recent changes
207     * entry for the related article so that watchers of that article can
208     * be aware of the story change.
209     *
210     * @note The logic for creating the fake RecentChanges entry is in this class
211     * because this is where we define how that entry is later visualized.
212     * The actual insertion of the fake RC entry is left to EventIngress, which
213     * handles core events triggered by page changes.
214     */
215    private function makeRecentChangesEntry(
216        PageIdentity $article,
217        RevisionRecord $revisionRecord,
218        UserIdentity $user,
219        string $summary,
220        string $requestIP,
221        bool $minor,
222        bool $bot,
223        int $patrolled,
224        ?EditResult $editResult
225    ): RecentChange {
226        // NOTE: $revisionRecord does not belong to $article!
227
228        $rc = new RecentChange;
229        $rc->mAttribs = [
230            'rc_timestamp' => $revisionRecord->getTimestamp(),
231            'rc_namespace' => $article->getNamespace(),
232            'rc_title' => $article->getDBkey(),
233            'rc_source' => RecentChangesPropagationHooks::SRC_WIKISTORIES,
234            'rc_minor' => $minor,
235            'rc_cur_id' => $article->getId(),
236            'rc_user' => $user->getId(),
237            'rc_user_text' => $user->getName(),
238            'rc_comment' => $summary,
239            'rc_comment_text' => $summary,
240            'rc_comment_data' => null,
241            'rc_this_oldid' => (int)$revisionRecord->getId(),
242            'rc_last_oldid' => (int)$revisionRecord->getParentId(),
243            'rc_bot' => $bot,
244            'rc_ip' => $requestIP,
245            'rc_patrolled' => $patrolled,
246            'rc_old_len' => 0,
247            'rc_new_len' => 0,
248            'rc_deleted' => 0,
249            'rc_logid' => 0,
250            'rc_log_type' => null,
251            'rc_log_action' => '',
252            'rc_params' => serialize( [
253                'story_title' => $revisionRecord->getPage()->getDBkey(),
254                'story_id' => $revisionRecord->getPage()->getId(),
255            ] )
256        ];
257
258        // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
259        $rc->mExtra = [
260            'prefixedDBkey' => $this->titleFormatter->getPrefixedDBkey( $article ),
261            'lastTimestamp' => 0,
262            'oldSize' => 0,
263            'newSize' => 0,
264            'pageStatus' => 'changed'
265        ];
266
267        if ( $editResult ) {
268            $rc->setEditResult( $editResult );
269        }
270
271        return $rc;
272    }
273
274}