From 195c7c10ac817e58003bc4141dbfa3726d979566 Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Tue, 9 May 2023 22:41:03 +0200 Subject: [PATCH] Import feature --- app/build.gradle.kts | 1 + app/src/controllers/SettingsController.kt | 17 ++++-- app/src/routes/SettingsRoutes.kt | 1 + .../serialization/LocalDateTimeSerializer.kt | 2 +- css/src/note.pcss | 2 +- domain/src/ImportService.kt | 30 ++++++++++ domain/src/MarkdownService.kt | 2 +- gradle/libs.versions.toml | 1 + .../src/repositories/NoteRepository.kt | 2 + .../src/repositories/NoteRepositoryImpl.kt | 38 +++++++++++++ views/src/SettingView.kt | 56 +++++++++++++------ 11 files changed, 128 insertions(+), 24 deletions(-) create mode 100644 domain/src/ImportService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 20a1c11..e201d60 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(project(":css")) implementation(libs.http4k.core) + implementation(libs.http4k.multipart) implementation(libs.bundles.jetty) implementation(libs.kotlinx.serialization.json) diff --git a/app/src/controllers/SettingsController.kt b/app/src/controllers/SettingsController.kt index 7fb67cf..0236968 100644 --- a/app/src/controllers/SettingsController.kt +++ b/app/src/controllers/SettingsController.kt @@ -2,21 +2,19 @@ package be.simplenotes.app.controllers import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.redirect -import be.simplenotes.domain.DeleteError -import be.simplenotes.domain.DeleteForm -import be.simplenotes.domain.ExportService -import be.simplenotes.domain.UserService +import be.simplenotes.domain.* import be.simplenotes.types.LoggedInUser import be.simplenotes.views.SettingView +import jakarta.inject.Singleton import org.http4k.core.* import org.http4k.core.body.form import org.http4k.core.cookie.invalidateCookie -import jakarta.inject.Singleton @Singleton class SettingsController( private val userService: UserService, private val exportService: ExportService, + private val importService: ImportService, private val settingView: SettingView, ) { fun settings(request: Request, loggedInUser: LoggedInUser): Response { @@ -36,6 +34,7 @@ class SettingsController( error = "Wrong password", ), ) + is DeleteError.InvalidForm -> Response(Status.OK).html( settingView.settings( loggedInUser, @@ -79,4 +78,12 @@ class SettingsController( private fun Request.deleteForm(loggedInUser: LoggedInUser) = DeleteForm(loggedInUser.username, form("password"), form("checked") != null) + + fun import(request: Request, loggedInUser: LoggedInUser): Response { + val form = MultipartFormBody.from(request) + val file = form.file("file") ?: return Response(Status.BAD_REQUEST) + val json = file.content.bufferedReader().readText() + importService.importJson(loggedInUser, json) + return Response.redirect("/notes") + } } diff --git a/app/src/routes/SettingsRoutes.kt b/app/src/routes/SettingsRoutes.kt index cde0e9d..5586e59 100644 --- a/app/src/routes/SettingsRoutes.kt +++ b/app/src/routes/SettingsRoutes.kt @@ -30,6 +30,7 @@ class SettingsRoutes( "/settings" bind GET to settingsController::settings, "/settings" bind POST to settingsController::settings, "/export" bind POST to settingsController::export, + "/import" bind POST to settingsController::import, ), ) } diff --git a/app/src/serialization/LocalDateTimeSerializer.kt b/app/src/serialization/LocalDateTimeSerializer.kt index 55827af..f656993 100644 --- a/app/src/serialization/LocalDateTimeSerializer.kt +++ b/app/src/serialization/LocalDateTimeSerializer.kt @@ -17,6 +17,6 @@ internal class LocalDateTimeSerializer : KSerializer { } override fun deserialize(decoder: Decoder): LocalDateTime { - TODO("Not implemented, isn't needed") + return LocalDateTime.parse(decoder.decodeString()) } } diff --git a/css/src/note.pcss b/css/src/note.pcss index 41bba9c..c3417f8 100644 --- a/css/src/note.pcss +++ b/css/src/note.pcss @@ -1,6 +1,6 @@ #note { a { - @apply text-blue-700 underline; + @apply text-blue-500 underline; } p { diff --git a/domain/src/ImportService.kt b/domain/src/ImportService.kt new file mode 100644 index 0000000..7fb8b9c --- /dev/null +++ b/domain/src/ImportService.kt @@ -0,0 +1,30 @@ +package be.simplenotes.domain + +import be.simplenotes.domain.security.HtmlSanitizer +import be.simplenotes.persistence.repositories.NoteRepository +import be.simplenotes.types.ExportedNote +import be.simplenotes.types.LoggedInUser +import jakarta.inject.Singleton +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +interface ImportService { + fun importJson(user: LoggedInUser, content: String) +} + +@Singleton +internal class ImportServiceImpl( + private val noteRepository: NoteRepository, + private val json: Json, + private val htmlSanitizer: HtmlSanitizer, +) : ImportService { + override fun importJson(user: LoggedInUser, content: String) { + val notes = json.decodeFromString(ListSerializer(ExportedNote.serializer()), content) + noteRepository.import( + user.userId, + notes.map { + it.copy(html = htmlSanitizer.sanitize(user, it.html)) + }, + ) + } +} diff --git a/domain/src/MarkdownService.kt b/domain/src/MarkdownService.kt index 9aa5d61..2fef829 100644 --- a/domain/src/MarkdownService.kt +++ b/domain/src/MarkdownService.kt @@ -26,7 +26,7 @@ internal class MarkdownServiceImpl( private fun splitMetaFromDocument(input: String) = either { val split = input.split(yamlBoundPattern, 3) ensure(split.size >= 3) { MarkdownParsingError.MissingMeta } - (split[1].trim() to split[2].trim()) + split[1].trim() to split[2].trim() } private val yaml = Yaml() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa12de4..33692d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ lucene-analyzers-common = { module = "org.apache.lucene:lucene-analysis-common", lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" } http4k-core = { module = "org.http4k:http4k-core", version.ref = "http4k" } +http4k-multipart = { module = "org.http4k:http4k-multipart", version.ref = "http4k" } http4k-testing-hamkrest = { module = "org.http4k:http4k-testing-hamkrest", version.ref = "http4k" } jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" } diff --git a/persistence/src/repositories/NoteRepository.kt b/persistence/src/repositories/NoteRepository.kt index dc91bc0..b5af3ff 100644 --- a/persistence/src/repositories/NoteRepository.kt +++ b/persistence/src/repositories/NoteRepository.kt @@ -26,6 +26,8 @@ interface NoteRepository { fun create(userId: Int, note: Note): PersistedNote fun find(userId: Int, uuid: UUID): PersistedNote? fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? + fun import(userId: Int, notes: List) + fun export(userId: Int): List fun findAllDetails(userId: Int): List diff --git a/persistence/src/repositories/NoteRepositoryImpl.kt b/persistence/src/repositories/NoteRepositoryImpl.kt index 585765c..8043f3a 100644 --- a/persistence/src/repositories/NoteRepositoryImpl.kt +++ b/persistence/src/repositories/NoteRepositoryImpl.kt @@ -3,6 +3,7 @@ package be.simplenotes.persistence.repositories import be.simplenotes.persistence.* import be.simplenotes.persistence.converters.NoteConverter import be.simplenotes.persistence.extensions.arrayContains +import be.simplenotes.types.ExportedNote import be.simplenotes.types.Note import be.simplenotes.types.PersistedNote import org.ktorm.database.Database @@ -118,6 +119,43 @@ internal class NoteRepositoryImpl( .map { it.getInt(1) } .first() + override fun import(userId: Int, notes: List) { + if (notes.isEmpty()) return + val notesByID = notes.associateBy { UUID.randomUUID() } + val tags = sequence> { + notesByID.entries.forEach { (key, value) -> + value.tags.forEach { tag -> + yield(key to tag) + } + } + }.toList() + + db.batchInsert(Notes) { + notesByID.forEach { (uuid, note) -> + item { + set(it.uuid, uuid) + set(it.userId, userId) + set(it.title, note.title) + set(it.html, note.html) + set(it.markdown, note.markdown) + set(it.updatedAt, note.updatedAt) + set(it.deleted, note.trash) + } + } + } + + tags.takeIf { it.isNotEmpty() }?.run { + db.batchInsert(Tags) { + forEach { (uuid, tagName) -> + item { + set(it.noteUuid, uuid) + set(it.name, tagName) + } + } + } + } + } + override fun export(userId: Int) = db.noteWithTags .filterColumns { it.columns - it.userId - it.public } .filter { it.userId eq userId } diff --git a/views/src/SettingView.kt b/views/src/SettingView.kt index effb031..bf54b06 100644 --- a/views/src/SettingView.kt +++ b/views/src/SettingView.kt @@ -6,10 +6,13 @@ import be.simplenotes.views.components.alert import be.simplenotes.views.components.input import be.simplenotes.views.extensions.summary import io.konform.validation.ValidationError -import kotlinx.html.* -import kotlinx.html.ButtonType.submit import jakarta.inject.Named import jakarta.inject.Singleton +import kotlinx.html.* +import kotlinx.html.ButtonType.submit +import kotlinx.html.FormEncType.multipartFormData +import kotlinx.html.FormMethod.post +import kotlinx.html.InputType.file @Singleton class SettingView(@Named("styles") styles: String) : View(styles) { @@ -28,34 +31,55 @@ class SettingView(@Named("styles") styles: String) : View(styles) { } section("m-4 p-2 bg-gray-800 rounded flex flex-wrap justify-around items-end") { - form(classes = "m-2", method = FormMethod.post, action = "/export") { + form(classes = "m-2 flex-1", method = post, action = "/export") { button( name = "display", - classes = "inline btn btn-teal block", + classes = "btn btn-teal block", type = submit, ) { +"Display my data" } } - form(classes = "m-2", method = FormMethod.post, action = "/export") { + form(classes = "m-2 flex-1", method = post, action = "/export") { div { listOf("json", "zip").forEach { format -> - radioInput(name = "format") { - id = format - attributes["value"] = format - if (format == "json") attributes["checked"] = "" - else attributes["class"] = "ml-4" - } - label(classes = "ml-2") { - attributes["for"] = format - +format + div("px-2") { + radioInput( + name = "format", + classes = + "checked:bg-blue-500 bg-gray-200 appearance-none rounded-full border-2 h-5 w-5", + ) { + id = format + attributes["value"] = format + if (format == "json") attributes["checked"] = "" + } + label(classes = "ml-2") { + attributes["for"] = format + +format + } } } } - button(name = "download", classes = "inline btn btn-green block mt-2", type = submit) { + button(name = "download", classes = "btn btn-green block mt-2", type = submit) { +"Download my data" } } + + form(classes = "m-2 flex-1", method = post, encType = multipartFormData, action = "/import") { + input( + file, + classes = "file:hidden mb-4", + name = "file", + ) { + attributes["accept"] = ".json,application/json" + attributes["required"] = "" + } + button( + name = "import", + classes = "btn btn-teal block", + type = submit, + ) { +"Import" } + } } section(classes = "m-4 p-4 bg-gray-800 rounded") { @@ -77,7 +101,7 @@ class SettingView(@Named("styles") styles: String) : View(styles) { } } - form(classes = "mt-4", method = FormMethod.post) { + form(classes = "mt-4", method = post) { input( id = "password", placeholder = "Password",