From f60b6c78bf9aaa31e2bc3c17268c6a2cf2ea657a Mon Sep 17 00:00:00 2001 From: Kateryna Oblakevych Date: Sun, 8 Feb 2026 20:15:34 -0800 Subject: [PATCH 1/2] feat: Add export_languages support in the config file --- .../cli/commands/actions/DownloadAction.java | 45 +++++++++------ .../cli/properties/PropertiesBuilder.java | 2 + .../cli/properties/PropertiesWithFiles.java | 6 +- .../commands/actions/DownloadActionTest.java | 56 +++++++++++++++++++ .../NewPropertiesWithFilesUtilBuilder.java | 5 ++ 5 files changed, 93 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/crowdin/cli/commands/actions/DownloadAction.java b/src/main/java/com/crowdin/cli/commands/actions/DownloadAction.java index 09f2bd4e5..74d889909 100644 --- a/src/main/java/com/crowdin/cli/commands/actions/DownloadAction.java +++ b/src/main/java/com/crowdin/cli/commands/actions/DownloadAction.java @@ -107,16 +107,8 @@ public void act(Outputter out, PropertiesWithFiles pb, ProjectClient client) { PlaceholderUtil placeholderUtil = new PlaceholderUtil(project.getProjectLanguages(true), pb.getBasePath()); - List languages = languageIds == null ? null : languageIds.stream() - .map(lang -> project.findLanguageById(lang) - .orElseThrow(() -> new RuntimeException( - String.format(RESOURCE_BUNDLE.getString("error.language_not_exist"), lang)))) - .collect(Collectors.toList()); - List excludeLanguages = excludeLanguageIds == null ? new ArrayList<>() : excludeLanguageIds.stream() - .map(lang -> project.findLanguageById(lang) - .orElseThrow(() -> new RuntimeException( - String.format(RESOURCE_BUNDLE.getString("error.language_not_exist"), lang)))) - .collect(Collectors.toList()); + List languages = languageIds == null ? null : resolveLanguagesById(languageIds, project); + List excludeLanguages = excludeLanguageIds == null ? new ArrayList<>() : resolveLanguagesById(excludeLanguageIds, project); Optional branch = Optional.ofNullable(project.getBranch()); @@ -157,11 +149,22 @@ public void act(Outputter out, PropertiesWithFiles pb, ProjectClient client) { } tempDirs.put(downloadedFiles.getLeft(), downloadedFiles.getRight()); } else { - List forLanguages = languages != null ? languages : - project.getProjectLanguages(true).stream() - .filter(language -> !excludeLanguages.contains(language)) - .collect(Collectors.toList()); - + List forLanguages; + List exportLanguages = new ArrayList<>(); + if (languages != null) { + forLanguages = languages; + } else { + List baseLanguages; + List exportLanguageIdsFromConfig = pb.getExportLanguages(); + exportLanguages = exportLanguageIdsFromConfig == null ? new ArrayList<>() : resolveLanguagesById(exportLanguageIdsFromConfig, project); + baseLanguages = exportLanguages.isEmpty() ? project.getProjectLanguages(true) : exportLanguages; + Set excludeIds = excludeLanguages.stream() + .map(Language::getId) + .collect(Collectors.toSet()); + forLanguages = baseLanguages.stream() + .filter(l -> !excludeIds.contains(l.getId())) + .collect(Collectors.toList()); + } if (!plainView) { out.println((languageIds != null) ? OK.withIcon(String.format(RESOURCE_BUNDLE.getString("message.build_language_archive"), String.join(", ", languageIds))) @@ -169,9 +172,7 @@ public void act(Outputter out, PropertiesWithFiles pb, ProjectClient client) { } CrowdinTranslationCreateProjectBuildForm templateRequest = new CrowdinTranslationCreateProjectBuildForm(); - if (languages != null) { - templateRequest.setTargetLanguageIds(languages.stream().map(Language::getId).collect(Collectors.toList())); - } else if (!excludeLanguages.isEmpty()) { + if (languages != null || !exportLanguages.isEmpty() || !excludeLanguages.isEmpty()) { templateRequest.setTargetLanguageIds(forLanguages.stream().map(Language::getId).collect(Collectors.toList())); } @@ -334,6 +335,14 @@ public void act(Outputter out, PropertiesWithFiles pb, ProjectClient client) { } } + private List resolveLanguagesById(List languageIds, CrowdinProjectFull project) { + return languageIds.stream() + .map(lang -> project.findLanguageById(lang) + .orElseThrow(() -> new RuntimeException( + String.format(RESOURCE_BUNDLE.getString("error.language_not_exist"), lang)))) + .collect(Collectors.toList()); + } + /** * Download archive, extract it and return information about that temporary directory * diff --git a/src/main/java/com/crowdin/cli/properties/PropertiesBuilder.java b/src/main/java/com/crowdin/cli/properties/PropertiesBuilder.java index 6bfbe8445..cb0121553 100644 --- a/src/main/java/com/crowdin/cli/properties/PropertiesBuilder.java +++ b/src/main/java/com/crowdin/cli/properties/PropertiesBuilder.java @@ -129,6 +129,8 @@ public abstract class PropertiesBuilder public static final String IMPORT_TRANSLATIONS = "import_translations"; + public static final String EXPORT_LANGUAGES = "export_languages"; + private Outputter out; private Map configFileParams; private Map identityFileParams; diff --git a/src/main/java/com/crowdin/cli/properties/PropertiesWithFiles.java b/src/main/java/com/crowdin/cli/properties/PropertiesWithFiles.java index a0e88567c..a664bae29 100644 --- a/src/main/java/com/crowdin/cli/properties/PropertiesWithFiles.java +++ b/src/main/java/com/crowdin/cli/properties/PropertiesWithFiles.java @@ -14,9 +14,7 @@ import static com.crowdin.cli.BaseCli.IGNORE_HIDDEN_FILES_PATTERN; import static com.crowdin.cli.BaseCli.RESOURCE_BUNDLE; -import static com.crowdin.cli.properties.PropertiesBuilder.FILES; -import static com.crowdin.cli.properties.PropertiesBuilder.PRESERVE_HIERARCHY; -import static com.crowdin.cli.properties.PropertiesBuilder.PSEUDO_LOCALIZATION; +import static com.crowdin.cli.properties.PropertiesBuilder.*; @EqualsAndHashCode(callSuper = true) @Data @@ -27,6 +25,7 @@ public class PropertiesWithFiles extends ProjectProperties { private Boolean preserveHierarchy; private List files; private PseudoLocalization pseudoLocalization; + private List exportLanguages; static class PropertiesWithFilesConfigurator implements PropertiesConfigurator { @@ -42,6 +41,7 @@ public void populateWithValues(PropertiesWithFiles props, Map ma .map(FileBean.CONFIGURATOR::buildFromMap) .collect(Collectors.toList())); props.setPseudoLocalization(PseudoLocalization.CONFIGURATOR.buildFromMap(PropertiesBuilder.getMap(map, PSEUDO_LOCALIZATION))); + PropertiesBuilder.setPropertyIfExists(props::setExportLanguages, map, EXPORT_LANGUAGES, List.class); } @Override diff --git a/src/test/java/com/crowdin/cli/commands/actions/DownloadActionTest.java b/src/test/java/com/crowdin/cli/commands/actions/DownloadActionTest.java index 1d59044c9..78bfbfd1c 100644 --- a/src/test/java/com/crowdin/cli/commands/actions/DownloadActionTest.java +++ b/src/test/java/com/crowdin/cli/commands/actions/DownloadActionTest.java @@ -22,6 +22,7 @@ import java.net.URL; import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -909,4 +910,59 @@ public void testProjectFittingFile_MultilingualWithDest() throws IOException, Re verify(files).deleteDirectory(tempDir.get()); verifyNoMoreInteractions(files); } + + @Test + public void testProjectFittingFile_ExcludeLanguagesOnly() throws IOException, ResponseException { + NewPropertiesWithFilesUtilBuilder pbBuilder = NewPropertiesWithFilesUtilBuilder + .minimalBuiltPropertiesBean("*", Utils.PATH_SEPARATOR + "%original_file_name%-CR-%locale%") + .setBasePath(project.getBasePath()) + .setExportLanguages(List.of("de", "ua")); + PropertiesWithFiles pb = pbBuilder.build(); + + project.addFile("first.po"); + + ProjectClient client = mock(ProjectClient.class); + when(client.downloadFullProject(null)) + .thenReturn(ProjectBuilder.emptyProject(Long.parseLong(pb.getProjectId())) + .addFile("first.po", "gettext", 101L, null, null, "/%original_file_name%-CR-%locale%").build()); + CrowdinTranslationCreateProjectBuildForm expectedRequest = new CrowdinTranslationCreateProjectBuildForm(); + expectedRequest.setTargetLanguageIds(List.of("de")); + + long buildId = 42L; + when(client.startBuildingTranslation(eq(expectedRequest))) + .thenReturn(buildProjectBuild(buildId, Long.parseLong(pb.getProjectId()), "finished", 100)); + URL urlMock = MockitoUtils.getMockUrl(getClass()); + when(client.downloadBuild(eq(buildId))) + .thenReturn(urlMock); + + FilesInterface files = mock(FilesInterface.class); + AtomicReference zipArchive = new AtomicReference<>(); + AtomicReference tempDir = new AtomicReference<>(); + when(files.extractZipArchive(any(), any())) + .thenAnswer((invocation -> { + zipArchive.set(invocation.getArgument(0)); + tempDir.set(invocation.getArgument(1)); + return new ArrayList() {{ + add(new File(tempDir.get().getAbsolutePath() + Utils.PATH_SEPARATOR + "first.po-CR-de-DE")); + }}; + })); + + NewAction action = + new DownloadAction(files, false, null, List.of("ua"), false, null, false, false, false, false, false); + action.act(Outputter.getDefault(), pb, client); + + verify(client).downloadFullProject(null); + verify(client).startBuildingTranslation(eq(expectedRequest)); + verify(client).downloadBuild(eq(buildId)); + verifyNoMoreInteractions(client); + + verify(files).writeToFile(any(), any()); + verify(files).extractZipArchive(any(), any()); + verify(files).copyFile( + new File(tempDir.get().getAbsolutePath() + Utils.PATH_SEPARATOR + "first.po-CR-de-DE"), + new File(pb.getBasePath() + "first.po-CR-de-DE")); + verify(files).deleteFile(eq(zipArchive.get())); + verify(files).deleteDirectory(tempDir.get()); + verifyNoMoreInteractions(files); + } } diff --git a/src/test/java/com/crowdin/cli/properties/NewPropertiesWithFilesUtilBuilder.java b/src/test/java/com/crowdin/cli/properties/NewPropertiesWithFilesUtilBuilder.java index fc0a56ab5..450c13701 100644 --- a/src/test/java/com/crowdin/cli/properties/NewPropertiesWithFilesUtilBuilder.java +++ b/src/test/java/com/crowdin/cli/properties/NewPropertiesWithFilesUtilBuilder.java @@ -68,6 +68,11 @@ public NewPropertiesWithFilesUtilBuilder setPreserveHierarchy(Boolean preserveHi return this; } + public NewPropertiesWithFilesUtilBuilder setExportLanguages(List exportLanguages) { + this.pb.setExportLanguages(exportLanguages); + return this; + } + public NewPropertiesWithFilesUtilBuilder addBuiltFileBean(String source, String translation) { return this.addBuiltFileBean(source, translation, null, null); } From f655e3ce001894dacb9f2d3bceb91dc34921c14f Mon Sep 17 00:00:00 2001 From: Kateryna Oblakevych Date: Tue, 17 Feb 2026 20:19:46 -0800 Subject: [PATCH 2/2] address comments --- .../cli/properties/PropertiesWithFiles.java | 7 +++++ .../resources/messages/messages.properties | 1 + .../commands/actions/DownloadActionTest.java | 2 +- .../NewPropertiesWithFilesUtilBuilder.java | 8 +++++ .../properties/PropertiesWithFilesTest.java | 29 +++++++++++++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/crowdin/cli/properties/PropertiesWithFiles.java b/src/main/java/com/crowdin/cli/properties/PropertiesWithFiles.java index a664bae29..33d7aaf9c 100644 --- a/src/main/java/com/crowdin/cli/properties/PropertiesWithFiles.java +++ b/src/main/java/com/crowdin/cli/properties/PropertiesWithFiles.java @@ -100,6 +100,13 @@ public PropertiesBuilder.Messages checkProperties(PropertiesWithFiles props, Che } } } + if (props.getExportLanguages() != null) { + List raw = (List) (Object) props.getExportLanguages(); + boolean hasNonString = raw.stream().anyMatch(item -> !(item instanceof String)); + if (hasNonString) { + messages.addError(String.format(RESOURCE_BUNDLE.getString("error.config.list_of_strings"), EXPORT_LANGUAGES)); + } + } if (props.getPseudoLocalization() != null) { messages.addAllErrors(PseudoLocalization.CONFIGURATOR.checkProperties(props.getPseudoLocalization())); } diff --git a/src/main/resources/messages/messages.properties b/src/main/resources/messages/messages.properties index 7ba49e663..817e39aa9 100755 --- a/src/main/resources/messages/messages.properties +++ b/src/main/resources/messages/messages.properties @@ -702,6 +702,7 @@ error.config.enum_class_exception=Configuration file contains unexpected '%s' va error.config.enum_wrong_value=Configuration file contains unexpected '%s' value. The expected values are: %s error.config.pseudo_localization_length_correction_out_of_bounds=Acceptable value for 'length_correction' is from -50 to 100 error.config.languages_mapping=The mapping format is the following: crowdin_language_code: code_you_use. Check the full list of Crowdin language codes that can be used for mapping: https://developer.crowdin.com/language-codes. +error.config.list_of_strings=Property %s must be a list of strings error.init.project_id_is_not_number='%s' is not a number! (Enter the correct value or leave the field empty) error.init.skip_project_validation=Skipping project checking due to lack of parameters error.init.path_not_exist=Path '%s' doesn't exist diff --git a/src/test/java/com/crowdin/cli/commands/actions/DownloadActionTest.java b/src/test/java/com/crowdin/cli/commands/actions/DownloadActionTest.java index 78bfbfd1c..6d66ac5f5 100644 --- a/src/test/java/com/crowdin/cli/commands/actions/DownloadActionTest.java +++ b/src/test/java/com/crowdin/cli/commands/actions/DownloadActionTest.java @@ -912,7 +912,7 @@ public void testProjectFittingFile_MultilingualWithDest() throws IOException, Re } @Test - public void testProjectFittingFile_ExcludeLanguagesOnly() throws IOException, ResponseException { + public void testProjectFittingFile_ExportLanguagesOnly() throws IOException, ResponseException { NewPropertiesWithFilesUtilBuilder pbBuilder = NewPropertiesWithFilesUtilBuilder .minimalBuiltPropertiesBean("*", Utils.PATH_SEPARATOR + "%original_file_name%-CR-%locale%") .setBasePath(project.getBasePath()) diff --git a/src/test/java/com/crowdin/cli/properties/NewPropertiesWithFilesUtilBuilder.java b/src/test/java/com/crowdin/cli/properties/NewPropertiesWithFilesUtilBuilder.java index 450c13701..6f404f849 100644 --- a/src/test/java/com/crowdin/cli/properties/NewPropertiesWithFilesUtilBuilder.java +++ b/src/test/java/com/crowdin/cli/properties/NewPropertiesWithFilesUtilBuilder.java @@ -125,6 +125,14 @@ public String buildToString() { if (pb.getPreserveHierarchy() != null) { sb.append("\"preserve_hierarchy\": \"").append(pb.getPreserveHierarchy()).append("\"\n"); } + if (pb.getExportLanguages() != null && !pb.getExportLanguages().isEmpty()) { + sb.append("\"export_languages\": ["); + for (int i = 0; i < pb.getExportLanguages().size(); i++) { + if (i > 0) sb.append(", "); + sb.append("\"").append(pb.getExportLanguages().get(i)).append("\""); + } + sb.append("]\n"); + } if (pb.getFiles() != null && !pb.getFiles().isEmpty()) { sb.append("files: [\n"); for (FileBean fb : pb.getFiles()) { diff --git a/src/test/java/com/crowdin/cli/properties/PropertiesWithFilesTest.java b/src/test/java/com/crowdin/cli/properties/PropertiesWithFilesTest.java index 766205b4b..7794952af 100644 --- a/src/test/java/com/crowdin/cli/properties/PropertiesWithFilesTest.java +++ b/src/test/java/com/crowdin/cli/properties/PropertiesWithFilesTest.java @@ -6,6 +6,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import static com.crowdin.cli.properties.PropertiesConfigurator.CheckType; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -119,4 +124,28 @@ public void testCheckProperties_sourceDoubleAsteriskPattern_noError() { assertTrue(messages.getErrors().isEmpty(), "Expected no errors when ** pattern matches files"); } + + @Test + public void testPropertiesWithFiles_parseExportLanguagesFromMap() { + Map map = new HashMap<>(); + map.put("project_id", "1"); + map.put("api_token", "token"); + map.put("base_path", "."); + map.put("base_url", "https://crowdin.com"); + map.put("preserve_hierarchy", false); + + List> files = new ArrayList<>(); + Map file = new HashMap<>(); + file.put("source", "*"); + file.put("translation", "/%original_file_name%-CR-%locale%"); + files.add(file); + map.put("files", files); + + map.put("export_languages", List.of("de", "ua")); + + PropertiesWithFiles props = new PropertiesWithFiles(); + PropertiesWithFiles.CONFIGURATOR.populateWithValues(props, map); + + assertEquals(List.of("de", "ua"), props.getExportLanguages()); + } }