diff --git a/api/src/controllers/exports.controller.ts b/api/src/controllers/exports.controller.ts index e5c9f2d5..1deace1e 100644 --- a/api/src/controllers/exports.controller.ts +++ b/api/src/controllers/exports.controller.ts @@ -22,7 +22,6 @@ import { phpExporter } from '../formatters/php'; import { ApiOAuth2, ApiTags, ApiOperation, ApiProduces, ApiResponse } from '@nestjs/swagger'; import { androidXmlExporter } from '../formatters/android-xml'; import { resXExporter } from '../formatters/resx'; -import { merge } from 'lodash'; import { resolveColumnName } from '../utils/alias-helper'; import { getLexicalOrderClause } from '../utils/database-type-helper'; @@ -129,9 +128,17 @@ export class ExportsController { translation: t.translations.length === 1 ? t.translations[0].value : '', })); + // Merge by term. The requested locale only contains the translated + // terms at this point, so a positional merge would pair unrelated + // terms and drop entries (#420). + const translated = new Map(data.translations.map(t => [t.term, t.translation])); + const dataWithFallback: IntermediateTranslationFormat = { iso: query.locale, - translations: merge(fallbackTermsWithTranslationsMapped, data.translations), + translations: fallbackTermsWithTranslationsMapped.map(t => ({ + term: t.term, + translation: translated.get(t.term) ?? t.translation, + })), }; serialized = await this.dump(query.format, dataWithFallback); diff --git a/api/test/export.e2e-spec.ts b/api/test/export.e2e-spec.ts index 0955e927..0b436482 100644 --- a/api/test/export.e2e-spec.ts +++ b/api/test/export.e2e-spec.ts @@ -111,6 +111,45 @@ describe('ExportController (e2e)', () => { }); }); + it('/api/v1/projects/:projectId/exports (GET) should fill untranslated terms from the fallback locale', async () => { + const authorized = (req: request.Test) => req.set('Authorization', `Bearer ${testingUser.accessToken}`); + + const createTerm = async (value: string): Promise => { + const res = await authorized(request(app.getHttpServer()).post(`/api/v1/projects/${testProject.id}/terms`)) + .send({ value }) + .expect(201); + return res.body.data.id; + }; + + const translate = (localeCode: string, termId: string, value: string) => + authorized(request(app.getHttpServer()).patch(`/api/v1/projects/${testProject.id}/translations/${localeCode}`)) + .send({ termId, value }) + .expect(200); + + const exportLocale = async (params: string): Promise => { + const res = await authorized(request(app.getHttpServer()).get(`/api/v1/projects/${testProject.id}/exports?${params}`)).expect(200); + return JSON.parse(Buffer.from(res.body).toString('utf-8')); + }; + + const nestedTermId = await createTerm('group.term'); + await authorized(request(app.getHttpServer()).post(`/api/v1/projects/${testProject.id}/translations`)) + .send({ code: 'nl' }) + .expect(201); + await translate('nl', termTwoId, 'twee'); + await translate('de_DE', nestedTermId, 'gruppe'); + + const flat = await exportLocale('locale=nl&format=jsonflat&fallbackLocale=de_DE'); + expect(Object.keys(flat)).toEqual(['group.term', 'term.one', 'term.two']); + expect(flat['group.term']).toEqual('gruppe'); + expect(flat['term.one']).toEqual('eins'); + expect(flat['term.two']).toEqual('twee'); + + const nested = await exportLocale('locale=nl&format=jsonnested&fallbackLocale=de_DE'); + expect(nested.group.term).toEqual('gruppe'); + expect(nested.term.one).toEqual('eins'); + expect(nested.term.two).toEqual('twee'); + }); + it('/api/v1/projects/:projectId/exports (GET) should export terms in lexical order', async () => { const input = ['app.login', 'should be before base terms', '1 goes first', 'app.logout', 'app.exit', 'menu.start', 'a term', '2 goes second'];