Repository: x-tools/xtools
Pull request #498: EditCounter: Handle partially deleted rights log
Author: login: agoujot
Created: 2025-04-16T19:33:15Z | Merged: 2025-04-24T05:16:49Z

Knowing that:
 - logs can only be partially revdeleted.
 - log_params is only null when revdeleted (at any rate in the whole of enwiki_p)
 - log_params is always null when *any* revdeleting was used
 - revdeleted values are null in the return

From that:
 1. in userrights.php: we here leave nulls instead of skipping for partially revdeleted logs. This includes added and removed.
 2. in userrightsrepository.php: we use LEFT OUTER JOIN and not JOIN actors table, to not exclude revdeleted actors. Also remove the IFNULL around comment_text because null is a good signal to twig.
 3. in rights_changes_table.html.twig and rights_changes_table.wikitext.twig: if users or comments or rights are null, put a more graceful message.
 


Things to consider:
 - This whole business is quite experimental, especially for the twig parts. I don't have the technical means to test any of my code for XTools.
 - I made the choice to use null to tell twig it's been deleted, but maybe something else should be used.
 - I used the message 'error-deleted-data-link' for revdeld information ("removed from public view" in english), but if maybe it wasn't meant for that
 - If it was a *decision* to leave out all partially revdel'd logs, then that should be documented in the user interface.

Bug: T391753


Diff:
diff --git a/src/Model/UserRights.php b/src/Model/UserRights.php
index b4495c5b1..5bfbe86bb 100644
--- a/src/Model/UserRights.php
+++ b/src/Model/UserRights.php
@@ -63,6 +63,9 @@ public function getRightsChanges(?int $limit = null): array
                     'removed' => [],
                     'grantType' => strtotime($acDate) > time() ? 'pending' : 'automatic',
                     'type' => 'local',
+                    'paramsDeleted' => false,
+                    'commentDeleted' => false,
+                    'performerDeleted' => false,
                 ];
                 krsort($this->rightsChanges);
             }
