Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 106 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
ReferenceRecorder | |
0.00% |
0 / 106 |
|
0.00% |
0 / 10 |
1406 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
onAfterLoad | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onAfterInsert | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
calculateChangesFromExisting | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
calculateChangesFromTopic | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
30 | |||
isHidden | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
collectTopicRevisions | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
getReferencesFromRevisionContent | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
132 | |||
getExistingReferences | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
referencesDifference | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
56 |
1 | <?php |
2 | |
3 | namespace Flow\Data\Listener; |
4 | |
5 | use Flow\Data\ManagerGroup; |
6 | use Flow\Exception\FlowException; |
7 | use Flow\Exception\InvalidDataException; |
8 | use Flow\LinksTableUpdater; |
9 | use Flow\Model\AbstractRevision; |
10 | use Flow\Model\PostRevision; |
11 | use Flow\Model\PostSummary; |
12 | use Flow\Model\Reference; |
13 | use Flow\Model\UUID; |
14 | use Flow\Model\Workflow; |
15 | use Flow\Parsoid\ReferenceExtractor; |
16 | use Flow\Repository\TreeRepository; |
17 | use MediaWiki\WikiMap\WikiMap; |
18 | use SplQueue; |
19 | |
20 | /** |
21 | * Listens for new revisions to be inserted. Calculates the difference in |
22 | * references(URLs, images, etc) between this new version and the previous |
23 | * revision. Uses calculated difference to update links tables to match the new revision. |
24 | */ |
25 | class ReferenceRecorder extends AbstractListener { |
26 | /** |
27 | * @var ReferenceExtractor |
28 | */ |
29 | protected $referenceExtractor; |
30 | |
31 | /** |
32 | * @var ManagerGroup |
33 | */ |
34 | protected $storage; |
35 | |
36 | /** |
37 | * @var LinksTableUpdater |
38 | */ |
39 | protected $linksTableUpdater; |
40 | |
41 | /** |
42 | * @var TreeRepository Used to query for the posts within a topic when moderation |
43 | * changes the visibility of a topic. |
44 | */ |
45 | protected $treeRepository; |
46 | |
47 | /** |
48 | * @var SplQueue |
49 | */ |
50 | protected $deferredQueue; |
51 | |
52 | public function __construct( |
53 | ReferenceExtractor $referenceExtractor, |
54 | LinksTableUpdater $linksTableUpdater, |
55 | ManagerGroup $storage, |
56 | TreeRepository $treeRepository, |
57 | SplQueue $deferredQueue |
58 | ) { |
59 | $this->referenceExtractor = $referenceExtractor; |
60 | $this->linksTableUpdater = $linksTableUpdater; |
61 | $this->storage = $storage; |
62 | $this->treeRepository = $treeRepository; |
63 | $this->deferredQueue = $deferredQueue; |
64 | } |
65 | |
66 | public function onAfterLoad( $object, array $old ) { |
67 | // Nuthin |
68 | } |
69 | |
70 | public function onAfterInsert( $revision, array $new, array $metadata ) { |
71 | if ( !isset( $metadata['workflow'] ) ) { |
72 | return; |
73 | } |
74 | if ( !$revision instanceof AbstractRevision ) { |
75 | throw new InvalidDataException( 'ReferenceRecorder can only attach to AbstractRevision storage' ); |
76 | } |
77 | /** @var Workflow $workflow */ |
78 | $workflow = $metadata['workflow']; |
79 | |
80 | if ( $revision instanceof PostRevision && $revision->isTopicTitle() ) { |
81 | [ $added, $removed ] = $this->calculateChangesFromTopic( $workflow, $revision ); |
82 | } else { |
83 | [ $added, $removed ] = $this->calculateChangesFromExisting( $workflow, $revision ); |
84 | } |
85 | |
86 | $this->storage->multiPut( $added ); |
87 | $this->storage->multiRemove( $removed ); |
88 | |
89 | // Data has not yet been committed at this point, so let's delay |
90 | // updating `categorylinks`, `externallinks`, etc. |
91 | $linksTableUpdater = $this->linksTableUpdater; |
92 | $this->deferredQueue->push( static function () use ( $linksTableUpdater, $workflow ) { |
93 | $linksTableUpdater->doUpdate( $workflow ); |
94 | } ); |
95 | } |
96 | |
97 | /** |
98 | * Compares the references contained within $revision against those stored for |
99 | * that revision. Returns the differences. |
100 | * |
101 | * @param Workflow $workflow |
102 | * @param AbstractRevision $revision |
103 | * @param PostRevision|null $root |
104 | * @return array Two nested arrays, first the references that were added and |
105 | * second the references that were removed. |
106 | */ |
107 | protected function calculateChangesFromExisting( |
108 | Workflow $workflow, |
109 | AbstractRevision $revision, |
110 | ?PostRevision $root = null |
111 | ) { |
112 | $prevReferences = $this->getExistingReferences( |
113 | $revision->getRevisionType(), |
114 | $revision->getCollectionId() |
115 | ); |
116 | $references = $this->getReferencesFromRevisionContent( $workflow, $revision, $root ); |
117 | |
118 | return $this->referencesDifference( $prevReferences, $references ); |
119 | } |
120 | |
121 | /** |
122 | * Topic titles themselves only support minimal wikitext, and references in the |
123 | * title itself are not tracked. |
124 | * |
125 | * However, moderation actions change what references are visible. When |
126 | * transitioning from or to a generically visible state (unmoderated or locked) the |
127 | * entire topic + summary needs to be re-evaluated. |
128 | * |
129 | * @param Workflow $workflow |
130 | * @param PostRevision $current Topic revision object that was inserted |
131 | * @return array Contains two arrays, first the references to add a second |
132 | * the references to remove |
133 | * @throws FlowException |
134 | */ |
135 | protected function calculateChangesFromTopic( Workflow $workflow, PostRevision $current ) { |
136 | if ( $current->isFirstRevision() ) { |
137 | return [ [], [] ]; |
138 | } |
139 | $previous = $this->storage->get( 'PostRevision', $current->getPrevRevisionId() ); |
140 | if ( !$previous ) { |
141 | throw new FlowException( 'Expected previous revision of ' . $current->getPrevRevisionId()->getAlphadecimal() ); |
142 | } |
143 | |
144 | $isHidden = self::isHidden( $current ); |
145 | $wasHidden = self::isHidden( $previous ); |
146 | |
147 | if ( $isHidden === $wasHidden ) { |
148 | return [ [], [] ]; |
149 | } |
150 | |
151 | // re-run |
152 | $revisions = $this->collectTopicRevisions( $workflow ); |
153 | $added = []; |
154 | $removed = []; |
155 | foreach ( $revisions as $revision ) { |
156 | [ $add, $remove ] = $this->calculateChangesFromExisting( $workflow, $revision, $current ); |
157 | $added = array_merge( $added, $add ); |
158 | $removed = array_merge( $removed, $remove ); |
159 | } |
160 | |
161 | return [ $added, $removed ]; |
162 | } |
163 | |
164 | protected static function isHidden( AbstractRevision $revision ) { |
165 | return $revision->isModerated() && $revision->getModerationState() !== $revision::MODERATED_LOCKED; |
166 | } |
167 | |
168 | /** |
169 | * Gets all the 'top' revisions within the topic, namely the posts and the |
170 | * summary. These are used when a topic changes is visibility via moderation |
171 | * to add or remove the relevant references. |
172 | * |
173 | * @param Workflow $workflow |
174 | * @return AbstractRevision[] |
175 | */ |
176 | protected function collectTopicRevisions( Workflow $workflow ) { |
177 | $found = $this->treeRepository->fetchSubtreeNodeList( [ $workflow->getId() ] ); |
178 | $queries = []; |
179 | foreach ( reset( $found ) as $uuid ) { |
180 | $queries[] = [ 'rev_type_id' => $uuid ]; |
181 | } |
182 | |
183 | $posts = $this->storage->findMulti( |
184 | 'PostRevision', |
185 | $queries, |
186 | [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ] |
187 | ); |
188 | |
189 | // we also need the most recent topic summary if it exists |
190 | $summaries = $this->storage->find( |
191 | 'PostSummary', |
192 | [ 'rev_type_id' => $workflow->getId() ], |
193 | [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ] |
194 | ); |
195 | |
196 | $result = $summaries; |
197 | // we have to unwrap the posts since we used findMulti, it returns |
198 | // a separate result set for each query |
199 | foreach ( $posts as $found ) { |
200 | $result[] = reset( $found ); |
201 | } |
202 | return $result; |
203 | } |
204 | |
205 | /** |
206 | * Pulls references from a revision's content |
207 | * |
208 | * @param Workflow $workflow The Workflow that the revision is attached to. |
209 | * @param AbstractRevision $revision The Revision to pull references from. |
210 | * @param PostRevision|null $root |
211 | * @return Reference[] Array of References. |
212 | */ |
213 | public function getReferencesFromRevisionContent( |
214 | Workflow $workflow, |
215 | AbstractRevision $revision, |
216 | ?PostRevision $root = null |
217 | ) { |
218 | // Locked is the only moderated state we still collect references for. |
219 | if ( self::isHidden( $revision ) ) { |
220 | return []; |
221 | } |
222 | |
223 | // We also do not track references in topic titles. |
224 | if ( $revision instanceof PostRevision && $revision->isTopicTitle() ) { |
225 | return []; |
226 | } |
227 | |
228 | // If this is attached to a topic we also need to check its permissions |
229 | if ( $root === null ) { |
230 | try { |
231 | if ( $revision instanceof PostRevision && !$revision->isTopicTitle() ) { |
232 | $root = $revision->getCollection()->getRoot()->getLastRevision(); |
233 | } elseif ( $revision instanceof PostSummary ) { |
234 | $root = $revision->getCollection()->getPost()->getRoot()->getLastRevision(); |
235 | } |
236 | } catch ( FlowException $e ) { |
237 | // Do nothing - we're likely in a unit test where no root can |
238 | // be resolved because the revision is created on the fly |
239 | } |
240 | } |
241 | |
242 | if ( $root && ( self::isHidden( $root ) ) ) { |
243 | return []; |
244 | } |
245 | |
246 | return $this->referenceExtractor->getReferences( |
247 | $workflow, |
248 | $revision->getRevisionType(), |
249 | $revision->getCollectionId(), |
250 | $revision->getContent( 'html' ) |
251 | ); |
252 | } |
253 | |
254 | /** |
255 | * Retrieves references that are already stored in the database for a given revision |
256 | * |
257 | * @param string $revType The value returned from Revision::getRevisionType() for the revision. |
258 | * @param UUID $objectId The revision's Object ID. |
259 | * @return Reference[] Array of References. |
260 | */ |
261 | public function getExistingReferences( $revType, UUID $objectId ) { |
262 | $prevWikiReferences = $this->storage->find( 'WikiReference', [ |
263 | 'ref_src_wiki' => WikiMap::getCurrentWikiId(), |
264 | 'ref_src_object_type' => $revType, |
265 | 'ref_src_object_id' => $objectId, |
266 | ] ); |
267 | |
268 | $prevUrlReferences = $this->storage->find( 'URLReference', [ |
269 | 'ref_src_wiki' => WikiMap::getCurrentWikiId(), |
270 | 'ref_src_object_type' => $revType, |
271 | 'ref_src_object_id' => $objectId, |
272 | ] ); |
273 | |
274 | return array_merge( (array)$prevWikiReferences, (array)$prevUrlReferences ); |
275 | } |
276 | |
277 | /** |
278 | * Compares two arrays of references |
279 | * |
280 | * Would be protected if not for testing. |
281 | * |
282 | * @param Reference[] $old The old references. |
283 | * @param Reference[] $new The new references. |
284 | * @return array Array with two elements: added and removed references. |
285 | */ |
286 | public function referencesDifference( array $old, array $new ) { |
287 | $newReferences = []; |
288 | |
289 | foreach ( $new as $ref ) { |
290 | $newReferences[$ref->getIdentifier()] = $ref; |
291 | } |
292 | |
293 | $oldReferences = []; |
294 | |
295 | foreach ( $old as $ref ) { |
296 | $oldReferences[$ref->getIdentifier()] = $ref; |
297 | } |
298 | |
299 | $addReferences = []; |
300 | |
301 | foreach ( $newReferences as $identifier => $ref ) { |
302 | if ( !isset( $oldReferences[$identifier] ) ) { |
303 | $addReferences[] = $ref; |
304 | } |
305 | } |
306 | |
307 | $removeReferences = []; |
308 | |
309 | foreach ( $oldReferences as $identifier => $ref ) { |
310 | if ( !isset( $newReferences[$identifier] ) ) { |
311 | $removeReferences[] = $ref; |
312 | } |
313 | } |
314 | |
315 | return [ $addReferences, $removeReferences ]; |
316 | } |
317 | } |