Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ target
.asciidoctor
.asciidoc
.diagrams
.text2confl
out
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
63 changes: 58 additions & 5 deletions cli/src/main/kotlin/com/github/zeldigas/text2confl/cli/Upload.kt
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()

Expand All @@ -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(
Expand All @@ -89,24 +106,46 @@ 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 {
converter.convertDir(docs.toPath())
}
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,
Expand Down Expand Up @@ -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 { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long, Space> = ConcurrentHashMap()

Expand Down Expand Up @@ -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<PageLoadOptions>?
Expand Down Expand Up @@ -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<UserSearchResult>()
return result.results.firstOrNull { it.user.email == email }?.user?.accountId
}

private fun versionNode(
version: Int,
pageUpdateOptions: PageUpdateOptions
Expand Down Expand Up @@ -518,4 +533,10 @@ private data class CloudAttachment(
val comment: String?,
@param:JsonProperty("_links")
val links: Map<String, String> = emptyMap()
)
)

private data class UserSearchResult(val results: List<UserSearchEntry>)

private data class UserSearchEntry(val user: UserSearchUser)

private data class UserSearchUser(val accountId: String, val email: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.github.zeldigas.confclient

interface ConfluenceUserSearchClient {

suspend fun findUserIdByEmail(email: String): String?

}
Original file line number Diff line number Diff line change
@@ -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?)
data class User(val type: String?, val username: String?, val userKey: String?, val displayName: String?, val email: String? = null)
Loading
Loading