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 fac352a6..bc8851dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added + +- 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] 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 ### Fixed 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..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,25 +1,36 @@ 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.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.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 { @@ -62,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() @@ -76,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( @@ -89,7 +106,12 @@ 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, t2cWorkDir) + ) val pagesToPublish = if (docs.isFile) { listOf(converter.convertFile(docs.toPath())) } else { @@ -97,16 +119,33 @@ 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)) + 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, t2cWorkDir: Path): UserResolver { + val userCache = t2cWorkDir / "users.json" + return if (confluenceClient is ConfluenceUserSearchClient) { + val resolver = createFromStoredData(confluenceClient, userCache) + postProcessActions.add { + persistResolvedUsers(resolver, userCache) + } + resolver + } else { + ServerUserResolver() + } + } + private fun operationsTracker(server: Url): UploadOperationTracker = PrintingUploadOperationsTracker( server = server, printer = terminal::println, @@ -151,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/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/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 57738517..ea9898fb 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() @@ -168,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? @@ -387,6 +394,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 +533,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/main/kotlin/com/github/zeldigas/confclient/model/User.kt b/confluence-client/src/main/kotlin/com/github/zeldigas/confclient/model/User.kt index 9e42d10e..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?) \ 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 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 c54401b0..75662c88 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 @@ -311,4 +312,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/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..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 @@ -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 @@ -44,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(), ) @@ -61,13 +61,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 +78,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 +87,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 +100,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/asciidoc/AsciidocFileConverter.kt b/convert/src/main/kotlin/com/github/zeldigas/text2confl/convert/asciidoc/AsciidocFileConverter.kt index f5bb5680..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 @@ -77,7 +77,7 @@ class AsciidocFileConverter(private val asciidocParser: AsciidocParser, private context.conversionParameters.addAutogeneratedNote, context.targetSpace, AsciidocAttachmentCollector(file, AttachmentCollector(context.referenceProvider, registry), workdir), - codeBlocksInExpand = context.conversionParameters.codeBlocksInExpand, + AsciidocUserResolver(context.userResolver), 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 f9b5fc85..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,7 @@ 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 "" ) + config.attributes + parameters.extraAttrs @@ -89,7 +89,7 @@ data class AsciidocRenderingParameters( val includeAutoText: Boolean, val space: String, val attachmentsCollector: AsciidocAttachmentCollector, - val codeBlocksInExpand: Boolean, + val userResolver: AsciidocUserResolver, 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/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..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 @@ -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 @@ -173,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) { @@ -536,8 +525,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/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/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/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/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..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 @@ -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 @@ -232,7 +234,7 @@ 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 { hasCause(cause) 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/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..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 @@ -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,7 +36,7 @@ internal open class RenderingTestBase { parser: AsciidocParser = DEFAULT_PARSER, attachmentsCollector: AsciidocAttachmentCollector? = null, referenceProvider: AsciidocReferenceProvider? = null, - codeBlocksInExpand: Boolean = false, + userResolver: AsciidocUserResolver? = null, attributes: Map = emptyMap(), ): String { val defaultSource = Path("./test.adoc") @@ -61,7 +62,7 @@ internal open class RenderingTestBase { addAutogenHeader, "TEST", effectiveCollector, - codeBlocksInExpand, + userResolver ?: AsciidocUserResolver(TestUserResolver), extraAttrs = attributes, ) ) 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/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 211b3b98..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 @@ -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 @@ -27,12 +28,11 @@ 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 { val context = ConvertingContext( - referenceProvider, + referenceProvider, TestUserResolver, ConversionParameters( languageMapper ?: this.languageMapper, { _, title -> title }, @@ -41,7 +41,6 @@ internal open class RenderingTestBase { docRootLocation = "http://example.com/", markdownConfiguration = config, editorVersion = editorVersion, - codeBlocksInExpand = codeBlockInExpand, ), "TEST", ) 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/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/ServiceProvider.kt b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/ServiceProvider.kt index e06b2df7..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 @@ -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( @@ -39,11 +40,10 @@ 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 - ) + ), + userResolver ) } 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 4054e103..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 + 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 new file mode 100644 index 00000000..e2bdd86f --- /dev/null +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolver.kt @@ -0,0 +1,114 @@ +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 io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.* +import java.nio.file.Path +import java.time.Instant +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 { + if (fileWithCache.exists()) { + val data = loadUsersOrEmpty(fileWithCache) + return CloudUserResolver(client, data?.users ?: emptyMap()) + } else { + return CloudUserResolver(client) + } +} + +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() +) : UserResolver { + + private val cache = Caffeine.newBuilder() + .asLoadingCache { email: String -> + 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 { + knownUsers.forEach { (k, v) -> cache[k] = UserValue.Found(v) } + } + + override suspend fun resolveUser(email: String): UserResolver.UserReference? { + val result = cache.get(email) + return if (result is UserValue.Found) user(result) else null + } + + 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 } + .toMap() + + sealed class UserValue { + 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/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..e10cb3d4 --- /dev/null +++ b/core/src/main/kotlin/com/github/zeldigas/text2confl/core/users/ServerUserResolver.kt @@ -0,0 +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): 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 fun registerReferencedUsers(email: List) { + // nothing to do here + } +} \ 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/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 new file mode 100644 index 00000000..d0b71b80 --- /dev/null +++ b/core/src/test/kotlin/com/github/zeldigas/text2confl/core/users/CloudUserResolverTest.kt @@ -0,0 +1,124 @@ +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 +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(UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "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 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) + 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 UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "acc-a"), + "c@example.com" to UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "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(UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "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(UserResolver.UserReference(UserResolver.UserIdFormat.ACCOUNT_ID, "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/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` 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 +