diff --git a/markwon-iframe-ext/.gitignore b/markwon-iframe-ext/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/markwon-iframe-ext/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/markwon-iframe-ext/build.gradle b/markwon-iframe-ext/build.gradle new file mode 100644 index 00000000..c24bf0a7 --- /dev/null +++ b/markwon-iframe-ext/build.gradle @@ -0,0 +1,48 @@ +plugins { + id 'com.android.library' +} + +android { + compileSdkVersion 31 + + defaultConfig { + minSdkVersion 21 + targetSdkVersion 31 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + + api project(':markwon-core') + + deps.with { + // add a compileOnly dependency, so if this artifact is present + // we will try to obtain a SpanFactory for a Strikethrough node and use + // it to be consistent with markdown (please note that we do not use markwon plugin + // for that in case if different implementation is used) + compileOnly it['commonmark-strikethrough'] + + testImplementation it['ix-java'] + } + + deps.test.with { + testImplementation it['junit'] + testImplementation it['robolectric'] + } +} \ No newline at end of file diff --git a/markwon-iframe-ext/src/androidTest/java/io/noties/markwon/iframe/ext/ExampleInstrumentedTest.java b/markwon-iframe-ext/src/androidTest/java/io/noties/markwon/iframe/ext/ExampleInstrumentedTest.java new file mode 100644 index 00000000..54a07579 --- /dev/null +++ b/markwon-iframe-ext/src/androidTest/java/io/noties/markwon/iframe/ext/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package io.noties.markwon.iframe.ext; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("io.noties.markwon.iframe.ext.test", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/markwon-iframe-ext/src/main/AndroidManifest.xml b/markwon-iframe-ext/src/main/AndroidManifest.xml new file mode 100644 index 00000000..880a9db8 --- /dev/null +++ b/markwon-iframe-ext/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameGroupNode.java b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameGroupNode.java new file mode 100644 index 00000000..e752277c --- /dev/null +++ b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameGroupNode.java @@ -0,0 +1,7 @@ +package io.noties.markwon.iframe.ext; + + +import org.commonmark.node.CustomNode; + +public class IFrameGroupNode extends CustomNode { +} diff --git a/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameNode.java b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameNode.java new file mode 100644 index 00000000..0dea2fda --- /dev/null +++ b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameNode.java @@ -0,0 +1,19 @@ +package io.noties.markwon.iframe.ext; + +import androidx.annotation.NonNull; + +import org.commonmark.node.CustomNode; + +public class IFrameNode extends CustomNode { + + public static final String DELIMITER_STRING = "!["; + private final String link; + public IFrameNode(@NonNull String link) { + this.link = link; + } + + @NonNull + public String link() { + return link; + } +} \ No newline at end of file diff --git a/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFramePlugIn.java b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFramePlugIn.java new file mode 100644 index 00000000..28a964d0 --- /dev/null +++ b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFramePlugIn.java @@ -0,0 +1,44 @@ +package io.noties.markwon.iframe.ext; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.commonmark.parser.Parser; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.MarkwonVisitor; + +public class IFramePlugIn extends AbstractMarkwonPlugin { + + @NonNull + public static IFramePlugIn create() { + return new IFramePlugIn(); + } + + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.customDelimiterProcessor(IFrameProcessor.create()); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(IFrameNode.class, new MarkwonVisitor.NodeVisitor() { + @Override + public void visit(@NonNull MarkwonVisitor visitor, @NonNull IFrameNode iFrameNode) { + + final String link = iFrameNode.link(); + if (!TextUtils.isEmpty(link)) { + visitor.builder().append(link); + visitor.builder().append(' '); + } + } + }); + } + + @NonNull + @Override + public String processMarkdown(@NonNull String markdown) { + return IFrameProcessor.prepare(markdown); + } +} diff --git a/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameProcessor.java b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameProcessor.java new file mode 100644 index 00000000..ff4a78eb --- /dev/null +++ b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameProcessor.java @@ -0,0 +1,166 @@ +package io.noties.markwon.iframe.ext; + +import static io.noties.markwon.iframe.ext.IFrameUtils.getDesmosId; +import static io.noties.markwon.iframe.ext.IFrameUtils.getVimeoVideoId; +import static io.noties.markwon.iframe.ext.IFrameUtils.getYoutubeVideoId; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Node; +import org.commonmark.node.Text; +import org.commonmark.parser.delimiter.DelimiterProcessor; +import org.commonmark.parser.delimiter.DelimiterRun; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class IFrameProcessor implements DelimiterProcessor { + + private static final String TO_FIND_STRING = IFrameNode.DELIMITER_STRING; + private static final String CLOSE_BRACKET = "]"; + public static final String REPLACE_STRING = "^"; + public static final char REPLACE_CHAR = '^'; + public static final String OPEN_STRING = "("; + public static final String SEPARATOR_STRING = REPLACE_STRING + OPEN_STRING; + public static final String CLOSE_STRING = ")"; + public static final char CLOSE_CHAR = ')'; + private static final Pattern PATTERN = Pattern.compile("!\\[[^\\]]*\\]\\([^)]+\\)"); + @NonNull + public static IFrameProcessor create() { + return new IFrameProcessor(); + } + + + @NonNull + public static String prepare(@NonNull String input) { + final StringBuilder builder = new StringBuilder(input); + if (builder.toString().toLowerCase().indexOf(TO_FIND_STRING) > -1) { + prepare(builder, TO_FIND_STRING); + } + return builder.toString(); + } + + public static void prepare(@NonNull StringBuilder builder, String finder) { + + int start = builder.indexOf(finder); + int end; + + while (start > -1) { + + end = inLineDefinitionEnd(start + finder.length(), builder); + if (iconDefinitionValid(builder.subSequence(start, end))) { + int startEndBracket = builder.indexOf(CLOSE_BRACKET, start + 2); + if (checkingURLValidate(builder.substring(startEndBracket + 2, end - 1))) { + builder.replace(start, start + 2, REPLACE_STRING); + builder.replace(startEndBracket - 1, startEndBracket, REPLACE_STRING); + } + } + // move to next + start = builder.indexOf(finder, end); + } + } + + private static int inLineDefinitionEnd(int startIndex, @NonNull StringBuilder builder) { + + // all spaces, new lines, non-words or digits, + + char c; + + int end = -1; + for (int i = startIndex; i < builder.length(); i++) { + c = builder.charAt(i); + if (c == ')') { + end = i + 1; + break; + } + } + + if (end == -1) { + end = builder.length(); + } + + return end; + } + + private static boolean iconDefinitionValid(@NonNull CharSequence cs) { + final Matcher matcher = PATTERN.matcher(cs); + return matcher.matches(); + } + + private static boolean checkingURLValidate(@NonNull String cs) { + if (getYoutubeVideoId(cs) != null && !getYoutubeVideoId(cs).isEmpty()) { + return true; + } + if (getVimeoVideoId(cs) != null && !getVimeoVideoId(cs).isEmpty()) { + return true; + } + if (getDesmosId(cs) != null && !getDesmosId(cs).isEmpty()) { + return true; + } + return false; + } + + @Override + public char getOpeningCharacter() { + return REPLACE_CHAR; + } + + @Override + public char getClosingCharacter() { + return CLOSE_CHAR; + } + + @Override + public int getMinLength() { + return 1; + } + + @Override + public int getDelimiterUse(DelimiterRun opener, DelimiterRun closer) { + return opener.length() >= 1 && closer.length() >= 1 ? 1 : 0; + } + + @Override + public void process(Text opener, Text closer, int delimiterUse) { + + final IFrameGroupNode iFrameGroupNode = new IFrameGroupNode(); + + final Node next = opener.getNext(); + + boolean handled = false; + + // process only if we have exactly one Text node + + + final String text = ((Text) next).getLiteral(); + + if (!TextUtils.isEmpty(text)) { + // attempt to match + int start_link_index = text.indexOf(OPEN_STRING); + if (start_link_index >= 0) { + String link = text.substring(start_link_index + OPEN_STRING.length()); + IFrameNode iFrameNode = new IFrameNode(link); + iFrameGroupNode.appendChild(iFrameNode); + next.unlink(); + handled = true; + } + } + + if (!handled) { + iFrameGroupNode.appendChild(new Text(REPLACE_STRING)); + Node node; + for (Node tmp = opener.getNext(); tmp != null && tmp != closer; tmp = node) { + node = tmp.getNext(); + // append a child anyway + iFrameGroupNode.appendChild(tmp); + } + + iFrameGroupNode.appendChild(new Text(CLOSE_STRING)); + } + opener.setLiteral(""); + closer.setLiteral(""); + opener.insertBefore(iFrameGroupNode); + } +} diff --git a/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameUtils.java b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameUtils.java new file mode 100644 index 00000000..93cf3a9b --- /dev/null +++ b/markwon-iframe-ext/src/main/java/io/noties/markwon/iframe/ext/IFrameUtils.java @@ -0,0 +1,54 @@ +package io.noties.markwon.iframe.ext; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class IFrameUtils { + private final static String youtube_expression = "(?<=watch\\?v=|/videos/|embed\\/|youtu.be\\/|\\/v\\/|\\/e\\/|watch\\?v%3D|watch\\?feature=player_embedded&v=|%2Fvideos%2F|embed%\u200C\u200B2F|youtu.be%2F|%2Fv%2F)[^#\\&\\?\\n]*"; + public static String getYoutubeVideoId(String videoUrl) { + if (videoUrl == null || videoUrl.trim().length() <= 0){ + return null; + } + Pattern pattern = Pattern.compile(youtube_expression); + Matcher matcher = pattern.matcher(videoUrl); + try { + if (matcher.find()) + return matcher.group(); + } catch (ArrayIndexOutOfBoundsException ex) { + ex.printStackTrace(); + } + return null; + } + + private final static String vimeo_expression = "https?:\\/\\/(?:www\\.)?vimeo.com\\/(?:channels\\/(?:\\w+\\/)?|groups\\/([^\\/]*)\\/videos\\/|album\\/(\\d+)\\/video\\/|)(\\d+)(?:$|\\/|\\?)"; + public static String getVimeoVideoId(String videoUrl) { + if (videoUrl == null || videoUrl.trim().length() <= 0){ + return null; + } + Pattern pattern = Pattern.compile(vimeo_expression); + Matcher matcher = pattern.matcher(videoUrl); + try { + if (matcher.find()) + return matcher.group(3); + } catch (ArrayIndexOutOfBoundsException ex) { + ex.printStackTrace(); + } + return null; + } + + private final static String desmos_expression = "https?:\\/\\/(www\\.)?desmos.com\\/calculator\\/(\\w+)($|\\/)"; + public static String getDesmosId(String desmosURL) { + if (desmosURL == null || desmosURL.trim().length() <= 0){ + return null; + } + Pattern pattern = Pattern.compile(desmos_expression); + Matcher matcher = pattern.matcher(desmosURL); + try { + if (matcher.find()) + return matcher.group(2); + } catch (ArrayIndexOutOfBoundsException ex) { + ex.printStackTrace(); + } + return null; + } +} diff --git a/markwon-iframe-ext/src/test/java/io/noties/markwon/iframe/ext/ExampleUnitTest.java b/markwon-iframe-ext/src/test/java/io/noties/markwon/iframe/ext/ExampleUnitTest.java new file mode 100644 index 00000000..1548a00d --- /dev/null +++ b/markwon-iframe-ext/src/test/java/io/noties/markwon/iframe/ext/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package io.noties.markwon.iframe.ext; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file