Skip to content

Commit f8422c5

Browse files
leandroBorgesFerreiraLeandro Ferreira
authored andcommitted
Sync images
1 parent 515e061 commit f8422c5

24 files changed

Lines changed: 350 additions & 6 deletions

File tree

application/core/documents/src/commonMain/kotlin/io/writeopia/core/folders/api/DocumentsApi.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import io.writeopia.sdk.serialization.extensions.toModel
2121
import io.writeopia.sdk.serialization.json.SendDocumentsRequest
2222
import io.writeopia.sdk.serialization.json.SendFoldersRequest
2323
import io.writeopia.sdk.serialization.request.WorkspaceDiffRequest
24-
import io.writeopia.sdk.serialization.request.WorkspaceDiffResponse
24+
import io.writeopia.sdk.serialization.response.WorkspaceDiffResponse
2525
import kotlin.time.ExperimentalTime
2626
import kotlin.time.Instant
2727

application/core/documents/src/commonMain/kotlin/io/writeopia/core/folders/di/WorkspaceInjection.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.writeopia.core.folders.di
33
import io.writeopia.auth.core.di.AuthCoreInjectionNeo
44
import io.writeopia.core.folders.api.DocumentsApi
55
import io.writeopia.core.folders.sync.DocumentConflictHandler
6+
import io.writeopia.core.folders.sync.ImageSync
67
import io.writeopia.core.folders.sync.WorkspaceSync
78
import io.writeopia.di.AppConnectionInjection
89
import io.writeopia.sdk.network.injector.WriteopiaConnectionInjector
@@ -31,6 +32,11 @@ class WorkspaceInjection private constructor(
3132
folderRepository = FoldersInjector.singleton().provideFoldersRepository(),
3233
authCoreInjection.provideAuthRepository()
3334
),
35+
imageSync = ImageSync(
36+
appConnectionInjection.provideHttpClient(),
37+
connectionInjector.baseUrl(),
38+
repositoryInjection.provideDocumentRepository()
39+
)
3440
)
3541
}
3642

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package io.writeopia.core.folders.sync
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.call.body
5+
import io.ktor.client.request.forms.formData
6+
import io.ktor.client.request.forms.submitFormWithBinaryData
7+
import io.ktor.client.statement.HttpResponse
8+
import io.ktor.http.ContentType
9+
import io.ktor.http.Headers
10+
import io.ktor.http.HttpHeaders
11+
import io.ktor.http.HttpStatusCode
12+
import io.writeopia.sdk.models.utils.ResultData
13+
import io.writeopia.sdk.repository.DocumentRepository
14+
import io.writeopia.sdk.serialization.request.ImageUploadRequest
15+
import kotlinx.io.buffered
16+
import kotlinx.io.files.Path
17+
import kotlinx.io.files.SystemFileSystem
18+
import kotlinx.io.readByteArray
19+
20+
class ImageSync(
21+
private val client: HttpClient,
22+
private val baseUrl: String,
23+
private val documentRepository: DocumentRepository
24+
) {
25+
26+
suspend fun syncAllImages(workspaceId: String, token: String) {
27+
val storySteps = documentRepository.queryUnsyncedImagesSteps()
28+
29+
storySteps
30+
.filter { storyStep -> storyStep.path != null }
31+
.forEach { step ->
32+
val result = sendImageFromPath(
33+
imagePath = step.path!!,
34+
workspaceId = workspaceId,
35+
token = token
36+
)
37+
38+
if (result is ResultData.Complete) {
39+
val url = step.url
40+
41+
if (url != null) {
42+
documentRepository.updateStoryStepUrl(url, step.id)
43+
}
44+
}
45+
}
46+
}
47+
48+
private suspend fun sendImageFromPath(
49+
imagePath: String,
50+
workspaceId: String,
51+
token: String
52+
): ResultData<ImageUploadRequest> =
53+
try {
54+
val contentType = detectContentType(imagePath)
55+
56+
val response: HttpResponse = client.submitFormWithBinaryData(
57+
url = "$baseUrl/api/workspace/$workspaceId/document/upload-image",
58+
formData = formData {
59+
append(
60+
"image",
61+
SystemFileSystem.source(Path(imagePath)).buffered().readByteArray(),
62+
Headers.build {
63+
append(HttpHeaders.ContentType, contentType.toString())
64+
append(
65+
HttpHeaders.ContentDisposition,
66+
"filename=\"${imagePath.substringAfterLast("/")}\""
67+
)
68+
append(HttpHeaders.Authorization, "Bearer $token")
69+
}
70+
)
71+
}
72+
)
73+
74+
if (response.status == HttpStatusCode.Created) {
75+
ResultData.Complete(response.body<ImageUploadRequest>())
76+
} else {
77+
ResultData.Error()
78+
}
79+
} catch (e: Exception) {
80+
ResultData.Error(e)
81+
}
82+
}
83+
84+
private fun detectContentType(path: String): ContentType {
85+
val extension = path.substringAfterLast(".", "").lowercase()
86+
return when (extension) {
87+
"jpg", "jpeg" -> ContentType.Image.JPEG
88+
"png" -> ContentType.Image.PNG
89+
"gif" -> ContentType.Image.GIF
90+
"webp" -> ContentType.Image.Any
91+
"svg" -> ContentType.Image.SVG
92+
else -> ContentType.Application.OctetStream // Fallback for unknown types
93+
}
94+
}

