From 0775bbcb28865afbc9d71030cb4aab7b921aab5e Mon Sep 17 00:00:00 2001 From: Dmitry Pavlov Date: Sun, 7 Jan 2024 17:20:54 +0300 Subject: [PATCH] feat: Resolving user references during export to markdown (Confluence Server only) Closes #51 --- CHANGELOG.md | 4 ++ .../zeldigas/confclient/ConfluenceClient.kt | 7 +-- .../confclient/ConfluenceClientImpl.kt | 25 +++++--- .../github/zeldigas/confclient/model/User.kt | 3 + .../confclient/ConfluenceClientImplTest.kt | 61 +++++++++++++++++++ .../export/ConfluenceCustomNodeRenderer.kt | 17 ++++++ .../markdown/export/ConfluenceUserResolver.kt | 13 ++++ .../export/HtmlToMarkdownConverter.kt | 15 ++++- .../export/HtmlToMarkdownConverterTest.kt | 18 ++++-- .../src/test/resources/convert/user-refs.html | 20 ++++++ .../src/test/resources/convert/user-refs.md | 7 +++ .../core/export/ConfluenceUserResolverImpl.kt | 20 ++++++ .../text2confl/core/export/PageExporter.kt | 6 +- 13 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 confluence-client/src/main/kotlin/com/github/zeldigas/confclient/model/User.kt create mode 100644 convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/ConfluenceUserResolver.kt create mode 100644 convert/src/test/resources/convert/user-refs.html create mode 100644 convert/src/test/resources/convert/user-refs.md create mode 100644 core/src/main/kotlin/com/github/zeldigas/text2confl/core/export/ConfluenceUserResolverImpl.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cb500496..14975051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added + +- \[export-to-md] now resolves user references for Confluence Server (#51) + ### Fixed - \[AsciiDoc] `xrefstyle` attribute is taken into account for references (#136) diff --git a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceClient.kt b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceClient.kt index a9f6c8d0..f67f4eae 100644 --- a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceClient.kt +++ b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceClient.kt @@ -1,9 +1,6 @@ package com.github.zeldigas.confclient -import com.github.zeldigas.confclient.model.Attachment -import com.github.zeldigas.confclient.model.ConfluencePage -import com.github.zeldigas.confclient.model.PageAttachments -import com.github.zeldigas.confclient.model.Space +import com.github.zeldigas.confclient.model.* import io.ktor.http.* import java.nio.file.Path @@ -76,6 +73,8 @@ interface ConfluenceClient { suspend fun downloadAttachment(attachment: Attachment, destination: Path) + suspend fun getUserByKey(userKey: String): User + } class PageNotCreatedException(val title: String, val status: Int, val body: String?) : diff --git a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceClientImpl.kt b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceClientImpl.kt index cb1d0b53..6cb2944a 100644 --- a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceClientImpl.kt +++ b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceClientImpl.kt @@ -3,10 +3,7 @@ package com.github.zeldigas.confclient import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.datatype.jdk8.Jdk8Module import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import com.github.zeldigas.confclient.model.Attachment -import com.github.zeldigas.confclient.model.ConfluencePage -import com.github.zeldigas.confclient.model.PageAttachments -import com.github.zeldigas.confclient.model.Space +import com.github.zeldigas.confclient.model.* import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.* import io.ktor.client.call.* @@ -19,6 +16,7 @@ import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* +import io.ktor.http.ContentType import io.ktor.serialization.* import io.ktor.serialization.jackson.* import io.ktor.util.cio.* @@ -329,22 +327,35 @@ class ConfluenceClientImpl( append(HttpHeaders.ContentDisposition, "filename=${attachment.name}") }) } + + override suspend fun getUserByKey(userKey: String): User { + return httpClient.get("$apiBase/user") { + parameter("key", userKey) + }.readApiResponse(expectSuccess = true) + } } -private suspend inline fun HttpResponse.readApiResponse(): T { +private suspend inline fun HttpResponse.readApiResponse(expectSuccess: Boolean = false): T { + if (expectSuccess && !status.isSuccess()) { + parseAndThrowConfluencError() + } val contentType = contentType() if (contentType != null && ContentType.Application.Json.match(contentType)){ try { return body() } catch (e: JsonConvertException) { - val content = body>() - throw ConfluenceApiErrorException(status.value, content["error"]?.toString() ?: "", content) + parseAndThrowConfluencError() } } else { throw UnknownConfluenceErrorException(status.value, bodyAsText()) } } +private suspend fun HttpResponse.parseAndThrowConfluencError(): Nothing { + val content = body>() + throw ConfluenceApiErrorException(status.value, content["error"]?.toString() ?: "", content) +} + private data class PageSearchResult( val results: List, val start: Int, diff --git a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/model/User.kt b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/model/User.kt new file mode 100644 index 00000000..9e42d10e --- /dev/null +++ b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/model/User.kt @@ -0,0 +1,3 @@ +package com.github.zeldigas.confclient.model + +data class User(val type: String?, val username: String?, val userKey: String?, val displayName: String?) \ No newline at end of file diff --git a/confluence-client/src/test/kotlin/com/github/zeldigas/confclient/ConfluenceClientImplTest.kt b/confluence-client/src/test/kotlin/com/github/zeldigas/confclient/ConfluenceClientImplTest.kt index 41db1fe8..2b12c096 100644 --- a/confluence-client/src/test/kotlin/com/github/zeldigas/confclient/ConfluenceClientImplTest.kt +++ b/confluence-client/src/test/kotlin/com/github/zeldigas/confclient/ConfluenceClientImplTest.kt @@ -1,7 +1,10 @@ package com.github.zeldigas.confclient import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder import com.github.tomakehurst.wiremock.client.WireMock.* @@ -9,10 +12,12 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo import com.github.tomakehurst.wiremock.junit5.WireMockTest import com.github.zeldigas.confclient.model.Attachment import com.github.zeldigas.confclient.model.PageAttachments +import com.github.zeldigas.confclient.model.User import io.ktor.http.* import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows @WireMockTest class ConfluenceClientImplTest(runtimeInfo: WireMockRuntimeInfo) { @@ -78,6 +83,62 @@ class ConfluenceClientImplTest(runtimeInfo: WireMockRuntimeInfo) { ) ) } + + @Test + fun getUserByKey() = runTest { + stubFor( + get("/rest/api/user?key=abc").willReturn( + ok().withJson( + mapOf( + "type" to "known", + "username" to "user@example.org", + "userKey" to "abc", + "profilePicture" to mapOf( + "path" to "/download/attachments/123/avatar.jpg", + "width" to 48, + "height" to 48, + "isDefault" to false + ), + "displayName" to "User Name", + "_links" to mapOf( + "base" to "https://wiki.example.org", + "context" to "", + "self" to "https://wiki..example.org/rest/api/user?key=abc" + ) + ) + ) + ) + ) + + val result = client.getUserByKey("abc") + + assertThat(result).isEqualTo(User("known", "user@example.org", "abc", "User Name")) + } + + @Test + fun `geUserByKey not found`() = runTest { + stubFor( + get("/rest/api/user?key=abc").willReturn( + notFound().withJson( + mapOf( + "statusCode" to 404, + "data" to mapOf( + "authorized" to false, + "valid" to true, + ), + "message" to "No user found with key : abc", + "reason" to "Not Found" + ) + ) + ) + ) + + val result = assertThrows { client.getUserByKey("abc") } + + assertThat(result.status).isEqualTo(404) + assertThat(result.error).isEmpty() + assertThat(result.message).isNotNull().contains("No user found with key : abc") + } } private fun ResponseDefinitionBuilder.withJson(data: Any): ResponseDefinitionBuilder? { diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/ConfluenceCustomNodeRenderer.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/ConfluenceCustomNodeRenderer.kt index e1c591a9..d1c30eac 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/ConfluenceCustomNodeRenderer.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/ConfluenceCustomNodeRenderer.kt @@ -12,6 +12,7 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { private val myHtmlConverterOptions = HtmlConverterOptions(options) private val linkResolver = HtmlToMarkdownConverter.LINK_RESOLVER.get(options) + private val userResolver = HtmlToMarkdownConverter.USER_RESOLVER.get(options) private val basicRenderer = HtmlConverterCoreNodeRenderer(options).htmlNodeRendererHandlers.map { it.tagName to it }.toMap() @@ -187,6 +188,8 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { processLinkToAttachment(element, context, writer) } else if (element.hasAttr("ac:anchor")) { processThisPageAnchor(element, context, writer) + } else if (element.getElementsByTag("ri:user").isNotEmpty()) { + processUserReference(element.getElementsByTag("ri:user").first()!!, context, writer) } } @@ -214,6 +217,20 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { writer.append("]") } + private fun processUserReference(element: Element, context: HtmlNodeConverterContext, writer: HtmlMarkdownWriter) { + val key = element.attr("ri:userkey") ?: return + val username = userResolver.resolve(key) ?: return + + writer.append('@') + if ('@' in username) { + writer.append('"') + writer.append(username) + writer.append('"') + } else { + writer.append(username) + } + } + private fun processThisPageAnchor(element: Element, context: HtmlNodeConverterContext, writer: HtmlMarkdownWriter) { generateLinkName(element, context, writer) diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/ConfluenceUserResolver.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/ConfluenceUserResolver.kt new file mode 100644 index 00000000..f5336a56 --- /dev/null +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/ConfluenceUserResolver.kt @@ -0,0 +1,13 @@ +package com.github.zeldigas.text2confl.convert.markdown.export + +interface ConfluenceUserResolver { + + companion object { + val NOP = object : ConfluenceUserResolver { + override fun resolve(userKey: String): String? = null + } + } + + fun resolve(userKey: String): String? + +} \ No newline at end of file diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/HtmlToMarkdownConverter.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/HtmlToMarkdownConverter.kt index 529d31b4..ee1a5321 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/HtmlToMarkdownConverter.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/HtmlToMarkdownConverter.kt @@ -6,11 +6,17 @@ import com.vladsch.flexmark.util.data.MutableDataSet import java.nio.file.Path import kotlin.io.path.Path -class HtmlToMarkdownConverter(resolver: ConfluenceLinksResolver, assetsLocation: String) { +class HtmlToMarkdownConverter( + linkResolver: ConfluenceLinksResolver, + assetsLocation: String, + userResolver: ConfluenceUserResolver? = null +) { companion object { val LINK_RESOLVER = DataKey("CONFLUENCE_LINK_RESOLVER") { ConfluenceLinksResolver.NOP } + val USER_RESOLVER = + DataKey("CONFLUENCE_USER_RESOLVER") { ConfluenceUserResolver.NOP } val ASSETS_DIR = DataKey("CONFLUENCE_ASSETS_DIR") { Path("_assets") } } @@ -18,8 +24,13 @@ class HtmlToMarkdownConverter(resolver: ConfluenceLinksResolver, assetsLocation: MutableDataSet() .set(FlexmarkHtmlConverter.SETEXT_HEADINGS, false) .set(FlexmarkHtmlConverter.LIST_CONTENT_INDENT, false) - .set(LINK_RESOLVER, resolver) + .set(LINK_RESOLVER, linkResolver) .set(ASSETS_DIR, Path(assetsLocation)) + .also { + if (userResolver != null) { + it.set(USER_RESOLVER, userResolver) + } + } ) .htmlNodeRendererFactory { ConfluenceCustomNodeRenderer(it) } .build() diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/HtmlToMarkdownConverterTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/HtmlToMarkdownConverterTest.kt index 4635d078..d9456588 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/HtmlToMarkdownConverterTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/export/HtmlToMarkdownConverterTest.kt @@ -7,7 +7,16 @@ import org.junit.jupiter.params.provider.ValueSource class HtmlToMarkdownConverterTest { - private val converter = HtmlToMarkdownConverter(ConfluenceLinksResolver.NOP, "_assets") + private val converter = HtmlToMarkdownConverter(ConfluenceLinksResolver.NOP, "_assets", + userResolver = object : ConfluenceUserResolver { + override fun resolve(userKey: String): String? { + return when (userKey) { + "known" -> "user" + "known_email" -> "user@example.org" + else -> null + } + } + }) @ValueSource( strings = [ @@ -16,18 +25,19 @@ class HtmlToMarkdownConverterTest { "links", "tables", "confluence-specific", + "user-refs", ] ) @ParameterizedTest fun `Conversion of confluence page`(pageId: String) { - val input = readResoource("/convert/$pageId.html") + val input = readResource("/convert/$pageId.html") val result = converter.convert(input) - assertThat(result).isEqualTo(readResoource("/convert/$pageId.md")) + assertThat(result).isEqualTo(readResource("/convert/$pageId.md")) } - private fun readResoource(resource: String): String { + private fun readResource(resource: String): String { return HtmlToMarkdownConverter::class.java.getResourceAsStream(resource)?.use { String(it.readAllBytes()).replace("\r\n", "\n") } ?: throw IllegalStateException("Failed to load $resource") diff --git a/convert/src/test/resources/convert/user-refs.html b/convert/src/test/resources/convert/user-refs.html new file mode 100644 index 00000000..126efd43 --- /dev/null +++ b/convert/src/test/resources/convert/user-refs.html @@ -0,0 +1,20 @@ +

