From 6525727ffa4206f8c0cd6921d3ab644743060fdb Mon Sep 17 00:00:00 2001 From: BuriedInCode <6057651+Buried-In-Code@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:18:24 +1200 Subject: [PATCH 1/2] Refresh project --- .gitignore | 1 + README.md | 14 +- app/build.gradle.kts | 63 ++-- app/src/main/jte/components/filters/book.kte | 10 +- .../main/jte/components/forms/input_date.kte | 2 +- .../main/jte/components/forms/select/book.kte | 2 +- .../jte/components/forms/select/creator.kte | 2 +- .../jte/components/forms/select/format.kte | 5 +- .../jte/components/forms/select/publisher.kte | 2 +- .../main/jte/components/forms/select/role.kte | 2 +- .../jte/components/forms/select/series.kte | 2 +- .../main/jte/components/forms/select/user.kte | 2 +- app/src/main/jte/components/navbar.kte | 2 +- app/src/main/jte/components/views/book.kte | 8 +- app/src/main/jte/components/views/creator.kte | 2 +- .../main/jte/components/views/publisher.kte | 2 +- app/src/main/jte/components/views/role.kte | 2 +- app/src/main/jte/components/views/series.kte | 2 +- app/src/main/jte/components/views/user.kte | 4 +- app/src/main/jte/templates/book/create.kte | 13 +- app/src/main/jte/templates/book/import.kte | 9 +- app/src/main/jte/templates/book/list.kte | 21 +- app/src/main/jte/templates/book/search.kte | 7 +- app/src/main/jte/templates/book/update.kte | 27 +- app/src/main/jte/templates/book/view.kte | 24 +- app/src/main/jte/templates/creator/update.kte | 17 +- app/src/main/jte/templates/creator/view.kte | 15 +- app/src/main/jte/templates/index.kte | 9 +- .../main/jte/templates/publisher/update.kte | 11 +- app/src/main/jte/templates/publisher/view.kte | 11 +- app/src/main/jte/templates/role/update.kte | 17 +- app/src/main/jte/templates/role/view.kte | 15 +- app/src/main/jte/templates/series/create.kte | 9 +- app/src/main/jte/templates/series/list.kte | 29 +- app/src/main/jte/templates/series/update.kte | 15 +- app/src/main/jte/templates/series/view.kte | 13 +- app/src/main/jte/templates/user/create.kte | 9 +- app/src/main/jte/templates/user/list.kte | 9 +- app/src/main/jte/templates/user/update.kte | 17 +- app/src/main/jte/templates/user/view.kte | 15 +- .../kotlin/github/buriedincode/Bookshelf.kt | 268 ++++++++++++++ .../github/buriedincode/ErrorResponse.kt | 3 + .../kotlin/github/buriedincode/Settings.kt | 24 ++ .../main/kotlin/github/buriedincode/Utils.kt | 143 ++++++++ .../github/buriedincode/bookshelf/App.kt | 272 -------------- .../buriedincode/bookshelf/ErrorResponse.kt | 8 - .../github/buriedincode/bookshelf/Settings.kt | 35 -- .../github/buriedincode/bookshelf/Utils.kt | 150 -------- .../buriedincode/bookshelf/models/Book.kt | 152 -------- .../bookshelf/models/BookSeries.kt | 28 -- .../buriedincode/bookshelf/models/Creator.kt | 60 --- .../buriedincode/bookshelf/models/Credit.kt | 29 -- .../buriedincode/bookshelf/models/Format.kt | 11 - .../buriedincode/bookshelf/models/IJson.kt | 5 - .../buriedincode/bookshelf/models/IdInput.kt | 5 - .../bookshelf/models/Publisher.kt | 44 --- .../buriedincode/bookshelf/models/ReadBook.kt | 27 -- .../buriedincode/bookshelf/models/Role.kt | 57 --- .../buriedincode/bookshelf/models/Series.kt | 49 --- .../buriedincode/bookshelf/models/User.kt | 60 --- .../bookshelf/routers/api/BaseApiRouter.kt | 48 --- .../bookshelf/routers/api/BookApiRouter.kt | 344 ------------------ .../bookshelf/routers/api/CreatorApiRouter.kt | 92 ----- .../routers/api/PublisherApiRouter.kt | 55 --- .../bookshelf/routers/api/RoleApiRouter.kt | 90 ----- .../bookshelf/routers/api/SeriesApiRouter.kt | 90 ----- .../bookshelf/routers/api/UserApiRouter.kt | 117 ------ .../bookshelf/routers/html/BaseHtmlRouter.kt | 60 --- .../bookshelf/routers/html/BookHtmlRouter.kt | 93 ----- .../routers/html/CreatorHtmlRouter.kt | 27 -- .../routers/html/PublisherHtmlRouter.kt | 15 - .../bookshelf/routers/html/RoleHtmlRouter.kt | 27 -- .../routers/html/SeriesHtmlRouter.kt | 27 -- .../bookshelf/routers/html/UserHtmlRouter.kt | 176 --------- .../bookshelf/tables/BookSeriesTable.kt | 31 -- .../bookshelf/tables/BookTable.kt | 43 --- .../bookshelf/tables/CreatorTable.kt | 17 - .../bookshelf/tables/CreditTable.kt | 36 -- .../bookshelf/tables/PublisherTable.kt | 16 - .../bookshelf/tables/ReadBookTable.kt | 33 -- .../bookshelf/tables/RoleTable.kt | 16 - .../bookshelf/tables/SeriesTable.kt | 16 - .../bookshelf/tables/UserTable.kt | 17 - .../bookshelf/tables/WishedTable.kt | 30 -- .../kotlin/github/buriedincode/models/Book.kt | 154 ++++++++ .../github/buriedincode/models/BookSeries.kt | 28 ++ .../github/buriedincode/models/Creator.kt | 46 +++ .../github/buriedincode/models/Credit.kt | 33 ++ .../github/buriedincode/models/Format.kt | 14 + .../github/buriedincode/models/IJson.kt | 5 + .../github/buriedincode/models/IdInput.kt | 3 + .../models/LocalDateDeserializer.kt | 22 +- .../github/buriedincode/models/Publisher.kt | 39 ++ .../github/buriedincode/models/ReadBook.kt | 28 ++ .../kotlin/github/buriedincode/models/Role.kt | 45 +++ .../github/buriedincode/models/Series.kt | 40 ++ .../kotlin/github/buriedincode/models/User.kt | 55 +++ .../buriedincode/routers/api/BaseApiRouter.kt | 46 +++ .../buriedincode/routers/api/BookApiRouter.kt | 336 +++++++++++++++++ .../routers/api/CreatorApiRouter.kt | 93 +++++ .../routers/api/PublisherApiRouter.kt | 53 +++ .../buriedincode/routers/api/RoleApiRouter.kt | 91 +++++ .../routers/api/SeriesApiRouter.kt | 79 ++++ .../buriedincode/routers/api/UserApiRouter.kt | 108 ++++++ .../routers/html/BaseHtmlRouter.kt | 63 ++++ .../routers/html/BookHtmlRouter.kt | 105 ++++++ .../routers/html/CreatorHtmlRouter.kt | 29 ++ .../routers/html/PublisherHtmlRouter.kt | 14 + .../routers/html/RoleHtmlRouter.kt | 29 ++ .../routers/html/SeriesHtmlRouter.kt | 23 ++ .../routers/html/UserHtmlRouter.kt | 184 ++++++++++ .../{bookshelf => }/services/OpenLibrary.kt | 18 +- .../buriedincode/tables/BookSeriesTable.kt | 33 ++ .../github/buriedincode/tables/BookTable.kt | 41 +++ .../buriedincode/tables/CreatorTable.kt | 15 + .../github/buriedincode/tables/CreditTable.kt | 39 ++ .../buriedincode/tables/PublisherTable.kt | 14 + .../buriedincode/tables/ReadBookTable.kt | 35 ++ .../github/buriedincode/tables/RoleTable.kt | 14 + .../github/buriedincode/tables/SeriesTable.kt | 14 + .../github/buriedincode/tables/UserTable.kt | 15 + .../github/buriedincode/tables/WishedTable.kt | 30 ++ app/src/main/resources/static/js/scripts.js | 2 +- build.gradle.kts | 107 +++--- gradle/libs.versions.toml | 57 +-- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 9 +- gradlew.bat | 4 +- openlibrary/build.gradle.kts | 19 +- .../buriedincode/openlibrary/OpenLibrary.kt | 278 +++++++------- .../buriedincode/openlibrary/SQLiteCache.kt | 120 +++--- .../openlibrary/schemas/Author.kt | 76 ++-- .../openlibrary/schemas/Common.kt | 18 +- .../openlibrary/schemas/Edition.kt | 184 +++++----- .../openlibrary/schemas/SearchResponse.kt | 236 ++++++------ .../buriedincode/openlibrary/schemas/Work.kt | 71 ++-- .../serializers/DescriptionSerializer.kt | 26 +- .../serializers/LocalDateSerializer.kt | 80 ++-- .../serializers/LocalDateTimeSerializer.kt | 72 ++-- .../openlibrary/ExceptionsTest.kt | 36 +- .../openlibrary/schemas/AuthorTest.kt | 90 +++-- .../openlibrary/schemas/EditionTest.kt | 232 ++++++------ .../openlibrary/schemas/WorkTest.kt | 238 ++++++------ settings.gradle.kts | 5 +- 145 files changed, 3595 insertions(+), 3787 deletions(-) create mode 100644 app/src/main/kotlin/github/buriedincode/Bookshelf.kt create mode 100644 app/src/main/kotlin/github/buriedincode/ErrorResponse.kt create mode 100644 app/src/main/kotlin/github/buriedincode/Settings.kt create mode 100644 app/src/main/kotlin/github/buriedincode/Utils.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/App.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/ErrorResponse.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/Settings.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/Utils.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/Book.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/BookSeries.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/Creator.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/Credit.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/Format.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/IJson.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/IdInput.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/Publisher.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/ReadBook.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/Role.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/Series.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/models/User.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/BaseApiRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/BookApiRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/CreatorApiRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/PublisherApiRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/RoleApiRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/SeriesApiRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/UserApiRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/BaseHtmlRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/BookHtmlRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/CreatorHtmlRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/PublisherHtmlRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/RoleHtmlRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/SeriesHtmlRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/UserHtmlRouter.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/BookSeriesTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/BookTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/CreatorTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/CreditTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/PublisherTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/ReadBookTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/RoleTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/SeriesTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/UserTable.kt delete mode 100644 app/src/main/kotlin/github/buriedincode/bookshelf/tables/WishedTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/Book.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/BookSeries.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/Creator.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/Credit.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/Format.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/IJson.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/IdInput.kt rename app/src/main/kotlin/github/buriedincode/{bookshelf => }/models/LocalDateDeserializer.kt (51%) create mode 100644 app/src/main/kotlin/github/buriedincode/models/Publisher.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/ReadBook.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/Role.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/Series.kt create mode 100644 app/src/main/kotlin/github/buriedincode/models/User.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/api/BaseApiRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/api/BookApiRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/api/CreatorApiRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/api/PublisherApiRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/api/RoleApiRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/api/SeriesApiRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/api/UserApiRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/html/BaseHtmlRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/html/BookHtmlRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/html/CreatorHtmlRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/html/PublisherHtmlRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/html/RoleHtmlRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/html/SeriesHtmlRouter.kt create mode 100644 app/src/main/kotlin/github/buriedincode/routers/html/UserHtmlRouter.kt rename app/src/main/kotlin/github/buriedincode/{bookshelf => }/services/OpenLibrary.kt (57%) create mode 100644 app/src/main/kotlin/github/buriedincode/tables/BookSeriesTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/BookTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/CreatorTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/CreditTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/PublisherTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/ReadBookTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/RoleTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/SeriesTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/UserTable.kt create mode 100644 app/src/main/kotlin/github/buriedincode/tables/WishedTable.kt diff --git a/.gitignore b/.gitignore index 017b388d..e46661b6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ jte-classes/ logs/ ### Files ### +.envrc diff --git a/README.md b/README.md index 38a1c1ff..19ba1044 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ # Bookshelf -![Java Version](https://img.shields.io/badge/Temurin-17-green?style=flat-square&logo=eclipse-adoptium) -![Kotlin Version](https://img.shields.io/badge/Kotlin-2.0.20-green?style=flat-square&logo=kotlin) +![Java Version](https://img.shields.io/badge/Temurin-20-green?style=flat-square&logo=eclipse-adoptium) +![Kotlin Version](https://img.shields.io/badge/Kotlin-2.2.0-green?style=flat-square&logo=kotlin) ![Status](https://img.shields.io/badge/Status-Beta-yellowgreen?style=flat-square) -[![Gradle](https://img.shields.io/badge/Gradle-8.10-informational?style=flat-square&logo=gradle)](https://github.com/gradle/gradle) -[![Ktlint](https://img.shields.io/badge/Ktlint-1.3.1-informational?style=flat-square)](https://github.com/pinterest/ktlint) -[![Javalin](https://img.shields.io/badge/Javalin-6.3.0-informational?style=flat-square)](https://github.com/javalin/javalin) -[![Bulma](https://img.shields.io/badge/Bulma-1.0.1-informational?style=flat-square)](https://github.com/jgthms/bulma) +[![Gradle](https://img.shields.io/badge/Gradle-9.0-informational?style=flat-square&logo=gradle)](https://github.com/gradle/gradle) +[![Ktlint](https://img.shields.io/badge/Spotless-7.2-informational?style=flat-square)](https://github.com/diffplug/spotless) +[![Javalin](https://img.shields.io/badge/Javalin-6.7-informational?style=flat-square)](https://github.com/javalin/javalin) +[![Bulma](https://img.shields.io/badge/Bulma-1.0-informational?style=flat-square)](https://github.com/jgthms/bulma) [![Github - Version](https://img.shields.io/github/v/tag/Buried-In-Code/Bookshelf?logo=Github&label=Version&style=flat-square)](https://github.com/Buried-In-Code/Bookshelf/tags) [![Github - License](https://img.shields.io/github/license/Buried-In-Code/Bookshelf?logo=Github&label=License&style=flat-square)](https://opensource.org/licenses/MIT) @@ -24,7 +24,7 @@ Tool for tracking books on your bookshelf or books you wish were on it. 1. Make sure you have a supported version of [Java](https://adoptium.net/temurin/releases/) installed: `java --version` 2. Clone the repo: `git clone https://github.com/Buried-In-Code/Bookshelf` 3. Build using: `./gradlew build` -4. Run using: `java -jar ./app/build/libs/app-0.4.1-all.jar` +4. Run using: `java -jar ./app/build/libs/app-0.5.0-all.jar` ### via Gradle diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee6ce952..8f791797 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,48 +1,49 @@ plugins { - application - alias(libs.plugins.jte) - alias(libs.plugins.shadow) + application + alias(libs.plugins.jte) + alias(libs.plugins.shadow) } dependencies { - implementation(project(":openlibrary")) - implementation(libs.bundles.exposed) - implementation(libs.bundles.jackson) - implementation(libs.bundles.javalin) - implementation(libs.bundles.jte) - implementation(libs.hoplite.core) - runtimeOnly(libs.postgres) + implementation(project(":openlibrary")) + implementation(libs.bundles.exposed) + implementation(libs.bundles.jackson) + implementation(libs.bundles.javalin) + implementation(libs.bundles.jte) + implementation(libs.hoplite.core) } application { - mainClass = "github.buriedincode.bookshelf.AppKt" - applicationName = "Bookshelf" + mainClass = "github.buriedincode.BookshelfKt" + applicationName = "Bookshelf" } jte { - precompile() - kotlinCompileArgs = arrayOf("-jvm-target", "17") + precompile() + kotlinCompileArgs = arrayOf("-jvm-target", "21") } +tasks.clean { doLast { delete("$projectDir/jte-classes") } } + tasks.jar { - dependsOn(tasks.precompileJte) - from( - fileTree("jte-classes") { - include("**/*.class") - include("**/*.bin") // Only required if you use binary templates - }, - ) - manifest.attributes["Main-Class"] = "github.buriedincode.bookshelf.AppKt" + dependsOn(tasks.precompileJte) + from( + fileTree("jte-classes") { + include("**/*.class") + include("**/*.bin") // Only required if you use binary templates + } + ) + manifest.attributes["Main-Class"] = "github.buriedincode.BookshelfKt" } tasks.shadowJar { - dependsOn(tasks.precompileJte) - from( - fileTree("jte-classes") { - include("**/*.class") - include("**/*.bin") // Only required if you use binary templates - }, - ) - manifest.attributes["Main-Class"] = "github.buriedincode.bookshelf.AppKt" - mergeServiceFiles() + dependsOn(tasks.precompileJte) + from( + fileTree("jte-classes") { + include("**/*.class") + include("**/*.bin") // Only required if you use binary templates + } + ) + manifest.attributes["Main-Class"] = "github.buriedincode.BookshelfKt" + mergeServiceFiles() } diff --git a/app/src/main/jte/components/filters/book.kte b/app/src/main/jte/components/filters/book.kte index 060c537c..6bbb7dcf 100644 --- a/app/src/main/jte/components/filters/book.kte +++ b/app/src/main/jte/components/filters/book.kte @@ -1,8 +1,8 @@ -@import github.buriedincode.bookshelf.models.Creator -@import github.buriedincode.bookshelf.models.Format -@import github.buriedincode.bookshelf.models.Publisher -@import github.buriedincode.bookshelf.models.Role -@import github.buriedincode.bookshelf.models.Series +@import github.buriedincode.models.Creator +@import github.buriedincode.models.Format +@import github.buriedincode.models.Publisher +@import github.buriedincode.models.Role +@import github.buriedincode.models.Series @import kotlin.collections.List @param creators: List @param creatorSelected: Creator? = null diff --git a/app/src/main/jte/components/forms/input_date.kte b/app/src/main/jte/components/forms/input_date.kte index c24d7e00..8d51d7a8 100644 --- a/app/src/main/jte/components/forms/input_date.kte +++ b/app/src/main/jte/components/forms/input_date.kte @@ -1,4 +1,4 @@ -@import github.buriedincode.bookshelf.Utils.toString +@import github.buriedincode.Utils.toString @import kotlinx.datetime.LocalDate @param label: String @param name: String diff --git a/app/src/main/jte/components/forms/select/book.kte b/app/src/main/jte/components/forms/select/book.kte index 0f218412..b6b4a925 100644 --- a/app/src/main/jte/components/forms/select/book.kte +++ b/app/src/main/jte/components/forms/select/book.kte @@ -1,5 +1,5 @@ +@import github.buriedincode.models.Book @import kotlin.collections.List -@import github.buriedincode.bookshelf.models.Book @param label: String = "Book" @param name: String = "book-id" @param options: List diff --git a/app/src/main/jte/components/forms/select/creator.kte b/app/src/main/jte/components/forms/select/creator.kte index 986180a2..3f167c84 100644 --- a/app/src/main/jte/components/forms/select/creator.kte +++ b/app/src/main/jte/components/forms/select/creator.kte @@ -1,5 +1,5 @@ +@import github.buriedincode.models.Creator @import kotlin.collections.List -@import github.buriedincode.bookshelf.models.Creator @param label: String = "Creator" @param name: String = "creator-id" @param options: List diff --git a/app/src/main/jte/components/forms/select/format.kte b/app/src/main/jte/components/forms/select/format.kte index c90aba3d..23f9aacc 100644 --- a/app/src/main/jte/components/forms/select/format.kte +++ b/app/src/main/jte/components/forms/select/format.kte @@ -1,6 +1,5 @@ +@import github.buriedincode.models.Format @import kotlin.collections.List -@import github.buriedincode.bookshelf.models.Format -@import github.buriedincode.bookshelf.Utils.titlecase @param label: String = "Format" @param name: String = "format" @param options: List @@ -13,7 +12,7 @@ diff --git a/app/src/main/jte/components/forms/select/publisher.kte b/app/src/main/jte/components/forms/select/publisher.kte index a059c8ff..06fc4e19 100644 --- a/app/src/main/jte/components/forms/select/publisher.kte +++ b/app/src/main/jte/components/forms/select/publisher.kte @@ -1,5 +1,5 @@ +@import github.buriedincode.models.Publisher @import kotlin.collections.List -@import github.buriedincode.bookshelf.models.Publisher @param label: String = "Publisher" @param name: String = "publisher-id" @param options: List diff --git a/app/src/main/jte/components/forms/select/role.kte b/app/src/main/jte/components/forms/select/role.kte index 7f13e18b..f5087731 100644 --- a/app/src/main/jte/components/forms/select/role.kte +++ b/app/src/main/jte/components/forms/select/role.kte @@ -1,5 +1,5 @@ +@import github.buriedincode.models.Role @import kotlin.collections.List -@import github.buriedincode.bookshelf.models.Role @param label: String = "Role" @param name: String = "role-id" @param options: List diff --git a/app/src/main/jte/components/forms/select/series.kte b/app/src/main/jte/components/forms/select/series.kte index 6e3ad8dc..b81ba3be 100644 --- a/app/src/main/jte/components/forms/select/series.kte +++ b/app/src/main/jte/components/forms/select/series.kte @@ -1,5 +1,5 @@ +@import github.buriedincode.models.Series @import kotlin.collections.List -@import github.buriedincode.bookshelf.models.Series @param label: String = "Series" @param name: String = "series-id" @param options: List diff --git a/app/src/main/jte/components/forms/select/user.kte b/app/src/main/jte/components/forms/select/user.kte index 9f30bc51..5b872e93 100644 --- a/app/src/main/jte/components/forms/select/user.kte +++ b/app/src/main/jte/components/forms/select/user.kte @@ -1,5 +1,5 @@ +@import github.buriedincode.models.User @import kotlin.collections.List -@import github.buriedincode.bookshelf.models.User @param label: String = "User" @param name: String = "user-id" @param options: List diff --git a/app/src/main/jte/components/navbar.kte b/app/src/main/jte/components/navbar.kte index 2b86b494..7bc9b425 100644 --- a/app/src/main/jte/components/navbar.kte +++ b/app/src/main/jte/components/navbar.kte @@ -1,4 +1,4 @@ -@import github.buriedincode.bookshelf.models.User +@import github.buriedincode.models.User @param session: User? = null
diff --git a/app/src/main/jte/templates/series/update.kte b/app/src/main/jte/templates/series/update.kte index 9d705327..93b68510 100644 --- a/app/src/main/jte/templates/series/update.kte +++ b/app/src/main/jte/templates/series/update.kte @@ -1,21 +1,20 @@ -@import github.buriedincode.bookshelf.models.Book -@import github.buriedincode.bookshelf.models.BookSeries -@import github.buriedincode.bookshelf.models.Series -@import github.buriedincode.bookshelf.models.User +@import github.buriedincode.models.Book +@import github.buriedincode.models.BookSeries +@import github.buriedincode.models.Series +@import github.buriedincode.models.User @import kotlin.collections.List @param session: User @param resource: Series @param books: List - + Bookshelf - - - + + diff --git a/app/src/main/jte/templates/series/view.kte b/app/src/main/jte/templates/series/view.kte index 818044d5..f87639aa 100644 --- a/app/src/main/jte/templates/series/view.kte +++ b/app/src/main/jte/templates/series/view.kte @@ -1,18 +1,17 @@ -@import github.buriedincode.bookshelf.models.BookSeries -@import github.buriedincode.bookshelf.models.Series -@import github.buriedincode.bookshelf.models.User +@import github.buriedincode.models.BookSeries +@import github.buriedincode.models.Series +@import github.buriedincode.models.User @param resource: Series @param session: User? - + Bookshelf - - - + + diff --git a/app/src/main/jte/templates/user/create.kte b/app/src/main/jte/templates/user/create.kte index 20e2eaff..0e864935 100644 --- a/app/src/main/jte/templates/user/create.kte +++ b/app/src/main/jte/templates/user/create.kte @@ -1,15 +1,14 @@ -@import github.buriedincode.bookshelf.models.User +@import github.buriedincode.models.User @param session: User? - + Bookshelf - - - + + diff --git a/app/src/main/jte/templates/user/list.kte b/app/src/main/jte/templates/user/list.kte index a828e758..3d41beef 100644 --- a/app/src/main/jte/templates/user/list.kte +++ b/app/src/main/jte/templates/user/list.kte @@ -1,4 +1,4 @@ -@import github.buriedincode.bookshelf.models.User +@import github.buriedincode.models.User @import kotlin.collections.List @import kotlin.collections.Map @param resources: List @@ -6,14 +6,13 @@ @param session: User? - + Bookshelf - - - + + diff --git a/app/src/main/jte/templates/user/update.kte b/app/src/main/jte/templates/user/update.kte index 38250631..ebd4b9e9 100644 --- a/app/src/main/jte/templates/user/update.kte +++ b/app/src/main/jte/templates/user/update.kte @@ -1,8 +1,8 @@ -@import github.buriedincode.bookshelf.Utils.toHumanReadable -@import github.buriedincode.bookshelf.Utils.toString -@import github.buriedincode.bookshelf.models.Book -@import github.buriedincode.bookshelf.models.ReadBook -@import github.buriedincode.bookshelf.models.User +@import github.buriedincode.Utils.toHumanReadable +@import github.buriedincode.Utils.toString +@import github.buriedincode.models.Book +@import github.buriedincode.models.ReadBook +@import github.buriedincode.models.User @import kotlin.collections.List @import kotlinx.datetime.LocalDate @param session: User @@ -11,14 +11,13 @@ @param wishedBooks: List - + Bookshelf - - - + + diff --git a/app/src/main/jte/templates/user/view.kte b/app/src/main/jte/templates/user/view.kte index 15be54f3..de1a529e 100644 --- a/app/src/main/jte/templates/user/view.kte +++ b/app/src/main/jte/templates/user/view.kte @@ -1,8 +1,8 @@ @import gg.jte.support.ForSupport -@import github.buriedincode.bookshelf.models.Book -@import github.buriedincode.bookshelf.models.ReadBook -@import github.buriedincode.bookshelf.models.Series -@import github.buriedincode.bookshelf.models.User +@import github.buriedincode.models.Book +@import github.buriedincode.models.ReadBook +@import github.buriedincode.models.Series +@import github.buriedincode.models.User @import kotlinx.datetime.LocalDate @import kotlin.collections.Map @param resource: User @@ -11,14 +11,13 @@ @param nextBooks: List - + Bookshelf - - - + + diff --git a/app/src/main/kotlin/github/buriedincode/Bookshelf.kt b/app/src/main/kotlin/github/buriedincode/Bookshelf.kt new file mode 100644 index 00000000..03090a99 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/Bookshelf.kt @@ -0,0 +1,268 @@ +package github.buriedincode + +import gg.jte.ContentType as JteType +import gg.jte.TemplateEngine +import gg.jte.resolve.DirectoryCodeResolver +import github.buriedincode.Utils.log +import github.buriedincode.Utils.toHumanReadable +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Creator +import github.buriedincode.models.Publisher +import github.buriedincode.models.Role +import github.buriedincode.models.Series +import github.buriedincode.models.User +import github.buriedincode.routers.api.BookApiRouter +import github.buriedincode.routers.api.CreatorApiRouter +import github.buriedincode.routers.api.PublisherApiRouter +import github.buriedincode.routers.api.RoleApiRouter +import github.buriedincode.routers.api.SeriesApiRouter +import github.buriedincode.routers.api.UserApiRouter +import github.buriedincode.routers.html.BookHtmlRouter +import github.buriedincode.routers.html.CreatorHtmlRouter +import github.buriedincode.routers.html.PublisherHtmlRouter +import github.buriedincode.routers.html.RoleHtmlRouter +import github.buriedincode.routers.html.SeriesHtmlRouter +import github.buriedincode.routers.html.UserHtmlRouter +import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.Level +import io.javalin.Javalin +import io.javalin.apibuilder.ApiBuilder.delete +import io.javalin.apibuilder.ApiBuilder.get +import io.javalin.apibuilder.ApiBuilder.path +import io.javalin.apibuilder.ApiBuilder.post +import io.javalin.apibuilder.ApiBuilder.put +import io.javalin.http.ContentType +import io.javalin.rendering.FileRenderer +import io.javalin.rendering.template.JavalinJte +import java.nio.file.Path +import kotlin.io.path.div + +object Bookshelf { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + + private fun createTemplateEngine(): TemplateEngine { + val codeResolver = DirectoryCodeResolver(Path.of("src") / "main" / "jte") + return TemplateEngine.create(codeResolver, JteType.Html) + // return TemplateEngine.createPrecompiled(Path.of("jte-classes"), JteType.Html) + } + + private fun createJavalinApp(renderer: FileRenderer): Javalin { + return Javalin.create { + it.fileRenderer(fileRenderer = renderer) + it.http.prefer405over404 = true + it.http.defaultContentType = ContentType.JSON + it.requestLogger.http { ctx, ms -> + val level = + when { + ctx.statusCode() in (100..<200) -> Level.WARN + ctx.statusCode() in (200..<300) -> Level.INFO + ctx.statusCode() in (300..<400) -> Level.INFO + ctx.statusCode() in (400..<500) -> Level.WARN + else -> Level.ERROR + } + LOGGER.log(level) { "${ctx.statusCode()}: ${ctx.method()} - ${ctx.path()} => ${ms.toHumanReadable()}" } + } + it.router.ignoreTrailingSlashes = true + it.router.treatMultipleSlashesAsSingleSlash = true + it.router.caseInsensitiveRoutes = true + it.router.apiBuilder { + path("/") { + get { ctx -> + transaction { + ctx.render( + filePath = "templates/index.kte", + model = + mapOf( + "session" to ctx.cookie("bookshelf_session-id")?.toLongOrNull()?.let { User.findById(it) }, + "users" to User.all().sorted().toList(), + ), + ) + } + } + path("books") { + get(BookHtmlRouter::list) + get("create", BookHtmlRouter::create) + get("import", BookHtmlRouter::import) + get("search", BookHtmlRouter::search) + path("{book-id}") { + get(BookHtmlRouter::view) + get("update", BookHtmlRouter::update) + } + } + path("creators") { + path("{creator-id}") { + get(CreatorHtmlRouter::view) + get("update", CreatorHtmlRouter::update) + } + } + path("publishers") { + path("{publisher-id}") { + get(PublisherHtmlRouter::view) + get("update", PublisherHtmlRouter::update) + } + } + path("roles") { + path("{role-id}") { + get(RoleHtmlRouter::view) + get("update", RoleHtmlRouter::update) + } + } + path("series") { + get(SeriesHtmlRouter::list) + get("create", SeriesHtmlRouter::create) + path("{series-id}") { + get(SeriesHtmlRouter::view) + get("update", SeriesHtmlRouter::update) + } + } + path("users") { + get(UserHtmlRouter::list) + get("create", UserHtmlRouter::create) + path("{user-id}") { + get(UserHtmlRouter::view) + get("readlist", UserHtmlRouter::readlist) + get("update", UserHtmlRouter::update) + get("wishlist", UserHtmlRouter::wishlist) + } + } + } + path("api") { + delete("clean") { ctx -> + transaction { + Creator.all().filter { it.credits.empty() }.forEach { it.delete() } + Publisher.all().filter { it.books.empty() }.forEach { it.delete() } + Role.all().filter { it.credits.empty() }.forEach { it.delete() } + Series.all().filter { it.books.empty() }.forEach { it.delete() } + } + } + path("books") { + post("search", BookApiRouter::search) + get(BookApiRouter::list) + post(BookApiRouter::create) + post("import", BookApiRouter::import) + path("{book-id}") { + get(BookApiRouter::read) + put(BookApiRouter::update) + delete(BookApiRouter::delete) + post("import", BookApiRouter::reimport) + path("collect") { + post(BookApiRouter::collectBook) + delete(BookApiRouter::discardBook) + } + path("wish") { + post(BookApiRouter::addWisher) + delete(BookApiRouter::removeWisher) + } + path("read") { + post(BookApiRouter::addReader) + delete(BookApiRouter::removeReader) + } + path("credits") { + post(BookApiRouter::addCredit) + delete(BookApiRouter::removeCredit) + } + path("series") { + post(BookApiRouter::addSeries) + delete(BookApiRouter::removeSeries) + } + } + } + path("creators") { + get(CreatorApiRouter::list) + post(CreatorApiRouter::create) + path("{creator-id}") { + get(CreatorApiRouter::read) + put(CreatorApiRouter::update) + delete(CreatorApiRouter::delete) + path("credits") { + post(CreatorApiRouter::addCredit) + delete(CreatorApiRouter::removeCredit) + } + } + } + path("publishers") { + get(PublisherApiRouter::list) + post(PublisherApiRouter::create) + path("{publisher-id}") { + get(PublisherApiRouter::read) + put(PublisherApiRouter::update) + delete(PublisherApiRouter::delete) + path("books") { + post(PublisherApiRouter::addBook) + delete(PublisherApiRouter::removeBook) + } + } + } + path("roles") { + get(RoleApiRouter::list) + post(RoleApiRouter::create) + path("{role-id}") { + get(RoleApiRouter::read) + put(RoleApiRouter::update) + delete(RoleApiRouter::delete) + path("credits") { + post(RoleApiRouter::addCredit) + delete(RoleApiRouter::removeCredit) + } + } + } + path("series") { + get(SeriesApiRouter::list) + post(SeriesApiRouter::create) + path("{series-id}") { + get(SeriesApiRouter::read) + put(SeriesApiRouter::update) + delete(SeriesApiRouter::delete) + path("books") { + post(SeriesApiRouter::addBook) + delete(SeriesApiRouter::removeBook) + } + } + } + path("users") { + get(UserApiRouter::list) + post(UserApiRouter::create) + path("{user-id}") { + get(UserApiRouter::read) + put(UserApiRouter::update) + delete(UserApiRouter::delete) + path("read") { + post(UserApiRouter::addReadBook) + delete(UserApiRouter::removeReadBook) + } + path("wished") { + post(UserApiRouter::addWishedBook) + delete(UserApiRouter::removeWishedBook) + } + } + } + } + } + it.staticFiles.add { + it.hostedPath = "/static" + it.directory = "/static" + } + } + } + + fun start(settings: Settings) { + val engine = createTemplateEngine() + engine.setTrimControlStructures(true) + val renderer = JavalinJte(templateEngine = engine) + + val app = createJavalinApp(renderer = renderer) + app.start(settings.website.host, settings.website.port) + } +} + +fun main(@Suppress("UNUSED_PARAMETER") vararg args: String) { + println("$PROJECT_NAME v$VERSION") + println("Kotlin v${KotlinVersion.CURRENT}") + println("Java v${System.getProperty("java.version")}") + println("Arch: ${System.getProperty("os.arch")}") + + val settings = Settings.load() + println(settings) + + Bookshelf.start(settings = settings) +} diff --git a/app/src/main/kotlin/github/buriedincode/ErrorResponse.kt b/app/src/main/kotlin/github/buriedincode/ErrorResponse.kt new file mode 100644 index 00000000..3f4955a9 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/ErrorResponse.kt @@ -0,0 +1,3 @@ +package github.buriedincode + +data class ErrorResponse(val title: String, val status: Int, val type: String, val details: Map?) diff --git a/app/src/main/kotlin/github/buriedincode/Settings.kt b/app/src/main/kotlin/github/buriedincode/Settings.kt new file mode 100644 index 00000000..b61dcd8c --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/Settings.kt @@ -0,0 +1,24 @@ +package github.buriedincode + +import com.sksamuel.hoplite.ConfigLoaderBuilder +import com.sksamuel.hoplite.ExperimentalHoplite +import com.sksamuel.hoplite.addPathSource +import com.sksamuel.hoplite.addResourceSource +import kotlin.io.path.div + +data class Settings(val database: Database, val website: Website) { + data class Database(val url: String) + + data class Website(val host: String, val port: Int) + + companion object { + @OptIn(ExperimentalHoplite::class) + fun load(): Settings = + ConfigLoaderBuilder.default() + .withExplicitSealedTypes() + .addPathSource(Utils.CONFIG_ROOT / "settings.properties", optional = true, allowEmpty = true) + .addResourceSource("/default.properties") + .build() + .loadConfigOrThrow() + } +} diff --git a/app/src/main/kotlin/github/buriedincode/Utils.kt b/app/src/main/kotlin/github/buriedincode/Utils.kt new file mode 100644 index 00000000..526e58c7 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/Utils.kt @@ -0,0 +1,143 @@ +package github.buriedincode + +import com.sksamuel.hoplite.Secret +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.Level +import java.nio.file.Path +import java.nio.file.Paths +import java.sql.Connection +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.time.temporal.TemporalAccessor +import java.util.Locale +import kotlin.io.path.createDirectories +import kotlin.io.path.div +import kotlin.io.path.exists +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.measureTimedValue +import kotlin.time.toDuration +import kotlinx.datetime.LocalDate +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toKotlinLocalDate +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.DatabaseConfig +import org.jetbrains.exposed.sql.ExperimentalKeywordApi +import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger +import org.jetbrains.exposed.sql.addLogger +import org.jetbrains.exposed.sql.transactions.transaction + +internal const val VERSION = "0.5.0" +internal const val PROJECT_NAME = "Bookshelf" + +object Utils { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + + private val USER_HOME: Path by lazy { Paths.get(System.getProperty("user.home")) } + private val XDG_CACHE_HOME: Path by lazy { + System.getenv("XDG_CACHE_HOME")?.let { Paths.get(it) } ?: (USER_HOME / ".cache") + } + private val XDG_CONFIG_HOME: Path by lazy { + System.getenv("XDG_CONFIG_HOME")?.let { Paths.get(it) } ?: (USER_HOME / ".config") + } + private val XDG_DATA_HOME: Path by lazy { + System.getenv("XDG_DATA_HOME")?.let { Paths.get(it) } ?: (USER_HOME / ".local" / "share") + } + + internal val CACHE_ROOT by lazy { XDG_CACHE_HOME / PROJECT_NAME.lowercase() } + internal val CONFIG_ROOT by lazy { XDG_CONFIG_HOME / PROJECT_NAME.lowercase() } + internal val DATA_ROOT by lazy { XDG_DATA_HOME / PROJECT_NAME.lowercase() } + + private val DATABASE: Database by lazy { + val settings = Settings.load() + return@lazy Database.connect( + url = "jdbc:sqlite:${settings.database.url}", + driver = "org.sqlite.JDBC", + databaseConfig = + DatabaseConfig { + @OptIn(ExperimentalKeywordApi::class) + preserveKeywordCasing = true + }, + ) + } + + init { + listOf(CACHE_ROOT, CONFIG_ROOT, DATA_ROOT).forEach { if (!it.exists()) it.createDirectories() } + } + + fun transaction(block: () -> T): T { + val result = measureTimedValue { + transaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, db = DATABASE) { + addLogger(Slf4jSqlDebugLogger) + block() + } + } + LOGGER.debug { "Took ${result.duration.toHumanReadable()}" } + return result.value + } + + private fun getDayNumberSuffix(day: Int): String { + return if (day in 11..13) { + "th" + } else { + when (day % 10) { + 1 -> "st" + 2 -> "nd" + 3 -> "rd" + else -> "th" + } + } + } + + internal fun KLogger.log(level: Level, message: () -> Any?) { + when (level) { + Level.TRACE -> this.trace(message) + Level.DEBUG -> this.debug(message) + Level.INFO -> this.info(message) + Level.WARN -> this.warn(message) + Level.ERROR -> this.error(message) + else -> return + } + } + + internal fun Duration.toHumanReadable(): String = this.toString() + + internal fun Long.toHumanReadable(): String = this.toDuration(DurationUnit.MILLISECONDS).toHumanReadable() + + internal fun Float.toHumanReadable(): String = this.toLong().toHumanReadable() + + internal fun Secret?.isNullOrBlank(): Boolean = this?.value.isNullOrBlank() + + inline fun > String.asEnumOrNull(): T? = + enumValues().firstOrNull { + it.name.equals(this, ignoreCase = true) || it.name.replace("_", " ").equals(this, ignoreCase = true) + } + + fun String.toLocalDateOrNull(pattern: String): LocalDate? { + return try { + val formatter = DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH) + java.time.LocalDate.parse(this, formatter).toKotlinLocalDate() + } catch (e: DateTimeParseException) { + null + } + } + + fun LocalDate.toHumanReadable(showFull: Boolean = false): String { + val pattern = + if (showFull) { + "EEE, d'${getDayNumberSuffix(this.day)}' MMM yyyy" + } else { + "d'${getDayNumberSuffix(this.day)}' MMM yyyy" + } + return this.toJavaLocalDate().formatToPattern(pattern) + } + + fun LocalDate.toString(pattern: String): String { + return this.toJavaLocalDate().formatToPattern(pattern) + } + + private fun TemporalAccessor.formatToPattern(pattern: String): String { + return DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH).format(this) + } +} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/App.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/App.kt deleted file mode 100644 index bc761a55..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/App.kt +++ /dev/null @@ -1,272 +0,0 @@ -package github.buriedincode.bookshelf - -import gg.jte.TemplateEngine -import gg.jte.resolve.DirectoryCodeResolver -import github.buriedincode.bookshelf.Utils.log -import github.buriedincode.bookshelf.models.Creator -import github.buriedincode.bookshelf.models.Publisher -import github.buriedincode.bookshelf.models.Role -import github.buriedincode.bookshelf.models.Series -import github.buriedincode.bookshelf.models.User -import github.buriedincode.bookshelf.routers.api.BookApiRouter -import github.buriedincode.bookshelf.routers.api.CreatorApiRouter -import github.buriedincode.bookshelf.routers.api.PublisherApiRouter -import github.buriedincode.bookshelf.routers.api.RoleApiRouter -import github.buriedincode.bookshelf.routers.api.SeriesApiRouter -import github.buriedincode.bookshelf.routers.api.UserApiRouter -import github.buriedincode.bookshelf.routers.html.BookHtmlRouter -import github.buriedincode.bookshelf.routers.html.CreatorHtmlRouter -import github.buriedincode.bookshelf.routers.html.PublisherHtmlRouter -import github.buriedincode.bookshelf.routers.html.RoleHtmlRouter -import github.buriedincode.bookshelf.routers.html.SeriesHtmlRouter -import github.buriedincode.bookshelf.routers.html.UserHtmlRouter -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.Level -import io.javalin.Javalin -import io.javalin.apibuilder.ApiBuilder.delete -import io.javalin.apibuilder.ApiBuilder.get -import io.javalin.apibuilder.ApiBuilder.path -import io.javalin.apibuilder.ApiBuilder.post -import io.javalin.apibuilder.ApiBuilder.put -import io.javalin.http.ContentType -import io.javalin.rendering.FileRenderer -import io.javalin.rendering.template.JavalinJte -import java.nio.file.Path -import kotlin.io.path.div -import gg.jte.ContentType as JteType - -object App { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - - private fun createTemplateEngine(environment: Settings.Environment): TemplateEngine { - return if (environment == Settings.Environment.DEV) { - val codeResolver = DirectoryCodeResolver(Path.of("src") / "main" / "jte") - TemplateEngine.create(codeResolver, JteType.Html) - } else { - TemplateEngine.createPrecompiled(Path.of("jte-classes"), JteType.Html) - } - } - - private fun createJavalinApp(renderer: FileRenderer): Javalin { - return Javalin.create { - it.fileRenderer(fileRenderer = renderer) - it.http.prefer405over404 = true - it.http.defaultContentType = ContentType.JSON - it.requestLogger.http { ctx, ms -> - val level = when { - ctx.statusCode() in (100..<200) -> Level.WARN - ctx.statusCode() in (200..<300) -> Level.INFO - ctx.statusCode() in (300..<400) -> Level.INFO - ctx.statusCode() in (400..<500) -> Level.WARN - else -> Level.ERROR - } - LOGGER.log(level) { "${ctx.statusCode()}: ${ctx.method()} - ${ctx.path()} => ${Utils.toHumanReadable(ms)}" } - } - it.router.ignoreTrailingSlashes = true - it.router.treatMultipleSlashesAsSingleSlash = true - it.router.caseInsensitiveRoutes = true - it.router.apiBuilder { - path("/") { - get { ctx -> - Utils.query { - ctx.render( - filePath = "templates/index.kte", - model = mapOf( - "session" to ctx.cookie("bookshelf_session-id")?.toLongOrNull()?.let { - User.findById(it) - }, - "users" to User.all().sorted().toList(), - ), - ) - } - } - path("books") { - get(BookHtmlRouter::list) - get("create", BookHtmlRouter::create) - get("import", BookHtmlRouter::import) - get("search", BookHtmlRouter::search) - path("{book-id}") { - get(BookHtmlRouter::view) - get("update", BookHtmlRouter::update) - } - } - path("creators") { - path("{creator-id}") { - get(CreatorHtmlRouter::view) - get("update", CreatorHtmlRouter::update) - } - } - path("publishers") { - path("{publisher-id}") { - get(PublisherHtmlRouter::view) - get("update", PublisherHtmlRouter::update) - } - } - path("roles") { - path("{role-id}") { - get(RoleHtmlRouter::view) - get("update", RoleHtmlRouter::update) - } - } - path("series") { - get(SeriesHtmlRouter::list) - get("create", SeriesHtmlRouter::create) - path("{series-id}") { - get(SeriesHtmlRouter::view) - get("update", SeriesHtmlRouter::update) - } - } - path("users") { - get(UserHtmlRouter::list) - get("create", UserHtmlRouter::create) - path("{user-id}") { - get(UserHtmlRouter::view) - get("readlist", UserHtmlRouter::readlist) - get("update", UserHtmlRouter::update) - get("wishlist", UserHtmlRouter::wishlist) - } - } - } - path("api") { - delete("clean") { ctx -> - Utils.query { - Creator.all().filter { it.credits.empty() }.forEach { it.delete() } - Publisher.all().filter { it.books.empty() }.forEach { it.delete() } - Role.all().filter { it.credits.empty() }.forEach { it.delete() } - Series.all().filter { it.books.empty() }.forEach { it.delete() } - } - } - path("books") { - post("search", BookApiRouter::search) - get(BookApiRouter::list) - post(BookApiRouter::create) - post("import", BookApiRouter::import) - path("{book-id}") { - get(BookApiRouter::read) - put(BookApiRouter::update) - delete(BookApiRouter::delete) - post("import", BookApiRouter::reimport) - path("collect") { - post(BookApiRouter::collectBook) - delete(BookApiRouter::discardBook) - } - path("wish") { - post(BookApiRouter::addWisher) - delete(BookApiRouter::removeWisher) - } - path("read") { - post(BookApiRouter::addReader) - delete(BookApiRouter::removeReader) - } - path("credits") { - post(BookApiRouter::addCredit) - delete(BookApiRouter::removeCredit) - } - path("series") { - post(BookApiRouter::addSeries) - delete(BookApiRouter::removeSeries) - } - } - } - path("creators") { - get(CreatorApiRouter::list) - post(CreatorApiRouter::create) - path("{creator-id}") { - get(CreatorApiRouter::read) - put(CreatorApiRouter::update) - delete(CreatorApiRouter::delete) - path("credits") { - post(CreatorApiRouter::addCredit) - delete(CreatorApiRouter::removeCredit) - } - } - } - path("publishers") { - get(PublisherApiRouter::list) - post(PublisherApiRouter::create) - path("{publisher-id}") { - get(PublisherApiRouter::read) - put(PublisherApiRouter::update) - delete(PublisherApiRouter::delete) - path("books") { - post(PublisherApiRouter::addBook) - delete(PublisherApiRouter::removeBook) - } - } - } - path("roles") { - get(RoleApiRouter::list) - post(RoleApiRouter::create) - path("{role-id}") { - get(RoleApiRouter::read) - put(RoleApiRouter::update) - delete(RoleApiRouter::delete) - path("credits") { - post(RoleApiRouter::addCredit) - delete(RoleApiRouter::removeCredit) - } - } - } - path("series") { - get(SeriesApiRouter::list) - post(SeriesApiRouter::create) - path("{series-id}") { - get(SeriesApiRouter::read) - put(SeriesApiRouter::update) - delete(SeriesApiRouter::delete) - path("books") { - post(SeriesApiRouter::addBook) - delete(SeriesApiRouter::removeBook) - } - } - } - path("users") { - get(UserApiRouter::list) - post(UserApiRouter::create) - path("{user-id}") { - get(UserApiRouter::read) - put(UserApiRouter::update) - delete(UserApiRouter::delete) - path("read") { - post(UserApiRouter::addReadBook) - delete(UserApiRouter::removeReadBook) - } - path("wished") { - post(UserApiRouter::addWishedBook) - delete(UserApiRouter::removeWishedBook) - } - } - } - } - } - it.staticFiles.add { - it.hostedPath = "/static" - it.directory = "/static" - } - } - } - - fun start(settings: Settings) { - val engine = createTemplateEngine(environment = settings.environment) - engine.setTrimControlStructures(true) - val renderer = JavalinJte(templateEngine = engine) - - val app = createJavalinApp(renderer = renderer) - app.start(settings.website.host, settings.website.port) - } -} - -fun main( - @Suppress("UNUSED_PARAMETER") vararg args: String, -) { - println("Bookshelf v${Utils.VERSION}") - println("Kotlin v${KotlinVersion.CURRENT}") - println("Java v${System.getProperty("java.version")}") - println("Arch: ${System.getProperty("os.arch")}") - - val settings = Settings.load() - println(settings) - - App.start(settings = settings) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/ErrorResponse.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/ErrorResponse.kt deleted file mode 100644 index a55919b9..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/ErrorResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -package github.buriedincode.bookshelf - -data class ErrorResponse( - val title: String, - val status: Int, - val type: String, - val details: Map?, -) diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/Settings.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/Settings.kt deleted file mode 100644 index 29014702..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/Settings.kt +++ /dev/null @@ -1,35 +0,0 @@ -package github.buriedincode.bookshelf - -import com.sksamuel.hoplite.ConfigLoaderBuilder -import com.sksamuel.hoplite.addPathSource -import com.sksamuel.hoplite.addResourceSource -import kotlin.io.path.div - -data class Settings( - val database: Database, - val environment: Environment, - val website: Website, -) { - enum class Environment { - DEV, - PROD, - } - - data class Database(val source: Source, val url: String, val user: String? = null, val password: String? = null) { - enum class Source { - POSTGRES, - SQLITE, - } - } - - data class Website(val host: String, val port: Int) - - companion object { - fun load(): Settings = ConfigLoaderBuilder - .default() - .addPathSource(Utils.CONFIG_ROOT / "settings.properties", optional = true, allowEmpty = true) - .addResourceSource("/default.properties") - .build() - .loadConfigOrThrow() - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/Utils.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/Utils.kt deleted file mode 100644 index 13218390..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/Utils.kt +++ /dev/null @@ -1,150 +0,0 @@ -package github.buriedincode.bookshelf - -import com.sksamuel.hoplite.Secret -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.oshai.kotlinlogging.Level -import kotlinx.datetime.LocalDate -import kotlinx.datetime.toJavaLocalDate -import kotlinx.datetime.toKotlinLocalDate -import org.jetbrains.exposed.sql.Database -import org.jetbrains.exposed.sql.DatabaseConfig -import org.jetbrains.exposed.sql.ExperimentalKeywordApi -import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger -import org.jetbrains.exposed.sql.addLogger -import org.jetbrains.exposed.sql.transactions.transaction -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.sql.Connection -import java.time.Duration -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException -import java.time.temporal.ChronoUnit -import java.time.temporal.TemporalAccessor -import java.util.Locale -import kotlin.io.path.div - -object Utils { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - private val HOME_ROOT: Path by lazy { Paths.get(System.getProperty("user.home")) } - private val XDG_CACHE: Path by lazy { System.getenv("XDG_CACHE_HOME")?.let(Paths::get) ?: (HOME_ROOT / ".cache") } - private val XDG_CONFIG: Path by lazy { System.getenv("XDG_CONFIG_HOME")?.let(Paths::get) ?: (HOME_ROOT / ".config") } - private val XDG_DATA: Path by lazy { System.getenv("XDG_DATA_HOME")?.let(Paths::get) ?: (HOME_ROOT / ".local" / "share") } - - internal val CACHE_ROOT: Path = XDG_CACHE / "bookshelf" - internal val CONFIG_ROOT: Path = XDG_CONFIG / "bookshelf" - internal val DATA_ROOT: Path = XDG_DATA / "bookshelf" - internal const val VERSION = "0.3.1" - - private val DATABASE: Database by lazy { - val settings = Settings.load() - Database.connect( - url = when (settings.database.source) { - Settings.Database.Source.POSTGRES -> "jdbc:postgresql://${settings.database.url}" - else -> "jdbc:sqlite:${settings.database.url}" - }, - driver = when (settings.database.source) { - Settings.Database.Source.POSTGRES -> "org.postgresql.Driver" - else -> "org.sqlite.JDBC" - }, - user = settings.database.user ?: "user", - password = settings.database.password ?: "password", - databaseConfig = DatabaseConfig { - @OptIn(ExperimentalKeywordApi::class) - preserveKeywordCasing = true - }, - ) - } - - init { - listOf(CACHE_ROOT, CONFIG_ROOT, DATA_ROOT).forEach { - if (!Files.exists(it)) it.toFile().mkdirs() - } - } - - fun query(block: () -> T): T { - val startTime = LocalDateTime.now() - val transaction = transaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, db = DATABASE) { - addLogger(Slf4jSqlDebugLogger) - block() - } - LOGGER.debug { "Took ${ChronoUnit.MILLIS.between(startTime, LocalDateTime.now())}ms" } - return transaction - } - - private fun getDayNumberSuffix(day: Int): String { - return if (day in 11..13) { - "th" - } else { - when (day % 10) { - 1 -> "st" - 2 -> "nd" - 3 -> "rd" - else -> "th" - } - } - } - - internal fun KLogger.log(level: Level, message: () -> Any?) { - when (level) { - Level.TRACE -> this.trace(message) - Level.DEBUG -> this.debug(message) - Level.INFO -> this.info(message) - Level.WARN -> this.warn(message) - Level.ERROR -> this.error(message) - else -> return - } - } - - internal fun toHumanReadable(milliseconds: Float): String { - val duration = Duration.ofMillis(milliseconds.toLong()) - val minutes = duration.toMinutes() - val seconds = duration.seconds - minutes * 60 - val millis = duration.toMillis() - (minutes * 60000 + seconds * 1000) - return when { - minutes > 0 -> "${minutes}min ${seconds}sec ${millis}ms" - seconds > 0 -> "${seconds}sec ${millis}ms" - else -> "${millis}ms" - } - } - - internal fun Secret?.isNullOrBlank(): Boolean = this?.value.isNullOrBlank() - - inline fun > String.asEnumOrNull(): T? = enumValues().firstOrNull { - it.name.equals(this, ignoreCase = true) || - it.name.replace("_", " ").equals(this, ignoreCase = true) - } - - inline fun > T.titlecase(): String = this.name.lowercase().split("_").joinToString(" ") { - it.replaceFirstChar(Char::uppercaseChar) - } - - fun String.toLocalDateOrNull(pattern: String): LocalDate? { - return try { - val formatter = DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH) - java.time.LocalDate.parse(this, formatter).toKotlinLocalDate() - } catch (e: DateTimeParseException) { - null - } - } - - fun LocalDate.toHumanReadable(showFull: Boolean = false): String { - val pattern = if (showFull) { - "EEE, d'${getDayNumberSuffix(this.dayOfMonth)}' MMM yyyy" - } else { - "d'${getDayNumberSuffix(this.dayOfMonth)}' MMM yyyy" - } - return this.toJavaLocalDate().formatToPattern(pattern) - } - - fun LocalDate.toString(pattern: String): String { - return this.toJavaLocalDate().formatToPattern(pattern) - } - - private fun TemporalAccessor.formatToPattern(pattern: String): String { - return DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH).format(this) - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Book.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/Book.kt deleted file mode 100644 index 9b37e96c..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Book.kt +++ /dev/null @@ -1,152 +0,0 @@ -package github.buriedincode.bookshelf.models - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import github.buriedincode.bookshelf.Utils.toString -import github.buriedincode.bookshelf.tables.BookSeriesTable -import github.buriedincode.bookshelf.tables.BookTable -import github.buriedincode.bookshelf.tables.CreditTable -import github.buriedincode.bookshelf.tables.ReadBookTable -import github.buriedincode.bookshelf.tables.WishedTable -import kotlinx.datetime.LocalDate -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.sql.and - -class Book(id: EntityID) : LongEntity(id), IJson, Comparable { - companion object : LongEntityClass(BookTable) { - val comparator = compareBy { it.series.firstOrNull()?.series } - .thenBy { it.series.firstOrNull()?.number ?: Int.MAX_VALUE } - .thenBy(Book::title) - .thenBy(nullsFirst(), Book::subtitle) - - fun find(title: String, subtitle: String? = null, isbn: String? = null, openLibraryId: String? = null): Book? { - var result: Book? = null - if (result == null && openLibraryId != null) { - result = find { BookTable.openLibraryCol eq openLibraryId }.firstOrNull() - } - if (result == null && isbn != null) { - result = find { BookTable.isbnCol eq isbn }.firstOrNull() - } - if (result == null) { - result = find { (BookTable.titleCol eq title) and (BookTable.subtitleCol eq subtitle) }.firstOrNull() - } - return result - } - - fun findOrCreate(title: String, subtitle: String? = null, isbn: String? = null, openLibraryId: String? = null): Book { - return find(title, subtitle, isbn, openLibraryId) ?: new { - this.title = title - this.subtitle = subtitle - this.isbn = isbn - this.openLibrary = openLibraryId - } - } - } - - val credits by Credit referrersOn CreditTable.bookCol - var format: Format by BookTable.formatCol - var goodreads: String? by BookTable.goodreadsCol - var googleBooks: String? by BookTable.googleBooksCol - var imageUrl: String? by BookTable.imageUrlCol - var isbn: String? by BookTable.isbnCol - var isCollected: Boolean by BookTable.isCollectedCol - var libraryThing: String? by BookTable.libraryThingCol - var openLibrary: String? by BookTable.openLibraryCol - var publishDate: LocalDate? by BookTable.publishDateCol - var publisher: Publisher? by Publisher optionalReferencedOn BookTable.publisherCol - val readers by ReadBook referrersOn ReadBookTable.bookCol - val series by BookSeries referrersOn BookSeriesTable.bookCol - var subtitle: String? by BookTable.subtitleCol - var summary: String? by BookTable.summaryCol - var title: String by BookTable.titleCol - var wishers by User via WishedTable - - override fun toJson(showAll: Boolean): Map { - return mutableMapOf( - "format" to format.name, - "id" to id.value, - "identifiers" to mapOf( - "goodreads" to goodreads, - "googleBooks" to googleBooks, - "isbn" to isbn, - "libraryThing" to libraryThing, - "openLibrary" to openLibrary, - ), - "imageUrl" to imageUrl, - "isCollected" to isCollected, - "publishDate" to publishDate?.toString("yyyy-MM-dd"), - "publisher" to publisher?.id?.value, - "subtitle" to subtitle, - "summary" to summary, - "title" to title, - ).apply { - if (showAll) { - put( - "credits", - credits.groupBy({ it.role }, { it.creator }).toSortedMap().map { (role, creators) -> - mapOf( - "role" to role.toJson(), - "creators" to creators.map { it.toJson() }, - ) - }, - ) - put("readers", readers.groupBy({ it.readDate?.toString("yyyy-MM-dd") ?: "null" }, { it.user.toJson() })) - put("series", series.sortedBy { it.series }.map { mapOf("series" to it.series.toJson(), "number" to it.number) }) - put("wishers", wishers.sorted().map { it.toJson() }) - } - }.toSortedMap() - } - - override fun compareTo(other: Book): Int = comparator.compare(this, other) -} - -data class BookInput( - val credits: List = emptyList(), - val format: Format = Format.PAPERBACK, - val identifiers: Identifiers? = null, - val imageUrl: String? = null, - val isCollected: Boolean = false, - @JsonDeserialize(using = LocalDateDeserializer::class) - val publishDate: LocalDate? = null, - val publisher: Long? = null, - val readers: List = emptyList(), - val series: List = emptyList(), - val subtitle: String? = null, - val summary: String? = null, - val title: String, - val wishers: List = emptyList(), -) { - data class Credit( - val creator: Long, - val role: Long, - ) - - data class Identifiers( - val goodreads: String? = null, - val googleBooks: String? = null, - val isbn: String? = null, - val libraryThing: String? = null, - val openLibrary: String? = null, - ) - - data class Reader( - val user: Long, - @JsonDeserialize(using = LocalDateDeserializer::class) - val readDate: LocalDate? = null, - ) - - data class Series( - val series: Long, - val number: Int? = null, - ) -} - -data class ImportBook( - val goodreadsId: String? = null, - val googleBooksId: String? = null, - val isbn: String? = null, - val isCollected: Boolean = false, - val libraryThingId: String? = null, - val openLibraryId: String? = null, -) diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/BookSeries.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/BookSeries.kt deleted file mode 100644 index 7b205019..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/BookSeries.kt +++ /dev/null @@ -1,28 +0,0 @@ -package github.buriedincode.bookshelf.models - -import github.buriedincode.bookshelf.tables.BookSeriesTable -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.sql.and - -class BookSeries(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(BookSeriesTable) { - fun find(book: Book, series: Series): BookSeries? { - return BookSeries - .find { (BookSeriesTable.bookCol eq book.id) and (BookSeriesTable.seriesCol eq series.id) } - .firstOrNull() - } - - fun findOrCreate(book: Book, series: Series): BookSeries { - return find(book, series) ?: BookSeries.new { - this.book = book - this.series = series - } - } - } - - var book: Book by Book referencedOn BookSeriesTable.bookCol - var series: Series by Series referencedOn BookSeriesTable.seriesCol - var number: Int? by BookSeriesTable.numberCol -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Creator.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/Creator.kt deleted file mode 100644 index 87bad22e..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Creator.kt +++ /dev/null @@ -1,60 +0,0 @@ -package github.buriedincode.bookshelf.models - -import github.buriedincode.bookshelf.tables.CreatorTable -import github.buriedincode.bookshelf.tables.CreditTable -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID - -class Creator(id: EntityID) : LongEntity(id), IJson, Comparable { - companion object : LongEntityClass(CreatorTable) { - val comparator = compareBy(Creator::name) - - fun find(name: String): Creator? { - return Creator.find { CreatorTable.nameCol eq name }.firstOrNull() - } - - fun findOrCreate(name: String): Creator { - return find(name) ?: Creator.new { - this.name = name - } - } - } - - val credits by Credit referrersOn CreditTable.creatorCol - var imageUrl: String? by CreatorTable.imageUrlCol - var name: String by CreatorTable.nameCol - - override fun toJson(showAll: Boolean): Map { - return mutableMapOf( - "id" to id.value, - "imageUrl" to imageUrl, - "name" to name, - ).apply { - if (showAll) { - put( - "credits", - credits.groupBy({ it.role }, { it.book }).toSortedMap().map { (role, books) -> - mapOf( - "role" to role.toJson(), - "books" to books.map { it.toJson() }, - ) - }, - ) - } - }.toSortedMap() - } - - override fun compareTo(other: Creator): Int = comparator.compare(this, other) -} - -data class CreatorInput( - val credits: List = emptyList(), - val imageUrl: String? = null, - val name: String, -) { - data class Credit( - val book: Long, - val role: Long, - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Credit.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/Credit.kt deleted file mode 100644 index 1b5eff27..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Credit.kt +++ /dev/null @@ -1,29 +0,0 @@ -package github.buriedincode.bookshelf.models - -import github.buriedincode.bookshelf.tables.CreditTable -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.sql.and - -class Credit(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(CreditTable) { - fun find(book: Book, creator: Creator, role: Role): Credit? { - return Credit - .find { (CreditTable.bookCol eq book.id) and (CreditTable.creatorCol eq creator.id) and (CreditTable.roleCol eq role.id) } - .firstOrNull() - } - - fun findOrCreate(book: Book, creator: Creator, role: Role): Credit { - return find(book, creator, role) ?: Credit.new { - this.book = book - this.creator = creator - this.role = role - } - } - } - - var book: Book by Book referencedOn CreditTable.bookCol - var creator: Creator by Creator referencedOn CreditTable.creatorCol - var role: Role by Role referencedOn CreditTable.roleCol -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Format.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/Format.kt deleted file mode 100644 index 0613d68a..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Format.kt +++ /dev/null @@ -1,11 +0,0 @@ -package github.buriedincode.bookshelf.models - -enum class Format { - BOX_SET, - GRAPHIC_NOVEL, - HARDBACK, // TODO: Remove - HARDCOVER, - MANGA, - PAPERBACK, - TRADEPAPERBACK, -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/IJson.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/IJson.kt deleted file mode 100644 index 563b9ce7..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/IJson.kt +++ /dev/null @@ -1,5 +0,0 @@ -package github.buriedincode.bookshelf.models - -interface IJson { - fun toJson(showAll: Boolean = false): Map -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/IdInput.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/IdInput.kt deleted file mode 100644 index c7239816..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/IdInput.kt +++ /dev/null @@ -1,5 +0,0 @@ -package github.buriedincode.bookshelf.models - -data class IdInput( - val id: Long, -) diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Publisher.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/Publisher.kt deleted file mode 100644 index 975afda5..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Publisher.kt +++ /dev/null @@ -1,44 +0,0 @@ -package github.buriedincode.bookshelf.models - -import github.buriedincode.bookshelf.tables.BookTable -import github.buriedincode.bookshelf.tables.PublisherTable -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID - -class Publisher(id: EntityID) : LongEntity(id), IJson, Comparable { - companion object : LongEntityClass(PublisherTable) { - val comparator = compareBy(Publisher::title) - - fun find(title: String): Publisher? { - return Publisher.find { PublisherTable.titleCol eq title }.firstOrNull() - } - - fun findOrCreate(title: String): Publisher { - return find(title) ?: Publisher.new { - this.title = title - } - } - } - - var title: String by PublisherTable.titleCol - - val books by Book optionalReferrersOn BookTable.publisherCol - - override fun toJson(showAll: Boolean): Map { - return mutableMapOf( - "id" to id.value, - "title" to title, - ).apply { - if (showAll) { - put("books", books.sorted().map { it.toJson() }) - } - }.toSortedMap() - } - - override fun compareTo(other: Publisher): Int = comparator.compare(this, other) -} - -data class PublisherInput( - val title: String, -) diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/ReadBook.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/ReadBook.kt deleted file mode 100644 index de5e0697..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/ReadBook.kt +++ /dev/null @@ -1,27 +0,0 @@ -package github.buriedincode.bookshelf.models - -import github.buriedincode.bookshelf.tables.ReadBookTable -import kotlinx.datetime.LocalDate -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.sql.and - -class ReadBook(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(ReadBookTable) { - fun find(book: Book, user: User): ReadBook? { - return ReadBook.find { (ReadBookTable.bookCol eq book.id) and (ReadBookTable.userCol eq user.id) }.firstOrNull() - } - - fun findOrCreate(book: Book, user: User): ReadBook { - return find(book, user) ?: ReadBook.new { - this.book = book - this.user = user - } - } - } - - var book: Book by Book referencedOn ReadBookTable.bookCol - var user: User by User referencedOn ReadBookTable.userCol - var readDate: LocalDate? by ReadBookTable.readDateCol -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Role.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/Role.kt deleted file mode 100644 index 51e989d2..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Role.kt +++ /dev/null @@ -1,57 +0,0 @@ -package github.buriedincode.bookshelf.models - -import github.buriedincode.bookshelf.tables.CreditTable -import github.buriedincode.bookshelf.tables.RoleTable -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID - -class Role(id: EntityID) : LongEntity(id), IJson, Comparable { - companion object : LongEntityClass(RoleTable) { - val comparator = compareBy(Role::title) - - fun find(title: String): Role? { - return Role.find { RoleTable.titleCol eq title }.firstOrNull() - } - - fun findOrCreate(title: String): Role { - return find(title) ?: Role.new { - this.title = title - } - } - } - - val credits by Credit referrersOn CreditTable.roleCol - var title: String by RoleTable.titleCol - - override fun toJson(showAll: Boolean): Map { - return mutableMapOf( - "id" to id.value, - "title" to title, - ).apply { - if (showAll) { - put( - "credits", - credits.groupBy({ it.creator }, { it.book }).toSortedMap().map { (creator, books) -> - mapOf( - "creator" to creator.toJson(), - "books" to books.map { it.toJson() }, - ) - }, - ) - } - }.toSortedMap() - } - - override fun compareTo(other: Role): Int = comparator.compare(this, other) -} - -data class RoleInput( - val credits: List = emptyList(), - val title: String, -) { - data class Credit( - val book: Long, - val creator: Long, - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Series.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/Series.kt deleted file mode 100644 index 68f72e8d..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/Series.kt +++ /dev/null @@ -1,49 +0,0 @@ -package github.buriedincode.bookshelf.models - -import github.buriedincode.bookshelf.tables.BookSeriesTable -import github.buriedincode.bookshelf.tables.SeriesTable -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID - -class Series(id: EntityID) : LongEntity(id), IJson, Comparable { - companion object : LongEntityClass(SeriesTable) { - val comparator = compareBy(Series::title) - - fun find(title: String): Series? { - return Series.find { SeriesTable.titleCol eq title }.firstOrNull() - } - - fun findOrCreate(title: String): Series { - return find(title) ?: Series.new { - this.title = title - } - } - } - - val books by BookSeries referrersOn BookSeriesTable.seriesCol - var title: String by SeriesTable.titleCol - - override fun toJson(showAll: Boolean): Map { - return mutableMapOf( - "id" to id.value, - "title" to title, - ).apply { - if (showAll) { - put("books", books.sortedBy { it.book }.map { mapOf("book" to it.book.toJson(), "number" to it.number) }) - } - }.toSortedMap() - } - - override fun compareTo(other: Series): Int = comparator.compare(this, other) -} - -data class SeriesInput( - val books: List = emptyList(), - val title: String, -) { - data class Book( - val book: Long, - val number: Int? = null, - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/User.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/models/User.kt deleted file mode 100644 index 9787e8d5..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/User.kt +++ /dev/null @@ -1,60 +0,0 @@ -package github.buriedincode.bookshelf.models - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import github.buriedincode.bookshelf.Utils.toString -import github.buriedincode.bookshelf.tables.ReadBookTable -import github.buriedincode.bookshelf.tables.UserTable -import github.buriedincode.bookshelf.tables.WishedTable -import kotlinx.datetime.LocalDate -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID - -class User(id: EntityID) : LongEntity(id), IJson, Comparable { - companion object : LongEntityClass(UserTable) { - val comparator = compareBy(User::username) - - fun findOrNull(username: String): User? { - return find { UserTable.usernameCol eq username }.firstOrNull() - } - - fun findOrCreate(username: String): User { - return findOrNull(username) ?: new { - this.username = username - } - } - } - - var imageUrl: String? by UserTable.imageUrlCol - val readBooks by ReadBook referrersOn ReadBookTable.userCol - var username: String by UserTable.usernameCol - var wishedBooks by Book via WishedTable - - override fun toJson(showAll: Boolean): Map { - return mutableMapOf( - "id" to id.value, - "imageUrl" to imageUrl, - "username" to username, - ).apply { - if (showAll) { - put("read", readBooks.groupBy({ it.readDate?.toString("yyyy-MM-dd") ?: "null" }, { it.book.toJson() })) - put("wished", wishedBooks.sorted().map { it.toJson() }) - } - }.toSortedMap() - } - - override fun compareTo(other: User): Int = comparator.compare(this, other) -} - -data class UserInput( - val readBooks: List = emptyList(), - val imageUrl: String? = null, - val username: String, - val wishedBooks: List = emptyList(), -) { - data class ReadBook( - val book: Long, - @JsonDeserialize(using = LocalDateDeserializer::class) - val readDate: LocalDate? = null, - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/BaseApiRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/BaseApiRouter.kt deleted file mode 100644 index a560a45a..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/BaseApiRouter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package github.buriedincode.bookshelf.routers.api - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.IJson -import io.javalin.http.BadRequestResponse -import io.javalin.http.Context -import io.javalin.http.HttpStatus -import io.javalin.http.NotFoundResponse -import io.javalin.http.bodyAsClass -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass - -abstract class BaseApiRouter(protected val entity: LongEntityClass) - where T : LongEntity, T : IJson { - protected val name: String = entity::class.java.declaringClass.simpleName - .lowercase() - protected val paramName: String = "$name-id" - protected val title: String = name.replaceFirstChar(Char::uppercaseChar) - - protected fun Context.getResource(): T = this.pathParam(paramName).toLongOrNull()?.let { - entity.findById(it) ?: throw NotFoundResponse("$title not found") - } ?: throw BadRequestResponse("Invalid $title Id") - - protected inline fun Context.processInput(crossinline block: (I) -> Unit) = block(bodyAsClass(I::class.java)) - - protected inline fun manage(ctx: Context, crossinline block: (I, T) -> Unit) = ctx.processInput { body -> - Utils.query { - val resource = ctx.getResource() - block(body, resource) - ctx.json(resource.toJson(showAll = true)) - } - } - - abstract fun list(ctx: Context) - - abstract fun create(ctx: Context) - - open fun read(ctx: Context) = Utils.query { - ctx.json(ctx.getResource().toJson(showAll = true)) - } - - abstract fun update(ctx: Context) - - open fun delete(ctx: Context) = Utils.query { - ctx.getResource().delete() - ctx.status(status = HttpStatus.NO_CONTENT) - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/BookApiRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/BookApiRouter.kt deleted file mode 100644 index 00904255..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/BookApiRouter.kt +++ /dev/null @@ -1,344 +0,0 @@ -package github.buriedincode.bookshelf.routers.api - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.Utils.asEnumOrNull -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.BookInput -import github.buriedincode.bookshelf.models.BookSeries -import github.buriedincode.bookshelf.models.Creator -import github.buriedincode.bookshelf.models.Credit -import github.buriedincode.bookshelf.models.Format -import github.buriedincode.bookshelf.models.IdInput -import github.buriedincode.bookshelf.models.ImportBook -import github.buriedincode.bookshelf.models.Publisher -import github.buriedincode.bookshelf.models.ReadBook -import github.buriedincode.bookshelf.models.Role -import github.buriedincode.bookshelf.models.Series -import github.buriedincode.bookshelf.models.User -import github.buriedincode.bookshelf.services.OpenLibrary -import github.buriedincode.bookshelf.services.getId -import github.buriedincode.bookshelf.tables.BookSeriesTable -import github.buriedincode.bookshelf.tables.BookTable -import github.buriedincode.bookshelf.tables.CreditTable -import github.buriedincode.openlibrary.schemas.Edition -import github.buriedincode.openlibrary.schemas.Work -import io.github.oshai.kotlinlogging.KotlinLogging -import io.javalin.http.BadRequestResponse -import io.javalin.http.ConflictResponse -import io.javalin.http.Context -import io.javalin.http.HttpStatus -import io.javalin.http.NotFoundResponse -import io.javalin.http.NotImplementedResponse -import org.jetbrains.exposed.sql.SizedCollection -import org.jetbrains.exposed.sql.andWhere -import org.jetbrains.exposed.sql.or -import org.jetbrains.exposed.sql.selectAll - -object BookApiRouter : BaseApiRouter(entity = Book) { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - - override fun list(ctx: Context): Unit = Utils.query { - val query = BookTable.selectAll() - ctx.queryParam("creator-id")?.toLongOrNull()?.let { - Creator.findById(it)?.let { creator -> query.andWhere { CreditTable.creatorCol eq creator.id } } - } - ctx.queryParam("format")?.asEnumOrNull()?.let { format -> - query.andWhere { BookTable.formatCol eq format } - } - ctx.queryParam("is-collected")?.lowercase()?.toBooleanStrictOrNull()?.let { isCollected -> - query.andWhere { BookTable.isCollectedCol eq isCollected } - } - ctx.queryParam("publisher-id")?.toLongOrNull()?.let { - Publisher.findById(it)?.let { publisher -> query.andWhere { BookTable.publisherCol eq publisher.id } } - } - ctx.queryParam("series-id")?.toLongOrNull()?.let { - Series.findById(it)?.let { series -> query.andWhere { BookSeriesTable.seriesCol eq series.id } } - } - ctx.queryParam("title")?.let { title -> - query.andWhere { (BookTable.titleCol like "%$title%") or (BookTable.subtitleCol like "%$title%") } - } - ctx.json(Book.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) - } - - override fun create(ctx: Context) = ctx.processInput { body -> - Utils.query { - Book.find(body.title, body.subtitle, body.identifiers?.isbn, body.identifiers?.openLibrary)?.let { - throw ConflictResponse("Book already exists") - } - val resource = Book.findOrCreate(body.title, body.subtitle, body.identifiers?.isbn, body.identifiers?.openLibrary).apply { - body.credits.forEach { - Credit.new { - this.book = this@apply - this.creator = Creator.findById(it.creator) ?: throw NotFoundResponse("Creator not found.") - this.role = Role.findById(it.role) ?: throw NotFoundResponse("Role not found.") - } - } - format = body.format - goodreads = body.identifiers?.goodreads - googleBooks = body.identifiers?.googleBooks - imageUrl = body.imageUrl - isCollected = body.isCollected - libraryThing = body.identifiers?.libraryThing - publishDate = body.publishDate - publisher = body.publisher?.let { - Publisher.findById(it) ?: throw NotFoundResponse("Publisher not found.") - } - body.readers.forEach { - ReadBook.new { - this.book = this@apply - this.user = User.findById(it.user) ?: throw NotFoundResponse("User not found.") - this.readDate = it.readDate - } - } - body.series.forEach { - BookSeries.new { - this.book = this@apply - this.series = Series.findById(it.series) ?: throw NotFoundResponse("Series not found.") - this.number = it.number - } - } - summary = body.summary - wishers = SizedCollection( - body.wishers.map { - User.findById(it) ?: throw NotFoundResponse("User not found.") - }, - ) - } - ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) - } - } - - override fun update(ctx: Context) = manage(ctx) { body, book -> - Book - .find(body.title, body.subtitle, body.identifiers?.isbn, body.identifiers?.openLibrary) - ?.takeIf { it != book } - ?.let { throw ConflictResponse("Book already exists") } - - book.apply { - credits.forEach { it.delete() } - body.credits.forEach { - Credit.findOrCreate( - this, - Creator.findById(it.creator) ?: throw NotFoundResponse("Creator not found."), - Role.findById(it.role) ?: throw NotFoundResponse("Role not found."), - ) - } - format = body.format - goodreads = body.identifiers?.goodreads - googleBooks = body.identifiers?.googleBooks - imageUrl = body.imageUrl - isbn = body.identifiers?.isbn - isCollected = body.isCollected - libraryThing = body.identifiers?.libraryThing - openLibrary = body.identifiers?.openLibrary - publishDate = body.publishDate - publisher = body.publisher?.let { - Publisher.findById(it) ?: throw NotFoundResponse("Publisher not found.") - } - readers.forEach { it.delete() } - body.readers.forEach { - ReadBook.findOrCreate(this, User.findById(it.user) ?: throw NotFoundResponse("User not found.")).apply { - readDate = it.readDate - } - } - series.forEach { it.delete() } - body.series.forEach { - BookSeries.findOrCreate(this, Series.findById(it.series) ?: throw NotFoundResponse("Series not found.")).apply { - number = it.number - } - } - subtitle = body.subtitle - summary = body.summary - title = body.title - wishers = SizedCollection( - body.wishers.map { - User.findById(it) ?: throw NotFoundResponse("User not found.") - }, - ) - } - } - - override fun delete(ctx: Context) = Utils.query { - ctx.getResource().apply { - credits.forEach { it.delete() } - readers.forEach { it.delete() } - series.forEach { it.delete() } - delete() - } - ctx.status(HttpStatus.NO_CONTENT) - } - - private fun manageStatus(ctx: Context, block: (Book) -> Unit) = Utils.query { - val resource = ctx.getResource() - block(resource) - ctx.json(resource.toJson(showAll = true)) - } - - fun collectBook(ctx: Context) = manageStatus(ctx) { resource -> - resource.isCollected = true - resource.wishers = SizedCollection() - } - - fun discardBook(ctx: Context) = manageStatus(ctx) { resource -> - resource.isCollected = false - resource.readers.forEach { it.delete() } - } - - fun addReader(ctx: Context) = manage(ctx) { body, book -> - val user = User.findById(body.user) ?: throw NotFoundResponse("User not found.") - if (!book.isCollected) { - throw BadRequestResponse("Book hasn't been collected") - } - ReadBook.findOrCreate(book, user).apply { - readDate = body.readDate - } - } - - fun removeReader(ctx: Context) = manage(ctx) { body, book -> - val user = User.findById(body.id) ?: throw NotFoundResponse("User not found.") - if (!book.isCollected) { - throw BadRequestResponse("Book hasn't been collected") - } - ReadBook.find(book, user)?.delete() - } - - fun addWisher(ctx: Context) = manage(ctx) { body, book -> - val user = User.findById(body.id) ?: throw NotFoundResponse("User not found.") - if (book.isCollected) { - throw BadRequestResponse("Book has been collected") - } - book.wishers = SizedCollection(book.wishers + user) - } - - fun removeWisher(ctx: Context) = manage(ctx) { body, book -> - val user = User.findById(body.id) ?: throw NotFoundResponse("User not found.") - if (book.isCollected) { - throw BadRequestResponse("Book has been collected") - } - book.wishers = SizedCollection(book.wishers - user) - } - - fun addCredit(ctx: Context) = manage(ctx) { body, book -> - Credit.findOrCreate( - book, - Creator.findById(body.creator) ?: throw NotFoundResponse("Creator not found."), - Role.findById(body.role) ?: throw NotFoundResponse("Role not found."), - ) - } - - fun removeCredit(ctx: Context) = manage(ctx) { body, book -> - Credit - .find( - book, - Creator.findById(body.creator) ?: throw NotFoundResponse("Creator not found."), - Role.findById(body.role) ?: throw NotFoundResponse("Role not found."), - )?.delete() - } - - fun addSeries(ctx: Context) = manage(ctx) { body, book -> - BookSeries - .findOrCreate( - book, - Series.findById(body.series) ?: throw NotFoundResponse("Series not found."), - ).apply { - number = if (body.number == 0) null else body.number - } - } - - fun removeSeries(ctx: Context) = manage(ctx) { body, book -> - BookSeries - .find( - book, - Series.findById(body.id) ?: throw NotFoundResponse("Series not found."), - )?.delete() - } - - private fun Book.applyOpenLibrary(edition: Edition, work: Work): Book = this.apply { - var format = edition.physicalFormat?.asEnumOrNull() - if (edition.physicalFormat.equals("Hardback", ignoreCase = true)) { - format = Format.HARDCOVER - } else if (edition.physicalFormat.equals("Mass Market Paperback", ignoreCase = true)) { - format = Format.PAPERBACK - } else if (format == null) { - LOGGER.warn { "Unmapped Format: ${edition.physicalFormat}" } - } - - this.format = format ?: Format.PAPERBACK - goodreads = edition.identifiers?.goodreads?.firstOrNull() - googleBooks = edition.identifiers?.google?.firstOrNull() - imageUrl = "https://covers.openlibrary.org/b/OLID/${edition.getId()}-L.jpg" - isbn = isbn ?: edition.isbn13.firstOrNull() ?: edition.isbn10.firstOrNull() - libraryThing = edition.identifiers?.librarything?.firstOrNull() - openLibrary = edition.getId() - publishDate = edition.publishDate - publisher = edition.publishers.firstOrNull()?.let { Publisher.findOrCreate(it) } - summary = edition.description ?: work.description - - credits.forEach { it.delete() } - work.authors - .map { OpenLibrary.getAuthor(it.author.getId()) } - .map { - Creator.findOrCreate(it.name).apply { - it.photos.firstOrNull()?.let { - imageUrl = "https://covers.openlibrary.org/a/id/$it-L.jpg" - } - } - }.forEach { - Credit.findOrCreate(this, it, Role.findOrCreate("Author")) - } - edition.contributors.forEach { - Credit.findOrCreate(this, Creator.findOrCreate(it.name), Role.findOrCreate(it.role)) - } - } - - fun search(ctx: Context) = ctx.processInput { body -> - val results = OpenLibrary.search(title = body.title) - ctx.json(results) - } - - fun import(ctx: Context) = ctx.processInput { body -> - Utils.query { - val edition = body.isbn?.let { - OpenLibrary.getEditionByISBN(it) - } ?: body.openLibraryId?.let { - OpenLibrary.getEdition(it) - } ?: throw NotImplementedResponse("Import only supports OpenLibrary currently") - val work = OpenLibrary.getWork(edition.works.first().getId()) - - Book.find(edition.title, edition.subtitle, edition.isbn13.firstOrNull() ?: edition.isbn10.firstOrNull(), edition.getId())?.let { - throw ConflictResponse("Book already exists") - } - val resource = Book - .findOrCreate( - edition.title, - edition.subtitle, - edition.isbn13.firstOrNull() ?: edition.isbn10.firstOrNull(), - edition.getId(), - ).applyOpenLibrary(edition, work) - .apply { - isCollected = body.isCollected - } - - ctx.json(resource.toJson(showAll = true)) - } - } - - fun reimport(ctx: Context) = Utils.query { - val resource = ctx.getResource() - val edition = resource.isbn?.let { - OpenLibrary.getEditionByISBN(it) - } ?: resource.openLibrary?.let { - OpenLibrary.getEdition(it) - } ?: throw NotImplementedResponse("Import only supports OpenLibrary currently") - val work = OpenLibrary.getWork(edition.works.first().getId()) - - Book - .find(edition.title, edition.subtitle, edition.isbn13.firstOrNull() ?: edition.isbn10.firstOrNull(), edition.getId()) - ?.takeIf { it != resource } - ?.let { throw ConflictResponse("Book already exists") } - resource.applyOpenLibrary(edition, work) - - ctx.json(resource.toJson(showAll = true)) - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/CreatorApiRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/CreatorApiRouter.kt deleted file mode 100644 index 72c4eee7..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/CreatorApiRouter.kt +++ /dev/null @@ -1,92 +0,0 @@ -package github.buriedincode.bookshelf.routers.api - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.Creator -import github.buriedincode.bookshelf.models.CreatorInput -import github.buriedincode.bookshelf.models.Credit -import github.buriedincode.bookshelf.models.Role -import github.buriedincode.bookshelf.tables.CreatorTable -import github.buriedincode.bookshelf.tables.CreditTable -import io.javalin.http.ConflictResponse -import io.javalin.http.Context -import io.javalin.http.HttpStatus -import io.javalin.http.NotFoundResponse -import org.jetbrains.exposed.sql.andWhere -import org.jetbrains.exposed.sql.selectAll - -object CreatorApiRouter : BaseApiRouter(entity = Creator) { - override fun list(ctx: Context): Unit = Utils.query { - val query = CreatorTable.selectAll() - ctx.queryParam("book-id")?.toLongOrNull()?.let { - Book.findById(it)?.let { book -> query.andWhere { CreditTable.bookCol eq book.id } } - } - ctx.queryParam("name")?.let { name -> - query.andWhere { CreatorTable.nameCol like "%$name%" } - } - ctx.queryParam("role-id")?.toLongOrNull()?.let { - Role.findById(it)?.let { role -> query.andWhere { CreditTable.roleCol eq role.id } } - } - ctx.json(Creator.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) - } - - override fun create(ctx: Context) = ctx.processInput { body -> - Utils.query { - Creator.find(body.name)?.let { - throw ConflictResponse("Creator already exists") - } - val resource = Creator.findOrCreate(body.name).apply { - body.credits.forEach { - Credit.new { - this.book = Book.findById(it.book) ?: throw NotFoundResponse("Book not found.") - this.creator = this@apply - this.role = Role.findById(it.role) ?: throw NotFoundResponse("Role not found.") - } - } - imageUrl = body.imageUrl - } - ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) - } - } - - override fun update(ctx: Context) = manage(ctx) { body, creator -> - Creator.find(body.name)?.takeIf { it != creator }?.let { throw ConflictResponse("Creator already exists") } - creator.apply { - credits.forEach { it.delete() } - body.credits.forEach { - Credit.findOrCreate( - Book.findById(it.book) ?: throw NotFoundResponse("Book not found."), - this, - Role.findById(it.role) ?: throw NotFoundResponse("Role not found."), - ) - } - imageUrl = body.imageUrl - name = body.name - } - } - - override fun delete(ctx: Context) = Utils.query { - ctx.getResource().apply { - credits.forEach { it.delete() } - delete() - } - ctx.status(HttpStatus.NO_CONTENT) - } - - fun addCredit(ctx: Context) = manage(ctx) { body, creator -> - Credit.findOrCreate( - Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), - creator, - Role.findById(body.role) ?: throw NotFoundResponse("Role not found."), - ) - } - - fun removeCredit(ctx: Context) = manage(ctx) { body, creator -> - Credit - .find( - Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), - creator, - Role.findById(body.role) ?: throw NotFoundResponse("Role not found."), - )?.delete() - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/PublisherApiRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/PublisherApiRouter.kt deleted file mode 100644 index a7f84043..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/PublisherApiRouter.kt +++ /dev/null @@ -1,55 +0,0 @@ -package github.buriedincode.bookshelf.routers.api - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.IdInput -import github.buriedincode.bookshelf.models.Publisher -import github.buriedincode.bookshelf.models.PublisherInput -import github.buriedincode.bookshelf.tables.BookTable -import github.buriedincode.bookshelf.tables.PublisherTable -import io.javalin.http.ConflictResponse -import io.javalin.http.Context -import io.javalin.http.HttpStatus -import io.javalin.http.NotFoundResponse -import org.jetbrains.exposed.sql.andWhere -import org.jetbrains.exposed.sql.selectAll - -object PublisherApiRouter : BaseApiRouter(entity = Publisher) { - override fun list(ctx: Context): Unit = Utils.query { - val query = PublisherTable.selectAll() - ctx.queryParam("book-id")?.toLongOrNull()?.let { - Book.findById(it)?.let { book -> query.andWhere { BookTable.id eq book.id } } - } - ctx.queryParam("title")?.let { title -> - query.andWhere { PublisherTable.titleCol like "%$title%" } - } - ctx.json(Publisher.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) - } - - override fun create(ctx: Context) = ctx.processInput { body -> - Utils.query { - Publisher.find(body.title)?.let { - throw ConflictResponse("Publisher already exists") - } - val resource = Publisher.findOrCreate(body.title) - ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) - } - } - - override fun update(ctx: Context) = manage(ctx) { body, publisher -> - Publisher.find(body.title)?.takeIf { it != publisher }?.let { throw ConflictResponse("Publisher already exists") } - publisher.apply { - title = body.title - } - } - - fun addBook(ctx: Context) = manage(ctx) { body, publisher -> - val book = Book.findById(body.id) ?: throw NotFoundResponse("Book not found.") - book.publisher = publisher - } - - fun removeBook(ctx: Context) = manage(ctx) { body, publisher -> - val book = Book.findById(body.id) ?: throw NotFoundResponse("Book not found.") - book.publisher = null - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/RoleApiRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/RoleApiRouter.kt deleted file mode 100644 index 4c4cfc89..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/RoleApiRouter.kt +++ /dev/null @@ -1,90 +0,0 @@ -package github.buriedincode.bookshelf.routers.api - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.Creator -import github.buriedincode.bookshelf.models.Credit -import github.buriedincode.bookshelf.models.Role -import github.buriedincode.bookshelf.models.RoleInput -import github.buriedincode.bookshelf.tables.CreditTable -import github.buriedincode.bookshelf.tables.RoleTable -import io.javalin.http.ConflictResponse -import io.javalin.http.Context -import io.javalin.http.HttpStatus -import io.javalin.http.NotFoundResponse -import org.jetbrains.exposed.sql.andWhere -import org.jetbrains.exposed.sql.selectAll - -object RoleApiRouter : BaseApiRouter(entity = Role) { - override fun list(ctx: Context): Unit = Utils.query { - val query = RoleTable.selectAll() - ctx.queryParam("book-id")?.toLongOrNull()?.let { - Book.findById(it)?.let { book -> query.andWhere { CreditTable.bookCol eq book.id } } - } - ctx.queryParam("creator-id")?.toLongOrNull()?.let { - Creator.findById(it)?.let { creator -> query.andWhere { CreditTable.creatorCol eq creator.id } } - } - ctx.queryParam("title")?.let { title -> - query.andWhere { RoleTable.titleCol like "%$title%" } - } - ctx.json(Role.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) - } - - override fun create(ctx: Context) = ctx.processInput { body -> - Utils.query { - Role.find(body.title)?.let { - throw ConflictResponse("Role already exists") - } - val resource = Role.findOrCreate(body.title).apply { - body.credits.forEach { - Credit.new { - this.book = Book.findById(it.book) ?: throw NotFoundResponse("Book not found.") - this.creator = Creator.findById(it.creator) ?: throw NotFoundResponse("Creator not found.") - this.role = this@apply - } - } - } - ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) - } - } - - override fun update(ctx: Context) = manage(ctx) { body, role -> - Role.find(body.title)?.takeIf { it != role }?.let { throw ConflictResponse("Role already exists") } - role.apply { - credits.forEach { it.delete() } - body.credits.forEach { - Credit.findOrCreate( - Book.findById(it.book) ?: throw NotFoundResponse("Book not found."), - Creator.findById(it.creator) ?: throw NotFoundResponse("Creator not found."), - this, - ) - } - title = body.title - } - } - - override fun delete(ctx: Context) = Utils.query { - ctx.getResource().apply { - credits.forEach { it.delete() } - delete() - } - ctx.status(HttpStatus.NO_CONTENT) - } - - fun addCredit(ctx: Context) = manage(ctx) { body, role -> - Credit.findOrCreate( - Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), - Creator.findById(body.creator) ?: throw NotFoundResponse("Creator not found."), - role, - ) - } - - fun removeCredit(ctx: Context) = manage(ctx) { body, role -> - Credit - .findOrCreate( - Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), - Creator.findById(body.creator) ?: throw NotFoundResponse("Creator not found."), - role, - )?.delete() - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/SeriesApiRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/SeriesApiRouter.kt deleted file mode 100644 index 168c5e42..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/SeriesApiRouter.kt +++ /dev/null @@ -1,90 +0,0 @@ -package github.buriedincode.bookshelf.routers.api - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.BookSeries -import github.buriedincode.bookshelf.models.IdInput -import github.buriedincode.bookshelf.models.Series -import github.buriedincode.bookshelf.models.SeriesInput -import github.buriedincode.bookshelf.tables.BookSeriesTable -import github.buriedincode.bookshelf.tables.SeriesTable -import io.javalin.http.ConflictResponse -import io.javalin.http.Context -import io.javalin.http.HttpStatus -import io.javalin.http.NotFoundResponse -import org.jetbrains.exposed.sql.andWhere -import org.jetbrains.exposed.sql.selectAll - -object SeriesApiRouter : BaseApiRouter(entity = Series) { - override fun list(ctx: Context): Unit = Utils.query { - val query = SeriesTable.selectAll() - ctx.queryParam("book-id")?.toLongOrNull()?.let { - Book.findById(it)?.let { book -> query.andWhere { BookSeriesTable.bookCol eq book.id } } - } - ctx.queryParam("title")?.let { title -> - query.andWhere { SeriesTable.titleCol like "%$title%" } - } - ctx.json(Series.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) - } - - override fun create(ctx: Context) = ctx.processInput { body -> - Utils.query { - Series.find(body.title)?.let { - throw ConflictResponse("Series already exists") - } - val resource = Series.findOrCreate(body.title).apply { - body.books.forEach { - BookSeries.new { - this.book = Book.findById(it.book) ?: throw NotFoundResponse("Book not found.") - this.series = this@apply - this.number = if (it.number == 0) null else it.number - } - } - } - ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) - } - } - - override fun update(ctx: Context) = manage(ctx) { body, series -> - Series.find(body.title)?.takeIf { it != series }?.let { throw ConflictResponse("Series already exists") } - series.apply { - books.forEach { it.delete() } - body.books.forEach { - BookSeries - .findOrCreate( - Book.findById(it.book) ?: throw NotFoundResponse("Book not found."), - series, - ).apply { - number = if (it.number == 0) null else it.number - } - } - title = body.title - } - } - - override fun delete(ctx: Context) = Utils.query { - ctx.getResource().apply { - books.forEach { it.delete() } - delete() - } - ctx.status(HttpStatus.NO_CONTENT) - } - - fun addBook(ctx: Context) = manage(ctx) { body, series -> - BookSeries - .findOrCreate( - Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), - series, - ).apply { - number = if (body.number == 0) null else body.number - } - } - - fun removeBook(ctx: Context) = manage(ctx) { body, series -> - BookSeries - .findOrCreate( - Book.findById(body.id) ?: throw NotFoundResponse("Book not found."), - series, - )?.delete() - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/UserApiRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/UserApiRouter.kt deleted file mode 100644 index f153028c..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/api/UserApiRouter.kt +++ /dev/null @@ -1,117 +0,0 @@ -package github.buriedincode.bookshelf.routers.api - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.IdInput -import github.buriedincode.bookshelf.models.ReadBook -import github.buriedincode.bookshelf.models.User -import github.buriedincode.bookshelf.models.UserInput -import github.buriedincode.bookshelf.tables.UserTable -import io.javalin.http.BadRequestResponse -import io.javalin.http.ConflictResponse -import io.javalin.http.Context -import io.javalin.http.HttpStatus -import io.javalin.http.NotFoundResponse -import org.jetbrains.exposed.sql.SizedCollection -import org.jetbrains.exposed.sql.andWhere -import org.jetbrains.exposed.sql.selectAll - -object UserApiRouter : BaseApiRouter(entity = User) { - override fun list(ctx: Context): Unit = Utils.query { - val query = UserTable.selectAll() - ctx.queryParam("username")?.let { username -> - query.andWhere { UserTable.usernameCol like "%$username%" } - } - ctx.json(User.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) - } - - override fun create(ctx: Context) = ctx.processInput { body -> - Utils.query { - User.findOrNull(body.username)?.let { - throw ConflictResponse("User already exists") - } - val resource = User.new User@{ - body.readBooks.forEach { - ReadBook.new { - this.book = Book.findById(it.book) ?: throw NotFoundResponse("Book not found.") - this.user = this@User - this.readDate = it.readDate - } - } - this.imageUrl = body.imageUrl - this.username = body.username - this.wishedBooks = SizedCollection( - body.wishedBooks.map { - Book.findById(it) ?: throw NotFoundResponse("Book not found.") - }, - ) - } - ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) - } - } - - override fun update(ctx: Context) = manage(ctx) { body, user -> - User.findOrNull(body.username)?.takeIf { it != user }?.let { throw ConflictResponse("User already exists") } - user.apply { - imageUrl = body.imageUrl - readBooks.forEach { it.delete() } - body.readBooks.forEach { - ReadBook - .findOrCreate( - Book.findById(it.book) ?: throw NotFoundResponse("Book not found."), - user, - ).apply { - readDate = it.readDate - } - } - username = body.username - wishedBooks = SizedCollection( - body.wishedBooks.map { - Book.findById(it) ?: throw NotFoundResponse("Book not found.") - }, - ) - } - } - - override fun delete(ctx: Context) = Utils.query { - ctx.getResource().apply { - readBooks.forEach { it.delete() } - delete() - } - ctx.status(HttpStatus.NO_CONTENT) - } - - fun addReadBook(ctx: Context) = manage(ctx) { body, user -> - val book = Book.findById(body.book) ?: throw NotFoundResponse("No Book found.") - if (!book.isCollected) { - throw BadRequestResponse("Book hasn't been collected") - } - ReadBook.findOrCreate(book, user).apply { - readDate = body.readDate - } - } - - fun removeReadBook(ctx: Context) = manage(ctx) { body, user -> - val book = Book.findById(body.id) ?: throw NotFoundResponse("No Book found.") - if (!book.isCollected) { - throw BadRequestResponse("Book hasn't been collected") - } - ReadBook.find(book, user)?.delete() - } - - fun addWishedBook(ctx: Context) = manage(ctx) { body, user -> - val book = Book.findById(body.id) ?: throw NotFoundResponse("No Book found.") - if (book.isCollected) { - throw BadRequestResponse("Book has been collected") - } - user.wishedBooks = SizedCollection(user.wishedBooks + book) - } - - fun removeWishedBook(ctx: Context) = manage(ctx) { body, user -> - val book = Book.findById(body.id) ?: throw NotFoundResponse("No Book found.") - if (book.isCollected) { - throw BadRequestResponse("Book has been collected") - } - user.wishedBooks = SizedCollection(user.wishedBooks - book) - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/BaseHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/BaseHtmlRouter.kt deleted file mode 100644 index 351e1230..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/BaseHtmlRouter.kt +++ /dev/null @@ -1,60 +0,0 @@ -package github.buriedincode.bookshelf.routers.html - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.User -import io.javalin.http.BadRequestResponse -import io.javalin.http.Context -import io.javalin.http.NotFoundResponse -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.LongEntityClass - -abstract class BaseHtmlRouter( - protected val entity: LongEntityClass, - protected val plural: String, -) { - protected val name: String = entity::class.java.declaringClass.simpleName.lowercase() - protected val paramName: String = "$name-id" - protected val title: String = name.replaceFirstChar(Char::uppercaseChar) - - protected fun Context.getResource(): T = this.pathParam(paramName).toLongOrNull()?.let { id -> - entity.findById(id) ?: throw NotFoundResponse("$title not found") - } ?: throw BadRequestResponse("Invalid $title Id") - - protected fun Context.getSession(): User? = this.cookie("bookshelf_session-id")?.toLongOrNull()?.let { User.findById(it) } - - protected fun render(ctx: Context, template: String, model: Map = emptyMap(), redirect: Boolean = true) { - val session = ctx.getSession() - if (session == null && redirect) { - ctx.redirect("/$plural") - } - ctx.render("templates/$name/$template.kte", mapOf("session" to session) + model) - } - - protected fun renderResource(ctx: Context, template: String, model: Map = emptyMap(), redirect: Boolean = true) { - render(ctx, template, mapOf("resource" to ctx.getResource()) + model, redirect) - } - - protected open fun filterResources(ctx: Context): List = emptyList() - - protected open fun filters(ctx: Context): Map = emptyMap() - - protected open fun createOptions(): Map = emptyMap() - - protected open fun updateOptions(ctx: Context): Map = emptyMap() - - open fun list(ctx: Context) = Utils.query { - render(ctx, "list", mapOf("resources" to filterResources(ctx), "filters" to filters(ctx)), redirect = false) - } - - open fun create(ctx: Context) = Utils.query { - render(ctx, "create", createOptions()) - } - - open fun view(ctx: Context) = Utils.query { - renderResource(ctx, "view", redirect = false) - } - - open fun update(ctx: Context) = Utils.query { - renderResource(ctx, "update", createOptions() + updateOptions(ctx)) - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/BookHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/BookHtmlRouter.kt deleted file mode 100644 index e50c4cd7..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/BookHtmlRouter.kt +++ /dev/null @@ -1,93 +0,0 @@ -package github.buriedincode.bookshelf.routers.html - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.Utils.asEnumOrNull -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.Creator -import github.buriedincode.bookshelf.models.Format -import github.buriedincode.bookshelf.models.Publisher -import github.buriedincode.bookshelf.models.Role -import github.buriedincode.bookshelf.models.Series -import io.javalin.http.Context - -object BookHtmlRouter : BaseHtmlRouter(entity = Book, plural = "books") { - override fun list(ctx: Context) = Utils.query { - val extras = if (ctx.getSession() == null) mapOf("read" to true, "wished" to true) else emptyMap() - render(ctx, "list", mapOf("resources" to filterResources(ctx), "filters" to filters(ctx), "extras" to extras), redirect = false) - } - - override fun view(ctx: Context) = Utils.query { - renderResource(ctx, "view", mapOf("credits" to ctx.getResource().credits.groupBy({ it.role }, { it.creator })), redirect = false) - } - - fun import(ctx: Context) = Utils.query { render(ctx, "import") } - - fun search(ctx: Context) = Utils.query { render(ctx, "search") } - - override fun filterResources(ctx: Context): List { - val isCollected: Boolean? = ctx.queryParam("is-collected")?.lowercase()?.toBooleanStrictOrNull() - var resources = Book.all().toList() - ctx.queryParam("creator-id")?.toLongOrNull()?.let { - Creator.findById(it)?.let { creator -> resources = resources.filter { it.credits.any { it.creator == creator } } } - } - ctx.queryParam("format")?.asEnumOrNull()?.let { format -> - resources = resources.filter { format == it.format } - } - ctx.getSession()?.let { session -> - if (isCollected == true || isCollected == null) { - ctx.queryParam("has-read")?.lowercase()?.toBooleanStrictOrNull()?.let { hasRead -> - resources = resources.filter { session.readBooks.any { it.book == it } == hasRead } - } - } - if (isCollected == false || isCollected == null) { - ctx.queryParam("has-wished")?.lowercase()?.toBooleanStrictOrNull()?.let { hasWished -> - resources = resources.filter { (it in session.wishedBooks) == hasWished } - } - } - } - isCollected?.let { - resources = resources.filter { it.isCollected == isCollected!! } - } - ctx.queryParam("publisher-id")?.toLongOrNull()?.let { - Publisher.findById(it)?.let { publisher -> resources = resources.filter { publisher == it.publisher } } - } - ctx.queryParam("role-id")?.toLongOrNull()?.let { - Role.findById(it)?.let { role -> resources = resources.filter { it.credits.any { it.role == role } } } - } - ctx.queryParam("series-id")?.toLongOrNull()?.let { - Series.findById(it)?.let { series -> resources = resources.filter { it.series.any { it.series == series } } } - } - ctx.queryParam("title")?.let { title -> - resources = resources.filter { - it.title.contains(title, ignoreCase = true) || (it.subtitle?.contains(title, ignoreCase = true) == true) - } - } - return resources - } - - override fun filters(ctx: Context): Map = mapOf( - "creator" to ctx.queryParam("creator-id")?.toLongOrNull()?.let { Creator.findById(it) }, - "format" to ctx.queryParam("format")?.asEnumOrNull(), - "has-read" to ctx.queryParam("has-read")?.lowercase()?.toBooleanStrictOrNull(), - "has-wished" to ctx.queryParam("has-wished")?.lowercase()?.toBooleanStrictOrNull(), - "is-collected" to ctx.queryParam("is-collected")?.lowercase()?.toBooleanStrictOrNull(), - "publisher" to ctx.queryParam("publisher-id")?.toLongOrNull()?.let { Publisher.findById(it) }, - "role" to ctx.queryParam("role-id")?.toLongOrNull()?.let { Role.findById(it) }, - "series" to ctx.queryParam("series-id")?.toLongOrNull()?.let { Series.findById(it) }, - "title" to ctx.queryParam("title"), - ) - - override fun createOptions(): Map = mapOf( - "formats" to Format.entries.toList(), - "has-read" to listOf(true, false), - "has-wished" to listOf(true, false), - "is-collected" to listOf(true, false), - "publishers" to Publisher.all().toList(), - ) - - override fun updateOptions(ctx: Context): Map = mapOf( - "creators" to Creator.all().toList(), - "roles" to Role.all().toList(), - "series" to Series.all().filterNot { series -> ctx.getResource().series.any { it.series == series } }.toList(), - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/CreatorHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/CreatorHtmlRouter.kt deleted file mode 100644 index 7926fbf6..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/CreatorHtmlRouter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package github.buriedincode.bookshelf.routers.html - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.Creator -import github.buriedincode.bookshelf.models.Role -import io.github.oshai.kotlinlogging.KotlinLogging -import io.javalin.http.Context -import io.javalin.http.NotImplementedResponse - -object CreatorHtmlRouter : BaseHtmlRouter(entity = Creator, plural = "creators") { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - - override fun list(ctx: Context) = throw NotImplementedResponse() - - override fun create(ctx: Context) = throw NotImplementedResponse() - - override fun view(ctx: Context) = Utils.query { - renderResource(ctx, "view", mapOf("credits" to ctx.getResource().credits.groupBy({ it.role }, { it.book })), redirect = false) - } - - override fun updateOptions(ctx: Context): Map = mapOf( - "books" to Book.all().toList(), - "roles" to Role.all().toList(), - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/PublisherHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/PublisherHtmlRouter.kt deleted file mode 100644 index 0e6f4bf7..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/PublisherHtmlRouter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package github.buriedincode.bookshelf.routers.html - -import github.buriedincode.bookshelf.models.Publisher -import io.github.oshai.kotlinlogging.KotlinLogging -import io.javalin.http.Context -import io.javalin.http.NotImplementedResponse - -object PublisherHtmlRouter : BaseHtmlRouter(entity = Publisher, plural = "publishers") { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - - override fun list(ctx: Context) = throw NotImplementedResponse() - - override fun create(ctx: Context) = throw NotImplementedResponse() -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/RoleHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/RoleHtmlRouter.kt deleted file mode 100644 index 49a1fd38..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/RoleHtmlRouter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package github.buriedincode.bookshelf.routers.html - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.Creator -import github.buriedincode.bookshelf.models.Role -import io.github.oshai.kotlinlogging.KotlinLogging -import io.javalin.http.Context -import io.javalin.http.NotImplementedResponse - -object RoleHtmlRouter : BaseHtmlRouter(entity = Role, plural = "roles") { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - - override fun list(ctx: Context) = throw NotImplementedResponse() - - override fun create(ctx: Context) = throw NotImplementedResponse() - - override fun view(ctx: Context) = Utils.query { - renderResource(ctx, "view", mapOf("credits" to ctx.getResource().credits.groupBy({ it.creator }, { it.book })), redirect = false) - } - - override fun updateOptions(ctx: Context): Map = mapOf( - "books" to Book.all().toList(), - "creators" to Creator.all().toList(), - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/SeriesHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/SeriesHtmlRouter.kt deleted file mode 100644 index d723c62e..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/SeriesHtmlRouter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package github.buriedincode.bookshelf.routers.html - -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.Series -import io.github.oshai.kotlinlogging.KotlinLogging -import io.javalin.http.Context - -object SeriesHtmlRouter : BaseHtmlRouter(entity = Series, plural = "series") { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - - override fun filterResources(ctx: Context): List { - var resources = Series.all().toList() - ctx.queryParam("title")?.let { title -> - resources = resources.filter { it.title.contains(title, ignoreCase = true) } - } - return resources - } - - override fun filters(ctx: Context): Map = mapOf( - "title" to ctx.queryParam("title"), - ) - - override fun updateOptions(ctx: Context): Map = mapOf( - "books" to Book.all().filter { book -> ctx.getResource().books.none { it.book == book } }.toList(), - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/UserHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/UserHtmlRouter.kt deleted file mode 100644 index 3825e238..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/routers/html/UserHtmlRouter.kt +++ /dev/null @@ -1,176 +0,0 @@ -package github.buriedincode.bookshelf.routers.html - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.Utils.asEnumOrNull -import github.buriedincode.bookshelf.models.Book -import github.buriedincode.bookshelf.models.Creator -import github.buriedincode.bookshelf.models.Format -import github.buriedincode.bookshelf.models.Publisher -import github.buriedincode.bookshelf.models.Role -import github.buriedincode.bookshelf.models.Series -import github.buriedincode.bookshelf.models.User -import github.buriedincode.bookshelf.tables.BookTable -import io.github.oshai.kotlinlogging.KotlinLogging -import io.javalin.http.Context - -object UserHtmlRouter : BaseHtmlRouter(entity = User, plural = "users") { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - - override fun create(ctx: Context) = Utils.query { - render(ctx, "create", createOptions(), redirect = false) - } - - override fun view(ctx: Context) = Utils.query { - val resource = ctx.getResource() - val nextBooks = resource.readBooks.flatMap { it.book.series.map { it.series } }.distinct().sorted().mapNotNull { - it.books.map { it.book }.sorted().firstOrNull { book -> - resource.readBooks.none { - it.book == book - } - } - } - val model = mapOf( - "stats" to mapOf( - "wishlist" to resource.wishedBooks.count().toInt(), - "shared" to Book.all().count { !it.isCollected && it.wishers.empty() }, - "unread" to (Book.find { BookTable.isCollectedCol eq true }.count() - resource.readBooks.count()).toInt(), - "read" to resource.readBooks.count().toInt(), - ), - "nextBooks" to nextBooks, - ) - renderResource(ctx, "view", model, redirect = false) - } - - fun wishlist(ctx: Context) = Utils.query { - val resource = ctx.getResource() - ctx.render( - "templates/book/list.kte", - mapOf( - "session" to ctx.getSession(), - "resources" to filterWishlist(ctx), - "filters" to wishlistFilters(ctx), - "extras" to mapOf( - "collected" to true, - "read" to true, - "resetUrl" to "/users/${resource.id.value}/wishlist", - "title" to "${resource.username}'s Wishlist", - ), - ), - ) - } - - fun readlist(ctx: Context) = Utils.query { - val resource = ctx.getResource() - ctx.render( - "templates/book/list.kte", - mapOf( - "session" to ctx.getSession(), - "resources" to filterReadlist(ctx), - "filters" to readlistFilters(ctx), - "extras" to mapOf( - "collected" to true, - "read" to true, - "resetUrl" to "/users/${resource.id.value}/readlist", - "title" to "${resource.username}'s Readlist", - "wished" to true, - ), - ), - ) - } - - override fun filterResources(ctx: Context): List { - var resources = User.all().toList() - ctx.queryParam("username")?.let { username -> - resources = resources.filter { it.username.contains(username, ignoreCase = true) } - } - return resources - } - - override fun filters(ctx: Context): Map = mapOf( - "username" to ctx.queryParam("username"), - ) - - override fun updateOptions(ctx: Context): Map = mapOf( - "readBooks" to Book.all().filter { it.isCollected }.filterNot { it in ctx.getResource().readBooks.map { it.book } }.toList(), - "wishedBooks" to Book.all().filterNot { it.isCollected }.filterNot { it in ctx.getResource().wishedBooks }.toList(), - ) - - private fun filterWishlist(ctx: Context): List { - val resource = ctx.getResource() - var resources = Book.all().filter { resource.wishedBooks.any { book -> it == book } || (!it.isCollected && it.wishers.empty()) } - ctx.queryParam("creator-id")?.toLongOrNull()?.let { - Creator.findById(it)?.let { creator -> resources = resources.filter { it.credits.any { it.creator == creator } } } - } - ctx.queryParam("format")?.asEnumOrNull()?.let { format -> - resources = resources.filter { format == it.format } - } - ctx.queryParam("has-wished")?.lowercase()?.toBooleanStrictOrNull()?.let { hasWished -> - resources = resources.filter { (it in resource.wishedBooks) == hasWished } - } - ctx.queryParam("publisher-id")?.toLongOrNull()?.let { - Publisher.findById(it)?.let { publisher -> resources = resources.filter { publisher == it.publisher } } - } - ctx.queryParam("role-id")?.toLongOrNull()?.let { - Role.findById(it)?.let { role -> resources = resources.filter { it.credits.any { it.role == role } } } - } - ctx.queryParam("series-id")?.toLongOrNull()?.let { - Series.findById(it)?.let { series -> resources = resources.filter { it.series.any { it.series == series } } } - } - ctx.queryParam("title")?.let { title -> - resources = resources.filter { - it.title.contains(title, ignoreCase = true) || (it.subtitle?.contains(title, ignoreCase = true) == true) - } - } - return resources.toList() - } - - private fun wishlistFilters(ctx: Context): Map = mapOf( - "creator" to ctx.queryParam("creator-id")?.toLongOrNull()?.let { Creator.findById(it) }, - "format" to ctx.queryParam("format")?.asEnumOrNull(), - "has-read" to false, - "has-wished" to ctx.queryParam("has-wished")?.lowercase()?.toBooleanStrictOrNull(), - "is-collected" to false, - "publisher" to ctx.queryParam("publisher-id")?.toLongOrNull()?.let { Publisher.findById(it) }, - "role" to ctx.queryParam("role-id")?.toLongOrNull()?.let { Role.findById(it) }, - "series" to ctx.queryParam("series-id")?.toLongOrNull()?.let { Series.findById(it) }, - "title" to ctx.queryParam("title"), - ) - - private fun filterReadlist(ctx: Context): List { - var resources = ctx.getResource().readBooks.map { it.book } - ctx.queryParam("creator-id")?.toLongOrNull()?.let { - Creator.findById(it)?.let { creator -> resources = resources.filter { it.credits.any { it.creator == creator } } } - } - ctx.queryParam("format")?.asEnumOrNull()?.let { format -> - resources = resources.filter { format == it.format } - } - ctx.queryParam("publisher-id")?.toLongOrNull()?.let { - Publisher.findById(it)?.let { publisher -> resources = resources.filter { publisher == it.publisher } } - } - ctx.queryParam("role-id")?.toLongOrNull()?.let { - Role.findById(it)?.let { role -> resources = resources.filter { it.credits.any { it.role == role } } } - } - ctx.queryParam("series-id")?.toLongOrNull()?.let { - Series.findById(it)?.let { series -> resources = resources.filter { it.series.any { it.series == series } } } - } - ctx.queryParam("title")?.let { title -> - resources = resources.filter { - it.title.contains(title, ignoreCase = true) || (it.subtitle?.contains(title, ignoreCase = true) == true) - } - } - return resources.toList() - } - - private fun readlistFilters(ctx: Context): Map = mapOf( - "creator" to ctx.queryParam("creator-id")?.toLongOrNull()?.let { Creator.findById(it) }, - "format" to ctx.queryParam("format")?.asEnumOrNull(), - "has-read" to true, - "has-wished" to null, - "is-collected" to true, - "publisher" to ctx.queryParam("publisher-id")?.toLongOrNull()?.let { Publisher.findById(it) }, - "role" to ctx.queryParam("role-id")?.toLongOrNull()?.let { Role.findById(it) }, - "series" to ctx.queryParam("series-id")?.toLongOrNull()?.let { Series.findById(it) }, - "title" to ctx.queryParam("title"), - ) -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/BookSeriesTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/BookSeriesTable.kt deleted file mode 100644 index ea4f38f9..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/BookSeriesTable.kt +++ /dev/null @@ -1,31 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.ReferenceOption -import org.jetbrains.exposed.sql.SchemaUtils - -object BookSeriesTable : LongIdTable(name = "books__series") { - val bookCol: Column> = reference( - name = "book_id", - foreign = BookTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - val seriesCol: Column> = reference( - name = "series_id", - foreign = SeriesTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - val numberCol: Column = integer(name = "number").nullable() - - init { - Utils.query { - uniqueIndex(bookCol, seriesCol) - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/BookTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/BookTable.kt deleted file mode 100644 index c9deb7a1..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/BookTable.kt +++ /dev/null @@ -1,43 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import github.buriedincode.bookshelf.models.Format -import kotlinx.datetime.LocalDate -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.ReferenceOption -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.kotlin.datetime.date - -object BookTable : LongIdTable(name = "books") { - val formatCol: Column = enumerationByName( - name = "format", - length = 16, - klass = Format::class, - ).default(defaultValue = Format.PAPERBACK) - val goodreadsCol: Column = text(name = "goodreads_id").nullable() - val googleBooksCol: Column = text(name = "google_books_id").nullable() - val imageUrlCol: Column = text(name = "image_url").nullable() - val isCollectedCol: Column = bool(name = "is_collected").default(defaultValue = false) - val isbnCol: Column = text(name = "isbn").nullable().uniqueIndex() - val libraryThingCol: Column = text(name = "library_thing_id").nullable() - val openLibraryCol: Column = text(name = "open_library_id").nullable().uniqueIndex() - val publishDateCol: Column = date(name = "publish_date").nullable() - val publisherCol: Column?> = optReference( - name = "publisher_id", - foreign = PublisherTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - val subtitleCol: Column = text(name = "subtitle").nullable() - val summaryCol: Column = text(name = "summary").nullable() - val titleCol: Column = text(name = "title") - - init { - Utils.query { - uniqueIndex(titleCol, subtitleCol) - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/CreatorTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/CreatorTable.kt deleted file mode 100644 index 912e8b22..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/CreatorTable.kt +++ /dev/null @@ -1,17 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.SchemaUtils - -object CreatorTable : LongIdTable(name = "creators") { - val imageUrlCol: Column = text(name = "image_url").nullable() - val nameCol: Column = text(name = "name").uniqueIndex() - - init { - Utils.query { - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/CreditTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/CreditTable.kt deleted file mode 100644 index dace642c..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/CreditTable.kt +++ /dev/null @@ -1,36 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.ReferenceOption -import org.jetbrains.exposed.sql.SchemaUtils - -object CreditTable : LongIdTable(name = "books__creators__roles") { - val bookCol: Column> = reference( - name = "book_id", - foreign = BookTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - val creatorCol: Column> = reference( - name = "creator_id", - foreign = CreatorTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - val roleCol: Column> = reference( - name = "role_id", - foreign = RoleTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - - init { - Utils.query { - uniqueIndex(bookCol, creatorCol, roleCol) - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/PublisherTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/PublisherTable.kt deleted file mode 100644 index c1eced0c..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/PublisherTable.kt +++ /dev/null @@ -1,16 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.SchemaUtils - -object PublisherTable : LongIdTable(name = "publishers") { - val titleCol: Column = text(name = "title").uniqueIndex() - - init { - Utils.query { - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/ReadBookTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/ReadBookTable.kt deleted file mode 100644 index d7fc7d7a..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/ReadBookTable.kt +++ /dev/null @@ -1,33 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import kotlinx.datetime.LocalDate -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.ReferenceOption -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.kotlin.datetime.date - -object ReadBookTable : LongIdTable(name = "read_books") { - val bookCol: Column> = reference( - name = "book_id", - foreign = BookTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - val userCol: Column> = reference( - name = "user_id", - foreign = UserTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - val readDateCol: Column = date(name = "read_date").nullable() - - init { - Utils.query { - uniqueIndex(bookCol, userCol) - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/RoleTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/RoleTable.kt deleted file mode 100644 index 134b506b..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/RoleTable.kt +++ /dev/null @@ -1,16 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.SchemaUtils - -object RoleTable : LongIdTable(name = "roles") { - val titleCol: Column = text(name = "title").uniqueIndex() - - init { - Utils.query { - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/SeriesTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/SeriesTable.kt deleted file mode 100644 index cb8ef76d..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/SeriesTable.kt +++ /dev/null @@ -1,16 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.SchemaUtils - -object SeriesTable : LongIdTable(name = "series") { - val titleCol: Column = text(name = "title").uniqueIndex() - - init { - Utils.query { - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/UserTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/UserTable.kt deleted file mode 100644 index c6b4ede0..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/UserTable.kt +++ /dev/null @@ -1,17 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.SchemaUtils - -object UserTable : LongIdTable(name = "users") { - val imageUrlCol: Column = text(name = "image_url").nullable() - val usernameCol: Column = text(name = "username").uniqueIndex() - - init { - Utils.query { - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/WishedTable.kt b/app/src/main/kotlin/github/buriedincode/bookshelf/tables/WishedTable.kt deleted file mode 100644 index 282acaac..00000000 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/tables/WishedTable.kt +++ /dev/null @@ -1,30 +0,0 @@ -package github.buriedincode.bookshelf.tables - -import github.buriedincode.bookshelf.Utils -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.ReferenceOption -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.Table - -object WishedTable : Table(name = "wished_books") { - val bookCol: Column> = reference( - name = "book_id", - foreign = BookTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - val userCol: Column> = reference( - name = "user_id", - foreign = UserTable, - onUpdate = ReferenceOption.CASCADE, - onDelete = ReferenceOption.CASCADE, - ) - override val primaryKey = PrimaryKey(bookCol, userCol) - - init { - Utils.query { - SchemaUtils.create(this) - } - } -} diff --git a/app/src/main/kotlin/github/buriedincode/models/Book.kt b/app/src/main/kotlin/github/buriedincode/models/Book.kt new file mode 100644 index 00000000..e1ecf9fb --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/Book.kt @@ -0,0 +1,154 @@ +package github.buriedincode.models + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import github.buriedincode.Utils.toString +import github.buriedincode.tables.BookSeriesTable +import github.buriedincode.tables.BookTable +import github.buriedincode.tables.CreditTable +import github.buriedincode.tables.ReadBookTable +import github.buriedincode.tables.WishedTable +import kotlinx.datetime.LocalDate +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.sql.and + +class Book(id: EntityID) : LongEntity(id), IJson, Comparable { + companion object : LongEntityClass(BookTable) { + val comparator = + compareBy { it.series.firstOrNull()?.series } + .thenBy { it.series.firstOrNull()?.number ?: Int.MAX_VALUE } + .thenBy(Book::title) + .thenBy(nullsFirst(), Book::subtitle) + + fun find(title: String, subtitle: String? = null, isbn: String? = null, openLibraryId: String? = null): Book? { + var result: Book? = null + if (result == null && openLibraryId != null) { + result = find { BookTable.openLibraryCol eq openLibraryId }.firstOrNull() + } + if (result == null && isbn != null) { + result = find { BookTable.isbnCol eq isbn }.firstOrNull() + } + if (result == null) { + result = find { (BookTable.titleCol eq title) and (BookTable.subtitleCol eq subtitle) }.firstOrNull() + } + return result + } + + fun findOrCreate( + title: String, + subtitle: String? = null, + isbn: String? = null, + openLibraryId: String? = null, + ): Book { + return find(title, subtitle, isbn, openLibraryId) + ?: new { + this.title = title + this.subtitle = subtitle + this.isbn = isbn + this.openLibrary = openLibraryId + } + } + } + + val credits by Credit referrersOn CreditTable.bookCol + var format: Format by BookTable.formatCol + var goodreads: String? by BookTable.goodreadsCol + var googleBooks: String? by BookTable.googleBooksCol + var imageUrl: String? by BookTable.imageUrlCol + var isbn: String? by BookTable.isbnCol + var isCollected: Boolean by BookTable.isCollectedCol + var libraryThing: String? by BookTable.libraryThingCol + var openLibrary: String? by BookTable.openLibraryCol + var publishDate: LocalDate? by BookTable.publishDateCol + var publisher: Publisher? by Publisher optionalReferencedOn BookTable.publisherCol + val readers by ReadBook referrersOn ReadBookTable.bookCol + val series by BookSeries referrersOn BookSeriesTable.bookCol + var subtitle: String? by BookTable.subtitleCol + var summary: String? by BookTable.summaryCol + var title: String by BookTable.titleCol + var wishers by User via WishedTable + + override fun toJson(showAll: Boolean): Map { + return mutableMapOf( + "format" to format.name, + "id" to id.value, + "identifiers" to + mapOf( + "goodreads" to goodreads, + "googleBooks" to googleBooks, + "isbn" to isbn, + "libraryThing" to libraryThing, + "openLibrary" to openLibrary, + ), + "imageUrl" to imageUrl, + "isCollected" to isCollected, + "publishDate" to publishDate?.toString("yyyy-MM-dd"), + "publisher" to publisher?.id?.value, + "subtitle" to subtitle, + "summary" to summary, + "title" to title, + ) + .apply { + if (showAll) { + put( + "credits", + credits.groupBy({ it.role }, { it.creator }).toSortedMap().map { (role, creators) -> + mapOf("role" to role.toJson(), "creators" to creators.map { it.toJson() }) + }, + ) + put("readers", readers.groupBy({ it.readDate?.toString("yyyy-MM-dd") ?: "null" }, { it.user.toJson() })) + put( + "series", + series.sortedBy { it.series }.map { mapOf("series" to it.series.toJson(), "number" to it.number) }, + ) + put("wishers", wishers.sorted().map { it.toJson() }) + } + } + .toSortedMap() + } + + override fun compareTo(other: Book): Int = comparator.compare(this, other) +} + +data class BookInput( + val credits: List = emptyList(), + val format: Format = Format.PAPERBACK, + val identifiers: Identifiers? = null, + val imageUrl: String? = null, + val isCollected: Boolean = false, + @param:JsonDeserialize(using = LocalDateDeserializer::class) val publishDate: LocalDate? = null, + val publisher: Long? = null, + val readers: List = emptyList(), + val series: List = emptyList(), + val subtitle: String? = null, + val summary: String? = null, + val title: String, + val wishers: List = emptyList(), +) { + data class Credit(val creator: Long, val role: Long) + + data class Identifiers( + val goodreads: String? = null, + val googleBooks: String? = null, + val isbn: String? = null, + val libraryThing: String? = null, + val openLibrary: String? = null, + ) + + data class Reader( + val user: Long, + @param:JsonDeserialize(using = LocalDateDeserializer::class) val readDate: LocalDate? = null, + ) + + data class Series(val series: Long, val number: Int? = null) +} + +data class ImportBook( + val goodreadsId: String? = null, + val googleBooksId: String? = null, + val isbn: String? = null, + val isCollected: Boolean = false, + val libraryThingId: String? = null, + val openLibraryId: String? = null, +) diff --git a/app/src/main/kotlin/github/buriedincode/models/BookSeries.kt b/app/src/main/kotlin/github/buriedincode/models/BookSeries.kt new file mode 100644 index 00000000..203489ae --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/BookSeries.kt @@ -0,0 +1,28 @@ +package github.buriedincode.models + +import github.buriedincode.tables.BookSeriesTable +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.sql.and + +class BookSeries(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(BookSeriesTable) { + fun find(book: Book, series: Series): BookSeries? { + return BookSeries.find { (BookSeriesTable.bookCol eq book.id) and (BookSeriesTable.seriesCol eq series.id) } + .firstOrNull() + } + + fun findOrCreate(book: Book, series: Series): BookSeries { + return find(book, series) + ?: BookSeries.new { + this.book = book + this.series = series + } + } + } + + var book: Book by Book referencedOn BookSeriesTable.bookCol + var series: Series by Series referencedOn BookSeriesTable.seriesCol + var number: Int? by BookSeriesTable.numberCol +} diff --git a/app/src/main/kotlin/github/buriedincode/models/Creator.kt b/app/src/main/kotlin/github/buriedincode/models/Creator.kt new file mode 100644 index 00000000..6c7d3435 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/Creator.kt @@ -0,0 +1,46 @@ +package github.buriedincode.models + +import github.buriedincode.tables.CreatorTable +import github.buriedincode.tables.CreditTable +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID + +class Creator(id: EntityID) : LongEntity(id), IJson, Comparable { + companion object : LongEntityClass(CreatorTable) { + val comparator = compareBy(Creator::name) + + fun find(name: String): Creator? { + return Creator.find { CreatorTable.nameCol eq name }.firstOrNull() + } + + fun findOrCreate(name: String): Creator { + return find(name) ?: Creator.new { this.name = name } + } + } + + val credits by Credit referrersOn CreditTable.creatorCol + var imageUrl: String? by CreatorTable.imageUrlCol + var name: String by CreatorTable.nameCol + + override fun toJson(showAll: Boolean): Map { + return mutableMapOf("id" to id.value, "imageUrl" to imageUrl, "name" to name) + .apply { + if (showAll) { + put( + "credits", + credits.groupBy({ it.role }, { it.book }).toSortedMap().map { (role, books) -> + mapOf("role" to role.toJson(), "books" to books.map { it.toJson() }) + }, + ) + } + } + .toSortedMap() + } + + override fun compareTo(other: Creator): Int = comparator.compare(this, other) +} + +data class CreatorInput(val credits: List = emptyList(), val imageUrl: String? = null, val name: String) { + data class Credit(val book: Long, val role: Long) +} diff --git a/app/src/main/kotlin/github/buriedincode/models/Credit.kt b/app/src/main/kotlin/github/buriedincode/models/Credit.kt new file mode 100644 index 00000000..f39b3d54 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/Credit.kt @@ -0,0 +1,33 @@ +package github.buriedincode.models + +import github.buriedincode.tables.CreditTable +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.sql.and + +class Credit(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(CreditTable) { + fun find(book: Book, creator: Creator, role: Role): Credit? { + return Credit.find { + (CreditTable.bookCol eq book.id) and + (CreditTable.creatorCol eq creator.id) and + (CreditTable.roleCol eq role.id) + } + .firstOrNull() + } + + fun findOrCreate(book: Book, creator: Creator, role: Role): Credit { + return find(book, creator, role) + ?: Credit.new { + this.book = book + this.creator = creator + this.role = role + } + } + } + + var book: Book by Book referencedOn CreditTable.bookCol + var creator: Creator by Creator referencedOn CreditTable.creatorCol + var role: Role by Role referencedOn CreditTable.roleCol +} diff --git a/app/src/main/kotlin/github/buriedincode/models/Format.kt b/app/src/main/kotlin/github/buriedincode/models/Format.kt new file mode 100644 index 00000000..aaa61d04 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/Format.kt @@ -0,0 +1,14 @@ +package github.buriedincode.models + +enum class Format { + BOX_SET, + GRAPHIC_NOVEL, + HARDBACK, // TODO: Remove + HARDCOVER, + MANGA, + PAPERBACK, + TRADEPAPERBACK; + + val displayName: String + get() = name.lowercase().split("_").joinToString(" ") { it.replaceFirstChar(Char::uppercaseChar) } +} diff --git a/app/src/main/kotlin/github/buriedincode/models/IJson.kt b/app/src/main/kotlin/github/buriedincode/models/IJson.kt new file mode 100644 index 00000000..42de907a --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/IJson.kt @@ -0,0 +1,5 @@ +package github.buriedincode.models + +interface IJson { + fun toJson(showAll: Boolean = false): Map +} diff --git a/app/src/main/kotlin/github/buriedincode/models/IdInput.kt b/app/src/main/kotlin/github/buriedincode/models/IdInput.kt new file mode 100644 index 00000000..7a4aa6ac --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/IdInput.kt @@ -0,0 +1,3 @@ +package github.buriedincode.models + +data class IdInput(val id: Long) diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/models/LocalDateDeserializer.kt b/app/src/main/kotlin/github/buriedincode/models/LocalDateDeserializer.kt similarity index 51% rename from app/src/main/kotlin/github/buriedincode/bookshelf/models/LocalDateDeserializer.kt rename to app/src/main/kotlin/github/buriedincode/models/LocalDateDeserializer.kt index 1815689d..cd2bdd33 100644 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/models/LocalDateDeserializer.kt +++ b/app/src/main/kotlin/github/buriedincode/models/LocalDateDeserializer.kt @@ -1,4 +1,4 @@ -package github.buriedincode.bookshelf.models +package github.buriedincode.models import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonProcessingException @@ -6,19 +6,19 @@ import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper -import kotlinx.datetime.LocalDate -import kotlinx.datetime.toKotlinLocalDate import java.io.IOException import java.time.format.DateTimeFormatter +import kotlinx.datetime.LocalDate +import kotlinx.datetime.toKotlinLocalDate class LocalDateDeserializer : JsonDeserializer() { - @Throws(IOException::class, JsonProcessingException::class) - override fun deserialize(parser: JsonParser, ctxt: DeserializationContext?): LocalDate? { - val mapper = parser.codec as ObjectMapper - val root = mapper.readTree(parser) as JsonNode - if (root.isNull || root.asText() == "null") { - return null - } - return java.time.LocalDate.parse(root.asText(), DateTimeFormatter.ISO_DATE).toKotlinLocalDate() + @Throws(IOException::class, JsonProcessingException::class) + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext?): LocalDate? { + val mapper = parser.codec as ObjectMapper + val root = mapper.readTree(parser) as JsonNode + if (root.isNull || root.asText() == "null") { + return null } + return java.time.LocalDate.parse(root.asText(), DateTimeFormatter.ISO_DATE).toKotlinLocalDate() + } } diff --git a/app/src/main/kotlin/github/buriedincode/models/Publisher.kt b/app/src/main/kotlin/github/buriedincode/models/Publisher.kt new file mode 100644 index 00000000..75c2575b --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/Publisher.kt @@ -0,0 +1,39 @@ +package github.buriedincode.models + +import github.buriedincode.tables.BookTable +import github.buriedincode.tables.PublisherTable +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID + +class Publisher(id: EntityID) : LongEntity(id), IJson, Comparable { + companion object : LongEntityClass(PublisherTable) { + val comparator = compareBy(Publisher::title) + + fun find(title: String): Publisher? { + return Publisher.find { PublisherTable.titleCol eq title }.firstOrNull() + } + + fun findOrCreate(title: String): Publisher { + return find(title) ?: Publisher.new { this.title = title } + } + } + + var title: String by PublisherTable.titleCol + + val books by Book optionalReferrersOn BookTable.publisherCol + + override fun toJson(showAll: Boolean): Map { + return mutableMapOf("id" to id.value, "title" to title) + .apply { + if (showAll) { + put("books", books.sorted().map { it.toJson() }) + } + } + .toSortedMap() + } + + override fun compareTo(other: Publisher): Int = comparator.compare(this, other) +} + +data class PublisherInput(val title: String) diff --git a/app/src/main/kotlin/github/buriedincode/models/ReadBook.kt b/app/src/main/kotlin/github/buriedincode/models/ReadBook.kt new file mode 100644 index 00000000..4409019f --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/ReadBook.kt @@ -0,0 +1,28 @@ +package github.buriedincode.models + +import github.buriedincode.tables.ReadBookTable +import kotlinx.datetime.LocalDate +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.sql.and + +class ReadBook(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(ReadBookTable) { + fun find(book: Book, user: User): ReadBook? { + return ReadBook.find { (ReadBookTable.bookCol eq book.id) and (ReadBookTable.userCol eq user.id) }.firstOrNull() + } + + fun findOrCreate(book: Book, user: User): ReadBook { + return find(book, user) + ?: ReadBook.new { + this.book = book + this.user = user + } + } + } + + var book: Book by Book referencedOn ReadBookTable.bookCol + var user: User by User referencedOn ReadBookTable.userCol + var readDate: LocalDate? by ReadBookTable.readDateCol +} diff --git a/app/src/main/kotlin/github/buriedincode/models/Role.kt b/app/src/main/kotlin/github/buriedincode/models/Role.kt new file mode 100644 index 00000000..73efb6fa --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/Role.kt @@ -0,0 +1,45 @@ +package github.buriedincode.models + +import github.buriedincode.tables.CreditTable +import github.buriedincode.tables.RoleTable +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID + +class Role(id: EntityID) : LongEntity(id), IJson, Comparable { + companion object : LongEntityClass(RoleTable) { + val comparator = compareBy(Role::title) + + fun find(title: String): Role? { + return Role.find { RoleTable.titleCol eq title }.firstOrNull() + } + + fun findOrCreate(title: String): Role { + return find(title) ?: Role.new { this.title = title } + } + } + + val credits by Credit referrersOn CreditTable.roleCol + var title: String by RoleTable.titleCol + + override fun toJson(showAll: Boolean): Map { + return mutableMapOf("id" to id.value, "title" to title) + .apply { + if (showAll) { + put( + "credits", + credits.groupBy({ it.creator }, { it.book }).toSortedMap().map { (creator, books) -> + mapOf("creator" to creator.toJson(), "books" to books.map { it.toJson() }) + }, + ) + } + } + .toSortedMap() + } + + override fun compareTo(other: Role): Int = comparator.compare(this, other) +} + +data class RoleInput(val credits: List = emptyList(), val title: String) { + data class Credit(val book: Long, val creator: Long) +} diff --git a/app/src/main/kotlin/github/buriedincode/models/Series.kt b/app/src/main/kotlin/github/buriedincode/models/Series.kt new file mode 100644 index 00000000..fca7cfc6 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/Series.kt @@ -0,0 +1,40 @@ +package github.buriedincode.models + +import github.buriedincode.tables.BookSeriesTable +import github.buriedincode.tables.SeriesTable +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID + +class Series(id: EntityID) : LongEntity(id), IJson, Comparable { + companion object : LongEntityClass(SeriesTable) { + val comparator = compareBy(Series::title) + + fun find(title: String): Series? { + return Series.find { SeriesTable.titleCol eq title }.firstOrNull() + } + + fun findOrCreate(title: String): Series { + return find(title) ?: Series.new { this.title = title } + } + } + + val books by BookSeries referrersOn BookSeriesTable.seriesCol + var title: String by SeriesTable.titleCol + + override fun toJson(showAll: Boolean): Map { + return mutableMapOf("id" to id.value, "title" to title) + .apply { + if (showAll) { + put("books", books.sortedBy { it.book }.map { mapOf("book" to it.book.toJson(), "number" to it.number) }) + } + } + .toSortedMap() + } + + override fun compareTo(other: Series): Int = comparator.compare(this, other) +} + +data class SeriesInput(val books: List = emptyList(), val title: String) { + data class Book(val book: Long, val number: Int? = null) +} diff --git a/app/src/main/kotlin/github/buriedincode/models/User.kt b/app/src/main/kotlin/github/buriedincode/models/User.kt new file mode 100644 index 00000000..830b4e10 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/models/User.kt @@ -0,0 +1,55 @@ +package github.buriedincode.models + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import github.buriedincode.Utils.toString +import github.buriedincode.tables.ReadBookTable +import github.buriedincode.tables.UserTable +import github.buriedincode.tables.WishedTable +import kotlinx.datetime.LocalDate +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID + +class User(id: EntityID) : LongEntity(id), IJson, Comparable { + companion object : LongEntityClass(UserTable) { + val comparator = compareBy(User::username) + + fun findOrNull(username: String): User? { + return find { UserTable.usernameCol eq username }.firstOrNull() + } + + fun findOrCreate(username: String): User { + return findOrNull(username) ?: new { this.username = username } + } + } + + var imageUrl: String? by UserTable.imageUrlCol + val readBooks by ReadBook referrersOn ReadBookTable.userCol + var username: String by UserTable.usernameCol + var wishedBooks by Book via WishedTable + + override fun toJson(showAll: Boolean): Map { + return mutableMapOf("id" to id.value, "imageUrl" to imageUrl, "username" to username) + .apply { + if (showAll) { + put("read", readBooks.groupBy({ it.readDate?.toString("yyyy-MM-dd") ?: "null" }, { it.book.toJson() })) + put("wished", wishedBooks.sorted().map { it.toJson() }) + } + } + .toSortedMap() + } + + override fun compareTo(other: User): Int = comparator.compare(this, other) +} + +data class UserInput( + val readBooks: List = emptyList(), + val imageUrl: String? = null, + val username: String, + val wishedBooks: List = emptyList(), +) { + data class ReadBook( + val book: Long, + @param:JsonDeserialize(using = LocalDateDeserializer::class) val readDate: LocalDate? = null, + ) +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/api/BaseApiRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/api/BaseApiRouter.kt new file mode 100644 index 00000000..76e792d9 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/api/BaseApiRouter.kt @@ -0,0 +1,46 @@ +package github.buriedincode.routers.api + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.IJson +import io.javalin.http.BadRequestResponse +import io.javalin.http.Context +import io.javalin.http.HttpStatus +import io.javalin.http.NotFoundResponse +import io.javalin.http.bodyAsClass +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass + +abstract class BaseApiRouter(protected val entity: LongEntityClass) where T : LongEntity, T : IJson { + protected val name: String = entity::class.java.declaringClass.simpleName.lowercase() + protected val paramName: String = "$name-id" + protected val title: String = name.replaceFirstChar(Char::uppercaseChar) + + protected fun Context.getResource(): T = + this.pathParam(paramName).toLongOrNull()?.let { entity.findById(it) ?: throw NotFoundResponse("$title not found") } + ?: throw BadRequestResponse("Invalid $title Id") + + protected inline fun Context.processInput(crossinline block: (I) -> Unit) = + block(bodyAsClass(I::class.java)) + + protected inline fun manage(ctx: Context, crossinline block: (I, T) -> Unit) = + ctx.processInput { body -> + transaction { + val resource = ctx.getResource() + block(body, resource) + ctx.json(resource.toJson(showAll = true)) + } + } + + abstract fun list(ctx: Context) + + abstract fun create(ctx: Context) + + open fun read(ctx: Context) = transaction { ctx.json(ctx.getResource().toJson(showAll = true)) } + + abstract fun update(ctx: Context) + + open fun delete(ctx: Context) = transaction { + ctx.getResource().delete() + ctx.status(status = HttpStatus.NO_CONTENT) + } +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/api/BookApiRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/api/BookApiRouter.kt new file mode 100644 index 00000000..7a360c43 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/api/BookApiRouter.kt @@ -0,0 +1,336 @@ +package github.buriedincode.routers.api + +import github.buriedincode.Utils.asEnumOrNull +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.BookInput +import github.buriedincode.models.BookSeries +import github.buriedincode.models.Creator +import github.buriedincode.models.Credit +import github.buriedincode.models.Format +import github.buriedincode.models.IdInput +import github.buriedincode.models.ImportBook +import github.buriedincode.models.Publisher +import github.buriedincode.models.ReadBook +import github.buriedincode.models.Role +import github.buriedincode.models.Series +import github.buriedincode.models.User +import github.buriedincode.openlibrary.schemas.Edition +import github.buriedincode.openlibrary.schemas.Work +import github.buriedincode.services.OpenLibrary +import github.buriedincode.services.getId +import github.buriedincode.tables.BookSeriesTable +import github.buriedincode.tables.BookTable +import github.buriedincode.tables.CreditTable +import io.github.oshai.kotlinlogging.KotlinLogging +import io.javalin.http.BadRequestResponse +import io.javalin.http.ConflictResponse +import io.javalin.http.Context +import io.javalin.http.HttpStatus +import io.javalin.http.NotFoundResponse +import io.javalin.http.NotImplementedResponse +import org.jetbrains.exposed.sql.SizedCollection +import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.or +import org.jetbrains.exposed.sql.selectAll + +object BookApiRouter : BaseApiRouter(entity = Book) { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + + override fun list(ctx: Context): Unit = transaction { + val query = BookTable.selectAll() + ctx.queryParam("creator-id")?.toLongOrNull()?.let { + Creator.findById(it)?.let { creator -> query.andWhere { CreditTable.creatorCol eq creator.id } } + } + ctx.queryParam("format")?.asEnumOrNull()?.let { format -> query.andWhere { BookTable.formatCol eq format } } + ctx.queryParam("is-collected")?.lowercase()?.toBooleanStrictOrNull()?.let { isCollected -> + query.andWhere { BookTable.isCollectedCol eq isCollected } + } + ctx.queryParam("publisher-id")?.toLongOrNull()?.let { + Publisher.findById(it)?.let { publisher -> query.andWhere { BookTable.publisherCol eq publisher.id } } + } + ctx.queryParam("series-id")?.toLongOrNull()?.let { + Series.findById(it)?.let { series -> query.andWhere { BookSeriesTable.seriesCol eq series.id } } + } + ctx.queryParam("title")?.let { title -> + query.andWhere { (BookTable.titleCol like "%$title%") or (BookTable.subtitleCol like "%$title%") } + } + ctx.json(Book.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) + } + + override fun create(ctx: Context) = + ctx.processInput { body -> + transaction { + Book.find(body.title, body.subtitle, body.identifiers?.isbn, body.identifiers?.openLibrary)?.let { + throw ConflictResponse("Book already exists") + } + val resource = + Book.findOrCreate(body.title, body.subtitle, body.identifiers?.isbn, body.identifiers?.openLibrary).apply { + body.credits.forEach { + Credit.new { + this.book = this@apply + this.creator = Creator.findById(it.creator) ?: throw NotFoundResponse("Creator not found.") + this.role = Role.findById(it.role) ?: throw NotFoundResponse("Role not found.") + } + } + format = body.format + goodreads = body.identifiers?.goodreads + googleBooks = body.identifiers?.googleBooks + imageUrl = body.imageUrl + isCollected = body.isCollected + libraryThing = body.identifiers?.libraryThing + publishDate = body.publishDate + publisher = body.publisher?.let { Publisher.findById(it) ?: throw NotFoundResponse("Publisher not found.") } + body.readers.forEach { + ReadBook.new { + this.book = this@apply + this.user = User.findById(it.user) ?: throw NotFoundResponse("User not found.") + this.readDate = it.readDate + } + } + body.series.forEach { + BookSeries.new { + this.book = this@apply + this.series = Series.findById(it.series) ?: throw NotFoundResponse("Series not found.") + this.number = it.number + } + } + summary = body.summary + wishers = + SizedCollection(body.wishers.map { User.findById(it) ?: throw NotFoundResponse("User not found.") }) + } + ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) + } + } + + override fun update(ctx: Context) = + manage(ctx) { body, book -> + Book.find(body.title, body.subtitle, body.identifiers?.isbn, body.identifiers?.openLibrary) + ?.takeIf { it != book } + ?.let { throw ConflictResponse("Book already exists") } + + book.apply { + credits.forEach { it.delete() } + body.credits.forEach { + Credit.findOrCreate( + this, + Creator.findById(it.creator) ?: throw NotFoundResponse("Creator not found."), + Role.findById(it.role) ?: throw NotFoundResponse("Role not found."), + ) + } + format = body.format + goodreads = body.identifiers?.goodreads + googleBooks = body.identifiers?.googleBooks + imageUrl = body.imageUrl + isbn = body.identifiers?.isbn + isCollected = body.isCollected + libraryThing = body.identifiers?.libraryThing + openLibrary = body.identifiers?.openLibrary + publishDate = body.publishDate + publisher = body.publisher?.let { Publisher.findById(it) ?: throw NotFoundResponse("Publisher not found.") } + readers.forEach { it.delete() } + body.readers.forEach { + ReadBook.findOrCreate(this, User.findById(it.user) ?: throw NotFoundResponse("User not found.")).apply { + readDate = it.readDate + } + } + series.forEach { it.delete() } + body.series.forEach { + BookSeries.findOrCreate(this, Series.findById(it.series) ?: throw NotFoundResponse("Series not found.")) + .apply { number = it.number } + } + subtitle = body.subtitle + summary = body.summary + title = body.title + wishers = SizedCollection(body.wishers.map { User.findById(it) ?: throw NotFoundResponse("User not found.") }) + } + } + + override fun delete(ctx: Context) = transaction { + ctx.getResource().apply { + credits.forEach { it.delete() } + readers.forEach { it.delete() } + series.forEach { it.delete() } + delete() + } + ctx.status(HttpStatus.NO_CONTENT) + } + + private fun manageStatus(ctx: Context, block: (Book) -> Unit) = transaction { + val resource = ctx.getResource() + block(resource) + ctx.json(resource.toJson(showAll = true)) + } + + fun collectBook(ctx: Context) = + manageStatus(ctx) { resource -> + resource.isCollected = true + resource.wishers = SizedCollection() + } + + fun discardBook(ctx: Context) = + manageStatus(ctx) { resource -> + resource.isCollected = false + resource.readers.forEach { it.delete() } + } + + fun addReader(ctx: Context) = + manage(ctx) { body, book -> + val user = User.findById(body.user) ?: throw NotFoundResponse("User not found.") + if (!book.isCollected) { + throw BadRequestResponse("Book hasn't been collected") + } + ReadBook.findOrCreate(book, user).apply { readDate = body.readDate } + } + + fun removeReader(ctx: Context) = + manage(ctx) { body, book -> + val user = User.findById(body.id) ?: throw NotFoundResponse("User not found.") + if (!book.isCollected) { + throw BadRequestResponse("Book hasn't been collected") + } + ReadBook.find(book, user)?.delete() + } + + fun addWisher(ctx: Context) = + manage(ctx) { body, book -> + val user = User.findById(body.id) ?: throw NotFoundResponse("User not found.") + if (book.isCollected) { + throw BadRequestResponse("Book has been collected") + } + book.wishers = SizedCollection(book.wishers + user) + } + + fun removeWisher(ctx: Context) = + manage(ctx) { body, book -> + val user = User.findById(body.id) ?: throw NotFoundResponse("User not found.") + if (book.isCollected) { + throw BadRequestResponse("Book has been collected") + } + book.wishers = SizedCollection(book.wishers - user) + } + + fun addCredit(ctx: Context) = + manage(ctx) { body, book -> + Credit.findOrCreate( + book, + Creator.findById(body.creator) ?: throw NotFoundResponse("Creator not found."), + Role.findById(body.role) ?: throw NotFoundResponse("Role not found."), + ) + } + + fun removeCredit(ctx: Context) = + manage(ctx) { body, book -> + Credit.find( + book, + Creator.findById(body.creator) ?: throw NotFoundResponse("Creator not found."), + Role.findById(body.role) ?: throw NotFoundResponse("Role not found."), + ) + ?.delete() + } + + fun addSeries(ctx: Context) = + manage(ctx) { body, book -> + BookSeries.findOrCreate(book, Series.findById(body.series) ?: throw NotFoundResponse("Series not found.")).apply { + number = if (body.number == 0) null else body.number + } + } + + fun removeSeries(ctx: Context) = + manage(ctx) { body, book -> + BookSeries.find(book, Series.findById(body.id) ?: throw NotFoundResponse("Series not found."))?.delete() + } + + private fun Book.applyOpenLibrary(edition: Edition, work: Work): Book = + this.apply { + var format = edition.physicalFormat?.asEnumOrNull() + if (edition.physicalFormat.equals("Hardback", ignoreCase = true)) { + format = Format.HARDCOVER + } else if (edition.physicalFormat.equals("Mass Market Paperback", ignoreCase = true)) { + format = Format.PAPERBACK + } else if (format == null) { + LOGGER.warn { "Unmapped Format: ${edition.physicalFormat}" } + } + + this.format = format ?: Format.PAPERBACK + goodreads = edition.identifiers?.goodreads?.firstOrNull() + googleBooks = edition.identifiers?.google?.firstOrNull() + imageUrl = "https://covers.openlibrary.org/b/OLID/${edition.getId()}-L.jpg" + isbn = isbn ?: edition.isbn13.firstOrNull() ?: edition.isbn10.firstOrNull() + libraryThing = edition.identifiers?.librarything?.firstOrNull() + openLibrary = edition.getId() + publishDate = edition.publishDate + publisher = edition.publishers.firstOrNull()?.let { Publisher.findOrCreate(it) } + summary = edition.description ?: work.description + + credits.forEach { it.delete() } + work.authors + .map { OpenLibrary.getAuthor(it.author.getId()) } + .map { + Creator.findOrCreate(it.name).apply { + it.photos.firstOrNull()?.let { imageUrl = "https://covers.openlibrary.org/a/id/$it-L.jpg" } + } + } + .forEach { Credit.findOrCreate(this, it, Role.findOrCreate("Author")) } + edition.contributors.forEach { + Credit.findOrCreate(this, Creator.findOrCreate(it.name), Role.findOrCreate(it.role)) + } + } + + fun search(ctx: Context) = + ctx.processInput { body -> + val results = OpenLibrary.search(title = body.title) + ctx.json(results) + } + + fun import(ctx: Context) = + ctx.processInput { body -> + transaction { + val edition = + body.isbn?.let { OpenLibrary.getEditionByISBN(it) } + ?: body.openLibraryId?.let { OpenLibrary.getEdition(it) } + ?: throw NotImplementedResponse("Import only supports OpenLibrary currently") + val work = OpenLibrary.getWork(edition.works.first().getId()) + + Book.find( + edition.title, + edition.subtitle, + edition.isbn13.firstOrNull() ?: edition.isbn10.firstOrNull(), + edition.getId(), + ) + ?.let { throw ConflictResponse("Book already exists") } + val resource = + Book.findOrCreate( + edition.title, + edition.subtitle, + edition.isbn13.firstOrNull() ?: edition.isbn10.firstOrNull(), + edition.getId(), + ) + .applyOpenLibrary(edition, work) + .apply { isCollected = body.isCollected } + + ctx.json(resource.toJson(showAll = true)) + } + } + + fun reimport(ctx: Context) = transaction { + val resource = ctx.getResource() + val edition = + resource.isbn?.let { OpenLibrary.getEditionByISBN(it) } + ?: resource.openLibrary?.let { OpenLibrary.getEdition(it) } + ?: throw NotImplementedResponse("Import only supports OpenLibrary currently") + val work = OpenLibrary.getWork(edition.works.first().getId()) + + Book.find( + edition.title, + edition.subtitle, + edition.isbn13.firstOrNull() ?: edition.isbn10.firstOrNull(), + edition.getId(), + ) + ?.takeIf { it != resource } + ?.let { throw ConflictResponse("Book already exists") } + resource.applyOpenLibrary(edition, work) + + ctx.json(resource.toJson(showAll = true)) + } +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/api/CreatorApiRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/api/CreatorApiRouter.kt new file mode 100644 index 00000000..69d3e314 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/api/CreatorApiRouter.kt @@ -0,0 +1,93 @@ +package github.buriedincode.routers.api + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.Creator +import github.buriedincode.models.CreatorInput +import github.buriedincode.models.Credit +import github.buriedincode.models.Role +import github.buriedincode.tables.CreatorTable +import github.buriedincode.tables.CreditTable +import io.javalin.http.ConflictResponse +import io.javalin.http.Context +import io.javalin.http.HttpStatus +import io.javalin.http.NotFoundResponse +import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.selectAll + +object CreatorApiRouter : BaseApiRouter(entity = Creator) { + override fun list(ctx: Context): Unit = transaction { + val query = CreatorTable.selectAll() + ctx.queryParam("book-id")?.toLongOrNull()?.let { + Book.findById(it)?.let { book -> query.andWhere { CreditTable.bookCol eq book.id } } + } + ctx.queryParam("name")?.let { name -> query.andWhere { CreatorTable.nameCol like "%$name%" } } + ctx.queryParam("role-id")?.toLongOrNull()?.let { + Role.findById(it)?.let { role -> query.andWhere { CreditTable.roleCol eq role.id } } + } + ctx.json(Creator.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) + } + + override fun create(ctx: Context) = + ctx.processInput { body -> + transaction { + Creator.find(body.name)?.let { throw ConflictResponse("Creator already exists") } + val resource = + Creator.findOrCreate(body.name).apply { + body.credits.forEach { + Credit.new { + this.book = Book.findById(it.book) ?: throw NotFoundResponse("Book not found.") + this.creator = this@apply + this.role = Role.findById(it.role) ?: throw NotFoundResponse("Role not found.") + } + } + imageUrl = body.imageUrl + } + ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) + } + } + + override fun update(ctx: Context) = + manage(ctx) { body, creator -> + Creator.find(body.name)?.takeIf { it != creator }?.let { throw ConflictResponse("Creator already exists") } + creator.apply { + credits.forEach { it.delete() } + body.credits.forEach { + Credit.findOrCreate( + Book.findById(it.book) ?: throw NotFoundResponse("Book not found."), + this, + Role.findById(it.role) ?: throw NotFoundResponse("Role not found."), + ) + } + imageUrl = body.imageUrl + name = body.name + } + } + + override fun delete(ctx: Context) = transaction { + ctx.getResource().apply { + credits.forEach { it.delete() } + delete() + } + ctx.status(HttpStatus.NO_CONTENT) + } + + fun addCredit(ctx: Context) = + manage(ctx) { body, creator -> + Credit.findOrCreate( + Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), + creator, + Role.findById(body.role) ?: throw NotFoundResponse("Role not found."), + ) + } + + fun removeCredit(ctx: Context) = + manage(ctx) { body, creator -> + Credit.find( + Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), + creator, + Role.findById(body.role) ?: throw NotFoundResponse("Role not found."), + ) + ?.delete() + } +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/api/PublisherApiRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/api/PublisherApiRouter.kt new file mode 100644 index 00000000..bb0136ac --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/api/PublisherApiRouter.kt @@ -0,0 +1,53 @@ +package github.buriedincode.routers.api + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.IdInput +import github.buriedincode.models.Publisher +import github.buriedincode.models.PublisherInput +import github.buriedincode.tables.BookTable +import github.buriedincode.tables.PublisherTable +import io.javalin.http.ConflictResponse +import io.javalin.http.Context +import io.javalin.http.HttpStatus +import io.javalin.http.NotFoundResponse +import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.selectAll + +object PublisherApiRouter : BaseApiRouter(entity = Publisher) { + override fun list(ctx: Context): Unit = transaction { + val query = PublisherTable.selectAll() + ctx.queryParam("book-id")?.toLongOrNull()?.let { + Book.findById(it)?.let { book -> query.andWhere { BookTable.id eq book.id } } + } + ctx.queryParam("title")?.let { title -> query.andWhere { PublisherTable.titleCol like "%$title%" } } + ctx.json(Publisher.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) + } + + override fun create(ctx: Context) = + ctx.processInput { body -> + transaction { + Publisher.find(body.title)?.let { throw ConflictResponse("Publisher already exists") } + val resource = Publisher.findOrCreate(body.title) + ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) + } + } + + override fun update(ctx: Context) = + manage(ctx) { body, publisher -> + Publisher.find(body.title)?.takeIf { it != publisher }?.let { throw ConflictResponse("Publisher already exists") } + publisher.apply { title = body.title } + } + + fun addBook(ctx: Context) = + manage(ctx) { body, publisher -> + val book = Book.findById(body.id) ?: throw NotFoundResponse("Book not found.") + book.publisher = publisher + } + + fun removeBook(ctx: Context) = + manage(ctx) { body, publisher -> + val book = Book.findById(body.id) ?: throw NotFoundResponse("Book not found.") + book.publisher = null + } +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/api/RoleApiRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/api/RoleApiRouter.kt new file mode 100644 index 00000000..38c96ae7 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/api/RoleApiRouter.kt @@ -0,0 +1,91 @@ +package github.buriedincode.routers.api + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.Creator +import github.buriedincode.models.Credit +import github.buriedincode.models.Role +import github.buriedincode.models.RoleInput +import github.buriedincode.tables.CreditTable +import github.buriedincode.tables.RoleTable +import io.javalin.http.ConflictResponse +import io.javalin.http.Context +import io.javalin.http.HttpStatus +import io.javalin.http.NotFoundResponse +import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.selectAll + +object RoleApiRouter : BaseApiRouter(entity = Role) { + override fun list(ctx: Context): Unit = transaction { + val query = RoleTable.selectAll() + ctx.queryParam("book-id")?.toLongOrNull()?.let { + Book.findById(it)?.let { book -> query.andWhere { CreditTable.bookCol eq book.id } } + } + ctx.queryParam("creator-id")?.toLongOrNull()?.let { + Creator.findById(it)?.let { creator -> query.andWhere { CreditTable.creatorCol eq creator.id } } + } + ctx.queryParam("title")?.let { title -> query.andWhere { RoleTable.titleCol like "%$title%" } } + ctx.json(Role.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) + } + + override fun create(ctx: Context) = + ctx.processInput { body -> + transaction { + Role.find(body.title)?.let { throw ConflictResponse("Role already exists") } + val resource = + Role.findOrCreate(body.title).apply { + body.credits.forEach { + Credit.new { + this.book = Book.findById(it.book) ?: throw NotFoundResponse("Book not found.") + this.creator = Creator.findById(it.creator) ?: throw NotFoundResponse("Creator not found.") + this.role = this@apply + } + } + } + ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) + } + } + + override fun update(ctx: Context) = + manage(ctx) { body, role -> + Role.find(body.title)?.takeIf { it != role }?.let { throw ConflictResponse("Role already exists") } + role.apply { + credits.forEach { it.delete() } + body.credits.forEach { + Credit.findOrCreate( + Book.findById(it.book) ?: throw NotFoundResponse("Book not found."), + Creator.findById(it.creator) ?: throw NotFoundResponse("Creator not found."), + this, + ) + } + title = body.title + } + } + + override fun delete(ctx: Context) = transaction { + ctx.getResource().apply { + credits.forEach { it.delete() } + delete() + } + ctx.status(HttpStatus.NO_CONTENT) + } + + fun addCredit(ctx: Context) = + manage(ctx) { body, role -> + Credit.findOrCreate( + Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), + Creator.findById(body.creator) ?: throw NotFoundResponse("Creator not found."), + role, + ) + } + + fun removeCredit(ctx: Context) = + manage(ctx) { body, role -> + Credit.findOrCreate( + Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), + Creator.findById(body.creator) ?: throw NotFoundResponse("Creator not found."), + role, + ) + ?.delete() + } +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/api/SeriesApiRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/api/SeriesApiRouter.kt new file mode 100644 index 00000000..cd939f3c --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/api/SeriesApiRouter.kt @@ -0,0 +1,79 @@ +package github.buriedincode.routers.api + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.BookSeries +import github.buriedincode.models.IdInput +import github.buriedincode.models.Series +import github.buriedincode.models.SeriesInput +import github.buriedincode.tables.BookSeriesTable +import github.buriedincode.tables.SeriesTable +import io.javalin.http.ConflictResponse +import io.javalin.http.Context +import io.javalin.http.HttpStatus +import io.javalin.http.NotFoundResponse +import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.selectAll + +object SeriesApiRouter : BaseApiRouter(entity = Series) { + override fun list(ctx: Context): Unit = transaction { + val query = SeriesTable.selectAll() + ctx.queryParam("book-id")?.toLongOrNull()?.let { + Book.findById(it)?.let { book -> query.andWhere { BookSeriesTable.bookCol eq book.id } } + } + ctx.queryParam("title")?.let { title -> query.andWhere { SeriesTable.titleCol like "%$title%" } } + ctx.json(Series.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) + } + + override fun create(ctx: Context) = + ctx.processInput { body -> + transaction { + Series.find(body.title)?.let { throw ConflictResponse("Series already exists") } + val resource = + Series.findOrCreate(body.title).apply { + body.books.forEach { + BookSeries.new { + this.book = Book.findById(it.book) ?: throw NotFoundResponse("Book not found.") + this.series = this@apply + this.number = if (it.number == 0) null else it.number + } + } + } + ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) + } + } + + override fun update(ctx: Context) = + manage(ctx) { body, series -> + Series.find(body.title)?.takeIf { it != series }?.let { throw ConflictResponse("Series already exists") } + series.apply { + books.forEach { it.delete() } + body.books.forEach { + BookSeries.findOrCreate(Book.findById(it.book) ?: throw NotFoundResponse("Book not found."), series).apply { + number = if (it.number == 0) null else it.number + } + } + title = body.title + } + } + + override fun delete(ctx: Context) = transaction { + ctx.getResource().apply { + books.forEach { it.delete() } + delete() + } + ctx.status(HttpStatus.NO_CONTENT) + } + + fun addBook(ctx: Context) = + manage(ctx) { body, series -> + BookSeries.findOrCreate(Book.findById(body.book) ?: throw NotFoundResponse("Book not found."), series).apply { + number = if (body.number == 0) null else body.number + } + } + + fun removeBook(ctx: Context) = + manage(ctx) { body, series -> + BookSeries.findOrCreate(Book.findById(body.id) ?: throw NotFoundResponse("Book not found."), series)?.delete() + } +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/api/UserApiRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/api/UserApiRouter.kt new file mode 100644 index 00000000..9123c32b --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/api/UserApiRouter.kt @@ -0,0 +1,108 @@ +package github.buriedincode.routers.api + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.IdInput +import github.buriedincode.models.ReadBook +import github.buriedincode.models.User +import github.buriedincode.models.UserInput +import github.buriedincode.tables.UserTable +import io.javalin.http.BadRequestResponse +import io.javalin.http.ConflictResponse +import io.javalin.http.Context +import io.javalin.http.HttpStatus +import io.javalin.http.NotFoundResponse +import org.jetbrains.exposed.sql.SizedCollection +import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.selectAll + +object UserApiRouter : BaseApiRouter(entity = User) { + override fun list(ctx: Context): Unit = transaction { + val query = UserTable.selectAll() + ctx.queryParam("username")?.let { username -> query.andWhere { UserTable.usernameCol like "%$username%" } } + ctx.json(User.wrapRows(query.withDistinct()).toList().sorted().map { it.toJson() }) + } + + override fun create(ctx: Context) = + ctx.processInput { body -> + transaction { + User.findOrNull(body.username)?.let { throw ConflictResponse("User already exists") } + val resource = + User.new User@{ + body.readBooks.forEach { + ReadBook.new { + this.book = Book.findById(it.book) ?: throw NotFoundResponse("Book not found.") + this.user = this@User + this.readDate = it.readDate + } + } + this.imageUrl = body.imageUrl + this.username = body.username + this.wishedBooks = + SizedCollection(body.wishedBooks.map { Book.findById(it) ?: throw NotFoundResponse("Book not found.") }) + } + ctx.status(HttpStatus.CREATED).json(resource.toJson(showAll = true)) + } + } + + override fun update(ctx: Context) = + manage(ctx) { body, user -> + User.findOrNull(body.username)?.takeIf { it != user }?.let { throw ConflictResponse("User already exists") } + user.apply { + imageUrl = body.imageUrl + readBooks.forEach { it.delete() } + body.readBooks.forEach { + ReadBook.findOrCreate(Book.findById(it.book) ?: throw NotFoundResponse("Book not found."), user).apply { + readDate = it.readDate + } + } + username = body.username + wishedBooks = + SizedCollection(body.wishedBooks.map { Book.findById(it) ?: throw NotFoundResponse("Book not found.") }) + } + } + + override fun delete(ctx: Context) = transaction { + ctx.getResource().apply { + readBooks.forEach { it.delete() } + delete() + } + ctx.status(HttpStatus.NO_CONTENT) + } + + fun addReadBook(ctx: Context) = + manage(ctx) { body, user -> + val book = Book.findById(body.book) ?: throw NotFoundResponse("No Book found.") + if (!book.isCollected) { + throw BadRequestResponse("Book hasn't been collected") + } + ReadBook.findOrCreate(book, user).apply { readDate = body.readDate } + } + + fun removeReadBook(ctx: Context) = + manage(ctx) { body, user -> + val book = Book.findById(body.id) ?: throw NotFoundResponse("No Book found.") + if (!book.isCollected) { + throw BadRequestResponse("Book hasn't been collected") + } + ReadBook.find(book, user)?.delete() + } + + fun addWishedBook(ctx: Context) = + manage(ctx) { body, user -> + val book = Book.findById(body.id) ?: throw NotFoundResponse("No Book found.") + if (book.isCollected) { + throw BadRequestResponse("Book has been collected") + } + user.wishedBooks = SizedCollection(user.wishedBooks + book) + } + + fun removeWishedBook(ctx: Context) = + manage(ctx) { body, user -> + val book = Book.findById(body.id) ?: throw NotFoundResponse("No Book found.") + if (book.isCollected) { + throw BadRequestResponse("Book has been collected") + } + user.wishedBooks = SizedCollection(user.wishedBooks - book) + } +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/html/BaseHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/html/BaseHtmlRouter.kt new file mode 100644 index 00000000..27c56232 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/html/BaseHtmlRouter.kt @@ -0,0 +1,63 @@ +package github.buriedincode.routers.html + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.User +import io.javalin.http.BadRequestResponse +import io.javalin.http.Context +import io.javalin.http.NotFoundResponse +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass + +abstract class BaseHtmlRouter(protected val entity: LongEntityClass, protected val plural: String) { + protected val name: String = entity::class.java.declaringClass.simpleName.lowercase() + protected val paramName: String = "$name-id" + protected val title: String = name.replaceFirstChar(Char::uppercaseChar) + + protected fun Context.getResource(): T = + this.pathParam(paramName).toLongOrNull()?.let { id -> + entity.findById(id) ?: throw NotFoundResponse("$title not found") + } ?: throw BadRequestResponse("Invalid $title Id") + + protected fun Context.getSession(): User? = + this.cookie("bookshelf_session-id")?.toLongOrNull()?.let { User.findById(it) } + + protected fun render( + ctx: Context, + template: String, + model: Map = emptyMap(), + redirect: Boolean = true, + ) { + val session = ctx.getSession() + if (session == null && redirect) { + ctx.redirect("/$plural") + } + ctx.render("templates/$name/$template.kte", mapOf("session" to session) + model) + } + + protected fun renderResource( + ctx: Context, + template: String, + model: Map = emptyMap(), + redirect: Boolean = true, + ) { + render(ctx, template, mapOf("resource" to ctx.getResource()) + model, redirect) + } + + protected open fun filterResources(ctx: Context): List = emptyList() + + protected open fun filters(ctx: Context): Map = emptyMap() + + protected open fun createOptions(): Map = emptyMap() + + protected open fun updateOptions(ctx: Context): Map = emptyMap() + + open fun list(ctx: Context) = transaction { + render(ctx, "list", mapOf("resources" to filterResources(ctx), "filters" to filters(ctx)), redirect = false) + } + + open fun create(ctx: Context) = transaction { render(ctx, "create", createOptions()) } + + open fun view(ctx: Context) = transaction { renderResource(ctx, "view", redirect = false) } + + open fun update(ctx: Context) = transaction { renderResource(ctx, "update", createOptions() + updateOptions(ctx)) } +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/html/BookHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/html/BookHtmlRouter.kt new file mode 100644 index 00000000..4fe9559f --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/html/BookHtmlRouter.kt @@ -0,0 +1,105 @@ +package github.buriedincode.routers.html + +import github.buriedincode.Utils.asEnumOrNull +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.Creator +import github.buriedincode.models.Format +import github.buriedincode.models.Publisher +import github.buriedincode.models.Role +import github.buriedincode.models.Series +import io.javalin.http.Context + +object BookHtmlRouter : BaseHtmlRouter(entity = Book, plural = "books") { + override fun list(ctx: Context) = transaction { + val extras = if (ctx.getSession() == null) mapOf("read" to true, "wished" to true) else emptyMap() + render( + ctx, + "list", + mapOf("resources" to filterResources(ctx), "filters" to filters(ctx), "extras" to extras), + redirect = false, + ) + } + + override fun view(ctx: Context) = transaction { + renderResource( + ctx, + "view", + mapOf("credits" to ctx.getResource().credits.groupBy({ it.role }, { it.creator })), + redirect = false, + ) + } + + fun import(ctx: Context) = transaction { render(ctx, "import") } + + fun search(ctx: Context) = transaction { render(ctx, "search") } + + override fun filterResources(ctx: Context): List { + val isCollected: Boolean? = ctx.queryParam("is-collected")?.lowercase()?.toBooleanStrictOrNull() + var resources = Book.all().toList() + ctx.queryParam("creator-id")?.toLongOrNull()?.let { + Creator.findById(it)?.let { creator -> resources = resources.filter { it.credits.any { it.creator == creator } } } + } + ctx.queryParam("format")?.asEnumOrNull()?.let { format -> + resources = resources.filter { format == it.format } + } + ctx.getSession()?.let { session -> + if (isCollected == true || isCollected == null) { + ctx.queryParam("has-read")?.lowercase()?.toBooleanStrictOrNull()?.let { hasRead -> + resources = resources.filter { session.readBooks.any { it.book == it } == hasRead } + } + } + if (isCollected == false || isCollected == null) { + ctx.queryParam("has-wished")?.lowercase()?.toBooleanStrictOrNull()?.let { hasWished -> + resources = resources.filter { (it in session.wishedBooks) == hasWished } + } + } + } + isCollected?.let { resources = resources.filter { it.isCollected == isCollected!! } } + ctx.queryParam("publisher-id")?.toLongOrNull()?.let { + Publisher.findById(it)?.let { publisher -> resources = resources.filter { publisher == it.publisher } } + } + ctx.queryParam("role-id")?.toLongOrNull()?.let { + Role.findById(it)?.let { role -> resources = resources.filter { it.credits.any { it.role == role } } } + } + ctx.queryParam("series-id")?.toLongOrNull()?.let { + Series.findById(it)?.let { series -> resources = resources.filter { it.series.any { it.series == series } } } + } + ctx.queryParam("title")?.let { title -> + resources = + resources.filter { + it.title.contains(title, ignoreCase = true) || (it.subtitle?.contains(title, ignoreCase = true) == true) + } + } + return resources + } + + override fun filters(ctx: Context): Map = + mapOf( + "creator" to ctx.queryParam("creator-id")?.toLongOrNull()?.let { Creator.findById(it) }, + "format" to ctx.queryParam("format")?.asEnumOrNull(), + "has-read" to ctx.queryParam("has-read")?.lowercase()?.toBooleanStrictOrNull(), + "has-wished" to ctx.queryParam("has-wished")?.lowercase()?.toBooleanStrictOrNull(), + "is-collected" to ctx.queryParam("is-collected")?.lowercase()?.toBooleanStrictOrNull(), + "publisher" to ctx.queryParam("publisher-id")?.toLongOrNull()?.let { Publisher.findById(it) }, + "role" to ctx.queryParam("role-id")?.toLongOrNull()?.let { Role.findById(it) }, + "series" to ctx.queryParam("series-id")?.toLongOrNull()?.let { Series.findById(it) }, + "title" to ctx.queryParam("title"), + ) + + override fun createOptions(): Map = + mapOf( + "formats" to Format.entries.toList(), + "has-read" to listOf(true, false), + "has-wished" to listOf(true, false), + "is-collected" to listOf(true, false), + "publishers" to Publisher.all().toList(), + ) + + override fun updateOptions(ctx: Context): Map = + mapOf( + "creators" to Creator.all().toList(), + "roles" to Role.all().toList(), + "series" to Series.all().filterNot { series -> ctx.getResource().series.any { it.series == series } }.toList(), + ) +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/html/CreatorHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/html/CreatorHtmlRouter.kt new file mode 100644 index 00000000..6a8c2968 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/html/CreatorHtmlRouter.kt @@ -0,0 +1,29 @@ +package github.buriedincode.routers.html + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.Creator +import github.buriedincode.models.Role +import io.github.oshai.kotlinlogging.KotlinLogging +import io.javalin.http.Context +import io.javalin.http.NotImplementedResponse + +object CreatorHtmlRouter : BaseHtmlRouter(entity = Creator, plural = "creators") { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + + override fun list(ctx: Context) = throw NotImplementedResponse() + + override fun create(ctx: Context) = throw NotImplementedResponse() + + override fun view(ctx: Context) = transaction { + renderResource( + ctx, + "view", + mapOf("credits" to ctx.getResource().credits.groupBy({ it.role }, { it.book })), + redirect = false, + ) + } + + override fun updateOptions(ctx: Context): Map = + mapOf("books" to Book.all().toList(), "roles" to Role.all().toList()) +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/html/PublisherHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/html/PublisherHtmlRouter.kt new file mode 100644 index 00000000..b517cd93 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/html/PublisherHtmlRouter.kt @@ -0,0 +1,14 @@ +package github.buriedincode.routers.html + +import github.buriedincode.models.Publisher +import io.github.oshai.kotlinlogging.KotlinLogging +import io.javalin.http.Context +import io.javalin.http.NotImplementedResponse + +object PublisherHtmlRouter : BaseHtmlRouter(entity = Publisher, plural = "publishers") { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + + override fun list(ctx: Context) = throw NotImplementedResponse() + + override fun create(ctx: Context) = throw NotImplementedResponse() +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/html/RoleHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/html/RoleHtmlRouter.kt new file mode 100644 index 00000000..c594a0bb --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/html/RoleHtmlRouter.kt @@ -0,0 +1,29 @@ +package github.buriedincode.routers.html + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.Creator +import github.buriedincode.models.Role +import io.github.oshai.kotlinlogging.KotlinLogging +import io.javalin.http.Context +import io.javalin.http.NotImplementedResponse + +object RoleHtmlRouter : BaseHtmlRouter(entity = Role, plural = "roles") { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + + override fun list(ctx: Context) = throw NotImplementedResponse() + + override fun create(ctx: Context) = throw NotImplementedResponse() + + override fun view(ctx: Context) = transaction { + renderResource( + ctx, + "view", + mapOf("credits" to ctx.getResource().credits.groupBy({ it.creator }, { it.book })), + redirect = false, + ) + } + + override fun updateOptions(ctx: Context): Map = + mapOf("books" to Book.all().toList(), "creators" to Creator.all().toList()) +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/html/SeriesHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/html/SeriesHtmlRouter.kt new file mode 100644 index 00000000..7284e309 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/html/SeriesHtmlRouter.kt @@ -0,0 +1,23 @@ +package github.buriedincode.routers.html + +import github.buriedincode.models.Book +import github.buriedincode.models.Series +import io.github.oshai.kotlinlogging.KotlinLogging +import io.javalin.http.Context + +object SeriesHtmlRouter : BaseHtmlRouter(entity = Series, plural = "series") { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + + override fun filterResources(ctx: Context): List { + var resources = Series.all().toList() + ctx.queryParam("title")?.let { title -> + resources = resources.filter { it.title.contains(title, ignoreCase = true) } + } + return resources + } + + override fun filters(ctx: Context): Map = mapOf("title" to ctx.queryParam("title")) + + override fun updateOptions(ctx: Context): Map = + mapOf("books" to Book.all().filter { book -> ctx.getResource().books.none { it.book == book } }.toList()) +} diff --git a/app/src/main/kotlin/github/buriedincode/routers/html/UserHtmlRouter.kt b/app/src/main/kotlin/github/buriedincode/routers/html/UserHtmlRouter.kt new file mode 100644 index 00000000..cfe98c05 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/routers/html/UserHtmlRouter.kt @@ -0,0 +1,184 @@ +package github.buriedincode.routers.html + +import github.buriedincode.Utils.asEnumOrNull +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Book +import github.buriedincode.models.Creator +import github.buriedincode.models.Format +import github.buriedincode.models.Publisher +import github.buriedincode.models.Role +import github.buriedincode.models.Series +import github.buriedincode.models.User +import github.buriedincode.tables.BookTable +import io.github.oshai.kotlinlogging.KotlinLogging +import io.javalin.http.Context + +object UserHtmlRouter : BaseHtmlRouter(entity = User, plural = "users") { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + + override fun create(ctx: Context) = transaction { render(ctx, "create", createOptions(), redirect = false) } + + override fun view(ctx: Context) = transaction { + val resource = ctx.getResource() + val nextBooks = + resource.readBooks + .flatMap { it.book.series.map { it.series } } + .distinct() + .sorted() + .mapNotNull { + it.books.map { it.book }.sorted().firstOrNull { book -> resource.readBooks.none { it.book == book } } + } + val model = + mapOf( + "stats" to + mapOf( + "wishlist" to resource.wishedBooks.count().toInt(), + "shared" to Book.all().count { !it.isCollected && it.wishers.empty() }, + "unread" to (Book.find { BookTable.isCollectedCol eq true }.count() - resource.readBooks.count()).toInt(), + "read" to resource.readBooks.count().toInt(), + ), + "nextBooks" to nextBooks, + ) + renderResource(ctx, "view", model, redirect = false) + } + + fun wishlist(ctx: Context) = transaction { + val resource = ctx.getResource() + ctx.render( + "templates/book/list.kte", + mapOf( + "session" to ctx.getSession(), + "resources" to filterWishlist(ctx), + "filters" to wishlistFilters(ctx), + "extras" to + mapOf( + "collected" to true, + "read" to true, + "resetUrl" to "/users/${resource.id.value}/wishlist", + "title" to "${resource.username}'s Wishlist", + ), + ), + ) + } + + fun readlist(ctx: Context) = transaction { + val resource = ctx.getResource() + ctx.render( + "templates/book/list.kte", + mapOf( + "session" to ctx.getSession(), + "resources" to filterReadlist(ctx), + "filters" to readlistFilters(ctx), + "extras" to + mapOf( + "collected" to true, + "read" to true, + "resetUrl" to "/users/${resource.id.value}/readlist", + "title" to "${resource.username}'s Readlist", + "wished" to true, + ), + ), + ) + } + + override fun filterResources(ctx: Context): List { + var resources = User.all().toList() + ctx.queryParam("username")?.let { username -> + resources = resources.filter { it.username.contains(username, ignoreCase = true) } + } + return resources + } + + override fun filters(ctx: Context): Map = mapOf("username" to ctx.queryParam("username")) + + override fun updateOptions(ctx: Context): Map = + mapOf( + "readBooks" to + Book.all().filter { it.isCollected }.filterNot { it in ctx.getResource().readBooks.map { it.book } }.toList(), + "wishedBooks" to + Book.all().filterNot { it.isCollected }.filterNot { it in ctx.getResource().wishedBooks }.toList(), + ) + + private fun filterWishlist(ctx: Context): List { + val resource = ctx.getResource() + var resources = + Book.all().filter { resource.wishedBooks.any { book -> it == book } || (!it.isCollected && it.wishers.empty()) } + ctx.queryParam("creator-id")?.toLongOrNull()?.let { + Creator.findById(it)?.let { creator -> resources = resources.filter { it.credits.any { it.creator == creator } } } + } + ctx.queryParam("format")?.asEnumOrNull()?.let { format -> + resources = resources.filter { format == it.format } + } + ctx.queryParam("has-wished")?.lowercase()?.toBooleanStrictOrNull()?.let { hasWished -> + resources = resources.filter { (it in resource.wishedBooks) == hasWished } + } + ctx.queryParam("publisher-id")?.toLongOrNull()?.let { + Publisher.findById(it)?.let { publisher -> resources = resources.filter { publisher == it.publisher } } + } + ctx.queryParam("role-id")?.toLongOrNull()?.let { + Role.findById(it)?.let { role -> resources = resources.filter { it.credits.any { it.role == role } } } + } + ctx.queryParam("series-id")?.toLongOrNull()?.let { + Series.findById(it)?.let { series -> resources = resources.filter { it.series.any { it.series == series } } } + } + ctx.queryParam("title")?.let { title -> + resources = + resources.filter { + it.title.contains(title, ignoreCase = true) || (it.subtitle?.contains(title, ignoreCase = true) == true) + } + } + return resources.toList() + } + + private fun wishlistFilters(ctx: Context): Map = + mapOf( + "creator" to ctx.queryParam("creator-id")?.toLongOrNull()?.let { Creator.findById(it) }, + "format" to ctx.queryParam("format")?.asEnumOrNull(), + "has-read" to false, + "has-wished" to ctx.queryParam("has-wished")?.lowercase()?.toBooleanStrictOrNull(), + "is-collected" to false, + "publisher" to ctx.queryParam("publisher-id")?.toLongOrNull()?.let { Publisher.findById(it) }, + "role" to ctx.queryParam("role-id")?.toLongOrNull()?.let { Role.findById(it) }, + "series" to ctx.queryParam("series-id")?.toLongOrNull()?.let { Series.findById(it) }, + "title" to ctx.queryParam("title"), + ) + + private fun filterReadlist(ctx: Context): List { + var resources = ctx.getResource().readBooks.map { it.book } + ctx.queryParam("creator-id")?.toLongOrNull()?.let { + Creator.findById(it)?.let { creator -> resources = resources.filter { it.credits.any { it.creator == creator } } } + } + ctx.queryParam("format")?.asEnumOrNull()?.let { format -> + resources = resources.filter { format == it.format } + } + ctx.queryParam("publisher-id")?.toLongOrNull()?.let { + Publisher.findById(it)?.let { publisher -> resources = resources.filter { publisher == it.publisher } } + } + ctx.queryParam("role-id")?.toLongOrNull()?.let { + Role.findById(it)?.let { role -> resources = resources.filter { it.credits.any { it.role == role } } } + } + ctx.queryParam("series-id")?.toLongOrNull()?.let { + Series.findById(it)?.let { series -> resources = resources.filter { it.series.any { it.series == series } } } + } + ctx.queryParam("title")?.let { title -> + resources = + resources.filter { + it.title.contains(title, ignoreCase = true) || (it.subtitle?.contains(title, ignoreCase = true) == true) + } + } + return resources.toList() + } + + private fun readlistFilters(ctx: Context): Map = + mapOf( + "creator" to ctx.queryParam("creator-id")?.toLongOrNull()?.let { Creator.findById(it) }, + "format" to ctx.queryParam("format")?.asEnumOrNull(), + "has-read" to true, + "has-wished" to null, + "is-collected" to true, + "publisher" to ctx.queryParam("publisher-id")?.toLongOrNull()?.let { Publisher.findById(it) }, + "role" to ctx.queryParam("role-id")?.toLongOrNull()?.let { Role.findById(it) }, + "series" to ctx.queryParam("series-id")?.toLongOrNull()?.let { Series.findById(it) }, + "title" to ctx.queryParam("title"), + ) +} diff --git a/app/src/main/kotlin/github/buriedincode/bookshelf/services/OpenLibrary.kt b/app/src/main/kotlin/github/buriedincode/services/OpenLibrary.kt similarity index 57% rename from app/src/main/kotlin/github/buriedincode/bookshelf/services/OpenLibrary.kt rename to app/src/main/kotlin/github/buriedincode/services/OpenLibrary.kt index aee09f07..a766cd3d 100644 --- a/app/src/main/kotlin/github/buriedincode/bookshelf/services/OpenLibrary.kt +++ b/app/src/main/kotlin/github/buriedincode/services/OpenLibrary.kt @@ -1,6 +1,7 @@ -package github.buriedincode.bookshelf.services +package github.buriedincode.services -import github.buriedincode.bookshelf.Utils +import github.buriedincode.Utils +import github.buriedincode.openlibrary.OpenLibrary as Session import github.buriedincode.openlibrary.SQLiteCache import github.buriedincode.openlibrary.schemas.Author import github.buriedincode.openlibrary.schemas.Edition @@ -8,20 +9,19 @@ import github.buriedincode.openlibrary.schemas.Resource import github.buriedincode.openlibrary.schemas.SearchResponse import github.buriedincode.openlibrary.schemas.Work import kotlin.io.path.div -import github.buriedincode.openlibrary.OpenLibrary as Session object OpenLibrary { - private val session = Session(cache = SQLiteCache(path = (Utils.CACHE_ROOT / "openlibrary.sqlite"), expiry = 14)) + private val session = Session(cache = SQLiteCache(path = (Utils.CACHE_ROOT / "openlibrary.sqlite"), expiry = 14)) - fun search(title: String): List = session.searchWork(params = mapOf("title" to title)) + fun search(title: String): List = session.searchWork(params = mapOf("title" to title)) - fun getEdition(id: String): Edition = session.getEdition(id = id) + fun getEdition(id: String): Edition = session.getEdition(id = id) - fun getEditionByISBN(isbn: String): Edition = session.getEditionByISBN(isbn = isbn) + fun getEditionByISBN(isbn: String): Edition = session.getEditionByISBN(isbn = isbn) - fun getWork(id: String): Work = session.getWork(id = id) + fun getWork(id: String): Work = session.getWork(id = id) - fun getAuthor(id: String): Author = session.getAuthor(id = id) + fun getAuthor(id: String): Author = session.getAuthor(id = id) } fun Author.getId(): String = this.key.split("/").last() diff --git a/app/src/main/kotlin/github/buriedincode/tables/BookSeriesTable.kt b/app/src/main/kotlin/github/buriedincode/tables/BookSeriesTable.kt new file mode 100644 index 00000000..f80b9ffd --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/BookSeriesTable.kt @@ -0,0 +1,33 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.SchemaUtils + +object BookSeriesTable : LongIdTable(name = "books__series") { + val bookCol: Column> = + reference( + name = "book_id", + foreign = BookTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + val seriesCol: Column> = + reference( + name = "series_id", + foreign = SeriesTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + val numberCol: Column = integer(name = "number").nullable() + + init { + transaction { + uniqueIndex(bookCol, seriesCol) + SchemaUtils.create(this) + } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/BookTable.kt b/app/src/main/kotlin/github/buriedincode/tables/BookTable.kt new file mode 100644 index 00000000..66248cbe --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/BookTable.kt @@ -0,0 +1,41 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import github.buriedincode.models.Format +import kotlinx.datetime.LocalDate +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.kotlin.datetime.date + +object BookTable : LongIdTable(name = "books") { + val formatCol: Column = + enumerationByName(name = "format", length = 16, klass = Format::class).default(defaultValue = Format.PAPERBACK) + val goodreadsCol: Column = text(name = "goodreads_id").nullable() + val googleBooksCol: Column = text(name = "google_books_id").nullable() + val imageUrlCol: Column = text(name = "image_url").nullable() + val isCollectedCol: Column = bool(name = "is_collected").default(defaultValue = false) + val isbnCol: Column = text(name = "isbn").nullable().uniqueIndex() + val libraryThingCol: Column = text(name = "library_thing_id").nullable() + val openLibraryCol: Column = text(name = "open_library_id").nullable().uniqueIndex() + val publishDateCol: Column = date(name = "publish_date").nullable() + val publisherCol: Column?> = + optReference( + name = "publisher_id", + foreign = PublisherTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + val subtitleCol: Column = text(name = "subtitle").nullable() + val summaryCol: Column = text(name = "summary").nullable() + val titleCol: Column = text(name = "title") + + init { + transaction { + uniqueIndex(titleCol, subtitleCol) + SchemaUtils.create(this) + } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/CreatorTable.kt b/app/src/main/kotlin/github/buriedincode/tables/CreatorTable.kt new file mode 100644 index 00000000..fd034c8f --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/CreatorTable.kt @@ -0,0 +1,15 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.SchemaUtils + +object CreatorTable : LongIdTable(name = "creators") { + val imageUrlCol: Column = text(name = "image_url").nullable() + val nameCol: Column = text(name = "name").uniqueIndex() + + init { + transaction { SchemaUtils.create(this) } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/CreditTable.kt b/app/src/main/kotlin/github/buriedincode/tables/CreditTable.kt new file mode 100644 index 00000000..6cf9e862 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/CreditTable.kt @@ -0,0 +1,39 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.SchemaUtils + +object CreditTable : LongIdTable(name = "books__creators__roles") { + val bookCol: Column> = + reference( + name = "book_id", + foreign = BookTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + val creatorCol: Column> = + reference( + name = "creator_id", + foreign = CreatorTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + val roleCol: Column> = + reference( + name = "role_id", + foreign = RoleTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + + init { + transaction { + uniqueIndex(bookCol, creatorCol, roleCol) + SchemaUtils.create(this) + } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/PublisherTable.kt b/app/src/main/kotlin/github/buriedincode/tables/PublisherTable.kt new file mode 100644 index 00000000..1cea0c4f --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/PublisherTable.kt @@ -0,0 +1,14 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.SchemaUtils + +object PublisherTable : LongIdTable(name = "publishers") { + val titleCol: Column = text(name = "title").uniqueIndex() + + init { + transaction { SchemaUtils.create(this) } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/ReadBookTable.kt b/app/src/main/kotlin/github/buriedincode/tables/ReadBookTable.kt new file mode 100644 index 00000000..ab153749 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/ReadBookTable.kt @@ -0,0 +1,35 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import kotlinx.datetime.LocalDate +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.kotlin.datetime.date + +object ReadBookTable : LongIdTable(name = "read_books") { + val bookCol: Column> = + reference( + name = "book_id", + foreign = BookTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + val userCol: Column> = + reference( + name = "user_id", + foreign = UserTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + val readDateCol: Column = date(name = "read_date").nullable() + + init { + transaction { + uniqueIndex(bookCol, userCol) + SchemaUtils.create(this) + } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/RoleTable.kt b/app/src/main/kotlin/github/buriedincode/tables/RoleTable.kt new file mode 100644 index 00000000..0833fa5b --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/RoleTable.kt @@ -0,0 +1,14 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.SchemaUtils + +object RoleTable : LongIdTable(name = "roles") { + val titleCol: Column = text(name = "title").uniqueIndex() + + init { + transaction { SchemaUtils.create(this) } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/SeriesTable.kt b/app/src/main/kotlin/github/buriedincode/tables/SeriesTable.kt new file mode 100644 index 00000000..d1d8fc97 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/SeriesTable.kt @@ -0,0 +1,14 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.SchemaUtils + +object SeriesTable : LongIdTable(name = "series") { + val titleCol: Column = text(name = "title").uniqueIndex() + + init { + transaction { SchemaUtils.create(this) } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/UserTable.kt b/app/src/main/kotlin/github/buriedincode/tables/UserTable.kt new file mode 100644 index 00000000..c7d89f85 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/UserTable.kt @@ -0,0 +1,15 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.SchemaUtils + +object UserTable : LongIdTable(name = "users") { + val imageUrlCol: Column = text(name = "image_url").nullable() + val usernameCol: Column = text(name = "username").uniqueIndex() + + init { + transaction { SchemaUtils.create(this) } + } +} diff --git a/app/src/main/kotlin/github/buriedincode/tables/WishedTable.kt b/app/src/main/kotlin/github/buriedincode/tables/WishedTable.kt new file mode 100644 index 00000000..84778128 --- /dev/null +++ b/app/src/main/kotlin/github/buriedincode/tables/WishedTable.kt @@ -0,0 +1,30 @@ +package github.buriedincode.tables + +import github.buriedincode.Utils.transaction +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.Table + +object WishedTable : Table(name = "wished_books") { + val bookCol: Column> = + reference( + name = "book_id", + foreign = BookTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + val userCol: Column> = + reference( + name = "user_id", + foreign = UserTable, + onUpdate = ReferenceOption.CASCADE, + onDelete = ReferenceOption.CASCADE, + ) + override val primaryKey = PrimaryKey(bookCol, userCol) + + init { + transaction { SchemaUtils.create(this) } + } +} diff --git a/app/src/main/resources/static/js/scripts.js b/app/src/main/resources/static/js/scripts.js index 592a5a97..cda8a358 100644 --- a/app/src/main/resources/static/js/scripts.js +++ b/app/src/main/resources/static/js/scripts.js @@ -38,7 +38,7 @@ function resetForm(page) { function setTheme() { const theme = getCookie("bookshelf_theme"); - document.documentElement.setAttribute("data-theme", theme === "dark" ? "villain" : "hero"); + document.documentElement.setAttribute("data-theme", theme === "dark" ? "dark" : "light"); } function toggleTheme() { diff --git a/build.gradle.kts b/build.gradle.kts index a90eadfb..8cf1bde9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,70 +1,89 @@ -import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask +import com.github.benmanes.gradle.versions.updates.resolutionstrategy.ComponentSelectionWithCurrent plugins { - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlinx.serialization) apply false - alias(libs.plugins.jte) apply false - alias(libs.plugins.shadow) apply false - alias(libs.plugins.ktlint) - alias(libs.plugins.versions) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlinx.serialization) apply false + alias(libs.plugins.jte) apply false + alias(libs.plugins.shadow) apply false + alias(libs.plugins.spotless) + alias(libs.plugins.versions) } println("Kotlin v${KotlinVersion.CURRENT}") + println("Java v${System.getProperty("java.version")}") + println("Arch: ${System.getProperty("os.arch")}") allprojects { - group = "github.buriedincode" - version = "0.4.1" + group = "github.buriedincode" + version = "0.5.0" - repositories { - mavenCentral() - mavenLocal() - } + repositories { + mavenLocal() + mavenCentral() + } - apply(plugin = "org.jlleitschuh.gradle.ktlint") - configure { - version = "1.3.1" + apply(plugin = rootProject.libs.plugins.spotless.get().pluginId) + spotless { + kotlin { + ktfmt().kotlinlangStyle().configure { + it.setMaxWidth(120) + it.setBlockIndent(2) + it.setContinuationIndent(2) + it.setRemoveUnusedImports(true) + it.setManageTrailingCommas(true) + } + } + kotlinGradle { + ktfmt().kotlinlangStyle().configure { + it.setMaxWidth(120) + it.setBlockIndent(2) + it.setContinuationIndent(2) + it.setRemoveUnusedImports(true) + it.setManageTrailingCommas(true) + } } + } } subprojects { - apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = rootProject.libs.plugins.kotlin.jvm.get().pluginId) - dependencies { - implementation(rootProject.libs.kotlin.logging) - implementation(rootProject.libs.kotlinx.datetime) - runtimeOnly(rootProject.libs.log4j2.slf4j2.impl) - runtimeOnly(rootProject.libs.sqlite.jdbc) - } + dependencies { + implementation(rootProject.libs.kotlin.logging) + implementation(rootProject.libs.kotlinx.datetime) - kotlin { - jvmToolchain(17) - } + runtimeOnly(rootProject.libs.log4j2.slf4j2) + runtimeOnly(rootProject.libs.sqlite.jdbc) + } - java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } - } + kotlin { jvmToolchain(21) } + + java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } } fun isNonStable(version: String): Boolean { - val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } - val regex = "^[0-9,.v-]+(-r)?$".toRegex() - val isStable = stableKeyword || regex.matches(version) - return isStable.not() + val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return isStable.not() } -tasks.withType { - gradleReleaseChannel = "current" - resolutionStrategy { - componentSelection { - all { - if (isNonStable(candidate.version) && !isNonStable(currentVersion)) { - reject("Release candidate") - } - } +tasks.withType { + gradleReleaseChannel = "current" + checkForGradleUpdate = true + checkConstraints = false + checkBuildEnvironmentConstraints = false + resolutionStrategy { + componentSelection { + all( + Action { + if (isNonStable(candidate.version) && !isNonStable(currentVersion)) { + reject("Release candidate") + } } + ) } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b364f74..d784caf4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,59 +1,42 @@ [versions] -exposed = "0.56.0" -hoplite = "2.8.2" -jackson = "2.18.1" -javalin = "6.3.0" -jte = "3.1.15" -junit = "5.11.3" -kotlin = "2.0.21" -kotlin-logging = "7.0.0" -kotlinx-datetime = "0.6.1" -kotlinx-serialization = "1.7.3" -ktlint = "12.1.2" -log4j2 = "2.24.2" -postgres = "42.7.4" -shadow = "8.1.1" -sqlite-jdbc = "3.47.1.0" -versions = "0.51.0" +exposed = "0.61.0" +jackson = "2.19.2" +javalin = "6.7.0" +jte = "3.2.1" +kotlin = "2.2.0" [plugins] jte = { id = "gg.jte.gradle", version.ref = "jte" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } -shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" } -versions = { id = "com.github.ben-manes.versions", version.ref = "versions" } +shadow = { id = "com.gradleup.shadow", version = "8.3.8" } +spotless = { id = "com.diffplug.spotless", version = "7.2.1" } +versions = { id = "com.github.ben-manes.versions", version = "0.52.0" } [libraries] -# Common -kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "kotlin-logging" } -kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" } -log4j2-slf4j2-impl = { group = "org.apache.logging.log4j", name = "log4j-slf4j2-impl", version.ref = "log4j2" } -sqlite-jdbc = { group = "org.xerial", name = "sqlite-jdbc", version.ref = "sqlite-jdbc" } - -# App exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" } exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" } exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" } exposed-kotlin-datetime = { group = "org.jetbrains.exposed", name = "exposed-kotlin-datetime", version.ref = "exposed" } -hoplite-core = { group = "com.sksamuel.hoplite", name = "hoplite-core", version.ref = "hoplite" } +hoplite-core = { group = "com.sksamuel.hoplite", name = "hoplite-core", version = "2.9.0" } jackson-databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson" } -jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jackson" } +jackson-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jackson" } +jackson-datatype-jdk8 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jdk8", version.ref = "jackson" } jackson-datatype-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jsr310", version.ref = "jackson" } -javalin-core = { group = "io.javalin", name = "javalin", version.ref = "javalin"} +javalin-core = { group = "io.javalin", name = "javalin", version.ref = "javalin" } javalin-rendering = { group = "io.javalin", name = "javalin-rendering", version.ref = "javalin" } jte-core = { group = "gg.jte", name = "jte", version.ref = "jte" } jte-kotlin = { group = "gg.jte", name = "jte-kotlin", version.ref = "jte" } -postgres = { group = "org.postgresql", name = "postgresql", version.ref = "postgres" } - -# OpenLibrary -junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } -junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } -kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version = "5.13.4" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "1.13.4" } +kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version = "7.0.11" } +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.7.1" } +kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.9.0" } +log4j2-slf4j2 = { group = "org.apache.logging.log4j", name = "log4j-slf4j2-impl", version = "2.25.1" } +sqlite-jdbc = { group = "org.xerial", name = "sqlite-jdbc", version = "3.50.3.0" } [bundles] exposed = ["exposed-core", "exposed-dao", "exposed-jdbc", "exposed-kotlin-datetime"] -jackson = ["jackson-databind", "jackson-module-kotlin", "jackson-datatype-jsr310"] +jackson = ["jackson-databind", "jackson-kotlin", "jackson-datatype-jdk8", "jackson-datatype-jsr310"] javalin = ["javalin-core", "javalin-rendering"] jte = ["jte-core", "jte-kotlin"] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 34943 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

Gn4F+B~IivciU9^t0j-Mgao3 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 DDw4oP diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9355b415..2a84e188 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6d..23d15a93 100755 --- a/gradlew +++ b/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -206,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9d21a218..db3a6ac2 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/openlibrary/build.gradle.kts b/openlibrary/build.gradle.kts index 785927d7..dd1fbcc5 100644 --- a/openlibrary/build.gradle.kts +++ b/openlibrary/build.gradle.kts @@ -1,18 +1,17 @@ plugins { - `java-library` - alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.kotlinx.serialization) + `java-library` } dependencies { - implementation(libs.kotlinx.serialization.json) - testImplementation(libs.junit.jupiter.api) - testRuntimeOnly(libs.junit.jupiter.engine) - testRuntimeOnly(libs.kotlin.reflect) + implementation(libs.kotlinx.serialization) + + testImplementation(libs.junit.jupiter) + + testRuntimeOnly(libs.junit.platform.launcher) } tasks.test { - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - } + useJUnitPlatform() + testLogging { events("passed", "skipped", "failed") } } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/OpenLibrary.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/OpenLibrary.kt index e7366b0c..fa995cd1 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/OpenLibrary.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/OpenLibrary.kt @@ -7,11 +7,6 @@ import github.buriedincode.openlibrary.schemas.Work import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.Level -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNamingStrategy -import kotlinx.serialization.json.jsonObject import java.io.IOException import java.net.URI import java.net.URLEncoder @@ -21,152 +16,147 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.StandardCharsets import java.time.Duration -import kotlin.collections.joinToString -import kotlin.collections.plus -import kotlin.collections.sortedBy -import kotlin.jvm.Throws -import kotlin.let -import kotlin.ranges.until -import kotlin.text.isNotEmpty - -class OpenLibrary( - private val cache: SQLiteCache? = null, - timeout: Duration = Duration.ofSeconds(30), -) { - private val client: HttpClient = HttpClient - .newBuilder() - .followRedirects(HttpClient.Redirect.ALWAYS) - .connectTimeout(timeout) - .build() - - fun encodeURI(endpoint: String, params: Map = emptyMap()): URI { - val encodedParams = params.entries - .sortedBy { it.key } - .joinToString("&") { "${it.key}=${URLEncoder.encode(it.value, StandardCharsets.UTF_8)}" } - return URI.create("$BASE_API$endpoint${if (encodedParams.isNotEmpty()) "?$encodedParams" else ""}") - } +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy +import kotlinx.serialization.json.jsonObject - @Throws(ServiceException::class) - private fun performGetRequest(uri: URI): String { - try { - @Suppress("ktlint:standard:max-line-length", "ktlint:standard:argument-list-wrapping") - val request = HttpRequest - .newBuilder() - .uri(uri) - .setHeader("Accept", "application/json") - .setHeader("User-Agent", "Bookshelf-Openlibrary/0.3.1 (${System.getProperty("os.name")}/${System.getProperty("os.version")}; Kotlin/${KotlinVersion.CURRENT})") - .GET() - .build() - val response = this.client.send(request, HttpResponse.BodyHandlers.ofString()) - val level = when (response.statusCode()) { - in 100 until 200 -> Level.WARN - in 200 until 300 -> Level.DEBUG - in 300 until 400 -> Level.INFO - in 400 until 500 -> Level.WARN - else -> Level.ERROR - } - LOGGER.log(level) { "GET: ${response.statusCode()} - $uri" } - if (response.statusCode() == 200) { - return response.body() - } - - val content = JSON.parseToJsonElement(response.body()).jsonObject - LOGGER.error { content.toString() } - throw ServiceException(content.toString()) - } catch (ioe: IOException) { - throw ServiceException(cause = ioe) - } catch (hcte: HttpConnectTimeoutException) { - throw ServiceException(cause = hcte) - } catch (ie: InterruptedException) { - throw ServiceException(cause = ie) - } catch (se: SerializationException) { - throw ServiceException(cause = se) +class OpenLibrary(private val cache: SQLiteCache? = null, timeout: Duration = Duration.ofSeconds(30)) { + private val client: HttpClient = + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).connectTimeout(timeout).build() + + fun encodeURI(endpoint: String, params: Map = emptyMap()): URI { + val encodedParams = + params.entries + .sortedBy { it.key } + .joinToString("&") { "${it.key}=${URLEncoder.encode(it.value, StandardCharsets.UTF_8)}" } + return URI.create("$BASE_API$endpoint${if (encodedParams.isNotEmpty()) "?$encodedParams" else ""}") + } + + @Throws(ServiceException::class) + private fun performGetRequest(uri: URI): String { + try { + val request = + HttpRequest.newBuilder() + .uri(uri) + .setHeader("Accept", "application/json") + .setHeader( + "User-Agent", + "OpenLibrary/0.5.0 (${System.getProperty("os.name")}/${System.getProperty("os.version")}; Kotlin/${KotlinVersion.CURRENT})", + ) + .GET() + .build() + val response = this.client.send(request, HttpResponse.BodyHandlers.ofString()) + val level = + when (response.statusCode()) { + in 100 until 200 -> Level.WARN + in 200 until 300 -> Level.DEBUG + in 300 until 400 -> Level.INFO + in 400 until 500 -> Level.WARN + else -> Level.ERROR } + LOGGER.log(level) { "GET: ${response.statusCode()} - $uri" } + if (response.statusCode() == 200) { + return response.body() + } + + val content = JSON.parseToJsonElement(response.body()).jsonObject + LOGGER.error { content.toString() } + throw ServiceException(content.toString()) + } catch (ioe: IOException) { + throw ServiceException(cause = ioe) + } catch (hcte: HttpConnectTimeoutException) { + throw ServiceException(cause = hcte) + } catch (ie: InterruptedException) { + throw ServiceException(cause = ie) + } catch (se: SerializationException) { + throw ServiceException(cause = se) } - - @Throws(ServiceException::class) - internal inline fun getRequest(uri: URI): T { - this.cache?.select(url = uri.toString())?.let { - try { - LOGGER.debug { "Using cached response for $uri" } - return JSON.decodeFromString(it) - } catch (se: SerializationException) { - LOGGER.warn(se) { "Unable to deserialize cached response" } - this.cache.delete(url = uri.toString()) - } - } - val response = this.performGetRequest(uri = uri) - this.cache?.insert(url = uri.toString(), response = response) - return try { - JSON.decodeFromString(response) - } catch (se: SerializationException) { - throw ServiceException(cause = se) - } + } + + @Throws(ServiceException::class) + internal inline fun getRequest(uri: URI): T { + this.cache?.select(url = uri.toString())?.let { + try { + LOGGER.debug { "Using cached response for $uri" } + return JSON.decodeFromString(it) + } catch (se: SerializationException) { + LOGGER.warn(se) { "Unable to deserialize cached response" } + this.cache.delete(url = uri.toString()) + } } - - @Throws(ServiceException::class) - fun searchWork(params: Map): List { - val resultList = mutableListOf() - var page = params.getOrDefault("page", "1").toInt() - - do { - val uri = this.encodeURI(endpoint = "/search.json", params = params + ("page" to page.toString())) - val response = this.getRequest>(uri = uri) - resultList.addAll(response.docs) - page++ - } while (response.numFound > resultList.size) - - return resultList + val response = this.performGetRequest(uri = uri) + this.cache?.insert(url = uri.toString(), response = response) + return try { + JSON.decodeFromString(response) + } catch (se: SerializationException) { + throw ServiceException(cause = se) } - - @Throws(ServiceException::class) - fun searchAuthor(params: Map): List { - val resultList = mutableListOf() - var page = params.getOrDefault("page", "1").toInt() - - do { - val uri = this.encodeURI(endpoint = "/search/authors.json", params = params + ("page" to page.toString())) - val response = this.getRequest>(uri = uri) - resultList.addAll(response.docs) - page++ - } while (response.numFound > resultList.size) - - return resultList - } - - @Throws(ServiceException::class) - fun getAuthor(id: String): Author = this.getRequest(uri = this.encodeURI(endpoint = "/author/$id.json")) - - @Throws(ServiceException::class) - fun getEdition(id: String): Edition = this.getRequest(uri = this.encodeURI(endpoint = "/edition/$id.json")) - - @Throws(ServiceException::class) - fun getEditionByISBN(isbn: String): Edition = this.getRequest(uri = this.encodeURI(endpoint = "/isbn/$isbn.json")) - - @Throws(ServiceException::class) - fun getWork(id: String): Work = this.getRequest(uri = this.encodeURI(endpoint = "/work/$id.json")) - - companion object { - @JvmStatic - private val LOGGER = KotlinLogging.logger { } - private const val BASE_API = "https://openlibrary.org" - - @OptIn(ExperimentalSerializationApi::class) - private val JSON: Json = Json { - prettyPrint = true - encodeDefaults = true - namingStrategy = JsonNamingStrategy.SnakeCase - } + } + + @Throws(ServiceException::class) + fun searchWork(params: Map): List { + val resultList = mutableListOf() + var page = params.getOrDefault("page", "1").toInt() + + do { + val uri = this.encodeURI(endpoint = "/search.json", params = params + ("page" to page.toString())) + val response = this.getRequest>(uri = uri) + resultList.addAll(response.docs) + page++ + } while (response.numFound > resultList.size) + + return resultList + } + + @Throws(ServiceException::class) + fun searchAuthor(params: Map): List { + val resultList = mutableListOf() + var page = params.getOrDefault("page", "1").toInt() + + do { + val uri = this.encodeURI(endpoint = "/search/authors.json", params = params + ("page" to page.toString())) + val response = this.getRequest>(uri = uri) + resultList.addAll(response.docs) + page++ + } while (response.numFound > resultList.size) + + return resultList + } + + @Throws(ServiceException::class) + fun getAuthor(id: String): Author = this.getRequest(uri = this.encodeURI(endpoint = "/author/$id.json")) + + @Throws(ServiceException::class) + fun getEdition(id: String): Edition = this.getRequest(uri = this.encodeURI(endpoint = "/edition/$id.json")) + + @Throws(ServiceException::class) + fun getEditionByISBN(isbn: String): Edition = this.getRequest(uri = this.encodeURI(endpoint = "/isbn/$isbn.json")) + + @Throws(ServiceException::class) + fun getWork(id: String): Work = this.getRequest(uri = this.encodeURI(endpoint = "/work/$id.json")) + + companion object { + @JvmStatic private val LOGGER = KotlinLogging.logger {} + private const val BASE_API = "https://openlibrary.org" + + @OptIn(ExperimentalSerializationApi::class) + private val JSON: Json = Json { + prettyPrint = true + encodeDefaults = true + namingStrategy = JsonNamingStrategy.SnakeCase } + } } private fun KLogger.log(level: Level, message: () -> Any?) { - when (level) { - Level.TRACE -> this.trace(message) - Level.DEBUG -> this.debug(message) - Level.INFO -> this.info(message) - Level.WARN -> this.warn(message) - Level.ERROR -> this.error(message) - else -> return - } + when (level) { + Level.TRACE -> this.trace(message) + Level.DEBUG -> this.debug(message) + Level.INFO -> this.info(message) + Level.WARN -> this.warn(message) + Level.ERROR -> this.error(message) + else -> return + } } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/SQLiteCache.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/SQLiteCache.kt index 2a3387f8..d26c5733 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/SQLiteCache.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/SQLiteCache.kt @@ -4,80 +4,76 @@ import java.nio.file.Path import java.sql.Date import java.sql.DriverManager import java.time.LocalDate -import kotlin.use data class SQLiteCache(val path: Path, val expiry: Int? = null) { - private val databaseUrl: String = "jdbc:sqlite:$path" + private val databaseUrl: String = "jdbc:sqlite:$path" - init { - this.createTable() - this.cleanup() - } + init { + this.createTable() + this.cleanup() + } - private fun createTable() { - val query = "CREATE TABLE IF NOT EXISTS queries (url, response, query_date);" - DriverManager.getConnection(this.databaseUrl).use { - it.createStatement().use { - it.execute(query) - } - } - } + private fun createTable() { + val query = "CREATE TABLE IF NOT EXISTS queries (url, response, query_date);" + DriverManager.getConnection(this.databaseUrl).use { it.createStatement().use { it.execute(query) } } + } - fun select(url: String): String? { - val query = if (this.expiry == null) { - "SELECT * FROM queries WHERE url = ?;" - } else { - "SELECT * FROM queries WHERE url = ? and query_date > ?;" + fun select(url: String): String? { + val query = + if (this.expiry == null) { + "SELECT * FROM queries WHERE url = ?;" + } else { + "SELECT * FROM queries WHERE url = ? and query_date > ?;" + } + DriverManager.getConnection(this.databaseUrl).use { + it.prepareStatement(query).use { + it.setString(1, url) + if (this.expiry != null) { + it.setDate(2, Date.valueOf(LocalDate.now().minusDays(this.expiry.toLong()))) } - DriverManager.getConnection(this.databaseUrl).use { - it.prepareStatement(query).use { - it.setString(1, url) - if (this.expiry != null) { - it.setDate(2, Date.valueOf(LocalDate.now().minusDays(this.expiry.toLong()))) - } - it.executeQuery().use { - return it.getString("response") - } - } + it.executeQuery().use { + return it.getString("response") } + } } + } - fun insert(url: String, response: String) { - if (this.select(url = url) != null) { - return - } - val query = "INSERT INTO queries (url, response, query_date) VALUES (?, ?, ?);" - DriverManager.getConnection(this.databaseUrl).use { - it.prepareStatement(query).use { - it.setString(1, url) - it.setString(2, response) - it.setDate(3, Date.valueOf(LocalDate.now())) - it.executeUpdate() - } - } + fun insert(url: String, response: String) { + if (this.select(url = url) != null) { + return } + val query = "INSERT INTO queries (url, response, query_date) VALUES (?, ?, ?);" + DriverManager.getConnection(this.databaseUrl).use { + it.prepareStatement(query).use { + it.setString(1, url) + it.setString(2, response) + it.setDate(3, Date.valueOf(LocalDate.now())) + it.executeUpdate() + } + } + } - fun delete(url: String) { - val query = "DELETE FROM queries WHERE url = ?;" - DriverManager.getConnection(this.databaseUrl).use { - it.prepareStatement(query).use { - it.setString(1, url) - it.executeUpdate() - } - } + fun delete(url: String) { + val query = "DELETE FROM queries WHERE url = ?;" + DriverManager.getConnection(this.databaseUrl).use { + it.prepareStatement(query).use { + it.setString(1, url) + it.executeUpdate() + } } + } - fun cleanup() { - if (this.expiry == null) { - return - } - val query = "DELETE FROM queries WHERE query_date < ?;" - val expiryDate = LocalDate.now().minusDays(this.expiry.toLong()) - DriverManager.getConnection(this.databaseUrl).use { - it.prepareStatement(query).use { - it.setDate(1, Date.valueOf(expiryDate)) - it.executeUpdate() - } - } + fun cleanup() { + if (this.expiry == null) { + return + } + val query = "DELETE FROM queries WHERE query_date < ?;" + val expiryDate = LocalDate.now().minusDays(this.expiry.toLong()) + DriverManager.getConnection(this.databaseUrl).use { + it.prepareStatement(query).use { + it.setDate(1, Date.valueOf(expiryDate)) + it.executeUpdate() + } } + } } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Author.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Author.kt index f76d81fd..e1b3ddb7 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Author.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Author.kt @@ -9,45 +9,41 @@ import kotlinx.serialization.Serializable @Serializable data class Author( - val alternateNames: List = emptyList(), - @Serializable(with = DescriptionSerializer::class) - val bio: String? = null, - val birthDate: String? = null, - val comment: String? = null, - @Serializable(with = LocalDateTimeSerializer::class) - val created: LocalDateTime? = null, - val date: String? = null, - @Serializable(with = LocalDateSerializer::class) - val deathDate: LocalDate? = null, - val entityType: String? = null, - val fullerName: String? = null, - val id: Long? = null, - val key: String, - @Serializable(with = LocalDateTimeSerializer::class) - val lastModified: LocalDateTime?, - val latestRevision: Int? = null, - val links: List = emptyList(), - val location: String? = null, - val name: String, - val personalName: String? = null, - val photos: List = emptyList(), - val remoteIds: RemoteIds? = null, - val revision: Int, - val sourceRecords: List = emptyList(), - val title: String? = null, - val type: Resource, - val wikipedia: String? = null, + val alternateNames: List = emptyList(), + @Serializable(with = DescriptionSerializer::class) val bio: String? = null, + val birthDate: String? = null, + val comment: String? = null, + @Serializable(with = LocalDateTimeSerializer::class) val created: LocalDateTime? = null, + val date: String? = null, + @Serializable(with = LocalDateSerializer::class) val deathDate: LocalDate? = null, + val entityType: String? = null, + val fullerName: String? = null, + val id: Long? = null, + val key: String, + @Serializable(with = LocalDateTimeSerializer::class) val lastModified: LocalDateTime?, + val latestRevision: Int? = null, + val links: List = emptyList(), + val location: String? = null, + val name: String, + val personalName: String? = null, + val photos: List = emptyList(), + val remoteIds: RemoteIds? = null, + val revision: Int, + val sourceRecords: List = emptyList(), + val title: String? = null, + val type: Resource, + val wikipedia: String? = null, ) { - @Serializable - data class RemoteIds( - val amazon: String? = null, - val goodreads: String? = null, - val isni: String? = null, - val librarything: String? = null, - val librivox: String? = null, - val projectGutenberg: String? = null, - val storygraph: String? = null, - val viaf: String? = null, - val wikidata: String? = null, - ) + @Serializable + data class RemoteIds( + val amazon: String? = null, + val goodreads: String? = null, + val isni: String? = null, + val librarything: String? = null, + val librivox: String? = null, + val projectGutenberg: String? = null, + val storygraph: String? = null, + val viaf: String? = null, + val wikidata: String? = null, + ) } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Common.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Common.kt index e725c27b..894cb165 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Common.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Common.kt @@ -2,20 +2,8 @@ package github.buriedincode.openlibrary.schemas import kotlinx.serialization.Serializable -@Serializable -data class Link( - val title: String, - val type: Resource? = null, - val url: String, -) +@Serializable data class Link(val title: String, val type: Resource? = null, val url: String) -@Serializable -data class Resource( - val key: String, -) +@Serializable data class Resource(val key: String) -@Serializable -data class TypedResource( - val type: String, - val value: String, -) +@Serializable data class TypedResource(val type: String, val value: String) diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Edition.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Edition.kt index fb5df099..aada351c 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Edition.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Edition.kt @@ -12,108 +12,92 @@ import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class Edition( - val authors: List = emptyList(), - val byStatement: String? = null, - val classifications: Classification? = null, - val contributions: List = emptyList(), - val contributors: List = emptyList(), - val copyrightDate: String? = null, - val covers: List = emptyList(), - @Serializable(with = LocalDateTimeSerializer::class) - val created: LocalDateTime? = null, - @Serializable(with = DescriptionSerializer::class) - val description: String? = null, - val deweyDecimalClass: List = emptyList(), - val editionName: String? = null, - @Serializable(with = DescriptionSerializer::class) - val firstSentence: String? = null, - val fullTitle: String? = null, - val genres: List = emptyList(), - val iaBoxId: List = emptyList(), - val iaLoadedId: List = emptyList(), - val identifiers: Identifiers? = null, - @JsonNames("isbn_10") - val isbn10: List = emptyList(), - @JsonNames("isbn_13") - val isbn13: List = emptyList(), - val key: String, - val languages: List = emptyList(), - @Serializable(with = LocalDateTimeSerializer::class) - val lastModified: LocalDateTime? = null, - val latestRevision: Int, - val lcClassifications: List = emptyList(), - val lccn: List = emptyList(), - val links: List = emptyList(), - val localId: List = emptyList(), - val location: List = emptyList(), - @Serializable(with = DescriptionSerializer::class) - val notes: String? = null, - val numberOfPages: Int? = null, - val ocaid: String? = null, - val oclcNumber: List = emptyList(), - val oclcNumbers: List = emptyList(), - val otherTitles: List = emptyList(), - val pagination: String? = null, - val physicalDimensions: String? = null, - val physicalFormat: String? = null, - val publishCountry: String? = null, - @Serializable(with = LocalDateSerializer::class) - val publishDate: LocalDate? = null, - val publishPlaces: List = emptyList(), - val publishers: List = emptyList(), - val revision: Int, - val series: List = emptyList(), - val sourceRecords: List = emptyList(), - val subjectPeople: List = emptyList(), - val subjectPlace: List = emptyList(), - val subjectPlaces: List = emptyList(), - val subjects: List = emptyList(), - val subjectTime: List = emptyList(), - val subtitle: String? = null, - val tableOfContents: List = emptyList(), - val translatedFrom: List = emptyList(), - val translationOf: String? = null, - val title: String, - val type: Resource, - val uriDescriptions: List = emptyList(), - val uris: List = emptyList(), - val url: List = emptyList(), - val weight: String? = null, - val works: List = emptyList(), - val workTitle: List = emptyList(), - val workTitles: List = emptyList(), + val authors: List = emptyList(), + val byStatement: String? = null, + val classifications: Classification? = null, + val contributions: List = emptyList(), + val contributors: List = emptyList(), + val copyrightDate: String? = null, + val covers: List = emptyList(), + @Serializable(with = LocalDateTimeSerializer::class) val created: LocalDateTime? = null, + @Serializable(with = DescriptionSerializer::class) val description: String? = null, + val deweyDecimalClass: List = emptyList(), + val editionName: String? = null, + @Serializable(with = DescriptionSerializer::class) val firstSentence: String? = null, + val fullTitle: String? = null, + val genres: List = emptyList(), + val iaBoxId: List = emptyList(), + val iaLoadedId: List = emptyList(), + val identifiers: Identifiers? = null, + @JsonNames("isbn_10") val isbn10: List = emptyList(), + @JsonNames("isbn_13") val isbn13: List = emptyList(), + val key: String, + val languages: List = emptyList(), + @Serializable(with = LocalDateTimeSerializer::class) val lastModified: LocalDateTime? = null, + val latestRevision: Int, + val lcClassifications: List = emptyList(), + val lccn: List = emptyList(), + val links: List = emptyList(), + val localId: List = emptyList(), + val location: List = emptyList(), + @Serializable(with = DescriptionSerializer::class) val notes: String? = null, + val numberOfPages: Int? = null, + val ocaid: String? = null, + val oclcNumber: List = emptyList(), + val oclcNumbers: List = emptyList(), + val otherTitles: List = emptyList(), + val pagination: String? = null, + val physicalDimensions: String? = null, + val physicalFormat: String? = null, + val publishCountry: String? = null, + @Serializable(with = LocalDateSerializer::class) val publishDate: LocalDate? = null, + val publishPlaces: List = emptyList(), + val publishers: List = emptyList(), + val revision: Int, + val series: List = emptyList(), + val sourceRecords: List = emptyList(), + val subjectPeople: List = emptyList(), + val subjectPlace: List = emptyList(), + val subjectPlaces: List = emptyList(), + val subjects: List = emptyList(), + val subjectTime: List = emptyList(), + val subtitle: String? = null, + val tableOfContents: List = emptyList(), + val translatedFrom: List = emptyList(), + val translationOf: String? = null, + val title: String, + val type: Resource, + val uriDescriptions: List = emptyList(), + val uris: List = emptyList(), + val url: List = emptyList(), + val weight: String? = null, + val works: List = emptyList(), + val workTitle: List = emptyList(), + val workTitles: List = emptyList(), ) { - @Serializable - data class Classification( - val key: String? = null, - ) + @Serializable data class Classification(val key: String? = null) - @Serializable - data class Content( - val label: String? = null, - val level: Int, - val pagenum: String? = null, - val title: String, - val type: Resource? = null, - ) + @Serializable + data class Content( + val label: String? = null, + val level: Int, + val pagenum: String? = null, + val title: String, + val type: Resource? = null, + ) - @Serializable - data class Contributor( - val name: String, - val role: String, - ) + @Serializable data class Contributor(val name: String, val role: String) - @OptIn(ExperimentalSerializationApi::class) - @Serializable - data class Identifiers( - val amazon: List = emptyList(), - @JsonNames("amazon.co.uk_asin") - val amazonCoUkAsin: List = emptyList(), - val betterWorldBooks: List = emptyList(), - val goodreads: List = emptyList(), - val google: List = emptyList(), - val issn: List = emptyList(), - val librarything: List = emptyList(), - val wikidata: List = emptyList(), - ) + @OptIn(ExperimentalSerializationApi::class) + @Serializable + data class Identifiers( + val amazon: List = emptyList(), + @JsonNames("amazon.co.uk_asin") val amazonCoUkAsin: List = emptyList(), + val betterWorldBooks: List = emptyList(), + val goodreads: List = emptyList(), + val google: List = emptyList(), + val issn: List = emptyList(), + val librarything: List = emptyList(), + val wikidata: List = emptyList(), + ) } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/SearchResponse.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/SearchResponse.kt index 6c7ba998..416ba2f1 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/SearchResponse.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/SearchResponse.kt @@ -9,135 +9,113 @@ import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class SearchResponse( - val docs: List = listOf(), - @JsonNames("numFound") - val numFound: Int, - @JsonNames("numFoundExact") - val numFoundExact: Boolean, - val offset: Int? = null, - val q: String? = null, - val start: Int, + val docs: List = listOf(), + @JsonNames("numFound") val numFound: Int, + @JsonNames("numFoundExact") val numFoundExact: Boolean, + val offset: Int? = null, + val q: String? = null, + val start: Int, ) { - @OptIn(ExperimentalSerializationApi::class) - @Serializable - data class Author( - val alternateNames: List = emptyList(), - @JsonNames("birth_date") - @Serializable(with = LocalDateSerializer::class) - val dateOfBirth: LocalDate? = null, - val date: String? = null, - @JsonNames("death_date") - @Serializable(with = LocalDateSerializer::class) - val dateOfDeath: LocalDate? = null, - val key: String, - val name: String, - val topSubjects: List = emptyList(), - val topWork: String? = null, - val type: String, - val workCount: Int, - @JsonNames("_version_") - val version: Long, - ) + @OptIn(ExperimentalSerializationApi::class) + @Serializable + data class Author( + val alternateNames: List = emptyList(), + @JsonNames("birth_date") @Serializable(with = LocalDateSerializer::class) val dateOfBirth: LocalDate? = null, + val date: String? = null, + @JsonNames("death_date") @Serializable(with = LocalDateSerializer::class) val dateOfDeath: LocalDate? = null, + val key: String, + val name: String, + val topSubjects: List = emptyList(), + val topWork: String? = null, + val type: String, + val workCount: Int, + @JsonNames("_version_") val version: Long, + ) - @OptIn(ExperimentalSerializationApi::class) - @Serializable - data class Work( - val alreadyReadCount: Int? = null, - val authorAlternativeName: List = emptyList(), - val authorFacet: List = emptyList(), - val authorKey: List = emptyList(), - val authorName: List = emptyList(), - val contributor: List = emptyList(), - val coverEditionKey: String? = null, - @JsonNames("cover_i") - val cover: Long? = null, - val currentlyReadingCount: Int? = null, - val ddc: List = emptyList(), - val ddcSort: String? = null, - val ebookAccess: String, - @JsonNames("ebook_count_i") - val ebookCount: Long, - val editionCount: Int, - val editionKey: List = emptyList(), - val firstPublishYear: Int? = null, - val firstSentence: List = emptyList(), - val format: List = emptyList(), - val hasFulltext: Boolean, - val ia: List = emptyList(), - val iaBoxId: List = emptyList(), - val iaCollection: List = emptyList(), - @JsonNames("ia_collection_s") - val iaCollectionString: String? = null, - val iaLoadedId: List = emptyList(), - val idAmazon: List = emptyList(), - val idBetterWorldBooks: List = emptyList(), - @JsonNames("id_depósito_legal") - val idDepositoLegal: List = emptyList(), - val idGoodreads: List = emptyList(), - val idGoogle: List = emptyList(), - val idIsfdb: List = emptyList(), - val idLibrarything: List = emptyList(), - val idOverdrive: List = emptyList(), - val idProjectGutenberg: List = emptyList(), - val idWikidata: List = emptyList(), - val isbn: List = emptyList(), - val key: String, - val language: List = emptyList(), - @JsonNames("last_modified_i") - val lastModified: Long, - val lcc: List = emptyList(), - val lccn: List = emptyList(), - val lccSort: String? = null, - @JsonNames("lending_edition_s") - val lendingEdition: String? = null, - @JsonNames("lending_identifier_s") - val lendingIdentifier: String? = null, - val numberOfPagesMedian: Int? = null, - val oclc: List = emptyList(), - val ospCount: Int? = null, - val person: List = emptyList(), - val personFacet: List = emptyList(), - val personKey: List = emptyList(), - val place: List = emptyList(), - val placeFacet: List = emptyList(), - val placeKey: List = emptyList(), - @JsonNames("printdisabled_s") - val printDisabled: String? = null, - @JsonNames("public_scan_b") - val publicScan: Boolean, - val publishDate: List = emptyList(), - val publishPlace: List = emptyList(), - val publishYear: List = emptyList(), - val publisher: List = emptyList(), - val publisherFacet: List = emptyList(), - val ratingsAverage: Double? = null, - val ratingsCount: Int? = null, - @JsonNames("ratings_count_1") - val oneStarRatings: Int? = null, - @JsonNames("ratings_count_2") - val twoStarRatings: Int? = null, - @JsonNames("ratings_count_3") - val threeStarRatings: Int? = null, - @JsonNames("ratings_count_4") - val fourStarRatings: Int? = null, - @JsonNames("ratings_count_5") - val fiveStarRatings: Int? = null, - val ratingsSortable: Double? = null, - val readinglogCount: Int? = null, - val seed: List = emptyList(), - val subject: List = emptyList(), - val subjectFacet: List = emptyList(), - val subjectKey: List = emptyList(), - val subtitle: String? = null, - val time: List = emptyList(), - val timeFacet: List = emptyList(), - val timeKey: List = emptyList(), - val title: String, - val titleSort: String, - val titleSuggest: String, - val type: String, - @JsonNames("_version_") - val version: Long, - val wantToReadCount: Int? = null, - ) + @OptIn(ExperimentalSerializationApi::class) + @Serializable + data class Work( + val alreadyReadCount: Int? = null, + val authorAlternativeName: List = emptyList(), + val authorFacet: List = emptyList(), + val authorKey: List = emptyList(), + val authorName: List = emptyList(), + val contributor: List = emptyList(), + val coverEditionKey: String? = null, + @JsonNames("cover_i") val cover: Long? = null, + val currentlyReadingCount: Int? = null, + val ddc: List = emptyList(), + val ddcSort: String? = null, + val ebookAccess: String, + @JsonNames("ebook_count_i") val ebookCount: Long, + val editionCount: Int, + val editionKey: List = emptyList(), + val firstPublishYear: Int? = null, + val firstSentence: List = emptyList(), + val format: List = emptyList(), + val hasFulltext: Boolean, + val ia: List = emptyList(), + val iaBoxId: List = emptyList(), + val iaCollection: List = emptyList(), + @JsonNames("ia_collection_s") val iaCollectionString: String? = null, + val iaLoadedId: List = emptyList(), + val idAmazon: List = emptyList(), + val idBetterWorldBooks: List = emptyList(), + @JsonNames("id_depósito_legal") val idDepositoLegal: List = emptyList(), + val idGoodreads: List = emptyList(), + val idGoogle: List = emptyList(), + val idIsfdb: List = emptyList(), + val idLibrarything: List = emptyList(), + val idOverdrive: List = emptyList(), + val idProjectGutenberg: List = emptyList(), + val idWikidata: List = emptyList(), + val isbn: List = emptyList(), + val key: String, + val language: List = emptyList(), + @JsonNames("last_modified_i") val lastModified: Long, + val lcc: List = emptyList(), + val lccn: List = emptyList(), + val lccSort: String? = null, + @JsonNames("lending_edition_s") val lendingEdition: String? = null, + @JsonNames("lending_identifier_s") val lendingIdentifier: String? = null, + val numberOfPagesMedian: Int? = null, + val oclc: List = emptyList(), + val ospCount: Int? = null, + val person: List = emptyList(), + val personFacet: List = emptyList(), + val personKey: List = emptyList(), + val place: List = emptyList(), + val placeFacet: List = emptyList(), + val placeKey: List = emptyList(), + @JsonNames("printdisabled_s") val printDisabled: String? = null, + @JsonNames("public_scan_b") val publicScan: Boolean, + val publishDate: List = emptyList(), + val publishPlace: List = emptyList(), + val publishYear: List = emptyList(), + val publisher: List = emptyList(), + val publisherFacet: List = emptyList(), + val ratingsAverage: Double? = null, + val ratingsCount: Int? = null, + @JsonNames("ratings_count_1") val oneStarRatings: Int? = null, + @JsonNames("ratings_count_2") val twoStarRatings: Int? = null, + @JsonNames("ratings_count_3") val threeStarRatings: Int? = null, + @JsonNames("ratings_count_4") val fourStarRatings: Int? = null, + @JsonNames("ratings_count_5") val fiveStarRatings: Int? = null, + val ratingsSortable: Double? = null, + val readinglogCount: Int? = null, + val seed: List = emptyList(), + val subject: List = emptyList(), + val subjectFacet: List = emptyList(), + val subjectKey: List = emptyList(), + val subtitle: String? = null, + val time: List = emptyList(), + val timeFacet: List = emptyList(), + val timeKey: List = emptyList(), + val title: String, + val titleSort: String, + val titleSuggest: String, + val type: String, + @JsonNames("_version_") val version: Long, + val wantToReadCount: Int? = null, + ) } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Work.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Work.kt index 7051982f..e9352ebe 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Work.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/schemas/Work.kt @@ -9,51 +9,34 @@ import kotlinx.serialization.Serializable @Serializable data class Work( - val authors: List = emptyList(), - val coverEdition: Resource? = null, - val covers: List = emptyList(), - @Serializable(with = LocalDateTimeSerializer::class) - val created: LocalDateTime?, - @Serializable(with = DescriptionSerializer::class) - val description: String? = null, - val deweyNumber: List = emptyList(), - val excerpts: List = emptyList(), - @Serializable(with = LocalDateSerializer::class) - val firstPublishDate: LocalDate? = null, - val firstSentence: TypedResource? = null, - val key: String, - @Serializable(with = LocalDateTimeSerializer::class) - val lastModified: LocalDateTime?, - val latestRevision: Int, - val lcClassifications: List = emptyList(), - val links: List = emptyList(), - val location: String? = null, - val revision: Int, - val series: Series? = null, - val subjectPeople: List = emptyList(), - val subjectPlaces: List = emptyList(), - val subjectTimes: List = emptyList(), - val subjects: List = emptyList(), - val subtitle: String? = null, - val title: String, - val type: Resource, + val authors: List = emptyList(), + val coverEdition: Resource? = null, + val covers: List = emptyList(), + @Serializable(with = LocalDateTimeSerializer::class) val created: LocalDateTime?, + @Serializable(with = DescriptionSerializer::class) val description: String? = null, + val deweyNumber: List = emptyList(), + val excerpts: List = emptyList(), + @Serializable(with = LocalDateSerializer::class) val firstPublishDate: LocalDate? = null, + val firstSentence: TypedResource? = null, + val key: String, + @Serializable(with = LocalDateTimeSerializer::class) val lastModified: LocalDateTime?, + val latestRevision: Int, + val lcClassifications: List = emptyList(), + val links: List = emptyList(), + val location: String? = null, + val revision: Int, + val series: Series? = null, + val subjectPeople: List = emptyList(), + val subjectPlaces: List = emptyList(), + val subjectTimes: List = emptyList(), + val subjects: List = emptyList(), + val subtitle: String? = null, + val title: String, + val type: Resource, ) { - @Serializable - data class Author( - val author: Resource, - val type: Resource, - ) + @Serializable data class Author(val author: Resource, val type: Resource) - @Serializable - data class Excerpt( - val author: Resource? = null, - val comment: String? = null, - val excerpt: String, - ) + @Serializable data class Excerpt(val author: Resource? = null, val comment: String? = null, val excerpt: String) - @Serializable - data class Series( - val name: String, - val work: String, - ) + @Serializable data class Series(val name: String, val work: String) } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/DescriptionSerializer.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/DescriptionSerializer.kt index d650b0c1..26f64575 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/DescriptionSerializer.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/DescriptionSerializer.kt @@ -14,21 +14,21 @@ import kotlinx.serialization.json.jsonPrimitive @OptIn(ExperimentalSerializationApi::class) object DescriptionSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Description", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Description", PrimitiveKind.STRING) - override fun deserialize(decoder: Decoder): String? { - return when (val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer())) { - is JsonObject -> jsonElement["value"]?.jsonPrimitive?.content - is JsonPrimitive -> jsonElement.content - else -> null - } + override fun deserialize(decoder: Decoder): String? { + return when (val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer())) { + is JsonObject -> jsonElement["value"]?.jsonPrimitive?.content + is JsonPrimitive -> jsonElement.content + else -> null } + } - override fun serialize(encoder: Encoder, value: String?) { - if (value != null) { - encoder.encodeString(value) - } else { - encoder.encodeNull() - } + override fun serialize(encoder: Encoder, value: String?) { + if (value != null) { + encoder.encodeString(value) + } else { + encoder.encodeNull() } + } } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/LocalDateSerializer.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/LocalDateSerializer.kt index d53a319a..02fcb19d 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/LocalDateSerializer.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/LocalDateSerializer.kt @@ -1,5 +1,9 @@ package github.buriedincode.openlibrary.serializers +import io.github.oshai.kotlinlogging.KotlinLogging +import java.time.format.DateTimeFormatterBuilder +import java.time.temporal.ChronoField +import java.util.Locale import kotlinx.datetime.LocalDate import kotlinx.datetime.toKotlinLocalDate import kotlinx.serialization.ExperimentalSerializationApi @@ -11,52 +15,46 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive -import java.time.format.DateTimeFormatterBuilder -import java.time.temporal.ChronoField @OptIn(ExperimentalSerializationApi::class) object LocalDateSerializer : KSerializer { - private val formatter = DateTimeFormatterBuilder() - .appendOptional(DateTimeFormatterBuilder().appendPattern("dd MMM yyyy").toFormatter()) - .appendOptional(DateTimeFormatterBuilder().appendPattern("yyyy-MM-dd").toFormatter()) - .appendOptional(DateTimeFormatterBuilder().appendPattern("MMMM d, yyyy").toFormatter()) - .appendOptional(DateTimeFormatterBuilder().appendPattern("yyyy-MMM-dd").toFormatter()) - .appendOptional(DateTimeFormatterBuilder().appendPattern("MMM dd, yyyy").toFormatter()) - .appendOptional( - DateTimeFormatterBuilder() - .appendPattern( - "yyyy.", - ).parseDefaulting(ChronoField.MONTH_OF_YEAR, 1) - .parseDefaulting(ChronoField.DAY_OF_MONTH, 1) - .toFormatter(), - ).appendOptional( - DateTimeFormatterBuilder() - .appendPattern( - "yyyy?", - ).parseDefaulting(ChronoField.MONTH_OF_YEAR, 1) - .parseDefaulting(ChronoField.DAY_OF_MONTH, 1) - .toFormatter(), - ).appendOptional( - DateTimeFormatterBuilder() - .appendPattern( - "yyyy", - ).parseDefaulting(ChronoField.MONTH_OF_YEAR, 1) - .parseDefaulting(ChronoField.DAY_OF_MONTH, 1) - .toFormatter(), - ).appendOptional(DateTimeFormatterBuilder().appendPattern("MMMM yyyy").parseDefaulting(ChronoField.DAY_OF_MONTH, 1).toFormatter()) - .appendOptional(DateTimeFormatterBuilder().appendPattern("MMM, yyyy").parseDefaulting(ChronoField.DAY_OF_MONTH, 1).toFormatter()) - .appendOptional(DateTimeFormatterBuilder().appendPattern("d MMMM yyyy").toFormatter()) - .appendOptional(DateTimeFormatterBuilder().appendPattern("dd/MM/yyyy").toFormatter()) - .toFormatter() + @JvmStatic private val LOGGER = KotlinLogging.logger {} + private val patterns = + setOf( + "dd MMM yyyy", + "yyyy-MM-dd", + "MMMM d, yyyy", + "yyyy-MMM-dd", + "MMM dd, yyyy", + "d MMMM yyyy", + "dd/MM/yyyy", + "yyyy.", + "yyyy?", + "yyyy", + "MMMM yyyy", + "MMM, yyyy", + ) - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING) - override fun deserialize(decoder: Decoder): LocalDate? { - val dateString = (decoder.decodeSerializableValue(JsonElement.serializer()) as? JsonPrimitive)?.content - return dateString?.let { java.time.LocalDate.parse(it, formatter).toKotlinLocalDate() } + override fun deserialize(decoder: Decoder): LocalDate? { + val dateString = + (decoder.decodeSerializableValue(JsonElement.serializer()) as? JsonPrimitive)?.content ?: return null + patterns.forEach { pattern -> + var builder = DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern(pattern) + if (!pattern.contains("d")) builder = builder.parseDefaulting(ChronoField.DAY_OF_MONTH, 1) + if (!pattern.contains("M")) builder = builder.parseDefaulting(ChronoField.MONTH_OF_YEAR, 1) + val formatter = builder.toFormatter(Locale.ENGLISH) + try { + LOGGER.debug { "Parsing date string: '$dateString' with pattern: '$pattern'" } + return java.time.LocalDate.parse(dateString, formatter).toKotlinLocalDate() + } catch (_: java.time.format.DateTimeParseException) {} } + LOGGER.warn { "Unable to parse date string: '$dateString'" } + return null + } - override fun serialize(encoder: Encoder, value: LocalDate?) { - encoder.encodeNullableSerializableValue(JsonElement.serializer(), value?.toString()?.let { JsonPrimitive(it) }) - } + override fun serialize(encoder: Encoder, value: LocalDate?) { + encoder.encodeNullableSerializableValue(JsonElement.serializer(), value?.toString()?.let { JsonPrimitive(it) }) + } } diff --git a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/LocalDateTimeSerializer.kt b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/LocalDateTimeSerializer.kt index ddc03e86..79e11f13 100644 --- a/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/LocalDateTimeSerializer.kt +++ b/openlibrary/src/main/kotlin/github/buriedincode/openlibrary/serializers/LocalDateTimeSerializer.kt @@ -1,5 +1,9 @@ package github.buriedincode.openlibrary.serializers +import java.time.format.DateTimeFormatterBuilder +import java.time.format.DateTimeParseException +import java.time.temporal.ChronoField +import java.util.Locale import kotlinx.datetime.LocalDateTime import kotlinx.datetime.toKotlinLocalDateTime import kotlinx.serialization.ExperimentalSerializationApi @@ -13,48 +17,46 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonPrimitive -import java.time.format.DateTimeFormatterBuilder -import java.time.format.DateTimeParseException -import java.time.temporal.ChronoField @OptIn(ExperimentalSerializationApi::class) object LocalDateTimeSerializer : KSerializer { - private val formatter = DateTimeFormatterBuilder() - .appendPattern("yyyy-MM-dd") - .optionalStart() - .appendPattern("'T'HH:mm:ss") - .optionalEnd() - .optionalStart() - .appendPattern(" HH:mm:ss") - .optionalEnd() - .optionalStart() - .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) - .optionalEnd() - .toFormatter() + private val formatter = + DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern("yyyy-MM-dd") + .optionalStart() + .appendPattern("'T'HH:mm:ss") + .optionalEnd() + .optionalStart() + .appendPattern(" HH:mm:ss") + .optionalEnd() + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .optionalEnd() + .toFormatter(Locale.ENGLISH) - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) - override fun deserialize(decoder: Decoder): LocalDateTime? { - val dateTimeString = when (val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer())) { - is JsonObject -> jsonElement["value"]?.jsonPrimitive?.content - is JsonPrimitive -> jsonElement.content - else -> null - } ?: return null + override fun deserialize(decoder: Decoder): LocalDateTime? { + val dateTimeString = + when (val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer())) { + is JsonObject -> jsonElement["value"]?.jsonPrimitive?.content + is JsonPrimitive -> jsonElement.content + else -> null + } ?: return null - return try { - java.time.LocalDateTime - .parse(dateTimeString, formatter) - .toKotlinLocalDateTime() - } catch (dtpe: DateTimeParseException) { - throw dtpe - } + return try { + java.time.LocalDateTime.parse(dateTimeString, formatter).toKotlinLocalDateTime() + } catch (dtpe: DateTimeParseException) { + throw dtpe } + } - override fun serialize(encoder: Encoder, value: LocalDateTime?) { - if (value != null) { - encoder.encodeString(value.toString()) - } else { - encoder.encodeNull() - } + override fun serialize(encoder: Encoder, value: LocalDateTime?) { + if (value != null) { + encoder.encodeString(value.toString()) + } else { + encoder.encodeNull() } + } } diff --git a/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/ExceptionsTest.kt b/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/ExceptionsTest.kt index acd097c9..829f15c2 100644 --- a/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/ExceptionsTest.kt +++ b/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/ExceptionsTest.kt @@ -1,32 +1,30 @@ package github.buriedincode.openlibrary +import java.time.Duration +import kotlin.jvm.java import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance.Lifecycle -import java.time.Duration -import kotlin.jvm.java @TestInstance(Lifecycle.PER_CLASS) class ExceptionsTest { - @Nested - inner class Service { - @Test - fun `Test throwing a ServiceException for a 404`() { - val session = OpenLibrary(cache = null) - assertThrows(ServiceException::class.java) { - val uri = session.encodeURI(endpoint = "/invalid") - session.getRequest(uri = uri) - } - } + @Nested + inner class Service { + @Test + fun `Test throwing a ServiceException for a 404`() { + val session = OpenLibrary(cache = null) + assertThrows(ServiceException::class.java) { + val uri = session.encodeURI(endpoint = "/invalid") + session.getRequest(uri = uri) + } + } - @Test - fun `Test throwing a ServiceException for a timeout`() { - val session = OpenLibrary(cache = null, timeout = Duration.ofMillis(1)) - assertThrows(ServiceException::class.java) { - session.getEdition(id = "OL26964454M") - } - } + @Test + fun `Test throwing a ServiceException for a timeout`() { + val session = OpenLibrary(cache = null, timeout = Duration.ofMillis(1)) + assertThrows(ServiceException::class.java) { session.getEdition(id = "OL26964454M") } } + } } diff --git a/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/AuthorTest.kt b/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/AuthorTest.kt index fac88fcf..626e5ccf 100644 --- a/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/AuthorTest.kt +++ b/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/AuthorTest.kt @@ -3,6 +3,7 @@ package github.buriedincode.openlibrary.schemas import github.buriedincode.openlibrary.OpenLibrary import github.buriedincode.openlibrary.SQLiteCache import github.buriedincode.openlibrary.ServiceException +import java.nio.file.Paths import org.junit.jupiter.api.Assertions.assertAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull @@ -13,59 +14,56 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance.Lifecycle -import java.nio.file.Paths @TestInstance(Lifecycle.PER_CLASS) class AuthorTest { - private val session: OpenLibrary + private val session: OpenLibrary - init { - val cache = SQLiteCache(path = Paths.get("cache.sqlite"), expiry = null) - session = OpenLibrary(cache = cache) - } + init { + val cache = SQLiteCache(path = Paths.get("cache.sqlite"), expiry = null) + session = OpenLibrary(cache = cache) + } - @Nested - inner class GetAuthor { - @Test - fun `Test GetAuthor with a valid id`() { - val result = session.getAuthor(id = "OL2993106A") - assertNotNull(result) - assertAll( - { assertEquals(11497441, result.id) }, - { assertEquals("/authors/OL2993106A", result.key) }, - { assertEquals("Riichiro Inagaki", result.name) }, - { assertEquals(1, result.revision) }, - { assertEquals("/type/author", result.type.key) }, - ) - } + @Nested + inner class GetAuthor { + @Test + fun `Test GetAuthor with a valid id`() { + val result = session.getAuthor(id = "OL2993106A") + assertNotNull(result) + assertAll( + { assertEquals(11497441, result.id) }, + { assertEquals("/authors/OL2993106A", result.key) }, + { assertEquals("Riichiro Inagaki", result.name) }, + { assertEquals(1, result.revision) }, + { assertEquals("/type/author", result.type.key) }, + ) + } - @Test - fun `Test GetAuthor with an invalid id`() { - assertThrows(ServiceException::class.java) { - session.getAuthor(id = "-1") - } - } + @Test + fun `Test GetAuthor with an invalid id`() { + assertThrows(ServiceException::class.java) { session.getAuthor(id = "-1") } } + } - @Nested - inner class SearchAuthor { - @Test - fun `Test SearchAuthor with a valid search`() { - val results = session.searchAuthor(params = mapOf("q" to "Riichiro Inagaki")) - assertEquals(5, results.size) - assertAll( - { assertTrue(results[0].alternateNames.isEmpty()) }, - { assertNull(results[0].dateOfBirth) }, - { assertNull(results[0].date) }, - { assertNull(results[0].dateOfDeath) }, - { assertEquals("OL2993106A", results[0].key) }, - { assertEquals("Riichiro Inagaki", results[0].name) }, - { assertEquals("Comic books, strips", results[0].topSubjects[0]) }, - { assertEquals("Eyeshield 21", results[0].topWork) }, - { assertEquals("author", results[0].type) }, - { assertEquals(213, results[0].workCount) }, - { assertEquals(1796011843638001667, results[0].version) }, - ) - } + @Nested + inner class SearchAuthor { + @Test + fun `Test SearchAuthor with a valid search`() { + val results = session.searchAuthor(params = mapOf("q" to "Riichiro Inagaki")) + assertEquals(5, results.size) + assertAll( + { assertTrue(results[0].alternateNames.isEmpty()) }, + { assertNull(results[0].dateOfBirth) }, + { assertNull(results[0].date) }, + { assertNull(results[0].dateOfDeath) }, + { assertEquals("OL2993106A", results[0].key) }, + { assertEquals("Riichiro Inagaki", results[0].name) }, + { assertEquals("Comic books, strips", results[0].topSubjects[0]) }, + { assertEquals("Eyeshield 21", results[0].topWork) }, + { assertEquals("author", results[0].type) }, + { assertEquals(213, results[0].workCount) }, + { assertEquals(1796011843638001667, results[0].version) }, + ) } + } } diff --git a/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/EditionTest.kt b/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/EditionTest.kt index 14011bd6..16eb189a 100644 --- a/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/EditionTest.kt +++ b/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/EditionTest.kt @@ -3,6 +3,7 @@ package github.buriedincode.openlibrary.schemas import github.buriedincode.openlibrary.OpenLibrary import github.buriedincode.openlibrary.SQLiteCache import github.buriedincode.openlibrary.ServiceException +import java.nio.file.Paths import kotlinx.datetime.LocalDate import org.junit.jupiter.api.Assertions.assertAll import org.junit.jupiter.api.Assertions.assertEquals @@ -14,132 +15,127 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance.Lifecycle -import java.nio.file.Paths @TestInstance(Lifecycle.PER_CLASS) class EditionTest { - private val session: OpenLibrary + private val session: OpenLibrary - init { - val cache = SQLiteCache(path = Paths.get("cache.sqlite"), expiry = null) - session = OpenLibrary(cache = cache) - } + init { + val cache = SQLiteCache(path = Paths.get("cache.sqlite"), expiry = null) + session = OpenLibrary(cache = cache) + } - @Nested - inner class GetEdition { - @Test - fun `Test GetEdition with a valid id`() { - val result = session.getEdition(id = "OL26964454M") - assertNotNull(result) - assertAll( - { assertEquals("/authors/OL2993106A", result.authors[0].key) }, - { assertNotNull(result.byStatement) }, - { assertEquals("Boichi, ill", result.contributions[0]) }, - { assertEquals("Boichi", result.contributors[0].name) }, - { assertEquals("Illustrator", result.contributors[0].role) }, - { assertEquals(14577228, result.covers[0]) }, - { assertEquals("741.5", result.deweyDecimalClass[0]) }, - { assertNull(result.fullTitle) }, - { assertTrue(result.identifiers?.goodreads?.isEmpty() ?: false) }, - { assertTrue(result.identifiers?.google?.isEmpty() ?: false) }, - { assertTrue(result.identifiers?.librarything?.isEmpty() ?: false) }, - { assertEquals("1974702618", result.isbn10[0]) }, - { assertEquals("9781974702619", result.isbn13[0]) }, - { assertEquals("/books/OL26964454M", result.key) }, - { assertEquals("/languages/eng", result.languages[0].key) }, - { assertEquals(10, result.latestRevision) }, - { assertEquals("PN6790", result.lcClassifications[0]) }, - { assertEquals("2018299499", result.lccn[0]) }, - { assertEquals("urn:sfpl:31223111921111", result.localId[0]) }, - { assertNotNull(result.notes) }, - { assertEquals(200, result.numberOfPages) }, - { assertEquals("1054104980", result.oclcNumbers[0]) }, - { assertEquals("Stone world", result.otherTitles[0]) }, - { assertEquals("1 v. (unpaged)", result.pagination) }, - { assertEquals("Manga", result.physicalFormat) }, - { assertEquals("cau", result.publishCountry) }, - { assertEquals(LocalDate(2018, 9, 4), result.publishDate) }, - { assertEquals("SHONEN JUMP", result.publishers[0]) }, - { assertEquals(10, result.revision) }, - { - assertEquals( - "marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run06.mrc:171004390:2606", - result.sourceRecords[0], - ) - }, - { assertEquals("Survival", result.subjects[0]) }, - { assertEquals("Stone World", result.subtitle) }, - { assertEquals("/languages/jpn", result.translatedFrom[0].key) }, - { assertEquals("Dr. STONE, Vol. 1", result.title) }, - { assertEquals("/type/edition", result.type.key) }, - { assertEquals("/works/OL37805541W", result.works[0].key) }, - ) - } + @Nested + inner class GetEdition { + @Test + fun `Test GetEdition with a valid id`() { + val result = session.getEdition(id = "OL26964454M") + assertNotNull(result) + assertAll( + { assertEquals("/authors/OL2993106A", result.authors[0].key) }, + { assertNotNull(result.byStatement) }, + { assertEquals("Boichi, ill", result.contributions[0]) }, + { assertEquals("Boichi", result.contributors[0].name) }, + { assertEquals("Illustrator", result.contributors[0].role) }, + { assertEquals(14577228, result.covers[0]) }, + { assertEquals("741.5", result.deweyDecimalClass[0]) }, + { assertNull(result.fullTitle) }, + { assertTrue(result.identifiers?.goodreads?.isEmpty() ?: false) }, + { assertTrue(result.identifiers?.google?.isEmpty() ?: false) }, + { assertTrue(result.identifiers?.librarything?.isEmpty() ?: false) }, + { assertEquals("1974702618", result.isbn10[0]) }, + { assertEquals("9781974702619", result.isbn13[0]) }, + { assertEquals("/books/OL26964454M", result.key) }, + { assertEquals("/languages/eng", result.languages[0].key) }, + { assertEquals(10, result.latestRevision) }, + { assertEquals("PN6790", result.lcClassifications[0]) }, + { assertEquals("2018299499", result.lccn[0]) }, + { assertEquals("urn:sfpl:31223111921111", result.localId[0]) }, + { assertNotNull(result.notes) }, + { assertEquals(200, result.numberOfPages) }, + { assertEquals("1054104980", result.oclcNumbers[0]) }, + { assertEquals("Stone world", result.otherTitles[0]) }, + { assertEquals("1 v. (unpaged)", result.pagination) }, + { assertEquals("Manga", result.physicalFormat) }, + { assertEquals("cau", result.publishCountry) }, + { assertEquals(LocalDate(2018, 9, 4), result.publishDate) }, + { assertEquals("SHONEN JUMP", result.publishers[0]) }, + { assertEquals(10, result.revision) }, + { + assertEquals( + "marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run06.mrc:171004390:2606", + result.sourceRecords[0], + ) + }, + { assertEquals("Survival", result.subjects[0]) }, + { assertEquals("Stone World", result.subtitle) }, + { assertEquals("/languages/jpn", result.translatedFrom[0].key) }, + { assertEquals("Dr. STONE, Vol. 1", result.title) }, + { assertEquals("/type/edition", result.type.key) }, + { assertEquals("/works/OL37805541W", result.works[0].key) }, + ) + } - @Test - fun `Test GetEdition with an invalid id`() { - assertThrows(ServiceException::class.java) { - session.getEdition(id = "-1") - } - } + @Test + fun `Test GetEdition with an invalid id`() { + assertThrows(ServiceException::class.java) { session.getEdition(id = "-1") } } + } - @Nested - inner class GetEditionByISBN { - @Test - fun `Test GetEditionByISBN with a valid id`() { - val result = session.getEditionByISBN(isbn = "9781974702619") - assertNotNull(result) - assertAll( - { assertEquals("/authors/OL2993106A", result.authors[0].key) }, - { assertNotNull(result.byStatement) }, - { assertEquals("Boichi, ill", result.contributions[0]) }, - { assertEquals("Boichi", result.contributors[0].name) }, - { assertEquals("Illustrator", result.contributors[0].role) }, - { assertEquals(14577228, result.covers[0]) }, - { assertEquals("741.5", result.deweyDecimalClass[0]) }, - { assertNull(result.fullTitle) }, - { assertTrue(result.identifiers?.goodreads?.isEmpty() ?: false) }, - { assertTrue(result.identifiers?.google?.isEmpty() ?: false) }, - { assertTrue(result.identifiers?.librarything?.isEmpty() ?: false) }, - { assertEquals("1974702618", result.isbn10[0]) }, - { assertEquals("9781974702619", result.isbn13[0]) }, - { assertEquals("/books/OL26964454M", result.key) }, - { assertEquals("/languages/eng", result.languages[0].key) }, - { assertEquals(10, result.latestRevision) }, - { assertEquals("PN6790", result.lcClassifications[0]) }, - { assertEquals("2018299499", result.lccn[0]) }, - { assertEquals("urn:sfpl:31223111921111", result.localId[0]) }, - { assertNotNull(result.notes) }, - { assertEquals(200, result.numberOfPages) }, - { assertEquals("1054104980", result.oclcNumbers[0]) }, - { assertEquals("Stone world", result.otherTitles[0]) }, - { assertEquals("1 v. (unpaged)", result.pagination) }, - { assertEquals("Manga", result.physicalFormat) }, - { assertEquals("cau", result.publishCountry) }, - { assertEquals(LocalDate(2018, 9, 4), result.publishDate) }, - { assertEquals("SHONEN JUMP", result.publishers[0]) }, - { assertEquals(10, result.revision) }, - { - assertEquals( - "marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run06.mrc:171004390:2606", - result.sourceRecords[0], - ) - }, - { assertEquals("Survival", result.subjects[0]) }, - { assertEquals("Stone World", result.subtitle) }, - { assertEquals("/languages/jpn", result.translatedFrom[0].key) }, - { assertEquals("Dr. STONE, Vol. 1", result.title) }, - { assertEquals("/type/edition", result.type.key) }, - { assertEquals("/works/OL37805541W", result.works[0].key) }, - ) - } + @Nested + inner class GetEditionByISBN { + @Test + fun `Test GetEditionByISBN with a valid id`() { + val result = session.getEditionByISBN(isbn = "9781974702619") + assertNotNull(result) + assertAll( + { assertEquals("/authors/OL2993106A", result.authors[0].key) }, + { assertNotNull(result.byStatement) }, + { assertEquals("Boichi, ill", result.contributions[0]) }, + { assertEquals("Boichi", result.contributors[0].name) }, + { assertEquals("Illustrator", result.contributors[0].role) }, + { assertEquals(14577228, result.covers[0]) }, + { assertEquals("741.5", result.deweyDecimalClass[0]) }, + { assertNull(result.fullTitle) }, + { assertTrue(result.identifiers?.goodreads?.isEmpty() ?: false) }, + { assertTrue(result.identifiers?.google?.isEmpty() ?: false) }, + { assertTrue(result.identifiers?.librarything?.isEmpty() ?: false) }, + { assertEquals("1974702618", result.isbn10[0]) }, + { assertEquals("9781974702619", result.isbn13[0]) }, + { assertEquals("/books/OL26964454M", result.key) }, + { assertEquals("/languages/eng", result.languages[0].key) }, + { assertEquals(10, result.latestRevision) }, + { assertEquals("PN6790", result.lcClassifications[0]) }, + { assertEquals("2018299499", result.lccn[0]) }, + { assertEquals("urn:sfpl:31223111921111", result.localId[0]) }, + { assertNotNull(result.notes) }, + { assertEquals(200, result.numberOfPages) }, + { assertEquals("1054104980", result.oclcNumbers[0]) }, + { assertEquals("Stone world", result.otherTitles[0]) }, + { assertEquals("1 v. (unpaged)", result.pagination) }, + { assertEquals("Manga", result.physicalFormat) }, + { assertEquals("cau", result.publishCountry) }, + { assertEquals(LocalDate(2018, 9, 4), result.publishDate) }, + { assertEquals("SHONEN JUMP", result.publishers[0]) }, + { assertEquals(10, result.revision) }, + { + assertEquals( + "marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run06.mrc:171004390:2606", + result.sourceRecords[0], + ) + }, + { assertEquals("Survival", result.subjects[0]) }, + { assertEquals("Stone World", result.subtitle) }, + { assertEquals("/languages/jpn", result.translatedFrom[0].key) }, + { assertEquals("Dr. STONE, Vol. 1", result.title) }, + { assertEquals("/type/edition", result.type.key) }, + { assertEquals("/works/OL37805541W", result.works[0].key) }, + ) + } - @Test - fun `Test GetEditionByISBN with an invalid id`() { - assertThrows(ServiceException::class.java) { - session.getEditionByISBN(isbn = "-1") - } - } + @Test + fun `Test GetEditionByISBN with an invalid id`() { + assertThrows(ServiceException::class.java) { session.getEditionByISBN(isbn = "-1") } } + } } diff --git a/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/WorkTest.kt b/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/WorkTest.kt index 2b678f9e..345c29aa 100644 --- a/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/WorkTest.kt +++ b/openlibrary/src/test/kotlin/github/buriedincode/openlibrary/schemas/WorkTest.kt @@ -3,6 +3,7 @@ package github.buriedincode.openlibrary.schemas import github.buriedincode.openlibrary.OpenLibrary import github.buriedincode.openlibrary.SQLiteCache import github.buriedincode.openlibrary.ServiceException +import java.nio.file.Paths import org.junit.jupiter.api.Assertions.assertAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -14,133 +15,130 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance.Lifecycle -import java.nio.file.Paths @TestInstance(Lifecycle.PER_CLASS) class WorkTest { - private val session: OpenLibrary + private val session: OpenLibrary - init { - val cache = SQLiteCache(path = Paths.get("cache.sqlite"), expiry = null) - session = OpenLibrary(cache = cache) - } + init { + val cache = SQLiteCache(path = Paths.get("cache.sqlite"), expiry = null) + session = OpenLibrary(cache = cache) + } - @Nested - inner class GetWork { - @Test - fun `Test GetWork with a valid id`() { - val result = session.getWork(id = "OL37805541W") - assertNotNull(result) - assertAll( - { assertEquals("/authors/OL2993106A", result.authors[0].author.key) }, - { assertEquals("/type/author_role", result.authors[0].type.key) }, - { assertTrue(result.covers.isEmpty()) }, - { assertNotNull(result.description) }, - { assertEquals("/works/OL37805541W", result.key) }, - { assertEquals(2, result.latestRevision) }, - { assertEquals(2, result.revision) }, - { assertTrue(result.subjects.isEmpty()) }, - { assertEquals("Dr. STONE, Vol. 1", result.title) }, - { assertEquals("/type/work", result.type.key) }, - ) - } + @Nested + inner class GetWork { + @Test + fun `Test GetWork with a valid id`() { + val result = session.getWork(id = "OL37805541W") + assertNotNull(result) + assertAll( + { assertEquals("/authors/OL2993106A", result.authors[0].author.key) }, + { assertEquals("/type/author_role", result.authors[0].type.key) }, + { assertTrue(result.covers.isEmpty()) }, + { assertNotNull(result.description) }, + { assertEquals("/works/OL37805541W", result.key) }, + { assertEquals(2, result.latestRevision) }, + { assertEquals(2, result.revision) }, + { assertTrue(result.subjects.isEmpty()) }, + { assertEquals("Dr. STONE, Vol. 1", result.title) }, + { assertEquals("/type/work", result.type.key) }, + ) + } - @Test - fun `Test GetWork with an invalid id`() { - assertThrows(ServiceException::class.java) { - session.getWork(id = "-1") - } - } + @Test + fun `Test GetWork with an invalid id`() { + assertThrows(ServiceException::class.java) { session.getWork(id = "-1") } } + } - @Nested - inner class SearchWork { - @Test - fun `Test SearchWork with a valid search`() { - val results = session.searchWork(params = mapOf("title" to "Dr. Stone, Vol. 1")) - assertEquals(1, results.size) - assertAll( - { assertEquals(0, results[0].alreadyReadCount) }, - { assertTrue(results[0].authorAlternativeName.isEmpty()) }, - { assertEquals("OL2993106A Riichiro Inagaki", results[0].authorFacet[0]) }, - { assertEquals("OL2993106A", results[0].authorKey[0]) }, - { assertEquals("Riichiro Inagaki", results[0].authorName[0]) }, - { assertEquals("Cook, Caleb D., translator", results[0].contributor[0]) }, - { assertEquals("OL38630032M", results[0].coverEditionKey) }, - { assertEquals(12821031, results[0].cover) }, - { assertEquals(0, results[0].currentlyReadingCount) }, - { assertEquals("741.5", results[0].ddc[0]) }, - { assertEquals("741.5", results[0].ddcSort) }, - { assertEquals("no_ebook", results[0].ebookAccess) }, - { assertEquals(0, results[0].ebookCount) }, - { assertEquals(5, results[0].editionCount) }, - { assertEquals("OL38630032M", results[0].editionKey[0]) }, - { assertEquals(2018, results[0].firstPublishYear) }, - { assertTrue(results[0].firstSentence.isEmpty()) }, - { assertEquals("Manga", results[0].format[0]) }, - { assertFalse(results[0].hasFulltext) }, - { assertTrue(results[0].ia.isEmpty()) }, - { assertTrue(results[0].iaBoxId.isEmpty()) }, - { assertTrue(results[0].iaCollection.isEmpty()) }, - { assertNull(results[0].iaCollectionString) }, - { assertTrue(results[0].iaLoadedId.isEmpty()) }, - { assertTrue(results[0].idAmazon.isEmpty()) }, - { assertTrue(results[0].idGoodreads.isEmpty()) }, - { assertTrue(results[0].idGoogle.isEmpty()) }, - { assertTrue(results[0].idLibrarything.isEmpty()) }, - { assertTrue(results[0].idOverdrive.isEmpty()) }, - { assertTrue(results[0].idProjectGutenberg.isEmpty()) }, - { assertTrue(results[0].idWikidata.isEmpty()) }, - { assertTrue(results[0].idDepositoLegal.isEmpty()) }, - { assertTrue(results[0].idIsfdb.isEmpty()) }, - { assertEquals("6076340487", results[0].isbn[0]) }, - { assertEquals("/works/OL37805541W", results[0].key) }, - { assertEquals("spa", results[0].language[0]) }, - { assertEquals(1715902450, results[0].lastModified) }, - { assertEquals("PN-6790.00000000.J34 D7313 2018", results[0].lcc[0]) }, - { assertEquals("2018299499", results[0].lccn[0]) }, - { assertEquals("PN-6790.00000000.J34 D7313 2018", results[0].lccSort) }, - { assertNull(results[0].lendingEdition) }, - { assertNull(results[0].lendingIdentifier) }, - { assertEquals(196, results[0].numberOfPagesMedian) }, - { assertEquals("1054104980", results[0].oclc[0]) }, - { assertNull(results[0].ospCount) }, - { assertTrue(results[0].person.isEmpty()) }, - { assertTrue(results[0].personFacet.isEmpty()) }, - { assertTrue(results[0].personKey.isEmpty()) }, - { assertTrue(results[0].place.isEmpty()) }, - { assertTrue(results[0].placeFacet.isEmpty()) }, - { assertTrue(results[0].placeKey.isEmpty()) }, - { assertNull(results[0].printDisabled) }, - { assertFalse(results[0].publicScan) }, - { assertEquals("2021", results[0].publishDate[0]) }, - { assertTrue(results[0].publishPlace.isEmpty()) }, - { assertEquals(2018, results[0].publishYear[0]) }, - { assertEquals("Panini", results[0].publisher[0]) }, - { assertEquals("Editorial Ivrea", results[0].publisherFacet[0]) }, - { assertNull(results[0].ratingsAverage) }, - { assertNull(results[0].ratingsCount) }, - { assertNull(results[0].oneStarRatings) }, - { assertNull(results[0].twoStarRatings) }, - { assertNull(results[0].threeStarRatings) }, - { assertNull(results[0].fourStarRatings) }, - { assertNull(results[0].fiveStarRatings) }, - { assertNull(results[0].ratingsSortable) }, - { assertEquals(1, results[0].readinglogCount) }, - { assertEquals("/books/OL38630032M", results[0].seed[0]) }, - { assertTrue(results[0].subject.isEmpty()) }, - { assertTrue(results[0].subjectFacet.isEmpty()) }, - { assertTrue(results[0].subjectKey.isEmpty()) }, - { assertTrue(results[0].time.isEmpty()) }, - { assertTrue(results[0].timeFacet.isEmpty()) }, - { assertTrue(results[0].timeKey.isEmpty()) }, - { assertEquals("Dr. STONE, Vol. 1", results[0].title) }, - { assertEquals("Dr. STONE, Vol. 1", results[0].titleSort) }, - { assertEquals("Dr. STONE, Vol. 1", results[0].titleSuggest) }, - { assertEquals("work", results[0].type) }, - { assertEquals(1799254130621939712, results[0].version) }, - { assertEquals(1, results[0].wantToReadCount) }, - ) - } + @Nested + inner class SearchWork { + @Test + fun `Test SearchWork with a valid search`() { + val results = session.searchWork(params = mapOf("title" to "Dr. Stone, Vol. 1")) + assertEquals(1, results.size) + assertAll( + { assertEquals(0, results[0].alreadyReadCount) }, + { assertTrue(results[0].authorAlternativeName.isEmpty()) }, + { assertEquals("OL2993106A Riichiro Inagaki", results[0].authorFacet[0]) }, + { assertEquals("OL2993106A", results[0].authorKey[0]) }, + { assertEquals("Riichiro Inagaki", results[0].authorName[0]) }, + { assertEquals("Cook, Caleb D., translator", results[0].contributor[0]) }, + { assertEquals("OL38630032M", results[0].coverEditionKey) }, + { assertEquals(12821031, results[0].cover) }, + { assertEquals(0, results[0].currentlyReadingCount) }, + { assertEquals("741.5", results[0].ddc[0]) }, + { assertEquals("741.5", results[0].ddcSort) }, + { assertEquals("no_ebook", results[0].ebookAccess) }, + { assertEquals(0, results[0].ebookCount) }, + { assertEquals(5, results[0].editionCount) }, + { assertEquals("OL38630032M", results[0].editionKey[0]) }, + { assertEquals(2018, results[0].firstPublishYear) }, + { assertTrue(results[0].firstSentence.isEmpty()) }, + { assertEquals("Manga", results[0].format[0]) }, + { assertFalse(results[0].hasFulltext) }, + { assertTrue(results[0].ia.isEmpty()) }, + { assertTrue(results[0].iaBoxId.isEmpty()) }, + { assertTrue(results[0].iaCollection.isEmpty()) }, + { assertNull(results[0].iaCollectionString) }, + { assertTrue(results[0].iaLoadedId.isEmpty()) }, + { assertTrue(results[0].idAmazon.isEmpty()) }, + { assertTrue(results[0].idGoodreads.isEmpty()) }, + { assertTrue(results[0].idGoogle.isEmpty()) }, + { assertTrue(results[0].idLibrarything.isEmpty()) }, + { assertTrue(results[0].idOverdrive.isEmpty()) }, + { assertTrue(results[0].idProjectGutenberg.isEmpty()) }, + { assertTrue(results[0].idWikidata.isEmpty()) }, + { assertTrue(results[0].idDepositoLegal.isEmpty()) }, + { assertTrue(results[0].idIsfdb.isEmpty()) }, + { assertEquals("6076340487", results[0].isbn[0]) }, + { assertEquals("/works/OL37805541W", results[0].key) }, + { assertEquals("spa", results[0].language[0]) }, + { assertEquals(1715902450, results[0].lastModified) }, + { assertEquals("PN-6790.00000000.J34 D7313 2018", results[0].lcc[0]) }, + { assertEquals("2018299499", results[0].lccn[0]) }, + { assertEquals("PN-6790.00000000.J34 D7313 2018", results[0].lccSort) }, + { assertNull(results[0].lendingEdition) }, + { assertNull(results[0].lendingIdentifier) }, + { assertEquals(196, results[0].numberOfPagesMedian) }, + { assertEquals("1054104980", results[0].oclc[0]) }, + { assertNull(results[0].ospCount) }, + { assertTrue(results[0].person.isEmpty()) }, + { assertTrue(results[0].personFacet.isEmpty()) }, + { assertTrue(results[0].personKey.isEmpty()) }, + { assertTrue(results[0].place.isEmpty()) }, + { assertTrue(results[0].placeFacet.isEmpty()) }, + { assertTrue(results[0].placeKey.isEmpty()) }, + { assertNull(results[0].printDisabled) }, + { assertFalse(results[0].publicScan) }, + { assertEquals("2021", results[0].publishDate[0]) }, + { assertTrue(results[0].publishPlace.isEmpty()) }, + { assertEquals(2018, results[0].publishYear[0]) }, + { assertEquals("Panini", results[0].publisher[0]) }, + { assertEquals("Editorial Ivrea", results[0].publisherFacet[0]) }, + { assertNull(results[0].ratingsAverage) }, + { assertNull(results[0].ratingsCount) }, + { assertNull(results[0].oneStarRatings) }, + { assertNull(results[0].twoStarRatings) }, + { assertNull(results[0].threeStarRatings) }, + { assertNull(results[0].fourStarRatings) }, + { assertNull(results[0].fiveStarRatings) }, + { assertNull(results[0].ratingsSortable) }, + { assertEquals(1, results[0].readinglogCount) }, + { assertEquals("/books/OL38630032M", results[0].seed[0]) }, + { assertTrue(results[0].subject.isEmpty()) }, + { assertTrue(results[0].subjectFacet.isEmpty()) }, + { assertTrue(results[0].subjectKey.isEmpty()) }, + { assertTrue(results[0].time.isEmpty()) }, + { assertTrue(results[0].timeFacet.isEmpty()) }, + { assertTrue(results[0].timeKey.isEmpty()) }, + { assertEquals("Dr. STONE, Vol. 1", results[0].title) }, + { assertEquals("Dr. STONE, Vol. 1", results[0].titleSort) }, + { assertEquals("Dr. STONE, Vol. 1", results[0].titleSuggest) }, + { assertEquals("work", results[0].type) }, + { assertEquals(1799254130621939712, results[0].version) }, + { assertEquals(1, results[0].wantToReadCount) }, + ) } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index ec396999..41a31031 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,7 @@ -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" -} +plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } rootProject.name = "Bookshelf" include("openlibrary") + include("app") From e4506ecc6fb953d2a8efacc8157398b1561246d5 Mon Sep 17 00:00:00 2001 From: BuriedInCode <6057651+Buried-In-Code@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:17:40 +1200 Subject: [PATCH 2/2] Add gradle testing workflow --- .github/workflows/testing.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/testing.yaml diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml new file mode 100644 index 00000000..380309d7 --- /dev/null +++ b/.github/workflows/testing.yaml @@ -0,0 +1,34 @@ +name: 'Testing' + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + gradle-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run tests + run: ./gradlew test