Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions weblate/formats/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@
# actual main unit
raise MissingTemplateError
else:
self.mainunit = unit

Check failure on line 172 in weblate/formats/base.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "TranslationUnit | BaseItem | None", variable has type "TranslationUnit | BaseItem")

def _invalidate_target(self) -> None:
"""Invalidate target cache."""
Expand Down Expand Up @@ -292,6 +292,10 @@
"""Check whether unit is read-only."""
return False

def is_automatically_translated(self, fallback: bool = False) -> bool:
"""Check whether unit is automatically translated."""
return fallback

def set_target(self, target: str | list[str]) -> None:
"""Set translation unit target."""
raise NotImplementedError
Expand All @@ -306,6 +310,9 @@
"""Set fuzzy /approved flag on translated unit."""
raise NotImplementedError

def set_automatically_translated(self, value: bool) -> None:
return
Comment on lines +313 to +314
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method body contains only a return statement without any implementation. Consider using 'pass' instead to make it clear this is an intentionally empty method, or add a docstring explaining why subclasses may override this.

Copilot uses AI. Check for mistakes.

def has_unit(self) -> bool:
return self.unit is not None

Expand Down Expand Up @@ -520,7 +527,7 @@
with tempfile.NamedTemporaryFile(
prefix=basename, dir=dirname, delete=False
) as temp:
callback(temp)

Check failure on line 530 in weblate/formats/base.py

View workflow job for this annotation

GitHub Actions / mypy

Argument 1 has incompatible type "_TemporaryFileWrapper[bytes]"; expected "BinaryIO"
os.replace(temp.name, filename)
finally:
if os.path.exists(temp.name):
Expand Down
10 changes: 10 additions & 0 deletions weblate/formats/tests/test_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,16 @@ def test_set_state(self) -> None:
Path(testfile).read_text(encoding="utf-8"),
)

def test_state_qualifier_autotranslated(self) -> None:
storage = self.parse_file(get_test_file("cs-auto.xliff"))
units = storage.all_units

self.assertTrue(
units[0].is_automatically_translated(),
)
self.assertTrue(units[1].is_automatically_translated())
self.assertFalse(units[2].is_automatically_translated())


class RichXliffFormatTest(XliffFormatTest):
format_class = RichXliffFormat
Expand Down
26 changes: 26 additions & 0 deletions weblate/formats/ttkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
return get_string(self.template.source)
if self.unit is None:
raise MissingTemplateError
return get_string(self.unit.name)

Check failure on line 215 in weblate/formats/ttkit.py

View workflow job for this annotation

GitHub Actions / mypy

"TranslationUnit" has no attribute "name"

@cached_property
def target(self):
Expand Down Expand Up @@ -248,7 +248,7 @@
super().set_target(target)
# Propagate to value so that searializing of empty values works correctly
if not target:
self.unit.value = self.unit.target

Check failure on line 251 in weblate/formats/ttkit.py

View workflow job for this annotation

GitHub Actions / mypy

"TranslationUnit" has no attribute "value"


class TTKitFormat(TranslationFormat):
Expand Down Expand Up @@ -672,6 +672,32 @@
result.add(state)
return result

def get_xliff_state_qualifiers(self) -> set[str]:
"""Return set of state-qualifier values from target nodes."""
result = set()
for node in self.get_xliff_nodes():
if node is None:
continue
state_qualifier = node.get("state-qualifier", None)
if state_qualifier is not None:
result.add(state_qualifier)
return result

def is_automatically_translated(self, fallback: bool = False) -> bool:
"""Check if unit is automatically translated based on state-qualifier."""
state_qualifiers = self.get_xliff_state_qualifiers()
return bool({"leveraged-mt", "mt-suggestion"}.intersection(state_qualifiers))

def set_automatically_translated(self, value: bool) -> None:
if self.is_automatically_translated() == value:
return
for node in self.get_xliff_nodes():
if node is not None:
if value:
node.set("state-qualifier", "leveraged-mt")
else:
node.attrib.pop("state-qualifier", None)

@cached_property
def context(self):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

# Generated by Django 5.2.9 on 2025-12-13 21:11

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("trans", "0060_alter_component_language_regex"),
]

operations = [
migrations.AddField(
model_name="pendingunitchange",
name="automatically_translated",
field=models.BooleanField(default=False),
),
]
5 changes: 5 additions & 0 deletions weblate/trans/models/pending.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,21 +75,21 @@
age_cutoff = timezone.now() - timedelta(hours=hours)
# Use per-component commit_pending_age setting to calculate age cutoff.
elif using_postgresql():
age_cutoff = ExpressionWrapper(

Check failure on line 78 in weblate/trans/models/pending.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "ExpressionWrapper[CombinedExpression]", variable has type "datetime")
Now()
- F("unit__translation__component__commit_pending_age")
* timedelta(hours=1),
output_field=DateTimeField(),
)
else:
age_cutoff = MySQLTimestampAdd(

Check failure on line 85 in weblate/trans/models/pending.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "MySQLTimestampAdd", variable has type "datetime")
"HOUR", -F("unit__translation__component__commit_pending_age"), Now()
)

