Repository: WeblateOrg/weblate

Issue:
"reused" translation warning with empty list and resist to reset
### Describe the issue

https://hosted.weblate.org/translate/godot-engine/godot/tr/?q=Create+a+Tile&sort_by=-priority%2Cposition&offset=12

there was a variant of this with small first letters: https://hosted.weblate.org/translate/godot-engine/godot/tr/?q=Create+a+Tile&sort_by=-priority%2Cposition&offset=6

both had the wrong translation.

I corrected the small-lettered first, but missed the size and made it capitalized.

after I corrected the actual capitalized translation, it collided with the other should-be-small-lettered translation and gave me the "reused translation warning".

I marked it to ignore for the time, and continued working with other strings.

I later went back and corrected small-lettered translation.

And Voila! The Bug. now the capitalized version has this empty "other sources" line and it rejects me from resetting it. I press reset, save the string again, change the page and come back. 

The translation does not "resaved" and the "reused" warning is still insisting not to go away.

### I already tried

- [X] I've read and searched [the documentation](https://docs.weblate.org/).
- [X] I've searched for similar issues in this repository.

### Steps to reproduce the behavior

1. have 2 similar strings, A and B
2. translate A  then B to the same content. B should get the "reused" warning
3. press "dismiss" button first
4. the go to A and change it a bit.
5. come back to B and "source" is empty and warning wont go

### Expected behavior

reused warnings on a string must resolve by self if the other string has changed.

### Screenshots

