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 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 a4b76b95..1b33c55b 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ 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")