From 4e025d1d58e453e25fa144bc4a99af5884e9d12d Mon Sep 17 00:00:00 2001 From: Serah Nderi Date: Mon, 11 May 2026 19:08:20 +0300 Subject: [PATCH 01/13] Add Copy from a Similar Locale batch action Enables translators working on closely related locales (eg en-GB and en-ZA) to reuse approved translations from one locale as a starting point in another as suggestions. Fixes #2255 --- pontoon/batch/actions.py | 80 +++++++++++++++++++ pontoon/batch/forms.py | 4 + pontoon/batch/tests/test_utils.py | 50 +++++++++++- translate/public/locale/en-US/translate.ftl | 18 +++++ translate/src/api/entity.ts | 6 +- translate/src/modules/batchactions/actions.ts | 4 +- .../batchactions/components/BatchActions.tsx | 53 ++++++++++++ .../components/CopyFromLocale.tsx | 70 ++++++++++++++++ 8 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 translate/src/modules/batchactions/components/CopyFromLocale.tsx diff --git a/pontoon/batch/actions.py b/pontoon/batch/actions.py index dec6672b25..5ea99e0f12 100644 --- a/pontoon/batch/actions.py +++ b/pontoon/batch/actions.py @@ -8,6 +8,7 @@ ) 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): @@ -277,6 +278,84 @@ def replace_translations(form, user, translations, locale): } +def copy_translation_from_locale(form, user, translations, locale): + """ + Copy translations that are approved in a simlar locale eg [en-GB] and not yet translated in another locale eg [en-ZA] + and have them as suggestions. This is useful for translators to easily copy translations + from a similar locale and then approve them in the new locale. + """ + + otherLocale = form.cleaned_data["other_locale"] + + # Get translations that are approved in the source locale and not yet translated in the current locale and add those as + # suggestions in the current locale + other_locale_translations = Translation.objects.filter( + locale__code=otherLocale, + entity__pk__in=form.cleaned_data["entities"], + approved=True, + ).exclude( + entity__translation__locale=locale, + entity__translation__approved=True, + ) + + # translations to create in the current locale + translations_to_create = [] + 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, + 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) + + changed_translation_pks = [t.pk for t in changed_translations] + return { + "count": count, + "translated_resources": translated_resources, + "changed_entities": changed_entities, + "latest_translation_pk": max(changed_translation_pks) + if changed_translation_pks + else None, + "changed_translation_pks": changed_translation_pks, + "invalid_translation_pks": [], + "badge_update": {}, + } + + """A map of action names to functions. The keys define the available batch actions in the `batch_edit_translations` @@ -288,4 +367,5 @@ def replace_translations(form, user, translations, locale): "approve": approve_translations, "reject": reject_translations, "replace": replace_translations, + "copy_from_locale": copy_translation_from_locale, } diff --git a/pontoon/batch/forms.py b/pontoon/batch/forms.py index e80cd4df3d..42e058817d 100644 --- a/pontoon/batch/forms.py +++ b/pontoon/batch/forms.py @@ -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"]) @@ -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") diff --git a/pontoon/batch/tests/test_utils.py b/pontoon/batch/tests/test_utils.py index 7addc74d95..e5f7d469d8 100644 --- a/pontoon/batch/tests/test_utils.py +++ b/pontoon/batch/tests/test_utils.py @@ -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, ) @@ -68,3 +75,42 @@ 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" diff --git a/translate/public/locale/en-US/translate.ftl b/translate/public/locale/en-US/translate.ftl index 54580f3d8c..f074ac3239 100644 --- a/translate/public/locale/en-US/translate.ftl +++ b/translate/public/locale/en-US/translate.ftl @@ -46,6 +46,11 @@ batchactions-BatchActions--replace-with = .placeholder = Replace with +batchactions-BatchActions--copy-from-locale-heading = COPY FROM A SIMILAR LOCALE +batchactions-BatchActions--copy-from-locale = + .placeholder = Source locale, e.g. en-US + + ## RejectAll ## Renders Reject All batch action button. @@ -73,6 +78,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 FROM A SIMILAR LOCALE +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. diff --git a/translate/src/api/entity.ts b/translate/src/api/entity.ts index 026ff37257..b4cf1712d7 100644 --- a/translate/src/api/entity.ts +++ b/translate/src/api/entity.ts @@ -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 { const csrfToken = getCSRFToken(); const payload = new FormData(); @@ -72,6 +73,9 @@ export async function batchEditEntities( if (replace) { payload.append('replace', replace); } + if (otherLocale) { + payload.append('other_locale', otherLocale); + } return await POST('/batch-edit-translations/', payload); } diff --git a/translate/src/modules/batchactions/actions.ts b/translate/src/modules/batchactions/actions.ts index 5e27024403..401ec863d5 100644 --- a/translate/src/modules/batchactions/actions.ts +++ b/translate/src/modules/batchactions/actions.ts @@ -117,7 +117,7 @@ const updateUI = export const performAction = ( location: Location, - action: 'approve' | 'reject' | 'replace', + action: 'approve' | 'reject' | 'replace' | 'copy_from_locale', entityIds: number[], showBadgeTooltip: (tooltip: { badgeName: string | null; @@ -125,6 +125,7 @@ export const performAction = }) => void, find?: string, replace?: string, + otherLocale?: string, ) => async (dispatch: AppDispatch) => { dispatch({ type: REQUEST_BATCHACTIONS, source: action }); @@ -135,6 +136,7 @@ export const performAction = entityIds, find, replace, + otherLocale, ); const response: ResponseType = { diff --git a/translate/src/modules/batchactions/components/BatchActions.tsx b/translate/src/modules/batchactions/components/BatchActions.tsx index 86bf12e292..86738dbea1 100644 --- a/translate/src/modules/batchactions/components/BatchActions.tsx +++ b/translate/src/modules/batchactions/components/BatchActions.tsx @@ -12,6 +12,7 @@ import { ApproveAll } from './ApproveAll'; import './BatchActions.css'; import { RejectAll } from './RejectAll'; import { ReplaceAll } from './ReplaceAll'; +import { CopyFromLocale } from './CopyFromLocale'; /** * Renders batch editor, used for performing mass actions on translations. @@ -24,6 +25,7 @@ export function BatchActions(): React.ReactElement<'div'> { const find = useRef(null); const replace = useRef(null); + const otherLocale = useRef(null); const quitBatchActions = useCallback(() => dispatch(resetSelection()), []); @@ -92,6 +94,28 @@ export function BatchActions(): React.ReactElement<'div'> { } }, [location, batchactions]); + const copyFromLocale = useCallback(() => { + if (otherLocale.current && !batchactions.requestInProgress) { + const olv = otherLocale.current.value; + + if (olv === '') { + otherLocale.current.focus(); + } else { + dispatch( + performAction( + location, + 'copy_from_locale', + batchactions.entities, + showBadgeTooltip, + undefined, + undefined, + olv, + ), + ); + } + } + }, [location, batchactions, showBadgeTooltip]); + const submitReplaceForm = useCallback( (ev: React.SyntheticEvent) => { ev.preventDefault(); @@ -100,6 +124,14 @@ export function BatchActions(): React.ReactElement<'div'> { [replaceAll], ); + const submitCopyFromLocaleForm = useCallback( + (ev: React.SyntheticEvent) => { + ev.preventDefault(); + copyFromLocale(); + }, + [copyFromLocale], + ); + return (
@@ -197,6 +229,27 @@ export function BatchActions(): React.ReactElement<'div'> {
+
+ +

COPY FROM OTHER LOCALE

+
+
+ + + + + +
); diff --git a/translate/src/modules/batchactions/components/CopyFromLocale.tsx b/translate/src/modules/batchactions/components/CopyFromLocale.tsx new file mode 100644 index 0000000000..f60cd80db5 --- /dev/null +++ b/translate/src/modules/batchactions/components/CopyFromLocale.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import type { BatchActionsState } from '../reducer'; +import { Localized } from '@fluent/react'; +import type { ResponseType } from '../actions'; + +type Props = { + copyFromLocale: () => void; + batchactions: BatchActionsState; +}; + +/** + * Renders Copy From a Similar Locale batch action button + */ +export function CopyFromLocale({ + copyFromLocale, + batchactions: { response, requestInProgress }, +}: Props): React.ReactElement<'button'> { + return ( +