diff --git a/documentation/docs/localizer/translate.md b/documentation/docs/localizer/translate.md index efbe6994b0..44ce4e37b3 100644 --- a/documentation/docs/localizer/translate.md +++ b/documentation/docs/localizer/translate.md @@ -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. diff --git a/pontoon/batch/actions.py b/pontoon/batch/actions.py index dec6672b25..4bb4a7f456 100644 --- a/pontoon/batch/actions.py +++ b/pontoon/batch/actions.py @@ -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): @@ -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` @@ -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, } 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..5d0d201a47 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,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 + ) diff --git a/translate/public/locale/en-US/translate.ftl b/translate/public/locale/en-US/translate.ftl index 54580f3d8c..9b1be72f31 100644 --- a/translate/public/locale/en-US/translate.ftl +++ b/translate/public/locale/en-US/translate.ftl @@ -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. @@ -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. diff --git a/translate/src/api/entity.ts b/translate/src/api/entity.ts index 026ff37257..c83f561601 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(); @@ -73,6 +74,8 @@ export async function batchEditEntities( payload.append('replace', replace); } + payload.append('other_locale', otherLocale ?? ''); + return await POST('/batch-edit-translations/', payload); } diff --git a/translate/src/api/other-locales.ts b/translate/src/api/other-locales.ts index dea0196d07..b0d9ebd38e 100644 --- a/translate/src/api/other-locales.ts +++ b/translate/src/api/other-locales.ts @@ -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, @@ -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 { + 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 : []; +} 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.css b/translate/src/modules/batchactions/components/BatchActions.css index 76712ede60..3703eded8f 100644 --- a/translate/src/modules/batchactions/components/BatchActions.css +++ b/translate/src/modules/batchactions/components/BatchActions.css @@ -56,7 +56,7 @@ bottom: 0; } -.batch-actions .actions-panel div { +.batch-actions .actions-panel > div { margin: 0 auto; min-width: 300px; padding: 20px; @@ -117,6 +117,159 @@ padding: 15px 0 5px; } +/* Copy from Locale */ +.batch-actions .copy-from-locale #copy-locale-form { + display: flex; + flex-direction: row; + box-sizing: border-box; + gap: 10px; + width: 100%; +} + +.batch-actions .copy-from-locale { + padding: 0; +} + +.batch-actions .copy-from-locale .copy-from-locale-btn { + margin-top: 0; +} + +.batch-actions .copy-from-locale .locale-selector, +.batch-actions .copy-from-locale .copy-from-locale-btn { + flex: 1 1 0; + min-width: 0; +} + +.batch-actions .actions-panel .locale-selector-trigger { + margin-top: 0; +} + +#copy-locale-form .locale-selector button { + text-align: center; + text-align-last: center; + border: none; + border-radius: 3px; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23ffffff' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + color: var(--white-1); + background-color: var(--dark-grey-1); + font-weight: 600; + margin: 0; + min-width: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + padding: 0 12px; + gap: 10px; +} + +#copy-locale-form .locale-selector button.is-open { + border-radius: 3px 3px 0 0; +} + +.locale-selector { + width: 100%; + display: flex; + flex-direction: column; + position: relative; +} + +.locale-selector-dropdown { + position: absolute; + top: 100%; + left: 0; + width: 100%; + z-index: 100; + background-color: var(--dark-grey-1); + padding: 0 12px 12px; + box-sizing: border-box; +} + +.locale-selector-search { + display: flex; + align-items: center; +} + +.locale-selector-list { + max-height: 200px; + overflow-y: auto; +} + +.locale-selector-trigger .locale-name { + color: var(--light-grey-7); + text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.locale-selector-trigger .locale-code { + color: var(--status-translated); + flex-shrink: 0; +} + +.locale-search-icon { + display: inline-block; + width: 16px; + height: 16px; + margin-left: 4px; + flex-shrink: 0; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='6' cy='6' r='4.5' stroke='%23fff' stroke-width='1.5' fill='none'/%3E%3Cline x1='9.5' y1='9.5' x2='14' y2='14' stroke='%23aaa' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-size: contain; +} + +.locale-selector-search input { + width: 100%; + background: transparent; + outline: none; + box-sizing: border-box; + color: var(--white-1); +} + +.locale-selector-dropdown ul { + list-style: none; + margin: 0; + padding: 0; +} + +.locale-selector-dropdown li { + display: flex; + font-size: 14px; + font-weight: 300; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 2px 4px; + line-height: 18px; +} + +.locale-selector-dropdown li:hover { + background-color: var(--dark-grey-2); +} + +.locale-selector-dropdown .locale-name { + color: var(--light-grey-7); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.locale-selector-dropdown .locale-code { + color: var(--status-translated); +} + +#copy-locale-form .locale-selector button.has-selection { + background-image: none; +} + /* Remove highlight in Chrome */ .batch-actions .actions-panel input:focus { outline: none; diff --git a/translate/src/modules/batchactions/components/BatchActions.test.jsx b/translate/src/modules/batchactions/components/BatchActions.test.jsx index 77b1b436c4..511d3cfc00 100644 --- a/translate/src/modules/batchactions/components/BatchActions.test.jsx +++ b/translate/src/modules/batchactions/components/BatchActions.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import * as Hooks from '~/hooks'; import * as Actions from '../actions'; +import * as OtherLocales from '~/api/other-locales'; import { BATCHACTIONS } from '../reducer'; import { BatchActions } from './BatchActions'; @@ -28,12 +29,17 @@ vi.mock('../actions', () => ({ selectAll: vi.fn(() => ({ type: 'whatever' })), })); +vi.mock('~/api/other-locales', () => ({ + fetchAllLocales: vi.fn(() => Promise.resolve([])), +})); + describe('', () => { afterAll(() => { Hooks.useAppDispatch.mockRestore(); Hooks.useAppSelector.mockRestore(); Actions.resetSelection.mockRestore(); Actions.selectAll.mockRestore(); + OtherLocales.fetchAllLocales.mockClear(); }); const WrapBatchAction = () => { @@ -71,6 +77,9 @@ describe('', () => { getByPlaceholderText(/FIND/i); getByPlaceholderText(/REPLACE WITH/i); getByRole('button', { name: /REPLACE ALL/i }); + + expect(container.querySelector('.copy-from-locale')).toBeInTheDocument(); + getByRole('button', { name: /COPY AS SUGGESTIONS/i }); }); it('closes batch actions panel when the Close button with selected count is clicked', () => { diff --git a/translate/src/modules/batchactions/components/BatchActions.tsx b/translate/src/modules/batchactions/components/BatchActions.tsx index 86bf12e292..13971ec7d9 100644 --- a/translate/src/modules/batchactions/components/BatchActions.tsx +++ b/translate/src/modules/batchactions/components/BatchActions.tsx @@ -1,5 +1,11 @@ import { Localized } from '@fluent/react'; -import React, { useCallback, useContext, useEffect, useRef } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { Location } from '~/context/Location'; import { ShowBadgeTooltip } from '~/context/BadgeTooltip'; @@ -12,7 +18,10 @@ import { ApproveAll } from './ApproveAll'; import './BatchActions.css'; import { RejectAll } from './RejectAll'; import { ReplaceAll } from './ReplaceAll'; - +import { CopyFromLocale } from './CopyFromLocale'; +import { fetchAllLocales } from '~/api/other-locales'; +import type { LocaleOption } from '~/api/other-locales'; +import LocaleSelector from './LocaleSelector'; /** * Renders batch editor, used for performing mass actions on translations. */ @@ -25,6 +34,9 @@ export function BatchActions(): React.ReactElement<'div'> { const find = useRef(null); const replace = useRef(null); + const [otherLocale, setOtherLocale] = useState(''); + const [locales, setLocales] = useState([]); + const quitBatchActions = useCallback(() => dispatch(resetSelection()), []); useEffect(() => { @@ -38,6 +50,12 @@ export function BatchActions(): React.ReactElement<'div'> { return () => document.removeEventListener('keydown', handleShortcuts); }, []); + useEffect(() => { + fetchAllLocales().then((all) => { + setLocales(all); + }); + }, []); + const selectAllEntities = useCallback( () => dispatch(selectAll(location)), [location], @@ -92,6 +110,22 @@ export function BatchActions(): React.ReactElement<'div'> { } }, [location, batchactions]); + const copyFromLocale = useCallback(() => { + if (!batchactions.requestInProgress) { + dispatch( + performAction( + location, + 'copy_from_locale', + batchactions.entities, + showBadgeTooltip, + undefined, + undefined, + otherLocale, + ), + ); + } + }, [location, batchactions, showBadgeTooltip, otherLocale]); + const submitReplaceForm = useCallback( (ev: React.SyntheticEvent) => { ev.preventDefault(); @@ -100,6 +134,14 @@ export function BatchActions(): React.ReactElement<'div'> { [replaceAll], ); + const submitCopyFromLocaleForm = useCallback( + (ev: React.SyntheticEvent) => { + ev.preventDefault(); + copyFromLocale(); + }, + [copyFromLocale], + ); + return (
@@ -197,6 +239,23 @@ export function BatchActions(): React.ReactElement<'div'> {
+
+ +

COPY FROM ANOTHER 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..21b248ee70 --- /dev/null +++ b/translate/src/modules/batchactions/components/CopyFromLocale.tsx @@ -0,0 +1,73 @@ +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 ( +