pending_changes = pending_changes.filter(timestamp__lt=age_cutoff)

# Apply retry eligibility filter WITHOUT expensive blocking unit check
pending_changes = self._apply_retry_filter(

Check failure on line 92 in weblate/trans/models/pending.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "PendingChangeQuerySet")
pending_changes, revision=None, blocking_unit_filter=False
)

Expand Down Expand Up @@ -159,7 +159,7 @@
qs = self.filter(base_filter)
if not apply_filters:
return qs
qs = self._apply_retry_filter(qs, revision=revision, blocking_unit_filter=True)

Check failure on line 162 in weblate/trans/models/pending.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "PendingChangeQuerySet")
return self._apply_commit_policy_filter(qs, commit_policy)

def _apply_retry_filter(
Expand Down Expand Up @@ -315,11 +315,11 @@
qs = self.filter(base_filter)
counts["total"] = self._count_units_helper(qs)

qs = self._apply_retry_filter(qs, revision=revision, blocking_unit_filter=True)

Check failure on line 318 in weblate/trans/models/pending.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "PendingChangeQuerySet")
eligible_after_retry_filter = self._count_units_helper(qs)
counts["errors_skipped"] = counts["total"] - eligible_after_retry_filter

qs = self._apply_commit_policy_filter(qs, commit_policy)

Check failure on line 322 in weblate/trans/models/pending.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "PendingChangeQuerySet")
eligible_after_commit_policy_filter = self._count_units_helper(qs)
counts["commit_policy_skipped"] = (
eligible_after_retry_filter - eligible_after_commit_policy_filter
Expand Down Expand Up @@ -350,6 +350,7 @@
state = models.IntegerField(default=0, choices=StringState.choices, db_index=True)
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
add_unit = models.BooleanField(default=False)
automatically_translated = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True, null=False)

objects = PendingChangeQuerySet.as_manager()
Expand All @@ -373,6 +374,7 @@
state: int | None = None,
add_unit: bool = False,
source_unit_explanation: str | None = None,
automatically_translated: bool | None = None,
timestamp: datetime | None = None,
store_disk_state: bool = True,
) -> PendingUnitChange:
Expand All @@ -392,6 +394,8 @@
source_unit_explanation = unit.source_unit.explanation
if author is None:
author = unit.get_last_content_change()[0]
if automatically_translated is None:
automatically_translated = unit.automatically_translated

kwargs = {
"unit": unit,
Expand All @@ -401,6 +405,7 @@
"state": state,
"add_unit": add_unit,
"source_unit_explanation": source_unit_explanation,
"automatically_translated": automatically_translated,
}
if timestamp is not None:
kwargs["timestamp"] = timestamp
Expand Down
4 changes: 4 additions & 0 deletions weblate/trans/models/translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1082,11 +1082,15 @@ def update_units(
# Update fuzzy/approved flag
pounit.set_state(pending_change.state)

# Update autotranslated state
pounit.set_automatically_translated(pending_change.automatically_translated)

# Update disk state from the file to the unit
unit.details["disk_state"] = {
"target": pounit.target,
"state": pending_change.state,
"explanation": pounit.explanation,
"automatically_translated": pending_change.automatically_translated,
}
unit.save(update_fields=["details"], only_save=True)

Expand Down
34 changes: 31 additions & 3 deletions weblate/trans/models/unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ class UnitAttributesDict(TypedDict):
created: bool
pos: int
id_hash: int
automatically_translated: bool


class Unit(models.Model, LoggerMixin):
Expand Down Expand Up @@ -663,6 +664,7 @@ def store_disk_state(self) -> None:
"target": self.old_unit["target"],
"state": self.old_unit["state"],
"explanation": self.old_unit["explanation"],
"automatically_translated": self.old_unit["automatically_translated"],
}
self.save(same_content=True, only_save=True, update_fields=["details"])

Expand Down Expand Up @@ -694,6 +696,7 @@ def get_comparison_state(self) -> dict[str, Any]:
"target": self.target,
"state": self.state,
"explanation": self.explanation,
"automatically_translated": self.automatically_translated,
}

