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
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package io.writeopia.mobile

import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.input.key.KeyEvent
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import io.writeopia.auth.navigation.authNavigation
import io.writeopia.common.utils.ALLOW_BACKEND
import io.writeopia.common.utils.Destinations
Expand Down Expand Up @@ -191,6 +195,51 @@ fun AppMobile(
) {
navController.navigate(Destinations.MAIN_APP.id)
}

composable(route = Destinations.SEARCH.id) {
navController.navigate(Destinations.MAIN_APP.id)
}

composable(route = Destinations.NOTIFICATIONS.id) {
navController.navigate(Destinations.MAIN_APP.id)
}

composable(route = Destinations.ACCOUNT.id) {
navController.navigate(Destinations.MAIN_APP.id)
}

composable(
route = "${Destinations.EDITOR.id}/{noteId}/{noteTitle}",
arguments = listOf(navArgument("noteId") { type = NavType.StringType }),
enterTransition = {
slideInHorizontally(
initialOffsetX = { intSize -> intSize }
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { intSize -> intSize }
)
}
) { backStackEntry ->
navController.navigate(Destinations.MAIN_APP.id)
}

composable(
route = "${Destinations.EDITOR.id}/{parentFolderId}",
enterTransition = {
slideInHorizontally(
initialOffsetX = { intSize -> intSize }
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { intSize -> intSize }
)
}
) { backStackEntry ->
navController.navigate(Destinations.MAIN_APP.id)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ selectWithContentById:
SELECT *
FROM document_entity
LEFT JOIN story_step_entity ON document_entity.id=story_step_entity.document_id
WHERE document_entity.id = ? AND deleted = FALSE
WHERE document_entity.id = ? AND deleted = FALSE AND workspace_id = ?
ORDER BY position;

selectWithContentByParentId:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import io.writeopia.api.documents.documents.repository.getUserFavoriteDocumentId
import io.writeopia.api.documents.documents.repository.isUserFavorite
import io.writeopia.api.documents.documents.repository.moveFolderToFolder
import io.writeopia.api.documents.documents.repository.removeUserFavorite
import io.writeopia.api.documents.documents.repository.getDocumentWithContentById
import io.writeopia.api.documents.documents.repository.saveDocument
import io.writeopia.api.documents.documents.repository.saveFolder
import io.writeopia.api.documents.search.SearchDocument
Expand All @@ -28,6 +29,7 @@ import io.writeopia.connection.wrWebClient
import io.writeopia.sdk.models.document.Document
import io.writeopia.sdk.models.document.Folder
import io.writeopia.sdk.models.id.GenerateId
import io.writeopia.sdk.models.story.StoryStep
import io.writeopia.sdk.serialization.extensions.toApi
import io.writeopia.sql.WriteopiaDbBackend
import kotlin.time.Clock
Expand Down Expand Up @@ -118,6 +120,56 @@ object DocumentsService {
return documentWithWorkspace
}

suspend fun cloneDocuments(
documentIds: List<String>,
workspaceId: String,
writeopiaDb: WriteopiaDbBackend,
useAi: Boolean
): List<Document> {
val now = Clock.System.now()
val clonedDocuments = mutableListOf<Document>()

for (documentId in documentIds) {
val originalDocument = writeopiaDb.getDocumentWithContentById(documentId, workspaceId)
?: continue

// Skip if document doesn't belong to the workspace
if (originalDocument.workspaceId != workspaceId) continue

// Clone the content with new IDs for each StoryStep
val clonedContent = originalDocument.content.mapValues { (_, storyStep) ->
cloneStoryStep(storyStep)
}

val clonedDocument = originalDocument.copy(
id = GenerateId.generate(),
title = "${originalDocument.title} (Copy)",
content = clonedContent,
createdAt = now,
lastUpdatedAt = now,
lastSyncedAt = now,
favorite = false
)

writeopiaDb.saveDocument(clonedDocument)
clonedDocuments.add(clonedDocument)
}

if (useAi && clonedDocuments.isNotEmpty()) {
sendToAiHub(clonedDocuments, workspaceId)
}

return clonedDocuments
}

private fun cloneStoryStep(storyStep: StoryStep): StoryStep {
return storyStep.copy(
id = GenerateId.generate(),
localId = GenerateId.generate(),
steps = storyStep.steps.map { cloneStoryStep(it) }
)
}

/**
* Recursively deletes a folder and all its contents (child folders and documents).
* This follows the same pattern as NotesUseCase.deleteFolderById.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,8 @@ class DocumentSqlBeDao(
storyStepQueries?.deleteByDocumentIds(ids)
}

fun loadDocumentWithContentById(documentId: String): Document? =
documentQueries?.selectWithContentById(documentId)
fun loadDocumentWithContentById(documentId: String, workspaceId: String): Document? =
documentQueries?.selectWithContentById(documentId, workspaceId)
?.executeAsList()
?.groupBy { it.id }
?.mapNotNull { (documentId, content) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ suspend fun WriteopiaDbBackend.getDocumentById(
workspaceId: String
): Document? = getDocumentDaoFn().loadDocumentById(id, workspaceId)

suspend fun WriteopiaDbBackend.getDocumentWithContentById(
id: String, workspaceId: String
): Document? = getDocumentDaoFn().loadDocumentWithContentById(id, workspaceId)

suspend fun WriteopiaDbBackend.getFolderById(id: String = "test", workspaceId: String): Folder? =
getDocumentDaoFn().loadFolderById(id, workspaceId)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import io.writeopia.sdk.serialization.extensions.toApi
import io.writeopia.sdk.serialization.extensions.toModel
import io.writeopia.sdk.serialization.json.SendDocumentsRequest
import io.writeopia.sdk.serialization.json.SendFoldersRequest
import io.writeopia.sdk.serialization.request.CloneDocumentsRequest
import io.writeopia.sdk.serialization.request.CreateFolderRequest
import io.writeopia.sdk.serialization.request.DeleteDocumentsRequest
import io.writeopia.sdk.serialization.request.FavoriteDocumentRequest
Expand Down Expand Up @@ -554,6 +555,42 @@ fun Routing.documentsRoute(
}
}

authenticate("auth-jwt", optional = debug) {
post<CloneDocumentsRequest>("/api/workspace/{workspaceId}/document/clone") { request ->
val userId = getUserId() ?: ""
val workspaceId = call.pathParameters["workspaceId"] ?: ""

runIfMember(userId, workspaceId, writeopiaDb, debug) {
try {
if (request.documentIds.isEmpty()) {
call.respond(
status = HttpStatusCode.BadRequest,
message = "Document IDs list cannot be empty"
)
return@runIfMember
}

val clonedDocuments = DocumentsService.cloneDocuments(
documentIds = request.documentIds,
workspaceId = workspaceId,
writeopiaDb = writeopiaDb,
useAi = useAi
)

call.respond(
status = HttpStatusCode.Created,
message = clonedDocuments.map { it.toApi() }
)
} catch (e: Exception) {
call.respond(
status = HttpStatusCode.InternalServerError,
message = "${e.message}"
)
}
}
}
}

authenticate("auth-jwt", optional = debug) {
post<FavoriteDocumentRequest>("/api/workspace/{workspaceId}/document/{documentId}/favorite") { request ->
val userId = getUserId() ?: ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import io.writeopia.sdk.serialization.data.StoryStepApi
import io.writeopia.sdk.serialization.extensions.toApi
import io.writeopia.sdk.serialization.json.SendDocumentsRequest
import io.writeopia.sdk.serialization.json.SendFoldersRequest
import io.writeopia.sdk.serialization.request.CloneDocumentsRequest
import io.writeopia.sdk.serialization.request.CreateFolderRequest
import io.writeopia.sdk.serialization.request.DeleteDocumentsRequest
import io.writeopia.sdk.serialization.request.FavoriteDocumentRequest
Expand Down Expand Up @@ -1145,4 +1146,166 @@ class DocumentationIntegrationTests {
}
assertEquals(HttpStatusCode.NotFound, favoriteResponse.status)
}

@Test
fun `it should be possible to clone multiple documents`() = testApplication {
application {
module(db, debugMode = true)
}

val client = defaultClient()
val workspaceId = Random.nextInt().toString()

// Create content for documents
val content1: Map<Int, StoryStepApi> = mapOf(
0 to StoryStep(id = "step1", type = StoryTypes.TEXT.type, text = "message1"),
1 to StoryStep(id = "step2", type = StoryTypes.TEXT.type, text = "message2"),
).mapValues { (position, step) ->
step.toApi(position)
}

val content2: Map<Int, StoryStepApi> = mapOf(
0 to StoryStep(id = "step3", type = StoryTypes.TEXT.type, text = "content A"),
).mapValues { (position, step) ->
step.toApi(position)
}

// Create documents to clone
val document1 = DocumentApi(
id = "docToClone1_${Random.nextInt()}",
title = "Document To Clone 1",
workspaceId = workspaceId,
parentId = "root",
isLocked = false,
createdAt = 1000L,
lastUpdatedAt = 2000L,
lastSyncedAt = 0L,
content = content1.values.toList()
)

val document2 = DocumentApi(
id = "docToClone2_${Random.nextInt()}",
title = "Document To Clone 2",
workspaceId = workspaceId,
parentId = "root",
isLocked = false,
createdAt = 1000L,
lastUpdatedAt = 2000L,
lastSyncedAt = 0L,
content = content2.values.toList()
)

// Save documents
val createResponse = client.post("/api/workspace/document") {
contentType(ContentType.Application.Json)
setBody(SendDocumentsRequest(listOf(document1, document2), workspaceId))
}
assertEquals(HttpStatusCode.OK, createResponse.status)

// Clone the documents
val cloneRequest = CloneDocumentsRequest(documentIds = listOf(document1.id, document2.id))

val cloneResponse = client.post("/api/workspace/$workspaceId/document/clone") {
contentType(ContentType.Application.Json)
setBody(cloneRequest)
}
assertEquals(HttpStatusCode.Created, cloneResponse.status)

val clonedDocuments = cloneResponse.body<List<DocumentApi>>()

// Verify we got 2 cloned documents
assertEquals(2, clonedDocuments.size)

// Find cloned documents by their original titles
val clonedDoc1 = clonedDocuments.find { it.title == "Document To Clone 1 (Copy)" }
val clonedDoc2 = clonedDocuments.find { it.title == "Document To Clone 2 (Copy)" }

// Verify cloned documents exist and have correct properties
assertTrue(clonedDoc1 != null)
assertTrue(clonedDoc2 != null)

// Verify cloned documents have different IDs from originals
assertTrue(clonedDoc1!!.id != document1.id)
assertTrue(clonedDoc2!!.id != document2.id)

// Verify cloned documents have the same parentId and workspaceId
assertEquals(document1.parentId, clonedDoc1.parentId)
assertEquals(document2.parentId, clonedDoc2.parentId)
assertEquals(workspaceId, clonedDoc1.workspaceId)
assertEquals(workspaceId, clonedDoc2.workspaceId)

// Verify cloned document 1 has content with new IDs
assertEquals(2, clonedDoc1.content.size)
val clonedStep1 = clonedDoc1.content.find { it.text == "message1" }
val clonedStep2 = clonedDoc1.content.find { it.text == "message2" }
assertTrue(clonedStep1 != null)
assertTrue(clonedStep2 != null)
assertTrue(clonedStep1!!.id != "step1") // ID should be different
assertTrue(clonedStep2!!.id != "step2") // ID should be different

// Verify cloned document 2 has content with new IDs
assertEquals(1, clonedDoc2.content.size)
val clonedStep3 = clonedDoc2.content.find { it.text == "content A" }
assertTrue(clonedStep3 != null)
assertTrue(clonedStep3!!.id != "step3") // ID should be different

// Verify cloned documents can be retrieved
val getCloned1 = client.get("/api/workspace/$workspaceId/document/${clonedDoc1.id}")
assertEquals(HttpStatusCode.OK, getCloned1.status)

val getCloned2 = client.get("/api/workspace/$workspaceId/document/${clonedDoc2.id}")
assertEquals(HttpStatusCode.OK, getCloned2.status)

// Verify original documents still exist
val getOriginal1 = client.get("/api/workspace/$workspaceId/document/${document1.id}")
assertEquals(HttpStatusCode.OK, getOriginal1.status)

val getOriginal2 = client.get("/api/workspace/$workspaceId/document/${document2.id}")
assertEquals(HttpStatusCode.OK, getOriginal2.status)

// Clean up
db.deleteDocumentById(document1.id)
db.deleteDocumentById(document2.id)
db.deleteDocumentById(clonedDoc1.id)
db.deleteDocumentById(clonedDoc2.id)
}

@Test
fun `it should return empty list when cloning non-existent documents`() = testApplication {
application {
module(db, debugMode = true)
}

val client = defaultClient()
val workspaceId = Random.nextInt().toString()

val cloneRequest = CloneDocumentsRequest(documentIds = listOf("nonExistent1", "nonExistent2"))

val cloneResponse = client.post("/api/workspace/$workspaceId/document/clone") {
contentType(ContentType.Application.Json)
setBody(cloneRequest)
}
assertEquals(HttpStatusCode.Created, cloneResponse.status)

val clonedDocuments = cloneResponse.body<List<DocumentApi>>()
assertEquals(0, clonedDocuments.size)
}

@Test
fun `it should return bad request when cloning with empty document list`() = testApplication {
application {
module(db, debugMode = true)
}

val client = defaultClient()
val workspaceId = Random.nextInt().toString()

val cloneRequest = CloneDocumentsRequest(documentIds = emptyList())

val cloneResponse = client.post("/api/workspace/$workspaceId/document/clone") {
contentType(ContentType.Application.Json)
setBody(cloneRequest)
}
assertEquals(HttpStatusCode.BadRequest, cloneResponse.status)
}
}
Loading