Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 142 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
SubscriptionStore | |
0.00% |
0 / 142 |
|
0.00% |
0 / 10 |
1056 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
fetchSubscriptions | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
getSubscriptionItemsForUser | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
42 | |||
getSubscriptionItemsForTopic | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
getSubscriptionItemFromRow | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
addSubscriptionForUser | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
30 | |||
removeSubscriptionForUser | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
addAutoSubscriptionForUser | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
updateSubscriptionTimestamp | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
updateSubscriptionNotifiedTimestamp | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\DiscussionTools; |
4 | |
5 | use MediaWiki\Config\Config; |
6 | use MediaWiki\Config\ConfigFactory; |
7 | use MediaWiki\Linker\LinkTarget; |
8 | use MediaWiki\Title\TitleValue; |
9 | use MediaWiki\User\UserFactory; |
10 | use MediaWiki\User\UserIdentity; |
11 | use MediaWiki\User\UserIdentityUtils; |
12 | use stdClass; |
13 | use Wikimedia\Rdbms\FakeResultWrapper; |
14 | use Wikimedia\Rdbms\IConnectionProvider; |
15 | use Wikimedia\Rdbms\IReadableDatabase; |
16 | use Wikimedia\Rdbms\IResultWrapper; |
17 | use Wikimedia\Rdbms\ReadOnlyMode; |
18 | |
19 | class SubscriptionStore { |
20 | |
21 | /** |
22 | * Constants for the values of the sub_state field. |
23 | */ |
24 | public const STATE_UNSUBSCRIBED = 0; |
25 | public const STATE_SUBSCRIBED = 1; |
26 | public const STATE_AUTOSUBSCRIBED = 2; |
27 | |
28 | private Config $config; |
29 | private IConnectionProvider $dbProvider; |
30 | private ReadOnlyMode $readOnlyMode; |
31 | private UserFactory $userFactory; |
32 | private UserIdentityUtils $userIdentityUtils; |
33 | |
34 | public function __construct( |
35 | ConfigFactory $configFactory, |
36 | IConnectionProvider $dbProvider, |
37 | ReadOnlyMode $readOnlyMode, |
38 | UserFactory $userFactory, |
39 | UserIdentityUtils $userIdentityUtils |
40 | ) { |
41 | $this->config = $configFactory->makeConfig( 'discussiontools' ); |
42 | $this->dbProvider = $dbProvider; |
43 | $this->readOnlyMode = $readOnlyMode; |
44 | $this->userFactory = $userFactory; |
45 | $this->userIdentityUtils = $userIdentityUtils; |
46 | } |
47 | |
48 | /** |
49 | * @param IReadableDatabase $db |
50 | * @param UserIdentity|null $user |
51 | * @param array|null $itemNames |
52 | * @param int|int[]|null $state One of (or an array of) SubscriptionStore::STATE_* constants |
53 | * @return IResultWrapper|false |
54 | */ |
55 | private function fetchSubscriptions( |
56 | IReadableDatabase $db, |
57 | ?UserIdentity $user = null, |
58 | ?array $itemNames = null, |
59 | $state = null |
60 | ) { |
61 | $conditions = []; |
62 | |
63 | if ( $user ) { |
64 | $conditions[ 'sub_user' ] = $user->getId(); |
65 | } |
66 | |
67 | if ( $itemNames !== null ) { |
68 | if ( !count( $itemNames ) ) { |
69 | // We are not allowed to construct a filter with an empty array. |
70 | // Any empty array should result in no items being returned. |
71 | return new FakeResultWrapper( [] ); |
72 | } |
73 | $conditions[ 'sub_item' ] = $itemNames; |
74 | } |
75 | |
76 | if ( $state !== null ) { |
77 | $conditions[ 'sub_state' ] = $state; |
78 | } |
79 | |
80 | return $db->newSelectQueryBuilder() |
81 | ->from( 'discussiontools_subscription' ) |
82 | ->fields( [ |
83 | 'sub_user', 'sub_item', 'sub_namespace', 'sub_title', 'sub_section', 'sub_state', |
84 | 'sub_created', 'sub_notified' |
85 | ] ) |
86 | ->where( $conditions ) |
87 | ->caller( __METHOD__ ) |
88 | ->fetchResultSet(); |
89 | } |
90 | |
91 | /** |
92 | * @param UserIdentity $user |
93 | * @param array|null $itemNames |
94 | * @param int[]|null $state Array of SubscriptionStore::STATE_* constants |
95 | * @param array $options |
96 | * @return SubscriptionItem[] |
97 | */ |
98 | public function getSubscriptionItemsForUser( |
99 | UserIdentity $user, |
100 | ?array $itemNames = null, |
101 | ?array $state = null, |
102 | array $options = [] |
103 | ): array { |
104 | // Only a registered user can be subscribed |
105 | if ( !$user->isRegistered() || $this->userIdentityUtils->isTemp( $user ) ) { |
106 | return []; |
107 | } |
108 | |
109 | $options += [ 'forWrite' => false ]; |
110 | if ( $options['forWrite'] ) { |
111 | $db = $this->dbProvider->getPrimaryDatabase(); |
112 | } else { |
113 | $db = $this->dbProvider->getReplicaDatabase(); |
114 | } |
115 | |
116 | $rows = $this->fetchSubscriptions( |
117 | $db, |
118 | $user, |
119 | $itemNames, |
120 | $state |
121 | ); |
122 | |
123 | if ( !$rows ) { |
124 | return []; |
125 | } |
126 | |
127 | $items = []; |
128 | foreach ( $rows as $row ) { |
129 | $target = new TitleValue( (int)$row->sub_namespace, $row->sub_title, $row->sub_section ); |
130 | $items[] = $this->getSubscriptionItemFromRow( $user, $target, $row ); |
131 | } |
132 | |
133 | return $items; |
134 | } |
135 | |
136 | /** |
137 | * @param string $itemName |
138 | * @param int[]|null $state An array of SubscriptionStore::STATE_* constants |
139 | * @param array $options |
140 | * @return array |
141 | */ |
142 | public function getSubscriptionItemsForTopic( |
143 | string $itemName, |
144 | ?array $state = null, |
145 | array $options = [] |
146 | ): array { |
147 | $options += [ 'forWrite' => false ]; |
148 | if ( $options['forWrite'] ) { |
149 | $db = $this->dbProvider->getPrimaryDatabase(); |
150 | } else { |
151 | $db = $this->dbProvider->getReplicaDatabase(); |
152 | } |
153 | |
154 | $rows = $this->fetchSubscriptions( |
155 | $db, |
156 | null, |
157 | [ $itemName ], |
158 | $state |
159 | ); |
160 | |
161 | if ( !$rows ) { |
162 | return []; |
163 | } |
164 | |
165 | $items = []; |
166 | foreach ( $rows as $row ) { |
167 | $target = new TitleValue( (int)$row->sub_namespace, $row->sub_title, $row->sub_section ); |
168 | $user = $this->userFactory->newFromId( $row->sub_user ); |
169 | $items[] = $this->getSubscriptionItemFromRow( $user, $target, $row ); |
170 | } |
171 | |
172 | return $items; |
173 | } |
174 | |
175 | private function getSubscriptionItemFromRow( |
176 | UserIdentity $user, |
177 | LinkTarget $target, |
178 | stdClass $row |
179 | ): SubscriptionItem { |
180 | return new SubscriptionItem( |
181 | $user, |
182 | $row->sub_item, |
183 | $target, |
184 | $row->sub_state, |
185 | $row->sub_created, |
186 | $row->sub_notified |
187 | ); |
188 | } |
189 | |
190 | public function addSubscriptionForUser( |
191 | UserIdentity $user, |
192 | LinkTarget $target, |
193 | string $itemName, |
194 | // Can not use static:: in compile-time constants |
195 | int $state = self::STATE_SUBSCRIBED |
196 | ): bool { |
197 | if ( $this->readOnlyMode->isReadOnly() ) { |
198 | return false; |
199 | } |
200 | // Only a registered user can subscribe |
201 | if ( !$user->isRegistered() || $this->userIdentityUtils->isTemp( $user ) ) { |
202 | return false; |
203 | } |
204 | |
205 | $section = $target->getFragment(); |
206 | // Truncate to the database field length, taking care not to mess up multibyte characters, |
207 | // appending a marker so that we can recognize this happened and display an ellipsis later. |
208 | // Using U+001F "Unit Separator" seems appropriate, and it can't occur in wikitext. |
209 | $truncSection = strlen( $section ) > 254 ? mb_strcut( $section, 0, 254 ) . "\x1f" : $section; |
210 | |
211 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
212 | $dbw->newInsertQueryBuilder() |
213 | ->table( 'discussiontools_subscription' ) |
214 | ->row( [ |
215 | 'sub_user' => $user->getId(), |
216 | 'sub_namespace' => $target->getNamespace(), |
217 | 'sub_title' => $target->getDBkey(), |
218 | 'sub_section' => $truncSection, |
219 | 'sub_item' => $itemName, |
220 | 'sub_state' => $state, |
221 | 'sub_created' => $dbw->timestamp(), |
222 | ] ) |
223 | ->onDuplicateKeyUpdate() |
224 | ->uniqueIndexFields( [ 'sub_user', 'sub_item' ] ) |
225 | ->set( [ |
226 | 'sub_state' => $state, |
227 | ] ) |
228 | ->caller( __METHOD__ )->execute(); |
229 | return (bool)$dbw->affectedRows(); |
230 | } |
231 | |
232 | public function removeSubscriptionForUser( |
233 | UserIdentity $user, |
234 | string $itemName |
235 | ): bool { |
236 | if ( $this->readOnlyMode->isReadOnly() ) { |
237 | return false; |
238 | } |
239 | // Only a registered user can subscribe |
240 | if ( !$user->isRegistered() || $this->userIdentityUtils->isTemp( $user ) ) { |
241 | return false; |
242 | } |
243 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
244 | $dbw->newUpdateQueryBuilder() |
245 | ->table( 'discussiontools_subscription' ) |
246 | ->set( [ 'sub_state' => static::STATE_UNSUBSCRIBED ] ) |
247 | ->where( [ |
248 | 'sub_user' => $user->getId(), |
249 | 'sub_item' => $itemName, |
250 | ] ) |
251 | ->caller( __METHOD__ ) |
252 | ->execute(); |
253 | return (bool)$dbw->affectedRows(); |
254 | } |
255 | |
256 | public function addAutoSubscriptionForUser( |
257 | UserIdentity $user, |
258 | LinkTarget $target, |
259 | string $itemName |
260 | ): bool { |
261 | // Check for existing subscriptions. |
262 | $subscriptionItems = $this->getSubscriptionItemsForUser( |
263 | $user, |
264 | [ $itemName ], |
265 | [ static::STATE_SUBSCRIBED, static::STATE_AUTOSUBSCRIBED ], |
266 | [ 'forWrite' => true ] |
267 | ); |
268 | if ( $subscriptionItems ) { |
269 | return false; |
270 | } |
271 | |
272 | return $this->addSubscriptionForUser( |
273 | $user, |
274 | $target, |
275 | $itemName, |
276 | static::STATE_AUTOSUBSCRIBED |
277 | ); |
278 | } |
279 | |
280 | /** |
281 | * @param string $field Timestamp field name |
282 | * @param UserIdentity|null $user |
283 | * @param string $itemName |
284 | * @return bool |
285 | */ |
286 | private function updateSubscriptionTimestamp( |
287 | string $field, |
288 | ?UserIdentity $user, |
289 | string $itemName |
290 | ): bool { |
291 | if ( $this->readOnlyMode->isReadOnly() ) { |
292 | return false; |
293 | } |
294 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
295 | |
296 | $conditions = [ |
297 | 'sub_item' => $itemName, |
298 | ]; |
299 | |
300 | if ( $user ) { |
301 | $conditions[ 'sub_user' ] = $user->getId(); |
302 | } |
303 | |
304 | $dbw->newUpdateQueryBuilder() |
305 | ->table( 'discussiontools_subscription' ) |
306 | ->set( [ $field => $dbw->timestamp() ] ) |
307 | ->where( $conditions ) |
308 | ->caller( __METHOD__ ) |
309 | ->execute(); |
310 | return (bool)$dbw->affectedRows(); |
311 | } |
312 | |
313 | /** |
314 | * Update the notified timestamp on a subscription |
315 | * |
316 | * This field could be used in future to cleanup notifications |
317 | * that are no longer needed (e.g. because the conversation has |
318 | * been archived), so should be set for muted notifications too. |
319 | */ |
320 | public function updateSubscriptionNotifiedTimestamp( |
321 | ?UserIdentity $user, |
322 | string $itemName |
323 | ): bool { |
324 | return $this->updateSubscriptionTimestamp( |
325 | 'sub_notified', |
326 | $user, |
327 | $itemName |
328 | ); |
329 | } |
330 | } |