Skip to content

Commit 4b81222

Browse files
committed
Add “download by locale” option.
Download all files as a ZIP file. When you download by locale, the string limit applies to that locale only. It no longer applies to the full search.
1 parent 25194b5 commit 4b81222

File tree

3 files changed

+327
-22
lines changed

3 files changed

+327
-22
lines changed

webapp/src/main/resources/properties/en.properties

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,18 @@ workbench.export.modal.format.csv=CSV
441441
# Label shown above the list of selectable export fields
442442
workbench.export.modal.fieldsLabel=Fields
443443

444+
# Label for the checkbox that enables per-locale exports
445+
workbench.export.modal.splitByLocale=Generate a separate file for each locale
446+
447+
# Helper text shown when per-locale export is unavailable
448+
workbench.export.modal.splitByLocale.disabled=Select repositories with locales to enable per-locale export.
449+
450+
# Label shown above the locale multi-select
451+
workbench.export.modal.localesLabel=Locales to include
452+
453+
# Helper text for the locale multi-select
454+
workbench.export.modal.localesHelp=Choose one or more locales. Leave all selected to export every locale.
455+
444456
# Label for the input controlling how many results are exported
445457
workbench.export.modal.limitLabel=Maximum number of records
446458

@@ -459,6 +471,15 @@ workbench.export.modal.error.searchNotReady=Select at least one repository and l
459471
# Generic error message shown when the export fails
460472
workbench.export.modal.error.generic=We couldn't export the search results. Try again or contact an administrator.
461473

474+
# Error message shown when no translations match the selected locales
475+
workbench.export.modal.error.localesEmpty=No translations were found for the selected locales.
476+
477+
# Error message shown when per-locale export is triggered without a locale selection
478+
workbench.export.modal.error.localesMissingSelection=Select at least one locale to export.
479+
480+
# Error message shown when some locales had no results
481+
workbench.export.modal.error.localesSkipped=Skipped locales with no translations: {locales}
482+
462483
# Label for the tmTextUnitId column in the export configuration
463484
workbench.export.fields.tmTextUnitId=Text Unit ID
464485

webapp/src/main/resources/public/js/components/workbench/ExportSearchResultsModal.js

Lines changed: 199 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import PropTypes from 'prop-types';
22
import React from "react";
33
import {FormattedMessage, injectIntl} from "react-intl";
4-
import {Alert, Button, ControlLabel, FormControl, FormGroup, Modal} from "react-bootstrap";
4+
import {Alert, Button, Checkbox, ControlLabel, FormControl, FormGroup, HelpBlock, Modal} from "react-bootstrap";
55
import SearchParamsStore from "../../stores/workbench/SearchParamsStore";
66
import {buildTextUnitSearcherParameters} from "../../utils/TextUnitSearcherParametersBuilder";
77
import TextUnitClient from "../../sdk/TextUnitClient";
8+
import RepositoryStore from "../../stores/RepositoryStore";
9+
import {buildZipFile} from "../../utils/ZipBuilder";
810

