Skip to content

Commit a891a24

Browse files
authored
Add Translation.value and .properties, with data model contents (mozilla#3802)
1 parent b9955d6 commit a891a24

16 files changed

Lines changed: 321 additions & 46 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 4.2.22 on 2025-10-06 20:59
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [("base", "0109_fix_xcode_translations")]
8+
9+
operations = [
10+
migrations.AddField(
11+
model_name="translation",
12+
name="value",
13+
field=models.JSONField(default=list),
14+
),
15+
migrations.AddField(
16+
model_name="translation",
17+
name="properties",
18+
field=models.JSONField(blank=True, null=True),
19+
),
20+
]
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from math import ceil
2+
3+
from moz.l10n.formats import Format
4+
from moz.l10n.formats.fluent import fluent_parse_entry
5+
from moz.l10n.message import message_to_json, parse_message
6+
from moz.l10n.model import CatchallKey, PatternMessage, SelectMessage
7+
8+
from django.db import migrations, models
9+
10+
11+
batch_size = 10000
12+
13+
14+
def set_value_and_properties(apps, schema_editor):
15+
Resource = apps.get_model("base", "Resource")
16+
Translation = apps.get_model("base", "Translation")
17+
18+
batch_total = ceil(Translation.objects.count() / batch_size)
19+
batch_count = 0
20+
21+
def print_progress():
22+
nonlocal batch_count
23+
if batch_count % 10 == 0:
24+
print(f".({(batch_count / batch_total):.1%})", end="", flush=True)
25+
else:
26+
print(".", end="", flush=True)
27+
batch_count += 1
28+
29+
pv_trans = []
30+
v_trans = []
31+
format_q = models.Subquery(
32+
Resource.objects.filter(id=models.OuterRef("entity__resource_id")).values(
33+
"format"
34+
)
35+
)
36+
for trans in Translation.objects.annotate(format=format_q).iterator():
37+
string = trans.string
38+
try:
39+
match trans.format:
40+
case "fluent":
41+
fe = fluent_parse_entry(string, with_linepos=False)
42+
msg = fe.value
43+
trans.properties = {
44+
name: message_to_json(msg)
45+
for name, msg in fe.properties.items()
46+
} or None
47+
case "lang" | "properties" | "":
48+
msg = PatternMessage([string])
49+
case "android" | "gettext" | "webext" | "xcode" | "xliff":
50+
msg = parse_message(Format.mf2, string)
51+
case _:
52+
msg = parse_message(Format[trans.format], string)
53+
54+
# MF2 syntax does not retain the catchall name/label
55+
if isinstance(msg, SelectMessage) and trans.format != "fluent":
56+
for keys in msg.variants:
57+
for key in keys:
58+
if isinstance(key, CatchallKey):
59+
key.value = "other"
60+
61+
trans.value = message_to_json(msg)
62+
if trans.properties:
63+
pv_trans.append(trans)
64+
else:
65+
v_trans.append(trans)
66+
except Exception:
67+
if (
68+
trans.approved
69+
and not trans.entity.obsolete
70+
and not trans.entity.resource.project.disabled
71+
):
72+
print(
73+
f"\nUsing fallback value for approved and active {trans.format} translation {trans.pk} "
74+
f"for entity {trans.entity.pk}, locale {trans.locale.code}:\n{trans.string}",
75+
flush=True,
76+
)
77+
trans.value = [trans.string]
78+
v_trans.append(trans)
79+
if len(pv_trans) == batch_size:
80+
Translation.objects.bulk_update(pv_trans, ["value", "properties"])
81+
pv_trans.clear()
82+
print_progress()
83+
if len(v_trans) == batch_size:
84+
Translation.objects.bulk_update(v_trans, ["value"])
85+
v_trans.clear()
86+
print_progress()
87+
if pv_trans:
88+
Translation.objects.bulk_update(pv_trans, ["value", "properties"])
89+
print_progress()
90+
if v_trans:
91+
Translation.objects.bulk_update(v_trans, ["value"])
92+
print_progress()
93+
94+
95+
class Migration(migrations.Migration):
96+
dependencies = [("base", "0110_add_translation_value_and_properties_schema")]
97+
98+
operations = [
99+
migrations.RunPython(
100+
set_value_and_properties, reverse_code=migrations.RunPython.noop
101+
),
102+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.29 on 2026-03-31 10:14
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("base", "0111_add_translation_value_and_properties_data"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="translation",
14+
name="value",
15+
field=models.JSONField(),
16+
),
17+
]

pontoon/base/models/entity.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def combine_entity_filters(entities, filter_choices, filters, *args):
4646
return reduce(ior, filters)
4747

4848

49-
class EntityQuerySet(models.QuerySet):
49+
class EntityQuerySet(models.QuerySet["Entity"]):
5050
def _get_query(self, locale: Locale, project: Project | None, query: Q) -> Q:
5151
from pontoon.base.models.translation import Translation
5252

@@ -253,7 +253,9 @@ def prefetch_entities_data(self, locale: Locale, preferred_source_locale: str):
253253

254254

255255
class Entity(DirtyFieldsMixin, models.Model):
256-
resource = models.ForeignKey(Resource, models.CASCADE, related_name="entities")
256+
resource: models.ForeignKey["Resource"] = models.ForeignKey(
257+
Resource, models.CASCADE, related_name="entities"
258+
)
257259
section = models.ForeignKey(
258260
Section, models.SET_NULL, related_name="entities", null=True, blank=True
259261
)

pontoon/base/models/resource.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
from django.db import models
33
from django.utils import timezone
44

5+
from pontoon.base.models.project import Project
6+
57

68
class Resource(models.Model):
7-
project = models.ForeignKey("Project", models.CASCADE, related_name="resources")
9+
project: models.ForeignKey["Project"] = models.ForeignKey(
10+
"Project", models.CASCADE, related_name="resources"
11+
)
812
path = models.TextField() # Path to localization file
913
meta = ArrayField(ArrayField(models.TextField(), size=2), default=list)
1014
comment = models.TextField(blank=True)

pontoon/base/models/translation.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pontoon.checks.utils import save_failed_checks
2020

2121

22-
class TranslationQuerySet(models.QuerySet):
22+
class TranslationQuerySet(models.QuerySet["Translation"]):
2323
def aggregate_stats(self) -> dict[str, int]:
2424
"""
2525
Aggregate translation stats for this queryset.
@@ -154,6 +154,8 @@ class Translation(DirtyFieldsMixin, models.Model):
154154
locale = models.ForeignKey(Locale, models.CASCADE)
155155
user = models.ForeignKey(User, models.SET_NULL, null=True, blank=True)
156156
string = models.TextField()
157+
value = models.JSONField()
158+
properties = models.JSONField(null=True, blank=True)
157159
date = models.DateTimeField(default=timezone.now)
158160

159161
# Active translations are displayed in the string list and as the first

pontoon/base/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ class TranslationFactory(DjangoModelFactory):
135135
entity = SubFactory(EntityFactory)
136136
locale = SubFactory(LocaleFactory)
137137
string = Sequence(lambda n: f"translation {n}")
138+
value = Sequence(lambda n: [f"translation {n}"])
138139
user = SubFactory(UserFactory)
139140

140141
class Meta:

pontoon/batch/utils.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
from django.utils import timezone
1010

11-
from pontoon.base.models import Entity, Resource
11+
from pontoon.base.models import Entity, Resource, User
12+
from pontoon.base.models.translation import TranslationQuerySet
1213
from pontoon.checks import DB_FORMATS
1314
from pontoon.checks.libraries import run_checks
15+
from pontoon.translations.utils import parse_db_string_to_json
1416

1517

1618
parser = FluentParser()
@@ -64,7 +66,9 @@ def visit_TextElement(self, node):
6466
return serializer.serialize_entry(new_ast)
6567

6668

67-
def find_and_replace(translations, find, replace, user):
69+
def find_and_replace(
70+
translations: TranslationQuerySet, find: str, replace: str, user: User
71+
):
6872
"""Replace text in a set of translation.
6973
7074
:arg QuerySet translations: a list of Translation objects in which to search
@@ -97,7 +101,8 @@ def find_and_replace(translations, find, replace, user):
97101
# Cache the old value to identify changed translations
98102
new_translation = deepcopy(translation)
99103

100-
if translation.entity.resource.format == Resource.Format.FLUENT:
104+
res_format = translation.entity.resource.format
105+
if res_format == Resource.Format.FLUENT:
101106
new_translation.string = ftl_find_and_replace(
102107
translation.string, find, replace
103108
)
@@ -119,15 +124,21 @@ def find_and_replace(translations, find, replace, user):
119124
new_translation.pretranslated = False
120125
new_translation.fuzzy = False
121126

122-
if new_translation.entity.resource.format in DB_FORMATS:
127+
errors = False
128+
try:
129+
new_translation.value, new_translation.properties = parse_db_string_to_json(
130+
res_format, new_translation.string
131+
)
132+
except ValueError:
133+
errors = True
134+
135+
if not errors and res_format in DB_FORMATS:
123136
errors = run_checks(
124137
new_translation.entity,
125138
new_translation.locale.code,
126139
new_translation.string,
127140
use_tt_checks=False,
128141
)
129-
else:
130-
errors = {}
131142

132143
if errors:
133144
translations_with_errors.append(translation.pk)

pontoon/pretranslation/tasks.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from pontoon.base.tasks import PontoonTask
2222
from pontoon.checks.libraries import run_checks
2323
from pontoon.checks.utils import bulk_run_checks
24+
from pontoon.translations.utils import parse_db_string_to_json
2425

2526
from . import AUTHORS
2627
from .pretranslate import get_pretranslation
@@ -135,11 +136,16 @@ def pretranslate(project: Project, paths: set[str] | None):
135136
log.info(f"Pretranslation error: {e}")
136137
continue
137138

139+
string, author_key = pretranslation
140+
value, properties = parse_db_string_to_json(entity.resource.format, string)
141+
138142
t = Translation(
139143
entity=entity,
140144
locale=locale,
141-
string=pretranslation[0],
142-
user=pt_authors[pretranslation[1]],
145+
string=string,
146+
value=value,
147+
properties=properties,
148+
user=pt_authors[author_key],
143149
approved=False,
144150
pretranslated=True,
145151
active=True,

0 commit comments

Comments
 (0)