diff --git a/cli/pom.xml b/cli/pom.xml index 813b35ac60..f0cf219b76 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -114,6 +114,7 @@ ${java.version} ${java.version} + true org.immutables diff --git a/cli/src/main/java/com/box/l10n/mojito/cli/command/PullCommand.java b/cli/src/main/java/com/box/l10n/mojito/cli/command/PullCommand.java index 42de156dc2..0e64fad2cc 100644 --- a/cli/src/main/java/com/box/l10n/mojito/cli/command/PullCommand.java +++ b/cli/src/main/java/com/box/l10n/mojito/cli/command/PullCommand.java @@ -17,6 +17,8 @@ import com.box.l10n.mojito.rest.entity.RepositoryLocale; import com.box.l10n.mojito.rest.entity.RepositoryLocaleStatistic; import com.box.l10n.mojito.rest.entity.RepositoryStatistic; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.AbstractMap.SimpleEntry; import java.util.HashMap; @@ -105,6 +107,12 @@ enum LocaleMappingType { description = Param.FILTER_OPTIONS_DESCRIPTION) List filterOptionsParam; + @Parameter( + names = {"--skip-empty-output"}, + description = + "Skip writing localized files when the generated content is empty (also requests the Android filter to blank empty resources)") + boolean skipEmptyOutput = false; + @Parameter( names = {Param.SOURCE_LOCALE_LONG, Param.SOURCE_LOCALE_SHORT}, arity = 1, @@ -431,12 +439,40 @@ void writeLocalizedAssetToTargetDirectory( .getTargetDirectoryPath() .resolve(sourceFileMatch.getTargetPath(localizedAsset.getBcp47Tag())); + if (skipWritingEmptyOutput(localizedAsset.getContent(), targetPath, sourceFileMatch)) { + return; + } + commandHelper.writeFileContent(localizedAsset.getContent(), targetPath, sourceFileMatch); Path relativeTargetFilePath = commandDirectories.relativizeWithUserDirectory(targetPath); consoleWriter.a(" --> ").fg(Color.MAGENTA).a(relativeTargetFilePath.toString()).println(); } + boolean skipWritingEmptyOutput(String content, Path targetPath, FileMatch sourceFileMatch) + throws CommandException { + if (!skipEmptyOutput || !(content == null || content.isBlank())) { + return false; + } + + try { + Files.deleteIfExists(targetPath); + } catch (IOException e) { + throw new CommandException( + "Cannot delete empty output file in path: " + targetPath.toString(), e); + } + + Path relativeTargetFilePath = commandDirectories.relativizeWithUserDirectory(targetPath); + consoleWriter + .a(" --> ") + .fg(Color.MAGENTA) + .a("skipped empty content: ") + .a(relativeTargetFilePath.toString()) + .println(); + + return true; + } + LocalizedAssetBody getLocalizedAsset( Repository repository, FileMatch sourceFileMatch, diff --git a/cli/src/main/java/com/box/l10n/mojito/cli/command/PullCommandParallel.java b/cli/src/main/java/com/box/l10n/mojito/cli/command/PullCommandParallel.java index 1221a5b75a..2d09a8ca02 100644 --- a/cli/src/main/java/com/box/l10n/mojito/cli/command/PullCommandParallel.java +++ b/cli/src/main/java/com/box/l10n/mojito/cli/command/PullCommandParallel.java @@ -49,6 +49,7 @@ public PullCommandParallel(PullCommand pullCommand) { this.pullRunName = pullCommand.pullRunName; this.recordPullRun = pullCommand.recordPullRun; this.isParallel = pullCommand.isParallel; + this.skipEmptyOutput = pullCommand.skipEmptyOutput; } public void pull() throws CommandException { @@ -117,6 +118,10 @@ void writeLocalizedAssetToTargetDirectory( .getTargetDirectoryPath() .resolve(sourceFileMatch.getTargetPath(localizedAsset.getBcp47Tag())); + if (skipWritingEmptyOutput(localizedAsset.getContent(), targetPath, sourceFileMatch)) { + return; + } + commandHelper.writeFileContent(localizedAsset.getContent(), targetPath, sourceFileMatch); Path relativeTargetFilePath = commandDirectories.relativizeWithUserDirectory(targetPath); diff --git a/cli/src/main/java/com/box/l10n/mojito/cli/command/UserCreateCommand.java b/cli/src/main/java/com/box/l10n/mojito/cli/command/UserCreateCommand.java index a73eed71df..9fdde8448b 100644 --- a/cli/src/main/java/com/box/l10n/mojito/cli/command/UserCreateCommand.java +++ b/cli/src/main/java/com/box/l10n/mojito/cli/command/UserCreateCommand.java @@ -9,6 +9,9 @@ import com.box.l10n.mojito.rest.client.exception.ResourceNotCreatedException; import com.box.l10n.mojito.rest.entity.Role; import com.box.l10n.mojito.rest.entity.User; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.List; import org.fusesource.jansi.Ansi; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,18 +73,61 @@ public class UserCreateCommand extends Command { description = Param.COMMON_NAME_DESCRIPTION) String commonName; + @Parameter( + names = {"--locales", "-l"}, + variableArity = true, + description = "List of locales (BCP47 tags) translators can work on, e.g. fr-FR ja-JP") + List locales; + + @Parameter( + names = {"--generate-password", "-gp"}, + description = "Generate a secure random password instead of prompting") + boolean generatePassword = false; + @Autowired Console console; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + @Override protected void execute() throws CommandException { consoleWriter.a("Create user: ").fg(Ansi.Color.CYAN).a(username).println(); try { - consoleWriter.a("Enter new password for " + username + ":").println(); - String password = console.readPassword(); + String password; + if (generatePassword) { + password = generateSecurePassword(); + consoleWriter + .a("Generated password for ") + .fg(Ansi.Color.CYAN) + .a(username) + .a(": ") + .fg(Ansi.Color.YELLOW) + .a(password) + .println(); + } else { + consoleWriter.a("Enter new password for " + username + ":").println(); + password = console.readPassword(); + } Role role = Role.fromString(rolename); - User user = userClient.createUser(username, password, role, surname, givenName, commonName); + + User user; + if (Role.ROLE_TRANSLATOR.equals(role)) { + boolean hasLocales = locales != null && !locales.isEmpty(); + boolean canTranslateAllLocales = !hasLocales; + user = + userClient.createUser( + username, + password, + role, + surname, + givenName, + commonName, + locales, + canTranslateAllLocales); + } else { + user = userClient.createUser(username, password, role, surname, givenName, commonName); + } consoleWriter .newLine() .a("created --> user: ") @@ -92,4 +138,10 @@ protected void execute() throws CommandException { throw new CommandException(ex.getMessage(), ex); } } + + private String generateSecurePassword() { + byte[] bytes = new byte[18]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } } diff --git a/cli/src/test/java/com/box/l10n/mojito/cli/command/PullCommandTest.java b/cli/src/test/java/com/box/l10n/mojito/cli/command/PullCommandTest.java index 6d11658a65..6a0a58ab7f 100644 --- a/cli/src/test/java/com/box/l10n/mojito/cli/command/PullCommandTest.java +++ b/cli/src/test/java/com/box/l10n/mojito/cli/command/PullCommandTest.java @@ -652,6 +652,18 @@ public void pullAndroidStrings() throws Exception { "-t", getTargetTestDir("target").getAbsolutePath()); + getL10nJCommander() + .run( + "pull", + "-r", + repository.getName(), + "-s", + getInputResourcesTestDir("source").getAbsolutePath(), + "-t", + getTargetTestDir("removeDescription").getAbsolutePath(), + "-fo", + "removeDescription=true"); + getL10nJCommander() .run( "pull", @@ -665,6 +677,42 @@ public void pullAndroidStrings() throws Exception { checkExpectedGeneratedResources(); } + @Test + public void pullAndroidStringsSkipEmpty() throws Exception { + + Repository repository = createTestRepoUsingRepoService(); + + getL10nJCommander() + .run( + "push", + "-r", + repository.getName(), + "-s", + getInputResourcesTestDir("source").getAbsolutePath()); + + Asset asset = + assetClient.getAssetByPathAndRepositoryId("res/values/strings.xml", repository.getId()); + + getL10nJCommander() + .run( + "pull", + "-r", + repository.getName(), + "-s", + getInputResourcesTestDir("source").getAbsolutePath(), + "-t", + getTargetTestDir("target").getAbsolutePath(), + "-fo", + "postEmptyResourcesToEmptyFile=true", + "postRemoveTranslatableFalse=true", + "removeDescription=true", + "--inheritance-mode", + "REMOVE_UNTRANSLATED", + "--skip-empty-output"); + + checkExpectedGeneratedResources(); + } + @Test public void pullMacStrings() throws Exception { diff --git a/cli/src/test/java/com/box/l10n/mojito/cli/command/UserCreateCommandTest.java b/cli/src/test/java/com/box/l10n/mojito/cli/command/UserCreateCommandTest.java index c8e24a8671..c728831cdc 100644 --- a/cli/src/test/java/com/box/l10n/mojito/cli/command/UserCreateCommandTest.java +++ b/cli/src/test/java/com/box/l10n/mojito/cli/command/UserCreateCommandTest.java @@ -1,6 +1,7 @@ package com.box.l10n.mojito.cli.command; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -11,6 +12,10 @@ import com.box.l10n.mojito.entity.security.user.User; import com.box.l10n.mojito.security.Role; import com.box.l10n.mojito.service.security.user.UserRepository; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; import org.junit.Test; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -62,7 +67,46 @@ public void testCreateUserWithDuplicatedUsername() throws Exception { outputCapture.toString().contains("User with username [" + username + "] already exists")); } + @Test + public void testCreateUserWithGeneratedPassword() throws Exception { + + String username = testIdWatcher.getEntityName("user"); + String commonName = createTestUser(username, null, null, true); + + User user = userRepository.findByUsername(username); + assertEquals(commonName, user.getCommonName()); + assertTrue(outputCapture.toString().contains("Generated password for " + username + ":")); + } + + @Test + public void testCreateTranslatorWithLocales() throws Exception { + + String username = testIdWatcher.getEntityName("user"); + List locales = Arrays.asList("fr-FR", "ja-JP"); + String commonName = createTestUser(username, "TRANSLATOR", locales); + + User user = userRepository.findByUsername(username); + assertEquals(commonName, user.getCommonName()); + assertFalse(user.getCanTranslateAllLocales()); + assertEquals( + locales.stream().collect(Collectors.toSet()), + user.getUserLocales().stream() + .map(userLocale -> userLocale.getLocale().getBcp47Tag()) + .collect(Collectors.toSet())); + } + private String createTestUser(String username, String role) throws Exception { + return createTestUser(username, role, null); + } + + private String createTestUser(String username, String role, List localeTags) + throws Exception { + return createTestUser(username, role, localeTags, false); + } + + private String createTestUser( + String username, String role, List localeTags, boolean generatePassword) + throws Exception { String surname = "Mojito"; String givenName = "Test"; String commonName = "Test Mojito " + username; @@ -83,32 +127,34 @@ public String answer(InvocationOnMock invocation) throws Throwable { userCreateCommand.console = mockConsole; logger.debug("Creating user with username: {}", username); - if (role == null) { - l10nJCommander.run( - "user-create", - Param.USERNAME_SHORT, - username, - Param.SURNAME_SHORT, - surname, - Param.GIVEN_NAME_SHORT, - givenName, - Param.COMMON_NAME_SHORT, - commonName); - } else { - l10nJCommander.run( - "user-create", - Param.USERNAME_SHORT, - username, - Param.ROLE_SHORT, - role, - Param.SURNAME_SHORT, - surname, - Param.GIVEN_NAME_SHORT, - givenName, - Param.COMMON_NAME_SHORT, - commonName); + List params = + new ArrayList<>( + Arrays.asList( + "user-create", + Param.USERNAME_SHORT, + username, + Param.SURNAME_SHORT, + surname, + Param.GIVEN_NAME_SHORT, + givenName, + Param.COMMON_NAME_SHORT, + commonName)); + + if (role != null) { + params.addAll(Arrays.asList(Param.ROLE_SHORT, role)); + } + + if (localeTags != null && !localeTags.isEmpty()) { + params.add("-l"); + params.addAll(localeTags); } + if (generatePassword) { + params.add("-gp"); + } + + l10nJCommander.run(params.toArray(new String[0])); + assertTrue(outputCapture.toString().contains("created --> user: ")); return commonName; } diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-fr-rCA/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-fr-rCA/strings.xml new file mode 100644 index 0000000000..874d11e030 --- /dev/null +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-fr-rCA/strings.xml @@ -0,0 +1,13 @@ + + + + %1$s fr + \'Description\' de + 100 + \"caractères\" :\n + + 15 min + 1 jour + 1 heure + 1 mois + diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-fr-rFR/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-fr-rFR/strings.xml new file mode 100644 index 0000000000..874d11e030 --- /dev/null +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-fr-rFR/strings.xml @@ -0,0 +1,13 @@ + + + + %1$s fr + \'Description\' de + 100 + \"caractères\" :\n + + 15 min + 1 jour + 1 heure + 1 mois + diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-ja-rJP/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-ja-rJP/strings.xml new file mode 100644 index 0000000000..3b72edad8f --- /dev/null +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/removeDescription/res/values-ja-rJP/strings.xml @@ -0,0 +1,12 @@ + + + + %1$s fr + 100 + \"文字\"\'の説明\':\n + + 15分 + 1日 + 1時間 + 1か月 + diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-fr-rCA/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-fr-rCA/strings.xml index 8198dbbfd9..ddfe067cec 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-fr-rCA/strings.xml +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-fr-rCA/strings.xml @@ -1,6 +1,6 @@ - \'Description\' de 100 \"caractères\" :\n + %1$s fr \'Description\' de 100 \"caractères\" :\n 15 min 1 jour 1 heure diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-fr-rFR/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-fr-rFR/strings.xml index 8198dbbfd9..ddfe067cec 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-fr-rFR/strings.xml +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-fr-rFR/strings.xml @@ -1,6 +1,6 @@ - \'Description\' de 100 \"caractères\" :\n + %1$s fr \'Description\' de 100 \"caractères\" :\n 15 min 1 jour 1 heure diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-ja-rJP/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-ja-rJP/strings.xml index 9454874e14..fccf2a10db 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-ja-rJP/strings.xml +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target/res/values-ja-rJP/strings.xml @@ -1,6 +1,6 @@ - 100\"文字\"\'の説明\':\n + %1$s fr100\"文字\"\'の説明\':\n 15分 1日 1時間 diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-fr-rCA/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-fr-rCA/strings.xml index 1d45f968c5..e8af130b15 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-fr-rCA/strings.xml +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-fr-rCA/strings.xml @@ -1,6 +1,6 @@ - \'Description\' de 100 \"caractères\" :\n + %1$s fr \'Description\' de 100 \"caractères\" :\n 15 min 1 heure 1 mois diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-fr-rFR/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-fr-rFR/strings.xml index 1d45f968c5..e8af130b15 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-fr-rFR/strings.xml +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-fr-rFR/strings.xml @@ -1,6 +1,6 @@ - \'Description\' de 100 \"caractères\" :\n + %1$s fr \'Description\' de 100 \"caractères\" :\n 15 min 1 heure 1 mois diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-ja-rJP/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-ja-rJP/strings.xml index 79e6af1ef0..2e196c2c42 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-ja-rJP/strings.xml +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/expected/target_modified/res/values-ja-rJP/strings.xml @@ -1,6 +1,6 @@ - 100\"文字\"\'の説明\':\n + %1$s fr100\"文字\"\'の説明\':\n 15分 1時間 1か月 diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/source/res/values/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/source/res/values/strings.xml index 4b69b4c3e2..ccf42f3d67 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/source/res/values/strings.xml +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/source/res/values/strings.xml @@ -1,6 +1,6 @@ - 100 \"character\" \'description\':\n + %1$s en100 \"character\" \'description\':\n 15 min 1 day 1 hour diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/source_modified/res/values/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/source_modified/res/values/strings.xml index 41d4434b32..e023e30ae2 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/source_modified/res/values/strings.xml +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/source_modified/res/values/strings.xml @@ -1,6 +1,6 @@ - 100 \"character\" \'description\':\n + %1$s en100 \"character\" \'description\':\n 15 min 1 hour 1 month diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/translations/source-xliff_fr-FR.xliff b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/translations/source-xliff_fr-FR.xliff index 7edd47f149..8ae817ce79 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/translations/source-xliff_fr-FR.xliff +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/translations/source-xliff_fr-FR.xliff @@ -3,9 +3,9 @@ - <b>100</b> "character" 'description': + <annotation tag="user">%1$s en</annotation><b>100</b> "character" 'description': - 'Description' de <b>100</b> "caractères" : + <annotation tag="user">%1$s fr</annotation> 'Description' de <b>100</b> "caractères" : diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/translations/source-xliff_ja-JP.xliff b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/translations/source-xliff_ja-JP.xliff index 924d650de0..fb3e2ed757 100644 --- a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/translations/source-xliff_ja-JP.xliff +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStrings/input/translations/source-xliff_ja-JP.xliff @@ -3,9 +3,9 @@ - <b>100</b> "character" 'description': + <annotation tag="user">%1$s en</annotation><b>100</b> "character" 'description': - <b>100</b>"文字"'の説明': + <annotation tag="user">%1$s fr</annotation><b>100</b>"文字"'の説明': diff --git a/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStringsSkipEmpty/input/source/res/values/strings.xml b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStringsSkipEmpty/input/source/res/values/strings.xml new file mode 100644 index 0000000000..92b6e06dd7 --- /dev/null +++ b/cli/src/test/resources/com/box/l10n/mojito/cli/command/PullCommandTest_IO/pullAndroidStringsSkipEmpty/input/source/res/values/strings.xml @@ -0,0 +1,9 @@ + + + + 100 \"character\" \'description\':\n + 15 min + 1 day + 1 hour + 1 month + diff --git a/common/src/main/java/com/box/l10n/mojito/okapi/filters/AndroidFilter.java b/common/src/main/java/com/box/l10n/mojito/okapi/filters/AndroidFilter.java index 06ff9c6d59..3963e1f11b 100644 --- a/common/src/main/java/com/box/l10n/mojito/okapi/filters/AndroidFilter.java +++ b/common/src/main/java/com/box/l10n/mojito/okapi/filters/AndroidFilter.java @@ -63,6 +63,9 @@ public class AndroidFilter extends XMLFilter { private static final String POST_PROCESS_REMOVE_TRANSLATABLE_FALSE = "postRemoveTranslatableFalse"; + private static final String POST_PROCESS_EMPTY_RESOURCES_TO_EMPTY_FILE = + "postEmptyResourcesToEmptyFile"; + private static final Pattern PATTERN_PLURAL_START = Pattern.compile(""); private static final Pattern PATTERN_XML_COMMENT = Pattern.compile(""); @@ -116,6 +119,8 @@ public List getConfigurations() { boolean removeTranslatableFalse = false; + boolean emptyResourcesToEmptyFile = false; + int postProcessIndent = 2; /** @@ -140,6 +145,7 @@ public void open(RawDocument input) { removeDescription, postProcessIndent, removeTranslatableFalse, + emptyResourcesToEmptyFile, shouldApplyPostProcessingRemoveUntranslatedExcluded))); } @@ -171,6 +177,13 @@ void applyFilterOptions(RawDocument input) { shouldApplyPostProcessingRemoveUntranslatedExcluded = true; }); + filterOptions.getBoolean( + POST_PROCESS_EMPTY_RESOURCES_TO_EMPTY_FILE, + b -> { + emptyResourcesToEmptyFile = b; + shouldApplyPostProcessingRemoveUntranslatedExcluded = true; + }); + filterOptions.getInteger( POST_PROCESS_INDENT, i -> { @@ -446,6 +459,7 @@ static class AndroidFilePostProcessor extends OutputDocumentPostProcessorBase { boolean removeDescription; boolean removeTranslatableFalse; int indent; + boolean emptyResourcesToEmptyFile; boolean shouldApplyPostProcessingRemoveUntranslatedExcluded; AndroidFilePostProcessor( @@ -453,11 +467,13 @@ static class AndroidFilePostProcessor extends OutputDocumentPostProcessorBase { boolean removeDescription, int indent, boolean removeTranslatableFalse, + boolean emptyResourcesToEmptyFile, boolean shouldApplyPostProcessingRemoveUntranslatedExcluded) { this.setRemoveUntranslated(removeUntranslated); this.removeDescription = removeDescription; this.removeTranslatableFalse = removeTranslatableFalse; this.indent = indent; + this.emptyResourcesToEmptyFile = emptyResourcesToEmptyFile; this.shouldApplyPostProcessingRemoveUntranslatedExcluded = shouldApplyPostProcessingRemoveUntranslatedExcluded; } @@ -540,6 +556,10 @@ public String execute(String xmlContent) { } removeWhitespaceNodes(document); + if (emptyResourcesToEmptyFile && isResourcesEmpty(document)) { + return ""; + } + TransformerFactory transformerFactory = TransformerFactory.newInstance(); Transformer transformer = transformerFactory.newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); @@ -601,5 +621,26 @@ void removeTranslatableFalseElements(Node node) { } } } + + boolean isResourcesEmpty(Document document) { + Element root = document.getDocumentElement(); + if (root == null) { + return false; + } + + NodeList childNodes = root.getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node child = childNodes.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + String nodeName = child.getNodeName(); + if ("string".equals(nodeName) + || "plurals".equals(nodeName) + || "string-array".equals(nodeName)) { + return false; + } + } + } + return true; + } } } diff --git a/common/src/main/java/com/box/l10n/mojito/okapi/filters/AndroidXMLEncoder.java b/common/src/main/java/com/box/l10n/mojito/okapi/filters/AndroidXMLEncoder.java index d82423de1e..d8cb944a01 100644 --- a/common/src/main/java/com/box/l10n/mojito/okapi/filters/AndroidXMLEncoder.java +++ b/common/src/main/java/com/box/l10n/mojito/okapi/filters/AndroidXMLEncoder.java @@ -31,7 +31,7 @@ public class AndroidXMLEncoder extends net.sf.okapi.common.encoder.XMLEncoder { // trying to match variables between html tags, for example, %d, %1$s, %2$s private static final Pattern ANDROID_VARIABLE_WITHIN_HTML = Pattern.compile( - "(<(?:b|i|u|annotation.*?)>)((.*?)%(([-0+ #]?)[-0+ #]?)((\\d\\$)?)(([\\d\\*]*)(\\.[\\d\\*]*)?)[dioxXucsfeEgGpn](.*?))+(</(?:b|i|u|annotation.*?)>)"); + "(<[biu]>)((.*?)%(([-0+ #]?)[-0+ #]?)((\\d\\$)?)(([\\d\\*]*)(\\.[\\d\\*]*)?)[dioxXucsfeEgGpn](.*?))+(</[biu]>)"); private static final Pattern ANDROID_HTML = Pattern.compile("(<)(/?)(b|i|u|annotation.*?)(>)"); private static final Pattern LINE_FEED = Pattern.compile("\n"); diff --git a/common/src/test/java/com/box/l10n/mojito/okapi/filters/AndroidFilterTest.java b/common/src/test/java/com/box/l10n/mojito/okapi/filters/AndroidFilterTest.java index 90874d2bce..2fffcfa7d8 100644 --- a/common/src/test/java/com/box/l10n/mojito/okapi/filters/AndroidFilterTest.java +++ b/common/src/test/java/com/box/l10n/mojito/okapi/filters/AndroidFilterTest.java @@ -113,7 +113,7 @@ void testUnescaping(String input, String expected) { @Test public void testPostProcessingKeepDescription() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, false, 2, false, false); + new AndroidFilter.AndroidFilePostProcessor(true, false, 2, false, false, false); String input = """ @@ -157,7 +157,7 @@ public void testPostProcessingKeepDescription() { @Test public void testPostProcessingRemoveDescription() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, true, 2, false, false); + new AndroidFilter.AndroidFilePostProcessor(true, true, 2, false, false, false); String input = """ @@ -201,17 +201,33 @@ public void testPostProcessingRemoveDescription() { @Test public void testPostProcessingEmptyFile() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, true, 2, false, false); + new AndroidFilter.AndroidFilePostProcessor(true, true, 2, false, false, false); String input = ""; String output = androidFilePostProcessor.execute(input); String expected = ""; assertEquals(expected, output); } + @Test + public void testPostProcessingEmptyResourcesToEmptyFile() { + AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = + new AndroidFilter.AndroidFilePostProcessor(false, false, 2, false, true, true); + String input = + """ + + + + + """; + + String output = androidFilePostProcessor.execute(input); + assertEquals("", output); + } + @Test public void testPostProcessingNoProlog() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, true, 2, false, false); + new AndroidFilter.AndroidFilePostProcessor(true, true, 2, false, false, false); String input = """ @@ -232,7 +248,7 @@ public void testPostProcessingNoProlog() { @Test public void testPostProcessingRemoveTranslatableFalse() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false); + new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false, false); String input = """ @@ -271,7 +287,7 @@ public void testPostProcessingRemoveTranslatableFalse() { @Test public void testPostProcessingRemoveMissingOther() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false); + new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false, false); String input = """ @@ -293,7 +309,7 @@ public void testPostProcessingRemoveMissingOther() { @Test public void testPostProcessingRemoveMissingOtherUntranslated() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false); + new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false, false); String input = """ @@ -316,7 +332,7 @@ public void testPostProcessingRemoveMissingOtherUntranslated() { @Test public void testPostProcessingStandaloneNo() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false); + new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false, false); String input = """ @@ -355,7 +371,7 @@ public void testPostProcessingStandaloneNo() { @Test public void testPostProcessingStandaloneYes() { AndroidFilter.AndroidFilePostProcessor androidFilePostProcessor = - new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false); + new AndroidFilter.AndroidFilePostProcessor(true, true, 2, true, false, false); String input = """ diff --git a/pom.xml b/pom.xml index 629f491990..64f1261c68 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.5 + 3.1.9 @@ -101,6 +101,7 @@ ${java.version} ${java.version} + true org.immutables @@ -230,4 +231,4 @@ webapp mavenplugin - \ No newline at end of file + diff --git a/restclient/src/main/java/com/box/l10n/mojito/rest/client/AssetClient.java b/restclient/src/main/java/com/box/l10n/mojito/rest/client/AssetClient.java index 02412a236c..8ca90a7086 100644 --- a/restclient/src/main/java/com/box/l10n/mojito/rest/client/AssetClient.java +++ b/restclient/src/main/java/com/box/l10n/mojito/rest/client/AssetClient.java @@ -13,17 +13,12 @@ import com.box.l10n.mojito.rest.entity.RepositoryLocale; import com.box.l10n.mojito.rest.entity.SourceAsset; import com.box.l10n.mojito.rest.entity.XliffExportBody; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpEntity; import org.springframework.stereotype.Component; -import org.springframework.util.Assert; import org.springframework.web.util.UriComponentsBuilder; /** @@ -288,8 +283,8 @@ public ImportLocalizedAssetBody importLocalizedAssetForContent( public Asset getAssetByPathAndRepositoryId(String path, Long repositoryId) throws AssetNotFoundException { - Assert.notNull(path, "path must not be null"); - Assert.notNull(repositoryId, "repository must not be null"); + Objects.requireNonNull(path, "path must not be null"); + Objects.requireNonNull(repositoryId, "repository must not be null"); List assets = getAssets(path, repositoryId); @@ -438,7 +433,7 @@ public PollableTask deleteAssetsInBranch(Set assetIds, Long branchId) { */ public List getAssetIds( Long repositoryId, Boolean deleted, Boolean virtual, Long branchId) { - Assert.notNull(repositoryId); + Objects.requireNonNull(repositoryId); UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromPath(getBasePathForEntity() + "/ids"); diff --git a/restclient/src/main/java/com/box/l10n/mojito/rest/client/TextUnitClient.java b/restclient/src/main/java/com/box/l10n/mojito/rest/client/TextUnitClient.java index 4abebde773..660190d6a1 100644 --- a/restclient/src/main/java/com/box/l10n/mojito/rest/client/TextUnitClient.java +++ b/restclient/src/main/java/com/box/l10n/mojito/rest/client/TextUnitClient.java @@ -57,6 +57,7 @@ public enum StatusFilter { APPROVED_AND_NOT_REJECTED, FOR_TRANSLATION, REVIEW_NEEDED, + REVIEW_NEEDED_OR_REJECTED, REVIEW_NOT_NEEDED, TRANSLATION_NEEDED, REJECTED, diff --git a/restclient/src/main/java/com/box/l10n/mojito/rest/client/UserClient.java b/restclient/src/main/java/com/box/l10n/mojito/rest/client/UserClient.java index df764caf9b..4d559627e7 100644 --- a/restclient/src/main/java/com/box/l10n/mojito/rest/client/UserClient.java +++ b/restclient/src/main/java/com/box/l10n/mojito/rest/client/UserClient.java @@ -3,21 +3,25 @@ import com.box.l10n.mojito.rest.client.exception.ResourceNotCreatedException; import com.box.l10n.mojito.rest.client.exception.ResourceNotFoundException; import com.box.l10n.mojito.rest.entity.Authority; +import com.box.l10n.mojito.rest.entity.Locale; import com.box.l10n.mojito.rest.entity.Page; import com.box.l10n.mojito.rest.entity.Role; import com.box.l10n.mojito.rest.entity.User; +import com.box.l10n.mojito.rest.entity.UserLocale; import com.google.common.collect.Sets; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; /** @@ -78,6 +82,33 @@ public User createUser( String givenName, String commonName) throws ResourceNotCreatedException { + return createUser(username, password, role, surname, givenName, commonName, null, true); + } + + /** + * Creates a {@link User} + * + * @param username + * @param password + * @param role + * @param surname + * @param givenName + * @param commonName + * @param localeTags + * @param canTranslateAllLocales + * @return + * @throws com.box.l10n.mojito.rest.client.exception.ResourceNotCreatedException + */ + public User createUser( + String username, + String password, + Role role, + String surname, + String givenName, + String commonName, + List localeTags, + boolean canTranslateAllLocales) + throws ResourceNotCreatedException { logger.debug("Creating user with username [{}]", username); User userToCreate = new User(); @@ -86,6 +117,7 @@ public User createUser( userToCreate.setSurname(surname); userToCreate.setGivenName(givenName); userToCreate.setCommonName(commonName); + userToCreate.setCanTranslateAllLocales(canTranslateAllLocales); if (role != null) { Authority authority = new Authority(); @@ -93,6 +125,15 @@ public User createUser( userToCreate.setAuthorities(Sets.newHashSet(authority)); } + if (localeTags != null) { + Set userLocales = + localeTags.stream() + .filter(StringUtils::hasText) + .map(this::createUserLocale) + .collect(Collectors.toSet()); + userToCreate.setUserLocales(userLocales); + } + try { return authenticatedRestTemplate.postForObject( getBasePathForEntity(), userToCreate, User.class); @@ -106,6 +147,14 @@ public User createUser( } } + private UserLocale createUserLocale(String localeTag) { + Locale locale = new Locale(); + locale.setBcp47Tag(localeTag); + UserLocale userLocale = new UserLocale(); + userLocale.setLocale(locale); + return userLocale; + } + /** * Deletes a {@link User} by the {@link User#username} * diff --git a/restclient/src/main/java/com/box/l10n/mojito/rest/entity/User.java b/restclient/src/main/java/com/box/l10n/mojito/rest/entity/User.java index 5b7dd27e81..4db7435847 100644 --- a/restclient/src/main/java/com/box/l10n/mojito/rest/entity/User.java +++ b/restclient/src/main/java/com/box/l10n/mojito/rest/entity/User.java @@ -25,7 +25,7 @@ public class User { private String commonName; - private boolean canTranslateAllLocales; + private boolean canTranslateAllLocales = true; @JsonManagedReference Set authorities = new HashSet<>(); diff --git a/test-common/src/main/java/com/box/l10n/mojito/test/IOTestBase.java b/test-common/src/main/java/com/box/l10n/mojito/test/IOTestBase.java index 9de797bb72..f66df3c9d5 100644 --- a/test-common/src/main/java/com/box/l10n/mojito/test/IOTestBase.java +++ b/test-common/src/main/java/com/box/l10n/mojito/test/IOTestBase.java @@ -13,6 +13,11 @@ import java.util.Collection; import java.util.List; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOCase; +import org.apache.commons.io.filefilter.IOFileFilter; +import org.apache.commons.io.filefilter.NameFileFilter; +import org.apache.commons.io.filefilter.NotFileFilter; +import org.apache.commons.io.filefilter.TrueFileFilter; import org.junit.Assert; import org.junit.Before; import org.junit.Rule; @@ -234,8 +239,13 @@ protected void checkDirectoriesContainSameContent(File dir1, File dir2) throws DifferentDirectoryContentException { try { - Collection listFiles1 = FileUtils.listFiles(dir1, null, true); - Collection listFiles2 = FileUtils.listFiles(dir2, null, true); + IOFileFilter filesToCompare = + new NotFileFilter(new NameFileFilter(".gitkeep", IOCase.SENSITIVE)); + + Collection listFiles1 = + FileUtils.listFiles(dir1, filesToCompare, TrueFileFilter.INSTANCE); + Collection listFiles2 = + FileUtils.listFiles(dir2, filesToCompare, TrueFileFilter.INSTANCE); // Get all the files inside the source directory, recursively for (File file1 : listFiles1) { diff --git a/webapp/pom.xml b/webapp/pom.xml index b6bcb08439..81ec55d24d 100644 --- a/webapp/pom.xml +++ b/webapp/pom.xml @@ -336,6 +336,7 @@ ${java.version} ${java.version} + true org.immutables diff --git a/webapp/src/main/java/com/box/l10n/mojito/Application.java b/webapp/src/main/java/com/box/l10n/mojito/Application.java index f3e6f01210..2cb07c6323 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/Application.java +++ b/webapp/src/main/java/com/box/l10n/mojito/Application.java @@ -14,6 +14,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration; import org.springframework.boot.context.ApplicationPidFileWriter; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.AdviceMode; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; @@ -43,6 +44,7 @@ @EnableTransactionManagement(mode = AdviceMode.ASPECTJ) @EnableRetry @EntityScan(basePackageClasses = BaseEntity.class) +@ConfigurationPropertiesScan public class Application { // TODO(spring2), find replacement - this was commented in previous attempt diff --git a/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/User.java b/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/User.java index 7e90ff05fc..a603610824 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/User.java +++ b/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/User.java @@ -45,7 +45,7 @@ attributeNodes = {@NamedAttributeNode("createdByUser"), @NamedAttributeNode("user")}), @NamedSubgraph( name = "User.legacy.userLocales", - attributeNodes = {@NamedAttributeNode("user")}), + attributeNodes = {@NamedAttributeNode("user"), @NamedAttributeNode("locale")}), }) public class User extends AuditableEntity implements Serializable { diff --git a/webapp/src/main/java/com/box/l10n/mojito/react/ReactAppController.java b/webapp/src/main/java/com/box/l10n/mojito/react/ReactAppController.java index 82f3ae1573..d409d22881 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/react/ReactAppController.java +++ b/webapp/src/main/java/com/box/l10n/mojito/react/ReactAppController.java @@ -79,6 +79,7 @@ public class ReactAppController { "branches", "screenshots", "screenshots-legacy", + "ai-translate", "settings", "settings/user-management", "settings/box", diff --git a/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/SearchTextUnitsHybridConfig.java b/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/SearchTextUnitsHybridConfig.java new file mode 100644 index 0000000000..7b00859f57 --- /dev/null +++ b/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/SearchTextUnitsHybridConfig.java @@ -0,0 +1,27 @@ +package com.box.l10n.mojito.rest.textunit; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class SearchTextUnitsHybridConfig { + + private final SearchTextUnitsHybridProperties searchTextUnitsHybridProperties; + + public SearchTextUnitsHybridConfig( + SearchTextUnitsHybridProperties searchTextUnitsHybridProperties) { + this.searchTextUnitsHybridProperties = searchTextUnitsHybridProperties; + } + + @Bean(name = "searchTextUnitsHybridExecutor") + public ThreadPoolTaskExecutor searchTextUnitsHybridExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(searchTextUnitsHybridProperties.pool().corePoolSize()); + executor.setMaxPoolSize(searchTextUnitsHybridProperties.pool().maxPoolSize()); + executor.setQueueCapacity(searchTextUnitsHybridProperties.pool().queueCapacity()); + executor.setThreadNamePrefix(searchTextUnitsHybridProperties.pool().threadNamePrefix() + "-"); + executor.initialize(); + return executor; + } +} diff --git a/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/SearchTextUnitsHybridProperties.java b/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/SearchTextUnitsHybridProperties.java new file mode 100644 index 0000000000..669c142173 --- /dev/null +++ b/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/SearchTextUnitsHybridProperties.java @@ -0,0 +1,18 @@ +package com.box.l10n.mojito.rest.textunit; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "l10n.textunitws.search.hybrid") +public record SearchTextUnitsHybridProperties( + @DefaultValue("PT1S") Duration convertToAsyncAfter, + @DefaultValue("PT60S") Duration recommendedPollingDuration, + @DefaultValue Pool pool) { + + record Pool( + @DefaultValue("4") int corePoolSize, + @DefaultValue("8") int maxPoolSize, + @DefaultValue("100") int queueCapacity, + @DefaultValue("textunit-search") String threadNamePrefix) {} +} diff --git a/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/TextUnitWS.java b/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/TextUnitWS.java index 3740d4df2f..3ebbdeb8e5 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/TextUnitWS.java +++ b/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/TextUnitWS.java @@ -14,12 +14,15 @@ import com.box.l10n.mojito.entity.security.user.UserLocale; import com.box.l10n.mojito.json.ObjectMapper; import com.box.l10n.mojito.rest.View; +import com.box.l10n.mojito.rest.textunit.TextUnitWS.SearchTextUnitsHybridResponse.HybridSearchError; import com.box.l10n.mojito.security.AuditorAwareImpl; import com.box.l10n.mojito.service.NormalizationUtils; import com.box.l10n.mojito.service.asset.AssetPathNotFoundException; import com.box.l10n.mojito.service.asset.AssetRepository; import com.box.l10n.mojito.service.assetTextUnit.AssetTextUnitRepository; import com.box.l10n.mojito.service.assetintegritychecker.integritychecker.IntegrityCheckException; +import com.box.l10n.mojito.service.blobstorage.Retention; +import com.box.l10n.mojito.service.blobstorage.StructuredBlobStorage; import com.box.l10n.mojito.service.gitblame.GitBlameService; import com.box.l10n.mojito.service.gitblame.GitBlameWithUsage; import com.box.l10n.mojito.service.locale.LocaleService; @@ -42,16 +45,24 @@ import com.box.l10n.mojito.service.tm.search.UsedFilter; import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.core.type.TypeReference; +import com.google.common.base.Throwables; +import com.google.common.util.concurrent.UncheckedExecutionException; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.commons.collections.CollectionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.security.access.AccessDeniedException; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PathVariable; @@ -101,6 +112,18 @@ public class TextUnitWS { @Autowired UserRepository userRepository; + @Autowired StructuredBlobStorage structuredBlobStorage; + + @Autowired + @Qualifier("fail_on_unknown_properties_false") + ObjectMapper objectMapper; + + @Autowired SearchTextUnitsHybridProperties searchTextUnitsHybridProperties; + + @Autowired + @Qualifier("searchTextUnitsHybridExecutor") + ThreadPoolTaskExecutor searchTextUnitsHybridExecutor; + /** * Gets the TextUnits that matches the search parameters. * @@ -147,6 +170,157 @@ private List getTextUnits(TextUnitSearchBody textUnitSearchBody) return search; } + record SearchTextUnitsHybridResponse( + List results, PollingToken pollingToken, HybridSearchError error) { + record PollingToken(UUID requestId, long recommendedPollingDurationMillis) {} + + record HybridSearchError(String type, String message, String stackTrace, boolean expected) {} + } + + /** + * Hybrid text unit search that returns results synchronously when the query completes quickly or + * falls back to asynchronous polling for longer queries. + * + *

Response semantics (same envelope for POST and polling GET): + * + *

    + *
  • {@code 200 OK}: {@link SearchTextUnitsHybridResponse#results() results()} contains the + * {@link TextUnitDTO}s; {@code pollingToken} and {@code error} are {@code null}. + *
  • {@code 202 Accepted}: {@code pollingToken} contains a {@code requestId} and {@code + * recommendedPollingDurationMillis}; {@code results} and {@code error} are {@code null}. + * Clients should poll {@code GET /api/textunits/search-hybrid/results/{requestId}} until it + * returns a 200 or until their own timeout elapses. The recommended duration is a hint for + * pacing retries. + *
  • {@code 4xx/5xx}: {@code error} is populated for expected validation errors (4xx) or + * unexpected failures (5xx); {@code results} and {@code pollingToken} are {@code null}. + *
+ * + *

Frontend implementation notes: + * + *

    + *
  • POST {@code /api/textunits/search-hybrid} with the usual search parameters. + *
  • If the response contains {@code results()}, convert it to {@link TextUnitDTO}s as before. + *
  • If the response contains a {@code pollingToken}, start polling the async GET endpoint + * using the provided {@code requestId}. Respect the {@code + * recommendedPollingDurationMillis} hint when calculating when to stop polling. + *
  • The polling endpoint returns the same envelope: 200 with {@code results()}, 202 with a + * {@code pollingToken}, or 4xx/5xx with {@code error}. + *
+ */ + @RequestMapping(method = RequestMethod.POST, value = "/api/textunits/search-hybrid") + public ResponseEntity searchTextUnitsHybrid( + @RequestBody TextUnitSearchBody textUnitSearchBody) + throws InvalidTextUnitSearchParameterException { + + final UUID requestId = UUID.randomUUID(); + final AtomicBoolean forceAsyncPersistence = new AtomicBoolean(false); + final long searchStartedAtNanos = System.nanoTime(); + + Future> searchFuture = + searchTextUnitsHybridExecutor.submit( + () -> { + List results; + try { + results = getTextUnits(textUnitSearchBody); + } catch (Exception e) { + if (forceAsyncPersistence.get()) { + HybridSearchError error = + new HybridSearchError( + e.getClass().getName(), + e.getMessage(), + Throwables.getStackTraceAsString(e), + e instanceof InvalidTextUnitSearchParameterException); + SearchTextUnitsHybridResponse payload = + new SearchTextUnitsHybridResponse(null, null, error); + String payloadJson = objectMapper.writeValueAsStringUnchecked(payload); + structuredBlobStorage.put( + StructuredBlobStorage.Prefix.TEXT_UNIT_WS_SEARCH_ASYNC, + requestId.toString(), + payloadJson, + Retention.MIN_1_DAY); + } + throw e; + } + long searchCompletedAtNanos = System.nanoTime(); + + if (forceAsyncPersistence.get() + || searchCompletedAtNanos - searchStartedAtNanos + >= searchTextUnitsHybridProperties.convertToAsyncAfter().toNanos()) { + logger.debug("search was slow, we must persist result for eventual polling"); + SearchTextUnitsHybridResponse payload = + new SearchTextUnitsHybridResponse(results, null, null); + String payloadJson = objectMapper.writeValueAsStringUnchecked(payload); + structuredBlobStorage.put( + StructuredBlobStorage.Prefix.TEXT_UNIT_WS_SEARCH_ASYNC, + requestId.toString(), + payloadJson, + Retention.MIN_1_DAY); + } + return results; + }); + + try { + List results = + searchFuture.get( + searchTextUnitsHybridProperties.convertToAsyncAfter().toNanos(), + TimeUnit.NANOSECONDS); + SearchTextUnitsHybridResponse response = + new SearchTextUnitsHybridResponse(results, null, null); + return ResponseEntity.ok(response); + } catch (TimeoutException e) { + forceAsyncPersistence.set(true); + SearchTextUnitsHybridResponse response = + new SearchTextUnitsHybridResponse(null, buildPollingToken(requestId), null); + return ResponseEntity.accepted().body(response); + } catch (ExecutionException e) { + if (e.getCause() instanceof InvalidTextUnitSearchParameterException) { + throw (InvalidTextUnitSearchParameterException) e.getCause(); + } else { + throw new UncheckedExecutionException(e); + } + } catch (InterruptedException e) { + searchFuture.cancel(true); + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + @RequestMapping( + method = RequestMethod.GET, + value = "/api/textunits/search-hybrid/results/{requestId}") + public ResponseEntity searchTextUnitsHybridGetResults(@PathVariable UUID requestId) { + + ResponseEntity response = null; + Optional storedResult = + structuredBlobStorage.getString( + StructuredBlobStorage.Prefix.TEXT_UNIT_WS_SEARCH_ASYNC, requestId.toString()); + + if (storedResult.isEmpty()) { + SearchTextUnitsHybridResponse searchTextUnitsHybridResponse = + new SearchTextUnitsHybridResponse(null, buildPollingToken(requestId), null); + response = ResponseEntity.accepted().body(searchTextUnitsHybridResponse); + + } else { + SearchTextUnitsHybridResponse searchTextUnitsHybridResponse = + objectMapper.readValueUnchecked(storedResult.get(), SearchTextUnitsHybridResponse.class); + + if (searchTextUnitsHybridResponse.error() == null) { + response = ResponseEntity.ok(searchTextUnitsHybridResponse); + } else if (searchTextUnitsHybridResponse.error().expected()) { + response = ResponseEntity.badRequest().body(searchTextUnitsHybridResponse); + } else { + response = ResponseEntity.internalServerError().body(searchTextUnitsHybridResponse); + } + } + + return response; + } + + private SearchTextUnitsHybridResponse.PollingToken buildPollingToken(UUID requestId) { + return new SearchTextUnitsHybridResponse.PollingToken( + requestId, searchTextUnitsHybridProperties.recommendedPollingDuration().toMillis()); + } + void applySharedSearchAndCountParameters(TextUnitSearcherParameters textUnitSearcherParameters) { textUnitSearcherParameters.setRootLocaleExcluded(false); } diff --git a/webapp/src/main/java/com/box/l10n/mojito/security/WebSecurityConfig.java b/webapp/src/main/java/com/box/l10n/mojito/security/WebSecurityConfig.java index 448e5bf03b..174ccfa3dd 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/security/WebSecurityConfig.java +++ b/webapp/src/main/java/com/box/l10n/mojito/security/WebSecurityConfig.java @@ -173,6 +173,8 @@ static void setAuthorizationRequests(HttpSecurity http, List extraPermit // Searching is also OK for users .requestMatchers(HttpMethod.POST, "/api/textunits/search") .authenticated() + .requestMatchers(HttpMethod.POST, "/api/textunits/search-hybrid") + .authenticated() // USERs are not allowed to change translations .requestMatchers("/api/textunits/**") .hasAnyRole("TRANSLATOR", "PM", "ADMIN") diff --git a/webapp/src/main/java/com/box/l10n/mojito/security/WebSecurityJWTConfig.java b/webapp/src/main/java/com/box/l10n/mojito/security/WebSecurityJWTConfig.java index c58e871b3f..b9f46ae0bd 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/security/WebSecurityJWTConfig.java +++ b/webapp/src/main/java/com/box/l10n/mojito/security/WebSecurityJWTConfig.java @@ -124,6 +124,7 @@ public static void applyStatelessSharedConfig(HttpSecurity http) throws Exceptio "/branches", "/screenshots", "/screenshots-legacy", + "/ai-translate", "/settings/**")); // forwarding was for the old implementation and is not needed anymore so diff --git a/webapp/src/main/java/com/box/l10n/mojito/service/blobstorage/StructuredBlobStorage.java b/webapp/src/main/java/com/box/l10n/mojito/service/blobstorage/StructuredBlobStorage.java index 755eb5702e..dbd88a9a02 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/service/blobstorage/StructuredBlobStorage.java +++ b/webapp/src/main/java/com/box/l10n/mojito/service/blobstorage/StructuredBlobStorage.java @@ -47,9 +47,10 @@ public enum Prefix { IMAGE, MULTI_BRANCH_STATE, TEXT_UNIT_DTOS_CACHE, + TEXT_UNIT_WS_SEARCH_ASYNC, CLOB_STORAGE_WS, AI_TRANSLATE_WS, AI_TRANSALATE_NO_BATCH_OUTPUT, - AI_REVIEW_WS, + AI_REVIEW_WS } } diff --git a/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserService.java b/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserService.java index 1a2d5d5fd4..6505fc0ed2 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserService.java +++ b/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserService.java @@ -449,6 +449,7 @@ public Page findAll(Specification spec, Pageable pageable) { Hibernate.initialize(a.getCreatedByUser()); }); Hibernate.initialize(u.getCreatedByUser()); + Hibernate.initialize(u.getUserLocales()); }); return users; } diff --git a/webapp/src/main/java/com/box/l10n/mojito/service/tm/search/StatusFilter.java b/webapp/src/main/java/com/box/l10n/mojito/service/tm/search/StatusFilter.java index 35065c5b8f..a22a77aa0b 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/service/tm/search/StatusFilter.java +++ b/webapp/src/main/java/com/box/l10n/mojito/service/tm/search/StatusFilter.java @@ -42,6 +42,11 @@ public enum StatusFilter { FOR_TRANSLATION, /** TextUnits with status ({@link TMTextUnitVariant.Status#REVIEW_NEEDED}). */ REVIEW_NEEDED, + /** + * TextUnits with status ({@link TMTextUnitVariant.Status#REVIEW_NEEDED}) or not included in the + * localized file ({@link TMTextUnitVariant#includedInLocalizedFile} is false). + */ + REVIEW_NEEDED_OR_REJECTED, /** TextUnits that don't have status ({@link TMTextUnitVariant.Status#REVIEW_NEEDED}). */ REVIEW_NOT_NEEDED, /** TextUnits with status ({@link TMTextUnitVariant.Status#TRANSLATION_NEEDED}). */ diff --git a/webapp/src/main/java/com/box/l10n/mojito/service/tm/search/TextUnitSearcher.java b/webapp/src/main/java/com/box/l10n/mojito/service/tm/search/TextUnitSearcher.java index 216d6f8e95..940544e201 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/service/tm/search/TextUnitSearcher.java +++ b/webapp/src/main/java/com/box/l10n/mojito/service/tm/search/TextUnitSearcher.java @@ -361,6 +361,14 @@ NativeCriteria getCriteriaForSearch(TextUnitSearcherParameters searchParameters) conjunction.add( new NativeEqExpFix("tuv.status", TMTextUnitVariant.Status.REVIEW_NEEDED.toString())); break; + case REVIEW_NEEDED_OR_REJECTED: + conjunction.add( + NativeExps.disjunction( + Arrays.asList( + new NativeEqExpFix( + "tuv.status", TMTextUnitVariant.Status.REVIEW_NEEDED.toString()), + new NativeEqExpFix("tuv.included_in_localized_file", Boolean.FALSE)))); + break; case REVIEW_NOT_NEEDED: conjunction.add( NativeExps.notEq("tuv.status", TMTextUnitVariant.Status.REVIEW_NEEDED.toString())); diff --git a/webapp/src/main/java/com/box/l10n/mojito/service/tm/textunitdtocache/TextUnitDTOsCacheService.java b/webapp/src/main/java/com/box/l10n/mojito/service/tm/textunitdtocache/TextUnitDTOsCacheService.java index b1914f43f2..2843bc268f 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/service/tm/textunitdtocache/TextUnitDTOsCacheService.java +++ b/webapp/src/main/java/com/box/l10n/mojito/service/tm/textunitdtocache/TextUnitDTOsCacheService.java @@ -349,9 +349,12 @@ public Predicate statusPredicate(StatusFilter statusFilter) { case NOT_REJECTED: return t.isIncludedInLocalizedFile(); case REJECTED: - return !t.isIncludedInLocalizedFile(); + return t.getTmTextUnitVariantId() != null && !t.isIncludedInLocalizedFile(); case REVIEW_NEEDED: return TMTextUnitVariant.Status.REVIEW_NEEDED.equals(t.getStatus()); + case REVIEW_NEEDED_OR_REJECTED: + return TMTextUnitVariant.Status.REVIEW_NEEDED.equals(t.getStatus()) + || (t.getTmTextUnitVariantId() != null && !t.isIncludedInLocalizedFile()); case REVIEW_NOT_NEEDED: return !TMTextUnitVariant.Status.REVIEW_NEEDED.equals(t.getStatus()); case TRANSLATION_NEEDED: diff --git a/webapp/src/main/resources/properties/en.properties b/webapp/src/main/resources/properties/en.properties index 6c2eff21a2..4cac5cff37 100644 --- a/webapp/src/main/resources/properties/en.properties +++ b/webapp/src/main/resources/properties/en.properties @@ -3,6 +3,7 @@ header.workbench=Workbench # Label displayed in the header menu to open the repositories page header.repositories=Repositories +header.aiTranslate=AI Translate # Label displayed in the header menu to open the branches header.branches=Branches @@ -1113,3 +1114,40 @@ monitoring.latency.iterationsUsed=Iterations used monitoring.latency.series.raw=Direct JDBC (select 1) monitoring.latency.series.hibernateHealth=Hibernate (select 1) monitoring.latency.series.hibernateRepo=Hibernate repositories query + +# AI Translate page +aiTranslate.title=Repository AI Translation +aiTranslate.description=Trigger an AI translation run for a repository and monitor the result. +aiTranslate.repository=Repository +aiTranslate.repository.help=Select the repository that should be machine translated. +aiTranslate.locales=Target locales +aiTranslate.locales.help=Leave unselected to translate every repository locale. +aiTranslate.sourceTextMaxCount=Source texts per locale +aiTranslate.sourceTextMaxCount.help=Maximum number of source strings per locale to send to AI translation. +aiTranslate.textUnitIds=Text unit IDs +aiTranslate.textUnitIds.help=Optional comma or whitespace separated list of TM text unit IDs. +aiTranslate.useModel=Model override +aiTranslate.useModel.help=Optional model identifier to use for the translation (defaults to gpt-4.1). +aiTranslate.promptSuffix=Prompt suffix +aiTranslate.promptSuffix.help=Optional text appended at the end of the base prompt. +aiTranslate.relatedStrings=Related strings +aiTranslate.translateType=Translate type +aiTranslate.statusFilter=Status filter +aiTranslate.importStatus=Import status +aiTranslate.timeoutSeconds=Request timeout (seconds) +aiTranslate.timeoutSeconds.help=Optional per request timeout. Leave blank to use the server default. +aiTranslate.downloadReport=Download JSON report after completion +aiTranslate.dryRun=Dry run (do not import results) +aiTranslate.error.repository=Select a repository before starting the translation. +aiTranslate.error.textUnitIds=Text unit IDs must be numeric values separated by commas or whitespace. +aiTranslate.error.number=Provide valid numeric values greater than zero. +aiTranslate.error.generic=Unexpected error while submitting the AI translation job. +aiTranslate.error.pollable=Unable to start the AI translation pollable task. +aiTranslate.success=AI translation job {pollableTaskId} finished successfully. +aiTranslate.waiting=Waiting for AI translation job {pollableTaskId} to finish... +aiTranslate.report.title=Download report +aiTranslate.report.download=Download report for {locale} +aiTranslate.report.fetching=Preparing report downloads... +aiTranslate.error.report=Unable to download the report files. +aiTranslate.submit=Start translation +aiTranslate.submitting=Submitting... diff --git a/webapp/src/main/resources/public/js/app.js b/webapp/src/main/resources/public/js/app.js index 0578adc0c9..a219aeca38 100644 --- a/webapp/src/main/resources/public/js/app.js +++ b/webapp/src/main/resources/public/js/app.js @@ -21,6 +21,7 @@ import UserManagement from "./components/users/UserManagement"; import Settings from "./components/settings/Settings"; import BoxSettings from "./components/settings/BoxSettings"; import DbLatencyMonitoring from "./components/settings/DbLatencyMonitoring"; +import RepositoryAiTranslatePage from "./components/aiTranslate/RepositoryAiTranslatePage"; import WorkbenchActions from "./actions/workbench/WorkbenchActions"; import RepositoryActions from "./actions/RepositoryActions"; import ScreenshotsPageActions from "./actions/screenshots/ScreenshotsPageActions"; @@ -117,6 +118,7 @@ function startApp(messages) { + @@ -229,6 +231,14 @@ function onEnterScreenshots() { }, 1); } +function onEnterAiTranslate(nextState, replace) { + if (!AuthorityService.isAdmin()) { + replace('/repositories'); + } else { + getAllRepositoriesDeffered(); + } +} + function onEnterRoot(nextState, replace) { if (location.pathname === '/') { replace('/repositories') diff --git a/webapp/src/main/resources/public/js/components/aiTranslate/RepositoryAiTranslatePage.js b/webapp/src/main/resources/public/js/components/aiTranslate/RepositoryAiTranslatePage.js new file mode 100644 index 0000000000..d25cdc5b60 --- /dev/null +++ b/webapp/src/main/resources/public/js/components/aiTranslate/RepositoryAiTranslatePage.js @@ -0,0 +1,580 @@ +import React from "react"; +import AltContainer from "alt-container"; +import { + Alert, + Button, + Checkbox, + ControlLabel, + Form, + FormControl, + FormGroup, + HelpBlock +} from "react-bootstrap"; +import {FormattedMessage} from "react-intl"; +import RepositoryActions from "../../actions/RepositoryActions"; +import RepositoryStore from "../../stores/RepositoryStore"; +import RepositoryLocale from "../../sdk/entity/RepositoryLocale"; +import RepositoryAiTranslateClient from "../../sdk/RepositoryAiTranslateClient"; +import PollableTaskClient from "../../sdk/PollableTaskClient"; + +class RepositoryAiTranslatePageContainer extends React.Component { + + render() { + return ( + + + + ); + } +} + +class RepositoryAiTranslatePage extends React.Component { + + constructor(props) { + super(props); + this.state = { + selectedRepositoryId: "", + selectedLocales: [], + sourceTextMaxCount: 100, + textUnitIdsRaw: "", + useModel: "gpt-4.1", + promptSuffix: "", + relatedStrings: "NONE", + translateType: "TARGET_ONLY_NEW", + statusFilter: "FOR_TRANSLATION", + importStatus: "REVIEW_NEEDED", + downloadReport: false, + timeoutSeconds: "", + dryRun: false, + isSubmitting: false, + isWaitingForCompletion: false, + jobError: null, + pollableTask: null, + reportLocaleUrls: [], + reportDownloads: [], + isFetchingReport: false, + reportError: null + }; + this.activeObjectUrls = []; + } + + componentWillUnmount() { + this.cleanupObjectUrls(); + } + + cleanupObjectUrls() { + this.activeObjectUrls.forEach(url => URL.revokeObjectURL(url)); + this.activeObjectUrls = []; + } + + getRepositories() { + return this.props.repositories || []; + } + + getSelectedRepository() { + const repositories = this.getRepositories(); + const {selectedRepositoryId} = this.state; + if (!selectedRepositoryId) { + return null; + } + const repositoryIdAsNumber = Number(selectedRepositoryId); + if (Number.isNaN(repositoryIdAsNumber)) { + return null; + } + return repositories.find(repository => repository.id === repositoryIdAsNumber) || null; + } + + getAvailableLocales() { + const repository = this.getSelectedRepository(); + if (!repository || !repository.repositoryLocales) { + return []; + } + return repository.repositoryLocales + .filter(repositoryLocale => !RepositoryLocale.isRootLocale(repositoryLocale)) + .map(repositoryLocale => repositoryLocale.locale.bcp47Tag) + .sort(); + } + + toggleLocale(locale) { + this.setState(previousState => { + const selectedLocales = new Set(previousState.selectedLocales); + if (selectedLocales.has(locale)) { + selectedLocales.delete(locale); + } else { + selectedLocales.add(locale); + } + return {selectedLocales: Array.from(selectedLocales)}; + }); + } + + handleRepositoryChange(event) { + const selectedRepositoryId = event.target.value; + this.cleanupObjectUrls(); + this.setState({ + selectedRepositoryId, + selectedLocales: [], + pollableTask: null, + reportLocaleUrls: [], + reportDownloads: [], + reportError: null, + jobError: null + }); + } + + handleInputChange(event) { + const {name, value, type, checked} = event.target; + this.setState({ + [name]: type === "checkbox" ? checked : value + }); + } + + parseTextUnitIds(rawValue) { + if (!rawValue || !rawValue.trim()) { + return null; + } + const ids = rawValue + .split(/[,\n\s]+/) + .map(entry => entry.trim()) + .filter(entry => entry.length > 0) + .map(entry => Number(entry)); + if (ids.some(Number.isNaN)) { + throw new Error("invalidTextUnitIds"); + } + return ids; + } + + parseInteger(value, allowEmpty = true) { + if (value === null || value === undefined) { + return null; + } + if (value === "") { + if (allowEmpty) { + return null; + } + throw new Error("invalidNumber"); + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new Error("invalidNumber"); + } + return parsed; + } + + buildRequestPayload() { + const repository = this.getSelectedRepository(); + if (!repository) { + throw new Error("missingRepository"); + } + + const sourceTextMaxCount = this.parseInteger(this.state.sourceTextMaxCount, false); + const timeoutSeconds = this.parseInteger(this.state.timeoutSeconds, true); + const tmTextUnitIds = this.parseTextUnitIds(this.state.textUnitIdsRaw); + + if (sourceTextMaxCount <= 0) { + throw new Error("invalidNumber"); + } + + if (timeoutSeconds !== null && timeoutSeconds < 0) { + throw new Error("invalidNumber"); + } + + return { + repositoryName: repository.name, + targetBcp47tags: this.state.selectedLocales.length ? this.state.selectedLocales : null, + sourceTextMaxCountPerLocale: sourceTextMaxCount, + tmTextUnitIds, + useBatch: false, + useModel: this.state.useModel || null, + promptSuffix: this.state.promptSuffix || null, + relatedStringsType: this.state.relatedStrings, + translateType: this.state.translateType, + statusFilter: this.state.statusFilter, + importStatus: this.state.importStatus, + glossaryName: null, + glossaryTermSource: null, + glossaryTermSourceDescription: null, + glossaryTermTarget: null, + glossaryTermTargetDescription: null, + glossaryTermDoNotTranslate: false, + glossaryTermCaseSensitive: false, + glossaryOnlyMatchedTextUnits: false, + dryRun: this.state.dryRun, + timeoutSeconds + }; + } + + submitJob(event) { + event.preventDefault(); + this.cleanupObjectUrls(); + + let requestPayload; + try { + requestPayload = this.buildRequestPayload(); + } catch (error) { + let jobErrorMessage; + switch (error.message) { + case "missingRepository": + jobErrorMessage = "aiTranslate.error.repository"; + break; + case "invalidTextUnitIds": + jobErrorMessage = "aiTranslate.error.textUnitIds"; + break; + case "invalidNumber": + jobErrorMessage = "aiTranslate.error.number"; + break; + default: + jobErrorMessage = "aiTranslate.error.generic"; + } + this.setState({jobError: jobErrorMessage}); + return; + } + + this.setState({ + isSubmitting: true, + isWaitingForCompletion: false, + jobError: null, + pollableTask: null, + reportLocaleUrls: [], + reportDownloads: [], + reportError: null + }); + + RepositoryAiTranslateClient.translateRepository(requestPayload) + .then(response => { + const pollableTask = response.pollableTask; + if (!pollableTask || !pollableTask.id) { + throw new Error("missingPollableTask"); + } + this.setState({ + pollableTask, + isWaitingForCompletion: true + }); + return PollableTaskClient.waitForPollableTaskToFinish(pollableTask.id, null); + }) + .then(pollableTask => { + this.setState({ + pollableTask, + isSubmitting: false, + isWaitingForCompletion: false + }); + if (pollableTask.errorMessage) { + this.setState({jobError: pollableTask.errorMessage}); + return; + } + if (this.state.downloadReport) { + this.fetchReport(pollableTask.id); + } + }) + .catch(error => { + let jobErrorMessage = "aiTranslate.error.generic"; + if (error.message === "missingPollableTask") { + jobErrorMessage = "aiTranslate.error.pollable"; + } + if (error.response) { + jobErrorMessage = `${error.response.status} ${error.response.statusText}`; + } + this.setState({ + jobError: jobErrorMessage, + isSubmitting: false, + isWaitingForCompletion: false + }); + }); + } + + fetchReport(pollableTaskId) { + this.setState({ + isFetchingReport: true, + reportError: null, + reportLocaleUrls: [], + reportDownloads: [] + }); + RepositoryAiTranslateClient.getReport(pollableTaskId) + .then(report => { + const reportLocaleUrls = report.reportLocaleUrls || []; + this.setState({reportLocaleUrls}); + return Promise.all(reportLocaleUrls.map(filename => + RepositoryAiTranslateClient.getReportLocale(filename).then(localeResponse => ({ + filename, + content: localeResponse.content + })) + )); + }) + .then(results => { + this.cleanupObjectUrls(); + const downloads = results.map(result => { + const locale = result.filename.split("/").pop(); + const blob = new Blob([result.content], {type: "application/json"}); + const href = URL.createObjectURL(blob); + this.activeObjectUrls.push(href); + return { + locale, + href, + filename: `${pollableTaskId}-${locale}.json` + }; + }); + this.setState({ + reportDownloads: downloads, + isFetchingReport: false + }); + }) + .catch(() => { + this.setState({ + reportError: "aiTranslate.error.report", + isFetchingReport: false + }); + }); + } + + renderRepositorySelect() { + const repositories = this.getRepositories(); + return ( + + + this.handleRepositoryChange(event)} + disabled={this.state.isSubmitting || this.state.isWaitingForCompletion} + > + + {repositories.map(repository => ( + + ))} + + + + ); + } + + renderLocalesSection() { + const availableLocales = this.getAvailableLocales(); + if (availableLocales.length === 0) { + return null; + } + return ( + + +
+ {availableLocales.map(locale => ( + this.toggleLocale(locale)} + disabled={this.state.isSubmitting || this.state.isWaitingForCompletion} + > + {locale} + + ))} +
+ +
+ ); + } + + renderNumericInput(id, labelId, stateKey, helpId = null, minValue = 1) { + return ( + + + this.handleInputChange(event)} + disabled={this.state.isSubmitting || this.state.isWaitingForCompletion} + /> + {helpId && } + + ); + } + + renderTextInput(id, labelId, stateKey, helpId = null) { + return ( + + + this.handleInputChange(event)} + disabled={this.state.isSubmitting || this.state.isWaitingForCompletion} + /> + {helpId && } + + ); + } + + renderTextarea(id, labelId, stateKey, helpId) { + return ( + + + this.handleInputChange(event)} + disabled={this.state.isSubmitting || this.state.isWaitingForCompletion} + /> + + + ); + } + + renderSelect(id, labelId, stateKey, options) { + return ( + + + this.handleInputChange(event)} + disabled={this.state.isSubmitting || this.state.isWaitingForCompletion} + > + {options.map(option => ( + + ))} + + + ); + } + + renderReportDownloads() { + if (!this.state.reportDownloads.length) { + return null; + } + return ( +
+

+
    + {this.state.reportDownloads.map(download => ( +
  • + + + +
  • + ))} +
+
+ ); + } + + render() { + const relatedStringsOptions = [ + {value: "NONE", label: "NONE"}, + {value: "USAGES", label: "USAGES"}, + {value: "ID_PREFIX", label: "ID_PREFIX"} + ]; + + const translateTypeOptions = [ + {value: "WITH_REVIEW", label: "WITH_REVIEW"}, + {value: "TARGET_ONLY", label: "TARGET_ONLY"}, + {value: "TARGET_ONLY_NEW", label: "TARGET_ONLY_NEW"} + ]; + + const statusFilterOptions = [ + {value: "FOR_TRANSLATION", label: "FOR_TRANSLATION"}, + {value: "ALL", label: "ALL"} + ]; + + const importStatusOptions = [ + {value: "REVIEW_NEEDED", label: "REVIEW_NEEDED"}, + {value: "ACCEPTED", label: "ACCEPTED"}, + {value: "TRANSLATION_NEEDED", label: "TRANSLATION_NEEDED"} + ]; + + const disableForm = this.state.isSubmitting || this.state.isWaitingForCompletion; + + return ( +
+

+

+
this.submitJob(event)}> + {this.renderRepositorySelect()} + {this.renderLocalesSection()} + {this.renderNumericInput("sourceTextMaxCount", "aiTranslate.sourceTextMaxCount", "sourceTextMaxCount", "aiTranslate.sourceTextMaxCount.help")} + {this.renderTextarea("textUnitIds", "aiTranslate.textUnitIds", "textUnitIdsRaw", "aiTranslate.textUnitIds.help")} + {this.renderTextInput("useModel", "aiTranslate.useModel", "useModel", "aiTranslate.useModel.help")} + {this.renderTextarea("promptSuffix", "aiTranslate.promptSuffix", "promptSuffix", "aiTranslate.promptSuffix.help")} + {this.renderSelect("relatedStrings", "aiTranslate.relatedStrings", "relatedStrings", relatedStringsOptions)} + {this.renderSelect("translateType", "aiTranslate.translateType", "translateType", translateTypeOptions)} + {this.renderSelect("statusFilter", "aiTranslate.statusFilter", "statusFilter", statusFilterOptions)} + {this.renderSelect("importStatus", "aiTranslate.importStatus", "importStatus", importStatusOptions)} + {this.renderNumericInput("timeoutSeconds", "aiTranslate.timeoutSeconds", "timeoutSeconds", "aiTranslate.timeoutSeconds.help", 0)} + this.handleInputChange(event)} + disabled={disableForm} + > + + + this.handleInputChange(event)} + disabled={disableForm} + > + + + + {this.state.jobError && + + + + } + + {this.state.pollableTask && !this.state.jobError && this.state.pollableTask.isAllFinished && + + + + } + + {this.state.isWaitingForCompletion && + + + + } + + {this.state.reportError && + + + + } + + {this.state.isFetchingReport && + + + + } + + {this.renderReportDownloads()} + + +
+
+ ); + } +} + +export default RepositoryAiTranslatePageContainer; diff --git a/webapp/src/main/resources/public/js/components/header/Header.js b/webapp/src/main/resources/public/js/components/header/Header.js index d96e73f81d..cb4dac9db8 100644 --- a/webapp/src/main/resources/public/js/components/header/Header.js +++ b/webapp/src/main/resources/public/js/components/header/Header.js @@ -106,6 +106,11 @@ class Header extends React.Component { }}> + {AuthorityService.isAdmin() && ( + + + + )} { if (this.props.router.isActive("/branches")) { BranchesPageActions.resetBranchesSearchParams(); diff --git a/webapp/src/main/resources/public/js/sdk/RepositoryAiTranslateClient.js b/webapp/src/main/resources/public/js/sdk/RepositoryAiTranslateClient.js new file mode 100644 index 0000000000..fb66aae32b --- /dev/null +++ b/webapp/src/main/resources/public/js/sdk/RepositoryAiTranslateClient.js @@ -0,0 +1,21 @@ +import BaseClient from "./BaseClient"; + +class RepositoryAiTranslateClient extends BaseClient { + translateRepository(request) { + return this.post(this.getUrl(), request); + } + + getReport(pollableTaskId) { + return this.get(this.getUrl(`report/${pollableTaskId}`), {}); + } + + getReportLocale(filename) { + return this.get(this.getUrl(`report/${filename}`), {}); + } + + getEntityName() { + return "proto-ai-translate"; + } +} + +export default new RepositoryAiTranslateClient(); diff --git a/webapp/src/main/resources/public/js/sdk/TextUnitClient.js b/webapp/src/main/resources/public/js/sdk/TextUnitClient.js index 8b547f2b98..376b1b62ae 100644 --- a/webapp/src/main/resources/public/js/sdk/TextUnitClient.js +++ b/webapp/src/main/resources/public/js/sdk/TextUnitClient.js @@ -4,6 +4,8 @@ import TextUnitIntegrityCheckRequest from "./textunit/TextUnitIntegrityCheckRequ import TextUnitIntegrityCheckResult from "./textunit/TextUnitIntegrityCheckResult"; import PollableTaskClient from "./PollableTaskClient"; +const ASYNC_SEARCH_POLL_INTERVAL_MS = 1000; +const ASYNC_SEARCH_POLL_TIMEOUT_FALLBACK_MS = 60000; class TextUnitClient extends BaseClient { @@ -17,10 +19,19 @@ class TextUnitClient extends BaseClient { * @returns {Promise.} a promise that retuns an array of text units */ getTextUnits(textUnitSearcherParameters) { - let promise = this.post(this.getUrl() + '/search', textUnitSearcherParameters.getParams()); - - return promise.then(function (result) { - return TextUnit.toTextUnits(result); + return this.post(this.getUrl() + '/search-hybrid', textUnitSearcherParameters.getParams()).then((result) => { + if (result && result.results) { + return TextUnit.toTextUnits(result.results); + } + + if (result && result.pollingToken) { + return this.pollAsyncSearchResult( + result.pollingToken.requestId, + result.pollingToken.recommendedPollingDurationMillis + ); + } + + throw new Error('Unexpected response from async text unit search'); }); } @@ -130,8 +141,70 @@ class TextUnitClient extends BaseClient { getEntityName() { return 'textunits'; } + + pollAsyncSearchResult(requestId, recommendedPollingDurationMillis) { + const startedAt = Date.now(); + const pollUntilMs = startedAt + (recommendedPollingDurationMillis || ASYNC_SEARCH_POLL_TIMEOUT_FALLBACK_MS); + const pollIntervalMs = ASYNC_SEARCH_POLL_INTERVAL_MS; + + const poll = async () => { + for (;;) { + if (Date.now() > pollUntilMs) { + throw new Error('Async search request timed out'); + } + + const response = await this.fetchAsyncSearchResult(requestId); + + if (response && response.results) { + return TextUnit.toTextUnits(response.results); + } + + if (response && response.pollingToken) { + await this.delay(pollIntervalMs); + continue; + } + + if (response && response.error) { + const errorMessage = response.error.message || 'Async search failed'; + throw new Error(errorMessage); + } + + throw new Error('Unexpected async search response'); + } + }; + + return poll(); + } + + fetchAsyncSearchResult(requestId) { + return this.buildHeaders('GET').then(headers => + fetch(this.getUrl() + '/search-hybrid/results/' + requestId, { + follow: 0, + credentials: this.getCredentialsMode(), + headers: headers + }).then(response => { + // we don't call baseClient.checkStatus because it would throw on 400, etc + if (response.status === 401) { + BaseClient.authenticateHandler(); + } + return response.json().then(body => { + if (!response.ok && body && body.error) { + const errorMessage = body.error.message || 'Async search failed'; + throw new Error(errorMessage); + } + if (!response.ok) { + throw new Error('Async search failed with status ' + response.status); + } + return body; + }); + }) + ); + } + + delay(durationMs) { + return new Promise(resolve => setTimeout(resolve, durationMs)); + } } ; export default new TextUnitClient(); - diff --git a/webapp/src/test/java/com/box/l10n/mojito/service/screenshot/ScreenshotServiceTest.java b/webapp/src/test/java/com/box/l10n/mojito/service/screenshot/ScreenshotServiceTest.java index defa6eb6f3..eaf024093f 100644 --- a/webapp/src/test/java/com/box/l10n/mojito/service/screenshot/ScreenshotServiceTest.java +++ b/webapp/src/test/java/com/box/l10n/mojito/service/screenshot/ScreenshotServiceTest.java @@ -81,17 +81,15 @@ public void testDeleteScreenshot() throws Exception { screenshotService.thirdPartyService = thirdPartyServiceMock; Screenshot screenshot = new Screenshot(); - screenshot.setId(1L); screenshot.setName("screenshot"); - screenshotRepository.save(screenshot); + screenshot = screenshotRepository.saveAndFlush(screenshot); Assert.assertNotNull(screenshotRepository.findById(screenshot.getId()).orElse(null)); ThirdPartyScreenshot thirdPartyScreenshot = new ThirdPartyScreenshot(); thirdPartyScreenshot.setScreenshot(screenshot); - thirdPartyScreenshot.setId(1L); thirdPartyScreenshot.setThirdPartyId("smartlingScreenshotId"); - thirdPartyScreenshotRepository.save(thirdPartyScreenshot); + thirdPartyScreenshot = thirdPartyScreenshotRepository.saveAndFlush(thirdPartyScreenshot); List createdThirdPartyScreenshots = thirdPartyScreenshotRepository.findAllByScreenshotId(screenshot.getId()); @@ -107,10 +105,9 @@ public void testDeleteScreenshot() throws Exception { tmTextUnitRepository.save(tmTextUnit); ScreenshotTextUnit screenshotTextUnit = new ScreenshotTextUnit(); - screenshotTextUnit.setId(1L); screenshotTextUnit.setScreenshot(screenshot); screenshotTextUnit.setTmTextUnit(tmTextUnit); - screenshotTextUnitRepository.save(screenshotTextUnit); + screenshotTextUnit = screenshotTextUnitRepository.saveAndFlush(screenshotTextUnit); ScreenshotTextUnit createdScreenshotTextUnit = screenshotTextUnitRepository.findById(screenshotTextUnit.getId()).orElse(null); @@ -134,9 +131,8 @@ public void testDeleteScreenshotNoThirdPartyConfig() throws RepositoryNameAlread screenshotService.thirdPartyService = thirdPartyServiceMock; Screenshot screenshot = new Screenshot(); - screenshot.setId(1L); screenshot.setName("screenshot"); - screenshotRepository.save(screenshot); + screenshot = screenshotRepository.saveAndFlush(screenshot); Assert.assertNotNull(screenshotRepository.findById(screenshot.getId()).orElse(null)); @@ -150,10 +146,9 @@ public void testDeleteScreenshotNoThirdPartyConfig() throws RepositoryNameAlread tmTextUnitRepository.save(tmTextUnit); ScreenshotTextUnit screenshotTextUnit = new ScreenshotTextUnit(); - screenshotTextUnit.setId(1L); screenshotTextUnit.setScreenshot(screenshot); screenshotTextUnit.setTmTextUnit(tmTextUnit); - screenshotTextUnitRepository.save(screenshotTextUnit); + screenshotTextUnit = screenshotTextUnitRepository.saveAndFlush(screenshotTextUnit); ScreenshotTextUnit createdScreenshotTextUnit = screenshotTextUnitRepository.findById(screenshotTextUnit.getId()).orElse(null); diff --git a/webapp/src/test/java/com/box/l10n/mojito/service/tm/TMServiceTest.java b/webapp/src/test/java/com/box/l10n/mojito/service/tm/TMServiceTest.java index 5ee946df5c..4cce8f807d 100644 --- a/webapp/src/test/java/com/box/l10n/mojito/service/tm/TMServiceTest.java +++ b/webapp/src/test/java/com/box/l10n/mojito/service/tm/TMServiceTest.java @@ -1502,9 +1502,11 @@ public void testLocalizeAndroidStringsWithSpecialCharacters() throws Exception { String assetContent = "\n" + "\n" + + " %1$s is drawing…" + " Welcome to Android!\n" + " Welcome to Android!\n" + " Welcome to Android!\n" + + " Welcome to Android!\n" + " \\\'Make sure you\\\'d \\\"escaped\\\" special characters like quotes & ampersands.\\n\n" + " \"This'll also work\"\n" + " \\.\n" @@ -1559,9 +1561,11 @@ public void testLocalizeAndroidStringsWithSpecialCharacters() throws Exception { String expected = "\n" + "\n" + + " %1$s is drawing…" + " Welcome to Android!\n" + " Welcome to Android!\n" + " Welcome to Android!\n" + + " Welcome to Android!\n" + " \\'Make sure you\\'d \\\"escaped\\\" special characters like quotes & ampersands.\\n\n" + " This\\'ll also work\n" + " .\n" diff --git a/webapp/src/test/java/com/box/l10n/mojito/service/tm/search/TextUnitSearcherTest.java b/webapp/src/test/java/com/box/l10n/mojito/service/tm/search/TextUnitSearcherTest.java index 837b6d8e80..7b598a5d28 100644 --- a/webapp/src/test/java/com/box/l10n/mojito/service/tm/search/TextUnitSearcherTest.java +++ b/webapp/src/test/java/com/box/l10n/mojito/service/tm/search/TextUnitSearcherTest.java @@ -448,6 +448,57 @@ public void testReviewNeeded() { textUnitDTOs.get(0).getTmTextUnitId()); } + @Transactional(noRollbackFor = {Throwable.class}) + @Test + public void testReviewNeededOrRejected() { + TMTestData tmTestData = new TMTestData(testIdWatcher); + + Long reviewNeededTmTextUnitId = + tmTestData.addCurrentTMTextUnitVariant1FrFR.getTmTextUnit().getId(); + tmService.addTMTextUnitCurrentVariant( + reviewNeededTmTextUnitId, + tmTestData.addCurrentTMTextUnitVariant1FrFR.getLocale().getId(), + tmTestData.addCurrentTMTextUnitVariant1FrFR.getContent(), + "mark as review needed", + TMTextUnitVariant.Status.REVIEW_NEEDED); + + Long rejectedTmTextUnitId = tmTestData.addCurrentTMTextUnitVariant3FrFR.getTmTextUnit().getId(); + tmService.addTMTextUnitCurrentVariant( + rejectedTmTextUnitId, + tmTestData.addCurrentTMTextUnitVariant3FrFR.getLocale().getId(), + tmTestData.addCurrentTMTextUnitVariant3FrFR.getContent(), + "mark as rejected", + TMTextUnitVariant.Status.APPROVED, + false); + + TextUnitSearcherParameters textUnitSearcherParameters = + new TextUnitSearcherParametersForTesting(); + textUnitSearcherParameters.setRepositoryIds(tmTestData.repository.getId()); + textUnitSearcherParameters.setLocaleId(tmTestData.frFR.getId()); + textUnitSearcherParameters.setStatusFilter(StatusFilter.REVIEW_NEEDED_OR_REJECTED); + + List textUnitDTOs = textUnitSearcher.search(textUnitSearcherParameters); + + assertEquals( + "The searcher should return review needed and rejected text unit DTOs", + 2, + textUnitDTOs.size()); + + boolean reviewNeededFound = false; + boolean rejectedFound = false; + + for (TextUnitDTO textUnitDTO : textUnitDTOs) { + if (reviewNeededTmTextUnitId.equals(textUnitDTO.getTmTextUnitId())) { + reviewNeededFound = true; + } else if (rejectedTmTextUnitId.equals(textUnitDTO.getTmTextUnitId())) { + rejectedFound = true; + } + } + + assertTrue("Review needed text unit should be present", reviewNeededFound); + assertTrue("Rejected text unit should be present", rejectedFound); + } + @Test public void testCountNone() throws Exception { TMTestData tmTestData = new TMTestData(testIdWatcher); diff --git a/webapp/src/test/java/com/box/l10n/mojito/service/tm/textunitdtocache/TextUnitDTOsCacheServiceTest.java b/webapp/src/test/java/com/box/l10n/mojito/service/tm/textunitdtocache/TextUnitDTOsCacheServiceTest.java index 2c5534108c..8d6f59a9eb 100644 --- a/webapp/src/test/java/com/box/l10n/mojito/service/tm/textunitdtocache/TextUnitDTOsCacheServiceTest.java +++ b/webapp/src/test/java/com/box/l10n/mojito/service/tm/textunitdtocache/TextUnitDTOsCacheServiceTest.java @@ -1,6 +1,7 @@ package com.box.l10n.mojito.service.tm.textunitdtocache; import static com.box.l10n.mojito.entity.TMTextUnitVariant.Status.APPROVED; +import static com.box.l10n.mojito.entity.TMTextUnitVariant.Status.REVIEW_NEEDED; import static com.box.l10n.mojito.entity.TMTextUnitVariant.Status.TRANSLATION_NEEDED; import static com.box.l10n.mojito.service.tm.search.StatusFilter.APPROVED_AND_NOT_REJECTED; import static com.box.l10n.mojito.service.tm.search.StatusFilter.FOR_TRANSLATION; @@ -13,6 +14,7 @@ import static org.mockito.Mockito.when; import com.box.l10n.mojito.entity.TMTextUnit; +import com.box.l10n.mojito.entity.TMTextUnitVariant; import com.box.l10n.mojito.okapi.TextUnitUtils; import com.box.l10n.mojito.service.asset.AssetService; import com.box.l10n.mojito.service.assetExtraction.ServiceTestBase; @@ -209,6 +211,45 @@ public void testStandardLocaleWithPlural() { .containsOnly(true); } + @Test + public void testStatusPredicateReviewNeededOrRejected() { + TMTestData tmTestData = new TMTestData(testIdWatcher); + + Long reviewNeededTmTextUnitId = + tmTestData.addCurrentTMTextUnitVariant1FrFR.getTmTextUnit().getId(); + tmService.addTMTextUnitCurrentVariant( + reviewNeededTmTextUnitId, + tmTestData.addCurrentTMTextUnitVariant1FrFR.getLocale().getId(), + tmTestData.addCurrentTMTextUnitVariant1FrFR.getContent(), + "mark as review needed", + TMTextUnitVariant.Status.REVIEW_NEEDED); + + Long rejectedTmTextUnitId = tmTestData.addCurrentTMTextUnitVariant3FrFR.getTmTextUnit().getId(); + tmService.addTMTextUnitCurrentVariant( + rejectedTmTextUnitId, + tmTestData.addCurrentTMTextUnitVariant3FrFR.getLocale().getId(), + tmTestData.addCurrentTMTextUnitVariant3FrFR.getContent(), + "mark as rejected", + TMTextUnitVariant.Status.APPROVED, + false); + + ImmutableMap result = + textUnitDTOsCacheService.getTextUnitDTOsForAssetAndLocaleByMD5( + tmTestData.asset.getId(), + tmTestData.frFR.getId(), + StatusFilter.REVIEW_NEEDED_OR_REJECTED, + false, + UpdateType.ALWAYS); + + assertThat(result.values().stream()) + .as("Only review needed or rejected strings") + .extracting( + TextUnitDTO::getName, TextUnitDTO::getStatus, TextUnitDTO::isIncludedInLocalizedFile) + .containsExactlyInAnyOrder( + tuple("zuora_error_message_verify_state_province", REVIEW_NEEDED, true), + tuple("TEST3", APPROVED, false)); + } + @Test public void testDeleteAsset() { TMTestData tmTestData = new TMTestData(testIdWatcher); diff --git a/webapp/use_local_npm.sh b/webapp/use_local_npm.sh index 95e57ddbac..e9aad719da 100755 --- a/webapp/use_local_npm.sh +++ b/webapp/use_local_npm.sh @@ -1,5 +1,29 @@ #!/usr/bin/env bash -# usage "source use_local_npm.sh" -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -export PATH="$DIR/node:$DIR/node/node_modules/npm/bin:$PATH" +# Usage: source use_local_npm.sh +# Works in bash and zsh, ensures PATH includes the Maven-managed Node/npm under webapp/node + +_mojito_use_local_npm_resolve_script() { + if [ -n "${BASH_SOURCE:-}" ]; then + printf '%s' "${BASH_SOURCE[0]}" + return + fi + + if [ -n "${ZSH_VERSION:-}" ]; then + # zsh-only expansion to get the current script path even when sourced + printf '%s' "${(%):-%x}" + return + fi + + # Fallback (may be incorrect when sourcing from other shells) + printf '%s' "$0" +} + +_mojito_use_local_npm_script="$(_mojito_use_local_npm_resolve_script)" +_mojito_use_local_npm_dir="$( cd "$( dirname "${_mojito_use_local_npm_script}" )" && pwd )" + +export PATH="$_mojito_use_local_npm_dir/node:$_mojito_use_local_npm_dir/node/node_modules/npm/bin:$PATH" + +unset -f _mojito_use_local_npm_resolve_script +unset _mojito_use_local_npm_script +unset _mojito_use_local_npm_dir