Skip to content
Open
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
2 changes: 2 additions & 0 deletions documentation/docs/localizer/translate.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ Note that `APPROVE ALL` accepts the latest suggestion, but doesn’t reject othe

In the `FIND & REPLACE IN TRANSLATIONS` section, the user can input the text to search for, and the text to replace it with. This is a basic find and replace feature that will work only on the selected strings.

In the COPY FROM ANOTHER LOCALE section, the user can select a locale and click COPY AS SUGGESTIONS. Approved translations from the selected locale will be added as suggestions in the current locale for all selected strings.

## Downloading and uploading translations

Pontoon provides the ability to download and upload translations, including [terminology](glossary.md#terminology) and [translation memories](glossary.md#translation-memory). To access these features, click on the profile icon in the top-right corner of any page. Note that the user must be in the translation workspace for the download/upload options to be displayed in the dropdown menu.
Expand Down
125 changes: 125 additions & 0 deletions pontoon/batch/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from pontoon.actionlog.models import ActionLog
from pontoon.base.badge_utils import badges_review_level, badges_translation_level
from pontoon.base.models import (
Entity,
Translation,
TranslationMemoryEntry,
)
from pontoon.batch import utils
from pontoon.messaging.notifications import send_badge_notification
from pontoon.translations.utils import parse_db_string_to_json


def batch_action_template(form, user, translations, locale):
Expand Down Expand Up @@ -277,6 +279,128 @@ def replace_translations(form, user, translations, locale):
}


def copy_translation_from_locale(form, user, translations, locale):
"""
Copy translations approved in a source locale and add them as suggestions
in the target locale. Existing active translations in the target locale
are deactivated before the new suggestions are created.
"""

other_locale = form.cleaned_data["other_locale"]

other_locale_translations = list(
Translation.objects.filter(
locale__code=other_locale,
entity__pk__in=form.cleaned_data["entities"],
approved=True,
)
)

before_level = badges_translation_level(user)

already_active_entity_pks = set(
Translation.objects.filter(
locale=locale,
entity__pk__in=form.cleaned_data["entities"],
active=True,
).values_list("entity__pk", flat=True)
)

# Translations to create in the current locale
translations_to_create = []

if not other_locale:
# Copy from other locale (entity.string directly)
entities = Entity.objects.filter(pk__in=form.cleaned_data["entities"])
for entity in entities:
value, properties = parse_db_string_to_json(
entity.resource.format, entity.string
)
translations_to_create.append(
Translation(
locale=locale,
entity=entity,
string=entity.string,
approved=False,
rejected=False,
fuzzy=False,
active=entity.pk not in already_active_entity_pks,
user=user,
value=value,
properties=properties,
)
)
else:
for t in other_locale_translations:
value, properties = parse_db_string_to_json(
t.entity.resource.format, t.string
)
translations_to_create.append(
Translation(
locale=locale,
entity=t.entity,
string=t.string,
approved=False,
rejected=False,
fuzzy=False,
active=t.entity.pk not in already_active_entity_pks,
user=user,
value=value,
properties=properties,
)
)

# Create new translations
changed_translations = Translation.objects.bulk_create(
translations_to_create,
)

# Requery translation to get PKs
changed_translations_qs = Translation.objects.filter(
pk__in=[t.pk for t in changed_translations]
)

count, translated_resources, changed_entities = utils.get_translations_info(
changed_translations_qs,
locale,
)

# Log creating actions
actions_to_log = [
ActionLog(
action_type=ActionLog.ActionType.TRANSLATION_CREATED,
performed_by=user,
translation=t,
)
for t in changed_translations
]
ActionLog.objects.bulk_create(actions_to_log)

# Send Translation Champion Badge notification information
after_level = badges_translation_level(user)
badge_update = {}
if after_level > before_level:
badge_update["level"] = after_level
badge_update["name"] = "Translation Champion"
send_badge_notification(user, badge_update["name"], badge_update["level"])

changed_translation_pks = [t.pk for t in changed_translations]

latest_translation_pk = None
if changed_translation_pks:
latest_translation_pk = max(changed_translation_pks)

return {
"count": count,
"translated_resources": translated_resources,
"changed_entities": changed_entities,
"latest_translation_pk": latest_translation_pk,
"changed_translation_pks": changed_translation_pks,
"invalid_translation_pks": [],
"badge_update": badge_update,
}


