From ad66f9cbe817168483361288001c37e3bddf84d6 Mon Sep 17 00:00:00 2001 From: erickgonzalez Date: Wed, 12 Jun 2024 18:31:21 -0600 Subject: [PATCH] #28306 include in 23.01.18 LTS --- .../java/com/dotcms/util/ImportUtilTest.java | 116 ++++++++ .../com/dotmarketing/util/ImportUtil.java | 260 ++++++++++++++++-- hotfix_tracking.md | 1 + 3 files changed, 354 insertions(+), 23 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotcms/util/ImportUtilTest.java b/dotCMS/src/integration-test/java/com/dotcms/util/ImportUtilTest.java index 1afb74af514f..e8769c5aa46f 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/util/ImportUtilTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/util/ImportUtilTest.java @@ -9,6 +9,7 @@ import com.dotcms.contenttype.business.ContentTypeAPIImpl; import com.dotcms.contenttype.business.FieldAPI; import com.dotcms.contenttype.model.field.*; +import com.dotcms.contenttype.model.type.BaseContentType; import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.contenttype.transform.contenttype.StructureTransformer; import com.dotcms.datagen.*; @@ -20,6 +21,8 @@ import com.dotmarketing.beans.Identifier; import com.dotmarketing.portlets.categories.model.Category; import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.htmlpageasset.business.HTMLPageAssetAPI; +import com.dotmarketing.portlets.templates.model.Template; import org.apache.commons.io.FileUtils; import com.dotcms.uuid.shorty.ShortyIdAPI; import com.dotmarketing.beans.Host; @@ -2482,4 +2485,117 @@ public void importFile_success_when_lineContainsLegacyFolderInode() } } } + + /** + * Method to test: {@link ImportUtil#importFile} + * Case: Import file including a page with URL field set to a valid URL + * @throws DotSecurityException when there is a security exception + * @throws DotDataException when there is a dotCMS data exception + * @throws IOException when there is an IO exception + */ + @Test + public void importFile_PagesWithURL_success() + throws DotSecurityException, DotDataException, IOException { + + Host site = null; + Template template = null; + ContentType pageType = null; + Folder parentFolder = null; + try { + site = new SiteDataGen().nextPersisted(); + + // create test template + template = new TemplateDataGen() + .site(APILocator.systemHost()) + .nextPersisted(); + final String templateId = template.getIdentifier(); + + long time = System.currentTimeMillis(); + final String pageTypeName = "TestPageTypeWithURL_" + time; + final String pageTypeVarName = "velocityVarNameTestPageTypeWithURL_" + time; + + // create test page type + pageType = new ContentTypeDataGen() + .baseContentType(BaseContentType.HTMLPAGE) + .host(APILocator.systemHost()) + .name(pageTypeName) + .velocityVarName(pageTypeVarName) + .nextPersisted(); + + // create test folder + final String parentFolderName = "test-base-folder_" + time; + parentFolder = new FolderDataGen() + .name(parentFolderName) + .site(site) + .nextPersisted(); + final Folder subFolder = new FolderDataGen() + .name("test-sub-folder") + .parent(parentFolder) + .nextPersisted(); + + final String testPageURL = StringPool.FORWARD_SLASH + + parentFolderName + "/test-sub-folder/test-page-1"; + final Reader reader = createTempFile( + HTMLPageAssetAPI.TITLE_FIELD + + "," + HTMLPageAssetAPI.URL_FIELD + + ",hostFolder" + + "," + HTMLPageAssetAPI.TEMPLATE_FIELD + + "," + HTMLPageAssetAPI.FRIENDLY_NAME_FIELD + + "," + HTMLPageAssetAPI.SORT_ORDER_FIELD + + "," + HTMLPageAssetAPI.CACHE_TTL_FIELD + + "\r\n" + + "Test Page 1," + testPageURL + "," + + site.getIdentifier() + "," + templateId + + ",Test Page 1,0,300\r\n"); + + final CsvReader csvreader = new CsvReader(reader); + csvreader.setSafetySwitch(false); + + final String[] csvHeaders = csvreader.getHeaders(); + + final Map> results = ImportUtil + .importFile(0L, defaultSite.getInode(), pageType.inode(), + new String[]{}, false, false, + user, defaultLanguage.getId(), csvHeaders, csvreader, + -1, -1, + reader, null, getHttpRequest()); + + Logger.info(ImportUtilTest.class, "page errors: " + results.get("errors")); + validate(results, false, false, true); + + final List savedData = contentletAPI + .findByStructure(pageType.inode(), user, false, 0, 0); + assertNotNull(savedData); + assertEquals(1, savedData.size()); + + final Contentlet contentlet = savedData.get(0); + final Identifier identifier = APILocator.getIdentifierAPI().find(contentlet); + assertNotNull(identifier); + assertEquals(testPageURL, identifier.getURI()); + + } finally { + try { + if (parentFolder != null) { + FolderDataGen.remove(parentFolder); + } + + if (pageType != null) { + contentTypeApi.delete(pageType); + } + + if (template != null) { + TemplateDataGen.remove(template); + } + + if (null != site) { + APILocator.getHostAPI().archive(site, APILocator.systemUser(), false); + APILocator.getHostAPI().delete(site, APILocator.systemUser(), false); + } + + } catch (Exception e) { + Logger.error("Error deleting test page type", e); + } + + } + } } \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotmarketing/util/ImportUtil.java b/dotCMS/src/main/java/com/dotmarketing/util/ImportUtil.java index a03ba9680349..ff7063360327 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/ImportUtil.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/ImportUtil.java @@ -3,6 +3,9 @@ import com.dotcms.content.elasticsearch.util.ESUtils; import com.dotcms.contenttype.model.field.BinaryField; import com.dotcms.contenttype.model.field.DataTypes; +import com.dotcms.contenttype.model.field.HostFolderField; +import com.dotcms.contenttype.model.type.BaseContentType; +import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.contenttype.transform.contenttype.StructureTransformer; import com.dotcms.contenttype.transform.field.LegacyFieldTransformer; import com.dotcms.repackage.com.csvreader.CsvReader; @@ -38,6 +41,7 @@ import com.dotmarketing.portlets.contentlet.model.IndexPolicy; import com.dotmarketing.portlets.folders.business.FolderAPI; import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.htmlpageasset.business.HTMLPageAssetAPI; import com.dotmarketing.portlets.languagesmanager.business.LanguageAPI; import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.portlets.structure.factories.FieldFactory; @@ -67,6 +71,7 @@ import javax.servlet.http.HttpServletRequest; import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; import org.jetbrains.annotations.NotNull; @@ -86,6 +91,7 @@ */ public class ImportUtil { + public static final String LINE_NO = "Line #"; private static PermissionAPI permissionAPI = APILocator.getPermissionAPI(); private final static ContentletAPI conAPI = APILocator.getContentletAPI(); private final static CategoryAPI catAPI = APILocator.getCategoryAPI(); @@ -284,8 +290,8 @@ public static HashMap> importFile(Long importId, String cur } } catch (final DotRuntimeException ex) { String errorMessage = getErrorMsgFromException(user, ex); - if(errorMessage.indexOf("Line #") == -1){ - errorMessage = "Line #" + lineNumber + ": " + errorMessage; + if(errorMessage.indexOf(LINE_NO) == -1){ + errorMessage = LINE_NO + lineNumber + ": " + errorMessage; } results.get("errors").add(errorMessage); errors++; @@ -692,10 +698,11 @@ private static void importLine ( String[] line, String currentHostId, Structure HashMap values = new HashMap(); Set categories = new HashSet<>(); Pair siteAndFolder = null; + Pair urlValue = null; for ( Integer column : headers.keySet() ) { Field field = headers.get( column ); if ( line.length < column ) { - throw new DotRuntimeException("Line #" + lineNumber + "doesn't contain all the required columns."); + throw new DotRuntimeException(LINE_NO + lineNumber + "doesn't contain all the required columns."); } String value = line[column]; Object valueObj = value; @@ -712,7 +719,7 @@ private static void importLine ( String[] line, String currentHostId, Structure for(String catKey : categoryKeys) { Category cat = catAPI.findByKey(catKey.trim(), user, false); if(cat == null) - throw new DotRuntimeException("Line #" + lineNumber + " contains errors. Column: '" + field.getVelocityVarName() + + throw new DotRuntimeException(LINE_NO + lineNumber + " contains errors. Column: '" + field.getVelocityVarName() + "', value: '" + value + "', invalid category key found. Line will be ignored."); categories.add(cat); } @@ -751,14 +758,14 @@ private static void importLine ( String[] line, String currentHostId, Structure } else if (field.getFieldType().equals(Field.FieldType.HOST_OR_FOLDER.toString())) { siteAndFolder = getSiteAndFolderFromIdOrName(value, user); if (siteAndFolder == null) { - throw new DotRuntimeException("Line #" + lineNumber + " contains errors. Column: '" + field.getVelocityVarName() + + throw new DotRuntimeException(LINE_NO + lineNumber + " contains errors. Column: '" + field.getVelocityVarName() + "', value: '" + value + "', invalid site/folder inode found. Line will be ignored."); } else { valueObj = value; } } else if (new LegacyFieldTransformer(field).from().typeName().equals(BinaryField.class.getName())){ if(UtilMethods.isSet(value) && !APILocator.getTempFileAPI().validUrl(value)){ - throw new DotRuntimeException("Line #" + lineNumber + " contains errors. URL is malformed or Response is not 200"); + throw new DotRuntimeException(LINE_NO + lineNumber + " contains errors. URL is malformed or Response is not 200"); } }else if(field.getFieldType().equals(Field.FieldType.IMAGE.toString()) || field.getFieldType().equals(Field.FieldType.FILE.toString())) { String filePath = value; @@ -809,6 +816,39 @@ private static void importLine ( String[] line, String currentHostId, Structure bean.setLanguageId(language); uniqueFieldBeans.add(bean); } + + if (valueObj != null + && field.getVelocityVarName().equals(HTMLPageAssetAPI.URL_FIELD)) { + final String uri = StringUtils.stripEnd( + StringUtils.strip(valueObj.toString()), StringPool.FORWARD_SLASH); + final StringBuilder uriBuilder = new StringBuilder(); + if (!uri.startsWith(StringPool.FORWARD_SLASH)) { + uriBuilder.append(StringPool.FORWARD_SLASH); + } + uriBuilder.append(uri); + urlValue = Pair.of(column, uriBuilder.toString()); + } + } + + // Check if the content type is HTMLPage and the URL field is set + final Pair, String> pathAndAssetNameForURL = + urlValue != null ? + checkURLFieldForHTMLPage(contentType, + urlValue.getRight(), siteAndFolder, user) : + null; + if (pathAndAssetNameForURL != null) { + final String assetNameForURL = pathAndAssetNameForURL.getRight(); + if (UtilMethods.isSet(assetNameForURL)) { + values.put(urlValue.getLeft(), assetNameForURL); + final Pair parentPathForURL = pathAndAssetNameForURL.getLeft(); + if (parentPathForURL == null) { + throw new DotRuntimeException(LINE_NO + lineNumber + " contains errors. Column: '" + + HTMLPageAssetAPI.URL_FIELD + "', value: '" + urlValue.getRight() + + "', invalid parent folder for URL. Line will be ignored."); + } else { + siteAndFolder = parentPathForURL; + } + } } //Find the relationships and their related contents @@ -880,26 +920,49 @@ private static void importLine ( String[] line, String currentHostId, Structure if ( UtilMethods.isSet( identifier ) ) { buffy.append(" +identifier:").append(identifier); - List contentsSearch = conAPI.searchIndex( buffy.toString(), 0, -1, null, user, true ); + List contentsSearch = conAPI.searchIndex(buffy.toString(), 0, -1, null, user, true); - if ( (contentsSearch == null) || (contentsSearch.size() == 0) ) { + if ((contentsSearch == null) || (contentsSearch.size() == 0)) { - Logger.warn(ImportUtil.class, "Line #" + lineNumber + ": Content not found with identifier " + identifier + "\n"); - throw new DotRuntimeException( "Line #" + lineNumber + ": Content not found with identifier " + identifier + "\n" ); + Logger.warn(ImportUtil.class, LINE_NO + lineNumber + ": Content not found with identifier " + identifier + "\n"); + throw new DotRuntimeException(LINE_NO + lineNumber + ": Content not found with identifier " + identifier + "\n"); } else { Contentlet contentlet; - for ( ContentletSearch contentSearch : contentsSearch ) { - contentlet = conAPI.find( contentSearch.getInode(), user, true ); - if ( (contentlet != null) && InodeUtils.isSet( contentlet.getInode() ) ) { - contentlets.add( contentlet ); + for (ContentletSearch contentSearch : contentsSearch) { + contentlet = conAPI.find(contentSearch.getInode(), user, true); + if ((contentlet != null) && InodeUtils.isSet(contentlet.getInode())) { + contentlets.add(contentlet); } else { - Logger.warn(ImportUtil.class, "Line #" + lineNumber + ": Content not found with identifier " + identifier + "\n"); - throw new DotRuntimeException( "Line #" + lineNumber + ": Content not found with identifier " + identifier + "\n" ); + Logger.warn(ImportUtil.class, LINE_NO + lineNumber + ": Content not found with identifier " + identifier + "\n"); + throw new DotRuntimeException(LINE_NO + lineNumber + ": Content not found with identifier " + identifier + "\n"); + } + } + } + } else if (pathAndAssetNameForURL != null && keyFields.isEmpty()) { + // For HTMLPageAsset, we need to search by URL to math existing pages + buffy.append( " +languageId:" ).append( language ); + buffy.append(addSiteAndFolderToESQuery(siteAndFolder, null)); + buffy.append(" +").append(contentType.getVelocityVarName()).append(StringPool.PERIOD) + .append(HTMLPageAssetAPI.URL_FIELD).append(StringPool.COLON) + .append(pathAndAssetNameForURL.getRight()); + List contentsSearch = conAPI.searchIndex( + buffy.toString(), 0, -1, null, user, true); + if (contentsSearch != null && !contentsSearch.isEmpty()) { + Contentlet contentlet; + for (ContentletSearch contentSearch : contentsSearch) { + contentlet = conAPI.find(contentSearch.getInode(), user, true); + if ((contentlet != null) && InodeUtils.isSet(contentlet.getInode())) { + contentlets.add(contentlet); + } else { + Logger.warn(ImportUtil.class, LINE_NO + lineNumber + ": Content not found with URL " + urlValue.getRight() + "\n"); + throw new DotRuntimeException(LINE_NO + lineNumber + ": Content not found with URL " + urlValue.getRight() + "\n"); } } } } else if (keyFields.size() > 0) { + boolean appendSiteToQuery = false; + String siteFieldValue = null; for (Integer column : keyFields.keySet()) { Field field = keyFields.get(column); Object value = values.get(column); @@ -923,10 +986,18 @@ private static void importLine ( String[] line, String currentHostId, Structure text = value.toString(); } if(!UtilMethods.isSet(text)){ - throw new DotRuntimeException("Line #" + lineNumber + " key field " + field.getVelocityVarName() + " is required since it was defined as a key\n"); + throw new DotRuntimeException(LINE_NO + lineNumber + " key field " + field.getVelocityVarName() + " is required since it was defined as a key\n"); }else{ - if(field.getFieldType().equals(Field.FieldType.HOST_OR_FOLDER.toString())) { - buffy.append(addSiteAndFolderToESQuery(siteAndFolder, text)); + if (field.getVelocityVarName().equals(HTMLPageAssetAPI.URL_FIELD) + && pathAndAssetNameForURL != null) { + appendSiteToQuery = true; + buffy.append(" +").append(contentType.getVelocityVarName()).append(StringPool.PERIOD) + .append(HTMLPageAssetAPI.URL_FIELD).append(StringPool.COLON) + .append(pathAndAssetNameForURL.getRight()); + value = getURLFromFolderAndAssetName(pathAndAssetNameForURL); + } else if(new LegacyFieldTransformer(field).from() instanceof HostFolderField) { + appendSiteToQuery = true; + siteFieldValue = text; } else { buffy.append(" +").append(contentType.getVelocityVarName()).append(StringPool.PERIOD) .append(field.getVelocityVarName()).append(field.isUnique()? ESUtils.SHA_256: "_dotraw") @@ -959,7 +1030,9 @@ private static void importLine ( String[] line, String currentHostId, Structure } } - + if (appendSiteToQuery) { + buffy.append(addSiteAndFolderToESQuery(siteAndFolder, siteFieldValue)); + } String noLanguageQuery = buffy.toString(); if ( !isMultilingual && !UtilMethods.isSet( identifier ) ) { buffy.append( " +languageId:" ).append( language ); @@ -1015,6 +1088,11 @@ private static void importLine ( String[] line, String currentHostId, Structure break; } }else{ + if (field.getVelocityVarName().equals(HTMLPageAssetAPI.URL_FIELD) + && pathAndAssetNameForURL != null) { + value = getURLFromFolderAndAssetName(pathAndAssetNameForURL); + conValue = getURLFromContentId(con.getIdentifier()); + } Logger.debug(ImportUtil.class,"conValue: " + conValue.toString()); Logger.debug(ImportUtil.class,"Value: " + value.toString()); if(conValue.toString().equalsIgnoreCase(value.toString())){ @@ -1058,6 +1136,11 @@ can manage multilingual inserts for already stored records but not for the case //Ok, comparing our keys with the contentlets we found trying to see if there is a contentlet to update with the specified keys Object conValue = conAPI.getFieldValue( contentlet, field ); + if (field.getVelocityVarName().equals(HTMLPageAssetAPI.URL_FIELD) + && pathAndAssetNameForURL != null) { + value = getURLFromFolderAndAssetName(pathAndAssetNameForURL); + conValue = getURLFromContentId(contentlet.getIdentifier()); + } if ( !conValue.equals( value ) ) { match = false; } @@ -1295,7 +1378,7 @@ can manage multilingual inserts for already stored records but not for the case new ArrayList<>(categories)); } } catch (DotContentletValidationException ex) { - StringBuffer sb = new StringBuffer("Line #" + lineNumber + " contains errors\n"); + StringBuffer sb = new StringBuffer(LINE_NO + lineNumber + " contains errors\n"); HashMap> errors = (HashMap>) ex.getNotValidFields(); Set keys = errors.keySet(); for (String key : keys) { @@ -1561,13 +1644,144 @@ private static String addSiteAndFolderToESQuery( final Folder folder = siteAndFolder.getRight(); siteAndFolderQuery.append(" +conFolder:").append(folder.getInode()); } - } else { + } else if (UtilMethods.isSet(fieldValue)) { siteAndFolderQuery.append(" +(conhost:").append(fieldValue).append(" conFolder:") .append(fieldValue).append(")"); } return siteAndFolderQuery.toString(); } + /** + * Check if the URL field for an HTMLPage content type is set correctly + * @param contentType the content type + * @param urlValue the URL field value + * @param siteAndFolderFromLine the site and folder from the site field in the import line + * it can be null if the site field is not set + * @param user the current user + */ + private static Pair, String> checkURLFieldForHTMLPage( + final Structure contentType, final String urlValue, + final Pair siteAndFolderFromLine, final User user) + throws DotDataException, DotSecurityException { + + final ContentType targetContentType = new StructureTransformer(contentType).from(); + if (UtilMethods.isSet(urlValue) && + BaseContentType.HTMLPAGE.getType() == targetContentType.baseType().getType()) { + + // Check if the URL field value includes parent folder and asset name + String assetName = urlValue; + String parentPath = null; + if (StringUtils.lastIndexOf(urlValue, StringPool.FORWARD_SLASH) >= 0) { + assetName = StringUtils.substringAfterLast(urlValue, StringPool.FORWARD_SLASH); + parentPath = StringUtils.substringBeforeLast(urlValue, StringPool.FORWARD_SLASH); + } + + // If there is parent path and also a site without folder was already read + // from the site field in the import line, check if the parent folder exists and return it + // If there is a parent path and a site was not read from the site field, + // check if the parent folder exists in the default site instead + final Host site = siteAndFolderFromLine == null ? + hostAPI.findDefaultHost(user, false) : + siteAndFolderFromLine.getLeft(); + final Folder folder = siteAndFolderFromLine == null ? + folderAPI.findSystemFolder() : siteAndFolderFromLine.getRight(); + final Pair siteAndFolder = ImmutablePair.of(site, folder); + + if (UtilMethods.isSet(parentPath)) { + return ImmutablePair.of( + getHostAndFolderFromParentPathOrSiteField( + siteAndFolder, user, parentPath), + assetName); + } + + // If there is no parent path, return the site and folder from the site field + return ImmutablePair.of(siteAndFolder, assetName); + } + + return null; + } + + /** + * Get the host and folder from given parent path. + * Returns the host and folder from the site field in the import line if already set + * @param siteAndFolder the site and folder from the site field in the import line + * @param user the current user + * @param parentPath the parent path for the URL field value + * @return the host and folder from the parent path or the site field + */ + private static Pair getHostAndFolderFromParentPathOrSiteField( + final Pair siteAndFolder, + final User user, final String parentPath) + throws DotDataException, DotSecurityException { + + final Host site = siteAndFolder.getLeft(); + final Folder siteFolder = siteAndFolder.getRight(); + final Folder parentFolder = folderAPI.findFolderByPath( + parentPath, site, user, false); + if (isFolderSet(parentFolder)) { + if ((siteFolder == null || siteFolder.isSystemFolder())) { + return ImmutablePair.of(site, parentFolder); + } else { + if (isFolderSet(siteFolder) && !parentFolder.getInode().equals(siteFolder.getInode())) { + Logger.warn(ImportUtil.class, String.format( + "Folder from site field %s doesn't match parent folder for URL: %s", + siteFolder.getPath(), parentFolder.getPath())); + } + return siteAndFolder; + } + } else { + Logger.warn(ImportUtil.class, String.format( + "Parent folder not found for URL field value: %s", parentPath)); + return null; + } + + } + + /** + * Get the URL from the folder path and asset name + * @param folderAndAssetName the folder and asset name pair + * @return the URL for the given folder path and asset name + */ + private static String getURLFromFolderAndAssetName( + final Pair,String> folderAndAssetName) { + final Pair siteAndFolder = folderAndAssetName.getLeft(); + final String assetName = folderAndAssetName.getRight(); + final StringBuilder url = new StringBuilder(); + if (siteAndFolder != null) { + final Folder folder = siteAndFolder.getRight(); + if (isFolderSet(folder)) { + url.append(folder.getPath()); + if (UtilMethods.isSet(folder.getPath()) + && !folder.getPath().endsWith(StringPool.FORWARD_SLASH)) { + url.append(StringPool.FORWARD_SLASH); + } + } + } + url.append(assetName); + return url.toString(); + } + + /** + * Get the URL from the content identifier + * @param contentId the content identifier + * @return the URL for the given content identifier + */ + private static String getURLFromContentId(final String contentId) { + StringBuilder url = new StringBuilder(); + if (contentId != null ) { + try { + final Identifier identifier = APILocator.getIdentifierAPI().find(contentId); + if (UtilMethods.isSet(identifier) && UtilMethods.isSet(identifier.getId())) { + url.append(identifier.getURI()); + } + } catch (DotDataException e) { + Logger.error(ImportUtil.class, "Unable to get Identifier with id [" + + contentId + "]. Could not get the url", e ); + } + } + return url.toString(); + } + /** * Set the site and folder for the given contentlet * @param user current user @@ -1798,7 +2012,7 @@ private static Object validateDateTypes(final int lineNumber, final Field field, valueObj = parseExcelDate(value); } catch (ParseException e) { throw new DotRuntimeException( - "Line #" + lineNumber + " contains errors, Column: " + field + LINE_NO + lineNumber + " contains errors, Column: " + field .getVelocityVarName() + ", value: " + value + ", couldn't be parsed as any of the following supported formats: " diff --git a/hotfix_tracking.md b/hotfix_tracking.md index df643c3a5ba2..a0731bb5399b 100644 --- a/hotfix_tracking.md +++ b/hotfix_tracking.md @@ -251,3 +251,4 @@ This maintenance release includes the following code fixes: 195. https://github.com/dotCMS/core/issues/24908 : Lost ability to select system host #24908 196. https://github.com/dotCMS/core/issues/28089 : Pound char not decoded when using Rules or Vanity URLs #28089 +197. https://github.com/dotCMS/core/issues/28306 : Can't Import Page Assets using the export file #28306