From e8dc4f52b75a8ee6c06ad99a11bb4d28bdadf09f Mon Sep 17 00:00:00 2001 From: Dmitry Pavlov Date: Mon, 18 May 2026 10:32:46 +0300 Subject: [PATCH 1/7] Wip on users resolution by email in confluence cloud 1. api client added 2. user resolver abstraction with server and cloud versions added --- .../zeldigas/confclient/ConfluenceClient.kt | 5 +- .../confclient/ConfluenceCloudClient.kt | 19 +++- .../confclient/ConfluenceUserSearchClient.kt | 7 ++ .../confclient/ConfluenceCloudClientTest.kt | 41 +++++++ .../data/responses/api-v1/user-search.json | 22 ++++ core/pom.xml | 9 ++ .../core/users/CloudUserResolver.kt | 76 +++++++++++++ .../core/users/ServerUserResolver.kt | 7 ++ .../text2confl/core/users/UserResolver.kt | 11 ++ .../core/users/CloudUserResolverTest.kt | 107 ++++++++++++++++++ pom.xml | 5 + 11 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceUserSearchClient.kt create mode 100644 confluence-client/src/test/resources/data/responses/api-v1/user-search.json create mode 100644 core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt create mode 100644 core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt create mode 100644 core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/UserResolver.kt create mode 100644 core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt 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 504bd845..f9ab4045 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 @@ -222,8 +222,9 @@ fun confluenceClientV2( } call } - val baseUrl = URLBuilder(config.server).appendPathSegments("api", "v2").build().toString() + val v2BaseUrl = URLBuilder(config.server).appendPathSegments("api", "v2").build().toString() + val v1BaseUrl = URLBuilder(config.server).appendPathSegments("rest", "api").build().toString() return ConfluenceCloudClient( - config.server, baseUrl, client, confluenceClientV1(config, client) + config.server, v2BaseUrl, v1BaseUrl, client, confluenceClientV1(config, client) ) } \ No newline at end of file diff --git a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClient.kt b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClient.kt index bb01d43e..01f7f27c 100644 --- a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClient.kt +++ b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClient.kt @@ -20,10 +20,11 @@ import java.util.concurrent.ConcurrentMap class ConfluenceCloudClient( override val confluenceBaseUrl: Url, private val apiBase: String, + private val legacyApiBase: String, private val httpClient: HttpClient, private val fallbackClient: ConfluenceClient, private val collectionsConcurrency: Int = 5, -) : ConfluenceClient by fallbackClient { +) : ConfluenceClient by fallbackClient, ConfluenceUserSearchClient { private val spacesCache: ConcurrentMap = ConcurrentHashMap() @@ -387,6 +388,14 @@ class ConfluenceCloudClient( } } + override suspend fun findUserIdByEmail(email: String): String? { + val username = email.substringBefore('@') + val result = httpClient.get("$legacyApiBase/search/user") { + parameter("cql", "user=$username") + }.readApiResponse() + return result.results.firstOrNull { it.user.email == email }?.user?.accountId + } + private fun versionNode( version: Int, pageUpdateOptions: PageUpdateOptions @@ -518,4 +527,10 @@ private data class CloudAttachment( val comment: String?, @param:JsonProperty("_links") val links: Map = emptyMap() -) \ No newline at end of file +) + +private data class UserSearchResult(val results: List) + +private data class UserSearchEntry(val user: UserSearchUser) + +private data class UserSearchUser(val accountId: String, val email: String) \ No newline at end of file diff --git a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceUserSearchClient.kt b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceUserSearchClient.kt new file mode 100644 index 00000000..da6c7726 --- /dev/null +++ b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceUserSearchClient.kt @@ -0,0 +1,7 @@ +package com.github.zeldigas.confclient + +interface ConfluenceUserSearchClient { + + suspend fun findUserIdByEmail(email: String): String? + +} \ No newline at end of file diff --git a/confluence-client/src/test/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClientTest.kt b/confluence-client/src/test/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClientTest.kt index 0237bb40..26539966 100644 --- a/confluence-client/src/test/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClientTest.kt +++ b/confluence-client/src/test/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClientTest.kt @@ -2,6 +2,7 @@ package com.github.zeldigas.confclient import assertk.assertThat import assertk.assertions.isEqualTo +import assertk.assertions.isNull import com.github.tomakehurst.wiremock.client.WireMock.* import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo import com.github.tomakehurst.wiremock.junit5.WireMockTest @@ -298,4 +299,44 @@ class ConfluenceCloudClientTest(runtimeInfo: WireMockRuntimeInfo) { ) ) } + + @Test + fun `Find user id by email - user found`() = runTest { + stubFor( + get(urlPathEqualTo("/rest/api/search/user")) + .withQueryParam("cql", equalTo("user=zeldigas")) + .willReturn(ok().withJsonFromFile("/data/responses/api-v1/user-search.json")) + ) + + val result = client.findUserIdByEmail("zeldigas@example.com") + + assertThat(result).isEqualTo("bbbbbb:aaaaaaaa-cccc-aaaa-ffff-123411111111") + } + + @Test + fun `Find user id by email - no results`() = runTest { + stubFor( + get(urlPathEqualTo("/rest/api/search/user")) + .withQueryParam("cql", equalTo("user=unknown")) + .willReturn(ok().withBody("""{"results":[],"start":0,"limit":25,"size":0,"totalSize":0}""") + .withHeader("Content-Type", "application/json")) + ) + + val result = client.findUserIdByEmail("unknown@example.com") + + assertThat(result).isNull() + } + + @Test + fun `Find user id by email - email mismatch in results`() = runTest { + stubFor( + get(urlPathEqualTo("/rest/api/search/user")) + .withQueryParam("cql", equalTo("user=zeldigas")) + .willReturn(ok().withJsonFromFile("/data/responses/api-v1/user-search.json")) + ) + + val result = client.findUserIdByEmail("zeldigas@other.com") + + assertThat(result).isNull() + } } diff --git a/confluence-client/src/test/resources/data/responses/api-v1/user-search.json b/confluence-client/src/test/resources/data/responses/api-v1/user-search.json new file mode 100644 index 00000000..5a2311be --- /dev/null +++ b/confluence-client/src/test/resources/data/responses/api-v1/user-search.json @@ -0,0 +1,22 @@ +{ + "results": [ + { + "user": { + "type": "known", + "accountId": "bbbbbb:aaaaaaaa-cccc-aaaa-ffff-123411111111", + "accountType": "atlassian", + "email": "zeldigas@example.com", + "publicName": "zeldigas", + "displayName": "Dmitry Pavlov", + "isExternalCollaborator": false + }, + "title": "Dmitry Pavlov", + "entityType": "user" + } + ], + "start": 0, + "limit": 25, + "size": 1, + "totalSize": 1, + "cqlQuery": "user = zeldigas" +} diff --git a/core/pom.xml b/core/pom.xml index 401acf19..149fb8d4 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -26,6 +26,10 @@ com.fasterxml.jackson.dataformat jackson-dataformat-yaml + + com.sksamuel.aedile + aedile-core + @@ -38,5 +42,10 @@ mockk-jvm test + + org.jetbrains.kotlinx + kotlinx-coroutines-test-jvm + test + \ No newline at end of file diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt new file mode 100644 index 00000000..6bebab49 --- /dev/null +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt @@ -0,0 +1,76 @@ +package com.github.zeldigas.text2confl.core.users + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.benmanes.caffeine.cache.Caffeine +import com.github.zeldigas.confclient.ConfluenceUserSearchClient +import com.sksamuel.aedile.core.asLoadingCache +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import java.nio.file.Path +import java.time.Instant +import kotlin.io.path.inputStream +import kotlin.io.path.outputStream + +private val MAPPER = jacksonObjectMapper() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + +fun createFromStoredData( + client: ConfluenceUserSearchClient, + fileWithCache: Path +): CloudUserResolver { + val data = fileWithCache.inputStream().use { + MAPPER.readValue(it, ResolvedUsers::class.java) + } + return CloudUserResolver(client, data.users) +} + +fun persistResolvedUsers(users: CloudUserResolver, destination: Path) { + val usersData = runBlocking { users.cachedUsers() } + destination.outputStream().use { + MAPPER.writeValue(it, ResolvedUsers(usersData, Instant.now())) + } +} + +class CloudUserResolver( + private val client: ConfluenceUserSearchClient, + knownUsers: Map = emptyMap() +) : UserResolver { + + private val cache = Caffeine.newBuilder() + .asLoadingCache { email: String -> + client.findUserIdByEmail(email)?.let { UserValue.Found(it) } ?: UserValue.Missing + } + + init { + knownUsers.forEach { (k, v) -> cache[k] = UserValue.Found(v) } + } + + override suspend fun resolveUser(email: String): String? { + val result = cache.get(email) + return if (result is UserValue.Found) result.user else null + } + + override suspend fun resolveUsers(users: List): Map = coroutineScope { + users.map { email -> async { email to resolveUser(email) } } + .awaitAll() + .mapNotNull { (email, id) -> id?.let { email to it } } + .toMap() + } + + suspend fun cachedUsers(): Map = cache.asMap() + .filterValues { it is UserValue.Found } + .map { (key, value) -> key to (value as UserValue.Found).user } + .toMap() + + sealed class UserValue { + object Missing : UserValue() + data class Found(val user: String) : UserValue() + } +} + +data class ResolvedUsers(val users: Map, val dateCreated: Instant) \ No newline at end of file diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt new file mode 100644 index 00000000..cb6eeef7 --- /dev/null +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt @@ -0,0 +1,7 @@ +package com.github.zeldigas.text2confl.core.users + +class ServerUserResolver : UserResolver { + override suspend fun resolveUser(email: String): String = email + + override suspend fun resolveUsers(users: List): Map = users.associateBy({ it }, { it }) +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/UserResolver.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/UserResolver.kt new file mode 100644 index 00000000..fd1b010d --- /dev/null +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/UserResolver.kt @@ -0,0 +1,11 @@ +package com.github.zeldigas.text2confl.core.users + +import com.github.zeldigas.confclient.model.User + +interface UserResolver { + + suspend fun resolveUser(email: String): String? + + suspend fun resolveUsers(users: List): Map + +} \ No newline at end of file diff --git a/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt new file mode 100644 index 00000000..6803c30a --- /dev/null +++ b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt @@ -0,0 +1,107 @@ +package com.github.zeldigas.text2confl.core.users + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.github.zeldigas.confclient.ConfluenceUserSearchClient +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +class CloudUserResolverTest( + @MockK private val client: ConfluenceUserSearchClient +) { + + @Test + fun `resolveUser returns account id when user found`() = runTest { + val resolver = CloudUserResolver(client) + coEvery { client.findUserIdByEmail("user@example.com") } returns "acc-123" + + assertThat(resolver.resolveUser("user@example.com")).isEqualTo("acc-123") + } + + @Test + fun `resolveUser returns null when user not found`() = runTest { + val resolver = CloudUserResolver(client) + coEvery { client.findUserIdByEmail("missing@example.com") } returns null + + assertThat(resolver.resolveUser("missing@example.com")).isNull() + } + + @Test + fun `resolveUser caches result and calls client only once for repeated lookups`() = runTest { + val resolver = CloudUserResolver(client) + coEvery { client.findUserIdByEmail("user@example.com") } returns "acc-123" + + resolver.resolveUser("user@example.com") + resolver.resolveUser("user@example.com") + + coVerify(exactly = 1) { client.findUserIdByEmail("user@example.com") } + } + + @Test + fun `resolveUsers returns map of found users only`() = runTest { + val resolver = CloudUserResolver(client) + coEvery { client.findUserIdByEmail("a@example.com") } returns "acc-a" + coEvery { client.findUserIdByEmail("b@example.com") } returns null + coEvery { client.findUserIdByEmail("c@example.com") } returns "acc-c" + + val result = resolver.resolveUsers(listOf("a@example.com", "b@example.com", "c@example.com")) + + assertThat(result).isEqualTo(mapOf("a@example.com" to "acc-a", "c@example.com" to "acc-c")) + } + + @Test + fun `concurrent resolveUser calls for same email trigger only one client request`() = runTest { + val resolver = CloudUserResolver(client) + coEvery { client.findUserIdByEmail("user@example.com") } returns "acc-123" + + val results = (1..5).map { async { resolver.resolveUser("user@example.com") } }.awaitAll() + + assertThat(results.distinct()).isEqualTo(listOf("acc-123")) + coVerify(exactly = 1) { client.findUserIdByEmail("user@example.com") } + } + + @Test + fun `known users are returned without calling client`() = runTest { + val resolver = CloudUserResolver(client, knownUsers = mapOf("known@example.com" to "acc-known")) + + assertThat(resolver.resolveUser("known@example.com")).isEqualTo("acc-known") + + coVerify(exactly = 0) { client.findUserIdByEmail(any()) } + } + + @Test + fun `cachedUsers returns pre-populated known users`() = runTest { + val resolver = CloudUserResolver(client, knownUsers = mapOf("a@example.com" to "acc-a", "b@example.com" to "acc-b")) + + assertThat(resolver.cachedUsers()).isEqualTo(mapOf("a@example.com" to "acc-a", "b@example.com" to "acc-b")) + } + + @Test + fun `cachedUsers includes users resolved via client`() = runTest { + val resolver = CloudUserResolver(client) + coEvery { client.findUserIdByEmail("user@example.com") } returns "acc-123" + + resolver.resolveUser("user@example.com") + + assertThat(resolver.cachedUsers()).isEqualTo(mapOf("user@example.com" to "acc-123")) + } + + @Test + fun `cachedUsers does not include users not found by client`() = runTest { + val resolver = CloudUserResolver(client) + coEvery { client.findUserIdByEmail("missing@example.com") } returns null + + resolver.resolveUser("missing@example.com") + + assertThat(resolver.cachedUsers()).isEqualTo(emptyMap()) + } +} diff --git a/pom.xml b/pom.xml index 8fe82527..2aa1471a 100644 --- a/pom.xml +++ b/pom.xml @@ -132,6 +132,11 @@ 3.13.2 test + + com.sksamuel.aedile + aedile-core + 3.0.4 + From 2d19bf4416506add06fbc55fe2f6a6eab50afed5 Mon Sep 17 00:00:00 2001 From: Dmitry Pavlov Date: Sun, 13 Sep 2026 23:30:44 +0300 Subject: [PATCH 2/7] Export to markdown improvements for cloud 1. proper user resolution 2. support for note admonitions Fixes #338 --- .../confclient/ConfluenceCloudClient.kt | 6 +++ .../github/zeldigas/confclient/model/User.kt | 2 +- .../export/ConfluenceCustomNodeRenderer.kt | 54 +++++++++++++++++-- .../export/HtmlToMarkdownConverterTest.kt | 1 + .../resources/convert/confluence-cloud.html | 30 +++++++++++ .../resources/convert/confluence-cloud.md | 19 +++++++ .../core/export/ConfluenceUserResolverImpl.kt | 2 +- 7 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 convert/src/test/resources/convert/confluence-cloud.html create mode 100644 convert/src/test/resources/convert/confluence-cloud.md diff --git a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClient.kt b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClient.kt index 01f7f27c..8a8b1b77 100644 --- a/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClient.kt +++ b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/ConfluenceCloudClient.kt @@ -169,6 +169,12 @@ class ConfluenceCloudClient( }.readApiResponse(expectSuccess = true) } + override suspend fun getUserByKey(userKey: String): User { + return httpClient.get("$legacyApiBase/user") { + parameter("accountId", userKey) + }.readApiResponse(expectSuccess = true) + } + override suspend fun findChildPages( pageId: String, loadOptions: Set? 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 index 9e42d10e..6afeec4d 100644 --- 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 @@ -1,3 +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 +data class User(val type: String?, val username: String?, val userKey: String?, val displayName: String?, val email: String?) \ No newline at end of file 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 d1c30eac..438f31e6 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 @@ -24,6 +24,7 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { override fun getHtmlNodeRendererHandlers(): Set> { return setOf( + HtmlNodeRendererHandler("ac:adf-extension", Element::class.java, this::processAdfMacro), HtmlNodeRendererHandler("ac:structured-macro", Element::class.java, this::processMacro), HtmlNodeRendererHandler("ac:task-list", Element::class.java, this::processTaskList), HtmlNodeRendererHandler("ac:link", Element::class.java, this::processLink), @@ -47,6 +48,17 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { context.wrapTextNodes(element, "**", false) } + private fun processAdfMacro( + element: Element, + context: HtmlNodeConverterContext, + out: HtmlMarkdownWriter + ) { + val extension = element.getElementsByTag("ac:adf-node").first() ?: return + when(extension.attr("type")) { + "panel" -> generateAdmonitionFromCloud(element, extension, context, out) + } + } + private fun processMacro( element: Element, context: HtmlNodeConverterContext, @@ -110,11 +122,39 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { } fun generateAdmonition(element: Element, context: HtmlNodeConverterContext, out: HtmlMarkdownWriter) { + val type = element.attr("ac:name") + val attributes = macroParameters(element) + val content = element.childNodes().filterIsInstance().first { it.tagName() == "ac:rich-text-body" } if (element.previousElementSibling() != null) { out.blankLine() } - out.append("!!! ").append(element.attr("ac:name")) - val attributes = macroParameters(element) + generateAdmonition(out, context, type, attributes, content) + } + + private fun generateAdmonitionFromCloud( + adfRoot: Element, + extensionRoot: Element, + context: HtmlNodeConverterContext, + out: HtmlMarkdownWriter + ) { + if (adfRoot.previousElementSibling() != null) { + out.blankLine() + } + val attributes = adfMacroParameters(extensionRoot) + val type = attributes.getValue("panel-type") + val content = extensionRoot.getElementsByTag("ac:adf-content").first()!! + if (adfRoot.previousElementSibling() != null) { + out.blankLine() + } + generateAdmonition(out, context, type, attributes, content) + } + + fun generateAdmonition(out: HtmlMarkdownWriter, + context: HtmlNodeConverterContext, + type: String, + attributes: Map, + content: Element) { + out.append("!!! ").append(type) if ("title" in attributes) { out.append(" \"") out.append(context.escapeSpecialChars(attributes["title"]!!)) @@ -124,7 +164,7 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { out.pushPrefix() out.addPrefix(" ") context.renderChildren( - element.childNodes().filterIsInstance().first { it.tagName() == "ac:rich-text-body" }, true, null + content, true, null ) out.popPrefix() } @@ -133,6 +173,10 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { element.childNodes().filterIsInstance().filter { it.tagName() == "ac:parameter" } .map { it.attr("ac:name") to it.ownText() }.toMap() + private fun adfMacroParameters(element: Element) = + element.childNodes().filterIsInstance().filter { it.tagName() == "ac:adf-attribute" } + .map { it.attr("key") to it.ownText() }.toMap() + private fun generateAttributes(out: HtmlMarkdownWriter, attributes: Map) { var counter = attributes.size for ((key, value) in attributes.entries) { @@ -218,7 +262,9 @@ class ConfluenceCustomNodeRenderer(options: DataHolder) : HtmlNodeRenderer { } private fun processUserReference(element: Element, context: HtmlNodeConverterContext, writer: HtmlMarkdownWriter) { - val key = element.attr("ri:userkey") ?: return + val key = element.attr("ri:userkey").ifEmpty { + element.attr("ri:account-id").ifEmpty { null } + } ?: return val username = userResolver.resolve(key) ?: return writer.append('@') 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 d9456588..bd6d47a7 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 @@ -26,6 +26,7 @@ class HtmlToMarkdownConverterTest { "tables", "confluence-specific", "user-refs", + "confluence-cloud", ] ) @ParameterizedTest diff --git a/convert/src/test/resources/convert/confluence-cloud.html b/convert/src/test/resources/convert/confluence-cloud.html new file mode 100644 index 00000000..609ad4c6 --- /dev/null +++ b/convert/src/test/resources/convert/confluence-cloud.html @@ -0,0 +1,30 @@ +

Export page with + + reference +

+ + + note + 853e55a82c3d +

hello

+ + +
+
+

hello

+
+
+
+ + +

hello

+

some data

+
+ +

yay!

+
+

And something else

\ No newline at end of file diff --git a/convert/src/test/resources/convert/confluence-cloud.md b/convert/src/test/resources/convert/confluence-cloud.md new file mode 100644 index 00000000..8166b827 --- /dev/null +++ b/convert/src/test/resources/convert/confluence-cloud.md @@ -0,0 +1,19 @@ +Export page with @"user@example.org" reference + +
+ +!!! note + + hello + +!!! warning + + **hello** + + some data + +!!! tip + + yay! + +And something else 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 index 4054e103..e818f3d6 100644 --- 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 @@ -12,7 +12,7 @@ class ConfluenceUserResolverImpl(private val client: ConfluenceClient) : Conflue return cache.computeIfAbsent(userKey) { key -> runBlocking { val user = client.getUserByKey(key) - user.username + user.username ?: user.email } } } From e1f699501ee5fa6318c956942bc54077a661d356 Mon Sep 17 00:00:00 2001 From: Dmitry Pavlov Date: Sun, 13 Sep 2026 23:33:43 +0300 Subject: [PATCH 3/7] Updated changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 783d9ff1..b503b31d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased -## 0.27.0 - 2026-09-13 +## 0.27.0 ### Added - `additional-files-to-ignore` parameter in configuration files. It allows you to define list of file names that must be ignored in addition to predefined `_` prefixed files. You can use this feature to ignore some technical files, such as `CLAUDE.md` and `AGENTS.md`. Every list item represent a filename that is matched ignoring case. +- \[export-to-md] now supports `note` panel from Confluence cloud (without customizations for now) + +### Fixed + +- \[export-to-md] for pages with user references (#338) ## 0.26.0 - 2026-06-30 From ab3e033f278d86d27eec9d1327bf3eb70c752c51 Mon Sep 17 00:00:00 2001 From: Dmitry Pavlov Date: Sun, 13 Sep 2026 23:37:53 +0300 Subject: [PATCH 4/7] Using null as email default --- .../main/kotlin/com/github/zeldigas/confclient/model/User.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6afeec4d..1c46c9f3 100644 --- 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 @@ -1,3 +1,3 @@ package com.github.zeldigas.confclient.model -data class User(val type: String?, val username: String?, val userKey: String?, val displayName: String?, val email: String?) \ No newline at end of file +data class User(val type: String?, val username: String?, val userKey: String?, val displayName: String?, val email: String? = null) \ No newline at end of file From 659e664ec690e3f1226579a80cc02f792d860b39 Mon Sep 17 00:00:00 2001 From: Dmitry Pavlov Date: Mon, 14 Sep 2026 10:21:21 +0300 Subject: [PATCH 5/7] Markdown converter now properly resolves users --- .../github/zeldigas/text2confl/cli/Convert.kt | 3 ++- .../github/zeldigas/text2confl/cli/Upload.kt | 17 +++++++++--- .../zeldigas/text2confl/cli/ConvertTest.kt | 6 +++-- .../zeldigas/text2confl/cli/UploadTest.kt | 10 ++++--- .../zeldigas/text2confl/convert/Converter.kt | 12 ++++++--- .../text2confl/convert/FileConverter.kt | 2 ++ .../convert/confluence/UserResolver.kt | 16 ++++++++++++ .../ConfluenceStorageFormatConverters.kt | 15 +++++++++-- .../convert/markdown/MarkdownFileConverter.kt | 13 ++++++++-- .../MarkdownUserReferencesCollector.kt | 17 ++++++++++++ .../convert/UniversalConverterTest.kt | 15 ++++++----- .../asciidoc/AsciidocFileConverterTest.kt | 4 ++- .../convert/confluence/TestUserResolver.kt | 15 +++++++++++ .../markdown/MarkdownFileConverterTest.kt | 7 ++--- .../convert/markdown/RenderingTestBase.kt | 3 ++- .../text2confl/core/ServiceProvider.kt | 8 +++--- .../core/users/CloudUserResolver.kt | 26 ++++++++++++++----- .../core/users/ServerUserResolver.kt | 15 +++++++++-- .../text2confl/core/users/UserResolver.kt | 11 -------- .../core/ServiceProviderImplTest.kt | 12 +++++---- .../core/users/CloudUserResolverTest.kt | 12 ++++++--- 21 files changed, 178 insertions(+), 61 deletions(-) create mode 100644 convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/confluence/UserResolver.kt create mode 100644 convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownUserReferencesCollector.kt create mode 100644 convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/confluence/TestUserResolver.kt delete mode 100644 core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/UserResolver.kt diff --git a/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Convert.kt b/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Convert.kt index 2bcb75ae..06082239 100644 --- a/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Convert.kt +++ b/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Convert.kt @@ -15,6 +15,7 @@ import com.github.zeldigas.text2confl.core.ServiceProvider import com.github.zeldigas.text2confl.core.config.createConversionConfig import com.github.zeldigas.text2confl.core.config.readDirectoryConfig import com.github.zeldigas.text2confl.core.export.sanitizeTitle +import com.github.zeldigas.text2confl.core.users.ServerUserResolver import io.ktor.http.* import java.io.File import java.nio.file.Path @@ -51,7 +52,7 @@ class Convert : CliktCommand(name = "convert"), autoFixContent ) val space = spaceKey ?: directoryConfig.space ?: "AAA" - val converter = serviceProvider.createConverter(space, conversionConfig) + val converter = serviceProvider.createConverter(space, conversionConfig, ServerUserResolver()) try { val result = tryConvert(converter) if (validate) { diff --git a/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt b/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt index 986c62a1..44cdc77e 100644 --- a/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt +++ b/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt @@ -7,13 +7,16 @@ import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.enum import com.github.zeldigas.confclient.ConfluenceClient import com.github.zeldigas.confclient.ConfluenceClientConfig -import com.github.zeldigas.confclient.PasswordAuth +import com.github.zeldigas.confclient.ConfluenceUserSearchClient import com.github.zeldigas.confclient.model.ConfluencePage import com.github.zeldigas.text2confl.convert.EditorVersion +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.github.zeldigas.text2confl.core.ServiceProvider import com.github.zeldigas.text2confl.core.config.* import com.github.zeldigas.text2confl.core.upload.ChangeDetector import com.github.zeldigas.text2confl.core.upload.UploadOperationTracker +import com.github.zeldigas.text2confl.core.users.CloudUserResolver +import com.github.zeldigas.text2confl.core.users.ServerUserResolver import io.ktor.client.plugins.logging.* import io.ktor.http.* import kotlinx.coroutines.Dispatchers @@ -89,7 +92,8 @@ class Upload : CliktCommand(name = "upload"), clientConfig.server, autoFixContent ) - val converter = serviceProvider.createConverter(uploadConfig.space, conversionConfig) + val confluenceClient = serviceProvider.createConfluenceClient(clientConfig, dryRun) + val converter = serviceProvider.createConverter(uploadConfig.space, conversionConfig, createUserResolver(confluenceClient)) val pagesToPublish = if (docs.isFile) { listOf(converter.convertFile(docs.toPath())) } else { @@ -97,7 +101,6 @@ class Upload : CliktCommand(name = "upload"), } val contentValidator = serviceProvider.createContentValidator() contentValidator.validate(pagesToPublish) - val confluenceClient = serviceProvider.createConfluenceClient(clientConfig, dryRun) val publishUnder = resolveParent(confluenceClient, uploadConfig, directoryStoredParams) val contentUploader = serviceProvider.createUploader(confluenceClient, uploadConfig, conversionConfig, operationsTracker(clientConfig.server)) @@ -107,6 +110,14 @@ class Upload : CliktCommand(name = "upload"), } } + private fun createUserResolver(confluenceClient: ConfluenceClient): UserResolver { + return if (confluenceClient is ConfluenceUserSearchClient) { + CloudUserResolver(confluenceClient) + } else { + ServerUserResolver() + } + } + private fun operationsTracker(server: Url): UploadOperationTracker = PrintingUploadOperationsTracker( server = server, printer = terminal::println, diff --git a/cli/src/test/kotlin/com/github/zeldigas/text2confl/cli/ConvertTest.kt b/cli/src/test/kotlin/com/github/zeldigas/text2confl/cli/ConvertTest.kt index b9a090b1..1ed70b72 100644 --- a/cli/src/test/kotlin/com/github/zeldigas/text2confl/cli/ConvertTest.kt +++ b/cli/src/test/kotlin/com/github/zeldigas/text2confl/cli/ConvertTest.kt @@ -9,6 +9,7 @@ import com.github.ajalt.clikt.core.parse import com.github.zeldigas.text2confl.convert.Attachment import com.github.zeldigas.text2confl.convert.Converter import com.github.zeldigas.text2confl.convert.Page +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.github.zeldigas.text2confl.core.ContentValidator import com.github.zeldigas.text2confl.core.ServiceProvider import io.mockk.Runs @@ -30,14 +31,15 @@ import kotlin.io.path.exists class ConvertTest( @param:MockK private val serviceProvider: ServiceProvider, @param:MockK private val converter: Converter, - @param:MockK private val contentValidator: ContentValidator + @param:MockK private val contentValidator: ContentValidator, + @param:MockK private val userResolver: UserResolver ) { private val command = Convert() @BeforeEach internal fun setUp() { - every { serviceProvider.createConverter(any(), any()) } returns converter + every { serviceProvider.createConverter(any(), any(), any()) } returns converter every { serviceProvider.createContentValidator() } returns contentValidator every { contentValidator.validate(any()) } just Runs diff --git a/cli/src/test/kotlin/com/github/zeldigas/text2confl/cli/UploadTest.kt b/cli/src/test/kotlin/com/github/zeldigas/text2confl/cli/UploadTest.kt index 4137cafe..8e70f0a7 100644 --- a/cli/src/test/kotlin/com/github/zeldigas/text2confl/cli/UploadTest.kt +++ b/cli/src/test/kotlin/com/github/zeldigas/text2confl/cli/UploadTest.kt @@ -19,6 +19,7 @@ import com.github.zeldigas.confclient.PasswordAuth import com.github.zeldigas.confclient.TokenAuth import com.github.zeldigas.text2confl.convert.* import com.github.zeldigas.text2confl.convert.asciidoc.AsciidoctorConfiguration +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.github.zeldigas.text2confl.convert.markdown.DiagramsConfiguration import com.github.zeldigas.text2confl.convert.markdown.MarkdownConfiguration import com.github.zeldigas.text2confl.core.ContentValidationFailedException @@ -27,6 +28,7 @@ import com.github.zeldigas.text2confl.core.ServiceProvider import com.github.zeldigas.text2confl.core.config.* import com.github.zeldigas.text2confl.core.upload.ChangeDetector import com.github.zeldigas.text2confl.core.upload.ContentUploader +import com.github.zeldigas.text2confl.core.users.ServerUserResolver import io.ktor.http.* import io.mockk.* import io.mockk.impl.annotations.MockK @@ -45,13 +47,13 @@ internal class UploadTest( @param:MockK private val contentUploader: ContentUploader, @param:MockK private val confluenceClient: ConfluenceClient, @param:MockK private val contentValidator: ContentValidator, - @param:MockK private val converter: Converter + @param:MockK private val converter: Converter, ) { private val command = Upload() @BeforeEach internal fun setUp() { - every { serviceProvider.createConverter(any(), any()) } returns converter + every { serviceProvider.createConverter(any(), any(), any()) } returns converter every { serviceProvider.createConfluenceClient(any(), any()) } returns confluenceClient every { serviceProvider.createUploader(confluenceClient, any(), any(), any()) } returns contentUploader every { serviceProvider.createContentValidator() } returns contentValidator @@ -103,7 +105,7 @@ internal class UploadTest( AsciidoctorConfiguration(libsToLoad = listOf("asciidoctor-diagram"), workdir = tempDir / ".asciidoc"), autoFixContentTags = false, filesToIgnore = emptyList() ) - verify { serviceProvider.createConverter("TR", expectedConverterConfig) } + verify { serviceProvider.createConverter("TR", expectedConverterConfig, any(ServerUserResolver::class)) } verify { serviceProvider.createUploader( confluenceClient, UploadConfig( @@ -162,7 +164,7 @@ internal class UploadTest( autoFixContentTags = true, emptyList() ) - verify { serviceProvider.createConverter(directoryConfig.space!!, converterConfig) } + verify { serviceProvider.createConverter(directoryConfig.space!!, converterConfig, any(ServerUserResolver::class)) } verify { serviceProvider.createUploader( confluenceClient, UploadConfig( diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/Converter.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/Converter.kt index e5649e27..60681cef 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/Converter.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/Converter.kt @@ -4,6 +4,7 @@ import com.github.zeldigas.text2confl.convert.asciidoc.AsciidocFileConverter import com.github.zeldigas.text2confl.convert.asciidoc.AsciidoctorConfiguration import com.github.zeldigas.text2confl.convert.confluence.LanguageMapper import com.github.zeldigas.text2confl.convert.confluence.ReferenceProvider +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.github.zeldigas.text2confl.convert.markdown.MarkdownConfiguration import com.github.zeldigas.text2confl.convert.markdown.MarkdownFileConverter import org.jsoup.Jsoup @@ -61,13 +62,15 @@ class ExactFileMatcher(val name: String, val ignoreCase: Boolean) : FileMatcher fun universalConverter( space: String, - parameters: ConversionParameters + parameters: ConversionParameters, + userResolver: UserResolver ): Converter { return UniversalConverter( space, parameters, mapOf( "md" to MarkdownFileConverter(parameters.markdownConfiguration), "adoc" to AsciidocFileConverter(parameters.asciidoctorConfiguration) - ), FileNameBasedDetector + ), FileNameBasedDetector, + userResolver ) } @@ -76,6 +79,7 @@ internal class UniversalConverter( val conversionParameters: ConversionParameters, val converters: Map, val pagesDetector: PagesDetector, + val userResolver: UserResolver ) : Converter { override fun convertFile(file: Path): Page { @@ -84,7 +88,7 @@ internal class UniversalConverter( } return Page( - performConversion(file, ConvertingContext(ReferenceProvider.singleFile(), conversionParameters, space)), + performConversion(file, ConvertingContext(ReferenceProvider.singleFile(), userResolver, conversionParameters, space)), file, emptyList() ) @@ -97,7 +101,7 @@ internal class UniversalConverter( return convertFilesInDirectory( dir, - ConvertingContext(ReferenceProvider.fromDocuments(dir, documents), conversionParameters, space) + ConvertingContext(ReferenceProvider.fromDocuments(dir, documents), userResolver, conversionParameters, space) ) } diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/FileConverter.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/FileConverter.kt index 1e0c1ca4..fe46f40b 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/FileConverter.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/FileConverter.kt @@ -2,6 +2,7 @@ package com.github.zeldigas.text2confl.convert import com.github.zeldigas.text2confl.convert.confluence.LanguageMapper import com.github.zeldigas.text2confl.convert.confluence.ReferenceProvider +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import java.nio.file.Path interface FileConverter { @@ -21,6 +22,7 @@ data class HeaderReadingContext( data class ConvertingContext( val referenceProvider: ReferenceProvider, + val userResolver: UserResolver, val conversionParameters: ConversionParameters, val targetSpace: String ) { diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/confluence/UserResolver.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/confluence/UserResolver.kt new file mode 100644 index 00000000..f73f83a6 --- /dev/null +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/confluence/UserResolver.kt @@ -0,0 +1,16 @@ +package com.github.zeldigas.text2confl.convert.confluence + +interface UserResolver { + + suspend fun resolveUser(email: String): UserReference? + + suspend fun resolveUsers(users: List): Map + + fun registerReferencedUsers(email: List) + + data class UserReference(val format: UserIdFormat, val value: String) + enum class UserIdFormat { + ACCOUNT_ID, USERNAME + } + +} \ No newline at end of file diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/ConfluenceStorageFormatConverters.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/ConfluenceStorageFormatConverters.kt index 42fba63d..eb0184ef 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/ConfluenceStorageFormatConverters.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/ConfluenceStorageFormatConverters.kt @@ -4,6 +4,7 @@ import com.github.zeldigas.text2confl.convert.Attachment import com.github.zeldigas.text2confl.convert.ConvertingContext import com.github.zeldigas.text2confl.convert.EditorVersion import com.github.zeldigas.text2confl.convert.confluence.Anchor +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.github.zeldigas.text2confl.convert.confluence.Xref import com.github.zeldigas.text2confl.convert.markdown.ext.AttributeRepositoryAware import com.vladsch.flexmark.ast.* @@ -35,6 +36,7 @@ import com.vladsch.flexmark.util.html.CellAlignment import com.vladsch.flexmark.util.sequence.BasedSequence import com.vladsch.flexmark.util.sequence.Escaping import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.runBlocking import kotlin.math.roundToInt @@ -536,8 +538,17 @@ class ConfluenceNodeRenderer(options: DataHolder) : PhasedNodeRenderer, Attribut @Suppress("UNUSED_PARAMETER") context: NodeRendererContext, html: HtmlWriter ) { - html.tag("ac:link") { - html.voidTag("ri:user", mapOf("ri:username" to node.text)) + var userRef = runBlocking { convertingContext.userResolver.resolveUser(node.text.toString()) } + if (userRef == null) { + html.append(node.text) + } else { + html.tag("ac:link") { + val attr = when(userRef.format) { + UserResolver.UserIdFormat.ACCOUNT_ID -> "ri:account-id" + UserResolver.UserIdFormat.USERNAME -> "ri:username" + } + html.voidTag("ri:user", mapOf(attr to userRef.value)) + } } } diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownFileConverter.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownFileConverter.kt index 83c1c1fc..0d468536 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownFileConverter.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownFileConverter.kt @@ -29,11 +29,14 @@ internal class MarkdownFileConverter(private val parser: MarkdownParser) : FileC val (header, ast) = parseToHeaderAndBody(file, context, attachmentsRegistry) collectAttachments(file, context, ast, attachmentsRegistry) + resolveUsers(ast, context) - val generator = parser.htmlRenderer(file, + val generator = parser.htmlRenderer( + file, attachmentsRegistry.collectedAttachments, header.attributes, - context) + context + ) return PageContent( header, generator.render(ast), @@ -64,6 +67,12 @@ internal class MarkdownFileConverter(private val parser: MarkdownParser) : FileC } } + private fun resolveUsers(ast: Document, context: ConvertingContext) { + context.userResolver.registerReferencedUsers( + MarkdownUserReferencesCollector().findUsers(ast) + ) + } + private fun parseToHeaderAndBody( file: Path, context: ConvertingContext, diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownUserReferencesCollector.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownUserReferencesCollector.kt new file mode 100644 index 00000000..fbf634f1 --- /dev/null +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownUserReferencesCollector.kt @@ -0,0 +1,17 @@ +package com.github.zeldigas.text2confl.convert.markdown + +import com.vladsch.flexmark.util.ast.Document +import com.vladsch.flexmark.util.ast.NodeVisitor +import com.vladsch.flexmark.util.ast.VisitHandler + +class MarkdownUserReferencesCollector { + fun findUsers(ast: Document): MutableList { + val users: MutableList = mutableListOf() + NodeVisitor( + listOf( + VisitHandler(ConfluenceUserNode::class.java) { users.add(it.text.toString()) }, + ) + ).visit(ast) + return users + } +} diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/UniversalConverterTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/UniversalConverterTest.kt index 651f16ef..a1e57409 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/UniversalConverterTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/UniversalConverterTest.kt @@ -8,6 +8,7 @@ import assertk.assertions.isSameInstanceAs import com.github.zeldigas.text2confl.convert.asciidoc.AsciidocFileConverter import com.github.zeldigas.text2confl.convert.confluence.LanguageMapper import com.github.zeldigas.text2confl.convert.confluence.ReferenceProvider +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.github.zeldigas.text2confl.convert.markdown.MarkdownConfiguration import com.github.zeldigas.text2confl.convert.markdown.MarkdownFileConverter import com.github.zeldigas.text2confl.convert.markdown.diagram.DiagramMakersImpl @@ -30,7 +31,8 @@ import kotlin.io.path.createFile @ExtendWith(MockKExtension::class) internal class UniversalConverterTest( @param:MockK private val languageMapper: LanguageMapper, - @param:MockK private val fileConverter: FileConverter + @param:MockK private val fileConverter: FileConverter, + @param:MockK private val userResolver: UserResolver ) { private val titleConverter: (Path, String) -> String = { _, t -> "Prefixed: $t" } @@ -43,7 +45,7 @@ internal class UniversalConverterTest( private val converter = UniversalConverter( "TEST", conversionParameters, mapOf( "t" to fileConverter - ), FileNameBasedDetector + ), FileNameBasedDetector, userResolver ) @Test @@ -65,7 +67,7 @@ internal class UniversalConverterTest( verify { fileConverter.convert( src, - ConvertingContext(ReferenceProvider.singleFile(), conversionParameters, "TEST") + ConvertingContext(ReferenceProvider.singleFile(), userResolver, conversionParameters, "TEST") ) } } @@ -163,8 +165,7 @@ internal class UniversalConverterTest( docAndHeader(dir.resolve("three.t")), docAndHeader(dir.resolve("three/foo.t")), ) - ), - conversionParameters, "TEST" + ), userResolver, conversionParameters, "TEST" ) ) } @@ -187,7 +188,7 @@ internal class UniversalConverterTest( mockkStatic(::createDiagramMakers) { every { createDiagramMakers(any()) } returns DiagramMakersImpl(Paths.get("."), emptyList()) - val result = universalConverter("TEST", conversionParameters) + val result = universalConverter("TEST", conversionParameters, userResolver) assertThat(result).isInstanceOf(UniversalConverter::class).all { prop(UniversalConverter::conversionParameters).isSameInstanceAs(conversionParameters) @@ -214,7 +215,7 @@ internal class UniversalConverterTest( val autoFixingConverter = UniversalConverter( "TEST", conversionParameters.copy(autoFixContentTags = true), mapOf( "t" to fileConverter - ), FileNameBasedDetector + ), FileNameBasedDetector, userResolver ) val result = autoFixingConverter.convertFile(src) diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt index 39f665f6..d9a20c73 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt @@ -7,6 +7,7 @@ import assertk.assertions.* import com.github.zeldigas.text2confl.convert.* import com.github.zeldigas.text2confl.convert.confluence.LanguageMappers import com.github.zeldigas.text2confl.convert.confluence.ReferenceProvider +import com.github.zeldigas.text2confl.convert.confluence.TestUserResolver import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Test @@ -152,6 +153,7 @@ class AsciidocFileConverterTest { dir, mapOf(dir / "src.adoc" to PageHeader("Test document", emptyMap())) ), + TestUserResolver, ConversionParameters( LanguageMappers.forServer(), titleTransformer(), @@ -193,7 +195,7 @@ class AsciidocFileConverterTest { val result = converter.convert( file, ConvertingContext( - ReferenceProvider.singleFile(), + ReferenceProvider.singleFile(), TestUserResolver, ConversionParameters( LanguageMappers.forServer(), titleTransformer(), addAutogeneratedNote = false, editorVersion = EditorVersion.V1 diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/confluence/TestUserResolver.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/confluence/TestUserResolver.kt new file mode 100644 index 00000000..7f19b1ec --- /dev/null +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/confluence/TestUserResolver.kt @@ -0,0 +1,15 @@ +package com.github.zeldigas.text2confl.convert.confluence + +object TestUserResolver : UserResolver { + + override suspend fun resolveUser(email: String): UserResolver.UserReference? { + return UserResolver.UserReference(UserResolver.UserIdFormat.USERNAME, email) + } + + override suspend fun resolveUsers(users: List): Map { + return users.map { it to UserResolver.UserReference(UserResolver.UserIdFormat.USERNAME, it) }.toMap() + } + + override fun registerReferencedUsers(email: List) { + } +} \ No newline at end of file diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownFileConverterTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownFileConverterTest.kt index 18aa2af3..0d1b8be9 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownFileConverterTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/MarkdownFileConverterTest.kt @@ -7,6 +7,7 @@ import assertk.assertions.* import com.github.zeldigas.text2confl.convert.* import com.github.zeldigas.text2confl.convert.confluence.LanguageMappers import com.github.zeldigas.text2confl.convert.confluence.ReferenceProvider +import com.github.zeldigas.text2confl.convert.confluence.TestUserResolver import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Test @@ -164,7 +165,7 @@ internal class MarkdownFileConverterTest { val result = converter.convert( file, ConvertingContext( - ReferenceProvider.singleFile(), + ReferenceProvider.singleFile(), TestUserResolver, ConversionParameters( LanguageMappers.forServer(), titleTransformer(), docRootLocation = "http://example.com/", markdownConfiguration = MarkdownConfiguration(true, emptyList()), @@ -206,7 +207,7 @@ internal class MarkdownFileConverterTest { val result = converter.convert( file, ConvertingContext( - ReferenceProvider.singleFile(), ConversionParameters( + ReferenceProvider.singleFile(), TestUserResolver, ConversionParameters( LanguageMappers.forServer(), titleTransformer(), addAutogeneratedNote = false, markdownConfiguration = MarkdownConfiguration(true, emptyList()), editorVersion = EditorVersion.V1 @@ -264,7 +265,7 @@ internal class MarkdownFileConverterTest { val result = converter.convert( file, ConvertingContext( - ReferenceProvider.singleFile(), ConversionParameters( + ReferenceProvider.singleFile(), TestUserResolver, ConversionParameters( LanguageMappers.forServer(), titleTransformer(), addAutogeneratedNote = false, markdownConfiguration = MarkdownConfiguration(true, emptyList()), editorVersion = EditorVersion.V1 diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingTestBase.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingTestBase.kt index 211b3b98..1b087df7 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingTestBase.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingTestBase.kt @@ -6,6 +6,7 @@ import com.github.zeldigas.text2confl.convert.* import com.github.zeldigas.text2confl.convert.confluence.LanguageMapper import com.github.zeldigas.text2confl.convert.confluence.LanguageMapperImpl import com.github.zeldigas.text2confl.convert.confluence.ReferenceProvider +import com.github.zeldigas.text2confl.convert.confluence.TestUserResolver import com.github.zeldigas.text2confl.convert.markdown.diagram.DiagramMakers import kotlin.io.path.Path @@ -32,7 +33,7 @@ internal open class RenderingTestBase { pageAttributes: Map = emptyMap() ): String { val context = ConvertingContext( - referenceProvider, + referenceProvider, TestUserResolver, ConversionParameters( languageMapper ?: this.languageMapper, { _, title -> title }, diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt index e06b2df7..4a0f959c 100644 --- a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt @@ -5,6 +5,7 @@ import com.github.zeldigas.confclient.ConfluenceClientConfig import com.github.zeldigas.confclient.confluenceClient import com.github.zeldigas.confclient.confluenceClientV2 import com.github.zeldigas.text2confl.convert.* +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.github.zeldigas.text2confl.core.config.ConverterConfig import com.github.zeldigas.text2confl.core.config.UploadConfig import com.github.zeldigas.text2confl.core.export.PageExporter @@ -13,7 +14,7 @@ import com.github.zeldigas.text2confl.core.upload.DryRunClient import com.github.zeldigas.text2confl.core.upload.UploadOperationTracker interface ServiceProvider { - fun createConverter(space: String, config: ConverterConfig): Converter + fun createConverter(space: String, config: ConverterConfig, userResolver: UserResolver): Converter fun createConfluenceClient(clientConfig: ConfluenceClientConfig, dryRun: Boolean): ConfluenceClient fun createUploader( client: ConfluenceClient, @@ -27,7 +28,7 @@ interface ServiceProvider { } class ServiceProviderImpl : ServiceProvider { - override fun createConverter(space: String, config: ConverterConfig): Converter { + override fun createConverter(space: String, config: ConverterConfig, userResolver: UserResolver): Converter { return universalConverter( space = space, ConversionParameters( @@ -43,7 +44,8 @@ class ServiceProviderImpl : ServiceProvider { ?: (config.editorVersion == EditorVersion.V2), autoFixContentTags = config.autoFixContentTags, filesToIgnore = config.filesToIgnore - ) + ), + userResolver ) } diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt index 6bebab49..b2ed43cb 100644 --- a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt @@ -5,10 +5,14 @@ import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.benmanes.caffeine.cache.Caffeine import com.github.zeldigas.confclient.ConfluenceUserSearchClient +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.sksamuel.aedile.core.asLoadingCache +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.nio.file.Path import java.time.Instant @@ -50,18 +54,28 @@ class CloudUserResolver( knownUsers.forEach { (k, v) -> cache[k] = UserValue.Found(v) } } - override suspend fun resolveUser(email: String): String? { + override suspend fun resolveUser(email: String): UserResolver.UserReference? { val result = cache.get(email) - return if (result is UserValue.Found) result.user else null + return if (result is UserValue.Found) user(result) else null } - override suspend fun resolveUsers(users: List): Map = coroutineScope { - users.map { email -> async { email to resolveUser(email) } } - .awaitAll() - .mapNotNull { (email, id) -> id?.let { email to it } } + private fun user(result: UserValue.Found): UserResolver.UserReference = UserResolver.UserReference( + UserResolver.UserIdFormat.ACCOUNT_ID, result.user + ) + + override suspend fun resolveUsers(users: List): Map = coroutineScope { + cache.getAll(users) + .mapNotNull { (email, resolved) -> if (resolved is UserValue.Found) email to user(resolved) else null } .toMap() } + @OptIn(DelicateCoroutinesApi::class) + override fun registerReferencedUsers(email: List) { + GlobalScope.launch { + resolveUsers(email) + } + } + suspend fun cachedUsers(): Map = cache.asMap() .filterValues { it is UserValue.Found } .map { (key, value) -> key to (value as UserValue.Found).user } diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt index cb6eeef7..e10cb3d4 100644 --- a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt @@ -1,7 +1,18 @@ package com.github.zeldigas.text2confl.core.users +import com.github.zeldigas.text2confl.convert.confluence.UserResolver + class ServerUserResolver : UserResolver { - override suspend fun resolveUser(email: String): String = email + override suspend fun resolveUser(email: String): UserResolver.UserReference = user(email) + + private fun user(email: String): UserResolver.UserReference = UserResolver.UserReference( + format = UserResolver.UserIdFormat.USERNAME, value = email + ) + + override suspend fun resolveUsers(users: List): Map = + users.associateBy({ it }, { user(it) }) - override suspend fun resolveUsers(users: List): Map = users.associateBy({ it }, { it }) + override fun registerReferencedUsers(email: List) { + // nothing to do here + } } \ No newline at end of file diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/UserResolver.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/UserResolver.kt deleted file mode 100644 index fd1b010d..00000000 --- a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/UserResolver.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.zeldigas.text2confl.core.users - -import com.github.zeldigas.confclient.model.User - -interface UserResolver { - - suspend fun resolveUser(email: String): String? - - suspend fun resolveUsers(users: List): Map - -} \ No newline at end of file diff --git a/core/src/test/kotlin/com/github/zeldigas/text2confl/core/ServiceProviderImplTest.kt b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/ServiceProviderImplTest.kt index 6e42976f..52026703 100644 --- a/core/src/test/kotlin/com/github/zeldigas/text2confl/core/ServiceProviderImplTest.kt +++ b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/ServiceProviderImplTest.kt @@ -12,6 +12,7 @@ import com.github.zeldigas.text2confl.convert.ConversionParameters import com.github.zeldigas.text2confl.convert.Converter import com.github.zeldigas.text2confl.convert.EditorVersion import com.github.zeldigas.text2confl.convert.asciidoc.AsciidoctorConfiguration +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.github.zeldigas.text2confl.convert.markdown.MarkdownConfiguration import com.github.zeldigas.text2confl.convert.universalConverter import com.github.zeldigas.text2confl.core.config.Cleanup @@ -93,24 +94,25 @@ internal class ServiceProviderImplTest { } @Test - internal fun `Converter creation`(@MockK converter: Converter) { + internal fun `Converter creation`(@MockK converter: Converter, @MockK userResolver: UserResolver) { mockkStatic(::universalConverter) { - every { universalConverter("TEST", any()) } returns converter + every { universalConverter("TEST", any(), any()) } returns converter val result = provider.createConverter( - "TEST", ConverterConfig( + "TEST", + ConverterConfig( "pre", "post", EditorVersion.V1, null, "http://example.org/", "custom text", CodeBlockParams(), MarkdownConfiguration(), AsciidoctorConfiguration(), autoFixContentTags = false, filesToIgnore = emptyList() - ) + ), userResolver ) assertThat(result).isEqualTo(converter) val configSlot = slot() - verify { universalConverter("TEST", capture(configSlot)) } + verify { universalConverter("TEST", capture(configSlot), userResolver) } assertThat(configSlot.captured).all { prop(ConversionParameters::titleConverter).transform { it(Path("."), "Test") }.isEqualTo("preTestpost") diff --git a/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt index 6803c30a..c3c0947f 100644 --- a/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt +++ b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt @@ -4,6 +4,7 @@ import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isNull import com.github.zeldigas.confclient.ConfluenceUserSearchClient +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK @@ -24,7 +25,7 @@ class CloudUserResolverTest( val resolver = CloudUserResolver(client) coEvery { client.findUserIdByEmail("user@example.com") } returns "acc-123" - assertThat(resolver.resolveUser("user@example.com")).isEqualTo("acc-123") + assertThat(resolver.resolveUser("user@example.com")).isEqualTo(UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "acc-123")) } @Test @@ -55,7 +56,10 @@ class CloudUserResolverTest( val result = resolver.resolveUsers(listOf("a@example.com", "b@example.com", "c@example.com")) - assertThat(result).isEqualTo(mapOf("a@example.com" to "acc-a", "c@example.com" to "acc-c")) + assertThat(result).isEqualTo(mapOf( + "a@example.com" to UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "acc-a"), + "c@example.com" to UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "acc-c") + )) } @Test @@ -65,7 +69,7 @@ class CloudUserResolverTest( val results = (1..5).map { async { resolver.resolveUser("user@example.com") } }.awaitAll() - assertThat(results.distinct()).isEqualTo(listOf("acc-123")) + assertThat(results.distinct()).isEqualTo(listOf(UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "acc-123"))) coVerify(exactly = 1) { client.findUserIdByEmail("user@example.com") } } @@ -73,7 +77,7 @@ class CloudUserResolverTest( fun `known users are returned without calling client`() = runTest { val resolver = CloudUserResolver(client, knownUsers = mapOf("known@example.com" to "acc-known")) - assertThat(resolver.resolveUser("known@example.com")).isEqualTo("acc-known") + assertThat(resolver.resolveUser("known@example.com")).isEqualTo(UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "acc-known")) coVerify(exactly = 0) { client.findUserIdByEmail(any()) } } From c1f573d5055dbd6e7f91d9af42fcdfff599f9625 Mon Sep 17 00:00:00 2001 From: Dmitry Pavlov Date: Fri, 18 Sep 2026 09:55:22 +0300 Subject: [PATCH 6/7] Asciidoctor converter now properly resolves users --- .../convert/asciidoc/AsciidocFileConverter.kt | 1 + .../convert/asciidoc/AsciidocParser.kt | 2 + .../convert/asciidoc/AsciidocUserResolver.kt | 18 +++++++ .../text2confl/asciidocmacros/UserMacro.rb | 22 +++++++-- .../asciidoc/AsciidocFileConverterTest.kt | 1 + ...nderingOfConfluenceSpecificFeaturesTest.kt | 48 +++++++++++++++++++ .../convert/asciidoc/RenderingTestBase.kt | 3 ++ 7 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocUserResolver.kt diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt index f5bb5680..f7311ed2 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt @@ -77,6 +77,7 @@ class AsciidocFileConverter(private val asciidocParser: AsciidocParser, private context.conversionParameters.addAutogeneratedNote, context.targetSpace, AsciidocAttachmentCollector(file, AttachmentCollector(context.referenceProvider, registry), workdir), + AsciidocUserResolver(context.userResolver), codeBlocksInExpand = context.conversionParameters.codeBlocksInExpand, extraAttrs = mapOf( "outdir" to workdir.toString(), diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocParser.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocParser.kt index f9b5fc85..4e9b139b 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocParser.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocParser.kt @@ -62,6 +62,7 @@ class AsciidocParser( "t2c-space" to parameters.space, "t2c-decoder" to Converter, "t2c-code-in-expand" to parameters.codeBlocksInExpand, + "t2c-user-resolver" to parameters.userResolver, "idseparator" to "-", "idprefix" to "" ) + config.attributes + parameters.extraAttrs @@ -89,6 +90,7 @@ data class AsciidocRenderingParameters( val includeAutoText: Boolean, val space: String, val attachmentsCollector: AsciidocAttachmentCollector, + val userResolver: AsciidocUserResolver, val codeBlocksInExpand: Boolean, val extraAttrs: Map = emptyMap(), ) diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocUserResolver.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocUserResolver.kt new file mode 100644 index 00000000..e30d37a6 --- /dev/null +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocUserResolver.kt @@ -0,0 +1,18 @@ +package com.github.zeldigas.text2confl.convert.asciidoc + +import com.github.zeldigas.text2confl.convert.confluence.UserResolver +import kotlinx.coroutines.runBlocking + +class AsciidocUserResolver(val resolver: UserResolver) { + + fun resolve(name: String): Map? { + return runBlocking { resolver.resolveUser(name) }?.let { user -> + when (user.format) { + UserResolver.UserIdFormat.USERNAME -> mapOf("attr" to "ri:username", "value" to user.value) + UserResolver.UserIdFormat.ACCOUNT_ID -> mapOf("attr" to "ri:account-id", "value" to user.value) + } + } + + } + +} \ No newline at end of file diff --git a/convert/src/main/resources/com/github/zeldigas/text2confl/asciidocmacros/UserMacro.rb b/convert/src/main/resources/com/github/zeldigas/text2confl/asciidocmacros/UserMacro.rb index 29cf79fa..fd1e9165 100644 --- a/convert/src/main/resources/com/github/zeldigas/text2confl/asciidocmacros/UserMacro.rb +++ b/convert/src/main/resources/com/github/zeldigas/text2confl/asciidocmacros/UserMacro.rb @@ -8,10 +8,24 @@ class UserMacro < Asciidoctor::Extensions::InlineMacroProcessor # @param target [String] def process parent, target, attrs - if Asciidoctor::InlineEmailRx.match target - create_inline_pass parent, %() + resolver = parent.document.attr 't2c-user-resolver' + resolved_user = resolver.resolve target + if resolved_user.nil? + create_inline parent, :quoted, escape_autolink(target) else - create_inline_pass parent, %() + value = escape_autolink(resolved_user['value']) + user_link = %() + create_inline parent, :quoted, user_link end end -end \ No newline at end of file + + private + + # A bare email is auto-linked by Asciidoctor's own substitutions when it + # rescans this macro's output, corrupting ri:username/plain text. A leading + # backslash is Asciidoctor's escape convention: it suppresses the autolink + # and is itself stripped, leaving plain text. See commit a02cf03 (#135). + def escape_autolink value + Asciidoctor::InlineEmailRx.match(value) ? %(\\#{value}) : value + end +end diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt index d9a20c73..c51de92d 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt @@ -235,6 +235,7 @@ class AsciidocFileConverterTest { every { conversionParameters.addAutogeneratedNote } returns true every { conversionParameters.editorVersion } returns EditorVersion.V1 every { conversionParameters.codeBlocksInExpand } returns false + every { userResolver } returns TestUserResolver }) }.isInstanceOf(ConversionFailedException::class).all { hasCause(cause) diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingOfConfluenceSpecificFeaturesTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingOfConfluenceSpecificFeaturesTest.kt index 04309ac6..6dfb326d 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingOfConfluenceSpecificFeaturesTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingOfConfluenceSpecificFeaturesTest.kt @@ -1,10 +1,20 @@ package com.github.zeldigas.text2confl.convert.asciidoc import assertk.assertThat +import com.github.zeldigas.text2confl.convert.confluence.UserResolver import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource internal class RenderingOfConfluenceSpecificFeaturesTest : RenderingTestBase() { + private val unresolvedUserResolver = AsciidocUserResolver(object : UserResolver { + override suspend fun resolveUser(email: String): UserResolver.UserReference? = null + override suspend fun resolveUsers(users: List): Map = + emptyMap() + override fun registerReferencedUsers(email: List) {} + }) + @Test internal fun `Confluence status macro`() { val result = toHtml( @@ -56,6 +66,44 @@ internal class RenderingOfConfluenceSpecificFeaturesTest : RenderingTestBase() { ) } + @CsvSource( + value = [ + "unknown-user;unknown-user", + "unknown@example.com;unknown@example.com", + ], delimiter = ';' + ) + @ParameterizedTest + internal fun `Confluence user macro renders literal text when user is not resolved`(target: String, expected: String) { + val result = toHtml( + "Hello user:$target[].", + userResolver = unresolvedUserResolver + ) + + assertThat(result).isEqualToConfluenceFormat( + "

