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

+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")
}
}
}
}
}
}
}