application/core/documents/src/commonMain/kotlin/io/writeopia/core/folders/sync/WorkspaceSync.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class WorkspaceSync(
2020
private val authRepository: AuthRepository,
2121
private val documentsApi: DocumentsApi,
2222
private val documentConflictHandler: DocumentConflictHandler,
23+
private val imageSync: ImageSync,
2324
private val minSyncInternal: Duration = 3.seconds
2425
) {
2526
private var lastSuccessfulSync: Instant = Instant.DISTANT_PAST
@@ -73,10 +74,14 @@ class WorkspaceSync(
7374
println("sending ${documentsNotSent.size} documents")
7475
val resultSendDocuments =
7576
documentsApi.sendDocuments(documentsNotSent, workspaceId, authToken)
77+
7678
println("sending ${foldersNotSent.size} folders")
7779
val resultSendFolders = documentsApi.sendFolders(foldersNotSent, workspaceId, authToken)
7880

79-
if (resultSendDocuments is ResultData.Complete && resultSendFolders is ResultData.Complete) {
81+
if (
82+
resultSendDocuments is ResultData.Complete &&
83+
resultSendFolders is ResultData.Complete
84+
) {
8085
println("documents sent")
8186
val now = Clock.System.now()
8287
// If everything ran accordingly, update the sync time of the folder.
@@ -90,6 +95,8 @@ class WorkspaceSync(
9095

9196
lastSuccessfulSync = now
9297

98+
imageSync.syncAllImages(workspaceId = workspaceId, token = authToken)
99+
93100
return ResultData.Complete(Unit)
94101
} else {
95102
println("documents NOT sent")

backend/core/buckets/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
plugins {
2+
id("java-library")
3+
alias(libs.plugins.org.jetbrains.kotlin.jvm)
4+
}
5+
java {
6+
sourceCompatibility = JavaVersion.VERSION_11
7+
targetCompatibility = JavaVersion.VERSION_11
8+
}
9+
kotlin {
10+
compilerOptions {
11+
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
12+
}
13+
}
14+
15+
dependencies {
16+
implementation(project(":backend:core:models"))
17+
implementation(libs.google.cloud.storage)
18+
19+
implementation(libs.ktor.client.core)
20+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.writeopia.buckets
2+
3+
import com.google.cloud.storage.Storage
4+
import com.google.cloud.storage.StorageOptions
5+
6+
object GcpStorageProvider {
7+
val storage: Storage by lazy {
8+
StorageOptions.getDefaultInstance().service
9+
}
10+
}
11+
12+
object BucketConfig {
13+
14+
fun imagesBucketName(): String =
15+
System.getenv("IMAGES_BUCKET_NAME")
16+
?: throw IllegalStateException(
17+
"Environment variable 'IMAGES_BUCKET_NAME' is not set."
18+
)
19+
20+
fun bucketBaseUrl(): String =
21+
System.getenv("BUCKET_BASE_NAME")
22+
?: throw IllegalStateException(
23+
"Environment variable 'BUCKET_BASE_NAME' is not set."
24+
)
25+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.writeopia.buckets
2+
3+
import com.google.cloud.storage.BlobInfo
4+
import io.ktor.http.content.*
5+
import io.writeopia.backend.models.ImageStorageService
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.withContext
8+
9+
object GcpBucketImageStorageService: ImageStorageService {
10+
11+
private val storage = GcpStorageProvider.storage
12+
private val bucketName = BucketConfig.imagesBucketName()
13+
private val storageBaseUrl = BucketConfig.bucketBaseUrl()
14+
15+
/**
16+
* Processes multipart data to find an image and upload it to GCP.
17+
* @return The public URL of the uploaded image, or null if no image was found.
18+
*/
19+
override suspend fun uploadImage(multipart: MultiPartData, userId: String): String? =
20+
withContext(Dispatchers.IO) {
21+
var uploadedUrl: String? = null
22+
23+
multipart.forEachPart { part ->
24+
if (part is PartData.FileItem) {
25+
val fileName =
26+
"uploads/$userId/${System.currentTimeMillis()}-${part.originalFileName}"
27+
28+
val blobInfo = BlobInfo.newBuilder(bucketName, fileName)
29+
.setContentType(part.contentType?.toString())
30+
.build()
31+
32+
val bytes = part.streamProvider().readBytes()
33+
val blob = storage.create(blobInfo, bytes)
34+
35+
uploadedUrl = "$storageBaseUrl/$bucketName/${blob.name}"
36+
}
37+
part.dispose()
38+
}
39+
40+
uploadedUrl
41+
}
42+
}

backend/core/models/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
plugins {
2+
id("java-library")
3+
alias(libs.plugins.org.jetbrains.kotlin.jvm)
4+
}
5+
java {
6+
sourceCompatibility = JavaVersion.VERSION_11
7+
targetCompatibility = JavaVersion.VERSION_11
8+
}
9+
kotlin {
10+
compilerOptions {
11+
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
12+
}
13+
}
14+
dependencies {
15+
implementation(libs.ktor.client.core)
16+
}

0 commit comments

Comments
 (0)