From 36e854bb84277c97d7934535e534ca6e0fe22698 Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20=E2=80=98Bombe=E2=80=99=20Roden?= Date: Thu, 10 Nov 2016 20:02:37 +0100 Subject: [PATCH] Add new render filter This filter will be one of two to replace the ParserFilter. --- .../pterodactylus/sone/template/RenderFilter.kt | 161 +++++++++++++++ .../sone/template/RenderFilterTest.kt | 224 +++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 src/main/kotlin/net/pterodactylus/sone/template/RenderFilter.kt create mode 100644 src/test/kotlin/net/pterodactylus/sone/template/RenderFilterTest.kt diff --git a/src/main/kotlin/net/pterodactylus/sone/template/RenderFilter.kt b/src/main/kotlin/net/pterodactylus/sone/template/RenderFilter.kt new file mode 100644 index 0000000..2c38e58 --- /dev/null +++ b/src/main/kotlin/net/pterodactylus/sone/template/RenderFilter.kt @@ -0,0 +1,161 @@ +package net.pterodactylus.sone.template + +import net.pterodactylus.sone.core.Core +import net.pterodactylus.sone.text.FreemailPart +import net.pterodactylus.sone.text.FreenetLinkPart +import net.pterodactylus.sone.text.LinkPart +import net.pterodactylus.sone.text.Part +import net.pterodactylus.sone.text.PlainTextPart +import net.pterodactylus.sone.text.PostPart +import net.pterodactylus.sone.text.SonePart +import net.pterodactylus.sone.text.SoneTextParser +import net.pterodactylus.sone.text.SoneTextParserContext +import net.pterodactylus.util.template.Filter +import net.pterodactylus.util.template.TemplateContext +import net.pterodactylus.util.template.TemplateContextFactory +import net.pterodactylus.util.template.TemplateParser +import java.io.StringReader +import java.io.StringWriter +import java.io.Writer +import java.net.URLEncoder +import java.util.ArrayList + +/** + * Renders a number of pre-parsed [Part] into a [String]. + * + * @author [David ‘Bombe’ Roden](mailto:bombe@pterodactylus.net) + */ +class RenderFilter(private val core: Core, private val templateContextFactory: TemplateContextFactory) : Filter { + + companion object { + private val plainTextTemplate = TemplateParser.parse(StringReader("<%text|html>")) + private val linkTemplate = TemplateParser.parse(StringReader("\" href=\"<%link|html>\" title=\"<%title|html>\"><%text|html>")) + } + + override fun format(templateContext: TemplateContext?, data: Any?, parameters: MutableMap?): Any? { + @Suppress("UNCHECKED_CAST") + val parts = getPartsToRender(parameters, data as? Iterable ?: return null) + val parsedTextWriter = StringWriter() + render(parsedTextWriter, parts) + return parsedTextWriter.toString() + } + + private fun Map.parseInt(key: String) = this[key]?.toString()?.toInt() + + private fun getPartsToRender(parameters: MutableMap?, parts: Iterable): Iterable { + val length = parameters?.parseInt("length") ?: -1 + val cutOffLength = parameters?.parseInt("cut-off-length") ?: length + if (length > -1) { + var allPartsLength = 0 + val shortenedParts = ArrayList() + for (part in parts) { + if (part is PlainTextPart) { + val longText = part.text + if (allPartsLength < cutOffLength) { + if (allPartsLength + longText.length > cutOffLength) { + shortenedParts.add(PlainTextPart(longText.substring(0, cutOffLength - allPartsLength) + "…")) + } else { + shortenedParts.add(part) + } + } + allPartsLength += longText.length + } else if (part is LinkPart) { + if (allPartsLength < cutOffLength) { + shortenedParts.add(part) + } + allPartsLength += part.text.length + } else { + if (allPartsLength < cutOffLength) { + shortenedParts.add(part) + } + } + } + if (allPartsLength >= length) { + return shortenedParts + } + } + return parts + } + + private fun render(writer: Writer, parts: Iterable) { + parts.forEach { render(writer, it) } + } + + private fun render(writer: Writer, part: Part) { + @Suppress("UNCHECKED_CAST") + when (part) { + is PlainTextPart -> render(writer, part) + is FreenetLinkPart -> render(writer, part) + is LinkPart -> render(writer, part) + is SonePart -> render(writer, part) + is PostPart -> render(writer, part) + is FreemailPart -> render(writer, part) + is Iterable<*> -> render(writer, part as Iterable) + } + } + + private fun render(writer: Writer, plainTextPart: PlainTextPart) { + val templateContext = templateContextFactory.createTemplateContext() + templateContext.set("text", plainTextPart.text) + plainTextTemplate.render(templateContext, writer) + } + + private fun render(writer: Writer, freenetLinkPart: FreenetLinkPart) { + renderLink(writer, "/${freenetLinkPart.link}", freenetLinkPart.text, freenetLinkPart.title, if (freenetLinkPart.isTrusted) "freenet-trusted" else "freenet") + } + + private fun render(writer: Writer, linkPart: LinkPart) { + renderLink(writer, "/external-link/?_CHECKED_HTTP_=${linkPart.link.urlEncode()}", linkPart.text, linkPart.title, "internet") + } + + private fun String.urlEncode() = URLEncoder.encode(this, "UTF-8")!! + + private fun render(writer: Writer, sonePart: SonePart) { + if (sonePart.sone.name != null) { + renderLink(writer, "viewSone.html?sone=${sonePart.sone.id}", SoneAccessor.getNiceName(sonePart.sone), SoneAccessor.getNiceName(sonePart.sone), "in-sone") + } else { + renderLink(writer, "/WebOfTrust/ShowIdentity?id=${sonePart.sone.id}", sonePart.sone.id, sonePart.sone.id, "in-sone") + } + } + + private fun render(writer: Writer, postPart: PostPart) { + val parser = SoneTextParser(core, core) + val parserContext = SoneTextParserContext(postPart.post.sone) + val parts = parser.parse(postPart.post.text, parserContext) + val excerpt = StringBuilder() + for (part in parts) { + excerpt.append(part.text) + if (excerpt.length > 20) { + val lastSpace = excerpt.lastIndexOf(" ", 20) + if (lastSpace > -1) { + excerpt.setLength(lastSpace) + } else { + excerpt.setLength(20) + } + excerpt.append("…") + break + } + } + renderLink(writer, "viewPost.html?post=${postPart.post.id}", excerpt.toString(), SoneAccessor.getNiceName(postPart.post.sone), "in-sone") + } + + private fun render(writer: Writer, freemailPart: FreemailPart) { + val sone = core.getSone(freemailPart.identityId) + val soneName = sone.transform(SoneAccessor::getNiceName).or(freemailPart.identityId) + renderLink(writer, + "/Freemail/NewMessage?to=${freemailPart.identityId}", + "${freemailPart.emailLocalPart}@$soneName.freemail", + "$soneName\n${freemailPart.emailLocalPart}@${freemailPart.freemailId}.freemail", + "in-sone") + } + + private fun renderLink(writer: Writer, link: String, text: String, title: String, cssClass: String) { + val templateContext = templateContextFactory.createTemplateContext() + templateContext["cssClass"] = cssClass + templateContext["link"] = link + templateContext["text"] = text + templateContext["title"] = title + linkTemplate.render(templateContext, writer) + } + +} diff --git a/src/test/kotlin/net/pterodactylus/sone/template/RenderFilterTest.kt b/src/test/kotlin/net/pterodactylus/sone/template/RenderFilterTest.kt new file mode 100644 index 0000000..2d7e6a5 --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/sone/template/RenderFilterTest.kt @@ -0,0 +1,224 @@ +package net.pterodactylus.sone.template + +import com.google.common.base.Optional +import net.pterodactylus.sone.core.Core +import net.pterodactylus.sone.data.Post +import net.pterodactylus.sone.data.Profile +import net.pterodactylus.sone.data.Sone +import net.pterodactylus.sone.text.FreemailPart +import net.pterodactylus.sone.text.FreenetLinkPart +import net.pterodactylus.sone.text.LinkPart +import net.pterodactylus.sone.text.Part +import net.pterodactylus.sone.text.PartContainer +import net.pterodactylus.sone.text.PlainTextPart +import net.pterodactylus.sone.text.PostPart +import net.pterodactylus.sone.text.SonePart +import net.pterodactylus.util.template.HtmlFilter +import net.pterodactylus.util.template.TemplateContext +import net.pterodactylus.util.template.TemplateContextFactory +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.containsInAnyOrder +import org.jsoup.Jsoup +import org.jsoup.nodes.Attribute +import org.jsoup.nodes.Element +import org.jsoup.nodes.TextNode +import org.junit.Test +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import java.net.URLEncoder + +/** + * Unit test for [RenderFilter]. + */ +class RenderFilterTest { + + companion object { + private const val FREEMAIL_ID = "t4dlzfdww3xvsnsc6j6gtliox6zaoak7ymkobbmcmdw527ubuqra" + private const val SONE_FREEMAIL = "sone@$FREEMAIL_ID.freemail" + private const val SONE_IDENTITY = "nwa8lHa271k2QvJ8aa0Ov7IHAV-DFOCFgmDt3X6BpCI" + private const val POST_ID = "37a06250-6775-4b94-86ff-257ba690953c" + } + + private val core = mock(Core::class.java) + private val templateContextFactory = TemplateContextFactory() + private val templateContext: TemplateContext + private val sone = setupSone(SONE_IDENTITY, "Sone", "First") + private val parameters = mutableMapOf() + + init { + templateContextFactory.addFilter("html", HtmlFilter()) + templateContext = templateContextFactory.createTemplateContext() + } + + private val filter = RenderFilter(core, templateContextFactory) + + @Test + fun plainTextIsRenderedCorrectly() { + assertThat(renderParts(PlainTextPart("plain text")), `is`("plain text")) + } + + private fun renderParts(vararg part: Part) = filter.format(templateContext, listOf(*part), parameters) as String + + @Test + fun plainTextPartIsShortenedIfLengthExceedsMaxLength() { + setLengthAndCutOffLength(15, 10) + assertThat(renderParts(PlainTextPart("This is a long text.")), `is`("This is a …")) + } + + @Test + fun plainTextPartIsNotShortenedIfLengthDoesNotExceedMaxLength() { + setLengthAndCutOffLength(20, 10) + assertThat(renderParts(PlainTextPart("This is a long text.")), `is`("This is a …")) + } + + @Test + fun shortPartsAreNotShortened() { + setLengthAndCutOffLength(15, 10) + assertThat(renderParts(PlainTextPart("This.")), `is`("This.")) + } + + @Test + fun multiplePlainTextPartsAreShortened() { + setLengthAndCutOffLength(15, 10) + assertThat(renderParts(PlainTextPart("This "), PlainTextPart("is a long text.")), `is`("This is a …")) + } + + @Test + fun partsAfterLengthHasBeenReachedAreIgnored() { + setLengthAndCutOffLength(15, 10) + assertThat(renderParts(PlainTextPart("This is a long text."), PlainTextPart(" And even more.")), `is`("This is a …")) + } + + @Test + fun linkPartsAreNotShortened() { + setLengthAndCutOffLength(15, 10) + val linkNode = Jsoup.parseBodyFragment(renderParts(FreenetLinkPart("KSK@gpl.txt", "This is a long text.", false))).body().child(0) + verifyLink(linkNode, "/KSK@gpl.txt", "freenet", "KSK@gpl.txt", "This is a long text.") + } + + @Test + fun additionalLinkPartsAreIgnored() { + setLengthAndCutOffLength(15, 10) + assertThat(renderParts(PlainTextPart("This is a long text."), FreenetLinkPart("KSK@gpl.txt", "This is a long text.", false)), `is`("This is a …")) + } + + private fun setLengthAndCutOffLength(length: Int, cutOffLength: Int) { + parameters.put("length", length) + parameters.put("cut-off-length", cutOffLength) + } + + @Test + fun sonePartsAreAddedButTheirLengthIsIgnored() { + setLengthAndCutOffLength(15, 10) + val body = Jsoup.parseBodyFragment(renderParts(SonePart(sone), PlainTextPart("This is a long text."))).body() + val linkNode = body.childNode(0) as Element + println(linkNode) + verifyLink(linkNode, "viewSone.html?sone=$SONE_IDENTITY", "in-sone", "First", "First") + assertThat((body.childNode(1) as TextNode).text(), `is`("This is a …")) + } + + @Test + fun additionalSonePartsAreIgnored() { + setLengthAndCutOffLength(15, 10) + assertThat(renderParts(PlainTextPart("This is a long text."), SonePart(sone)), `is`("This is a …")) + } + + @Test + fun freenetLinkIsRenderedCorrectly() { + val linkNode = renderParts(FreenetLinkPart("KSK@gpl.txt", "gpl.txt", false)).toLinkNode() + verifyLink(linkNode, "/KSK@gpl.txt", "freenet", "KSK@gpl.txt", "gpl.txt") + } + + private fun verifyLink(linkNode: Element, url: String, cssClass: String, tooltip: String, text: String) { + assertThat(linkNode.nodeName(), `is`("a")) + assertThat>(linkNode.attributes().asList(), containsInAnyOrder( + Attribute("href", url), + Attribute("class", cssClass), + Attribute("title", tooltip) + )) + assertThat(linkNode.text(), `is`(text)) + } + + @Test + fun trustedFreenetLinkIsRenderedWithCorrectCssClass() { + val linkNode = renderParts(FreenetLinkPart("KSK@gpl.txt", "gpl.txt", true)).toLinkNode() + verifyLink(linkNode, "/KSK@gpl.txt", "freenet-trusted", "KSK@gpl.txt", "gpl.txt") + } + + private fun String.toLinkNode() = Jsoup.parseBodyFragment(this).body().child(0) + + @Test + fun internetLinkIsRenderedCorrectly() { + val linkNode = renderParts(LinkPart("http://test.com/test.html", "test.com/test.html")).toLinkNode() + verifyLink(linkNode, "/external-link/?_CHECKED_HTTP_=${URLEncoder.encode("http://test.com/test.html", "UTF-8")}", "internet", + "http://test.com/test.html", "test.com/test.html") + } + + @Test + fun sonePartsAreRenderedCorrectly() { + val linkNode = renderParts(SonePart(sone)).toLinkNode() + verifyLink(linkNode, "viewSone.html?sone=" + SONE_IDENTITY, "in-sone", "First", "First") + } + + private fun setupSone(identity: String, name: String?, firstName: String): Sone { + val sone = mock(Sone::class.java) + `when`(sone.id).thenReturn(identity) + `when`(sone.profile).thenReturn(Profile(sone)) + `when`(sone.name).thenReturn(name) + sone.profile.firstName = firstName + `when`(core.getSone(identity)).thenReturn(Optional.of(sone)) + return sone + } + + @Test + fun sonePartsWithUnknownSoneIsRenderedAsLinkToWebOfTrust() { + val sone = setupSone(SONE_IDENTITY, null, "First") + val linkNode = renderParts(SonePart(sone)).toLinkNode() + verifyLink(linkNode, "/WebOfTrust/ShowIdentity?id=$SONE_IDENTITY", "in-sone", SONE_IDENTITY, SONE_IDENTITY) + } + + @Test + fun postPartIsCutOffCorrectlyWhenThereAreSpaces() { + val post = setupPost(sone, "1234 678901 345 789012 45678 01.") + val linkNode = renderParts(PostPart(post)).toLinkNode() + verifyLink(linkNode, "viewPost.html?post=$POST_ID", "in-sone", "First", "1234 678901 345…") + } + + private fun setupPost(sone: Sone, value: String): Post { + val post = mock(Post::class.java) + `when`(post.id).thenReturn(POST_ID) + `when`(post.sone).thenReturn(sone) + `when`(post.text).thenReturn(value) + return post + } + + @Test + fun postPartIsCutOffCorrectlyWhenThereAreNoSpaces() { + val post = setupPost(sone, "1234567890123456789012345678901.") + val linkNode = renderParts(PostPart(post)).toLinkNode() + verifyLink(linkNode, "viewPost.html?post=$POST_ID", "in-sone", "First", "12345678901234567890…") + } + + @Test + fun postPartShorterThan21CharsIsNotCutOff() { + val post = setupPost(sone, "12345678901234567890") + val linkNode = renderParts(PostPart(post)).toLinkNode() + verifyLink(linkNode, "viewPost.html?post=$POST_ID", "in-sone", "First", "12345678901234567890") + } + + @Test + fun multiplePartsAreRenderedCorrectly() { + val parts = PartContainer() + parts.add(PlainTextPart("te")) + parts.add(PlainTextPart("xt")) + assertThat(renderParts(parts), `is`("text")) + } + + @Test + fun freemailAddressIsDisplayedCorrectly() { + val linkNode = renderParts(FreemailPart("sone", FREEMAIL_ID, SONE_IDENTITY)).toLinkNode() + verifyLink(linkNode, "/Freemail/NewMessage?to=$SONE_IDENTITY", "in-sone", "First\n$SONE_FREEMAIL", "sone@First.freemail") + } + +} -- 2.7.4