Known user: + + + +

+

Known user as email: + + + +

+

Unknown user: + + + +

+

Ignored user: + + + +

diff --git a/convert/src/test/resources/convert/user-refs.md b/convert/src/test/resources/convert/user-refs.md new file mode 100644 index 00000000..2ba63f1e --- /dev/null +++ b/convert/src/test/resources/convert/user-refs.md @@ -0,0 +1,7 @@ +Known user: @user + +Known user as email: @"user@example.org" + +Unknown user: + +Ignored user: diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/export/ConfluenceUserResolverImpl.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/export/ConfluenceUserResolverImpl.kt new file mode 100644 index 00000000..4054e103 --- /dev/null +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/export/ConfluenceUserResolverImpl.kt @@ -0,0 +1,20 @@ +package com.github.zeldigas.text2confl.core.export + +import com.github.zeldigas.confclient.ConfluenceClient +import com.github.zeldigas.text2confl.convert.markdown.export.ConfluenceUserResolver +import kotlinx.coroutines.runBlocking + +class ConfluenceUserResolverImpl(private val client: ConfluenceClient) : ConfluenceUserResolver { + + private val cache: MutableMap = mutableMapOf() + + override fun resolve(userKey: String): String? { + return cache.computeIfAbsent(userKey) { key -> + runBlocking { + val user = client.getUserByKey(key) + user.username + } + } + } + +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/export/PageExporter.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/export/PageExporter.kt index b233f45c..a5dab6a6 100644 --- a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/export/PageExporter.kt +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/export/PageExporter.kt @@ -37,7 +37,11 @@ class PageExporter(internal val client: ConfluenceClient, internal val saveConte val attachmentDir = assetsLocation?.let { destinationDir / it } ?: destinationDir val space = page.space?.key!! - val converter = HtmlToMarkdownConverter(ConfluenceLinkResolverImpl(client, space), assetsLocation ?: "") + val converter = HtmlToMarkdownConverter( + ConfluenceLinkResolverImpl(client, space), + assetsLocation ?: "", + ConfluenceUserResolverImpl(client) + ) val attachments = page.children?.attachment?.let { client.fetchAllAttachments(it) } ?: emptyList() exportPageContent(converter, page, attachments, destinationDir, Path.of(assetsLocation ?: ""))