1<?php
2
3namespace MediaWiki\Revision;
4
5use ContribsPager;
6use FauxRequest;
7use MediaWiki\User\UserIdentity;
8use RequestContext;
9use User;
10
11/**
12 * @since 1.35
13 */
14class ContributionsLookup {
15
16	/**
17	 * @var RevisionStore
18	 */
19	private $revisionStore;
20
21	/**
22	 * ContributionsLookup constructor.
23	 *
24	 * @param RevisionStore $revisionStore
25	 */
26	public function __construct( RevisionStore $revisionStore ) {
27		$this->revisionStore = $revisionStore;
28	}
29
30	/**
31	 * Constructs fake query parameters to be passed to ContribsPager
32	 *
33	 * @param int $limit Maximum number of revisions to return.
34	 * @param string $segment Indicates which segment of the contributions to return.
35	 * The segment should consist of 2 parts separated by a pipe character.
36	 * The first part is mapped to the 'dir' parameter.
37	 * The second part is mapped to the 'offset' parameter.
38	 * The value for the offset is opaque and is ultimately supplied by ContribsPager::getPagingQueries().
39	 * @return array
40	 */
41	private function getPagerParams( int $limit, string $segment ) {
42		$dir = 'next';
43		$seg = explode( '|', $segment, 2 );
44		if ( count( $seg ) > 1 ) {
45			if ( $seg[0] === 'after' ) {
46				$dir = 'prev';
47				$segment = $seg[1];
48			} elseif ( $seg[0] == 'before' ) {
49				$dir = 'next';
50				$segment = $seg[1];
51			} else {
52				$dir = null;
53				$segment = null;
54			}
55		} else {
56			$segment = null;
57		}
58		return [
59			'limit' => $limit,
60			'offset' => $segment,
61			'dir' => $dir
62		];
63	}
64
65	/**
66	 * @param UserIdentity $target the user from whom to retrieve contributions
67	 * @param int $limit the maximum number of revisions to return
68	 * @param User $performer the user used for permission checks
69	 * @param string $segment
70	 * @param string|null $tag
71	 *
72	 * @return ContributionsSegment
73	 * @throws \MWException
74	 */
75	public function getContributions(
76		UserIdentity $target,
77		int $limit,
78		User $performer,
79		string $segment = '',
80		string $tag = null
81	): ContributionsSegment {
82		$context = new RequestContext();
83		$context->setUser( $performer );
84
85		$paramArr = $this->getPagerParams( $limit, $segment );
86		$context->setRequest( new FauxRequest( $paramArr ) );
87
88		// TODO: explore moving this to factory method for testing
89		$pager = new ContribsPager( $context, [
90			'target' => $target->getName(),
91			'tagfilter' => $tag,
92		] );
93		$revisions = [];
94		$tags = [];
95		$count = 0;
96		if ( $pager->getNumRows() > 0 ) {
97			foreach ( $pager->mResult as $row ) {
98				// We retrieve and ignore one extra record to see if we are on the oldest segment.
99				if ( ++$count > $limit ) {
100					break;
101				}
102
103				// TODO: pre-load title batch?
104				$revision = $this->revisionStore->newRevisionFromRow( $row, 0 );
105				$revisions[] = $revision;
106				$tags[ $row->rev_id ] =
107					$row->ts_tags ? explode( ',', $row->ts_tags ) : [];
108			}
109		}
110
111		$deltas = $this->getContributionDeltas( $revisions );
112
113		$flags = [
114			'newest' => $pager->mIsFirst,
115			'oldest' => $pager->mIsLast,
116		];
117
118		// TODO: Make me an option in IndexPager
119		$pager->mIsFirst = false; // XXX: nasty...
120		$pagingQueries = $pager->getPagingQueries();
121
122		$prev = $pagingQueries['prev']['offset'] ?? null;
123		$next = $pagingQueries['next']['offset'] ?? null;
124
125		$after = $prev ? 'after|' . $prev : null; // later in time
126		$before = $next ? 'before|' . $next : null; // earlier in time
127
128		// TODO: Possibly return public $pager properties to segment for populating URLS ($mIsFirst, $mIsLast)
129		// HACK: Force result set order to be descending. Sorting logic in ContribsPager::reallyDoQuery is confusing.
130		if ( $paramArr['dir'] === 'prev' ) {
131			$revisions = array_reverse( $revisions );
132		}
133		return new ContributionsSegment( $revisions, $tags, $before, $after,  $deltas, $flags );
134	}
135
136	/**
137	 * Gets size deltas of a revision and its parent revision
138	 * @param RevisionRecord[] $revisions
139	 * @return int[] Associative array of revision ids and their deltas.
140	 *  If revision is the first on a page, delta is revision size.
141	 *  If parent revision is unknown, delta is null.
142	 */
143	private function getContributionDeltas( $revisions ) {
144		// SpecialContributions uses the size of the revision if the parent revision is unknown. Cases include:
145		// - revision has been deleted
146		// - parent rev id has not been populated (this is the case for very old revisions)
147		$parentIds = [];
148		foreach ( $revisions as $revision ) {
149			$revId = $revision->getId();
150			$parentIds[$revId] = $revision->getParentId();
151		}
152		$parentSizes = $this->revisionStore->getRevisionSizes( $parentIds );
153		$deltas = [];
154		foreach ( $revisions as $revision ) {
155			$parentId = $revision->getParentId();
156			if ( $parentId === 0 ) { // first revision on a page
157				$delta = $revision->getSize();
158			} elseif ( !isset( $parentSizes[$parentId] ) ) { // parent revision is either deleted or untracked
159				$delta = null;
160			} else {
161				$delta = $revision->getSize() - $parentSizes[$parentId];
162			}
163			$deltas[ $revision->getId() ] = $delta;
164		}
165		return $deltas;
166	}
167
168	/**
169	 * Returns the number of edits by the given user.
170	 *
171	 * @param UserIdentity $user
172	 * @param User $performer the user used for permission checks
173	 * @param string|null $tag
174	 *
175	 * @return int
176	 */
177	public function getContributionCount( UserIdentity $user, User $performer, $tag = null ): int {
178		$context = new RequestContext();
179		$context->setUser( $performer );
180		$context->setRequest( new FauxRequest( [] ) );
181
182		// TODO: explore moving this to factory method for testing
183		$pager = new ContribsPager( $context, [
184			'target' => $user->getName(),
185			'tagfilter' => $tag,
186		] );
187
188		$query = $pager->getQueryInfo();
189
190		$count = $pager->mDb->selectField(
191			$query['tables'],
192			'COUNT(*)',
193			$query['conds'],
194			__METHOD__,
195			[],
196			$query['join_conds']
197		);
198
199		// FIXME: this count does not include contributions that extensions would be injecting
200		//   via the ContribsPager__reallyDoQuery.
201
202		return (int)$count;
203	}
204}
205