@@ -223,77 +226,86 @@ private function processRightsChanges(array $logData): array
         $rightsChanges = [];
 
         foreach ($logData as $row) {
-            // Can happen if the log entry has been deleted.
+            // Happens when the log entry has been partially deleted.
+            // This is when comment or performer was deleted.
             if (!isset($row['log_params']) || null === $row['log_params']) {
-                continue;
-            }
-
-            $unserialized = @unserialize($row['log_params']);
-
-            if (false !== $unserialized) {
-                $old = $unserialized['4::oldgroups'] ?? $unserialized['oldGroups'];
-                $new = $unserialized['5::newgroups'] ?? $unserialized['newGroups'];
-                $added = array_diff($new, $old);
-                $removed = array_diff($old, $new);
-                $oldMetadata = $unserialized['oldmetadata'] ?? $unserialized['oldMetadata'] ?? null;
-                $newMetadata = $unserialized['newmetadata'] ?? $unserialized['newMetadata'] ?? null;
-
-                // Check for changes only to expiry. If such exists, treat it as added. Various issets are safeguards.
-                if (empty($added) && empty($removed) && isset($oldMetadata) && isset($newMetadata)) {
-                    foreach ($old as $index => $right) {
-                        $oldExpiry = $oldMetadata[$index]['expiry'] ?? null;
-                        $newExpiry = $newMetadata[$index]['expiry'] ?? null;
-
-                        // Check if an expiry was added, removed, or modified.
-                        if ((null !== $oldExpiry && null === $newExpiry) ||
-                            (null === $oldExpiry && null !== $newExpiry) ||
-                            (null !== $oldExpiry && null !== $newExpiry)
-                        ) {
-                            $added[$index] = $right;
-
-                            // Remove the last auto-removal(s), which must exist.
-                            foreach (array_reverse($rightsChanges, true) as $timestamp => $change) {
-                                if (in_array($right, $change['removed']) && !in_array($right, $change['added']) &&
-                                    'automatic' === $change['grantType']
-                                ) {
-                                    unset($rightsChanges[$timestamp]);
+                // As log_params is NULL, we don't know.
+                // Leave arrays here to not crash later.
+                // Twig will know from log_deleted.
+                $added = [];
+                $removed = [];
+            // Nothing was deleted.
+            } else {
+                $unserialized = @unserialize($row['log_params']);
+    
+                if (false !== $unserialized) {
+                    $old = $unserialized['4::oldgroups'] ?? $unserialized['oldGroups'];
+                    $new = $unserialized['5::newgroups'] ?? $unserialized['newGroups'];
+                    $added = array_diff($new, $old);
+                    $removed = array_diff($old, $new);
+                    $oldMetadata = $unserialized['oldmetadata'] ?? $unserialized['oldMetadata'] ?? null;
+                    $newMetadata = $unserialized['newmetadata'] ?? $unserialized['newMetadata'] ?? null;
+    
+                    // Check for changes only to expiry.
+                    // If such exists, treat it as added. Various issets are safeguards.
+                    if (empty($added) && empty($removed) && isset($oldMetadata) && isset($newMetadata)) {
+                        foreach ($old as $index => $right) {
+                            $oldExpiry = $oldMetadata[$index]['expiry'] ?? null;
+                            $newExpiry = $newMetadata[$index]['expiry'] ?? null;
+    
+                            // Check if an expiry was added, removed, or modified.
+                            if ((null !== $oldExpiry && null === $newExpiry) ||
+                                (null === $oldExpiry && null !== $newExpiry) ||
+                                (null !== $oldExpiry && null !== $newExpiry)
+                            ) {
+                                $added[$index] = $right;
+    
+                                // Remove the last auto-removal(s), which must exist.
+                                foreach (array_reverse($rightsChanges, true) as $timestamp => $change) {
+                                    if (in_array($right, $change['removed']) && !in_array($right, $change['added']) &&
+                                        'automatic' === $change['grantType']
+                                    ) {
+                                        unset($rightsChanges[$timestamp]);
+                                    }
                                 }
                             }
                         }
                     }
+    
+                    // If a right was removed, remove any previously pending auto-removals.
+                    if (count($removed) > 0) {
+                        $this->unsetAutoRemoval($rightsChanges, $removed);
+                    }
+    
+                    $this->setAutoRemovals($rightsChanges, $row, $unserialized, $added);
+                } else {
+                    // This is the old school format that most likely contains
+                    // the list of rights additions as a comma-separated list.
+                    try {
+                        [$old, $new] = explode("\n", $row['log_params']);
+                        $old = array_filter(array_map('trim', explode(',', $old)));
+                        $new = array_filter(array_map('trim', explode(',', (string)$new)));
+                        $added = array_diff($new, $old);
+                        $removed = array_diff($old, $new);
+                    } catch (Exception $e) {
+                        // Really, really old school format that may be missing metadata
+                        // altogether. Here we'll just leave $added and $removed empty.
+                        $added = [];
+                        $removed = [];
+                    }
                 }
-
-                // If a right was removed, remove any previously pending auto-removals.
-                if (count($removed) > 0) {
-                    $this->unsetAutoRemoval($rightsChanges, $removed);
+    
+                // Remove '(none)'.
+                if (in_array('(none)', $added)) {
+                    array_splice($added, array_search('(none)', $added), 1);
                 }
-
-                $this->setAutoRemovals($rightsChanges, $row, $unserialized, $added);
-            } else {
-                // This is the old school format that most likely contains
-                // the list of rights additions as a comma-separated list.
-                try {
-                    [$old, $new] = explode("\n", $row['log_params']);
-                    $old = array_filter(array_map('trim', explode(',', $old)));
-                    $new = array_filter(array_map('trim', explode(',', (string)$new)));
-                    $added = array_diff($new, $old);
-                    $removed = array_diff($old, $new);
-                } catch (Exception $e) {
-                    // Really, really old school format that may be missing metadata
-                    // altogether. Here we'll just leave $added and $removed empty.
-                    $added = [];
-                    $removed = [];
+                if (in_array('(none)', $removed)) {
+                    array_splice($removed, array_search('(none)', $removed), 1);
                 }
+                $added = array_values($added);
+                $removed = array_values($removed);
             }
-
-            // Remove '(none)'.
-            if (in_array('(none)', $added)) {
-                array_splice($added, array_search('(none)', $added), 1);
-            }
-            if (in_array('(none)', $removed)) {
-                array_splice($removed, array_search('(none)', $removed), 1);
-            }
-
+            
             $rightsChanges[$row['log_timestamp']] = [
                 'logId' => $row['log_id'],
                 'performer' => 'autopromote' === $row['log_action'] ? null : $row['performer'],
@@ -302,6 +314,9 @@ private function processRightsChanges(array $logData): array
                 'removed' => array_values($removed),
                 'grantType' => 'autopromote' === $row['log_action'] ? 'automatic' : 'manual',
                 'type' => $row['type'],
+                'paramsDeleted' => $row['log_deleted'] > 0,
+                'commentDeleted' => ($row['log_deleted'] % 4) >= 2,
+                'performerDeleted' => ($row['log_deleted'] % 8) >= 4,
             ];
         }
 
@@ -344,6 +359,9 @@ private function setAutoRemovals(array &$rightsChanges, array $row, array $param
                     'removed' => [$entry],
                     'grantType' => strtotime($expiry) > time() ? 'pending' : 'automatic',
                     'type' => $row['type'],
+                    'paramsDeleted' => false,
+                    'commentDeleted' => false,
+                    'performerDeleted' => false,
                 ];
             }
         }
diff --git a/src/Repository/UserRightsRepository.php b/src/Repository/UserRightsRepository.php
index 3ca094639..f0527764d 100644
--- a/src/Repository/UserRightsRepository.php
+++ b/src/Repository/UserRightsRepository.php
@@ -84,9 +84,9 @@ private function queryRightsChanges(Project $project, User $user, string $type =
         $logType = 'global' == $type ? 'gblrights' : 'rights';
 
         $sql = "SELECT log_id, log_timestamp, log_params, log_action, actor_name AS `performer`,
-                    IFNULL(comment_text, '') AS `log_comment`, '$type' AS type
+                    comment_text AS `log_comment`, log_deleted, '$type' AS type
                 FROM $loggingTable
-                JOIN $actorTable ON log_actor = actor_id
+                LEFT OUTER JOIN $actorTable ON log_actor = actor_id
                 LEFT OUTER JOIN $commentTable ON comment_id = log_comment_id
                 WHERE log_type = '$logType'
                 AND log_namespace = 2
diff --git a/templates/editCounter/rights_changes_table.html.twig b/templates/editCounter/rights_changes_table.html.twig
index c7e5be107..5016becd6 100644
--- a/templates/editCounter/rights_changes_table.html.twig
+++ b/templates/editCounter/rights_changes_table.html.twig
@@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('data-unavailable') }}</i>
+                        {% else %}
+                            {% for right in change.added %}
+                                <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
+                            {% endfor %}
+                            {% for right in change.removed %}
+                                <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
+                            {% endfor %}
+                        {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--performer" data-value="{{ change.performer }}">
-                    {% if change.grantType == 'manual' %}
-                        {{ wiki.userLink(change.performer, change.type == 'local' ? project : metaProject) }}
+                    {% if change.performerDeleted %}
+                        <i class="text-muted">{{ msg('username-removed') }}</i>
+                    {% else %}
+                        {% if change.grantType == 'manual' %}
+                            {{ wiki.userLink(change.performer, change.type == 'local' ? project : metaProject) }}
+                        {% endif %}
                     {% endif %}
                 </td>
                 <td class="sort-entry--summary" data-value="{{ change.comment }}">
                     {% if change.grantType == 'manual' %}
-                        {{ change.comment|wikify(change.type == 'local' ? project : metaProject)|raw }}
+                        {% if change.commentDeleted %}
+                            <i class="text-muted">{{ msg('edit-summary-removed') }}</i>
+                        {% else %}
+                            {{ change.comment|wikify(change.type == 'local' ? project : metaProject)|raw }}
+                        {% endif %}
                     {% else %}
                         <i class="text-muted">{{ msg('automatic') }}</i>
                     {% endif %}
diff --git a/templates/editCounter/rights_changes_table.wikitext.twig b/templates/editCounter/rights_changes_table.wikitext.twig
index a7baee314..4339dc469 100644
--- a/templates/editCounter/rights_changes_table.wikitext.twig
+++ b/templates/editCounter/rights_changes_table.wikitext.twig
@@ -15,7 +15,8 @@
     #}{{ timestamp|trans|date_format }}{#
 #}{% endif %}
 
-| {% for right in change.added %}
+| {% if change.paramsDeleted %}{{ msg('data-unavailable') }}{% else %}
+{% for right in change.added %}
 <span style="color:#006400">+{{ ec.userRights.rightsName(right)|trim }}</span>{% if not loop.last %}, {% endif %}
 {% endfor %}{% if change.added|length > 0 and change.removed|length > 0 %}
 
@@ -23,10 +24,11 @@
 {% for right in change.removed %}
 <span style="color:#8B0000">-{{ ec.userRights.rightsName(right)|trim }}</span>{% if not loop.last %}, {% endif %}
 {% endfor %}
+{% endif %}
 
-|{% if change.performer is not empty %} <span class="plainlinks">[{% verbatim %}{{{% endverbatim %}fullurl:User:{{ change.performer }}}} {{ change.performer }}]</span>{% endif %}
+| {% if change.performerDeleted %}{{ msg('username-removed') }}{% else %}{% if change.performer is not empty %} <span class="plainlinks">[{% verbatim %}{{{% endverbatim %}fullurl:User:{{ change.performer }}}} {{ change.performer }}]</span>{% endif %}{% endif %}
 
-| {% if change.grantType == 'manual' %}{{ change.comment }}{% else %}''{{ msg('automatic') }}''{% endif %}
+| {% if change.grantType == 'manual' %}{% if change.commentDeleted %}{{ msg('edit-summary-removed') }}{{ change.comment }}{% else %}{% endif %}{% else %}''{{ msg('automatic') }}''{% endif %}
 
 {% endfor %}
 |}
diff --git a/tests/Model/UserRightsTest.php b/tests/Model/UserRightsTest.php
index 8d9726199..f1dc7a919 100644
--- a/tests/Model/UserRightsTest.php
+++ b/tests/Model/UserRightsTest.php
@@ -60,6 +60,7 @@ public function testUserRightsChanges(): void
                 'performer' => 'Worm That Turned',
                 'log_comment' => 'per [[Special:Diff/856641107]]',
                 'type' => 'local',
+                'log_deleted' => '0',
             ], [
                 // Removed: ipblock-exempt, filemover.
                 'log_id' => '210221',
@@ -77,6 +78,7 @@ public function testUserRightsChanges(): void
                 'log_action' => 'rights',
                 'performer' => 'MusikAnimal',
                 'type' => 'local',
+                'log_deleted' => '0',
             ], [
                 // Added: ipblock-exempt, filemover, templateeditor.
                 'log_id' => '210220',
@@ -92,6 +94,7 @@ public function testUserRightsChanges(): void
                 'log_action' => 'rights',
                 'performer' => 'MusikAnimal',
                 'type' => 'local',
+                'log_deleted' => '0',
             ], [
                 // Added: bureaucrat; Removed: rollbacker.
                 'log_id' => '155321',
@@ -103,6 +106,7 @@ public function testUserRightsChanges(): void
                 'log_action' => 'rights',
                 'performer' => 'Cyberpower678',
                 'type' => 'meta',
+                'log_deleted' => '0',
             ], [
                 // Old-school log entry, adds sysop.
                 'log_id' => '140643',
@@ -112,6 +116,17 @@ public function testUserRightsChanges(): void
                 'log_action' => 'rights',
                 'performer' => 'Snowolf',
                 'type' => 'meta',
+                'log_deleted' => '0',
+            ], [
+                // Comment deleted
+                'log_id' => '168397975',
+                'log_timestamp' => '20250310044508',
+                'log_comment' => null,
+                'log_params' => null,
+                'log_action' => 'rights',
+                'performer' => 'Queen of Hearts',
+                'type' => 'local',
+                'log_deleted' => '2',
             ],
             ]);
 
@@ -133,6 +148,9 @@ public function testUserRightsChanges(): void
                 'removed' => ['interface-admin'],
                 'grantType' => 'automatic',
                 'type' => 'local',
+                'paramsDeleted' => false,
+                'commentDeleted' => false,
+                'performerDeleted' => false,
             ],
             20180826173045 => [
                 'logId' => '92769185',
@@ -142,6 +160,9 @@ public function testUserRightsChanges(): void
                 'removed' => [],
                 'grantType' => 'manual',
                 'type' => 'local',
+                'paramsDeleted' => false,
+                'commentDeleted' => false,
+                'performerDeleted' => false,
             ],
             20180108132858 => [
                 'logId' => '210220',
@@ -151,6 +172,9 @@ public function testUserRightsChanges(): void
                 'removed' => ['ipblock-exempt', 'filemover'],
                 'grantType' => 'automatic',
                 'type' => 'local',
+                'paramsDeleted' => false,
+                'commentDeleted' => false,
+                'performerDeleted' => false,
             ],
             20180108132810 => [
                 'logId' => '210221',
@@ -160,6 +184,9 @@ public function testUserRightsChanges(): void
                 'removed' => ['templateeditor'],
                 'grantType' => 'manual',
                 'type' => 'local',
+                'paramsDeleted' => false,
+                'commentDeleted' => false,
+                'performerDeleted' => false,
             ],
             20180108132758 => [
                 'logId' => '210220',
@@ -169,6 +196,9 @@ public function testUserRightsChanges(): void
                 'removed' => [],
                 'grantType' => 'manual',
                 'type' => 'local',
+                'paramsDeleted' => false,
+                'commentDeleted' => false,
+                'performerDeleted' => false,
             ],
             20150716002614 => [
                 'logId' => '155321',
@@ -178,6 +208,9 @@ public function testUserRightsChanges(): void
                 'removed' => ['rollbacker'],
                 'grantType' => 'manual',
                 'type' => 'meta',
+                'paramsDeleted' => false,
+                'commentDeleted' => false,
+                'performerDeleted' => false,
             ],
             20141222034127 => [
                 'logId' => '140643',
@@ -187,6 +220,21 @@ public function testUserRightsChanges(): void
                 'removed' => [],
                 'grantType' => 'manual',
                 'type' => 'meta',
+                'paramsDeleted' => false,
+                'commentDeleted' => false,
+                'performerDeleted' => false,
+            ],
+            20250310044508 => [
+                'logId' => '168397975',
+                'performer' => 'Queen of Hearts',
+                'comment' => null,
+                'added' => [],
+                'removed' => [],
+                'grantType' => 'manual',
+                'type' => 'local',
+                'paramsDeleted' => true,
+                'commentDeleted' => true,
+                'performerDeleted' => false,
             ],
         ], $this->userRights->getRightsChanges());
 
