Import feature

This commit is contained in:
Hubert Van De Walle 2023-05-09 22:41:03 +02:00
parent 724fa0483e
commit 195c7c10ac
11 changed files with 128 additions and 24 deletions

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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,
),
)
}

View File

@ -17,6 +17,6 @@ internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
return LocalDateTime.parse(decoder.decodeString())
}
}

View File

@ -1,6 +1,6 @@
#note {
a {
@apply text-blue-700 underline;
@apply text-blue-500 underline;
}
p {

View File

@ -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))
},
)
}
}

View File

@ -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()

View File

@ -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" }

View File

@ -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<ExportedNote>)
fun export(userId: Int): List<ExportedNote>
fun findAllDetails(userId: Int): List<PersistedNote>

View File

@ -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<ExportedNote>) {
if (notes.isEmpty()) return
val notesByID = notes.associateBy { UUID.randomUUID() }
val tags = sequence<Pair<UUID, String>> {
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 }

View File

@ -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",