Hello $expected.

" + ) + } + + @Test + internal fun `Confluence user macro renders account id reference`() { + val resolver = object : UserResolver { + override suspend fun resolveUser(email: String) = + UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "acc-123") + override suspend fun resolveUsers(users: List): Map = + emptyMap() + override fun registerReferencedUsers(email: List) {} + } + + val result = toHtml( + "Hello user:someone[].", + userResolver = AsciidocUserResolver(resolver) + ) + + assertThat(result).isEqualToConfluenceFormat( + """

Hello .

""" + ) + } + @Test internal fun `Confluence user macro is ignored in code literal block`() { val result = toHtml( diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingTestBase.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingTestBase.kt index 28c7dbea..a53011e5 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingTestBase.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingTestBase.kt @@ -8,6 +8,7 @@ import com.github.zeldigas.text2confl.convert.AttachmentsRegistry import com.github.zeldigas.text2confl.convert.confluence.LanguageMapper import com.github.zeldigas.text2confl.convert.confluence.LanguageMapperImpl import com.github.zeldigas.text2confl.convert.confluence.ReferenceProvider +import com.github.zeldigas.text2confl.convert.confluence.TestUserResolver import kotlin.io.path.Path internal open class RenderingTestBase { @@ -35,6 +36,7 @@ internal open class RenderingTestBase { parser: AsciidocParser = DEFAULT_PARSER, attachmentsCollector: AsciidocAttachmentCollector? = null, referenceProvider: AsciidocReferenceProvider? = null, + userResolver: AsciidocUserResolver? = null, codeBlocksInExpand: Boolean = false, attributes: Map = emptyMap(), ): String { @@ -61,6 +63,7 @@ internal open class RenderingTestBase { addAutogenHeader, "TEST", effectiveCollector, + userResolver ?: AsciidocUserResolver(TestUserResolver), codeBlocksInExpand, extraAttrs = attributes, ) From 0be7721608ef6474b84b5034b1bd66682daf74a9 Mon Sep 17 00:00:00 2001 From: Dmitry Pavlov Date: Mon, 28 Sep 2026 10:46:03 +0300 Subject: [PATCH 7/7] Implemented user resolution for cloud 1. Caching resolved users 2. Documentation updates including newly identified scope to properly resolve users 3. Emitting warning on issues with user resolution instead failure 4. Removed feature about wrapping code blocks in expand --- .gitignore | 1 + CHANGELOG.md | 17 +++++- .../github/zeldigas/text2confl/cli/Upload.kt | 54 ++++++++++++++++--- .../zeldigas/text2confl/convert/Converter.kt | 1 - .../convert/asciidoc/AsciidocFileConverter.kt | 1 - .../convert/asciidoc/AsciidocParser.kt | 2 - .../ConfluenceStorageFormatConverters.kt | 15 +----- .../asciidoc/block_listing.html.slim | 11 +--- .../asciidoc/AsciidocFileConverterTest.kt | 1 - .../asciidoc/RenderingOfCodeBlocksTest.kt | 31 ----------- .../convert/asciidoc/RenderingTestBase.kt | 2 - .../markdown/RenderingOfCodeBlocksTest.kt | 33 ------------ .../convert/markdown/RenderingTestBase.kt | 2 - .../text2confl/core/ServiceProvider.kt | 2 - .../text2confl/core/config/DirectoryConfig.kt | 8 ++- .../core/export/ConfluenceUserResolverImpl.kt | 15 +++++- .../core/users/CloudUserResolver.kt | 50 ++++++++++++----- .../export/ConfluenceUserResolverImplTest.kt | 27 ++++++++++ .../core/users/CloudUserResolverTest.kt | 13 +++++ docs/.text2confl.yml | 2 - docs/config.schema.json | 9 ++-- docs/explanation/how-publishing-works.md | 7 ++- .../how-to/authenticate-with-scoped-tokens.md | 5 ++ docs/how-to/export-to-markdown.md | 12 +++++ docs/how-to/troubleshoot-conversion.md | 7 +++ .../asciidoc/confluence-specific.adoc | 24 +++++++-- docs/reference/asciidoc/includes.adoc | 2 +- docs/reference/asciidoc/links-images.adoc | 6 +-- docs/reference/code-blocks.adoc | 2 +- docs/reference/configuration.adoc | 16 +++--- .../reference/markdown/confluence-specific.md | 27 ++++++++-- .../first-upload-cloud-scoped-tokens.adoc | 1 + 32 files changed, 255 insertions(+), 151 deletions(-) create mode 100644 core/src/test/kotlin/com/github/zeldigas/text2confl/core/export/ConfluenceUserResolverImplTest.kt diff --git a/.gitignore b/.gitignore index ce26f81c..143bfa80 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ target .asciidoctor .asciidoc .diagrams +.text2confl out \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e08a3913..bc8851dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,25 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- \[export-to-md] now supports `note` panel from Confluence cloud (without customizations for now) +- User mentions in Confluence Cloud. Use user's email as a reference: `@"someuser@example.com"` in Markdown or + `user:someuser@example.com[]` in AsciiDoc. During `upload` email is resolved to the Cloud account id; if user can't + be found (e.g. email is hidden by profile visibility settings), reference is rendered as plain text. + Scoped tokens need `read:content-details:confluence` scope for this. +- `t2c-dir` configuration parameter and `--t2c-dir` option of `upload` command - directory for text2confl work files + (`.text2confl` in docs root by default). Currently used to cache resolved Confluence Cloud users (`users.json`), so + consider adding it to `.gitignore`. +- \[export-to-md] now supports `note` panel from Confluence Cloud (without customizations for now) ### Fixed -- \[export-to-md] for pages with user references (#338) +- \[export-to-md] export of pages with user references (#338), including Confluence Cloud references by account id + (scoped tokens need `read:content-details:confluence` scope) +### Removed + +- `code-blocks.collapse-with-expand` configuration (introduced in 0.19.0 to address #268), since Confluence Cloud now + properly supports collapsible code blocks (#362). Code blocks with `collapse=true` are no longer wrapped in expand + macro and use native collapse of code block instead. The parameter is ignored if still present in configuration. ## 0.27.1 - 2026-09-18 diff --git a/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt b/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt index 44cdc77e..0cfb8790 100644 --- a/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt +++ b/cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt @@ -1,10 +1,14 @@ package com.github.zeldigas.text2confl.cli import PrintingUploadOperationsTracker -import com.github.ajalt.clikt.core.* +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.requireObject +import com.github.ajalt.clikt.core.terminal import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.enum +import com.github.ajalt.clikt.parameters.types.file import com.github.zeldigas.confclient.ConfluenceClient import com.github.zeldigas.confclient.ConfluenceClientConfig import com.github.zeldigas.confclient.ConfluenceUserSearchClient @@ -15,14 +19,18 @@ import com.github.zeldigas.text2confl.core.ServiceProvider import com.github.zeldigas.text2confl.core.config.* import com.github.zeldigas.text2confl.core.upload.ChangeDetector import com.github.zeldigas.text2confl.core.upload.UploadOperationTracker -import com.github.zeldigas.text2confl.core.users.CloudUserResolver import com.github.zeldigas.text2confl.core.users.ServerUserResolver +import com.github.zeldigas.text2confl.core.users.createFromStoredData +import com.github.zeldigas.text2confl.core.users.persistResolvedUsers +import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.plugins.logging.* import io.ktor.http.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.File +import java.nio.file.Path +import kotlin.io.path.div class Upload : CliktCommand(name = "upload"), WithConversionOptions, WithConfluenceServerOptions { @@ -65,9 +73,12 @@ class Upload : CliktCommand(name = "upload"), ).optionalFlag("--no-notify-watchers") private val dryRun: Boolean by option("--dry", help = "Enables dry run simulation of documents upload") .flag("--no-dry") + private val t2cDir: File? by option("--t2c-dir", help = "Path to directory with t2c work files") + .file(canBeFile = false) override val editorVersion: EditorVersion? by editorVersion() override val autoFixContent: Boolean? by autoFixContentFlag() private val docs: File by docsLocation() + private val postProcessActions: MutableList<() -> Unit> = mutableListOf() private val serviceProvider: ServiceProvider by requireObject() @@ -79,11 +90,14 @@ class Upload : CliktCommand(name = "upload"), tryUpload() } catch (ex: Exception) { tryHandleException(ex) + } finally { + runPostProcessActions() } } private suspend fun tryUpload() { val directoryStoredParams = readDirectoryConfig(docs.toPath()) + val t2cWorkDir = directoryStoredParams.resolveText2ConflDir(t2cDir?.toPath()) val uploadConfig = createUploadConfig(directoryStoredParams) val clientConfig = createClientConfig(directoryStoredParams) val conversionConfig = createConversionConfig( @@ -93,7 +107,11 @@ class Upload : CliktCommand(name = "upload"), autoFixContent ) val confluenceClient = serviceProvider.createConfluenceClient(clientConfig, dryRun) - val converter = serviceProvider.createConverter(uploadConfig.space, conversionConfig, createUserResolver(confluenceClient)) + val converter = serviceProvider.createConverter( + uploadConfig.space, + conversionConfig, + createUserResolver(confluenceClient, t2cWorkDir) + ) val pagesToPublish = if (docs.isFile) { listOf(converter.convertFile(docs.toPath())) } else { @@ -103,16 +121,26 @@ class Upload : CliktCommand(name = "upload"), contentValidator.validate(pagesToPublish) val publishUnder = resolveParent(confluenceClient, uploadConfig, directoryStoredParams) - val contentUploader = serviceProvider.createUploader(confluenceClient, uploadConfig, conversionConfig, operationsTracker(clientConfig.server)) + val contentUploader = serviceProvider.createUploader( + confluenceClient, + uploadConfig, + conversionConfig, + operationsTracker(clientConfig.server) + ) withContext(Dispatchers.Default) { contentValidator.checkNoClashWithParent(publishUnder, pagesToPublish) contentUploader.uploadPages(pages = pagesToPublish, uploadConfig.space, publishUnder.id) } } - private fun createUserResolver(confluenceClient: ConfluenceClient): UserResolver { + private fun createUserResolver(confluenceClient: ConfluenceClient, t2cWorkDir: Path): UserResolver { + val userCache = t2cWorkDir / "users.json" return if (confluenceClient is ConfluenceUserSearchClient) { - CloudUserResolver(confluenceClient) + val resolver = createFromStoredData(confluenceClient, userCache) + postProcessActions.add { + persistResolvedUsers(resolver, userCache) + } + resolver } else { ServerUserResolver() } @@ -162,4 +190,18 @@ class Upload : CliktCommand(name = "upload"), return confluenceClient.describeSpace(uploadConfig.space).homepage!! } + + private fun runPostProcessActions() { + for (action in postProcessActions) { + try { + action() + } catch (ex: Exception) { + logger.warn(ex) { "Failed to invoke post process action" } + } + } + } + + private companion object { + val logger = KotlinLogging.logger { } + } } \ No newline at end of file diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/Converter.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/Converter.kt index 60681cef..37f1528f 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/Converter.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/Converter.kt @@ -45,7 +45,6 @@ data class ConversionParameters( val markdownConfiguration: MarkdownConfiguration = MarkdownConfiguration(), val asciidoctorConfiguration: AsciidoctorConfiguration = AsciidoctorConfiguration(), val editorVersion: EditorVersion, - val codeBlocksInExpand: Boolean = false, val autoFixContentTags: Boolean = false, val filesToIgnore: List = emptyList(), ) diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt index f7311ed2..0bdc2dec 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt @@ -78,7 +78,6 @@ class AsciidocFileConverter(private val asciidocParser: AsciidocParser, private context.targetSpace, AsciidocAttachmentCollector(file, AttachmentCollector(context.referenceProvider, registry), workdir), AsciidocUserResolver(context.userResolver), - codeBlocksInExpand = context.conversionParameters.codeBlocksInExpand, extraAttrs = mapOf( "outdir" to workdir.toString(), "imagesoutdir" to workdir.toString(), diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocParser.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocParser.kt index 4e9b139b..5ac16e63 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocParser.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocParser.kt @@ -61,7 +61,6 @@ class AsciidocParser( "t2c-attachments-collector" to parameters.attachmentsCollector, "t2c-space" to parameters.space, "t2c-decoder" to Converter, - "t2c-code-in-expand" to parameters.codeBlocksInExpand, "t2c-user-resolver" to parameters.userResolver, "idseparator" to "-", "idprefix" to "" @@ -91,7 +90,6 @@ data class AsciidocRenderingParameters( val space: String, val attachmentsCollector: AsciidocAttachmentCollector, val userResolver: AsciidocUserResolver, - val codeBlocksInExpand: Boolean, val extraAttrs: Map = emptyMap(), ) diff --git a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/ConfluenceStorageFormatConverters.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/ConfluenceStorageFormatConverters.kt index eb0184ef..3846fcba 100644 --- a/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/ConfluenceStorageFormatConverters.kt +++ b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/markdown/ConfluenceStorageFormatConverters.kt @@ -175,20 +175,7 @@ class ConfluenceNodeRenderer(options: DataHolder) : PhasedNodeRenderer, Attribut private fun render(node: FencedCodeBlock, context: NodeRendererContext, html: HtmlWriter) { html.line() - if (convertingContext.conversionParameters.codeBlocksInExpand && "true" == node.attributesMap["collapse"]) { - html.tag("ac:structured-macro", mapOf("ac:name" to "expand")) { - node.attributesMap["title"]?.let { - html.addParameter("title", it) - } - html.tag("ac:rich-text-body") { - html.line() - generateCodeBlock(node, context, html) - html.line() - } - } - } else { - generateCodeBlock(node, context, html) - } + generateCodeBlock(node, context, html) } private fun generateCodeBlock(node: FencedCodeBlock, context: NodeRendererContext, html: HtmlWriter) { diff --git a/convert/src/main/resources/com/github/zeldigas/text2confl/asciidoc/block_listing.html.slim b/convert/src/main/resources/com/github/zeldigas/text2confl/asciidoc/block_listing.html.slim index 7464dd28..20fff809 100644 --- a/convert/src/main/resources/com/github/zeldigas/text2confl/asciidoc/block_listing.html.slim +++ b/convert/src/main/resources/com/github/zeldigas/text2confl/asciidoc/block_listing.html.slim @@ -1,14 +1,5 @@ - if style == 'source' - - if (attr? :collapse, 'true') and (document.attr? 't2c-code-in-expand') - ac:structured-macro ac:name="expand" - - if title? - ac:parameter ac:name="title" #{title} - ac:rich-text-body - = line_break - include _code_block.html - = line_break - - else - include _code_block.html + include _code_block.html - else ac:structured-macro ac:name="noformat" - if title? diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt index c51de92d..78e09beb 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverterTest.kt @@ -234,7 +234,6 @@ class AsciidocFileConverterTest { every { targetSpace } returns "SPACE" every { conversionParameters.addAutogeneratedNote } returns true every { conversionParameters.editorVersion } returns EditorVersion.V1 - every { conversionParameters.codeBlocksInExpand } returns false every { userResolver } returns TestUserResolver }) }.isInstanceOf(ConversionFailedException::class).all { diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingOfCodeBlocksTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingOfCodeBlocksTest.kt index ba686e42..8514b614 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingOfCodeBlocksTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingOfCodeBlocksTest.kt @@ -125,37 +125,6 @@ internal class RenderingOfCodeBlocksTest : RenderingTestBase() { assertThat(result).codeBlockWithAttributes() } - @Test - internal fun `Source code block with collapse attribute is wrapped in expand if configured`() { - fun Assert.codeBlockWithAttributes() { - val codeBlock = - """kotlin""" + - """hello.kt & world""" + - """true""" + - """true""" + - """3""" + - """Eclipse""" + - """""" - isEqualToConfluenceFormat( - """ - hello.kt & world - ${codeBlock} - """.trimIndent() - ) - } - - val result = toHtml( - """ - [,kotlin,collapse,linenums,start=3,collapse=true,theme=Eclipse,title="hello.kt & world"] - ---- - println("Hello") - ---- - """.trimIndent(), - codeBlocksInExpand = true - ) - assertThat(result).codeBlockWithAttributes() - } - @Test internal fun `Listing block is rendered as pretext`() { val result = toHtml( diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingTestBase.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingTestBase.kt index a53011e5..fa3655c8 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingTestBase.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/RenderingTestBase.kt @@ -37,7 +37,6 @@ internal open class RenderingTestBase { attachmentsCollector: AsciidocAttachmentCollector? = null, referenceProvider: AsciidocReferenceProvider? = null, userResolver: AsciidocUserResolver? = null, - codeBlocksInExpand: Boolean = false, attributes: Map = emptyMap(), ): String { val defaultSource = Path("./test.adoc") @@ -64,7 +63,6 @@ internal open class RenderingTestBase { "TEST", effectiveCollector, userResolver ?: AsciidocUserResolver(TestUserResolver), - codeBlocksInExpand, extraAttrs = attributes, ) ) diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingOfCodeBlocksTest.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingOfCodeBlocksTest.kt index 378e404e..96ee6891 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingOfCodeBlocksTest.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingOfCodeBlocksTest.kt @@ -128,39 +128,6 @@ internal class RenderingOfCodeBlocksTest : RenderingTestBase() { assertThat(result1).codeBlockWithAttributes() } - @Test - internal fun `Fenced code block with attributes wrapped in expand if enabled`() { - fun Assert.codeBlockWithAttributes() { - val codeBlock = - ("""kotlin""" - + """hello.kt & world""" - + """true""" - + """true""" - + """3""" - + """Eclipse""" - + """""") - - isEqualToConfluenceFormat( - """ - hello.kt & world - $codeBlock - - """.trimIndent() - ) - } - - val attributes = - """{title="hello.kt & world" collapse=true linenumbers=true firstline=3 theme=Eclipse unknown=yes}""" - val result = toHtml( - """ - ```kotlin $attributes - println("Hello") - ``` - """.trimIndent(), - codeBlockInExpand = true - ) - assertThat(result).codeBlockWithAttributes() - } } diff --git a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingTestBase.kt b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingTestBase.kt index 1b087df7..0c2edf01 100644 --- a/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingTestBase.kt +++ b/convert/src/test/kotlin/com/github/zeldigas/text2confl/convert/markdown/RenderingTestBase.kt @@ -28,7 +28,6 @@ internal open class RenderingTestBase { autogenText: String = "Generated for __doc-root____file__", config: MarkdownConfiguration = MarkdownConfiguration(true, emptyList()), diagramMakers: DiagramMakers = DiagramMakers.NOP, - codeBlockInExpand: Boolean = false, editorVersion: EditorVersion = EditorVersion.V1, pageAttributes: Map = emptyMap() ): String { @@ -42,7 +41,6 @@ internal open class RenderingTestBase { docRootLocation = "http://example.com/", markdownConfiguration = config, editorVersion = editorVersion, - codeBlocksInExpand = codeBlockInExpand, ), "TEST", ) diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt index 4a0f959c..97db0aba 100644 --- a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt @@ -40,8 +40,6 @@ class ServiceProviderImpl : ServiceProvider { markdownConfiguration = config.markdownConfig, asciidoctorConfiguration = config.asciidocConfig, editorVersion = config.editorVersion, - codeBlocksInExpand = config.codeBlockParams.collapseWithExpand - ?: (config.editorVersion == EditorVersion.V2), autoFixContentTags = config.autoFixContentTags, filesToIgnore = config.filesToIgnore ), diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/config/DirectoryConfig.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/config/DirectoryConfig.kt index 867e82db..6b7dad75 100644 --- a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/config/DirectoryConfig.kt +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/config/DirectoryConfig.kt @@ -10,6 +10,8 @@ import kotlin.io.path.Path import kotlin.io.path.createTempDirectory import kotlin.io.path.div +const val DEFAULT_T2C_DIR = ".text2confl" + /** * Holder of data that can be put to `.text2confl.yml` configuration file that is located in root directory of directory structure */ @@ -35,14 +37,18 @@ data class DirectoryConfig( val asciidoc: AsciidocParams = AsciidocParams(), val client: HttpClientParams = HttpClientParams(), val additionalFilesToIgnore: List = emptyList(), + val t2cDir: String? = null ) { lateinit var docsDir: Path + + fun resolveText2ConflDir(location: Path? = null): Path { + return docsDir.resolve(location ?: Path(t2cDir ?: DEFAULT_T2C_DIR)) + } } data class CodeBlockParams( val defaultLanguage: String? = null, val extraMapping: Map = emptyMap(), - val collapseWithExpand: Boolean? = null ) data class MarkdownParams( 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 index e818f3d6..2fb1fb02 100644 --- 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 @@ -1,7 +1,9 @@ package com.github.zeldigas.text2confl.core.export +import com.github.zeldigas.confclient.ConfluenceAuthorizationException import com.github.zeldigas.confclient.ConfluenceClient import com.github.zeldigas.text2confl.convert.markdown.export.ConfluenceUserResolver +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.runBlocking class ConfluenceUserResolverImpl(private val client: ConfluenceClient) : ConfluenceUserResolver { @@ -11,10 +13,19 @@ class ConfluenceUserResolverImpl(private val client: ConfluenceClient) : Conflue override fun resolve(userKey: String): String? { return cache.computeIfAbsent(userKey) { key -> runBlocking { - val user = client.getUserByKey(key) - user.username ?: user.email + try { + val user = client.getUserByKey(key) + user.username ?: user.email + } catch (e: ConfluenceAuthorizationException) { + logger.warn(e) { "Not enough permissions to resolve user by key" } + null + } } } } + private companion object { + val logger = KotlinLogging.logger { } + } + } \ No newline at end of file diff --git a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt index b2ed43cb..e2bdd86f 100644 --- a/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt @@ -2,44 +2,59 @@ package com.github.zeldigas.text2confl.core.users import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.benmanes.caffeine.cache.Caffeine +import com.github.zeldigas.confclient.ConfluenceAuthorizationException import com.github.zeldigas.confclient.ConfluenceUserSearchClient import com.github.zeldigas.text2confl.convert.confluence.UserResolver import com.sksamuel.aedile.core.asLoadingCache -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.* import java.nio.file.Path import java.time.Instant -import kotlin.io.path.inputStream -import kotlin.io.path.outputStream +import kotlin.io.path.* private val MAPPER = jacksonObjectMapper() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .registerModule(JavaTimeModule()) fun createFromStoredData( client: ConfluenceUserSearchClient, fileWithCache: Path ): CloudUserResolver { - val data = fileWithCache.inputStream().use { - MAPPER.readValue(it, ResolvedUsers::class.java) + if (fileWithCache.exists()) { + val data = loadUsersOrEmpty(fileWithCache) + return CloudUserResolver(client, data?.users ?: emptyMap()) + } else { + return CloudUserResolver(client) } - return CloudUserResolver(client, data.users) } fun persistResolvedUsers(users: CloudUserResolver, destination: Path) { val usersData = runBlocking { users.cachedUsers() } + if (!destination.parent.exists()) { + destination.parent.createDirectories() + } + val storedUsers = loadUsersOrEmpty(destination) + if (storedUsers != null && storedUsers.users == usersData) return destination.outputStream().use { MAPPER.writeValue(it, ResolvedUsers(usersData, Instant.now())) } } +private fun loadUsersOrEmpty(fileWithCache: Path): ResolvedUsers? { + return try { + fileWithCache.inputStream().use { + MAPPER.readValue(it, ResolvedUsers::class.java) + } + } catch (_: Exception) { + fileWithCache.deleteExisting() + null + } +} + class CloudUserResolver( private val client: ConfluenceUserSearchClient, knownUsers: Map = emptyMap() @@ -47,7 +62,12 @@ class CloudUserResolver( private val cache = Caffeine.newBuilder() .asLoadingCache { email: String -> - client.findUserIdByEmail(email)?.let { UserValue.Found(it) } ?: UserValue.Missing + try { + client.findUserIdByEmail(email)?.let { UserValue.Found(it) } ?: UserValue.Missing + } catch (e: ConfluenceAuthorizationException) { + logger.warn(e) { "Not enough permissions to resolve user: $email" } + UserValue.Missing + } } init { @@ -85,6 +105,10 @@ class CloudUserResolver( object Missing : UserValue() data class Found(val user: String) : UserValue() } + + private companion object { + val logger = KotlinLogging.logger { } + } } data class ResolvedUsers(val users: Map, val dateCreated: Instant) \ No newline at end of file diff --git a/core/src/test/kotlin/com/github/zeldigas/text2confl/core/export/ConfluenceUserResolverImplTest.kt b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/export/ConfluenceUserResolverImplTest.kt new file mode 100644 index 00000000..65c3e407 --- /dev/null +++ b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/export/ConfluenceUserResolverImplTest.kt @@ -0,0 +1,27 @@ +package com.github.zeldigas.text2confl.core.export + +import assertk.assertThat +import assertk.assertions.isNull +import com.github.zeldigas.confclient.ConfluenceAuthorizationException +import com.github.zeldigas.confclient.ConfluenceClient +import com.github.zeldigas.confclient.RequestDetails +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +class ConfluenceUserResolverImplTest { + + @Test + fun `Unresolved user when not authorized to read users`(@MockK client: ConfluenceClient) { + coEvery { client.getUserByKey("acc-123") } throws ConfluenceAuthorizationException( + RequestDetails("GET", "/rest/api/user"), 401, emptyMap(), "Unauthorized; scope does not match" + ) + + val resolver = ConfluenceUserResolverImpl(client) + + assertThat(resolver.resolve("acc-123")).isNull() + } +} diff --git a/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt index c3c0947f..d0b71b80 100644 --- a/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt +++ b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt @@ -3,7 +3,9 @@ package com.github.zeldigas.text2confl.core.users import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isNull +import com.github.zeldigas.confclient.ConfluenceAuthorizationException import com.github.zeldigas.confclient.ConfluenceUserSearchClient +import com.github.zeldigas.confclient.RequestDetails import com.github.zeldigas.text2confl.convert.confluence.UserResolver import io.mockk.coEvery import io.mockk.coVerify @@ -36,6 +38,17 @@ class CloudUserResolverTest( assertThat(resolver.resolveUser("missing@example.com")).isNull() } + @Test + fun `resolveUser returns null when not authorized to search users`() = runTest { + val resolver = CloudUserResolver(client) + coEvery { client.findUserIdByEmail("user@example.com") } throws ConfluenceAuthorizationException( + RequestDetails("GET", "/rest/api/search/user"), 401, emptyMap(), "Unauthorized; scope does not match" + ) + + assertThat(resolver.resolveUser("user@example.com")).isNull() + assertThat(resolver.cachedUsers()).isEqualTo(emptyMap()) + } + @Test fun `resolveUser caches result and calls client only once for repeated lookups`() = runTest { val resolver = CloudUserResolver(client) diff --git a/docs/.text2confl.yml b/docs/.text2confl.yml index ada35c1e..b4b689fc 100644 --- a/docs/.text2confl.yml +++ b/docs/.text2confl.yml @@ -3,8 +3,6 @@ server: https://text2conf.atlassian.net/wiki space: docs docs-location: https://github.com/zeldigas/text2confl/tree/master/docs/ title-prefix: "[text2confl] " -code-blocks: - collapse-with-expand: false additional-files-to-ignore: - CLAUDE.md - AGENTS.md \ No newline at end of file diff --git a/docs/config.schema.json b/docs/config.schema.json index 8fe6fe85..9c71bce3 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -86,6 +86,11 @@ "type": "string", "description": "Tenant identifier for multi-tenant setups" }, + "t2c-dir": { + "type": "string", + "default": ".text2confl", + "description": "Directory for text2confl work files (e.g. cached results of user resolution in Confluence Cloud). Relative path is resolved from docs root directory" + }, "code-blocks": { "$ref": "#/definitions/CodeBlockParams" }, @@ -116,10 +121,6 @@ } }, "description": "Additional mappings for language aliases. Use when to map language tags to values known by Confluence" - }, - "collapse-with-expand": { - "type": "boolean", - "description": "Whether to wrap collapsible code blocks into expand macro. Activated by default for Confluence Cloud since code blocks in v2 editor does not support collapsing" } }, "additionalProperties": false diff --git a/docs/explanation/how-publishing-works.md b/docs/explanation/how-publishing-works.md index 02c872fe..7d3728a6 100644 --- a/docs/explanation/how-publishing-works.md +++ b/docs/explanation/how-publishing-works.md @@ -84,6 +84,11 @@ Asciidoctor's default HTML output templates and emit CSF XML directly. Diagrams Both paths produce a `PageContent` object containing the CSF XML body and a list of attachments (files to be uploaded alongside the page). +User mentions are resolved during conversion. For Confluence Server/Data Center, the reference is used as a username +as is. For Confluence Cloud (on `upload` only), the email in the reference is looked up via Confluence API to get the +account id. Resolved users are cached in the text2confl work directory (`.text2confl/users.json` by default), so only +new users trigger API calls on subsequent runs. + ## Step 4 - Optional: auto-fix If `auto-fix-content-tags: true` is configured, text2confl runs the converted body through [Jsoup](https://jsoup.org/) @@ -92,7 +97,7 @@ XML. See [Content auto-fix](./auto-fix-content.md) for the tradeoffs. ## Step 5 - XML validation -text2confl validates the CSF XML before any network calls. This catches converter bugs and malformed raw HTML snippets +text2confl validates the CSF XML before any page-related network calls. This catches converter bugs and malformed raw HTML snippets early, giving a clear error message pointing to the problematic file rather than a cryptic Confluence API error. ## Step 6 - Change detection diff --git a/docs/how-to/authenticate-with-scoped-tokens.md b/docs/how-to/authenticate-with-scoped-tokens.md index 21566f30..23eaf405 100644 --- a/docs/how-to/authenticate-with-scoped-tokens.md +++ b/docs/how-to/authenticate-with-scoped-tokens.md @@ -64,6 +64,7 @@ used for classic API tokens. Create a new token and select **Scoped** when promp | Read | `read:hierarchical-content:confluence` | | Read | `read:attachment:confluence` | | Read | `read:label:confluence` | +| Read | `read:content-details:confluence` | | Write | `write:page:confluence` | | Write | `write:label:confluence` | | Write | `write:confluence-file` | @@ -81,6 +82,10 @@ Save the generated access token. > **Note:** `write:page:confluence` covers page property operations — no separate property scope is needed. `write:confluence-file` is a classic scope (no granular equivalent) and covers attachment uploads. +`read:content-details:confluence` is needed to resolve users: by email for +[user mentions](../reference/markdown/confluence-specific.md#mentioning-user) during upload, and by account id during +[export to Markdown](./export-to-markdown.md). Without it, text2confl logs a warning, renders user mentions as plain text +on upload and skips them on export. ## Step 4: Pass the access token diff --git a/docs/how-to/export-to-markdown.md b/docs/how-to/export-to-markdown.md index 48753e70..b9721957 100644 --- a/docs/how-to/export-to-markdown.md +++ b/docs/how-to/export-to-markdown.md @@ -61,6 +61,18 @@ text2confl export-to-md \ --dump-also-storage-format ``` +## What gets converted + +Most of the regular formatting is converted to standard Markdown. Some Confluence-specific elements are converted to +text2confl [Markdown extensions](../reference/markdown/confluence-specific.md): + +- **User mentions** are exported as `@username` for Confluence Server/Data Center and as `@"email"` for Confluence + Cloud. In Cloud the email is available only if user's profile visibility settings allow it for the account used for + export; otherwise the mention is skipped. Scoped tokens need `read:content-details:confluence` scope to look up + users; without it a warning is logged and mentions are skipped. +- **Info, note, tip, warning panels** are exported as admonitions (`!!! note`). This also works for panels created in + Confluence Cloud editor, but panel customizations (custom colors, icons) are not preserved. + ## Authentication `export-to-md` accepts the same authentication flags as the `upload` command. diff --git a/docs/how-to/troubleshoot-conversion.md b/docs/how-to/troubleshoot-conversion.md index 067469af..b2a274ce 100644 --- a/docs/how-to/troubleshoot-conversion.md +++ b/docs/how-to/troubleshoot-conversion.md @@ -32,6 +32,13 @@ text2confl convert --file my-page.md --editor-version v1 The editor version affects table rendering, list item markup, and image attributes - so a mismatch can produce output that looks correct locally but renders differently in Confluence. +## User mentions + +`convert` does not connect to Confluence, so user mentions are always rendered with `ri:username`, as used by +Confluence Server/Data Center. During `upload` to Confluence Cloud, the same mentions are resolved to account ids by +email, and mentions of users that can't be found are rendered as plain text. See +[Mentioning user](../reference/markdown/confluence-specific.md#mentioning-user) for details. + ## See also - [Configuration reference](../reference/configuration.adoc) - `convert` command options and output directory setting diff --git a/docs/reference/asciidoc/confluence-specific.adoc b/docs/reference/asciidoc/confluence-specific.adoc index d59839d9..676213a1 100644 --- a/docs/reference/asciidoc/confluence-specific.adoc +++ b/docs/reference/asciidoc/confluence-specific.adoc @@ -15,14 +15,28 @@ To insert it, use custom macro `+status:$color[$text_of_status]+`, where `$color NOTE: Only limited colors are supported, and you need to properly specify one of the following allowed values: `grey` , `red`, `green`, `purple`, `blue`. -== Mentioning user macro (Confluence Server only) +== Mentioning user macro -You can mention user using `+user:username[]+` macro, where `username` is your login in Confluence. -If you use email to log in, use it in macro: for example login \jsmith@example.org should be used as `+user:jsmith@example.org+[]`. +You can mention user using `+user:reference[]+` macro. What should be used as a reference depends on Confluence edition: -Unfortunately due to absence of human-readable usernames in Cloud edition this will work only on Server/Datacenter where human-readable usernames are still supported. +[cols="1,1,2",options="header"] +|=== +| Confluence edition | Reference | Example + +| Server/Data Center +| Username (login) +| `+user:jsmith[]+`, or `+user:jsmith@example.org[]+` if you use email to log in + +| Cloud +| User email +| `+user:jsmith@example.org[]+` +|=== + +In Confluence Cloud users don't have human-readable usernames, so during `upload` *text2confl* looks up the account id of the user by email. +If the user can't be found (e.g. email is not visible to the account used for upload because of user's profile visibility settings, or a scoped token lacks `read:content-details:confluence` scope), a warning is logged, the reference is rendered as plain text and no mention is created. +Resolved users are cached in the text2confl work directory (see `t2c-dir` in xref:../configuration.adoc[configuration reference]), so users are not looked up again on subsequent uploads. -If you still need to mention user in Cloud, consider using <> +NOTE: Resolution of users happens only during `upload`. The `convert` command does not connect to Confluence and always renders references as usernames. == Putting date diff --git a/docs/reference/asciidoc/includes.adoc b/docs/reference/asciidoc/includes.adoc index 55b5c40e..a9a047e4 100644 --- a/docs/reference/asciidoc/includes.adoc +++ b/docs/reference/asciidoc/includes.adoc @@ -10,7 +10,7 @@ link:{adocs}[Include functionality] of AsciiDoc is supported as expected - inclu If you don't want to publish included page on its own, prefix it with `+_+` symbol, e.g. `+_included_page.adoc+`, because *text2confl* ignores files prefixed with `+_+` ==== -=== Example +== Example If you have a file [source,asciidoc,subs="none"] diff --git a/docs/reference/asciidoc/links-images.adoc b/docs/reference/asciidoc/links-images.adoc index aabe9fdd..90c302d0 100644 --- a/docs/reference/asciidoc/links-images.adoc +++ b/docs/reference/asciidoc/links-images.adoc @@ -12,10 +12,10 @@ Links of different types supported |External | `+https://example.org[external]+` | https://example.org[external] |Cross-link to another page | `+xref:../markdown.md[Markdown]+` | xref:../markdown.md[Markdown] -|Cross-link to anchor on another page | `+xref:../markdown.md#sSupported-features[anchors inside page]+` | xref:../markdown.md#sSupported-features[anchors inside page] +|Cross-link to anchor on another page | `+xref:../markdown.md#supported-features[anchors inside page]+` | xref:../markdown.md#supported-features[anchors inside page] |Link to file with supported format works same as `xref` -| `+link:../../storage-formats.md#markdown[same as previous table row]+` -| link:../../storage-formats.md#markdown[same as previous table row] +| `+link:../markdown.md#supported-features[same as previous table row]+` +| link:../markdown.md#supported-features[same as previous table row] |=== == Images diff --git a/docs/reference/code-blocks.adoc b/docs/reference/code-blocks.adoc index b0645442..040cf758 100644 --- a/docs/reference/code-blocks.adoc +++ b/docs/reference/code-blocks.adoc @@ -21,7 +21,7 @@ See xref:#language-tags[Language tags] below for the full list. | `collapse` | Controls whether the code block is collapsed by default. Allowed values: `true`, `false` | ✅ -| WARNING: Only if editor v1 is forcibly set +| ✅ | `linenumbers` | Controls whether line numbers are displayed. Allowed values: `true`, `false` diff --git a/docs/reference/configuration.adoc b/docs/reference/configuration.adoc index 7fd4d078..088d072e 100644 --- a/docs/reference/configuration.adoc +++ b/docs/reference/configuration.adoc @@ -102,6 +102,16 @@ See xref:#modifications-check-strategies[Modifications check strategies] below | | Tenant id for uploaded pages. See xref:../how-to/configure-multi-tenancy.md[page about multi-tenancy] +| t2c-dir +| `--t2c-dir` +| +| Directory for text2confl work files. Default is `.text2confl`, relative path is resolved from docs root directory. + +Currently used to cache results of user resolution in Confluence Cloud (`users.json`), so that mentioned users are not +looked up on every upload. Content of this directory is not meant to be committed, consider adding it to `.gitignore`. + +NOTE: CLI option is available only for `upload` command + | docs-location | | @@ -147,12 +157,6 @@ Matching is case-insensitive. Useful for excluding technical files such as `CLAU | | Custom language mapping for code blocks. Key is the language tag in the document, value is the Confluence language tag -| code-blocks.collapse-with-expand -| -| -| Wrap code blocks in an expand macro. Useful for Confluence Cloud where code blocks lack a native collapse feature. -Enabled by default for v2 editor if not specified explicitly - | markdown | | diff --git a/docs/reference/markdown/confluence-specific.md b/docs/reference/markdown/confluence-specific.md index b9be61af..50df13a6 100644 --- a/docs/reference/markdown/confluence-specific.md +++ b/docs/reference/markdown/confluence-specific.md @@ -189,12 +189,29 @@ Note that only limited colors are supported, and you need to properly specify on values: `grey` , `red`, `green`, `purple`, `blue`. -## Mentioning user (Confluence Server only) +## Mentioning user -You can mention user using `@username` format just like you can do on GitHub or in WYSIWYG Confluence editor. -Unfortunately due to absence of human-readable usernames in Cloud edition this will work only on Server/Datacenter where -human-readable usernames are still supported. If you still need to mention user in Cloud, consider -using [raw Confluence markdown](#adding-raw-confluence-formatting) +You can mention user using `@username` format just like you can do on GitHub or in WYSIWYG Confluence editor. If +the reference contains characters other than letters, digits, `.`, `_` and `-` (e.g. it is an email), wrap it in +double quotes: `@"jsmith@example.org"`. + +What should be used as a reference depends on Confluence edition: + +| Confluence edition | Reference | Example | +|--------------------|-------------------|-------------------------------------------| +| Server/Data Center | Username (login) | `@jsmith` or `@"jsmith@example.org"` | +| Cloud | User email | `@"jsmith@example.org"` | + +In Confluence Cloud users don't have human-readable usernames, so during `upload` *text2confl* looks up the account +id of the user by email. If the user can't be found (e.g. email is not visible to the account used for upload +because of user's profile visibility settings, or a scoped token lacks `read:content-details:confluence` scope), a +warning is logged, the reference is rendered as plain text and no mention is created. +Resolved users are cached in the text2confl work directory (see `t2c-dir` in +[configuration reference](../configuration.adoc)), so users are not looked up again on subsequent uploads. + +> [!NOTE] +> Resolution of users happens only during `upload`. The `convert` command does not connect to Confluence and always +> renders references as usernames. ## Putting date diff --git a/docs/tutorials/first-upload-cloud-scoped-tokens.adoc b/docs/tutorials/first-upload-cloud-scoped-tokens.adoc index 4dbd3bf0..e612ef80 100644 --- a/docs/tutorials/first-upload-cloud-scoped-tokens.adoc +++ b/docs/tutorials/first-upload-cloud-scoped-tokens.adoc @@ -43,6 +43,7 @@ Next, go to link:https://id.atlassian.com/manage-profile/security/api-tokens[Atl |Read |`read:hierarchical-content:confluence` |Read |`read:attachment:confluence` |Read |`read:label:confluence` +|Read |`read:content-details:confluence` |Write |`write:page:confluence` |Write |`write:label:confluence` |Write |`write:confluence-file`