@@ -200,6 +248,7 @@ public function testUserRightsChanges(): void
                 'log_action' => 'gblrights',
                 'performer' => 'Snowolf',
                 'type' => 'global',
+                'log_deleted' => '0',
             ]]);
 
         static::assertEquals([
@@ -211,6 +260,9 @@ public function testUserRightsChanges(): void
                 'removed' => [],
                 'grantType' => 'manual',
                 'type' => 'global',
+                'paramsDeleted' => false,
+                'commentDeleted' => false,
+                'performerDeleted' => false,
             ],
         ], $this->userRights->getGlobalRightsChanges());
 


Reviews:
user: login: MusikAnimal
body: Causes the `TypeError`:
```
in_array(): Argument #2 ($haystack) must be of type array, null given in src/Model/UserRights.php (line 263)
```
This PR is complicated enough that I think you'd need a dev environment set up to verify your work, and ideally add a test case for these changes. If a dev environment doesn't work out or just isn't possible for you, rest assured I will still get this merged eventually, we'll just have to take a slower route where I do the debugging for you.

This is to say I'm _very_ grateful for any all contributions to XTools, so please don't let me discourage any further contributions (with or without a working dev environment)!
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-17T20:30:24Z
user: login: MusikAnimal
body: Looks amazing! Thank you :)

