diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore index 524f096..60e5d42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,38 @@ -# Compiled class file -*.class +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ -# Log file -*.log +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ -# BlueJ files -*.ctxt +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ -# Mobile Tools for Java (J2ME) -.mtj.tmp/ +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* -replay_pid* +### VS Code ### +.vscode/ +/data/* diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..73f833d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,30 @@ +FROM gradle:jdk21-alpine AS build +WORKDIR /app + +# Nur die Build-Dateien kopieren, um Caching zu nutzen +COPY build.gradle settings.gradle gradlew ./ +COPY gradle/ ./gradle/ + +# Abhängigkeiten herunterladen (wird gecacht) +RUN gradle dependencies --no-daemon + +# Restlichen Source-Code kopieren und App bauen +COPY . . +RUN gradle clean build --no-daemon -x test + +# --- + +# --- + +# Runtime Stage: Startet die gebaute JAR-Datei +FROM amazoncorretto:21-alpine +WORKDIR /app + +# Kopiere nur die gebaute JAR aus dem 'build'-Stage +COPY --from=build /app/build/libs/*.jar app.jar + +# Ports freigeben (gute Praxis) +EXPOSE 8080 + +# Starte die App UND aktiviere Remote Debugging an Port 5005 +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 0000000..723da67 --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,73 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.5' + id 'io.spring.dependency-management' version '1.1.7' +// id "com.gorylenko.gradle-git-properties" version "2.4.1" +} + +group = 'de.haevn' +version = '0.0.1-SNAPSHOT' +description = 'backend' + +bootRun { + jvmArgs += "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=0.0.0.0:9876" + jvmArgs += '--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED' +} + + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +// implementation 'com.h2database:h2' + developmentOnly("org.springframework.boot:spring-boot-devtools") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.12") +// implementation("pl.project13.maven:git-commit-id-plugin:4.9.10") + implementation('org.springframework.boot:spring-boot-starter-actuator') + implementation 'com.fasterxml.jackson.core:jackson-core' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.core:jackson-annotations' + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml' + implementation 'com.github.librepdf:openpdf:1.3.30' + implementation 'org.postgresql:postgresql:42.7.3' + implementation 'org.springframework.boot:spring-boot-devtools' +// https://mvnrepository.com/artifact/com.google.googlejavaformat/google-java-format + implementation("com.google.googlejavaformat:google-java-format:1.31.0") + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + implementation 'org.postgresql:postgresql:42.7.7' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation("org.apache.pdfbox:pdfbox:3.0.6") + implementation("org.apache.pdfbox:pdfbox:3.0.6") + implementation("io.github.openhtmltopdf:openhtmltopdf-core:1.1.31") + implementation("io.github.openhtmltopdf:openhtmltopdf-pdfbox:1.1.31") + implementation("io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.31") +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.named("compileJava") { + doLast { + file(".trigger-file").setLastModified(System.currentTimeMillis()) + } +} \ No newline at end of file diff --git a/backend/dev.Dockerfile b/backend/dev.Dockerfile new file mode 100644 index 0000000..e0bf341 --- /dev/null +++ b/backend/dev.Dockerfile @@ -0,0 +1,24 @@ +# Wir nutzen direkt das Gradle Image als Basis (kein Multi-Stage für Dev!) +FROM gradle:jdk21-alpine + +WORKDIR /app + +# 1. Gradle-Konfiguration kopieren (für Caching der Dependencies) +COPY build.gradle settings.gradle gradlew ./ +COPY gradle/ ./gradle/ + +# 2. Abhängigkeiten laden +RUN gradle dependencies --no-daemon + +# 3. Source Code kopieren +# (Wird zur Laufzeit durch dein Docker-Compose Volume überschrieben, +# aber gut als Fallback) +COPY . . + +# Ports: 8080 (App) und 5005 (Debugger) +EXPOSE 8080 5005 + +# WICHTIG: Statt ein fertiges JAR zu starten, lassen wir Gradle die App starten. +# Das sorgt dafür, dass Änderungen im Code (via Volume) beim Neustart erkannt werden. +# Wir übergeben hier auch die Debugger-Argumente direkt an die JVM von Gradle. +CMD ["gradle", "bootRun", "--no-daemon", "-PjvmArgs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"] \ No newline at end of file diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 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/HEAD/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 + +CLASSPATH="\\\"\\\"" + + +# 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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" \ + -classpath "$CLASSPATH" \ + -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" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,94 @@ +@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 with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +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 + +goto fail + +: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 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 0000000..5ec532e --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'WorkTool' \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/SnippetManageApplication.java b/backend/src/main/java/de/haevn/snippetmanage/SnippetManageApplication.java new file mode 100644 index 0000000..7c00708 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/SnippetManageApplication.java @@ -0,0 +1,14 @@ +package de.haevn.snippetmanage; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class SnippetManageApplication { + public static void main(String[] args) { + SpringApplication.run(SnippetManageApplication.class, args); + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/annotation/DoNotPatch.java b/backend/src/main/java/de/haevn/snippetmanage/common/annotation/DoNotPatch.java new file mode 100644 index 0000000..f4835c8 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/annotation/DoNotPatch.java @@ -0,0 +1,18 @@ +package de.haevn.snippetmanage.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + *

DoNotPatch

