Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.65% |
59 / 63 |
|
75.00% |
6 / 8 |
CRAP | |
0.00% |
0 / 1 |
ResolutionSuggester | |
93.65% |
59 / 63 |
|
75.00% |
6 / 8 |
33.28 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
isIdenticalCopyBlock | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
isAddition | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
getResolutionSuggestion | |
91.89% |
34 / 37 |
|
0.00% |
0 / 1 |
14.10 | |||
getBaseRevisionLines | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
countNewlines | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
moveNewlinesUp | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
moveNewlinesDown | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 |
1 | <?php |
2 | |
3 | namespace TwoColConflict\TalkPageConflict; |
4 | |
5 | use MediaWiki\Revision\RevisionRecord; |
6 | use MediaWiki\Revision\SlotRecord; |
7 | use TwoColConflict\AnnotatedHtmlDiffFormatter; |
8 | use TwoColConflict\SplitConflictUtils; |
9 | use Wikimedia\Diff\ComplexityException; |
10 | |
11 | /** |
12 | * @license GPL-2.0-or-later |
13 | * @author Christoph Jauera <christoph.jauera@wikimedia.de> |
14 | */ |
15 | class ResolutionSuggester { |
16 | |
17 | private ?RevisionRecord $baseRevision; |
18 | private string $contentFormat; |
19 | |
20 | public function __construct( ?RevisionRecord $baseRevision, string $contentFormat ) { |
21 | $this->baseRevision = $baseRevision; |
22 | $this->contentFormat = $contentFormat; |
23 | } |
24 | |
25 | /** |
26 | * @param array $a First block to compare |
27 | * @param array $b Second block |
28 | * @return bool True if the blocks are both copy blocks, with identical content. |
29 | */ |
30 | private function isIdenticalCopyBlock( array $a, array $b ): bool { |
31 | return $a['action'] === 'copy' && $a === $b; |
32 | } |
33 | |
34 | private function isAddition( array $change ): bool { |
35 | return $change['action'] === 'add' |
36 | || ( $change['action'] === 'change' && $change['oldtext'] === '' ); |
37 | } |
38 | |
39 | /** |
40 | * @param string[] $storedLines |
41 | * @param string[] $yourLines |
42 | * @return TalkPageResolution|null |
43 | */ |
44 | public function getResolutionSuggestion( |
45 | array $storedLines, |
46 | array $yourLines |
47 | ): ?TalkPageResolution { |
48 | $baseLines = $this->getBaseRevisionLines(); |
49 | $formatter = new AnnotatedHtmlDiffFormatter(); |
50 | |
51 | try { |
52 | // TODO: preSaveTransform $yourLines, but not $storedLines |
53 | $diffYourLines = $formatter->format( $baseLines, $yourLines, $yourLines ); |
54 | $diffStoredLines = $formatter->format( $baseLines, $storedLines, $storedLines ); |
55 | } catch ( ComplexityException $ex ) { |
56 | return null; |
57 | } |
58 | |
59 | if ( count( $diffYourLines ) !== count( $diffStoredLines ) ) { |
60 | return null; |
61 | } |
62 | |
63 | foreach ( $diffStoredLines as $i => $stored ) { |
64 | $unsaved = $diffYourLines[$i]; |
65 | |
66 | // We only care about copies that are *almost* identical, except for extra newlines |
67 | if ( !isset( $stored['copytext'] ) |
68 | || !isset( $unsaved['copytext'] ) |
69 | || $stored['copytext'] === $unsaved['copytext'] |
70 | || trim( $stored['copytext'], "\n" ) !== trim( $unsaved['copytext'], "\n" ) |
71 | ) { |
72 | continue; |
73 | } |
74 | |
75 | [ $beforeStored, $afterStored ] = $this->countNewlines( $stored['copytext'] ); |
76 | [ $beforeUnsafed, $afterUnsafed ] = $this->countNewlines( $unsaved['copytext'] ); |
77 | |
78 | $this->moveNewlinesUp( $diffStoredLines, $i, $beforeStored - $beforeUnsafed ); |
79 | $this->moveNewlinesUp( $diffYourLines, $i, $beforeUnsafed - $beforeStored ); |
80 | $this->moveNewlinesDown( $diffStoredLines, $i, $afterStored - $afterUnsafed ); |
81 | $this->moveNewlinesDown( $diffYourLines, $i, $afterUnsafed - $afterStored ); |
82 | } |
83 | |
84 | // only diffs that contain exactly one addition, that is optionally |
85 | // preceded and/or succeeded by one identical copy line, are |
86 | // candidates for the resolution suggestion |
87 | |
88 | $diff = []; |
89 | /** @var ?int $spliceIndex */ |
90 | $spliceIndex = null; |
91 | // Copy over identical blocks, and splice the two alternatives. |
92 | foreach ( $diffYourLines as $index => $yourLine ) { |
93 | $otherLine = $diffStoredLines[$index]; |
94 | if ( $this->isIdenticalCopyBlock( $yourLine, $otherLine ) ) { |
95 | // Copy |
96 | $diff[] = $otherLine; |
97 | } elseif ( $this->isAddition( $yourLine ) |
98 | && $this->isAddition( $otherLine ) |
99 | && $spliceIndex === null |
100 | ) { |
101 | // Splice alternatives |
102 | $spliceIndex = count( $diff ); |
103 | $diff[] = [ 'action' => 'add' ] + $otherLine; |
104 | $diff[] = [ 'action' => 'add' ] + $yourLine; |
105 | } else { |
106 | return null; |
107 | } |
108 | } |
109 | if ( $spliceIndex === null ) { |
110 | // TODO: I'm not sure yet, but this might be a logic error and should be logged. |
111 | return null; |
112 | } |
113 | |
114 | // @phan-suppress-next-line SecurityCheck-DoubleEscaped |
115 | return new TalkPageResolution( $diff, $spliceIndex, $spliceIndex + 1 ); |
116 | } |
117 | |
118 | /** |
119 | * @return string[] |
120 | */ |
121 | private function getBaseRevisionLines(): array { |
122 | if ( !$this->baseRevision ) { |
123 | return []; |
124 | } |
125 | |
126 | $baseContent = $this->baseRevision->getContent( SlotRecord::MAIN ); |
127 | if ( !$baseContent ) { |
128 | return []; |
129 | } |
130 | |
131 | $baseText = $baseContent->serialize( $this->contentFormat ); |
132 | if ( !$baseText ) { |
133 | return []; |
134 | } |
135 | |
136 | return SplitConflictUtils::splitText( $baseText ); |
137 | } |
138 | |
139 | private function countNewlines( string $text ): array { |
140 | // Start from the end, because we want "\n" to be reported as [ 0, 1 ] |
141 | $endOfText = strlen( rtrim( $text, "\n" ) ); |
142 | $after = strlen( $text ) - $endOfText; |
143 | $before = strspn( $text, "\n", 0, $endOfText ); |
144 | return [ $before, $after ]; |
145 | } |
146 | |
147 | private function moveNewlinesUp( array &$diff, int $i, int $count ): void { |
148 | if ( $count < 1 || !isset( $diff[$i - 1] ) || $diff[$i - 1]['action'] !== 'add' ) { |
149 | return; |
150 | } |
151 | |
152 | $diff[$i - 1]['newtext'] .= str_repeat( "\n", $count ); |
153 | // The current row is guaranteed to be a copy |
154 | $diff[$i]['copytext'] = substr( $diff[$i]['copytext'], $count ); |
155 | } |
156 | |
157 | private function moveNewlinesDown( array &$diff, int $i, int $count ): void { |
158 | if ( $count < 1 || !isset( $diff[$i + 1] ) || $diff[$i + 1]['action'] !== 'add' ) { |
159 | return; |
160 | } |
161 | |
162 | $diff[$i + 1]['newtext'] = str_repeat( "\n", $count ) . $diff[$i + 1]['newtext']; |
163 | // The current row is guaranteed to be a copy |
164 | $diff[$i]['copytext'] = substr( $diff[$i]['copytext'], 0, -$count ); |
165 | } |
166 | |
167 | } |