In addition to the comments: Knowing you're not a PHP guru I won't pester you into writing new test coverage. For now we just need to fix the DB mocks in `UserRightsTest::testUserRightsChanges()` to return `log_deleted`. Simply adding `'log_deleted' => 0,` to each mock should do it, then also add `'paramsDeleted' => false` etc. to the expectations. I'm happy to help if you need it.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-21T22:45:27Z
user: login: agoujot
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T23:10:07Z
user: login: agoujot
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T23:11:36Z
user: login: agoujot
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T01:36:57Z
user: login: agoujot
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T01:42:07Z
user: login: MusikAnimal
body: Just need to change the `error-deleted-data-link` message and this is good to go, I think! My other comments are not blockers.

Thank you for the tests :)
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-23T18:46:07Z
user: login: agoujot
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T19:25:21Z
user: login: agoujot
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T19:28:23Z
user: login: MusikAnimal
body: :shipit: 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T05:12:18Z

Review comments:
diff_hunk: @@ -223,77 +226,86 @@ private function processRightsChanges(array $logData): array
         $rightsChanges = [];
 
         foreach ($logData as $row) {
-            // Can happen if the log entry has been deleted.
+            // Happens when the log entry has been partially deleted.
+            // This is when comment or performer was deleted.
             if (!isset($row['log_params']) || null === $row['log_params']) {
-                continue;
-            }
-
-            $unserialized = @unserialize($row['log_params']);
-
-            if (false !== $unserialized) {
-                $old = $unserialized['4::oldgroups'] ?? $unserialized['oldGroups'];
-                $new = $unserialized['5::newgroups'] ?? $unserialized['newGroups'];
-                $added = array_diff($new, $old);
-                $removed = array_diff($old, $new);
-                $oldMetadata = $unserialized['oldmetadata'] ?? $unserialized['oldMetadata'] ?? null;
-                $newMetadata = $unserialized['newmetadata'] ?? $unserialized['newMetadata'] ?? null;
-
-                // Check for changes only to expiry. If such exists, treat it as added. Various issets are safeguards.
-                if (empty($added) && empty($removed) && isset($oldMetadata) && isset($newMetadata)) {
-                    foreach ($old as $index => $right) {
-                        $oldExpiry = $oldMetadata[$index]['expiry'] ?? null;
-                        $newExpiry = $newMetadata[$index]['expiry'] ?? null;
-
-                        // Check if an expiry was added, removed, or modified.
-                        if ((null !== $oldExpiry && null === $newExpiry) ||
-                            (null === $oldExpiry && null !== $newExpiry) ||
-                            (null !== $oldExpiry && null !== $newExpiry)
-                        ) {
-                            $added[$index] = $right;
-
-                            // Remove the last auto-removal(s), which must exist.
-                            foreach (array_reverse($rightsChanges, true) as $timestamp => $change) {
-                                if (in_array($right, $change['removed']) && !in_array($right, $change['added']) &&
-                                    'automatic' === $change['grantType']
-                                ) {
-                                    unset($rightsChanges[$timestamp]);
+                // As log_params is NULL, we don't know.
+                // Leave arrays here to not crash later.
+                // Twig will know from log_deleted.
+                $added = [];
+                $removed = [];
path: src/Model/UserRights.php
user: login: MusikAnimal
body: (nitpick) Consider using `continue;` here, then we don't need the `else {}` block and thus we don't have to indent those lines of code
created_at: 2025-04-21T22:25:17Z
author_association: MEMBER
diff_hunk: @@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('error-deleted-data-link') }}</i>
path: templates/editCounter/rights_changes_table.html.twig
user: login: MusikAnimal
body: The message is used inside of another message, and hence some translations may not make sense in this isolated context. Fortunately we have the `edit-summary-removed` message which despite the name is used for this very purpose; It's documented to be identical to the [rev-deleted-comment](https://en.wikipedia.org/wiki/MediaWiki:Rev-deleted-comment) in MediaWiki, which also reads "edit summary removed".
created_at: 2025-04-21T22:37:16Z
author_association: MEMBER
diff_hunk: @@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('error-deleted-data-link') }}</i>
path: templates/editCounter/rights_changes_table.html.twig
user: login: agoujot
body: The last commits use error-deleted-data-link for all columns; perhaps then we want to change these too?  Looking into the details, we already have `username-removed`. Don't know really what to put for the rights changes proper, though.
created_at: 2025-04-21T23:10:07Z
author_association: MEMBER
diff_hunk: @@ -223,77 +226,86 @@ private function processRightsChanges(array $logData): array
         $rightsChanges = [];
 
         foreach ($logData as $row) {
-            // Can happen if the log entry has been deleted.
+            // Happens when the log entry has been partially deleted.
+            // This is when comment or performer was deleted.
             if (!isset($row['log_params']) || null === $row['log_params']) {
-                continue;
-            }
-
-            $unserialized = @unserialize($row['log_params']);
-
-            if (false !== $unserialized) {
-                $old = $unserialized['4::oldgroups'] ?? $unserialized['oldGroups'];
-                $new = $unserialized['5::newgroups'] ?? $unserialized['newGroups'];
-                $added = array_diff($new, $old);
-                $removed = array_diff($old, $new);
-                $oldMetadata = $unserialized['oldmetadata'] ?? $unserialized['oldMetadata'] ?? null;
-                $newMetadata = $unserialized['newmetadata'] ?? $unserialized['newMetadata'] ?? null;
-
-                // Check for changes only to expiry. If such exists, treat it as added. Various issets are safeguards.
-                if (empty($added) && empty($removed) && isset($oldMetadata) && isset($newMetadata)) {
-                    foreach ($old as $index => $right) {
-                        $oldExpiry = $oldMetadata[$index]['expiry'] ?? null;
-                        $newExpiry = $newMetadata[$index]['expiry'] ?? null;
-
-                        // Check if an expiry was added, removed, or modified.
-                        if ((null !== $oldExpiry && null === $newExpiry) ||
-                            (null === $oldExpiry && null !== $newExpiry) ||
-                            (null !== $oldExpiry && null !== $newExpiry)
-                        ) {
-                            $added[$index] = $right;
-
-                            // Remove the last auto-removal(s), which must exist.
-                            foreach (array_reverse($rightsChanges, true) as $timestamp => $change) {
-                                if (in_array($right, $change['removed']) && !in_array($right, $change['added']) &&
-                                    'automatic' === $change['grantType']
-                                ) {
-                                    unset($rightsChanges[$timestamp]);
+                // As log_params is NULL, we don't know.
+                // Leave arrays here to not crash later.
+                // Twig will know from log_deleted.
+                $added = [];
+                $removed = [];
path: src/Model/UserRights.php
user: login: agoujot
body: Yeah, it used (when I was trying to figure out what does what) to be more complex; agree `continue` makes more sense with what it ended up being.
created_at: 2025-04-21T23:11:36Z
author_association: MEMBER
diff_hunk: @@ -223,77 +226,86 @@ private function processRightsChanges(array $logData): array
         $rightsChanges = [];
 
         foreach ($logData as $row) {
-            // Can happen if the log entry has been deleted.
+            // Happens when the log entry has been partially deleted.
+            // This is when comment or performer was deleted.
             if (!isset($row['log_params']) || null === $row['log_params']) {
-                continue;
-            }
-
-            $unserialized = @unserialize($row['log_params']);
-
-            if (false !== $unserialized) {
-                $old = $unserialized['4::oldgroups'] ?? $unserialized['oldGroups'];
-                $new = $unserialized['5::newgroups'] ?? $unserialized['newGroups'];
-                $added = array_diff($new, $old);
-                $removed = array_diff($old, $new);
-                $oldMetadata = $unserialized['oldmetadata'] ?? $unserialized['oldMetadata'] ?? null;
-                $newMetadata = $unserialized['newmetadata'] ?? $unserialized['newMetadata'] ?? null;
-
-                // Check for changes only to expiry. If such exists, treat it as added. Various issets are safeguards.
-                if (empty($added) && empty($removed) && isset($oldMetadata) && isset($newMetadata)) {
-                    foreach ($old as $index => $right) {
-                        $oldExpiry = $oldMetadata[$index]['expiry'] ?? null;
-                        $newExpiry = $newMetadata[$index]['expiry'] ?? null;
-
-                        // Check if an expiry was added, removed, or modified.
-                        if ((null !== $oldExpiry && null === $newExpiry) ||
-                            (null === $oldExpiry && null !== $newExpiry) ||
-                            (null !== $oldExpiry && null !== $newExpiry)
-                        ) {
-                            $added[$index] = $right;
-
-                            // Remove the last auto-removal(s), which must exist.
-                            foreach (array_reverse($rightsChanges, true) as $timestamp => $change) {
-                                if (in_array($right, $change['removed']) && !in_array($right, $change['added']) &&
-                                    'automatic' === $change['grantType']
-                                ) {
-                                    unset($rightsChanges[$timestamp]);
+                // As log_params is NULL, we don't know.
+                // Leave arrays here to not crash later.
+                // Twig will know from log_deleted.
+                $added = [];
+                $removed = [];
path: src/Model/UserRights.php
user: login: agoujot
body: Hang on - no, actually, we can't use continue, else we're skipping the whole point of this :) (as it'll exclude the stuff at the very end of the loop that actually adds to the list).
created_at: 2025-04-22T01:36:57Z
author_association: MEMBER
diff_hunk: @@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('error-deleted-data-link') }}</i>
path: templates/editCounter/rights_changes_table.html.twig
user: login: agoujot
body: There we go, done except for the params.
created_at: 2025-04-22T01:42:07Z
author_association: MEMBER
diff_hunk: @@ -223,77 +226,86 @@ private function processRightsChanges(array $logData): array
         $rightsChanges = [];
 
         foreach ($logData as $row) {
-            // Can happen if the log entry has been deleted.
+            // Happens when the log entry has been partially deleted.
+            // This is when comment or performer was deleted.
             if (!isset($row['log_params']) || null === $row['log_params']) {
-                continue;
-            }
-
-            $unserialized = @unserialize($row['log_params']);
-
-            if (false !== $unserialized) {
-                $old = $unserialized['4::oldgroups'] ?? $unserialized['oldGroups'];
-                $new = $unserialized['5::newgroups'] ?? $unserialized['newGroups'];
-                $added = array_diff($new, $old);
-                $removed = array_diff($old, $new);
-                $oldMetadata = $unserialized['oldmetadata'] ?? $unserialized['oldMetadata'] ?? null;
-                $newMetadata = $unserialized['newmetadata'] ?? $unserialized['newMetadata'] ?? null;
-
-                // Check for changes only to expiry. If such exists, treat it as added. Various issets are safeguards.
-                if (empty($added) && empty($removed) && isset($oldMetadata) && isset($newMetadata)) {
-                    foreach ($old as $index => $right) {
-                        $oldExpiry = $oldMetadata[$index]['expiry'] ?? null;
-                        $newExpiry = $newMetadata[$index]['expiry'] ?? null;
-
-                        // Check if an expiry was added, removed, or modified.
-                        if ((null !== $oldExpiry && null === $newExpiry) ||
-                            (null === $oldExpiry && null !== $newExpiry) ||
-                            (null !== $oldExpiry && null !== $newExpiry)
-                        ) {
-                            $added[$index] = $right;
-
-                            // Remove the last auto-removal(s), which must exist.
-                            foreach (array_reverse($rightsChanges, true) as $timestamp => $change) {
-                                if (in_array($right, $change['removed']) && !in_array($right, $change['added']) &&
-                                    'automatic' === $change['grantType']
-                                ) {
-                                    unset($rightsChanges[$timestamp]);
+                // As log_params is NULL, we don't know.
+                // Leave arrays here to not crash later.
+                // Twig will know from log_deleted.
+                $added = [];
+                $removed = [];
path: src/Model/UserRights.php
user: login: MusikAnimal
body: Ah, right! Then moving to a `private function` is probably what I would do, then. But no need to bother, this passes the linter rules (which for this repo are quite strict) so beyond that is just a nitpick.
created_at: 2025-04-23T03:01:09Z
author_association: MEMBER
diff_hunk: @@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('error-deleted-data-link') }}</i>
path: templates/editCounter/rights_changes_table.html.twig
user: login: MusikAnimal
body: Hmm, so 'tis a shame that the `logging` view for Toolforge will scrub out the `log_params` even when it's visible on-wiki! I think either the `unknown` or `data-unavailable` messages are better suited here, or we create a new message. `error-deleted-data-link` can't be used as it is a "lego" message (a message that's part of another message).
created_at: 2025-04-23T18:33:51Z
author_association: MEMBER
diff_hunk: @@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('error-deleted-data-link') }}</i>
+                        {% else %}
+                            {% for right in change.added %}
+                                <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
+                            {% endfor %}
+                            {% for right in change.removed %}
+                                <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
+                            {% endfor %}
+                        {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--performer" data-value="{{ change.performer }}">
-                    {% if change.grantType == 'manual' %}
-                        {{ wiki.userLink(change.performer, change.type == 'local' ? project : metaProject) }}
+                    {% if change.performerDeleted %}
+                        <i class="text-muted">{{ msg('username-removed') }}</i>
+                    {% else %}
+                        {% if change.grantType == 'manual' %}
+                            {{ wiki.userLink(change.performer, change.type == 'local' ? project : metaProject) }}
+                        {% endif %}
                     {% endif %}
path: templates/editCounter/rights_changes_table.html.twig
user: login: MusikAnimal
body: What happens for an automatic change with `performerDeleted`? I guess that can never happen?

I was going to suggest using `{% elseif` then noticed this potential code smell.
created_at: 2025-04-23T18:43:03Z
author_association: MEMBER
diff_hunk: @@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('error-deleted-data-link') }}</i>
+                        {% else %}
+                            {% for right in change.added %}
+                                <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
+                            {% endfor %}
+                            {% for right in change.removed %}
+                                <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
+                            {% endfor %}
+                        {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--performer" data-value="{{ change.performer }}">
-                    {% if change.grantType == 'manual' %}
-                        {{ wiki.userLink(change.performer, change.type == 'local' ? project : metaProject) }}
+                    {% if change.performerDeleted %}
+                        <i class="text-muted">{{ msg('username-removed') }}</i>
+                    {% else %}
+                        {% if change.grantType == 'manual' %}
+                            {{ wiki.userLink(change.performer, change.type == 'local' ? project : metaProject) }}
+                        {% endif %}
                     {% endif %}
path: templates/editCounter/rights_changes_table.html.twig
user: login: agoujot
body: Hmm. Weird thing: there is a Special:Log option to see autopromotes, but it can't manage to find autoconfirmed, only other autopromotes. So it can theoretically be done. But then, if performer is deleted I think showing "user deleted" is what we should do, as it what the admins wanted. I checked, and `log_actor` is the user themselves for autopromotions. Note: thanks god testwiki exists. [We can see](https://testwiki.wiki/wiki/Special:Log?type=rights&user=&page=&wpdate=&tagfilter=&subtype=autopromote&wpFormIdentifier=logeventslist) that it is possible, and that what MW does is show "removed". So, we should do the same.
created_at: 2025-04-23T19:25:21Z
author_association: MEMBER
diff_hunk: @@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('error-deleted-data-link') }}</i>
path: templates/editCounter/rights_changes_table.html.twig
user: login: agoujot
body: Done, used `data-unavailable`.
created_at: 2025-04-23T19:28:23Z
author_association: MEMBER
diff_hunk: @@ -23,21 +23,33 @@
                     {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--rights" data-value="{{ change.added|length + change.removed|length }}">
-                    {% for right in change.added %}
-                        <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
-                    {% for right in change.removed %}
-                        <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
-                    {% endfor %}
+                        {% if change.paramsDeleted %}
+                            <i class="text-muted">{{ msg('error-deleted-data-link') }}</i>
+                        {% else %}
+                            {% for right in change.added %}
+                                <div class="diff-pos">{{ ec.userRights.rightsName(right)|trim }}</div>
+                            {% endfor %}
+                            {% for right in change.removed %}
+                                <div class="diff-neg" dir="ltr">-{{ ec.userRights.rightsName(right)|trim }}</div>
+                            {% endfor %}
+                        {% endif %}
                 </td>
                 <td class="{{ change.grantType == 'pending' ? 'pending ' : '' }}sort-entry--performer" data-value="{{ change.performer }}">
-                    {% if change.grantType == 'manual' %}
-                        {{ wiki.userLink(change.performer, change.type == 'local' ? project : metaProject) }}
+                    {% if change.performerDeleted %}
+                        <i class="text-muted">{{ msg('username-removed') }}</i>
+                    {% else %}
+                        {% if change.grantType == 'manual' %}
+                            {{ wiki.userLink(change.performer, change.type == 'local' ? project : metaProject) }}
+                        {% endif %}
                     {% endif %}
path: templates/editCounter/rights_changes_table.html.twig
user: login: MusikAnimal
body: Indeed, autoconfirmed is a special case! It goes by [$wgAutopromote](https://www.mediawiki.org/wiki/Manual:$wgAutopromote) which [does not actually add the user to a user group](https://www.mediawiki.org/wiki/Manual:$wgAutopromote#Caveats), as opposed to [$wgAutopromoteOnce](https://www.mediawiki.org/wiki/Manual:$wgAutopromoteOnce) which does. To work around this, we have to first parse the expression used for the `autoconfirmed` formula, resulting in this atrocity (including the use of [eval](https://www.php.net/manual/en/function.eval.php) :vomiting_face:):

https://github.com/x-tools/xtools/blob/7d6ff4805ded5995e45707b68bf556ef8f8feae7/src/Repository/UserRightsRepository.php#L178

Which reminds me, a colleague kindly added an API endpoint for this last year ([T196059](https://phabricator.wikimedia.org/T196059)) and I have yet to update XTools to make use of it. I've filed [T392551](https://phabricator.wikimedia.org/T392551)

Anyway, thanks for doing the investigative work here, and reminding me of this tech debt! :stuck_out_tongue: 
created_at: 2025-04-24T04:46:07Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-17T06:33:09Z
body: ## [Codecov](https://app.codecov.io/gh/x-tools/xtools/pull/498?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=x-tools) Report
Attention: Patch coverage is `60.41667%` with `19 lines` in your changes missing coverage. Please review.
> Project coverage is 71.00%. Comparing base [(`4b8eb28`)](https://app.codecov.io/gh/x-tools/xtools/commit/4b8eb2803f3891db4f402bad8077ebb2e5c87530?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=x-tools) to head [(`b1ec277`)](https://app.codecov.io/gh/x-tools/xtools/commit/b1ec2777dfd6cf9964ae023da260ce045a880bb1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=x-tools).
> Report is 73 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/x-tools/xtools/pull/498?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=x-tools) | Patch % | Lines |
|---|---|---|
| [src/Model/UserRights.php](https://app.codecov.io/gh/x-tools/xtools/pull/498?src=pr&el=tree&filepath=src%2FModel%2FUserRights.php&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=x-tools#diff-c3JjL01vZGVsL1VzZXJSaWdodHMucGhw) | 60.41% | [19 Missing :warning: ](https://app.codecov.io/gh/x-tools/xtools/pull/498?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=x-tools) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##               main     #498      +/-   ##
============================================
- Coverage     71.30%   71.00%   -0.31%     
- Complexity     1275     1293      +18     
============================================
  Files            46       46              
  Lines          3792     3904     +112     
============================================
+ Hits           2704     2772      +68     
- Misses         1088     1132      +44     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/x-tools/xtools/pull/498?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=x-tools).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=x-tools).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: agoujot
created_at: 2025-04-21T21:49:25Z
body: There we go! Should be done. In the end, twig didn't have a clean way of distinguishing null and [], so I ended up querying log_deleted too and directly giving bools to twig based on log_deleted. Tested html and wikitext output with /ec/en.wikipedia.org/Kencf0618 and /ec/en.wikipedia.org/Chew.
author_association: MEMBER
user: login: agoujot
created_at: 2025-04-22T01:43:56Z
body: On test coverage: if you just tell me how it works :), glad to help. Or maybe I'll try to figure it out from the source.
author_association: MEMBER
user: login: MusikAnimal
created_at: 2025-04-23T18:45:06Z
body: > On test coverage: if you just tell me how it works :), glad to help. Or maybe I'll try to figure it out from the source.

What you have looks awesome, thank you!
author_association: MEMBER
user: login: agoujot
created_at: 2025-04-23T21:05:09Z
body: (note: I did use data-unavailable for both commits, just mixed up in second commit message.)
author_association: MEMBER