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(project(":css"))
implementation(libs.http4k.core) implementation(libs.http4k.core)
implementation(libs.http4k.multipart)
implementation(libs.bundles.jetty) implementation(libs.bundles.jetty)
implementation(libs.kotlinx.serialization.json) 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.html
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.DeleteError import be.simplenotes.domain.*
import be.simplenotes.domain.DeleteForm
import be.simplenotes.domain.ExportService
import be.simplenotes.domain.UserService
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.SettingView import be.simplenotes.views.SettingView
import jakarta.inject.Singleton
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.body.form import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie import org.http4k.core.cookie.invalidateCookie
import jakarta.inject.Singleton
@Singleton @Singleton
class SettingsController( class SettingsController(
private val userService: UserService, private val userService: UserService,
private val exportService: ExportService, private val exportService: ExportService,
private val importService: ImportService,
private val settingView: SettingView, private val settingView: SettingView,
) { ) {
fun settings(request: Request, loggedInUser: LoggedInUser): Response { fun settings(request: Request, loggedInUser: LoggedInUser): Response {
@ -36,6 +34,7 @@ class SettingsController(
error = "Wrong password", error = "Wrong password",
), ),
) )
is DeleteError.InvalidForm -> Response(Status.OK).html( is DeleteError.InvalidForm -> Response(Status.OK).html(
settingView.settings( settingView.settings(
loggedInUser, loggedInUser,
@ -79,4 +78,12 @@ class SettingsController(
private fun Request.deleteForm(loggedInUser: LoggedInUser) = private fun Request.deleteForm(loggedInUser: LoggedInUser) =
DeleteForm(loggedInUser.username, form("password"), form("checked") != null) 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 GET to settingsController::settings,
"/settings" bind POST to settingsController::settings, "/settings" bind POST to settingsController::settings,
"/export" bind POST to settingsController::export, "/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 { override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed") return LocalDateTime.parse(decoder.decodeString())
} }
} }

View File

@ -1,6 +1,6 @@
#note { #note {
a { a {
@apply text-blue-700 underline; @apply text-blue-500 underline;
} }
p { 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 { private fun splitMetaFromDocument(input: String) = either {
val split = input.split(yamlBoundPattern, 3) val split = input.split(yamlBoundPattern, 3)
ensure(split.size >= 3) { MarkdownParsingError.MissingMeta } ensure(split.size >= 3) { MarkdownParsingError.MissingMeta }
(split[1].trim() to split[2].trim()) split[1].trim() to split[2].trim()
} }
private val yaml = Yaml() 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" } lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" }
http4k-core = { module = "org.http4k:http4k-core", version.ref = "http4k" } 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" } http4k-testing-hamkrest = { module = "org.http4k:http4k-testing-hamkrest", version.ref = "http4k" }
jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" } 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 create(userId: Int, note: Note): PersistedNote
fun find(userId: Int, uuid: UUID): PersistedNote? fun find(userId: Int, uuid: UUID): PersistedNote?
fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? fun update(userId: Int, uuid: UUID, note: Note): PersistedNote?
fun import(userId: Int, notes: List<ExportedNote>)
fun export(userId: Int): List<ExportedNote> fun export(userId: Int): List<ExportedNote>
fun findAllDetails(userId: Int): List<PersistedNote> 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.*
import be.simplenotes.persistence.converters.NoteConverter import be.simplenotes.persistence.converters.NoteConverter
import be.simplenotes.persistence.extensions.arrayContains import be.simplenotes.persistence.extensions.arrayContains
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.Note import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote import be.simplenotes.types.PersistedNote
import org.ktorm.database.Database import org.ktorm.database.Database
@ -118,6 +119,43 @@ internal class NoteRepositoryImpl(
.map { it.getInt(1) } .map { it.getInt(1) }
.first() .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 override fun export(userId: Int) = db.noteWithTags
.filterColumns { it.columns - it.userId - it.public } .filterColumns { it.columns - it.userId - it.public }
.filter { it.userId eq userId } .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.components.input
import be.simplenotes.views.extensions.summary import be.simplenotes.views.extensions.summary
import io.konform.validation.ValidationError import io.konform.validation.ValidationError
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
import jakarta.inject.Named import jakarta.inject.Named
import jakarta.inject.Singleton 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 @Singleton
class SettingView(@Named("styles") styles: String) : View(styles) { 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") { 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( button(
name = "display", name = "display",
classes = "inline btn btn-teal block", classes = "btn btn-teal block",
type = submit, type = submit,
) { +"Display my data" } ) { +"Display my data" }
} }
form(classes = "m-2", method = FormMethod.post, action = "/export") { form(classes = "m-2 flex-1", method = post, action = "/export") {
div { div {
listOf("json", "zip").forEach { format -> listOf("json", "zip").forEach { format ->
radioInput(name = "format") { div("px-2") {
id = format radioInput(
attributes["value"] = format name = "format",
if (format == "json") attributes["checked"] = "" classes =
else attributes["class"] = "ml-4" "checked:bg-blue-500 bg-gray-200 appearance-none rounded-full border-2 h-5 w-5",
} ) {
label(classes = "ml-2") { id = format
attributes["for"] = format attributes["value"] = format
+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" +"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") { 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( input(
id = "password", id = "password",
placeholder = "Password", placeholder = "Password",