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