"""A map of action names to functions.

The keys define the available batch actions in the `batch_edit_translations`
Expand All @@ -288,4 +412,5 @@ def replace_translations(form, user, translations, locale):
"approve": approve_translations,
"reject": reject_translations,
"replace": replace_translations,
"copy_from_locale": copy_translation_from_locale,
}
4 changes: 4 additions & 0 deletions pontoon/batch/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class BatchActionsForm(forms.Form):
entities = forms.CharField(required=False)
find = forms.CharField(required=False)
replace = forms.CharField(required=False)
other_locale = forms.CharField(required=False)

def clean_entities(self):
return utils.split_ints(self.cleaned_data["entities"])
Expand All @@ -32,3 +33,6 @@ def clean_find(self):

def clean_replace(self):
return self.decode_field("replace")

def clean_other_locale(self):
return self.decode_field("other_locale")
123 changes: 121 additions & 2 deletions pontoon/batch/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from unittest.mock import MagicMock

import pytest

from fluent.syntax import FluentParser, FluentSerializer

from pontoon.base.models import Translation
from pontoon.batch.utils import find_and_replace, ftl_find_and_replace
from pontoon.base.models import Locale, Translation
from pontoon.batch.actions import copy_translation_from_locale
from pontoon.batch.utils import (
find_and_replace,
ftl_find_and_replace,
)
from pontoon.test.factories import (
EntityFactory,
ProjectFactory,
ResourceFactory,
TranslationFactory,
UserFactory,
)


Expand Down Expand Up @@ -68,3 +75,115 @@ def test_ftl_find_and_replace_non_text_value(locale_a, user_a):
assert len(translations) == 0
assert translations_to_create == []
assert translations_with_errors == []


@pytest.mark.django_db
def test_copy_from_similar_locale():
"""
Translations that are approved in a similar locale can be copied to the current locale as suggestions.
"""

project = ProjectFactory(slug="project", name="Project")
resource = ResourceFactory(project=project, path="resource.ftl", format="fluent")
entity = EntityFactory(resource=resource, string="key = value")
target_locale = Locale.objects.get(code="en-ZA")
user = UserFactory()

source_locale = Locale.objects.get(code="en-GB")

TranslationFactory(
entity=entity, locale=source_locale, string="key = value", approved=True
)

# Mock form and call the function
form = MagicMock()
form.cleaned_data = {
"other_locale": "en-GB",
"entities": [entity.pk],
}

copy_translation_from_locale(form, user, Translation.objects.none(), target_locale)
result = Translation.objects.filter(locale=target_locale, entity=entity)

# Assert a suggestion was created in the target locale
assert Translation.objects.filter(
locale=target_locale,
entity=entity,
approved=False,
).exists()
assert result.count() == 1
assert not result.first().approved
assert result.first().string == "key = value"


@pytest.mark.django_db
def test_copy_from_similar_locale_copies_all_strings():
"""
Translations are copied for all selected strings, including those
that already have an active translation in the target locale.
"""
project = ProjectFactory(slug="project3", name="Project3")
resource = ResourceFactory(project=project, path="resource.ftl", format="fluent")
entity1 = EntityFactory(resource=resource, string="key1 = value1")
entity2 = EntityFactory(resource=resource, string="key2 = value2")
target_locale = Locale.objects.get(code="en-ZA")
source_locale = Locale.objects.get(code="en-GB")
user = UserFactory()

TranslationFactory(
entity=entity1,
locale=target_locale,
string="old value",
approved=True,
active=True,
)

# Both entities have approved translations in the source locale
TranslationFactory(
entity=entity1, locale=source_locale, string="key1 = value1", approved=True
)
TranslationFactory(
entity=entity2, locale=source_locale, string="key2 = value2", approved=True
)

form = MagicMock()
form.cleaned_data = {
"other_locale": "en-GB",
"entities": [entity1.pk, entity2.pk],
}

copy_translation_from_locale(form, user, Translation.objects.none(), target_locale)

# entity1 already has an active translation - new suggestion should be active=False
assert (
Translation.objects.filter(
locale=target_locale,
entity=entity1,
string="key1 = value1",
approved=False,
active=False,
).count()
== 1
)
# entity2 has no existing translation - new suggestion should be active=True
assert (
Translation.objects.filter(
locale=target_locale,
entity=entity2,
approved=False,
active=True,
).count()
== 1
)

# The old approved translation for entity1 should remain unchanged
assert (
Translation.objects.filter(
locale=target_locale,
string="old value",
entity=entity1,
approved=True,
active=True,
).count()
== 1
)
15 changes: 15 additions & 0 deletions translate/public/locale/en-US/translate.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ batchactions-BatchActions--replace-with =
.placeholder = Replace with


batchactions-BatchActions--copy-from-locale-heading = COPY FROM ANOTHER LOCALE

## RejectAll
## Renders Reject All batch action button.

Expand Down Expand Up @@ -73,6 +75,19 @@ batchactions-ReplaceAll--invalid = { $invalidCount } FAILED
batchactions-ReplaceAll--error = OOPS, SOMETHING WENT WRONG


## CopyFromLocale
## Renders Copy From a Similar Locale button.
batchactions-CopyFromLocale--default = COPY AS SUGGESTIONS
batchactions-CopyFromLocale--success =
{
$changedCount ->
[one] {$changedCount} STRING COPIED
*[other] {$changedCount} STRINGS COPIED
}

batchactions-CopyFromLocale--invalid = {$invalidCount} FAILED
batchactions-CopyFromLocale--error = OOPS, SOMETHING WENT WRONG

## ResourceProgress
## Show a panel with progress chart and stats for the current resource.

Expand Down
5 changes: 4 additions & 1 deletion translate/src/api/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ type BatchEditResponse =
| { error: true };

export async function batchEditEntities(
action: 'approve' | 'reject' | 'replace',
action: 'approve' | 'reject' | 'replace' | 'copy_from_locale',
locale: string,
entityIds: number[],
find: string | undefined,
replace: string | undefined,
otherLocale?: string,
): Promise<BatchEditResponse> {
const csrfToken = getCSRFToken();
const payload = new FormData();
Expand All @@ -73,6 +74,8 @@ export async function batchEditEntities(
payload.append('replace', replace);
}

payload.append('other_locale', otherLocale ?? '');

return await POST('/batch-edit-translations/', payload);
}

Expand Down
15 changes: 15 additions & 0 deletions translate/src/api/other-locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export type OtherLocaleTranslation = {
readonly is_preferred: boolean | null | undefined;
};

export type LocaleOption = {
code: string;
name: string;
};

export async function fetchOtherLocales(
entity: number,
locale: string,
Expand All @@ -23,3 +28,13 @@ export async function fetchOtherLocales(
const results = await GET('/other-locales/', search, { singleton: true });
return Array.isArray(results) ? results : [];
}

export async function fetchAllLocales(): Promise<LocaleOption[]> {
const search = new URLSearchParams({
fields: 'code,name',
page_size: '200',
ordering: 'name',
});
const result = await GET('/api/v2/locales/', search);
return Array.isArray(result?.results) ? result.results : [];
}
Loading
Loading