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 JobQueueGroup;
7use MediaWiki\Content\TextContent;
8use MediaWiki\Context\RequestContext;
9use MediaWiki\Json\FormatJson;
10use MediaWiki\Language\Language;
11use MediaWiki\Logger\LoggerFactory;
12use MediaWiki\Registration\ExtensionRegistry;
13use MediaWiki\Revision\RevisionStore;
14use MediaWiki\Revision\SlotRecord;
15use MediaWiki\Title\Title;
16use MediaWiki\User\Options\UserOptionsLookup;
17use MediaWiki\User\Options\UserOptionsManager;
18use MediaWiki\User\User;
19use UserOptionsUpdateJob;
20use Wikimedia\Rdbms\IDBAccessObject;
21
22class 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}