@property
Expand Down Expand Up @@ -841,6 +844,17 @@ def get_unit_state(

return STATE_TRANSLATED

def get_unit_automatically_translated(
self,
unit,
string_changed: bool,
disk_automatically_translated: bool | None = None,
) -> bool:
return unit.is_automatically_translated(
(self.automatically_translated or disk_automatically_translated)
and not string_changed
)

@staticmethod
def check_valid(texts) -> None:
for text in texts:
Expand Down Expand Up @@ -947,6 +961,7 @@ def store_unit_attributes(
"created": created,
"pos": pos,
"id_hash": id_hash,
"automatically_translated": unit.is_automatically_translated(),
}
return self.unit_attributes

Expand Down Expand Up @@ -982,6 +997,7 @@ def update_from_unit( # noqa: C901,PLR0914
unit = unit_attributes["unit"]
created = unit_attributes["created"]
pos = unit_attributes["pos"]
automatically_translated = unit_attributes["automatically_translated"]

# Should not be needed again
self.unit_attributes = None
Expand All @@ -1008,16 +1024,20 @@ def update_from_unit( # noqa: C901,PLR0914
# Has source/target changed
same_source = source == self.source and context == self.context
same_target = target == comparison_state["target"]

string_changed = not same_source or not same_target
# Calculate state
state = self.get_unit_state(
unit,
flags,
string_changed=not same_source or not same_target,
string_changed=string_changed,
disk_unit_state=comparison_state["state"],
)
original_state = self.get_unit_state(unit, None)

automatically_translated = self.get_unit_automatically_translated(
unit, string_changed, comparison_state["automatically_translated"]
)

# Monolingual files handling (without target change)
if (
not created
Expand Down Expand Up @@ -1059,6 +1079,7 @@ def update_from_unit( # noqa: C901,PLR0914
and explanation == comparison_state["explanation"]
and note == self.note
and pos == self.position
and automatically_translated == self.automatically_translated
and not pending
)
same_data = (
Expand Down Expand Up @@ -1095,6 +1116,7 @@ def update_from_unit( # noqa: C901,PLR0914
self.context = context
self.note = note
self.previous_source = previous_source
self.automatically_translated = automatically_translated
self.update_priority(save=False)

# Metadata update only, these do not trigger any actions in Weblate and
Expand All @@ -1103,7 +1125,13 @@ def update_from_unit( # noqa: C901,PLR0914
self.save(
same_content=True,
only_save=True,
update_fields=["location", "explanation", "note", "position"],
update_fields=[
"location",
"explanation",
"note",
"position",
"automatically_translated",
],
)
return

Expand Down
19 changes: 19 additions & 0 deletions weblate/trans/tests/data/cs-auto.xliff
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="cs" datatype="plaintext">
<body>
<trans-unit id="1">
<source>Hello</source>
<target state-qualifier="leveraged-mt">Ahoj</target>
</trans-unit>
<trans-unit id="2">
<source>World</source>
<target state-qualifier="mt-suggestion">svět</target>
</trans-unit>
<trans-unit id="3">
<source>Car</source>
<target>Auto</target>
</trans-unit>
</body>
</file>
</xliff>
60 changes: 60 additions & 0 deletions weblate/trans/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import os
from datetime import timedelta
from pathlib import Path

from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.core.management.color import no_style
Expand Down Expand Up @@ -1030,3 +1031,62 @@ def test_find_committable_components_with_retry_filter(self) -> None:
set(components.values_list("pk", flat=True)),
{self.component.pk, self.component3.pk},
)


class AutomaticallyTranslatedFromFileTest(RepoTestCase):
def test_xliff_state_qualifier_loaded_to_database(self) -> None:
component = self.create_xliff_auto()
translation = component.translation_set.get(language_code="cs")
user = create_test_user()

file_content = Path(translation.get_filename()).read_text(encoding="utf-8")
self.assertEqual(file_content.count('state-qualifier="leveraged-mt"'), 1)
self.assertEqual(file_content.count('state-qualifier="mt-suggestion"'), 1)

self.assertTrue(
translation.unit_set.get(source="Hello").automatically_translated
)

world_unit = translation.unit_set.get(source="World")
self.assertTrue(world_unit.automatically_translated)
world_unit.translate(
user=user, new_target=world_unit.target, new_state=STATE_TRANSLATED
)
self.assertFalse(world_unit.automatically_translated)

car_unit = translation.unit_set.get(source="Car")
self.assertFalse(car_unit.automatically_translated)
car_unit.translate(
user=user,
new_target="Automobil",
new_state=STATE_TRANSLATED,
change_action=ActionEvents.AUTO,
)
self.assertTrue(car_unit.automatically_translated)

translation.commit_pending("test", None)

file_content = Path(translation.get_filename()).read_text(encoding="utf-8")
self.assertIn("Automobil", file_content)
self.assertEqual(file_content.count('state-qualifier="leveraged-mt"'), 2)
self.assertEqual(file_content.count('state-qualifier="mt-suggestion"'), 0)

def test_xliff_file_sync_gets_automatically_translated_from_file(self) -> None:
component = self.create_xliff_auto()
translation = component.translation_set.get(language_code="cs")

car_unit = translation.unit_set.get(source="Car")
self.assertFalse(car_unit.automatically_translated)

file_content = Path(translation.get_filename()).read_text(encoding="utf-8")
modified_content = file_content.replace(
"<target>Auto</target>",
'<target state-qualifier="leveraged-mt">Auto</target>',
)
Path(translation.get_filename()).write_text(modified_content, encoding="utf-8")

translation = component.translation_set.get(language_code="cs")
translation.check_sync(force=True)

car_unit = translation.unit_set.get(source="Car")
self.assertTrue(car_unit.automatically_translated)
Loading
Loading