diff --git a/app/src/main/kotlin/controllers/NoteController.kt b/app/src/main/kotlin/controllers/NoteController.kt index f2a3940..ce6d465 100644 --- a/app/src/main/kotlin/controllers/NoteController.kt +++ b/app/src/main/kotlin/controllers/NoteController.kt @@ -2,12 +2,14 @@ package be.simplenotes.app.controllers import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.redirect +import be.simplenotes.app.utils.parseSearchTerms import be.simplenotes.app.views.NoteView import be.simplenotes.domain.security.JwtPayload import be.simplenotes.domain.usecases.NoteService import be.simplenotes.domain.usecases.markdown.InvalidMeta import be.simplenotes.domain.usecases.markdown.MissingMeta import be.simplenotes.domain.usecases.markdown.ValidationError +import be.simplenotes.search.SearchTerms import org.http4k.core.Method import org.http4k.core.Request import org.http4k.core.Response @@ -34,7 +36,9 @@ class NoteController( val html = when (it) { MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm) InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm) - is ValidationError -> view.noteEditor(jwtPayload, validationErrors = it.validationErrors, textarea = markdownForm) + is ValidationError -> view.noteEditor(jwtPayload, + validationErrors = it.validationErrors, + textarea = markdownForm) } Response(BAD_REQUEST).html(html) }, @@ -52,6 +56,13 @@ class NoteController( return Response(OK).html(view.notes(jwtPayload, notes, currentPage, pages, deletedCount, tag = tag)) } + fun search(request: Request, jwtPayload: JwtPayload): Response { + val terms = request.searchTerms() + val notes = noteService.search(jwtPayload.userId, terms) + val deletedCount = noteService.countDeleted(jwtPayload.userId) + return Response(OK).html(view.search(jwtPayload, notes, deletedCount)) + } + fun note(request: Request, jwtPayload: JwtPayload): Response { val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND) @@ -121,4 +132,6 @@ class NoteController( null } } + + private fun Request.searchTerms(): SearchTerms = parseSearchTerms(form("search") ?: "") } diff --git a/app/src/main/kotlin/routes/Router.kt b/app/src/main/kotlin/routes/Router.kt index 54a002a..a96db09 100644 --- a/app/src/main/kotlin/routes/Router.kt +++ b/app/src/main/kotlin/routes/Router.kt @@ -48,6 +48,7 @@ class Router( "/settings" bind POST to { protected(it, settingsController::settings) }, "/export" bind POST to { protected(it, settingsController::export) }, "/notes" bind GET to { protected(it, noteController::list) }, + "/notes" bind POST to { protected(it, noteController::search) }, "/notes/new" bind GET to { protected(it, noteController::new) }, "/notes/new" bind POST to { protected(it, noteController::new) }, "/notes/trash" bind GET to { protected(it, noteController::trash) }, diff --git a/app/src/main/kotlin/utils/SearchTermsParser.kt b/app/src/main/kotlin/utils/SearchTermsParser.kt new file mode 100644 index 0000000..0062844 --- /dev/null +++ b/app/src/main/kotlin/utils/SearchTermsParser.kt @@ -0,0 +1,33 @@ +package be.simplenotes.app.utils + +import be.simplenotes.search.SearchTerms + +private val titleRe = """title:['"](?.*?)['"]""".toRegex() +private val outerTitleRe = """(?<title>title:['"].*?['"])""".toRegex() + +private val tagRe = """tag:['"](?<tag>.*?)['"]""".toRegex() +private val outerTagRe = """(?<tag>tag:['"].*?['"])""".toRegex() + +fun parseSearchTerms(input: String): SearchTerms { + val title: String? = titleRe.find(input)?.groups?.get("title")?.value + val tag: String? = tagRe.find(input)?.groups?.get("tag")?.value + var c: String = input + + if (title != null) { + val titleGroup = outerTitleRe.find(input)?.groups?.get("title")?.value + titleGroup?.let { c = c.replace(it, "") } + } + + if (tag != null) { + val tagGroup = outerTagRe.find(input)?.groups?.get("tag")?.value + tagGroup?.let { c = c.replace(it, "") } + } + + val content = c.trim().ifEmpty { null } + + return SearchTerms( + title = title, + tag = tag, + content = content + ) +} diff --git a/app/src/main/kotlin/views/NoteView.kt b/app/src/main/kotlin/views/NoteView.kt index c7d02a1..6e5a6b1 100644 --- a/app/src/main/kotlin/views/NoteView.kt +++ b/app/src/main/kotlin/views/NoteView.kt @@ -55,20 +55,7 @@ class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver tag: String?, ) = renderPage(title = "Notes", jwtPayload = jwtPayload) { div("container mx-auto p-4") { - div("flex justify-between mb-4") { - h1("text-2xl underline") { +"Notes" } - span { - a( - href = "/notes/trash", - classes = "underline font-semibold" - ) { +"Trash ($numberOfDeletedNotes)" } - a( - href = "/notes/new", - classes = "ml-2 btn btn-green" - ) { +"New" } - } - - } + noteListHeader(numberOfDeletedNotes) if (notes.isNotEmpty()) noteTable(notes) else @@ -81,11 +68,22 @@ class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver } } + fun search( + jwtPayload: JwtPayload, + notes: List<PersistedNoteMetadata>, + numberOfDeletedNotes: Int, + ) = renderPage("Notes", jwtPayload = jwtPayload) { + div("container mx-auto p-4") { + noteListHeader(numberOfDeletedNotes) + noteTable(notes) + } + } + fun trash( jwtPayload: JwtPayload, notes: List<PersistedNoteMetadata>, currentPage: Int, - numberOfPages: Int + numberOfPages: Int, ) = renderPage(title = "Notes", jwtPayload = jwtPayload) { div("container mx-auto p-4") { div("flex justify-between mb-4") { diff --git a/app/src/main/kotlin/views/components/NoteListHeader.kt b/app/src/main/kotlin/views/components/NoteListHeader.kt new file mode 100644 index 0000000..a315060 --- /dev/null +++ b/app/src/main/kotlin/views/components/NoteListHeader.kt @@ -0,0 +1,19 @@ +package be.simplenotes.app.views.components + +import kotlinx.html.* + +fun DIV.noteListHeader(numberOfDeletedNotes: Int) { + div("flex justify-between mb-4") { + h1("text-2xl underline") { +"Notes" } + span { + a( + href = "/notes/trash", + classes = "underline font-semibold" + ) { +"Trash ($numberOfDeletedNotes)" } + a( + href = "/notes/new", + classes = "ml-2 btn btn-green" + ) { +"New" } + } + } +} diff --git a/app/src/test/kotlin/utils/SearchTermsParserKtTest.kt b/app/src/test/kotlin/utils/SearchTermsParserKtTest.kt new file mode 100644 index 0000000..39f996a --- /dev/null +++ b/app/src/test/kotlin/utils/SearchTermsParserKtTest.kt @@ -0,0 +1,39 @@ +package be.simplenotes.app.utils + +import be.simplenotes.search.SearchTerms +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +internal class SearchTermsParserKtTest { + + private fun createResult( + input: String, + title: String? = null, + tag: String? = null, + content: String? = null, + ): Pair<String, SearchTerms> = input to SearchTerms(title, tag, content) + + @Suppress("Unused") + private fun results() = Stream.of( + createResult("title:'example'", title = "example"), + createResult("title:'example with words'", title = "example with words"), + createResult("title:'example with words'", title = "example with words"), + createResult("""title:"double quotes"""", title = "double quotes"), + createResult("title:'example' something else", title = "example", content = "something else"), + createResult("tag:'example'", tag = "example"), + createResult("tag:'example' title:'other'", title = "other", tag = "example"), + createResult("blah blah tag:'example' title:'other'", title = "other", tag = "example", content = "blah blah"), + createResult("tag:'example' middle title:'other'", title = "other", tag = "example", content = "middle"), + createResult("tag:'example' title:'other' end", title = "other", tag = "example", content = "end"), + createResult("tag:'example abc' title:'other with words' this is the end ", title = "other with words", tag = "example abc", content = "this is the end"), + ) + + @ParameterizedTest + @MethodSource("results") + fun `valid search parser`(case: Pair<String, SearchTerms>) { + assertThat(parseSearchTerms(case.first)).isEqualTo(case.second) + } + +} diff --git a/domain/src/main/kotlin/usecases/NoteService.kt b/domain/src/main/kotlin/usecases/NoteService.kt index d28d9ef..d7fdabd 100644 --- a/domain/src/main/kotlin/usecases/NoteService.kt +++ b/domain/src/main/kotlin/usecases/NoteService.kt @@ -11,6 +11,7 @@ import be.simplenotes.domain.usecases.markdown.MarkdownParsingError import be.simplenotes.domain.usecases.repositories.NoteRepository import be.simplenotes.domain.usecases.repositories.UserRepository import be.simplenotes.search.NoteSearcher +import be.simplenotes.search.SearchTerms import java.util.* class NoteService( @@ -83,6 +84,8 @@ class NoteService( searcher.indexNotes(id, notes) } } + + fun search(userId: Int, searchTerms: SearchTerms) = searcher.search(userId, searchTerms) } data class PaginatedNotes(val pages: Int, val notes: List<PersistedNoteMetadata>)