Skip to content

Commit 569c220

Browse files
Adding clone endpoint and fixing navigation (#574)
1 parent 69eb3be commit 569c220

File tree

8 files changed

+316
-3
lines changed

8 files changed

+316
-3
lines changed

application/composeApp/src/commonMain/kotlin/io/writeopia/mobile/AppMobile.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package io.writeopia.mobile
22

3+
import androidx.compose.animation.slideInHorizontally
4+
import androidx.compose.animation.slideOutHorizontally
35
import androidx.compose.foundation.layout.BoxWithConstraints
46
import androidx.compose.runtime.Composable
57
import androidx.compose.runtime.CompositionLocalProvider
68
import androidx.compose.runtime.rememberCoroutineScope
79
import androidx.compose.ui.input.key.KeyEvent
810
import androidx.navigation.NavHostController
11+
import androidx.navigation.NavType
912
import androidx.navigation.compose.NavHost
1013
import androidx.navigation.compose.composable
14+
import androidx.navigation.navArgument
1115
import io.writeopia.auth.navigation.authNavigation
1216
import io.writeopia.common.utils.ALLOW_BACKEND
1317
import io.writeopia.common.utils.Destinations
@@ -191,6 +195,51 @@ fun AppMobile(
191195
) {
192196
navController.navigate(Destinations.MAIN_APP.id)
193197
}
198+
199+
composable(route = Destinations.SEARCH.id) {
200+
navController.navigate(Destinations.MAIN_APP.id)
201+
}
202+
203+
composable(route = Destinations.NOTIFICATIONS.id) {
204+
navController.navigate(Destinations.MAIN_APP.id)
205+
}
206+
207+
composable(route = Destinations.ACCOUNT.id) {
208+
navController.navigate(Destinations.MAIN_APP.id)
209+
}
210+
211+
composable(
212+
route = "${Destinations.EDITOR.id}/{noteId}/{noteTitle}",
213+
arguments = listOf(navArgument("noteId") { type = NavType.StringType }),
214+
enterTransition = {
215+
slideInHorizontally(
216+
initialOffsetX = { intSize -> intSize }
217+
)
218+
},
219+
exitTransition = {
220+
slideOutHorizontally(
221+
targetOffsetX = { intSize -> intSize }
222+
)
223+
}
224+
) { backStackEntry ->
225+
navController.navigate(Destinations.MAIN_APP.id)
226+
}
227+
228+
composable(
229+
route = "${Destinations.EDITOR.id}/{parentFolderId}",
230+
enterTransition = {
231+
slideInHorizontally(
232+
initialOffsetX = { intSize -> intSize }
233+
)
234+
},
235+
exitTransition = {
236+
slideOutHorizontally(
237+
targetOffsetX = { intSize -> intSize }
238+
)
239+
}
240+
) { backStackEntry ->
241+
navController.navigate(Destinations.MAIN_APP.id)
242+
}
194243
}
195244
}
196245

backend/core/database/src/main/sqldelight/io/writeopia/sql/DocumentEntity.sq

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ selectWithContentById:
9696
SELECT *
9797
FROM document_entity
9898
LEFT JOIN story_step_entity ON document_entity.id=story_step_entity.document_id
99-
WHERE document_entity.id = ? AND deleted = FALSE
99+
WHERE document_entity.id = ? AND deleted = FALSE AND workspace_id = ?
100100
ORDER BY position;
101101

102102
selectWithContentByParentId:

backend/documents/documents/src/main/java/io/writeopia/api/documents/documents/DocumentsService.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import io.writeopia.api.documents.documents.repository.getUserFavoriteDocumentId
1919
import io.writeopia.api.documents.documents.repository.isUserFavorite
2020
import io.writeopia.api.documents.documents.repository.moveFolderToFolder
2121
import io.writeopia.api.documents.documents.repository.removeUserFavorite
22+
import io.writeopia.api.documents.documents.repository.getDocumentWithContentById
2223
import io.writeopia.api.documents.documents.repository.saveDocument
2324
import io.writeopia.api.documents.documents.repository.saveFolder
2425
import io.writeopia.api.documents.search.SearchDocument
@@ -28,6 +29,7 @@ import io.writeopia.connection.wrWebClient
2829
import io.writeopia.sdk.models.document.Document
2930
import io.writeopia.sdk.models.document.Folder
3031
import io.writeopia.sdk.models.id.GenerateId
32+
import io.writeopia.sdk.models.story.StoryStep
3133
import io.writeopia.sdk.serialization.extensions.toApi
3234
import io.writeopia.sql.WriteopiaDbBackend
3335
import kotlin.time.Clock
@@ -118,6 +120,56 @@ object DocumentsService {
118120
return documentWithWorkspace
119121
}
120122

