diff --git a/.github/workflows/mavenCi.yml b/.github/workflows/mavenCi.yml index 88e6ec69..e3d7aace 100644 --- a/.github/workflows/mavenCi.yml +++ b/.github/workflows/mavenCi.yml @@ -15,24 +15,23 @@ on: jobs: build-analyze: - runs-on: ubuntu-latest env: - version: 1.3.${{ github.run_number }} + version: 2.0.${{ github.run_number }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up JDK 11 - uses: actions/setup-java@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'corretto' cache: maven - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: 'java' @@ -40,7 +39,7 @@ jobs: run: mvn -V -B clean package org.jacoco:jacoco-maven-plugin:0.8.7:prepare-agent org.jacoco:jacoco-maven-plugin:0.8.7:report -Pcoverage -Dproject.version=${{ env.version }}-SNAPSHOT - name: Upload Build Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: KeepTime-${{ env.version }} path: /home/runner/work/KeepTime/KeepTime/target/*-bin.zip @@ -49,29 +48,41 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -V -B sonar:sonar - -Dsonar.host.url=${{ secrets.HOST_URL }} - -Dsonar.organization=${{ secrets.ORGANIZATION_NAME }} - -Dsonar.projectKey=${{ secrets.PROJECT_KEY }} - -Dsonar.java.binaries=. - -Dsonar.qualitygate.wait=false + run: mvn -V -B sonar:sonar -Dsonar.host.url=${{ secrets.HOST_URL }} -Dsonar.organization=${{ secrets.ORGANIZATION_NAME }} -Dsonar.projectKey=${{ secrets.PROJECT_KEY }} -Dsonar.java.binaries=. -Dsonar.qualitygate.wait=false - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 dependency-check: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set up JDK 11 - uses: actions/setup-java@v3 - with: - java-version: '11' - distribution: 'corretto' - cache: maven - - - name: dependencyCheck - run: mvn dependency-check:check \ No newline at end of file + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + cache: maven + - name: Build + run: mvn -V -B clean package + - name: Depcheck + uses: dependency-check/Dependency-Check_Action@main + id: Depcheck + env: + # actions/setup-java@v1 changes JAVA_HOME so it needs to be reset to match the depcheck image + JAVA_HOME: /opt/jdk + with: + project: 'KeepTime' + path: '.' + format: 'HTML' + out: 'reports' # this is the default, no need to specify unless you wish to override it + args: > + --failOnCVSS 8.9 + --enableRetired + - name: Upload Test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: Depcheck report + path: ${{github.workspace}}/reports \ No newline at end of file diff --git a/.gitignore b/.gitignore index 631c05b6..1184cc0a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ logs/ config.xml /db/ +application.properties diff --git a/README.md b/README.md index 296cbe07..89fccd79 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Create projects and choose if they are counted as 'work time'. Select the projec ## Install -* Download keeptime.bat and keeptime--bin.zip (see [releases](https://github.com/doubleSlashde/KeepTime/releases)) +* Download keeptime-\.zip (see [releases](https://github.com/doubleSlashde/KeepTime/releases)) * Extract the downloaded .zip * Try starting the application by executing the *keeptime.bat* file. The start may take up to one minute. @@ -45,7 +45,24 @@ It is recommended to run the application at computer start, so you do not forget You should put the .jar in an extra folder as a *logs* and a *db* folder will be created next to it.\ -### Migrate from older version than v1.2.0 +## Update KeepTime +1. Start your current version of KeepTime +1. Open `Settings` -> `Import/Export` -> `Export` to export your data to an .sql file (Backup data) +1. Stop KeepTime +1. Download new version of KeepTime and extract it +1. Start new version of KeepTime +1. If your projects are available you are already done. But most likely your projects are missing. In this case follow the [Migrate](#Migrate) chapter + - Missing data is expected behavior with most updates as updates often include database version update which require an import of data + +### Migrate data +1. Notice that your old data is not available after an update +1. Open the new version of KeepTime +1. Open `Settings` -> `Import/Export` -> `Import` and import the previously exported .sql file +1. After the import KeepTime closes automatically +1. Start KeepTime +1. Your data is restored in the new version now + +### Migrate from version older than v1.2.0 1. Download new version and replace the .jar file. 2. Start new version of KeepTime. Notice that your old data is not available. @@ -53,18 +70,9 @@ You should put the .jar in an extra folder as a *logs* and a *db* folder will be 4. Copy the files (not directories) of directory `db` (next to the .jar file) into `db/1.4.197/` (path now includes the database version). 5. Start KeepTime again. Notice that your data is available again. -### Migrate from KeepTime v1.2.0 - -1. Start your current version of KeepTime (v1.2.0) -2. Go to the settings and export your KeepTime data -3. Download new version and replace the .jar file. -4. Start new version of KeepTime. Notice that your old data is not available. -5. Open the new version and import the exported sql script -6. After the import KeepTime closes automatically -7. To see the changes just start the new KeepTime again - ## Requirements - -* Windows 7, 10 -* Linux (tested on Ubuntu 18.04) -* Java 11 \ No newline at end of file +* Operating System + * Windows 7, 10, 11 + * Linux (tested on Ubuntu 18.04) + * Mac (tested on MacBook M2 Pro (ARM based CPU)) +* Java >= 17 diff --git a/pom.xml b/pom.xml index 650ecd4c..dd649d92 100644 --- a/pom.xml +++ b/pom.xml @@ -1,16 +1,29 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + de.doubleslash keeptime ${project.version} - jar + jar KeepTime + Time tracker + + doubleSlash Net-Business GmbH + https://www.doubleslash.de/ + + GNU General Public License (GPL) version 3.0 @@ -19,26 +32,14 @@ - - doubleSlash Net-Business GmbH - https://www.doubleslash.de/ - - - - org.springframework.boot - spring-boot-starter-parent - 2.7.5 - - - - 1.3.0-SNAPSHOT + 2.0.0-SNAPSHOT UTF-8 UTF-8 - 11 - 11 + 17 + 22 - 8.0.2 + 12.0.1 ALL true @@ -47,37 +48,78 @@ + + org.mapstruct + mapstruct + 1.6.3 + + + org.mapstruct + mapstruct-processor + 1.6.3 + provided + org.openjfx javafx-controls - 11.0.2 + ${javafx.version} org.openjfx javafx-fxml - 11.0.2 + ${javafx.version} org.openjfx javafx-swing - 11 + ${javafx.version} org.openjfx javafx-graphics - 11.0.2 + ${javafx.version} win org.openjfx javafx-graphics - 11.0.2 + ${javafx.version} linux + + org.openjfx + javafx-graphics + ${javafx.version} + mac-aarch64 + org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-web + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.5 + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + + org.glassfish.jaxb + jaxb-runtime + provided + com.h2database @@ -105,55 +147,31 @@ org.apache.maven.plugins maven-assembly-plugin - 3.4.2 + 3.7.1 maven-plugin org.sonarsource.scanner.maven sonar-maven-plugin - 3.9.1.2184 + 3.11.0.3922 org.hamcrest hamcrest-library - 2.2 test + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.18.2 + + - - - coverage - - - - org.jacoco - jacoco-maven-plugin - 0.8.7 - - - prepare-agent - - prepare-agent - - - - report - - report - - - - XML - - - - - - - - - + keeptime-${project.version} + org.springframework.boot @@ -161,8 +179,8 @@ - pl.project13.maven - git-commit-id-plugin + io.github.git-commit-id + git-commit-id-maven-plugin false @@ -181,7 +199,6 @@ ./assembly.xml - keeptime-${project.version} @@ -207,16 +224,15 @@ dependency-check-report_suppressions.xml - - - check - - + + + check + + - @@ -242,4 +258,52 @@ - \ No newline at end of file + + + + coverage + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.10 + + + prepare-agent + + prepare-agent + + + + report + + report + + + + XML + + + + + + + + + + diff --git a/src/main/java/de/doubleslash/keeptime/App.java b/src/main/java/de/doubleslash/keeptime/App.java index 9eaa5182..2c3ff0a3 100644 --- a/src/main/java/de/doubleslash/keeptime/App.java +++ b/src/main/java/de/doubleslash/keeptime/App.java @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.stream.Collectors; +import javafx.stage.Window; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; @@ -78,6 +79,7 @@ public class App extends Application { private ViewController viewController; private GlobalScreenListener globalScreenListener; + private Settings settings; @Override public void init() throws Exception { @@ -95,6 +97,8 @@ public void init() throws Exception { model = springContext.getBean(Model.class); controller = springContext.getBean(Controller.class); + settings = springContext.getBean(Settings.class); + controller.enableAutoSave(); model.setSpringContext(springContext); } @@ -107,36 +111,43 @@ public void start(final Stage primaryStage) { } catch (final Exception e) { LOG.error("There was an error while initialising the UI", e); - final Alert alert = new Alert(AlertType.ERROR); - alert.setTitle("Error"); - alert.setHeaderText("Could not start application"); - alert.setContentText("Please send the error with your logs folder to a developer"); + showErrorDialogAndWait("Error", "Could not start application", + "Please send the error with your logs folder to a developer", e, null); + System.exit(1); + } + } - final StringWriter sw = new StringWriter(); - final PrintWriter pw = new PrintWriter(sw); - e.printStackTrace(pw); - final String exceptionText = sw.toString(); + public static void showErrorDialogAndWait(String title, String header, String content, final Exception e, Window window) { + final Alert alert = new Alert(AlertType.ERROR); + alert.setTitle(title); + alert.setHeaderText(header); + alert.setContentText(content); + if(window != null) { + alert.initOwner(window); + } + final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + final String exceptionText = sw.toString(); - final Label label = new Label("The exception stacktrace was:"); + final Label label = new Label("The exception stacktrace was:"); - final TextArea textArea = new TextArea(exceptionText); - textArea.setEditable(false); - textArea.setWrapText(true); + final TextArea textArea = new TextArea(exceptionText); + textArea.setEditable(false); + textArea.setWrapText(true); - textArea.setMaxWidth(Double.MAX_VALUE); - textArea.setMaxHeight(Double.MAX_VALUE); - GridPane.setVgrow(textArea, Priority.ALWAYS); - GridPane.setHgrow(textArea, Priority.ALWAYS); + textArea.setMaxWidth(Double.MAX_VALUE); + textArea.setMaxHeight(Double.MAX_VALUE); + GridPane.setVgrow(textArea, Priority.ALWAYS); + GridPane.setHgrow(textArea, Priority.ALWAYS); - final GridPane expContent = new GridPane(); - expContent.setMaxWidth(Double.MAX_VALUE); - expContent.add(label, 0, 0); - expContent.add(textArea, 0, 1); + final GridPane expContent = new GridPane(); + expContent.setMaxWidth(Double.MAX_VALUE); + expContent.add(label, 0, 0); + expContent.add(textArea, 0, 1); - alert.getDialogPane().setExpandableContent(expContent); - alert.showAndWait(); - System.exit(1); - } + alert.getDialogPane().setExpandableContent(expContent); + alert.showAndWait(); } private void initialiseApplication(final Stage primaryStage) throws Exception { @@ -144,6 +155,7 @@ private void initialiseApplication(final Stage primaryStage) throws Exception { readSettings(); final List todaysWorkItems = model.getWorkRepository().findByStartDateOrderByStartTimeAsc(LocalDate.now()); + LOG.info("Found {} past work items", todaysWorkItems.size()); model.getPastWorkItems().addAll(todaysWorkItems); @@ -181,25 +193,6 @@ private void initialiseApplication(final Stage primaryStage) throws Exception { private void readSettings() { LOG.debug("Reading configuration"); - final List settingsList = model.getSettingsRepository().findAll(); - final Settings settings; - if (settingsList.isEmpty()) { - settings = new Settings(); - settings.setTaskBarColor(model.taskBarColor.get()); - - settings.setDefaultBackgroundColor(Model.ORIGINAL_DEFAULT_BACKGROUND_COLOR); - settings.setDefaultFontColor(Model.ORIGINAL_DEFAULT_FONT_COLOR); - - settings.setHoverBackgroundColor(Model.ORIGINAL_HOVER_BACKGROUND_COLOR); - settings.setHoverFontColor(Model.ORIGINAL_HOVER_Font_COLOR); - settings.setUseHotkey(false); - settings.setDisplayProjectsRight(false); - settings.setHideProjectsOnMouseExit(false); - model.getSettingsRepository().save(settings); - } else { - settings = settingsList.get(0); - } - model.defaultBackgroundColor.set(settings.getDefaultBackgroundColor()); model.defaultFontColor.set(settings.getDefaultFontColor()); model.hoverBackgroundColor.set(settings.getHoverBackgroundColor()); @@ -239,7 +232,7 @@ private void initialisePopupUI(final Stage primaryStage) throws IOException { final ViewControllerPopup viewControllerPopupController = loader.getController(); viewControllerPopupController.setStage(popupViewStage); - if (!OS.isLinux()) { + if (OS.isWindows()) { globalScreenListener = new GlobalScreenListener(); globalScreenListener.register(model.useHotkey.get()); globalScreenListener.setViewController(viewControllerPopupController); @@ -301,7 +294,7 @@ private void registerMaximizeEventlistener(final Scene mainScene, final Stage pr @Override public void stop() throws Exception { - springContext.stop(); + springContext.close(); } } diff --git a/src/main/java/de/doubleslash/keeptime/common/BrowserHelper.java b/src/main/java/de/doubleslash/keeptime/common/BrowserHelper.java index 57db1d04..bea8f417 100644 --- a/src/main/java/de/doubleslash/keeptime/common/BrowserHelper.java +++ b/src/main/java/de/doubleslash/keeptime/common/BrowserHelper.java @@ -34,30 +34,43 @@ public static void openURL(final String url) { openUrlWindows(rt, url); } else if (OS.isLinux()) { openUrlLinux(rt, url); + } else if (OS.isMacOS()) { + openUrlMac(rt, url); } else { - LOG.warn("OS is not supported"); + LOG.warn("OS '{}' is not supported", OS.getOSName()); } } private static void openUrlWindows(final Runtime rt, final String url) { final String command = "rundll32 url.dll,FileProtocolHandler " + url; + executeCommand(rt, command, url); + } + + private static void openUrlLinux(final Runtime rt, final String url) { + final String[] command = {"xdg-open", url}; + executeCommand(rt, command, url); + } + + private static void openUrlMac(final Runtime rt, final String url) { + final String[] command = {"open", url}; + executeCommand(rt, command, url); + } + + private static void executeCommand(final Runtime rt, final String command, final String url) { try { LOG.debug("Executing command: {}", command); rt.exec(command); } catch (final Exception e) { - LOG.error("Could not open url '" + url + "' with command '" + command + "'.", e); + LOG.error("Could not open url '{}' with command '{}'.", url, command, e); } } - private static void openUrlLinux(final Runtime rt, final String url) { - final String[] command = { - "xdg-open", url - }; + private static void executeCommand(final Runtime rt, final String[] command, final String url) { try { LOG.debug("Executing command: {}", Arrays.toString(command)); - rt.exec(command); + rt.exec(command); } catch (final Exception e) { - LOG.error("Could not open url '" + url + "' with command '" + Arrays.toString(command) + "'.", e); + LOG.error("Could not open url '{}' with command '{}'.", url, Arrays.toString(command), e); } } } diff --git a/src/main/java/de/doubleslash/keeptime/common/ColorHelper.java b/src/main/java/de/doubleslash/keeptime/common/ColorHelper.java index ed601c8e..b8991c06 100644 --- a/src/main/java/de/doubleslash/keeptime/common/ColorHelper.java +++ b/src/main/java/de/doubleslash/keeptime/common/ColorHelper.java @@ -18,14 +18,21 @@ import javafx.scene.paint.Color; +import java.util.Random; + public class ColorHelper { + private static final Random random = new Random(); + private ColorHelper() { throw new IllegalStateException("Utility class: ColorHelper"); } public static Color randomColor() { - return Color.BLACK; + double hue = random.nextDouble() * 360; + double saturation = 0.7 + random.nextDouble() * 0.3; // High saturation + double brightness = 0.8 + random.nextDouble() * 0.2; // High brightness + return Color.hsb(hue, saturation, brightness); } public static String colorToCssRgba(final Color color) { diff --git a/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java b/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java index 0ccfb39c..03406f2b 100644 --- a/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java +++ b/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java @@ -32,18 +32,19 @@ public static boolean openFile(final String filePath) { final File file = new File(filePath); final Runtime rt = Runtime.getRuntime(); - if (file.exists() && file.isFile()) { - if (OS.isWindows()) { - openFileWindows(rt, file); - } else if (OS.isLinux()) { - openFileLinux(rt, filePath); - } else { - LOG.warn("OS is not supported"); - } - return true; - } else { + if (!file.exists() || file.isFile()) { + LOG.warn("Filepath does not seem to exist or does not point to a file: '{}'.", filePath); return false; } + + if (OS.isWindows()) { + openFileWindows(rt, file); + } else if (OS.isLinux()) { + openFileLinux(rt, filePath); + } else { + LOG.warn("OS '{}' is not supported", OS.getOSName()); + } + return true; } private static void openFileWindows(final Runtime rt, final File file) { @@ -52,7 +53,7 @@ private static void openFileWindows(final Runtime rt, final File file) { LOG.debug("executing command: {}", command); rt.exec(command); } catch (final Exception e) { - LOG.error("Could not open file '" + file + "' with command '" + command + "'.", e); + LOG.error("Could not open file '{}' with command '{}'.", file, command, e); } } @@ -64,7 +65,8 @@ private static void openFileLinux(final Runtime rt, final String filePath) { LOG.debug("executing command: {}", Arrays.toString(command)); rt.exec(command); } catch (final Exception e) { - LOG.error("Could not open file '" + filePath + "' with command '" + Arrays.toString(command) + "'.", e); + LOG.error("Could not open file '{}' with command '{}'.", filePath, Arrays.toString(command), + e); } } } diff --git a/src/main/java/de/doubleslash/keeptime/common/OS.java b/src/main/java/de/doubleslash/keeptime/common/OS.java index e161bb06..5ad13a24 100644 --- a/src/main/java/de/doubleslash/keeptime/common/OS.java +++ b/src/main/java/de/doubleslash/keeptime/common/OS.java @@ -19,29 +19,26 @@ public class OS { private static final String OS_PROPERTY = "os.name"; + private static final String OS_NAME = System.getProperty(OS_PROPERTY).toLowerCase(); private OS() { // prevent instance creation } public static boolean isWindows() { - if (System.getProperty(OS_PROPERTY).toLowerCase().contains("windows")) { - return true; - } - - return false; + return OS_NAME.contains("windows"); } public static boolean isLinux() { - if (System.getProperty(OS_PROPERTY).toLowerCase().contains("linux")) { - return true; - } + return OS_NAME.contains("linux"); + } - return false; + public static String getOSName() { + return OS_NAME; } - public static String getOSname() { - return System.getProperty(OS_PROPERTY); + public static boolean isMacOS() { + return OS_NAME.contains("mac os x"); } } diff --git a/src/main/java/de/doubleslash/keeptime/common/Resources.java b/src/main/java/de/doubleslash/keeptime/common/Resources.java index 12310945..da245988 100644 --- a/src/main/java/de/doubleslash/keeptime/common/Resources.java +++ b/src/main/java/de/doubleslash/keeptime/common/Resources.java @@ -40,6 +40,8 @@ public enum RESOURCE { FXML_MANAGE_PROJECT("/layouts/manage-project.fxml"), FXML_MANAGE_WORK("/layouts/manage-work.fxml"), + FXML_EXT_PROJECT_MAPPING("/layouts/externalProjectMapping.fxml"), + FXML_EXT_PROJECT_SYNC("/layouts/externalProjectSync.fxml"), SVG_CALENDAR_DAYS_ICON("/svgs/calendar-days.svg"), @@ -53,7 +55,7 @@ public enum RESOURCE { SVG_PENCIL_ICON("/svgs/pencil.svg"), - SVG_CLIPBOARD("/svgs/clipboard.svg"), + SVG_CLIPBOARD_ICON("/svgs/clipboard.svg"), SVG_BUG_ICON("/svgs/bug.svg"), @@ -67,8 +69,22 @@ public enum RESOURCE { SVG_LICENSES_ICON("/svgs/closed-captioning.svg"), - ICON_MAIN("/icons/icon.png") + SVG_MULTIPLE_CLIPBOARD_ICON("/svgs/copy.svg"), + SVG_SPINNER_SOLID("/svgs/spinner-solid.svg"), + + SVG_XMARK_SOLID("/svgs/xmark-solid.svg"), + + SVG_THUMBS_UP_SOLID("/svgs/thumbs-up-solid.svg"), + + SVG_GLOBE_ICON("/svgs/globe-solid.svg"), + + SVG_ROTATE_ICON("/svgs/rotate-solid.svg"), + + ICON_MAIN("/icons/icon.png"), + + /** CSS **/ + CSS_DS_STYLE("/css/dsStyles.css") ; String resourceLocation; diff --git a/src/main/java/de/doubleslash/keeptime/common/SvgNodeProvider.java b/src/main/java/de/doubleslash/keeptime/common/SvgNodeProvider.java index bc7b754b..81cebbf3 100644 --- a/src/main/java/de/doubleslash/keeptime/common/SvgNodeProvider.java +++ b/src/main/java/de/doubleslash/keeptime/common/SvgNodeProvider.java @@ -65,7 +65,7 @@ public static String getSvgPathWithXMl(Resources.RESOURCE resource){ return svgPath; } - public static SVGPath getSvgNodeWithScale(Resources.RESOURCE resource, Double scaleX, Double scaleY) { + public static SVGPath getSvgNodeWithScale(Resources.RESOURCE resource, double scaleX, double scaleY) { SVGPath iconSvg = new SVGPath(); iconSvg.setContent(getSvgPathWithXMl(resource)); iconSvg.setScaleX(scaleX); diff --git a/src/main/java/de/doubleslash/keeptime/controller/Controller.java b/src/main/java/de/doubleslash/keeptime/controller/Controller.java index 50aadf24..afe02d9d 100644 --- a/src/main/java/de/doubleslash/keeptime/controller/Controller.java +++ b/src/main/java/de/doubleslash/keeptime/controller/Controller.java @@ -22,11 +22,10 @@ import java.util.ArrayList; import java.util.List; -import javax.annotation.PreDestroy; - +import de.doubleslash.keeptime.model.settings.HeimatSettings; +import javafx.scene.paint.Color; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import de.doubleslash.keeptime.common.DateFormatter; @@ -36,25 +35,36 @@ import de.doubleslash.keeptime.model.Project; import de.doubleslash.keeptime.model.Settings; import de.doubleslash.keeptime.model.Work; -import javafx.collections.ObservableList; +import jakarta.annotation.PreDestroy; @Service public class Controller { - private final long QUICK_SAVE_INTERVAL = 60; private static final Logger LOG = LoggerFactory.getLogger(Controller.class); + private final long AUTO_SAVE_INTERVAL_SECONDS = 60; + private final HeimatSettings heimatSettings; + private Interval autoSaveInterval; + private final Model model; + private final Settings settings; private final DateProvider dateProvider; - @Autowired - public Controller(final Model model, final DateProvider dateProvider) { + public Controller(final Model model, Settings settings, HeimatSettings heimatSettings, final DateProvider dateProvider) { this.model = model; + this.settings = settings; + this.heimatSettings = heimatSettings; this.dateProvider = dateProvider; + } - // initiate quicksaving - new Interval(QUICK_SAVE_INTERVAL).registerCallBack(() -> saveCurrentWork(dateProvider.dateTimeNow())); + public void enableAutoSave() { + LOG.info("Enabling auto save with interval '{}' seconds.", AUTO_SAVE_INTERVAL_SECONDS); + autoSaveInterval = new Interval(AUTO_SAVE_INTERVAL_SECONDS); + autoSaveInterval.registerCallBack(() -> { + LOG.debug("Auto saving current work."); + saveCurrentWork(); + }); } public void changeProject(final Project newProject) { @@ -79,12 +89,14 @@ public void changeProject(final Project newProject, final long minusSeconds) { final Work newWork = new Work(workEnd, workEnd.plusSeconds(minusSeconds), newProject, ""); model.getPastWorkItems().add(newWork); - model.activeWorkItem.set(newWork); + } + public void saveCurrentWork() { + saveCurrentWork(dateProvider.dateTimeNow()); } - public Work saveCurrentWork(final LocalDateTime workEnd) { + private Work saveCurrentWork(final LocalDateTime workEnd) { final Work currentWork = model.activeWorkItem.get(); if (currentWork == null) { @@ -93,19 +105,19 @@ public Work saveCurrentWork(final LocalDateTime workEnd) { currentWork.setEndTime(workEnd); - final String time = DateFormatter - .secondsToHHMMSS(Duration.between(currentWork.getStartTime(), currentWork.getEndTime()).getSeconds()); + final String time = DateFormatter.secondsToHHMMSS( + Duration.between(currentWork.getStartTime(), currentWork.getEndTime()).getSeconds()); LOG.info("Saving Work from '{}' to '{}' ({}) on project '{}' with notes '{}'", currentWork.getStartTime(), currentWork.getEndTime(), time, currentWork.getProject().getName(), currentWork.getNotes()); // Save in db return model.getWorkRepository().save(currentWork); - } - public void addNewProject(final Project project) { + public Project addNewProject(final Project project) { LOG.info("Creating new project '{}'.", project); + model.getAllProjects().add(project); model.getAvailableProjects().add(project); @@ -113,46 +125,63 @@ public void addNewProject(final Project project) { model.getAvailableProjects().size(), project.getIndex()); changedProjects.add(project); model.getProjectRepository().saveAll(changedProjects); + return project; } - public void updateSettings(final Settings newValuedSettings) { - Settings settings = model.getSettingsRepository().findAll().get(0); - - settings.setTaskBarColor(newValuedSettings.getTaskBarColor()); - settings.setDefaultBackgroundColor(newValuedSettings.getDefaultBackgroundColor()); - settings.setDefaultFontColor(newValuedSettings.getDefaultFontColor()); - settings.setHoverBackgroundColor(newValuedSettings.getHoverBackgroundColor()); - settings.setHoverFontColor(newValuedSettings.getHoverFontColor()); - settings.setUseHotkey(newValuedSettings.isUseHotkey()); - settings.setDisplayProjectsRight(newValuedSettings.isDisplayProjectsRight()); - settings.setHideProjectsOnMouseExit(newValuedSettings.isHideProjectsOnMouseExit()); - settings.setSaveWindowPosition(newValuedSettings.isSaveWindowPosition()); - settings.setWindowXProportion(newValuedSettings.getWindowXProportion()); - settings.setWindowYProportion(newValuedSettings.getWindowYProportion()); - settings.setScreenHash(newValuedSettings.getScreenHash()); - settings.setRemindIfNotesAreEmpty(newValuedSettings.isRemindIfNotesAreEmpty()); - settings.setRemindIfNotesAreEmptyOnlyForWorkEntry(newValuedSettings.isRemindIfNotesAreEmptyOnlyForWorkEntry()); - settings.setConfirmClose(newValuedSettings.isConfirmClose()); - - settings = model.getSettingsRepository().save(settings); + + public void updateColorSettings(final Color hoverBackgroundColor,final Color hoverFontColor,final Color defaultBackgroundColor,final Color defaultFontColor,final Color taskBarColor) { + settings.setTaskBarColor(taskBarColor); + settings.setDefaultBackgroundColor(defaultBackgroundColor); + settings.setDefaultFontColor(defaultFontColor); + settings.setHoverBackgroundColor(hoverBackgroundColor); + settings.setHoverFontColor(hoverFontColor); + settings.save(); model.defaultBackgroundColor.set(settings.getDefaultBackgroundColor()); model.defaultFontColor.set(settings.getDefaultFontColor()); model.hoverBackgroundColor.set(settings.getHoverBackgroundColor()); model.hoverFontColor.set(settings.getHoverFontColor()); model.taskBarColor.set(settings.getTaskBarColor()); - model.useHotkey.set(settings.isUseHotkey()); + } + + public void updateLayoutSettings(final boolean displayProjectsRight,final boolean hideProjectsOnMouseExit,final double proportionalX,final double proportionalY,final int screenHash,final boolean saveWindowPosition) { + settings.setDisplayProjectsRight(displayProjectsRight); + settings.setHideProjectsOnMouseExit(hideProjectsOnMouseExit); + settings.setSaveWindowPosition(saveWindowPosition); + settings.setWindowXProportion(proportionalX); + settings.setWindowYProportion(proportionalY); + settings.setScreenHash(screenHash); + settings.save(); + model.displayProjectsRight.set(settings.isDisplayProjectsRight()); model.hideProjectsOnMouseExit.set(settings.isHideProjectsOnMouseExit()); model.screenSettings.saveWindowPosition.set(settings.isSaveWindowPosition()); model.screenSettings.proportionalX.set(settings.getWindowXProportion()); model.screenSettings.proportionalY.set(settings.getWindowYProportion()); model.screenSettings.screenHash.set(settings.getScreenHash()); + } + + public void updateFeatureSettings(final boolean useHotkey,final boolean emptyNoteReminder,final boolean emptyNoteReminderOnlyForWorkEntry,final boolean confirmClose) { + settings.setUseHotkey(useHotkey); + settings.setRemindIfNotesAreEmpty(emptyNoteReminder); + settings.setRemindIfNotesAreEmptyOnlyForWorkEntry(emptyNoteReminderOnlyForWorkEntry); + settings.setConfirmClose(confirmClose); + settings.save(); + + model.useHotkey.set(settings.isUseHotkey()); model.remindIfNotesAreEmpty.set(settings.isRemindIfNotesAreEmpty()); model.remindIfNotesAreEmptyOnlyForWorkEntry.set(settings.isRemindIfNotesAreEmptyOnlyForWorkEntry()); model.confirmClose.set(settings.isConfirmClose()); } + + public void updateHeimatSettings(final boolean active, final String url, final String pat){ + heimatSettings.setHeimatActive(active); + heimatSettings.setHeimatUrl(url); + heimatSettings.setHeimatPat(pat); + heimatSettings.save(); + } + @PreDestroy public void shutdown() { LOG.info("Controller shutdown"); @@ -161,14 +190,11 @@ public void shutdown() { changeProject(model.getIdleProject(), 0); LOG.info("Updating settings to persist local changes on shutdown."); - final Settings newSettings = new Settings(model.hoverBackgroundColor.get(), model.hoverFontColor.get(), - model.defaultBackgroundColor.get(), model.defaultFontColor.get(), model.taskBarColor.get(), - model.useHotkey.get(), model.displayProjectsRight.get(), model.hideProjectsOnMouseExit.get(), - model.screenSettings.proportionalX.get(), model.screenSettings.proportionalY.get(), - model.screenSettings.screenHash.get(), model.screenSettings.saveWindowPosition.get(), - model.remindIfNotesAreEmpty.get(), model.remindIfNotesAreEmptyOnlyForWorkEntry.get(), - model.confirmClose.get()); - updateSettings(newSettings); + // these are changed while dragging the windows - not via Settings-Dialog. Therefore, we need to save them separately. + settings.setScreenHash(model.screenSettings.screenHash.get()); + settings.setWindowXProportion(model.screenSettings.proportionalX.get()); + settings.setWindowYProportion(model.screenSettings.proportionalY.get()); + settings.save(); } public void deleteProject(final Project p) { @@ -181,11 +207,11 @@ public void deleteProject(final Project p) { changeProject(model.getIdleProject()); } - LOG.info("Disabeling project '{}'.", p); + LOG.info("Disabling project '{}'.", p); final int indexToRemove = p.getIndex(); p.setEnabled(false); // we don't delete it because of the referenced work - // items + // items p.setIndex(-1); model.getAvailableProjects().remove(p); @@ -248,7 +274,7 @@ public void deleteWork(final Work workToBeDeleted) { /** * Changes the indexes of the originalList parameter to have a consistent order. - * + * * @param originalList * list of all projects to adapt the indexes for * @param changedProject @@ -291,7 +317,7 @@ List resortProjectIndexes(final List originalList, final Proje /** * Decreases all indexes by one, after the removed index - * + * * @param originalList * list of all projects to adapt the indexes for * @param removedIndex @@ -320,7 +346,7 @@ public void setComment(final String notes) { } /** - * Calculate todays seconds counted as work + * Calculate today's seconds counted as work */ public long calcTodaysWorkSeconds() { final List workItems = new ArrayList<>(); @@ -341,16 +367,12 @@ public long calcTodaysWorkSeconds() { } /** - * Calculate todays present seconds (work+nonWork) + * Calculate today's present seconds (work+nonWork) */ public long calcTodaysSeconds() { return calcSeconds(model.getPastWorkItems()); } - public ObservableList getAvailableProjects() { - return model.getAvailableProjects(); - } - public long calcSeconds(final List workItems) { long seconds = 0; @@ -361,4 +383,5 @@ public long calcSeconds(final List workItems) { return seconds; } + } diff --git a/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java new file mode 100644 index 00000000..cd6f7aaa --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java @@ -0,0 +1,455 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.doubleslash.keeptime.model.*; +import de.doubleslash.keeptime.model.repos.ExternalProjectsMappingsRepository; +import de.doubleslash.keeptime.model.settings.HeimatSettings; +import de.doubleslash.keeptime.rest.integration.heimat.HeimatAPI; +import de.doubleslash.keeptime.rest.integration.heimat.model.ExistingAndInvalidMappings; +import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask; +import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTime; +import de.doubleslash.keeptime.view.ProjectReport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class HeimatController { + private static final Logger LOG = LoggerFactory.getLogger(HeimatController.class); + + private final Controller controller; + private final HeimatSettings heimatSettings; + private final ExternalProjectsMappingsRepository externalProjectsMappingsRepository; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private HeimatAPI heimatAPI; + private final Model model; + + @Autowired + public HeimatController(HeimatSettings heimatSettings, + ExternalProjectsMappingsRepository externalProjectsMappingsRepository, final Controller controller, + Model model) { + this.heimatSettings = heimatSettings; + this.controller = controller; + this.model = model; + this.externalProjectsMappingsRepository = externalProjectsMappingsRepository; + this.heimatAPI = new HeimatAPI(heimatSettings.getHeimatUrl(), heimatSettings.getHeimatPat()); + } + + // for testing only + HeimatController(HeimatSettings heimatSettings, HeimatAPI heimatAPI, + ExternalProjectsMappingsRepository externalProjectsMappingsRepository, final Controller controller, + Model model) { + this.heimatSettings = heimatSettings; + this.controller = controller; + this.externalProjectsMappingsRepository = externalProjectsMappingsRepository; + this.heimatAPI = heimatAPI; + this.model = model; + } + + /** + * can be called when heimat settings have changed + */ + public void refreshConnection() { + heimatAPI = new HeimatAPI(heimatSettings.getHeimatUrl(), heimatSettings.getHeimatPat()); + } + + /** + * throws SecurityException when login or url is not valid + */ + public void tryLogin() { + try { + heimatAPI.isLoginValid(); + } catch (Exception e) { + throw new SecurityException("Could not connect to HEIMAT API. Maybe wrong configuration?", e); + } + } + + public List getTableRows(final LocalDate currentReportDate, final List currentWorkItems) { + final List heimatTasks = heimatAPI.getMyTasks(currentReportDate); + final List heimatTimes = heimatAPI.getMyTimes(currentReportDate); + final List mappedProjects = externalProjectsMappingsRepository.findByExternalSystemId( + ExternalSystem.Heimat); + + final List list = new ArrayList<>(); + + final SortedSet workedProjectsSet = currentWorkItems.stream() + .map(Work::getProject) + .filter(Project::isWork) + .collect(Collectors.toCollection(() -> new TreeSet<>( + Comparator.comparing(Project::getIndex)))); + final Map> taskIdToHeimatTimesMap = heimatTimes.stream() + .collect(Collectors.groupingBy( + HeimatTime::taskId)); + + for (final Project project : workedProjectsSet) { + String heimatNotes = ""; + long heimatTimeSeconds = 0; + boolean isMappedInHeimat = false; + final Optional optHeimatMapping = mappedProjects.stream() + .filter(mp -> mp.getProject().getId() + == project.getId()) + .findAny(); + List optionalAlreadyBookedTimes = new ArrayList<>(); + Optional optionalExistingMapping = Optional.empty(); + if (optHeimatMapping.isPresent()) { + isMappedInHeimat = true; + optionalExistingMapping = list.stream() + .filter(mapping -> mapping.heimatTaskId == optHeimatMapping.get() + .getExternalTaskId()) + .findAny(); + + final List heimatTimesForTaskId = taskIdToHeimatTimesMap.get( + optHeimatMapping.get().getExternalTaskId()); + if (heimatTimesForTaskId != null) { + optionalAlreadyBookedTimes = heimatTimesForTaskId; + } + if (!optionalAlreadyBookedTimes.isEmpty()) { + heimatNotes = addHeimatNotes(optionalAlreadyBookedTimes); + heimatTimeSeconds = addHeimatTimes(optionalAlreadyBookedTimes); + } + } + final List onlyCurrentProjectWork = currentWorkItems.stream() + .filter(w -> w.getProject() == project) + .toList(); + + final long projectWorkSeconds = controller.calcSeconds(onlyCurrentProjectWork); + + final ProjectReport pr = new ProjectReport(); + for (final Work work : onlyCurrentProjectWork) { + final String currentWorkNote = work.getNotes(); + pr.appendToWorkNotes(currentWorkNote); + } + final String keeptimeNotes = pr.getNotes(); + String canBeSyncedMessage; + if (!isMappedInHeimat) { + canBeSyncedMessage = "Not mapped to Heimat task.\nMap in settings dialog."; + } else if (heimatTasks.stream().noneMatch(ht -> ht.id() == optHeimatMapping.get().getExternalTaskId())) { + canBeSyncedMessage = "Heimat Task is not available (anymore).\nPlease check mappings in settings dialog."; + isMappedInHeimat = false; + } else { + final ExternalProjectMapping externalProjectMapping = optHeimatMapping.get(); + canBeSyncedMessage = "Sync to " + externalProjectMapping.getExternalTaskName() + "\n(" + + externalProjectMapping.getExternalProjectName() + ")"; + } + + if (optionalExistingMapping.isPresent()) { + final Mapping existingMapping = optionalExistingMapping.get(); + final ArrayList projects = new ArrayList<>(existingMapping.projects()); + projects.add(project); + final long keepTimeSeconds = existingMapping.keeptimeSeconds() + projectWorkSeconds; + final long heimatSeconds = existingMapping.heimatSeconds(); + final boolean shouldBeSynced = + isMappedInHeimat && differenceGreaterOrEqual15Minutes(heimatSeconds, keepTimeSeconds); + final Mapping mapping = new Mapping(isMappedInHeimat ? optHeimatMapping.get().getExternalTaskId() : -1, + isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, existingMapping.existingTimes(), projects, + existingMapping.heimatNotes(), existingMapping.keeptimeNotes() + ". " + keeptimeNotes, heimatSeconds, + keepTimeSeconds); + list.remove(existingMapping); + list.add(mapping); + } else { + final boolean shouldBeSynced = + isMappedInHeimat && differenceGreaterOrEqual15Minutes(heimatTimeSeconds, projectWorkSeconds); + final List projects = Collections.singletonList(project); + final Mapping mapping = new Mapping(isMappedInHeimat ? optHeimatMapping.get().getExternalTaskId() : -1, + isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, optionalAlreadyBookedTimes, projects, + heimatNotes, keeptimeNotes, heimatTimeSeconds, projectWorkSeconds); + list.add(mapping); + } + } + + final List mappedIds = mappedProjects.stream().map(ExternalProjectMapping::getExternalTaskId).toList(); + final Map> notMappedExistingTimes = heimatTimes.stream() + .filter(ht -> !mappedIds.contains( + ht.taskId())) + .collect(Collectors.groupingBy( + HeimatTime::taskId)); + notMappedExistingTimes.forEach((id, times) -> { + String heimatNotes = times.stream().map(HeimatTime::note).collect(Collectors.joining(". ")); + long heimatTimeSeconds = times.stream() + .reduce(0L, (subtotal, element) -> subtotal + element.durationInMinutes() * 60L, + Long::sum); + final HeimatTask heimatTask = heimatTasks.stream() + .filter(t -> t.id() == times.get(0).taskId()) + .findAny() + .orElseThrow(); + final Mapping mapping = new Mapping(id, true, false, + "Not mapped in KeepTime\n\n" + heimatTask.name() + "\n" + heimatTask.taskHolderName(), times, + new ArrayList<>(0), heimatNotes, "", heimatTimeSeconds, 0); + list.add(mapping); + }); + + taskIdToHeimatTimesMap.forEach((id, times) -> { + final Optional mapping = mappedProjects.stream() + .filter(mp -> mp.getExternalTaskId() == id) + .findAny(); + if (mapping.isEmpty()) + return; + final ExternalProjectMapping externalProjectMapping = mapping.get(); + final Optional optionalProject = workedProjectsSet.stream() + .filter(wp -> wp.getId() + == externalProjectMapping.getProject() + .getId()) + .findAny(); + if (optionalProject.isPresent()) { + return; + } + String heimatNotes = addHeimatNotes(times); + long heimatTimeSeconds = addHeimatTimes(times); + + final Mapping mapping2 = new Mapping(id, true, false, + "Present in HEIMAT but not KeepTime\n\nSync to " + externalProjectMapping.getExternalTaskName() + "\n(" + + externalProjectMapping.getExternalProjectName() + ")", times, mappedProjects.stream() + .filter( + mp -> mp.getExternalTaskId() + == id) + .map(ExternalProjectMapping::getProject) + .toList(), + heimatNotes, "", heimatTimeSeconds, 0); + list.add(mapping2); + }); + + return list; + } + + private static boolean differenceGreaterOrEqual15Minutes(final long heimatTimeSeconds, + final long projectWorkSeconds) { + return heimatTimeSeconds == 0L || Math.abs(heimatTimeSeconds - projectWorkSeconds) >= 15 * 60L; + } + + private static long addHeimatTimes(final List optionalAlreadyBookedTimes) { + long heimatTimeSeconds; + heimatTimeSeconds = optionalAlreadyBookedTimes.stream() + .reduce(0L, (subtotal, element) -> subtotal + + element.durationInMinutes() * 60L, Long::sum); + return heimatTimeSeconds; + } + + private static String addHeimatNotes(final List optionalAlreadyBookedTimes) { + String heimatNotes; + heimatNotes = optionalAlreadyBookedTimes.stream().map(HeimatTime::note).collect(Collectors.joining(". ")); + return heimatNotes; + } + + public List saveDay(final List items, LocalDate date) { + List errors = new ArrayList<>(); + + items.stream().filter(tr -> tr.shouldSync).forEach(item -> { + final int durationInMinutes = item.userMinutes; + final HeimatTime heimatTime = new HeimatTime(item.mapping.heimatTaskId, date, null, null, durationInMinutes, + item.userNotes, 0L); + + try { + item.mapping.existingTimes().forEach(existingTime -> { + LOG.info("Removing existing booked time '{}'", existingTime); + heimatAPI.deleteMyTime(existingTime.id()); + }); + LOG.info("Adding new time time '{}'", heimatTime); + heimatAPI.addMyTime(heimatTime); + } catch (Exception e) { + LOG.error("Error while persisting time '{}'", heimatTime, e); + errors.add(new HeimatErrors(item, "Error while persisting." + e.getMessage())); + } + }); + + return errors; + } + + public String getUrlForDay(final LocalDate currentReportDate) { + return heimatSettings.getHeimatUrl() + "/core/heimat/time/main/day/" + currentReportDate.format( + DateTimeFormatter.ofPattern("yyyy/M/d")); + } + + public List getTasks(final LocalDate forDate) { + final List myTasks = heimatAPI.getMyTasks(forDate); + // TODO remove this when api returns tasks only once + Map uniqueMap = new LinkedHashMap<>(); + for (HeimatTask obj : myTasks) { + uniqueMap.putIfAbsent(obj.id(), obj); + } + return uniqueMap.values() + .stream() + .filter(p -> !p.isStartAndEndTimeRequired()) // not supported + .sorted(Comparator.comparing(HeimatTask::taskHolderName).thenComparing(HeimatTask::name)) + .toList(); + } + + public void updateMappings(final List newMappings) { + LOG.debug("New mappings to be saved '{}'.", newMappings); + final List alreadyMappedProjects = externalProjectsMappingsRepository.findByExternalSystemId( + ExternalSystem.Heimat); + + final List mappingsToCreateOrUpdate = newMappings.stream() + .filter(pm -> pm.getHeimatTask() != null) + .map(projectMapping -> { + final Optional any = alreadyMappedProjects.stream() + .filter( + pm -> pm.getProject() + .getId() + == projectMapping.getProject() + .getId()) + .findAny(); + final HeimatTask heimatTask = projectMapping.getHeimatTask(); + if (any.isPresent()) { + final ExternalProjectMapping projectMapping1 = any.get(); + if (projectMapping1.getExternalTaskId() + == heimatTask.id()) { + // mapping did not change + return null; + } + projectMapping1.setExternalProjectName( + heimatTask.taskHolderName()); + projectMapping1.setExternalTaskId( + heimatTask.id()); + projectMapping1.setExternalTaskName( + heimatTask.name()); + projectMapping1.setExternalTaskMetadata( + getAsJson(heimatTask)); + + return projectMapping1; + } + return new ExternalProjectMapping( + ExternalSystem.Heimat, + heimatTask.taskHolderName(), + heimatTask.id(), + heimatTask.name(), + getAsJson(heimatTask), + projectMapping.getProject()); + }) + .filter(Objects::nonNull) + .toList(); + LOG.info("Save/Updating mappings '{}'", mappingsToCreateOrUpdate); + externalProjectsMappingsRepository.saveAll(mappingsToCreateOrUpdate); + + // remove mappings which were removed also from database + final ArrayList mappingsToRemove = alreadyMappedProjects.stream() + .filter(em -> newMappings.stream() + .anyMatch( + wantedMapping -> + wantedMapping.getProject() + .getId() + == em.getProject() + .getId() + && + wantedMapping.getHeimatTask() + == null)) + .collect(Collectors.toCollection( + ArrayList::new)); + // remove mappings of projects which were 'deleted' + alreadyMappedProjects.stream().filter(em -> !em.getProject().isEnabled()).forEach(mappingsToRemove::add); + LOG.info("Removing mappings '{}'", mappingsToRemove); + externalProjectsMappingsRepository.deleteAll(mappingsToRemove); + } + + private String getAsJson(final HeimatTask heimatTask) { + try { + return objectMapper.writeValueAsString(heimatTask); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public ExistingAndInvalidMappings getExistingProjectMappings(List externalProjects) { + final List alreadyMappedProjects = externalProjectsMappingsRepository.findByExternalSystemId( + ExternalSystem.Heimat); + final List invalidExternalMappings = new ArrayList<>(); + + final List validProjectMappings = model.getSortedAvailableProjects().stream().map(p -> { + final Optional mapping = alreadyMappedProjects.stream() + .filter(mp -> mp.getProject().getId() + == p.getId()) + .findAny(); + if (mapping.isEmpty()) { + return new ProjectMapping(p, null); + } + final Optional any = externalProjects.stream() + .filter(ep -> ep.id() == mapping.get().getExternalTaskId()) + .findAny(); + if (any.isEmpty()) { + LOG.warn("A mapping exists but task does not exist anymore in HEIMAT! '{}'->'{}'.", + mapping.get().getProject(), mapping.get().getExternalTaskId()); + invalidExternalMappings.add(mapping.get()); + return new ProjectMapping(p, null); + } + return new ProjectMapping(p, any.get()); + }).toList(); + + final List invalidMappingsAsString = invalidExternalMappings.stream() + .map(em -> "Task no longer exists: " + + em.getExternalProjectName() + " - " + + em.getExternalTaskName() + + "'. Was mapped to '" + em.getProject() + .getName() + + "'.") + .collect(Collectors.toCollection( + ArrayList::new)); + /* + // I do not have all external projects here :( only already filtered ones + allExternalProjects.stream() + .filter(HeimatTask::isStartAndEndTimeRequired) + .map(p -> "Task " + p.taskHolderName() + " - " + p.name() + + " requires start+end time which is not supported.") + .forEach(invalidMappingsAsString::add); + */ + + return new ExistingAndInvalidMappings(validProjectMappings, invalidMappingsAsString); + } + + public record UserMapping(Mapping mapping, boolean shouldSync, String userNotes, int userMinutes) {} + + public record Mapping(long heimatTaskId, boolean canBeSynced, boolean shouldBeSynced, String syncMessage, + List existingTimes, List projects, String heimatNotes, + String keeptimeNotes, long heimatSeconds, long keeptimeSeconds) {} + + public record HeimatErrors(UserMapping mapping, String errorMessage) {} + + public static class ProjectMapping { + private Project project; + private HeimatTask heimatTask; + + public ProjectMapping(final Project project, final HeimatTask heimatTask) { + this.project = project; + this.heimatTask = heimatTask; + } + + public Project getProject() { + return project; + } + + public void setProject(final Project project) { + this.project = project; + } + + public HeimatTask getHeimatTask() { + return heimatTask; + } + + public void setHeimatTask(final HeimatTask heimatTask) { + this.heimatTask = heimatTask; + } + } +} diff --git a/src/main/java/de/doubleslash/keeptime/model/ExternalProjectMapping.java b/src/main/java/de/doubleslash/keeptime/model/ExternalProjectMapping.java new file mode 100644 index 00000000..c5ae8631 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/model/ExternalProjectMapping.java @@ -0,0 +1,111 @@ +// Copyright 2019 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "ExternalProjectMapping") +public class ExternalProjectMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", updatable = false, nullable = false) + private long id; + + @Enumerated(EnumType.STRING) + private ExternalSystem externalSystemId; + + private String externalProjectName; + + private long externalTaskId; + private String externalTaskName; + @Lob + private String externalTaskMetadata; + + @ManyToOne + private Project project; + + public ExternalProjectMapping() { + // Needed for jpa + } + + public ExternalProjectMapping(ExternalSystem externalSystemId, String externalProjectName, + long externalTaskId, String externalTaskName, String externalTaskMetadata, + Project project) { + this.externalSystemId = externalSystemId; + this.externalProjectName = externalProjectName; + this.externalTaskId = externalTaskId; + this.externalTaskName = externalTaskName; + this.externalTaskMetadata = externalTaskMetadata; + this.project = project; + } + + public ExternalSystem getExternalSystemId() { + return externalSystemId; + } + + public void setExternalSystemId(ExternalSystem externalSystemId) { + this.externalSystemId = externalSystemId; + } + + public String getExternalProjectName() { + return externalProjectName; + } + + public void setExternalProjectName(String externalProjectName) { + this.externalProjectName = externalProjectName; + } + + public long getExternalTaskId() { + return externalTaskId; + } + + public void setExternalTaskId(long externalTaskId) { + this.externalTaskId = externalTaskId; + } + + public String getExternalTaskName() { + return externalTaskName; + } + + public void setExternalTaskName(String externalTaskName) { + this.externalTaskName = externalTaskName; + } + + public String getExternalTaskMetadata() { + return externalTaskMetadata; + } + + public void setExternalTaskMetadata(String externalTaskMetadata) { + this.externalTaskMetadata = externalTaskMetadata; + } + + public Project getProject() { + return project; + } + + public void setProject(Project project) { + this.project = project; + } + + @Override + public String toString() { + return "ExternalProjectMapping{" + "id=" + id + ", project.name=" + project.getName() + ", externalTaskName='" + externalTaskName + + '\'' + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/doubleslash/keeptime/model/ExternalSystem.java b/src/main/java/de/doubleslash/keeptime/model/ExternalSystem.java new file mode 100644 index 00000000..c4243b23 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/model/ExternalSystem.java @@ -0,0 +1,5 @@ +package de.doubleslash.keeptime.model; + +public enum ExternalSystem { + Heimat +} diff --git a/src/main/java/de/doubleslash/keeptime/model/Model.java b/src/main/java/de/doubleslash/keeptime/model/Model.java index db79a842..c4c2b846 100644 --- a/src/main/java/de/doubleslash/keeptime/model/Model.java +++ b/src/main/java/de/doubleslash/keeptime/model/Model.java @@ -16,35 +16,39 @@ package de.doubleslash.keeptime.model; -import java.util.Comparator; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.stereotype.Component; - import de.doubleslash.keeptime.model.repos.ProjectRepository; import de.doubleslash.keeptime.model.repos.SettingsRepository; import de.doubleslash.keeptime.model.repos.WorkRepository; +import de.doubleslash.keeptime.model.settings.HeimatSettings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.scene.paint.Color; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.stereotype.Component; + +import java.util.Comparator; @Component public class Model { private ProjectRepository projectRepository; private WorkRepository workRepository; - private SettingsRepository settingsRepository; + public HeimatSettings getHeimatSettings() { + return heimatSettings; + } + + private HeimatSettings heimatSettings; @Autowired public Model(final ProjectRepository projectRepository, final WorkRepository workRepository, - final SettingsRepository settingsRepository) { + final HeimatSettings heimatSettings) { super(); this.projectRepository = projectRepository; this.workRepository = workRepository; - this.settingsRepository = settingsRepository; + this.heimatSettings = heimatSettings; } public static final Color ORIGINAL_HOVER_BACKGROUND_COLOR = new Color(54 / 255., 143 / 255., 179 / 255., .7); @@ -98,10 +102,6 @@ public void setProjectRepository(final ProjectRepository projectRepository) { this.projectRepository = projectRepository; } - public void setSettingsRepository(final SettingsRepository settingsRepository) { - this.settingsRepository = settingsRepository; - } - public void setIdleProject(final Project idleProject) { this.idleProject = idleProject; } @@ -122,10 +122,6 @@ public ProjectRepository getProjectRepository() { return projectRepository; } - public SettingsRepository getSettingsRepository() { - return settingsRepository; - } - public ObservableList getPastWorkItems() { return pastWorkItems; } diff --git a/src/main/java/de/doubleslash/keeptime/model/Project.java b/src/main/java/de/doubleslash/keeptime/model/Project.java index c6b28711..9a8593ab 100644 --- a/src/main/java/de/doubleslash/keeptime/model/Project.java +++ b/src/main/java/de/doubleslash/keeptime/model/Project.java @@ -16,14 +16,7 @@ package de.doubleslash.keeptime.model; -import javax.persistence.Column; -import javax.persistence.Convert; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Lob; -import javax.persistence.Table; +import jakarta.persistence.*; import de.doubleslash.keeptime.model.persistenceconverter.ColorConverter; import javafx.scene.paint.Color; @@ -36,19 +29,16 @@ public class Project { @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", updatable = false, nullable = false) private long id; - private String name; @Lob private String description; - @Convert(converter = ColorConverter.class, disableConversion = false) + @Convert(converter = ColorConverter.class) private Color color; private boolean isWork; - private boolean isDefault; - private boolean isEnabled; private int index; @@ -57,9 +47,7 @@ public Project() { // Needed for jpa } - public Project(final String name, final String description, final Color color, final boolean isWork, final int index, - final boolean isDefault) { - super(); + public Project(String name, String description, Color color, boolean isWork, int index, boolean isDefault) { this.name = name; this.description = description; this.color = color; @@ -69,8 +57,7 @@ public Project(final String name, final String description, final Color color, f this.index = index; } - public Project(final String name, final String description, final Color color, final boolean isWork, - final int index) { + public Project(String name, String description, Color color, boolean isWork, int index) { this(name, description, color, isWork, index, false); } @@ -78,7 +65,7 @@ public String getName() { return name; } - public void setName(final String name) { + public void setName(String name) { this.name = name; } @@ -86,7 +73,7 @@ public Color getColor() { return color; } - public void setColor(final Color color) { + public void setColor(Color color) { this.color = color; } @@ -94,7 +81,7 @@ public boolean isWork() { return isWork; } - public void setWork(final boolean isWork) { + public void setWork(boolean isWork) { this.isWork = isWork; } @@ -102,7 +89,7 @@ public boolean isDefault() { return isDefault; } - public void setDefault(final boolean isDefault) { + public void setDefault(boolean isDefault) { this.isDefault = isDefault; } @@ -110,7 +97,7 @@ public boolean isEnabled() { return isEnabled; } - public void setEnabled(final boolean isEnabled) { + public void setEnabled(boolean isEnabled) { this.isEnabled = isEnabled; } @@ -122,7 +109,7 @@ public int getIndex() { return index; } - public void setIndex(final int index) { + public void setIndex(int index) { this.index = index; } @@ -130,7 +117,7 @@ public String getDescription() { return description; } - public void setDescription(final String description) { + public void setDescription(String description) { this.description = description; } @@ -139,5 +126,4 @@ public String toString() { return "Project [id=" + id + ", name=" + name + ", description=" + description + ", color=" + color + ", isWork=" + isWork + ", isDefault=" + isDefault + ", isEnabled=" + isEnabled + ", index=" + index + "]"; } - -} +} \ No newline at end of file diff --git a/src/main/java/de/doubleslash/keeptime/model/ScreenSettings.java b/src/main/java/de/doubleslash/keeptime/model/ScreenSettings.java index da43dbec..0dc8e19e 100644 --- a/src/main/java/de/doubleslash/keeptime/model/ScreenSettings.java +++ b/src/main/java/de/doubleslash/keeptime/model/ScreenSettings.java @@ -1,3 +1,19 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + package de.doubleslash.keeptime.model; import javafx.beans.property.ObjectProperty; diff --git a/src/main/java/de/doubleslash/keeptime/model/Setting.java b/src/main/java/de/doubleslash/keeptime/model/Setting.java new file mode 100644 index 00000000..c2937035 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/model/Setting.java @@ -0,0 +1,56 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "Settings") +public class Setting { + + @Id + @Column(nullable = false, unique = true) + private String setting; + + private String settingValue; + + public Setting(){ + // for hibernate + } + + public Setting(final String setting, final String settingValue) { + this.setting = setting; + this.settingValue = settingValue; + } + + public String getSetting() { + return setting; + } + + public void setSetting(final String setting) { + this.setting = setting; + } + + public String getSettingValue() { + return settingValue; + } + + public void setSettingValue(final String settingValue) { + this.settingValue = settingValue; + } + +} diff --git a/src/main/java/de/doubleslash/keeptime/model/Settings.java b/src/main/java/de/doubleslash/keeptime/model/Settings.java index 9bf5505e..124e85aa 100644 --- a/src/main/java/de/doubleslash/keeptime/model/Settings.java +++ b/src/main/java/de/doubleslash/keeptime/model/Settings.java @@ -16,211 +16,148 @@ package de.doubleslash.keeptime.model; -import javax.persistence.Column; -import javax.persistence.Convert; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Table; - -import de.doubleslash.keeptime.model.persistenceconverter.ColorConverter; +import de.doubleslash.keeptime.model.settings.SettingsBase; import javafx.scene.paint.Color; +import org.springframework.stereotype.Service; /** * Object holding settings - * + * * @author nmutter */ -@Entity -@Table(name = "Settings") +@Service public class Settings { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", updatable = false, nullable = false) - private long id; + SettingsBase settingsBase; - @Convert(converter = ColorConverter.class, disableConversion = false) - private Color hoverBackgroundColor; - @Convert(converter = ColorConverter.class, disableConversion = false) - private Color hoverFontColor; - @Convert(converter = ColorConverter.class, disableConversion = false) - private Color defaultBackgroundColor; - @Convert(converter = ColorConverter.class, disableConversion = false) - private Color defaultFontColor; + public Settings(SettingsBase settingsBase) { + this.settingsBase = settingsBase; + } - @Convert(converter = ColorConverter.class, disableConversion = false) - private Color taskBarColor; + // TODO add default values - private boolean useHotkey; + public boolean isRemindIfNotesAreEmptyOnlyForWorkEntry() { + return settingsBase.getBoolean("remind_if_notes_are_empty_only_for_work_entry", false); + } - private boolean displayProjectsRight; + public void setRemindIfNotesAreEmptyOnlyForWorkEntry(boolean emptyNoteReminderCheckBoxIsWork) { + settingsBase.setBoolean("remind_if_notes_are_empty_only_for_work_entry", + emptyNoteReminderCheckBoxIsWork); + } - private boolean hideProjectsOnMouseExit; + public boolean isConfirmClose() { + return settingsBase.getBoolean("confirm_close", false); + } - private double windowXProportion; + public void setConfirmClose(boolean confirmClose) { + settingsBase.setBoolean("confirm_close", confirmClose); + } - private double windowYProportion; + public Color getHoverBackgroundColor() { + return settingsBase.getColor("hover_background_color", Model.ORIGINAL_HOVER_BACKGROUND_COLOR); + } - private int windowScreenhash; + public void setHoverBackgroundColor(final Color hoverBackgroundColor) { + settingsBase.setColor("hover_background_color", hoverBackgroundColor); + } - private boolean saveWindowPosition; + public Color getHoverFontColor() { + return settingsBase.getColor("hover_font_color", Model.ORIGINAL_HOVER_Font_COLOR); + } - private boolean remindIfNotesAreEmpty; + public void setHoverFontColor(final Color hoverFontColor) { + settingsBase.setColor("hover_font_color", hoverFontColor); + } - private boolean remindIfNotesAreEmptyOnlyForWorkEntry; + public Color getDefaultBackgroundColor() { + return settingsBase.getColor("default_background_color", Model.ORIGINAL_DEFAULT_BACKGROUND_COLOR); + } - private boolean confirmClose; + public void setDefaultBackgroundColor(final Color defaultBackgroundColor) { + settingsBase.setColor("default_background_color", defaultBackgroundColor); + } - public Settings() {} + public Color getDefaultFontColor() { + return settingsBase.getColor("default_font_color", Model.ORIGINAL_DEFAULT_FONT_COLOR); + } - public Settings(final Color hoverBackgroundColor, final Color hoverFontColor, final Color defaultBackgroundColor, - final Color defaultFontColor, final Color taskBarColor, final boolean useHotkey, - final boolean displayProjectsRight, final boolean hideProjectsOnMouseExit, final double windowPositionX, - final double windowPositionY, final int screenHash, final boolean saveWindowPosition, - final boolean remindIfNotesAreEmpty, final boolean remindIfNotesAreEmptyOnlyForWorkEntry, - final boolean confirmClose) { - this.hoverBackgroundColor = hoverBackgroundColor; - this.hoverFontColor = hoverFontColor; - this.defaultBackgroundColor = defaultBackgroundColor; - this.defaultFontColor = defaultFontColor; - this.taskBarColor = taskBarColor; - this.useHotkey = useHotkey; - this.displayProjectsRight = displayProjectsRight; - this.hideProjectsOnMouseExit = hideProjectsOnMouseExit; - this.windowXProportion = windowPositionX; - this.windowYProportion = windowPositionY; - this.windowScreenhash = screenHash; - this.saveWindowPosition = saveWindowPosition; - this.remindIfNotesAreEmpty = remindIfNotesAreEmpty; - this.remindIfNotesAreEmptyOnlyForWorkEntry = remindIfNotesAreEmptyOnlyForWorkEntry; - this.confirmClose = confirmClose; + public void setDefaultFontColor(final Color defaultFontColor) { + settingsBase.setColor("default_font_color", defaultFontColor); + } - } + public Color getTaskBarColor() { + return settingsBase.getColor("task_bar_color", Model.ORIGINAL_TASK_BAR_FONT_COLOR); + } - public boolean isRemindIfNotesAreEmptyOnlyForWorkEntry() { - return remindIfNotesAreEmptyOnlyForWorkEntry; - } + public void setTaskBarColor(final Color taskBarColor) { + settingsBase.setColor("task_bar_color", taskBarColor); + } - public void setRemindIfNotesAreEmptyOnlyForWorkEntry(boolean emptyNoteReminderCheckBoxIsWork) { - this.remindIfNotesAreEmptyOnlyForWorkEntry = emptyNoteReminderCheckBoxIsWork; - } + public boolean isUseHotkey() { + return settingsBase.getBoolean("use_hotkey", false); + } - public boolean isConfirmClose() { - return confirmClose; - } + public void setUseHotkey(final boolean useHotkey) { + settingsBase.setBoolean("use_hotkey", useHotkey); + } - public void setConfirmClose(boolean confirmClose) { - this.confirmClose = confirmClose; - } + public boolean isDisplayProjectsRight() { + return settingsBase.getBoolean("display_projects_right", false); + } - public long getId() { - return id; - } + public void setDisplayProjectsRight(final boolean displayProjectsRight) { + settingsBase.setBoolean("display_projects_right", displayProjectsRight); + } - public Color getHoverBackgroundColor() { - return hoverBackgroundColor; - } + public boolean isHideProjectsOnMouseExit() { + return settingsBase.getBoolean("hide_projects_on_mouse_exit", false); + } - public void setHoverBackgroundColor(final Color hoverBackgroundColor) { - this.hoverBackgroundColor = hoverBackgroundColor; - } + public void setHideProjectsOnMouseExit(final boolean hideProjectsOnMouseExit) { + settingsBase.setBoolean("hide_projects_on_mouse_exit", hideProjectsOnMouseExit); + } - public Color getHoverFontColor() { - return hoverFontColor; - } + public double getWindowXProportion() { + return settingsBase.getDouble("windowxproportion", 0.5); + } - public void setHoverFontColor(final Color hoverFontColor) { - this.hoverFontColor = hoverFontColor; - } + public void setWindowXProportion(final double windowPositionX) { + settingsBase.setDouble("windowxproportion", windowPositionX); + } - public Color getDefaultBackgroundColor() { - return defaultBackgroundColor; - } + public double getWindowYProportion() { + return settingsBase.getDouble("windowyproportion", 0.5); + } - public void setDefaultBackgroundColor(final Color defaultBackgroundColor) { - this.defaultBackgroundColor = defaultBackgroundColor; - } + public void setWindowYProportion(final double windowPositionY) { + settingsBase.setDouble("windowyproportion", windowPositionY); + } - public Color getDefaultFontColor() { - return defaultFontColor; - } + public int getScreenHash() { + return settingsBase.getInt("window_screenhash", 0); + } + + public void setScreenHash(final int screenHash) { + settingsBase.setInt("window_screenhash", screenHash); + } + + public boolean isSaveWindowPosition() { + return settingsBase.getBoolean("save_window_position", false); + } - public void setDefaultFontColor(final Color defaultFontColor) { - this.defaultFontColor = defaultFontColor; - } + public void setSaveWindowPosition(final boolean saveWindowPosition) { + settingsBase.setBoolean("save_window_position", saveWindowPosition); + } - public Color getTaskBarColor() { - return taskBarColor; - } + public boolean isRemindIfNotesAreEmpty() { + return settingsBase.getBoolean("remind_if_notes_are_empty", false); + } - public void setTaskBarColor(final Color taskBarColor) { - this.taskBarColor = taskBarColor; - } - - public boolean isUseHotkey() { - return useHotkey; - } - - public void setUseHotkey(final boolean useHotkey) { - this.useHotkey = useHotkey; - } - - public boolean isDisplayProjectsRight() { - return displayProjectsRight; - } - - public void setDisplayProjectsRight(final boolean displayProjectsRight) { - this.displayProjectsRight = displayProjectsRight; - } - - public boolean isHideProjectsOnMouseExit() { - return hideProjectsOnMouseExit; - } - - public void setHideProjectsOnMouseExit(final boolean hideProjectsOnMouseExit) { - this.hideProjectsOnMouseExit = hideProjectsOnMouseExit; - } - - public double getWindowXProportion() { - return windowXProportion; - } - - public void setWindowXProportion(final double windowPositionX) { - this.windowXProportion = windowPositionX; - } - - public double getWindowYProportion() { - return windowYProportion; - } - - public void setWindowYProportion(final double windowPositionY) { - this.windowYProportion = windowPositionY; - } - - public int getScreenHash() { - return windowScreenhash; - } - - public void setScreenHash(final int screenHash) { - this.windowScreenhash = screenHash; - } - - public boolean isSaveWindowPosition() { - return saveWindowPosition; - } - - public void setSaveWindowPosition(final boolean saveWindowPosition) { - this.saveWindowPosition = saveWindowPosition; - } - - public boolean isRemindIfNotesAreEmpty() { - return remindIfNotesAreEmpty; - } - - public void setRemindIfNotesAreEmpty(final boolean emptyNoteReminder) { - this.remindIfNotesAreEmpty = emptyNoteReminder; - } + public void setRemindIfNotesAreEmpty(final boolean emptyNoteReminder) { + settingsBase.setBoolean("remind_if_notes_are_empty", emptyNoteReminder); + } + public void save() { + settingsBase.saveAll(); + } } diff --git a/src/main/java/de/doubleslash/keeptime/model/Work.java b/src/main/java/de/doubleslash/keeptime/model/Work.java index 9d53a06e..27ce51c4 100644 --- a/src/main/java/de/doubleslash/keeptime/model/Work.java +++ b/src/main/java/de/doubleslash/keeptime/model/Work.java @@ -18,14 +18,14 @@ import java.time.LocalDateTime; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Lob; -import javax.persistence.ManyToOne; -import javax.persistence.Table; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; @Entity @Table(name = "Work") @@ -37,14 +37,12 @@ public class Work { private LocalDateTime startTime; private LocalDateTime endTime; - @ManyToOne private Project project; @Lob private String notes; - public Work() { - } + public Work() {} public Work(final LocalDateTime startTime, final LocalDateTime endTime, final Project project, final String notes) { super(); @@ -95,5 +93,4 @@ public String toString() { return "Work [id=" + id + ", startTime=" + startTime + ", endTime=" + endTime + ", projectName=" + project.getName() + ", notes=" + notes + "]"; } - } diff --git a/src/main/java/de/doubleslash/keeptime/model/persistenceconverter/ColorConverter.java b/src/main/java/de/doubleslash/keeptime/model/persistenceconverter/ColorConverter.java index 5bf1c10c..2e40fefa 100644 --- a/src/main/java/de/doubleslash/keeptime/model/persistenceconverter/ColorConverter.java +++ b/src/main/java/de/doubleslash/keeptime/model/persistenceconverter/ColorConverter.java @@ -16,12 +16,10 @@ package de.doubleslash.keeptime.model.persistenceconverter; -import javax.persistence.AttributeConverter; - +import jakarta.persistence.AttributeConverter; import javafx.scene.paint.Color; public class ColorConverter implements AttributeConverter { - @Override public Color convertToEntityAttribute(final String arg0) { try { @@ -35,5 +33,4 @@ public Color convertToEntityAttribute(final String arg0) { public String convertToDatabaseColumn(final Color arg0) { return arg0.toString(); } - } diff --git a/src/main/java/de/doubleslash/keeptime/model/repos/ExternalProjectsMappingsRepository.java b/src/main/java/de/doubleslash/keeptime/model/repos/ExternalProjectsMappingsRepository.java new file mode 100644 index 00000000..863bf8b4 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/model/repos/ExternalProjectsMappingsRepository.java @@ -0,0 +1,29 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.model.repos; + +import de.doubleslash.keeptime.model.ExternalProjectMapping; +import de.doubleslash.keeptime.model.ExternalSystem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ExternalProjectsMappingsRepository extends JpaRepository { + List findByExternalSystemId(ExternalSystem externalSystem); +} diff --git a/src/main/java/de/doubleslash/keeptime/model/repos/ProjectRepository.java b/src/main/java/de/doubleslash/keeptime/model/repos/ProjectRepository.java index 5af3e8cc..e1c7248d 100644 --- a/src/main/java/de/doubleslash/keeptime/model/repos/ProjectRepository.java +++ b/src/main/java/de/doubleslash/keeptime/model/repos/ProjectRepository.java @@ -14,14 +14,16 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . + package de.doubleslash.keeptime.model.repos; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; - import de.doubleslash.keeptime.model.Project; +import java.util.List; + @Repository public interface ProjectRepository extends JpaRepository { - + List findByName(String name); } diff --git a/src/main/java/de/doubleslash/keeptime/model/repos/SettingsRepository.java b/src/main/java/de/doubleslash/keeptime/model/repos/SettingsRepository.java index 006ccf3d..cd8cdaf5 100644 --- a/src/main/java/de/doubleslash/keeptime/model/repos/SettingsRepository.java +++ b/src/main/java/de/doubleslash/keeptime/model/repos/SettingsRepository.java @@ -16,12 +16,11 @@ package de.doubleslash.keeptime.model.repos; +import de.doubleslash.keeptime.model.Setting; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import de.doubleslash.keeptime.model.Settings; - @Repository -public interface SettingsRepository extends JpaRepository { - +public interface SettingsRepository extends JpaRepository { + Setting findBySetting(String key); } diff --git a/src/main/java/de/doubleslash/keeptime/model/repos/WorkRepository.java b/src/main/java/de/doubleslash/keeptime/model/repos/WorkRepository.java index dcee0a4c..b6ac0532 100644 --- a/src/main/java/de/doubleslash/keeptime/model/repos/WorkRepository.java +++ b/src/main/java/de/doubleslash/keeptime/model/repos/WorkRepository.java @@ -16,18 +16,24 @@ package de.doubleslash.keeptime.model.repos; -import java.time.LocalDate; -import java.util.List; - +import de.doubleslash.keeptime.model.Work; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import de.doubleslash.keeptime.model.Work; +import java.time.LocalDate; +import java.util.List; @Repository public interface WorkRepository extends JpaRepository { - @Query(value = "SELECT * FROM work WHERE CAST(start_time AS DATE) = ?1 ORDER BY start_time ASC", nativeQuery = true) + @Query(value = "SELECT w FROM Work w WHERE CAST(startTime AS DATE) = ?1 ORDER BY startTime ASC") List findByStartDateOrderByStartTimeAsc(LocalDate creationDate); + + @Query("SELECT w FROM Work w WHERE " + "(:projectId IS NULL OR w.project.id = :projectId) " + + "AND (:minStartTime IS NULL OR CAST(w.startTime AS DATE) >= :minStartTime) " + + "AND (:maxStartTime IS NULL OR CAST(w.startTime AS DATE) <= :maxStartTime)") + List findWorkItems(@Param("projectId") Long projectId, @Param("minStartTime") LocalDate minStartTime, + @Param("maxStartTime") LocalDate maxStartTime); } diff --git a/src/main/java/de/doubleslash/keeptime/model/settings/HeimatSettings.java b/src/main/java/de/doubleslash/keeptime/model/settings/HeimatSettings.java new file mode 100644 index 00000000..da2defda --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/model/settings/HeimatSettings.java @@ -0,0 +1,59 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.model.settings; + +import org.springframework.stereotype.Service; + +import java.util.Arrays; + +@Service +public class HeimatSettings { + + private final SettingsBase settingsBase; + + public HeimatSettings(SettingsBase settingsBase) { + this.settingsBase = settingsBase; + } + + public boolean isHeimatActive() { + return settingsBase.getBoolean("heimat_active", false); + } + + public void setHeimatActive(boolean heimatActive) { + settingsBase.setBoolean("heimat_active", heimatActive); + } + + public String getHeimatUrl() { + return settingsBase.getString("heimat_url", ""); + } + + public void setHeimatUrl(String heimatUrl) { + settingsBase.setString("heimat_url", heimatUrl); + } + + public String getHeimatPat() { + return settingsBase.getString("heimat_pat", ""); + } + + public void setHeimatPat(String heimatPat) { + settingsBase.setString("heimat_pat", heimatPat); + } + + public void save() { + settingsBase.save(Arrays.asList("heimat_active", "heimat_url", "heimat_pat")); + } +} diff --git a/src/main/java/de/doubleslash/keeptime/model/settings/SettingsBase.java b/src/main/java/de/doubleslash/keeptime/model/settings/SettingsBase.java new file mode 100644 index 00000000..2dacca79 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/model/settings/SettingsBase.java @@ -0,0 +1,128 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.model.settings; + +import de.doubleslash.keeptime.model.Setting; +import de.doubleslash.keeptime.model.persistenceconverter.ColorConverter; +import de.doubleslash.keeptime.model.repos.SettingsRepository; +import javafx.scene.paint.Color; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Caches settings from database to be used by other Settings classes. + */ +@Service +public class SettingsBase { + + private final SettingsRepository settingsRepository; + + private final Map settingsMap; + + public SettingsBase(SettingsRepository settingsRepository) { + this.settingsRepository = settingsRepository; + + settingsMap = settingsRepository.findAll().stream().collect(Collectors.toMap(Setting::getSetting, item -> item)); + } + + public void saveAll() { + settingsRepository.saveAll(settingsMap.values()); + } + + public void save(List settingsToSave) { + settingsRepository.saveAll(settingsMap.entrySet().stream().filter(entry-> settingsToSave.contains(entry.getKey())).map( + Map.Entry::getValue).toList()); + } + + public boolean getBoolean(String key, boolean orDefault) { + final Setting bySetting = settingsMap.get(key); + if (bySetting == null) + return orDefault; + return Boolean.parseBoolean(bySetting.getSettingValue()); + } + + public void setBoolean(String key, boolean value) { + Setting setting = settingsMap.get(key); + if (setting == null) + setting = new Setting(key, ""); + setting.setSettingValue(String.valueOf(value)); + settingsMap.put(key, setting); + } + + String getString(String key, String orDefault) { + final Setting bySetting = settingsMap.get(key); + if (bySetting == null) + return orDefault; + return bySetting.getSettingValue(); + } + + public void setString(String key, String value) { + Setting setting = settingsMap.get(key); + if (setting == null) + setting = new Setting(key, ""); + setting.setSettingValue(value); + settingsMap.put(key, setting); + } + + public Color getColor(String key, Color orDefault) { + Setting setting = settingsMap.get(key); + if (setting == null) + return orDefault; + return new ColorConverter().convertToEntityAttribute(setting.getSettingValue()); + } + + public void setColor(String key, Color value) { + Setting setting = settingsMap.get(key); + if (setting == null) + setting = new Setting(key, ""); + setting.setSettingValue(new ColorConverter().convertToDatabaseColumn(value)); + settingsMap.put(key, setting); + } + + public double getDouble(String key, double orDefault) { + Setting setting = settingsMap.get(key); + if (setting == null) + return orDefault; + return Double.parseDouble(setting.getSettingValue()); + } + + public void setDouble(String key, double value) { + Setting setting = settingsMap.get(key); + if (setting == null) + setting = new Setting(key, ""); + setting.setSettingValue(Double.toString(value)); + settingsMap.put(key, setting); + } + + public int getInt(String key, int orDefault) { + Setting setting = settingsMap.get(key); + if (setting == null) + return orDefault; + return Integer.parseInt(setting.getSettingValue()); + } + + public void setInt(String key, int value) { + Setting setting = settingsMap.get(key); + if (setting == null) + setting = new Setting(key, ""); + setting.setSettingValue(Integer.toString(value)); + settingsMap.put(key, setting); + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/DTO/ProjectDTO.java b/src/main/java/de/doubleslash/keeptime/rest/DTO/ProjectDTO.java new file mode 100644 index 00000000..b309f3e2 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/DTO/ProjectDTO.java @@ -0,0 +1,102 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.DTO; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.PositiveOrZero; + +public class ProjectDTO { + private long id; + @NotEmpty(message = "Name must not be null or empty") + private String name; + private String description; + /** + * Color in format of 0xRRGGBBAA (R=Red, G=Green, B=Blue, A=Alpha). E.g. 0xff0000ff is fully opaque red. + */ + private String color; + private boolean isWork; + @PositiveOrZero(message = "Index must not be negative") + private int index; + private boolean isEnabled; + + public ProjectDTO(long id, String name, String description, String color, boolean isWork, int index, + boolean isEnabled) { + this.id = id; + this.name = name; + this.description = description; + this.color = color; + this.isWork = isWork; + this.index = index; + this.isEnabled = isEnabled; + } + + public long getId() { + return id; + } + + public void setId(final long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public boolean isWork() { + return isWork; + } + + public void setWork(boolean isWork) { + this.isWork = isWork; + } + + public int getIndex() { + return index; + } + + public void setIndex(int index) { + this.index = index; + } + + public boolean isEnabled() { + return isEnabled; + } + + public void setEnabled(final boolean enabled) { + isEnabled = enabled; + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/DTO/ProjectIdentificationDTO.java b/src/main/java/de/doubleslash/keeptime/rest/DTO/ProjectIdentificationDTO.java new file mode 100644 index 00000000..46a63cd4 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/DTO/ProjectIdentificationDTO.java @@ -0,0 +1,35 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.DTO; + +public class ProjectIdentificationDTO { + private long id; + + public ProjectIdentificationDTO() {} + + public ProjectIdentificationDTO(final long id) { + this.id = id; + } + + public long getId() { + return id; + } + + public void setId(final long id) { + this.id = id; + } +} \ No newline at end of file diff --git a/src/main/java/de/doubleslash/keeptime/rest/DTO/WorkDTO.java b/src/main/java/de/doubleslash/keeptime/rest/DTO/WorkDTO.java new file mode 100644 index 00000000..0bb05e5d --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/DTO/WorkDTO.java @@ -0,0 +1,85 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.DTO; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotNull; + +public class WorkDTO { + private long id; + + @NotNull + private LocalDateTime startTime; + + @NotNull + private LocalDateTime endTime; + + @NotNull + private ProjectIdentificationDTO project; + + private String notes; + + public WorkDTO(long id, LocalDateTime startTime, LocalDateTime endTime, ProjectIdentificationDTO project, + String notes) { + this.id = id; + this.startTime = startTime; + this.endTime = endTime; + this.project = project; + this.notes = notes; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public ProjectIdentificationDTO getProject() { + return project; + } + + public void setProject(ProjectIdentificationDTO project) { + this.project = project; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/SecurityConfiguration.java b/src/main/java/de/doubleslash/keeptime/rest/SecurityConfiguration.java new file mode 100644 index 00000000..b40a5ae2 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/SecurityConfiguration.java @@ -0,0 +1,42 @@ +// Copyright 2023 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest; + +import static org.springframework.security.config.Customizer.withDefaults; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfiguration { + + @Bean + public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .httpBasic(withDefaults()); + + return http.build(); + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/controller/FXUtils.java b/src/main/java/de/doubleslash/keeptime/rest/controller/FXUtils.java new file mode 100644 index 00000000..3ea76b98 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/controller/FXUtils.java @@ -0,0 +1,43 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.controller; + +import javafx.application.Platform; + +import java.util.concurrent.CompletableFuture; + +public class FXUtils { + public static void runInFxThreadAndWait(Runnable runnable) { + CompletableFuture future = new CompletableFuture<>(); + + Platform.runLater(() -> { + try { + runnable.run(); + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + + // Wait for the result (blocking for simplicity; adjust as needed for async handling) + try { + future.get(); // This blocks until the CompletableFuture is completed + } catch (Exception e) { + throw new RuntimeException("Error processing request", e); + } + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/controller/ProjectController.java b/src/main/java/de/doubleslash/keeptime/rest/controller/ProjectController.java new file mode 100644 index 00000000..c684ff00 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/controller/ProjectController.java @@ -0,0 +1,204 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.controller; + +import de.doubleslash.keeptime.controller.Controller; +import de.doubleslash.keeptime.model.Model; +import de.doubleslash.keeptime.model.Project; +import de.doubleslash.keeptime.model.Work; +import de.doubleslash.keeptime.model.repos.ProjectRepository; +import de.doubleslash.keeptime.model.repos.WorkRepository; +import de.doubleslash.keeptime.rest.DTO.ProjectDTO; +import de.doubleslash.keeptime.rest.DTO.ProjectIdentificationDTO; +import de.doubleslash.keeptime.rest.DTO.WorkDTO; +import de.doubleslash.keeptime.rest.mapper.ProjectMapper; +import de.doubleslash.keeptime.rest.mapper.WorkMapper; +import jakarta.validation.Valid; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Optional; + +@RestController +@RequestMapping("/api/projects") +public class ProjectController { + private final ProjectRepository projectRepository; + private final WorkRepository workRepository; + private final Controller controller; + private final Model model; + private final WorkMapper workMapper; + private final ProjectMapper projectMapper; + + public ProjectController(final ProjectRepository projectRepository, final WorkRepository workRepository, + final Controller controller, Model model, WorkMapper workMapper, ProjectMapper projectMapper) { + this.projectRepository = projectRepository; + this.workRepository = workRepository; + this.controller = controller; + this.model = model; + this.workMapper = workMapper; + this.projectMapper = projectMapper; + } + + @GetMapping + public ResponseEntity> getProjectColorDTOsByName( + @RequestParam(name = "name", required = false) final String name) { + List projects; + + if (name != null) { + projects = projectRepository.findByName(name); + } else { + projects = projectRepository.findAll(); + } + List projectDTOS = projects.stream().map(projectMapper::projectToProjectDTO).toList(); + return ResponseEntity.ok(projectDTOS); + } + + @GetMapping("/{id}") + public @Valid ProjectDTO getProjectById(@PathVariable final long id) { + final Optional project = projectRepository.findById(id); + + if (project.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Project with id '" + id + "' not found"); + } + return projectMapper.projectToProjectDTO(project.get()); + } + + @GetMapping("/{id}/works") + public List getWorksFromProject(@PathVariable final long id) { + return workRepository.findWorkItems(id, null, null).stream().map(workMapper::workToWorkDTO).toList(); + } + + @PostMapping + public ResponseEntity createProject(@Valid @RequestBody final ProjectDTO newProjectDTO) { + try { + Project newProject = projectMapper.projectDTOToProject(newProjectDTO); + + FXUtils.runInFxThreadAndWait(() -> controller.addNewProject(newProject)); + + ProjectDTO projectDTO = projectMapper.projectToProjectDTO(newProject); + return ResponseEntity.status(HttpStatus.CREATED).body(projectDTO); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @PutMapping("/{id}") + public ResponseEntity updateProject(@PathVariable final long id, + @Valid @RequestBody final ProjectDTO newValuedProjectDTO) { + + if (id != newValuedProjectDTO.getId()) { + return ResponseEntity.badRequest().build(); + } + Optional optionalProject = projectRepository.findById(id); + + if (optionalProject.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + Project existingProject = optionalProject.get(); + + try { + Project newValuedProject = projectMapper.projectDTOToProject(newValuedProjectDTO); + + FXUtils.runInFxThreadAndWait(() -> controller.editProject(existingProject, newValuedProject)); + + ProjectDTO updatedProjectDTO = projectMapper.projectToProjectDTO(existingProject); + + return ResponseEntity.ok(updatedProjectDTO); + } catch (DataAccessException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @PostMapping("/{id}/works") + public ResponseEntity createWorkInProject(@PathVariable final long id, + @Valid @RequestBody final WorkDTO workDTO) { + + if (id != workDTO.getProject().getId()) { + return ResponseEntity.badRequest().build(); + } + + Optional projectOptional = projectRepository.findById(id); + + if (projectOptional.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + final Work newWork = workMapper.workDTOToWork(workDTO); + Project project = projectOptional.get(); + newWork.setProject(project); + + workRepository.save(newWork); + + WorkDTO createdWorkDTO = workMapper.workToWorkDTO(newWork); + + return ResponseEntity.status(HttpStatus.CREATED).body(createdWorkDTO); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteProject(@PathVariable final long id) { + Optional projectOptional = projectRepository.findById(id); + + if (projectOptional.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + Project project = projectOptional.get(); + + if (project.isDefault()) { + return new ResponseEntity<>("Project cannot be deleted as it is the default", HttpStatus.BAD_REQUEST); + } + FXUtils.runInFxThreadAndWait(() -> controller.deleteProject(project)); + + return new ResponseEntity<>("Project successfully deleted", HttpStatus.OK); + } + + @GetMapping("/current") + public ProjectDTO getWorkProjects() { + Project project = model.activeWorkItem.get().getProject(); + return projectMapper.projectToProjectDTO(project); + } + + @PutMapping("/current") + public ResponseEntity changeProject( + @Valid @RequestBody ProjectIdentificationDTO newProject) { + Optional projectOptional = projectRepository.findById(newProject.getId()); + + if (projectOptional.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + try { + FXUtils.runInFxThreadAndWait(() -> controller.changeProject(projectOptional.get())); + + return ResponseEntity.ok(newProject); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); + } + } + + @ResponseStatus(value = HttpStatus.NOT_FOUND) + public static class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/doubleslash/keeptime/rest/controller/WorksController.java b/src/main/java/de/doubleslash/keeptime/rest/controller/WorksController.java new file mode 100644 index 00000000..1e1dcc3f --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/controller/WorksController.java @@ -0,0 +1,109 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.controller; + +import de.doubleslash.keeptime.model.Model; +import de.doubleslash.keeptime.model.Project; +import de.doubleslash.keeptime.model.Work; +import de.doubleslash.keeptime.model.repos.ProjectRepository; +import de.doubleslash.keeptime.model.repos.WorkRepository; +import de.doubleslash.keeptime.rest.DTO.WorkDTO; +import de.doubleslash.keeptime.rest.mapper.WorkMapper; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@RestController +@RequestMapping("/api/works") +public class WorksController { + + private final WorkRepository workRepository; + private final ProjectRepository projectRepository; + private final Model model; + private final WorkMapper workMapper; + + public WorksController(final WorkRepository workRepository, final ProjectRepository projectRepository, Model model, + WorkMapper workMapper) { + this.workRepository = workRepository; + this.projectRepository = projectRepository; + this.model = model; + this.workMapper = workMapper; + } + + @GetMapping + public List getWorks(@RequestParam(name = "projectId", required = false) final Long projectId, + @RequestParam(name = "fromDate", required = false) final LocalDate fromDate, + @RequestParam(name = "toDate", required = false) final LocalDate toDate) { + List works = workRepository.findWorkItems(projectId, fromDate, toDate); + return works.stream().map(workMapper::workToWorkDTO).toList(); + } + + @PutMapping("/{id}") + public ResponseEntity editWork(@PathVariable("id") Long workId, @RequestBody WorkDTO newValuedWorkDTO) { + + if (workId != newValuedWorkDTO.getId()) { + return ResponseEntity.badRequest().build(); + } + + Work newValuedWork = workMapper.workDTOToWork(newValuedWorkDTO); + Optional optionalWork = workRepository.findById(workId); + Optional optionalProject = projectRepository.findById(newValuedWorkDTO.getProject().getId()); + + if (optionalWork.isEmpty() || optionalProject.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + Work workToBeEdited = optionalWork.get(); + + workToBeEdited.setStartTime(newValuedWork.getStartTime()); + workToBeEdited.setEndTime(newValuedWork.getEndTime()); + workToBeEdited.setNotes(newValuedWork.getNotes()); + workToBeEdited.setProject(optionalProject.get()); + + Work editedWork = workRepository.save(workToBeEdited); + + return ResponseEntity.ok(workMapper.workToWorkDTO(editedWork)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteWork(@PathVariable final long id) { + Optional optionalWork = workRepository.findById(id); + + if (optionalWork.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + Work workToBeDeleted = optionalWork.get(); + workRepository.delete(workToBeDeleted); + return new ResponseEntity<>("Work successfully deleted", HttpStatus.OK); + } + + @GetMapping("/current") + public ResponseEntity getCurrentWork() { + Work workProjects = model.activeWorkItem.get(); + + if (workProjects != null) { + return ResponseEntity.ok(workMapper.workToWorkDTO(workProjects)); + } else { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/HeimatAPI.java b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/HeimatAPI.java new file mode 100644 index 00000000..25c3ab15 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/HeimatAPI.java @@ -0,0 +1,107 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.integration.heimat; + +import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask; +import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTime; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +public class HeimatAPI { + + private final RestClient restClient; + + public HeimatAPI(final String baseUrl, final String bearerToken) { + restClient = RestClient.builder() + .baseUrl(baseUrl + "/heimat-core/api/v1/") + .defaultHeader("X-Client-Identifier", "KeepTime") + .defaultHeader("Authorization", "Bearer " + bearerToken) + .defaultHeader("Accept", "application/json") + .build(); + } + + public boolean isLoginValid() { + getMyTasks(); + return true; + } + + public List getMyTasks() { + return getMyTasks(null); + } + + // GET /my/tasks + public List getMyTasks(final LocalDate forDate) { + return restClient.get().uri(uriBuilder -> { + UriBuilder builder = uriBuilder.path("/my/tasks"); + if (forDate != null) { + builder.queryParam("date", forDate.format(DateTimeFormatter.ISO_DATE)); + } + return builder.build(); + }).retrieve().onStatus(HttpStatus.UNAUTHORIZED::equals, (request, response) -> { + throw new UnauthorizedException(); + }).body(new ParameterizedTypeReference<>() {}); + } + + public List getMyTimes() { + return getMyTimes(null); + } + + // GET /my/times + public List getMyTimes(final LocalDate forDate) { + return restClient.get().uri(uriBuilder -> { + UriBuilder builder = uriBuilder.path("/my/times"); + if (forDate != null) { + builder.queryParam("date", forDate.format(DateTimeFormatter.ISO_DATE)); + } + return builder.build(); + }).retrieve().onStatus(HttpStatus.UNAUTHORIZED::equals, (request, response) -> { + throw new UnauthorizedException(); + }).body(new ParameterizedTypeReference<>() {}); + } + + // POST /my/times + public void addMyTime(final HeimatTime heimatTime) { + restClient.post() + .uri(uriBuilder -> { + UriBuilder builder = uriBuilder.path("/my/times"); + return builder.build(); + }) + .header("Content-Type", "application/json") + .body(heimatTime) + .retrieve() + .onStatus(HttpStatus.UNAUTHORIZED::equals, (request, response) -> { + throw new UnauthorizedException(); + }) + .toEntity(String.class); + } + + // DELETE /my/times/{id} + public void deleteMyTime(final long timeId) { + restClient.delete().uri(uriBuilder -> { + UriBuilder builder = uriBuilder.path("/my/times/" + timeId); + return builder.build(); + }).retrieve().onStatus(HttpStatus.UNAUTHORIZED::equals, (request, response) -> { + throw new UnauthorizedException(); + }).toEntity(String.class); + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java new file mode 100644 index 00000000..c5a903d4 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java @@ -0,0 +1,63 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.integration.heimat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Map; + +public class JwtDecoder { + + public record JWTTokenAttributes( + String header, + String payload, + LocalDateTime expiration + ) {} + + + public static JWTTokenAttributes parse(String bearerToken) { + String token = removeBearerPrefix(bearerToken); + + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new IllegalArgumentException("Invalid JWT token format"); + } + + String header = new String(Base64.getUrlDecoder().decode(parts[0])); + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + + ObjectMapper mapper = new ObjectMapper(); + Map claims = null; + try { + claims = mapper.readValue(payload, Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + final LocalDateTime expiration = LocalDateTime.parse((String) claims.get("expiration"), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + return new JWTTokenAttributes(header, payload, expiration); + } + + private static String removeBearerPrefix(String token) { + return token.startsWith("Bearer ") ? token.substring(7) : token; + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/UnauthorizedException.java b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/UnauthorizedException.java new file mode 100644 index 00000000..f677bb36 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/UnauthorizedException.java @@ -0,0 +1,19 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.integration.heimat; + +public class UnauthorizedException extends RuntimeException {} diff --git a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/ExistingAndInvalidMappings.java b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/ExistingAndInvalidMappings.java new file mode 100644 index 00000000..fc650c0e --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/ExistingAndInvalidMappings.java @@ -0,0 +1,24 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.integration.heimat.model; + +import de.doubleslash.keeptime.controller.HeimatController; + +import java.util.List; + +public record ExistingAndInvalidMappings(List validMappings, + List invalidMappingsAsString) {} diff --git a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/HeimatTask.java b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/HeimatTask.java new file mode 100644 index 00000000..847ae54f --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/HeimatTask.java @@ -0,0 +1,28 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.integration.heimat.model; + +public record HeimatTask( + long id, // int64 + String name, + String taskHolderName, + String taskHolderType, + boolean isFavorite, + String bookingHint, + boolean isStartAndEndTimeRequired, + boolean isNoteOptional +) {} diff --git a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/HeimatTime.java b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/HeimatTime.java new file mode 100644 index 00000000..d2e83b2f --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/model/HeimatTime.java @@ -0,0 +1,33 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.integration.heimat.model; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDate; +import java.time.LocalTime; + +public record HeimatTime( + long taskId, // int64 + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate date, // 2024-01-01 + LocalTime start, // 23:59 + LocalTime end, // 23:59 + int durationInMinutes, // int32 + String note, + long id // int64 +) {} diff --git a/src/main/java/de/doubleslash/keeptime/rest/mapper/ColorMapper.java b/src/main/java/de/doubleslash/keeptime/rest/mapper/ColorMapper.java new file mode 100644 index 00000000..8854b1f2 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/mapper/ColorMapper.java @@ -0,0 +1,42 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.mapper; + +import de.doubleslash.keeptime.model.persistenceconverter.ColorConverter; +import javafx.scene.paint.Color; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface ColorMapper { + + ColorConverter colorConverter = new ColorConverter(); + + default String colorToColorDTO(Color color) { + if (color == null) { + return null; + } + return colorConverter.convertToDatabaseColumn(color); + } + + default Color colorDTOToColor(String colorDTO) { + if (colorDTO == null) { + return null; + } + + return colorConverter.convertToEntityAttribute(colorDTO); + } +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/mapper/ProjectMapper.java b/src/main/java/de/doubleslash/keeptime/rest/mapper/ProjectMapper.java new file mode 100644 index 00000000..922983b9 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/mapper/ProjectMapper.java @@ -0,0 +1,29 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.mapper; + +import de.doubleslash.keeptime.rest.DTO.ProjectDTO; +import de.doubleslash.keeptime.model.Project; +import org.mapstruct.Mapper; + +@Mapper(uses = ColorMapper.class, componentModel = "spring") +public interface ProjectMapper { + + ProjectDTO projectToProjectDTO(Project project); + + Project projectDTOToProject(ProjectDTO projectDTO); +} diff --git a/src/main/java/de/doubleslash/keeptime/rest/mapper/WorkMapper.java b/src/main/java/de/doubleslash/keeptime/rest/mapper/WorkMapper.java new file mode 100644 index 00000000..4b57f1e3 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/rest/mapper/WorkMapper.java @@ -0,0 +1,30 @@ +// Copyright 2024 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.rest.mapper; + +import org.mapstruct.Mapper; + +import de.doubleslash.keeptime.model.Work; +import de.doubleslash.keeptime.rest.DTO.WorkDTO; + +@Mapper(componentModel = "spring") +public interface WorkMapper { + + WorkDTO workToWorkDTO(Work work); + + Work workDTOToWork(WorkDTO workDTO); +} diff --git a/src/main/java/de/doubleslash/keeptime/view/ChangeWithTimeDialog.java b/src/main/java/de/doubleslash/keeptime/view/ChangeWithTimeDialog.java index 4120e94c..4c9dfc1a 100644 --- a/src/main/java/de/doubleslash/keeptime/view/ChangeWithTimeDialog.java +++ b/src/main/java/de/doubleslash/keeptime/view/ChangeWithTimeDialog.java @@ -16,6 +16,7 @@ package de.doubleslash.keeptime.view; +import de.doubleslash.keeptime.common.Resources; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,6 +65,10 @@ private void setUpDialog() { okButton.setDefaultButton(true); final Button cancelButton = (Button) getDialogPane().lookupButton(ButtonType.CANCEL); cancelButton.setDefaultButton(false); + okButton.getStyleClass().add("primary-button"); + cancelButton.getStyleClass().add("secondary-button"); + getDialogPane().getStylesheets().add(Resources.getResource(Resources.RESOURCE.CSS_DS_STYLE).toExternalForm()); + final VBox vBox = new VBox(); final Label description = new Label( diff --git a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java new file mode 100644 index 00000000..53a94f64 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java @@ -0,0 +1,308 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.view; + +import de.doubleslash.keeptime.common.ColorHelper; +import de.doubleslash.keeptime.common.Resources; +import de.doubleslash.keeptime.controller.Controller; +import de.doubleslash.keeptime.controller.HeimatController; +import de.doubleslash.keeptime.model.Model; +import de.doubleslash.keeptime.model.Project; +import de.doubleslash.keeptime.rest.integration.heimat.model.ExistingAndInvalidMappings; +import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask; +import javafx.application.Platform; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.transformation.FilteredList; +import javafx.fxml.FXML; +import javafx.scene.control.*; +import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Component +public class ExternalProjectsMapController { + + private static final Logger LOG = LoggerFactory.getLogger(ExternalProjectsMapController.class); + + private final Model model; + private final Controller controller; + private final HeimatController heimatController; + + private Stage thisStage; + + @FXML + private TableView mappingTableView; + + @FXML + private Button saveButton; + + @FXML + private Button cancelButton; + + @FXML + private Button addNewProjectButton; + + @FXML + private DatePicker tasksForDateDatePicker; + + public ExternalProjectsMapController(final Model model, Controller controller, HeimatController heimatController) { + this.model = model; + this.controller = controller; + this.heimatController = heimatController; + } + + public void setStage(final Stage thisStage) { + this.thisStage = thisStage; + } + + @FXML + private void initialize() { + tasksForDateDatePicker.setValue(LocalDate.now()); + tasksForDateDatePicker.setDisable(true); + // TODO add listener on this thing + // but what happens with mapped projects not existing at that date? but actually not related to this feature alone + + final List externalProjects = heimatController.getTasks(tasksForDateDatePicker.getValue()); + final ExistingAndInvalidMappings existingAndInvalidMappings = heimatController.getExistingProjectMappings( + externalProjects); + final List previousProjectMappings = existingAndInvalidMappings.validMappings(); + + final ObservableList newProjectMappings = FXCollections.observableArrayList( + previousProjectMappings); + final FilteredList value = new FilteredList<>(newProjectMappings, + pm -> pm.getProject().isWork()); + mappingTableView.setItems(value); + + // KeepTime Project column + TableColumn keepTimeColumn = new TableColumn<>("KeepTime project"); + keepTimeColumn.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().getProject().getName())); + + // External Project column with dropdown + final ObservableList externalProjectsObservableList = FXCollections.observableArrayList( + externalProjects); + externalProjectsObservableList.add(0, null); // option to clear selection + + TableColumn externalColumn = new TableColumn<>("HEIMAT project"); + externalColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue().getHeimatTask())); + externalColumn.setCellFactory(col -> new TableCell<>() { + // TODO search in box would be nice + private final ComboBox comboBox = new ComboBox<>(externalProjectsObservableList); + + @Override + protected void updateItem(HeimatTask item, boolean empty) { + super.updateItem(item, empty); + // selected item + comboBox.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(HeimatTask item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + setText(item.taskHolderName() + " - " + item.name()); + } + } + }); + + // Dropdown + comboBox.setCellFactory(param -> new ListCell<>() { + @Override + protected void updateItem(HeimatTask item, boolean empty) { + super.updateItem(item, empty); + if (item == null || empty) { + setGraphic(null); + setText(null); + } else { + // TODO maybe show if the project was already mapped + setText(item.taskHolderName() + " - " + item.name()); + } + } + }); + + if (empty) { + setGraphic(null); + setText(null); + } else { + comboBox.setValue(getTableView().getItems().get(getIndex()).getHeimatTask()); + comboBox.setOnAction(e -> { + HeimatController.ProjectMapping mapping = getTableView().getItems().get(getIndex()); + mapping.setHeimatTask(comboBox.getValue()); + }); + setGraphic(comboBox); + setText(null); + } + } + }); + + double scrollbarWidth = 17; // Approximate width of a vertical scrollbar + keepTimeColumn.prefWidthProperty().bind(mappingTableView.widthProperty().subtract(scrollbarWidth).multiply(.4)); + externalColumn.prefWidthProperty().bind(mappingTableView.widthProperty().subtract(scrollbarWidth).multiply(.6)); + + mappingTableView.getColumns().addAll(keepTimeColumn, externalColumn); + + addNewProjectButton.setOnAction(e -> { + final List unmappedHeimatTasks = externalProjects.stream().filter(ht -> { + final boolean alreadyMapped = value.stream() + .anyMatch(mapping -> mapping.getHeimatTask() != null + && mapping.getHeimatTask().id() == ht.id()); + return !alreadyMapped; + }).toList(); + List selectedItems = showMultiSelectDialog(externalProjects, unmappedHeimatTasks); + for (HeimatTask toBeCreatedHeimatTask : selectedItems) { + final int sortIndex = model.getAvailableProjects().size(); + final Project project = controller.addNewProject( + new Project(toBeCreatedHeimatTask.name() + " - " + toBeCreatedHeimatTask.taskHolderName(), + toBeCreatedHeimatTask.bookingHint(), ColorHelper.randomColor(), true, sortIndex)); + newProjectMappings.add(new HeimatController.ProjectMapping(project, toBeCreatedHeimatTask)); + } + }); + + saveButton.setOnAction(ae -> { + heimatController.updateMappings(newProjectMappings); + thisStage.close(); + }); + + cancelButton.setOnAction(ae -> thisStage.close()); + + List warnings = existingAndInvalidMappings.invalidMappingsAsString(); + if (!warnings.isEmpty()) { + Platform.runLater(() -> showInvalidMappingsDialog(warnings)); + } + } + + private List showMultiSelectDialog(final List externalProjects, + List unmappedHeimatTasks) { + Dialog> dialog = new Dialog<>(); + dialog.setTitle("Import HEIMAT projects"); + dialog.setHeaderText("You can select mutliple items"); + dialog.initOwner(this.thisStage); + dialog.setWidth(600); + dialog.setHeight(500); + + // Buttons + ButtonType okButtonType = new ButtonType("OK", ButtonBar.ButtonData.OK_DONE); + ButtonType cancelButtonType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + dialog.getDialogPane().getButtonTypes().addAll(okButtonType, cancelButtonType); + + TableView tableView = new TableView<>(); + TableColumn nameColumn = new TableColumn<>("HEIMAT project"); + nameColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue())); + nameColumn.setCellFactory(param -> new TableCell<>() { + @Override + protected void updateItem(HeimatTask item, boolean empty) { + super.updateItem(item, empty); + if (item == null || empty) { + setGraphic(null); + setText(null); + } else { + setText(item.taskHolderName() + " - " + item.name()); + } + } + }); + + // Column for Mapped Status (Read-Only CheckBox) + TableColumn mappedColumn = new TableColumn<>("Mapped"); + mappedColumn.setCellValueFactory( + cellData -> new SimpleBooleanProperty(!unmappedHeimatTasks.contains(cellData.getValue()))); + mappedColumn.setCellFactory(CheckBoxTableCell.forTableColumn(mappedColumn)); + mappedColumn.setEditable(false); + + mappedColumn.setPrefWidth(75); + tableView.getColumns().addAll(mappedColumn, nameColumn); + tableView.setEditable(false); + + tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + tableView.setItems(FXCollections.observableArrayList(externalProjects)); + + Button selectAllUnmappedButton = new Button("Select unmapped projects (" + unmappedHeimatTasks.size() + ")"); + selectAllUnmappedButton.getStyleClass().add("secondary-button"); + selectAllUnmappedButton.setOnAction(e -> { + tableView.getSelectionModel().clearSelection(); + for (HeimatTask ht : unmappedHeimatTasks) { + tableView.getSelectionModel().select(ht); + } + tableView.requestFocus(); + }); + + VBox content = new VBox(10, selectAllUnmappedButton, tableView); + dialog.getDialogPane().setContent(content); + final List emptyList = List.of(); + dialog.setResultConverter(dialogButton -> { + if (dialogButton == okButtonType) { + return tableView.getSelectionModel().getSelectedItems().stream().toList(); + } + return emptyList; // cancel was clicked + }); + + Button okButton = (Button) dialog.getDialogPane().lookupButton(okButtonType); + okButton.setText("Import (0)"); + okButton.setPrefWidth(100); + okButton.getStyleClass().add("primary-button"); + Button dialogCancelButton = (Button) dialog.getDialogPane().lookupButton(cancelButtonType); + dialogCancelButton.getStyleClass().add("secondary-button"); + dialog.getDialogPane() + .getStylesheets() + .add(Resources.getResource(Resources.RESOURCE.CSS_DS_STYLE).toExternalForm()); + + tableView.getSelectionModel().getSelectedItems().addListener((ListChangeListener) change -> { + int selectedCount = tableView.getSelectionModel().getSelectedItems().size(); + okButton.setText("Import (" + selectedCount + ")"); + }); + + Optional> result = dialog.showAndWait(); + return result.orElse(emptyList); + } + + private void showInvalidMappingsDialog(final List warnings) { + Dialog dialog = new Dialog<>(); + dialog.initOwner(this.thisStage); + dialog.setTitle("Invalid mappings"); + dialog.setHeaderText("Please note to following issue:"); + + VBox warningBox = new VBox(10); + for (String warning : warnings) { + Label label = new Label(warning); + label.setWrapText(true); + warningBox.getChildren().add(label); + } + + ScrollPane scrollPane = new ScrollPane(warningBox); + scrollPane.setFitToWidth(true); + scrollPane.setPrefHeight(Math.min(300, warnings.size() * 30 + 50)); // Adjust height dynamically + + dialog.getDialogPane().setContent(scrollPane); + dialog.getDialogPane().setMinWidth(400); + + // Add OK button + ButtonType okButton = new ButtonType("OK", ButtonBar.ButtonData.OK_DONE); + dialog.getDialogPane().getButtonTypes().add(okButton); + + dialog.showAndWait(); + } +} diff --git a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java new file mode 100644 index 00000000..0f6dac2f --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java @@ -0,0 +1,674 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.view; + +import de.doubleslash.keeptime.common.BrowserHelper; +import de.doubleslash.keeptime.common.DateFormatter; +import de.doubleslash.keeptime.common.Resources; +import de.doubleslash.keeptime.common.SvgNodeProvider; +import de.doubleslash.keeptime.controller.HeimatController; +import de.doubleslash.keeptime.model.Project; +import de.doubleslash.keeptime.model.Work; +import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask; +import javafx.animation.Animation; +import javafx.animation.KeyFrame; +import javafx.animation.RotateTransition; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.binding.StringBinding; +import javafx.beans.property.*; +import javafx.beans.value.ChangeListener; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.transformation.FilteredList; +import javafx.concurrent.Task; +import javafx.fxml.FXML; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.effect.GaussianBlur; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; +import javafx.scene.shape.SVGPath; +import javafx.stage.Stage; +import javafx.util.Duration; +import javafx.util.converter.LocalTimeStringConverter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeParseException; +import java.time.format.FormatStyle; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static de.doubleslash.keeptime.view.ReportController.copyToClipboard; + +@Component +public class ExternalProjectsSyncController { + + private static final Logger LOG = LoggerFactory.getLogger(ExternalProjectsSyncController.class); + + @FXML + private TableView mappingTableView; + + @FXML + private Button saveButton; + + @FXML + private Button cancelButton; + + @FXML + private Label dayOfSyncLabel; + + @FXML + private Label sumTimeLabel; + @FXML + private Label keepTimeTimeLabel; + @FXML + private Label heimatTimeLabel; + + @FXML + private Hyperlink externalSystemLink; + @FXML + private Hyperlink externalSystemLinkLoadingScreen; + + @FXML + private VBox loadingScreen; + + @FXML + private AnchorPane pane; + + @FXML + private Label loadingMessage; + @FXML + private Label loadingClosingMessage; + @FXML + private Region syncingIconRegion; + + @FXML + private ComboBox heimatTaskComboBox; + @FXML + private Button addHeimatTaskButton; + + private final SVGPath loadingSpinner = SvgNodeProvider.getSvgNodeWithScale(Resources.RESOURCE.SVG_SPINNER_SOLID, 0.1, + 0.1); + private final SVGPath loadingSuccess = SvgNodeProvider.getSvgNodeWithScale(Resources.RESOURCE.SVG_THUMBS_UP_SOLID, + 0.1, 0.1); + private final SVGPath loadingFailure = SvgNodeProvider.getSvgNodeWithScale(Resources.RESOURCE.SVG_XMARK_SOLID, 0.1, + 0.1); + private final Color colorLoadingSpinner = Color.valueOf("#00A5E1"); + private final Color colorLoadingSuccess = Color.valueOf("#74a317"); + private final Color colorLoadingFailure = Color.valueOf("#c63329"); + + private final LocalTimeStringConverter localTimeStringConverter = new LocalTimeStringConverter(FormatStyle.MEDIUM); + private ObservableList items; + + private LocalDate currentReportDate; + private Stage thisStage; + private final HeimatController heimatController; + private final RotateTransition loadingSpinnerAnimation = new RotateTransition(Duration.seconds(1), + syncingIconRegion); + + public ExternalProjectsSyncController(final HeimatController heimatController) { + this.heimatController = heimatController; + } + + public void initForDate(LocalDate currentReportDate, List currentWorkItems) { + dayOfSyncLabel.setText(DateFormatter.toDayDateString(currentReportDate)); + this.currentReportDate = currentReportDate; + + // TODO add a spinner while loading? + final List tableRows = heimatController.getTableRows(currentReportDate, + currentWorkItems); + + items = FXCollections.observableArrayList(tableRows.stream().map(mapping -> { + String userNotes = mapping.keeptimeNotes(); + long userSeconds = mapping.keeptimeSeconds(); + // use info from heimat when already present + if (mapping.heimatSeconds() != 0L) { + userNotes = mapping.heimatNotes(); + userSeconds = mapping.heimatSeconds(); + } + return new TableRow(mapping, userNotes, userSeconds); + }).toList()); + + mappingTableView.setItems(items); + + ObservableList items2 = FXCollections.observableArrayList( + item -> new javafx.beans.Observable[] { item.userTimeSeconds, item.shouldSyncCheckBox, item.userNotes }); + items2.addAll(items); + StringBinding totalSum = Bindings.createStringBinding(() -> localTimeStringConverter.toString( + LocalTime.ofSecondOfDay( + items.stream().filter(item -> item.mapping.heimatTaskId() != -1L) // if its bookable in heimat + .mapToLong(item -> { + if (item.shouldSyncCheckBox.get()) + return item.userTimeSeconds.getValue(); + else + return item.heimatTimeSeconds.get(); + }).sum())), items2); + sumTimeLabel.textProperty().bind(totalSum); + + keepTimeTimeLabel.setText(localTimeStringConverter.toString( + LocalTime.ofSecondOfDay(tableRows.stream().mapToLong(HeimatController.Mapping::keeptimeSeconds).sum()))); + heimatTimeLabel.setText(localTimeStringConverter.toString( + LocalTime.ofSecondOfDay(tableRows.stream().mapToLong(HeimatController.Mapping::heimatSeconds).sum()))); + + BooleanBinding projectsValidProperty = Bindings.createBooleanBinding(() -> items.stream().anyMatch(item -> { + boolean shouldSync = item.shouldSyncCheckBox.get(); + boolean hasNote = !item.userNotes.get().isBlank(); + boolean hasTime = areSecondsOfDayValid(item.userTimeSeconds.get()); + return shouldSync && !(hasNote && hasTime); + }), items2); + + saveButton.disableProperty().bind(projectsValidProperty); + externalSystemLink.setOnAction(ae -> BrowserHelper.openURL(heimatController.getUrlForDay(currentReportDate))); + externalSystemLinkLoadingScreen.setOnAction( + ae -> BrowserHelper.openURL(heimatController.getUrlForDay(currentReportDate))); + + final List tasksForDay = heimatController.getTasks(currentReportDate); + final FilteredList tasksNotInList = new FilteredList<>(FXCollections.observableArrayList(tasksForDay), + (task) -> items.stream().noneMatch(tr -> task.id() == tr.mapping.heimatTaskId())); + items.addListener((ListChangeListener) c -> { + final Predicate predicate = tasksNotInList.getPredicate(); + tasksNotInList.setPredicate(null); + tasksNotInList.setPredicate(predicate); + }); + heimatTaskComboBox.setItems(tasksNotInList); + addHeimatTaskButton.disableProperty() + .bind(heimatTaskComboBox.getSelectionModel().selectedItemProperty().isNull()); + addHeimatTaskButton.setOnAction(ae -> { + final HeimatTask task = heimatTaskComboBox.getValue(); + final TableRow addedRow = new TableRow(new HeimatController.Mapping(task.id(), true, true, + "Manually added\n\nSync to " + task.name() + "\n(" + task.taskHolderName() + ")", List.of(), List.of(), + "", "", 0, 0), "", 0); + items.add(addedRow); + items2.add(addedRow); // add new row also to items2 - as it is not added automatically :( + heimatTaskComboBox.getSelectionModel().clearSelection(); + mappingTableView.scrollTo(items.size() - 1); // scroll to newly added row + }); + + } + + @FXML + private void initialize() { + heimatTaskComboBox.setCellFactory(param -> new ListCell<>() { + @Override + protected void updateItem(HeimatTask item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + setText(item.taskHolderName() + " - " + item.name()); + } + } + }); + heimatTaskComboBox.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(HeimatTask item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + setText(item.name() + " - " + item.taskHolderName()); + } + } + }); + initializeLoadingScreen(); + + TableColumn shouldSyncColumn = new TableColumn<>("Sync"); + shouldSyncColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue())); + // Custom Cell Factory to disable CheckBoxes + shouldSyncColumn.setCellFactory(col -> new TableCell<>() { + private final CheckBox checkBox = new CheckBox(); + private ChangeListener boolChangeListener; + + @Override + protected void updateItem(TableRow item, boolean empty) { + super.updateItem(item, empty); + if (boolChangeListener != null) + checkBox.selectedProperty().removeListener(boolChangeListener); + + if (empty || item == null) { + setGraphic(null); + } else { + checkBox.setDisable(!item.mapping.canBeSynced()); + checkBox.setSelected(item.shouldSyncCheckBox.get()); + boolChangeListener = (obs, oldText, newBoolean) -> item.shouldSyncCheckBox.set(newBoolean); + checkBox.selectedProperty().addListener(boolChangeListener); + setAlignment(Pos.TOP_CENTER); + setGraphic(checkBox); + } + } + }); + mappingTableView.setEditable(true); + shouldSyncColumn.setEditable(true); + + TableColumn> projectColumn = new TableColumn<>("Project"); + projectColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue().mapping.projects())); + projectColumn.setCellFactory(column -> new TableCell<>() { + @Override + protected void updateItem(List item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setGraphic(null); + setText(null); + } else { + VBox vbox = new VBox(5); + item.forEach(project -> vbox.getChildren().add(createRow(project.getColor(), project.getName()))); + setGraphic(vbox); + } + } + + private HBox createRow(Color color, String text) { + Circle circle = new Circle(6, color); + Label label = new Label(text); + + return new HBox(5, circle, label); + } + }); + + TableColumn timeColumn = new TableColumn<>("Time"); + timeColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue())); // Placeholder property + + Consumer> spinnerValidConsumer = (Spinner spinner) -> { + final boolean isValid = areSecondsOfDayValid(spinner.getValue().toSecondOfDay()); + markNodeValidOrNot(spinner, isValid); + }; + timeColumn.setCellFactory(column -> new TableCell<>() { + private final Spinner timeSpinner = new Spinner<>(); + private final Label keeptimeLabel = new Label(); + private final Label heimatLabel = new Label(); + private ChangeListener localTimeChangeListener; + private final VBox container = new VBox(5); // Space between TextArea and Label + + { + setUpTimeSpinner(timeSpinner); + container.getChildren().addAll(timeSpinner, keeptimeLabel, heimatLabel); + } + + @Override + protected void updateItem(TableRow item, boolean empty) { + super.updateItem(item, empty); + if (localTimeChangeListener != null) + timeSpinner.valueProperty().removeListener(localTimeChangeListener); + if (empty || item == null) { + setGraphic(null); + } else { + keeptimeLabel.setText("KeepTime: " + localTimeStringConverter.toString( + LocalTime.ofSecondOfDay(item.keeptimeTimeSeconds.get()))); + heimatLabel.setText("HEIMAT: " + localTimeStringConverter.toString( + LocalTime.ofSecondOfDay(item.heimatTimeSeconds.get()))); + timeSpinner.setDisable(!item.mapping.canBeSynced()); + timeSpinner.getValueFactory().setValue(LocalTime.ofSecondOfDay(0)); + if (item.mapping.canBeSynced()) { + timeSpinner.getValueFactory().setValue(LocalTime.ofSecondOfDay(item.userTimeSeconds.get())); + localTimeChangeListener = (observable, oldValue, newValue) -> { + item.userTimeSeconds.set(newValue.toSecondOfDay()); + spinnerValidConsumer.accept(timeSpinner); + }; + spinnerValidConsumer.accept(timeSpinner); + timeSpinner.valueProperty().addListener(localTimeChangeListener); + } + setGraphic(container); + } + } + }); + + TableColumn notesColumn = new TableColumn<>("Notes"); + notesColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue())); // Placeholder property + + Consumer