initial commit
This commit is contained in:
commit
d84861949f
92 files changed
+8425
No files matched your search
+21
@@ -0,0 +1,21 @@
|
|||||||
|
*.iml
|
||||||
|
.kotlin
|
||||||
|
.gradle
|
||||||
|
**/build/
|
||||||
|
xcuserdata
|
||||||
|
!src/**/build/
|
||||||
|
local.properties
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
*.xcodeproj/*
|
||||||
|
!*.xcodeproj/project.pbxproj
|
||||||
|
!*.xcodeproj/xcshareddata/
|
||||||
|
!*.xcodeproj/project.xcworkspace/
|
||||||
|
!*.xcworkspace/contents.xcworkspacedata
|
||||||
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
|
node_modules/
|
||||||
|
/div/
|
||||||
|
/server/h2.mv.db
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# core
|
||||||
|
|
||||||
|
This project was created using the [Ktor Project Generator](https://start.ktor.io).
|
||||||
|
|
||||||
|
Here are some useful links to get you started:
|
||||||
|
|
||||||
|
* [Ktor Documentation](https://ktor.io/docs/home.html)
|
||||||
|
* [Ktor GitHub page](https://github.com/ktorio/ktor)
|
||||||
|
* [Ktor Slack chat](https://app.slack.com/client/T09229ZC6/C0A974TJ9). [Request an invite](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
Here's a list of features included in this project:
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
|---------------------------------------------------------------------------------------|------------------------------------------------------------------------------------|
|
||||||
|
| [Status Pages](https://start.ktor.io/p/io.ktor/server-status-pages) | Provides exception handling for routes |
|
||||||
|
| [kotlinx.serialization](https://start.ktor.io/p/io.ktor/server-kotlinx-serialization) | Handles JSON serialization using kotlinx.serialization library |
|
||||||
|
| [Content Negotiation](https://start.ktor.io/p/io.ktor/server-content-negotiation) | Provides automatic content conversion according to Content-Type and Accept headers |
|
||||||
|
| [Authentication](https://start.ktor.io/p/io.ktor/server-auth) | Provides extension point for handling the Authorization header |
|
||||||
|
| [Static Content](https://start.ktor.io/p/io.ktor/server-static-content) | Serves static files from defined locations |
|
||||||
|
| [Caching Headers](https://start.ktor.io/p/io.ktor/server-caching-headers) | Provides options for responding with standard cache-control headers |
|
||||||
|
| [HTMX](https://start.ktor.io/p/io.ktor/server-htmx) | Includes HTMX for front-end scripting |
|
||||||
|
| [HTML DSL](https://start.ktor.io/p/org.jetbrains/server-html-dsl) | Generates HTML from Kotlin DSL |
|
||||||
|
| [PostgreSQL](https://start.ktor.io/p/org.jetbrains/server-postgres) | Adds Postgres database support |
|
||||||
|
| [Exposed](https://start.ktor.io/p/org.jetbrains/server-exposed) | Adds Exposed database to your application |
|
||||||
|
| [Dependency Injection](https://start.ktor.io/p/io.ktor/server-di) | Enables dependency injection for your server |
|
||||||
|
| [CSRF](https://start.ktor.io/p/io.ktor/server-csrf) | Cross-site request forgery mitigation |
|
||||||
|
| [Authentication OAuth](https://start.ktor.io/p/io.ktor/server-auth-oauth) | Handles OAuth Bearer authentication scheme |
|
||||||
|
| [Authentication JWT](https://start.ktor.io/p/io.ktor/server-auth-jwt) | Handles JSON Web Token (JWT) bearer authentication scheme |
|
||||||
|
| [Forwarded Headers](https://start.ktor.io/p/io.ktor/server-forwarded-header-support) | Allows handling proxied headers (X-Forwarded-*) |
|
||||||
|
| [Default Headers](https://start.ktor.io/p/io.ktor/server-default-headers) | Adds a default set of headers to HTTP responses |
|
||||||
|
| [CORS](https://start.ktor.io/p/io.ktor/server-cors) | Enables Cross-Origin Resource Sharing (CORS) |
|
||||||
|
| [Compression](https://start.ktor.io/p/io.ktor/server-compression) | Compresses responses using encoding algorithms like GZIP |
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
This project includes the following modules:
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| | null |
|
||||||
|
| server | null |
|
||||||
|
| web | null |
|
||||||
|
|
||||||
|
## Building & Running
|
||||||
|
|
||||||
|
To build or run the project, use one of the following tasks:
|
||||||
|
|
||||||
|
| Task | Description |
|
||||||
|
|---------------------------|-------------------|
|
||||||
|
| `./gradlew :server:test` | Run the tests |
|
||||||
|
| `./gradlew :server:build` | Build the project |
|
||||||
|
| `./gradlew :server:run` | Run the server |
|
||||||
|
|
||||||
|
If the server starts successfully, you'll see the following output:
|
||||||
|
|
||||||
|
```
|
||||||
|
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
|
||||||
|
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
kotlin.code.style=official
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[versions]
|
||||||
|
appsrv = "0.2.0-alpha"
|
||||||
|
books = "0.2.0-alpha"
|
||||||
|
|
||||||
|
exposed = "1.5.0"
|
||||||
|
h2 = "2.4.240"
|
||||||
|
h2-r2dbc = "1.1.0.RELEASE"
|
||||||
|
hikari = "7.1.0"
|
||||||
|
html5Qrcode = "2.3.8"
|
||||||
|
htmx = "2.0.8"
|
||||||
|
jvm = "25"
|
||||||
|
koin = "4.2.2"
|
||||||
|
koinAnnotations = "2.3.1"
|
||||||
|
kotlin = "2.4.20"
|
||||||
|
kotlinx-html = "0.12.0"
|
||||||
|
kspPlugin = "2.3.12"
|
||||||
|
logback = "1.6.5}"
|
||||||
|
postgresql = "42.7.13"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
|
||||||
|
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
|
||||||
|
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
|
||||||
|
|
||||||
|
exposed-r2dbc = { module = "org.jetbrains.exposed:exposed-r2dbc", version.ref = "exposed" }
|
||||||
|
h2database-h2 = { module = "com.h2database:h2", version.ref = "h2" }
|
||||||
|
h2database-r2dbc = { module = "io.r2dbc:r2dbc-h2", version.ref = "h2-r2dbc" }
|
||||||
|
|
||||||
|
hikaricp = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
|
||||||
|
koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
|
||||||
|
koin-logger = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" }
|
||||||
|
koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koinAnnotations" }
|
||||||
|
koin-ksp = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koinAnnotations" }
|
||||||
|
kotlinx-kotlinxHtml = { module = "org.jetbrains.kotlinx:kotlinx-html", version.ref = "kotlinx-html" }
|
||||||
|
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
|
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "kspPlugin" }
|
||||||
Vendored
BIN
Binary file not shown.
+9
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
File diff suppressed because it is too large.
Load diff
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
function switch_srv() {
|
||||||
|
local cmd=$1
|
||||||
|
ssh container@nathan systemctl --user $cmd kas_srv
|
||||||
|
return $?
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy_to_server(){
|
||||||
|
rsync -rti --delete --stats server/build/install/server/* container@nathan:/var/containers/vol/kas/server
|
||||||
|
return $?
|
||||||
|
}
|
||||||
|
|
||||||
|
./gradlew :server:installDist && switch_srv stop && copy_to_server && switch_srv start
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(ktorLibs.plugins.ktor)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
// alias(libs.plugins.ksp)
|
||||||
|
alias(masterLibs.plugins.versionPlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "net.keinekekse.app"
|
||||||
|
version = libs.versions.appsrv.get()
|
||||||
|
description = "Ktor Application Server"
|
||||||
|
extra["displayname"] = "Application Server"
|
||||||
|
extra["copyright"] = "Copyright (c) 2026 Hartmut Bauer"
|
||||||
|
|
||||||
|
versionConfig {
|
||||||
|
versionClassFile.set(layout.projectDirectory.file("src/main/kotlin/Version.kt"))
|
||||||
|
versionPackageName.set("net.keinekekse.app")
|
||||||
|
|
||||||
|
modules.set(
|
||||||
|
mapOf(
|
||||||
|
"books" to mapOf(
|
||||||
|
"version" to libs.versions.books.get(),
|
||||||
|
"displayName" to "Books-DB",
|
||||||
|
"description" to "Books + Comics Database"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass = "io.ktor.server.netty.EngineMain"
|
||||||
|
|
||||||
|
// java >= 24
|
||||||
|
// WARNING: A restricted method in java.lang.System has been called
|
||||||
|
// WARNING: java.lang.System::loadLibrary has been called by io.netty.util.internal.NativeLibraryUtil in an unnamed module
|
||||||
|
// (file:/Users/hb/.gradle/caches/modules-2/files-2.1/io.netty/netty-common/4.2.16.Final/c2bc7fa5acfa2afc777e944d84f0aa65559cc81e/netty-common-4.2.16.Final.jar)
|
||||||
|
// WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
|
||||||
|
// WARNING: Restricted methods will be blocked in a future release unless native access is enabled
|
||||||
|
applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
|
||||||
|
}
|
||||||
|
|
||||||
|
val buildTailwind = tasks.register("buildTailwind", Exec::class) {
|
||||||
|
description = "build tailwind css file"
|
||||||
|
workingDir = rootProject.layout.projectDirectory.dir("tailwind").asFile
|
||||||
|
// direkt ins buildDir schreiben, weil Task processResources hier zu spät kommt
|
||||||
|
commandLine(
|
||||||
|
"/Users/hb/.npm-packages/bin/npx",
|
||||||
|
"@tailwindcss/cli",
|
||||||
|
"-i",
|
||||||
|
"./input.css",
|
||||||
|
"-o",
|
||||||
|
layout.buildDirectory.dir("resources/main/static").get()
|
||||||
|
.toString() + "/styles/tailwind.css",
|
||||||
|
"--minify"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val copyWebDistToServerResources = tasks.register("copyWebDistToServerResources", Copy::class) {
|
||||||
|
description = "copy web dist to server resources"
|
||||||
|
dependsOn(project(":web").tasks.named("jsBrowserDistribution"))
|
||||||
|
from(project(":web").layout.buildDirectory.dir("dist/js/productionExecutable"))
|
||||||
|
into(layout.buildDirectory.dir("resources/main/web"))
|
||||||
|
|
||||||
|
// from(rootProject.layout.projectDirectory.file("build/js/node_modules/bootstrap/dist/css/bootstrap.min.css"))
|
||||||
|
// into(layout.buildDirectory.dir("resources/main/web"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("processResources") {
|
||||||
|
dependsOn(buildTailwind)
|
||||||
|
dependsOn(copyWebDistToServerResources)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(libs.versions.jvm.get().toInt())
|
||||||
|
}
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(libs.versions.jvm.get()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":shared"))
|
||||||
|
implementation(masterLibs.utilLibrary)
|
||||||
|
|
||||||
|
implementation(ktorLibs.client.apache5)
|
||||||
|
implementation(ktorLibs.client.cio)
|
||||||
|
implementation(ktorLibs.client.core)
|
||||||
|
implementation(ktorLibs.htmx.html)
|
||||||
|
implementation(ktorLibs.serialization.kotlinx.json)
|
||||||
|
implementation(ktorLibs.server.auth)
|
||||||
|
implementation(ktorLibs.server.auth.oidc)
|
||||||
|
implementation(ktorLibs.server.auth.jwt)
|
||||||
|
implementation(ktorLibs.server.cachingHeaders)
|
||||||
|
implementation(ktorLibs.server.compression)
|
||||||
|
implementation(ktorLibs.server.config.yaml)
|
||||||
|
implementation(ktorLibs.server.contentNegotiation)
|
||||||
|
implementation(ktorLibs.server.core)
|
||||||
|
implementation(ktorLibs.server.cors)
|
||||||
|
implementation(ktorLibs.server.csrf)
|
||||||
|
implementation(ktorLibs.server.defaultHeaders)
|
||||||
|
implementation(ktorLibs.server.di)
|
||||||
|
implementation(ktorLibs.server.forwardedHeader)
|
||||||
|
implementation(ktorLibs.server.htmlBuilder)
|
||||||
|
implementation(ktorLibs.server.htmx)
|
||||||
|
implementation(ktorLibs.server.netty)
|
||||||
|
implementation(ktorLibs.server.statusPages)
|
||||||
|
implementation(ktorLibs.server.sessions)
|
||||||
|
implementation(libs.exposed.core)
|
||||||
|
implementation(libs.exposed.jdbc)
|
||||||
|
implementation(libs.exposed.dao)
|
||||||
|
implementation(libs.exposed.r2dbc)
|
||||||
|
implementation(libs.h2database.h2)
|
||||||
|
implementation(libs.h2database.r2dbc)
|
||||||
|
implementation(libs.kotlinx.kotlinxHtml)
|
||||||
|
implementation(libs.logback.classic)
|
||||||
|
implementation(libs.postgresql)
|
||||||
|
implementation(libs.hikaricp)
|
||||||
|
|
||||||
|
// implementation(libs.koin.ktor)
|
||||||
|
// implementation(libs.koin.logger)
|
||||||
|
// implementation(libs.koin.annotations)
|
||||||
|
// ksp(libs.koin.ksp)
|
||||||
|
|
||||||
|
// testImplementation(kotlin("test"))
|
||||||
|
// testImplementation(ktorLibs.server.testHost)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#auto generated
|
||||||
|
#Fri Oct 09 01:13:32 CEST 2026
|
||||||
|
build.number=533
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 <img src="...">
|
||||||
|
*
|
||||||
|
* @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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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 |
Binary file not shown.
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven("https://nexus.keinekekse.net/repository/maven-public/")
|
||||||
|
google {
|
||||||
|
mavenContent {
|
||||||
|
includeGroupAndSubgroups("androidx")
|
||||||
|
includeGroupAndSubgroups("com.android")
|
||||||
|
includeGroupAndSubgroups("com.google")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
maven("https://nexus.keinekekse.net/repository/maven-public/")
|
||||||
|
google {
|
||||||
|
mavenContent {
|
||||||
|
includeGroupAndSubgroups("androidx")
|
||||||
|
includeGroupAndSubgroups("com.android")
|
||||||
|
includeGroupAndSubgroups("com.google")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
versionCatalogs {
|
||||||
|
create("masterLibs") {
|
||||||
|
from("net.keinekekse:version-catalog:141")
|
||||||
|
}
|
||||||
|
create("ktorLibs").from("io.ktor:ktor-version-catalog:3.6.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "app"
|
||||||
|
|
||||||
|
include(":server")
|
||||||
|
include(":web")
|
||||||
|
include(":shared")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
jvm()
|
||||||
|
js {
|
||||||
|
browser()
|
||||||
|
binaries.executable()
|
||||||
|
}
|
||||||
|
commonMain.dependencies {
|
||||||
|
// implementation(project(":server"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package net.keinekekse.app.books
|
||||||
|
|
||||||
|
const val isbnScanResultPath = "/books/new/scan/fetch"
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
cd $(dirname $0)
|
||||||
|
npx @tailwindcss/cli -i ./input.css -o ../server/build/resources/main/web/tailwind.css
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss" source("../server/src");
|
||||||
Generated
+1061
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
|
"tailwindcss": "^4.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
js {
|
||||||
|
browser()
|
||||||
|
binaries.executable()
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
jsMain.dependencies {
|
||||||
|
implementation(npm("htmx.org", libs.versions.htmx.get()))
|
||||||
|
implementation(npm("html5-qrcode", libs.versions.html5Qrcode.get()))
|
||||||
|
// implementation(npm("@popperjs/core", "2.11.8"))
|
||||||
|
// implementation(npm("bootstrap", "5.3.8"))
|
||||||
|
// implementation(npm("tailwindcss", "*"))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":shared"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@file:JsModule("html5-qrcode")
|
||||||
|
@file:JsNonModule
|
||||||
|
|
||||||
|
package net.keinekekse.app
|
||||||
|
|
||||||
|
/* AI sagt:
|
||||||
|
In Kotlin/JS werden Named Exports eines npm-Pakets deklariert, indem die
|
||||||
|
Annotation auf Dateiebene (@file:JsModule) gesetzt wird. Dadurch weiß der
|
||||||
|
Compiler, dass jede Top-Level-Klasse in dieser Datei ein benannter Export
|
||||||
|
des Moduls ist.
|
||||||
|
*/
|
||||||
|
external class Html5QrcodeScanner(
|
||||||
|
elementId: String,
|
||||||
|
config: dynamic,
|
||||||
|
verbose: Boolean = definedExternally,
|
||||||
|
) {
|
||||||
|
fun render(
|
||||||
|
successCallback: (decodedText: String, decodedResult: dynamic) -> Unit,
|
||||||
|
errorCallback: ((errorMessage: String) -> Unit)? = definedExternally,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun clear(): dynamic
|
||||||
|
}
|
||||||
|
|
||||||
|
external class Html5Qrcode(
|
||||||
|
elementId: String,
|
||||||
|
config: dynamic = definedExternally,
|
||||||
|
) {
|
||||||
|
// Falls später benötigt
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package net.keinekekse.app
|
||||||
|
|
||||||
|
@JsModule("htmx.org")
|
||||||
|
@JsNonModule
|
||||||
|
external object htmx
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package net.keinekekse.app
|
||||||
|
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.browser.window
|
||||||
|
import net.keinekekse.app.books.isbnScanResultPath
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
window.asDynamic().renderScanner = ::renderScanner
|
||||||
|
|
||||||
|
// htmx reference is needed for import
|
||||||
|
console.log("htmx loaded ", htmx)
|
||||||
|
|
||||||
|
// event handling for htmx
|
||||||
|
var rowCount = 1
|
||||||
|
document.addEventListener("htmx:beforeSwap", {
|
||||||
|
document.getElementById("total-count")?.innerHTML = "Total: ${(++rowCount) * 10}"
|
||||||
|
})
|
||||||
|
// Scroll to the bottom of the page after adding content
|
||||||
|
document.addEventListener("htmx:afterSwap", {
|
||||||
|
val scrollHeight = document.body?.scrollHeight ?: 0
|
||||||
|
window.scrollTo(0.0, scrollHeight.toDouble())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Die fn wird in den Namensraum <project>.<package> veröffentlicht
|
||||||
|
-> web.net.keinekekse.app
|
||||||
|
Wenn die fn ohne den Rattenschwanz aufgerufen werden soll, muss sie
|
||||||
|
in main() so exportiert werden:
|
||||||
|
js("window.renderScanner = renderScanner")
|
||||||
|
oder
|
||||||
|
window.asDynamic().renderScanner = ::renderScanner
|
||||||
|
|
||||||
|
Des Weiteren benötigt sie ein DOM-Element (id="reader"), daher ist es sinnvoll,
|
||||||
|
sie erst bei geladenem DOM auszuführen:
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
renderScanner();
|
||||||
|
})
|
||||||
|
|
||||||
|
Beim Import von htmx wird der gesamte Import an ein Objekt namens "default"
|
||||||
|
gehängt. Da sich auch Ktor auf diesen Import verlässt, ist es hier besser,
|
||||||
|
zu kapitulieren und das Objekt als htmx.asDynamic().default.<whatever> zu
|
||||||
|
verwenden.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalJsExport::class)
|
||||||
|
@JsExport
|
||||||
|
fun renderScanner(scanTarget: String = "reader") {
|
||||||
|
// Ausgabebereich fürs Ergebnis zurücksetzen
|
||||||
|
document.getElementById("scan-result")?.innerHTML =
|
||||||
|
"<p>Bitte Barcode vor die Kamera halten...</p>"
|
||||||
|
|
||||||
|
// Scanner öffnen
|
||||||
|
val config = js("{ fps: 10, qrbox: { width: 300, height: 180 } }")
|
||||||
|
val scanner = Html5QrcodeScanner(scanTarget, config, /*debug*/ false)
|
||||||
|
|
||||||
|
// Ergebnis verarbeiten
|
||||||
|
scanner.render({ decodedText, _ ->
|
||||||
|
scanner.clear()
|
||||||
|
document.getElementById("scan-result")?.innerHTML =
|
||||||
|
"<p>Hole Daten für ISBN: $decodedText</p>"
|
||||||
|
console.log("Scanned: $decodedText")
|
||||||
|
// console.log("htmx: ", htmx)
|
||||||
|
htmx.asDynamic().default.ajax(
|
||||||
|
"GET",
|
||||||
|
"${isbnScanResultPath}/" + js("encodeURIComponent(decodedText)"),
|
||||||
|
js("{ target: '#scan-result', swap: 'innerHTML' }")
|
||||||
|
)
|
||||||
|
}, { errorMessage ->
|
||||||
|
console.log("Error: $errorMessage")
|
||||||
|
})
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Reference in new issue
Block a user