Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReferenceRecorder
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 10
1406
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onAfterLoad
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAfterInsert
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 calculateChangesFromExisting
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 calculateChangesFromTopic
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 isHidden
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 collectTopicRevisions
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 getReferencesFromRevisionContent
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
132
 getExistingReferences
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 referencesDifference
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2
3namespace Flow\Data\Listener;
4
5use Flow\Data\ManagerGroup;
6use Flow\Exception\FlowException;
7use Flow\Exception\InvalidDataException;
8use Flow\LinksTableUpdater;
9use Flow\Model\AbstractRevision;
10use Flow\Model\PostRevision;
11use Flow\Model\PostSummary;
12use Flow\Model\Reference;
13use Flow\Model\UUID;
14use Flow\Model\Workflow;
15use Flow\Parsoid\ReferenceExtractor;
16use Flow\Repository\TreeRepository;
17use MediaWiki\WikiMap\WikiMap;
18use 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 */
25class 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}