![empty reused translation warningweblate](https://github.com/WeblateOrg/weblate/assets/1812105/e684aede-f461-4af6-9bdf-bcd704181c2a)


### Exception traceback

_No response_

### How do you run Weblate?

weblate.org service

### Weblate versions

_No response_

### Weblate deploy checks

_No response_

### Additional context

_No response_


Discussion:
Bug confirmed, the check propagation does not work well for reused check.
Thank you for your report; the issue you have reported has just been fixed.

* In case you see a problem with the fix, please comment on this issue.
* In case you see a similar problem, please open a separate issue.
* If you are happy with the outcome, don’t hesitate to support Weblate by making a [donation](https://weblate.org/donate/).
@nijel  Hi again,

I guess your fix is alive, but it seems the problem avoided the fix because it is still there. I had to edit one of these strings with an extra "." and then reverted this change, adding 2 entries to the change history.

As I mentioned, the step is "edit string A, then string B". But this has a gotcha: Only string B has the first reused warning.

Since string A does not have this warning (it has no idea it is reused), applying "go to string A and change it" will propagate nowhere, or maybe it forgets old content and propagates with its new content. the system knows about the change because B now has an empty list.

I could not follow the code in which order it saves and propagates. for this "reused" issue, I think, it should be "first propagate the change for the old state, then check and save the new state".

On the other hand, this might come with performance issues. another way to fix this might be adding an "on-demand check" button onto the "things to check" panel: A "check again" button to trigger checks for the current string, for all checks, or for only the reused check.
how long does it take before any direct commit goes live on the "hosted.weblate.org"?
There is no automated deploy, so it depends.
Sorry to bear the bad news: The problem is still there.

Let me shorten my last post:
1- string B knows it is a duplicate, but string A does not know it has a duplicate.
2- thus, changing A does not send a signal to B
3- or it sends a signal but with the wrong content. it should:
  - first send a signal to all strings that may have its "old" content so they may remove their duplicate tags (I think this is the missing part)
  - then send a signal to all strings that may have its "new" content so they may add duplicate tags to themselves.
 
Yes, the problem is still there, that's why this issue is reopened.

Resolving patch:
diff --git a/weblate/checks/base.py b/weblate/checks/base.py
--- a/weblate/checks/base.py
+++ b/weblate/checks/base.py
@@ -30,7 +30,7 @@ class Check:
     source = False
     ignore_untranslated = True
     default_disabled = False
-    propagates: str = ""
+    propagates: bool = False
     param_type = None
     always_display = False
     batch_project_wide = False
@@ -39,6 +39,11 @@ class Check:
     def get_identifier(self):
         return self.check_id
 
+    def get_propagated_units(self, unit, target: str | None = None):
+        from weblate.trans.models import Unit
+
+        return Unit.objects.none()
+
     def __init__(self):
         id_dash = self.check_id.replace("_", "-")
         self.url_id = f"check:{self.check_id}"
diff --git a/weblate/checks/consistency.py b/weblate/checks/consistency.py
--- a/weblate/checks/consistency.py
+++ b/weblate/checks/consistency.py
@@ -2,6 +2,8 @@
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
+from __future__ import annotations
+
 from functools import reduce
 
 from django.db.models import Count, Prefetch, Q
@@ -68,10 +70,13 @@ class ConsistencyCheck(TargetCheck):
         "or is untranslated in some components."
     )
     ignore_untranslated = False
-    propagates = "same_source_units"
+    propagates = True
     batch_project_wide = True
     skip_suggestions = True
 
+    def get_propagated_units(self, unit, target: str | None = None):
+        return unit.same_source_units
+
     def check_target_unit(self, sources, targets, unit):
         component = unit.translation.component
         if not component.allow_translation_propagation:
@@ -81,7 +86,7 @@ def check_target_unit(self, sources, targets, unit):
         if component.batch_checks:
             return self.handle_batch(unit, component)
 
-        for other in unit.same_source_units:
+        for other in self.get_propagated_units(unit):
             if unit.target == other.target:
                 continue
             if unit.translated or other.translated:
@@ -141,10 +146,17 @@ class ReusedCheck(TargetCheck):
     check_id = "reused"
     name = gettext_lazy("Reused translation")
     description = gettext_lazy("Different strings are translated the same.")
-    propagates = "same_target_units"
+    propagates = True
     batch_project_wide = True
     skip_suggestions = True
 
+    def get_propagated_units(self, unit, target: str | None = None):
+        from weblate.trans.models import Unit
+
+        if target is None:
+            return unit.same_target_units
+        return Unit.objects.same_target(unit, target)
+
     def should_skip(self, unit):
         if unit.translation.plural.number <= 1:
             return True
@@ -160,7 +172,7 @@ def check_target_unit(self, sources, targets, unit):
         if component.batch_checks:
             return self.handle_batch(unit, component)
 
-        return unit.same_target_units.exists()
+        return self.get_propagated_units(unit).exists()
 
     def get_description(self, check_obj):
         other_sources = check_obj.unit.same_target_units.values_list(
diff --git a/weblate/trans/models/unit.py b/weblate/trans/models/unit.py
--- a/weblate/trans/models/unit.py
+++ b/weblate/trans/models/unit.py
@@ -5,7 +5,6 @@
 from __future__ import annotations
 
 import re
-from functools import reduce
 from typing import TYPE_CHECKING
 
 from django.conf import settings
@@ -156,6 +155,21 @@ def same(self, unit, exclude=True):
             result = result.exclude(pk=unit.id)
         return result
 
+    def same_target(self, unit: Unit, target: str | None = None):
+        if target is None:
+            target = unit.target
+        translation = unit.translation
+        component = translation.component
+        return self.filter(
+            target__md5=MD5(Value(target)),
+            translation__component__project_id=component.project_id,
+            translation__language_id=translation.language_id,
+            translation__component__source_language_id=component.source_language_id,
+            translation__component__allow_translation_propagation=True,
+            translation__plural_id=translation.plural_id,
+            translation__plural__number__gt=1,
+        ).exclude(source__md5=MD5(Value(unit.source)))
+
     def order_by_request(self, form_data, obj):
         sort_list_request = form_data.get("sort_by", "").split(",")
         available_sort_choices = [
@@ -1207,7 +1221,7 @@ def unresolved_comments(self):
 
     def run_checks(self, propagate: bool | None = None):  # noqa: C901
         """Update checks for this unit."""
-        needs_propagate = bool(propagate)
+        bool(propagate)
 
         src = self.get_source_plurals()
         tgt = self.get_target_plurals()
@@ -1228,6 +1242,10 @@ def run_checks(self, propagate: bool | None = None):  # noqa: C901
             checks = {}
 
         # Run all checks
+        if propagate is True:
+            propagated_units = self.same_source_units
+        else:
+            propagated_units = Unit.objects.none()
         for check, check_obj in checks.items():
             # Does the check fire?
             if getattr(check_obj, meth)(*args):
@@ -1238,14 +1256,15 @@ def run_checks(self, propagate: bool | None = None):  # noqa: C901
                 else:
                     # Create new check
                     create.append(Check(unit=self, dismissed=False, name=check))
-                    needs_propagate |= bool(check_obj.propagates)
+                    if check_obj.propagates:
+                        propagated_units |= check_obj.get_propagated_units(self)
 
         if create:
             Check.objects.bulk_create(create, batch_size=500, ignore_conflicts=True)
 
         # Propagate checks which need it (for example consistency)
-        if (needs_propagate and propagate is not False) or propagate is True:
-            for unit in self.same_source_units | self.same_target_units:
+        if propagate is not False and propagated_units:
+            for unit in propagated_units:
                 try:
                     # Ensure we get a fresh copy of checks
                     # It might be modified meanwhile by propagating to other units
@@ -1261,28 +1280,25 @@ def run_checks(self, propagate: bool | None = None):  # noqa: C901
 
         # Delete no longer failing checks
         if old_checks:
+            propagated_units = Unit.objects.none()
             Check.objects.filter(unit=self, name__in=old_checks).delete()
             propagated_old_checks = []
-            propagated_filters = set()
             for check_name in old_checks:
                 try:
-                    check_propagates = CHECKS[check_name].propagates
+                    check_obj = CHECKS[check_name]
                 except KeyError:
                     # Skip disabled/removed checks
                     continue
-                if check_propagates:
-                    propagated_filters.add(check_propagates)
+                if check_obj.propagates:
+                    propagated_units |= check_obj.get_propagated_units(
+                        self, self.old_unit["target"]
+                    )
                     propagated_old_checks.append(check_name)
-            units = reduce(
-                lambda x, y: x | getattr(self, y),
-                propagated_filters,
-                Unit.objects.none(),
-            )
             if propagated_old_checks:
                 Check.objects.filter(
-                    unit__in=units, name__in=propagated_old_checks
+                    unit__in=propagated_units, name__in=propagated_old_checks
                 ).delete()
-                for other in units:
+                for other in propagated_units:
                     other.translation.invalidate_cache()
                     other.clear_checks_cache()
 
@@ -1528,17 +1544,7 @@ def same_source_units(self):
 
     @cached_property
     def same_target_units(self):
-        translation = self.translation
-        component = translation.component
-        return Unit.objects.filter(
-            target__md5=MD5(Value(self.target)),
-            translation__component__project_id=component.project_id,
-            translation__language_id=translation.language_id,
-            translation__component__source_language_id=component.source_language_id,
-            translation__component__allow_translation_propagation=True,
-            translation__plural_id=translation.plural_id,
-            translation__plural__number__gt=1,
-        ).exclude(source__md5=MD5(Value(self.source)))
+        return Unit.objects.same_target(self)
 
     def get_max_length(self):
         """Returns maximal translation length."""


Test patch:
diff --git a/weblate/checks/tests/test_consistency_checks.py b/weblate/checks/tests/test_consistency_checks.py
--- a/weblate/checks/tests/test_consistency_checks.py
+++ b/weblate/checks/tests/test_consistency_checks.py
@@ -158,6 +158,16 @@ def test_reuse(self):
 
         self.assertNotEqual(check.check_component(self.component), [])
 
+        # Run all checks
+        unit.run_checks()
+        # All three units should be now failing
+        self.assertEqual(Check.objects.filter(name="reused").count(), 3)
+
+        # Change translation
+        unit.translate(self.user, "Dva", STATE_TRANSLATED)
+        # No units should be now failing
+        self.assertEqual(Check.objects.filter(name="reused").count(), 0)
+
     def test_reuse_nocontext(self):
         check = ReusedCheck()
         self.assertEqual(check.check_component(self.component), [])