Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
SplitConflictMerger
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
4 / 4
12
100.00% covered (success)
100.00%
1 / 1
 mergeSplitConflictResults
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 pickBestPossibleValue
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 parseExtraLineFeeds
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 lineFeeds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace TwoColConflict;
4
5/**
6 * @license GPL-2.0-or-later
7 * @author Thiemo Kreuz
8 */
9class SplitConflictMerger {
10
11    /**
12     * @param array[] $contentRows
13     * @param array[] $extraLineFeeds
14     * @param string[]|string $sideSelection Either an array of side identifiers per row ("copy",
15     *  "other", or "your"). Or one side identifier for all rows (either "other" or "your").
16     *
17     * @return string Wikitext
18     */
19    public function mergeSplitConflictResults(
20        array $contentRows,
21        array $extraLineFeeds,
22        $sideSelection
23    ): string {
24        $textLines = [];
25
26        foreach ( $contentRows as $num => $row ) {
27            if ( is_array( $sideSelection ) ) {
28                // There was no selection to be made for "copy" rows in the interface
29                $side = $sideSelection[$num] ?? 'copy';
30            } else {
31                $side = isset( $row['copy'] ) ? 'copy' : $sideSelection;
32            }
33
34            $line = $this->pickBestPossibleValue( $row, $side );
35            // Don't remove all whitespace, because this is not necessarily the end of the article
36            $line = rtrim( $line, "\r\n" );
37            // *Possibly* emptied by the user, or the line was empty before
38            $emptiedByUser = $line === '';
39
40            if ( isset( $extraLineFeeds[$num] ) ) {
41                $value = $this->pickBestPossibleValue( $extraLineFeeds[$num], $side );
42                [ $before, $after ] = $this->parseExtraLineFeeds( $value );
43                // We want to understand the difference between a row the user emptied (extra
44                // linefeeds are removed as well then), or a row that was empty before. This is
45                // how HtmlEditableTextComponent marked empty rows.
46                if ( $before === 'was-empty' ) {
47                    $emptiedByUser = false;
48                } else {
49                    $line = $this->lineFeeds( $before ) . $line;
50                }
51                $line .= $this->lineFeeds( $after );
52            }
53
54            // In case a line was emptied, we need to skip the extra linefeeds as well
55            if ( !$emptiedByUser ) {
56                $textLines[] = $line;
57            }
58        }
59        return SplitConflictUtils::mergeTextLines( $textLines );
60    }
61
62    /**
63     * @param string[]|mixed $postedValues Typically an array of strings, but not guaranteed
64     * @param string $key Preferred array key to pick from the list of values, if present
65     *
66     * @return string
67     */
68    private function pickBestPossibleValue( $postedValues, string $key ): string {
69        // A mismatch here means the request is either incomplete (by design) or broken, and already
70        // detected as such (see ConflictFormValidator). Intentionally return the most recent, most
71        // conflicting value. Fall back to the users unsaved edit, or to *whatever* is there, no
72        // matter how invalid it might be. We *never* want to loose anything.
73        return (string)(
74            $postedValues[$key] ??
75            $postedValues['your'] ??
76            current( (array)$postedValues )
77        );
78    }
79
80    /**
81     * @param string $postedValue
82     *
83     * @return string[]
84     */
85    private function parseExtraLineFeeds( string $postedValue ): array {
86        $counts = explode( ',', $postedValue, 2 );
87        // "Before" and "after" are intentionally flipped, because "before" is very rare
88        return [ $counts[1] ?? '', $counts[0] ];
89    }
90
91    private function lineFeeds( string $count ): string {
92        $count = (int)$count;
93
94        // Arbitrary limit just to not end with megabytes in case of an attack
95        if ( $count <= 0 || $count > 1000 ) {
96            return '';
97        }
98
99        return str_repeat( "\n", $count );
100    }
101
102}