Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.08% covered (warning)
81.08%
30 / 37
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditedSectionFinder
81.08% covered (warning)
81.08%
30 / 37
80.00% covered (warning)
80.00%
4 / 5
18.96
0.00% covered (danger)
0.00%
0 / 1
 findEditedSectionsBetweenTextContents
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 findEditedSectionsBetweenRevisions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 findEditedSectionsByDiffOps
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 isSectionLine
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSectionTitlesFromLines
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2declare( strict_types = 1 );
3
4namespace ContentTranslation\Service;
5
6use MediaWiki\Content\TextContent;
7use MediaWiki\Revision\RevisionRecord;
8use MediaWiki\Revision\SlotRecord;
9use Wikimedia\Diff\DiffOp;
10
11class EditedSectionFinder {
12    // Top section titles follow this pattern:
13    // == Section Title ==
14    private const SECTION_TITLE_PATTERN = "/^==([^=].*?)==$/";
15    private const WIKITEXT_COMMENT_PATTERN = "/<!--(.*?)-->/";
16
17    /**
18     * Given two TextContent instances, one representing the new version of the
19     * text content and the second the old one, this method an array of diff
20     * operations (DiffOp objects), that represents the changes between these
21     * two text contents.
22     *
23     * @param TextContent $newContent
24     * @param TextContent $oldContent
25     * @return string[]
26     */
27    public function findEditedSectionsBetweenTextContents( TextContent $newContent, TextContent $oldContent ): array {
28        $diff = $oldContent->diff( $newContent );
29        $diffOps = $diff->getEdits();
30
31        return $this->findEditedSectionsByDiffOps( $diffOps );
32    }
33
34    /**
35     * Given a revision, this static method finds all sections that were edited
36     * in this revision, and returns an array filled with these section titles
37     *
38     * @param RevisionRecord $currentRevision
39     * @param RevisionRecord|null $previousRevision
40     * @return string[]
41     */
42    public function findEditedSectionsBetweenRevisions(
43        RevisionRecord $currentRevision,
44        ?RevisionRecord $previousRevision
45    ): array {
46        $newContent = $currentRevision->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
47        // If no previous revision, set old content to empty TextContent object
48        if ( $previousRevision instanceof RevisionRecord ) {
49            $oldContent = $previousRevision->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
50        } else {
51            // TextContentHandler::makeEmptyContent method doesn't initialize the content model
52            // properly, leading to CI errors. To fix this issue, instantiate oldContent manually
53            $oldContent = new TextContent( '', $newContent->getModel() );
54        }
55
56        // We can only calculate the difference for TextContent instances.
57        // If any of the content variables is not an instance of TextContent
58        // class, then return an empty array. In fact, both variables are
59        // expected to be instances of TextContent class when the method is
60        // actually called, however that may not be the case for CI tests.
61        if ( !$newContent instanceof TextContent || !$oldContent instanceof TextContent ) {
62            return [];
63        }
64
65        return $this->findEditedSectionsBetweenTextContents( $newContent, $oldContent );
66    }
67
68    /**
69     * Given an array of diff operations, find all sections that were changed during the
70     * latest page edit
71     *
72     * @param DiffOp[] $diffOps
73     * @return string[]
74     */
75    private function findEditedSectionsByDiffOps( array $diffOps ): array {
76        $diffOps = array_values( $diffOps );
77        $editedSections = [];
78        foreach ( $diffOps as $i => $diffOp ) {
79            // If first diff operation is an "add" operation, it should refer
80            // to an edit in the lead section of the page. Since we only care
81            // about edits in non-lead sections, we skip first operation.
82            // If current diff operation is a copy, then we just skip, as copies
83            // that are followed by other copies do not contain edited sections
84            if ( $i === 0 || $diffOp->getType() === "copy" ) {
85                continue;
86            }
87
88            /**
89             * This variable contains all lines on the right ("new") side of the diff, or
90             * false when it's a delete operation.
91             * In case of delete operations, we do no consider sections that were deleted as
92             * edited sections.
93             *
94             * @type string[]|false
95             */
96            $textAfterOp = $diffOp->getClosing();
97            $currentEditedSections = [];
98            // This variable indicates whether the first line of the current diff operation
99            // is a section title. When true, this variable indicates that a new section
100            // was added without modifying the previous one.
101            // If current diff operation is a "delete" operation, this variable is always
102            // false.
103            $isFirstLineSectionTitle = false;
104            if ( $textAfterOp ) {
105                $isFirstLineSectionTitle = $this->isSectionLine( $diffOp->getClosing( 0 ) );
106                $currentEditedSections = $this->getSectionTitlesFromLines( $textAfterOp );
107            }
108
109            // If first line of the current diff operation is a section title, then
110            // previous section was not edited. If not, the title of the currently
111            // edited section, is contained inside the previous diff operation
112            // (which is a copy).
113            if ( !$isFirstLineSectionTitle ) {
114                // This operation is the first operation or a copy operation
115                $previousOp = $diffOps[$i - 1];
116                // The title of the current edited section is the last section title
117                // present inside the previous diff operation
118                $revertedPreviousLines = array_reverse( $previousOp->orig );
119                $previousSectionTitles = $this->getSectionTitlesFromLines( $revertedPreviousLines );
120                if ( $previousSectionTitles ) {
121                    $editedSections[] = current( $previousSectionTitles );
122                }
123            }
124            $editedSections = array_merge( $editedSections, $currentEditedSections );
125        }
126        return $editedSections;
127    }
128
129    /**
130     * Given a string, this method returns a boolean indicating whether this
131     * string is a line containing a section title.
132     *
133     * @param string $line
134     * @return bool
135     */
136    private static function isSectionLine( string $line ): bool {
137        return (bool)preg_match( self::SECTION_TITLE_PATTERN, $line );
138    }
139
140    /**
141     * Given an array of strings, representing text lines, this static method
142     * filters only the lines that contain a section title, and finally returns
143     * an array with the actual section titles among these lines (if any).
144     *
145     * @param string[] $lines
146     * @return string[]
147     */
148    private function getSectionTitlesFromLines( array $lines ): array {
149        $editedSections = [];
150        foreach ( $lines as $line ) {
151            // Find the section title without the equal signs (==)
152            preg_match( self::SECTION_TITLE_PATTERN, trim( $line ), $matches );
153            if ( $matches && isset( $matches[1] ) ) {
154                // Remove comments
155                $sectionTitle = preg_replace( self::WIKITEXT_COMMENT_PATTERN, '', $matches[1 ] );
156                $editedSections[] = trim( $sectionTitle );
157            }
158        }
159        return $editedSections;
160    }
161}