911
const DEFAULT_LIMIT = 10000;
1012
const DEFAULT_FIELDS = [
@@ -98,15 +100,38 @@ class ExportSearchResultsModal extends React.Component {
98100
}
99101

100102
getDefaultState() {
103+
const {availableLocales, defaultSelectedLocales} = this.getInitialLocales();
101104
return {
102105
selectedFields: DEFAULT_FIELDS.slice(),
103106
limit: DEFAULT_LIMIT.toString(),
104107
isExporting: false,
105108
errorMessage: null,
106109
format: EXPORT_FORMATS.CSV,
110+
splitByLocale: false,
111+
availableLocales,
112+
selectedLocales: defaultSelectedLocales,
107113
};
108114
}
109115

116+
getInitialLocales() {
117+
const searchParams = {...SearchParamsStore.getState()};
118+
const repoIds = Array.isArray(searchParams.repoIds) ? searchParams.repoIds : [];
119+
let availableLocales = [];
120+
if (repoIds && repoIds.length) {
121+
availableLocales = RepositoryStore.getAllBcp47TagsForRepositoryIds(repoIds) || [];
122+
}
123+
availableLocales = Array.from(new Set(availableLocales)).sort();
124+
125+
let defaultSelectedLocales = [];
126+
if (Array.isArray(searchParams.bcp47Tags) && searchParams.bcp47Tags.length) {
127+
defaultSelectedLocales = searchParams.bcp47Tags.filter(tag => availableLocales.indexOf(tag) !== -1);
128+
} else {
129+
defaultSelectedLocales = availableLocales.slice();
130+
}
131+
132+
return {availableLocales, defaultSelectedLocales};
133+
}
134+
110135
toggleField(fieldName) {
111136
this.setState((prevState) => {
112137
let nextFields;
@@ -142,8 +167,28 @@ class ExportSearchResultsModal extends React.Component {
142167
return;
143168
}
144169

145-
const searchParams = {...SearchParamsStore.getState()};
146-
const {textUnitSearcherParameters, returnEmpty} = buildTextUnitSearcherParameters(searchParams);
170+
const baseSearchParams = {...SearchParamsStore.getState()};
171+
const fieldsForExport = EXPORT_FIELD_PRIORITY
172+
.filter(field => selectedFields.indexOf(field) !== -1)
173+
.concat(selectedFields.filter(field => EXPORT_FIELD_PRIORITY.indexOf(field) === -1));
174+
175+
if (this.state.splitByLocale && this.state.selectedLocales.length) {
176+
this.setState({isExporting: true, errorMessage: null});
177+
try {
178+
await this.exportPerLocaleWithFetch(parsedLimit, fieldsForExport, format, baseSearchParams);
179+
} catch (error) {
180+
console.error("Failed to export search results", error);
181+
const fallbackMessage = intl.formatMessage({id: "workbench.export.modal.error.generic"});
182+
const normalizedError = (error && error.message) ? error.message : null;
183+
this.setState({
184+
isExporting: false,
185+
errorMessage: normalizedError ? `${fallbackMessage} (${normalizedError})` : fallbackMessage,
186+
});
187+
}
188+
return;
189+
}
190+
191+
const {textUnitSearcherParameters, returnEmpty} = buildTextUnitSearcherParameters(baseSearchParams);
147192

148193
if (returnEmpty) {
149194
this.setState({errorMessage: intl.formatMessage({id: "workbench.export.modal.error.searchNotReady"})});
@@ -156,26 +201,9 @@ class ExportSearchResultsModal extends React.Component {
156201

157202
try {
158203
const textUnits = await this.fetchAllTextUnits(textUnitSearcherParameters, parsedLimit);
159-
const fieldsForExport = EXPORT_FIELD_PRIORITY
160-
.filter(field => selectedFields.indexOf(field) !== -1)
161-
.concat(selectedFields.filter(field => EXPORT_FIELD_PRIORITY.indexOf(field) === -1));
162204
const rows = this.buildRows(textUnits, fieldsForExport);
163-
164-
let blob;
165-
let extension;
166-
167-
if (format === EXPORT_FORMATS.CSV) {
168-
const csv = this.convertRowsToCsv(rows, fieldsForExport);
169-
blob = new Blob([csv], {type: "text/csv;charset=utf-8;"});
170-
extension = "csv";
171-
} else {
172-
const json = JSON.stringify(rows, null, 2);
173-
blob = new Blob([json], {type: "application/json;charset=utf-8;"});
174-
extension = "json";
175-
}
176-
205+
const {blob, extension} = this.buildExportPayload(rows, fieldsForExport, format);
177206
this.triggerDownload(blob, `workbench-export-${Date.now()}.${extension}`);
178-
179207
this.setState({isExporting: false}, () => this.props.onClose());
180208
} catch (error) {
181209
console.error("Failed to export search results", error);
@@ -188,6 +216,128 @@ class ExportSearchResultsModal extends React.Component {
188216
}
189217
}
190218

219+
isExportDisabled() {
220+
if (this.state.isExporting) {
221+
return true;
222+
}
223+
if (!this.state.selectedFields.length) {
224+
return true;
225+
}
226+
if (this.state.splitByLocale && !this.state.selectedLocales.length) {
227+
return true;
228+
}
229+
return false;
230+
}
231+
232+
onSplitByLocaleToggle(splitByLocale) {
233+
if (this.state.isExporting) {
234+
return;
235+
}
236+
if (splitByLocale && !this.state.availableLocales.length) {
237+
this.setState({splitByLocale: false});
238+
return;
239+
}
240+
let nextSelectedLocales = this.state.selectedLocales;
241+
if (splitByLocale && nextSelectedLocales.length === 0) {
242+
nextSelectedLocales = this.state.availableLocales.slice();
243+
}
244+
this.setState({splitByLocale, selectedLocales: nextSelectedLocales});
245+
}
246+
247+
onLocalesChange(event) {
248+
if (this.state.isExporting) {
249+
return;
250+
}
251+
const options = event.target.options;
252+
const selectedLocales = [];
253+
for (let i = 0; i < options.length; i++) {
254+
if (options[i].selected) {
255+
selectedLocales.push(options[i].value);
256+
}
257+
}
258+
this.setState({selectedLocales});
259+
}
260+
261+
buildExportPayload(rows, fieldsForExport, format) {
262+
const encoder = new TextEncoder();
263+
if (format === EXPORT_FORMATS.CSV) {
264+
const csv = this.convertRowsToCsv(rows, fieldsForExport);
265+
const bytes = encoder.encode(csv);
266+
return {
267+
blob: new Blob([bytes], {type: "text/csv;charset=utf-8;"}),
268+
extension: "csv",
269+
bytes,
270+
};
271+
}
272+
const json = JSON.stringify(rows, null, 2);
273+
const bytes = encoder.encode(json);
274+
return {
275+
blob: new Blob([bytes], {type: "application/json;charset=utf-8;"}),
276+
extension: "json",
277+
bytes,
278+
};
279+
}
280+
281+
async exportPerLocaleWithFetch(limit, fieldsForExport, format, baseSearchParams) {
282+
const {intl} = this.props;
283+
const {selectedLocales} = this.state;
284+
const timestamp = Date.now();
285+
let filesGenerated = 0;
286+
const missingLocales = [];
287+
const files = [];
288+
289+
for (const locale of selectedLocales) {
290+
const localeSearchParams = {...baseSearchParams, bcp47Tags: [locale]};
291+
const {textUnitSearcherParameters, returnEmpty} = buildTextUnitSearcherParameters(localeSearchParams);
292+
293+
if (returnEmpty) {
294+
missingLocales.push(locale);
295+
continue;
296+
}
297+
298+
textUnitSearcherParameters.offset(0);
299+
300+
const localeTextUnits = await this.fetchAllTextUnits(textUnitSearcherParameters, limit);
301+
302+
if (!localeTextUnits.length) {
303+
missingLocales.push(locale);
304+
continue;
305+
}
306+
307+
const rows = this.buildRows(localeTextUnits, fieldsForExport);
308+
const payload = this.buildExportPayload(rows, fieldsForExport, format);
309+
const safeLocale = locale.replace(/[^A-Za-z0-9._-]/g, '_');
310+
files.push({
311+
name: `workbench-export-${safeLocale}-${timestamp}.${payload.extension}`,
312+
content: payload.bytes,
313+
});
314+
filesGenerated += 1;
315+
}
316+
317+
if (!filesGenerated) {
318+
const message = selectedLocales.length
319+
? intl.formatMessage({id: "workbench.export.modal.error.localesEmpty"})
320+
: intl.formatMessage({id: "workbench.export.modal.error.localesMissingSelection"});
321+
this.setState({isExporting: false, errorMessage: message});
322+
return;
323+
}
324+
325+
const zipArray = buildZipFile(files);
326+
const zipBlob = new Blob([zipArray], {type: "application/zip"});
327+
this.triggerDownload(zipBlob, `workbench-export-locales-${timestamp}.zip`);
328+
329+
if (missingLocales.length) {
330+
const message = intl.formatMessage(
331+
{id: "workbench.export.modal.error.localesSkipped"},
332+
{locales: missingLocales.join(', ')}
333+
);
334+
this.setState({isExporting: false, errorMessage: message});
335+
return;
336+
}
337+
338+
this.setState({isExporting: false}, () => this.props.onClose());
339+
}
340+
191341
renderFields() {
192342
const {intl} = this.props;
193343
const containerStyle = {
@@ -237,6 +387,8 @@ class ExportSearchResultsModal extends React.Component {
237387

238388
render() {
239389
const {intl} = this.props;
390+
const {splitByLocale, availableLocales, selectedLocales} = this.state;
391+
const localesEnabled = availableLocales.length > 0;
240392
return (
241393
<Modal show={this.props.show}
242394
onHide={() => !this.state.isExporting && this.props.onClose()}
@@ -259,6 +411,31 @@ class ExportSearchResultsModal extends React.Component {
259411
<option value={EXPORT_FORMATS.JSON}>{intl.formatMessage({id: "workbench.export.modal.format.json"})}</option>
260412
<option value={EXPORT_FORMATS.CSV}>{intl.formatMessage({id: "workbench.export.modal.format.csv"})}</option>
261413
</FormControl>
414+
<Checkbox
415+
className="mtm"
416+
checked={splitByLocale}
417+
onChange={(e) => this.onSplitByLocaleToggle(e.target.checked)}
418+
disabled={this.state.isExporting || !localesEnabled}>
419+
<FormattedMessage id="workbench.export.modal.splitByLocale"/>
420+
</Checkbox>
421+
{!localesEnabled &&
422+
<HelpBlock><FormattedMessage id="workbench.export.modal.splitByLocale.disabled"/></HelpBlock>
423+
}
424+
{splitByLocale && localesEnabled &&
425+
<div className="mtm">
426+
<ControlLabel><FormattedMessage id="workbench.export.modal.localesLabel"/></ControlLabel>
427+
<FormControl componentClass="select"
428+
multiple
429+
value={selectedLocales}
430+
onChange={(e) => this.onLocalesChange(e)}
431+
disabled={this.state.isExporting}>
432+
{availableLocales.map(locale => (
433+
<option key={locale} value={locale}>{locale}</option>
434+
))}
435+
</FormControl>
436+
<HelpBlock><FormattedMessage id="workbench.export.modal.localesHelp"/></HelpBlock>
437+
</div>
438+
}
262439
</FormGroup>
263440
<FormGroup>
264441
<ControlLabel><FormattedMessage id="workbench.export.modal.fieldsLabel"/></ControlLabel>
@@ -285,7 +462,7 @@ class ExportSearchResultsModal extends React.Component {
285462
</Button>
286463
<Button bsStyle="primary"
287464
onClick={() => this.startExport()}
288-
disabled={this.state.isExporting || this.state.selectedFields.length === 0}>
465+
disabled={this.isExportDisabled()}>
289466
<FormattedMessage id="workbench.export.modal.export"/>
290467
</Button>
291468
</Modal.Footer>

0 commit comments

Comments
 (0)