Skip to content

Commit d93f5fc

Browse files
committed
feat: add experimentalPushAll option
1 parent ffbbc43 commit d93f5fc

10 files changed

Lines changed: 253 additions & 24 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ out
88

99
# Env
1010
.env
11+
12+
# macOS
13+
.DS_Store

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ module.exports = {
9090
<summary>PushOptions</summary>
9191
<br>
9292

93+
- `experimentalPushAll`: Upload all locales in a single API request instead of one request per locale. This can significantly improve performance for projects with many locales.
9394
- `ignore-new`: Specify that new assets will NOT be added to the project.
9495
- `ignore-existing`: Specify that existing assets encountered in the file will NOT be updated.
9596
- `tag-new`: Tag any NEW assets added during the import with the given tags (comma separated).
@@ -144,6 +145,7 @@ Push changes to the translation files to Loco. Depending on the `push` options,
144145
#### Options
145146

146147
- `-y, --yes`: Automatically answer yes to all confirmation prompts (default: false)
148+
- `--experimental-push-all`: Upload all locales in a single API request for improved performance. Reduces API calls from N (one per locale) to 1. Can also be set via `push.experimentalPushAll` in config.
147149

148150
## Contributing
149151

cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ program
3030
'The status option is removed in v2, use the `push.flag-new` option in `loco.config.js` instead'
3131
)
3232
.option('-y, --yes', 'Answer yes to all confirmation prompts', false)
33+
.option('--experimental-push-all', 'Upload all locales in a single request')
3334
.description('Upload assets to Loco')
3435
.action(handleAsyncErrors(push));
3536

src/commands/push.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,25 @@ import chalk from 'chalk';
22
import { Command } from 'commander';
33
import inquirer from 'inquirer';
44
import cliProgress from 'cli-progress';
5-
import { apiPull, apiPush } from '../lib/api';
5+
import { apiPull, apiPush, apiPushAll } from '../lib/api';
66
import { diff } from '../lib/diff';
77
import { readFiles } from '../lib/readFiles';
88
import { getGlobalOptions } from '../util/options';
99
import { printDiff } from '../util/print';
10-
import { flattenTranslations } from '../lib/dotObject';
10+
import { flattenTranslations, flattenAllTranslations } from '../lib/dotObject';
1111
import { log } from '../util/logger';
1212

1313
interface CommandOptions {
1414
yes?: boolean;
1515
status?: string;
1616
tag?: string;
17+
experimentalPushAll?: boolean;
1718
}
1819

