AutomatedEditsHelper::getRevertTools()   A
last analyzed

Complexity

Conditions 4
Paths 2

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 13
nc 2
nop 1
dl 0
loc 25
rs 9.8333
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Helper;
6
7
use App\Model\Project;
8
use DateInterval;
9
use MediaWiki\OAuthClient\Client;
10
use Psr\Cache\CacheItemPoolInterface;
11
use Symfony\Component\HttpFoundation\RequestStack;
12
use Symfony\Component\HttpFoundation\Session\SessionInterface;
13
14
/**
15
 * Helper class for fetching semi-automated definitions.
16
 */
17
class AutomatedEditsHelper
18
{
19
    protected CacheItemPoolInterface $cache;
20
    protected SessionInterface $session;
21
22
    /** @var array The list of tools that are considered reverting. */
23
    protected array $revertTools = [];
24
25
    /** @var array The list of tool names and their regexes/tags. */
26
    protected array $tools = [];
27
28
    /**
29
     * AutomatedEditsHelper constructor.
30
     * @param RequestStack $requestStack
31
     * @param CacheItemPoolInterface $cache
32
     */
33
    public function __construct(RequestStack $requestStack, CacheItemPoolInterface $cache)
34
    {
35
        $this->session = $requestStack->getSession();
36
        $this->cache = $cache;
37
    }
38
39
    /**
40
     * Get the tool that matched the given edit summary.
41
     * This only works for tools defined with regular expressions, not tags.
42
     * @param string $summary Edit summary
43
     * @param Project $project
44
     * @return string[]|null Tool entry including key for 'name', or false if nothing was found
45
     */
46
    public function getTool(string $summary, Project $project): ?array
47
    {
48
        foreach ($this->getTools($project) as $tool => $values) {
49
            if (isset($values['regex']) && preg_match('/'.$values['regex'].'/', $summary)) {
50
                return array_merge([
51
                    'name' => $tool,
52
                ], $values);
53
            }
54
        }
55
56
        return null;
57
    }
58
59
    /**
60
     * Was the edit (semi-)automated, based on the edit summary?
61
     * This only works for tools defined with regular expressions, not tags.
62
     * @param string $summary Edit summary
63
     * @param Project $project
64
     * @return bool
65
     */
66
    public function isAutomated(string $summary, Project $project): bool
67
    {
68
        return (bool)$this->getTool($summary, $project);
69
    }
70
71
    /**
72
     * Fetch the config from https://meta.wikimedia.org/wiki/MediaWiki:XTools-AutoEdits.json
73
     * @param bool $useSandbox Use the sandbox version of the config, located at MediaWiki:XTools-AutoEdits.json/sandbox
74
     * @return array
75
     */
76
    public function getConfig(bool $useSandbox = false): array
77
    {
78
        $cacheKey = 'autoedits_config';
79
        if (!$useSandbox && $this->cache->hasItem($cacheKey)) {
80
            return $this->cache->getItem($cacheKey)->get();
81
        }
82
83
        $uri = 'https://meta.wikimedia.org/w/api.php?' . http_build_query([
84
            'action' => 'query',
85
            'prop' => 'revisions',
86
            'rvprop' => 'content',
87
            'rvslots' => 'main',
88
            'format' => 'json',
89
            'formatversion' => 2,
90
            'titles' => 'MediaWiki:XTools-AutoEdits.json' . ($useSandbox ? '/sandbox' : ''),
91
        ]);
92
93
        if ($useSandbox && $this->session->get('logged_in_user')) {
94
            // Request via OAuth to get around server-side caching.
95
            /** @var Client $client */
96
            $client = $this->session->get('oauth_client');
97
            $resp = json_decode($client->makeOAuthCall(
98
                $this->session->get('oauth_access_token'),
99
                $uri
100
            ));
101
        } else {
102
            $resp = json_decode(file_get_contents($uri));
103
        }
104
105
        $ret = json_decode($resp->query->pages[0]->revisions[0]->slots->main->content, true);
106
107
        if (!$useSandbox) {
108
            $cacheItem = $this->cache
109
                ->getItem($cacheKey)
110
                ->set($ret)
111
                ->expiresAfter(new DateInterval('PT20M'));
112
            $this->cache->save($cacheItem);
113
        }
114
115
        return $ret;
116
    }
117
118
    /**
119
     * Get list of automated tools and their associated info for the given project.
120
     * This defaults to the DEFAULT_PROJECT if entries for the given project are not found.
121
     * @param Project $project
122
     * @param bool $useSandbox Whether to use the /sandbox version for testing (also bypasses caching).
123
     * @return array Each tool with the tool name as the key and 'link', 'regex' and/or 'tag' as the subarray keys.
124
     */
125
    public function getTools(Project $project, bool $useSandbox = false): array
126
    {
127
        $projectDomain = $project->getDomain();
128
129
        if (isset($this->tools[$projectDomain])) {
130
            return $this->tools[$projectDomain];
131
        }
132
133
        // Load the semi-automated edit types.
134
        $tools = $this->getConfig($useSandbox);
135
136
        if (isset($tools[$projectDomain])) {
137
            $localRules = $tools[$projectDomain];
138
        } else {
139
            $localRules = [];
140
        }
141
142
        $langRules = $tools[$project->getLang()] ?? [];
143
144
        // Per-wiki rules have priority, followed by language-specific and global.
145
        $globalWithLangRules = $this->mergeRules($tools['global'], $langRules);
146
147
        $this->tools[$projectDomain] = $this->mergeRules(
148
            $globalWithLangRules,
149
            $localRules
150
        );
151
152
        // Once last walk through for some tidying up and validation.
153
        $invalid = [];
154
        array_walk($this->tools[$projectDomain], function (&$data, $tool) use (&$invalid): void {
155
            // Populate the 'label' with the tool name, if a label doesn't already exist.
156
            $data['label'] = $data['label'] ?? $tool;
157
158
            // 'namespaces' should be an array of ints.
159
            $data['namespaces'] = $data['namespaces'] ?? [];
160
            if (isset($data['namespace'])) {
161
                $data['namespaces'][] = $data['namespace'];
162
                unset($data['namespace']);
163
            }
164
165
            // 'tags' should be an array of strings.
166
            $data['tags'] = $data['tags'] ?? [];
167
            if (isset($data['tag'])) {
168
                $data['tags'][] = $data['tag'];
169
                unset($data['tag']);
170
            }
171
172
            // If neither a tag or regex is given, it's invalid.
173
            if (empty($data['tags']) && empty($data['regex'])) {
174
                $invalid[] = $tool;
175
            }
176
        });
177
178
        uksort($this->tools[$projectDomain], 'strcasecmp');
179
180
        if ($invalid) {
181
            $this->tools[$projectDomain]['invalid'] = $invalid;
182
        }
183
184
        return $this->tools[$projectDomain];
185
    }
186
187
    /**
188
     * Merges the given rule sets, giving priority to the local set. Regex is concatenated, not overridden.
189
     * @param string[] $globalRules The global rule set.
190
     * @param string[] $localRules The rule set for the local wiki.
191
     * @return string[] Merged rules.
192
     */
193
    private function mergeRules(array $globalRules, array $localRules): array
194
    {
195
        // Initial set, including just the global rules.
196
        $tools = $globalRules;
197
198
        // Loop through local rules and override/merge as necessary.
199
        foreach ($localRules as $tool => $rules) {
200
            $newRules = $rules;
201
202
            if (isset($globalRules[$tool])) {
203
                // Order within array_merge is important, so that local rules get priority.
204
                $newRules = array_merge($globalRules[$tool], $rules);
0 ignored issues
show
Bug introduced by
$globalRules[$tool] of type string is incompatible with the type array expected by parameter $arrays of array_merge(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

204
                $newRules = array_merge(/** @scrutinizer ignore-type */ $globalRules[$tool], $rules);
Loading history...
205
            }
206
207
            // Regex should be merged, not overridden.
208
            if (isset($rules['regex']) && isset($globalRules[$tool]['regex'])) {
209
                $newRules['regex'] = implode('|', [
210
                    $rules['regex'],
211
                    $globalRules[$tool]['regex'],
212
                ]);
213
            }
214
215
            $tools[$tool] = $newRules;
216
        }
217
218
        return $tools;
219
    }
220
221
    /**
222
     * Get only tools that are used to revert edits.
223
     * Revert detection happens only by testing against a regular expression, and not by checking tags.
224
     * @param Project $project
225
     * @return string[][] Each tool with the tool name as the key,
226
     *   and 'link' and 'regex' as the subarray keys.
227
     */
228
    public function getRevertTools(Project $project): array
229
    {
230
        $projectDomain = $project->getDomain();
231
232
        if (isset($this->revertTools[$projectDomain])) {
233
            return $this->revertTools[$projectDomain];
234
        }
235
236
        $revertEntries = array_filter(
237
            $this->getTools($project),
238
            function ($tool) {
239
                return isset($tool['revert']) && isset($tool['regex']);
240
            }
241
        );
242
243
        // If 'revert' is set to `true`, then use 'regex' as the regular expression,
244
        //  otherwise 'revert' is assumed to be the regex string.
245
        $this->revertTools[$projectDomain] = array_map(function ($revertTool) {
246
            return [
247
                'link' => $revertTool['link'],
248
                'regex' => true === $revertTool['revert'] ? $revertTool['regex'] : $revertTool['revert'],
249
            ];
250
        }, $revertEntries);
251
252
        return $this->revertTools[$projectDomain];
253
    }
254
255
    /**
256
     * Was the edit a revert, based on the edit summary?
257
     * This only works for tools defined with regular expressions, not tags.
258
     * @param string|null $summary Edit summary. Can be null for instance for suppressed edits.
259
     * @param Project $project
260
     * @return bool
261
     */
262
    public function isRevert(?string $summary, Project $project): bool
263
    {
264
        foreach (array_values($this->getRevertTools($project)) as $values) {
265
            if (preg_match('/'.$values['regex'].'/', (string)$summary)) {
266
                return true;
267
            }
268
        }
269
270
        return false;
271
    }
272
}
273