Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
9.92% |
12 / 121 |
|
14.29% |
2 / 14 |
CRAP | |
0.00% |
0 / 1 |
QuestionStore | |
9.92% |
12 / 121 |
|
14.29% |
2 / 14 |
879.05 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
add | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
loadQuestions | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
loadQuestionsAndUpdate | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
110 | |||
excludeHiddenQuestions | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
questionExistsOnPage | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
write | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
saveToUserSettings | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
saveToUserSettingsWithJob | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
encodeQuestionsToJson | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
prependQuestion | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
assignArchiveUrl | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
trimQuestion | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
isRevisionVisible | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\HelpPanel; |
4 | |
5 | use Flow\Container; |
6 | use JobQueueGroup; |
7 | use MediaWiki\Content\TextContent; |
8 | use MediaWiki\Context\RequestContext; |
9 | use MediaWiki\Json\FormatJson; |
10 | use MediaWiki\Language\Language; |
11 | use MediaWiki\Logger\LoggerFactory; |
12 | use MediaWiki\Registration\ExtensionRegistry; |
13 | use MediaWiki\Revision\RevisionStore; |
14 | use MediaWiki\Revision\SlotRecord; |
15 | use MediaWiki\Title\Title; |
16 | use MediaWiki\User\Options\UserOptionsLookup; |
17 | use MediaWiki\User\Options\UserOptionsManager; |
18 | use MediaWiki\User\User; |
19 | use UserOptionsUpdateJob; |
20 | use Wikimedia\Rdbms\IDBAccessObject; |
21 | |
22 | class QuestionStore { |
23 | |
24 | private const BLOB_SIZE = 65535; |
25 | private const QUESTION_CHAR_LIMIT = 60; |
26 | private const MAX_QUESTIONS = 3; |
27 | |
28 | /** |
29 | * @var User |
30 | */ |
31 | private $user; |
32 | /** |
33 | * @var string |
34 | */ |
35 | private $preference; |
36 | /** |
37 | * @var RevisionStore |
38 | */ |
39 | private $revisionStore; |
40 | /** |
41 | * @var Language |
42 | */ |
43 | private $language; |
44 | /** |
45 | * @var UserOptionsManager |
46 | */ |
47 | private $userOptionsManager; |
48 | /** |
49 | * @var UserOptionsLookup |
50 | */ |
51 | private $userOptionsLookup; |
52 | /** |
53 | * @var JobQueueGroup |
54 | */ |
55 | private $jobQueueGroup; |
56 | /** |
57 | * @var bool |
58 | */ |
59 | private $wasPosted; |
60 | |
61 | /** |
62 | * @param User $user |
63 | * @param string $preference |
64 | * @param RevisionStore $revisionStore |
65 | * @param Language $language |
66 | * @param UserOptionsManager $userOptionsManager |
67 | * @param UserOptionsLookup $userOptionsLookup |
68 | * @param JobQueueGroup $jobQueueGroup |
69 | * @param bool $wasPosted |
70 | */ |
71 | public function __construct( |
72 | $user, |
73 | $preference, |
74 | RevisionStore $revisionStore, |
75 | Language $language, |
76 | UserOptionsManager $userOptionsManager, |
77 | UserOptionsLookup $userOptionsLookup, |
78 | JobQueueGroup $jobQueueGroup, |
79 | $wasPosted |
80 | ) { |
81 | $this->user = $user; |
82 | $this->preference = $preference; |
83 | $this->revisionStore = $revisionStore; |
84 | $this->language = $language; |
85 | $this->userOptionsManager = $userOptionsManager; |
86 | $this->userOptionsLookup = $userOptionsLookup; |
87 | $this->jobQueueGroup = $jobQueueGroup; |
88 | $this->wasPosted = $wasPosted; |
89 | } |
90 | |
91 | /** |
92 | * Add the content to the user preference. |
93 | * @param QuestionRecord $question |
94 | */ |
95 | public function add( QuestionRecord $question ) { |
96 | $question = $this->assignArchiveUrl( $question ); |
97 | $trimmedQuestion = $this->trimQuestion( $question ); |
98 | $questions = $this->prependQuestion( $trimmedQuestion ); |
99 | $this->write( $questions ); |
100 | } |
101 | |
102 | /** |
103 | * @return QuestionRecord[] |
104 | */ |
105 | public function loadQuestions() { |
106 | $pref = $this->userOptionsLookup->getOption( $this->user, $this->preference ); |
107 | if ( !$pref ) { |
108 | return []; |
109 | } |
110 | $questions = FormatJson::decode( $pref, true ); |
111 | if ( !is_array( $questions ) ) { |
112 | return []; |
113 | } |
114 | $questions = array_filter( $questions ); |
115 | $questionRecords = []; |
116 | foreach ( $questions as $question ) { |
117 | $questionRecords[] = QuestionRecord::newFromArray( $question ); |
118 | |
119 | } |
120 | return $questionRecords; |
121 | } |
122 | |
123 | /** |
124 | * @return QuestionRecord[] |
125 | */ |
126 | public function loadQuestionsAndUpdate() { |
127 | $questionRecords = $this->loadQuestions(); |
128 | if ( !count( $questionRecords ) ) { |
129 | return []; |
130 | } |
131 | $checkedQuestionRecords = []; |
132 | $needsUpdate = false; |
133 | foreach ( $questionRecords as $questionRecord ) { |
134 | $checkedRecord = clone $questionRecord; |
135 | |
136 | if ( $questionRecord->getContentModel() === CONTENT_MODEL_WIKITEXT ) { |
137 | $checkedRecord->setVisible( $this->isRevisionVisible( $checkedRecord ) ); |
138 | $checkedRecord->setArchived( !$this->questionExistsOnPage( $checkedRecord ) ); |
139 | if ( !$checkedRecord->getTimestamp() ) { |
140 | // Some records did not have timestamps (T223338); backfill the |
141 | // timestamp if it's not set. |
142 | $checkedRecord->setTimestamp( (int)wfTimestamp( TS_UNIX ) ); |
143 | } |
144 | } elseif ( ExtensionRegistry::getInstance()->isLoaded( 'Flow' ) && |
145 | $questionRecord->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) { |
146 | $workflowLoaderFactory = Container::get( 'factory.loader.workflow' ); |
147 | $topicTitle = Title::newFromText( $checkedRecord->getRevId(), NS_TOPIC ); |
148 | $loader = $workflowLoaderFactory->createWorkflowLoader( $topicTitle ); |
149 | $topicBlock = $loader->getBlocks()['topic']; |
150 | $topicBlock->init( RequestContext::getMain(), 'view-topic' ); |
151 | $output = $topicBlock->renderApi( [] ); |
152 | $deletedOrSuppressed = isset( $output['errors']['permissions'] ); |
153 | $checkedRecord->setVisible( !$deletedOrSuppressed ); |
154 | if ( !$deletedOrSuppressed ) { |
155 | $topicRoot = $output['roots'][0]; |
156 | $topicRootRev = $output['posts'][$topicRoot][0]; |
157 | $topic = $output['revisions'][$topicRootRev]; |
158 | $checkedRecord->setArchived( ( $topic['moderateState'] ?? '' ) === 'hide' ); |
159 | $checkedRecord->setArchiveUrl( $checkedRecord->getResultUrl() ); |
160 | } |
161 | } |
162 | |
163 | $checkedQuestionRecords[] = $checkedRecord; |
164 | if ( $questionRecord->jsonSerialize() !== $checkedRecord->jsonSerialize() ) { |
165 | $needsUpdate = true; |
166 | } |
167 | } |
168 | if ( $needsUpdate ) { |
169 | $this->write( $checkedQuestionRecords ); |
170 | } |
171 | return $this->excludeHiddenQuestions( $checkedQuestionRecords ); |
172 | } |
173 | |
174 | /** |
175 | * @param QuestionRecord[] $questionRecords |
176 | * @return QuestionRecord[] |
177 | */ |
178 | private function excludeHiddenQuestions( array $questionRecords ) { |
179 | return array_filter( $questionRecords, static function ( QuestionRecord $questionRecord ) { |
180 | return $questionRecord->isVisible(); |
181 | } ); |
182 | } |
183 | |
184 | /** |
185 | * Does the question exists on the page specified by $questionRecord->resultUrl? |
186 | * Note this is not always the same as asking whether it exists on the page that contains |
187 | * $questionRecord->revId. |
188 | * @param QuestionRecord $questionRecord |
189 | * @return bool |
190 | */ |
191 | private function questionExistsOnPage( QuestionRecord $questionRecord ) { |
192 | $revision = $this->revisionStore->getRevisionById( $questionRecord->getRevId() ); |
193 | if ( !$revision ) { |
194 | return false; |
195 | } |
196 | |
197 | $oldUrl = explode( '#', $questionRecord->getResultUrl() )[0]; |
198 | $newUrl = Title::newFromLinkTarget( $revision->getPageAsLinkTarget() )->getLinkURL(); |
199 | if ( $oldUrl !== $newUrl ) { |
200 | return false; |
201 | } |
202 | |
203 | $latestPageRevision = $this->revisionStore->getRevisionByTitle( |
204 | $revision->getPageAsLinkTarget() |
205 | ); |
206 | /** @var TextContent|null $content */ |
207 | $content = $latestPageRevision->getContent( SlotRecord::MAIN ); |
208 | return $content instanceof TextContent |
209 | && strpos( $content->getText(), $questionRecord->getSectionHeader() ) !== false; |
210 | } |
211 | |
212 | /** |
213 | * @param QuestionRecord[] $questionRecords |
214 | */ |
215 | private function write( array $questionRecords ) { |
216 | $formattedQuestions = $this->encodeQuestionsToJson( $questionRecords ); |
217 | $storage = $this->preference; |
218 | if ( strlen( $formattedQuestions ) >= self::BLOB_SIZE ) { |
219 | LoggerFactory::getInstance( 'GrowthExperiments' ) |
220 | ->warning( 'Unable to save question records for user {userId} because they are too big.', |
221 | [ |
222 | 'userId' => $this->user->getId(), |
223 | 'storage' => $storage, |
224 | 'length' => strlen( $formattedQuestions ) |
225 | ] ); |
226 | return; |
227 | } |
228 | if ( $this->wasPosted ) { |
229 | $this->saveToUserSettings( $storage, $formattedQuestions ); |
230 | } else { |
231 | $this->saveToUserSettingsWithJob( $storage, $formattedQuestions ); |
232 | } |
233 | } |
234 | |
235 | private function saveToUserSettings( $storage, $formattedQuestions ) { |
236 | $updateUser = $this->user->getInstanceForUpdate(); |
237 | $this->userOptionsManager->setOption( $updateUser, $storage, $formattedQuestions ); |
238 | $updateUser->saveSettings(); |
239 | } |
240 | |
241 | private function saveToUserSettingsWithJob( $storage, $formattedQuestions ) { |
242 | $job = new UserOptionsUpdateJob( [ |
243 | 'userId' => $this->user->getId(), |
244 | 'options' => [ $storage => $formattedQuestions ] |
245 | ] ); |
246 | $this->jobQueueGroup->lazyPush( $job ); |
247 | } |
248 | |
249 | private function encodeQuestionsToJson( array $questionRecords ) { |
250 | return FormatJson::encode( $questionRecords, false, FormatJson::ALL_OK ); |
251 | } |
252 | |
253 | private function prependQuestion( QuestionRecord $questionRecord ) { |
254 | $questions = $this->loadQuestions(); |
255 | array_unshift( $questions, $questionRecord ); |
256 | return array_slice( $questions, 0, self::MAX_QUESTIONS ); |
257 | } |
258 | |
259 | private function assignArchiveUrl( QuestionRecord $questionRecord ) { |
260 | $revision = $this->revisionStore->getRevisionById( $questionRecord->getRevId(), |
261 | $this->wasPosted ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL ); |
262 | if ( !$revision ) { |
263 | return $questionRecord; |
264 | } |
265 | $title = Title::newFromLinkTarget( $revision->getPageAsLinkTarget() ); |
266 | // Hack: Extract fragment from result URL, so we can use it for archive URL. |
267 | $fragment = substr( |
268 | $questionRecord->getResultUrl(), |
269 | strpos( $questionRecord->getResultUrl(), '#' ) + 1 |
270 | ); |
271 | $questionRecord->setArchiveUrl( |
272 | $title->createFragmentTarget( $fragment ) |
273 | ->getLinkURL( [ 'oldid' => $questionRecord->getRevId() ] ) |
274 | ); |
275 | return $questionRecord; |
276 | } |
277 | |
278 | /** |
279 | * @param QuestionRecord $question |
280 | * @return QuestionRecord |
281 | */ |
282 | private function trimQuestion( QuestionRecord $question ) { |
283 | $trimmedQuestionText = $this->language->truncateForVisual( |
284 | $question->getQuestionText(), |
285 | self::QUESTION_CHAR_LIMIT |
286 | ); |
287 | $question->setQuestionText( $trimmedQuestionText ?? '' ); |
288 | return $question; |
289 | } |
290 | |
291 | private function isRevisionVisible( QuestionRecord $questionRecord ) { |
292 | $revision = $this->revisionStore->getRevisionById( $questionRecord->getRevId() ); |
293 | return $revision && $revision->getVisibility() === 0; |
294 | } |
295 | |
296 | } |