Skip to content

Commit

Permalink
feat: Resolving user references during export to markdown (Confluence…
Browse files Browse the repository at this point in the history
… Server only)

Closes #51
  • Loading branch information
zeldigas committed Jan 7, 2024
1 parent 85ad862 commit 0775bbc
Show file tree
Hide file tree
Showing 13 changed files with 198 additions and 18 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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?) :
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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.*
Expand Down Expand Up @@ -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<User>(expectSuccess = true)
}
}

private suspend inline fun <reified T> HttpResponse.readApiResponse(): T {
private suspend inline fun <reified T> 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<T>()
} catch (e: JsonConvertException) {
val content = body<Map<String, Any?>>()
throw ConfluenceApiErrorException(status.value, content["error"]?.toString() ?: "", content)
parseAndThrowConfluencError()
}
} else {
throw UnknownConfluenceErrorException(status.value, bodyAsText())
}
}

private suspend fun HttpResponse.parseAndThrowConfluencError(): Nothing {
val content = body<Map<String, Any?>>()
throw ConfluenceApiErrorException(status.value, content["error"]?.toString() ?: "", content)
}

private data class PageSearchResult(
val results: List<ConfluencePage>,
val start: Int,
Expand Down
Original file line number Diff line number Diff line change
@@ -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?)
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
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.*
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) {
Expand Down Expand Up @@ -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<ConfluenceApiErrorException> { 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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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?

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,31 @@ 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<ConfluenceLinksResolver>("CONFLUENCE_LINK_RESOLVER") { ConfluenceLinksResolver.NOP }
val USER_RESOLVER =
DataKey<ConfluenceUserResolver>("CONFLUENCE_USER_RESOLVER") { ConfluenceUserResolver.NOP }
val ASSETS_DIR = DataKey<Path>("CONFLUENCE_ASSETS_DIR") { Path("_assets") }
}

private val converter = FlexmarkHtmlConverter.builder(
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions convert/src/test/resources/convert/user-refs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<p>Known user:
<ac:link>
<ri:user ri:userkey="known"/>
</ac:link>
</p>
<p>Known user as email:
<ac:link>
<ri:user ri:userkey="known_email"/>
</ac:link>
</p>
<p>Unknown user:
<ac:link>
<ri:user ri:userkey="unknown"/>
</ac:link>
</p>
<p>Ignored user:
<ac:link>
<ri:user ri:account-id="some-id"/>
</ac:link>
</p>
7 changes: 7 additions & 0 deletions convert/src/test/resources/convert/user-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Known user: @user

Known user as email: @"user@example.org"

Unknown user:

Ignored user:
Original file line number Diff line number Diff line change
@@ -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<String, String?> = mutableMapOf()

override fun resolve(userKey: String): String? {
return cache.computeIfAbsent(userKey) { key ->
runBlocking {
val user = client.getUserByKey(key)
user.username
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?: ""))
Expand Down

0 comments on commit 0775bbc

Please sign in to comment.