initial commit

This commit is contained in:
Hartmut Bauer committed 2026-10-09 17:13:39 +02:00
commit d84861949f
92 files changed
+8425

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
package net.keinekekse.app
import net.keinekekse.version.Version
object VERSION: Version {
override val version = "0.2.0-alpha"
override val fullVersion = "0.2.0-alpha+build.533"
override val major = 0
override val minor = 2
override val patch = 0
override val preRelease = "alpha"
override val buildMetadata = "build.533"
override val buildDate = "2026-10-08T23:13:32.459638Z"
override val displayName = "Application Server"
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "server"
override val description = "Ktor Application Server"
}
object VERSION_books: Version {
override val version = "0.2.0-alpha"
override val fullVersion = "0.2.0-alpha+build.533"
override val major = 0
override val minor = 2
override val patch = 0
override val preRelease = "alpha"
override val buildMetadata = "build.533"
override val buildDate = "2026-10-08T23:13:32.459742Z"
override val displayName = "Books-DB"
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "books"
override val description = "Books + Comics Database"
}
+18
View File
@@ -0,0 +1,18 @@
package net.keinekekse.app.books.config
import io.ktor.server.application.*
import io.ktor.server.plugins.di.*
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.db.BookRepositoryImpl
fun Application.configureBookDependencyInjection() {
log.info("Configuring book dependency injection")
val booksDb = configureBooksDatabase()
dependencies {
provide("booksDb") { booksDb }
provide<BookRepository> { BookRepositoryImpl(booksDb) }
}
}
@@ -0,0 +1,34 @@
package net.keinekekse.app.books.config
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.*
import net.keinekekse.app.core.config.getKeyFromConfiguration
import org.jetbrains.exposed.v1.core.DatabaseConfig
import org.jetbrains.exposed.v1.jdbc.Database
fun Application.configureBooksDatabase(): Database {
log.info("Configuring books database")
val config = HikariConfig().apply {
dataSourceClassName = "org.postgresql.ds.PGSimpleDataSource"
username = getKeyFromConfiguration("postgres.books_schema")
password = getKeyFromConfiguration("postgres.books_pwd")
addDataSourceProperty("serverName", getKeyFromConfiguration("postgres.host", "localhost"))
addDataSourceProperty("portNumber", getKeyFromConfiguration("postgres.port", "5432"))
addDataSourceProperty("databaseName", username)
maximumPoolSize = 6
isReadOnly = false
transactionIsolation = "TRANSACTION_SERIALIZABLE"
}
val dataSource = HikariDataSource(config)
return Database.connect(
datasource = dataSource,
databaseConfig = DatabaseConfig {
// set other parameters here
}
)
}
@@ -0,0 +1,177 @@
package net.keinekekse.app.books.config
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.oidc.*
import io.ktor.server.html.*
import io.ktor.server.htmx.*
import io.ktor.server.plugins.di.annotations.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.utils.io.*
import kotlinx.html.div
import kotlinx.html.p
import kotlinx.html.span
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.db.CalibreBookInfoRepositoryImpl
import net.keinekekse.app.books.scan.getCover
import net.keinekekse.app.books.scan.getIsbnFromCalibre
import net.keinekekse.app.books.ui.*
import net.keinekekse.app.core.auth.UserRoles
import net.keinekekse.app.core.config.KeycloakSession
import net.keinekekse.app.core.config.onUnauthorized
import org.jetbrains.exposed.v1.jdbc.Database
var thumbnailUrl: String? = null
@OptIn(ExperimentalKtorApi::class)
fun Application.configureRouting(
@Named("booksDb") db: Database,
booksRepo: BookRepository,
keycloakSession: KeycloakSession,
// resultCallback: (String, String) -> Unit,
) {
log.info("Configuring books routing")
routing {
authenticateWith(
keycloakSession,
roles = setOf(UserRoles.BOOKS_USER),
onUnauthorized = { onUnauthorized(call) }
) {
// Start
get(navBooks.second) {
log.info(" ### Books page requested")
val p = call.principal
call.application.log.info("AppPrincipal userId: ${p.userId}")
val pp = call.principal<OidcToken.Id>()
call.application.log.info("OidcToken.Id.Principal userId: ${pp?.userInfo?.preferredUsername}")
call.respondHtml {
testPage(db)
}
}
log.info(" ### Books page done")
// New/ Import
get(navBooksNew.second) {
val books = CalibreBookInfoRepositoryImpl(db).readAllBookInfo()
call.respondHtml {
importPage(books)
}
}
get(navBooksScan.second) {
call.respondHtml {
scanPage()
}
}
get("${navBooksScanEdit.second}/{id}") {
val id = call.parameters["id"]?.toInt() ?: 0
val bookInfo = CalibreBookInfoRepositoryImpl(db).readBookInfo(id)
call.respondHtml {
importEditPage(bookInfo, booksRepo)
}
}
delete("${navBooksScanEdit.second}/{id}") {
val id = call.parameters["id"]?.toInt() ?: 0
val biRepo = CalibreBookInfoRepositoryImpl(db)
val bookInfo = biRepo.readBookInfo(id)
if (bookInfo != null) {
biRepo.removeBookInfo(bookInfo.id)
}
val books = biRepo.readAllBookInfo()
call.respondHtml {
importPage(books)
}
}
hx.get("${navBooksScanFetch.second}/{isbn}") {
// val isbn = call.request.queryParameters["isbn"] ?: "" // "?isbn=..."
val isbn = call.parameters["isbn"] ?: ""
call.application.log.info("ISBN lookup for $isbn")
val bookInfo = getIsbnFromCalibre(isbn, db)
val image = getCover(isbn)
val dataUrl = embedImageURL(image)
call.respondHtmlPartial(status = HttpStatusCode.OK) {
isbnResultCallback(bookInfo, dataUrl)
}
}
// Edit
get(navBooksEdit.second) {
// TODO
}
hx.post(navBooksEdit.second) {
call.application.log.info("Edit book")
call.receiveParameters().forEach { param: String, value: List<String> ->
call.application.log.info("param: $param, value: $value")
}
call.respondHtmlPartial(HttpStatusCode.OK) {
p {
+"OK"
}
}
}
// hx.get("/isbnlookupreset") {
// call.respondHtmlPartial(HttpStatusCode.OK) {
// p { +"Bitte Barcode vor die Kamera halten..." }
// }
// }
// get("/testimage/{isbn}") {
// val isbn = call.parameters["isbn"] ?: ""
// val image = getCover(isbn)
// call.application.log.info("/testimage img len ${image.size}")
// call.respondBytes(
// bytes = image,
// contentType = ContentType.Image.JPEG,
// status = HttpStatusCode.OK
// )
// }
// "hx." lässt den Aufruf nur per HTMX zu. Der Pfad kann nicht
// direkt im Browser aufgerufen werden.
hx.get("/books/testdb") {
log.info(" ### DB test requested")
dbTest(db, booksRepo)
call.respondHtmlPartial(HttpStatusCode.OK) {
span {
+"DB created"
}
}
}
hx.post("/books/testform") {
log.info(" ### Form test requested")
val inp = call.receiveParameters()["testInput"]
log.info(" ### Form input: $inp")
// val formContent = call.receiveParameters()
// formContent.forEach { key, value -> // key -> input field name
// call.application.log.info(" ### Form key: $key") // == formContent[key]
// for (v in value) {
// call.application.log.info(" ### Form value(s): $v")
// }
// }
call.respondHtmlPartial(HttpStatusCode.OK) {
div {
+"form submitted: $inp"
}
}
}
// Test 401
authenticateWith(
keycloakSession,
roles = setOf(UserRoles.BOOKS_USER),
// onUnauthorized = { onUnauthorized(call) }
) {
get("/test") {
call.respondText("Test")
}
}
}
}
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.books.db
import net.keinekekse.app.books.model.*
interface BookRepository {
suspend fun allBooks(): List<Book>
suspend fun bookById(id: Int): Book?
suspend fun booksBySeries(series: Series): List<Book>
suspend fun bookByTitle(title: String): List<Book>
suspend fun booksByArtist(artist: Artist): List<Book>
suspend fun addBook(book: Book)
suspend fun removeBook(id: Int): Boolean
suspend fun allPublishers(): List<Publisher>
suspend fun allArtists(): List<Artist>
suspend fun allSeries(): List<Series>
suspend fun allTags(): List<Tag>
}
@@ -0,0 +1,61 @@
package net.keinekekse.app.books.db
import net.keinekekse.app.books.db.schema.*
import net.keinekekse.app.books.model.*
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction
class BookRepositoryImpl(val booksDb: Database) : BookRepository {
override suspend fun allBooks(): List<Book> = suspendTransaction(booksDb) {
BookDAO.all().map(::bookDaoToModel)
}
override suspend fun bookById(id: Int): Book? = suspendTransaction(booksDb) {
BookDAO.findById(id)?.let { bookDaoToModel(it) }
}
// TODO noch zu testen
override suspend fun booksBySeries(series: Series): List<Book> = suspendTransaction(booksDb) {
BookDAO.find { BooksTable.series eq series.id }.map(::bookDaoToModel)
}
// TODO noch zu testen
override suspend fun bookByTitle(title: String): List<Book> = suspendTransaction(booksDb) {
val comp = "%$title%"
BookDAO.find { BooksTable.title like comp }.map(::bookDaoToModel)
}
// TODO noch zu testen
override suspend fun booksByArtist(artist: Artist): List<Book> = suspendTransaction(booksDb) {
BookDAO.find { (ArtistToBookTable.artistId eq artist.id) and (ArtistToBookTable.bookId eq BooksTable.id) }
.map(::bookDaoToModel)
}
override suspend fun addBook(book: Book) {
TODO("Not yet implemented")
}
override suspend fun removeBook(id: Int): Boolean {
TODO("Not yet implemented")
}
override suspend fun allPublishers(): List<Publisher> = suspendTransaction(booksDb) {
PublisherDAO.all().map(::publisherDaoToModel)
}
override suspend fun allArtists(): List<Artist> = suspendTransaction(booksDb) {
ArtistDAO.all().map(::artistDaoToModel)
}
override suspend fun allSeries(): List<Series> = suspendTransaction(booksDb) {
SeriesDAO.all().map(::seriesDaoToModel)
}
override suspend fun allTags(): List<Tag> = suspendTransaction(booksDb) {
TagsDAO.all().map(::tagDaoToModel)
}
}
@@ -0,0 +1,11 @@
package net.keinekekse.app.books.db
import net.keinekekse.app.books.db.schema.CalibreBookInfoDAO
import net.keinekekse.app.books.model.CalibreBookInfo
interface CalibreBookInfoRepository {
suspend fun addBookInfo(bookInfo: CalibreBookInfo): CalibreBookInfoDAO
suspend fun readAllBookInfo(): List<CalibreBookInfo>
suspend fun readBookInfo(id: Int): CalibreBookInfo?
suspend fun removeBookInfo(id: Int)
}
@@ -0,0 +1,31 @@
package net.keinekekse.app.books.db
import net.keinekekse.app.books.db.schema.CalibreBookInfoDAO
import net.keinekekse.app.books.db.schema.calibreBookInfoDaoToModel
import net.keinekekse.app.books.db.schema.modelToCalibreBookInfoDao
import net.keinekekse.app.books.model.CalibreBookInfo
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction
class CalibreBookInfoRepositoryImpl(val booksDb: Database) : CalibreBookInfoRepository {
override suspend fun addBookInfo(bookInfo: CalibreBookInfo) =
suspendTransaction(booksDb) {
modelToCalibreBookInfoDao(bookInfo)
}
override suspend fun readAllBookInfo(): List<CalibreBookInfo> =
suspendTransaction(booksDb) {
CalibreBookInfoDAO.all().map(::calibreBookInfoDaoToModel)
}
override suspend fun readBookInfo(id: Int): CalibreBookInfo? =
suspendTransaction(booksDb) {
CalibreBookInfoDAO.findById(id)?.let { calibreBookInfoDaoToModel(it) }
}
override suspend fun removeBookInfo(id: Int) =
suspendTransaction(booksDb) {
CalibreBookInfoDAO[id].delete()
}
}
@@ -0,0 +1,21 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.Artist
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object ArtistsTable : IntIdTable("artists") {
var name = varchar("name", 100).index()
var givenName = varchar("given_name", 100).index()
}
class ArtistDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<ArtistDAO>(ArtistsTable)
var name by ArtistsTable.name
var givenName by ArtistsTable.givenName
}
fun artistDaoToModel(dao: ArtistDAO) = Artist(dao.id.value, dao.name, dao.givenName)
@@ -0,0 +1,19 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.ArtistRole
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object ArtistRolesTable : IntIdTable("artist_roles") {
var role = varchar("role", 50).index()
}
class ArtistRoleDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<ArtistRoleDAO>(ArtistRolesTable)
var role by ArtistRolesTable.role
}
fun artistToRoleDaoToModel(dao: ArtistRoleDAO) = ArtistRole(dao.id.value, dao.role)
@@ -0,0 +1,20 @@
package net.keinekekse.app.books.db.schema
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object ArtistToBookTable : IntIdTable("artist_to_book") {
val artistId = reference("artist_id", ArtistsTable.id).index()
val bookId = reference("book_id", BooksTable.id).index()
val role = reference("role_id", ArtistRolesTable.id).index()
}
class ArtistToBookDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<ArtistToBookDAO>(ArtistToBookTable)
var artist by ArtistDAO referencedOn ArtistToBookTable.artistId
var book by BookDAO referencedOn ArtistToBookTable.bookId
var role by ArtistRoleDAO referencedOn ArtistToBookTable.role
}
@@ -0,0 +1,91 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.*
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object BooksTable : IntIdTable("books") {
var title = varchar("title", 150).index()
var subTitle = varchar("sub_title", 150).nullable()
var originalTitle = varchar("original_title", 150).nullable()
var year = integer("year")
var originalYear = integer("original_year").nullable()
var language = varchar("language", 2)
var isbn = varchar("isbn", 13).index()
var publisher = reference("publisher_id", PublisherTable.id)
var series = optReference("series_id", SeriesTable.id)
var seriesIndex = integer("seriesIndex").nullable()
var comments = varchar("comments", 255)
var parentBook = optReference("parent_book_id", id)
val cover = blob("cover").nullable()
}
class BookDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<BookDAO>(BooksTable)
var title by BooksTable.title
var subTitle by BooksTable.subTitle
var seriesIndex by BooksTable.seriesIndex
var originalTitle by BooksTable.originalTitle
var year by BooksTable.year
var originalYear by BooksTable.originalYear
var language: Languages by BooksTable.language.transform({ it.name }, { Languages.valueOf(it) })
var isbn by BooksTable.isbn
var publisher by PublisherDAO.Companion referencedOn BooksTable.publisher
var comments by BooksTable.comments
var cover by BooksTable.cover
var series by SeriesDAO.Companion optionalReferencedOn BooksTable.series
val artists by ArtistToBookDAO referrersOn ArtistToBookTable.bookId
val tags by TagsToBookDAO referrersOn TagsToBookTable.bookId
// Verweis auf Sammelband (falls dieses Buch ein Teilroman ist)
var parentBook by BookDAO optionalReferencedOn BooksTable.parentBook
// Enthaltene Einzelromane (falls dieses Buch der Sammelband ist)
val containedBooks by BookDAO optionalReferrersOn BooksTable.parentBook
}
fun bookDaoToModel(dao: BookDAO, includeChildren: Boolean = true): Book = Book(
dao.id.value,
dao.title,
dao.subTitle,
dao.originalTitle,
dao.year,
dao.originalYear,
dao.language,
dao.isbn,
publisher = Publisher(dao.publisher.id.value, dao.publisher.name),
dao.comments,
cover = dao.cover?.bytes ?: ByteArray(0),
series = if (dao.series != null) Series(dao.series!!.id.value, dao.series!!.name) else null,
seriesIndex = dao.seriesIndex,
dao.parentBook?.id?.value,
containedBooks = if (includeChildren) {
dao.containedBooks.map { bookDaoToModel(it, includeChildren = false) }
} else {
emptyList()
},
artists = dao.artists.map {
ArtistForBook(
Artist(it.artist.id.value, it.artist.name, it.artist.givenName),
ArtistRole(it.role.id.value, it.role.role)
)
},
tags = dao.tags.map {
Tag(it.tag.id.value, it.tag.tag)
},
)
fun emptyBook(title: String = "", year: Int = 0, isbn: String = ""): Book =
Book(
title = title,
year = year,
isbn = isbn,
publisher = Publisher(0, ""),
)
@@ -0,0 +1,65 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.CalibreBookInfo
import net.keinekekse.app.books.model.Languages
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.core.statements.api.ExposedBlob
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object CalibreBookInfoTable : IntIdTable("calibre_book_info") {
var title = varchar("title", 150)
val authors = varchar("authors", 200)
val publisher = varchar("publisher", 100)
val tags = varchar("tags", 200)
val language = varchar("language", 2)
val published = integer("published")
val identifiers = varchar("identifiers", 200)
val isbn = varchar("isbn", 20)
val comments = varchar("comments", 255)
val cover = blob("cover")
}
class CalibreBookInfoDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<CalibreBookInfoDAO>(CalibreBookInfoTable)
var title by CalibreBookInfoTable.title
var authors by CalibreBookInfoTable.authors
var publisher by CalibreBookInfoTable.publisher
var tags by CalibreBookInfoTable.tags
var language by CalibreBookInfoTable.language.transform({ it.name }, { Languages.valueOf(it) })
var published by CalibreBookInfoTable.published
var identifiers by CalibreBookInfoTable.identifiers
var isbn by CalibreBookInfoTable.isbn
var comments by CalibreBookInfoTable.comments
var cover by CalibreBookInfoTable.cover
}
fun calibreBookInfoDaoToModel(dao: CalibreBookInfoDAO): CalibreBookInfo = CalibreBookInfo(
dao.id.value,
dao.title,
dao.authors,
dao.publisher,
dao.tags,
dao.language,
dao.published,
dao.identifiers,
dao.isbn,
dao.comments,
dao.cover.bytes
)
fun modelToCalibreBookInfoDao(bookInfo: CalibreBookInfo): CalibreBookInfoDAO =
CalibreBookInfoDAO.new {
title = bookInfo.title.take(150)
authors = bookInfo.authors.take(200)
publisher = bookInfo.publisher.take(100)
tags = bookInfo.tags.take(200)
language = bookInfo.language
published = bookInfo.published
identifiers = bookInfo.identifiers.take(200)
isbn = bookInfo.isbn.take(20)
comments = bookInfo.comments.take(255)
cover = ExposedBlob(bookInfo.cover)
}
@@ -0,0 +1,19 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.Publisher
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object PublisherTable : IntIdTable("publisher") {
var name = varchar("name", 100).index()
}
class PublisherDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<PublisherDAO>(PublisherTable)
var name by PublisherTable.name
}
fun publisherDaoToModel(dao: PublisherDAO) = Publisher(dao.id.value, dao.name)
@@ -0,0 +1,19 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.Series
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object SeriesTable : IntIdTable("series") {
var name = varchar("name", 100).index()
}
class SeriesDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SeriesDAO>(SeriesTable)
var name by SeriesTable.name
}
fun seriesDaoToModel(dao: SeriesDAO) = Series(dao.id.value, dao.name)
@@ -0,0 +1,19 @@
package net.keinekekse.app.books.db.schema
import net.keinekekse.app.books.model.Tag
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object TagsTable : IntIdTable("tags") {
var tag = varchar("tag", 50).index()
}
class TagsDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TagsDAO>(TagsTable)
var tag by TagsTable.tag
}
fun tagDaoToModel(dao: TagsDAO): Tag = Tag(dao.id.value, dao.tag)
@@ -0,0 +1,18 @@
package net.keinekekse.app.books.db.schema
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
object TagsToBookTable : IntIdTable("tag_to_book") {
var tagId = reference("tag_id", TagsTable.id).index()
var bookId = reference("book_id", BooksTable.id).index()
}
class TagsToBookDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TagsToBookDAO>(TagsToBookTable)
var tag by TagsDAO referencedOn TagsToBookTable.tagId
var book by BookDAO referencedOn TagsToBookTable.bookId
}
@@ -0,0 +1,10 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class Artist(
val id: Int = 0,
val name: String = "",
val givenName: String = "",
)
@@ -0,0 +1,9 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class ArtistRole(
val id: Int = 0,
val role: String = "",
)
@@ -0,0 +1,76 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class ArtistForBook(val artist: Artist, val role: ArtistRole)
@Serializable
data class Book(
val id: Int = 0,
val title: String = "",
val subTitle: String? = null,
val originalTitle: String? = null,
val year: Int = 0,
val originalYear: Int? = null,
val language: Languages = Languages.DE,
val isbn: String = "",
val publisher: Publisher,
val comments: String = "",
val cover: ByteArray = byteArrayOf(0),
val series: Series? = null,
val seriesIndex: Int? = null,
val parentBookId: Int? = null, // ID des Sammelbands
val containedBooks: List<Book> = emptyList(), // Enthaltene Romane
val artists: List<ArtistForBook> = emptyList(),
val tags: List<Tag> = emptyList(),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Book
if (id != other.id) return false
if (year != other.year) return false
if (originalYear != other.originalYear) return false
if (seriesIndex != other.seriesIndex) return false
if (parentBookId != other.parentBookId) return false
if (title != other.title) return false
if (subTitle != other.subTitle) return false
if (originalTitle != other.originalTitle) return false
if (language != other.language) return false
if (isbn != other.isbn) return false
if (publisher != other.publisher) return false
if (comments != other.comments) return false
if (!cover.contentEquals(other.cover)) return false
if (series != other.series) return false
if (containedBooks != other.containedBooks) return false
if (artists != other.artists) return false
if (tags != other.tags) return false
return true
}
override fun hashCode(): Int {
var result = id
result = 31 * result + year
result = 31 * result + originalYear.hashCode()
result = 31 * result + seriesIndex.hashCode()
result = 31 * result + parentBookId.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + subTitle.hashCode()
result = 31 * result + originalTitle.hashCode()
result = 31 * result + language.hashCode()
result = 31 * result + isbn.hashCode()
result = 31 * result + publisher.hashCode()
result = 31 * result + comments.hashCode()
result = 31 * result + cover.contentHashCode()
result = 31 * result + series.hashCode()
result = 31 * result + containedBooks.hashCode()
result = 31 * result + artists.hashCode()
result = 31 * result + tags.hashCode()
return result
}
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class CalibreBookInfo(
val id: Int = 0,
val title: String,
val authors: String,
val publisher: String,
val tags: String,
val language: Languages,
val published: Int,
val identifiers: String,
val isbn: String,
val comments: String,
val cover: ByteArray,
)
@@ -0,0 +1,7 @@
package net.keinekekse.app.books.model
enum class Languages {
DE,
EN,
FR,
}
@@ -0,0 +1,6 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class Publisher(val id: Int, val name: String)
@@ -0,0 +1,6 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class Series(val id: Int, val name: String)
@@ -0,0 +1,6 @@
package net.keinekekse.app.books.model
import kotlinx.serialization.Serializable
@Serializable
data class Tag(val id: Int, val tag: String)
@@ -0,0 +1,98 @@
package net.keinekekse.app.books.scan
import io.ktor.util.logging.*
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.keinekekse.app.books.db.CalibreBookInfoRepositoryImpl
import net.keinekekse.app.books.model.CalibreBookInfo
import net.keinekekse.app.books.model.Languages
import net.keinekekse.exec.CommandExecutionException
import net.keinekekse.exec.ExecutionResult
import org.jetbrains.exposed.v1.jdbc.Database
import java.io.File
import kotlin.time.Instant
val LOG = KtorSimpleLogger("net.keinekekse.app.books.scan.fetchBookInfo")
suspend fun getIsbnFromCalibre(isbn: String, db: Database): String {
try {
val runResult = ExecutionResult(
listOf(
"fetch-ebook-metadata",
"--isbn",
isbn,
"--cover",
"/opt/app/download/$isbn.jpg"
)
)
LOG.info("fetch-ebook-metadata for isbn: $isbn")
LOG.info("RC = ${runResult.rc}")
LOG.info("Output = ${runResult.stdout}")
LOG.info("Error = ${runResult.stderr}")
val cali = extractData(runResult.stdout, isbn)
CalibreBookInfoRepositoryImpl(db).addBookInfo(cali)
LOG.info("CalibreBookInfo.language = ${cali.language}")
LOG.info("CalibreBookInfo.published = ${cali.published}")
return runResult.stdout
} catch (e: CommandExecutionException) {
LOG.error("Error running fetch-ebook-metadata for isbn: $isbn", e)
return "nothing found for $isbn"
}
}
fun extractData(input: String, isbn: String): CalibreBookInfo {
val title = extractLine(input, "Title")?.trim() ?: ""
val authors = extractLine(input, "Author.s.")?.trim() ?: ""
val publisher = extractLine(input, "Publisher")?.trim() ?: ""
val tags = extractLine(input, "Tags")?.trim() ?: ""
val languages = extractLine(input, "Languages")?.trim() ?: ""
val published = extractLine(input, "Published")?.trim() ?: ""
val identifiers = extractLine(input, "Identifiers")?.trim() ?: ""
val comments = extractLine(input, "Comments")?.trim() ?: ""
// val authorList = authors.split("&").map { it.trim() }
val lang = Languages.valueOf(languages.uppercase().slice(0..1))
val year = Instant.parse(published).toLocalDateTime(TimeZone.UTC).year
val cover = getCover(isbn)
return CalibreBookInfo(
0,
title,
authors,
publisher,
tags,
lang,
year,
identifiers,
isbn,
comments,
cover
)
}
fun extractLine(input: String, text: String): String? {
val pat = """$text\s*:\s*(.*)"""
val regex = Regex(pat)
return regex.find(input)?.groupValues?.get(1)
// if (matchResult != null) {
// val title = matchResult.groupValues[1]
// LOG.info("Title: $title")
// }
}
fun getCover(isbn: String): ByteArray {
var coverBytes: ByteArray = ByteArray(0)
val file = File("/opt/app/download/$isbn.jpg")
if (file.exists()) {
LOG.info("Cover file exists")
coverBytes = file.readBytes()
} else {
LOG.info("Cover file does not exist")
}
return coverBytes
}
+202
View File
@@ -0,0 +1,202 @@
package net.keinekekse.app.books.ui
import io.ktor.htmx.html.*
import kotlinx.coroutines.runBlocking
import kotlinx.html.*
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.model.*
import net.keinekekse.app.books.model.Tag
fun FlowContent.bookEdit(book: Book, booksRepo: BookRepository) {
val publishers: List<Publisher> = runBlocking {
booksRepo.allPublishers()
}
val series: List<Series> = runBlocking {
booksRepo.allSeries()
}
val artists: List<Artist> = runBlocking {
booksRepo.allArtists()
}
val tags: List<Tag> = runBlocking {
booksRepo.allTags()
}
div(classes = "flex flex-col m-8") {
form {
attributes.hx {
post = navBooksEdit.second
target = "#response"
swap = "innerHTML"
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30 ") {
+"Titel:"
}
input(classes = "$cssTextInput w-150") {
name = "title"
tabIndex = "1"
value = book.title
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30 ") {
+"Originaltitel:"
}
input(classes = "$cssTextInput w-150") {
name = "originaltitle"
tabIndex = "2"
value = book.originalTitle ?: ""
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30 ") {
+"Untertitel:"
}
input(classes = "$cssTextInput w-150") {
name = "subtitle"
tabIndex = "3"
value = book.subTitle ?: ""
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30") {
+"Sprache"
}
select(classes = "$cssTextInput w-16") {
name = "language"
tabIndex = "4"
for (language in Languages.entries) {
option {
if (language == book.language)
selected = true
value = language.ordinal.toString()
+language.name
}
}
}
div(classes = "ml-9 pr-3 ") {
+"Erscheinungsjahr:"
}
input(classes = "$cssTextInput w-16") {
name = "year"
tabIndex = "5"
value = book.year.toString()
}
div(classes = "ml-8 pr-3") {
+"Jahr der Originalausgabe:"
}
input(classes = "$cssTextInput w-16") {
name = "originalyear"
tabIndex = "6"
value = book.originalYear?.toString() ?: ""
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30") {
+"ISBN"
}
input(classes = "$cssTextInput w-40") {
name = "isbn"
tabIndex = "7"
value = book.isbn
}
div(classes = "ml-14 pr-3") {
+"Verlag"
}
select(classes = "$cssTextInput w-74") {
name = "publisher"
tabIndex = "8"
option {
value = "0"
+"---"
}
for (pub in publishers) {
option {
if (pub == book.publisher)
selected = true
value = pub.id.toString()
+pub.name
}
}
}
a(classes = cssFormIcon) {
tabIndex = "9"
title = "Verlag hinzufügen"
materialSymbolFormButton("add_box")
}
}
div(classes = "flex flex-row mb-5 h-9") {
div(classes = "w-30") {
+"Serie"
}
select(classes = "$cssTextInput w-98") {
name = "series"
tabIndex = "10"
option {
value = "0"
+"---"
}
for (serie in series) {
option {
if (serie == book.series)
selected = true
value = serie.id.toString()
+serie.name
}
}
}
a(classes = cssFormIcon) {
tabIndex = "11"
title = "Serie hinzufügen"
materialSymbolFormButton("add_box")
}
div(classes = "ml-13 pr-3 ") {
+"Index"
}
input(classes = "$cssTextInput w-16") {
name = "seriesindex"
tabIndex = "12"
value = book.seriesIndex?.toString() ?: ""
}
}
div(classes = "flex flex-row mb-5 h-9") {
// artists tags
// | | | |
// | | | |
}
div(classes = "flex flex-row mb-5 h-9") {
// evtl. switch
// Teil von Sammelband _____v / enthält (-> Link)
// comment
}
button(classes = "$cssButtonPri block mt-8") {
tabIndex = "20"
type = ButtonType.submit
+"Save"
}
}
div {
id = "response"
}
}
}
@@ -0,0 +1,97 @@
package net.keinekekse.app.books.ui
import kotlinx.html.*
import net.keinekekse.app.VERSION_books
import net.keinekekse.app.core.ui.cssFullscreen
import net.keinekekse.app.core.ui.stdHead
fun FlowContent.programName() {
a(classes = "flex") {
href = navBooks.second
title = "v ${VERSION_books.fullVersion}"
img(classes = "h-14 my-2 $bgHeaderIcon") {
src = "/static/books.png"
}
div(classes = "self-center text-right ml-3") {
div {
div(classes = "text-3xl") {
+VERSION_books.displayName
}
div(classes = "text-xs") {
+VERSION_books.version
}
}
}
}
}
fun HTML.booksPage(title: String, breadcrumbs: Map<String, String>, block: DIV.() -> Unit = {}) {
// val booksDb = Dependencies.getBooksDb()
LOG.info(" ### building test page")
// var breadcrumbMap = emptyMap<String, String>()
// if (breadcrumbs.isEmpty()) {
// breadcrumbMap = breadcrumbs.plus(Pair("Start", ""))
// } else {
// breadcrumbMap = mapOf(Pair("Start", "/books")).plus(breadcrumbs)
// }
head {
stdHead(title)
}
body {
// page
div(classes = "$cssFullscreen flex-col") {
// header
div(classes = "flex flex-row justify-between py-2 px-5 $bgHeader $textHeader") {
// header left
div(classes = "flex flex-row") {
programName()
div(classes = "ml-10 flex self-end pr-5 pt-2 border-t-1 items-baseline") {
a(classes = cssHeaderLink) {
href = "/"
materialSymbol("home")
// +"Start"
}
div(classes = "px-3") { +">" }
breadcrumbs.forEach { (name, url) ->
if (url.isNotEmpty()) {
a(classes = cssHeaderLink) {
href = url
+name
}
div(classes = "px-3") { +">" }
} else {
div {
+name
}
}
}
}
}
// header right TODO nötig?
// div(classes = "flex") {
// div(classes = "self-center text-xl") {
// materialSymbol("Settings")
// }
// }
}
div(classes = "flex flex-row flex-auto overflow-hidden") {
block()
}
// footer
div(classes = "p-1 text-sm $bgFooter") {
div(classes = "col") {
+"some footer content"
}
}
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package net.keinekekse.app.books.ui
import io.ktor.util.logging.*
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.db.schema.*
import net.keinekekse.app.books.model.Languages
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
val logger = KtorSimpleLogger("net.keinekekse.app.books.ui")
suspend fun dbTest(db: Database, booksRepo: BookRepository) {
// val db = Dependencies.getBooksDb()
// val repo = Dependencies.getBookRepository()
// DB-Test
transaction(db) {
logger.debug("start DB reset - drop")
// create if not exists
SchemaUtils.drop(
PublisherTable,
TagsTable,
TagsToBookTable,
SeriesTable,
ArtistToBookTable,
ArtistRolesTable,
ArtistsTable,
BooksTable,
CalibreBookInfoTable
)
logger.debug("create tables")
SchemaUtils.create(
PublisherTable,
TagsTable,
TagsToBookTable,
SeriesTable,
ArtistRolesTable,
ArtistsTable,
ArtistToBookTable,
BooksTable,
CalibreBookInfoTable
)
logger.debug("Tables created")
val auth = ArtistRoleDAO.new { role = "Autor" }
ArtistRoleDAO.new { role = "Übersetzer" }
val kia1 = ArtistDAO.new {
name = "Aigner"
givenName = "Sebastian"
}
val kia2 = ArtistDAO.new {
name = "Elizarov"
givenName = "Roman"
}
val kia3 = ArtistDAO.new {
name = "Isakova"
givenName = "Svetlana"
}
val kia4 = ArtistDAO.new {
name = "Jemerov"
givenName = "Dmitry"
}
val kp1 = ArtistDAO.new {
name = "Soshin"
givenName = "Alexey"
}
val pub1 = PublisherDAO.new {
name = "Manning"
}
val pub2 = PublisherDAO.new {
name = "Packt Publishing"
}
val kia = BookDAO.new {
title = "Kotlin in Action, 2nd Edition"
originalTitle = ""
subTitle = ""
year = 2024
originalYear = 2024
seriesIndex = 0
language = Languages.EN
isbn = "9781617296823"
publisher = pub1
comments = ""
}
val kp = BookDAO.new {
title = "Kotlin Design Patterns and Best Practices, 3rd Edition"
originalTitle = ""
subTitle = ""
year = 2024
originalYear = 2024
seriesIndex = 0
language = Languages.EN
isbn = "9781617298823"
publisher = pub2
comments = ""
}
ArtistToBookDAO.new {
book = kia
role = auth
artist = kia1
}
ArtistToBookDAO.new {
book = kia
role = auth
artist = kia2
}
ArtistToBookDAO.new {
book = kia
role = auth
artist = kia3
}
ArtistToBookDAO.new {
book = kia
role = auth
artist = kia4
}
ArtistToBookDAO.new {
book = kp
role = auth
artist = kp1
}
}
val books = booksRepo.allBooks()
books.forEach { book ->
logger.debug("### ${book.title}:")
book.artists.forEach { atb ->
logger.debug("### ${atb.role.role}: ${atb.artist.givenName} ${atb.artist.name}")
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package net.keinekekse.app.books.ui
import kotlinx.html.FlowContent
import kotlinx.html.div
import kotlinx.html.span
import kotlinx.html.style
import net.keinekekse.app.books.isbnScanResultPath
import kotlin.io.encoding.Base64
// * colors *
const val bgHeader = "bg-orange-500"
const val textHeader = "text-orange-100"
const val bgHeaderIcon = "bg-orange-100"
const val bgFooter = "bg-orange-50"
const val bgLeftColumn = "bg-slate-200"
const val bgRightColumn = "bg-slate-50"
const val bgThead = "bg-slate-200"
const val borderTable = "border-slate-400"
const val bgStdButton = "bg-taupe-500"
const val bgStdButtonHover = "hover:bg-taupe-700"
const val bgStdButtonFocus = "focus:bg-taupe-700"
const val bgPriButton = "bg-orange-400"
const val bgPriButtonHover = "hover:bg-orange-600"
const val bgPriButtonFocus = "focus:bg-orange-600"
const val bgSecButton = "bg-indigo-400"
const val bgSecButtonHover = "hover:bg-indigo-600"
const val bgSecButtonFocus = "focus:bg-indigo-600"
const val textLink = "text-orange-400"
const val textLinkHover = "hover:text-orange-600"
const val textLinkFocus = "focus:text-orange-600"
const val bgLinkHover = "hover:bg-orange-200"
const val bgLinkFocus = "focus:bg-orange-200"
// * button & link *
const val cssButton = "cursor-pointer rounded-md px-4 py-2"
const val cssButtonStd =
"text-white $bgStdButton $bgStdButtonHover $cssButton $bgStdButtonFocus"
const val cssButtonPri =
"text-white $bgPriButton $bgPriButtonHover $cssButton $bgPriButtonFocus"
const val cssButtonSec =
"text-white $bgSecButton $bgSecButtonHover $cssButton $bgSecButtonFocus"
const val cssHeaderLink =
"font-bold px-1 $textLinkHover $textLinkFocus $bgLinkHover $bgLinkFocus rounded-md cursor-pointer m-0 no-underline"
const val cssLink =
"font-bold $textLink $textLinkHover $textLinkFocus $bgLinkHover $bgLinkFocus rounded-md cursor-pointer m-0 no-underline"
const val cssLinkText =
"px-1 $cssLink"
const val cssLinkIcon =
"px-2 $cssLink "
const val cssFormIcon =
"font-bold text-slate-400 $textLinkHover $textLinkFocus $bgLinkHover $bgLinkFocus rounded-md " +
"cursor-pointer m-0 px-2 pt-1 no-underline "
const val cssTextInput =
"rounded-sm border-1 border-slate-400 px-2 py-1 focus:border-orange-400 focus:border-4" +
" invalid:focus:bg-red-200 invalid:focus:border-red-500 invalid:focus:border-5" +
" invalid:bg-red-200 invalid:border-red-500 invalid:border-3 "
const val _cssTextInput =
"rounded-sm border-b-1 px-2 py-1 focus:border-b-orange-400 focus:border-b-4" +
" invalid:focus:bg-red-200 invalid:focus:border-red-500 invalid:focus:border-5" +
" invalid:bg-red-200 invalid:border-red-500 invalid:border-3 "
// * fixed table header *
const val cssFixedTHead = "fixed-table-header $bgThead "
// m-1 -> margin (--spacing * 1) -> --spacing = .25em = 4px => m-1 = 4px, m-2 = 8px
// Navigation
val navBooks = "Bücher" to "/books"
val navBooksEdit = "Buch bearbeiten" to "/books/edit"
val navBooksNew = "Neue Bücher" to "/books/new"
val navBooksScan = "ISBN Scan" to "/books/new/scan"
val navBooksScanFetch = "" to isbnScanResultPath // has to be shared with :web-Project
val navBooksScanEdit = "Scan bearbeiten" to "/books/new/editscan"
fun FlowContent.materialSymbol(symbol: String, styles: String = "") {
// Den Icon via Tailwind formatieren klappt nicht. Daher muss z. B. font-size oder
// font-weight per style= gesetzt werden.
span(classes = "material-symbols-outlined align-text-bottom") {
style = styles
+symbol
}
}
fun FlowContent.materialSymbolButton(symbol: String, styles: String = "") {
div(classes = "$cssLinkIcon inline-block") {
materialSymbol(symbol, styles)
}
}
fun FlowContent.materialSymbolFormButton(symbol: String, styles: String = "") {
div(classes = " inline-block") {
materialSymbol(symbol, styles)
}
}
/**
* Converts a byte array representing an image into a Base64 encoded Data URL string.
*
* Use in &lt;img src="..."&gt;
*
* @param image A nullable byte array containing the binary data of the image.
* When null or empty, an empty string is returned.
* @return A Data URL string containing the Base64 encoded image data,
* or an empty string if the input is null or empty.
*/
fun embedImageURL(image: ByteArray?): String =
if (image != null && image.isNotEmpty()) {
val base64String =
Base64.encode(image) // kotlin.io.encoding.Base64
val mimeType = "image/jpeg"
"data:$mimeType;base64,$base64String"
} else ""
@@ -0,0 +1,72 @@
package net.keinekekse.app.books.ui
import kotlinx.html.HTML
import kotlinx.html.div
import kotlinx.html.img
import kotlinx.html.span
import net.keinekekse.app.books.db.BookRepository
import net.keinekekse.app.books.model.Book
import net.keinekekse.app.books.model.CalibreBookInfo
import net.keinekekse.app.books.model.Languages
import net.keinekekse.app.books.model.Publisher
fun HTML.importEditPage(bookInfo: CalibreBookInfo?, booksRepo: BookRepository) {
val dataUrl = embedImageURL(bookInfo?.cover)
booksPage(
navBooksScanEdit.first,
mapOf(navBooks, navBooksNew, Pair(navBooksScanEdit.first, ""))
) {
div(classes = "flex flex-col flex-auto") {
div(classes = "flex flex-row h-60 m-8 w-3/4") {
img(classes = "inline-block h-60 pr-6 ") {
src = dataUrl
}
div {
div {
span(classes = "inline-block w-25 ") { +"ISBN: " }
+(bookInfo?.isbn ?: "")
}
div {
span(classes = "inline-block w-25") { +"Titel: " }
+(bookInfo?.title ?: "")
}
div {
span(classes = "inline-block w-25") { +"Autor(en): " }
+(bookInfo?.authors ?: "")
}
div {
span(classes = "inline-block w-25") { +"Verlag: " }
+(bookInfo?.publisher ?: "")
}
div {
span(classes = "inline-block w-25") { +"Jahr: " }
+bookInfo?.published.toString()
}
div {
span(classes = "inline-block w-25") { +"Tags: " }
+(bookInfo?.tags ?: "")
}
div {
span(classes = "inline-block w-25") { +"Identifiers: " }
+(bookInfo?.identifiers ?: "")
}
div {
span(classes = "inline-block w-25") { +"Comment: " }
+(bookInfo?.comments ?: "")
}
}
}
bookEdit(
Book(
title = bookInfo?.title ?: "",
year = bookInfo?.published ?: 0,
isbn = bookInfo?.isbn ?: "",
publisher = Publisher(0, ""),
language = bookInfo?.language ?: Languages.DE
), booksRepo
)
}
}
}
@@ -0,0 +1,110 @@
package net.keinekekse.app.books.ui
import kotlinx.html.*
import net.keinekekse.app.books.model.CalibreBookInfo
fun HTML.importPage(books: List<CalibreBookInfo>) {
// head {
// stdHead("ISBN Scannen", false)
// }
// body {
booksPage(navBooksNew.first, mapOf(navBooks, Pair(navBooksNew.first, ""))) {
div(classes = "basis-64 px-1 py-2 text-center $bgLeftColumn") {
div {
+"some content on the left "
}
div(classes = "my-6") {
a(classes = cssButtonPri) {
href = navBooksScan.second
+"ISBN Scan"
}
}
}
// content right
div(classes = "flex flex-auto $bgRightColumn") {
div(classes = "flex flex-col flex-auto") {
div {
// +"some heading"
}
importTable(books)
}
}
}
}
fun FlowContent.importTable(books: List<CalibreBookInfo>) {
div(classes = "flex-auto overflow-auto px-1 pb-1") {
table {
id = "importtable"
thead(classes = "$cssFixedTHead ") {
tr {
th(classes = "py-3 px-2") { +"ISBN" }
th(classes = "py-3 px-2") { +"Titel" }
th(classes = "py-3 px-2") { +"Autoren" }
th(classes = "py-3 px-2") { +"Verlag" }
th(classes = "py-3 px-2") { +"Jahr" }
th(classes = "py-3 px-2") { +"Sprache" }
th(classes = "py-3 px-2") { +" " }
}
}
tbody {
for ((id, title, authors, publisher, tags, language, published, identifiers, isbn, comment, cover) in books) {
tr {
td(classes = "border-t-1 $borderTable px-2 py-1") {
+isbn
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+title
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+authors
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+publisher
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+published.toString()
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+language.name
}
td(classes = "border-t-1 $borderTable px-2 py-1 text-nowrap") {
a {
href = "${navBooksScanEdit.second}/$id"
materialSymbolButton("Edit")
}
materialSymbolButton("Delete")
}
}
tr {
td {}
td {
colSpan = "5"
details(classes = "mb-2") {
attributes["name"] = "comments"
summary(classes = "text-sm opacity-50") {
+"Tags, Comments"
}
div(classes = "flex flex-row") {
img(classes = "inline-block h-30 pr-6 ") {
src = embedImageURL(cover)
}
div {
+tags
br {}
+identifiers
br {}
+comment
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,94 @@
package net.keinekekse.app.books.ui
import kotlinx.html.*
fun HTML.scanPage() {
// head {
// stdHead("ISBN Scannen", false)
// }
// body {
booksPage(
navBooksScan.first,
mapOf(navBooks, navBooksNew, Pair(navBooksScan.first, ""))
) {
style {
type = "text/css"
+"""
button {
background-color: var(--color-orange-400);
color: white;
padding: 8px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background-color: var(--color-orange-600);
}
h3 {
font-weight: bold;
margin: 10px;
}
""".trimIndent()
}
div(classes = "m-4 overflow-auto flex-auto") {
h3 { +"ISBN / Barcode erfassen" }
// 1. Kamera-Ausgabebereich
div {
id = "camera"
style = "width: 400px;"
}
// 2. Zielbereich für das Buch-Ergebnis (wird per HTMX befüllt)
div {
id = "scan-result"
// p { +"Bitte Barcode vor die Kamera halten..." }
}
}
// 3. JavaScript zur Initialisierung & HTMX-Bridge
script {
unsafe {
raw(
"""
document.addEventListener('DOMContentLoaded', function () {
renderScanner("camera");
});
""".trimIndent()
)
}
}
}
}
fun TagConsumer<Appendable>.isbnResultCallback(bookInfo: String, dataUrl: String) {
div {
pre {
// gefundene Daten anzeigen -> Bearbeitung später
+bookInfo
}
if (dataUrl.isNotEmpty()) {
div {
// img(src = "/books/testimage/$isbn")
img(src = dataUrl) {
style = "height: 200px; margin-bottom: 10px;"
}
}
}
button {
style = "margin-left: 10px; font-size: 16px;"
onClick = "renderScanner('camera')"
+"Nächtes Buch"
}
button {
style = "margin-left: 10px; font-size: 16px;"
type = ButtonType.button
onClick = "location.href='${navBooksNew.second}'"
+"Fertig"
}
}
}
+279
View File
@@ -0,0 +1,279 @@
@file:OptIn(ExperimentalKtorApi::class)
package net.keinekekse.app.books.ui
import io.ktor.htmx.*
import io.ktor.htmx.html.*
import io.ktor.util.logging.*
import io.ktor.utils.io.*
import kotlinx.html.*
import net.keinekekse.app.core.config.LOGOUT_PATH
import org.jetbrains.exposed.v1.jdbc.Database
import kotlin.random.Random
val LOG = KtorSimpleLogger("net.keinekekse.app.books.ui.TestPage")
//fun FlowContent.programName() {
//
// a(classes = "flex") {
// href = "/"
// title = "v ${VERSION_books.fullVersion}"
//
// img(classes = "h-14 my-2 $bgHeaderIcon") {
// src = "/static/books.png"
// }
// div(classes = "self-center text-right ml-3") {
// div {
// div(classes = "text-3xl") {
// +VERSION_books.displayName
// }
// div(classes = "text-xs") {
// +VERSION_books.version
// }
// }
// }
// }
//}
//
//fun HTML.testPage(booksDb: Database) {
//// val booksDb = Dependencies.getBooksDb()
// LOG.info(" ### building test page")
//
// head {
// stdHead("Test Page")
// }
//
// body {
// // page
// div(classes = "$cssFullscreen flex-col") {
// // header
// div(classes = "flex flex-row justify-between py-2 px-5 $bgHeader $textHeader") {
// // header left
// div(classes = "flex flex-row") {
// programName()
// div(classes = "ml-10 flex self-center") {
// +"MENÜ"
// }
// }
// // header right
// div(classes = "flex") {
// div(classes = "self-center text-xl") {
// materialSymbol("Settings")
// }
// }
// }
fun HTML.testPage(booksDb: Database) {
booksPage("Books-DB", mapOf(Pair(navBooks.first, ""))) {
// content left
div(classes = "basis-64 px-1 py-2 text-center $bgLeftColumn") {
div {
+"some content on the left "
}
div {
button(classes = "$cssButtonStd m-2") {
attributes.hx {
// TODO
get = "/books/testdb"
target = "#testdb"
swap = HxSwap.innerHtml
trigger = "click"
}
+"DB test"
}
}
div {
button(classes = "$cssButtonPri m-2 focus:outline-none focus:outline-orange-800") {
+"primary button"
}
}
div {
+"text text "
button(classes = "$cssLinkText ") { +"click me" }
+" test test"
}
div {
button(classes = "$cssButtonSec m-2") { +"secondary button" }
}
div {
+"Database: ${booksDb.vendor} ${booksDb.version}"
}
div {
form(action = LOGOUT_PATH, method = FormMethod.post) {
button(classes = cssLinkText) {
type = ButtonType.submit
+"Logout"
}
}
}
div {
id = "testdb"
}
div(classes = "mt-6") {
// script {
// unsafe {
// raw(
// """
// function toggleField(hideObj,showObj){
// hideObj.disabled=true;
// hideObj.style.display='none';
// showObj.disabled=false;
// showObj.style.display='inline';
// showObj.focus();
// }
// """.trimIndent()
// )
// }
//
// }
// fkt. nur so lala (onBlur!), lässt sich das hinbiegen?
// form {
// attributes.hx {
// post = "/books/testform"
// target = "#testForm"
// swap = HxSwap.innerHtml
// }
// name = "BrowserSurvey"
// select {
// name = "browser"
// onChange = """
// if(this.options[this.selectedIndex].value=='customOption'){
// toggleField(this,this.nextSibling);
// this.selectedIndex='0';
// }
// """.trimIndent()
// label { +"Browser: " }
// option {
// value = "customOption"
// +"[type a custom value]"
// }
// option {
// value = "vChrome"
// +"Chrome"
// }
// option {
// value = "vFirefox"
// +"Firefox"
// }
// option {
// value = "vInternetExplorer"
// +"Internet Explorer"
// }
// option {
// value = "vOpera"
// +"Opera"
// }
// }
// input {
// type = InputType.text
// name = "browser"
// style = "display:none;"
// disabled = true
// onBlur = """
// if(this.value==null || this.value==''){toggleField(this,this.previousSibling);}
// """.trimIndent()
// }
// input(type = InputType.submit) {
// value = "Submit"
// }
// }
// klappt, es fehlt halt die "value" -> alpha search ?!
form {
attributes.hx {
post = "/books/testform"
target = "#testForm"
swap = HxSwap.innerHtml
}
input(
classes = cssTextInput,
type = InputType.text
) {
name = "testInput"
list = "lst"
minLength = "2"
dataList {
id = "lst"
option(content = "alpha")
option(content = "beta")
option(content = "gamma")
}
}
input(type = InputType.submit) {
value = "Submit"
}
}
div {
id = "testForm"
}
}
}
// content right
div(classes = "flex flex-auto $bgRightColumn") {
div(classes = "flex flex-col flex-auto") {
// div {
// +"some heading"
// }
testTable()
}
}
}
}
// // footer
// div(classes = "p-1 text-sm $bgFooter") {
// div(classes = "col") {
// +"some footer content"
// }
// }
// }
//// }
//
// }
//}
fun FlowContent.testTable() {
div(classes = "flex-auto overflow-auto px-1 pb-1") {
table {
id = "testtable"
thead(classes = "$cssFixedTHead ") {
tr {
th(classes = "py-3 px-2") { +"#" }
th(classes = "py-3 px-2") { +"Alias" }
th(classes = "py-3 px-2") { +"Score" }
th(classes = "py-3 px-2") {
a {
href = navBooksNew.second
title = navBooksNew.first
materialSymbolButton(
"auto_stories",
"font-weight: bold; font-size: 2em"
)
// library_add
}
}
}
}
tbody {
for (i in 0..100) {
tr {
td(classes = "border-t-1 $borderTable px-2 py-1") {
+i.toString()
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+Random.nextLong().toString()
}
td(classes = "border-t-1 $borderTable px-2 py-1") {
+Random.nextLong().toString()
}
td(classes = "border-t-1 $borderTable px-2 py-1 text-nowrap") {
materialSymbolButton("Edit")
materialSymbolButton("Delete")
}
}
}
}
}
}
}
@@ -0,0 +1,52 @@
package net.keinekekse.app.core.auth
import io.ktor.server.auth.oidc.*
import io.ktor.utils.io.*
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.security.Principal
import kotlin.time.Instant
@OptIn(ExperimentalKtorApi::class)
class AppPrincipal(credential: OidcToken.Id) : Principal {
val userId: String = credential.userInfo.preferredUsername ?: ""
val vorname: String = credential.userInfo.givenName ?: ""
val nachname: String = credential.userInfo.familyName ?: ""
val email: String = credential.userInfo.email ?: ""
val roles: Set<UserRoles> = extractRoles(credential)
val expiresAt: Instant? = credential.claims.expiresAt
private fun extractRoles(credential: OidcToken.Id): Set<UserRoles> {
val payload = credential.claims.payload
// 1. Realm-Rollen extrahieren
val realmRoles = payload["realm_access"]
?.jsonObject?.get("roles")
?.jsonArray?.mapNotNull { it.jsonPrimitive.content }
?: emptyList()
// 2. Client-Rollen extrahieren
val clientRoles = payload["resource_access"]
?.jsonObject?.get("pkce-book-app") // TODO parametrisieren
?.jsonObject?.get("roles")
?.jsonArray?.mapNotNull { it.jsonPrimitive.content }
?: emptyList()
println("AppPrincipal: User ${userId}, Roles extracted: ${realmRoles.size}")
// 3. Zusammenführen und auf UserRoles-Enum mappen
return (realmRoles + clientRoles).mapNotNull { rawRole ->
val formatted = rawRole.uppercase().replace("-", "_")
try {
UserRoles.valueOf(formatted)
} catch (_: IllegalArgumentException) {
null // Rollen ignorieren, die nicht im Enum definiert sind
}
}.toSet()
}
override fun getName(): String {
return "$userId ($vorname $nachname) <$email>"
}
}
@@ -0,0 +1,28 @@
package net.keinekekse.app.core.auth
import io.ktor.server.auth.*
import io.ktor.utils.io.*
/**
* Die enum spiegelt die verfügbaren Rollen aus Keycloak wider.
*
* Für den Vergleich wird die Keycloak-Rolle in Großbuchstaben umgewandelt
* und "-" durch "_" ersetzt.
*
* Alle Änderungen auf der Keycloak-Seite müssen hier nachgefahren werden.
*
* Wegen der Art des Vergleichs (für eine Route wird immer nur eine Rolle geprüft)
* ist zu beachten, dass der User jeweils alle benötigten Rollen bekommt. Implizit
* ist nur Admin, der für alle Routen zugelassen ist.
*
*/
@OptIn(ExperimentalKtorApi::class)
enum class UserRoles : AuthenticationRole {
ADMIN,
BOOKS_ADMIN,
BOOKS_USER,
BOOKS_EDITOR,
BOOKS_RO
}
@@ -0,0 +1,78 @@
package net.keinekekse.app.core.config
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import kotlinx.serialization.json.Json
const val LOGIN_PATH = "/oidc/keycloak/login"
const val LOGOUT_PATH = "/oidc/keycloak/logout"
const val REFRESH_PATH = "/oidc/keycloak/refresh"
const val SESSION_NAME = "KEYCLOAK_SESSION"
const val REDIRECT_COOKIE = "loginredirect"
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false // wg. fehlender Felder
}
)
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
/**
* Handles unauthorized access attempts by logging the request URI and redirecting the user
* to the login page.
*
* @param call The `ApplicationCall` instance that contains details about the current HTTP call,
* including the request and response.
*/
suspend fun onUnauthorized(call: ApplicationCall) {
call.application.log.info("UNAUTHORIZED: uri = ${call.request.local.uri}")
setRedirectCookie(call)
call.respondRedirect(LOGIN_PATH)
}
fun setRedirectCookie(call: ApplicationCall) {
call.response.cookies.append(
name = REDIRECT_COOKIE,
value = call.request.local.uri,
maxAge = 240,
path = "/",
httpOnly = true
)
}
fun getRedirectUri(call: ApplicationCall): String {
val uri = call.request.cookies[REDIRECT_COOKIE]
if (uri != null) {
call.response.cookies.append(
name = REDIRECT_COOKIE,
value = "",
maxAge = 0,
path = "/",
httpOnly = true
)
}
return uri ?: "/"
}
fun Application.getKeyFromConfiguration(key: String, default: String? = null): String {
val value = environment.config.propertyOrNull(key)?.getString() ?: default
// ohne die Konfig-Params geht nix, hier ist nur noch Abbruch möglich
return value
?: throw IllegalStateException("Configuration key '$key' not found, server start aborted.")
}
+13
View File
@@ -0,0 +1,13 @@
package net.keinekekse.app.core.config
import io.ktor.server.application.*
import io.ktor.server.plugins.di.*
fun Application.configureDependencyInjection() {
log.info("Configuring dependency injection")
val kasDb = configureCoreDatabase()
dependencies {
provide("kasDb") { kasDb }
}
}
@@ -0,0 +1,34 @@
package net.keinekekse.app.core.config
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.*
import org.jetbrains.exposed.v1.core.DatabaseConfig
import org.jetbrains.exposed.v1.jdbc.Database
fun Application.configureCoreDatabase(): Database {
log.info("Configuring database")
val config = HikariConfig().apply {
dataSourceClassName = "org.postgresql.ds.PGSimpleDataSource"
username = getKeyFromConfiguration("postgres.kas_schema")
password = getKeyFromConfiguration("postgres.kas_pwd")
addDataSourceProperty("serverName", getKeyFromConfiguration("postgres.host", "localhost"))
addDataSourceProperty("portNumber", getKeyFromConfiguration("postgres.port", "5432"))
addDataSourceProperty("databaseName", username)
maximumPoolSize = 6
isReadOnly = false
transactionIsolation = "TRANSACTION_SERIALIZABLE"
}
val dataSource = HikariDataSource(config)
return Database.connect(
datasource = dataSource,
databaseConfig = DatabaseConfig {
// set other parameters here
}
)
}
@@ -0,0 +1,7 @@
package net.keinekekse.app.core.config
import io.ktor.server.application.*
fun Application.configureHtmlDsl() {
log.info("Configuring html dsl")
}
@@ -0,0 +1,44 @@
package net.keinekekse.app.core.config
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.plugins.cachingheaders.*
import io.ktor.server.plugins.compression.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.plugins.forwardedheaders.*
import net.keinekekse.app.VERSION
fun Application.configureHttp() {
log.info("Configuring HTML")
log.info(" *** ${VERSION.description} ${VERSION.fullVersion} - ${VERSION.copyright} ***")
install(CachingHeaders) {
options { call, outgoingContent ->
when (outgoingContent.contentType?.withoutParameters()) {
ContentType.Text.CSS -> CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 24 * 60 * 60))
else -> null
}
}
}
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
allowHeader("MyCustomHeader")
allowHost("kas.keinekekse.net")
allowHost("kec.keinekekse.net")
// anyHost() // Don't do this in production if possible. Try to limit it.
}
install(Compression)
install(DefaultHeaders) {
header("X-Engine", "Ktor") // will send this header with each response
}
install(ForwardedHeaders) // WARNING: for security, do not include this if not behind a reverse proxy
install(XForwardedHeaders)
}
@@ -0,0 +1,43 @@
package net.keinekekse.app.core.config
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.html.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.utils.io.*
import net.keinekekse.app.core.ui.adminPage
import net.keinekekse.app.core.ui.landingPage
@OptIn(ExperimentalKtorApi::class)
fun Application.configureRouting(keycloakSession: KeycloakSession) {
log.info("Configuring core routing")
routing {
staticResources("/", "/web")
staticResources("/static", "static")
authenticateWithOptional(keycloakSession) {
get("/") {
call.respondHtml {
// ist ein User angemeldet?
val loggedOn = call.principalOrNull != null
landingPage(loggedOn)
}
}
get("/admin") {
call.respondHtml {
adminPage()
}
}
}
get("/json/kotlinx-serialization") {
call.respond(mapOf("hello" to "world"))
}
}
}
@@ -0,0 +1,196 @@
package net.keinekekse.app.core.config
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.oidc.*
import io.ktor.server.plugins.di.*
import io.ktor.server.response.*
import io.ktor.server.sessions.*
import io.ktor.utils.io.*
import net.keinekekse.app.core.auth.AppPrincipal
import net.keinekekse.app.core.auth.UserRoles
import java.io.File
import java.security.SecureRandom
import java.util.*
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalKtorApi::class)
typealias KeycloakSession = AuthenticationSchemeWithRoles<AppPrincipal, UserRoles, SessionContext<OidcToken.Id>, AuthenticationScheme<AppPrincipal, SessionContext<OidcToken.Id>>>
@OptIn(ExperimentalKtorApi::class)
suspend fun Application.configureSecurity() {
log.info("configure Security")
// install(CSRF) {
// // tests Origin is an expected value
// allowOrigin("http://localhost:8080")
//
// // tests Origin matches Host header
// originMatchesHost()
//
// // custom header checks
// checkHeader("X-CSRF-Token")
// }
fun generateRandomString(): String {
val bytes = ByteArray(64)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
val keycloakUrl = getKeyFromConfiguration("keycloak.url")
val keycloakRealm = getKeyFromConfiguration("keycloak.realm")
val keycloakClientId = getKeyFromConfiguration("keycloak.clientId")
val keycloakIssuer = "$keycloakUrl/realms/$keycloakRealm"
val keycloakBaseUrl = "$keycloakIssuer/protocol/openid-connect"
// werden automatisch gesetzt
// val authorizationEndpoint = "$keycloakBaseUrl/auth"
// val tokenEndpoint = "$keycloakBaseUrl/token"
// val certificatesEndpoint = "$keycloakBaseUrl/certs"
// TODO besser irgendwo speichern, damit es Server-Restarts überlebt
// val secret = "vncosiifikvjbaiorfhosdjbkjcfehjdc" //
val secret = generateRandomString()
// TODO Zufallswert extern speichern
val b: ByteArray = byteArrayOf()
val state = byteArrayOf(
1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
1, 2
)
val oidc = install(Oidc) { // OAuth2 OpenId w/PKCE
initialDiscoveryAttempts = 5
initialDiscoveryRetryDelay = 15.seconds
}
val keycloakIdentity = oidc.identityProvider("keycloak") {
issuer = keycloakIssuer
oauth {
clientId = keycloakClientId
clientSecret = secret
stateEncryptionKey = OidcStateEncryptionKey.of(state)
onAuthenticated { idToken ->
call.application.log.info("keycloak: onAuthenticated ${idToken.value}")
// == accessToken
println("keycloak claims ${idToken.claims}")
println(" expiresAt ${idToken.claims.expiresAt}")
println(" issuer ${idToken.claims.issuer}")
// issuer https://kec.keinekekse.net/realms/app-server-realm
println(" audience ${idToken.claims.audience}")
// audience [pkce-book-app]
println(" header ${idToken.claims.header}")
// header {"alg":"RS256","typ":"JWT","kid":"WEQgQnKpr4yjwbehpXIsz0HlW3u5pIA8ie3wv1qi-2I"}
println(" jwtId ${idToken.claims.jwtId}")
// jwtId 0bc5a09a-e1b1-d249-5990-aaf2970dd04f
println(" subject ${idToken.claims.subject}")
// subject b5b40fa3-468f-443c-8eb0-e220bdd353bc
println(" type ${idToken.claims.type}")
// type JWT
println(" payload ${idToken.claims.payload}")
// JWT as JSON (preferred_username -> UserID)
println("keycloak userInfo ${idToken.userInfo}")
println(" subject ${idToken.userInfo.subject}")
// subject b5b40fa3-468f-443c-8eb0-e220bdd353bc
println(" name ${idToken.userInfo.name}")
println(" familyName ${idToken.userInfo.familyName}")
println(" preferredUsername ${idToken.userInfo.preferredUsername}")
// preferredUsername hb42
// URI von der aus der Login ausgelöst wurde (default: "/")
// wird ggf. im authenticateWith-Block via onUnauthenticated(call) gesetzt
val redirect = getRedirectUri(call)
call.application.log.info("loginredirect cookie: $redirect")
// zurück zur Seite, die den Login angefordert hat (oder "/")
call.respondRedirect(redirect)
}
onAuthenticationFailed { cause ->
val reason = (cause as? AuthenticationFailedCause.Error)?.message
call.application.log.error("keycloak: Login failed: $reason")
call.respondRedirect(LOGIN_PATH)
}
logout {
call.application.log.info("keycloak: Logout")
call.respondRedirect("/")
}
sessions {
name = SESSION_NAME
// hier werden die keycloak tokens gespeichert
storage = directorySessionStorage(
File("/opt/app/.sessions") // liegt auf dem Volume
)
// hier ist nur cookie möglich (speichert sessionId)
cookie {
cookie.path = "/"
cookie.maxAgeInSeconds = 600 // length of session
}
// Macht Ärger, wenn die Session serverseitig abgelaufen ist und der
// Client über den Ablauf hinaus idle war. Der nächste Seitenaufruf
// triggert dann einen Refresh, dessen Antwort "400 - invalid_grant" Ktor
// leider mit einer Exception quittiert.
// (s. a. Exception-Handler in StatusPages.kt).
// Ohne läuft die Session einfach ohne Refresh ab, was nicht Sinn
// der Sache ist. Also nachsehen, ob abgelaufen und dann Login triggern.
val custom = OidcTokenRefreshStrategy.Custom { provider, token, now ->
val expiresAt = token.claims.expiresAt
call.application.log.info("#### Refreshing token $expiresAt - $now")
if (expiresAt != null && expiresAt <= now) {
// Token ist abgelaufen
// -> aktuelle Route merken, zum Login und zurück zur Route
call.application.log.info("#### redirect to login")
onUnauthorized(call)
call.application.log.info("#### exit refresh (!?)")
return@Custom null
}
val stale = expiresAt != null && expiresAt <= now + 1.minutes
val refreshToken = token.refreshToken
if (stale && refreshToken != null) {
call.application.log.info("#### Refreshing token with refresh token")
provider.refreshToken(refreshToken).idToken
} else {
token
}
}
tokenRefreshStrategy = custom
}
}
}
// log.info("keycloak issuer: ${keycloakIdentity.issuer}")
// log.info("keycloak name: ${keycloakIdentity.name}")
// log.info("keycloak session name: ${keycloakIdentity.session.name}")
/* Keycloak-Session
*
* Die Rollen des jeweiligen Users werden zwar von Keycloak geliefert
* (Keycloak: Client Scopes->"Roles"->Mappers->"realm roles"/ "client roles"->"Add to ID token")
* allerdings werden sie nicht automatisch in die Session übernommen. Deshalb müssen
* zunächst die Rollen aus dem ID-Token extrahiert werden. Das wird in AppPrincipal erledigt.
* Das Objekt übernimmt zudem alle sonstigen für die Anwendung relevanten Userdaten.
* Mit .mapPrincipal() wird es dann als weiterer Principal in der Session übernommen und mit
* .withRoles() werden die extrahierten Rollen in die Session übernommen.
*
* Fürs Handling ist dabei wichtig, wie auf die AppPrincipal-Instanz im Routing-Kontext
* zugegriffen werden kann:
* val appPrincipal = call.principal // ohne () aufrufen, wird bei .mapPrincipal() als
* // Attribut angelegt!
* val oicdPrincipal = call.principal<OicdToken.Id>() // der automatisch angelegte principal
*/
val keycloakSession = keycloakIdentity.session
.mapPrincipal { idToken -> AppPrincipal(idToken) }
.withRoles { principal -> principal.roles }
dependencies {
provide<OidcProvider> { keycloakIdentity }
provide<KeycloakSession> { keycloakSession }
}
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.core.config
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
fun Application.configureSerialization() {
log.info("Configuring serialization")
install(ContentNegotiation) {
json(
json = kotlinx.serialization.json.Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
}
)
}
}
@@ -0,0 +1,96 @@
package net.keinekekse.app.core.config
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.html.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import kotlinx.html.a
import kotlinx.html.div
import kotlinx.html.h1
import kotlinx.html.span
import net.keinekekse.app.core.ui.*
fun Application.configureStatusPages() {
log.info("Configuring status pages")
install(StatusPages) {
status(HttpStatusCode.Forbidden) {
// TODO Hinweis an den User?
call.application.log.warn("403 Forbidden - redirect to /")
call.respondRedirect("/")
}
status(HttpStatusCode.Unauthorized) {
call.application.log.warn("401 Unauthorized - redirect to /")
// für Redirect nach Login (Cookie läuft nach 2 Minuten ab!)
setRedirectCookie(call)
call.respondHtml {
mainApplicationPage {
div(classes = "$cssFlexColumnCenter w-full pt-10") {
h1(classes = "m-8 px-8 text-xl font-bold $textMainBody") {
+"Fehlende Berechtigung für die Seite "
span(classes = cssCode) {
+call.request.path()
}
}
div(classes = "m-8 inline-flex flex-row") {
div(classes = "px-8") {
a(classes = cssMainLink) {
href = "/"
+"Zurück zur Startseite"
}
}
div(classes = "ml-4 px-8") {
a(classes = cssMainLink) {
href = LOGIN_PATH
+"Anmelden"
}
}
}
}
}
}
}
exception<Throwable> { call, cause ->
call.application.log.error("+++ Exception ", cause)
// Session-Cookie verhindert u. U. die Weiterleitung zur Startseite
// also hier löschen (ein Cookie mit httponly=true kann im Browser
// nicht gelöscht werden).
call.response.cookies.append(
name = SESSION_NAME,
value = "",
maxAge = 0,
path = "/",
domain = null,
secure = true,
httpOnly = true
)
call.respondHtml {
mainApplicationPage {
div(classes = "inline-flex flex-col $cssMainWidth") {
h1(classes = "m-8 text-xl font-bold $textMainBody") {
+"Ein Fehler ist aufgetreten"
}
div(classes = "p-2 font-bold $textMainBody") {
+(cause.message ?: "")
}
div(classes = "flex-auto overflow-auto mt-2 p-2 font-mono $textMainBody") {
+cause.stackTraceToString()
}
div(classes = "m-8") {
a(classes = cssMainLink) {
href = "/"
+"Zurück zur Startseite"
}
}
}
}
}
}
}
}
@@ -0,0 +1,25 @@
package net.keinekekse.app.core.ui
import kotlinx.html.*
import net.keinekekse.app.books.ui.materialSymbol
fun HTML.adminPage() {
val title: String = "Configuration"
head {
stdHead(title)
}
body {
div(classes = "m-4") {
a(classes = "px-2 font-bold text-sky-500 hover:text-sky-700 hover:bg-sky-100 cursor-pointer") {
href = "/"
materialSymbol("Home")
+"Home"
}
}
div(classes = "m-4") {
+"Admin Page"
}
}
}
@@ -0,0 +1,74 @@
package net.keinekekse.app.core.ui
import io.ktor.util.logging.*
import io.ktor.utils.io.*
import kotlinx.html.*
import net.keinekekse.app.VERSION_books
import net.keinekekse.app.books.ui.materialSymbol
import net.keinekekse.app.books.ui.navBooks
import net.keinekekse.app.core.config.LOGIN_PATH
import net.keinekekse.app.core.config.LOGOUT_PATH
val LOG = KtorSimpleLogger("net.keinekekse.app.core.ui.LandingPage")
@OptIn(ExperimentalKtorApi::class)
fun HTML.landingPage(loggedOn: Boolean) {
// val String = "Ktor Application Server"
LOG.info("LandingPage loggedOn $loggedOn")
mainApplicationPage {
div(classes = "$cssFlexColumnCenter w-full") {
div(classes = "p-8") {
a(classes = "inline-flex $cssMainLink") {
href = navBooks.second
img(classes = "h-14 my-2") {
src = "/static/books.png"
}
div(classes = "self-center text-right ml-3") {
div {
div {
+VERSION_books.description
}
div(classes = "text-xs") {
+VERSION_books.version
}
}
}
}
// a(classes = "p-2 text-xl font-bold text-sky-500 hover:text-sky-700 hover:bg-sky-100 cursor-pointer") {
// href = "/books"
// +VERSION_books.description
// }
}
div(classes = "m-8") {
a(classes = cssMainLink) {
href = "/admin"
materialSymbol("Settings")
+" Configuration"
}
}
div(classes = "m-8") {
// a(classes = "p-2 font-bold text-sky-500 hover:text-sky-700 hover:bg-cyan-100 cursor-pointer") {
// href = "/oidc/keycloak/login"
// +"Login"
// }
var url = LOGIN_PATH
var method = FormMethod.get
var text = "Login"
if (loggedOn) {
url = LOGOUT_PATH // /logout ?
method = FormMethod.post
text = "Logout"
}
form(action = url, method = method) {
button(classes = cssMainButton) { // "ml-4 $cssMainLink disabled:text-slate-400 disabled:cursor-not-allowed") {
type = ButtonType.submit
+text
}
}
}
}
}
}
@@ -0,0 +1,59 @@
package net.keinekekse.app.core.ui
import kotlinx.html.*
import net.keinekekse.app.VERSION
import net.keinekekse.app.books.ui.cssButton
const val bgMainHeader = "bg-sky-300"
const val textMainHeader = "text-sky-900"
const val bgMainBody = "bg-cyan-50"
const val textMainBody = "text-sky-500"
const val textMainBodyHover = "hover:text-sky-700"
const val bgMainBodyHover = "hover:bg-sky-200"
const val cssMainWidth = "max-w-270"
const val cssFlexColumnCenter = "inline-flex flex-col items-center"
const val cssFlexRowCenter = "inline-flex flex-row justify-center"
const val cssFullscreen = "flex w-full h-screen overflow-hidden"
const val cssCode = "px-1 font-mono bg-slate-100 rounded-md border-slate-300 border-1"
const val cssMainLink =
"py-2 px-4 text-xl cursor-pointer font-bold $textMainBody $textMainBodyHover $bgMainBodyHover"
const val cssMainButton =
"text-xl font-bold $cssButton $textMainBody bg-sky-200 hover:bg-sky-400 hover:text-sky-50"
fun HTML.mainApplicationPage(block: DIV.() -> Unit = {}) {
head {
stdHead(VERSION.displayName)
}
body {
// Box
div(classes = "$cssFullscreen justify-center") {
// Head
div(classes = "$cssFlexColumnCenter m-10 w-8/10 h-9/10 overflow-hidden $cssMainWidth shadow-xl") {
h1(classes = "$cssFlexRowCenter w-full rounded-t-lg text-4xl font-bold py-3 px-6 $textMainHeader $bgMainHeader") {
a {
href = "https://ktor.io"
img(classes = "h-12 mr-4 ") {
src = "/static/ktor_icon.png"
}
}
div {
title = VERSION.fullVersion
+VERSION.description
}
}
// body
div(classes = "inline-flex $bgMainBody py-10 h-full overflow-hidden w-full rounded-b-lg") {
block()
}
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package net.keinekekse.app.core.ui
import kotlinx.html.*
fun HEAD.stdHead(title: String = "", importStyles: Boolean = true) {
title(title)
meta(name = "viewport", content = "width=device-width, initial-scale=1")
script(src = "/web.js") {}
script {
unsafe {
raw(
"""
function toggleField(hideObj,showObj){
hideObj.disabled=true;
hideObj.style.display='none';
showObj.disabled=false;
showObj.style.display='inline';
showObj.focus();
}
""".trimIndent()
)
}
}
// alle weiteren Stylesheets werden in books.css importiert
if (importStyles)
link(rel = "stylesheet", href = "/static/styles/books.css")
}
@@ -0,0 +1,72 @@
package net.keinekekse.app.app.zz
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.sql.Connection
import java.sql.Statement
@Serializable
data class City(val name: String, val population: Int)
class CityService(private val connection: Connection) {
companion object {
private const val CREATE_TABLE_CITIES =
"CREATE TABLE CITIES (ID SERIAL PRIMARY KEY, NAME VARCHAR(255), POPULATION INT);"
private const val SELECT_CITY_BY_ID = "SELECT name, population FROM cities WHERE id = ?"
private const val INSERT_CITY = "INSERT INTO cities (name, population) VALUES (?, ?)"
private const val UPDATE_CITY = "UPDATE cities SET name = ?, population = ? WHERE id = ?"
private const val DELETE_CITY = "DELETE FROM cities WHERE id = ?"
}
init {
val statement = connection.createStatement()
statement.executeUpdate(CREATE_TABLE_CITIES)
}
// Create new city
suspend fun create(city: City): Int = withContext(Dispatchers.IO) {
val statement = connection.prepareStatement(INSERT_CITY, Statement.RETURN_GENERATED_KEYS)
statement.setString(1, city.name)
statement.setInt(2, city.population)
statement.executeUpdate()
val generatedKeys = statement.generatedKeys
if (generatedKeys.next()) {
return@withContext generatedKeys.getInt(1)
} else {
throw Exception("Unable to retrieve the id of the newly inserted city")
}
}
// Read a city
suspend fun read(id: Int): City = withContext(Dispatchers.IO) {
val statement = connection.prepareStatement(SELECT_CITY_BY_ID)
statement.setInt(1, id)
val resultSet = statement.executeQuery()
if (resultSet.next()) {
val name = resultSet.getString("name")
val population = resultSet.getInt("population")
return@withContext City(name, population)
} else {
throw Exception("Record not found")
}
}
// Update a city
suspend fun update(id: Int, city: City) = withContext(Dispatchers.IO) {
val statement = connection.prepareStatement(UPDATE_CITY)
statement.setString(1, city.name)
statement.setInt(2, city.population)
statement.setInt(3, id)
statement.executeUpdate()
}
// Delete a city
suspend fun delete(id: Int) = withContext(Dispatchers.IO) {
val statement = connection.prepareStatement(DELETE_CITY)
statement.setInt(1, id)
statement.executeUpdate()
}
}
+58
View File
@@ -0,0 +1,58 @@
package net.keinekekse.app.core.zz
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import net.keinekekse.app.app.zz.ExposedUser
import net.keinekekse.app.app.zz.ExposedUserService
import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase
// TODO kann weg sobald sich die Beispiele erledigt habne
suspend fun Application.configureExposed() {
val database = R2dbcDatabase.connect(
url = "r2dbc:h2:file:///./h2",
user = "root",
password = "",
)
val userService = ExposedUserService(database).also {
it.createSchema()
}
routing {
// Create user
post("/users") {
val user = call.receive<ExposedUser>()
val id = userService.create(user)
call.respond(HttpStatusCode.Created, id)
}
// Read user
get("/users/{id}") {
val id = call.parameters["id"]?.toUInt() ?: throw IllegalArgumentException("Invalid ID")
val user = userService.read(id)
if (user != null) {
call.respond(HttpStatusCode.OK, user)
} else {
call.respond(HttpStatusCode.NotFound)
}
}
// Update user
put("/users/{id}") {
val id = call.parameters["id"]?.toUInt() ?: throw IllegalArgumentException("Invalid ID")
val user = call.receive<ExposedUser>()
userService.update(id, user)
call.respond(HttpStatusCode.NoContent)
}
// Delete user
delete("/users/{id}") {
val id = call.parameters["id"]?.toUInt() ?: throw IllegalArgumentException("Invalid ID")
userService.delete(id)
call.respond(HttpStatusCode.NoContent)
}
}
}
@@ -0,0 +1,5 @@
package net.keinekekse.app.app.zz
fun interface GreetingService {
fun sayHello(): String
}
@@ -0,0 +1,95 @@
package net.keinekekse.app.app.zz
import io.ktor.htmx.html.*
import io.ktor.utils.io.*
import kotlinx.html.*
import java.nio.file.Paths
import kotlin.io.path.exists
import kotlin.io.path.useLines
import kotlin.random.Random
fun HTML.leaderboardPage(random: Random) {
head {
title("HTMX Example")
script(src = "/web.js") {}
link(rel = "stylesheet", href = "/leaderboard.css")
}
body {
h1 {
+"Leaderboard"
}
table {
id = "leaderboard"
thead {
tr {
th { +"Alias" }
th { +"Score" }
}
}
tbody {
randomRows(random)
}
}
h2 {
id = "total-count"
+"Total: 10"
}
}
}
fun TBODY.randomRows(random: Random) {
for (contestant in generateSequence { random.nextContestant() }.take(10)) {
tr {
td { +contestant.alias }
td { +contestant.score.toString() }
}
}
loadMoreRows()
}
@OptIn(ExperimentalKtorApi::class)
fun TBODY.loadMoreRows() {
tr {
id = "replaceMe"
td {
colSpan = "3"
style = "text-align: center;"
button {
attributes.hx {
get = "/more-rows"
target = "#replaceMe"
swap = "outerHTML"
trigger = "click"
select = "tr"
}
+"Load More..."
}
}
}
}
private val dictionary: List<String> by lazy {
listOf("/usr/share/dict/words", "/usr/dict/words").map(Paths::get).firstOrNull {
it.exists()
}?.let { dictionaryFile ->
dictionaryFile.useLines(Charsets.UTF_8) { lines ->
lines.filter { it.length >= 4 }.toList()
}
} ?: listOf("red", "blue", "green", "yellow", "pink", "violet", "black")
}
fun Random.nextContestant(): Contestant =
Contestant(
nextAlias(),
nextInt(1_000_000)
)
fun Random.nextAlias(): String =
dictionary[nextInt(dictionary.size - 1)]
data class Contestant(
val alias: String,
val score: Int,
)
@@ -0,0 +1,96 @@
package net.keinekekse.app.core.zz
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import net.keinekekse.app.app.zz.City
import net.keinekekse.app.app.zz.CityService
import java.sql.Connection
import java.sql.DriverManager
// TODO kann weg sobald sich die Beispiele erledigt haben
fun Application.configurePostgres() {
val dbConnection: Connection = connectToPostgres(embedded = true)
val cityService = CityService(dbConnection)
// dependency injection
// dependencies {
// provide("testDb") { dbConnection }
// }
routing {
// Create city
post("/cities") {
val city = call.receive<City>()
val id = cityService.create(city)
call.respond(HttpStatusCode.Created, id)
}
// Read city
get("/cities/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
try {
val city = cityService.read(id)
call.respond(HttpStatusCode.OK, city)
} catch (e: Exception) {
call.respond(HttpStatusCode.NotFound)
}
}
// Update city
put("/cities/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
val user = call.receive<City>()
cityService.update(id, user)
call.respond(HttpStatusCode.OK)
}
// Delete city
delete("/cities/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
cityService.delete(id)
call.respond(HttpStatusCode.OK)
}
}
}
/**
* Makes a connection to a Postgres database.
*
* In order to connect to your running Postgres process,
* please specify the following parameters in your configuration file:
* - postgres.url -- Url of your running database process.
* - postgres.user -- Username for database connection
* - postgres.password -- Password for database connection
*
* If you don't have a database process running yet, you may need to [download]((https://www.postgresql.org/download/))
* and install Postgres and follow the instructions [here](https://postgresapp.com/).
* Then, you would be able to edit your url, which is usually "jdbc:postgresql://host:port/database", as well as
* user and password values.
*
*
* @param embedded -- if [true] defaults to an embedded database for tests that runs locally in the same process.
* In this case you don't have to provide any parameters in configuration file, and you don't have to run a process.
*
* @return [Connection] that represent connection to the database. Please, don't forget to close this connection when
* your application shuts down by calling [Connection.close]
* */
fun Application.connectToPostgres(embedded: Boolean): Connection {
Class.forName("org.postgresql.Driver")
if (embedded) {
log.info("Using embedded H2 database for testing; replace this flag to use postgres")
return DriverManager.getConnection("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", "root", "")
} else {
val url = environment.config.property("postgres.url").getString()
log.info("Connecting to postgres database at $url")
val user = environment.config.property("postgres.user").getString()
val password = environment.config.property("postgres.password").getString()
return DriverManager.getConnection(url, user, password)
}
}
@@ -0,0 +1,3 @@
package net.keinekekse.app.app.zz
class UserSession(accessToken: String)
@@ -0,0 +1,56 @@
package net.keinekekse.app.app.zz
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.singleOrNull
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.dao.id.UIntIdTable
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.r2dbc.*
import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction
@Serializable
data class ExposedUser(val name: String, val age: Int)
class ExposedUserService(val database: R2dbcDatabase) {
object Users : UIntIdTable() {
val name = varchar("name", length = 50)
val age = integer("age")
}
suspend fun createSchema() {
suspendTransaction(database) {
SchemaUtils.create(Users)
}
}
suspend fun create(user: ExposedUser): UInt = suspendTransaction(database) {
val newRecord = Users.insert {
it[name] = user.name
it[age] = user.age
}
newRecord[Users.id].value
}
suspend fun read(id: UInt): ExposedUser? {
return suspendTransaction(database) {
Users.selectAll()
.where { Users.id eq id }
.map { ExposedUser(it[Users.name], it[Users.age]) }
.singleOrNull()
}
}
suspend fun update(id: UInt, user: ExposedUser) {
suspendTransaction(database) {
Users.update({ Users.id eq id }) {
it[name] = user.name
it[age] = user.age
}
}
}
suspend fun delete(id: UInt) {
suspendTransaction(database) { Users.deleteWhere { Users.id.eq(id) } }
}
}
+5
View File
@@ -0,0 +1,5 @@
package net.keinekekse.app
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
@@ -0,0 +1,33 @@
ktor:
deployment:
port: 7770
application:
modules:
- net.keinekekse.app.core.config.HttpKt.configureHttp
- net.keinekekse.app.core.config.SerializationKt.configureSerialization
- net.keinekekse.app.core.config.StatusPagesKt.configureStatusPages
- net.keinekekse.app.core.config.HtmlDslKt.configureHtmlDsl
- net.keinekekse.app.core.config.SecurityKt.configureSecurity
- net.keinekekse.app.core.config.DatabaseKt.configureCoreDatabase
- net.keinekekse.app.core.config.DIKt.configureDependencyInjection
- net.keinekekse.app.core.config.RoutingKt.configureRouting
- net.keinekekse.app.books.config.DatabaseKt.configureBooksDatabase
- net.keinekekse.app.books.config.DIKt.configureBookDependencyInjection
- net.keinekekse.app.books.config.RoutingKt.configureRouting
postgres:
# DB-Params werden vom Container als env vars gesetzt
host: ${DB_HOST}
port: ${DB_PORT}
books_schema: ${BOOKS_SCHEMA}
books_pwd: ${BOOKS_DB_PASSWORD}
kas_schema: ${KAS_SCHEMA}
kas_pwd: ${KAS_DB_PASSWORD}
keycloak:
url: "https://kec.keinekekse.net"
realm: "app-server-realm"
clientId: "pkce-book-app"
kas:
serverUrl: "https://kas.keinekekse.net"
+18
View File
@@ -0,0 +1,18 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
<!-- div Schwätzer raushalten -->
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.apache.hc" level="INFO"/>
<logger name="net.keinekekse.app" level="DEBUG"/>
<logger name="io.ktor.server.auth.oidc" level="TRACE"/>
</configuration>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,7 @@
<html>
<head>
</head>
<body>
<h1>Hello Ktor!</h1>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,21 @@
function onScanSuccess (decodedText, decodedResult) {
console.log(`Gescannter Code: ` + decodedText)
// Scanner stoppen, sobald ein Code erkannt wurde
html5QrcodeScanner.clear()
// HTMX AJAX-Aufruf auslösen, um Buchdaten vom Backend zu laden:
htmx.ajax('GET', '/books/isbnlookup/' + encodeURIComponent(decodedText), {
target: '#book-result',
swap : 'innerHTML'
})
}
function renderScanner () {
const html5QrcodeScanner = new Html5QrcodeScanner(
'reader',
{fps: 10, qrbox: {width: 250, height: 150}},
/* verbose= */ false
)
html5QrcodeScanner.render(onScanSuccess)
}
@@ -0,0 +1,31 @@
/* Google Material Symbols (die Datei landet beim Build im web root) */
@import url("/static/styles/google.css");
/* Bootstrap Icon - Alternative zu Google, Datei ist etwa halb so groß */
/* @import url("/bootstrap-icons.min.css"); */
/* Tailwind - .css wird beim Build generiert */
@import url("/static/styles/tailwind.css");
/*
Fixed Table Header -> THEAD
zusätzlich passende background-color setzten
*/
thead.fixed-table-header {
position: sticky;
inset-block-start: 0;
z-index: 1;
}
/*
Den lästigen blauen Rand abschalten, muss bei interaktiven Elementen
gezielt gesetzt werden (i. A. analog hover).
*/
@layer utilities {
*:focus,
*:focus-visible {
outline: none;
border: 0;
}
}
@@ -0,0 +1,35 @@
/*
Material Symbols
----------------
Ansehen unter: https://fonts.google.com/icons
Doku: https://developers.google.com/fonts/docs/material_symbols?hl=de
> git clone https://github.com/google/material-design-icons
> cp material-design-icons/variablefont/*.woff2 .
> rm -rf material-design-icons
MaterialSymbolsOutline*.woff2 nach resources/material-symbols.woff kopieren
<span class="material-symbols-outlined">symbol</span>
bzw.
span(classes = "material-symbols-outlined") { +"symbol" }
*/
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
src: url(/static/fonts/material-symbols.woff2) format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
}
+15
View File
@@ -0,0 +1,15 @@
package net.keinekekse.app
class ServerTest {
// @Test
// klappt nicht, da die Container-Umgebung fehlt (incl. DB)
// -> evtl. später mal checken
// fun `test root endpoint`() = testApplication {
// // loads default configuration
// configure()
// // verify server root returns 200
// assertEquals(HttpStatusCode.OK, client.get("/").status)
// }
}