Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.92% covered (danger)
9.92%
12 / 121
14.29% covered (danger)
14.29%
2 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
QuestionStore
9.92% covered (danger)
9.92%
12 / 121
14.29% covered (danger)
14.29%
2 / 14
879.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 loadQuestions
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 loadQuestionsAndUpdate
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
110
 excludeHiddenQuestions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 questionExistsOnPage
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 write
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 saveToUserSettings
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 saveToUserSettingsWithJob
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 encodeQuestionsToJson
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prependQuestion
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 assignArchiveUrl
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 trimQuestion
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 isRevisionVisible
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\HelpPanel;
4
5use Flow\Container;
6use FormatJson;
7use IDBAccessObject;
8use JobQueueGroup;
9use Language;
10use MediaWiki\Logger\LoggerFactory;
11use MediaWiki\Revision\RevisionStore;
12use MediaWiki\Revision\SlotRecord;
13use MediaWiki\Title\Title;
14use MediaWiki\User\Options\UserOptionsLookup;
15use MediaWiki\User\Options\UserOptionsManager;
16use MediaWiki\User\User;
17use RequestContext;
18use TextContent;
19use UserOptionsUpdateJob;
20
21class 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}