From 6cc6f12044d130f471488abc41ec92501275e0df Mon Sep 17 00:00:00 2001 From: Serah Nderi Date: Thu, 27 Aug 2026 20:15:02 +0300 Subject: [PATCH 1/5] Fixes #2790 Batch actions: Add the ability to pretranslate strings This is a draft pr because I want to context switch Allows contributors to manually batch pretranslate strings for locales supported by translation engines. --- pontoon/batch/actions.py | 98 +++++++++++++++++++ pontoon/batch/forms.py | 5 + pontoon/batch/tests/test_utils.py | 77 ++++++++++++++- pontoon/batch/views.py | 3 + translate/public/locale/en-US/translate.ftl | 13 +++ translate/src/api/entity.ts | 10 +- translate/src/modules/batchactions/actions.ts | 9 +- .../batchactions/components/BatchActions.tsx | 47 +++++++++ .../batchactions/components/Pretranslate.tsx | 69 +++++++++++++ .../modules/editor/components/EditorMenu.tsx | 16 ++- 10 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 translate/src/modules/batchactions/components/Pretranslate.tsx diff --git a/pontoon/batch/actions.py b/pontoon/batch/actions.py index 69427959ec..85bd0da2f8 100644 --- a/pontoon/batch/actions.py +++ b/pontoon/batch/actions.py @@ -12,6 +12,7 @@ from pontoon.base.models.translation import TranslationQuerySet from pontoon.batch import utils from pontoon.messaging.notifications import send_badge_notification +from pontoon.pretranslation.pretranslate import get_pretranslation from pontoon.translations.utils import parse_source_string_to_json @@ -403,6 +404,102 @@ def copy_translation_from_locale( } +def pretranslate_translations(user, locale: Locale, entities: QuerySet[Entity]): + """ + Enable teams to pretranslate an entire project or a subset of strings on projects + for which pretranslation is not enabled by the admins. + """ + + entity_pks_with_translation = set( + Translation.objects.filter( + locale=locale, + entity__in=entities, + approved=True, + ).values_list("entity__pk", flat=True) + ) + + already_active_entity_pks = set( + Translation.objects.filter( + locale=locale, + entity__in=entities, + active=True, + ).values_list("entity__pk", flat=True) + ) + + eligible_entities = entities.exclude(pk__in=entity_pks_with_translation) + + before_level = badges_translation_level(user) + + translations_to_create = [] + + for entity in eligible_entities: + pretranslation = get_pretranslation(entity, locale) + + string, _engine = pretranslation + _, value, properties = parse_source_string_to_json( + entity.resource.format, string + ) + + translations_to_create.append( + Translation( + locale=locale, + entity=entity, + string=string, + approved=False, + rejected=False, + fuzzy=False, + pretranslated=True, + active=entity.pk not in already_active_entity_pks, + user=user, + value=value, + properties=properties, + ) + ) + + changed_translations = Translation.objects.bulk_create(translations_to_create) + 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 + ) + + 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) + + 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] + + 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": badge_update, + } + + """A map of action names to functions. The keys define the available batch actions in the `batch_edit_translations` @@ -415,4 +512,5 @@ def copy_translation_from_locale( "reject": reject_translations, "replace": replace_translations, "copy_from_locale": copy_translation_from_locale, + "pretranslate": pretranslate_translations, } diff --git a/pontoon/batch/forms.py b/pontoon/batch/forms.py index 8faaebefb0..5a0caeb19b 100644 --- a/pontoon/batch/forms.py +++ b/pontoon/batch/forms.py @@ -15,12 +15,14 @@ class BatchActionsForm(forms.Form): ("reject", "reject"), ("replace", "replace"), ("copy_from_locale", "copy_from_locale"), + ("pretranslate", "pretranslate"), ] ) entities = forms.CharField(required=False) find = forms.CharField(required=False) replace = forms.CharField(required=False) other_locale = forms.CharField(required=False) + pretranslate = forms.CharField(required=False) def clean_entities(self): return utils.split_ints(self.cleaned_data["entities"]) @@ -42,3 +44,6 @@ def clean_replace(self): def clean_other_locale(self): return self.decode_field("other_locale") + + def clean_pretranslate(self): + return self.decode_field("pretranslate") diff --git a/pontoon/batch/tests/test_utils.py b/pontoon/batch/tests/test_utils.py index 70e9cd05f0..9670034f74 100644 --- a/pontoon/batch/tests/test_utils.py +++ b/pontoon/batch/tests/test_utils.py @@ -1,9 +1,14 @@ +from unittest.mock import patch + import pytest from fluent.syntax import FluentParser, FluentSerializer -from pontoon.base.models import Locale, Translation -from pontoon.batch.actions import copy_translation_from_locale +from pontoon.base.models import Entity, Locale, Translation +from pontoon.batch.actions import ( + copy_translation_from_locale, + pretranslate_translations, +) from pontoon.batch.utils import ( find_and_replace, ftl_find_and_replace, @@ -172,3 +177,71 @@ def test_copy_from_another_locale_copies_all_strings(): ).count() == 1 ) + + +@pytest.mark.django_db +@patch("pontoon.batch.actions.get_pretranslation") +def test_pretranslate_translations_skips_approved_includes_suggestions( + get_pretranslation_mock, +): + """ + Pretranslation is skipped for entities with an approved translation, + but generated for entities with only a pending suggestion or no + translation at all. + """ + + get_pretranslation_mock.return_value = ("key = pretranslated value", "gt") + + project = ProjectFactory(slug="project4", name="Project4") + resource = ResourceFactory(project=project, path="resource.ftl", format="fluent") + entity_approved = EntityFactory(resource=resource, string="key1 = value1") + entity_suggestion = EntityFactory(resource=resource, string="key2 = value2") + entity_untranslated = EntityFactory(resource=resource, string="key3 = value3") + locale = Locale.objects.get(code="en-ZA") + user = UserFactory() + + TranslationFactory( + entity=entity_approved, + locale=locale, + string="already approved", + approved=True, + active=True, + ) + + TranslationFactory( + entity=entity_suggestion, + locale=locale, + string="pending suggestion", + approved=False, + active=True, + ) + + entities = Entity.objects.filter( + pk__in=[entity_approved.pk, entity_suggestion.pk, entity_untranslated.pk] + ) + + result = pretranslate_translations(user, locale, entities) + + # entity_approved: skipped entirely, no new translation created + assert not Translation.objects.filter( + entity=entity_approved, string="key = pretranslated value" + ).exists() + + # entity_suggestion: pretranslated even though a suggestion already exists; + # existing suggestion is still active, so the new one is not + assert Translation.objects.filter( + entity=entity_suggestion, + string="key = pretranslated value", + pretranslated=True, + active=False, + ).exists() + + # entity_untranslated: pretranslated and active, since nothing existed before + assert Translation.objects.filter( + entity=entity_untranslated, + string="key = pretranslated value", + pretranslated=True, + active=True, + ).exists() + + assert result["count"] == 2 diff --git a/pontoon/batch/views.py b/pontoon/batch/views.py index 98a6d84fb4..ff063a3b39 100644 --- a/pontoon/batch/views.py +++ b/pontoon/batch/views.py @@ -23,6 +23,7 @@ from pontoon.batch.actions import ( approve_translations, copy_translation_from_locale, + pretranslate_translations, reject_translations, replace_translations, ) @@ -110,6 +111,8 @@ def batch_edit_translations(request): action_status = copy_translation_from_locale( user, locale, entities, form.cleaned_data["other_locale"] ) + case "pretranslate": + action_status = pretranslate_translations(user, locale, entities) case "reject": action_status = reject_translations(user, locale, entities) case "replace": diff --git a/translate/public/locale/en-US/translate.ftl b/translate/public/locale/en-US/translate.ftl index 0b1f8d6963..719034cbcd 100644 --- a/translate/public/locale/en-US/translate.ftl +++ b/translate/public/locale/en-US/translate.ftl @@ -47,6 +47,8 @@ batchactions-BatchActions--replace-with = batchactions-BatchActions--copy-from-locale-heading = COPY FROM ANOTHER LOCALE +batchactions-BatchActions--pretranslate-heading = PRETRANSLATE + ## RejectAll ## Renders Reject All batch action button. @@ -88,6 +90,16 @@ batchactions-CopyFromLocale--success = batchactions-CopyFromLocale--invalid = {$invalidCount} FAILED batchactions-CopyFromLocale--error = OOPS, SOMETHING WENT WRONG +## Pretranslate +batchactions-Pretranslate--default = PRETRANSLATE +batchactions-Pretranslate--error = OOPS, SOMETHING WENT WRONG +batchactions-Pretranslate--success = + { $changedCount -> + [one] { $changedCount } STRING PRETRANSLATED + *[other] { $changedCount } STRINGS PRETRANSLATED + } +batchactions-Pretranslate--invalidCount = { $invalidCount } FAILED + ## ResourceProgress ## Show a panel with progress chart and stats for the current resource. @@ -125,6 +137,7 @@ comments-Comment--cancel-button = CANCEL editor-EditorMenu--sign-in-to-translate =
Sign in
to translate. editor-EditorMenu--read-only-localization = This is a read-only localization. +editor-EditorMenu--pretranslation-in-progress = Pretranslation in progress. editor-EditorMenu--button-copy = COPY .title = Copy From Source (Ctrl + Shift + C) editor-EditorMenu--button-clear = CLEAR diff --git a/translate/src/api/entity.ts b/translate/src/api/entity.ts index 6261f47874..70eb5fecde 100644 --- a/translate/src/api/entity.ts +++ b/translate/src/api/entity.ts @@ -54,12 +54,18 @@ type BatchEditResponse = | { error: true }; export async function batchEditEntities( - action: 'approve' | 'reject' | 'replace' | 'copy_from_locale', + action: + | 'approve' + | 'reject' + | 'replace' + | 'copy_from_locale' + | 'pretranslate', locale: string, entityIds: number[], find: string | undefined, replace: string | undefined, otherLocale?: string, + pretranslate?: string, ): Promise { const csrfToken = getCSRFToken(); const payload = new FormData(); @@ -76,6 +82,8 @@ export async function batchEditEntities( payload.append('other_locale', otherLocale ?? ''); + payload.append('pretranslate', pretranslate ?? ''); + return await POST('/batch-edit-translations/', payload); } diff --git a/translate/src/modules/batchactions/actions.ts b/translate/src/modules/batchactions/actions.ts index 401ec863d5..0c13d1ff73 100644 --- a/translate/src/modules/batchactions/actions.ts +++ b/translate/src/modules/batchactions/actions.ts @@ -117,7 +117,12 @@ const updateUI = export const performAction = ( location: Location, - action: 'approve' | 'reject' | 'replace' | 'copy_from_locale', + action: + | 'approve' + | 'reject' + | 'replace' + | 'copy_from_locale' + | 'pretranslate', entityIds: number[], showBadgeTooltip: (tooltip: { badgeName: string | null; @@ -126,6 +131,7 @@ export const performAction = find?: string, replace?: string, otherLocale?: string, + pretranslate?: string, ) => async (dispatch: AppDispatch) => { dispatch({ type: REQUEST_BATCHACTIONS, source: action }); @@ -137,6 +143,7 @@ export const performAction = find, replace, otherLocale, + pretranslate, ); const response: ResponseType = { diff --git a/translate/src/modules/batchactions/components/BatchActions.tsx b/translate/src/modules/batchactions/components/BatchActions.tsx index dc1cd1d2f9..dea4b04c3c 100644 --- a/translate/src/modules/batchactions/components/BatchActions.tsx +++ b/translate/src/modules/batchactions/components/BatchActions.tsx @@ -5,6 +5,7 @@ import React, { useEffect, useRef, useState, + useMemo, } from 'react'; import { Location } from '~/context/Location'; @@ -22,6 +23,8 @@ import { CopyFromLocale } from './CopyFromLocale'; import LocaleMenu from '~/modules/locale/components/LocaleMenu'; import { useProject } from '~/modules/project'; import { fetchAllLocales, LocaleOption } from '~/api/other-locales'; +import { Locale } from '~/context/Locale'; +import { Pretranslate } from './Pretranslate'; /** * Renders batch editor, used for performing mass actions on translations. @@ -58,6 +61,7 @@ export function BatchActions(): React.ReactElement<'div'> { }, [slug, projectLocales]); const quitBatchActions = useCallback(() => dispatch(resetSelection()), []); + const locale = useContext(Locale); useEffect(() => { const handleShortcuts = (ev: KeyboardEvent) => { @@ -124,6 +128,28 @@ export function BatchActions(): React.ReactElement<'div'> { } }, [location, batchactions]); + const pretranslate = useCallback(() => { + if (!batchactions.requestInProgress) { + dispatch( + performAction( + location, + 'pretranslate', + batchactions.entities, + showBadgeTooltip, + undefined, + undefined, + ), + ); + } + }, [location, batchactions, showBadgeTooltip]); + + const canPretranslate = useMemo(() => { + const root = document.getElementById('root'); + const isGoogleTranslateSupported = + root?.dataset.isGoogleTranslateSupported === 'true'; + return isGoogleTranslateSupported && !!locale.googleTranslateCode; + }, [locale.googleTranslateCode]); + const copyFromLocale = useCallback(() => { if (!batchactions.requestInProgress) { dispatch( @@ -156,6 +182,14 @@ export function BatchActions(): React.ReactElement<'div'> { [copyFromLocale], ); + const submitPretranslateForm = useCallback( + (ev: React.SyntheticEvent) => { + ev.preventDefault(); + pretranslate(); + }, + [pretranslate], + ); + return (
@@ -270,6 +304,19 @@ export function BatchActions(): React.ReactElement<'div'> { />
+ {canPretranslate && ( +
+ +

PRETRANSLATE

+
+
+ + +
+ )}
); diff --git a/translate/src/modules/batchactions/components/Pretranslate.tsx b/translate/src/modules/batchactions/components/Pretranslate.tsx new file mode 100644 index 0000000000..1c8dcb7fde --- /dev/null +++ b/translate/src/modules/batchactions/components/Pretranslate.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { BatchActionsState } from '../reducer'; +import { Localized } from '@fluent/react'; +import type { ResponseType } from '../actions'; + +type Props = { + pretranslate: () => void; + batchactions: BatchActionsState; +}; +export function Pretranslate({ + pretranslate, + batchactions: { response, requestInProgress }, +}: Props): React.ReactElement<'button'> { + return ( +