19-
const push = async ({ yes, status, tag }: CommandOptions, program: Command) => {
20+
const push = async (
21+
{ yes, status, tag, experimentalPushAll }: CommandOptions,
22+
program: Command
23+
) => {
2024
if (status) {
2125
log.warn(
2226
'The status option is removed in v2, use the `push.flag-new` option in `loco.config.js` instead'
@@ -32,10 +36,15 @@ const push = async ({ yes, status, tag }: CommandOptions, program: Command) => {
3236
accessKey,
3337
localesDir,
3438
namespaces,
35-
push: pushOptions,
39+
push: configPushOptions,
3640
pull: pullOptions,
3741
maxFiles
3842
} = options;
43+
// Merge CLI flag with config options (CLI takes precedence)
44+
const pushOptions = {
45+
...configPushOptions,
46+
...(experimentalPushAll !== undefined && { experimentalPushAll })
47+
};
3948
const deleteAbsent = pushOptions?.['delete-absent'] ?? false;
4049
const local = await readFiles(localesDir, namespaces);
4150
const remote = await apiPull(accessKey, pullOptions);
@@ -84,19 +93,28 @@ const push = async ({ yes, status, tag }: CommandOptions, program: Command) => {
8493
}
8594
}
8695

87-
const length = Object.keys(remote).length;
88-
const progressbar = new cliProgress.SingleBar({
89-
format: `Uploading in ${length} locales |${chalk.cyan('{bar}')}| {value}/{total}`,
90-
barCompleteChar: '\u2588',
91-
barIncompleteChar: '\u2591',
92-
hideCursor: true
93-
});
94-
progressbar.start(length, 0);
95-
for (const [locale, translations] of Object.entries(local)) {
96-
progressbar.increment();
97-
await apiPush(accessKey, locale, flattenTranslations(translations), pushOptions);
96+
if (pushOptions?.experimentalPushAll) {
97+
const localeCount = Object.keys(local).length;
98+
log.log(
99+
`Uploading ${localeCount} locale${localeCount > 1 ? 's' : ''} (experimentalPushAll)...`
100+
);
101+
await apiPushAll(accessKey, flattenAllTranslations(local), pushOptions);
102+
log.log('Done.');
103+
} else {
104+
const length = Object.keys(remote).length;
105+
const progressbar = new cliProgress.SingleBar({
106+
format: `Uploading in ${length} locales |${chalk.cyan('{bar}')}| {value}/{total}`,
107+
barCompleteChar: '\u2588',
108+
barIncompleteChar: '\u2591',
109+
hideCursor: true
110+
});
111+
progressbar.start(length, 0);
112+
for (const [locale, translations] of Object.entries(local)) {
113+
progressbar.increment();
114+
await apiPush(accessKey, locale, flattenTranslations(translations), pushOptions);
115+
}
116+
progressbar.stop();
98117
}
99-
progressbar.stop();
100118

101119
log.log();
102120

src/lib/api.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,30 @@ export const apiPush = (
5555
locale,
5656
...Object.fromEntries(
5757
Object.entries(options)
58-
.filter(([, v]) => v !== undefined)
58+
.filter(([k, v]) => v !== undefined && k !== 'experimentalPushAll')
59+
.map(([k, v]) => [k, String(v)])
60+
)
61+
},
62+
{
63+
method: 'POST',
64+
body: JSON.stringify(translations)
65+
}
66+
);
67+
68+
export const apiPushAll = (
69+
key: string,
70+
translations: Record<string, FlatTranslations>,
71+
options: PushOptions = {}
72+
) =>
73+
fetchApi<void>(
74+
key,
75+
'/import/json',
76+
{
77+
locale: 'auto',
78+
format: 'multi',
79+
...Object.fromEntries(
80+
Object.entries(options)
81+
.filter(([k, v]) => v !== undefined && k !== 'experimentalPushAll')
5982
.map(([k, v]) => [k, String(v)])
6083
)
6184
},

src/lib/dotObject.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DiffRecord, TranslationValue } from '../../types';
1+
import { DiffRecord, TranslationValue, Translations } from '../../types';
22

33
type DiffValue = string | undefined;
44
type DiffObject = { [key: string]: DiffValue | DiffObject };
@@ -49,3 +49,14 @@ export const flattenTranslations = (obj: TranslationValue): Record<string, strin
4949
recurse(obj);
5050
return res;
5151
};
52+
53+
/**
54+
* Flatten all translations for all locales at once.
55+
* Returns a Record mapping locale codes to their flattened translations.
56+
*/
57+
export const flattenAllTranslations = (
58+
translations: Translations
59+
): Record<string, Record<string, string>> =>
60+
Object.fromEntries(
61+
Object.entries(translations).map(([locale, values]) => [locale, flattenTranslations(values)])
62+
);

test/api.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
vi.mock('isomorphic-unfetch');
1212

1313
import fetch from 'isomorphic-unfetch';
14-
import { apiPull, apiPush } from '../src/lib/api';
14+
import { apiPull, apiPush, apiPushAll } from '../src/lib/api';
1515

1616
const mockFetch = vi.mocked(fetch);
1717

@@ -136,4 +136,74 @@ describe('apiPush', () => {
136136

137137
await expect(apiPush('test-api-key', 'en', {})).rejects.toThrow('HTTPError: 403 Forbidden');
138138
});
139+
140+
test('excludes experimentalPushAll from query params', async () => {
141+
mockFetch.mockResolvedValueOnce(createMockResponse({}) as Response);
142+
143+
await apiPush('test-api-key', 'en', { 'common.hello': 'Hello' }, { experimentalPushAll: true });
144+
145+
const call = mockFetch.mock.calls[0]!;
146+
const url = call[0] as URL;
147+
expect(url.searchParams.has('experimentalPushAll')).toBe(false);
148+
});
149+
});
150+
151+
describe('apiPushAll', () => {
152+
test('sends POST to /import/json with locale=auto and format=multi', async () => {
153+
mockFetch.mockResolvedValueOnce(createMockResponse({}) as Response);
154+
155+
await apiPushAll('test-api-key', { en: { 'common.hello': 'Hello' } });
156+
157+
expect(mockFetch).toHaveBeenCalledTimes(1);
158+
159+
const call = mockFetch.mock.calls[0]!;
160+
const url = call[0] as URL;
161+
expect(url.pathname).toBe('/api/import/json');
162+
expect(url.searchParams.get('locale')).toBe('auto');
163+
expect(url.searchParams.get('format')).toBe('multi');
164+
165+
const options = call[1] as RequestInit;
166+
expect(options.method).toBe('POST');
167+
expect(options.headers).toEqual({ Authorization: 'Loco test-api-key' });
168+
});
169+
170+
test('sends multi-locale JSON body', async () => {
171+
mockFetch.mockResolvedValueOnce(createMockResponse({}) as Response);
172+
173+
const translations = {
174+
en: { 'common.hello': 'Hello', 'common.bye': 'Goodbye' },
175+
es: { 'common.hello': 'Hola', 'common.bye': 'Adiós' }
176+
};
177+
await apiPushAll('test-api-key', translations);
178+
179+
const call = mockFetch.mock.calls[0]!;
180+
const options = call[1] as RequestInit;
181+
expect(options.body).toBe(JSON.stringify(translations));
182+
});
183+
184+
test('converts boolean options to strings', async () => {
185+
mockFetch.mockResolvedValueOnce(createMockResponse({}) as Response);
186+
187+
await apiPushAll('test-api-key', { en: {} }, { 'delete-absent': true });
188+
189+
const call = mockFetch.mock.calls[0]!;
190+
const url = call[0] as URL;
191+
expect(url.searchParams.get('delete-absent')).toBe('true');
192+
});
193+
194+
test('excludes experimentalPushAll from query params', async () => {
195+
mockFetch.mockResolvedValueOnce(createMockResponse({}) as Response);
196+
197+
await apiPushAll('test-api-key', { en: {} }, { experimentalPushAll: true });
198+
199+
const call = mockFetch.mock.calls[0]!;
200+
const url = call[0] as URL;
201+
expect(url.searchParams.has('experimentalPushAll')).toBe(false);
202+
});
203+
204+
test('throws on non-2xx response', async () => {
205+
mockFetch.mockResolvedValueOnce(createMockErrorResponse(403, 'Forbidden') as Response);
206+
207+
await expect(apiPushAll('test-api-key', {})).rejects.toThrow('HTTPError: 403 Forbidden');
208+
});
139209
});

