Import feature
This commit is contained in:
parent
724fa0483e
commit
195c7c10ac
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#note {
|
#note {
|
||||||
a {
|
a {
|
||||||
@apply text-blue-700 underline;
|
@apply text-blue-500 underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
|
|||||||
30
domain/src/ImportService.kt
Normal file
30
domain/src/ImportService.kt
Normal 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))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user