1<?php
2
3namespace MediaWiki\Revision;
4
5use ActorMigration;
6use ChangeTags;
7use ContribsPager;
8use FauxRequest;
9use IContextSource;
10use MediaWiki\Cache\LinkBatchFactory;
11use MediaWiki\HookContainer\HookContainer;
12use MediaWiki\Linker\LinkRendererFactory;
13use MediaWiki\Permissions\Authority;
14use MediaWiki\User\UserIdentity;
15use Message;
16use NamespaceInfo;
17use RequestContext;
18use Wikimedia\Rdbms\ILoadBalancer;
19
20/**
21 * @since 1.35
22 */
23class ContributionsLookup {
24
25	/** @var RevisionStore */
26	private $revisionStore;
27
28	/** @var LinkRendererFactory */
29	private $linkRendererFactory;
30
31	/** @var LinkBatchFactory */
32	private $linkBatchFactory;
33
34	/** @var HookContainer */
35	private $hookContainer;
36
37	/** @var ILoadBalancer */
38	private $loadBalancer;
39
40	/** @var ActorMigration */
41	private $actorMigration;
42
43	/** @var NamespaceInfo */
44	private $namespaceInfo;
45
46	/**
47	 * @param RevisionStore $revisionStore
48	 * @param LinkRendererFactory $linkRendererFactory
49	 * @param LinkBatchFactory $linkBatchFactory
50	 * @param HookContainer $hookContainer
51	 * @param ILoadBalancer $loadBalancer
52	 * @param ActorMigration $actorMigration
53	 * @param NamespaceInfo $namespaceInfo
54	 */
55	public function __construct(
56		RevisionStore $revisionStore,
57		LinkRendererFactory $linkRendererFactory,
58		LinkBatchFactory $linkBatchFactory,
59		HookContainer $hookContainer,
60		ILoadBalancer $loadBalancer,
61		ActorMigration $actorMigration,
62		NamespaceInfo $namespaceInfo
63	) {
64		$this->revisionStore = $revisionStore;
65		$this->linkRendererFactory = $linkRendererFactory;
66		$this->linkBatchFactory = $linkBatchFactory;
67		$this->hookContainer = $hookContainer;
68		$this->loadBalancer = $loadBalancer;
69		$this->actorMigration = $actorMigration;
70		$this->namespaceInfo = $namespaceInfo;
71	}
72
73	/**
74	 * Constructs fake query parameters to be passed to ContribsPager
75	 *
76	 * @param int $limit Maximum number of revisions to return.
77	 * @param string $segment Indicates which segment of the contributions to return.
78	 * The segment should consist of 2 parts separated by a pipe character.
79	 * The first part is mapped to the 'dir' parameter.
80	 * The second part is mapped to the 'offset' parameter.
81	 * The value for the offset is opaque and is ultimately supplied by ContribsPager::getPagingQueries().
82	 * @return array
83	 */
84	private function getPagerParams( int $limit, string $segment ): array {
85		$dir = 'next';
86		$seg = explode( '|', $segment, 2 );
87		if ( count( $seg ) > 1 ) {
88			if ( $seg[0] === 'after' ) {
89				$dir = 'prev';
90				$segment = $seg[1];
91			} elseif ( $seg[0] == 'before' ) {
92				$segment = $seg[1];
93			} else {
94				$dir = null;
95				$segment = null;
96			}
97		} else {
98			$segment = null;
99		}
100		return [
101			'limit' => $limit,
102			'offset' => $segment,
103			'dir' => $dir
104		];
105	}
106
107	/**
108	 * @param UserIdentity $target the user from whom to retrieve contributions
109	 * @param int $limit the maximum number of revisions to return
110	 * @param Authority $performer the user used for permission checks
111	 * @param string $segment
112	 * @param string|null $tag
113	 *
114	 * @return ContributionsSegment
115	 * @throws \MWException
116	 */
117	public function getContributions(
118		UserIdentity $target,
119		int $limit,
120		Authority $performer,
121		string $segment = '',
122		string $tag = null
123	): ContributionsSegment {
124		$context = new RequestContext();
125		$context->setAuthority( $performer );
126
127		$paramArr = $this->getPagerParams( $limit, $segment );
128		$context->setRequest( new FauxRequest( $paramArr ) );
129
130		// TODO: explore moving this to factory method for testing
131		$pager = $this->getContribsPager( $context, $target, [
132			'tagfilter' => $tag,
133			'revisionsOnly' => true
134		] );
135		$revisions = [];
136		$tags = [];
137		$count = 0;
138		if ( $pager->getNumRows() > 0 ) {
139			foreach ( $pager->mResult as $row ) {
140				// We retrieve and ignore one extra record to see if we are on the oldest segment.
141				if ( ++$count > $limit ) {
142					break;
143				}
144
145				// TODO: pre-load title batch?
146				$revision = $this->revisionStore->newRevisionFromRow( $row, 0 );
147				$revisions[] = $revision;
148				if ( $row->ts_tags ) {
149					$tagNames = explode( ',', $row->ts_tags );
150					$tags[ $row->rev_id ] = $this->getContributionTags( $tagNames );
151				}
152			}
153		}
154
155		$deltas = $this->getContributionDeltas( $revisions );
156
157		$flags = [
158			'newest' => $pager->mIsFirst,
159			'oldest' => $pager->mIsLast,
160		];
161
162		// TODO: Make me an option in IndexPager
163		$pager->mIsFirst = false; // XXX: nasty...
164		$pagingQueries = $pager->getPagingQueries();
165
166		$prev = $pagingQueries['prev']['offset'] ?? null;
167		$next = $pagingQueries['next']['offset'] ?? null;
168
169		$after = $prev ? 'after|' . $prev : null; // later in time
170		$before = $next ? 'before|' . $next : null; // earlier in time
171
172		// TODO: Possibly return public $pager properties to segment for populating URLS ($mIsFirst, $mIsLast)
173		// HACK: Force result set order to be descending. Sorting logic in ContribsPager::reallyDoQuery is confusing.
174		if ( $paramArr['dir'] === 'prev' ) {
175			$revisions = array_reverse( $revisions );
176		}
177		return new ContributionsSegment( $revisions, $tags, $before, $after, $deltas, $flags );
178	}
179
180	/**
181	 * @param string[] $tagNames Array of tag names
182	 * @return Message[] Associative array mapping tag name to a Message object containing the tag's display value
183	 */
184	private function getContributionTags( array $tagNames ): array {
185		$tagMetadata = [];
186		foreach ( $tagNames as $name ) {
187			$tagDisplay = ChangeTags::tagShortDescriptionMessage( $name, RequestContext::getMain() );
188			if ( $tagDisplay ) {
189				$tagMetadata[$name] = $tagDisplay;
190			}
191		}
192		return $tagMetadata;
193	}
194
195	/**
196	 * Gets size deltas of a revision and its parent revision
197	 * @param RevisionRecord[] $revisions
198	 * @return int[] Associative array of revision ids and their deltas.
199	 *  If revision is the first on a page, delta is revision size.
200	 *  If parent revision is unknown, delta is null.
201	 */
202	private function getContributionDeltas( $revisions ): array {
203		// SpecialContributions uses the size of the revision if the parent revision is unknown. Cases include:
204		// - revision has been deleted
205		// - parent rev id has not been populated (this is the case for very old revisions)
206		$parentIds = [];
207		foreach ( $revisions as $revision ) {
208			$revId = $revision->getId();
209			$parentIds[$revId] = $revision->getParentId();
210		}
211		$parentSizes = $this->revisionStore->getRevisionSizes( $parentIds );
212		$deltas = [];
213		foreach ( $revisions as $revision ) {
214			$parentId = $revision->getParentId();
215			if ( $parentId === 0 ) { // first revision on a page
216				$delta = $revision->getSize();
217			} elseif ( !isset( $parentSizes[$parentId] ) ) { // parent revision is either deleted or untracked
218				$delta = null;
219			} else {
220				$delta = $revision->getSize() - $parentSizes[$parentId];
221			}
222			$deltas[ $revision->getId() ] = $delta;
223		}
224		return $deltas;
225	}
226
227	/**
228	 * Returns the number of edits by the given user.
229	 *
230	 * @param UserIdentity $user
231	 * @param Authority $performer the user used for permission checks
232	 * @param string|null $tag
233	 *
234	 * @return int
235	 */
236	public function getContributionCount( UserIdentity $user, Authority $performer, $tag = null ): int {
237		$context = new RequestContext();
238		$context->setAuthority( $performer );
239		$context->setRequest( new FauxRequest( [] ) );
240
241		// TODO: explore moving this to factory method for testing
242		$pager = $this->getContribsPager( $context, $user, [
243			'tagfilter' => $tag,
244		] );
245
246		$query = $pager->getQueryInfo();
247
248		$count = $pager->mDb->selectField(
249			$query['tables'],
250			'COUNT(*)',
251			$query['conds'],
252			__METHOD__,
253			[],
254			$query['join_conds']
255		);
256
257		return (int)$count;
258	}
259
260	private function getContribsPager(
261		IContextSource $context,
262		UserIdentity $targetUser,
263		array $options
264	) {
265		return new ContribsPager(
266			$context,
267			$options,
268			$this->linkRendererFactory->create(),
269			$this->linkBatchFactory,
270			$this->hookContainer,
271			$this->loadBalancer,
272			$this->actorMigration,
273			$this->revisionStore,
274			$this->namespaceInfo,
275			$targetUser
276		);
277	}
278
279}
280