+ * This annotation is used to mark fields that should not be patched by the + * patching mechanism. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface DoNotPatch { + +} + diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/annotation/PDFService.java b/backend/src/main/java/de/haevn/snippetmanage/common/annotation/PDFService.java new file mode 100644 index 0000000..d074bbf --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/annotation/PDFService.java @@ -0,0 +1,46 @@ +package de.haevn.snippetmanage.common.annotation; + + +import com.lowagie.text.Document; + +import java.util.ArrayList; +import java.util.List; + +public class PDFService { + private final String author; + private final String subject; + private final String creator; + private final List keywords = new ArrayList<>(); + private String title; + + public PDFService(String subject) { + this.author = "DashboardManager"; + this.subject = subject; + this.creator = "DashboardManager"; + } + + public PDFService addTitle(String title) { + this.title = title; + return this; + } + + public PDFService addKeywords(String... kws) { + keywords.addAll(List.of(kws)); + return this; + } + + public void appendToDoc(Document document) { + document.addAuthor(author); + document.addSubject(subject); + document.addCreator(creator); + if (!keywords.isEmpty()) { + document.addKeywords(String.join(", ", keywords)); + } + + if (title != null && !title.isBlank()) { + document.addTitle(title); + } + } + + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/annotation/RestApiController.java b/backend/src/main/java/de/haevn/snippetmanage/common/annotation/RestApiController.java new file mode 100644 index 0000000..8f83356 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/annotation/RestApiController.java @@ -0,0 +1,29 @@ +package de.haevn.snippetmanage.common.annotation; + +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Component +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@RestController +@RequestMapping +@Tag(name = "API Controller", description = "Controller for REST API endpoints") +public @interface RestApiController { + @AliasFor(annotation = RequestMapping.class, attribute = "value") + String[] value(); + + @AliasFor(annotation = Tag.class, attribute = "name") + String tagName() default "API Controller"; + + @AliasFor(annotation = Tag.class, attribute = "description") + String description() default ""; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/GlobalExceptionHandler.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..7104192 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/GlobalExceptionHandler.java @@ -0,0 +1,75 @@ +package de.haevn.snippetmanage.common.config; + +import de.haevn.snippetmanage.common.exception.ApplicationException; +import de.haevn.snippetmanage.els.exception.ElsException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + + +/** + *

GlobalExceptionHandler

+ * This class handles all exceptions thrown by the application and returns a proper response. + * It is annotated with {@link ControllerAdvice} to make it available to all controllers. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + *

buildResponse({@link HttpStatus}, {@link String}, {@link Object})

+ * This is an internal method to build a response entity with the given status, message and data. + * + * @param status The HTTP status of the response. + * @param message The message of the response. + * @param data The data of the response. + * @return A {@link ResponseEntity} containing the {@link Map} with the given status, message and data. + */ + private ResponseEntity> buildResponse(final HttpStatus status, final String message, final Object data) { + Map body = new HashMap<>(); + + if (message != null) { + body.put("message", message); + } + if (data != null) { + body.put("data", data); + } + body.put("status", status.value()); + + return ResponseEntity.status(status).body(body); + } + + @ExceptionHandler(ElsException.class) + public ResponseEntity> handleElsException(final ElsException e) { + return buildResponse(e.getStatus(), e.getMessage(), e.getErrors()); + } + + /** + *

handleApplicationException({@link ApplicationException})

+ * This method handles all {@link ApplicationException}s thrown by the application and returns a proper response. + * + * @param e The {@link ApplicationException} that was thrown. + * @return A {@link ResponseEntity} containing the {@link Map} with the status of the exception and the message of the exception. + */ + @ExceptionHandler(ApplicationException.class) + public ResponseEntity> handleApplicationException(final ApplicationException e) { + return buildResponse(e.getStatus(), e.getMessage(), e.getData()); + } + + + /** + *

handleException({@link Exception})

+ * This method handles all exceptions thrown by the application and returns a proper response. + * + * @param e The exception that was thrown. + * @return A {@link ResponseEntity} containing the {@link Map} with the status 500 and the message of the exception. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(final Exception e) { + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), null); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/RateLimitingInterceptor.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/RateLimitingInterceptor.java new file mode 100644 index 0000000..207a227 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/RateLimitingInterceptor.java @@ -0,0 +1,76 @@ +package de.haevn.snippetmanage.common.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +public class RateLimitingInterceptor implements HandlerInterceptor { + + private static final int MAX_REQUESTS = 20; + private static final long WINDOW_SIZE_MS = 60 * 1000; + + private final ConcurrentHashMap requestCounts = new ConcurrentHashMap<>(); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + + long now = System.currentTimeMillis(); + String ip = getClientIp(request); + RateLimitData rateLimitData = requestCounts.computeIfAbsent(ip, k -> new RateLimitData(now)); + + synchronized (rateLimitData) { + + if (now - rateLimitData.getWindowStartTime() > WINDOW_SIZE_MS) { + rateLimitData.setWindowStartTime(now); + rateLimitData.getCount().set(1); + return true; + } + + int currentCount = rateLimitData.getCount().incrementAndGet(); + if (currentCount > MAX_REQUESTS) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("Too many requests"); + return false; + } + } + + return true; + } + + private String getClientIp(HttpServletRequest request) { + String xfHeader = request.getHeader("X-Forwarded-For"); + if (xfHeader == null || xfHeader.isEmpty() || "unknown".equalsIgnoreCase(xfHeader)) { + return request.getRemoteAddr(); + } + return xfHeader.split(",")[0]; + } + + private static class RateLimitData { + private final AtomicInteger count; + private long windowStartTime; + + public RateLimitData(long windowStartTime) { + this.windowStartTime = windowStartTime; + this.count = new AtomicInteger(0); + } + + public long getWindowStartTime() { + return windowStartTime; + } + + public void setWindowStartTime(long windowStartTime) { + this.windowStartTime = windowStartTime; + } + + public AtomicInteger getCount() { + return count; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/RequestContextUtil.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/RequestContextUtil.java new file mode 100644 index 0000000..edf1114 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/RequestContextUtil.java @@ -0,0 +1,138 @@ +package de.haevn.snippetmanage.common.config; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Optional; + +/** + *

RequestContextUtil

+ * This utility class provides methods to get information about the current request. + */ +@Component +public class RequestContextUtil { + + /** + *

getCurrentHttpRequestOpt()

+ * This method retrieves the current HTTP request and returns it as an {@link Optional}. + * + * @return An {@link Optional} containing the current HTTP request or an empty {@link Optional} if no request is found. + */ + public Optional getCurrentHttpRequestOpt() { + final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) { + return Optional.of(servletRequestAttributes.getRequest()); + } + return Optional.empty(); + } + + /** + *

getCurrentHttpRequest()

+ * This method retrieves the current HTTP request and returns it. + * + * @return The current HTTP request. + * @throws IllegalStateException If no request is found. + */ + public HttpServletRequest getCurrentHttpRequest() { + return getCurrentHttpRequestOpt().orElseThrow(() -> new IllegalStateException("No request found")); + } + + /** + *

getApplication()

+ * This method retrieves the application name from the current HTTP request. + * + * @return The application name. + */ + public String getApplication() { + return getCurrentHttpRequest().getHeader("X-API-KEY"); + } + + /** + *

getIpAddress()

+ * This method retrieves the IP address of the current HTTP request. + * It first tries to get the IP address from the "X-FORWARDED-FOR" header.
+ * If that fails, it tries to get the IP address from the remote address.
+ * If that fails, it returns "unknown". + * + * @return The IP address of the current HTTP request. + */ + public String getIpAddress() { + final String forWardedFor = getCurrentHttpRequest().getHeader("X-FORWARDED-FOR"); + if (forWardedFor != null && !forWardedFor.isEmpty()) { + return forWardedFor; + } + final String remoteAddr = getCurrentHttpRequest().getRemoteAddr(); + if (remoteAddr != null && !remoteAddr.isEmpty()) { + return remoteAddr; + } + return "unknown"; + } + + /** + *

getRemoteHost()

+ * This method retrieves the remote host of the current HTTP request. + * It first tries to get the remote host from the current HTTP request.
+ * If that fails, it returns "unknown". + * + * @return The remote host of the current HTTP request. + */ + public String getRemoteHost() { + final String remoteHost = getCurrentHttpRequest().getRemoteHost(); + if (remoteHost != null && !remoteHost.isEmpty()) { + return remoteHost; + } + return "unknown"; + } + + + public Optional getApiKey() { + final HttpServletRequest request = getCurrentHttpRequest(); + final String apiKey = request.getHeader("X-API-KEY"); + if (apiKey != null && !apiKey.isEmpty()) { + return Optional.of(apiKey); + } + + return Optional.empty(); + } + + public String getRequestedApiVersion() { + final HttpServletRequest request = getCurrentHttpRequest(); + final String requestURI = request.getRequestURI(); + if (requestURI.startsWith("/api/")) { + final String[] parts = requestURI.split("/"); + if (parts.length > 2) { + return parts[2]; // The version is the third part of the path + } + } + + return "unknown"; // Default or unknown version + } + + public boolean isApiRequest() { + final HttpServletRequest request = getCurrentHttpRequest(); + final String requestURI = request.getRequestURI(); + return requestURI.startsWith("/api/"); + } + + public boolean isAndroid() { + final HttpServletRequest request = getCurrentHttpRequest(); + String userAgent = request.getHeader("User-Agent"); + return userAgent != null && userAgent.contains("Android"); + } + + public boolean isIphone() { + final HttpServletRequest request = getCurrentHttpRequest(); + String userAgent = request.getHeader("User-Agent"); + return userAgent != null && userAgent.contains("iPhone"); + } + + public boolean isMobileRequest() { + final HttpServletRequest request = getCurrentHttpRequest(); + String userAgent = request.getHeader("User-Agent"); + return userAgent != null && userAgent.matches(".*(Mobile|iPhone|Android).*"); + } +} + diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/RestTemplateConfig.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/RestTemplateConfig.java new file mode 100644 index 0000000..761159b --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/RestTemplateConfig.java @@ -0,0 +1,13 @@ +package de.haevn.snippetmanage.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/SameSiteCookieFilter.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/SameSiteCookieFilter.java new file mode 100644 index 0000000..1429fbe --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/SameSiteCookieFilter.java @@ -0,0 +1,28 @@ +package de.haevn.snippetmanage.common.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class SameSiteCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { + chain.doFilter(req, res); + if (res instanceof HttpServletResponse) { + HttpServletResponse response = res; + // append SameSite=None; Secure to all Set-Cookie headers (dev only - adjust for production) + for (String header : response.getHeaders("Set-Cookie")) { + if (!header.toLowerCase().contains("samesite")) { + response.setHeader("Set-Cookie", header + "; SameSite=None; Secure"); + } + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/SecurityConfig.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/SecurityConfig.java new file mode 100644 index 0000000..7a51dc6 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/SecurityConfig.java @@ -0,0 +1,41 @@ +package de.haevn.snippetmanage.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +public class SecurityConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration cfg = new CorsConfiguration(); + // allow origin patterns (use specific origins in production) + cfg.setAllowedOriginPatterns(List.of("*")); + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + cfg.setAllowedHeaders(List.of("*")); + cfg.setAllowCredentials(true); + cfg.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/SecurityHeadersFilter.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/SecurityHeadersFilter.java new file mode 100644 index 0000000..688a609 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/SecurityHeadersFilter.java @@ -0,0 +1,57 @@ +package de.haevn.snippetmanage.common.config; + +import de.haevn.snippetmanage.common.utils.CryptoUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.util.Set; + +/** + *

SecurityHeadersFilter

+ * This filter adds security headers to the response. + */ +@Component +public class SecurityHeadersFilter extends OncePerRequestFilter { + private static final Set ALLOWED_ORIGINS = Set.of( + "http://localhost:3000", + "http://localhost", + "http://127.0.0.1:3000", + "http://127.0.0.1" + ); + private final RequestContextUtil requestContextUtils; + private final CryptoUtils cryptoUtils; + + public SecurityHeadersFilter(final RequestContextUtil requestContextUtils, CryptoUtils cryptoUtils) { + this.requestContextUtils = requestContextUtils; + this.cryptoUtils = cryptoUtils; + } + + @Override + protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, + final FilterChain filterChain) throws ServletException, IOException { + ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response); + filterChain.doFilter(request, wrapper); + + wrapper.setHeader("Content-Security-Policy", "default-src 'self'"); + wrapper.setHeader("X-Content-Type-Options", "nosniff"); + wrapper.setHeader("X-Frame-Options", "DENY"); + wrapper.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + + final byte[] body = wrapper.getContentAsByteArray(); + final String algorithm = "SHA-256"; + wrapper.setHeader("X-API-Version", requestContextUtils.getRequestedApiVersion()); + wrapper.setHeader("X-Processing-Time", String.valueOf(System.currentTimeMillis())); + wrapper.setHeader("X-Checksum", cryptoUtils.calculateCheckSum(body, algorithm)); + wrapper.setHeader("X-Checksum-Algorithm", algorithm); + wrapper.setHeader("X-Application", requestContextUtils.getApplication()); + wrapper.setHeader("X-Client-IP", requestContextUtils.getIpAddress()); + + wrapper.copyBodyToResponse(); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/StartupRunner.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/StartupRunner.java new file mode 100644 index 0000000..7b1beed --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/StartupRunner.java @@ -0,0 +1,36 @@ +package de.haevn.snippetmanage.common.config; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +public class StartupRunner { + private static final Logger log = LoggerFactory.getLogger(StartupRunner.class); + + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + log.info("Application ready — running post-startup tasks"); + + try { + final Path base = Paths.get("/data"); + Files.createDirectories(base.resolve("els")); + Files.createDirectories(base.resolve("els/input")); + Files.createDirectories(base.resolve("els/output")); + Files.createDirectories(base.resolve("els/xsd")); + Files.createDirectories(base.resolve("user-images")); + Files.createDirectories(base.resolve("file_share")); + log.info("Default data directories created/verified"); + + } catch (IOException e) { + log.error("Failed to create data directories", e); + } + log.info("Post-startup tasks finished"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/config/WebConfig.java b/backend/src/main/java/de/haevn/snippetmanage/common/config/WebConfig.java new file mode 100644 index 0000000..9f2be89 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/config/WebConfig.java @@ -0,0 +1,24 @@ + +package de.haevn.snippetmanage.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns( + "http://*:3000", "https://*:3000", + "http://localhost", "https://localhost", + "http://127.0.0.1", "https://127.0.0.1", + "http://localhost:3000", "https://localhost:3000" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/AccessDeniedException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/AccessDeniedException.java new file mode 100644 index 0000000..f26ff61 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/AccessDeniedException.java @@ -0,0 +1,15 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

AccessDeniedException

+ * This exception is thrown when a user tries to access a resource they are not allowed to access. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#FORBIDDEN}. + */ +public class AccessDeniedException extends ApplicationException { + public AccessDeniedException() { + super(HttpStatus.FORBIDDEN); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/AlreadyReportedException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/AlreadyReportedException.java new file mode 100644 index 0000000..8ddd0e9 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/AlreadyReportedException.java @@ -0,0 +1,19 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

AlreadyReportedException

+ * This exception is thrown when a user tries to report a resource that has already been reported. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#ALREADY_REPORTED}. + */ +public class AlreadyReportedException extends ApplicationException { + public AlreadyReportedException() { + super(HttpStatus.ALREADY_REPORTED); + } + + public AlreadyReportedException(final String message) { + super(message, HttpStatus.ALREADY_REPORTED); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/ApplicationException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/ApplicationException.java new file mode 100644 index 0000000..f314409 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/ApplicationException.java @@ -0,0 +1,94 @@ +package de.haevn.snippetmanage.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import java.io.Serializable; + +/** + *

ApplicationException

+ * This class extends the {@link RuntimeException} and is used to throw exceptions with a status code and a message.
+ * The {@link info.hausheld.user_service_backend.config.GlobalExceptionHandler GlobalExceptionHandler} will catch + * this and its subclasses and return a response with the status code and message. + */ +@Getter +public class ApplicationException extends RuntimeException implements Serializable { + private final HttpStatus status; + private final Serializable data; + + /** + *

ApplicationException(int)

+ * Calls {@link ApplicationException#ApplicationException(HttpStatus)} with the status code. + * + * @param status The status code of the exception. + */ + public ApplicationException(final int status) { + this(HttpStatus.valueOf(status)); + } + + /** + *

ApplicationException(HttpStatus)

+ * Creates a new instance of the {@link ApplicationException} with the status code. + * + * @param status The status code of the exception. + */ + public ApplicationException(final HttpStatus status) { + this.status = status; + this.data = null; + } + + /** + *

ApplicationException(String)

+ * Calls {@link ApplicationException#ApplicationException(String, HttpStatus)} with the message and + * {@link HttpStatus#INTERNAL_SERVER_ERROR} as status code. + * + * @param message The message of the exception. + */ + public ApplicationException(final String message) { + this(message, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + *

ApplicationException(String, HttpStatus)

+ * Calls {@link ApplicationException#ApplicationException(String, HttpStatus, Serializable)} with the message, status code and null as data. + * + * @param message The message of the exception. + * @param status The status code of the exception. + */ + public ApplicationException(final String message, final HttpStatus status) { + this(message, status, null); + } + + /** + *

ApplicationException(String, {@link Throwable})

+ * Creates a new instance of the {@link ApplicationException} with the message and cause. + * + * @param message The message of the exception. + * @param cause The cause of the exception. + */ + public ApplicationException(final String message, final Throwable cause) { + super(message, cause); + this.status = HttpStatus.INTERNAL_SERVER_ERROR; + this.data = null; + } + + /** + *

ApplicationException(String, HttpStatus, Serializable)

+ * Creates a new instance of the {@link ApplicationException} with the message, status code and data. + * + * @param message The message of the exception. + * @param status The status code of the exception. + * @param data The data of the exception. + */ + public ApplicationException(final String message, final HttpStatus status, final Serializable data) { + super(message); + this.status = status; + this.data = data; + } + + public ApplicationException(String reason, Throwable cause, HttpStatus httpStatus) { + super(reason, cause); + this.status = httpStatus; + this.data = null; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/BadRequestException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/BadRequestException.java new file mode 100644 index 0000000..913b74e --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/BadRequestException.java @@ -0,0 +1,23 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

BadRequestException

+ * This exception is thrown when a user sends a request that is not valid. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#BAD_REQUEST}. + */ +public class BadRequestException extends ApplicationException { + public BadRequestException() { + super(HttpStatus.BAD_REQUEST); + } + + public BadRequestException(String reason) { + super(reason, HttpStatus.BAD_REQUEST); + } + + public BadRequestException(String reason, Throwable cause) { + super(reason, cause, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/ConflictException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/ConflictException.java new file mode 100644 index 0000000..a8d520b --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/ConflictException.java @@ -0,0 +1,16 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

ConflictException

+ * This exception is thrown when a user tries to create a resource that already exists. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#CONFLICT}. + */ +public class ConflictException extends ApplicationException { + public ConflictException() { + super(HttpStatus.CONFLICT); + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/DownloadException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/DownloadException.java new file mode 100644 index 0000000..5f08c0f --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/DownloadException.java @@ -0,0 +1,22 @@ +package de.haevn.snippetmanage.common.exception; + +import de.haevn.snippetmanage.common.utils.DownloadUtils; +import org.springframework.http.HttpStatus; + +import java.io.File; + +/** + *

AccessDeniedException

+ * This exception is thrown when a user tries to access a resource they are not allowed to access. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#FORBIDDEN}. + */ +public class DownloadException extends ApplicationException { + public DownloadException() { + super(HttpStatus.PRECONDITION_FAILED); + } + + public DownloadException(final File filename, final DownloadUtils.FileType fileType) { + super("Failed to download " + filename + " with file type " + fileType.toString(), HttpStatus.PRECONDITION_FAILED); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/InternalServerErrorException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/InternalServerErrorException.java new file mode 100644 index 0000000..e8e48b8 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/InternalServerErrorException.java @@ -0,0 +1,27 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

BadRequestException

+ * This exception is thrown when a user sends a request that is not valid. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#BAD_REQUEST}. + */ +public class InternalServerErrorException extends ApplicationException { + public InternalServerErrorException() { + super(HttpStatus.INTERNAL_SERVER_ERROR); + } + + public InternalServerErrorException(String message) { + super(message, HttpStatus.INTERNAL_SERVER_ERROR); + } + + public InternalServerErrorException(Throwable e) { + this(e.getMessage(), e); + } + + public InternalServerErrorException(String message, Throwable e) { + super(message, HttpStatus.INTERNAL_SERVER_ERROR, e); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/LockedException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/LockedException.java new file mode 100644 index 0000000..1346eab --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/LockedException.java @@ -0,0 +1,15 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

LockedException

+ * This exception is thrown when a user tries to access a resource that is locked. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#LOCKED}. + */ +public class LockedException extends ApplicationException { + public LockedException() { + super(HttpStatus.LOCKED); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/NotFoundException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/NotFoundException.java new file mode 100644 index 0000000..9e5af24 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/NotFoundException.java @@ -0,0 +1,19 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

NotFoundException

+ * This exception is thrown when a user tries to access a resource that does not exist. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#NOT_FOUND}. + */ +public class NotFoundException extends ApplicationException { + public NotFoundException() { + super(HttpStatus.NOT_FOUND); + } + + public NotFoundException(final String columnNotFound) { + super(columnNotFound, HttpStatus.NOT_FOUND); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/ToManyRequestException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/ToManyRequestException.java new file mode 100644 index 0000000..f9303ff --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/ToManyRequestException.java @@ -0,0 +1,16 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

ToManyRequestException

+ * This exception is thrown when a user sends to many requests in a given time frame. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#TOO_MANY_REQUESTS}. + */ +public class ToManyRequestException extends ApplicationException { + public ToManyRequestException() { + super(HttpStatus.TOO_MANY_REQUESTS); + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnauthorizedException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnauthorizedException.java new file mode 100644 index 0000000..0045150 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnauthorizedException.java @@ -0,0 +1,16 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

UnauthorizedException

+ * This exception is thrown when a user tries to access a resource without being authorized. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#UNAUTHORIZED}. + */ +public class UnauthorizedException extends ApplicationException { + public UnauthorizedException() { + super(HttpStatus.UNAUTHORIZED); + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnprocessableContentException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnprocessableContentException.java new file mode 100644 index 0000000..de77085 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnprocessableContentException.java @@ -0,0 +1,15 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

AccessDeniedException

+ * This exception is thrown when a user tries to access a resource they are not allowed to access. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#FORBIDDEN}. + */ +public class UnprocessableContentException extends ApplicationException { + public UnprocessableContentException() { + super(HttpStatus.UNPROCESSABLE_ENTITY); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnprocessableEntityException.java b/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnprocessableEntityException.java new file mode 100644 index 0000000..cbdfff0 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/exception/UnprocessableEntityException.java @@ -0,0 +1,20 @@ +package de.haevn.snippetmanage.common.exception; + +import org.springframework.http.HttpStatus; + +/** + *

UnprocessableEntityException

+ * This exception is thrown when a user sends a request that is valid but cannot be processed. + * It extends the {@link ApplicationException}. + * The HTTP status code is set to {@link HttpStatus#UNPROCESSABLE_ENTITY}. + */ +public class UnprocessableEntityException extends ApplicationException { + public UnprocessableEntityException() { + super(HttpStatus.UNPROCESSABLE_ENTITY); + } + + public UnprocessableEntityException(String message) { + super(message, HttpStatus.UNPROCESSABLE_ENTITY); + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/BasicJpaService.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/BasicJpaService.java new file mode 100644 index 0000000..84663b2 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/BasicJpaService.java @@ -0,0 +1,40 @@ +package de.haevn.snippetmanage.common.utils; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +public class BasicJpaService { + private final JpaRepository repository; + + public BasicJpaService(JpaRepository repository) { + this.repository = repository; + } + + + @GetMapping("/{id}") + public T getById(String id) { + return null; + } + + @DeleteMapping("/{id}") + public void deleteById(String id) { + + } + + @PatchMapping("/{id}") + public T updateById(String id, T entity) { + return null; + } + + @PostMapping("") + public T create(T entity) { + return null; + } + + @GetMapping("") + public List getAll() { + return null; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/Constants.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/Constants.java new file mode 100644 index 0000000..47765be --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/Constants.java @@ -0,0 +1,6 @@ +package de.haevn.snippetmanage.common.utils; + +public class Constants { + public static final String DEFAULT_MESSAGE_USER_NOT_FOUND = "User %s not found."; + public static final String DEFAULT_MESSAGE_USER_ALREADY_EXISTS = "User %s already exists."; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/CryptoUtils.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/CryptoUtils.java new file mode 100644 index 0000000..d3e6987 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/CryptoUtils.java @@ -0,0 +1,27 @@ +package de.haevn.snippetmanage.common.utils; + +import jakarta.xml.bind.DatatypeConverter; +import org.springframework.stereotype.Component; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Component +public class CryptoUtils { + + public boolean isChecksumCorrect(byte[] data, String checksum, String algorithm) { + String calculatedChecksum = calculateCheckSum(data, algorithm); + return calculatedChecksum.equalsIgnoreCase(checksum); + } + + public String calculateCheckSum(final byte[] body, final String algorithm) { + try { + final MessageDigest digest = MessageDigest.getInstance(algorithm); + final byte[] hashedBytes = digest.digest(body); + return DatatypeConverter.printHexBinary(hashedBytes); + } catch (NoSuchAlgorithmException e) { + return "Could not calculate checksum: " + e.getMessage(); + } + + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/DownloadUtils.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/DownloadUtils.java new file mode 100644 index 0000000..b887b3c --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/DownloadUtils.java @@ -0,0 +1,83 @@ +package de.haevn.snippetmanage.common.utils; + +import de.haevn.snippetmanage.common.exception.DownloadException; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; + +@Component +public class DownloadUtils { + + private final ZipUtils zipUtils; + + public DownloadUtils(ZipUtils zipUtils) { + this.zipUtils = zipUtils; + } + + public ResponseEntity downloadFile(File filename, FileType fileType) { + try { + byte[] data = Files.readAllBytes(filename.toPath()); + return download(data, filename.getName(), fileType); + } catch (IOException ignored) { + throw new DownloadException(filename, fileType); + } + } + + public ResponseEntity download(byte[] data, String fileName, FileType fileType) { + final ByteArrayResource resource = new ByteArrayResource(data); + + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") + .header("x-filename", fileName) + .header("x-filetype", fileType.mediaType) + .header("x-fileextension", fileType.extension) + .header("x-filesize", String.valueOf(data.length)) + .header("x-suprise", RandomUtils.generateRandomString()) + .contentLength(data.length) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(resource); + } + + public ResponseEntity downloadFilesAsZip(List pdfs, String filename) throws IOException { + final byte[] zipBytes = zipUtils.zipZipFiles(pdfs); + return download(zipBytes, filename, DownloadUtils.FileType.ZIP); + } + + + public enum FileType { + ZIP("zip", "application/zip"), + JSON("json", "application/json"), + XML("xml", "application/xml"), + TXT("txt", "text/plain"), + IMG_PNG("png", "image/png"), + IMG_JPG("jpg", "image/jpg"), + IMG_JPEG("jpeg", "image/jpeg"), + IMG_GIF("gif", "image/gif"), + PDF("pdf", "application/pdf"), + HTML("html", "text/html"), + CSV("csv", "text/csv"), + MD("md", "text/markdown"), + BIN("bin", "application/octet-stream"), + UNKNOWN("???", "???"); + + final String extension; + final String mediaType; + + FileType(final String ext, final String contentType) { + this.extension = ext; + this.mediaType = contentType; + } + + @Override + public String toString() { + return mediaType + " (*." + extension + ")"; + } + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/FileService.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/FileService.java new file mode 100644 index 0000000..5a1535d --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/FileService.java @@ -0,0 +1,111 @@ +package de.haevn.snippetmanage.common.utils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import org.springframework.stereotype.Component; + +public class FileService { + private final String rootPath; + private final Path rootDir; + public FileService(final String rootPath) { + this.rootPath = rootPath; + rootDir = Path.of("/data/files", rootPath); + if(!rootDir.toFile().exists() && !rootDir.toFile().mkdirs()){ + throw new RuntimeException("Could not create root directory for file storage"); + } + } + + public final void storeFile(final String name, final byte[] content) throws IOException { + storeFile(null, name, content, false); + } + + public final void storeFile(final String dir, final String name, final byte[] content) throws IOException { + storeFile(dir, name, content, false); + } + + + public final void storeFile(final String dir, final String name, final byte[] content, final boolean override) throws IOException { + final Path path; + + if(null == dir || dir.isBlank()){ + path = rootDir.resolve(name); + }else { + final Path dirPath = rootDir.resolve(dir); + if (!dirPath.toFile().exists() && !dirPath.toFile().mkdirs()) { + throw new IOException("Could not create directory " + dir); + } + path = dirPath.resolve(name); + } + + if(path.toFile().exists() && !override){ + throw new IOException("File with name " + name + " already exists"); + } + + Files.write(path, content); + } + + public FileObject getFile(final String name) throws IOException { + final Path path = rootDir.resolve(name); + if(!path.toFile().exists()){ + throw new IOException("File with name " + name + " does not exist"); + } + + return new FileObject(name, Files.readAllBytes(path), path.toFile().length()); + + } + + public List listFiles(String ticketId) throws IOException{ + final Path path = rootDir.resolve(ticketId); + return Files.list(path).map(FileObject::fromPath).toList(); + } + + public final void deleteFile(final String name) throws IOException { + final Path path = rootDir.resolve(name); + if(path.toFile().exists() && !Files.deleteIfExists(path)){ + throw new IOException("Could not delete file with name " + name); + } + } + + public boolean fileExists(final String name){ + final Path path = rootDir.resolve(name); + return path.toFile().exists(); + } + + public String getRootPath() { + return rootPath; + } + + public Path getRootDir() { + return rootDir; + } + + public void deleteFile(final String id, final String name) throws IOException { + deleteFile(id+"/"+name); + } + + public List getAllLines(final String s) { + final Path path = rootDir.resolve(s); + if(!path.toFile().exists()){ + throw new RuntimeException("File with name " + s + " does not exist"); + } + try { + return Files.readAllLines(path); + } catch (IOException e) { + throw new RuntimeException("Could not read file with name " + s, e); + } + } + + public record FileObject(String name, byte[] content, long size){ + + public static FileObject fromPath(Path path){ + try{ + return new FileObject(path.getFileName().toString(), Files.readAllBytes(path), path.toFile().length()); + }catch (IOException ex){ + throw new RuntimeException("Could not read file " + path.getFileName(), ex); + } + } + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/Patcher.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/Patcher.java new file mode 100644 index 0000000..96d6bac --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/Patcher.java @@ -0,0 +1,82 @@ +package de.haevn.snippetmanage.common.utils; + +import de.haevn.snippetmanage.common.annotation.DoNotPatch; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Arrays; + +/** + *

Patcher

+ * This class is used to patch an existing object with values from another + * object.
It utilizes reflection to iterate over the fields of the objects + * and sets the values of the fields of the existing object + */ +@Component +public class Patcher { + + private static final Logger LOGGER = LoggerFactory.getLogger(Patcher.class); + + /** + *

patch(T, T)

+ * This method patches the existing object with the values of the incomplete + * object.
It iterates over the fields of the objects and sets the + * values of the fields of the existing object. + * + * @param originalObject The existing object + * @param newObject The incomplete object + * @param The type of the objects + * @return The number of fields patched + */ + public long patch(final T originalObject, final T newObject) { + if (originalObject == null || newObject == null) { + throw new IllegalArgumentException("Input parameters cannot be null"); + } + if (originalObject.getClass() != newObject.getClass()) { + throw new IllegalArgumentException("Input parameters must be of the same class"); + } + + final Class internClass = originalObject.getClass(); + final Field[] internFields = internClass.getDeclaredFields(); + return Arrays.stream(internFields) + .filter(field -> !field.isAnnotationPresent(DoNotPatch.class)) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .filter(field -> !Modifier.isFinal(field.getModifiers())) + .map(field -> patchField(field, originalObject, newObject)) + .filter(patched -> patched) + .count(); + } + + /** + *

patchField(Field, Object, Object)

+ * This is the actual patching method that sets the value of the field of + * the existing object with the value of the field of the incomplete + * object. + * + * @param field The field to patch + * @param originalObject The existing object + * @param newObject The incomplete object + */ + private boolean patchField(final Field field, final Object originalObject, final Object newObject) { + try { + field.setAccessible(true); + final Object value = field.get(newObject); + if (value != null && !value.equals(field.get(originalObject))) { + LOGGER.debug("Patching field: {} with value: {}", field.getName(), value); + field.set(originalObject, value); + return !field.getName().contains("accessTokenRevoked"); + } + return false; + } catch (final Exception e) { + final String errorMessage = String.format("Error while patching field: %s in class: %s. Cause: %s", + field.getName(), originalObject.getClass().getName(), e.getMessage()); + LOGGER.error(errorMessage); + throw new RuntimeException(e); + } + } + +} + diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/RandomUtils.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/RandomUtils.java new file mode 100644 index 0000000..e7e0ae3 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/RandomUtils.java @@ -0,0 +1,48 @@ +package de.haevn.snippetmanage.common.utils; + +public class RandomUtils { + private RandomUtils() { + } + + public static String generateRandomString(int length, String alphabet) { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + int index = (int) (alphabet.length() * Math.random()); + sb.append(alphabet.charAt(index)); + } + return sb.toString(); + } + + public static String generateRandomString(int length) { + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return generateRandomString(length, alphabet); + } + + public static String generateRandomString() { + return generateRandomString(12); + } + + public static String randomNumberString(int length) { + String numbers = "0123456789"; + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + int index = (int) (numbers.length() * Math.random()); + sb.append(numbers.charAt(index)); + } + + return sb.toString(); + } + + public static long generateRandomLong(int digits) { + String numberString = randomNumberString(digits); + return Long.parseLong(numberString); + } + + public static double generateRandomDouble(int integerDigits, int fractionalDigits) { + String integerPart = randomNumberString(integerDigits); + String fractionalPart = randomNumberString(fractionalDigits); + String numberString = integerPart + "." + fractionalPart; + return Double.parseDouble(numberString); + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/RequestContextUtils.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/RequestContextUtils.java new file mode 100644 index 0000000..d5a7adc --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/RequestContextUtils.java @@ -0,0 +1,129 @@ +package de.haevn.snippetmanage.common.utils; + + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Optional; + +/** + *

RequestContextUtil

+ * This utility class provides methods to get information about the current + * request. + */ +@Component +public class RequestContextUtils { + + /** + *

getApplication()

+ * This method retrieves the application name from the current HTTP + * request. + * + * @return The application name. + */ + public String getApplication() { + return getCurrentHttpRequest().getHeader("X-API-KEY"); + } + + /** + *

getCurrentHttpRequest()

+ * This method retrieves the current HTTP request and returns it. + * + * @return The current HTTP request. + * @throws IllegalStateException If no request is found. + */ + public HttpServletRequest getCurrentHttpRequest() { + return getCurrentHttpRequestOpt().orElseThrow(() -> new IllegalStateException("No request found")); + } + + /** + *

getCurrentHttpRequestOpt()

+ * This method retrieves the current HTTP request and returns it as an + * {@link Optional}. + * + * @return An {@link Optional} containing the current HTTP request or an + * empty {@link Optional} if no request is found. + */ + public Optional getCurrentHttpRequestOpt() { + final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes instanceof final ServletRequestAttributes servletRequestAttributes) { + return Optional.of(servletRequestAttributes.getRequest()); + } + return Optional.empty(); + } + + /** + *

getIpAddress()

+ * This method retrieves the IP address of the current HTTP request. It + * first tries to get the IP address from the "X-FORWARDED-FOR" header.
+ * If that fails, it tries to get the IP address from the remote + * address.
If that fails, it returns "unknown". + * + * @return The IP address of the current HTTP request. + */ + public String getIpAddress() { + final String forWardedFor = getCurrentHttpRequest().getHeader("X-FORWARDED-FOR"); + if (forWardedFor != null && !forWardedFor.isEmpty()) { + return forWardedFor; + } + final String remoteAddr = getCurrentHttpRequest().getRemoteAddr(); + if (remoteAddr != null && !remoteAddr.isEmpty()) { + return remoteAddr; + } + return "unknown"; + } + + /** + *

getRemoteHost()

+ * This method retrieves the remote host of the current HTTP request. It + * first tries to get the remote host from the current HTTP request.
If + * that fails, it returns "unknown". + * + * @return The remote host of the current HTTP request. + */ + public String getRemoteHost() { + final String remoteHost = getCurrentHttpRequest().getRemoteHost(); + if (remoteHost != null && !remoteHost.isEmpty()) { + return remoteHost; + } + return "unknown"; + } + + public Optional getHeader(String name) { + final HttpServletRequest request = getCurrentHttpRequest(); + final String value = request.getHeader(name); + if (value != null && !value.isEmpty()) { + return Optional.of(value); + } + + return Optional.empty(); + } + + public Optional getApiKey() { + final HttpServletRequest request = getCurrentHttpRequest(); + final String apiKey = request.getHeader("X-API-KEY"); + if (apiKey != null && !apiKey.isEmpty()) { + return Optional.of(apiKey); + } + + return Optional.empty(); + } + + public String getRequestedApiVersion() { + final HttpServletRequest request = getCurrentHttpRequest(); + final String requestURI = request.getRequestURI(); + if (requestURI.startsWith("/api/")) { + final String[] parts = requestURI.split("/"); + if (parts.length > 2) { + return parts[2]; // The version is the third part of the path + } + } + + return "unknown"; // Default or unknown version + } + +} + diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/ShortIdGenerator.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/ShortIdGenerator.java new file mode 100644 index 0000000..0b6cdb0 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/ShortIdGenerator.java @@ -0,0 +1,42 @@ +package de.haevn.snippetmanage.common.utils; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.util.function.Predicate; + +@Component +public final class ShortIdGenerator { + private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private ShortIdGenerator() { + } + + public static String generate(int length) { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length()))); + } + return sb.toString(); + } + + /** + * Generate a unique id by checking existence with the provided predicate. + * + * @param length desired id length (use 8) + * @param exists predicate that returns true if id already exists (e.g. repo::existsByShortId) + * @param maxAttempts number of retries before failing + * @return unique id + * @throws IllegalStateException if uniqueness cannot be achieved within maxAttempts + */ + public static String generateUnique(int length, Predicate exists, int maxAttempts) { + for (int attempt = 0; attempt < maxAttempts; attempt++) { + String id = generate(length); + if (!exists.test(id)) { + return id; + } + } + throw new IllegalStateException("Unable to generate unique id after " + maxAttempts + " attempts"); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/Tuple.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/Tuple.java new file mode 100644 index 0000000..b865a70 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/Tuple.java @@ -0,0 +1,5 @@ +package de.haevn.snippetmanage.common.utils; + +public record Tuple(T first, K second) { +} + diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/ZipUtils.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/ZipUtils.java new file mode 100644 index 0000000..381a765 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/ZipUtils.java @@ -0,0 +1,43 @@ +package de.haevn.snippetmanage.common.utils; + +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Component +public class ZipUtils { + public byte[] zipFiles(List files) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + + for (Path file : files) { + zos.putNextEntry(new ZipEntry(file.getFileName().toString())); + java.nio.file.Files.copy(file, zos); + zos.closeEntry(); + } + zos.finish(); + return baos.toByteArray(); + } + } + + public byte[] zipZipFiles(List files) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + + for (ZipFile file : files) { + zos.putNextEntry(new ZipEntry(file.filename())); + zos.write(file.data()); + zos.closeEntry(); + } + zos.finish(); + return baos.toByteArray(); + } + } + + public static record ZipFile(byte[] data, String filename) { } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlBR.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlBR.java new file mode 100644 index 0000000..e4793d2 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlBR.java @@ -0,0 +1,8 @@ +package de.haevn.snippetmanage.common.utils.html; + +public class HtmlBR implements IHtmlElement{ + @Override + public String toHtml() { + return "

"; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlCode.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlCode.java new file mode 100644 index 0000000..63699d3 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlCode.java @@ -0,0 +1,44 @@ +package de.haevn.snippetmanage.common.utils.html; + + +public class HtmlCode implements IHtmlElement { + private final String code; + private final boolean preserveWhitespace; + + public HtmlCode(String code) { + this(code, false); + } + + /** + * @param code the code text + * @param preserveWhitespace when true uses
 to preserve exact whitespace/newlines (no wrapping).
+     *                           when false uses inline  with CSS enabling wrapping.
+     */
+    public HtmlCode(String code, boolean preserveWhitespace) {
+        this.code = code == null ? "" : code;
+        this.preserveWhitespace = preserveWhitespace;
+    }
+
+    private String escapeHtml(String s) {
+        return s
+                .replace("&", "&")
+                .replace("<", "<")
+                .replace(">", ">")
+                .replace("\"", """)
+                .replace("'", "'");
+    }
+
+    @Override
+    public String toHtml() {
+        String escaped = escapeHtml(code);
+        if (preserveWhitespace) {
+            // preserve exact formatting, don't enable wrapping
+            return "
" + escaped + "
"; + } else { + // allow wrapping inside table cells while keeping line breaks + return "" + + escaped + + ""; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlDoc.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlDoc.java new file mode 100644 index 0000000..c753907 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlDoc.java @@ -0,0 +1,84 @@ +package de.haevn.snippetmanage.common.utils.html; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +public final class HtmlDoc { + + private List elements = new ArrayList<>(); + private List css = new ArrayList<>(); + + public HtmlDoc(){ + + String baseCss = """ + * { + font-family: Arial, sans-serif; + font-size: 12px; + }; + body { + margin: 20px; + }; + table { + border-collapse: collapse; + width: 100%; + } + .codeBlock { + + font-family: "Courier New", Courier, monospace; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: anywhere; + } + """; + + this.css.add(baseCss); + } + + public static String sanitize(String input){ + if(input == null) return null; + return input.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + public HtmlDoc addElement(IHtmlElement element){ + this.elements.add(element); + return this; + } + + public HtmlDoc setCss(String css){ + this.css.add(css); + return this; + } + + public String getCss(){ + StringBuilder sb = new StringBuilder(); + for(String cssPart : css){ + sb.append(cssPart).append("\n"); + } + return sb.toString(); + } + + public String getHtmlContent(){ + StringBuilder sb = new StringBuilder(); + sb.append("\n\n\n") +// .append("\n") + .append("\n") + .append("Document\n\n\n"); + + for(IHtmlElement element : elements){ + sb.append(element.toHtml()).append("\n"); + } + + sb.append("\n"); + return sb.toString(); + } + + public void exportAsHtmlFile(String filePath) throws IOException { + Files.writeString(java.nio.file.Paths.get(filePath), getHtmlContent()); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlHeading.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlHeading.java new file mode 100644 index 0000000..1edcc37 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlHeading.java @@ -0,0 +1,66 @@ +// java +package de.haevn.snippetmanage.common.utils.html; + +public class HtmlHeading implements IHtmlElement { + public static enum HeadingLevel { + H1, H2, H3, H4, H5, H6 + } + + public static HtmlHeading h1(String text) { return new HtmlHeading(HeadingLevel.H1, text); } + public static HtmlHeading h2(String text) { return new HtmlHeading(HeadingLevel.H2, text); } + public static HtmlHeading h3(String text) { return new HtmlHeading(HeadingLevel.H3, text); } + public static HtmlHeading h4(String text) { return new HtmlHeading(HeadingLevel.H4, text); } + public static HtmlHeading h5(String text) { return new HtmlHeading(HeadingLevel.H5, text); } + public static HtmlHeading h6(String text) { return new HtmlHeading(HeadingLevel.H6, text); } + + public static HtmlHeading h1(String text, boolean center) { return new HtmlHeading(HeadingLevel.H1, text, center); } + public static HtmlHeading h2(String text, boolean center) { return new HtmlHeading(HeadingLevel.H2, text, center); } + public static HtmlHeading h3(String text, boolean center) { return new HtmlHeading(HeadingLevel.H3, text, center); } + public static HtmlHeading h4(String text, boolean center) { return new HtmlHeading(HeadingLevel.H4, text, center); } + public static HtmlHeading h5(String text, boolean center) { return new HtmlHeading(HeadingLevel.H5, text, center); } + public static HtmlHeading h6(String text, boolean center) { return new HtmlHeading(HeadingLevel.H6, text, center); } + + + private final HeadingLevel level; + private final String text; + private final boolean center; + + public HtmlHeading(HeadingLevel level, String text) { + this(level, text, false); + } + + public HtmlHeading(HeadingLevel level, String text, boolean center) { + this.level = level; + this.text = HtmlDoc.sanitize(text); + this.center = center; + } + + public HtmlHeading(String text) { + this(HeadingLevel.H1, text); + } + + public HtmlHeading(String text, boolean center) { + this(HeadingLevel.H1, text, center); + } + + /** + * Return a new instance with centering enabled. + */ + public HtmlHeading withCenter(boolean center) { + return new HtmlHeading(this.level, this.text, center); + } + + /** + * Convenience: enable centering. + */ + public HtmlHeading center() { + return withCenter(true); + } + + @Override + public String toHtml() { + String tag = level.name().toLowerCase(); + String style = center ? " style=\"text-align:center;\"" : ""; + return "<" + tag + style + ">" + text + ""; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlList.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlList.java new file mode 100644 index 0000000..d41df9b --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlList.java @@ -0,0 +1,41 @@ +package de.haevn.snippetmanage.common.utils.html; + +public class HtmlList implements IHtmlElement { + private final java.util.List items = new java.util.ArrayList<>(); + private final boolean ordered; + private final String title; + + public HtmlList(boolean ordered) { + this(ordered, null); + } + + public HtmlList(boolean ordered, String title) { + this.ordered = ordered; + this.title = HtmlDoc.sanitize(title); + } + + public HtmlList addItem(IHtmlElement item) { + items.add(item); + return this; + } + + public HtmlList addItem(String item) { + items.add(new HtmlText(item)); + return this; + } + + + @Override + public String toHtml() { + StringBuilder sb = new StringBuilder(); + sb.append(ordered ? "
    " : "
      "); + if(title != null && !title.isEmpty()) { + sb.append("").append(title).append(""); + } + for (IHtmlElement item : items) { + sb.append("
    • ").append(item.toHtml()).append("
    • "); + } + sb.append(ordered ? "
" : ""); + return sb.toString(); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlTable.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlTable.java new file mode 100644 index 0000000..d25b91a --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlTable.java @@ -0,0 +1,187 @@ +package de.haevn.snippetmanage.common.utils.html; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +public class HtmlTable implements IHtmlElement { + public List> rows = new ArrayList<>(); + private int[] dimensionRatios = null; + private final int maxColumns; + private boolean dimensionsTotalValues = false; + + // wrapping controls + private Boolean[] columnWrapping = null; // null = use table-level default + private boolean tableCellWrapping = false; // default: no wrap (preserve previous truncation) + + public HtmlTable(int maxColumns) { + this.maxColumns = maxColumns; + } + + public HtmlTable(List headers) { + this(headers.size()); + List headerRow = new ArrayList<>(); + for (String header : headers) { + headerRow.add(new HtmlText(header)); + } + addRow(headerRow); + } + + public HtmlTable(String ... headers){ + this(Arrays.asList(headers)); + } + + public HtmlTable addRow(List row) { + if (row.size() > maxColumns) { + throw new IllegalArgumentException("Row exceeds maximum number of columns: " + maxColumns); + } + rows.add(row); + return this; + } + + public HtmlTable addRow(IHtmlElement... elements) { + List row = new ArrayList<>(); + for (IHtmlElement element : elements) { + row.add(element); + } + return addRow(row); + } + + public HtmlTable addRow(String... texts) { + List row = new ArrayList<>(); + for (String text : texts) { + row.add(new HtmlText(text)); + } + return addRow(row); + } + + public HtmlTable addEmptyRow() { + List row = new ArrayList<>(); + for (int i = 0; i < maxColumns; i++) { + row.add(new HtmlText("")); + } + return addRow(row); + } + + /** + * Set column width ratios. Example: setDimensions(1, 1, 2) -> 25% / 25% / 50%. + * Length must match the table's maxColumns and all values must be positive. + */ + public HtmlTable setDimensions(int... dimensions) { + return setDimensions(false, dimensions); + } + + public HtmlTable setDimensions(boolean totalValues, int... dimensions) { + if (dimensions == null || dimensions.length != maxColumns) { + throw new IllegalArgumentException("Dimensions length must match number of columns: " + maxColumns); + } + for (int d : dimensions) { + if (d <= 0) { + throw new IllegalArgumentException("Dimension ratios must be positive values"); + } + } + this.dimensionRatios = Arrays.copyOf(dimensions, dimensions.length); + this.dimensionsTotalValues = totalValues; + return this; + } + + /** + * Enable or disable wrapping for all cells (table-level default). + * If per-column wrapping is set via setColumnWrapping, that overrides the table-level setting. + */ + public HtmlTable setCellWrapping(boolean wrap) { + this.tableCellWrapping = wrap; + return this; + } + + /** + * Set per-column wrapping. Length must match maxColumns. Values: + * - true: wrap this column + * - false: do not wrap this column (use ellipsis) + */ + public HtmlTable setColumnWrapping(boolean... wraps) { + if (wraps == null || wraps.length != maxColumns) { + throw new IllegalArgumentException("Wrapping length must match number of columns: " + maxColumns); + } + this.columnWrapping = new Boolean[maxColumns]; + for (int i = 0; i < wraps.length; i++) { + this.columnWrapping[i] = wraps[i]; + } + return this; + } + + /** + * Convenience: set wrapping for all columns at once. + * Example: setAllColumnsWrapping(true) -> all columns will wrap. + */ + public HtmlTable setAllColumnsWrapping(boolean wrap) { + this.columnWrapping = new Boolean[maxColumns]; + Arrays.fill(this.columnWrapping, wrap); + return this; + } + + private String tdStyleForColumn(int colIndex) { + boolean wrap; + if (columnWrapping != null && columnWrapping[colIndex] != null) { + wrap = columnWrapping[colIndex]; + } else { + wrap = tableCellWrapping; + } + + if (wrap) { + // allow wrapping and breaking long words + return "style=\"white-space:normal;word-wrap:break-word;word-break:break-word;\""; + } else { + // prevent wrap and show ellipsis when overflow + return "style=\"white-space:nowrap;overflow:hidden;text-overflow:ellipsis;\""; + } + } + + @Override + public String toHtml() { + StringBuilder sb = new StringBuilder(); + + if (dimensionRatios != null) { + int sum = 0; + for (int r : dimensionRatios) sum += r; + + if (dimensionsTotalValues && sum > 0) { + // Treat provided values as absolute pixel widths + sb.append("\n"); + sb.append(" \n"); + for (int r : dimensionRatios) { + sb.append(" \n"); + } + sb.append(" \n"); + } else { + // Treat provided values as ratios and emit percentages + sb.append("
\n"); + sb.append(" \n"); + for (int r : dimensionRatios) { + double pct = (sum == 0) ? 0.0 : (r * 100.0) / sum; + sb.append(" \n"); + } + sb.append(" \n"); + } + } else { + sb.append("
\n"); + } + + for (List row : rows) { + sb.append(" \n"); + int i = 0; + for (IHtmlElement cell : row) { + sb.append(" \n"); + i++; + } + // pad missing cells to reach maxColumns + for (; i < maxColumns; i++) { + sb.append(" \n"); + } + sb.append(" \n"); + } + sb.append("
").append(cell.toHtml()).append("
"); + return sb.toString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlText.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlText.java new file mode 100644 index 0000000..b1e5a08 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlText.java @@ -0,0 +1,15 @@ +package de.haevn.snippetmanage.common.utils.html; + +public class HtmlText implements IHtmlElement{ + + private String text = ""; + + public HtmlText(String text){ + this.text = HtmlDoc.sanitize(text); + } + + @Override + public String toHtml() { + return "

" + text + "

"; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/IHtmlElement.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/IHtmlElement.java new file mode 100644 index 0000000..5e3a534 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/IHtmlElement.java @@ -0,0 +1,5 @@ +package de.haevn.snippetmanage.common.utils.html; + +public interface IHtmlElement { + String toHtml(); +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PageCanvas.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PageCanvas.java new file mode 100644 index 0000000..ed4d351 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PageCanvas.java @@ -0,0 +1,401 @@ +package de.haevn.snippetmanage.common.utils.pdf; + + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; + +import java.awt.*; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Repräsentiert eine einzelne Seite ("Leinwand") zum Zeichnen. + * Implementiert AutoCloseable, um den PDPageContentStream zu schließen. + */ +public class PageCanvas implements AutoCloseable { + + // --- Öffentliche Konstanten für Schriftarten --- + public static final PDType1Font FONT_BOLD = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + public static final PDType1Font FONT_NORMAL = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + public static final PDType1Font FONT_OBLIQUE = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE); + public static final PDType1Font FONT_CODE = new PDType1Font(Standard14Fonts.FontName.COURIER); + + // --- Tabellen-Konstanten --- + private static final float FONT_SIZE_TABLE_HEADER = 8; + private static final float FONT_SIZE_TABLE_BODY = 8; + private static final float TABLE_ROW_MIN_HEIGHT = 15; + private static final float TABLE_LINE_HEIGHT = 10; // Für umgebrochenen Text + private static final float CELL_PADDING = 5; + + private final PDDocument document; + private final PDPageContentStream contentStream; + private final PDRectangle mediaBox; + + /** + * Erstellt eine neue Zeichenleinwand für eine Seite. + * (Wird intern von PdfDocument aufgerufen) + */ + PageCanvas(PDDocument document, PDPage page) throws IOException { + this.document = document; + this.mediaBox = page.getMediaBox(); + this.contentStream = new PDPageContentStream(document, page); + } + + // --- ÖFFENTLICHE ZEICHENMETHODEN --- + + /** + * Zeichnet einen Text-String. + */ + public void drawText(float x, float y, String text, PDType1Font font, float fontSize, Color color) throws IOException { + if (text == null) return; + String sanitizedText = text.replaceAll("\\r\\n|\\r|\\n", " "); + + contentStream.beginText(); + contentStream.setFont(font, fontSize); + contentStream.setNonStrokingColor(color); + contentStream.newLineAtOffset(x, y); + contentStream.showText(sanitizedText); + contentStream.endText(); + contentStream.setNonStrokingColor(Color.BLACK); // Zurücksetzen + } + + /** + * Zeichnet ein gefülltes Rechteck. + */ + public void drawRectangle(float x, float y, float width, float height, Color color) throws IOException { + contentStream.setNonStrokingColor(color); + contentStream.addRect(x, y, width, height); + contentStream.fill(); + contentStream.setNonStrokingColor(Color.BLACK); // Zurücksetzen + } + + /** + * Zeichnet einen gefüllten Kreis. + */ + public void drawCircle(float cx, float cy, float r, Color color) throws IOException { + final float k = 0.552284749831f; + + contentStream.setNonStrokingColor(color); + contentStream.moveTo(cx - r, cy); + contentStream.curveTo(cx - r, cy + k * r, cx - k * r, cy + r, cx, cy + r); + contentStream.curveTo(cx + k * r, cy + r, cx + r, cy + k * r, cx + r, cy); + contentStream.curveTo(cx + r, cy - k * r, cx + k * r, cy - r, cx, cy - r); + contentStream.curveTo(cx - k * r, cy - r, cx - r, cy - k * r, cx - r, cy); + contentStream.fill(); + contentStream.setNonStrokingColor(Color.BLACK); // Zurücksetzen + } + + /** + * Zeichnet einen Textblock mit Hintergrund, ideal für Code oder Notizen. + */ + public void drawCode(float x, float yTop, String text, float width, Color bgColor) throws IOException { + if (text == null) return; + + float padding = 5; + float lineHeight = 12; + float fontSize = 9; + + List lines = getLines(text, width - padding * 2, FONT_CODE, fontSize); + + float height = lines.size() * lineHeight + padding * 2; + float yBottom = yTop - height; + drawRectangle(x, yBottom, width, height, bgColor); + + float textY = yTop - padding - lineHeight + 2; + + contentStream.setFont(FONT_CODE, fontSize); + contentStream.setNonStrokingColor(Color.BLACK); + + float textX = x + padding; + for (String line : lines) { + contentStream.beginText(); + contentStream.newLineAtOffset(textX, textY); + contentStream.showText(line); + contentStream.endText(); + textY -= lineHeight; + } + } + + /** + * Zeichnet ein Bild. + */ + public void drawImage(float x, float y, byte[] imageBytes, float width, float height) throws IOException { + PDImageXObject pdImage = PDImageXObject.createFromByteArray(document, imageBytes, "image"); + contentStream.drawImage(pdImage, x, y, width, height); + } + + + /** + * Zeichnet eine PdfTable-Datenklasse mit Paginierungs-Logik. + * + * @param x X-Koordinate der Tabelle. + * @param yStart Y-Koordinate für die *Oberkante* der Tabelle. + * @param table Das PdfTable-Objekt, das die Daten enthält. + * @param yBottomLimit Die Y-Koordinate (z.B. der untere Rand), an der nicht mehr gezeichnet werden darf. + * @return Ein neues PdfTable-Objekt, das alle Zeilen enthält, die nicht gezeichnet wurden, + * oder 'null', wenn alle Zeilen gezeichnet wurden. + * @throws IOException Wenn das Zeichnen fehlschlägt. + */ + public PdfTable drawTable(float x, float yStart, PdfTable table, float yBottomLimit) throws IOException { + float y = yStart; + + // 1. Header zeichnen + List header = table.getHeader(); + if (header == null) { + throw new IOException("Table must have a header for pagination."); + } + + float headerHeight = calculateRowHeight(header, table.getColWidths(), FONT_SIZE_TABLE_HEADER, true); + + // Prüfen, ob der Header selbst auf die Seite passt + if (y - headerHeight < yBottomLimit) { + throw new IOException("Table Header does not fit on page. availableHeight is too small."); + } + + // Header zeichnen + drawTableRow(x, y, header, table.getColWidths(), headerHeight, true); + y -= headerHeight; + + // 2. Datenzeilen zeichnen + List> rows = table.getRows(); + List> remainingRows = new ArrayList<>(); + boolean pageBreakOccurred = false; + + for (List row : rows) { + float rowHeight = calculateRowHeight(row, table.getColWidths(), FONT_SIZE_TABLE_BODY, false); + + // Prüfen, ob die Zeile auf die Seite passt + if (y - rowHeight < yBottomLimit) { + pageBreakOccurred = true; + } + + if (pageBreakOccurred) { + // Diese und alle folgenden Zeilen für die nächste Seite aufheben + remainingRows.add(row); + } else { + // Zeile passt, also zeichnen + drawTableRow(x, y, row, table.getColWidths(), rowHeight, false); + y -= rowHeight; + } + } + + // 3. Reste zurückgeben + if (!pageBreakOccurred) { + return null; // Alles wurde gezeichnet + } + + // Es gibt Reste, erstelle eine neue Tabelle für die nächste Seite + PdfTable leftoverTable = new PdfTable(table.getColWidths()); + leftoverTable.addHeaderRow(header); // WICHTIG: Header erneut hinzufügen + remainingRows.forEach(leftoverTable::addRow); + + return leftoverTable; + } + + + // --- HILFSMETHODEN FÜR TABELLEN --- + + /** + * Zeichnet eine einzelne Tabellenzeile (Header oder Daten). + * (Kombiniert Linien- und Inhaltszeichnung) + */ + private void drawTableRow(float x, float y, List row, float[] colWidths, float rowHeight, boolean isHeader) throws IOException { + float tableWidth = 0; + for (float w : colWidths) tableWidth += w; + + // 1. Linien zeichnen + drawRowLines(x, y, rowHeight, colWidths, tableWidth); + + // 2. Inhalt zeichnen + PDType1Font font = isHeader ? FONT_BOLD : FONT_NORMAL; + float fontSize = isHeader ? FONT_SIZE_TABLE_HEADER : FONT_SIZE_TABLE_BODY; + + float currentX = x; + float textY = y - TABLE_LINE_HEIGHT + 2; // Y-Startposition für Text (oben in Zelle) + + for (int i = 0; i < row.size(); i++) { + String text = (row.get(i) == null) ? "-" : row.get(i); + float cellWidth = colWidths[i] - CELL_PADDING * 2; + + // Wähle die Schriftart (Code-Schriftart für Spalte 5) + PDType1Font cellFont = font; + if (!isHeader && i == 4) { // Index 5 ist "Regex / Erlaubte Werte" + cellFont = FONT_CODE; + } + + + + // Hole alle Zeilen (unterstützt \n und automatischen Umbruch) + List lines = getLines(text, cellWidth, cellFont, fontSize); + float currentTextY = textY; + + contentStream.setFont(cellFont, fontSize); + contentStream.setNonStrokingColor(Color.BLACK); + + // Zeichne jede Zeile des Zellinhalts + for (String line : lines) { + contentStream.beginText(); + contentStream.newLineAtOffset(currentX + CELL_PADDING, currentTextY); + contentStream.showText(line); + contentStream.endText(); + currentTextY -= TABLE_LINE_HEIGHT; + } + currentX += colWidths[i]; + } + } + + + /** + * Berechnet die Höhe einer Tabellenzeile basierend auf dem Inhalt. + */ + private float calculateRowHeight(List row, float[] colWidths, float fontSize, boolean isHeader) throws IOException { + int maxLines = 1; + + for (int i = 0; i < row.size(); i++) { + String text = (row.get(i) == null) ? "-" : row.get(i); + float cellWidth = colWidths[i] - CELL_PADDING * 2; + + PDType1Font font = FONT_NORMAL; + if (isHeader) { + font = FONT_BOLD; + } else if (i == 5) { // Index 5 ist "Regex / Erlaubte Werte" + font = FONT_CODE; + } + + List lines = getLines(text, cellWidth, font, fontSize); + maxLines = Math.max(maxLines, lines.size()); + } + + float height = (maxLines * TABLE_LINE_HEIGHT) + (CELL_PADDING * 2); + return Math.max(TABLE_ROW_MIN_HEIGHT, height); + } + + /** + * Zeichnet die Gitterlinien für EINE Zeile. + */ + private void drawRowLines(float x, float y, float rowHeight, float[] colWidths, float tableWidth) throws IOException { + contentStream.setStrokingColor(Color.DARK_GRAY); + contentStream.setLineWidth(0.5f); + + // Horizontale Linien + contentStream.moveTo(x, y); + contentStream.lineTo(x + tableWidth, y); + contentStream.moveTo(x, y - rowHeight); + contentStream.lineTo(x + tableWidth, y - rowHeight); + + // Vertikale Linien + float currentX = x; + contentStream.moveTo(currentX, y); + contentStream.lineTo(currentX, y - rowHeight); // Erste Linie links + + for (float width : colWidths) { + currentX += width; + contentStream.moveTo(currentX, y); + contentStream.lineTo(currentX, y - rowHeight); // Linien zwischen den Spalten + } + contentStream.stroke(); + } + + /** + * Bricht Text in Zeilen um, *behält* aber manuelle \n-Umbrüche bei. + */ + private List getLines(String text, float maxWidth, PDType1Font font, float fontSize) throws IOException { + List finalLines = new ArrayList<>(); + if (text == null || text.isEmpty()) { + finalLines.add(""); + return finalLines; + } + + String[] parts = text.split("\n"); + + for (String part : parts) { + // Jedes Teil dann automatisch umbrechen + finalLines.addAll(wrapText(part, maxWidth, font, fontSize)); + } + return finalLines; + } + + + /** + * Bricht einen langen Text in mehrere Zeilen um, damit er in eine Breite passt. + * (Ignoriert/entfernt \n-Zeichen) + */ + private List wrapText(String text, float maxWidth, PDType1Font font, float fontSize) throws IOException { + List lines = new ArrayList<>(); + if (text == null || text.isEmpty()) { + lines.add(""); + return lines; + } + + String sanitizedText = text.replaceAll("\\r\\n|\\r|\\n", " "); + + String[] words = sanitizedText.split(" "); + StringBuilder line = new StringBuilder(); + + for (String word : words) { + if (word.isEmpty()) continue; + + float wordWidth = font.getStringWidth(word) / 1000 * fontSize; + float lineWidth = font.getStringWidth(line.toString()) / 1000 * fontSize; + + if (wordWidth > maxWidth) { + String tempWord = word; + while (!tempWord.isEmpty()) { + int breakIndex = findBreakIndex(tempWord, maxWidth, font, fontSize); + String part = tempWord.substring(0, breakIndex); + + if (line.length() > 0) { + lines.add(line.toString()); + line = new StringBuilder(part); + } else { + lines.add(part); + } + tempWord = tempWord.substring(breakIndex); + } + } else if (lineWidth + wordWidth < maxWidth) { + if (line.length() > 0) line.append(" "); + line.append(word); + } else { + lines.add(line.toString()); + line = new StringBuilder(word); + } + } + lines.add(line.toString()); // Letzte Zeile hinzufügen + return lines; + } + + /** + * Hilfsfunktion für wrapText: Findet den Umbruchpunkt in einem zu langen Wort. + */ + private int findBreakIndex(String word, float maxWidth, PDType1Font font, float fontSize) throws IOException { + int i = 0; + float width = 0; + while (i < word.length()) { + char c = word.charAt(i); + float charWidth = font.getStringWidth(String.valueOf(c)) / 1000 * fontSize; + if (width + charWidth > maxWidth) { + break; + } + width += charWidth; + i++; + } + return Math.max(1, i); // Mindestens 1 Zeichen zurückgeben + } + + /** + * Schließt den Content-Stream. + * Erforderlich durch AutoCloseable. + */ + @Override + public void close() throws IOException { + if (this.contentStream != null) { + this.contentStream.close(); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfDocument.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfDocument.java new file mode 100644 index 0000000..07b390d --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfDocument.java @@ -0,0 +1,165 @@ +package de.haevn.snippetmanage.common.utils.pdf; + +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import de.haevn.snippetmanage.common.exception.InternalServerErrorException; +import de.haevn.snippetmanage.common.utils.html.HtmlDoc; +import org.apache.pdfbox.Loader; // NEUER Import +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; + +/** + * Repräsentiert ein komplettes PDF-Dokument. + * Diese Klasse ist AutoCloseable, um sicherzustellen, dass das + * zugrundeliegende PDDocument immer geschlossen wird. + * + * Beispiel-Nutzung: + * try (PdfDocument doc = new PdfDocument()) { + * doc.addTitlePage("Titel", infoMap); + * + * PageCanvas page1 = doc.addPage(); + * page1.drawText(50, 700, "Hallo Welt"); + * page1.close(); // WICHTIG: Jede Seite nach dem Zeichnen schließen + * + * doc.addHtmlPages(new PdfHtml("

Titel

")); // HTML-Seiten hinzufügen + * + * doc.save("pfad/zur/datei.pdf"); + * } + */ +public class PdfDocument implements AutoCloseable { + + private final PDDocument document; + + /** + * Erstellt ein neues, leeres PDF-Dokument. + */ + public PdfDocument() { + this.document = new PDDocument(); + } + + /** + * Fügt eine standardmäßige A4-Inhaltsseite hinzu. + * Sie MÜSSEN .close() auf dem zurückgegebenen PageCanvas aufrufen, + * wenn Sie mit dem Zeichnen fertig sind. + * + * @return Ein PageCanvas-Objekt zum Zeichnen auf der neuen Seite. + * @throws IOException Wenn der Content-Stream nicht erstellt werden kann. + */ + public PageCanvas addPage() throws IOException { + PDPage page = new PDPage(PDRectangle.A4); + this.document.addPage(page); + return new PageCanvas(this.document, page); + } + + /** + * Fügt eine Titelseite mit einem Haupttitel und einer zentrierten + * Key-Value-Informationskarte hinzu. + * + * @param title Der Haupttitel der Seite. + * @param info Eine Map von Key-Value-Paaren (z.B. "Autor", "Max Mustermann"). + * @throws IOException Wenn beim Zeichnen ein Fehler auftritt. + */ + public void addTitlePage(String title, Map info) throws IOException { + TitlePageRenderer.draw(this.document, title, info); + } + + /** + * Rendert den Inhalt eines PdfHtml-Objekts (mithilfe von openhtmltopdf) + * und fügt alle resultierenden Seiten diesem Dokument hinzu. + * + * @param html Das PdfHtml-Builder-Objekt, das den HTML-Code enthält. + * @throws IOException Wenn das Rendern oder Importieren fehlschlägt. + */ + public void addHtmlPages(HtmlDoc html) throws IOException { + // 1. Rendere das HTML in ein *temporäres*, separates In-Memory-PDDocument + PDDocument tempDoc = null; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + PdfRendererBuilder builder = new PdfRendererBuilder(); + builder.withHtmlContent(html.getHtmlContent(), "file:/"); // Basis-URI (wichtig für relative Pfade) + builder.toStream(baos); + builder.run(); + + // KORREKTUR: Verwende Loader.loadPDF statt PDDocument.load + tempDoc = Loader.loadPDF(baos.toByteArray()); // Lade das gerenderte PDF + + }catch (Exception ex) { + throw new InternalServerErrorException(ex); + } + + // 2. Importiere alle Seiten aus dem temporären Dokument in das Hauptdokument + if (tempDoc != null) { + for (PDPage page : tempDoc.getPages()) { + // Importiere die Seite in den Kontext unseres Hauptdokuments + this.document.importPage(page); + + // (Optional) Manchmal ist es nötig, die Boxen anzupassen, + // aber openhtmltopdf sollte korrekte A4-Seiten erstellen. + // this.document.getPage(this.document.getNumberOfPages() - 1).setMediaBox(PDRectangle.A4); + } + + // 3. Schließe das temporäre Dokument, um Speicher freizugeben + tempDoc.close(); + } + } + + + /** + * Speichert das Dokument im Dateisystem. + * + * @param path Der vollständige Pfad, einschließlich Dateiname. + * @throws IOException Wenn das Speichern fehlschlägt. + */ + public void save(String path) throws IOException { + this.document.save(path); + } + + /** + * Speichert das Dokument im Dateisystem. + * + * @param path Der Pfad als Path-Objekt. + * @throws IOException Wenn das Speichern fehlschlägt. + */ + public void save(Path path) throws IOException { + this.document.save(path.toFile()); + } + + /** + * Exportiert das Dokument als Byte-Array. + * + * @return Ein Byte-Array des PDF-Dokuments. + * @throws IOException Wenn das Speichern in den Stream fehlschlägt. + */ + public byte[] getAsBytes() throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + this.document.save(baos); + return baos.toByteArray(); + } + } + + /** + * Schließt das zugrundeliegende PDDocument. + * Erforderlich durch AutoCloseable. + * + * @throws IOException Wenn das Schließen fehlschlägt. + */ + @Override + public void close() throws IOException { + if (this.document != null) { + this.document.close(); + } + } + + /** + * Gibt das interne PDDocument zurück, falls erweiterte PDFBox-Operationen + * erforderlich sind. + * @return Das PDDocument. + */ + public PDDocument getInternalDocument() { + return this.document; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfHtml.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfHtml.java new file mode 100644 index 0000000..abda64e --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfHtml.java @@ -0,0 +1,268 @@ +package de.haevn.snippetmanage.common.utils.pdf; + +import java.util.ArrayList; +import java.util.List; + +/** + * Eine Datenklasse, die HTML-Inhalt für den PDF-Export repräsentiert. + * Sie verwenden diese Klasse, um den HTML-Inhalt zu übergeben, + * und rufen dann PdfDocument.addHtmlPages() auf, um ihn zu rendern + * und dem Dokument hinzuzufügen. + * + * Dies ist eine Builder-Klasse, um HTML-Strukturen flüssig zu erstellen. + */ +public class PdfHtml { + + // Liste für die einzelnen HTML-Element-Strings + private final List elements = new ArrayList<>(); + + // Speicher für zusätzliches, benutzerdefiniertes CSS + private String customCss = ""; + + /** + * Standard-Konstruktor. + */ + public PdfHtml() { + // Startet einen leeren HTML-Builder + } + + // --- ÖFFENTLICHE BUILDER-METHODEN --- + + /** + * Fügt einen rohen HTML-Element-String hinzu. + * @param element Ein vollständiger HTML-String (z.B. "
Hallo
") + * @return this (für flüssige Verkettung) + */ + public PdfHtml addElement(String element) { + elements.add(element); + return this; + } + + /** + * Fügt eine Überschrift der Ebene 1 hinzu. + * @param text Der Text für die Überschrift. + * @return this + */ + public PdfHtml addH1(String text) { + elements.add(String.format("

%s

", escapeHtml(text))); + return this; + } + + /** + * Fügt eine Überschrift der Ebene 2 hinzu. + * @param text Der Text für die Überschrift. + * @return this + */ + public PdfHtml addH2(String text) { + elements.add(String.format("

%s

", escapeHtml(text))); + return this; + } + + /** + * Fügt eine Überschrift der Ebene 3 hinzu. + * @param text Der Text für die Überschrift. + * @return this + */ + public PdfHtml addH3(String text) { + elements.add(String.format("

%s

", escapeHtml(text))); + return this; + } + + /** + * Fügt einen Standard-Absatz hinzu. + * @param text Der Text für den Absatz (HTML-Tags wie oder sind erlaubt). + * @return this + */ + public PdfHtml addParagraph(String text) { + elements.add(String.format("

%s

", escapeHtml(text))); + return this; + } + + /** + * Fügt eine unsortierte Liste (Bullet-Points) hinzu. + * @param items Eine Liste von Strings für die Listeneinträge. + * @return this + */ + public PdfHtml addList(List items) { + StringBuilder sb = new StringBuilder(); + sb.append("
    "); + for (String item : items) { + sb.append(String.format("
  • %s
  • ", escapeHtml(item))); // Erlaubt HTML im Item + } + sb.append("
"); + elements.add(sb.toString()); + return this; + } + + public PdfHtml addList(String title, List items) { + StringBuilder sb = new StringBuilder(); + sb.append("
    "); + sb.append(String.format("%s", escapeHtml(title))); + for (String item : items) { + sb.append(String.format("
  • %s
  • ", escapeHtml(item))); // Erlaubt HTML im Item + } + sb.append("
"); + elements.add(sb.toString()); + return this; + } + + /** + * Fügt eine sortierte (nummerierte) Liste hinzu. + * @param items Eine Liste von Strings für die Listeneinträge. + * @return this + */ + public PdfHtml addOrderedList(List items) { + StringBuilder sb = new StringBuilder(); + sb.append("
    "); + for (String item : items) { + sb.append(String.format("
  1. %s
  2. ", escapeHtml(item))); // Erlaubt HTML im Item + } + sb.append("
"); + elements.add(sb.toString()); + return this; + } + + public PdfHtml addOrderedList(String title, List items) { + StringBuilder sb = new StringBuilder(); + sb.append("
    "); + sb.append(String.format("%s", escapeHtml(title))); + for (String item : items) { + sb.append(String.format("
  1. %s
  2. ", escapeHtml(item))); // Erlaubt HTML im Item + } + sb.append("
"); + elements.add(sb.toString()); + return this; + } + + /** + * Fügt einen Code-Block hinzu, ideal für Quellcode. + * Der Inhalt wird automatisch HTML-escaped. + * @param code Der rohe Quellcode. + * @return this + */ + public PdfHtml addCodeBlock(String code) { + String escapedCode = escapeHtml(code); + elements.add(String.format("
%s
", escapedCode)); + return this; + } + + public PdfHtml addCodeBlock(String subTitle, String code) { + String escapedCode = escapeHtml(code); + elements.add(String.format("%s
%s
", escapeHtml(subTitle), escapedCode)); + return this; + } + + /** + * Fügt ein Zitat (Blockquote) hinzu. + * @param text Der Text des Zitats. + * @return this + */ + public PdfHtml addBlockquote(String text) { + elements.add(String.format("
%s
", text)); // Erlaubt HTML + return this; + } + + /** + * Fügt eine horizontale Trennlinie hinzu. + * @return this + */ + public PdfHtml addHorizontalRule() { + elements.add("
"); + return this; + } + + /** + * Fügt eine HTML-Tabelle hinzu. + * @param headers Spaltenüberschriften (kann null oder leer sein). + * @param data Eine Liste von Listen für die Zeilendaten. + * @return this + */ + public PdfHtml addTable(List headers, List> data) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + + // 1. Header + if (headers != null && !headers.isEmpty()) { + sb.append(""); + for (String header : headers) { + sb.append(String.format("", escapeHtml(header))); + } + sb.append(""); + } + + // 2. Body + sb.append(""); + if (data != null) { + for (List row : data) { + sb.append(""); + for (String cell : row) { + // Zelleninhalt nicht escapen, um HTML (z.B. ) zu erlauben + sb.append(String.format("", cell)); + } + sb.append(""); + } + } + sb.append("
%s
%s
"); + elements.add(sb.toString()); + return this; + } + + /** + * Fügt eine benutzerdefinierte CSS-Regel zum " + content + ""; + } + + /** + * Einfache Hilfsmethode, um HTML-Sonderzeichen zu escapen. + * Verhindert, dass z.B. "<" als Tag-Anfang interpretiert wird. + */ + private String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfTable.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfTable.java new file mode 100644 index 0000000..b688e2e --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfTable.java @@ -0,0 +1,88 @@ +package de.haevn.snippetmanage.common.utils.pdf; + +import java.util.ArrayList; +import java.util.List; + +/** + * Eine Datenklasse, die eine Tabelle für den PDF-Export repräsentiert. + * Sie verwenden diese Klasse, um die Tabelle zu "bauen" (Header, Zeilen) + * und übergeben sie dann an PageCanvas.drawTable() zum Zeichnen. + */ +public class PdfTable { + + private final float[] colWidths; + private final List> rows; + private List header; + + /** + * Erstellt eine neue Tabelle mit definierten Spaltenbreiten. + * + * @param colWidths Ein Array von float-Werten, das die Breite jeder Spalte angibt. + */ + public PdfTable(float[] colWidths) { + if (colWidths == null || colWidths.length == 0) { + throw new IllegalArgumentException("Column widths cannot be null or empty."); + } + this.colWidths = colWidths; + this.rows = new ArrayList<>(); + } + + /** + * Fügt die Kopfzeile zur Tabelle hinzu. + * + * @param headerRow Eine Liste von Strings für die Header-Zellen. + */ + public void addHeaderRow(List headerRow) { + if (headerRow.size() != colWidths.length) { + throw new IllegalArgumentException("Header row cell count (" + headerRow.size() + + ") does not match column widths count (" + colWidths.length + ")."); + } + this.header = new ArrayList<>(headerRow); + } + + /** + * Fügt eine Inhaltszeile zur Tabelle hinzu. + * + * @param dataRow Eine Liste von Strings für die Daten-Zellen. + */ + public void addRow(List dataRow) { + if (dataRow.size() != colWidths.length) { + throw new IllegalArgumentException("Data row cell count (" + dataRow.size() + + ") does not match column widths count (" + colWidths.length + ")."); + } + this.rows.add(new ArrayList<>(dataRow)); + } + + // --- Getter, die von PageCanvas verwendet werden --- + + float[] getColWidths() { + return colWidths; + } + + List getHeader() { + return header; + } + + public List> getRows() { + return rows; + } + + public boolean isEmpty() { + return header == null && rows.isEmpty(); + } + + public List> getAsList() { + List> table = new ArrayList<>(); + if (header != null) { + table.add(header); + } + table.addAll(rows); + return table; + + } +} + +/* + + + */ \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/TitlePageRenderer.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/TitlePageRenderer.java new file mode 100644 index 0000000..0fea70f --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/TitlePageRenderer.java @@ -0,0 +1,173 @@ +package de.haevn.snippetmanage.common.utils.pdf; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Zeichnet die Titelseite. + * (Interne Hilfsklasse für PdfDocument) + */ +class TitlePageRenderer { + + // Schriftarten und Größen + private static final PDType1Font FONT_BOLD = PageCanvas.FONT_BOLD; + private static final PDType1Font FONT_NORMAL = PageCanvas.FONT_NORMAL; + private static final float FONT_SIZE_TITLE = 24; + private static final float FONT_SIZE_INFO_LABEL = 12; + private static final float FONT_SIZE_INFO_VALUE = 12; + private static final float LINE_HEIGHT_INFO = 20; + private static final float MARGIN = 50; + + /** + * Zeichnet die Titelseite auf eine neue Seite im Dokument. + * + * @param document Das Zieldokument + * @param title Der Haupttitel + * @param info Die Key-Value-Paare + * @throws IOException Wenn das Zeichnen fehlschlägt + */ + static void draw(PDDocument document, String title, Map info) throws IOException { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + + float width = page.getMediaBox().getWidth(); + float height = page.getMediaBox().getHeight(); + + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + + // --- 1. Titel (Zentriert) --- + cs.setFont(FONT_BOLD, FONT_SIZE_TITLE); + String safeTitle = sanitize(title); + float titleWidth = FONT_BOLD.getStringWidth(safeTitle) / 1000 * FONT_SIZE_TITLE; + float titleX = (width - titleWidth) / 2; + float titleY = height - 150; // Oben zentriert + + cs.beginText(); + cs.newLineAtOffset(titleX, titleY); + cs.showText(safeTitle); + cs.endText(); + + // --- 2. Info-Block (Key-Value-Paare) --- + float y = titleY - 80; // Startposition unter dem Titel + float x_label = MARGIN + 50; + float x_value = x_label + 170; // Position für die Werte + float valueMaxWidth = width - x_value - MARGIN; // Max. Breite für umgebrochene Werte + + if (info != null && !info.isEmpty()) { + for (Map.Entry entry : info.entrySet()) { + String key = sanitize(entry.getKey()); + String value = sanitize(entry.getValue()); + + // Label (Key) zeichnen + cs.setFont(FONT_BOLD, FONT_SIZE_INFO_LABEL); + cs.beginText(); + cs.newLineAtOffset(x_label, y); + cs.showText(key); + cs.endText(); + + // --- KORREKTUR: Wert(e) mit Textumbruch zeichnen --- + cs.setFont(FONT_NORMAL, FONT_SIZE_INFO_VALUE); + + // Text umbrechen + List lines = wrapText(value, valueMaxWidth, FONT_NORMAL, FONT_SIZE_INFO_VALUE); + + float currentY = y; + for (String line : lines) { + cs.beginText(); + cs.newLineAtOffset(x_value, currentY); + cs.showText(line); + cs.endText(); + currentY -= LINE_HEIGHT_INFO; // Gehe für die *nächste* Zeile nach unten + } + + // Passe die Y-Position für das *nächste* Label an + // (Anzahl der Zeilen * Zeilenhöhe) + y -= (lines.size() * LINE_HEIGHT_INFO); + // --- ENDE KORREKTUR --- + } + } + } + } + + /** + * Bereinigt Text von Zeilenumbrüchen. + */ + private static String sanitize(String text) { + if (text == null) return "N/A"; + return text.replaceAll("\\r\\n|\\r|\\n", " "); + } + + // --- HELPER-METHODEN (Kopiert aus PdfBoxDinA4Generator) --- + + /** + * Bricht einen langen Text in mehrere Zeilen um, damit er in eine Breite passt. + */ + private static List wrapText(String text, float maxWidth, PDType1Font font, float fontSize) throws IOException { + List lines = new ArrayList<>(); + if (text == null || text.isEmpty()) { + lines.add(""); + return lines; + } + + String sanitizedText = text.replaceAll("\\r\\n|\\r|\\n", " "); + String[] words = sanitizedText.split(" "); + StringBuilder line = new StringBuilder(); + + for (String word : words) { + if (word.isEmpty()) continue; + + float wordWidth = font.getStringWidth(word) / 1000 * fontSize; + float lineWidth = font.getStringWidth(line.toString()) / 1000 * fontSize; + + if (wordWidth > maxWidth) { + String tempWord = word; + while (!tempWord.isEmpty()) { + int breakIndex = findBreakIndex(tempWord, maxWidth, font, fontSize); + String part = tempWord.substring(0, breakIndex); + + if (line.length() > 0) { + lines.add(line.toString()); + line = new StringBuilder(part); + } else { + lines.add(part); + } + tempWord = tempWord.substring(breakIndex); + } + } else if (lineWidth + wordWidth < maxWidth) { + if (line.length() > 0) line.append(" "); + line.append(word); + } else { + lines.add(line.toString()); + line = new StringBuilder(word); + } + } + lines.add(line.toString()); + return lines; + } + + /** + * Hilfsfunktion für wrapText: Findet den Umbruchpunkt in einem zu langen Wort. + */ + private static int findBreakIndex(String word, float maxWidth, PDType1Font font, float fontSize) throws IOException { + int i = 0; + float width = 0; + while (i < word.length()) { + char c = word.charAt(i); + float charWidth = font.getStringWidth(String.valueOf(c)) / 1000 * fontSize; + if (width + charWidth > maxWidth) { + break; + } + width += charWidth; + i++; + } + return Math.max(1, i); // Mindestens 1 Zeichen zurückgeben + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/CustomPdfDocument.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/CustomPdfDocument.java new file mode 100644 index 0000000..ab29bef --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/CustomPdfDocument.java @@ -0,0 +1,57 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; + +// --------------------------------------------------------- +// 7. Klasse: PdfDocument (Der Hauptcontainer) +// --------------------------------------------------------- +public class CustomPdfDocument { + private List pages = new ArrayList<>(); + private PdfMeta meta; + + public CustomPdfDocument(PdfMeta meta) { + this.meta = meta; + } + + public void addPage(PdfPage page) { + this.pages.add(page); + } + + public void save(String filename) throws IOException { + final byte[] bytes = toBytes(); + Files.write(Path.of(filename), bytes); + } + + public byte[] toBytes() throws IOException { + try (PDDocument doc = new PDDocument()) { + // Metadaten setzen + if (meta != null) { + PDDocumentInformation pdd = doc.getDocumentInformation(); + pdd.setTitle(meta.title); + pdd.setAuthor(meta.author); + pdd.setSubject(meta.subject); + pdd.setKeywords(meta.keywords); + pdd.setCreationDate(Calendar.getInstance()); + } + + // Seiten rendern + for (PdfPage page : pages) { + page.render(doc); + } + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + doc.save(baos); + return baos.toByteArray(); + } + } + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/IPdfObject.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/IPdfObject.java new file mode 100644 index 0000000..6abb5be --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/IPdfObject.java @@ -0,0 +1,14 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPageContentStream; + +import java.io.IOException; + +public interface IPdfObject { + float getX(); + float getY(); + float getWidth(); + float getHeight(); + void render(PDDocument document, PDPageContentStream contentStream) throws IOException; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/MultiPageTable.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/MultiPageTable.java new file mode 100644 index 0000000..9b1d72b --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/MultiPageTable.java @@ -0,0 +1,122 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; + +import java.util.ArrayList; +import java.util.List; + +import static de.haevn.snippetmanage.common.utils.pdfv2.PdfTable.wrapText; + +public class MultiPageTable { + private String[][] data; + private float[] colWeights; + private float x, width, minRowHeight; + private float startY; + private float subsequentY; + private float bottomMargin; + + private boolean drawBorders; + private TextAlign[] alignments; + private float[] calculatedColWidths; + + public MultiPageTable(String[][] data, float x, float startY, float width, float rowHeight, float[] colWeights, boolean drawBorders, TextAlign[] alignments) { + this.data = data; + this.colWeights = colWeights; + this.x = x; + this.startY = startY; + this.width = width; + this.minRowHeight = rowHeight; + this.drawBorders = drawBorders; + this.alignments = alignments; + + this.subsequentY = 800; + this.bottomMargin = 50; + + // Spaltenbreiten vorab berechnen für die Höhenvorhersage + int cols = (data != null && data.length > 0) ? data[0].length : 0; + this.calculatedColWidths = new float[cols]; + if (cols > 0) { + if (colWeights != null && colWeights.length == cols) { + float totalWeight = 0; + for (float w : colWeights) totalWeight += w; + for (int i = 0; i < cols; i++) this.calculatedColWidths[i] = (colWeights[i] / totalWeight) * width; + } else { + float even = width / cols; + for (int i = 0; i < cols; i++) this.calculatedColWidths[i] = even; + } + } + } + + public PdfPage drawToDocument(CustomPdfDocument doc, PdfPage currentPage) { + if (data == null || data.length == 0) return currentPage; + + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + int fontSize = 10; + + String[] headerRow = data[0]; + List buffer = new ArrayList<>(); + buffer.add(headerRow); + + PdfPage activePage = currentPage; + float currentTableTopY = (activePage == currentPage) ? startY : subsequentY; + + // Initiale Cursor Position berechnen (Header Höhe abziehen) + float headerHeight = calculateRealRowHeight(headerRow, font, fontSize); + float cursorY = currentTableTopY - headerHeight; + + for (int i = 1; i < data.length; i++) { + // 1. Berechnen, wie hoch DIESE Zeile sein wird + float neededHeight = calculateRealRowHeight(data[i], font, fontSize); + + // 2. Passt sie noch drauf? + if (cursorY - neededHeight < bottomMargin) { + // NEIN -> Rendern und neue Seite + renderBufferToPage(activePage, buffer, currentTableTopY); + + activePage = new PdfPage(); + doc.addPage(activePage); + + buffer.clear(); + buffer.add(headerRow); // Header auf neuer Seite + + currentTableTopY = subsequentY; + // Header Höhe auf neuer Seite neu abziehen + headerHeight = calculateRealRowHeight(headerRow, font, fontSize); + cursorY = subsequentY - headerHeight; + } + + buffer.add(data[i]); + cursorY -= neededHeight; + } + + if (!buffer.isEmpty()) { + renderBufferToPage(activePage, buffer, currentTableTopY); + } + + return activePage; + } + + // Berechnet die tatsächliche Höhe einer Zeile basierend auf Textumbruch + private float calculateRealRowHeight(String[] row, PDFont font, int fontSize) { + int maxLines = 1; + for (int c = 0; c < row.length; c++) { + if (c >= calculatedColWidths.length) break; + String text = (row[c] != null) ? row[c] : ""; + // Verwende die gleiche Wrapper-Logik wie PdfTable + List lines = wrapText(text, calculatedColWidths[c] - 10, font, fontSize); + if (lines.size() > maxLines) maxLines = lines.size(); + } + float required = (maxLines * (fontSize + 2)) + 8; + return Math.max(minRowHeight, required); + } + + private void renderBufferToPage(PdfPage page, List rows, float topY) { + String[][] tableData = rows.toArray(new String[0][]); + PdfTable tablePart = new PdfTable( + x, topY, width, minRowHeight, tableData, colWeights, drawBorders, alignments + ); + page.addObject(tablePart); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfGeneratorSystem.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfGeneratorSystem.java new file mode 100644 index 0000000..02cafa8 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfGeneratorSystem.java @@ -0,0 +1,9 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +public class PdfGeneratorSystem { + + + public static void main(String[] args) { + + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfImage.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfImage.java new file mode 100644 index 0000000..dc0e2c3 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfImage.java @@ -0,0 +1,59 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; + +import java.awt.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +public class PdfImage implements IPdfObject { + private float x, y, width, height; + private String imagePath; + + public PdfImage(String imagePath, float x, float y, float width, float height) { + this.imagePath = imagePath; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + private void drawPlaceholder(PDPageContentStream contentStream, String msg) throws IOException { + // Zeichnet ein graues Rechteck als Platzhalter + contentStream.setNonStrokingColor(Color.LIGHT_GRAY); + contentStream.addRect(x, y, width, height); + contentStream.fill(); + + contentStream.setNonStrokingColor(Color.RED); + contentStream.beginText(); + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.COURIER), 10); + contentStream.newLineAtOffset(x + 5, y + (height/2)); + contentStream.showText("[" + msg + "]"); + contentStream.endText(); + } + + @Override public float getX() { return x; } + @Override public float getY() { return y; } + @Override public float getWidth() { return width; } + @Override public float getHeight() { return height; } + + @Override + public void render(PDDocument document, PDPageContentStream contentStream) throws IOException { + try { + // Versuchen das Bild zu laden + if (Files.exists(Paths.get(imagePath))) { + PDImageXObject pdImage = PDImageXObject.createFromFile(imagePath, document); + contentStream.drawImage(pdImage, x, y, width, height); + } else { + drawPlaceholder(contentStream, "Bild nicht gef."); + } + } catch (Exception e) { + drawPlaceholder(contentStream, "Error"); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfMeta.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfMeta.java new file mode 100644 index 0000000..9e41dee --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfMeta.java @@ -0,0 +1,13 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +public class PdfMeta { + public String title; + public String author; + public String subject; + public String keywords; + + public PdfMeta(String title, String author) { + this.title = title; + this.author = author; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfPage.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfPage.java new file mode 100644 index 0000000..8ccd40a --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfPage.java @@ -0,0 +1,37 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class PdfPage { + private List elements = new ArrayList<>(); + + // Standard A4 Größe + private float width = PDRectangle.A4.getWidth(); + private float height = PDRectangle.A4.getHeight(); + + public void addObject(IPdfObject obj) { + this.elements.add(obj); + } + + /** + * Generiert diese Seite und fügt sie dem PDFDocument hinzu. + */ + public void render(PDDocument document) throws IOException { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + + // Content Stream öffnen (try-with-resources schließt ihn automatisch) + try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) { + for (IPdfObject obj : elements) { + obj.render(document, contentStream); + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfTable.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfTable.java new file mode 100644 index 0000000..3d4b1cc --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfTable.java @@ -0,0 +1,259 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; + +import java.awt.*; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class PdfTable implements IPdfObject { + private float x, y, width, height; + private String[][] data; + private float baseRowHeight; // Mindesthöhe + private float[] colWidths; + private float[] rowHeights; // Tatsächliche Höhe pro Zeile (dynamisch) + private boolean drawBorders; + private TextAlign[] alignments; + + // Cache für berechnete Zeilenumbrüche: List[Spalte] pro Datenzeile + private List[]> wrappedData; + + public PdfTable(float x, float y, float width, float rowHeight, String[][] data) { + this(x, y, width, rowHeight, data, null, true, null); + } + + public PdfTable(float x, float y, float width, float rowHeight, String[][] data, float[] colWeights) { + this(x, y, width, rowHeight, data, colWeights, true, null); + } + + public PdfTable(float x, float y, float width, float rowHeight, String[][] data, float[] colWeights, boolean drawBorders, TextAlign[] alignments) { + this.x = x; + this.y = y; + this.width = width; + this.baseRowHeight = rowHeight; + this.data = data; + this.drawBorders = drawBorders; + this.alignments = alignments; + this.wrappedData = new ArrayList<>(); + + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + int fontSize = 10; + + if (data.length > 0) { + int cols = data[0].length; + this.colWidths = new float[cols]; + this.rowHeights = new float[data.length]; + + // 1. Spaltenbreiten berechnen + if (colWeights != null && colWeights.length == cols) { + float totalWeight = 0; + for (float w : colWeights) totalWeight += w; + for (int i = 0; i < cols; i++) { + this.colWidths[i] = (colWeights[i] / totalWeight) * width; + } + } else { + float evenWidth = width / cols; + for (int i = 0; i < cols; i++) { + this.colWidths[i] = evenWidth; + } + } + + // 2. Zeilenhöhen vor-berechnen (Pre-Processing) + if (this.alignments == null) { + this.alignments = new TextAlign[cols]; + for(int i=0; i[] rowWrappedContent = new List[cols]; + int maxLinesInRow = 1; + + for (int c = 0; c < cols; c++) { + String rawText = (c < row.length && row[c] != null) ? row[c] : ""; + // Umbruch berechnen (Padding abziehen: 5 links + 5 rechts = 10) + List lines = wrapText(rawText, colWidths[c] - 10, font, fontSize); + rowWrappedContent[c] = lines; + if (lines.size() > maxLinesInRow) { + maxLinesInRow = lines.size(); + } + } + this.wrappedData.add(rowWrappedContent); + + // Höhe dieser Zeile: (Anzahl Zeilen * FontHöhe) + Padding + // Wir nehmen baseRowHeight als Minimum, falls nur 1 Zeile da ist + float requiredHeight = (maxLinesInRow * (fontSize + 2)) + 8; // +2 Leading, +8 Padding + this.rowHeights[r] = Math.max(this.baseRowHeight, requiredHeight); + totalTableHeight += this.rowHeights[r]; + } + this.height = totalTableHeight; + } + } + + @Override + public void render(PDDocument document, PDPageContentStream contentStream) throws IOException { + contentStream.setStrokingColor(Color.BLACK); + contentStream.setLineWidth(0.5f); + + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + int fontSize = 10; + float leading = fontSize + 2; // Zeilenabstand + + float currentY = y; + + for (int r = 0; r < data.length; r++) { + float currentRowHeight = rowHeights[r]; + float currentX = x; + List[] rowContent = wrappedData.get(r); + + for (int c = 0; c < rowContent.length; c++) { + List lines = rowContent[c]; + float currentCellWidth = (colWidths != null && c < colWidths.length) ? colWidths[c] : 0; + TextAlign align = (alignments != null && c < alignments.length) ? alignments[c] : TextAlign.LEFT; + + // 1. Rahmen zeichnen + if (drawBorders) { + contentStream.addRect(currentX, currentY - currentRowHeight, currentCellWidth, currentRowHeight); + contentStream.stroke(); + } + + // 2. Text rendern (Zeile für Zeile) + contentStream.setNonStrokingColor(Color.BLACK); + contentStream.setFont(font, fontSize); + + // Start Y für Text (oben in der Zelle mit etwas Padding) + float textStartY = currentY - fontSize - 4; + + for (String line : lines) { + float textWidth = 0; + try { + textWidth = font.getStringWidth(line) / 1000 * fontSize; + } catch (Exception e) {} // Ignore font errors + + float textX = currentX + 5; + + if (align == TextAlign.RIGHT) { + textX = currentX + currentCellWidth - textWidth - 5; + } else if (align == TextAlign.CENTER) { + textX = currentX + (currentCellWidth - textWidth) / 2; + } + + contentStream.beginText(); + contentStream.newLineAtOffset(textX, textStartY); + contentStream.showText(line); + contentStream.endText(); + + textStartY -= leading; // Nächste Zeile + } + + currentX += currentCellWidth; + } + currentY -= currentRowHeight; + } + } + + @Override public float getX() { return x; } + @Override public float getY() { return y; } + @Override public float getWidth() { return width; } + @Override public float getHeight() { return height; } + + + + public static List wrapText(String text, float maxWidth, PDFont font, int fontSize) { + List lines = new ArrayList<>(); + if (text == null || text.isEmpty()) { + return lines; + } + + // Sicherheitscheck falls Spalte extrem klein + if (maxWidth < 5) maxWidth = 5; + + // 1. Sanitize + String cleanText = text.replace("\n", " ").replace("\r", " ").replace("\t", " "); + + String[] words = cleanText.split(" "); + StringBuilder currentLine = new StringBuilder(); + float currentLineWidth = 0; + + for (String word : words) { + float wordWidth = 0; + try { + wordWidth = font.getStringWidth(word) / 1000 * fontSize; + } catch (Exception e) { continue; } + + float spaceWidth = 0; + if (currentLine.length() > 0) { + try { spaceWidth = font.getStringWidth(" ") / 1000 * fontSize; } catch (Exception e) {} + } + + // Fall 1: Wort passt auf die aktuelle Zeile + if (currentLineWidth + spaceWidth + wordWidth <= maxWidth) { + if (currentLine.length() > 0) { + currentLine.append(" "); + currentLineWidth += spaceWidth; + } + currentLine.append(word); + currentLineWidth += wordWidth; + } else { + // Zeile voll -> aktuelle Zeile abschließen + if (currentLine.length() > 0) { + lines.add(currentLine.toString()); + currentLine = new StringBuilder(); + currentLineWidth = 0; + spaceWidth = 0; + } + + // Fall 2: Wort passt alleine auf eine neue Zeile + if (wordWidth <= maxWidth) { + currentLine.append(word); + currentLineWidth = wordWidth; + } else { + // Fall 3: Wort ist SELBST zu lang für eine Zeile -> Hart trennen + String remaining = word; + while (!remaining.isEmpty()) { + int splitIndex = 0; + // Zeichenweise prüfen wie viel passt + for (int i = 1; i <= remaining.length(); i++) { + String sub = remaining.substring(0, i); + float subWidth = 0; + try { subWidth = font.getStringWidth(sub) / 1000 * fontSize; } catch(Exception e){} + + if (subWidth > maxWidth) { + break; + } + splitIndex = i; + } + + // Mindestens 1 Zeichen nehmen, um Endlosschleifen zu vermeiden + if (splitIndex == 0) splitIndex = 1; + + String chunk = remaining.substring(0, splitIndex); + + // Wenn noch Rest übrig ist, Chunk direkt als Zeile adden + if (splitIndex < remaining.length()) { + lines.add(chunk); + } else { + // Letztes Stück in den Buffer für evtl. folgende Wörter + currentLine.append(chunk); + try { currentLineWidth = font.getStringWidth(chunk) / 1000 * fontSize; } catch(Exception e){} + } + remaining = remaining.substring(splitIndex); + } + } + } + } + // Letzte Zeile hinzufügen + if (currentLine.length() > 0) { + lines.add(currentLine.toString()); + } + + return lines; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfText.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfText.java new file mode 100644 index 0000000..c98f2c0 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/PdfText.java @@ -0,0 +1,71 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; + +import java.awt.*; +import java.io.IOException; + +public class PdfText implements IPdfObject { + private float x, y; + private String text; + private int fontSize; + private Color color; + private boolean centered; + private float pageWidth = PDRectangle.A4.getWidth(); + + // Standard: Links bündig an X + public PdfText(String text, float x, float y, int fontSize, Color color) { + this(text, x, y, fontSize, color, false); + } + + // Zentriert (X wird ignoriert) + public PdfText(String text, float y, int fontSize, Color color) { + this(text, 0, y, fontSize, color, true); + } + + private PdfText(String text, float x, float y, int fontSize, Color color, boolean centered) { + this.text = text; + this.x = x; + this.y = y; + this.fontSize = fontSize; + this.color = color; + this.centered = centered; + } + + @Override + public void render(PDDocument document, PDPageContentStream contentStream) throws IOException { + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + + // Sanitize Text: PDFBox kann \n oder \r nicht in showText rendern -> Crash + String cleanText = text.replace("\n", " ").replace("\r", " ").replace("\t", " "); + + + float drawX = x; + + if (centered) { + try { + float textWidth = font.getStringWidth(text) / 1000 * fontSize; + drawX = (pageWidth - textWidth) / 2; + } catch (IllegalArgumentException e) { + drawX = (pageWidth) / 2; + } + } + + contentStream.beginText(); + contentStream.setFont(font, fontSize); + contentStream.setNonStrokingColor(color); + contentStream.newLineAtOffset(drawX, y); + contentStream.showText(cleanText); + contentStream.endText(); + } + + @Override public float getX() { return x; } + @Override public float getY() { return y; } + @Override public float getWidth() { return 0; } + @Override public float getHeight() { return fontSize; } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TableCell.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TableCell.java new file mode 100644 index 0000000..0926aef --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TableCell.java @@ -0,0 +1,24 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +public class TableCell { + private String content; + private float width; + + public TableCell(String content, float width) { + + this.content = content; + this.width = width; + } + + public TableCell(String content) { + this(content, -1); // Default width + } + + public String getContent() { + return content; + } + + public float getWidth() { + return width; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TableRow.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TableRow.java new file mode 100644 index 0000000..e750e70 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TableRow.java @@ -0,0 +1,15 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +import java.util.ArrayList; +import java.util.List; + +public class TableRow { + List cells = new ArrayList<>(); + public void addCell(TableCell cell) { + cells.add(cell); + } + + public List getCells() { + return cells; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TextAlign.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TextAlign.java new file mode 100644 index 0000000..1ff17dc --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdfv2/TextAlign.java @@ -0,0 +1,6 @@ +package de.haevn.snippetmanage.common.utils.pdfv2; + +public enum TextAlign { + LEFT, CENTER, RIGHT +} + diff --git a/backend/src/main/java/de/haevn/snippetmanage/file/FileInfo.java b/backend/src/main/java/de/haevn/snippetmanage/file/FileInfo.java new file mode 100644 index 0000000..389df22 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/file/FileInfo.java @@ -0,0 +1,20 @@ +package de.haevn.snippetmanage.file; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Data; + +@Entity(name = "file_info") +@Data +public class FileInfo +{ + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private String id; + private String name; + private String path; + private String type; + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/file/FileRepository.java b/backend/src/main/java/de/haevn/snippetmanage/file/FileRepository.java new file mode 100644 index 0000000..8d11278 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/file/FileRepository.java @@ -0,0 +1,7 @@ +package de.haevn.snippetmanage.file; + + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FileRepository extends JpaRepository { +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShare.java b/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShare.java new file mode 100644 index 0000000..915af04 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShare.java @@ -0,0 +1,22 @@ +// java +package de.haevn.snippetmanage.fileshare; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Transient; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@Document(collection = "file_shares") +class FileShare { + @Transient + byte[] data; + @Id + private String id; // can store UUID.toString() or Mongo ObjectId + private String shortId; + private String password; + private long creationDate; + private long expirationDate; + private String filename; + private String hash; +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareController.java b/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareController.java new file mode 100644 index 0000000..6e01bd0 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareController.java @@ -0,0 +1,58 @@ +package de.haevn.snippetmanage.fileshare; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import de.haevn.snippetmanage.common.exception.ApplicationException; +import de.haevn.snippetmanage.common.utils.DownloadUtils; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@RestApiController("/api/file_shares") +class FileShareController { + private final FileShareService fileShareService; + private final DownloadUtils downloadUtils; + + public FileShareController(final FileShareService fileShareService, DownloadUtils downloadUtils) { + this.fileShareService = fileShareService; + this.downloadUtils = downloadUtils; + } + + @GetMapping + @Operation(summary = "Gets all file shares' metadata") + public List getAllFileShares() { + return fileShareService.getAllFileShares(); + } + + @PostMapping(path = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "Uploads a file via multipart/form-data (field name: `file`)") + public List uploadFileMultipart(@RequestPart("file") MultipartFile[] files) throws IOException { + List ids = new ArrayList<>(); + for (MultipartFile file : files) { + if (file == null || file.isEmpty()) { + continue; + } + ids.add(fileShareService.storeFile(file.getOriginalFilename(), file.getBytes())); + } + return ids; + } + + @GetMapping("/download/{shortId}") + @Operation(summary = "Downloads a file from the file share service") + public ResponseEntity downloadFile(@PathVariable String shortId) throws ApplicationException, IOException { + var fs = fileShareService.getFile(shortId); + return downloadUtils.download(fs.getData(), fs.getFilename(), DownloadUtils.FileType.BIN); + } + + @DeleteMapping("/delete/{shortId}") + @Operation(summary = "Deletes a file from the file share service") + public void deleteFile(@PathVariable String shortId) { + fileShareService.deleteFile(shortId); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareRepository.java b/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareRepository.java new file mode 100644 index 0000000..404e3af --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareRepository.java @@ -0,0 +1,21 @@ +package de.haevn.snippetmanage.fileshare; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; + +import java.util.List; +import java.util.Optional; + +interface FileShareRepository extends MongoRepository { + boolean existsByShortId(String shortId); + + List findAllByShortId(String shortId); + + Optional findByShortId(String shortId); + + void deleteByShortId(String shortId); + + // return count of documents where password exists and is not empty + @Query(value = "{ 'shortId': ?0, 'password': { $exists: true, $ne: '' } }", count = true) + long countWithPassword(String shortId); +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareService.java b/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareService.java new file mode 100644 index 0000000..df8f48a --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/fileshare/FileShareService.java @@ -0,0 +1,102 @@ +package de.haevn.snippetmanage.fileshare; + +import de.haevn.snippetmanage.common.exception.InternalServerErrorException; +import de.haevn.snippetmanage.common.exception.NotFoundException; +import de.haevn.snippetmanage.common.exception.UnprocessableEntityException; +import de.haevn.snippetmanage.common.utils.CryptoUtils; +import de.haevn.snippetmanage.common.utils.ShortIdGenerator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +@Service +class FileShareService { + + @Value("${haevn.application.file_share.path}") + private String dataDirectory; + + private final FileShareRepository shareRepository; + private final CryptoUtils cryptoUtils; + + public FileShareService(final FileShareRepository shareRepository, CryptoUtils cryptoUtils) { + this.shareRepository = shareRepository; + this.cryptoUtils = cryptoUtils; + } + + String storeFile(String filename, byte[] data) { + FileShare fs = new FileShare(); + String hash = cryptoUtils.calculateCheckSum(data, "SHA-256"); + + String sid = ShortIdGenerator.generateUnique(8, shareRepository::existsByShortId, 10); + fs.setShortId(sid); + + + fs.setFilename(filename); + fs.setCreationDate(System.currentTimeMillis()); + fs.setHash(hash); + + try { + + Files.write(Path.of(dataDirectory, fs.getFilename()), data); + shareRepository.save(fs); + } catch (IOException e) { + throw new InternalServerErrorException("Could not store file", e); + } + + return sid; + } + + FileShare getFile(String sid) throws IOException { + final FileShare fs = shareRepository.findByShortId(sid).orElseThrow(NotFoundException::new); + File f = new File(dataDirectory, fs.getFilename()); + if (!f.exists()) { + shareRepository.deleteByShortId(sid); + throw new NotFoundException("File not found"); + + } + byte[] data = Files.readAllBytes(f.toPath()); + boolean checksumEquals = cryptoUtils.isChecksumCorrect(data, fs.getHash(), "SHA-256"); + if (!checksumEquals) { + throw new UnprocessableEntityException("File checksum does not match, file may be corrupted"); + } + + fs.setData(data); + + return fs; + } + + void deleteFile(String sid) { + try { + final FileShare fs = shareRepository.findByShortId(sid).orElseThrow(NotFoundException::new); + final File f = new File(dataDirectory, fs.getFilename()); + if (f.exists()) { + Files.delete(f.toPath()); + } + + shareRepository.deleteByShortId(sid); + } catch (Exception ignored) { + // Ignore exceptions during delete + } + } + + @Scheduled(cron = "0 0 * * * *") + void deleteOrphanedFiles() { + List allShares = shareRepository.findAll(); + for (FileShare fs : allShares) { + File f = new File(dataDirectory, fs.getFilename()); + if (!f.exists()) { + shareRepository.deleteByShortId(fs.getShortId()); + } + } + } + + public List getAllFileShares() { + return shareRepository.findAll(); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/gitlab/GitlabController.java b/backend/src/main/java/de/haevn/snippetmanage/gitlab/GitlabController.java new file mode 100644 index 0000000..266b2d1 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/gitlab/GitlabController.java @@ -0,0 +1,26 @@ +package de.haevn.snippetmanage.gitlab; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@RestApiController(value = "/api/git", tagName = "GitLab Info", description = "Controller for GitLab information and metadata") +public class GitlabController { + + private final GitlabService gitlabService; + + public GitlabController(GitlabService gitlabService) { + this.gitlabService = gitlabService; + } + + @GetMapping("/merge-request/{state}") + List listMergeRequests(@PathVariable("state") String state) { + return gitlabService.listMergeRequests(state); + } + + @GetMapping("/pipeline/{id}") + Pipeline listPipeline(@PathVariable("id") long id) { + return gitlabService.getPipeline(id); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/gitlab/GitlabService.java b/backend/src/main/java/de/haevn/snippetmanage/gitlab/GitlabService.java new file mode 100644 index 0000000..4918beb --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/gitlab/GitlabService.java @@ -0,0 +1,104 @@ +package de.haevn.snippetmanage.gitlab; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import de.haevn.snippetmanage.redmine.RedmineResponse; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class GitlabService { + + @Value("${secure.gitlab.token}") + private String token; + @Value("${secure.gitlab.url}") + private String gitlabUrl; + + private String projectId = "250"; + + private List mergeRequests = new ArrayList<>(); + + private final File file = new File("/data/pipeline.json"); + + public GitlabService() { + mergeRequests = readDebug(); + } + + public Pipeline getPipeline(long pipelineId) { + final RestTemplate restTemplate = restTemplateWithApiKey(); + final String endpoint = gitlabUrl + "/" + projectId + "/pipelines/" + pipelineId; + return restTemplate.getForObject(endpoint, Pipeline.class); + } + + public List listMergeRequests(final String state) { + if(!mergeRequests.isEmpty()){ + return mergeRequests; + } + final RestTemplate restTemplate = restTemplateWithApiKey(); + final String endpoint = gitlabUrl + "/" + projectId + "/merge_requests?scope=assigned_to_me&status=" + state; + var response = restTemplate.getForObject(endpoint, MergeRequest[].class); + if(null == response) { + return List.of(); + } + + mergeRequests = Arrays.stream(response).toList(); + refreshPipelines(); + debugSave(); + return mergeRequests; + } + + + List refreshPipelines(){ + final RestTemplate restTemplate = restTemplateWithApiKey(); + for(MergeRequest mr : mergeRequests) { + var endpoint = gitlabUrl + "/" + projectId + "/merge_requests/" + mr.getIid() + "/pipelines?per_page=1"; + var response = restTemplate.getForObject(endpoint, Pipeline[].class); + if(response != null && response.length > 0) { + mr.setPipeline(response[0]); + } + + } + return List.of(); + } + + private RestTemplate restTemplateWithApiKey() { + RestTemplate rt = new RestTemplate(); + rt.getInterceptors().add((request, body, execution) -> { + request.getHeaders().add("PRIVATE-TOKEN", token); + request.getHeaders().setAccept(List.of(org.springframework.http.MediaType.APPLICATION_JSON)); + return execution.execute(request, body); + }); + return rt; + } + + + List readDebug(){ + List result = new ArrayList<>(); + + try{ + ObjectMapper mapper = new JsonMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + result = Arrays.asList(mapper.readValue(file, MergeRequest[].class)); + }catch (Exception ex){ + ex.printStackTrace(); + } + return result; + } + + void debugSave(){ + ObjectMapper mapper = new JsonMapper(); + try { + mapper.writeValue(file, mergeRequests); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/gitlab/MergeRequest.java b/backend/src/main/java/de/haevn/snippetmanage/gitlab/MergeRequest.java new file mode 100644 index 0000000..e1f636e --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/gitlab/MergeRequest.java @@ -0,0 +1,30 @@ +package de.haevn.snippetmanage.gitlab; + +import java.util.List; +import java.util.Objects; +import lombok.Data; + +@Data +public class MergeRequest { + private long id; + private long iid; + private long project_id; + private String title; + private String description; + private String state; + private String created_at; + private String updated_at; + private String target_branch; + private String source_branch; + private User author; + private User assignee; + private List reviewers; + private String merge_status; + private String detailed_merge_status; + private String web_url; + private boolean has_conflicts; + private boolean blocking_discussions_resolved; + private Pipeline pipeline; + private Pipeline head_pipeline; + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/gitlab/Pipeline.java b/backend/src/main/java/de/haevn/snippetmanage/gitlab/Pipeline.java new file mode 100644 index 0000000..3073708 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/gitlab/Pipeline.java @@ -0,0 +1,4 @@ +package de.haevn.snippetmanage.gitlab; + +public record Pipeline(long id, long iid, long project_id, String sha, String ref, String status, String source, String created_at, + String updated_at, String web_url) { } diff --git a/backend/src/main/java/de/haevn/snippetmanage/gitlab/User.java b/backend/src/main/java/de/haevn/snippetmanage/gitlab/User.java new file mode 100644 index 0000000..053ab16 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/gitlab/User.java @@ -0,0 +1,4 @@ +package de.haevn.snippetmanage.gitlab; + +public record User(long id, String username, String name, String state, boolean locked, String avatar_url, String web_url) { +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/info/InfoController.java b/backend/src/main/java/de/haevn/snippetmanage/info/InfoController.java new file mode 100644 index 0000000..17d69ac --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/info/InfoController.java @@ -0,0 +1,85 @@ +package de.haevn.snippetmanage.info; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@RestApiController(value = "/api/info", tagName = "Info Controller", description = "Controller for service information and metadata") +public class InfoController { + + private final InfoService infoService; + + public InfoController(InfoService infoService) { + this.infoService = infoService; + } + + @GetMapping + @Operation(summary = "Get Service Info", description = "Retrieve basic information about the service") + @ApiResponse(responseCode = "200", description = "Info retrieved successfully") + public Map getInfo(@RequestHeader Map headers) { + final Map info = new ConcurrentHashMap<>(); + info.put("serviceName", infoService.getServiceName()); + info.put("version", infoService.getVersion()); + info.put("hostName", infoService.getHostName()); + info.put("timestamp", infoService.getTimeStamp()); + info.put("serverTime", String.valueOf(infoService.getServerTime())); + info.put("uptime", String.valueOf(infoService.getUptime())); + + return info; + } + + @GetMapping("/ping") + @Operation(summary = "Ping", description = "Ping the service to check if it's reachable") + @ApiResponse(responseCode = "200", description = "Service is reachable") + public String ping() { + return "pong"; + } + + @GetMapping("/health") + @Operation(summary = "Health Check", description = "Check if the service is running") + @ApiResponse(responseCode = "200", description = "Service is running") + public ResponseEntity healthCheck() { + return ResponseEntity.ok("Service is running"); + } + + + @GetMapping("/version") + @Operation(summary = "Get Service Version", description = "Retrieve the current version of the service") + @ApiResponse(responseCode = "200", description = "Version retrieved successfully") + String version() { + return infoService.getVersion(); + } + + @GetMapping("/status") + @Operation(summary = "Get Service Status", description = "Retrieve the current status of the service components") + @ApiResponse(responseCode = "200", description = "Status retrieved successfully") + Map getStatus() { + final Map status = new ConcurrentHashMap<>(); + status.put("database", infoService.checkDatabase()); + status.put("services", infoService.checkServices()); + return status; + } + + @GetMapping("/time") + @Operation(summary = "Get Service Time Info", description = "Retrieve the current server time and uptime") + @ApiResponse(responseCode = "200", description = "Time info retrieved successfully") + long getTime() { + return infoService.getServerTime(); + } + + @GetMapping("/uptime") + @Operation(summary = "Get Service Uptime", description = "Retrieve the uptime of the service") + @ApiResponse(responseCode = "200", description = "Uptime retrieved successfully") + long getUptime() { + return infoService.getUptime(); + } + + + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/info/InfoService.java b/backend/src/main/java/de/haevn/snippetmanage/info/InfoService.java new file mode 100644 index 0000000..1ce8fbf --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/info/InfoService.java @@ -0,0 +1,52 @@ +package de.haevn.snippetmanage.info; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; + +import java.lang.management.ManagementFactory; +import java.time.Instant; + +@Service +public class InfoService { + private final Environment env; + + public InfoService(final Environment env) { + this.env = env; + } + + public long getUptime() { + return ManagementFactory.getRuntimeMXBean().getUptime(); + } + + public long getServerTime() { + return System.currentTimeMillis(); + } + + public String getTimeStamp() { + return Instant.now().toString(); + } + + public String getServiceName() { + return env.getProperty("spring.application.name", "N/A"); + } + + public String getVersion() { + return env.getProperty("spring.application.version", "N/A"); + } + + public String getHostName() { + try { + return java.net.InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + return "unknown"; + } + } + + public String checkDatabase() { + return "UP"; + } + + public String checkServices() { + return "UP"; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/info/sup/SupController.java b/backend/src/main/java/de/haevn/snippetmanage/info/sup/SupController.java new file mode 100644 index 0000000..dec9ecb --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/info/sup/SupController.java @@ -0,0 +1,84 @@ +package de.haevn.snippetmanage.info.sup; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import de.haevn.snippetmanage.common.exception.BadRequestException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@RestApiController("/api/db") +public class SupController { + private final SupService supService; + + public SupController(final SupService supService) { + this.supService = supService; + } + + @GetMapping("/tables") + public ResponseEntity getAllSup() { + var result = supService.getAllSup().keySet(); + return ResponseEntity.ok(result); + } + + @GetMapping("/tables/{name}") + public ResponseEntity>> getSup(@PathVariable("name") String name) { + final var result = supService.getAllSup().get(name); + return ResponseEntity.ok(result); + } + + @GetMapping("/tables/{name}/{type}") + public ResponseEntity>> getSupByType(@PathVariable("name") final String name, @PathVariable("type") final String type, + @RequestParam("value") final Optional valueOpt) { + var table = supService.getAllSup().get(name); + if (table == null) { + throw new TableNotFoundException(name); + } + if (valueOpt.isEmpty()) { + return ResponseEntity.ok(table); + } + + final String value = valueOpt.get(); + final List> result = table.stream().filter(entry -> { + final var entryValue = entry.get(type); + return entryValue != null && entryValue.toString().equals(value); + }).toList(); + return ResponseEntity.ok(result); + + } + + @GetMapping("/query") + public ResponseEntity>> querySup(@RequestBody final String sql) { + if (sql.toLowerCase().contains("delete") || sql.toLowerCase().contains("update") || sql.toLowerCase() + .contains("insert")) { + throw new BadRequestException("Only SELECT queries are allowed."); + } + var result = supService.get(sql); + return ResponseEntity.ok(result); + } + + @GetMapping("/get/{id}") + public ResponseEntity> getSupById(@PathVariable("id") final int id) { + final var result = supService.getAllSup(); + final Map found = new HashMap<>(); + + result.forEach((k, v) -> { + for (final Map stringObjectMap : v) { + if (stringObjectMap.get("id").equals(id)) { + found.put(k, stringObjectMap); + break; + } + } + }); + + return ResponseEntity.ok(found); + } + + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/info/sup/SupService.java b/backend/src/main/java/de/haevn/snippetmanage/info/sup/SupService.java new file mode 100644 index 0000000..33c30f0 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/info/sup/SupService.java @@ -0,0 +1,197 @@ +package de.haevn.snippetmanage.info.sup; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.sql.*; +import java.util.*; + +@Service +public class SupService { + + @Value("${haevn.application.wmt.db.url}") + private String dbUrl; + + private final Map>> cache = new HashMap<>(); + + List> get(String query) { + // Manual SQL Query Execution + Properties info = new Properties(); + info.put("user", "postgres"); + try (Connection con = DriverManager.getConnection(dbUrl, info)) { + + try (PreparedStatement pst = con.prepareStatement(query)) { + List> result = new ArrayList<>(); + + if (pst.execute()) { + ResultSet rs; + while ((rs = pst.getResultSet()).next()) { + Map map = new HashMap<>(); + int id = rs.getInt("id"); + String token = rs.getString("token"); + map.put("id", id); + map.put("token", token); + + try { + String description = rs.getString("description"); + map.put("description", description); + } catch (SQLException ex) { + } + + try { + String description = rs.getString("network_operator"); + map.put("description", description); + } catch (SQLException ex) { + } + // when there is a field 'active' + try { + String active = rs.getString("unit"); + map.put("unit", active); + } catch (SQLException ex) { + } + + + result.add(map); + } + return result; + } + } + } catch (Exception ex) { + ex.printStackTrace(); + return List.of(); + } + return List.of(); + } + + + public Map>> getAllSup() { + if (!cache.isEmpty()) { + return cache; + } + Map>> result = new HashMap<>(); + result.put("sup_contact_type", sup_contact_type()); + result.put("sup_geodata_info_type", sup_geodata_info_type()); + result.put("sup_installation_power_supply", sup_installation_power_supply()); + result.put("sup_job_action_document_type", sup_job_action_document_type()); + result.put("sup_measurement_type", sup_measurement_type()); + result.put("sup_device_status", sup_device_status()); + result.put("sup_installation_access_level", sup_installation_access_level()); + result.put("sup_installation_status", sup_installation_status()); + result.put("sup_job_action_result", sup_job_action_result()); + result.put("sup_sm_type", sup_sm_type()); + result.put("sup_device_type", sup_device_type()); + result.put("sup_installation_cable_length", sup_installation_cable_length()); + result.put("sup_installation_type", sup_installation_type()); + result.put("sup_job_metering_point_type", sup_job_metering_point_type()); + result.put("sup_tariff_type", sup_tariff_type()); + result.put("sup_geodata_info_message", sup_geodata_info_message()); + result.put("sup_installation_mounting_variant", sup_installation_mounting_variant()); + result.put("sup_job_action", sup_job_action()); + result.put("sup_job_status", sup_job_status()); + result.put("gln", gln()); + + cache.putAll(result); + + return result; + } + + public List> gln() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_contact_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_geodata_info_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_installation_power_supply() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_job_action_document_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_measurement_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_device_status() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_installation_access_level() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_installation_status() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_job_action_result() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_sm_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_device_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_installation_cable_length() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_installation_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_job_metering_point_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_tariff_type() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_geodata_info_message() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_installation_mounting_variant() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_job_action() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } + + public List> sup_job_status() { + String methodName = Thread.currentThread().getStackTrace()[1].getMethodName(); + return get("SELECT * FROM " + methodName + ";"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/info/sup/Table.java b/backend/src/main/java/de/haevn/snippetmanage/info/sup/Table.java new file mode 100644 index 0000000..9308500 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/info/sup/Table.java @@ -0,0 +1,7 @@ +package de.haevn.snippetmanage.info.sup; + +import java.util.HashMap; + +public class Table extends HashMap { + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/info/sup/TableNotFoundException.java b/backend/src/main/java/de/haevn/snippetmanage/info/sup/TableNotFoundException.java new file mode 100644 index 0000000..9b1d965 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/info/sup/TableNotFoundException.java @@ -0,0 +1,9 @@ +package de.haevn.snippetmanage.info.sup; + +import de.haevn.snippetmanage.common.exception.NotFoundException; + +public class TableNotFoundException extends NotFoundException { + public TableNotFoundException(final String tableName) { + super(String.format("Table %s not found.", tableName)); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/note/Note.java b/backend/src/main/java/de/haevn/snippetmanage/note/Note.java new file mode 100644 index 0000000..b4ace10 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/note/Note.java @@ -0,0 +1,18 @@ +package de.haevn.snippetmanage.note; + +import jakarta.persistence.Id; +import lombok.Data; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Document(collection = "notes") +class Note { + @Id + private String id; + @Field("code") + private String content; + private String title; + private String createdAt; + private String updatedAt; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/note/NoteController.java b/backend/src/main/java/de/haevn/snippetmanage/note/NoteController.java new file mode 100644 index 0000000..a077d8d --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/note/NoteController.java @@ -0,0 +1,99 @@ +package de.haevn.snippetmanage.note; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import de.haevn.snippetmanage.common.exception.InternalServerErrorException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestApiController(value = "/api/notes", tagName = "Note Controller", description = "Controller for managing notes") +class NoteController { + + private final NoteService noteService; + + public NoteController(NoteService noteService) { + this.noteService = noteService; + } + + @GetMapping("/limit") + @Operation(summary = "Get text limit", description = "Retrieve the maximum allowed text limit for notes") + public ResponseEntity getTextLimit() { + return ResponseEntity.ok(noteService.getTextLimit()); + } + + @GetMapping("") + @Operation(summary = "Get all notes", description = "Retrieve a list of all notes") + public ResponseEntity> getAllNotes() { + return ResponseEntity.ok(noteService.getAllNotes()); + } + + @PostMapping("") + @Operation(summary = "Create a new note", description = "Add a new note to the system") + @ApiResponse(responseCode = "200", description = "Note created successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, note is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to create note") + public ResponseEntity createNote(@RequestBody Optional snippetOpt) { + if (snippetOpt.isEmpty()) { + return ResponseEntity.badRequest().body("Note is empty"); + } + + Note note = snippetOpt.get(); + + boolean created = noteService.createNote(note); + if (created) { + return ResponseEntity.ok("Note created with ID: " + note.getId()); + } else { + return ResponseEntity.status(500).body("Failed to create note"); + } + } + + @PutMapping("/{id}") + @Operation(summary = "Update an existing note", description = "Modify the details of an existing note") + @ApiResponse(responseCode = "200", description = "Note updated successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, note is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to update note") + public ResponseEntity updateNote(@RequestBody Optional noteOpt, @PathVariable("id") String id) { + if (noteOpt.isEmpty()) { + return ResponseEntity.badRequest().body("Note is empty"); + } + + Note note = noteOpt.get(); + + boolean updated = noteService.updateNote(note, id); + if (updated) { + return ResponseEntity.ok("Note updated with ID: " + id); + } else { + return ResponseEntity.status(500).body("Failed to update note"); + } + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete a note", description = "Remove a note from the system by its ID") + @ApiResponse(responseCode = "200", description = "Note deleted successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, ID is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to delete note") + public ResponseEntity deleteNote(@PathVariable String id) { + boolean deleted = noteService.deleteNote(id); + if (deleted) { + return ResponseEntity.ok("Note deleted with ID: " + id); + } else { + return ResponseEntity.status(500).body("Failed to delete note"); + } + } + + + @GetMapping("/{id}/pdf") + @Operation(summary = "Print note to PDF", description = "Generate a PDF document for the specified note") + @ApiResponse(responseCode = "200", description = "PDF generated successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, ID is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to generate PDF") + public ResponseEntity printNoteToPDF(@PathVariable String id) { + final byte[] data = noteService.printNoteToPDF(id) // + .orElseThrow(() -> new InternalServerErrorException("Failed to generate PDF")); + return ResponseEntity.ok(data); + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/note/NoteRepository.java b/backend/src/main/java/de/haevn/snippetmanage/note/NoteRepository.java new file mode 100644 index 0000000..42c2837 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/note/NoteRepository.java @@ -0,0 +1,7 @@ +package de.haevn.snippetmanage.note; + + +import org.springframework.data.mongodb.repository.MongoRepository; + +interface NoteRepository extends MongoRepository { +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/note/NoteService.java b/backend/src/main/java/de/haevn/snippetmanage/note/NoteService.java new file mode 100644 index 0000000..d6ec89d --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/note/NoteService.java @@ -0,0 +1,102 @@ +package de.haevn.snippetmanage.note; + +import com.lowagie.text.*; +import com.lowagie.text.pdf.PdfWriter; +import de.haevn.snippetmanage.common.annotation.PDFService; +import de.haevn.snippetmanage.common.exception.BadRequestException; +import de.haevn.snippetmanage.common.exception.NotFoundException; +import de.haevn.snippetmanage.common.utils.Patcher; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Optional; + +@Service +class NoteService { + private final Patcher patcher; + private final NoteRepository repository; + + public NoteService(Patcher patcher, NoteRepository repository) { + this.patcher = patcher; + this.repository = repository; + } + + public boolean createNote(Note note) { + repository.save(note); + return true; + } + + public boolean updateNote(Note note, String id) { + var existingNote = repository.findAll().stream().filter(n -> n.getId().equalsIgnoreCase(id)).findFirst().orElseThrow(NotFoundException::new); + patcher.patch(existingNote, note); + repository.save(existingNote); + return true; + } + + public boolean deleteNote(String id) { + if (id == null || !repository.existsById(id)) return false; + + repository.deleteById(id); + return true; + } + + public Optional getNote(String id) { + if (id == null || !repository.existsById(id)) return Optional.empty(); + + return repository.findById(id); + } + + public List getAllNotes() { + return repository.findAll(); + } + + public Integer getTextLimit() { + return 4096; + } + + public Optional printNoteToPDF(final String id) { + Note note = repository.findById(id).orElseThrow(NotFoundException::new); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + Document document = new Document(); + PdfWriter.getInstance(document, baos); + document.open(); + + new PDFService("Note Export") + .addTitle("Note " + id) + .addKeywords("Note", "DashboardManager") + .appendToDoc(document); + + Font titleFont = new Font(Font.HELVETICA, 18, Font.BOLD); + document.add(new Paragraph(note.getTitle(), titleFont)); + document.add(new Paragraph(" ")); + document.add(new Paragraph("Note ID: " + id)); + document.add(new Paragraph(" ")); + if (note.getCreatedAt() != null) { + document.add(new Paragraph("Created at: " + note.getCreatedAt())); + document.add(new Paragraph(" ")); + } + if (note.getUpdatedAt() != null) { + document.add(new Paragraph("Last update at: " + note.getUpdatedAt())); + document.add(new Paragraph(" ")); + } + + document.add(new Paragraph(" ")); + + + if (note.getContent() == null || note.getContent().isBlank()) { + document.add(new Paragraph("No content available.")); + } else { + document.add(new Paragraph(note.getContent())); + } + + document.add(new Paragraph(" ")); + document.add(new Footnote("Generated by DashboardManager")); + + document.close(); + return Optional.of(baos.toByteArray()); + } catch (DocumentException | java.io.IOException e) { + throw new BadRequestException("PDF creation failed: " + e.getMessage()); + } + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/Checklist.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/Checklist.java new file mode 100644 index 0000000..009dc03 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/Checklist.java @@ -0,0 +1,10 @@ +package de.haevn.snippetmanage.redmine; + +import lombok.Data; + +@Data +public class Checklist { + long issue_id; + String subject; + int is_done; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistDTO.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistDTO.java new file mode 100644 index 0000000..ce3d734 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistDTO.java @@ -0,0 +1,8 @@ +package de.haevn.snippetmanage.redmine; + +import lombok.Data; + +@Data +public class ChecklistDTO{ + private Checklist checklist; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistResponse.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistResponse.java new file mode 100644 index 0000000..d712b62 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistResponse.java @@ -0,0 +1,10 @@ +package de.haevn.snippetmanage.redmine; + +import java.util.List; +import lombok.Data; + +@Data +public class ChecklistResponse { + List checklists; + int total_count; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistResponseDTO.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistResponseDTO.java new file mode 100644 index 0000000..0ba45c5 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/ChecklistResponseDTO.java @@ -0,0 +1,25 @@ +package de.haevn.snippetmanage.redmine; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class ChecklistResponseDTO { + long id; + @JsonProperty("issue_id") + long issueId; + String subject; + + @JsonProperty("is_done") + boolean isDone; + int position; + + @JsonProperty("is_section") + boolean isSection; + + @JsonProperty("created_at") + String createdAt; + + @JsonProperty("updated_at") + String updatedAt; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/CustomField.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/CustomField.java new file mode 100644 index 0000000..1528632 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/CustomField.java @@ -0,0 +1,10 @@ +package de.haevn.snippetmanage.redmine; + +import lombok.Data; + +@Data +public class CustomField { + private int id; + private String name; + private String value; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/Issue.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/Issue.java new file mode 100644 index 0000000..21b7f73 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/Issue.java @@ -0,0 +1,48 @@ +package de.haevn.snippetmanage.redmine; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class Issue { + private int id; + private Tracker tracker; + private Project project; + private String subject; + private String description; + private Status status; + + @JsonProperty("start_data") + private String startData; + + @JsonProperty("estimated_hours") + private long estimatedHours; + + @JsonProperty("spent_hours") + private long spentHours; + + @JsonProperty("total_spent_hours") + private long totalSpentHours; + + @JsonProperty("custom_fields") + private List customFields; + + @JsonProperty("created_on") + private String createdOn; + + @JsonProperty("updated_on") + private String updatedOn; + + @JsonProperty("journals") + private List journals; + + @JsonIgnore + @JsonProperty("checklist") + private List checklist; + + public static record IssueWrapper(Issue issue) { + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/Journal.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/Journal.java new file mode 100644 index 0000000..37bcab9 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/Journal.java @@ -0,0 +1,17 @@ +package de.haevn.snippetmanage.redmine; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class Journal { + private int id; + private String notes; + + @JsonProperty("created_on") + private String createdOn; + + private User user; +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/Project.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/Project.java new file mode 100644 index 0000000..d68fa7f --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/Project.java @@ -0,0 +1,9 @@ +package de.haevn.snippetmanage.redmine; + +import lombok.Data; + +@Data +public class Project { + private int id; + private String name; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineController.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineController.java new file mode 100644 index 0000000..a6c1acb --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineController.java @@ -0,0 +1,92 @@ +package de.haevn.snippetmanage.redmine; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.Optional; + +@RestApiController(value = "/api/tickets", tagName = "Redmine Controller", + description = "Controller for Redmine service details") +public class RedmineController { + + private final RedmineService infoService; + + public RedmineController(RedmineService infoService) { + this.infoService = infoService; + } + + @GetMapping("/refresh") + @Operation(summary = "Refresh Redmine details", description = "Force refresh of redmine ticket details") + @ApiResponse(responseCode = "200", description = "Details refreshed successfully") + public ResponseEntity refresh() { + infoService.refresh(); + return ResponseEntity.ok().build(); + } + + @GetMapping("") + @Operation(summary = "Get Redmine details", description = "Retrieve detailed information about a redmine ticket") + @ApiResponse(responseCode = "200", description = "Details retrieved successfully") + public ResponseEntity> tickets(@RequestParam("type") Optional type) { + List tickets = infoService.getIssues(type); + return ResponseEntity.ok(tickets); + } + + @GetMapping("/{id}") + @Operation(summary = "Get Redmine details", description = "Retrieve detailed information about a redmine ticket") + @ApiResponse(responseCode = "200", description = "Details retrieved successfully") + public ResponseEntity ticketsById(@PathVariable("id") int id, @RequestParam("type") Optional type) { + TicketResponse ticket = infoService.getIssues(id, type); + return ResponseEntity.ok(ticket); + } + + @GetMapping("/last-update") + @Operation(summary = "Get last update time", description = "Retrieve the last update time of redmine tickets") + @ApiResponse(responseCode = "200", description = "Last update time retrieved successfully") + public long getLastUpdateTime() { + return infoService.getLastUpdateTimestamp(); + } + + @GetMapping("/{id}/add_checkbox") + @Operation(summary = "Add checkbox to ticket", description = "Add a checkbox custom field to a redmine ticket") + @ApiResponse(responseCode = "200", description = "Checkbox added successfully") + public ResponseEntity addCheckboxToTicket(@PathVariable("id") int ticketId) { + infoService.addCheckboxToTicket(ticketId); + return ResponseEntity.ok().build(); + } + + + + @GetMapping("/ignore") + @Operation(summary = "Get ignored tickets", description = "Retrieve the list of ignored redmine tickets") + @ApiResponse(responseCode = "200", description = "Ignored tickets retrieved successfully") + public ResponseEntity> getIgnoredTickets() { + List ignoredTickets = infoService.getIgnored(); + return ResponseEntity.ok(ignoredTickets); + } + + @PutMapping("/ignore/{id}") + public ResponseEntity ignoreTicket(@PathVariable("id") int ticketId) { + infoService.addToIgnore(ticketId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/ignore/{id}") + public ResponseEntity unignoreTicket(@PathVariable("id") int ticketId) { + infoService.removeFromIgnore(ticketId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/global/{id}") + public ResponseEntity getGlobalTicket(@PathVariable("id") int ticketId) { + TicketResponse ticket = infoService.getGlobalIssue(ticketId); + return ResponseEntity.ok(ticket); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineResponse.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineResponse.java new file mode 100644 index 0000000..c0d5dab --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineResponse.java @@ -0,0 +1,39 @@ +package de.haevn.snippetmanage.redmine; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import de.haevn.snippetmanage.common.exception.InternalServerErrorException; +import lombok.Data; + +import java.io.File; +import java.util.List; + +@Data +public class RedmineResponse { + private List issues; + + @JsonProperty("total_count") + private int totalCount; + private int offset; + private int limit; + + public static RedmineResponse fromFile(File f) { + ObjectMapper objectMapper = new JsonMapper(); + try { + return objectMapper.readValue(f, RedmineResponse.class); + } catch (Exception e) { + throw new InternalServerErrorException("Failed to read RedmineResponse from file", e); + + } + } + + public void toFile(File f) { + ObjectMapper objectMapper = new JsonMapper(); + try { + objectMapper.writeValue(f, this); + } catch (Exception e) { + throw new InternalServerErrorException("Failed to write RedmineResponse to file", e); + } + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineService.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineService.java new file mode 100644 index 0000000..51d661e --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/RedmineService.java @@ -0,0 +1,315 @@ +package de.haevn.snippetmanage.redmine; + +import de.haevn.snippetmanage.common.exception.BadRequestException; +import de.haevn.snippetmanage.common.exception.NotFoundException; +import de.haevn.snippetmanage.redmine.repository.IssueFileService; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import lombok.SneakyThrows; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class RedmineService { + + @Value("${haevn.application.redmine.tmp_file}") + private String tmpFile; + + @Value("${secure.redmine.key}") + private String apiKey; + + @Value("${secure.redmine.url}") + private String url; + + @Value("${haevn.application.redmine.debug:false}") + private boolean isDebug; + + private long updateTime = 0; + private RedmineResponse response; + private List redmineIgnore; + private final IssueFileService issueFileService; + + public RedmineService(final IssueFileService issueFileService) { + this.issueFileService = issueFileService; + try { + redmineIgnore = Files.readAllLines(Path.of("/data/redmine/.redmineignore")).stream() // + .filter(line -> !line.isBlank() && !line.startsWith("#")) // + .map(Integer::parseInt) // + .toList(); + } catch (IOException e) { + redmineIgnore = List.of(); + } + } + + public List getIssues(final Optional type) { + if (apiKey.isBlank()) { + return List.of(); + } + if (response == null) { + refresh(); + } + + Predicate filter = issue -> true; + if (type.isPresent()) { + switch (type.get().toLowerCase()) { + case "bug" -> filter = issue -> issue.getTracker().getName().equalsIgnoreCase("bug"); + case "feature" -> filter = issue -> issue.getTracker().getName().equalsIgnoreCase("feature"); + case "support" -> filter = issue -> issue.getTracker().getName().equalsIgnoreCase("support"); + default -> throw new IllegalArgumentException("Unknown type: " + type.get()); + } + } + + return response.getIssues() // + .stream().filter(filter) // + .map(issue -> TicketMapper.mapToTicketResponse(issue, url)) // + .filter(ticket -> !redmineIgnore.contains(ticket.getTicketId())) + .filter(ticket -> !ticket.getStatus().is_closed).filter(ticket -> ticket.getStatus().getId() != 1).toList(); + } + + public TicketResponse getIssues(final int id, final Optional type) { + if (apiKey.isBlank()) { + return null; + } + List tickets = getIssues(type).stream().filter(ticket -> ticket.getTicketId() == id).toList(); + if (tickets.size() > 1) { + throw new IllegalStateException("Multiple tickets found for id " + id); + } else if (tickets.isEmpty()) { + throw new IllegalStateException("No ticket found for id " + id); + } + return tickets.getFirst(); + + } + + public void refresh() { + final File f = new File(tmpFile); + + if (isDebug && f.exists()) { + this.response = RedmineResponse.fromFile(f); + return; + } + final RestTemplate restTemplate = restTemplateWithApiKey(); + final String endpoint = url + "/issues.json?assigned_to_id=me&include=journals"; + this.response = restTemplate.getForObject(endpoint, RedmineResponse.class); + updateTime = System.currentTimeMillis(); + updateComments(); + updateChecklist(); + if (isDebug) { + this.response.toFile(f); + } + updateMongo(); + } + + + @SneakyThrows + private void updateMongo(){ + for(Issue issue : response.getIssues()){ + issueFileService.storeIssue(issue); + } + } + + @SneakyThrows + private void updateComments() { + if (apiKey.isBlank()) { + return; + } + + RestTemplate restTemplate = restTemplateWithApiKey(); + for (Issue issue : response.getIssues()) { + int id = issue.getId(); + String issueEndpoint = url + "issues/" + id + ".json?include=journals"; + Issue.IssueWrapper detailedIssue = restTemplate.getForObject(issueEndpoint, Issue.IssueWrapper.class); + + if (detailedIssue != null) { + issue.setJournals(detailedIssue.issue().getJournals()); + } + } + + createDirectoryStructureIfNotExists(response.getIssues()); + + + for (Issue issue : response.getIssues()) { + var journals = + issue.getJournals().stream().filter(j -> j.getNotes() != null && !j.getNotes().isBlank()).toList(); + issue.setJournals(new ArrayList<>(journals)); + } + } + + private void updateChecklist() { + if (apiKey.isBlank()) { + return; + } + RestTemplate restTemplate = restTemplateWithApiKey(); + for (Issue issue : response.getIssues()) { + int id = issue.getId(); + + final String endpoint = url + "/issues/" + id + "/checklists.json"; + ChecklistResponse response = restTemplate.getForObject(endpoint, ChecklistResponse.class); + + + if (response != null) { + issue.setChecklist(response.getChecklists()); + } + } + + for (Issue issue : response.getIssues()) { + var journals = + issue.getJournals().stream().filter(j -> j.getNotes() != null && !j.getNotes().isBlank()).toList(); + issue.setJournals(new ArrayList<>(journals)); + } + } + + public long getLastUpdateTimestamp() { + return updateTime; + } + + private RestTemplate restTemplateWithApiKey() { + RestTemplate rt = new RestTemplate(); + rt.getInterceptors().add((request, body, execution) -> { + request.getHeaders().add("X-Redmine-API-Key", apiKey); + request.getHeaders().setAccept(List.of(org.springframework.http.MediaType.APPLICATION_JSON)); + return execution.execute(request, body); + }); + return rt; + } + + + private ChecklistResponse hasAlreadyChecklist(final int ticketId) { + if (apiKey.isBlank()) { + return null; + } + + final RestTemplate restTemplate = restTemplateWithApiKey(); + final String endpoint = url + "/issues/" + ticketId + "/checklists.json"; + ChecklistResponse response = restTemplate.getForObject(endpoint, ChecklistResponse.class); + return response; + } + + + private void addCheckboxToTicket(final int ticketId, final String subject) { + if (apiKey.isBlank()) { + return; + } + + final RestTemplate restTemplate = restTemplateWithApiKey(); + String endpoint = url + "issues/" + ticketId + "/checklists.json"; + Map checklist = Map.of("issue_id", ticketId, "subject", subject, "is_done", 0); + Map body = Map.of("checklist", checklist); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + headers.setAccept(List.of(org.springframework.http.MediaType.APPLICATION_JSON)); + + org.springframework.http.HttpEntity> request = + new org.springframework.http.HttpEntity<>(body, headers); + + String resp = restTemplate.postForObject(endpoint, request, String.class); + System.out.println(resp); + + } + + + public void addCheckboxToTicket(final int ticketId) { + if (apiKey.isBlank()) { + return; + } + + final Issue issue = this.response.getIssues().stream().filter(issues -> issues.getId() == ticketId).findFirst() + .orElseThrow(NotFoundException::new); + + if (issue.getTracker().getName() == null) { + throw new BadRequestException("Ticket with id " + ticketId + " has no tracker"); + } + + final boolean isTask = issue.getTracker().getName().equalsIgnoreCase("task"); + ChecklistResponse existing = hasAlreadyChecklist(ticketId); + if (existing == null || existing.getChecklists() == null || existing.getChecklists().isEmpty()) { + addCheckboxToTicket(ticketId, "Dokumentation erstellt"); + if(isTask) { + addCheckboxToTicket(ticketId, "AK geprüft"); + } + addCheckboxToTicket(ticketId, "MR erstellt"); + addCheckboxToTicket(ticketId, "MR approved"); + addCheckboxToTicket(ticketId, "MR gemerged"); + return; + } + + if (existing.getChecklists().stream().noneMatch(c -> c.getSubject().equals("Dokumentation erstellt"))) { + addCheckboxToTicket(ticketId, "Dokumentation erstellt"); + } + if (isTask && existing.getChecklists().stream().noneMatch(c -> c.getSubject().equals("AK geprüft"))) { + addCheckboxToTicket(ticketId, "AK geprüft"); + } + if (existing.getChecklists().stream().noneMatch(c -> c.getSubject().equals("MR erstellt"))) { + addCheckboxToTicket(ticketId, "MR erstellt"); + } + if (existing.getChecklists().stream().noneMatch(c -> c.getSubject().equals("MR approved"))) { + addCheckboxToTicket(ticketId, "MR approved"); + } + if (existing.getChecklists().stream().noneMatch(c -> c.getSubject().equals("MR gemerged"))) { + addCheckboxToTicket(ticketId, "MR gemerged"); + } + } + + public List getIgnored() { + return redmineIgnore; + } + + public void addToIgnore(int ticketId) { + if (!redmineIgnore.contains(ticketId)) { + redmineIgnore.add(ticketId); + flush(); + } + } + + public void removeFromIgnore(int ticketId) { + redmineIgnore.removeIf(id -> id == ticketId); + + flush(); + } + + void flush() { + try { + Files.write(Path.of("/data/redmine/.redmineignore"), redmineIgnore.stream().map(String::valueOf).toList()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public TicketResponse getGlobalIssue(final int ticketId) { + if (apiKey.isBlank()) { + return null; + } + try { + final RestTemplate restTemplate = restTemplateWithApiKey(); + final String endpoint = url + "issues/" + ticketId + ".json?include=journals"; + Issue.IssueWrapper detailedIssue = restTemplate.getForObject(endpoint, Issue.IssueWrapper.class); + + if (detailedIssue == null) { + throw new NotFoundException("Ticket with id " + ticketId + " not found"); + } + + return TicketMapper.mapToTicketResponse(detailedIssue.issue(), url); + } catch (RuntimeException e) { + return null; + } + } + + private void createDirectoryStructureIfNotExists(List ticketNumber) throws IOException { + List ids = ticketNumber.stream().map(issue -> issue.getId() + ";:;" + issue.getSubject()).toList(); + String data = String.join("\n", ids); + Path ticketPath = Path.of("/data/tickets.txt"); + if(!Files.exists(ticketPath)){ + Files.createFile(ticketPath); + } + + Files.write(ticketPath, data.getBytes()); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/Status.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/Status.java new file mode 100644 index 0000000..44a7ad3 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/Status.java @@ -0,0 +1,10 @@ +package de.haevn.snippetmanage.redmine; + +import lombok.Data; + +@Data +public class Status { + int id; + String name; + boolean is_closed; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/TicketMapper.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/TicketMapper.java new file mode 100644 index 0000000..be9fca9 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/TicketMapper.java @@ -0,0 +1,48 @@ +package de.haevn.snippetmanage.redmine; + +import java.util.List; + +public final class TicketMapper { + private TicketMapper(){ + // Private constructor to prevent instantiation + } + + public static TicketResponse mapToTicketResponse(Issue issue, String url) { + TicketResponse response = new TicketResponse(); + response.setTicketId(issue.getId()); + response.setTitle(issue.getSubject()); + response.setDescription(issue.getDescription()); + response.setEstimatedHours(issue.getEstimatedHours()); + response.setSpentHours(issue.getSpentHours()); + response.setCreatedOn(issue.getCreatedOn() + " TEST"); + response.setLastUpdatedOn(issue.getUpdatedOn()); + response.setTracker(issue.getTracker().getName()); + response.setProject(issue.getProject().getName()); + response.setStatus(issue.getStatus()); + + // Extract Merge Request URL from custom fields + if (issue.getCustomFields() != null) { + for (CustomField field : issue.getCustomFields()) { + if ("Merge Request URL".equalsIgnoreCase(field.getName())) { + response.setMergeRequestUrl(field.getValue()); + break; + } + } + } + + // Construct repository URL and ticket URL + if (issue.getProject() != null) { + response.setTicketUrl(url + "/issues/" + issue.getId()); + } + + response.setJournals(issue.getJournals()); + response.setChecklist(issue.getChecklist()); + return response; + } + + public static List mapToTicketResponse(List issues, String url) { + return issues.stream() + .map(issue -> mapToTicketResponse(issue, url)) + .toList(); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/TicketResponse.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/TicketResponse.java new file mode 100644 index 0000000..d0a546f --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/TicketResponse.java @@ -0,0 +1,24 @@ +package de.haevn.snippetmanage.redmine; + +import lombok.Data; + +import java.util.List; + +@Data +public class TicketResponse { + private int ticketId; + private String title; + private String project; + private String tracker; + private String description; + private long estimatedHours; + private long spentHours; + private String mergeRequestUrl; + private String createdOn; + private String lastUpdatedOn; + private String repoUrl; + private String ticketUrl; + private List journals; + private List checklist; + private Status status; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/Tracker.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/Tracker.java new file mode 100644 index 0000000..742a0a9 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/Tracker.java @@ -0,0 +1,9 @@ +package de.haevn.snippetmanage.redmine; + +import lombok.Data; + +@Data +public class Tracker { + private int id; + private String name; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/User.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/User.java new file mode 100644 index 0000000..e491e0f --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/User.java @@ -0,0 +1,9 @@ +package de.haevn.snippetmanage.redmine; + +import lombok.Data; + +@Data +public class User { + private int id; + private String name; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFile.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFile.java new file mode 100644 index 0000000..d27c7ac --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFile.java @@ -0,0 +1,27 @@ +package de.haevn.snippetmanage.redmine.repository; + +import de.haevn.snippetmanage.common.utils.FileService; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Transient; +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +@Data +@Entity(name = "issue_file") +public class IssueFile { + + @Transient + private List imageList = new ArrayList<>(); + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + private String ticketId; + private String url; + private String title; + private String notes; +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFileRepository.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFileRepository.java new file mode 100644 index 0000000..1cd97a2 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFileRepository.java @@ -0,0 +1,54 @@ +package de.haevn.snippetmanage.redmine.repository; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import java.io.IOException; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +@RestApiController("/api/issue-file") +public class IssueFileRepository { + private final IssueFileService issueFileService; + + public IssueFileRepository(final IssueFileService issueFileService) { + this.issueFileService = issueFileService; + } + + @GetMapping("/get") + public List getIssue() { + return issueFileService.getAllIssues(); + } + + @GetMapping("/get/{id}") + public IssueFile getIssue(@PathVariable("id") String id) { + return issueFileService.getIssueByTicketId(id); + } + + @PostMapping("/set-note/{id}") + public void setNote(@PathVariable("id") String id, @RequestBody String note) { + issueFileService.setNote(id, note); + } + + @PostMapping(path = "/add-image/{id}/{name}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void addImage(@PathVariable("id") String id, @PathVariable("name") String name, @RequestPart("files") MultipartFile[] files) throws Exception { + for (final MultipartFile file : files) { + issueFileService.addImage(id, file.getOriginalFilename(), file.getBytes()); + } + } + + @DeleteMapping("/image/delete/{id}/{name}") + public void deleteImage(@PathVariable("id") String id, @PathVariable("name") String name) throws IOException { + issueFileService.delete(id, name); + } + @DeleteMapping("/delete/{id}") + public void deleteIssue(@PathVariable("id") String id) { + + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFileService.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFileService.java new file mode 100644 index 0000000..d10e4d9 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueFileService.java @@ -0,0 +1,64 @@ +package de.haevn.snippetmanage.redmine.repository; + +import de.haevn.snippetmanage.common.exception.NotFoundException; +import de.haevn.snippetmanage.common.utils.FileService; +import de.haevn.snippetmanage.redmine.Issue; +import java.io.IOException; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class IssueFileService { + private final FileService fileService; + private final IssueRepository issueRepository; + + public IssueFileService(final IssueRepository issueRepository) { + this.issueRepository = issueRepository; + fileService = new FileService("tickets"); + } + + public void storeIssue(Issue issue) { + boolean ex = issueRepository.findByTicketId(String.valueOf(issue.getId())).isEmpty(); + if(!ex)return; + final IssueFile issueFile = new IssueFile(); + issueFile.setNotes(""); + issueFile.setTitle(issue.getSubject()); + issueFile.setTicketId(String.valueOf(issue.getId())); + issueRepository.save(issueFile); + } + + public void setNote(String issueId, String note) { + final IssueFile issueFile = issueRepository.findByTicketId(issueId).orElseThrow(NotFoundException::new); + issueFile.setNotes(note); + issueRepository.save(issueFile); + } + + public void addImage(String issueId, String name, byte[] content) throws IOException { + issueRepository.findByTicketId(issueId).orElseThrow(NotFoundException::new); + fileService.storeFile(issueId, name, content); + } + + public IssueFile getIssueByTicketId(String ticketId) { + final IssueFile issueFile = issueRepository.findByTicketId(ticketId).orElseThrow(NotFoundException::new); + try { + issueFile.setImageList(fileService.listFiles(ticketId)); + } catch (IOException ignored) { + } + return issueFile; + } + + public List getAllIssues() { + final List issueFiles = issueRepository.findAll(); + for (IssueFile issueFile : issueFiles) { + try { + issueFile.setImageList(fileService.listFiles(String.valueOf(issueFile.getTicketId()))); + } catch (IOException ignored) { + } + } + return issueFiles; + } + + public void delete(final String id, final String name) throws IOException { + fileService.deleteFile(id, name); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueRepository.java b/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueRepository.java new file mode 100644 index 0000000..7f54742 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/redmine/repository/IssueRepository.java @@ -0,0 +1,11 @@ +package de.haevn.snippetmanage.redmine.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface IssueRepository extends JpaRepository { + + Optional findByTicketId(String ticketId); + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/share/Share.java b/backend/src/main/java/de/haevn/snippetmanage/share/Share.java new file mode 100644 index 0000000..1ab04a9 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/share/Share.java @@ -0,0 +1,46 @@ +// java +package de.haevn.snippetmanage.share; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Document(collection = "shares") +class Share { + @Id + private String id; // can store UUID.toString() or Mongo ObjectId + + private String shortId; + private String password; + + private long creationDate; + private long expirationDate; + private String language; + private String shareType; + + @Field("data") + private byte[] data; + + public static Share fromDTO(ShareDTO share) { + Share s = new Share(); + s.setShortId(share.getShortId()); + s.setPassword(share.getPassword()); + s.setCreationDate(share.getCreationDate()); + s.setExpirationDate(share.getExpirationDate()); + s.setData(share.getContent().getBytes()); // assuming data is stored as byte[] + s.setLanguage(share.getLanguage()); + s.setShareType(share.getShareType()); + return s; + } + + public ShareMetaDTO toMetaDTO() { + ShareMetaDTO dto = new ShareMetaDTO(); + dto.setShortId(this.shortId); + dto.setCreationDate(this.creationDate); + dto.setExpirationDate(this.expirationDate); + dto.setShareType(this.shareType); + return dto; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/share/ShareController.java b/backend/src/main/java/de/haevn/snippetmanage/share/ShareController.java new file mode 100644 index 0000000..f5030cf --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/share/ShareController.java @@ -0,0 +1,58 @@ +package de.haevn.snippetmanage.share; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import de.haevn.snippetmanage.common.exception.BadRequestException; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; + +@RestApiController("/api/share") +class ShareController { + private final ShareService shareService; + + public ShareController(final ShareService shareService) { + this.shareService = shareService; + } + + @PostMapping("/create") + public String createShare(@RequestBody ShareDTO shareDto) { + return shareService.createShare(shareDto); + } + + @GetMapping("/{id}") + public ShareDTO getShare(@PathVariable String id, @RequestHeader("password") Optional password) { + final Share share = shareService.findShareById(id, password); + if (share.getExpirationDate() < System.currentTimeMillis()) { + shareService.deleteShareById(id); + throw new BadRequestException("Share has expired"); + } + return ShareDTO.fromShare(share); + } + + @GetMapping("/{id}/hasPassword") + public boolean hasPassword(@PathVariable String id) { + return shareService.hasPassword(id); + } + + @GetMapping("/all") + public List getAllSharedId() { + return shareService.getAllSharedId(); + } + + @DeleteMapping("/{id}") + public void deleteShareById(@PathVariable String id) { + shareService.deleteShareById(id); + } + + @DeleteMapping("/all") + public void purgeAllShares() { + shareService.deleteAllShares(); + } + + @DeleteMapping("/cleanup") + public void cleanupShares() { + shareService.cleanUpShares(); + } + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/share/ShareDTO.java b/backend/src/main/java/de/haevn/snippetmanage/share/ShareDTO.java new file mode 100644 index 0000000..f0cd7f1 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/share/ShareDTO.java @@ -0,0 +1,28 @@ +// java +package de.haevn.snippetmanage.share; + +import lombok.Data; + +@Data +class ShareDTO { + private String shortId; + private String password; + private long creationDate; + private long expirationDate; + private String content; + private String language; + private String shareType; + + + public static ShareDTO fromShare(Share share) { + ShareDTO dto = new ShareDTO(); + dto.setShortId(share.getShortId()); + dto.setPassword(share.getPassword()); + dto.setCreationDate(share.getCreationDate()); + dto.setExpirationDate(share.getExpirationDate()); + dto.setContent(new String(share.getData())); // assuming data is stored as byte[] + dto.setLanguage(share.getLanguage()); + dto.setShareType(share.getShareType()); + return dto; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/share/ShareMetaDTO.java b/backend/src/main/java/de/haevn/snippetmanage/share/ShareMetaDTO.java new file mode 100644 index 0000000..b70bdd4 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/share/ShareMetaDTO.java @@ -0,0 +1,11 @@ +package de.haevn.snippetmanage.share; + +import lombok.Data; + +@Data +class ShareMetaDTO { + private String shortId; + private long creationDate; + private long expirationDate; + private String shareType; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/share/ShareRepository.java b/backend/src/main/java/de/haevn/snippetmanage/share/ShareRepository.java new file mode 100644 index 0000000..4bcfd44 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/share/ShareRepository.java @@ -0,0 +1,21 @@ +package de.haevn.snippetmanage.share; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; + +import java.util.List; +import java.util.Optional; + +interface ShareRepository extends MongoRepository { + boolean existsByShortId(String shortId); + + List findAllByShortId(String shortId); + + Optional findByShortId(String shortId); + + void deleteByShortId(String shortId); + + // return count of documents where password exists and is not empty + @Query(value = "{ 'shortId': ?0, 'password': { $exists: true, $ne: '' } }", count = true) + long countWithPassword(String shortId); +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/share/ShareService.java b/backend/src/main/java/de/haevn/snippetmanage/share/ShareService.java new file mode 100644 index 0000000..03bd661 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/share/ShareService.java @@ -0,0 +1,69 @@ +package de.haevn.snippetmanage.share; + +import de.haevn.snippetmanage.common.exception.BadRequestException; +import de.haevn.snippetmanage.common.utils.ShortIdGenerator; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +class ShareService { + private final ShareRepository shareRepository; + + public ShareService(final ShareRepository shareRepository) { + this.shareRepository = shareRepository; + + } + + public String createShare(final ShareDTO share) { + final long now = System.currentTimeMillis(); + share.setCreationDate(now); + share.setExpirationDate(now + 7 * 24 * 60 * 60 * 1000); // Default expiration: 7 days + final String sid = ShortIdGenerator.generateUnique(8, shareRepository::existsByShortId, 1000); + share.setShortId(sid); + final Share savedShare = shareRepository.save(Share.fromDTO(share)); + return savedShare.getShortId(); + + } + + public Share findShareById(final String id, Optional password) { + final Share share = shareRepository.findByShortId(id) + .orElseThrow(() -> new RuntimeException("Share not found")); + validatePassword(share, password); + + return share; + } + + private void validatePassword(Share share, Optional password) { + if (share.getPassword() != null && !share.getPassword().isEmpty() // + && (password.isEmpty() || !share.getPassword().equals(password.get()))) { + throw new BadRequestException("Invalid password"); + } + + } + + public boolean hasPassword(final String id) { + return shareRepository.countWithPassword(id) > 0; + } + + public List getAllSharedId() { + + return shareRepository.findAll().stream() + .map(Share::toMetaDTO) + .toList(); + } + + public void deleteShareById(String id) { + shareRepository.deleteByShortId(id); + } + + public void deleteAllShares() { + shareRepository.deleteAll(); + } + + public void cleanUpShares() { + shareRepository.findAll().stream().filter(e -> e.getExpirationDate() < System.currentTimeMillis()) + .forEach(shareRepository::delete); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/snippet/Snippet.java b/backend/src/main/java/de/haevn/snippetmanage/snippet/Snippet.java new file mode 100644 index 0000000..ffe8e13 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/snippet/Snippet.java @@ -0,0 +1,18 @@ +package de.haevn.snippetmanage.snippet; + +import jakarta.persistence.Id; +import lombok.Data; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@Document(collection = "snippets") +class Snippet { + + @Id + private String id; + + private String title; + private String code; + private String language; + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetController.java b/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetController.java new file mode 100644 index 0000000..aed350e --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetController.java @@ -0,0 +1,85 @@ +package de.haevn.snippetmanage.snippet; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import de.haevn.snippetmanage.common.exception.BadRequestException; +import de.haevn.snippetmanage.common.exception.InternalServerErrorException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; +import java.util.function.Predicate; + +@RestApiController(value = "/api/snippets", tagName = "Snippet Controller", description = "Controller for managing code snippets") +class SnippetController { + + private final SnippetService snippetService; + + public SnippetController(SnippetService snippetService) { + this.snippetService = snippetService; + } + + @GetMapping("") + @Operation(summary = "Get all snippets", description = "Retrieve a list of all code snippets with optional filtering by language, title, and tag") + @ApiResponse(responseCode = "200", description = "Successfully retrieved list of snippets") + public ResponseEntity> getAllSnippets(@RequestParam("lang") final Optional lang, + @RequestParam("title") final Optional title) { + Predicate titleFilter = null; + Predicate langFilter = null; + + Predicate filter = snippet -> true; + + if (title.isPresent() && !title.get().isEmpty()) { + titleFilter = snippet -> snippet.getTitle() != null && snippet.getTitle().toLowerCase().contains(title.get().toLowerCase()); + filter = filter.and(titleFilter); + } + if (lang.isPresent() && !lang.get().isEmpty()) { + langFilter = snippet -> snippet.getLanguage() != null && snippet.getLanguage().equalsIgnoreCase(lang.get()); + filter = filter.and(langFilter); + } + return ResponseEntity.ok(snippetService.findBy(filter)); + } + + @PostMapping("") + @Operation(summary = "Create a new snippet", description = "Add a new code snippet to the system") + @ApiResponse(responseCode = "201", description = "Snippet created successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, snippet is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to create snippet") + public ResponseEntity createSnippet(@RequestBody final Optional snippetOpt) { + final Snippet snippet = snippetOpt.orElseThrow(() -> new BadRequestException("Snippet is empty")); + final boolean created = snippetService.createSnippet(snippet); + if (!created) { + throw new InternalServerErrorException("Failed to create snippet"); + } + return ResponseEntity.status(201).body("Snippet created with ID: " + snippet.getId()); + } + + @PutMapping("/{id}") + @Operation(summary = "Update an existing snippet", description = "Update the details of a snippet") + @ApiResponse(responseCode = "200", description = "Snippet updated successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, snippet is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to update snippet") + public ResponseEntity updateSnippet(@PathVariable("id") final String id, @RequestBody final Optional snippetOpt) { + final Snippet snippet = snippetOpt.orElseThrow(() -> new BadRequestException("Snippet is empty")); + final boolean updated = snippetService.updateSnippet(id, snippet); + if (!updated) { + throw new InternalServerErrorException("Failed to update snippet"); + } + return ResponseEntity.ok("Snippet updated with ID: " + snippet.getId()); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete a snippet", description = "Remove a code snippet from the system by its ID") + @ApiResponse(responseCode = "200", description = "Snippet deleted successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, ID is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to delete snippet") + public ResponseEntity deleteSnippet(@PathVariable("id") final Optional idOpt) { + final String id = idOpt.orElseThrow(() -> new BadRequestException("ID is empty")); + boolean deleted = snippetService.deleteSnippet(id); + if (!deleted) { + throw new InternalServerErrorException("Failed to delete snippet"); + } + return ResponseEntity.ok("Snippet deleted with ID: " + id); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetRepository.java b/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetRepository.java new file mode 100644 index 0000000..b5e0506 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetRepository.java @@ -0,0 +1,6 @@ +package de.haevn.snippetmanage.snippet; + +import org.springframework.data.mongodb.repository.MongoRepository; + +interface SnippetRepository extends MongoRepository { +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetService.java b/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetService.java new file mode 100644 index 0000000..8e587cc --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/snippet/SnippetService.java @@ -0,0 +1,60 @@ +package de.haevn.snippetmanage.snippet; + +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +@Service +class SnippetService { + private final SnippetRepository repository; + + public SnippetService(final SnippetRepository repository) { + this.repository = repository; + } + + public boolean createSnippet(Snippet snippet) { + snippet.setId(null); + repository.save(snippet); + return true; + } + + public boolean updateSnippet(String id, Snippet snippet) { + final Snippet target = repository.findAll().stream() + .filter(s -> s.getId().equalsIgnoreCase(id)) + .findFirst() + .orElseThrow(); + + target.setCode(snippet.getCode()); + target.setLanguage(snippet.getLanguage()); + target.setTitle(snippet.getTitle()); + repository.save(target); + return true; + } + + public boolean deleteSnippet(String id) { + if (id == null || !repository.existsById(id)) { + return false; + } + + repository.deleteById(id); + return true; + } + + public Optional getSnippet(String id) { + if (id == null || !repository.existsById(id)) { + return Optional.empty(); + } + + return repository.findById(id); + } + + public List getAllSnippets() { + return repository.findAll(); + } + + public List findBy(Predicate filter) { + return repository.findAll().stream().filter(filter).toList(); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/timetable/Timetable.java b/backend/src/main/java/de/haevn/snippetmanage/timetable/Timetable.java new file mode 100644 index 0000000..776977d --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/timetable/Timetable.java @@ -0,0 +1,27 @@ +package de.haevn.snippetmanage.timetable; + +import de.haevn.snippetmanage.common.annotation.DoNotPatch; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import lombok.Data; +@Data +@Entity +@Table(name = "timetable") +class Timetable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @DoNotPatch + private long id; + @DoNotPatch + private long startTime; + @DoNotPatch + private Long endTime; + private String comment; + private String type; + private String ticket; + +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableController.java b/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableController.java new file mode 100644 index 0000000..3311bf5 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableController.java @@ -0,0 +1,43 @@ +package de.haevn.snippetmanage.timetable; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import java.util.List; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@RestApiController("/api/timetable") +class TimetableController { + private final TimetableService timetableService; + + public TimetableController(final TimetableService timetableService) { + this.timetableService = timetableService; + } + + @GetMapping("/start") + public long start(){ + return timetableService.start(); + } + + @GetMapping("/stop/{id}") + public Timetable stop(@PathVariable long id) { + return timetableService.stop(id); + } + + @PostMapping("/update/{id}") + public Timetable update(@PathVariable long id, @RequestBody Timetable timetable) { + return timetableService.update(id, timetable); + } + + @GetMapping("/get") + public List getAll(){ + return timetableService.getAll(); + } + + @DeleteMapping("/delete/{id}") + public void delete(@PathVariable long id) { + timetableService.delete(id); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableRepository.java b/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableRepository.java new file mode 100644 index 0000000..2a3d67e --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableRepository.java @@ -0,0 +1,7 @@ +package de.haevn.snippetmanage.timetable; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +interface TimetableRepository extends JpaRepository { +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableService.java b/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableService.java new file mode 100644 index 0000000..9d82ab1 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/timetable/TimetableService.java @@ -0,0 +1,49 @@ +package de.haevn.snippetmanage.timetable; + +import de.haevn.snippetmanage.common.exception.BadRequestException; +import de.haevn.snippetmanage.common.exception.NotFoundException; +import de.haevn.snippetmanage.common.utils.Patcher; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +class TimetableService { + private final TimetableRepository timetableRepository; + private final Patcher patcher; + public TimetableService(final TimetableRepository timetableRepository, final Patcher patcher) { + this.timetableRepository = timetableRepository; + this.patcher = patcher; + } + + public long start(){ + Timetable timetable = new Timetable(); + timetable.setStartTime(System.currentTimeMillis()); + timetable.setEndTime(-1L); + Timetable responseTable = timetableRepository.save(timetable); + return responseTable.getId(); + } + + public Timetable stop(final long id) { + Timetable timetable = timetableRepository.findById(id).orElseThrow(NotFoundException::new); + if(timetable.getEndTime() > 0){ + throw new BadRequestException("Timetable already stopped"); + } + final long currentTime = System.currentTimeMillis(); + timetable.setEndTime(currentTime); + return timetableRepository.save(timetable); + } + + public Timetable update(final long id, final Timetable timetable) { + Timetable existingTimetable = timetableRepository.findById(id).orElseThrow(NotFoundException::new); + patcher.patch(existingTimetable, timetable); + return timetableRepository.save(existingTimetable); + } + + public List getAll() { + return timetableRepository.findAll(); + } + + public void delete(final long id) { + timetableRepository.deleteById(id); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/todo/TodoController.java b/backend/src/main/java/de/haevn/snippetmanage/todo/TodoController.java new file mode 100644 index 0000000..33375d4 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/todo/TodoController.java @@ -0,0 +1,46 @@ +package de.haevn.snippetmanage.todo; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import io.swagger.v3.oas.annotations.Operation; +import java.io.IOException; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@RestApiController("/api/todo") +class TodoController { + private final TodoService fileShareService; + + public TodoController(final TodoService fileShareService) { + this.fileShareService = fileShareService; + } + + @GetMapping + @Operation(summary = "Gets all file shares' metadata") + public List getAllFileShares() { + return fileShareService.getAllFileShares(); + } + + @PostMapping(path = "/create", consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Uploads a file via multipart/form-data (field name: `file`)") + public void uploadFileMultipart(@RequestBody TodoItem item) throws IOException { + fileShareService.createTodo(item.getContent(), item.getPriority()); + } + + + @PostMapping(path = "/update", consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Uploads a file via multipart/form-data (field name: `file`)") + public void updateTodo(@RequestBody TodoItem item) throws IOException { + fileShareService.updateTodo(item); + } + + @DeleteMapping("/delete/{id}") + @Operation(summary = "Deletes a file from the file share service") + public void deleteFile(@PathVariable long id) { + fileShareService.deleteFile(id); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/todo/TodoItem.java b/backend/src/main/java/de/haevn/snippetmanage/todo/TodoItem.java new file mode 100644 index 0000000..bf5c3cc --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/todo/TodoItem.java @@ -0,0 +1,23 @@ +// java +package de.haevn.snippetmanage.todo; + +import de.haevn.snippetmanage.common.annotation.DoNotPatch; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Data; + +@Data +@Entity +class TodoItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @DoNotPatch + private long id; // can store UUID.toString() or Mongo ObjectId + private String content; + private long timestamp; + @Nullable + private String priority; +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/todo/TodoRepository.java b/backend/src/main/java/de/haevn/snippetmanage/todo/TodoRepository.java new file mode 100644 index 0000000..ccfddee --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/todo/TodoRepository.java @@ -0,0 +1,14 @@ +package de.haevn.snippetmanage.todo; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +interface TodoRepository extends JpaRepository { + + @Override + List findAll(); + + @Override + Optional findById(Long s); +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/todo/TodoService.java b/backend/src/main/java/de/haevn/snippetmanage/todo/TodoService.java new file mode 100644 index 0000000..5202f67 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/todo/TodoService.java @@ -0,0 +1,47 @@ +package de.haevn.snippetmanage.todo; + +import de.haevn.snippetmanage.common.exception.NotFoundException; +import de.haevn.snippetmanage.common.utils.CryptoUtils; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +class TodoService { + + private final TodoRepository todoRepository; + private final CryptoUtils cryptoUtils; + + public TodoService(final TodoRepository todoRepository, CryptoUtils cryptoUtils) { + this.todoRepository = todoRepository; + this.cryptoUtils = cryptoUtils; + } + + void createTodo(String content, String priority) { + TodoItem item = new TodoItem(); + item.setContent(content); + item.setPriority(priority); + item.setTimestamp(System.currentTimeMillis()); + todoRepository.save(item); + } + + void updateTodo(TodoItem todo){ + TodoItem item = todoRepository.findById(todo.getId()).orElseThrow(NotFoundException::new); + item.setContent(todo.getContent()); + item.setPriority(todo.getPriority()); + todoRepository.save(item); + } + + + void deleteFile(long id) { + try { + todoRepository.findById(id).orElseThrow(NotFoundException::new); + todoRepository.deleteById(id); + } catch (Exception ignored) { + // Ignore exceptions during delete + } + } + + public List getAllFileShares() { + return todoRepository.findAll(); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/user/User.java b/backend/src/main/java/de/haevn/snippetmanage/user/User.java new file mode 100644 index 0000000..85bba9c --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/user/User.java @@ -0,0 +1,25 @@ +package de.haevn.snippetmanage.user; + +import de.haevn.snippetmanage.common.annotation.DoNotPatch; +import jakarta.persistence.*; +import lombok.Data; + +@Data +@Entity +@Table(name = "users") +class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @DoNotPatch + private Long id; + private String firstName; + private String lastName; + @DoNotPatch + private String email; + private String password; + private String motto; + private String imgId; + private boolean darkMode; + private String username; + private String theme; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/user/UserAlreadyExistsException.java b/backend/src/main/java/de/haevn/snippetmanage/user/UserAlreadyExistsException.java new file mode 100644 index 0000000..9850830 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/user/UserAlreadyExistsException.java @@ -0,0 +1,9 @@ +package de.haevn.snippetmanage.user; + +import de.haevn.snippetmanage.common.exception.AlreadyReportedException; + +class UserAlreadyExistsException extends AlreadyReportedException { + public UserAlreadyExistsException(final String email) { + super(String.format("User with email %s already exists.", email)); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/user/UserController.java b/backend/src/main/java/de/haevn/snippetmanage/user/UserController.java new file mode 100644 index 0000000..3743a34 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/user/UserController.java @@ -0,0 +1,43 @@ +package de.haevn.snippetmanage.user; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import org.springframework.web.bind.annotation.*; + +@RestApiController("/api/user") +class UserController { + private final UserService userService; + + public UserController(final UserService userService) { + this.userService = userService; + } + + @PostMapping("/create") + public void createUser(@RequestBody User user) { + userService.createUser(user); + } + + @PutMapping("/{email}") + public void updateUser(@PathVariable("email") final String email, @RequestBody final User user) { + userService.updateUser(user, email); + } + + @PatchMapping("/{email}/theme") + public void updateUserTheme(@PathVariable("email") final String email, @RequestHeader("theme") final String theme) { + userService.updateTheme(email, theme); + } + + @DeleteMapping("/{email}") + public void deleteUser(@PathVariable("email") final String email) { + userService.deleteUser(email); + } + + @GetMapping("/{email}") + public User getUserByEmail(@PathVariable("email") final String email) { + return userService.getUserByEmail(email); + } + + @GetMapping("/{email}/picture") + public byte[] getUserPicture(@PathVariable("email") final String email) { + return userService.getUserPicture(email); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/user/UserNotFoundException.java b/backend/src/main/java/de/haevn/snippetmanage/user/UserNotFoundException.java new file mode 100644 index 0000000..e25b234 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/user/UserNotFoundException.java @@ -0,0 +1,9 @@ +package de.haevn.snippetmanage.user; + +import de.haevn.snippetmanage.common.exception.NotFoundException; + +class UserNotFoundException extends NotFoundException { + public UserNotFoundException(final String email) { + super(String.format("User with email %s not found.", email)); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/user/UserRepository.java b/backend/src/main/java/de/haevn/snippetmanage/user/UserRepository.java new file mode 100644 index 0000000..d6b4c9b --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/user/UserRepository.java @@ -0,0 +1,9 @@ +package de.haevn.snippetmanage.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +interface UserRepository extends JpaRepository { + List findByEmail(String email); +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/user/UserService.java b/backend/src/main/java/de/haevn/snippetmanage/user/UserService.java new file mode 100644 index 0000000..9289a1c --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/user/UserService.java @@ -0,0 +1,112 @@ +package de.haevn.snippetmanage.user; + +import de.haevn.snippetmanage.common.exception.BadRequestException; +import de.haevn.snippetmanage.common.exception.InternalServerErrorException; +import de.haevn.snippetmanage.common.exception.NotFoundException; +import de.haevn.snippetmanage.common.utils.Patcher; +import de.haevn.snippetmanage.common.utils.RequestContextUtils; +import java.io.File; +import java.nio.file.Files; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +class UserService { + private final UserRepository userRepository; + private final RequestContextUtils requestContextUtils; + private final Patcher patcher; + + @Value("${haevn.user.image.path}") + private String defaultImagePath; + + public UserService(final UserRepository userRepository, final RequestContextUtils requestContextUtils, + final Patcher patcher) { + this.userRepository = userRepository; + this.requestContextUtils = requestContextUtils; + this.patcher = patcher; + } + + public void createUser(final User user) { + boolean userExists = userRepository.findByEmail(user.getEmail()).isEmpty(); + if (userExists) { + userRepository.save(user); + } else { + throw new UserAlreadyExistsException(user.getEmail()); + } + + } + + public void updateUser(final User user, final String email) { + if (email.isEmpty() || !email.equalsIgnoreCase(user.getEmail())) { + throw new NotFoundException("Email cannot be changed or be empty."); + } + final User dbUser = userRepository.findByEmail(email).stream().findFirst().orElseThrow(NotFoundException::new); + + patcher.patch(dbUser, user); + userRepository.save(user); + } + + public void deleteUser(final String email) { + verifyPassword(); + final User user = userRepository.findByEmail(email).stream() // + .findFirst() // + .orElseThrow(() -> new UserNotFoundException(email)); + userRepository.delete(user); + } + + + public User getUserByEmail(final String email) { + verifyPassword(); + return userRepository.findByEmail(email).stream() // + .findFirst() // + .orElseThrow(() -> new UserNotFoundException(email)); + } + + private void verifyPassword() { + final String mail = requestContextUtils.getHeader("email") // + .orElseThrow(() -> new BadRequestException("Email header is missing")); + final String password = requestContextUtils.getHeader("password")// + .orElseThrow(() -> new BadRequestException("Password header is missing")); + final User user = userRepository.findByEmail(mail).stream() // + .findFirst() // + .orElseThrow(() -> new UserNotFoundException(mail)); + if (!user.getPassword().equals(password)) { + throw new BadRequestException("Invalid password for user " + mail); + } + } + + public byte[] getUserPicture(final String email) { + final User user = userRepository.findByEmail(email).stream() // + .findFirst() // + .orElseThrow(() -> new UserNotFoundException(email)); + + String imageId = user.getImgId(); + if (imageId == null || imageId.isEmpty()) { + imageId = "default"; + } + try { + + final File imgFile; + if (imageId.equalsIgnoreCase("default")) { + imgFile = new File(getClass().getClassLoader().getResource("img/default.png").getFile()); + } else { + imgFile = new File(defaultImagePath, imageId + ".png"); + } + + if (!imgFile.exists()) { + throw new NotFoundException("Default image file does not exist."); + } + return Files.readAllBytes(imgFile.toPath()); + } catch (Exception e) { + throw new InternalServerErrorException("Failed to read image file " + imageId, e); + } + } + + public void updateTheme(final String email, final String theme) { + final User user = userRepository.findByEmail(email).stream() // + .findFirst() // + .orElseThrow(() -> new UserNotFoundException(email)); + user.setTheme(theme); + userRepository.save(user); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNote.java b/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNote.java new file mode 100644 index 0000000..f4a962c --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNote.java @@ -0,0 +1,22 @@ +package de.haevn.snippetmanage.wnote; + +import jakarta.persistence.Id; +import lombok.Data; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@Document(collection = "weekly_meeting_notes") +class WeeklyNote { + @Id + private String id; + private String title = ""; + private String monday = ""; + private String tuesday = ""; + private String wednesday = ""; + private String thursday = ""; + private String friday = ""; + private String other = ""; + private String startDate = ""; + private String endDate = ""; + private String weekNumber = ""; +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteController.java b/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteController.java new file mode 100644 index 0000000..a23de73 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteController.java @@ -0,0 +1,93 @@ +package de.haevn.snippetmanage.wnote; + +import de.haevn.snippetmanage.common.annotation.RestApiController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestApiController(value = "/api/meetings", tagName = "Note Controller", description = "Controller for managing notes") +class WeeklyNoteController { + + private final WeeklyNoteService weeklyNoteService; + + public WeeklyNoteController(WeeklyNoteService weeklyNoteService) { + this.weeklyNoteService = weeklyNoteService; + } + + @GetMapping("/limit") + @Operation(summary = "Get text limit", description = "Retrieve the maximum allowed text limit for notes") + public ResponseEntity getTextLimit() { + return ResponseEntity.ok(weeklyNoteService.getTextLimit()); + } + + @GetMapping("") + @Operation(summary = "Get all notes", description = "Retrieve a list of all notes") + public ResponseEntity> getAllNotes() { + return ResponseEntity.ok(weeklyNoteService.getAllNotes()); + } + + @PostMapping("") + @Operation(summary = "Create a new note", description = "Add a new note to the system") + @ApiResponse(responseCode = "200", description = "Note created successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, note is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to create note") + public ResponseEntity createNote(@RequestBody Optional snippetOpt) { + if (snippetOpt.isEmpty()) { + return ResponseEntity.badRequest().body("Note is empty"); + } + + WeeklyNote weeklyNote = snippetOpt.get(); + + boolean created = weeklyNoteService.createNote(weeklyNote); + if (created) { + return ResponseEntity.ok("Note created with ID: " + weeklyNote.getId()); + } else { + return ResponseEntity.status(500).body("Failed to create note"); + } + } + + @PatchMapping("/{id}") + @Operation(summary = "Update an existing note", description = "Modify the details of an existing note") + @ApiResponse(responseCode = "200", description = "Note updated successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, note is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to update note") + public ResponseEntity updateNote(@RequestBody Optional codeOpt, @PathVariable("id") String id) { + if (codeOpt.isEmpty()) { + return ResponseEntity.badRequest().body("Note is empty"); + } + + WeeklyNote code = codeOpt.get(); + + boolean updated = weeklyNoteService.updateNote(code, id); + if (updated) { + return ResponseEntity.ok("Note updated with ID: " + id); + } else { + return ResponseEntity.status(500).body("Failed to update note"); + } + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete a note", description = "Remove a note from the system by its ID") + @ApiResponse(responseCode = "200", description = "Note deleted successfully") + @ApiResponse(responseCode = "400", description = "Invalid input, ID is empty") + @ApiResponse(responseCode = "500", description = "Internal server error, failed to delete note") + public ResponseEntity deleteNote(@PathVariable String id) { + boolean deleted = weeklyNoteService.deleteNote(id); + if (deleted) { + return ResponseEntity.ok("Note deleted with ID: " + id); + } else { + return ResponseEntity.status(500).body("Failed to delete note"); + } + } + + @GetMapping("/{id}/pdf") + public ResponseEntity printNoteToPDF(@PathVariable String id) { + Optional pdfOpt = weeklyNoteService.printNoteToPDF(id); + + return pdfOpt.map(ResponseEntity::ok) // + .orElseGet(() -> ResponseEntity.status(500).body("Failed to generate PDF".getBytes())); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteRepository.java b/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteRepository.java new file mode 100644 index 0000000..709b737 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteRepository.java @@ -0,0 +1,6 @@ +package de.haevn.snippetmanage.wnote; + +import org.springframework.data.mongodb.repository.MongoRepository; + +interface WeeklyNoteRepository extends MongoRepository { +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteService.java b/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteService.java new file mode 100644 index 0000000..b9be4a3 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/wnote/WeeklyNoteService.java @@ -0,0 +1,186 @@ +package de.haevn.snippetmanage.wnote; + +import com.lowagie.text.Document; +import com.lowagie.text.DocumentException; +import com.lowagie.text.Font; +import com.lowagie.text.Paragraph; +import com.lowagie.text.Rectangle; +import com.lowagie.text.pdf.PdfPCell; +import com.lowagie.text.pdf.PdfPTable; +import com.lowagie.text.pdf.PdfWriter; +import de.haevn.snippetmanage.common.annotation.PDFService; +import de.haevn.snippetmanage.common.exception.BadRequestException; +import de.haevn.snippetmanage.common.exception.NotFoundException; +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +class WeeklyNoteService { + private final WeeklyNoteRepository repository; + + public WeeklyNoteService(WeeklyNoteRepository repository) { + this.repository = repository; + } + + public boolean createNote(WeeklyNote weeklyNote) { + weeklyNote.setId(null); + repository.save(weeklyNote); + return true; + } + + public boolean updateNote(WeeklyNote note, String id) { + var existingNote = repository.findAll().stream().filter(n -> n.getId().equalsIgnoreCase(id)).findFirst() + .orElseThrow(NotFoundException::new); + + existingNote.setMonday(note.getMonday()); + existingNote.setTuesday(note.getTuesday()); + existingNote.setWednesday(note.getWednesday()); + existingNote.setThursday(note.getThursday()); + existingNote.setFriday(note.getFriday()); + + repository.save(existingNote); + return true; + } + + public boolean deleteNote(String id) { + if (id == null || !repository.existsById(id)) { + return false; + } + repository.deleteById(id); + return true; + } + + public Optional getNote(String id) { + if (id == null || !repository.existsById(id)) { + return Optional.empty(); + } + + return repository.findById(id); + } + + public List getAllNotes() { + return repository.findAll(); + } + + public Integer getTextLimit() { + return 4096; + } + + private void addWeek(Document document, String name, String... items) { + var overview = new com.lowagie.text.List(false, 10); + Font headingFont = new Font(Font.HELVETICA, 16, Font.BOLD); + Font regularFontBold = new Font(Font.HELVETICA, 12, Font.BOLD); + Font regularFont = new Font(Font.HELVETICA, 12, Font.NORMAL); + Font regularFontItalic = new Font(Font.HELVETICA, 12, Font.ITALIC); + document.add(new Paragraph(name, headingFont)); + for (String item : items) { + if (item.startsWith("**")) { + item = item.replace("**", ""); + document.add(new Paragraph(item, regularFontBold)); + } else if (item.startsWith("*")) { + item = item.replace("*", ""); + document.add(new Paragraph(item, regularFontItalic)); + } else if (item.startsWith("-")) { + item = item.replace("-", "•"); + document.add(new Paragraph(item, regularFont)); + } else { + overview.add(new Paragraph(item, regularFont)); + } + } + + document.add(overview); + } + + private String mapDateToString(String date) { + final String[] month = + {"Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", + "Dezember"}; + + String[] parts = date.split("T")[0].split("-"); + int year = Integer.parseInt(parts[0]); + int monthIndex = Integer.parseInt(parts[1]) - 1; + int day = Integer.parseInt(parts[2]); + + String dayStr = String.format("%02d", day); + + return dayStr + " " + month[monthIndex] + " " + year; + + } + + public Optional printNoteToPDF(final String id) { + WeeklyNote weeklyNote = repository.findById(id).orElseThrow(NotFoundException::new); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + Document document = new Document(); + PdfWriter.getInstance(document, baos); + document.open(); + + new PDFService("Developer Meeting Export").addTitle("DeveloperNote " + id) + .addKeywords("DeveloperNote", "DashboardManager").appendToDoc(document); + // Abschnitt für Montag + com.lowagie.text.Font headingFont = + new Font(com.lowagie.text.Font.HELVETICA, 16, com.lowagie.text.Font.BOLD); + + document.add(createDateCell(weeklyNote, headingFont)); + document.add(new Paragraph(" ")); + addWeek(document, "Dienstag", weeklyNote.getTuesday().split("\n")); + addWeek(document, "Mittwoch", weeklyNote.getWednesday().split("\n")); + addWeek(document, "Donnerstag", weeklyNote.getThursday().split("\n")); + addWeek(document, "Freitag", weeklyNote.getFriday().split("\n")); + addWeek(document, "Montag", weeklyNote.getMonday().split("\n")); + + String other = weeklyNote.getOther(); + if (other == null || other.isBlank()) { + other = ""; + } + addWeek(document, "Sonstiges", other.split("\n")); + document.close(); + + return Optional.of(baos.toByteArray()); + } catch (DocumentException | java.io.IOException e) { + throw new BadRequestException("PDF creation failed: " + e.getMessage()); + } + } + + + private PdfPTable createDateCell(WeeklyNote weeklyNote, Font headingFont) { + PdfPTable dateTable = new PdfPTable(2); + dateTable.setWidthPercentage(100); + dateTable.setWidths(new float[] {1f, 3f}); // label column narrower, value column wider + dateTable.setSpacingBefore(4f); + + PdfPCell labelCell = new PdfPCell(new Paragraph("Startdatum:", headingFont)); + labelCell.setBorder(Rectangle.NO_BORDER); + labelCell.setPadding(0); + dateTable.addCell(labelCell); + + PdfPCell valueCell = new PdfPCell(new Paragraph(mapDateToString(weeklyNote.getStartDate()))); + valueCell.setBorder(Rectangle.NO_BORDER); + valueCell.setPadding(0); + dateTable.addCell(valueCell); + + PdfPCell labelCell2 = new PdfPCell(new Paragraph("Enddatum:", headingFont)); + labelCell2.setBorder(Rectangle.NO_BORDER); + labelCell2.setPadding(0); + dateTable.addCell(labelCell2); + + PdfPCell valueCell2 = new PdfPCell(new Paragraph(mapDateToString(weeklyNote.getEndDate()))); + valueCell2.setBorder(Rectangle.NO_BORDER); + valueCell2.setPadding(0); + dateTable.addCell(valueCell2); + + + PdfPCell labelCell3 = new PdfPCell(new Paragraph("Woche:", headingFont)); + labelCell3.setBorder(Rectangle.NO_BORDER); + labelCell3.setPadding(0); + dateTable.addCell(labelCell3); + + PdfPCell valueCell3 = new PdfPCell(new Paragraph(weeklyNote.getWeekNumber())); + valueCell3.setBorder(Rectangle.NO_BORDER); + valueCell3.setPadding(0); + dateTable.addCell(valueCell3); + + return dateTable; + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/xml/EmptyStringAdapter.java b/backend/src/main/java/de/haevn/snippetmanage/xml/EmptyStringAdapter.java new file mode 100644 index 0000000..0ae0f98 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/xml/EmptyStringAdapter.java @@ -0,0 +1,19 @@ +// java +package de.haevn.snippetmanage.xml; + +import jakarta.xml.bind.annotation.adapters.XmlAdapter; + +public class EmptyStringAdapter extends XmlAdapter { + + @Override + public String unmarshal(String v) { + return v; + } + + @Override + public String marshal(String v) { + if (v == null) return null; + String trimmed = v.trim(); + return trimmed.isEmpty() ? null : v; + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/xml/FormatRule.java b/backend/src/main/java/de/haevn/snippetmanage/xml/FormatRule.java new file mode 100644 index 0000000..0010b58 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/xml/FormatRule.java @@ -0,0 +1,48 @@ +package de.haevn.snippetmanage.xml; + +import jakarta.validation.constraints.NotNull; +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlAttribute; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import lombok.Data; + +@XmlAccessorType(XmlAccessType.FIELD) +@Data +public class FormatRule { + + @XmlAttribute(name = "identifierColumn") + private int identifierColumn = 0; + + @NotNull + @XmlElement(name = "fieldName") + private String fieldName = ""; + + @NotNull + @XmlJavaTypeAdapter(EmptyStringAdapter.class) + @XmlElement(name = "description") + private String description = ""; + + @XmlJavaTypeAdapter(EmptyStringAdapter.class) + @XmlElement(name = "regex") + private String regex = ""; + + @XmlJavaTypeAdapter(EmptyStringAdapter.class) + @XmlElement(name = "choice") + private String choice = ""; + + @XmlJavaTypeAdapter(EmptyStringAdapter.class) + @XmlElement(name = "errorCode") + private String errorCode; + + @XmlJavaTypeAdapter(EmptyStringAdapter.class) + @XmlElement(name = "errorMessage") + private String errorMessage; + + @XmlElement(name = "column") + private int column = -1; + + @XmlElement(name = "optional") + private boolean optional = false; +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/xml/PdfBoxDinA4Generator.java b/backend/src/main/java/de/haevn/snippetmanage/xml/PdfBoxDinA4Generator.java new file mode 100644 index 0000000..687c404 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/xml/PdfBoxDinA4Generator.java @@ -0,0 +1,588 @@ +package de.haevn.snippetmanage.xml; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@Component +public class PdfBoxDinA4Generator { + + // --- Konstanten --- + private static final PDType1Font FONT_BOLD = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + private static final PDType1Font FONT_NORMAL = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + private static final PDType1Font FONT_OBLIQUE = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE); + + private static final float MARGIN = 50; + private static final float FONT_SIZE_TABLE_HEADER = 8; + private static final float FONT_SIZE_TABLE_BODY = 8; + private static final float TABLE_ROW_MIN_HEIGHT = 15; + private static final float TABLE_LINE_HEIGHT = 10; // Für umgebrochenen Text + private static final float CELL_PADDING = 5; + + // Spaltenbreiten (Summe = 595 - 2*MARGIN = 495) + // ANGEPASST: 8 Spalten, Regex/Choice zusammengelegt (65+65=130) + private static final float[] COL_WIDTHS = {90, 30, 40, 40, 60, 130, 45, 60}; + private static final String[] HEADERS = { + "Feldname", "Spalte", "Excel", "Pflicht", "Beschreibung", + "Regex / Erlaubte Werte", "Fehler-\nCode", "Fehler-\nmeldung" + }; + + /** + * Hilfsfunktion zum Zeichnen der Gitterlinien für EINE Zeile. + */ + private static void drawRowLines(PDPageContentStream cs, float x, float y, float rowHeight, float[] colWidths) throws IOException { + float tableWidth = 0; + for (float w : colWidths) tableWidth += w; + + // Horizontale Linien + cs.moveTo(x, y); + cs.lineTo(x + tableWidth, y); + cs.moveTo(x, y - rowHeight); + cs.lineTo(x + tableWidth, y - rowHeight); + + // Vertikale Linien + float currentX = x; + cs.moveTo(currentX, y); + cs.lineTo(currentX, y - rowHeight); // Erste Linie links + + for (float width : colWidths) { + currentX += width; + cs.moveTo(currentX, y); + cs.lineTo(currentX, y - rowHeight); // Linien zwischen den Spalten + } + cs.stroke(); // Alle Linien auf einmal zeichnen + } + + /** + * Zeichnet die Kopf- und Fußzeile (Seitenzahl). + */ + private static void drawPageHeaderAndFooter(PDPageContentStream cs, PDPage page, String docTitle, int pageNum, float margin) throws IOException { + float width = page.getMediaBox().getWidth(); + float height = page.getMediaBox().getHeight(); + + // Kopfzeile + // KORREKTUR: docTitle bereinigen, bevor er verwendet wird + String safeDocTitle = (docTitle != null ? docTitle : "N/A").replaceAll("\\r\\n|\\r|\\n", " "); + cs.beginText(); + cs.setFont(FONT_NORMAL, 10); + cs.newLineAtOffset(margin, height - margin + 10); // Oben links + cs.showText("Dokumentation: " + safeDocTitle); + cs.endText(); + + // Fußzeile (Seitenzahl) + String pageNumText = "Seite " + pageNum; + float textWidth = FONT_NORMAL.getStringWidth(pageNumText) / 1000 * 10; + float pageNumX = width - margin - textWidth; // Unten rechts + + cs.beginText(); + cs.setFont(FONT_NORMAL, 10); + cs.newLineAtOffset(pageNumX, margin - 10); + cs.showText(pageNumText); + cs.endText(); + } + + /** + * Zeichnet die Box mit den allgemeinen Hinweisen. + * Gibt die Y-Position *nach* der Box zurück. + */ + private static float drawNotesBox(PDPageContentStream cs, float margin, float yStart) throws IOException { + float width = PDRectangle.A4.getWidth() - 2 * margin; + float y = yStart; + float padding = 10; + float lineHeight = 12; + + String[] notes = { + "Hinweis zur Validierung:", + "- Dynamische Fehlermeldungen: ... werden generiert nach dem Muster: ", + "- Regel-Priorität: Für jedes Feld ist ENTWEDER Regex ODER Auswahl definiert." + }; + + float boxHeight = (notes.length * lineHeight) + (2 * padding); + cs.setNonStrokingColor(0.95f, 0.95f, 0.95f); // Helles Grau + cs.addRect(margin, y - boxHeight, width, boxHeight); + cs.fill(); + cs.setNonStrokingColor(0, 0, 0); // Zurück zu Schwarz + + y -= (padding + lineHeight); + + cs.beginText(); + cs.setFont(FONT_BOLD, 10); + cs.newLineAtOffset(margin + padding, y); + cs.showText(notes[0]); + cs.endText(); + y -= lineHeight; + + cs.beginText(); + cs.setFont(FONT_OBLIQUE, 9); + cs.newLineAtOffset(margin + padding, y); + cs.showText(notes[1]); + cs.newLineAtOffset(0, -lineHeight); // Nächste Zeile + cs.showText(notes[2]); + cs.endText(); + + return y - padding - lineHeight; // Y-Position unterhalb der Box + } + + /** + * Erstellt das komplette PDF-Dokument und gibt es als Byte-Array zurück. + * + * @param schema Das geladene Validierungsschema + * @return Ein byte[] des PDF-Dokuments + * @throws IOException Wenn beim Zeichnen ein Fehler auftritt + */ + public byte[] createDocumentationAsBytes(ValidationSchema schema) throws IOException { + // Wir verwenden try-with-resources, um sicherzustellen, dass BEIDE + // Ressourcen (Dokument und Stream) am Ende geschlossen werden. + try (PDDocument document = createDocumentation(schema); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + + // Speichere das Dokument in den In-Memory-Stream + document.save(baos); + + // Gib die Bytes des Streams zurück + return baos.toByteArray(); + } + // document.close() und baos.close() werden hier automatisch aufgerufen. + } + + /** + * Erstellt das komplette PDF-Dokument basierend auf einem ValidationSchema. + * + * @param schema Das geladene Validierungsschema + * @return Ein PDDocument, bereit zum Speichern + * @throws IOException Wenn beim Zeichnen ein Fehler auftritt + */ + public PDDocument createDocumentation(ValidationSchema schema) throws IOException { + PDDocument document = new PDDocument(); + try { + // --- SEITE 1: DECKBLATT --- + addTitlePageContent(document, schema); + + // --- SEITE 2+: DEFINITIONEN --- + addDefinitionPages(document, schema); + + return document; + } catch (IOException e) { + // Wenn ein Fehler auftritt, schließe das Dokument, bevor die Exception geworfen wird + document.close(); + throw e; + } + } + + /** + * Fügt das Deckblatt (Seite 1) hinzu. + * (ANGEPASST, um alle XML-Attribute aufzulisten) + */ + private void addTitlePageContent(PDDocument doc, ValidationSchema schema) throws IOException { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + + float width = page.getMediaBox().getWidth(); + float height = page.getMediaBox().getHeight(); + + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + + // --- Titel (zentriert) --- + String title = (schema.getReadableName() != null ? schema.getReadableName() : "Validierungsregeln") + .replaceAll("\\r\\n|\\r|\\n", " "); // Bereinigen + float fontSizeTitle = 24; + float titleWidth = FONT_BOLD.getStringWidth(title) / 1000 * fontSizeTitle; + float titleX = (width - titleWidth) / 2; + float titleY = height - 150; // Oben zentriert + + cs.beginText(); + cs.setFont(FONT_BOLD, fontSizeTitle); + cs.newLineAtOffset(titleX, titleY); + cs.showText(title); + cs.endText(); + + // --- Info-Block (Attribute) --- + float y = titleY - 80; + float x_label = MARGIN + 50; + float x_value = x_label + 160; + float lineHeight = 25; + float fontSizeInfo = 12; + + // 1. Schema-Name + String schemaName = (schema.getSchemaName() != null ? schema.getSchemaName() : "N/A") + .replaceAll("\\r\\n|\\r|\\n", " "); + drawInfoLine(cs, x_label, x_value, y, "Schema-Name:", schemaName, fontSizeInfo); + y -= lineHeight; + + // 2. ID-Name + String idName = (schema.getIdName() != null ? schema.getIdName() : "N/A") + .replaceAll("\\r\\n|\\r|\\n", " "); + drawInfoLine(cs, x_label, x_value, y, "ID-Name (Schlüssel):", idName, fontSizeInfo); + y -= lineHeight; + + // 3. ID-Spalte + String idColumnStr = String.valueOf(schema.getIdColumn()) + + " (Excel-Spalte: " + getExcelColumnName(schema.getIdColumn()) + ")"; + drawInfoLine(cs, x_label, x_value, y, "ID-Spalte (Index):", idColumnStr, fontSizeInfo); + y -= lineHeight; + + // 4. Header-Identifier + String headerIdentifier = (schema.getHeaderIdentifier() != null ? schema.getHeaderIdentifier() : "N/A") + .replaceAll("\\r\\n|\\r|\\n", " "); + + // --- KORREKTUR: Text-Umbruch für Header-Erkennung --- + int wrapLimit = 45; + if (headerIdentifier.length() > wrapLimit) { + // Versuche, an einem Leerzeichen vor dem Limit zu umbrechen + int wrapIndex = headerIdentifier.lastIndexOf(' ', wrapLimit); + + // Wenn kein Leerzeichen gefunden wurde oder das Leerzeichen sehr früh ist (z.B. bei einem langen Wort), + // dann hart am Limit umbrechen. + if (wrapIndex == -1 || wrapIndex < 20) { + wrapIndex = wrapLimit; + } + + String part1 = headerIdentifier.substring(0, wrapIndex); + String part2 = headerIdentifier.substring(wrapIndex).trim(); // .trim() entfernt das Leerzeichen am Anfang + + // Zeichne Label und Teil 1 + drawInfoLine(cs, x_label, x_value, y, "Header-Erkennung:", part1, fontSizeInfo); + y -= lineHeight; // Gehe zur nächsten Zeile + + // Zeichne Teil 2 (eingerückt auf Höhe des Wertes) + cs.beginText(); + cs.setFont(FONT_NORMAL, fontSizeInfo); + cs.newLineAtOffset(x_value, y); // Gleiche X-Position wie der Wert + cs.showText(part2); + cs.endText(); + y -= lineHeight; // Gehe zur nächsten Zeile (jetzt 2x dekrementiert für diese Info) + + } else { + // Alte Logik: Passt in eine Zeile + drawInfoLine(cs, x_label, x_value, y, "Header-Erkennung:", headerIdentifier, fontSizeInfo); + y -= lineHeight; + } + // --- ENDE KORREKTUR --- + + // 5. Gesamtspalten + String totalColsStr = String.valueOf(schema.getTotalColumns()); + if (totalColsStr.equals("-1")) totalColsStr = "N/A"; + drawInfoLine(cs, x_label, x_value, y, "Gesamtspalten (erwartet):", totalColsStr, fontSizeInfo); + y -= lineHeight; + } + } + + /** + * NEUE Hilfsmethode zum Zeichnen einer Zeile im Info-Block (Label + Wert). + */ + private void drawInfoLine(PDPageContentStream cs, float x_label, float x_value, float y, String label, String value, float fontSize) throws IOException { + // Label (Fett) + cs.beginText(); + cs.setFont(FONT_BOLD, fontSize); + cs.newLineAtOffset(x_label, y); + cs.showText(label); + cs.endText(); + + // Wert (Normal) + cs.beginText(); + cs.setFont(FONT_NORMAL, fontSize); + cs.newLineAtOffset(x_value, y); + cs.showText(value); + cs.endText(); + } + + /** + * Fügt alle Definitionsseiten hinzu, inkl. automatischer Seitenumbrüche. + */ + private void addDefinitionPages(PDDocument doc, ValidationSchema schema) throws IOException { + // 1. Alle Regeln sammeln und sortieren + List allRules = new ArrayList<>(); + // KORREKTUR: Regeln explizit als Pflicht (nicht optional) markieren + schema.getMandatory().forEach(rule -> { + rule.setOptional(false); + allRules.add(rule); + }); + // KORREKTUR: Regeln explizit als optional markieren + schema.getOptional().forEach(rule -> { + rule.setOptional(true); + allRules.add(rule); + }); + + // Sortiere nach Spaltenindex + allRules.sort(Comparator.comparing(FormatRule::getColumn)); + + // 2. Setup für die erste Seite + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDPageContentStream cs = new PDPageContentStream(doc, page); + + int pageNum = 2; + float yStart = page.getMediaBox().getHeight() - MARGIN - 40; // Y-Position nach Header + float y = yStart; // Aktuelle Y-Position + + // 3. Header, Footer und Notizen für die erste Definitionsseite + drawPageHeaderAndFooter(cs, page, schema.getReadableName(), pageNum, MARGIN); + y = drawNotesBox(cs, MARGIN, y); + y -= 20; // Abstand zur Tabe lle + + // 4. Tabellenkopf zeichnen + // ANGEPASST: Header-Höhe fix auf 2 Zeilen eingestellt wegen \n + float headerHeight = 2 * TABLE_LINE_HEIGHT + CELL_PADDING; // ca. 25 + drawTableHeader(cs, MARGIN, y, headerHeight); + y -= headerHeight; + + // 5. Alle Regeln durchlaufen und Zeilen zeichnen + for (FormatRule rule : allRules) { + + // 5a. Dynamische Zeilenhöhe basierend auf Textumbruch berechnen + float dynamicRowHeight = calculateDynamicRowHeight(rule); + + // 5b. Prüfen, ob ein Seitenumbruch NÖTIG ist + if (y - dynamicRowHeight < MARGIN) { + // Seite abschließen + cs.close(); + + // Neue Seite erstellen + pageNum++; + page = new PDPage(PDRectangle.A4); + doc.addPage(page); + cs = new PDPageContentStream(doc, page); + y = yStart; // Y-Position zurücksetzen + + // Header und Footer auf neuer Seite + drawPageHeaderAndFooter(cs, page, schema.getReadableName(), pageNum, MARGIN); + + // Tabellenkopf erneut zeichnen + drawTableHeader(cs, MARGIN, y, headerHeight); + y -= headerHeight; + } + + // 5c. Zeile zeichnen (Linien und Inhalt) + drawRowLines(cs, MARGIN, y, dynamicRowHeight, COL_WIDTHS); + drawRowContent(cs, rule, MARGIN, y, dynamicRowHeight); + + y -= dynamicRowHeight; + } + + // Letzten ContentStream schließen + cs.close(); + } + + /** + * Berechnet die Höhe einer Zeile basierend auf dem Inhalt, der umgebrochen werden muss. + */ + private float calculateDynamicRowHeight(FormatRule rule) throws IOException { + // Leere Strings oder Null-Werte abfangen + // ANGEPASST: Logik für zusammengefasste Spalte + String regex = rule.getRegex() != null && !rule.getRegex().isEmpty() ? rule.getRegex() : null; + String choice = rule.getChoice() != null && !rule.getChoice().isEmpty() ? rule.getChoice() : null; + String ruleContent = regex != null ? regex : (choice != null ? choice : "-"); + String errorMsg = rule.getErrorMessage() != null ? rule.getErrorMessage() : "-"; + + // Berechne Zeilen für jede Zelle, die umgebrochen werden könnte + int linesFeld = wrapText(rule.getFieldName(), COL_WIDTHS[0] - CELL_PADDING * 2, FONT_NORMAL, FONT_SIZE_TABLE_BODY).size(); + // ANGEPASST: Spaltenindizes und Logik + int linesRule = wrapText(ruleContent, COL_WIDTHS[5] - CELL_PADDING * 2, FONT_NORMAL, FONT_SIZE_TABLE_BODY).size(); + int linesError = wrapText(errorMsg, COL_WIDTHS[7] - CELL_PADDING * 2, FONT_NORMAL, FONT_SIZE_TABLE_BODY).size(); + + // Finde die maximale Anzahl an Zeilen + int maxLines = Math.max(1, linesFeld); // Mindestens 1 Zeile + maxLines = Math.max(maxLines, linesRule); + maxLines = Math.max(maxLines, linesError); + + // Berechne Höhe: (Anzahl Zeilen * Zeilenhöhe) + etwas Padding oben/unten + float height = (maxLines * TABLE_LINE_HEIGHT) + (CELL_PADDING / 2); + return Math.max(TABLE_ROW_MIN_HEIGHT, height); // Mindesthöhe sicherstellen + } + + /** + * Zeichnet den Inhalt einer einzelnen Tabellenzeile (mit Textumbruch). + */ + private void drawRowContent(PDPageContentStream cs, FormatRule rule, float x, float y, float rowHeight) throws IOException { + // ANGEPASST: Logik für zusammengefasste Spalte + String regex = rule.getRegex() != null && !rule.getRegex().isEmpty() ? rule.getRegex() : null; + String choice = rule.getChoice() != null && !rule.getChoice().isEmpty() ? rule.getChoice() : null; + String ruleContent = regex != null ? "Regex: " + regex : (choice != null ? "Auswahl: " + choice : "-"); + + // Daten für die Zeile vorbereiten + // ANGEPASST: 8 Spalten + String[] rowData = { + rule.getFieldName(), + String.valueOf(rule.getColumn()), + getExcelColumnName(rule.getColumn()), + rule.isOptional() ? "Nein" : "Ja", // KORRIGIERT: Basiert auf mandatory/optional Liste + rule.getDescription() != null ? rule.getDescription() : "tba", + ruleContent, // Zusammengefasste Spalte + rule.getErrorCode() != null ? rule.getErrorCode() : "-", + rule.getErrorMessage() != null ? rule.getErrorMessage() : "-" + }; + + cs.setFont(FONT_NORMAL, FONT_SIZE_TABLE_BODY); + + float currentX = x; + // Vertikale Position: Oben in der Zelle starten + float textY = y - TABLE_LINE_HEIGHT; + + for (int i = 0; i < rowData.length; i++) { + float cellWidth = COL_WIDTHS[i] - CELL_PADDING * 2; + String text = rowData[i]; + + if (text == null) text = "-"; + + // Text umbrechen + List lines = wrapText(text, cellWidth, FONT_NORMAL, FONT_SIZE_TABLE_BODY); + + float currentTextY = textY; + for (String line : lines) { + cs.beginText(); + cs.newLineAtOffset(currentX + CELL_PADDING, currentTextY); + cs.showText(line); + cs.endText(); + currentTextY -= TABLE_LINE_HEIGHT; // Nächste Zeile nach unten + } + currentX += COL_WIDTHS[i]; + } + } + + /** + * Zeichnet den Tabellenkopf. + */ + private void drawTableHeader(PDPageContentStream cs, float x, float y, float rowHeight) throws IOException { + // Linien für Header zeichnen + drawRowLines(cs, x, y, rowHeight, COL_WIDTHS); + + cs.setFont(FONT_BOLD, FONT_SIZE_TABLE_HEADER); + float currentX = x; + + // ANGEPASST: Logik für Zeilenumbrüche (\n) im Header + for (int i = 0; i < HEADERS.length; i++) { + String header = HEADERS[i]; + float cellX = currentX + CELL_PADDING; + + if (header.contains("\n")) { + // Header mit Zeilenumbruch + String[] parts = header.split("\n"); + float textY1 = y - (rowHeight / 2) + (FONT_SIZE_TABLE_HEADER / 2) + 2; // Oben + float textY2 = y - (rowHeight / 2) - (FONT_SIZE_TABLE_HEADER / 2) - 2; // Unten + + cs.beginText(); + cs.newLineAtOffset(cellX, textY1); + cs.showText(parts[0]); + cs.endText(); + + cs.beginText(); + cs.newLineAtOffset(cellX, textY2); + cs.showText(parts[1]); + cs.endText(); + } else { + // Standard-Header (vertikal zentriert) + float textY = y - (rowHeight / 2) - (FONT_SIZE_TABLE_HEADER / 2); + cs.beginText(); + cs.newLineAtOffset(cellX, textY); + cs.showText(header); + cs.endText(); + } + currentX += COL_WIDTHS[i]; + } + } + + // --- HELPER-METHODEN --- + + /** + * Bricht einen langen Text in mehrere Zeilen um, damit er in eine Breite passt. + */ + private List wrapText(String text, float maxWidth, PDType1Font font, float fontSize) throws IOException { + List lines = new ArrayList<>(); + if (text == null || text.isEmpty()) { + lines.add(""); + return lines; + } + + // --- KORREKTUR --- + // Ersetze alle Steuerzeichen (wie \n, \r) durch Leerzeichen, + // damit showText() nicht fehlschlägt und der Umbruch korrekt funktioniert. + String sanitizedText = text.replaceAll("\\r\\n|\\r|\\n", " "); + // --- ENDE KORREKTUR --- + + String[] words = sanitizedText.split(" "); // Benutze den bereinigten Text + StringBuilder line = new StringBuilder(); + + for (String word : words) { + if (word.isEmpty()) continue; + + float wordWidth = font.getStringWidth(word) / 1000 * fontSize; + float lineWidth = font.getStringWidth(line.toString()) / 1000 * fontSize; + + // Prüfen, ob das Wort selbst zu lang ist + if (wordWidth > maxWidth) { + // Wort aufteilen (einfache Aufteilung nach Zeichen) + String tempWord = word; + while (!tempWord.isEmpty()) { + int breakIndex = findBreakIndex(tempWord, maxWidth, font, fontSize); + String part = tempWord.substring(0, breakIndex); + + if (line.length() > 0) { + lines.add(line.toString()); + line = new StringBuilder(part); + } else { + lines.add(part); + } + tempWord = tempWord.substring(breakIndex); + } + } + // Prüfen, ob das Wort auf die aktuelle Zeile passt + else if (lineWidth + wordWidth < maxWidth) { + if (line.length() > 0) line.append(" "); + line.append(word); + } + // Passt nicht mehr, neue Zeile beginnen + else { + lines.add(line.toString()); + line = new StringBuilder(word); + } + } + lines.add(line.toString()); // Letzte Zeile hinzufügen + return lines; + } + + /** + * Hilfsfunktion für wrapText: Findet den Umbruchpunkt in einem zu langen Wort. + */ + private int findBreakIndex(String word, float maxWidth, PDType1Font font, float fontSize) throws IOException { + int i = 0; + float width = 0; + while (i < word.length()) { + char c = word.charAt(i); + float charWidth = font.getStringWidth(String.valueOf(c)) / 1000 * fontSize; + if (width + charWidth > maxWidth) { + break; + } + width += charWidth; + i++; + } + return Math.max(1, i); // Mindestens 1 Zeichen zurückgeben + } + + + /** + * Konvertiert einen 0-basierten Spaltenindex in einen Excel-Spaltennamen (A, B, ..., Z, AA, ...). + */ + private String getExcelColumnName(int column) { + if (column < 0) return "-"; + + StringBuilder sb = new StringBuilder(); + while (column >= 0) { + int remainder = column % 26; + sb.append((char) (remainder + 'A')); + column = (column / 26) - 1; + if (column < -1) break; // Korrektur für 0-basiert + } + return sb.reverse().toString(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchema.java b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchema.java new file mode 100644 index 0000000..8cd7f5e --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchema.java @@ -0,0 +1,51 @@ +package de.haevn.snippetmanage.xml; + +import jakarta.validation.constraints.NotNull; +import jakarta.xml.bind.annotation.*; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + *

ValidationSchema

+ * This class represents a validation schema. + */ +@XmlRootElement(name = "validation") +@XmlAccessorType(value = XmlAccessType.FIELD) +@Data +public class ValidationSchema { + + @NotNull + @XmlAttribute(name = "readableName") + private String readableName = "N/A"; + + @NotNull + @XmlAttribute(name = "schemaName") + private String schemaName = "N/A"; + + @NotNull + @XmlAttribute(name = "headerIdentifier") + private String headerIdentifier = "N/A"; + + @XmlAttribute(name = "idColumn") + private int idColumn = 0; + + @NotNull + @XmlAttribute(name = "idName") + private String idName = "N/A"; + + @XmlAttribute(name = "totalColumns") + private int totalColumns = -1; + + @NotNull + @XmlElementWrapper(name = "mandatory") + @XmlElement(name = "rule") + private List mandatory = new ArrayList<>(); + + @NotNull + @XmlElementWrapper(name = "optional") + @XmlElement(name = "rule") + private List optional = new ArrayList<>(); +} + diff --git a/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaController.java b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaController.java new file mode 100644 index 0000000..628fcf3 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaController.java @@ -0,0 +1,62 @@ +package de.haevn.snippetmanage.xml; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import de.haevn.snippetmanage.common.annotation.RestApiController; +import de.haevn.snippetmanage.common.utils.DownloadUtils; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@RestApiController("/api/validation-schema") +public class ValidationSchemaController { + + private final DownloadUtils downloadUtils; + private final ValidationService validationService; + + public ValidationSchemaController(DownloadUtils downloadUtils, ValidationService validationService) { + this.downloadUtils = downloadUtils; + this.validationService = validationService; + } + + @PostMapping(path = "/pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "Uploads a file via multipart/form-data (field name: `file`)") + + public ResponseEntity getPDF(@RequestPart("file") MultipartFile[] files) throws JsonProcessingException { + // map body to ValidationSchema + + List fileList = List.of(files); + if (fileList.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + try { + if (fileList.size() == 1) { + final MultipartFile file = fileList.getFirst(); + final XmlMapper mapper = new XmlMapper(); + final ValidationSchema schema = mapper.readValue(file.getBytes(), ValidationSchema.class); + final var pdf = validationService.generateDocumentation(schema).orElseThrow(InternalError::new); + return downloadUtils.download(pdf, schema.getSchemaName() + "_documentation.pdf", DownloadUtils.FileType.PDF); + } else { + final var pdfs = validationService.generateDocumentation(fileList); + return downloadUtils.downloadFilesAsZip(pdfs, "validation_schemas_documentation.zip"); + } + } catch (IOException e) { + return ResponseEntity.badRequest().build(); + } + } + + @PostMapping(path = "/create") + @Operation(summary = "Uploads a file via multipart/form-data (field name: `file`)") + public ResponseEntity validateSchema(@RequestBody ValidationSchema schema) { + String xml = validationService.generateSchemaString(schema); + return downloadUtils.download(xml.getBytes(), schema.getSchemaName() + ".xml", DownloadUtils.FileType.XML); + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaPdfGenerator.java b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaPdfGenerator.java new file mode 100644 index 0000000..5285ee9 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaPdfGenerator.java @@ -0,0 +1,173 @@ +package de.haevn.snippetmanage.xml; + +import de.haevn.snippetmanage.common.utils.pdfv2.*; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.springframework.stereotype.Service; + +import java.awt.*; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.*; + +@Service +public class ValidationSchemaPdfGenerator { + + public byte[] createDocumentationAsBytes(final ValidationSchema schema) throws IOException { + // 1. Dokument erstellen + PdfMeta meta = new PdfMeta(schema.getSchemaName(), "Auto-generated Documentation"); + CustomPdfDocument doc = new CustomPdfDocument(meta); + + // 2. Seite 1 erstellen + PdfPage titlePage = createTitlePage(schema); + doc.addPage(titlePage); + + createRules(schema, doc); + + return doc.toBytes(); + + } + + public PdfPage createTitlePage(ValidationSchema schema) { + PdfPage page = new PdfPage(); + + float tableY = 450; + float tableWidth = 400; + float startX = (PDRectangle.A4.getWidth() - tableWidth) / 2; + + // Datum Formatieren + SimpleDateFormat sdf = new SimpleDateFormat("EEEE, d. MMMM yyyy, HH:mm 'Uhr'", Locale.GERMANY); + String currentDate = sdf.format(new Date()); + + String titleText = "Dokumentation für " + schema.getReadableName(); + String subTitle = "Erstellt am: " + currentDate; + // A. Titel (Mittig, Oberes 1/3 -> Y=700) + page.addObject(new PdfText( + titleText, + 700, + 18, + Color.BLACK + )); + + // B. Datum (Unter dem Titel) + page.addObject(new PdfText( + subTitle, + 670, + 10, + Color.GRAY + )); + // C. Auflistung (Tabelle ohne Rahmen) + String[][] tableData = { + {"Schemaname:", schema.getReadableName()}, + {"XML Dateiname:", schema.getSchemaName()}, + {"ID Name (Schlüssel):", schema.getIdName()}, + {"ID-Spalte (Index):", schema.getIdColumn() + ""}, + {"Header-Erkennung:", schema.getHeaderIdentifier()}, + {"Anzahl Spalten:", schema.getTotalColumns() + ""} + }; + + // Konfiguration: Linke Spalte Links (40%), Rechte Spalte Rechts (60%) + float[] colWeights = {4, 6}; + TextAlign[] alignments = {TextAlign.LEFT, TextAlign.LEFT}; + + PdfTable infoTable = new PdfTable( + startX, + tableY, + tableWidth, + 20, // Zeilenhöhe + tableData, + colWeights, + false, // drawBorders = FALSE + alignments + ); + + page.addObject(infoTable); + + return page; + } + + public PdfPage createNotePage() { + PdfPage page = new PdfPage(); + + float pageWidth = PDRectangle.A4.getWidth(); + + String noteTitle = "Hinweise zur Validierungsdokumentation"; + String noteContent = "Diese Dokumentation wurde automatisch generiert und dient als Referenz für die Validierungen des angegebenen Schemas. " + + "Bitte beachten Sie, dass Änderungen am Schema die Validierungsregeln beeinflussen können. " + + "Für detaillierte Informationen zu den einzelnen Validierungen konsultieren Sie bitte die zugehörige technische Dokumentation oder wenden Sie sich an den Systemadministrator."; + + return page; + } + + private String getExcelColumnName(int columnIndex) { + StringBuilder columnName = new StringBuilder(); + while (columnIndex >= 0) { + int remainder = columnIndex % 26; + columnName.insert(0, (char) (remainder + 'A')); + columnIndex = (columnIndex / 26) - 1; + } + return columnName.toString(); + } + + String[][] ruleToTable(List rules) { + String[] headers = {"Feldname", "Spalte", "Excel", "Pflicht", "Regex / Auswahl", "Fehlercode", "Fehlermeldung"}; + String[][] data = new String[rules.size() + 1][headers.length]; + data[0] = headers; + int rowIndex = 1; + for (FormatRule rule : rules) { + data[rowIndex][0] = rule.getFieldName(); + data[rowIndex][1] = String.valueOf(rule.getColumn()); + data[rowIndex][2] = getExcelColumnName(rule.getColumn()); + data[rowIndex][3] = rule.isOptional() ? "Nein" : "Ja"; + String ruleContent = ""; + if (rule.getRegex() != null && !rule.getRegex().isEmpty()) { + ruleContent = rule.getRegex(); + } else if (rule.getChoice() != null && !rule.getChoice().isEmpty()) { + ruleContent = String.join(";\n", rule.getChoice().split(";")); + } else { + ruleContent = ""; + } + data[rowIndex][4] = ruleContent; + data[rowIndex][5] = rule.getErrorCode() != null ? rule.getErrorCode() : ""; + data[rowIndex][6] = rule.getErrorMessage() != null ? rule.getErrorMessage() : ""; + rowIndex++; + } + return data; + } + + public void createRules(ValidationSchema schema, CustomPdfDocument doc) { + final List rules = new ArrayList<>(); + rules.addAll(schema.getMandatory()); + rules.addAll(schema.getOptional()); + + rules.sort(Comparator.comparingInt(FormatRule::getColumn)); + + final String[][] tableData = ruleToTable(rules); + MultiPageTable rulesTable = new MultiPageTable( + tableData, + 20, + 750, + PDRectangle.A4.getWidth() - 40, + 20, + new float[]{2, 1, 1, 1, 4, 2, 3}, // Gewichte der Spalten + true, + new TextAlign[]{ + TextAlign.LEFT, + TextAlign.CENTER, + TextAlign.CENTER, + TextAlign.CENTER, + TextAlign.LEFT, + TextAlign.LEFT, + TextAlign.LEFT + } + ); + + + PdfPage page = new PdfPage(); + doc.addPage(page); + + + rulesTable.drawToDocument(doc, page); + + } +} diff --git a/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaPdfGeneratorV2.java b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaPdfGeneratorV2.java new file mode 100644 index 0000000..9350897 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationSchemaPdfGeneratorV2.java @@ -0,0 +1,227 @@ +package de.haevn.snippetmanage.xml; + + +import de.haevn.snippetmanage.common.utils.html.*; +import de.haevn.snippetmanage.common.utils.pdf.PdfDocument; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.*; + +@Component +public class ValidationSchemaPdfGeneratorV2 { + + private static final float[] COL_WIDTHS = {90, 35, 35, 40, 175, 45, 75}; + + private static final float MARGIN = 50; + + public byte[] createDocumentationAsBytes(final ValidationSchema schema) throws IOException { + try (final PdfDocument doc = createDocumentation(schema)) { + return doc.getAsBytes(); + } + } + + public PdfDocument createDocumentation(final ValidationSchema schema) throws IOException { + final PdfDocument doc = new PdfDocument(); + boolean success = false; + try { + + + + doc.addTitlePage(schema.getReadableName(), createTitlePageInfoMap(schema)); + + String fontCss = """ +// + table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + } + """; + + HtmlDoc titlePage = new HtmlDoc(); + titlePage.addElement(HtmlHeading.h1("Dokumentation für die Validierungen des Schemas", true)); + titlePage.addElement(HtmlHeading.h2("'" + schema.getReadableName() + "'", true)); + titlePage.addElement(new HtmlBR()); + titlePage.addElement(new HtmlBR()); + titlePage.addElement(new HtmlBR()); + titlePage.setCss(fontCss); + + HtmlTable introTable = new HtmlTable(2); + introTable.setDimensions(1,1); + introTable.addRow(new HtmlText("Erstellt am:"), new HtmlText(new Date().toString())); + introTable.addRow(new HtmlBR()); + introTable.addRow(new HtmlText("Schema-Name:"), new HtmlText(schema.getSchemaName() != null ? schema.getSchemaName() : "N/A")); + introTable.addRow(new HtmlText("ID-Name (Schlüssel):"), new HtmlText(schema.getIdName() != null ? schema.getIdName() : "N/A")); + final String idColumnStr = schema.getIdColumn() + + " (Excel: " + getExcelColumnName(schema.getIdColumn()) + ")"; + introTable.addRow(new HtmlText("ID-Spalte (Index):"), new HtmlText(idColumnStr)); + introTable.addRow(new HtmlText("Header-Erkennung:"), new HtmlText(schema.getHeaderIdentifier() != null ? schema.getHeaderIdentifier() : "N/A")); + String totalCols = String.valueOf(schema.getTotalColumns()); + if (totalCols.equals("-1")) totalCols = "N/A"; + introTable.addRow(new HtmlText("Gesamtspalten (erwartet):"), new HtmlText(totalCols)); + titlePage.addElement(introTable); + + + doc.addHtmlPages(titlePage); + + + + + + + HtmlDoc notePage = new HtmlDoc(); + + final HtmlList list1 = new HtmlList(false, "Hinweise Zur Validierung"); + list1.addItem("Wird keine Fehlermeldung angegeben werden diese basierend auf den Angaben dynamisch generiert dabei wird eines der folgende Muster verwendet:"); + list1.addItem("Das Feld '' ist und muss dem Muster '' entsprechend."); + list1.addItem("Das Feld '' ist und muss einen der folgenden enthalten: ."); + notePage.addElement(list1); + + final HtmlText paragraph1 = new HtmlText("Es wird entweder auf Regex oder Auswahl geprüft, nicht jedoch auf beide gleichzeitig. " + + "Hierbei ist zu beachten, dass Auswahl-Prüfungen priorisiert werden und eine Regex-Prüfung nicht durchgeführt wird."); + notePage.addElement(paragraph1); + + final HtmlList list2 = new HtmlList(false, "Das Validierungssystem arbeitet wie folgt:"); + list2.addItem("Prüfen ob der Wert in der Zelle vorhanden ist, wenn nein wird der status optional zurück gegeben."); + list2.addItem("Wenn eine Auswahl angegeben ist, wird geprüft ob der Wert in der Auswahl enthalten ist und der entsprechende Status zurückgegeben."); + list2.addItem("Wenn eine Regex angegeben ist, wird geprüft ob der Wert dem Regex entspricht und der entsprechende Status zurückgegeben."); + list2.addItem("Wenn weder eine Auswahl noch ein Regex angegeben ist, wird der Status erfolgreich zurückgegeben."); + notePage.addElement(list2); + + final HtmlList list3 = new HtmlList(false, "Hinweis zu Auswahl-Werten:"); + list3.addItem("Mehrere erlaubte Werte sind durch Semikolons zu trennen."); + list3.addItem("Leerzeichen um die Werte werden ignoriert."); + list3.addItem("Die Groß-/Kleinschreibung wird berücksichtigt."); + notePage.addElement(list3); + + String css = """ + + table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + } + + th, td { + border: 1px solid #ddd; + padding: 8px; + vertical-align: top; + } + + th { + background-color: #f2f2f2; + text-align: left; + } + + tr:nth-child(even) { + background-color: #f9f9f9; + } + + ul, ol { + margin-top: 10px; + margin-bottom: 10px; + } + """; + + notePage.setCss(css); + doc.addHtmlPages(notePage); + + HtmlDoc rulePage = new HtmlDoc(); + + rulePage.setCss(fontCss); + HtmlTable htmlTable = createRuleTableNew(schema); + rulePage.setCss(css); + rulePage.addElement(htmlTable); + doc.addHtmlPages(rulePage); + + success = true; + return doc; + } finally { + if (!success) { + doc.close(); + } + } + } + + HtmlTable createRuleTableNew(final ValidationSchema schema) { + HtmlTable htmlTable = new HtmlTable(Arrays.asList( + "Feldname", "Spalte", "Excel", "Pflicht", + "Regex / Erlaubte Werte", "Fehler-Code", "Fehler-Meldung" + )); + htmlTable.setDimensions( 2, 1, 1, 1, 3, 1, 3); + final List allRules = new ArrayList<>(); + schema.getMandatory().forEach(rule -> { + rule.setOptional(false); // Sicherstellen, dass der Status korrekt ist + allRules.add(rule); + }); + schema.getOptional().forEach(rule -> { + rule.setOptional(true); // Sicherstellen, dass der Status korrekt ist + allRules.add(rule); + }); + allRules.sort(Comparator.comparing(FormatRule::getColumn)); + + for (FormatRule rule : allRules) { + String regex = rule.getRegex() != null && !rule.getRegex().isEmpty() ? rule.getRegex() : null; + String choice = rule.getChoice() != null && !rule.getChoice().isEmpty() ? rule.getChoice() : null; + String ruleContent = ""; + if (regex != null) { + ruleContent = regex; + } else if (choice != null) { + ruleContent = String.join(";\n", choice.split(";")); + } + + htmlTable.addRow( + new HtmlText(rule.getFieldName()), + new HtmlText(String.valueOf(rule.getColumn())), + new HtmlText(getExcelColumnName(rule.getColumn())), + new HtmlText(rule.isOptional() ? "Nein" : "Ja"), + new HtmlCode(ruleContent), + new HtmlCode(rule.getErrorCode() != null ? rule.getErrorCode() : ""), + new HtmlText(rule.getErrorMessage() != null ? rule.getErrorMessage() : "") + ); + } + + + htmlTable.setAllColumnsWrapping(true); + return htmlTable; + } + + /** + * Erstellt die Daten-Map für die Titelseite. + */ + private Map createTitlePageInfoMap(final ValidationSchema schema) { + final Map info = new LinkedHashMap<>(); + info.put("Schema-Name:", schema.getSchemaName() != null ? schema.getSchemaName() : "N/A"); + info.put("ID-Name (Schlüssel):", schema.getIdName() != null ? schema.getIdName() : "N/A"); + + final String idColumnStr = schema.getIdColumn() + + " (Excel: " + getExcelColumnName(schema.getIdColumn()) + ")"; + info.put("ID-Spalte (Index):", idColumnStr); + + info.put("Header-Erkennung:", schema.getHeaderIdentifier() != null ? schema.getHeaderIdentifier() : "N/A"); + + String totalCols = String.valueOf(schema.getTotalColumns()); + if (totalCols.equals("-1")) totalCols = "N/A"; + info.put("Gesamtspalten (erwartet):", totalCols); + + return info; + } + + + /** + * Konvertiert einen 0-basierten Spaltenindex in einen Excel-Spaltennamen (A, B, ..., Z, AA, ...). + */ + private String getExcelColumnName(int column) { + if (column < 0) return ""; + + final StringBuilder sb = new StringBuilder(); + while (column >= 0) { + int remainder = column % 26; + sb.append((char) (remainder + 'A')); + column = (column / 26) - 1; + if (column < -1) break; // Korrektur für 0-basiert + } + return sb.reverse().toString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationService.java b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationService.java new file mode 100644 index 0000000..9cfdac4 --- /dev/null +++ b/backend/src/main/java/de/haevn/snippetmanage/xml/ValidationService.java @@ -0,0 +1,102 @@ +package de.haevn.snippetmanage.xml; + +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import de.haevn.snippetmanage.common.exception.BadRequestException; +import de.haevn.snippetmanage.common.exception.InternalServerErrorException; +import de.haevn.snippetmanage.common.utils.ZipUtils; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import jakarta.xml.bind.Marshaller; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +public class ValidationService { + + private final ValidationSchemaPdfGeneratorV2 generator; + private final ValidationSchemaPdfGenerator generatorNew; + + public ValidationService(ValidationSchemaPdfGeneratorV2 generatorV2, ValidationSchemaPdfGenerator generatorNew) { + this.generator = generatorV2; + this.generatorNew = generatorNew; + } + + public static String marshalToString(Object obj) throws JAXBException { + if (obj == null) return ""; + JAXBContext ctx = JAXBContext.newInstance(obj.getClass()); + Marshaller marshaller = ctx.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + StringWriter sw = new StringWriter(); + marshaller.marshal(obj, sw); + return sw.toString(); + } + + + public List generateDocumentation(List files) { + List zipFiles = new ArrayList<>(); + final List failedFiles = new ArrayList<>(); + final XmlMapper mapper = new XmlMapper(); + for (MultipartFile file : files) { + try { + final ValidationSchema schema = mapper.readValue(file.getBytes(), ValidationSchema.class); + final byte[] data = generatorNew.createDocumentationAsBytes(schema); + zipFiles.add(new ZipUtils.ZipFile(data, schema.getSchemaName() + "_documentation.pdf")); + } catch (IOException e) { + failedFiles.add(file.getOriginalFilename()); + } + } + + if(!failedFiles.isEmpty()){ + // java + final long count = failedFiles.size(); + final long total = files.size(); + final long success = total - count; + final long failure = count; + final long successRate = (total == 0) ? 0 : (success * 100) / total; + final long failureRate = (total == 0) ? 0 : (failure * 100) / total; + final long timestamp = System.currentTimeMillis(); + final String serverTimeUtc = java.time.Instant.ofEpochMilli(timestamp) + .atZone(java.time.ZoneOffset.UTC) + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss 'UTC'")); + + StringBuilder sb = new StringBuilder(); + sb.append("The Server encountered one or multiple error during the process.").append("\n") + .append("Server time: ").append(serverTimeUtc).append("\n") + .append("Timestamp: ").append(timestamp).append("\n") + .append("Total files: ").append(total).append("\n") + .append("Successful: ").append(success).append(" (").append(successRate).append("%)").append("\n") + .append("Failed: ").append(failure).append(" (").append(failureRate).append("%)").append("\n\n") + .append("This could be due to invalid XML format or other errors, please verify the following files:\n\n") + .append(String.join("\n", failedFiles)); + + String data = sb.toString(); + zipFiles.add(new ZipUtils.ZipFile(data.getBytes(), "failed_files.txt")); + } + + return zipFiles; + } + + public Optional generateDocumentation(ValidationSchema schema) { + try { + byte[] data = generatorNew.createDocumentationAsBytes(schema); + return Optional.of(data); + } catch (IOException e) { + throw new InternalServerErrorException(e); + } + + } + + public String generateSchemaString(ValidationSchema schema) { + try { + return marshalToString(schema); + } catch (JAXBException e) { + throw new BadRequestException(e.getMessage()); + } + } +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml new file mode 100644 index 0000000..c60ca27 --- /dev/null +++ b/backend/src/main/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + config: + import: + - "optional:file:/data/config/secure.yaml" + - "file:/data/config/application.yaml" diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql new file mode 100644 index 0000000..0f3fd25 --- /dev/null +++ b/backend/src/main/resources/data.sql @@ -0,0 +1,3 @@ +INSERT INTO users (first_name, last_name, email, password, motto, img_id, dark_mode, username, theme) +SELECT 'user','user','user@example.com','password123','', 'default2', false, 'user','light' +WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'user@example.com'); diff --git a/backend/src/main/resources/img/default.png b/backend/src/main/resources/img/default.png new file mode 100644 index 0000000..afcbdbe Binary files /dev/null and b/backend/src/main/resources/img/default.png differ diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..176a840 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,75 @@ +services: + nginx: + build: + context: ./nginx + dockerfile: dev.Dockerfile + ports: + - "81:80" + - "444:443" + - "8081:8081" # for nginx status + - "8443:8443" # for nginx status over TLS + volumes: + - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - frontend + networks: + - work_tool_network + + backend: + build: + context: ./backend + dockerfile: dev.Dockerfile + ports: + - "8080:8080" + - "9876:9876" + volumes: + - ./data:/data:rw + - ./backend/src:/app/src:rw + - ./.trigger-file:/app/.trigger-file + depends_on: + - mongo + - postgres + networks: + - work_tool_network + + frontend: + build: + context: ./frontend + dockerfile: dev.Dockerfile + ports: + - "3000:3000" + volumes: + - ./frontend:/app # bind mount source so edits are visible in container + - /app/node_modules # avoid overwriting container node_modules on Windows + environment: + - CHOKIDAR_USEPOLLING=true # helps file watch on Docker for Windows / WSL2 + - CHOKIDAR_INTERVAL=100 + restart: unless-stopped + networks: + - work_tool_network + + mongo: + image: mongo:latest + restart: unless-stopped + volumes: + - ./data/db/mongo:/data/db:rw + - ./data/db/config/mongo:/data/configdb:rw + networks: + - work_tool_network + + postgres: + image: postgres:latest + restart: unless-stopped + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: dev_board + volumes: + - ./data/db/postgres:/var/lib/postgresql:rw + networks: + - work_tool_network + +networks: + work_tool_network: + driver: bridge diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f715906 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,62 @@ +services: + nginx: + build: + context: ./nginx + dockerfile: Dockerfile + ports: + - "81:80" + - "444:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - frontend + networks: + - work_tool_network + + backend: + build: + context: ./backend + dockerfile: dev.Dockerfile + volumes: + - ./data:/data:rw + - ./backend/src:/app/src:rw + - ./.trigger-file:/app/.trigger-file + depends_on: + - mongo + - postgres + networks: + - work_tool_network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + restart: unless-stopped + networks: + - work_tool_network + + mongo: + image: mongo:latest + restart: unless-stopped + volumes: + - ./data/db/mongo:/data/db:rw + - ./data/db/config/mongo:/data/configdb:rw + networks: + - work_tool_network + + postgres: + image: postgres:latest + restart: unless-stopped + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: dev_board + volumes: + - ./data/db/postgres:/var/lib/postgresql:rw + networks: + - work_tool_network + +networks: + work_tool_network: + driver: bridge \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..475c70b --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +npm-debug.log +dist \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..ccb45f9 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +data/* \ No newline at end of file diff --git a/frontend/App.tsx b/frontend/App.tsx new file mode 100644 index 0000000..9804d15 --- /dev/null +++ b/frontend/App.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import {HashRouter, Navigate, Route, Routes} from 'react-router-dom'; +import {ThemeProvider} from './contexts/ThemeContext'; +import {ToastProvider} from './contexts/ToastContext'; +import SharePage from "./pages/SharePage.tsx"; +import Layout from './components/Layout'; +import {UserProvider} from './contexts/UserContext'; +import {BackendCheckProvider} from "./contexts/BackendCheck.tsx"; +import SignupPage from "./pages/SignupPage.tsx"; +import uiElements from './components/UiElements'; + +function App() { + return ( + + + + + }/> + }/> + + + + + + }/> + {uiElements.map(u => ( + + + + ))} + + + + + + }/> + + + + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/App.tsx.bck b/frontend/App.tsx.bck new file mode 100644 index 0000000..af4cf78 --- /dev/null +++ b/frontend/App.tsx.bck @@ -0,0 +1,53 @@ +import React from 'react'; +import {HashRouter, Navigate, Route, Routes} from 'react-router-dom'; +import {ThemeProvider} from './contexts/ThemeContext'; +import {ToastProvider} from './contexts/ToastContext'; +import Layout from './components/Layout'; +import Home from './pages/Home'; +import Snippets from './pages/Snippets'; +import Notes from './pages/Notes'; +import WeeklyMeeting from './pages/WeeklyMeeting'; +import ElsErstellung from './pages/ElsErstellung'; +import Settings from './pages/Settings'; +import Ticketsystem from './pages/Ticketsystem'; +import DatabaseSearch from './pages/DatabaseSearch'; +import SharePage from './pages/SharePage'; +import FileUploadPage from "@/pages/FileUploadPage.tsx"; +import {UserProvider} from "@/contexts/UserContext.tsx"; +import RulePage from "@/pages/RulePage.tsx"; +import {BackendCheckProvider} from "@/contexts/BackendCheck.tsx"; +import FacilityOverviewPage from "@/wmt/pages/FacilityOverviewPage.tsx"; +import SMJobPage from "@/wmt/pages/SMJobPage.tsx"; + +function App() { + return ( + + + + + + + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + + + + + + + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5ca3491 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +# Use official Node.js image for build +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . +RUN npm run build + +# Use nginx to serve the built app +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..6524f8a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,19 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1VD6D2RJQQ_17tE7ICaYYt2depqbVa-cD + +## Run Locally + +**Prerequisites:** Node.js + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/frontend/components/Bubble.tsx b/frontend/components/Bubble.tsx new file mode 100644 index 0000000..b1ac4ba --- /dev/null +++ b/frontend/components/Bubble.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import {X} from 'lucide-react'; + +interface BubbleProps { + text: string; + onRemove?: () => void; + readOnly?: boolean; +} + +const Bubble: React.FC = ({text, onRemove, readOnly}) => { + return ( +
+ {text} + {!readOnly && onRemove && ( + + )} +
+ ); +}; + +export default Bubble; diff --git a/frontend/components/BubbleContainer.tsx b/frontend/components/BubbleContainer.tsx new file mode 100644 index 0000000..713f937 --- /dev/null +++ b/frontend/components/BubbleContainer.tsx @@ -0,0 +1,68 @@ +import React, {useState} from 'react'; +import Bubble from './Bubble'; +import {Plus} from 'lucide-react'; + +interface BubbleContainerProps { + selectedItems: string[]; + possibleItems: string[]; + onAddItem: (item: string) => void; + onRemoveItem: (item: string) => void; + readOnly?: boolean; +} + +const BubbleContainer: React.FC = ({ + selectedItems, + possibleItems, + onAddItem, + onRemoveItem, + readOnly = false, + }) => { + const [showDropdown, setShowDropdown] = useState(false); + const availableItems = possibleItems.filter(item => !selectedItems.includes(item)); + + const handleSelect = (item: string) => { + onAddItem(item); + setShowDropdown(false); + }; + + return ( +
+ {selectedItems.map(item => ( + onRemoveItem(item)} readOnly={readOnly}/> + ))} + + {!readOnly && availableItems.length > 0 && ( +
+ + {showDropdown && ( +
+
    + {availableItems.map(item => ( +
  • handleSelect(item)} + className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" + > + {item} +
  • + ))} +
+
+ )} +
+ )} +
+ ); +}; + +export default BubbleContainer; diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx new file mode 100644 index 0000000..8a8892f --- /dev/null +++ b/frontend/components/Header.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {Box, Menu, Search} from 'lucide-react'; + +interface HeaderProps { + onToggleSidebar: () => void; +} + +const Header: React.FC = ({onToggleSidebar}) => { + return ( +
+
+ +
+ +

OpsDash

+
+
+ +
+ {/* Search Bar */} +
+ + +
+
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/frontend/components/Layout.tsx b/frontend/components/Layout.tsx new file mode 100644 index 0000000..995fcdb --- /dev/null +++ b/frontend/components/Layout.tsx @@ -0,0 +1,65 @@ +import React, {ReactNode, useEffect, useState} from 'react'; +import Sidebar from './Sidebar'; +import Header from './Header'; +import ProfileModal from './user/ProfileModal.tsx'; +import TicTacToe from './TicTacToe'; +import {User} from '../types'; +import {userService} from '../services'; +import {useUser} from "../contexts/UserContext.tsx"; + +interface LayoutProps { + children: ReactNode; +} + +const Layout: React.FC = ({children}) => { + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isProfileModalOpen, setProfileModalOpen] = useState(false); + + const [versionClicks, setVersionClicks] = useState(0); + const [showTicTacToe, setShowTicTacToe] = useState(false); + const { user } = useUser(); + + const toggleSidebar = () => { + setIsSidebarOpen(prev => !prev); + }; + + const handleProfileClick = () => { + setProfileModalOpen(true); + }; + + const handleVersionClick = () => { + const newClickCount = versionClicks + 1; + if (newClickCount >= 10) { + setShowTicTacToe(true); + setVersionClicks(0); + } else { + setVersionClicks(newClickCount); + } + }; + + return ( +
+ +
+
+
+ {children} +
+
+ setProfileModalOpen(false)} + /> + setShowTicTacToe(false)} + /> +
+ ); +}; + +export default Layout; diff --git a/frontend/components/RuleGeneration.tsx b/frontend/components/RuleGeneration.tsx new file mode 100644 index 0000000..ad86fe1 --- /dev/null +++ b/frontend/components/RuleGeneration.tsx @@ -0,0 +1,244 @@ +// typescript +import React, { useState } from "react"; +import { FormatRule, ValidationSchema } from "../types.ts"; +import { ruleService } from "../services/RuleService.ts"; +import { downloadUtils } from "../utils/DownloadUtils.ts"; +import RuleModal from "./RuleModal.tsx"; + +export default function RuleGeneration(): React.ReactElement { + const [schema, setSchema] = useState({ + readableName: "", + schemaName: "", + headerIdentifier: "", + headerIdentifierColumn: 0, + idColumn: 0, + idName: "", + totalColumns: undefined, + mandatory: [], + optional: [], + }); + + // editing state + const [editingIndex, setEditingIndex] = useState(null); + const [editingOriginalTarget, setEditingOriginalTarget] = useState<"mandatory" | "optional" | null>(null); + + // modal state + const [isModalOpen, setIsModalOpen] = useState(false); + + function updateSchema(key: K, value: ValidationSchema[K]) { + setSchema(prev => ({ ...prev, [key]: value })); + } + + function startEdit(target: "mandatory" | "optional", index: number) { + setEditingIndex(index); + setEditingOriginalTarget(target); + setIsModalOpen(true); + } + + function openAddModal() { + setEditingIndex(null); + setEditingOriginalTarget(null); + setIsModalOpen(true); + } + + function closeModal() { + setIsModalOpen(false); + setEditingIndex(null); + setEditingOriginalTarget(null); + } + + function handleSave(rule: FormatRule, target: "mandatory" | "optional") { + // if editing, remove original and append to target (supports moving between lists) + if (editingIndex !== null && editingOriginalTarget !== null) { + setSchema(prev => { + const mandatory = [...prev.mandatory]; + const optional = [...prev.optional]; + + if (editingOriginalTarget === "mandatory") { + mandatory.splice(editingIndex, 1); + } else { + optional.splice(editingIndex, 1); + } + + if (target === "mandatory") mandatory.push(rule); + else optional.push(rule); + + return { ...prev, mandatory, optional }; + }); + closeModal(); + return; + } + + // normal add + setSchema(prev => ({ + ...prev, + [target]: [...prev[target], rule], + })); + closeModal(); + } + + function removeRule(target: "mandatory" | "optional", index: number) { + // if removing currently edited rule, close modal / clear editing state + if (editingIndex === index && editingOriginalTarget === target) { + closeModal(); + } + setSchema(prev => { + const arr = [...prev[target]]; + arr.splice(index, 1); + return { ...prev, [target]: arr }; + }); + } + + async function submitSchema() { + try { + console.log("Submitting schema", schema); + const resp = await ruleService.createValidationSchema(schema); + downloadUtils.download(resp); + } catch (err) { + console.error(err); + } + } + + // provide initialRule to modal when editing + const initialRule = (editingIndex !== null && editingOriginalTarget !== null) + ? schema[editingOriginalTarget][editingIndex] + : undefined; + + const initialTarget = (editingOriginalTarget ?? "mandatory"); + + return ( +
+

Rule Generation

+ +
+ {/* Left: metadata + open modal */} +
+ + updateSchema("readableName", e.target.value)} + className="w-full px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> + + + updateSchema("schemaName", e.target.value)} + className="w-full px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> + + + updateSchema("headerIdentifier", e.target.value)} + className="w-full px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> + + + updateSchema("headerIdentifierColumn", Number(e.target.value))} + className="w-full px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> + + + updateSchema("idName", e.target.value)} + className="w-full px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> + + + updateSchema("totalColumns", e.target.value === "" ? undefined : Number(e.target.value))} + className="w-full px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> + +
+

Rules

+ + +
+ +
+
+
+ + {/* Right: two lists */} +
+
+

Mandatory

+ {schema.mandatory.length === 0 ? ( +
None
+ ) : ( +
    + {schema.mandatory.map((r, i) => ( +
  • +
    +
    {r.fieldName}
    +
    {r.description}
    +
    col: {r.column}
    + {r.regex &&
    regex: {r.regex}
    } + {r.choice &&
    choice: {r.choice}
    } + {r.errorCode &&
    code: {r.errorCode}
    } + {r.errorMessage &&
    {r.errorMessage}
    } +
    +
    + + +
    +
  • + ))} +
+ )} +
+ +
+

Optional

+ {schema.optional.length === 0 ? ( +
None
+ ) : ( +
    + {schema.optional.map((r, i) => ( +
  • +
    +
    {r.fieldName}
    +
    {r.description}
    +
    col: {r.column}
    + {r.regex &&
    regex: {r.regex}
    } + {r.choice &&
    choice: {r.choice}
    } + {r.errorCode &&
    code: {r.errorCode}
    } + {r.errorMessage &&
    {r.errorMessage}
    } +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/components/RuleModal.tsx b/frontend/components/RuleModal.tsx new file mode 100644 index 0000000..c4752ea --- /dev/null +++ b/frontend/components/RuleModal.tsx @@ -0,0 +1,279 @@ +// TypeScript +// frontend/components/RuleModal.tsx +import React, { useEffect, useRef, useState } from "react"; +import { FormatRule } from "../types.ts"; +import { Save, X } from "lucide-react"; + +type Target = "mandatory" | "optional"; + +type Props = { + isOpen: boolean; + initialRule?: FormatRule | null; + initialTarget?: Target; + onClose: () => void; + onSave: (rule: FormatRule, target: Target) => void; +}; + +export default function RuleModal({ isOpen, initialRule, initialTarget = "mandatory", onClose, onSave }: Props): React.ReactElement | null { + const [fieldName, setFieldName] = useState(""); + const [description, setDescription] = useState(""); + const [column, setColumn] = useState(undefined); + const [target, setTarget] = useState(initialTarget); + const [regex, setRegex] = useState(""); + const [choice, setChoice] = useState(""); + const [errorCode, setErrorCode] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + + // touched state for inline validation + const [touchedField, setTouchedField] = useState(false); + const [touchedColumn, setTouchedColumn] = useState(false); + + const overlayRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + if (initialRule) { + setFieldName(initialRule.fieldName ?? ""); + setDescription(initialRule.description ?? ""); + setColumn(initialRule.column === -1 ? undefined : initialRule.column); + setRegex(initialRule.regex ?? ""); + setChoice(initialRule.choice ?? ""); + setErrorCode(initialRule.errorCode ?? ""); + setErrorMessage(initialRule.errorMessage ?? ""); + setTarget(initialRule.optional ? "optional" : initialTarget); + } else { + clearForm(); + setTarget(initialTarget); + } + // reset touched state when opening + setTouchedField(false); + setTouchedColumn(false); + }, [isOpen, initialRule, initialTarget]); + + // close on Escape + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + clearForm(); + onClose(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [isOpen, onClose]); + + function clearForm() { + setFieldName(""); + setDescription(""); + setColumn(undefined); + setRegex(""); + setChoice(""); + setErrorCode(""); + setErrorMessage(""); + setTouchedField(false); + setTouchedColumn(false); + } + + const isFieldValid = fieldName.trim().length > 0; + const isColumnValid = column !== undefined && !Number.isNaN(column); + const isSaveDisabled = !isFieldValid || !isColumnValid; + + function handleSave() { + // mark touched so errors display + setTouchedField(true); + setTouchedColumn(true); + + if (!isFieldValid || !isColumnValid) { + return; + } + + const rule: FormatRule = { + fieldName: fieldName.trim(), + description: description, + column: column ?? -1, + optional: target === "optional", + regex: regex || undefined, + choice: choice || undefined, + errorCode: errorCode || undefined, + errorMessage: errorMessage || undefined, + identifierColumn: 0, + }; + + onSave(rule, target); + clearForm(); + onClose(); + } + + function handleCancel() { + clearForm(); + onClose(); + } + + function handleKeyActivate(e: React.KeyboardEvent, action: () => void, disabled?: boolean) { + if (disabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + action(); + } + } + + function handleBackdropMouseDown(e: React.MouseEvent) { + // close when clicking directly on the backdrop (outside the modal) + if (e.target === overlayRef.current) { + clearForm(); + onClose(); + } + } + + if (!isOpen) return null; + + return ( +
+
+

{initialRule ? "Edit Rule" : "Add Rule"}

+ +
+ + +
+ +
+ + setFieldName(e.target.value)} + onBlur={() => setTouchedField(true)} + aria-required + aria-invalid={!isFieldValid && touchedField} + className={`flex-1 px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm ${!isFieldValid && touchedField ? "border-red-600" : ""}`} + /> +
+ {(!isFieldValid && touchedField) && ( +
Field is required.
+ )} + +
+ + setDescription(e.target.value)} + className="flex-1 px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> +
+ +
+ + setColumn(e.target.value === "" ? undefined : Number(e.target.value))} + onBlur={() => setTouchedColumn(true)} + aria-required + aria-invalid={!isColumnValid && touchedColumn} + className={`flex-1 px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm ${!isColumnValid && touchedColumn ? "border-red-600" : ""}`} + /> +
+ {(!isColumnValid && touchedColumn) && ( +
Column is required and must be a number.
+ )} + +
+ + setRegex(e.target.value)} + className="flex-1 px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> +
+ +
+ + setChoice(e.target.value)} + className="flex-1 px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> +
+ +
+ + setErrorCode(e.target.value)} + className="flex-1 px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> +
+ +
+ + setErrorMessage(e.target.value)} + className="flex-1 px-2 py-1 rounded border bg-white dark:bg-gray-800 text-sm" + /> +
+ +
+ handleKeyActivate(e, handleCancel)} + className="p-2 rounded border border-red-600 bg-red-600 text-white text-sm flex items-center justify-center cursor-pointer" + aria-label="Cancel" + > + + + { if (!isSaveDisabled) handleSave(); }} + onKeyDown={(e) => handleKeyActivate(e, handleSave, isSaveDisabled)} + className={`p-2 rounded text-sm flex items-center justify-center ${isSaveDisabled ? "bg-green-600 opacity-50 pointer-events-none" : "bg-green-600 cursor-pointer"}`} + aria-label="Save" + aria-disabled={isSaveDisabled} + > + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/SearchResultItem.tsx b/frontend/components/SearchResultItem.tsx new file mode 100644 index 0000000..298fa50 --- /dev/null +++ b/frontend/components/SearchResultItem.tsx @@ -0,0 +1,103 @@ +import React, {useState} from 'react'; +import {SearchResult} from '../types'; +import {Check, Clock, Copy} from 'lucide-react'; +import {useToast} from '../hooks/useToast'; + +interface SearchResultItemProps { + result: SearchResult; +} + +const typeColors: { [key in SearchResult['type']]: string } = { + 'User Data': 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300', + 'Transaction Logs': 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300', + 'System Events': 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300', +}; + +const SearchResultItem: React.FC = ({result}) => { + const {showToast} = useToast(); + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(result.token).then(() => { + showToast('Token copied to clipboard!', 'success'); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }).catch(err => { + showToast('Failed to copy token.', 'error'); + console.error('Copy failed:', err); + }); + }; + + const formatDateTime = (isoString: string) => { + return new Date(isoString).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); + }; + + return ( +
+
+

+ Token: {result.token} +

+ + {result.type} + +
+

+ {result.description} +

+
+
+

ID:

+
+ {result.id} + +
+
+
+

Token:

+
+ {result.token} + +
+
+
+

Description:

+
+ {result.description} + +
+
+
+ + {formatDateTime(result.timestamp)} +
+
+
+ ); +}; + +export default SearchResultItem; diff --git a/frontend/components/ServiceError.tsx b/frontend/components/ServiceError.tsx new file mode 100644 index 0000000..21a0681 --- /dev/null +++ b/frontend/components/ServiceError.tsx @@ -0,0 +1,134 @@ +// frontend/components/ServiceError.tsx +import React, {useEffect, useRef, useState} from 'react'; +import {useBackendCheck} from '../contexts/BackendCheck'; +import {PlugZap, Search, CloudOff, Snail, ShieldX, Fingerprint} from 'lucide-react'; +import { + BACKEND_UNAVAILABLE_EVENT, + TO_MANY_REQUESTS_EVENT, + NOT_FOUND_ERROR_EVENT, + SERVER_ERROR_EVENT, + AUTHORIZATION_ERROR_EVENT, + BACKEND_OK_EVENT +} from "../services/IService.ts"; + +interface ServiceErrorProps { + errorType?: string; +} + +const messages: Record = { + [BACKEND_UNAVAILABLE_EVENT]: { + icon: , + title: 'Service not available', + description: 'The backend service is currently unavailable. Try reconnecting to re-establish the connection.', + }, + [TO_MANY_REQUESTS_EVENT]: { + icon: , + title: 'Too many requests', + description: 'You or the server are sending too many requests. Please wait a moment and try again.', + }, + [NOT_FOUND_ERROR_EVENT]: { + icon: , + title: 'Resource not found', + description: 'The requested resource could not be found on the server.', + }, + [SERVER_ERROR_EVENT]: { + icon: , + title: 'Unexpected error', + description: 'An unexpected error occurred. Try reconnecting or contact support if the issue persists.', + }, + [AUTHORIZATION_ERROR_EVENT]: { + icon: , + title: 'Authorization error', + description: 'There was an authorization error. Please check your credentials or contact support.', + }, + [BACKEND_OK_EVENT]: { + icon: , + title: 'No error', + description: 'There is no error to display.', + } +}; + +const ServiceError: React.FC = ({errorType = BACKEND_UNAVAILABLE_EVENT}) => { + const {checkBackend} = useBackendCheck(); + const fallback = messages[BACKEND_UNAVAILABLE_EVENT]; + const {icon, title, description} = messages[errorType] ?? fallback; + + const [disabled, setDisabled] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const handleReconnect = () => { + if (disabled) return; + + setDisabled(true); + setIsLoading(true); + + const maybePromise = checkBackend?.(); + + if (maybePromise && typeof (maybePromise as Promise).then === 'function') { + (maybePromise as Promise).finally(() => { + setIsLoading(false); + }); + } else { + setIsLoading(false); + } + + timeoutRef.current = window.setTimeout(() => { + setDisabled(false); + timeoutRef.current = null; + }, 3000); + }; + + return ( +
+
+ {icon} +
+ +

{title}

+

+ {description} +

+ + +
+ ); +}; + +export default ServiceError; \ No newline at end of file diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx new file mode 100644 index 0000000..83c9d96 --- /dev/null +++ b/frontend/components/Sidebar.tsx @@ -0,0 +1,153 @@ +// File: frontend/components/Sidebar.tsx +import React, {useState} from 'react'; +import {NavLink} from 'react-router-dom'; +import {Droplets, LogOut, Moon, Sparkles, Sun} from 'lucide-react'; +import {useTheme} from '../hooks/useTheme'; +import {Theme} from '../types'; +import {useUser} from "../contexts/UserContext.tsx"; +import {userService} from "../services"; +import {navItems} from './UiElements'; + +// navItems are now provided by frontend/components/UiElements.tsx + +interface SidebarProps { + isOpen: boolean; + onProfileClick: () => void; + onVersionClick: () => void; +} + +const Sidebar: React.FC = ({isOpen, onProfileClick, onVersionClick}) => { + const {theme, changeTheme} = useTheme(); + const [isThemeMenuOpen, setIsThemeMenuOpen] = useState(false); + const {user, logout} = useUser(); + + const themeOptions = [ + {name: 'light', icon: Sun, label: 'Light'}, + {name: 'dark', icon: Moon, label: 'Dark'}, + {name: 'colorful', icon: Sparkles, label: 'Colorful'}, + {name: 'ocean', icon: Droplets, label: 'Ocean'}, + ]; + + const CurrentThemeIcon = themeOptions.find(t => t.name === theme)?.icon || Sun; + + const handleLogout = async () => { + await logout(); + }; + + const setNewTheme = async (newTheme: Theme) => { + changeTheme(newTheme); + userService.updateTheme(user.email, newTheme); + setIsThemeMenuOpen(false); + } + + return ( + + ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/frontend/components/TextAreaWithToolbar.tsx b/frontend/components/TextAreaWithToolbar.tsx new file mode 100644 index 0000000..0c17d9b --- /dev/null +++ b/frontend/components/TextAreaWithToolbar.tsx @@ -0,0 +1,140 @@ +import React, {useRef} from 'react'; +import sanitizeHtml from 'sanitize-html'; +import { marked } from 'marked'; +import {Bold, Italic, List, ListOrdered} from 'lucide-react'; + +interface TextAreaWithToolbarProps { + id: string; + value: string; + onChange: (value: string) => void; + readOnly?: boolean; + rows?: number; + placeholder?: string; +} + +const EditorToolbarButton: React.FC<{ + onClick: () => void; + children: React.ReactNode; + 'aria-label': string +}> = (props) => ( + +); + +const TextAreaWithToolbar: React.FC = ({id, value, onChange, readOnly, ...props}) => { + const textareaRef = useRef(null); + + // Render full CommonMark when readOnly; use `marked` to parse and `sanitize-html` to sanitize output. + const renderFormatted = (text: string) => { + if (!text) return ''; + try { + const raw = marked.parse(text); + const clean = sanitizeHtml(raw, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code']), + allowedAttributes: { + a: ['href', 'name', 'target', 'rel'], + img: ['src', 'alt', 'title'], + '*': ['class'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'data'], + }); + return clean; + } catch (e) { + // fallback to escaped text + return sanitizeHtml(escapeHtml(text)); + } + }; + + const escapeHtml = (unsafe: string) => { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + + const applyFormat = (format: 'bold' | 'italic' | 'ul' | 'ol') => { + const textarea = textareaRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = value.substring(start, end); + let newText; + + switch (format) { + case 'bold': + newText = `**${selectedText}**`; + break; + case 'italic': + newText = `*${selectedText}*`; + break; + case 'ul': + newText = selectedText.split('\n').map(line => `- ${line}`).join('\n'); + break; + case 'ol': + newText = selectedText.split('\n').map((line, index) => `${index + 1}. ${line}`).join('\n'); + break; + default: + newText = selectedText; + } + + onChange(value.substring(0, start) + newText + value.substring(end)); + + // Focus and adjust cursor position after formatting + textarea.focus(); + setTimeout(() => { + if (format === 'bold' || format === 'italic') { + textarea.setSelectionRange(start + 2, end + 2); + } else { + textarea.setSelectionRange(start, start + newText.length); + } + }, 0); + }; + + const commonClasses = 'mt-1 block w-full px-3 py-2 border rounded-md shadow-sm sm:text-sm resize-y'; + const readOnlyClasses = 'bg-gray-100 dark:bg-gray-700/50 border-gray-200 dark:border-gray-700'; + const editableClasses = 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 focus:ring-primary focus:border-primary'; + + return ( +
+ {!readOnly && ( +
+ applyFormat('bold')} aria-label="Bold"> + applyFormat('italic')} aria-label="Italic"> + applyFormat('ul')} aria-label="Unordered List"> + applyFormat('ol')} aria-label="Ordered List"> +
+ )} + {readOnly ? ( +
+ ) : ( +