diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtils.java index 31c5005cd53d..1ce4cf240a9c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtils.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtils.java @@ -426,7 +426,7 @@ public static String replaceMediaFileWithUrlInGutenbergPost(@NonNull String post .notNullStr(Utils.escapeQuotes(mediaFile.getFileURL())); MediaUploadCompletionProcessor processor = new MediaUploadCompletionProcessor(localMediaId, mediaFile, siteUrl); - postContent = processor.processPost(postContent); + postContent = processor.processContent(postContent); } return postContent; } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/BlockProcessor.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/BlockProcessor.java index eccb6b37d43c..80129877df69 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/BlockProcessor.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/BlockProcessor.java @@ -1,5 +1,8 @@ package org.wordpress.android.ui.posts.mediauploadcompletionprocessors; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Document.OutputSettings; @@ -7,7 +10,8 @@ import org.wordpress.android.util.helpers.MediaFile; import java.util.regex.Matcher; -import java.util.regex.Pattern; + +import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaUploadCompletionProcessorPatterns.PATTERN_BLOCK_CAPTURES; /** * Abstract class to be extended for each enumerated {@link MediaBlockType}. @@ -27,11 +31,12 @@ abstract class BlockProcessor { String mRemoteId; String mRemoteUrl; - private Pattern mBlockPattern; - private String mHeaderComment; - private String mBlockContent; + private String mBlockName; + private JsonObject mJsonAttributes; + private Document mBlockContentDocument; private String mClosingComment; + /** * @param localId The local media id that needs replacement * @param mediaFile The mediaFile containing the remote id and remote url @@ -40,51 +45,38 @@ abstract class BlockProcessor { mLocalId = localId; mRemoteId = mediaFile.getMediaId(); mRemoteUrl = org.wordpress.android.util.StringUtils.notNullStr(Utils.escapeQuotes(mediaFile.getFileURL())); - mBlockPattern = Pattern.compile(String.format(getBlockPatternTemplate(), localId), Pattern.DOTALL); } - // TODO: consider processing block header JSON in a more robust way (current processing uses RexEx) - /** - * @param block The raw block contents of the block to be matched - * @return A {@link Matcher} to extract block contents and splice the header with a remote id. The matcher has the - * following capture groups: - * - *
    - *
  1. Block header before id
  2. - *
  3. The localId (to be replaced)
  4. - *
  5. Block header after id
  6. - *
  7. Block contents
  8. - *
  9. Block closing comment and any following characters
  10. - *
- */ - Matcher getMatcherForBlock(String block) { - return mBlockPattern.matcher(block); + private JsonObject parseJson(String blockJson) { + JsonParser parser = new JsonParser(); + return parser.parse(blockJson).getAsJsonObject(); + } + + private Document parseHTML(String blockContent) { + // create document from block content + Document document = Jsoup.parse(blockContent); + document.outputSettings(OUTPUT_SETTINGS); + return document; } - boolean matchAndSpliceBlockHeader(String block) { - Matcher matcher = getMatcherForBlock(block); + private boolean splitBlock(String block) { + Matcher captures = PATTERN_BLOCK_CAPTURES.matcher(block); - boolean matchFound = matcher.find(); + boolean capturesFound = captures.find(); - if (matchFound) { - mHeaderComment = new StringBuilder() - .append(matcher.group(1)) - .append(mRemoteId) // here we substitute remote id in place of the local id - .append(matcher.group(3)) - .toString(); - mBlockContent = matcher.group(4); - mClosingComment = matcher.group(5); + if (capturesFound) { + mBlockName = captures.group(1); + mJsonAttributes = parseJson(captures.group(2)); + mBlockContentDocument = parseHTML(captures.group(3)); + mClosingComment = captures.group(4); + return true; } else { - mHeaderComment = null; - mBlockContent = null; + mBlockName = null; + mJsonAttributes = null; + mBlockContentDocument = null; mClosingComment = null; + return false; } - - return matchFound; - } - - String getHeaderComment() { - return mHeaderComment; } /** @@ -94,46 +86,28 @@ String getHeaderComment() { * @param block The raw block contents * @return A string containing content with ids and urls replaced */ - String processBlock(String block) { - if (matchAndSpliceBlockHeader(block)) { - // create document from block content - Document document = Jsoup.parse(mBlockContent); - document.outputSettings(OUTPUT_SETTINGS); - - if (processBlockContentDocument(document)) { - // return injected block - return new StringBuilder() - .append(getHeaderComment()) - .append(document.body().html()) // parser output - .append(mClosingComment) - .toString(); - } - } - - // leave block unchanged - return block; - } - - /** - * All concrete implementations must implement this method to return a regex pattern template for the particular - * block type.
- *
- * The pattern template should contain a format specifier for the local id that needs to be matched and - * replaced in the block header, and the format specifier should be within its own capture group, e.g. `(%1$s)`.
- *
- * The pattern template should result in a matcher with the following capture groups: - * - *
    - *
  1. Block header before id
  2. - *
  3. The format specifier for the local id (to be replaced by the local id when generating the pattern)
  4. - *
  5. Block header after id
  6. - *
  7. Block contents
  8. - *
  9. Block closing comment and any following characters
  10. - *
- * - * @return String with the regex pattern template - */ - abstract String getBlockPatternTemplate(); + String processBlock(String block) { + if (splitBlock(block)) { + if (processBlockJsonAttributes(mJsonAttributes)) { + if (processBlockContentDocument(mBlockContentDocument)) { + // return injected block + return new StringBuilder() + .append("\n") + .append(mBlockContentDocument.body().html()) // HTML parser output + .append(mClosingComment) + .toString(); + } + } else { + return processInnerBlock(block); // delegate to inner blocks if needed + } + } + // leave block unchanged + return block; + } /** * All concrete implementations must implement this method for the particular block type. The document represents @@ -146,4 +120,33 @@ String processBlock(String block) { * @return A boolean value indicating whether or not the block contents should be replaced */ abstract boolean processBlockContentDocument(Document document); + + /** + * All concrete implementations must implement this method for the particular block type. The jsonAttributes object + * is a {@link JsonObject} parsed from the block header attributes. This object can be used to check for a match, + * and can be directly mutated if necessary.
+ *
+ * This method should return true to indicate success. Returning false will result in the block contents being + * unmodified. + * + * @param jsonAttributes the attributes object used to check for a match with the local id, and mutated if necessary + * @return + */ + abstract boolean processBlockJsonAttributes(JsonObject jsonAttributes); + + /** + * This method can be optionally overriden by concrete implementations to delegate further processing via recursion + * when {@link BlockProcessor#processBlockJsonAttributes(JsonObject)} returns false (i.e. the block did not match + * the local id being replaced). This is useful for implementing mutual recursion with + * {@link MediaUploadCompletionProcessor#processContent(String)} for block types that have media-containing blocks + * within their inner content.
+ *
+ * The default implementation provided is a NOOP that leaves the content of the block unchanged. + * + * @param block The raw block contents + * @return A string containing content with ids and urls replaced + */ + String processInnerBlock(String block) { + return block; + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/BlockProcessorFactory.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/BlockProcessorFactory.java index 1c0d8e60528b..b33568c3c850 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/BlockProcessorFactory.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/BlockProcessorFactory.java @@ -5,19 +5,22 @@ import java.util.HashMap; import java.util.Map; +import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaBlockType.COVER; import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaBlockType.GALLERY; import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaBlockType.IMAGE; import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaBlockType.MEDIA_TEXT; import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaBlockType.VIDEO; class BlockProcessorFactory { + private final MediaUploadCompletionProcessor mMediaUploadCompletionProcessor; private final Map mMediaBlockTypeBlockProcessorMap; /** * This factory initializes block processors for all media block types and provides a method to retrieve a block * processor instance for a given block type. */ - BlockProcessorFactory() { + BlockProcessorFactory(MediaUploadCompletionProcessor mediaUploadCompletionProcessor) { + mMediaUploadCompletionProcessor = mediaUploadCompletionProcessor; mMediaBlockTypeBlockProcessorMap = new HashMap<>(); } @@ -32,6 +35,8 @@ BlockProcessorFactory init(String localId, MediaFile mediaFile, String siteUrl) mMediaBlockTypeBlockProcessorMap.put(VIDEO, new VideoBlockProcessor(localId, mediaFile)); mMediaBlockTypeBlockProcessorMap.put(MEDIA_TEXT, new MediaTextBlockProcessor(localId, mediaFile)); mMediaBlockTypeBlockProcessorMap.put(GALLERY, new GalleryBlockProcessor(localId, mediaFile, siteUrl)); + mMediaBlockTypeBlockProcessorMap.put(COVER, new CoverBlockProcessor(localId, mediaFile, + mMediaUploadCompletionProcessor)); return this; } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/CoverBlockProcessor.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/CoverBlockProcessor.java new file mode 100644 index 000000000000..f5cc3e18b6bc --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/CoverBlockProcessor.java @@ -0,0 +1,79 @@ +package org.wordpress.android.ui.posts.mediauploadcompletionprocessors; + +import com.google.gson.JsonObject; + +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.wordpress.android.util.helpers.MediaFile; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CoverBlockProcessor extends BlockProcessor { + /** + * Template pattern used to match and splice cover inner blocks + */ + private static final Pattern PATTERN_COVER_INNER = Pattern.compile(new StringBuilder() + .append("(^.*?
\\s*)") + .append("(.*)") // inner block contents + .append("(\\s*
\\s*\\s*.*)").toString(), Pattern.DOTALL); + + /** + * Pattern to match background-image url in cover block html content + */ + private static final Pattern PATTERN_BACKGROUND_IMAGE_URL = Pattern.compile( + "background-image:\\s*url\\([^\\)]+\\)"); + + private final MediaUploadCompletionProcessor mMediaUploadCompletionProcessor; + + public CoverBlockProcessor(String localId, MediaFile mediaFile, + MediaUploadCompletionProcessor mediaUploadCompletionProcessor) { + super(localId, mediaFile); + mMediaUploadCompletionProcessor = mediaUploadCompletionProcessor; + } + + @Override String processInnerBlock(String block) { + Matcher innerMatcher = PATTERN_COVER_INNER.matcher(block); + boolean innerCapturesFound = innerMatcher.find(); + + // process inner contents recursively + if (innerCapturesFound) { + String innerProcessed = mMediaUploadCompletionProcessor.processContent(innerMatcher.group(2)); // + return new StringBuilder() + .append(innerMatcher.group(1)) + .append(innerProcessed) + .append(innerMatcher.group(3)) + .toString(); + } + + return block; + } + + @Override boolean processBlockJsonAttributes(JsonObject jsonAttributes) { + if (jsonAttributes.get("id").getAsInt() == Integer.parseInt(mLocalId, 10)) { + jsonAttributes.addProperty("id", Integer.parseInt(mRemoteId, 10)); + jsonAttributes.addProperty("url", mRemoteUrl); + return true; + } + + return false; + } + + @Override boolean processBlockContentDocument(Document document) { + // select cover block div + Element targetDiv = document.select(".wp-block-cover").first(); + + // if a match is found, proceed with replacement + if (targetDiv != null) { + // replace background-image url in style attribute + String style = PATTERN_BACKGROUND_IMAGE_URL.matcher(targetDiv.attr("style")) + .replaceFirst(String.format("background-image:url(%1$s)", mRemoteUrl)); + targetDiv.attr("style", style); + + // return injected block + return true; + } + + return false; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/GalleryBlockProcessor.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/GalleryBlockProcessor.java index 4d5c25a9c226..59d7f8363036 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/GalleryBlockProcessor.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/GalleryBlockProcessor.java @@ -1,38 +1,17 @@ package org.wordpress.android.ui.posts.mediauploadcompletionprocessors; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.wordpress.android.util.helpers.MediaFile; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - public class GalleryBlockProcessor extends BlockProcessor { - /** - * Template pattern used to match and splice gallery blocks - */ - private static final String PATTERN_TEMPLATE_GALLERY = "(\n?)" // rest of header - + "(.*)" // block contents - + "(\n?)"; // closing comment - - - /** - * A {@link Pattern} to match and capture gallery linkTo property from block header - * - *
    - *
  1. Block header before linkTo property
  2. - *
  3. The linkTo property
  4. - *
  5. Block header after linkTo property
  6. - *
- */ - public static final Pattern PATTERN_GALLERY_LINK_TO = Pattern.compile("(\n?)"); // rest of header - - private String mAttachmentPageUrl; + private String mLinkTo; /** * Query selector for selecting the img element from gallery which needs processing @@ -49,10 +28,6 @@ public GalleryBlockProcessor(String localId, MediaFile mediaFile, String siteUrl mAttachmentPageUrl = mediaFile.getAttachmentPageURL(siteUrl); } - @Override String getBlockPatternTemplate() { - return PATTERN_TEMPLATE_GALLERY; - } - @Override boolean processBlockContentDocument(Document document) { // select image element with our local id Element targetImg = document.select(mGalleryImageQuerySelector).first(); @@ -69,15 +44,10 @@ public GalleryBlockProcessor(String localId, MediaFile mediaFile, String siteUrl targetImg.removeClass("wp-image-" + mLocalId); targetImg.addClass("wp-image-" + mRemoteId); - // check for linkTo property - Matcher linkToMatcher = PATTERN_GALLERY_LINK_TO.matcher(getHeaderComment()); - // set parent anchor href if necessary Element parent = targetImg.parent(); - if (parent != null && parent.is("a") && linkToMatcher.find()) { - String linkToValue = linkToMatcher.group(2); - - switch (linkToValue) { + if (parent != null && parent.is("a") && mLinkTo != null) { + switch (mLinkTo) { case "media": parent.attr("href", mRemoteUrl); break; @@ -95,4 +65,19 @@ public GalleryBlockProcessor(String localId, MediaFile mediaFile, String siteUrl return false; } + + @Override boolean processBlockJsonAttributes(JsonObject jsonAttributes) { + JsonArray ids = jsonAttributes.getAsJsonArray("ids"); + JsonElement linkTo = jsonAttributes.get("linkTo"); + if (linkTo != null) { + mLinkTo = linkTo.getAsString(); + } + for (int i = 0; i < ids.size(); i++) { + if (ids.get(i).getAsString().equals(mLocalId)) { + ids.set(i, new JsonPrimitive(Integer.parseInt(mRemoteId, 10))); + return true; + } + } + return false; + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/ImageBlockProcessor.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/ImageBlockProcessor.java index 650a92ca148a..fc2dd197060f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/ImageBlockProcessor.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/ImageBlockProcessor.java @@ -1,27 +1,16 @@ package org.wordpress.android.ui.posts.mediauploadcompletionprocessors; +import com.google.gson.JsonObject; + import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.wordpress.android.util.helpers.MediaFile; public class ImageBlockProcessor extends BlockProcessor { - /** - * Template pattern used to match and splice image blocks - */ - private static final String PATTERN_TEMPLATE_IMAGE = "(\n?)" // rest of header - + "(.*)" // block contents - + "(\n?)"; // closing comment - public ImageBlockProcessor(String localId, MediaFile mediaFile) { super(localId, mediaFile); } - @Override String getBlockPatternTemplate() { - return PATTERN_TEMPLATE_IMAGE; - } - @Override boolean processBlockContentDocument(Document document) { // select image element with our local id Element targetImg = document.select("img").first(); @@ -40,4 +29,13 @@ public ImageBlockProcessor(String localId, MediaFile mediaFile) { return false; } + + @Override boolean processBlockJsonAttributes(JsonObject jsonAttributes) { + if (jsonAttributes.get("id").getAsString().equals(mLocalId)) { + jsonAttributes.addProperty("id", Integer.parseInt(mRemoteId)); + return true; + } + + return false; + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaBlockType.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaBlockType.java index fd3fa6a1de10..7de0bf67305b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaBlockType.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaBlockType.java @@ -3,6 +3,8 @@ import org.apache.commons.lang3.StringUtils; import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -12,7 +14,26 @@ enum MediaBlockType { IMAGE("image"), VIDEO("video"), MEDIA_TEXT("media-text"), - GALLERY("gallery"); + GALLERY("gallery"), + COVER("cover"); + + private static final Map MAP = new HashMap<>(); + private static final String MATCHING_GROUP; + private static final Pattern PATTERN_MEDIA_BLOCK_TYPES; + + static { + for (MediaBlockType type : values()) { + MAP.put(type.mName, type); + } + + MATCHING_GROUP = StringUtils.join(Arrays.asList(MediaBlockType.values()), "|"); + + PATTERN_MEDIA_BLOCK_TYPES = Pattern.compile(new StringBuilder() + .append(PATTERN_BLOCK_PREFIX) + .append(MATCHING_GROUP) + .append(")") + .toString()); + } private final String mName; @@ -25,12 +46,7 @@ public String toString() { } static MediaBlockType fromString(String blockType) { - for (MediaBlockType mediaBlockType : MediaBlockType.values()) { - if (mediaBlockType.mName.equals(blockType)) { - return mediaBlockType; - } - } - return null; + return MAP.get(blockType); } /** @@ -38,7 +54,7 @@ static MediaBlockType fromString(String blockType) { * regex capturing group pattern) */ static String getMatchingGroup() { - return StringUtils.join(Arrays.asList(MediaBlockType.values()), "|"); + return MATCHING_GROUP; } /** @@ -48,12 +64,7 @@ static String getMatchingGroup() { * @return The media block type or null if no match is found */ static MediaBlockType detectBlockType(String block) { - final Pattern pattern = Pattern.compile(new StringBuilder() - .append(PATTERN_BLOCK_PREFIX) - .append(MediaBlockType.getMatchingGroup()) - .append(")") - .toString()); - Matcher matcher = pattern.matcher(block); + Matcher matcher = PATTERN_MEDIA_BLOCK_TYPES.matcher(block); if (matcher.find()) { return MediaBlockType.fromString(matcher.group(1)); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaTextBlockProcessor.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaTextBlockProcessor.java index 588f630ddd02..558eaa12536d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaTextBlockProcessor.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaTextBlockProcessor.java @@ -1,27 +1,16 @@ package org.wordpress.android.ui.posts.mediauploadcompletionprocessors; +import com.google.gson.JsonObject; + import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.wordpress.android.util.helpers.MediaFile; public class MediaTextBlockProcessor extends BlockProcessor { - /** - * Template pattern used to match and splice media-text blocks - */ - private static final String PATTERN_TEMPLATE_MEDIA_TEXT = "(\n?)" // rest of header - + "(.*)" // block contents - + "(\n?)"; // closing comment - public MediaTextBlockProcessor(String localId, MediaFile mediaFile) { super(localId, mediaFile); } - @Override String getBlockPatternTemplate() { - return PATTERN_TEMPLATE_MEDIA_TEXT; - } - @Override boolean processBlockContentDocument(Document document) { // select image element with our local id Element targetImg = document.select("img").first(); @@ -53,4 +42,13 @@ public MediaTextBlockProcessor(String localId, MediaFile mediaFile) { return false; } + + @Override boolean processBlockJsonAttributes(JsonObject jsonAttributes) { + if (jsonAttributes.get("mediaId").getAsString().equals(mLocalId)) { + jsonAttributes.addProperty("mediaId", Integer.parseInt(mRemoteId)); + return true; + } + + return false; + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessor.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessor.java index f741dc7a820d..bd75608a9e24 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessor.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessor.java @@ -3,8 +3,10 @@ import org.wordpress.android.util.helpers.MediaFile; import java.util.regex.Matcher; +import java.util.regex.Pattern; -import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaUploadCompletionProcessorPatterns.PATTERN_BLOCK; +import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaUploadCompletionProcessorPatterns.PATTERN_BLOCK_HEADER; +import static org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaUploadCompletionProcessorPatterns.PATTERN_TEMPLATE_BLOCK_BOUNDARY; public class MediaUploadCompletionProcessor { private final BlockProcessorFactory mBlockProcessorFactory; @@ -18,34 +20,49 @@ public class MediaUploadCompletionProcessor { * @param siteUrl The site url - used to generate the attachmentPage url */ public MediaUploadCompletionProcessor(String localId, MediaFile mediaFile, String siteUrl) { - mBlockProcessorFactory = new BlockProcessorFactory() + mBlockProcessorFactory = new BlockProcessorFactory(this) .init(localId, mediaFile, siteUrl); } /** - * Processes a post to replace the local ids and local urls of media with remote ids and remote urls. This matches - * media-containing blocks and delegates further processing to {@link #processBlock(String)} + * Processes content to replace the local ids and local urls of media with remote ids and remote urls. This method + * delineates block boundaries for media-containing blocks and delegates further processing via itself and / or + * {@link #processBlock(String)}, via direct and mutual recursion, respectively. * - * @param postContent The post content to be processed - * @return A string containing the processed post, or the original content if no match was found + * @param content The content to be processed + * @return A string containing the processed content, or the original content if no match was found */ - public String processPost(String postContent) { - Matcher matcher = PATTERN_BLOCK.matcher(postContent); - StringBuilder result = new StringBuilder(); + public String processContent(String content) { + Matcher headerMatcher = PATTERN_BLOCK_HEADER.matcher(content); - int position = 0; + int positionBlockStart, positionBlockEnd = content.length(); - while (matcher.find()) { - result.append(postContent.substring(position, matcher.start())); - result.append(processBlock(matcher.group())); - position = matcher.end(); - } + if (headerMatcher.find()) { + positionBlockStart = headerMatcher.start(); + String blockType = headerMatcher.group(1); + Matcher blockBoundaryMatcher = Pattern.compile(String.format(PATTERN_TEMPLATE_BLOCK_BOUNDARY, blockType), + Pattern.DOTALL).matcher(content.substring(headerMatcher.end())); - result.append(postContent.substring(position)); + int nestLevel = 1; - return result.toString(); - } + while (0 < nestLevel && blockBoundaryMatcher.find()) { + if (blockBoundaryMatcher.group(1).equals("/")) { + positionBlockEnd = headerMatcher.end() + blockBoundaryMatcher.end(); + nestLevel--; + } else { + nestLevel++; + } + } + return new StringBuilder() + .append(content.substring(0, positionBlockStart)) + .append(processBlock(content.substring(positionBlockStart, positionBlockEnd))) + .append(processContent(content.substring(positionBlockEnd))) + .toString(); + } else { + return content; + } + } /** * Processes a media block returning a raw content replacement string diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorPatterns.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorPatterns.java index 157730a9aad5..643fada1f527 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorPatterns.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorPatterns.java @@ -3,17 +3,40 @@ import java.util.regex.Pattern; public class MediaUploadCompletionProcessorPatterns { - // TODO: these patterns can be DRYed up after implementing JSON handling for the block header public static final String PATTERN_BLOCK_PREFIX = ""; /** - * A {@link Pattern} to match Gutenberg media-containing blocks with a capture group for the block type + * A {@link Pattern} to match headers for Gutenberg media-containing blocks with a capture group for the block type */ - public static final Pattern PATTERN_BLOCK = Pattern.compile(new StringBuilder() + public static final Pattern PATTERN_BLOCK_HEADER = Pattern.compile(new StringBuilder() .append(PATTERN_BLOCK_PREFIX) .append(MediaBlockType.getMatchingGroup()) - .append(").*?") - .append(PATTERN_BLOCK_SUFFIX) + .append(").*? -->\n?") + .toString(), Pattern.DOTALL); + + /** + * A pattern template to match the block boundaries of a specific Gutenberg block type with a capture group to + * identify the match as either the beginning or end of a block: group(1) == "/" for the end of a block + */ + public static final String PATTERN_TEMPLATE_BLOCK_BOUNDARY = "\n?"; + + /** + * A {@link Pattern} to match Gutenberg media-containing blocks with the following capture groups: + * + *
    + *
  1. Block type
  2. + *
  3. Block json attributes
  4. + *
  5. Block html content
  6. + *
  7. Block closing comment and any following characters
  8. + *
+ * + */ + public static final Pattern PATTERN_BLOCK_CAPTURES = Pattern.compile(new StringBuilder() + .append(PATTERN_BLOCK_PREFIX) // start-of-group: block type + .append(MediaBlockType.getMatchingGroup()) + .append(")") // end-of-group: block type + .append(" (\\{.*?\\}) -->\n?") // group: block header json + .append("(.*)") // group: html content + .append("(.*)") // group: closing-comment (name must match group 1: block type) .toString(), Pattern.DOTALL); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/VideoBlockProcessor.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/VideoBlockProcessor.java index 1f188f253fef..ae91d130dbdf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/VideoBlockProcessor.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/VideoBlockProcessor.java @@ -1,27 +1,16 @@ package org.wordpress.android.ui.posts.mediauploadcompletionprocessors; +import com.google.gson.JsonObject; + import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.wordpress.android.util.helpers.MediaFile; public class VideoBlockProcessor extends BlockProcessor { - /** - * Template pattern used to match and splice video blocks - */ - private static final String PATTERN_TEMPLATE_VIDEO = "(\n?)" // rest of header - + "(.*)" // block contents - + "(\n?)"; // closing comment - public VideoBlockProcessor(String localId, MediaFile mediaFile) { super(localId, mediaFile); } - @Override String getBlockPatternTemplate() { - return PATTERN_TEMPLATE_VIDEO; - } - @Override boolean processBlockContentDocument(Document document) { // select video element with our local id Element targetVideo = document.select("video").first(); @@ -37,4 +26,13 @@ public VideoBlockProcessor(String localId, MediaFile mediaFile) { return false; } + + @Override boolean processBlockJsonAttributes(JsonObject jsonAttributes) { + if (jsonAttributes.get("id").getAsString().equals(mLocalId)) { + jsonAttributes.addProperty("id", Integer.parseInt(mRemoteId)); + return true; + } + + return false; + } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/CoverBlockProcessorTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/CoverBlockProcessorTest.kt new file mode 100644 index 000000000000..c67dfbcab422 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/CoverBlockProcessorTest.kt @@ -0,0 +1,63 @@ +package org.wordpress.android.ui.posts.mediauploadcompletionprocessors + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.assertj.core.api.Assertions +import org.junit.Test +import org.junit.Before + +import org.wordpress.android.util.helpers.MediaFile + +class CoverBlockProcessorTest { + private val mediaFile: MediaFile = mock() + private val mediaUploadCompletionProcessor: MediaUploadCompletionProcessor = mock() + private lateinit var processor: CoverBlockProcessor + + @Before + fun before() { + whenever(mediaFile.mediaId).thenReturn(TestContent.remoteMediaId) + whenever(mediaFile.fileURL).thenReturn(TestContent.remoteImageUrl) + processor = CoverBlockProcessor(TestContent.localMediaId, mediaFile, mediaUploadCompletionProcessor) + } + + @Test + fun `processBlock replaces temporary local id and url for cover block`() { + val processedBlock = processor.processBlock(TestContent.oldCoverBlock) + Assertions.assertThat(processedBlock).isEqualTo(TestContent.newCoverBlock) + } + + @Test + fun `processBlock works with nested image blocks`() { + val processedBlock = processor.processBlock(TestContent.oldCoverBlockWithNestedImageBlock) + Assertions.assertThat(processedBlock).isEqualTo(TestContent.newCoverBlockWithNestedImageBlock) + } + + @Test + fun `processBlock does not process inner nested cover blocks`() { + whenever(mediaUploadCompletionProcessor.processContent(any())).thenReturn(TestContent.oldCoverBlock + "\n ") + val processedBlock = processor.processBlock(TestContent.oldCoverBlockWithNestedCoverBlockInner) + Assertions.assertThat(processedBlock).isEqualTo(TestContent.oldCoverBlockWithNestedCoverBlockInner) + } + + @Test + fun `processBlock works with outer nested cover blocks`() { + whenever(mediaFile.mediaId).thenReturn(TestContent.remoteMediaId2) + whenever(mediaFile.fileURL).thenReturn(TestContent.remoteImageUrl2) + processor = CoverBlockProcessor(TestContent.localMediaId2, mediaFile, mediaUploadCompletionProcessor) + val processedBlock = processor.processBlock(TestContent.oldCoverBlockWithNestedCoverBlockOuter) + Assertions.assertThat(processedBlock).isEqualTo(TestContent.newCoverBlockWithNestedCoverBlockOuter) + } + + @Test + fun `processBlock works with different inline style order`() { + val processedBlock = processor.processBlock(TestContent.oldCoverBlockDifferentStyleOrder) + Assertions.assertThat(processedBlock).isEqualTo(TestContent.newCoverBlockDifferentStyleOrder) + } + + @Test + fun `processBlock works with a space in inline styles`() { + val processedBlock = processor.processBlock(TestContent.oldCoverBlockStyleOrderWithSpace) + Assertions.assertThat(processedBlock).isEqualTo(TestContent.newCoverBlockStyleOrderWithoutSpace) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorPatternsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorPatternsTest.kt new file mode 100644 index 000000000000..87f2b2c407c4 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorPatternsTest.kt @@ -0,0 +1,51 @@ +package org.wordpress.android.ui.posts.mediauploadcompletionprocessors + +import org.assertj.core.api.Assertions +import org.junit.Test +import org.wordpress.android.ui.posts.mediauploadcompletionprocessors.MediaUploadCompletionProcessorPatterns.PATTERN_BLOCK_CAPTURES + +const val blockType = "image" +const val blockJson = """{"url":"file:///image.png","id":123,someObject:{a:1,b:2},someArray:[1,2,3]}""" +const val blockHTML = """
+
+ +

+ +
+
+""" +const val blockHeader = """""" +const val blockClosingComment = """""" +const val rawBlock = """$blockHeader +$blockHTML +$blockClosingComment""" +const val nestedRawBlock = """$blockHeader +
+
+ $rawBlock +
+
+$blockClosingComment +""" + +class MediaUploadCompletionProcessorPatternsTest { + @Test + fun `PATTERN_BLOCK_CAPTURES captures the block type`() { + val matcher = PATTERN_BLOCK_CAPTURES.matcher(rawBlock) + val outcome = matcher.find() + Assertions.assertThat(outcome).isEqualTo(true) + Assertions.assertThat(matcher.group(1)).isEqualTo(blockType) + } + @Test + fun `PATTERN_BLOCK_CAPTURES captures the block header json`() { + val matcher = PATTERN_BLOCK_CAPTURES.matcher(rawBlock) + Assertions.assertThat(matcher.find()).isEqualTo(true) + Assertions.assertThat(matcher.group(2)).isEqualTo(blockJson) + } + @Test + fun `PATTERN_BLOCK_CAPTURES captures the block content`() { + val matcher = PATTERN_BLOCK_CAPTURES.matcher(rawBlock) + Assertions.assertThat(matcher.find()).isEqualTo(true) + Assertions.assertThat(matcher.group(3)).isEqualTo(blockHTML + "\n") + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorTest.kt index fd6b52b30389..5d8a29bfd706 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/MediaUploadCompletionProcessorTest.kt @@ -26,7 +26,7 @@ class MediaUploadCompletionProcessorTest { @Test fun `processPost splices id and url for an image block`() { - val blocks = processor.processPost(TestContent.oldPostImage) + val blocks = processor.processContent(TestContent.oldPostImage) Assertions.assertThat(blocks).isEqualTo(TestContent.newPostImage) } @@ -34,19 +34,52 @@ class MediaUploadCompletionProcessorTest { fun `processPost splices id and url for a video block`() { whenever(mediaFile.fileURL).thenReturn(TestContent.remoteVideoUrl) processor = MediaUploadCompletionProcessor(TestContent.localMediaId, mediaFile, TestContent.siteUrl) - val blocks = processor.processPost(TestContent.oldPostVideo) + val blocks = processor.processContent(TestContent.oldPostVideo) Assertions.assertThat(blocks).isEqualTo(TestContent.newPostVideo) } @Test fun `processPost splices id and url for a media-text block`() { - val blocks = processor.processPost(TestContent.oldPostMediaText) + val blocks = processor.processContent(TestContent.oldPostMediaText) Assertions.assertThat(blocks).isEqualTo(TestContent.newPostMediaText) } @Test fun `processPost splices id and url for a gallery block`() { - val blocks = processor.processPost(TestContent.oldPostGallery) + val blocks = processor.processContent(TestContent.oldPostGallery) Assertions.assertThat(blocks).isEqualTo(TestContent.newPostGallery) } + + @Test + fun `processPost splices id and url for a cover block`() { + val blocks = processor.processContent(TestContent.oldPostCover) + Assertions.assertThat(blocks).isEqualTo(TestContent.newPostCover) + } + + @Test + fun `processPost works for nested inner cover blocks`() { + val blocks = processor.processContent(TestContent.oldCoverBlockWithNestedCoverBlockInner) + Assertions.assertThat(blocks).isEqualTo(TestContent.newCoverBlockWithNestedCoverBlockInner) + } + + @Test + fun `processPost works for nested outer cover blocks`() { + whenever(mediaFile.mediaId).thenReturn(TestContent.remoteMediaId2) + whenever(mediaFile.fileURL).thenReturn(TestContent.remoteImageUrl2) + processor = MediaUploadCompletionProcessor(TestContent.localMediaId2, mediaFile, TestContent.siteUrl) + val blocks = processor.processContent(TestContent.oldCoverBlockWithNestedCoverBlockOuter) + Assertions.assertThat(blocks).isEqualTo(TestContent.newCoverBlockWithNestedCoverBlockOuter) + } + + @Test + fun `processPost works for image blocks nested within a cover block`() { + val processedContent = processor.processContent(TestContent.oldImageBlockNestedInCoverBlock) + Assertions.assertThat(processedContent).isEqualTo(TestContent.newImageBlockNestedInCoverBlock) + } + + @Test + fun `processPost leaves malformed cover block unchanged`() { + val processedContent = processor.processContent(TestContent.malformedCoverBlock) + Assertions.assertThat(processedContent).isEqualTo(TestContent.malformedCoverBlock) + } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/TestContent.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/TestContent.kt index 2a6a02dd9147..985bcc976ea8 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/TestContent.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/mediauploadcompletionprocessors/TestContent.kt @@ -5,18 +5,21 @@ package org.wordpress.android.ui.posts.mediauploadcompletionprocessors object TestContent { const val siteUrl = "https://wordpress.org" private const val localImageUrl = "file://Screenshot-1-1.png" + private const val localImageUrl2 = "file://Screenshot-1-2.png" const val remoteImageUrl = "https://onetwoonetwothisisjustatesthome.files.wordpress.com/2019/11/pexels-photo-1671668.jpg" + const val remoteImageUrl2 = "https://onetwoonetwothisisjustatesthome.files.wordpress.com/2019/12/img_20191202_094944-19.jpg" private const val remoteImageUrlBlogLink = "http://onetwoonetwothisisjustatest.home.blog/pexels-photo-1671668/" private const val remoteImageUrlWithSize = "https://onetwoonetwothisisjustatesthome.files.wordpress.com/2019/11/pexels-photo-1671668.jpg?w=1024" - private const val remoteImageUrl2 = "https://onetwoonetwothisisjustatesthome.files.wordpress.com/2019/12/img_20191202_094944-19.jpg" private const val remoteImageUrl2BlogLink = "http://onetwoonetwothisisjustatest.home.blog/?attachment_id=369" private const val remoteImageUrl2WithSize = "https://onetwoonetwothisisjustatesthome.files.wordpress.com/2019/12/img_20191202_094944-19.jpg?w=768" private const val localVideoUrl = "file://local-video.mov" const val remoteVideoUrl = "https://videos.files.wordpress.com/qeJFeNa2/macintosh-plus-floral-shoppe-02-e383aae382b5e38395e383a9e383b3e382af420-e78fbee4bba3e381aee382b3e383b3e38394e383a5e383bc-1_hd.mp4" const val localMediaId = "112" + const val localMediaId2 = "113" private const val collidingPrefixMediaId = "${localMediaId}42" private const val collidingSuffixMediaId = "42${localMediaId}" const val remoteMediaId = "97629" + const val remoteMediaId2 = "97630" const val attachmentPageUrl = "https://wordpress.org?p=${remoteMediaId}" const val oldImageBlock = """ @@ -357,6 +360,143 @@ object TestContent { +""" + + const val oldCoverBlock = """ +
+
+ +

+ +
+
+ +""" + + const val newCoverBlock = """ +
+
+ +

+ +
+
+ +""" + const val oldCoverBlockWithNestedImageBlock = """ +
+
+ $newImageBlock +
+
+ +""" + const val newCoverBlockWithNestedImageBlock = """ +
+
+ $newImageBlock +
+
+ +""" + const val oldCoverBlockWithNestedCoverBlockOuter = """ +
+
+ $oldCoverBlock +
+
+ +""" + const val newCoverBlockWithNestedCoverBlockOuter = """ +
+
+ $oldCoverBlock +
+
+ +""" + const val oldCoverBlockWithNestedCoverBlockInner = """ +
+
+ $oldCoverBlock +
+
+ +""" + const val newCoverBlockWithNestedCoverBlockInner = """ +
+
+ $newCoverBlock +
+
+ +""" + const val oldImageBlockNestedInCoverBlock = """ +
+
+ $oldImageBlock +
+
+ +""" + const val newImageBlockNestedInCoverBlock = """ +
+
+ $newImageBlock +
+
+ +""" + const val malformedCoverBlock = """ +
+
+ +

+ +
+
+""" + const val oldCoverBlockDifferentStyleOrder = """ +
+
+ +

+ +
+
+ +""" + + const val newCoverBlockDifferentStyleOrder = """ +
+
+ +

+ +
+
+ +""" + const val oldCoverBlockStyleOrderWithSpace = """ +
+
+ +

+ +
+
+ +""" + + const val newCoverBlockStyleOrderWithoutSpace = """ +
+
+ +

+ +
+
+ """ const val oldPostImage = paragraphBlock + oldImageBlock + newVideoBlock + newMediaTextBlock + newGalleryBlock @@ -367,4 +507,6 @@ object TestContent { const val newPostMediaText = paragraphBlock + newImageBlock + newVideoBlock + newMediaTextBlock + newGalleryBlock const val oldPostGallery = paragraphBlock + newImageBlock + newVideoBlock + newMediaTextBlock + oldGalleryBlock const val newPostGallery = paragraphBlock + newImageBlock + newVideoBlock + newMediaTextBlock + newGalleryBlock + const val oldPostCover = paragraphBlock + newImageBlock + oldCoverBlock + newMediaTextBlock + oldGalleryBlock + const val newPostCover = paragraphBlock + newImageBlock + newCoverBlock + newMediaTextBlock + newGalleryBlock } diff --git a/libs/gutenberg-mobile b/libs/gutenberg-mobile index 6906be56ace3..6d7a9952ea96 160000 --- a/libs/gutenberg-mobile +++ b/libs/gutenberg-mobile @@ -1 +1 @@ -Subproject commit 6906be56ace31186ded1df0b65b9ea34fe2aaf34 +Subproject commit 6d7a9952ea964eb9d3eb79f41463e2465fba6585