From 16927668f5ec8fd755b3de50f829d8e795abb076 Mon Sep 17 00:00:00 2001 From: Isaries Date: Tue, 27 Oct 2026 19:48:27 +0800 Subject: [PATCH] fix(Authoring): import assets whose file names contain parentheses When steps are imported from another unit, getAssetFileNames searched the serialized steps with one regex whose name class has no parentheses and no CJK characters. A name such as "diagram (1).png" was not found, so the asset was not copied, and a match could start at the closing quote of one JSON string and run into the next, yielding names like ',"b.png'. Each string value of the steps JSON is now handled on its own: a value that is itself a relative file name is taken whole, and any other value is searched with the same pattern the client uses, case-insensitively. Renaming an imported asset used replaceAll, which treats the file name as a regex: a name with parentheses silently matched nothing and an unbalanced parenthesis threw PatternSyntaxException. The rename is now a literal replace and also covers the %20-encoded form of the name. --- .../author/project/AuthorAPIController.java | 76 +++++++++++++++---- .../project/AuthorAPIControllerTest.java | 51 +++++++++++++ 2 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIController.java b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIController.java index 7a0f38f4e..01473d4a7 100644 --- a/src/main/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIController.java +++ b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIController.java @@ -36,6 +36,7 @@ import java.util.Locale; import java.util.Map; import java.util.Random; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -45,8 +46,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import org.apache.commons.lang3.StringUtils; import org.json.JSONException; @@ -527,9 +530,7 @@ protected String importSteps(Authentication auth, @RequestBody ObjectNode object toProjectAssetsUrl); if (newFileName != null && !fileName.equals(newFileName)) { - // the file name was changed so we need to update the step content by replacing - // all instances of the file name with the new file name - steps = steps.replaceAll(fileName, newFileName); + steps = replaceAssetFileName(steps, fileName, newFileName); } } @@ -537,24 +538,67 @@ protected String importSteps(Authentication auth, @RequestBody ObjectNode object return steps; } + protected String replaceAssetFileName(String steps, String fileName, String newFileName) { + // getAssetFileNames found the name after decoding %20, so the encoded form is replaced too + return steps.replace(fileName, newFileName) + .replace(fileName.replace(" ", "%20"), newFileName.replace(" ", "%20")); + } + + // Same name class as ProjectService.replaceAssetPaths in WISE-Client. Widening it lets a match + // start at an HTML attribute's closing quote and swallow the next src. + private static final Pattern EMBEDDED_ASSET = Pattern.compile( + "('|\"|\\\\'|\\\\\")([^:][^/]?[^/]?[a-zA-Z0-9\\u4e00-\\u9fa5@%&;\\._\\/\\s\\-']*[.]" + + "(png|jpe?g|pdf|gif|mov|mp4|mp3|wav|swf|css|txt|json|xlsx?|doc|html.*?|js)).*?" + + "('|\"|\\\\'|\\\\\")", + Pattern.CASE_INSENSITIVE); + + private static final Pattern RELATIVE_ASSET_FILE_NAME = Pattern.compile( + "^(?!/)[a-zA-Z0-9@%&;()._/ \\-" + + "\\u3008-\\u3011\\u3014-\\u301b\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uac00-\\ud7af" + + "\\uff08\\uff09\\uff10-\\uff19\\uff21-\\uff3a\\uff3b\\uff3d\\uff41-\\uff5a]+" + + "\\.(png|jpe?g|pdf|gif|mov|mp4|mp3|wav|swf|css|txt|json|xlsx?|doc|html?|js)$", + Pattern.CASE_INSENSITIVE); + protected List getAssetFileNames(String stepsText) { - // Replace %20 with a space so that file names with a space will be properly found. Sometimes - // html content that contains a file name with a space will replace the space with %20. - String stepsTextModified = stepsText.replaceAll("%20", " "); - - // Regex string to match asset file references in the step/component content. e.g. carbon.png - String patternString = """ - ('|"|\\\\'|\\\\")([^:][^/]?[^/]?[a-zA-Z0-9@\\._\\/\\s\\-]*[.]\ - (png|PNG|jpe?g|JPE?G|pdf|PDF|gif|GIF|mov|MOV|mp4|MP4|mp3|MP3|wav|WAV|swf|SWF|css|CSS\ - |txt|TXT|json|JSON|xlsx?|XLSX?|doc|DOC|html.*?|HTML.*?|js|JS)).*?('|"|\\\\'|\\\\")\ - """; - Pattern pattern = Pattern.compile(patternString); - Matcher matcher = pattern.matcher(stepsTextModified); HashSet fileNames = new HashSet(); + JsonNode steps = null; + try { + steps = new ObjectMapper().readTree(stepsText); + } catch (JsonProcessingException e) { + // not JSON, e.g. an HTML fragment: search it as one text + } + if (steps != null && steps.isContainerNode()) { + collectAssetFileNames(steps, fileNames); + } else { + addEmbeddedAssetFileNames(stepsText, fileNames); + } + return new ArrayList(fileNames); + } + + // Searching the serialized JSON as a whole lets a match run from the closing quote of one string + // into the next, so each string value is searched on its own. + private void collectAssetFileNames(JsonNode node, Set fileNames) { + if (node.isTextual()) { + String value = node.asText(); + String decoded = value.replace("%20", " "); + if (RELATIVE_ASSET_FILE_NAME.matcher(decoded).matches()) { + fileNames.add(decoded); + } else { + addEmbeddedAssetFileNames(TextNode.valueOf(value).toString(), fileNames); + } + } else { + for (JsonNode child : node) { + collectAssetFileNames(child, fileNames); + } + } + } + + private void addEmbeddedAssetFileNames(String text, Set fileNames) { + // html content sometimes encodes the space in a file name as %20 + Matcher matcher = EMBEDDED_ASSET.matcher(text.replace("%20", " ")); while (matcher.find()) { fileNames.add(matcher.group(2)); } - return new ArrayList(fileNames); } private void createNewProjectDirectory(String projectId) throws IOException { diff --git a/src/test/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIControllerTest.java b/src/test/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIControllerTest.java index 1c1f7b4d9..6191dab84 100644 --- a/src/test/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIControllerTest.java +++ b/src/test/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIControllerTest.java @@ -6,6 +6,7 @@ import static org.easymock.EasyMock.replay; import static org.easymock.EasyMock.verify; import static org.easymock.EasyMock.anyObject; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -305,4 +306,54 @@ public void getAssetFileNames_withASpace_shouldReturnFileNames() throws Exceptio assertTrue(fileNames.contains("carbon dioxide.png")); assertTrue(fileNames.contains("carbon monoxide.png")); } + + @Test + public void getAssetFileNames_withParenthesesAndCJK_shouldReturnEachFileName() throws Exception { + String stepsText = "{\"Stamps\":[\"示意圖 (chatgpt生成) 1.png\",\"細胞(核)【卵】.png\",\"b.png\"]}"; + List fileNames = authorAPIController.getAssetFileNames(stepsText); + assertEquals(3, fileNames.size()); + assertTrue(fileNames.contains("示意圖 (chatgpt生成) 1.png")); + assertTrue(fileNames.contains("細胞(核)【卵】.png")); + assertTrue(fileNames.contains("b.png")); + } + + @Test + public void getAssetFileNames_afterAnUnmatchedName_shouldNotRunIntoTheNextString() + throws Exception { + String stepsText = "[\"image#1.png\",\"b.png\"]"; + List fileNames = authorAPIController.getAssetFileNames(stepsText); + assertTrue(fileNames.contains("b.png"), fileNames.toString()); + for (String fileName : fileNames) { + assertFalse(fileName.contains("\""), fileName); + } + } + + @Test + public void getAssetFileNames_withParenthesesInHtmlText_shouldStillFindTheNextImage() + throws Exception { + String stepsText = "[{\"html\":\"

(see) a.png

\"}]"; + List fileNames = authorAPIController.getAssetFileNames(stepsText); + assertTrue(fileNames.contains("b.png"), fileNames.toString()); + } + + @Test + public void getAssetFileNames_withMixedCaseExtension_shouldReturnFileName() throws Exception { + String stepsText = "[{\"src\":\"Photo.Png\",\"html\":\"\"}]"; + List fileNames = authorAPIController.getAssetFileNames(stepsText); + assertTrue(fileNames.contains("Photo.Png"), fileNames.toString()); + assertTrue(fileNames.contains("x.Jpg"), fileNames.toString()); + } + + @Test + public void replaceAssetFileName_withParentheses_shouldReplaceLiterally() { + String steps = "[\"a (1).png\",\"\"]"; + String result = authorAPIController.replaceAssetFileName(steps, "a (1).png", "a (1)-2.png"); + assertEquals("[\"a (1)-2.png\",\"\"]", result); + } + + @Test + public void replaceAssetFileName_withUnbalancedParenthesis_shouldNotThrow() { + String result = authorAPIController.replaceAssetFileName("[\"a(.png\"]", "a(.png", "a(-1.png"); + assertEquals("[\"a(-1.png\"]", result); + } }