Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.37% covered (success)
94.37%
67 / 71
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryMembershipChange
94.37% covered (success)
94.37%
67 / 71
60.00% covered (warning)
60.00%
6 / 10
19.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 overrideNewForCategorizationCallback
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 checkTemplateLinks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 triggerCategoryAddedNotification
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 triggerCategoryRemovedNotification
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createRecentChangesEntry
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 notifyCategorization
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
5
 getUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getChangeMessageText
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getPreviousRevisionTimestamp
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
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 */
20
21use MediaWiki\Cache\BacklinkCache;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\Page\PageIdentity;
24use MediaWiki\Revision\RevisionRecord;
25use MediaWiki\Title\Title;
26use MediaWiki\User\UserIdentity;
27use Wikimedia\Rdbms\IDBAccessObject;
28
29/**
30 * Helper class for category membership changes
31 *
32 * @since 1.27
33 * @ingroup RecentChanges
34 * @author Kai Nissen
35 * @author Addshore
36 */
37class CategoryMembershipChange {
38
39    private const CATEGORY_ADDITION = 1;
40    private const CATEGORY_REMOVAL = -1;
41
42    /**
43     * @var string Timestamp of the revision associated with this category membership change
44     */
45    private $timestamp;
46
47    /**
48     * @var Title Title instance of the categorized page
49     */
50    private $pageTitle;
51
52    /**
53     * @var RevisionRecord Latest revision of the categorized page
54     */
55    private RevisionRecord $revision;
56
57    /** @var bool Whether this was caused by an import */
58    private $forImport;
59
60    /**
61     * @var int
62     * Number of pages this WikiPage is embedded by
63     * Set by CategoryMembershipChange::checkTemplateLinks()
64     */
65    private $numTemplateLinks = 0;
66
67    /**
68     * @var callable|null
69     */
70    private $newForCategorizationCallback = null;
71
72    /** @var BacklinkCache */
73    private $backlinkCache;
74
75    /**
76     * @param Title $pageTitle Title instance of the categorized page
77     * @param BacklinkCache $backlinkCache
78     * @param RevisionRecord $revision Latest revision of the categorized page.
79     * @param bool $forImport Whether this was caused by an import
80     */
81    public function __construct(
82        Title $pageTitle, BacklinkCache $backlinkCache, RevisionRecord $revision, bool $forImport
83    ) {
84        $this->pageTitle = $pageTitle;
85        $this->revision = $revision;
86
87        // Use the current timestamp for creating the RC entry when dealing with imported revisions,
88        // since their timestamp may be significantly older than the current time.
89        // This ensures the resulting RC entry won't be immediately reaped by probabilistic RC purging if
90        // the imported revision is older than $wgRCMaxAge (T377392).
91        $this->timestamp = $forImport ? wfTimestampNow() : $revision->getTimestamp();
92        $this->newForCategorizationCallback = [ RecentChange::class, 'newForCategorization' ];
93        $this->backlinkCache = $backlinkCache;
94        $this->forImport = $forImport;
95    }
96
97    /**
98     * Overrides the default new for categorization callback
99     * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
100     *
101     * @param callable $callback
102     * @see RecentChange::newForCategorization for callback signiture
103     */
104    public function overrideNewForCategorizationCallback( callable $callback ) {
105        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
106            throw new LogicException( 'Cannot override newForCategorization callback in operation.' );
107        }
108        $this->newForCategorizationCallback = $callback;
109    }
110
111    /**
112     * Determines the number of template links for recursive link updates
113     */
114    public function checkTemplateLinks() {
115        $this->numTemplateLinks = $this->backlinkCache->getNumLinks( 'templatelinks' );
116    }
117
118    /**
119     * Create a recentchanges entry for category additions
120     *
121     * @param PageIdentity $categoryPage
122     */
123    public function triggerCategoryAddedNotification( PageIdentity $categoryPage ) {
124        $this->createRecentChangesEntry( $categoryPage, self::CATEGORY_ADDITION );
125    }
126
127    /**
128     * Create a recentchanges entry for category removals
129     *
130     * @param PageIdentity $categoryPage
131     */
132    public function triggerCategoryRemovedNotification( PageIdentity $categoryPage ) {
133        $this->createRecentChangesEntry( $categoryPage, self::CATEGORY_REMOVAL );
134    }
135
136    /**
137     * Create a recentchanges entry using RecentChange::notifyCategorization()
138     *
139     * @param PageIdentity $categoryPage
140     * @param int $type
141     */
142    private function createRecentChangesEntry( PageIdentity $categoryPage, $type ) {
143        $this->notifyCategorization(
144            $this->timestamp,
145            $categoryPage,
146            $this->getUser(),
147            $this->getChangeMessageText(
148                $type,
149                $this->pageTitle->getPrefixedText(),
150                $this->numTemplateLinks
151            ),
152            $this->pageTitle,
153            $this->getPreviousRevisionTimestamp(),
154            $this->revision,
155            $this->forImport,
156            $type === self::CATEGORY_ADDITION
157        );
158    }
159
160    /**
161     * @param string $timestamp Timestamp of the recent change to occur in TS_MW format
162     * @param PageIdentity $categoryPage Page of the category a page is being added to or removed from
163     * @param UserIdentity|null $user User object of the user that made the change
164     * @param string $comment Change summary
165     * @param PageIdentity $page Page that is being added or removed
166     * @param string $lastTimestamp Parent revision timestamp of this change in TS_MW format
167     * @param RevisionRecord $revision
168     * @param bool $forImport Whether the associated revision was imported
169     * @param bool $added true, if the category was added, false for removed
170     */
171    private function notifyCategorization(
172        $timestamp,
173        PageIdentity $categoryPage,
174        ?UserIdentity $user,
175        $comment,
176        PageIdentity $page,
177        $lastTimestamp,
178        RevisionRecord $revision,
179        bool $forImport,
180        $added
181    ) {
182        $deleted = $revision->getVisibility() & RevisionRecord::SUPPRESSED_USER;
183        $newRevId = $revision->getId();
184
185        /**
186         * T109700 - Default bot flag to true when there is no corresponding RC entry
187         * This means all changes caused by parser functions & Lua on reparse are marked as bot
188         * Also in the case no RC entry could be found due to replica DB lag
189         */
190        $bot = 1;
191        $lastRevId = 0;
192        $ip = '';
193
194        $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
195        $correspondingRc = $revisionStore->getRecentChange( $revision ) ??
196            $revisionStore->getRecentChange( $revision, IDBAccessObject::READ_LATEST );
197        if ( $correspondingRc !== null ) {
198            $bot = $correspondingRc->getAttribute( 'rc_bot' ) ?: 0;
199            $ip = $correspondingRc->getAttribute( 'rc_ip' ) ?: '';
200            $lastRevId = $correspondingRc->getAttribute( 'rc_last_oldid' ) ?: 0;
201        }
202
203        /** @var RecentChange $rc */
204        $rc = ( $this->newForCategorizationCallback )(
205            $timestamp,
206            $categoryPage,
207            $user,
208            $comment,
209            $page,
210            $lastRevId,
211            $newRevId,
212            $lastTimestamp,
213            $bot,
214            $ip,
215            $deleted,
216            $added,
217            $forImport
218        );
219        $rc->save();
220    }
221
222    /**
223     * Get the user associated with this change, or `null` if there is no valid author
224     * associated with this change.
225     *
226     * @return UserIdentity|null
227     */
228    private function getUser(): ?UserIdentity {
229        return $this->revision->getUser( RevisionRecord::RAW );
230    }
231
232    /**
233     * Returns the change message according to the type of category membership change
234     *
235     * The message keys created in this method may be one of:
236     * - recentchanges-page-added-to-category
237     * - recentchanges-page-added-to-category-bundled
238     * - recentchanges-page-removed-from-category
239     * - recentchanges-page-removed-from-category-bundled
240     *
241     * @param int $type may be CategoryMembershipChange::CATEGORY_ADDITION
242     * or CategoryMembershipChange::CATEGORY_REMOVAL
243     * @param string $prefixedText result of Title::->getPrefixedText()
244     * @param int $numTemplateLinks
245     *
246     * @return string
247     */
248    private function getChangeMessageText( $type, $prefixedText, $numTemplateLinks ) {
249        $array = [
250            self::CATEGORY_ADDITION => 'recentchanges-page-added-to-category',
251            self::CATEGORY_REMOVAL => 'recentchanges-page-removed-from-category',
252        ];
253
254        $msgKey = $array[$type];
255
256        if ( intval( $numTemplateLinks ) > 0 ) {
257            $msgKey .= '-bundled';
258        }
259
260        return wfMessage( $msgKey, $prefixedText )->inContentLanguage()->text();
261    }
262
263    /**
264     * Returns the timestamp of the page's previous revision or null if the latest revision
265     * does not refer to a parent revision
266     *
267     * @return null|string
268     */
269    private function getPreviousRevisionTimestamp() {
270        $rl = MediaWikiServices::getInstance()->getRevisionLookup();
271        $latestRev = $rl->getRevisionByTitle( $this->pageTitle );
272        if ( $latestRev ) {
273            $previousRev = $rl->getPreviousRevision( $latestRev );
274            if ( $previousRev ) {
275                return $previousRev->getTimestamp();
276            }
277        }
278        return null;
279    }
280
281}