Skip to content

Commit f95fd2a

Browse files
authored
Add auto-translate feature when pushing translations (#379)
1 parent aab83c8 commit f95fd2a

File tree

9 files changed

+243
-14
lines changed

9 files changed

+243
-14
lines changed

.changeset/loose-cups-hide.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@vocab/cli": minor
3+
"@vocab/phrase": minor
4+
---
5+
6+
Add auto-translate feature for push command
7+
8+
Adds a new `--auto-translate` flag to the `vocab push` command that enables automatic translation for missing translations in the Phrase platform. When enabled, this flag instructs Phrase to automatically translate any missing keys using machine translation.

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,17 @@ This flag accepts an array of glob patterns to ignore.
732732
vocab push --branch my-branch --ignore "**/dist/**" "**/another_ignored_directory/**"
733733
```
734734

735+
#### Auto-Translation
736+
737+
By default, Phrase may not apply the project's automatic translation behaviour for new keys uploaded via API.
738+
739+
The `--auto-translate` flag instructs Phrase to automatically translate any missing keys using machine translation.. See [Phrase auto-translate API Documentation] for more information.
740+
741+
```sh
742+
vocab push --branch my-branch --auto-translate
743+
```
744+
745+
[Phrase auto-translate API Documentation]: https://developers.phrase.com/en/api/strings/uploads/upload-a-new-file#body-autotranslate
735746
[phrase]: https://developers.phrase.com/api/
736747

737748
#### [Tags]

fixtures/phrase/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@vocab/cli": "workspace:*",
13-
"dotenv-cli": "^11.0.0"
13+
"@vocab/core": "workspace:*",
14+
"dotenv-cli": "^10.0.0"
1415
}
1516
}

packages/cli/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const branchOption = new Option(
3838
).default(DEFAULT_BRANCH);
3939

4040
const pushAction = async (options: {
41+
autoTranslate?: boolean;
4142
branch: string;
4243
deleteUnusedKeys?: boolean;
4344
ignore?: string[];
@@ -48,6 +49,7 @@ const pushAction = async (options: {
4849
branch: options.branch,
4950
deleteUnusedKeys: options.deleteUnusedKeys,
5051
ignore: options.ignore,
52+
autoTranslate: options.autoTranslate,
5153
},
5254
options.userConfig,
5355
);
@@ -57,6 +59,11 @@ program
5759
.command('push')
5860
.description('Push translations to Phrase')
5961
.addOption(branchOption)
62+
.option(
63+
'--auto-translate',
64+
'Enable automatic translation for missing translations',
65+
false,
66+
)
6067
.option(
6168
'--delete-unused-keys',
6269
'Whether or not to delete unused keys after pushing',
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { vi } from 'vitest';
2+
import type { TranslationsByLanguage } from '@vocab/core';
3+
import { pushTranslations } from './phrase-api';
4+
5+
// Mock global fetch
6+
const mockFetch = vi.fn();
7+
global.fetch = mockFetch;
8+
9+
const getFormDataEntries = (call: [unknown, { body: FormData }]) =>
10+
[...call[1].body.entries()].filter(([key]) => key !== 'file');
11+
12+
describe('phrase-api', () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
// Reset environment variables
16+
process.env.PHRASE_API_TOKEN = 'test-token';
17+
process.env.PHRASE_PROJECT_ID = 'test-project-id';
18+
});
19+
20+
describe('pushTranslations', () => {
21+
const mockTranslations: TranslationsByLanguage = {
22+
en: {
23+
hello: { message: 'Hello' },
24+
world: { message: 'World' },
25+
},
26+
fr: {
27+
hello: { message: 'Bonjour' },
28+
world: { message: 'Monde' },
29+
},
30+
};
31+
32+
const mockUploadResponse = {
33+
status: 200,
34+
statusText: 'OK',
35+
headers: {
36+
get: vi.fn(() => null),
37+
},
38+
json: vi.fn(() => Promise.resolve({ id: 'upload-123' })),
39+
};
40+
41+
beforeEach(() => {
42+
mockFetch.mockResolvedValue(mockUploadResponse);
43+
});
44+
45+
it('should upload translations for each language', async () => {
46+
const result = await pushTranslations(mockTranslations, {
47+
branch: 'test-branch',
48+
devLanguage: 'en',
49+
});
50+
51+
expect(mockFetch).toHaveBeenCalledTimes(2); // One call per language
52+
expect(result).toEqual({ devLanguageUploadId: 'upload-123' });
53+
54+
const firstCall = mockFetch.mock.calls[0];
55+
expect(firstCall[0]).toBe(
56+
'https://api.phrase.com/v2/projects/test-project-id/uploads',
57+
);
58+
59+
// Check that fetch was called with correct FormData
60+
expect(getFormDataEntries(firstCall as any)).toMatchInlineSnapshot(`
61+
[
62+
[
63+
"file_format",
64+
"csv",
65+
],
66+
[
67+
"branch",
68+
"test-branch",
69+
],
70+
[
71+
"update_translations",
72+
"true",
73+
],
74+
[
75+
"update_descriptions",
76+
"true",
77+
],
78+
[
79+
"locale_mapping[en]",
80+
"4",
81+
],
82+
[
83+
"format_options[key_index]",
84+
"1",
85+
],
86+
[
87+
"format_options[comment_index]",
88+
"2",
89+
],
90+
[
91+
"format_options[tag_column]",
92+
"3",
93+
],
94+
[
95+
"format_options[enable_pluralization]",
96+
"false",
97+
],
98+
]
99+
`);
100+
});
101+
102+
it('should include autoTranslate parameter when enabled', async () => {
103+
await pushTranslations(mockTranslations, {
104+
branch: 'test-branch',
105+
devLanguage: 'en',
106+
autoTranslate: true,
107+
});
108+
109+
const firstCall = mockFetch.mock.calls[0];
110+
expect(getFormDataEntries(firstCall as any)).toMatchInlineSnapshot(`
111+
[
112+
[
113+
"file_format",
114+
"csv",
115+
],
116+
[
117+
"branch",
118+
"test-branch",
119+
],
120+
[
121+
"update_translations",
122+
"true",
123+
],
124+
[
125+
"update_descriptions",
126+
"true",
127+
],
128+
[
129+
"autotranslate",
130+
"true",
131+
],
132+
[
133+
"locale_mapping[en]",
134+
"4",
135+
],
136+
[
137+
"format_options[key_index]",
138+
"1",
139+
],
140+
[
141+
"format_options[comment_index]",
142+
"2",
143+
],
144+
[
145+
"format_options[tag_column]",
146+
"3",
147+
],
148+
[
149+
"format_options[enable_pluralization]",
150+
"false",
151+
],
152+
]
153+
`);
154+
});
155+
});
156+
});

packages/phrase/src/phrase-api.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ export async function pullAllTranslations(
118118

119119
export async function pushTranslations(
120120
translationsByLanguage: TranslationsByLanguage,
121-
{ devLanguage, branch }: { devLanguage: string; branch: string },
121+
{
122+
autoTranslate,
123+
branch,
124+
devLanguage,
125+
}: { autoTranslate?: boolean; branch: string; devLanguage: string },
122126
) {
123127
const { csvFileStrings, keyIndex, commentIndex, tagColumn, messageIndex } =
124128
translationsToCsv(translationsByLanguage, devLanguage);
@@ -141,6 +145,10 @@ export async function pushTranslations(
141145
formData.append('update_translations', 'true');
142146
formData.append('update_descriptions', 'true');
143147

148+
if (autoTranslate) {
149+
formData.append('autotranslate', 'true');
150+
}
151+
144152
formData.append(`locale_mapping[${language}]`, messageIndex.toString());
145153

146154
formData.append('format_options[key_index]', keyIndex.toString());

packages/phrase/src/push-translations.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ vi.mock('./phrase-api', () => ({
1717

1818
const devLanguageUploadId = '1234';
1919

20-
function runPhrase(config: { deleteUnusedKeys: boolean; ignore?: string[] }) {
20+
function runPhrase(config: {
21+
autoTranslate?: boolean;
22+
deleteUnusedKeys: boolean;
23+
ignore?: string[];
24+
}) {
2125
return push(
2226
{
27+
autoTranslate: config.autoTranslate,
2328
branch: 'tester',
2429
deleteUnusedKeys: config.deleteUnusedKeys,
2530
ignore: config.ignore || [],
@@ -347,4 +352,32 @@ describe('push', () => {
347352
`);
348353
});
349354
});
355+
356+
describe('when autoTranslate is enabled', () => {
357+
const config = { autoTranslate: true, deleteUnusedKeys: false };
358+
359+
beforeEach(() => {
360+
vi.mocked(pushTranslations).mockClear();
361+
vi.mocked(writeFile).mockClear();
362+
vi.mocked(deleteUnusedKeys).mockClear();
363+
364+
vi.mocked(pushTranslations).mockImplementation(() =>
365+
Promise.resolve({ devLanguageUploadId }),
366+
);
367+
});
368+
369+
it('should pass autoTranslate parameter to pushTranslations', async () => {
370+
await expect(runPhrase(config)).resolves.toBeUndefined();
371+
372+
// Check that pushTranslations was called with the correct parameters
373+
expect(vi.mocked(pushTranslations)).toHaveBeenCalledWith(
374+
expect.any(Object), // translations data
375+
expect.objectContaining({
376+
devLanguage: 'en',
377+
branch: 'tester',
378+
autoTranslate: true,
379+
}),
380+
);
381+
});
382+
});
350383
});

packages/phrase/src/push-translations.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { trace } from './logger';
1414

1515
interface PushOptions {
16+
autoTranslate?: boolean;
1617
branch: string;
1718
deleteUnusedKeys?: boolean;
1819
ignore?: string[];
@@ -23,7 +24,7 @@ interface PushOptions {
2324
* A unique namespace is appended to each key using the file path the key came from.
2425
*/
2526
export async function push(
26-
{ branch, deleteUnusedKeys, ignore }: PushOptions,
27+
{ autoTranslate, branch, deleteUnusedKeys, ignore }: PushOptions,
2728
config: UserConfig,
2829
) {
2930
if (ignore) {
@@ -77,8 +78,9 @@ export async function push(
7778
}
7879

7980
const { devLanguageUploadId } = await pushTranslations(phraseTranslations, {
80-
devLanguage: config.devLanguage,
81+
autoTranslate,
8182
branch,
83+
devLanguage: config.devLanguage,
8284
});
8385

8486
if (deleteUnusedKeys) {

pnpm-lock.yaml

Lines changed: 12 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)