123+
suspend fun cloneDocuments(
124+
documentIds: List<String>,
125+
workspaceId: String,
126+
writeopiaDb: WriteopiaDbBackend,
127+
useAi: Boolean
128+
): List<Document> {
129+
val now = Clock.System.now()
130+
val clonedDocuments = mutableListOf<Document>()
131+
132+
for (documentId in documentIds) {
133+
val originalDocument = writeopiaDb.getDocumentWithContentById(documentId, workspaceId)
134+
?: continue
135+
136+
// Skip if document doesn't belong to the workspace
137+
if (originalDocument.workspaceId != workspaceId) continue
138+
139+
// Clone the content with new IDs for each StoryStep
140+
val clonedContent = originalDocument.content.mapValues { (_, storyStep) ->
141+
cloneStoryStep(storyStep)
142+
}
143+
144+
val clonedDocument = originalDocument.copy(
145+
id = GenerateId.generate(),
146+
title = "${originalDocument.title} (Copy)",
147+
content = clonedContent,
148+
createdAt = now,
149+
lastUpdatedAt = now,
150+
lastSyncedAt = now,
151+
favorite = false
152+
)
153+
154+
writeopiaDb.saveDocument(clonedDocument)
155+
clonedDocuments.add(clonedDocument)
156+
}
157+
158+
if (useAi && clonedDocuments.isNotEmpty()) {
159+
sendToAiHub(clonedDocuments, workspaceId)
160+
}
161+
162+
return clonedDocuments
163+
}
164+
165+
private fun cloneStoryStep(storyStep: StoryStep): StoryStep {
166+
return storyStep.copy(
167+
id = GenerateId.generate(),
168+
localId = GenerateId.generate(),
169+
steps = storyStep.steps.map { cloneStoryStep(it) }
170+
)
171+
}
172+
121173
/**
122174
* Recursively deletes a folder and all its contents (child folders and documents).
123175
* This follows the same pattern as NotesUseCase.deleteFolderById.

backend/documents/documents/src/main/java/io/writeopia/api/documents/documents/repository/DocumentSqlBeDao.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,8 +595,8 @@ class DocumentSqlBeDao(
595595
storyStepQueries?.deleteByDocumentIds(ids)
596596
}
597597

598-
fun loadDocumentWithContentById(documentId: String): Document? =
599-
documentQueries?.selectWithContentById(documentId)
598+
fun loadDocumentWithContentById(documentId: String, workspaceId: String): Document? =
599+
documentQueries?.selectWithContentById(documentId, workspaceId)
600600
?.executeAsList()
601601
?.groupBy { it.id }
602602
?.mapNotNull { (documentId, content) ->

backend/documents/documents/src/main/java/io/writeopia/api/documents/documents/repository/DocumentsRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ suspend fun WriteopiaDbBackend.getDocumentById(
5353
workspaceId: String
5454
): Document? = getDocumentDaoFn().loadDocumentById(id, workspaceId)
5555

56+
suspend fun WriteopiaDbBackend.getDocumentWithContentById(
57+
id: String, workspaceId: String
58+
): Document? = getDocumentDaoFn().loadDocumentWithContentById(id, workspaceId)
59+
5660
suspend fun WriteopiaDbBackend.getFolderById(id: String = "test", workspaceId: String): Folder? =
5761
getDocumentDaoFn().loadFolderById(id, workspaceId)
5862

backend/documents/documents/src/main/java/io/writeopia/api/documents/routing/DocumentsRouting.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import io.writeopia.sdk.serialization.extensions.toApi
2828
import io.writeopia.sdk.serialization.extensions.toModel
2929
import io.writeopia.sdk.serialization.json.SendDocumentsRequest
3030
import io.writeopia.sdk.serialization.json.SendFoldersRequest
31+
import io.writeopia.sdk.serialization.request.CloneDocumentsRequest
3132
import io.writeopia.sdk.serialization.request.CreateFolderRequest
3233
import io.writeopia.sdk.serialization.request.DeleteDocumentsRequest
3334
import io.writeopia.sdk.serialization.request.FavoriteDocumentRequest
@@ -554,6 +555,42 @@ fun Routing.documentsRoute(
554555
}
555556
}
556557

558+
authenticate("auth-jwt", optional = debug) {
559+
post<CloneDocumentsRequest>("/api/workspace/{workspaceId}/document/clone") { request ->
560+
val userId = getUserId() ?: ""
561+
val workspaceId = call.pathParameters["workspaceId"] ?: ""
562+
563+
runIfMember(userId, workspaceId, writeopiaDb, debug) {
564+
try {
565+
if (request.documentIds.isEmpty()) {
566+
call.respond(
567+
status = HttpStatusCode.BadRequest,
568+
message = "Document IDs list cannot be empty"
569+
)
570+
return@runIfMember
571+
}
572+
573+
val clonedDocuments = DocumentsService.cloneDocuments(
574+
documentIds = request.documentIds,
575+
workspaceId = workspaceId,
576+
writeopiaDb = writeopiaDb,
577+
useAi = useAi
578+
)
579+
580+
call.respond(
581+
status = HttpStatusCode.Created,
582+
message = clonedDocuments.map { it.toApi() }
583+
)
584+
} catch (e: Exception) {
585+
call.respond(
586+
status = HttpStatusCode.InternalServerError,
587+
message = "${e.message}"
588+
)
589+
}
590+
}
591+
}
592+
}
593+
557594
authenticate("auth-jwt", optional = debug) {
558595
post<FavoriteDocumentRequest>("/api/workspace/{workspaceId}/document/{documentId}/favorite") { request ->
559596
val userId = getUserId() ?: ""

backend/gateway/src/test/kotlin/io/writeopia/api/gateway/DocumentsIntegrationTest.kt

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import io.writeopia.sdk.serialization.data.StoryStepApi
2323
import io.writeopia.sdk.serialization.extensions.toApi
2424
import io.writeopia.sdk.serialization.json.SendDocumentsRequest
2525
import io.writeopia.sdk.serialization.json.SendFoldersRequest
26+
import io.writeopia.sdk.serialization.request.CloneDocumentsRequest
2627
import io.writeopia.sdk.serialization.request.CreateFolderRequest
2728
import io.writeopia.sdk.serialization.request.DeleteDocumentsRequest
2829
import io.writeopia.sdk.serialization.request.FavoriteDocumentRequest
@@ -1145,4 +1146,166 @@ class DocumentationIntegrationTests {
11451146
}
11461147
assertEquals(HttpStatusCode.NotFound, favoriteResponse.status)
11471148
}
1149+
1150+
@Test
1151+
fun `it should be possible to clone multiple documents`() = testApplication {
1152+
application {
1153+
module(db, debugMode = true)
1154+
}
1155+
1156+
val client = defaultClient()
1157+
val workspaceId = Random.nextInt().toString()
1158+
1159+
// Create content for documents
1160+
val content1: Map<Int, StoryStepApi> = mapOf(
1161+
0 to StoryStep(id = "step1", type = StoryTypes.TEXT.type, text = "message1"),
1162+
1 to StoryStep(id = "step2", type = StoryTypes.TEXT.type, text = "message2"),
1163+
).mapValues { (position, step) ->
1164+
step.toApi(position)
1165+
}
1166+
1167+
val content2: Map<Int, StoryStepApi> = mapOf(
1168+
0 to StoryStep(id = "step3", type = StoryTypes.TEXT.type, text = "content A"),
1169+
).mapValues { (position, step) ->
1170+
step.toApi(position)
1171+
}
1172+
1173+
// Create documents to clone
1174+
val document1 = DocumentApi(
1175+
id = "docToClone1_${Random.nextInt()}",
1176+
title = "Document To Clone 1",
1177+
workspaceId = workspaceId,
1178+
parentId = "root",
1179+
isLocked = false,
1180+
createdAt = 1000L,
1181+
lastUpdatedAt = 2000L,
1182+
lastSyncedAt = 0L,
1183+
content = content1.values.toList()
1184+
)
1185+
1186+
val document2 = DocumentApi(
1187+
id = "docToClone2_${Random.nextInt()}",
1188+
title = "Document To Clone 2",
1189+
workspaceId = workspaceId,
1190+
parentId = "root",
1191+
isLocked = false,
1192+
createdAt = 1000L,
1193+
lastUpdatedAt = 2000L,
1194+
lastSyncedAt = 0L,
1195+
content = content2.values.toList()
1196+
)
1197+
1198+
// Save documents
1199+
val createResponse = client.post("/api/workspace/document") {
1200+
contentType(ContentType.Application.Json)
1201+
setBody(SendDocumentsRequest(listOf(document1, document2), workspaceId))
1202+
}
1203+
assertEquals(HttpStatusCode.OK, createResponse.status)
1204+
1205+
// Clone the documents
1206+
val cloneRequest = CloneDocumentsRequest(documentIds = listOf(document1.id, document2.id))
1207+
1208+
val cloneResponse = client.post("/api/workspace/$workspaceId/document/clone") {
1209+
contentType(ContentType.Application.Json)
1210+
setBody(cloneRequest)
1211+
}
1212+
assertEquals(HttpStatusCode.Created, cloneResponse.status)
1213+
1214+
val clonedDocuments = cloneResponse.body<List<DocumentApi>>()
1215+
1216+
// Verify we got 2 cloned documents
1217+
assertEquals(2, clonedDocuments.size)
1218+
1219+
// Find cloned documents by their original titles
1220+
val clonedDoc1 = clonedDocuments.find { it.title == "Document To Clone 1 (Copy)" }
1221+
val clonedDoc2 = clonedDocuments.find { it.title == "Document To Clone 2 (Copy)" }
1222+
1223+
// Verify cloned documents exist and have correct properties
1224+
assertTrue(clonedDoc1 != null)
1225+
assertTrue(clonedDoc2 != null)
1226+
1227+
// Verify cloned documents have different IDs from originals
1228+
assertTrue(clonedDoc1!!.id != document1.id)
1229+
assertTrue(clonedDoc2!!.id != document2.id)
1230+
1231+
// Verify cloned documents have the same parentId and workspaceId
1232+
assertEquals(document1.parentId, clonedDoc1.parentId)
1233+
assertEquals(document2.parentId, clonedDoc2.parentId)
1234+
assertEquals(workspaceId, clonedDoc1.workspaceId)
1235+
assertEquals(workspaceId, clonedDoc2.workspaceId)
1236+
1237+
// Verify cloned document 1 has content with new IDs
1238+
assertEquals(2, clonedDoc1.content.size)
1239+
val clonedStep1 = clonedDoc1.content.find { it.text == "message1" }
1240+
val clonedStep2 = clonedDoc1.content.find { it.text == "message2" }
1241+
assertTrue(clonedStep1 != null)
1242+
assertTrue(clonedStep2 != null)
1243+
assertTrue(clonedStep1!!.id != "step1") // ID should be different
1244+
assertTrue(clonedStep2!!.id != "step2") // ID should be different
1245+
1246+
// Verify cloned document 2 has content with new IDs
1247+
assertEquals(1, clonedDoc2.content.size)
1248+
val clonedStep3 = clonedDoc2.content.find { it.text == "content A" }
1249+
assertTrue(clonedStep3 != null)
1250+
assertTrue(clonedStep3!!.id != "step3") // ID should be different
1251+
1252+
// Verify cloned documents can be retrieved
1253+
val getCloned1 = client.get("/api/workspace/$workspaceId/document/${clonedDoc1.id}")
1254+
assertEquals(HttpStatusCode.OK, getCloned1.status)
1255+
1256+
val getCloned2 = client.get("/api/workspace/$workspaceId/document/${clonedDoc2.id}")
1257+
assertEquals(HttpStatusCode.OK, getCloned2.status)
1258+
1259+
// Verify original documents still exist
1260+
val getOriginal1 = client.get("/api/workspace/$workspaceId/document/${document1.id}")
1261+
assertEquals(HttpStatusCode.OK, getOriginal1.status)
1262+
1263+
val getOriginal2 = client.get("/api/workspace/$workspaceId/document/${document2.id}")
1264+
assertEquals(HttpStatusCode.OK, getOriginal2.status)
1265+
1266+
// Clean up
1267+
db.deleteDocumentById(document1.id)
1268+
db.deleteDocumentById(document2.id)
1269+
db.deleteDocumentById(clonedDoc1.id)
1270+
db.deleteDocumentById(clonedDoc2.id)
1271+
}
1272+
1273+
@Test
1274+
fun `it should return empty list when cloning non-existent documents`() = testApplication {
1275+
application {
1276+
module(db, debugMode = true)
1277+
}
1278+
1279+
val client = defaultClient()
1280+
val workspaceId = Random.nextInt().toString()
1281+
1282+
val cloneRequest = CloneDocumentsRequest(documentIds = listOf("nonExistent1", "nonExistent2"))
1283+
1284+
val cloneResponse = client.post("/api/workspace/$workspaceId/document/clone") {
1285+
contentType(ContentType.Application.Json)
1286+
setBody(cloneRequest)
1287+
}
1288+
assertEquals(HttpStatusCode.Created, cloneResponse.status)
1289+
1290+
val clonedDocuments = cloneResponse.body<List<DocumentApi>>()
1291+
assertEquals(0, clonedDocuments.size)
1292+
}
1293+
1294+
@Test
1295+
fun `it should return bad request when cloning with empty document list`() = testApplication {
1296+
application {
1297+
module(db, debugMode = true)
1298+
}
1299+
1300+
val client = defaultClient()
1301+
val workspaceId = Random.nextInt().toString()
1302+
1303+
val cloneRequest = CloneDocumentsRequest(documentIds = emptyList())
1304+
1305+
val cloneResponse = client.post("/api/workspace/$workspaceId/document/clone") {
1306+
contentType(ContentType.Application.Json)
1307+
setBody(cloneRequest)
1308+
}
1309+
assertEquals(HttpStatusCode.BadRequest, cloneResponse.status)
1310+
}
11481311
}

0 commit comments

Comments
 (0)