From 22ef054e1f362d56ab011ec5617111a95cdcbcdc Mon Sep 17 00:00:00 2001 From: Simon Forschner <26634807+Crusader99@users.noreply.github.com> Date: Thu, 12 Aug 2021 00:33:36 +0200 Subject: [PATCH 1/4] Feat: Allow renaming while in document editor (#74) --- .../requests/ReferenceRepositoryImpl.kt | 2 + web-app/build.gradle.kts | 4 +- .../cmt/components/ViewReferenceList.kt | 19 +++-- .../cmt/components/header/DocumentTitle.kt | 81 +++++++++++++++++++ .../cmt/components/header/LabelSearch.kt | 24 +++++- .../cmt/components/header/ViewAppBar.kt | 8 +- .../de/hsaalen/cmt/events/EventTypes.kt | 6 +- .../de/hsaalen/cmt/events/GuiOperations.kt | 10 ++- .../handlers/AuthenticationEventHandlers.kt | 13 ++- .../events/handlers/ReferenceEventHandlers.kt | 18 +++++ .../de/hsaalen/cmt/pages/DocumentEditPage.kt | 2 + .../de/hsaalen/cmt/pages/OverviewPage.kt | 20 +---- 12 files changed, 158 insertions(+), 49 deletions(-) create mode 100644 web-app/src/main/kotlin/de/hsaalen/cmt/components/header/DocumentTitle.kt diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/ReferenceRepositoryImpl.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/ReferenceRepositoryImpl.kt index 771d968..8d5cfb9 100644 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/ReferenceRepositoryImpl.kt +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/ReferenceRepositoryImpl.kt @@ -63,6 +63,8 @@ internal interface ReferenceRepositoryImpl : ClientSupport, ReferenceRepository override suspend fun rename(uuid: UUID, newTitle: String) { if (newTitle.isBlank()) { error("Empty title is not allowed") + } else if (newTitle.length > 50) { + error("Title max length is 50 characters") } val url = Url("$apiEndpoint$apiPathRenameReference") return Client.request(url) { diff --git a/web-app/build.gradle.kts b/web-app/build.gradle.kts index 78415c7..0562939 100755 --- a/web-app/build.gradle.kts +++ b/web-app/build.gradle.kts @@ -28,9 +28,9 @@ dependencies { kotlin { js(IR) { binaries.executable() + useCommonJs() browser { // For continuous integration: gradle browserDevelopmentRun --continuous - useCommonJs() distribution { directory = File("$buildDir/artifact-js/") } @@ -56,7 +56,7 @@ kotlin { // Configure detekt code analyze tool to generate HTML report detekt { - ignoreFailures = true // Currently only print warning + ignoreFailures = true // Only print warning reports { html.enabled = true } diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/components/ViewReferenceList.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/components/ViewReferenceList.kt index 63d12d4..526be91 100755 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/components/ViewReferenceList.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/components/ViewReferenceList.kt @@ -4,12 +4,12 @@ import com.ccfraser.muirwik.components.* import com.ccfraser.muirwik.components.button.mIconButton import com.ccfraser.muirwik.components.table.* import de.crusader.extensions.toDate -import de.crusader.objects.color.Color +import de.crusader.extensions.toTimeSpanStr import de.hsaalen.cmt.events.* import de.hsaalen.cmt.network.dto.objects.Reference import de.hsaalen.cmt.network.dto.server.ServerReferenceListDto -import de.hsaalen.cmt.theme.toCssColor import kotlinx.css.* +import kotlinx.html.currentTimeMillis import react.RBuilder import react.RComponent import react.RProps @@ -18,7 +18,7 @@ import styled.css import styled.styledDiv /** - * Wrapper function to simplify creation of this react component. + * Wrapper function to simplify creation of this React component. */ fun RBuilder.referenceList( dto: ServerReferenceListDto, @@ -66,9 +66,6 @@ class ViewReferenceList : RComponent() { mTableRow { for (column in columns) { mTableCell { - css { - backgroundColor = Color.DARK_GRAY.toCssColor() - } +column } } @@ -103,7 +100,9 @@ class ViewReferenceList : RComponent() { } for (label in ref.labels) { mChip(label, onDelete = { - dispatch(it, EventType.PRE_USER_REMOVE_LABEL, LabelEditEvent(ref, label)) + dispatch(it, EventType.PRE_USER_REMOVE_LABEL, LabelEvent(ref, label)) + }, onClick = { + dispatch(it, EventType.PRE_USER_CLICK_ON_LABEL, LabelEvent(ref, label)) }) { attrs { asDynamic().clickable = true @@ -128,7 +127,11 @@ class ViewReferenceList : RComponent() { } } } - mTableCell { +ref.dateLastAccess.toDate().toDateString() } + mTooltip((currentTimeMillis() - ref.dateLastAccess).toTimeSpanStr() + " ago") { + mTableCell { + +ref.dateLastAccess.toDate().toDateString() + } + } mTableCell(align = MTableCellAlign.right) { mTooltip("Download") { mIconButton("download", onClick = { diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/DocumentTitle.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/DocumentTitle.kt new file mode 100644 index 0000000..c3d9536 --- /dev/null +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/DocumentTitle.kt @@ -0,0 +1,81 @@ +package de.hsaalen.cmt.components.header + +import com.ccfraser.muirwik.components.mTooltip +import com.ccfraser.muirwik.components.mTypography +import de.hsaalen.cmt.events.* +import de.hsaalen.cmt.network.dto.objects.Reference +import de.hsaalen.cmt.network.dto.rsocket.ReferenceUpdateRenameDto +import kotlinx.css.* +import react.* +import styled.css +import styled.styledDiv + +/** + * React state of the [DocumentTitle] component. + */ +external interface DocumentTitleState : RState { + var reference: Reference +} + +/** + * The [DocumentTitle] component allows renaming the title of a document. + */ +@JsExport +class DocumentTitle : RComponent() { + + /** + * Register events for this component. + */ + private val events = GlobalEventDispatcher.createBundle(this) { + register(::onServerRenamedReference) // Event called by server + } + + /** + * Initialize state of the [DocumentTitle]. + */ + override fun DocumentTitleState.init() { + reference = GuiOperations.webApp.state.reference!! + } + + /** + * Remove registered event handlers. + */ + override fun componentWillUnmount() { + events.unregisterAll() + } + + /** + * Called when complete [DocumentTitle] component is rendered. + */ + override fun RBuilder.render() { + mTooltip("Rename") { + mTypography(state.reference.displayName) { + attrs { + onClick = { + launchNotification(EventType.PRE_USER_RENAME_REFERENCE, ReferenceEvent(state.reference)) + } + } + css { + cursor = Cursor.pointer + } + } + } + styledDiv { + css { + position = Position.relative + flex(1.0, 1.0, FlexBasis.auto) + } + } + } + + /** + * Event called by server after a reference got renamed. + */ + private fun onServerRenamedReference(event: ReferenceUpdateRenameDto) { + setState { + reference = reference.copy(displayName = event.newName) + } + } + + +} diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/LabelSearch.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/LabelSearch.kt index 7d49747..0c511c4 100644 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/LabelSearch.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/LabelSearch.kt @@ -5,10 +5,7 @@ import com.ccfraser.muirwik.components.form.MFormControlVariant import com.ccfraser.muirwik.components.lab.mAutoCompleteMultiValue import com.ccfraser.muirwik.components.mTextField import com.ccfraser.muirwik.components.spreadProps -import de.hsaalen.cmt.events.EventType -import de.hsaalen.cmt.events.GlobalEventDispatcher -import de.hsaalen.cmt.events.SearchEvent -import de.hsaalen.cmt.events.launchNotification +import de.hsaalen.cmt.events.* import de.hsaalen.cmt.extensions.launch import de.hsaalen.cmt.extensions.onEnterKey import de.hsaalen.cmt.extensions.onTextChange @@ -41,6 +38,7 @@ class LabelSearch : RComponent() { */ private val events = GlobalEventDispatcher.createBundle(this) { register(::onServerLabelUpdate) // Event called by server + register(EventType.PRE_USER_CLICK_ON_LABEL, ::onUserClickOnLabel) // Event called by user launch { val labels = Session.instance!!.listLabels() setState { @@ -123,6 +121,9 @@ class LabelSearch : RComponent() { launchNotification(EventType.PRE_USER_MODIFY_SEARCH, event) } } + css { + backgroundColor = Color.white + } } /** @@ -137,4 +138,19 @@ class LabelSearch : RComponent() { } } + /** + * Event called by client after clicking on a label in reference list. + */ + private fun onUserClickOnLabel(event: LabelEvent) { + if (event.labelName in state.filterLabels) { + return // Skip because already added as filter + } + val filters = state.filterLabels + event.labelName + setState { + filterLabels = filters + } + val newEvent = SearchEvent(state.searchText, filters.toSet()) + launchNotification(EventType.PRE_USER_MODIFY_SEARCH, newEvent) + } + } diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/ViewAppBar.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/ViewAppBar.kt index 27fdd4e..872b002 100755 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/ViewAppBar.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/components/header/ViewAppBar.kt @@ -5,9 +5,11 @@ import com.ccfraser.muirwik.components.button.mIconButton import com.ccfraser.muirwik.components.list.mList import com.ccfraser.muirwik.components.list.mListItem import com.ccfraser.muirwik.components.list.mListItemText +import de.hsaalen.cmt.EnumPageType import de.hsaalen.cmt.SoftwareInfo import de.hsaalen.cmt.components.dialogs.aboutSoftwareDialog import de.hsaalen.cmt.events.EventType +import de.hsaalen.cmt.events.GuiOperations import de.hsaalen.cmt.events.launchNotification import de.hsaalen.cmt.network.session.Session import kotlinx.browser.window @@ -73,7 +75,11 @@ class ViewAppBar : RComponent() { }) } if (props.isLoggedIn) { - child(LabelSearch::class) {} + if (GuiOperations.page == EnumPageType.OVERVIEW) { + child(LabelSearch::class) {} + } else if (GuiOperations.page == EnumPageType.EDIT_DOCUMENT) { + child(DocumentTitle::class) {} + } if (window.innerWidth > 500) { // Only print username when on large page Session.instance?.userInfo?.let { userInfo -> mTooltip("Logged in as " + userInfo.email) { diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/events/EventTypes.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/events/EventTypes.kt index d70f1c5..066fa7d 100644 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/events/EventTypes.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/events/EventTypes.kt @@ -18,6 +18,7 @@ enum class EventType { START_KEEP_ALIVE_JOB, PRE_USER_ADD_LABEL, PRE_USER_REMOVE_LABEL, + PRE_USER_CLICK_ON_LABEL, PRE_USER_OPEN_REFERENCE, PRE_USER_DOWNLOAD_REFERENCE, PRE_USER_DELETE_REFERENCE, @@ -30,19 +31,16 @@ enum class EventType { */ data class ReferenceEvent(val reference: Reference) : Event - /** * [Event] class for an event handler related to a label modification. */ -data class LabelEditEvent(val reference: Reference, val labelName: String) : Event - +data class LabelEvent(val reference: Reference, val labelName: String) : Event /** * Specific event type for the login event to allow passing parameters. */ data class LoginEvent(val credentials: Credentials, val isRegistration: Boolean) : Event - /** * Specific event type for the search event to allow searching for specific references. */ diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/events/GuiOperations.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/events/GuiOperations.kt index ebfc1ab..73e812c 100644 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/events/GuiOperations.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/events/GuiOperations.kt @@ -66,10 +66,12 @@ object GuiOperations { /** * Determinate which page should be printed on web app main screen. */ - fun setPage(pageType: EnumPageType) { - webApp.setState { - page = pageType + var page: EnumPageType + get() = webApp.state.page + set(newPageType) { + webApp.setState { + page = newPageType + } } - } } diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/AuthenticationEventHandlers.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/AuthenticationEventHandlers.kt index 01c65f0..41aff2a 100644 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/AuthenticationEventHandlers.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/AuthenticationEventHandlers.kt @@ -41,18 +41,17 @@ object AuthenticationEventHandlers { * Configures the React state to open the connecting page. */ private suspend fun onReconnect() { - GuiOperations.setPage(EnumPageType.CONNECTING) + GuiOperations.page = EnumPageType.CONNECTING GuiOperations.loading { try { logger.info { "Try restoring session...." } val restoredSession = Session.restore() // Only print overview page when session restore was successful - val nextPage = if (restoredSession) EnumPageType.OVERVIEW else EnumPageType.AUTHENTICATION - GuiOperations.setPage(nextPage) + GuiOperations.page = if (restoredSession) EnumPageType.OVERVIEW else EnumPageType.AUTHENTICATION launchNotification(EventType.START_KEEP_ALIVE_JOB) } catch (ex: ConnectException) { delay(2000) - GuiOperations.setPage(EnumPageType.UNAVAILABLE) + GuiOperations.page = EnumPageType.UNAVAILABLE } catch (ex: Exception) { // Ignore other errors logger.error(ex) { "Unable to restore session" } @@ -93,14 +92,14 @@ object AuthenticationEventHandlers { try { GuiOperations.loading { Session.instance?.logout() - GuiOperations.setPage(EnumPageType.AUTHENTICATION) + GuiOperations.page = EnumPageType.AUTHENTICATION coroutines.launch { GuiOperations.showSnackBar("Logged out", MAlertSeverity.success) } delay(400) } } finally { - GuiOperations.setPage(EnumPageType.AUTHENTICATION) + GuiOperations.page = EnumPageType.AUTHENTICATION } } @@ -122,7 +121,7 @@ object AuthenticationEventHandlers { Session.login(event.credentials.email, event.credentials.password) } } - GuiOperations.setPage(EnumPageType.OVERVIEW) + GuiOperations.page = EnumPageType.OVERVIEW } coroutines.launch { GuiOperations.showSnackBar("Successfully logged in!", MAlertSeverity.success) diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt index 21d7ed0..97799fa 100644 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt @@ -32,6 +32,7 @@ object ReferenceEventHandlers { register(EventType.PRE_CREATE_NEW_DOCUMENT, ReferenceEventHandlers::onCreateReference) register(EventType.PRE_FILE_UPLOAD, ReferenceEventHandlers::onUploadFile) register(EventType.PRE_USER_OPEN_REFERENCE, ReferenceEventHandlers::onReferenceOpen) + register(EventType.PRE_USER_RENAME_REFERENCE, ::onClientReferenceRename) } } @@ -125,4 +126,21 @@ object ReferenceEventHandlers { } } + /** + * Request server to rename a reference. + */ + private suspend fun onClientReferenceRename(event: ReferenceEvent) { + val oldTitle = event.reference.displayName + val message = "New title for reference '$oldTitle':" + val newTitle = GuiOperations.showInputDialog("Rename", message, defaultValue = oldTitle) + try { + if (oldTitle != newTitle) { + Session.instance?.rename(event.reference.uuid, newTitle ?: return) + } + } catch (ex: Exception) { + val error = ex.message ?: "Unable to rename reference" + GuiOperations.showSnackBar(error, MAlertSeverity.warning) + } + } + } diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/pages/DocumentEditPage.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/pages/DocumentEditPage.kt index f439d6e..74eaac1 100755 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/pages/DocumentEditPage.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/pages/DocumentEditPage.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.css.* import kotlinx.css.properties.BoxShadows +import kotlinx.css.properties.LineHeight import kotlinx.html.js.onInputFunction import mu.KotlinLogging import org.w3c.dom.HTMLTextAreaElement @@ -113,6 +114,7 @@ class DocumentEditPage : RComponent() { // Clientside events register(EventType.PRE_USER_DELETE_REFERENCE, ::onClientReferenceDelete) register(EventType.PRE_USER_DOWNLOAD_REFERENCE, ::onClientReferenceDownload) - register(EventType.PRE_USER_RENAME_REFERENCE, ::onClientReferenceRename) register(EventType.PRE_USER_ADD_LABEL, ::onClientLabelAdd) register(EventType.PRE_USER_REMOVE_LABEL, ::onClientLabelRemove) register(EventType.PRE_USER_MODIFY_SEARCH, ::onClientChangeSearch) @@ -140,23 +139,6 @@ class OverviewPage : RComponent() { Session.instance?.deleteReference(event.reference) } - /** - * Request server to rename a reference. - */ - private suspend fun onClientReferenceRename(event: ReferenceEvent) { - val oldTitle = event.reference.displayName - val message = "New title for reference '$oldTitle':" - val newTitle = GuiOperations.showInputDialog("Rename", message, defaultValue = oldTitle) - try { - if (oldTitle != newTitle) { - Session.instance?.rename(event.reference.uuid, newTitle ?: return) - } - } catch (ex: Exception) { - val error = ex.message ?: "Unable to rename reference" - GuiOperations.showSnackBar(error, MAlertSeverity.warning) - } - } - /** * Called when user adds a label to a reference. */ @@ -178,7 +160,7 @@ class OverviewPage : RComponent() { /** * Called when user removes a label from a reference. */ - private suspend fun onClientLabelRemove(event: LabelEditEvent) { + private suspend fun onClientLabelRemove(event: LabelEvent) { Session.instance?.removeLabel(event.reference, event.labelName) } From b73820eb904f86de28facf8ef18d95837e94e26b Mon Sep 17 00:00:00 2001 From: Simon Forschner <26634807+Crusader99@users.noreply.github.com> Date: Sat, 14 Aug 2021 03:49:28 +0200 Subject: [PATCH 2/4] Fix: Display "Successfully imported" after cancel (Close #84) --- backend-environment/build.gradle.kts | 2 +- backend-server/build.gradle.kts | 2 +- redeploy.sh | 2 +- .../hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt | 8 +++++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/backend-environment/build.gradle.kts b/backend-environment/build.gradle.kts index 320f74a..d922b82 100755 --- a/backend-environment/build.gradle.kts +++ b/backend-environment/build.gradle.kts @@ -23,7 +23,7 @@ configurations.all { // Configure detekt code analyze tool to generate HTML report detekt { - ignoreFailures = true // Currently only print warning + ignoreFailures = true // Only print warning reports { html.enabled = true } diff --git a/backend-server/build.gradle.kts b/backend-server/build.gradle.kts index ebde7c6..f9669bf 100755 --- a/backend-server/build.gradle.kts +++ b/backend-server/build.gradle.kts @@ -70,7 +70,7 @@ tasks.test { // Configure detekt code analyze tool to generate HTML report detekt { - ignoreFailures = true // Currently, only print warning + ignoreFailures = true // Only print warning reports { html.enabled = true } diff --git a/redeploy.sh b/redeploy.sh index 7abcc11..9e4756a 100755 --- a/redeploy.sh +++ b/redeploy.sh @@ -15,4 +15,4 @@ docker-compose down & echo Execute gradle using user \'$(users)\'... su $(users) -c "./gradlew --stop" # Ensure other gradle sessions are closed chown $(users) . -R # Ensure Gradle has user permissions for building -su $(users) -c "./gradlew build -x detekt -x test" && docker-compose up --build -d +su $(users) -c "./gradlew build -x detekt -x test -x browserTest" && docker-compose up --build -d diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt index 97799fa..d167fd5 100644 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/events/handlers/ReferenceEventHandlers.kt @@ -51,15 +51,21 @@ object ReferenceEventHandlers { } try { + var importCount = 0 GuiOperations.loading { for (file in GuiOperations.showFileSelector()) { logger.info { "Importing " + file.name + "..." } val text = file.readText() importFile(file.name, text) logger.info { file.name + " successfully imported" } + importCount++ } } - GuiOperations.showSnackBar("Successfully imported", MAlertSeverity.success) + if (importCount > 0) { + GuiOperations.showSnackBar("Successfully imported $importCount documents", MAlertSeverity.success) + } else { + GuiOperations.showSnackBar("Import cancelled", MAlertSeverity.warning) + } } catch (ex: Exception) { logger.warn(ex) { "Document import failed" } GuiOperations.showSnackBar(ex.message ?: return, MAlertSeverity.warning) From c2a48548d2635f73fdf74ec6b536a4f5c360ddcc Mon Sep 17 00:00:00 2001 From: Simon Forschner <26634807+Crusader99@users.noreply.github.com> Date: Sat, 14 Aug 2021 06:40:58 +0200 Subject: [PATCH 3/4] Fix: Label still in existing after deleted references (Close #85 ) --- .../cmt/repository/FileRepositoryImpl.kt | 22 +++++++++++++++---- .../cmt/repository/LabelRepositoryImpl.kt | 6 ++--- .../cmt/repository/ReferenceRepositoryImpl.kt | 17 ++++++++++---- .../network/requests/FileRepositoryImpl.kt | 8 +++---- .../hsaalen/cmt/repository/FileRepository.kt | 4 ++-- .../src/main/kotlin/de/hsaalen/cmt/WebApp.kt | 6 ++--- 6 files changed, 43 insertions(+), 20 deletions(-) diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/FileRepositoryImpl.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/FileRepositoryImpl.kt index 10ef5ca..cc385ea 100644 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/FileRepositoryImpl.kt +++ b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/FileRepositoryImpl.kt @@ -1,7 +1,11 @@ package de.hsaalen.cmt.repository import de.hsaalen.cmt.network.dto.objects.UUID +import de.hsaalen.cmt.session.currentSession +import de.hsaalen.cmt.sql.schema.ReferenceDao import de.hsaalen.cmt.storage.StorageS3 +import de.hsaalen.cmt.utils.id +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction /** * Server implementation of the file repository to provide access to the AWS S3 file storage. @@ -12,16 +16,26 @@ internal object FileRepositoryImpl : FileRepository { * Download the reference content by a specific [UUID]. */ override suspend fun download(uuid: UUID): ByteArray { - // TODO: Ensure user has edit permissions for that file + checkHasPermissions(currentSession.userMail, uuid) return StorageS3.downloadFile(uuid).readBytes() } /** * Upload or overwrite the reference content by a specific [UUID]. */ - override suspend fun upload(uuid: UUID, content: ByteArray) { - // TODO: Ensure user has edit permissions for that file - StorageS3.uploadFile(uuid, content.inputStream(), content.size.toLong()) + override suspend fun upload(reference: UUID, content: ByteArray) { + checkHasPermissions(currentSession.userMail, reference) + StorageS3.uploadFile(reference, content.inputStream(), content.size.toLong()) + } + + /** + * Ensure user has edit permissions for that file to upload/download. + */ + private suspend fun checkHasPermissions(userMail: String, reference: UUID) = newSuspendedTransaction { + val ref = ReferenceDao.findById(reference.id) ?: error("No reference with uuid=$reference found!") + if (ref.owner.email != userMail) { + throw SecurityException("Can not access references from different users!") + } } } diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/LabelRepositoryImpl.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/LabelRepositoryImpl.kt index 7f92bd8..bed5953 100755 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/LabelRepositoryImpl.kt +++ b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/LabelRepositoryImpl.kt @@ -61,12 +61,12 @@ internal object LabelRepositoryImpl : LabelRepository { val label = findLabel(creator, labelName) ?: error("Label not found") // Remove label from reference - val removeQuery = + LabelRefMappingTable.deleteWhere { (LabelRefMappingTable.label eq label.id) and (LabelRefMappingTable.reference eq ref.id) - LabelRefMappingTable.deleteWhere { removeQuery } + } // Cleanup label when used nowhere - if (LabelRefMappingDao.find(LabelRefMappingTable.label eq label.id).count() == 0L) { + if (LabelRefMappingDao.find(LabelRefMappingTable.label eq label.id).none()) { LabelTable.deleteWhere { LabelTable.id eq label.id } } } diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/ReferenceRepositoryImpl.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/ReferenceRepositoryImpl.kt index c6aa53e..1095cde 100755 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/ReferenceRepositoryImpl.kt +++ b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/ReferenceRepositoryImpl.kt @@ -13,15 +13,14 @@ import de.hsaalen.cmt.network.dto.rsocket.ReferenceUpdateRemoveDto import de.hsaalen.cmt.network.dto.rsocket.ReferenceUpdateRenameDto import de.hsaalen.cmt.network.dto.server.ServerReferenceListDto import de.hsaalen.cmt.session.currentSession -import de.hsaalen.cmt.sql.schema.ReferenceDao -import de.hsaalen.cmt.sql.schema.ReferenceTable -import de.hsaalen.cmt.sql.schema.RevisionDao -import de.hsaalen.cmt.sql.schema.UserDao +import de.hsaalen.cmt.sql.schema.* import de.hsaalen.cmt.storage.StorageS3 import de.hsaalen.cmt.utils.id import de.hsaalen.cmt.utils.toUUID import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.jetbrains.exposed.sql.upperCase import org.joda.time.DateTime @@ -124,6 +123,16 @@ internal object ReferenceRepositoryImpl : ReferenceRepository { } contentType = ref.contentType + + // Cleanup labels when used nowhere + for (label in ref.labels) { + if (LabelRefMappingDao.find(LabelRefMappingTable.label eq label.id).count() <= 1) { + // Delete label when this is the only reference, which will be deleted now + LabelTable.deleteWhere { LabelTable.id eq label.id } + } + } + + // Remove actual reference element ref.delete() } diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/FileRepositoryImpl.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/FileRepositoryImpl.kt index eb65320..4518cfa 100644 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/FileRepositoryImpl.kt +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/FileRepositoryImpl.kt @@ -17,8 +17,8 @@ internal interface FileRepositoryImpl : ClientSupport, FileRepository { /** * Download the reference content by a specific [UUID]. */ - override suspend fun download(uuid: UUID): ByteArray { - val url = Url("$apiEndpoint$apiPathDownloadFile/$uuid") + override suspend fun download(reference: UUID): ByteArray { + val url = Url("$apiEndpoint$apiPathDownloadFile/$reference") val encryptedContent: ByteArray = Client.request(url) { method = HttpMethod.Get } @@ -28,8 +28,8 @@ internal interface FileRepositoryImpl : ClientSupport, FileRepository { /** * Upload or overwrite the reference content by a specific [UUID]. */ - override suspend fun upload(uuid: UUID, content: ByteArray) { - val url = Url("$apiEndpoint$apiPathUploadFile/$uuid") + override suspend fun upload(reference: UUID, content: ByteArray) { + val url = Url("$apiEndpoint$apiPathUploadFile/$reference") val encryptedContent = encrypt(content) return Client.request(url) { method = HttpMethod.Post diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/repository/FileRepository.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/repository/FileRepository.kt index 6cdcc10..844de9b 100644 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/repository/FileRepository.kt +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/repository/FileRepository.kt @@ -12,11 +12,11 @@ interface FileRepository { /** * Download the reference content by a specific [UUID]. */ - suspend fun download(uuid: UUID): ByteArray + suspend fun download(reference: UUID): ByteArray /** * Upload or overwrite the reference content by a specific [UUID]. */ - suspend fun upload(uuid: UUID, content: ByteArray) + suspend fun upload(reference: UUID, content: ByteArray) } diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/WebApp.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/WebApp.kt index 92fd9ce..ac120a4 100755 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/WebApp.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/WebApp.kt @@ -105,7 +105,7 @@ class WebApp : RComponent() { } } EnumPageType.OVERVIEW -> { - val localSession = Session.instance!! // TODO: exception handling + val localSession = Session.instance ?: return@child launchNotification(EventType.PRE_LOGOUT) // When already logged in child(OverviewPage::class) { attrs { @@ -114,8 +114,8 @@ class WebApp : RComponent() { } } EnumPageType.EDIT_DOCUMENT -> { - val localSession = Session.instance!! // TODO: exception handling - val ref = state.reference!! + val localSession = Session.instance ?: return@child launchNotification(EventType.PRE_LOGOUT) + val ref = state.reference ?: return@child launchNotification(EventType.PRE_LOGOUT) child(DocumentEditPage::class) { attrs { session = localSession From a39ff1a71d7667f5f7a608742202b663fb31595b Mon Sep 17 00:00:00 2001 From: Simon Forschner <26634807+Crusader99@users.noreply.github.com> Date: Sun, 15 Aug 2021 19:26:53 +0200 Subject: [PATCH 4/4] Feat: Synchronize cursor position (Close #62) --- .../cmt/repository/DocumentRepositoryImpl.kt | 15 ++- .../cmt/repository/FileRepositoryImpl.kt | 2 + .../cmt/repository/ReferenceRepositoryImpl.kt | 13 +-- .../kotlin/de/hsaalen/cmt/sql/Postgresql.kt | 2 +- .../hsaalen/cmt/sql/schema/ReferenceSchema.kt | 3 +- .../hsaalen/cmt/sql/schema/RevisionSchema.kt | 38 ------- .../de/hsaalen/cmt/rsocket/Connection.kt | 21 ++-- build.gradle.kts | 8 ++ .../de/hsaalen/cmt/events/ListenerBundle.kt | 19 +++- .../events/server/UserDocumentActionEvent.kt | 17 +++ .../events/server/UserDocumentChangeEvent.kt | 15 --- .../cmt/network/dto/objects/Reference.kt | 1 - .../network/dto/rsocket/CursorUpdateDto.kt | 32 ++++++ .../requests/DocumentRepositoryImpl.kt | 4 +- .../de/hsaalen/cmt/network/session/Session.kt | 5 +- .../cmt/repository/DocumentRepository.kt | 2 +- .../de/hsaalen/cmt/utils/CursorUtils.kt | 26 +++++ docker-compose.yml | 9 +- .../cmt/components/documenteditor/Engine.kt | 7 ++ .../documenteditor/TextareaEngine.kt | 104 +++++++++++++++++- .../de/hsaalen/cmt/pages/DocumentEditPage.kt | 46 +++++++- web-app/src/test/kotlin/CursorUpdateTest.kt | 39 +++++++ web-app/src/test/kotlin/TestDiffCalculator.kt | 20 ---- 23 files changed, 320 insertions(+), 128 deletions(-) delete mode 100755 backend-database/src/main/kotlin/de/hsaalen/cmt/sql/schema/RevisionSchema.kt create mode 100644 common/src/commonMain/kotlin/de/hsaalen/cmt/events/server/UserDocumentActionEvent.kt delete mode 100644 common/src/commonMain/kotlin/de/hsaalen/cmt/events/server/UserDocumentChangeEvent.kt create mode 100755 common/src/commonMain/kotlin/de/hsaalen/cmt/network/dto/rsocket/CursorUpdateDto.kt create mode 100644 common/src/commonMain/kotlin/de/hsaalen/cmt/utils/CursorUtils.kt create mode 100644 web-app/src/test/kotlin/CursorUpdateTest.kt delete mode 100755 web-app/src/test/kotlin/TestDiffCalculator.kt diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/DocumentRepositoryImpl.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/DocumentRepositoryImpl.kt index 7f71cd3..99dc535 100644 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/DocumentRepositoryImpl.kt +++ b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/DocumentRepositoryImpl.kt @@ -2,7 +2,7 @@ package de.hsaalen.cmt.repository import com.mongodb.client.model.PushOptions import de.hsaalen.cmt.events.GlobalEventDispatcher -import de.hsaalen.cmt.events.server.UserDocumentChangeEvent +import de.hsaalen.cmt.events.server.UserDocumentActionEvent import de.hsaalen.cmt.mongo.MongoDB import de.hsaalen.cmt.mongo.TextDocument import de.hsaalen.cmt.network.dto.objects.ContentType @@ -14,6 +14,7 @@ import de.hsaalen.cmt.session.senderSocketId import de.hsaalen.cmt.sql.schema.ReferenceDao import de.hsaalen.cmt.utils.id import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.joda.time.DateTime import org.litote.kmongo.* /** @@ -45,19 +46,20 @@ internal object DocumentRepositoryImpl : DocumentRepository { } // Notify event listeners - val event = UserDocumentChangeEvent(request, currentSession.userMail, currentSession.senderSocketId) - GlobalEventDispatcher.notify(event) + UserDocumentActionEvent(request, request.uuid, currentSession.userMail, currentSession.senderSocketId).let { + GlobalEventDispatcher.notify(it) + } } /** * Download the content of a specific reference by uuid. */ - override suspend fun downloadDocument(uuid: UUID): String { + override suspend fun downloadDocument(reference: UUID): String { // Ensure user has permissions to access this document - checkAccess(currentSession.userMail, uuid) + checkAccess(currentSession.userMail, reference) // Read document content from MongoDB - return MongoDB.getDocumentContent(uuid.value) + return MongoDB.getDocumentContent(reference.value) } /** @@ -69,6 +71,7 @@ internal object DocumentRepositoryImpl : DocumentRepository { val ref = ReferenceDao.findById(reference.id) ?: error("Reference not found: $reference") check(ref.owner.email == userMail) { "No permissions to access document" } check(ref.contentType == ContentType.TEXT) { "Type " + ref.contentType.name + " is no document" } + ref.dateLastModified = DateTime.now() } } diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/FileRepositoryImpl.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/FileRepositoryImpl.kt index cc385ea..f095591 100644 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/FileRepositoryImpl.kt +++ b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/FileRepositoryImpl.kt @@ -6,6 +6,7 @@ import de.hsaalen.cmt.sql.schema.ReferenceDao import de.hsaalen.cmt.storage.StorageS3 import de.hsaalen.cmt.utils.id import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.joda.time.DateTime /** * Server implementation of the file repository to provide access to the AWS S3 file storage. @@ -36,6 +37,7 @@ internal object FileRepositoryImpl : FileRepository { if (ref.owner.email != userMail) { throw SecurityException("Can not access references from different users!") } + ref.dateLastModified = DateTime.now() } } diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/ReferenceRepositoryImpl.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/ReferenceRepositoryImpl.kt index 1095cde..65d423f 100755 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/ReferenceRepositoryImpl.kt +++ b/backend-database/src/main/kotlin/de/hsaalen/cmt/repository/ReferenceRepositoryImpl.kt @@ -43,29 +43,18 @@ internal object ReferenceRepositoryImpl : ReferenceRepository { val ref: Reference = newSuspendedTransaction { // Create document in SQL val creator = UserDao.findUserByEmail(userEmail) - val now = DateTime.now() val reference = ReferenceDao.new { this.displayName = request.displayName this.contentType = request.contentType this.owner = creator this.dateLastModified = DateTime.now() } - val revision = RevisionDao.new { - this.item = reference - this.index = 0 - - this.dateCreation = now - this.dateLastAccess = now - this.creator = creator - this.accessCount = 0 - } Reference( uuid = reference.id.toUUID(), displayName = reference.displayName, contentType = reference.contentType, - dateCreation = revision.dateCreation.millis, - dateLastAccess = revision.dateLastAccess.millis, + dateLastAccess = reference.dateLastModified.millis, labels = request.labels.toMutableSet() ) } diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/Postgresql.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/Postgresql.kt index 974480f..874ea83 100755 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/Postgresql.kt +++ b/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/Postgresql.kt @@ -37,7 +37,7 @@ internal object Postgresql { // Creates the tables when not existing // Also used to test the connection to database - SchemaUtils.create(UserTable, ReferenceTable, RevisionTable, LabelTable, LabelRefMappingTable) + SchemaUtils.create(UserTable, ReferenceTable, LabelTable, LabelRefMappingTable) } } diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/schema/ReferenceSchema.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/schema/ReferenceSchema.kt index ca234e6..5fbbb57 100755 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/schema/ReferenceSchema.kt +++ b/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/schema/ReferenceSchema.kt @@ -37,8 +37,7 @@ class ReferenceDao(id: EntityID) : UUIDEntity(id) { * Convert [ReferenceDao] to [Reference] instance to be transmitted oer network. */ fun toReference(): Reference { - val now = System.currentTimeMillis() val labels = labels.map { it.labelName }.toMutableSet() - return Reference(id.toUUID(), displayName, contentType, now, now, labels) + return Reference(id.toUUID(), displayName, contentType, dateLastModified.millis, labels) } } diff --git a/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/schema/RevisionSchema.kt b/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/schema/RevisionSchema.kt deleted file mode 100755 index 5b4f1d4..0000000 --- a/backend-database/src/main/kotlin/de/hsaalen/cmt/sql/schema/RevisionSchema.kt +++ /dev/null @@ -1,38 +0,0 @@ -package de.hsaalen.cmt.sql.schema - -import org.jetbrains.exposed.dao.UUIDEntity -import org.jetbrains.exposed.dao.UUIDEntityClass -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.UUIDTable -import org.jetbrains.exposed.sql.ReferenceOption -import org.jetbrains.exposed.sql.jodatime.datetime -import java.util.* - -/** - * The postgresql table of the revision data. - */ -object RevisionTable : UUIDTable("revision") { - val item = reference("item", ReferenceTable, onDelete = ReferenceOption.CASCADE) // primary - var index = integer("index") // primary - - val dateCreation = datetime("date_creation") - val dateLastAccess = datetime("date_last_access") - val creator = reference("creator", UserTable) - val accessCount = long("access_count") -} - -/** - * A data access object for a revision instance. - */ -class RevisionDao(id: EntityID) : UUIDEntity(id) { - companion object : UUIDEntityClass(RevisionTable) - - var item by ReferenceDao referencedOn RevisionTable.item - var index by RevisionTable.index - - var dateCreation by RevisionTable.dateCreation - var dateLastAccess by RevisionTable.dateLastAccess - var creator by UserDao referencedOn RevisionTable.creator - var accessCount by RevisionTable.accessCount - -} diff --git a/backend-server/src/main/kotlin/de/hsaalen/cmt/rsocket/Connection.kt b/backend-server/src/main/kotlin/de/hsaalen/cmt/rsocket/Connection.kt index 85aebaa..50723f8 100644 --- a/backend-server/src/main/kotlin/de/hsaalen/cmt/rsocket/Connection.kt +++ b/backend-server/src/main/kotlin/de/hsaalen/cmt/rsocket/Connection.kt @@ -1,14 +1,11 @@ package de.hsaalen.cmt.rsocket import de.hsaalen.cmt.events.GlobalEventDispatcher -import de.hsaalen.cmt.events.server.UserDocumentChangeEvent +import de.hsaalen.cmt.events.server.UserDocumentActionEvent import de.hsaalen.cmt.extensions.launch import de.hsaalen.cmt.network.dto.objects.LabelChangeMode import de.hsaalen.cmt.network.dto.objects.LineChangeMode -import de.hsaalen.cmt.network.dto.rsocket.DocumentChangeDto -import de.hsaalen.cmt.network.dto.rsocket.LabelUpdateDto -import de.hsaalen.cmt.network.dto.rsocket.LiveDto -import de.hsaalen.cmt.network.dto.rsocket.RequestReferenceDto +import de.hsaalen.cmt.network.dto.rsocket.* import de.hsaalen.cmt.repository.DocumentRepository import de.hsaalen.cmt.repository.LabelRepository import de.hsaalen.cmt.session.jwt.JwtPayload @@ -102,8 +99,11 @@ class Connection(socket: RSocket, private val payload: JwtPayload, val jwtToken: withWebSocketSession(userEmail, socketId) { input.collect { try { - val dto: DocumentChangeDto = it.decodeProtobufData() - docRepo.modifyDocument(dto) + when (val dto: LiveDto = it.decodeProtobufData()) { + is DocumentChangeDto -> docRepo.modifyDocument(dto) + is CursorUpdateDto -> GlobalEventDispatcher.notify(UserDocumentActionEvent(dto.copy(cursorOwner = de.hsaalen.cmt.network.dto.objects.UUID(socketId)), documentUUID, userEmail, socketId)) // Notify other clients + else -> logger.warn { "Unknown document modification: " + dto::class.simpleName } + } } catch (ex: Exception) { logger.error("Unable to handle document change", ex) } @@ -112,18 +112,19 @@ class Connection(socket: RSocket, private val payload: JwtPayload, val jwtToken: } // Get modifications from other clients as stream - val eventFlow = events.receiveEventsAsFlow() + val eventFlow = events.receiveEventsAsFlow() .filter { it.senderSocketId != socketId } + .filter { it.reference == documentUUID } .map { it.modification } - .filter { it.uuid == documentUUID } // Provide live flow synchronisation - channelFlow { + channelFlow{ documentFlow.collect { send(it) } eventFlow.collect { send(it) } }.onCompletion { logger.info("Cancel document editing") events.unregisterAll() + GlobalEventDispatcher.notify(UserDocumentActionEvent(CursorUpdateDto(de.hsaalen.cmt.network.dto.objects.UUID(socketId), null), documentUUID, userEmail, socketId)) }.map { it.buildPayload() } } } diff --git a/build.gradle.kts b/build.gradle.kts index 7937937..71b3b30 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,3 +43,11 @@ tasks.dokkaHtmlMultiModule { outputDirectory.set(buildDir.resolve("api-doc")) includes.from("README.md") // Print on index page } + +// Execute this task to generate API documentation. +val docs by tasks.registering { + dependsOn(tasks.dokkaHtmlMultiModule) +} + +tasks.withType().configureEach { dependsOn("assemble") } +tasks.withType().configureEach { dependsOn("assemble") } diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/events/ListenerBundle.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/events/ListenerBundle.kt index 115f7d0..4c3fadd 100644 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/events/ListenerBundle.kt +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/events/ListenerBundle.kt @@ -4,6 +4,7 @@ import io.ktor.utils.io.core.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlin.reflect.KClass /** @@ -38,10 +39,20 @@ class ListenerBundle(val caller: KClass<*>?) { * Will automatically be closed when [ListenerBundle] is unregistered. */ inline fun receiveEventsAsFlow(): Flow { - val ch = Channel() - listeners += EventHandler(SpecificEvent::class) { - if (it is SpecificEvent) { - ch.send(it) + return receiveEventsAsFlow(SpecificEvent::class).filterIsInstance() + } + + /** + * Build a flow of events. The flow will suspend until a new event occurred. + * Will automatically be closed when [ListenerBundle] is unregistered. + */ + fun receiveEventsAsFlow(vararg eventTypes: KClass): Flow { + val ch = Channel() + for (eventType in eventTypes) { + listeners += EventHandler(eventType) { + if (eventType.isInstance(it)) { + ch.send(it) + } } } scopeElements += object : Closeable { diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/events/server/UserDocumentActionEvent.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/events/server/UserDocumentActionEvent.kt new file mode 100644 index 0000000..785ab83 --- /dev/null +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/events/server/UserDocumentActionEvent.kt @@ -0,0 +1,17 @@ +package de.hsaalen.cmt.events.server + +import de.hsaalen.cmt.events.Event +import de.hsaalen.cmt.network.dto.objects.UUID +import de.hsaalen.cmt.network.dto.rsocket.LiveDto +import kotlinx.serialization.Serializable + +/** + * Event to be called when a user changes the cursor position. + */ +@Serializable +data class UserDocumentActionEvent( + val modification: LiveDto, + val reference: UUID, + val senderEmail: String, + val senderSocketId: String, +) : Event diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/events/server/UserDocumentChangeEvent.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/events/server/UserDocumentChangeEvent.kt deleted file mode 100644 index 3a839a6..0000000 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/events/server/UserDocumentChangeEvent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package de.hsaalen.cmt.events.server - -import de.hsaalen.cmt.events.Event -import de.hsaalen.cmt.network.dto.rsocket.DocumentChangeDto -import kotlinx.serialization.Serializable - -/** - * Event to be called when a user modified a line of a document. - */ -@Serializable -data class UserDocumentChangeEvent( - val modification: DocumentChangeDto, - val senderEmail: String, - val senderSocketId: String, -) : Event diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/dto/objects/Reference.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/dto/objects/Reference.kt index a676043..e8f402d 100755 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/dto/objects/Reference.kt +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/dto/objects/Reference.kt @@ -13,7 +13,6 @@ data class Reference( val uuid: UUID, var displayName: String, // Allow renaming the reference val contentType: ContentType, - val dateCreation: Long, val dateLastAccess: Long, val labels: MutableSet ) : Encryptable { diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/dto/rsocket/CursorUpdateDto.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/dto/rsocket/CursorUpdateDto.kt new file mode 100755 index 0000000..118cc29 --- /dev/null +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/dto/rsocket/CursorUpdateDto.kt @@ -0,0 +1,32 @@ +package de.hsaalen.cmt.network.dto.rsocket + +import de.hsaalen.cmt.network.dto.objects.UUID +import kotlinx.serialization.Serializable + +/** + * Data transfer object for updating the cursor position in a text document. + */ +@Serializable +data class CursorUpdateDto( + /** + * Unique ID to identify the owner of the cursor. + */ + val cursorOwner: UUID = UUID("?"), + + /** + * The new index of the cursor. May be null, when owner of the cursor leaves the session. + */ + val cursorIndex: Int?, +) : LiveDto() { + + /** + * Cursor positions are not sensible information, so they are not encrypted so return same instance. + */ + override fun encrypt() = this + + /** + * Cursor positions are not sensible information, so they are not decrypted. + */ + override fun decrypt() = this + +} diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/DocumentRepositoryImpl.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/DocumentRepositoryImpl.kt index fd75c3f..e6733c6 100644 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/DocumentRepositoryImpl.kt +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/requests/DocumentRepositoryImpl.kt @@ -27,8 +27,8 @@ internal interface DocumentRepositoryImpl : ClientSupport, DocumentRepository { /** * Download the content of a specific reference by uuid. */ - override suspend fun downloadDocument(uuid: UUID): String { - val url = Url("$apiEndpoint/$apiPathDownloadDocument/$uuid") + override suspend fun downloadDocument(reference: UUID): String { + val url = Url("$apiEndpoint/$apiPathDownloadDocument/$reference") val encryptedText: String = Client.request(url) { method = HttpMethod.Get } diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/session/Session.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/session/Session.kt index d5e8ab0..b4a922d 100755 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/network/session/Session.kt +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/network/session/Session.kt @@ -3,7 +3,6 @@ package de.hsaalen.cmt.network.session import de.hsaalen.cmt.network.RestPaths import de.hsaalen.cmt.network.apiPathRSocket import de.hsaalen.cmt.network.dto.objects.UUID -import de.hsaalen.cmt.network.dto.rsocket.DocumentChangeDto import de.hsaalen.cmt.network.dto.rsocket.LiveDto import de.hsaalen.cmt.network.dto.rsocket.RequestReferenceDto import de.hsaalen.cmt.network.dto.server.ServerUserInfoDto @@ -70,10 +69,10 @@ class Session( /** * Open a modification channel for editing a document. Also changes from other clients will be received. */ - fun modifyDocument(reference: UUID, sendChannel: Channel): Flow { + fun modifyDocument(reference: UUID, sendChannel: Channel): Flow { val init = RequestReferenceDto(reference).encrypt().buildPayload() val sendEvents = sendChannel.receiveAsFlow().map { it.encrypt().buildPayload() } - return rSocket.requestChannel(init, sendEvents).map { it.decodeProtobufData().decrypt() } + return rSocket.requestChannel(init, sendEvents).map { it.decodeProtobufData().decrypt() } } /** diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/repository/DocumentRepository.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/repository/DocumentRepository.kt index 9d8af8a..f7b57b1 100644 --- a/common/src/commonMain/kotlin/de/hsaalen/cmt/repository/DocumentRepository.kt +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/repository/DocumentRepository.kt @@ -18,6 +18,6 @@ interface DocumentRepository { /** * Download the content of a specific reference by uuid. */ - suspend fun downloadDocument(uuid: UUID): String + suspend fun downloadDocument(reference: UUID): String } diff --git a/common/src/commonMain/kotlin/de/hsaalen/cmt/utils/CursorUtils.kt b/common/src/commonMain/kotlin/de/hsaalen/cmt/utils/CursorUtils.kt new file mode 100644 index 0000000..dba8869 --- /dev/null +++ b/common/src/commonMain/kotlin/de/hsaalen/cmt/utils/CursorUtils.kt @@ -0,0 +1,26 @@ +package de.hsaalen.cmt.utils + +/** + * A unicode character for a fake cursor. + */ +const val cursorCharacter = "\u2502" + +/** + * Add fake cursors to a provided string using the [cursorCharacter] as cursor. + */ +fun String.addCursors(cursors: Set): String { + val txt = replace(cursorCharacter, "") + val textParts = mutableListOf() + val endIndex = if (cursors.isEmpty()) { + 0 // No cursors so nothing to do + } else { + val validCursors = cursors.map { it.coerceIn(0, txt.length) } + textParts += txt.substring(0, validCursors.first()) + validCursors.reduce { acc, i -> + textParts += txt.substring(acc, i) + i + } + } + textParts += txt.substring(endIndex) + return textParts.joinToString(cursorCharacter) +} diff --git a/docker-compose.yml b/docker-compose.yml index ccbdb45..3768d81 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,17 +94,18 @@ services: volumes: - ./docker-compose/prometheus/:/etc/prometheus/:z # Config files - prometheus:/prometheus # Database volume + - "/var/run/docker.sock:/var/run/docker.sock:ro" + user: root + privileged: true # Required for usage in podman command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' depends_on: - backend-server - grafana: + grafana: # Use http://prometheus:9090 as connect url for data source image: grafana/grafana:7.5.3 ports: - '${GRAFANA_PORT}:3000' - volumes: # Use http://prometheus:9090 as connect url for data source - - ./docker-compose/grafana:/var/lib/grafana:Z # :z is required in SELinux using podman depends_on: - prometheus environment: @@ -119,7 +120,7 @@ services: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web-app.address=:80" - privileged: true + privileged: true # Required for usage in podman depends_on: - backend-server - web-app diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/components/documenteditor/Engine.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/components/documenteditor/Engine.kt index c2ddf9a..5ebad44 100644 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/components/documenteditor/Engine.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/components/documenteditor/Engine.kt @@ -1,5 +1,7 @@ package de.hsaalen.cmt.components.documenteditor +import de.hsaalen.cmt.network.dto.objects.UUID + /** * Engine for handling text area content. */ @@ -25,4 +27,9 @@ interface Engine { */ fun deleteLine(lineNumber: Int) + /** + * Called by server to update cursor position of another editor instance. + */ + fun updateCursor(engineId: UUID, cursorPosition: Int?) + } diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/components/documenteditor/TextareaEngine.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/components/documenteditor/TextareaEngine.kt index c0bdca6..bcb0c77 100644 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/components/documenteditor/TextareaEngine.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/components/documenteditor/TextareaEngine.kt @@ -1,24 +1,107 @@ package de.hsaalen.cmt.components.documenteditor +import de.hsaalen.cmt.extensions.coroutines +import de.hsaalen.cmt.network.dto.objects.UUID +import de.hsaalen.cmt.network.dto.rsocket.CursorUpdateDto +import de.hsaalen.cmt.network.dto.rsocket.LiveDto +import de.hsaalen.cmt.utils.addCursors +import de.hsaalen.cmt.utils.cursorCharacter +import kotlinx.atomicfu.AtomicInt +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import mu.KotlinLogging import org.w3c.dom.HTMLTextAreaElement import react.RReadableRef /** * Engine for handling text area content. */ -class TextareaEngine(private val textarea: RReadableRef) : Engine { +class TextareaEngine( + private val textarea: RReadableRef, + private val mouseDowns: AtomicInt, + private val channelSend: Channel +) : Engine { + + /** + * Local logging instance. + */ + private val logger = KotlinLogging.logger("TextareaEngine") + + /** + * Store the last position of the own cursor to detect changes. + */ + private var lastCursorPosition: Int? = null + + /** + * The current cursors of other text engines. + */ + private val cursors = mutableMapOf() + + /** + * The cursors before text update. This is required to detect the differences. + */ + private var previousCursors = mapOf() + + /** + * Task to send cursor position to other text engines periodically. + */ + init { + coroutines.launch { + while (isActive) { + delay(100) + val selection = textarea.current?.selectionStart ?: 0 + try { + if (selection != lastCursorPosition) { + // Send cursor update to server when changed + val actualCursorPosition = selection - cursors.values.count { it < selection } + channelSend.send(CursorUpdateDto(cursorIndex = actualCursorPosition)) + } + } finally { + lastCursorPosition = selection + // Don't update selection when currently selection + if (mouseDowns.value <= 0) { + text = text // Will update the cursor positions + } + } + } + } + } + /** * Modifies the text without resetting the cursor. */ override var text: String - get() = textarea.current?.value ?: "" + get() = textarea.current?.value?.replace(cursorCharacter, "") ?: "" set(value) { + // There is a mismatch between real cursor position and actual cursor position due to fake cursors val element = textarea.current ?: return val cursorStart = element.selectionStart ?: 0 val cursorEnd = element.selectionEnd ?: 0 - element.value = value - element.setSelectionRange(cursorStart, cursorEnd); + val actualCursorStart = cursorStart - previousCursors.values.count { it < cursorStart } + val actualCursorEnd = cursorEnd - previousCursors.values.count { it < cursorEnd } + + // Add virtual cursors to new text + val cleanValue = value.replace(cursorCharacter, "") + var textWithCursors = cleanValue + try { + val otherCursorIndices = cursors.values + textWithCursors = cleanValue.addCursors(otherCursorIndices.toSet()) + if (textWithCursors.replace(cursorCharacter, "") != cleanValue) { + logger.warn { "Failed cursor adding: '$cleanValue' with cursors $otherCursorIndices! Result: $textWithCursors" } + } + } catch (ex: Exception) { + logger.warn(ex) { "Can not update cursor position" } + } + textarea.current?.value = textWithCursors + + // Correct cursor position after the text was updated + val newCursorStart = actualCursorStart + cursors.values.count { it <= cursorStart } + val newCursorEnd = actualCursorEnd + cursors.values.count { it <= cursorEnd } + element.setSelectionRange(newCursorStart, newCursorEnd) + previousCursors = cursors.toMap() } /** @@ -68,4 +151,17 @@ class TextareaEngine(private val textarea: RReadableRef) : throw IllegalArgumentException("Unable to delete line at $lineNumber of " + lines.size) } } + + /** + * Update the cursor position of another text engine. + */ + override fun updateCursor(engineId: UUID, cursorPosition: Int?) { + if (cursorPosition == null) { + cursors -= engineId + } else { + cursors[engineId] = cursorPosition + } + text = text // Will update the cursor positions + } + } diff --git a/web-app/src/main/kotlin/de/hsaalen/cmt/pages/DocumentEditPage.kt b/web-app/src/main/kotlin/de/hsaalen/cmt/pages/DocumentEditPage.kt index 74eaac1..29cc597 100755 --- a/web-app/src/main/kotlin/de/hsaalen/cmt/pages/DocumentEditPage.kt +++ b/web-app/src/main/kotlin/de/hsaalen/cmt/pages/DocumentEditPage.kt @@ -8,9 +8,12 @@ import de.hsaalen.cmt.extensions.coroutines import de.hsaalen.cmt.extensions.launch import de.hsaalen.cmt.network.dto.objects.LineChangeMode import de.hsaalen.cmt.network.dto.objects.Reference +import de.hsaalen.cmt.network.dto.rsocket.CursorUpdateDto import de.hsaalen.cmt.network.dto.rsocket.DocumentChangeDto +import de.hsaalen.cmt.network.dto.rsocket.LiveDto import de.hsaalen.cmt.network.dto.rsocket.ReferenceUpdateRemoveDto import de.hsaalen.cmt.network.session.Session +import kotlinx.atomicfu.atomic import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -18,6 +21,8 @@ import kotlinx.css.* import kotlinx.css.properties.BoxShadows import kotlinx.css.properties.LineHeight import kotlinx.html.js.onInputFunction +import kotlinx.html.js.onMouseDownFunction +import kotlinx.html.js.onMouseUpFunction import mu.KotlinLogging import org.w3c.dom.HTMLTextAreaElement import react.* @@ -62,19 +67,28 @@ class DocumentEditPage : RComponent() /** - * The engine for handling text changes. + * Output channel for changed that will be sent to server. */ - private val engine: Engine = TextareaEngine(textarea) + private var channelSend = Channel() /** - * Output channel for changed that will be sent to server. + * Count the amount of mouse downs to check if the user + * has currently his mouse down. This is used to fix a bug, where + * it is not possible to select text, because of constant + * text updates. + */ + private val mouseDowns = atomic(0) + + /** + * The engine for handling text changes. */ - private var channelSend = Channel() + private val engine: Engine = TextareaEngine(textarea, mouseDowns, channelSend) /** * Register events for this component. */ private val events = GlobalEventDispatcher.createBundle(this) { + register(::onRemovedReference) register(::onRemovedReference) launch { val text = "" @@ -84,7 +98,11 @@ class DocumentEditPage : RComponent - onDocumentChangedRemote(event) + when (event) { + is DocumentChangeDto -> onDocumentChangedRemote(event) + is CursorUpdateDto -> onCursorPositionChange(event) + else -> logger.warn { "Unknown document modification received: " + event::class.simpleName } + } } } } @@ -119,6 +137,16 @@ class DocumentEditPage : RComponent(Random.nextInt(text.length / 2)) { + random.nextInt() + }.toSet() + + assertEquals(text, text.addCursors(cursors).replace(cursorCharacter, "")) + } + } + + /** + * Test a specific case that previously failed. + */ + @Test + fun testWithSpecificText() { + val text = "abc" + val cursors = setOf(1) + assertEquals(text, text.addCursors(cursors).replace(cursorCharacter, "")) + } + +} diff --git a/web-app/src/test/kotlin/TestDiffCalculator.kt b/web-app/src/test/kotlin/TestDiffCalculator.kt deleted file mode 100755 index 3b18b5d..0000000 --- a/web-app/src/test/kotlin/TestDiffCalculator.kt +++ /dev/null @@ -1,20 +0,0 @@ -import de.hsaalen.cmt.network.dto.objects.LineChangeMode -import kotlin.test.Test - -class TestDiffCalculator { - - private val changedLines = mutableListOf>() - - @Test - fun test() { - // TODO: implement test -// val calc = DiffCalculator(::onChangeLine) -// calc.findChangedLines("line-1\nline-2\nline-3") -// println(changedLines) - } - - private fun onChangeLine(lineNumber: Int, lineContent: String, changeMode: LineChangeMode) { - changedLines += Pair(lineNumber, changeMode) - } - -}