Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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_pendingunitchange_automatically_translated"),
]

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)
3 changes: 3 additions & 0 deletions weblate/trans/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,9 @@ def create_xliff(self, name="default", **kwargs) -> Component:
def create_xliff_mono(self) -> Component:
return self._create_component("xliff", "xliff-mono/*.xlf", "xliff-mono/en.xlf")

def create_xliff_auto(self) -> Component:
return self._create_component("xliff", "xliff-auto/*.xlf")

def create_resx(self) -> Component:
return self._create_component("resx", "resx/*.resx", "resx/en.resx")

Expand Down
Loading