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