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