test/commands/push.test.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ vi.mock('cli-progress', () => ({
1818

1919
import { getGlobalOptions } from '../../src/util/options';
2020
import { readFiles } from '../../src/lib/readFiles';
21-
import { apiPull, apiPush } from '../../src/lib/api';
21+
import { apiPull, apiPush, apiPushAll } from '../../src/lib/api';
2222
import { log } from '../../src/util/logger';
2323
import inquirer from 'inquirer';
2424
import push from '../../src/commands/push';
@@ -27,6 +27,7 @@ const mockGetGlobalOptions = vi.mocked(getGlobalOptions);
2727
const mockReadFiles = vi.mocked(readFiles);
2828
const mockApiPull = vi.mocked(apiPull);
2929
const mockApiPush = vi.mocked(apiPush);
30+
const mockApiPushAll = vi.mocked(apiPushAll);
3031
const mockLog = vi.mocked(log);
3132
const mockInquirer = vi.mocked(inquirer);
3233

@@ -58,6 +59,7 @@ beforeEach(() => {
5859
mockLog.warn = vi.fn();
5960
mockLog.info = vi.fn();
6061
mockApiPush.mockResolvedValue(undefined);
62+
mockApiPushAll.mockResolvedValue(undefined);
6163
mockExit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
6264
throw new ExitError(code ?? 0);
6365
}) as never);
@@ -114,8 +116,8 @@ describe('push command', () => {
114116
await expect(push({}, mockProgram)).rejects.toThrow(ExitError);
115117

116118
expect(mockApiPush).toHaveBeenCalledTimes(2);
117-
expect(mockApiPush).toHaveBeenCalledWith('test-key', 'en', { hello: 'Hello' }, undefined);
118-
expect(mockApiPush).toHaveBeenCalledWith('test-key', 'es', { hello: 'Hola' }, undefined);
119+
expect(mockApiPush).toHaveBeenCalledWith('test-key', 'en', { hello: 'Hello' }, {});
120+
expect(mockApiPush).toHaveBeenCalledWith('test-key', 'es', { hello: 'Hola' }, {});
119121
});
120122

121123
test('prompts for confirmation', async () => {
@@ -208,4 +210,52 @@ describe('push command', () => {
208210
{ 'tag-new': 'from-code' }
209211
);
210212
});
213+
214+
test('uses apiPushAll when experimentalPushAll is enabled via CLI', async () => {
215+
const local = { en: { hello: 'Hello' }, es: { hello: 'Hola' } };
216+
const remote = { en: {}, es: {} };
217+
mockReadFiles.mockResolvedValue(local);
218+
mockApiPull.mockResolvedValue(remote);
219+
vi.mocked(mockInquirer.prompt).mockResolvedValue({ confirm: true });
220+
221+
await expect(push({ experimentalPushAll: true }, mockProgram)).rejects.toThrow(ExitError);
222+
223+
expect(mockApiPushAll).toHaveBeenCalledTimes(1);
224+
expect(mockApiPushAll).toHaveBeenCalledWith(
225+
'test-key',
226+
{ en: { hello: 'Hello' }, es: { hello: 'Hola' } },
227+
{ experimentalPushAll: true }
228+
);
229+
expect(mockApiPush).not.toHaveBeenCalled();
230+
});
231+
232+
test('uses apiPushAll when experimentalPushAll is enabled via config', async () => {
233+
const local = { en: { hello: 'Hello' }, es: { hello: 'Hola' } };
234+
const remote = { en: {}, es: {} };
235+
mockGetGlobalOptions.mockResolvedValue({
236+
...defaultOptions,
237+
push: { experimentalPushAll: true }
238+
});
239+
mockReadFiles.mockResolvedValue(local);
240+
mockApiPull.mockResolvedValue(remote);
241+
vi.mocked(mockInquirer.prompt).mockResolvedValue({ confirm: true });
242+
243+
await expect(push({}, mockProgram)).rejects.toThrow(ExitError);
244+
245+
expect(mockApiPushAll).toHaveBeenCalledTimes(1);
246+
expect(mockApiPush).not.toHaveBeenCalled();
247+
});
248+
249+
test('uses default per-locale push when experimentalPushAll is false', async () => {
250+
const local = { en: { hello: 'Hello' }, es: { hello: 'Hola' } };
251+
const remote = { en: {}, es: {} };
252+
mockReadFiles.mockResolvedValue(local);
253+
mockApiPull.mockResolvedValue(remote);
254+
vi.mocked(mockInquirer.prompt).mockResolvedValue({ confirm: true });
255+
256+
await expect(push({}, mockProgram)).rejects.toThrow(ExitError);
257+
258+
expect(mockApiPush).toHaveBeenCalledTimes(2);
259+
expect(mockApiPushAll).not.toHaveBeenCalled();
260+
});
211261
});

0 commit comments

Comments
 (0)