From 88f099cab4a1fbedbd44f518a4955792aac3ebfd Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Wed, 15 Jan 2025 08:07:43 +0100 Subject: [PATCH 01/15] WIP: AI time savings in time sheets. --- .../org/projectforge/business/timesheet/TimesheetDO.kt | 5 +++++ .../common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 projectforge-business/src/main/resources/flyway/migrate/common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt index 702aef6431..0295dfacac 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt @@ -38,6 +38,7 @@ import org.projectforge.framework.persistence.entities.DefaultBaseDO import org.projectforge.framework.persistence.user.api.UserPrefParameter import org.projectforge.framework.persistence.user.entities.PFUserDO import org.projectforge.framework.time.* +import java.math.BigDecimal import java.util.* /** @@ -110,6 +111,10 @@ open class TimesheetDO : DefaultBaseDO(), Comparable { @get:Column(name = "stop_time", nullable = false) open var stopTime: Date? = null + @PropertyInfo(i18nKey = "timesheet.ai.minutesSavedByAI", tooltip = "timesheet.ai.minutesSavedByAI.info") + @get:Column(name = "minutes_saved_by_ai") + open var minutesSavedByAI: Int? = null + @PropertyInfo(i18nKey = "timesheet.location") @UserPrefParameter(i18nKey = "timesheet.location") @FullTextField diff --git a/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql b/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql new file mode 100644 index 0000000000..242eb6f896 --- /dev/null +++ b/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql @@ -0,0 +1,3 @@ +-- Add parameter AI time savings to t_timesheet: + +ALTER TABLE t_timesheet ADD COLUMN minutesSavedByAI INTEGER; From 26a7fd093b3f86415357272c095ba05035a300db Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Tue, 21 Jan 2025 08:17:58 +0100 Subject: [PATCH 02/15] WIP timesheet: saving by AI --- .../projectforge/business/timesheet/TimesheetDO.kt | 14 +++++++++++--- .../src/main/resources/I18nResources.properties | 8 ++++++-- .../src/main/resources/I18nResources_de.properties | 8 ++++++-- ...V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql | 5 +++++ .../V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql | 3 --- .../org/projectforge/rest/TimesheetPagesRest.kt | 9 ++++++++- .../kotlin/org/projectforge/rest/dto/Timesheet.kt | 7 ++++++- 7 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 projectforge-business/src/main/resources/flyway/migrate/common/V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql delete mode 100644 projectforge-business/src/main/resources/flyway/migrate/common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt index 0295dfacac..cc87ae870f 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt @@ -111,9 +111,17 @@ open class TimesheetDO : DefaultBaseDO(), Comparable { @get:Column(name = "stop_time", nullable = false) open var stopTime: Date? = null - @PropertyInfo(i18nKey = "timesheet.ai.minutesSavedByAI", tooltip = "timesheet.ai.minutesSavedByAI.info") - @get:Column(name = "minutes_saved_by_ai") - open var minutesSavedByAI: Int? = null + @PropertyInfo(i18nKey = "timesheet.ai.timeSavedByAIPercentage", tooltip = "timesheet.ai.timeSavedByAIPercentage.info") + @get:Column(name = "time_saved_by_ai_percentage") + open var timeSavedByAIPercentage: Int? = null + + @PropertyInfo(i18nKey = "timesheet.ai.timeSavedByAIHours", tooltip = "timesheet.ai.timeSavedByAIHours.info") + @get:Column(name = "time_saved_by_ai_hours") + open var timeSavedByAIHours: BigDecimal? = null + + @PropertyInfo(i18nKey = "timesheet.ai.timeSavedByAIDescription", tooltip = "timesheet.ai.timeSavedByAIDescription.info") + @get:Column(name = "time_saved_by_ai_description", length = 1000) + open var timeSavedByAIDescription: String? = null @PropertyInfo(i18nKey = "timesheet.location") @UserPrefParameter(i18nKey = "timesheet.location") diff --git a/projectforge-business/src/main/resources/I18nResources.properties b/projectforge-business/src/main/resources/I18nResources.properties index 523c08487f..ed74511bf9 100644 --- a/projectforge-business/src/main/resources/I18nResources.properties +++ b/projectforge-business/src/main/resources/I18nResources.properties @@ -2370,8 +2370,12 @@ task.wizard.team=Team users (optional) task.wizard.team.intro=The team members are able to create and modify structure sub elements and to book their time sheets. timePeriodPanel.startTimeAfterStopTime=Start time should not be after stop time. timesheet=Time-sheet -timesheet.ai.minutesSavedByAI=Minutes saved by AI -timesheet.ai.minutesSavedByAI.info=Estimated minutes saved by using AI for this time sheet. +timesheet.ai.timeSavedByAIPercentage=Time savings AI [%] +timesheet.ai.timeSavedByAIPercentage.info=Estimated time savings in percent due to the use of AI in this time report. +timesheet.ai.timeSavedByAIHours=Time savings AI [h] +timesheet.ai.timeSavedByAIHours.info=Estimated time savings in hours by using AI in this time report. +timesheet.ai.timeSavedByAIDescription=AI description +timesheet.ai.timeSavedByAIDescription.info=Optional description of which AI technology was used to achieve the time saving. timesheet.break=Break timesheet.description=Activity report timesheet.duration=Duration diff --git a/projectforge-business/src/main/resources/I18nResources_de.properties b/projectforge-business/src/main/resources/I18nResources_de.properties index 9906ccd380..ee200a9c9a 100644 --- a/projectforge-business/src/main/resources/I18nResources_de.properties +++ b/projectforge-business/src/main/resources/I18nResources_de.properties @@ -2453,8 +2453,12 @@ task.wizard.team=Team (optional) task.wizard.team.intro=Die Teammitglieder haben das Recht, Strukturunterelemente anzulegen oder zu ändern, sowie eigene Zeitberichte zu erfassen. timePeriodPanel.startTimeAfterStopTime=Der Beginn muss vor dem Ende liegen. timesheet=Zeitbericht -timesheet.ai.minutesSavedByAI=Gesparte Minuten durch KI -timesheet.ai.minutesSavedByAI.info=Geschätze Zeitersparnis in Minuten durch den Einsatz von KI in diesem Zeitbericht. +timesheet.ai.timeSavedByAIPercentage=KI-Ersparnis [%] +timesheet.ai.timeSavedByAIPercentage.info=Geschätze Zeitersparnis in Prozent durch den Einsatz von KI in diesem Zeitbericht. +timesheet.ai.timeSavedByAIHours=KI-Ersparnis [Stunden] +timesheet.ai.timeSavedByAIHours.info=Geschätze Zeitersparnis in Stunden durch den Einsatz von KI in diesem Zeitbericht. +timesheet.ai.timeSavedByAIDescription=KI-Beschreibung +timesheet.ai.timeSavedByAIDescription.info=Optionale Beschreibung, mit welcher KI-Technologie die Zeitersparnis erreicht wurde. timesheet.break=leer timesheet.description=Tätigkeitsbericht timesheet.duration=Dauer diff --git a/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql b/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql new file mode 100644 index 0000000000..71b8823e75 --- /dev/null +++ b/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql @@ -0,0 +1,5 @@ +-- Add parameters: time savings by usage of AI to t_timesheet: + +ALTER TABLE t_timesheet ADD COLUMN time_saved_by_ai_percentage INTEGER; +ALTER TABLE t_timesheet ADD COLUMN time_saved_by_ai_hours NUMERIC(10, 2); +ALTER TABLE t_timesheet ADD COLUMN time_saved_by_ai_description VARCHAR(1000); diff --git a/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql b/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql deleted file mode 100644 index 242eb6f896..0000000000 --- a/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.7__RELEASE-TimesheetDO_AI-TimeSavings.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add parameter AI time savings to t_timesheet: - -ALTER TABLE t_timesheet ADD COLUMN minutesSavedByAI INTEGER; diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt index c63ca7f79f..9df2b6450a 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt @@ -24,6 +24,7 @@ package org.projectforge.rest import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid import org.projectforge.Constants import org.projectforge.business.PfCaches import org.projectforge.business.fibu.kost.KostCache @@ -58,7 +59,6 @@ import org.projectforge.ui.filter.UIFilterElement import org.springframework.beans.factory.annotation.Autowired import org.springframework.web.bind.annotation.* import java.util.* -import jakarta.validation.Valid @RestController @RequestMapping("${Rest.URL}/timesheet") @@ -336,6 +336,13 @@ class TimesheetPagesRest : AbstractDTOPagesRest() +) : BaseDTO() { + var timeSavedByAIPercentage: Int? = null + var timeSavedByAIHours: BigDecimal? = null + var timeSavedByAIDescription: String? = null +} From cbffabd60c3dc161959bc599f615c1e8c2fd2b63 Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Wed, 22 Jan 2025 00:06:37 +0100 Subject: [PATCH 03/15] WIP: time savings by AI --- .../src/main/resources/i18nKeys.json | 14 +++++--- .../business/timesheet/TimesheetDO.kt | 22 +++++++++---- .../business/timesheet/TimesheetDao.kt | 21 ++++++++++++ .../main/resources/I18nResources.properties | 12 ++++--- .../resources/I18nResources_de.properties | 12 ++++--- ...12__RELEASE-TimesheetDO_AI-TimeSavings.sql | 4 +-- .../projectforge/rest/TimesheetPagesRest.kt | 22 +++++++++++-- .../org/projectforge/rest/dto/Timesheet.kt | 33 ++++++++++--------- 8 files changed, 100 insertions(+), 40 deletions(-) diff --git a/projectforge-application/src/main/resources/i18nKeys.json b/projectforge-application/src/main/resources/i18nKeys.json index 593541fe6b..6c5ba9c1c3 100644 --- a/projectforge-application/src/main/resources/i18nKeys.json +++ b/projectforge-application/src/main/resources/i18nKeys.json @@ -1340,7 +1340,7 @@ {"i18nKey":"history.propertyName","bundleName":"I18nResources","translation":"Property","translationDE":"Feld","usedInClasses":["org.projectforge.web.wicket.AbstractEditPage"],"usedInFiles":["./projectforge-business/src/main/resources/mail/mailHistoryTable.html"]}, {"i18nKey":"history.was","bundleName":"I18nResources","translation":"was","translationDE":"war","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"holidays","bundleName":"I18nResources","translation":"Holidays","translationDE":"Feiertage","usedInClasses":["org.projectforge.business.teamcal.model.CalendarFeedConst","org.projectforge.rest.calendar.CalendarSubscriptionInfoPageRest","org.projectforge.rest.pub.CalendarSubscriptionServiceRest"],"usedInFiles":[]}, - {"i18nKey":"hours","bundleName":"I18nResources","translation":"Hours","translationDE":"Stunden","usedInClasses":["org.projectforge.business.fibu.datev.EmployeeSalaryExportDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.framework.i18n.Duration","org.projectforge.framework.i18n.TimeAgo","org.projectforge.statistics.TimesheetDisciplineChartBuilder","org.projectforge.web.humanresources.HRPlanningEditForm"],"usedInFiles":[]}, + {"i18nKey":"hours","bundleName":"I18nResources","translation":"Hours","translationDE":"Stunden","usedInClasses":["org.projectforge.business.fibu.datev.EmployeeSalaryExportDao","org.projectforge.business.timesheet.TimesheetDO","org.projectforge.business.timesheet.TimesheetDO$TimeSavedByAIUnit","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.framework.i18n.Duration","org.projectforge.framework.i18n.TimeAgo","org.projectforge.statistics.TimesheetDisciplineChartBuilder","org.projectforge.web.humanresources.HRPlanningEditForm"],"usedInFiles":[]}, {"i18nKey":"hr.planning.description","bundleName":"I18nResources","translation":"Activity report","translationDE":"Tätigkeit","usedInClasses":["org.projectforge.business.humanresources.HRPlanningEntryDO","org.projectforge.web.humanresources.HRPlanningEditForm","org.projectforge.web.humanresources.HRPlanningListPage"],"usedInFiles":[]}, {"i18nKey":"hr.planning.entry.copyFromPredecessor","bundleName":"I18nResources","translation":"Copy from predecessor","translationDE":"Vorgänger kopieren","usedInClasses":["org.projectforge.web.humanresources.HRPlanningEditForm"],"usedInFiles":[]}, {"i18nKey":"hr.planning.entry.error.entryDoesAlreadyExistForUserAndWeekOfYear","bundleName":"I18nResources","translation":"An entry with the same user and week of year does already exist.","translationDE":"Dieser Eintrag kollidiert mit einem bereits vorhandenem für den gleichen Benutzer für die gleiche Kalenderwoche.","usedInClasses":["org.projectforge.web.humanresources.HRPlanningEditForm"],"usedInFiles":[]}, @@ -1781,7 +1781,7 @@ {"i18nKey":"password.reset.username_email","bundleName":"I18nResources","translation":"Username/e-mail","translationDE":"Benutzer:inname/E-Mail","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"passwordRepeat","bundleName":"I18nResources","translation":"Repeat password","translationDE":"Passwort wiederholen","usedInClasses":["org.projectforge.rest.ChangePasswordPageRest","org.projectforge.rest.pub.PasswordResetPageRest","org.projectforge.web.admin.SetupForm"],"usedInFiles":[]}, {"i18nKey":"paste","bundleName":"I18nResources","translation":"Paste","translationDE":"Einfügen","usedInClasses":["org.projectforge.web.gantt.GanttChartEditTreeTablePanel"],"usedInFiles":[]}, - {"i18nKey":"percent","bundleName":"I18nResources","translation":"Percent","translationDE":"Prozent","usedInClasses":[],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/fibu/RechnungCostEditTablePanel.html","./projectforge-wicket/src/main/java/org/projectforge/web/fibu/RechnungCostTablePanel.html"]}, + {"i18nKey":"percent","bundleName":"I18nResources","translation":"Percent","translationDE":"Prozent","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO","org.projectforge.business.timesheet.TimesheetDO$TimeSavedByAIUnit"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/fibu/RechnungCostEditTablePanel.html","./projectforge-wicket/src/main/java/org/projectforge/web/fibu/RechnungCostTablePanel.html"]}, {"i18nKey":"personal.statistics.timesheetDisciplineChart.title","bundleName":"I18nResources","translation":"Early booking of time sheets is essentiell for a successful project management!","translationDE":"Zeitnahes Erfassen von Zeitberichten ist ein wichtiger Grundpfeiler erfolgreichen Projektmanagements!","usedInClasses":["org.projectforge.web.statistics.PersonalStatisticsPage"],"usedInFiles":[]}, {"i18nKey":"personal.statistics.timesheetDisciplineChart1.legend","bundleName":"I18nResources","translation":"The last {0} days results in {1} workhours. For this period {2} hours where already booked.","translationDE":"Insgesamt sind {1} Arbeitszeitstunden in den letzten {0} Tagen angefallen. Bisher wurden für diesen Zeitraum {2} Stunden erfasst.","usedInClasses":["org.projectforge.web.statistics.PersonalStatisticsPage"],"usedInFiles":[]}, {"i18nKey":"personal.statistics.timesheetDisciplineChart2.legend","bundleName":"I18nResources","translation":"In the last {0} days time sheets were booked after {2} days (average). The goal is to achieve {1} days at maximum.","translationDE":"In den letzten {0} Tagen wurden Zeitberichte nach durchschnittlich {2} Tagen erfasst. Die Zielgröße ist maximal {1} Tage.","usedInClasses":["org.projectforge.web.statistics.PersonalStatisticsPage"],"usedInFiles":[]}, @@ -2403,8 +2403,14 @@ {"i18nKey":"timeleft.years","bundleName":"I18nResources","translation":"in {0} years","translationDE":"in {0} Jahren","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"timeleft.years.one","bundleName":"I18nResources","translation":"in a year","translationDE":"in einem Jahr","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"timesheet","bundleName":"I18nResources","translation":"Time-sheet","translationDE":"Zeitbericht","usedInClasses":["org.projectforge.Constants","org.projectforge.business.timesheet.TimesheetFavoritesService","org.projectforge.framework.persistence.DaoConst","org.projectforge.registry.Registry","org.projectforge.rest.calendar.CalendarServicesRest","org.projectforge.rest.calendar.FullCalendarEvent","org.projectforge.rest.calendar.TimesheetEventsProvider","org.projectforge.web.calendar.TimesheetEventsProvider","org.projectforge.web.timesheet.TimesheetEditPage","org.projectforge.web.timesheet.TimesheetListPage"],"usedInFiles":[]}, - {"i18nKey":"timesheet.ai.minutesSavedByAI","bundleName":"I18nResources","translation":"Minutes saved by AI","translationDE":"Gesparte Minuten durch KI","usedInClasses":[],"usedInFiles":[]}, - {"i18nKey":"timesheet.ai.minutesSavedByAI.info","bundleName":"I18nResources","translation":"Estimated minutes saved by using AI for this time sheet.","translationDE":"Geschätze Zeitersparnis in Minuten durch den Einsatz von KI in diesem Zeitbericht.","usedInClasses":[],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAI","bundleName":"I18nResources","translation":"Time savings AI","translationDE":"KI-Ersparnis","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAI.error.invalidPercentage","bundleName":"I18nResources","translation":"The percentage is invalid. It must be between 0 and 99.","translationDE":"Die Prozentangabe ist ungültig. Sie muss zwischen 0 und 99 liegen.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDao"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAI.error.unitMissing","bundleName":"I18nResources","translation":"Please specify unit for AI savings.","translationDE":"Bitte Einheit für die KI-Ersparnis angeben.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDao"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAI.info","bundleName":"I18nResources","translation":"Estimated time savings in percent or hours due to the use of AI in this time report.\nThis information is used in evaluations and reports.","translationDE":"Geschätze Zeitersparnis in Prozent oder Stunden durch den Einsatz von KI in diesem Zeitbericht.\nDiese Angabe wird in Auswertungen und Reports verwendet.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAIDescription","bundleName":"I18nResources","translation":"AI description","translationDE":"KI-Beschreibung","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAIDescription.info","bundleName":"I18nResources","translation":"Optional description of which AI technology or methods were used to achieve the time saving.","translationDE":"Optionale Beschreibung, mit welchen KI-Technologien oder Methoden die Zeitersparnis erreicht wurde.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAIUnit","bundleName":"I18nResources","translation":"Unit","translationDE":"Einheit","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO","org.projectforge.rest.TimesheetPagesRest"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAIUnit.info","bundleName":"I18nResources","translation":"Percentage time savings in percent in this time report (e.g. 10% of 10 hours is 1 hour) or in hours:\nNote: A time report of one hour can also save many hours or days of effort.","translationDE":"Prozentuale Ersparnis in Prozent in diesem Zeitbericht (also z. B. 10% von 10 Stunden ist 1 Stunde) oder in Stunden:\nBeachte: Es kann bei einem Zeitbericht von einer Stunde auch ein Aufwand von vielen Stunden oder Tagen eingespart werden. ","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO","org.projectforge.rest.TimesheetPagesRest"],"usedInFiles":[]}, {"i18nKey":"timesheet.break","bundleName":"I18nResources","translation":"Break","translationDE":"leer","usedInClasses":["org.projectforge.rest.calendar.TimesheetEventsProvider","org.projectforge.web.calendar.TimesheetEventsProvider"],"usedInFiles":[]}, {"i18nKey":"timesheet.description","bundleName":"I18nResources","translation":"Activity report","translationDE":"Tätigkeitsbericht","usedInClasses":["org.projectforge.business.humanresources.HRPlanningExport","org.projectforge.business.timesheet.TimesheetDO","org.projectforge.rest.TimesheetPagesRest","org.projectforge.web.timesheet.TimesheetEditForm","org.projectforge.web.timesheet.TimesheetEditSelectRecentDialogPanel"],"usedInFiles":[]}, {"i18nKey":"timesheet.duration","bundleName":"I18nResources","translation":"Duration","translationDE":"Dauer","usedInClasses":["org.projectforge.business.timesheet.TimesheetExport","org.projectforge.renderer.custom.MicromataFormatter","org.projectforge.rest.TimesheetPagesRest","org.projectforge.web.calendar.CalendarForm","org.projectforge.web.teamcal.event.MyWicketEvent","org.projectforge.web.timesheet.TimesheetListPage"],"usedInFiles":[]}, diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt index cc87ae870f..f01f5e318e 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt @@ -32,6 +32,7 @@ import org.hibernate.search.mapper.pojo.mapping.definition.annotation.* import org.projectforge.business.fibu.kost.Kost2DO import org.projectforge.business.task.TaskDO import org.projectforge.common.anots.PropertyInfo +import org.projectforge.common.i18n.I18nEnum import org.projectforge.framework.calendar.DurationUtils import org.projectforge.framework.json.IdOnlySerializer import org.projectforge.framework.persistence.entities.DefaultBaseDO @@ -77,6 +78,10 @@ import java.util.* ) ) open class TimesheetDO : DefaultBaseDO(), Comparable { + enum class TimeSavedByAIUnit(override val i18nKey: String) : I18nEnum { + PERCENTAGE("percent"), + HOURS("hours"); + } @PropertyInfo(i18nKey = "task") @UserPrefParameter(i18nKey = "task", orderString = "2") @@ -111,15 +116,18 @@ open class TimesheetDO : DefaultBaseDO(), Comparable { @get:Column(name = "stop_time", nullable = false) open var stopTime: Date? = null - @PropertyInfo(i18nKey = "timesheet.ai.timeSavedByAIPercentage", tooltip = "timesheet.ai.timeSavedByAIPercentage.info") - @get:Column(name = "time_saved_by_ai_percentage") - open var timeSavedByAIPercentage: Int? = null + @PropertyInfo(i18nKey = "timesheet.ai.timeSavedByAI", tooltip = "timesheet.ai.timeSavedByAI.info") + @get:Column(name = "time_saved_by_ai") + open var timeSavedByAI: BigDecimal? = null - @PropertyInfo(i18nKey = "timesheet.ai.timeSavedByAIHours", tooltip = "timesheet.ai.timeSavedByAIHours.info") - @get:Column(name = "time_saved_by_ai_hours") - open var timeSavedByAIHours: BigDecimal? = null + @PropertyInfo(i18nKey = "timesheet.ai.timeSavedByAIUnit", tooltip = "timesheet.ai.timeSavedByAIUnit.info") + @get:Column(name = "time_saved_by_ai_unit") + open var timeSavedByAIUnit: TimeSavedByAIUnit? = null - @PropertyInfo(i18nKey = "timesheet.ai.timeSavedByAIDescription", tooltip = "timesheet.ai.timeSavedByAIDescription.info") + @PropertyInfo( + i18nKey = "timesheet.ai.timeSavedByAIDescription", + tooltip = "timesheet.ai.timeSavedByAIDescription.info" + ) @get:Column(name = "time_saved_by_ai_description", length = 1000) open var timeSavedByAIDescription: String? = null diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDao.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDao.kt index a2a76b2840..b6a6902f34 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDao.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDao.kt @@ -36,6 +36,7 @@ import org.projectforge.business.task.TaskNode import org.projectforge.business.task.TaskTree import org.projectforge.business.user.ProjectForgeGroup import org.projectforge.business.user.UserDao +import org.projectforge.common.extensions.isZeroOrNull import org.projectforge.common.i18n.MessageParam import org.projectforge.common.i18n.UserException import org.projectforge.common.task.TaskStatus @@ -67,6 +68,7 @@ import org.projectforge.framework.time.PFDateTime.Companion.now import org.projectforge.framework.utils.NumberHelper.isEqual import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service +import java.math.BigDecimal import java.util.* private val log = KotlinLogging.logger {} @@ -277,6 +279,7 @@ open class TimesheetDao : BaseDao(TimesheetDO::class.java) { override fun onInsertOrModify(obj: TimesheetDO, operationType: OperationType) { validateTimestamp(obj.startTime, "startTime") validateTimestamp(obj.stopTime, "stopTime") + validateTimeSavingsByAI(obj)?.let { throw UserException(it) } if (obj.duration < 60000) { throw UserException("timesheet.error.zeroDuration") // "Duration of time sheet must be at minimum 60s! } @@ -340,6 +343,24 @@ open class TimesheetDao : BaseDao(TimesheetDO::class.java) { } } + fun validateTimeSavingsByAI(timesheet: TimesheetDO): String? { + return validateTimeSavingsByAI(timesheet.timeSavedByAI, timesheet.timeSavedByAIUnit) + } + + fun validateTimeSavingsByAI(timeSavedByAI: BigDecimal?, unit: TimesheetDO.TimeSavedByAIUnit?): String? { + if (timeSavedByAI.isZeroOrNull()) { + return null + } + unit ?: return "timesheet.ai.timeSavedByAI.error.unitMissing" + if (unit == TimesheetDO.TimeSavedByAIUnit.PERCENTAGE) { + if (timeSavedByAI!! < BigDecimal.ZERO || timeSavedByAI > BigDecimal(99)) { + return "timesheet.ai.timeSavedByAI.error.invalidPercentage" + } + } + // All values (also negative ones) are allowed for hours. + return null + } + private fun validateTimestamp(date: Date?, name: String) { if (date == null) { return diff --git a/projectforge-business/src/main/resources/I18nResources.properties b/projectforge-business/src/main/resources/I18nResources.properties index ed74511bf9..e80a33a0ba 100644 --- a/projectforge-business/src/main/resources/I18nResources.properties +++ b/projectforge-business/src/main/resources/I18nResources.properties @@ -2370,12 +2370,14 @@ task.wizard.team=Team users (optional) task.wizard.team.intro=The team members are able to create and modify structure sub elements and to book their time sheets. timePeriodPanel.startTimeAfterStopTime=Start time should not be after stop time. timesheet=Time-sheet -timesheet.ai.timeSavedByAIPercentage=Time savings AI [%] -timesheet.ai.timeSavedByAIPercentage.info=Estimated time savings in percent due to the use of AI in this time report. -timesheet.ai.timeSavedByAIHours=Time savings AI [h] -timesheet.ai.timeSavedByAIHours.info=Estimated time savings in hours by using AI in this time report. +timesheet.ai.timeSavedByAI=Time savings AI +timesheet.ai.timeSavedByAI.error.invalidPercentage=The percentage is invalid. It must be between 0 and 99. +timesheet.ai.timeSavedByAI.error.unitMissing=Please specify unit for AI savings. +timesheet.ai.timeSavedByAI.info=Estimated time savings in percent or hours due to the use of AI in this time report.\nThis information is used in evaluations and reports. timesheet.ai.timeSavedByAIDescription=AI description -timesheet.ai.timeSavedByAIDescription.info=Optional description of which AI technology was used to achieve the time saving. +timesheet.ai.timeSavedByAIDescription.info=Optional description of which AI technology or methods were used to achieve the time saving. +timesheet.ai.timeSavedByAIUnit=Unit +timesheet.ai.timeSavedByAIUnit.info=Percentage time savings in percent in this time report (e.g. 10% of 10 hours is 1 hour) or in hours:\nNote: A time report of one hour can also save many hours or days of effort. timesheet.break=Break timesheet.description=Activity report timesheet.duration=Duration diff --git a/projectforge-business/src/main/resources/I18nResources_de.properties b/projectforge-business/src/main/resources/I18nResources_de.properties index ee200a9c9a..a82a3f98e7 100644 --- a/projectforge-business/src/main/resources/I18nResources_de.properties +++ b/projectforge-business/src/main/resources/I18nResources_de.properties @@ -2453,12 +2453,14 @@ task.wizard.team=Team (optional) task.wizard.team.intro=Die Teammitglieder haben das Recht, Strukturunterelemente anzulegen oder zu ändern, sowie eigene Zeitberichte zu erfassen. timePeriodPanel.startTimeAfterStopTime=Der Beginn muss vor dem Ende liegen. timesheet=Zeitbericht -timesheet.ai.timeSavedByAIPercentage=KI-Ersparnis [%] -timesheet.ai.timeSavedByAIPercentage.info=Geschätze Zeitersparnis in Prozent durch den Einsatz von KI in diesem Zeitbericht. -timesheet.ai.timeSavedByAIHours=KI-Ersparnis [Stunden] -timesheet.ai.timeSavedByAIHours.info=Geschätze Zeitersparnis in Stunden durch den Einsatz von KI in diesem Zeitbericht. +timesheet.ai.timeSavedByAI=KI-Ersparnis +timesheet.ai.timeSavedByAI.error.invalidPercentage=Die Prozentangabe ist ungültig. Sie muss zwischen 0 und 99 liegen. +timesheet.ai.timeSavedByAI.error.unitMissing=Bitte Einheit für die KI-Ersparnis angeben. +timesheet.ai.timeSavedByAI.info=Geschätze Zeitersparnis in Prozent oder Stunden durch den Einsatz von KI in diesem Zeitbericht.\nDiese Angabe wird in Auswertungen und Reports verwendet. timesheet.ai.timeSavedByAIDescription=KI-Beschreibung -timesheet.ai.timeSavedByAIDescription.info=Optionale Beschreibung, mit welcher KI-Technologie die Zeitersparnis erreicht wurde. +timesheet.ai.timeSavedByAIDescription.info=Optionale Beschreibung, mit welchen KI-Technologien oder Methoden die Zeitersparnis erreicht wurde. +timesheet.ai.timeSavedByAIUnit=Einheit +timesheet.ai.timeSavedByAIUnit.info=Prozentuale Ersparnis in Prozent in diesem Zeitbericht (also z. B. 10% von 10 Stunden ist 1 Stunde) oder in Stunden:\nBeachte: Es kann bei einem Zeitbericht von einer Stunde auch ein Aufwand von vielen Stunden oder Tagen eingespart werden. timesheet.break=leer timesheet.description=Tätigkeitsbericht timesheet.duration=Dauer diff --git a/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql b/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql index 71b8823e75..08a1ddabc5 100644 --- a/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql +++ b/projectforge-business/src/main/resources/flyway/migrate/common/V8.0.12__RELEASE-TimesheetDO_AI-TimeSavings.sql @@ -1,5 +1,5 @@ -- Add parameters: time savings by usage of AI to t_timesheet: -ALTER TABLE t_timesheet ADD COLUMN time_saved_by_ai_percentage INTEGER; -ALTER TABLE t_timesheet ADD COLUMN time_saved_by_ai_hours NUMERIC(10, 2); +ALTER TABLE t_timesheet ADD COLUMN time_saved_by_ai NUMERIC(10, 2); +ALTER TABLE t_timesheet ADD COLUMN time_saved_by_ai_unit CHAR(10); ALTER TABLE t_timesheet ADD COLUMN time_saved_by_ai_description VARCHAR(1000); diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt index 9df2b6450a..de77bba24e 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt @@ -30,6 +30,7 @@ import org.projectforge.business.PfCaches import org.projectforge.business.fibu.kost.KostCache import org.projectforge.business.fibu.kost.KundeCache import org.projectforge.business.fibu.kost.ProjektCache +import org.projectforge.business.scripting.ScriptParameterType import org.projectforge.business.system.SystemInfoCache import org.projectforge.business.task.TaskTree import org.projectforge.business.timesheet.* @@ -184,6 +185,12 @@ class TimesheetPagesRest : AbstractDTOPagesRest, dto: Timesheet) { + timesheetDao.validateTimeSavingsByAI(dto.timeSavedByAI, dto.timeSavedByAIUnit)?.let { + validationErrors.add(ValidationError(translate(it), fieldId = "timeSavedByAI")) + } + } + override fun onAfterEdit(obj: TimesheetDO, postData: PostData, event: RestButtonEvent): ResponseAction { // Save time sheet as recent time sheet val timesheet = postData.data @@ -338,8 +345,19 @@ class TimesheetPagesRest : AbstractDTOPagesRest( + "timeSavedByAIUnit", + required = true, + label = "timesheet.ai.timeSavedByAIUnit", + tooltip = "timesheet.ai.timeSavedByAIUnit.info", + ).buildValues( + TimesheetDO.TimeSavedByAIUnit::class.java + ) + ) + ) .add(UICol(md = 6).add(lc, TimesheetDO::timeSavedByAIDescription)) ) diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/dto/Timesheet.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/dto/Timesheet.kt index 2780ce45b1..4246016ecd 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/dto/Timesheet.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/dto/Timesheet.kt @@ -25,25 +25,28 @@ package org.projectforge.rest.dto import com.fasterxml.jackson.annotation.JsonIgnoreProperties import org.projectforge.business.timesheet.TimesheetDO +import org.projectforge.common.i18n.I18nEnum import java.math.BigDecimal import java.util.* +// Json ignore: These are properties by calendar events, not by timesheets. They exist after switching from calendar events to timesheets. @JsonIgnoreProperties(value = ["reminderDuration", "reminderDurationUnit"]) -class Timesheet(var task: Task? = null, - var location: String? = null, - var reference: String? = null, - var tag: String? = null, - var description: String? = null, - var user: User? = null, - var kost2: Kost2? = null, - var startTime: Date? = null, - var stopTime: Date? = null, - /** - * A counter (incremented by one for each recent entry) usable by React as key. - */ - var counter: Int? = null +class Timesheet( + var task: Task? = null, + var location: String? = null, + var reference: String? = null, + var tag: String? = null, + var description: String? = null, + var user: User? = null, + var kost2: Kost2? = null, + var startTime: Date? = null, + var stopTime: Date? = null, + /** + * A counter (incremented by one for each recent entry) usable by React as key. + */ + var counter: Int? = null ) : BaseDTO() { - var timeSavedByAIPercentage: Int? = null - var timeSavedByAIHours: BigDecimal? = null + var timeSavedByAI: BigDecimal? = null + var timeSavedByAIUnit: TimesheetDO.TimeSavedByAIUnit? = TimesheetDO.TimeSavedByAIUnit.PERCENTAGE var timeSavedByAIDescription: String? = null } From 1449f4859142a9ea1ee2cc6ed9cb432468d7fc38 Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Wed, 22 Jan 2025 23:45:01 +0100 Subject: [PATCH 04/15] WIP: timesavings by AI in timesheets, config param for note added. --- .../src/main/resources/i18nKeys.json | 4 ++- .../configuration/ConfigurationParam.java | 4 +++ .../configuration/ConfigurationService.kt | 5 +++ .../business/timesheet/TimesheetDao.kt | 5 +++ .../main/resources/I18nResources.properties | 2 ++ .../resources/I18nResources_de.properties | 2 ++ .../src/main/resources/application.properties | 3 ++ .../projectforge/rest/TimesheetPagesRest.kt | 35 +++++++++++-------- 8 files changed, 44 insertions(+), 16 deletions(-) diff --git a/projectforge-application/src/main/resources/i18nKeys.json b/projectforge-application/src/main/resources/i18nKeys.json index 6c5ba9c1c3..0e83f9c1b5 100644 --- a/projectforge-application/src/main/resources/i18nKeys.json +++ b/projectforge-application/src/main/resources/i18nKeys.json @@ -385,6 +385,8 @@ {"i18nKey":"administration.configuration.param.systemAdministratorEMail","bundleName":"I18nResources","translation":"E-mail of administrators","translationDE":"E-Mail-Adressen der Systemadministratoren","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"administration.configuration.param.systemAdministratorEMail.description","bundleName":"I18nResources","translation":"ProjectForge sends e-mails to this address(es) in the case of special errors. You can specify one or more (coma separated) addresses (RFC822).","translationDE":"Im schwerwiegenderen Fehlerfalle versendet ProjectForge eine E-Mail an diese Adresse. Es können mehrere Adressen eingegeben werden, getrennt durch Leerzeichen und Kommata (RFC822).","usedInClasses":["org.projectforge.web.admin.SetupForm"],"usedInFiles":[]}, {"i18nKey":"administration.configuration.param.systemAdministratorEMail.label","bundleName":"I18nResources","translation":"Administrators","translationDE":"Administrator:innen","usedInClasses":["org.projectforge.web.admin.SetupForm"],"usedInFiles":[]}, + {"i18nKey":"administration.configuration.param.timesheet.noteSavingsByAI","bundleName":"I18nResources","translation":"Note regarding AI savings","translationDE":"Hinweis KI-Einsparungen","usedInClasses":[],"usedInFiles":[]}, + {"i18nKey":"administration.configuration.param.timesheet.noteSavingsByAI.description","bundleName":"I18nResources","translation":"Note in the time reports regarding AI savings (markdown is supported, only visible if: projectforge.timesheets.timeSavingsByAI.enabled=true)","translationDE":"Hinweis in den Zeitberichten hinsichtlich KI-Einsparungen (Markdown wird unterstützt, nur sichtbar bei: projectforge.timesheets.timeSavingsByAI.enabled=true)","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"administration.configuration.param.timesheetTags","bundleName":"I18nResources","translation":"Time sheet tags","translationDE":"Tags für Zeitberichte","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"administration.configuration.param.timesheetTags.description","bundleName":"I18nResources","translation":"If defined, a semicolon separated list of tags (strings). On each time sheet the user may add one of these tags.","translationDE":"Hier können Semikolon-separierte Tags für die Verwendung in Zeitberichten angegeben werden. Diese Tags gelten global.","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"administration.configuration.param.timezone","bundleName":"I18nResources","translation":"Default time zone","translationDE":"Standard-Zeitzone","usedInClasses":["org.projectforge.web.admin.SetupForm"],"usedInFiles":[]}, @@ -2403,7 +2405,7 @@ {"i18nKey":"timeleft.years","bundleName":"I18nResources","translation":"in {0} years","translationDE":"in {0} Jahren","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"timeleft.years.one","bundleName":"I18nResources","translation":"in a year","translationDE":"in einem Jahr","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"timesheet","bundleName":"I18nResources","translation":"Time-sheet","translationDE":"Zeitbericht","usedInClasses":["org.projectforge.Constants","org.projectforge.business.timesheet.TimesheetFavoritesService","org.projectforge.framework.persistence.DaoConst","org.projectforge.registry.Registry","org.projectforge.rest.calendar.CalendarServicesRest","org.projectforge.rest.calendar.FullCalendarEvent","org.projectforge.rest.calendar.TimesheetEventsProvider","org.projectforge.web.calendar.TimesheetEventsProvider","org.projectforge.web.timesheet.TimesheetEditPage","org.projectforge.web.timesheet.TimesheetListPage"],"usedInFiles":[]}, - {"i18nKey":"timesheet.ai.timeSavedByAI","bundleName":"I18nResources","translation":"Time savings AI","translationDE":"KI-Ersparnis","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAI","bundleName":"I18nResources","translation":"Time savings AI","translationDE":"KI-Ersparnis","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO","org.projectforge.rest.TimesheetPagesRest"],"usedInFiles":[]}, {"i18nKey":"timesheet.ai.timeSavedByAI.error.invalidPercentage","bundleName":"I18nResources","translation":"The percentage is invalid. It must be between 0 and 99.","translationDE":"Die Prozentangabe ist ungültig. Sie muss zwischen 0 und 99 liegen.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDao"],"usedInFiles":[]}, {"i18nKey":"timesheet.ai.timeSavedByAI.error.unitMissing","bundleName":"I18nResources","translation":"Please specify unit for AI savings.","translationDE":"Bitte Einheit für die KI-Ersparnis angeben.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDao"],"usedInFiles":[]}, {"i18nKey":"timesheet.ai.timeSavedByAI.info","bundleName":"I18nResources","translation":"Estimated time savings in percent or hours due to the use of AI in this time report.\nThis information is used in evaluations and reports.","translationDE":"Geschätze Zeitersparnis in Prozent oder Stunden durch den Einsatz von KI in diesem Zeitbericht.\nDiese Angabe wird in Auswertungen und Reports verwendet.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO"],"usedInFiles":[]}, diff --git a/projectforge-business/src/main/java/org/projectforge/framework/configuration/ConfigurationParam.java b/projectforge-business/src/main/java/org/projectforge/framework/configuration/ConfigurationParam.java index 8b808133b6..bf1887d6cf 100644 --- a/projectforge-business/src/main/java/org/projectforge/framework/configuration/ConfigurationParam.java +++ b/projectforge-business/src/main/java/org/projectforge/framework/configuration/ConfigurationParam.java @@ -71,6 +71,10 @@ public enum ConfigurationParam implements IConfigurationParam * Cost configured configuration param. */ COST_CONFIGURED("fibu.costConfigured", ConfigurationType.BOOLEAN), // + /** + * Cost configured configuration param. + */ + TIMESHEET_NOTE_SAVINGS_BY_AI("timesheet.noteSavingsByAI", ConfigurationType.STRING), // /** * Default country phone prefix configuration param. */ diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/configuration/ConfigurationService.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/configuration/ConfigurationService.kt index 52e9ab6a77..354e264f13 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/configuration/ConfigurationService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/configuration/ConfigurationService.kt @@ -401,6 +401,11 @@ open class ConfigurationService { } } + open val timesheetNoteSavingsByAI: String? + get() { + return configDao.getEntry(ConfigurationParam.TIMESHEET_NOTE_SAVINGS_BY_AI)?.stringValue + } + /** * 31.03. of the given year, if not configured different. This date determine when vacation days of an employee * from the last year will be invalid, if not used. diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDao.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDao.kt index b6a6902f34..9b5a97607e 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDao.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDao.kt @@ -67,6 +67,7 @@ import org.projectforge.framework.time.PFDateTime.Companion.from import org.projectforge.framework.time.PFDateTime.Companion.now import org.projectforge.framework.utils.NumberHelper.isEqual import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.math.BigDecimal import java.util.* @@ -78,6 +79,10 @@ private val log = KotlinLogging.logger {} */ @Service open class TimesheetDao : BaseDao(TimesheetDO::class.java) { + @Value("\${projectforge.timesheets.timeSavingsByAI.enabled}") + var timeSavingsByAIEnabled = false + private set + @Autowired private lateinit var userDao: UserDao diff --git a/projectforge-business/src/main/resources/I18nResources.properties b/projectforge-business/src/main/resources/I18nResources.properties index e80a33a0ba..a92c5495d3 100644 --- a/projectforge-business/src/main/resources/I18nResources.properties +++ b/projectforge-business/src/main/resources/I18nResources.properties @@ -550,6 +550,8 @@ administration.configuration.param.pluginsActivated.description=List of activate administration.configuration.param.systemAdministratorEMail=E-mail of administrators administration.configuration.param.systemAdministratorEMail.description=ProjectForge sends e-mails to this address(es) in the case of special errors. You can specify one or more (coma separated) addresses (RFC822). administration.configuration.param.systemAdministratorEMail.label=Administrators +administration.configuration.param.timesheet.noteSavingsByAI=Note regarding AI savings +administration.configuration.param.timesheet.noteSavingsByAI.description=Note in the time reports regarding AI savings (markdown is supported, only visible if: projectforge.timesheets.timeSavingsByAI.enabled=true) administration.configuration.param.timesheetTags=Time sheet tags administration.configuration.param.timesheetTags.description=If defined, a semicolon separated list of tags (strings). On each time sheet the user may add one of these tags. administration.configuration.param.timezone=Default time zone diff --git a/projectforge-business/src/main/resources/I18nResources_de.properties b/projectforge-business/src/main/resources/I18nResources_de.properties index a82a3f98e7..425f01feda 100644 --- a/projectforge-business/src/main/resources/I18nResources_de.properties +++ b/projectforge-business/src/main/resources/I18nResources_de.properties @@ -636,6 +636,8 @@ administration.configuration.param.pluginsActivated.description=Liste der aktivi administration.configuration.param.systemAdministratorEMail=E-Mail-Adressen der Systemadministratoren administration.configuration.param.systemAdministratorEMail.description=Im schwerwiegenderen Fehlerfalle versendet ProjectForge eine E-Mail an diese Adresse. Es können mehrere Adressen eingegeben werden, getrennt durch Leerzeichen und Kommata (RFC822). administration.configuration.param.systemAdministratorEMail.label=Administrator:innen +administration.configuration.param.timesheet.noteSavingsByAI=Hinweis KI-Einsparungen +administration.configuration.param.timesheet.noteSavingsByAI.description=Hinweis in den Zeitberichten hinsichtlich KI-Einsparungen (Markdown wird unterstützt, nur sichtbar bei: projectforge.timesheets.timeSavingsByAI.enabled=true) administration.configuration.param.timesheetTags=Tags für Zeitberichte administration.configuration.param.timesheetTags.description=Hier können Semikolon-separierte Tags für die Verwendung in Zeitberichten angegeben werden. Diese Tags gelten global. administration.configuration.param.timezone=Standard-Zeitzone diff --git a/projectforge-business/src/main/resources/application.properties b/projectforge-business/src/main/resources/application.properties index 0367974d4d..9eaa69acaf 100644 --- a/projectforge-business/src/main/resources/application.properties +++ b/projectforge-business/src/main/resources/application.properties @@ -19,6 +19,9 @@ projectforge.defaultFirstDayOfWeek=MONDAY # defaultFirstDayOfWeek and minimalDaysInFirstWeek is used for calculating week of year projectforge.minimalDaysInFirstWeek= projectforge.excelPaperSize=DINA4 +# If true, the user's may enter time savings by AI (artificial intelligence) in the timesheet. +# Please note: you may configure a note to display while editing timesheets (please refer admins configuration page). +projectforge.timesheets.timeSavingsByAI.enabled=false # For development purposes (npm/yarn) you may enable the CORS filter for allowing cross origins. # But never ever use this in productive environments. projectforge.web.development.enableCORSFilter=false diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt index de77bba24e..af112b4b28 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/TimesheetPagesRest.kt @@ -27,9 +27,7 @@ import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid import org.projectforge.Constants import org.projectforge.business.PfCaches -import org.projectforge.business.fibu.kost.KostCache -import org.projectforge.business.fibu.kost.KundeCache -import org.projectforge.business.fibu.kost.ProjektCache +import org.projectforge.business.configuration.ConfigurationService import org.projectforge.business.scripting.ScriptParameterType import org.projectforge.business.system.SystemInfoCache import org.projectforge.business.task.TaskTree @@ -73,16 +71,10 @@ class TimesheetPagesRest : AbstractDTOPagesRest, dto: Timesheet) { - timesheetDao.validateTimeSavingsByAI(dto.timeSavedByAI, dto.timeSavedByAIUnit)?.let { - validationErrors.add(ValidationError(translate(it), fieldId = "timeSavedByAI")) + if (baseDao.timeSavingsByAIEnabled) { + timesheetDao.validateTimeSavingsByAI(dto.timeSavedByAI, dto.timeSavedByAIUnit)?.let { + validationErrors.add(ValidationError(translate(it), fieldId = "timeSavedByAI")) + } } } @@ -343,7 +337,8 @@ class TimesheetPagesRest : AbstractDTOPagesRest + if (hint.isNotBlank()) { + layout.layoutBelowActions.add( + UIAlert(hint, title = "timesheet.ai.timeSavedByAI", color = UIColor.SECONDARY, markdown = true) + ) + } + } + } JiraSupport.createJiraElement(dto.description, descriptionArea) ?.let { layout.add(UIRow().add(UICol().add(it))) } @@ -395,7 +400,7 @@ class TimesheetPagesRest : AbstractDTOPagesRest Date: Thu, 23 Jan 2025 21:47:47 +0100 Subject: [PATCH 05/15] JCR: RepoService refactored (prepared for other storages than segment tar) --- gradle/libs.versions.toml | 7 +- .../datatransfer/DatatransferJCRCleanUpJob.kt | 3 +- .../kotlin/org/projectforge/jcr/FileObject.kt | 56 +- .../org/projectforge/jcr/FileStoreInfo.kt | 39 -- .../jcr/JCRCheckSanityCheckJob.kt | 4 +- .../kotlin/org/projectforge/jcr/OakStorage.kt | 648 ++++++++++++++++++ .../org/projectforge/jcr/RepoBackupService.kt | 8 +- .../org/projectforge/jcr/RepoService.kt | 574 ++-------------- .../org/projectforge/jcr/SegmentTarStorage.kt | 127 ++++ .../org/projectforge/jcr/SessionWrapper.kt | 6 +- 10 files changed, 881 insertions(+), 591 deletions(-) delete mode 100644 projectforge-jcr/src/main/kotlin/org/projectforge/jcr/FileStoreInfo.kt create mode 100644 projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt create mode 100644 projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6f9d8b20b..65cbb33646 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,8 +42,7 @@ org-apache-directory-server-apacheds-server-integ = "1.5.7" org-apache-groovy = "4.0.22" org-apache-httpcomponents-client5-httpclient5 = "5.4.1" org-apache-httpcomponents-core5 = "5.3.1" # Needed by httpclient5 -org-apache-jackrabbit-oak-jcr = "1.66.0" -org-apache-jackrabbit-oak-segment-tar = "1.66.0" +org-apache-jackrabbit-oak = "1.74.0" org-apache-poi = "5.3.0" org-apache-poi-ooxml = "5.3.0" org-apache-wicket-myextensions = "10.3.0" @@ -151,8 +150,8 @@ org-apache-commons-lang3 = { module = "org.apache.commons:commons-lang3", versio org-apache-commons-text = { module = "org.apache.commons:commons-text", version.ref = "org-apache-commons-text" } org-apache-groovy-all = { module = "org.apache.groovy:groovy-all", version.ref = "org-apache-groovy" } org-apache-httpcomponents-client5-httpclient5 = { module = "org.apache.httpcomponents.client5:httpclient5", version.ref = "org-apache-httpcomponents-client5-httpclient5" } -org-apache-jackrabbit-oak-jcr = { module = "org.apache.jackrabbit:oak-jcr", version.ref = "org-apache-jackrabbit-oak-jcr" } -org-apache-jackrabbit-oak-segment-tar = { module = "org.apache.jackrabbit:oak-segment-tar", version.ref = "org-apache-jackrabbit-oak-segment-tar" } +org-apache-jackrabbit-oak-jcr = { module = "org.apache.jackrabbit:oak-jcr", version.ref = "org-apache-jackrabbit-oak" } +org-apache-jackrabbit-oak-segment-tar = { module = "org.apache.jackrabbit:oak-segment-tar", version.ref = "org-apache-jackrabbit-oak" } org-apache-logging-log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "org-apache-logging-log4j" } org-apache-logging-log4j-to-slf4j = { module = "org.apache.logging.log4j:log4j-to-slf4j", version.ref = "org-apache-logging-log4j" } org-apache-poi = { module = "org.apache.poi:poi", version.ref = "org-apache-poi" } diff --git a/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/DatatransferJCRCleanUpJob.kt b/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/DatatransferJCRCleanUpJob.kt index 9068fb9eb2..b644a5819a 100644 --- a/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/DatatransferJCRCleanUpJob.kt +++ b/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/DatatransferJCRCleanUpJob.kt @@ -27,6 +27,7 @@ import mu.KotlinLogging import org.projectforge.common.FormatterUtils import org.projectforge.framework.jcr.AttachmentsService import org.projectforge.framework.utils.NumberHelper +import org.projectforge.jcr.OakStorage import org.projectforge.jcr.RepoService import org.projectforge.plugins.core.PluginAdminService import org.projectforge.plugins.datatransfer.rest.DataTransferAreaPagesRest @@ -124,7 +125,7 @@ class DataTransferJCRCleanUpJob { val files = mutableListOf() child.findDescendant( AttachmentsService.DEFAULT_NODE, - RepoService.NODENAME_FILES + OakStorage.NODENAME_FILES )?.children?.forEach { deletedCounter++ deletedSize += it.getProperty("size")?.value?.long ?: 0 diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/FileObject.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/FileObject.kt index 4bc3ab32fc..107df9c1c8 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/FileObject.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/FileObject.kt @@ -56,14 +56,14 @@ class FileObject() : FileInfo() { } internal constructor(nodeInfo: NodeInfo) : this() { - fileName = nodeInfo.getProperty(RepoService.PROPERTY_FILENAME)?.value?.string - description = nodeInfo.getProperty(RepoService.PROPERTY_FILEDESC)?.value?.string - created = PFJcrUtils.convertToDate(nodeInfo.getProperty(RepoService.PROPERTY_CREATED)?.value?.string) - createdByUser = nodeInfo.getProperty(RepoService.PROPERTY_CREATED_BY_USER)?.value?.string - lastUpdate = PFJcrUtils.convertToDate(nodeInfo.getProperty(RepoService.PROPERTY_LAST_UPDATE)?.value?.string) - lastUpdateByUser = nodeInfo.getProperty(RepoService.PROPERTY_LAST_UPDATE_BY_USER)?.value?.string + fileName = nodeInfo.getProperty(OakStorage.PROPERTY_FILENAME)?.value?.string + description = nodeInfo.getProperty(OakStorage.PROPERTY_FILEDESC)?.value?.string + created = PFJcrUtils.convertToDate(nodeInfo.getProperty(OakStorage.PROPERTY_CREATED)?.value?.string) + createdByUser = nodeInfo.getProperty(OakStorage.PROPERTY_CREATED_BY_USER)?.value?.string + lastUpdate = PFJcrUtils.convertToDate(nodeInfo.getProperty(OakStorage.PROPERTY_LAST_UPDATE)?.value?.string) + lastUpdateByUser = nodeInfo.getProperty(OakStorage.PROPERTY_LAST_UPDATE_BY_USER)?.value?.string fileId = nodeInfo.name - size = nodeInfo.getProperty(RepoService.PROPERTY_FILESIZE)?.value?.long + size = nodeInfo.getProperty(OakStorage.PROPERTY_FILESIZE)?.value?.long if (log.isDebugEnabled) { log.debug { "Restoring: ${PFJcrUtils.toJson(this)}" } } @@ -73,17 +73,17 @@ class FileObject() : FileInfo() { * Copies all fields from node to this, excluding content and path setting (parent path as well as relative path). */ internal fun copyFrom(node: Node) { - fileName = PFJcrUtils.getProperty(node, RepoService.PROPERTY_FILENAME)?.string - description = PFJcrUtils.getProperty(node, RepoService.PROPERTY_FILEDESC)?.string - created = PFJcrUtils.getPropertyAsDate(node, RepoService.PROPERTY_CREATED) - createdByUser = PFJcrUtils.getProperty(node, RepoService.PROPERTY_CREATED_BY_USER)?.string - lastUpdate = PFJcrUtils.getPropertyAsDate(node, RepoService.PROPERTY_LAST_UPDATE) - lastUpdateByUser = PFJcrUtils.getProperty(node, RepoService.PROPERTY_LAST_UPDATE_BY_USER)?.string + fileName = PFJcrUtils.getProperty(node, OakStorage.PROPERTY_FILENAME)?.string + description = PFJcrUtils.getProperty(node, OakStorage.PROPERTY_FILEDESC)?.string + created = PFJcrUtils.getPropertyAsDate(node, OakStorage.PROPERTY_CREATED) + createdByUser = PFJcrUtils.getProperty(node, OakStorage.PROPERTY_CREATED_BY_USER)?.string + lastUpdate = PFJcrUtils.getPropertyAsDate(node, OakStorage.PROPERTY_LAST_UPDATE) + lastUpdateByUser = PFJcrUtils.getProperty(node, OakStorage.PROPERTY_LAST_UPDATE_BY_USER)?.string fileId = node.name - size = PFJcrUtils.getProperty(node, RepoService.PROPERTY_FILESIZE)?.long - checksum = PFJcrUtils.getProperty(node, RepoService.PROPERTY_CHECKSUM)?.string - aesEncrypted = PFJcrUtils.getProperty(node, RepoService.PROPERTY_AES_ENCRYPTED)?.boolean == true - PFJcrUtils.getProperty(node, RepoService.PROPERTY_ZIP_MODE)?.string?.let { + size = PFJcrUtils.getProperty(node, OakStorage.PROPERTY_FILESIZE)?.long + checksum = PFJcrUtils.getProperty(node, OakStorage.PROPERTY_CHECKSUM)?.string + aesEncrypted = PFJcrUtils.getProperty(node, OakStorage.PROPERTY_AES_ENCRYPTED)?.boolean == true + PFJcrUtils.getProperty(node, OakStorage.PROPERTY_ZIP_MODE)?.string?.let { zipMode = ZipMode.valueOf(it) } if (log.isDebugEnabled) { @@ -95,18 +95,18 @@ class FileObject() : FileInfo() { * Copies all fields from this to node, excluding content and id/name. */ internal fun copyTo(node: Node) { - node.setProperty(RepoService.PROPERTY_FILENAME, fileName) - node.setProperty(RepoService.PROPERTY_FILEDESC, description ?: "") - node.setProperty(RepoService.PROPERTY_CREATED, PFJcrUtils.convertToString(created) ?: "") - node.setProperty(RepoService.PROPERTY_CREATED_BY_USER, createdByUser ?: "") - node.setProperty(RepoService.PROPERTY_LAST_UPDATE, PFJcrUtils.convertToString(lastUpdate) ?: "") - node.setProperty(RepoService.PROPERTY_LAST_UPDATE_BY_USER, lastUpdateByUser ?: "") - node.setProperty(RepoService.PROPERTY_AES_ENCRYPTED, aesEncrypted == true) + node.setProperty(OakStorage.PROPERTY_FILENAME, fileName) + node.setProperty(OakStorage.PROPERTY_FILEDESC, description ?: "") + node.setProperty(OakStorage.PROPERTY_CREATED, PFJcrUtils.convertToString(created) ?: "") + node.setProperty(OakStorage.PROPERTY_CREATED_BY_USER, createdByUser ?: "") + node.setProperty(OakStorage.PROPERTY_LAST_UPDATE, PFJcrUtils.convertToString(lastUpdate) ?: "") + node.setProperty(OakStorage.PROPERTY_LAST_UPDATE_BY_USER, lastUpdateByUser ?: "") + node.setProperty(OakStorage.PROPERTY_AES_ENCRYPTED, aesEncrypted == true) zipMode?.let { - node.setProperty(RepoService.PROPERTY_ZIP_MODE, it.name) + node.setProperty(OakStorage.PROPERTY_ZIP_MODE, it.name) } setChecksum(node, checksum) - size?.let { node.setProperty(RepoService.PROPERTY_FILESIZE, it) } + size?.let { node.setProperty(OakStorage.PROPERTY_FILESIZE, it) } log.info { "Storing file info: ${PFJcrUtils.toJson(this)}" } } @@ -133,7 +133,7 @@ class FileObject() : FileInfo() { * the file node with id as name resists. */ val location: String - get() = "${RepoService.getAbsolutePath(parentNodePath, relPath)}" + get() = "${OakStorage.getAbsolutePath(parentNodePath, relPath)}" /** * The location is built of parentNodePath and relPath. @@ -151,7 +151,7 @@ class FileObject() : FileInfo() { companion object { internal fun setChecksum(node: Node, checksum: String?) { - node.setProperty(RepoService.PROPERTY_CHECKSUM, checksum ?: "") + node.setProperty(OakStorage.PROPERTY_CHECKSUM, checksum ?: "") } } } diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/FileStoreInfo.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/FileStoreInfo.kt deleted file mode 100644 index 204428c4d2..0000000000 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/FileStoreInfo.kt +++ /dev/null @@ -1,39 +0,0 @@ -///////////////////////////////////////////////////////////////////////////// -// -// Project ProjectForge Community Edition -// www.projectforge.org -// -// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) -// -// ProjectForge is dual-licensed. -// -// This community edition 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; version 3 of the License. -// -// This community edition 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 http://www.gnu.org/licenses/. -// -///////////////////////////////////////////////////////////////////////////// - -package org.projectforge.jcr - -import org.projectforge.common.FormatterUtils - -/** - * For information. - */ -class FileStoreInfo(repoService: RepoService) { - val approximateSize = FormatterUtils.formatBytes(repoService.fileStore?.stats?.approximateSize) - val tarFileCount = repoService.fileStore?.stats?.tarFileCount - val location = repoService.fileStoreLocation?.absolutePath - - override fun toString(): String { - return PFJcrUtils.toJson(this) - } -} diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/JCRCheckSanityCheckJob.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/JCRCheckSanityCheckJob.kt index 54c545e773..ef2abd7073 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/JCRCheckSanityCheckJob.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/JCRCheckSanityCheckJob.kt @@ -91,7 +91,7 @@ open class JCRCheckSanityCheckJob : AbstractJob("JCR Check Sanity") { if (repoChecksum != null && repoChecksum.length > 10) { val checksum = repoService.getFileInputStream(fileNode, fileObject, true, useEncryptedFile = true) - .use { istream -> RepoService.checksum(istream) } + .use { istream -> OakStorage.checksum(istream) } if (!validateChecksum(checksum, repoChecksum)) { ++failedChecks val msg = @@ -117,7 +117,7 @@ open class JCRCheckSanityCheckJob : AbstractJob("JCR Check Sanity") { repoService.getFileInputStream(fileNode, fileObject, true, useEncryptedFile = true) .use { istream -> ZipUtils.determineZipMode(istream) } if (newZipMode != null) { - fileNode.setProperty(RepoService.PROPERTY_ZIP_MODE, newZipMode.name) + fileNode.setProperty(OakStorage.PROPERTY_ZIP_MODE, newZipMode.name) } } } diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt new file mode 100644 index 0000000000..d8aa1f6203 --- /dev/null +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt @@ -0,0 +1,648 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition 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; version 3 of the License. +// +// This community edition 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 http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.jcr + +import mu.KotlinLogging +import org.apache.commons.codec.digest.DigestUtils +import org.apache.jackrabbit.oak.spi.state.NodeStore +import org.projectforge.common.CryptStreamUtils +import org.projectforge.common.FormatterUtils +import org.projectforge.common.NumberOfBytes +import java.io.InputStream +import java.security.SecureRandom +import java.util.* +import javax.jcr.* +import kotlin.concurrent.thread + +private val log = KotlinLogging.logger {} + +/** + * Abstract content store for JCR. Implementations are e. g. [SegmentTarStorage]. + * @param mainNodeName The name of the top node. + */ +abstract class OakStorage(val mainNodeName: String) { + internal lateinit var repository: Repository + + lateinit var nodeStore: NodeStore + protected set + + abstract fun shutdown() + + abstract fun onLogout() + + abstract fun afterSessionClose() + + internal open fun close(session: Session) { + session.save() + afterSessionClose() + } + + + /** + * @param parentNodePath Path, nodes are separated by '/', e. g. "world/germany". The nodes of this path must already exist. + * For creating top level nodes (direct child of main node), set parentNode to null, empty string or "/". + * @param relPath Sub node parent node to create if not exists. Null value results in nop. + */ + open fun ensureNode(parentNodePath: String?, relPath: String? = null): Node? { + relPath ?: return null + return runInSession { session -> + val node = getNode(session, parentNodePath, relPath, true) + session.save() + node + } + } + + @JvmOverloads + open fun storeProperty( + parentNodePath: String?, + relPath: String?, + name: String, + value: String, + ensureRelNode: Boolean = true + ) { + runInSession { session -> + val node = getNode(session, parentNodePath, relPath, ensureRelNode) + node.setProperty(name, value) + session.save() + } + } + + open fun retrievePropertyString(parentNodePath: String?, relPath: String?, name: String): String? { + return runInSession { session -> + getNodeOrNull(session, parentNodePath, relPath, false)?.getProperty(name)?.string + } + } + + /** + * Content of file should be given as [FileObject.content]. + * @param password Optional password for encryption. The password will not be stored in any kind! + */ + @JvmOverloads + open fun storeFile( + fileObject: FileObject, fileSizeChecker: FileSizeChecker, + user: String? = null, + password: String? = null, + ) { + val content = fileObject.content ?: ByteArray(0) // Assuming 0 byte file if no content is given. + return storeFile(fileObject, content.inputStream(), fileSizeChecker, user, password = password) + } + + /** + * @param password Optional password for encryption. The password will not be stored in any kind! + */ + @JvmOverloads + open fun storeFile( + fileObject: FileObject, + content: InputStream, + fileSizeChecker: FileSizeChecker, + user: String? = null, + /** + * Optional data e. g. for fileSizeChecker of data transfer area size. + */ + data: Any? = null, + password: String? = null, + ) { + if (fileObject.size != null) { // file size already known: + fileSizeChecker.checkSize(fileObject, data) + } + val parentNodePath = fileObject.parentNodePath + val relPath = fileObject.relPath + if (parentNodePath == null || relPath == null) { + throw IllegalArgumentException("Parent node path and relPath not given. Can't determine location of file to store: $fileObject") + } + var lazyCheckSumFileObject: FileObject? = null + runInSession { session -> + val node = getNode(session, parentNodePath, relPath, true) + val filesNode = ensureNode(node, NODENAME_FILES) + val fileId = fileObject.fileId ?: createRandomId + fileObject.fileId = fileId + log.info { "Storing file: $fileObject" } + val fileNode = filesNode.addNode(fileId) + val now = Date() + if (fileObject.created == null) { + // created should only be preset for test cases. So normally, use current date. + fileObject.created = now + } + fileObject.createdByUser = user + if (fileObject.lastUpdate == null) { + // last update should only be preset for test cases. So normally, use current date. + fileObject.lastUpdate = now + } + fileObject.lastUpdate = fileObject.created + fileObject.lastUpdateByUser = user + var bin: Binary? = null + try { + if (password.isNullOrBlank()) { + bin = session.valueFactory.createBinary(content) + } else { + val inputStream = CryptStreamUtils.pipeToEncryptedInputStream(content, password) + bin = session.valueFactory.createBinary(inputStream) + fileObject.aesEncrypted = true + } + fileNode.setProperty(PROPERTY_FILECONTENT, bin) + fileObject.size = bin?.size + Integer.MAX_VALUE + } finally { + bin?.dispose() + } + // Check size again for the case, the fileObject didn't contain file size before processing the stream. + try { + fileSizeChecker.checkSize(fileObject, data) + } catch (ex: Exception) { + fileNode.remove() + throw ex + } + if ((fileObject.size ?: 0) > NumberOfBytes.MEGA_BYTES * 50) { + lazyCheckSumFileObject = fileObject + fileObject.checksum = "..." + } else { + checksum(fileNode, fileObject) + } + fileObject.copyTo(fileNode) + session.save() + } + lazyCheckSumFileObject?.let { + thread { + checksum(it) + } + } + } + + private fun checksum(fileNode: Node, fileObject: FileObject) { + // Calculate checksum for files smaller than 50MB (it's fast enough). + val startTime = System.currentTimeMillis() + // Calculate checksum + getFileInputStream(fileNode, fileObject, useEncryptedFile = true).use { istream -> + fileObject.checksum = checksum(istream) + } + FileObject.setChecksum(fileNode, fileObject.checksum) + log.info { + "Checksum of '${fileObject.fileName}' of size ${FormatterUtils.formatBytes(fileObject.size)} calculated in ${ + FormatterUtils.format( + (System.currentTimeMillis() - startTime) / 1000 + ) + }s." + } + } + + open fun deleteFile(fileObject: FileObject): Boolean { + return runInSession { session -> + val node = getNode(session, fileObject.parentNodePath, fileObject.relPath, false) + if (!node.hasNode(NODENAME_FILES)) { + log.error { "Can't delete file, because '$NODENAME_FILES' not found for node '${node.path}': $fileObject" } + false + } else { + val filesNode = node.getNode(NODENAME_FILES) + val fileNode = findFile(filesNode, fileObject.fileId, fileObject.fileName) + if (fileNode == null) { + log.info { "Nothing to delete, file node doesn't exit: $fileObject" } + false + } else { + fileObject.copyFrom(fileNode) + log.info { "Deleting file: $fileObject" } + fileNode.remove() + session.save() + true + } + } + } + } + + open fun deleteNode(nodeInfo: NodeInfo): Boolean { + return runInSession { session -> + val node = getNode(session, nodeInfo.path, nodeInfo.name, false) + log.info { "Deleting node: $nodeInfo" } + node.remove() + session.save() + true + } + } + + /** + * @return list of file infos without content. + */ + @JvmOverloads + open fun getFileInfos(parentNodePath: String?, relPath: String? = null): List? { + return runInSession { session -> + val filesNode = getFilesNode(session, parentNodePath, relPath) + getFileInfos(filesNode) + } + } + + /** + * @return file info without content. + */ + @JvmOverloads + open fun getFileInfo( + parentNodePath: String?, + relPath: String? = null, + fileId: String? = null, + fileName: String? = null + ): FileObject? { + return runInSession { session -> + val filesNode = getFilesNode(session, parentNodePath, relPath) + val node = findFile(filesNode, fileId, fileName) + if (node != null) { + FileObject(node, parentNodePath, relPath) + } else { + null + } + } + } + + /** + * Change fileName and/or description if given. + * @param updateLastUpdateInfo If true (default), + * time stamp of last update and user of this update will be updated. Otherwise time stamp and user info will be left untouched. + * @return new file info without content. + */ + @JvmOverloads + open fun changeFileInfo( + fileObject: FileObject, + user: String, + newFileName: String? = null, + newDescription: String? = null, + newZipMode: ZipMode? = null, + updateLastUpdateInfo: Boolean = true, + ): FileObject? { + return runInSession { session -> + val node = getNode(session, fileObject.parentNodePath, fileObject.relPath, false) + if (!node.hasNode(NODENAME_FILES)) { + log.error { "Can't change file info, because '$NODENAME_FILES' not found for node '${node.path}': $fileObject" } + null + } else { + val filesNode = node.getNode(NODENAME_FILES) + val fileNode = findFile(filesNode, fileObject.fileId, fileObject.fileName) + if (fileNode == null) { + log.error { "Can't change file info, file node doesn't exit: $fileObject" } + null + } else { + var modified = false + if (!newFileName.isNullOrBlank()) { + log.info { "Changing file name to '$newFileName' for: $fileObject" } + fileNode.setProperty(PROPERTY_FILENAME, newFileName) + modified = true + } + if (newDescription != null) { + log.info { "Changing file description to '$newDescription' for: $fileObject" } + fileNode.setProperty(PROPERTY_FILEDESC, newDescription) + modified = true + } + if (newZipMode != null) { + log.info { "Changing zip encryption algorithm to '$newZipMode' for: $fileObject" } + fileNode.setProperty(PROPERTY_ZIP_MODE, newZipMode.name) + modified = true + } + if (modified && updateLastUpdateInfo) { + fileNode.setProperty(PROPERTY_LAST_UPDATE_BY_USER, user) + fileNode.setProperty(PROPERTY_LAST_UPDATE, PFJcrUtils.convertToString(Date()) ?: "") + } + session.save() + FileObject(fileNode) + } + } + } + } + + /** + * Returns the already calculated checksum or calculates it, if not given. + * @return new file info including checksum without content. + */ + open fun checksum(fileObject: FileObject): String? { + return runInSession { session -> + val node = getNode(session, fileObject.parentNodePath, fileObject.relPath, false) + if (!node.hasNode(NODENAME_FILES)) { + log.error { "Can't change file info, because '$NODENAME_FILES' not found for node '${node.path}': $fileObject" } + null + } else { + val filesNode = node.getNode(NODENAME_FILES) + val fileNode = findFile(filesNode, fileObject.fileId, fileObject.fileName) + if (fileNode == null) { + log.error { "Can't get or calculate file info, file node doesn't exit: $fileObject" } + null + } else { + val storedFileObject = FileObject(fileNode) + checksum(fileNode, storedFileObject) + session.save() + fileObject.checksum = storedFileObject.checksum + fileObject.checksum + } + } + } + } + + @JvmOverloads + open fun getNodeInfo(absPath: String, recursive: Boolean = false): NodeInfo { + return runInSession { session -> + log.info { "Getting node info of path '$absPath'..." } + val node = session.getNode(absPath) + NodeInfo(node, recursive) + } + } + + private fun getFilesNode( + sessionWrapper: SessionWrapper, + parentNodePath: String?, + relPath: String?, + ensureFilesNode: Boolean = false + ): Node? { + val parentNode = getNodeOrNull(sessionWrapper, parentNodePath, relPath, false) + if (parentNode == null) { + log.info { "Parent node '${getAbsolutePath(parentNodePath, relPath)}' doesn't exist. No files found (OK)." } + return null + } + return if (ensureFilesNode || parentNode.hasNode(NODENAME_FILES)) { + ensureNode(parentNode, NODENAME_FILES) + } else { + null + } + } + + internal fun getFileInfos( + filesNode: Node?, + parentNodePath: String? = null, + relPath: String? = null + ): List? { + filesNode ?: return null + var fileNodes: NodeIterator? = null + try { + fileNodes = filesNode.nodes + if (fileNodes == null || !fileNodes.hasNext()) { + return null + } + } catch (ex: Exception) { + log.error { "Error while reading file nodes of '${filesNode.path}': ${ex.message}" } + return null + } + val result = mutableListOf() + while (fileNodes.hasNext()) { + val node = fileNodes.nextNode() + try { + if (node.hasProperty(PROPERTY_FILENAME)) { + result.add(FileObject(node, parentNodePath ?: node.path, relPath)) + } + } catch (ex: Exception) { + log.error { "Error while reading file node '${node.path}': ${ex.message}" } + } + } + return result + } + + fun getFileInfos(nodeInfo: NodeInfo?): List? { + nodeInfo ?: return null + val fileNodes = nodeInfo.children + if (fileNodes.isNullOrEmpty()) { + return null + } + val result = mutableListOf() + fileNodes.forEach { node -> + if (node.hasProperty(PROPERTY_FILENAME)) { + result.add(FileObject(node)) + } + } + return result + } + + internal fun findFile(filesNode: Node?, fileId: String?, fileName: String? = null): Node? { + filesNode ?: return null + if (!filesNode.hasNodes()) { + return null + } + filesNode.nodes?.let { + while (it.hasNext()) { + val node = it.nextNode() + if (node.name == fileId || PFJcrUtils.getProperty(node, PROPERTY_FILENAME)?.string == fileName) { + return node + } + } + } + return null + } + + @JvmOverloads + open fun retrieveFile(fileObject: FileObject, password: String? = null): Boolean { + return runInSession { session -> + val filesNode = getFilesNode(session, fileObject.parentNodePath, fileObject.relPath, false) + val node = findFile(filesNode, fileObject.fileId, fileObject.fileName) + if (node == null) { + log.warn { "File not found in repository: $fileObject" } + false + } else { + fileObject.copyFrom(node) + fileObject.content = getFileContent(node, fileObject, password) + true + } + } + } + + open fun retrieveFileInputStream(fileObject: FileObject, password: String? = null): InputStream? { + return runInSession { session -> + val filesNode = getFilesNode(session, fileObject.parentNodePath, fileObject.relPath, false) + val node = findFile(filesNode, fileObject.fileId, fileObject.fileName) + if (node == null) { + log.warn { "File not found in repository: $fileObject" } + null + } else { + getFileInputStream(node, fileObject) + } + } + } + + internal fun getFileContent( + node: Node?, fileObject: FileObject, + password: String? = null, + useEncryptedFile: Boolean = false, + ): ByteArray? { + return getFileInputStream(node, fileObject, password = password, useEncryptedFile = useEncryptedFile)?.use( + InputStream::readBytes + ) + } + + /** + * @param password Must be given for encrypted file to decrypt (if useEncryptedFile isn't true) + * @param useEncryptedFile If true, work with encrypted file directly without password and decryption. + * Used by internal checksum and backup functionality. + */ + internal fun getFileInputStream( + node: Node?, + fileObject: FileObject, + suppressLogInfo: Boolean = false, + password: String? = null, + useEncryptedFile: Boolean = false, + ): InputStream? { + node ?: return null + if (!suppressLogInfo) { + log.info { "Reading file from repository '${node.path}': $fileObject..." } + } + if (!useEncryptedFile && fileObject.aesEncrypted == true && password.isNullOrBlank()) { + log.error { "File is encrypted, but no password given to decrypt in repository '${node.path}': $fileObject" } + return null + } + var binary: Binary? = null + try { + binary = node.getProperty(PROPERTY_FILECONTENT)?.binary ?: return null + return if (useEncryptedFile || password.isNullOrBlank()) { + binary.stream + } else { + try { + CryptStreamUtils.pipeToDecryptedInputStream(binary.stream, password) + } catch (ex: Exception) { + if (CryptStreamUtils.wasWrongPassword(ex)) { + log.error { "Can't decrypt and retrieve file (wrong password) in repository '${node.path}': $fileObject" } + null + } else { + throw ex + } + } + } + } finally { + binary?.dispose() + } + } + + internal fun getFileSize(node: Node?, fileObject: FileObject, suppressLogInfo: Boolean = false): Long? { + node ?: return null + if (!suppressLogInfo) { + log.info { "Determining size of file from repository '${node.path}': '${fileObject.fileName}'..." } + } + var binary: Binary? = null + try { + binary = node.getProperty(PROPERTY_FILECONTENT)?.binary + return binary?.size + } finally { + binary?.dispose() + } + } + + internal fun getNode( + session: SessionWrapper, + parentNodePath: String?, + relPath: String? = null, + ensureRelNode: Boolean = true + ): Node { + return getNodeOrNull(session, parentNodePath, relPath, ensureRelNode) + ?: throw IllegalArgumentException("Can't find node ${getAbsolutePath(parentNodePath, relPath)}.") + } + + internal fun getNodeOrNull( + session: SessionWrapper, + parentNodePath: String?, + relPath: String? = null, + ensureRelNode: Boolean = true + ): Node? { + val absolutePath = getAbsolutePath(parentNodePath) + if (!session.nodeExists(absolutePath)) { + return null + } + val parentNode = try { + session.getNode(absolutePath) + } catch (ex: Exception) { + log.error { "Can't get node '$absolutePath'. ${ex::class.java.name}: ${ex.message}." } + return null + } + return when { + ensureRelNode -> ensureNode(parentNode, relPath) + parentNode.hasNode(relPath) -> parentNode.getNode(relPath) + else -> null + } + } + + fun getAbsolutePath(nodePath: String?): String { + val path = nodePath?.removePrefix("/")?.removePrefix(mainNodeName)?.removePrefix("/") ?: "" + return "/$mainNodeName/$path" + } + + private fun getAbsolutePath(parentNode: Node, relPath: String?): String? { + val parentPath = parentNode.path + return getAbsolutePath(parentPath, relPath) + } + + abstract fun cleanup() + + internal fun ensureNode(parentNode: Node, relPath: String?): Node { + relPath ?: return parentNode + var current: Node = parentNode + relPath.split("/").forEach { + current = if (current.hasNode(it)) { + current.getNode(it) + } else { + log.info { "Creating node ${getAbsolutePath(current, it)}." } + current.addNode(it) + } + } + return current + } + + private val createRandomId: String + get() { + val random = SecureRandom() + val bytes = ByteArray(PROPERTY_RANDOM_ID_LENGTH) + random.nextBytes(bytes) + val sb = StringBuilder() + for (i in 0 until PROPERTY_RANDOM_ID_LENGTH) { + sb.append(ALPHA_CHARSET[(bytes[i].toInt() and 0xFF) % PROPERTY_RANDOM_ID_LENGTH]) + } + return sb.toString() + } + + internal fun runInSession(method: (sessionWrapper: SessionWrapper) -> T): T { + val session = SessionWrapper(this) + try { + return method(session) + } finally { + session.logout() + } + } + + companion object { + const val NODENAME_FILES = "__FILES" + internal const val PROPERTY_FILENAME = "fileName" + internal const val PROPERTY_FILESIZE = "size" + internal const val PROPERTY_FILECONTENT = "content" + internal const val PROPERTY_CREATED = "created" + internal const val PROPERTY_CREATED_BY_USER = "createdByUser" + internal const val PROPERTY_FILEDESC = "fileDescription" + internal const val PROPERTY_LAST_UPDATE = "lastUpdate" + internal const val PROPERTY_LAST_UPDATE_BY_USER = "lastUpdateByUser" + internal const val PROPERTY_CHECKSUM = "checksum" + internal const val PROPERTY_AES_ENCRYPTED = "aesEncrypted" + internal const val PROPERTY_ZIP_MODE = "zipMode" + private const val PROPERTY_RANDOM_ID_LENGTH = 20 + private val ALPHA_CHARSET: Array = ('a'..'z').toList().toTypedArray() + + internal fun checksum(istream: InputStream?): String { + istream ?: return "" + return "SHA256: ${DigestUtils.sha256Hex(istream)}" + } + + internal fun getAbsolutePath(parentPath: String?, relPath: String?): String? { + if (parentPath == null && relPath == null) { + return null + } + parentPath ?: return relPath + relPath ?: return parentPath + return if (parentPath.endsWith("/")) "$parentPath$relPath" else "$parentPath/$relPath" + } + } +} diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoBackupService.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoBackupService.kt index 81a26b987a..833023b2b1 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoBackupService.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoBackupService.kt @@ -234,7 +234,7 @@ open class RepoBackupService { val content = zipIn.readBytes() val inputStream = ByteArrayInputStream(content) val bin: Binary = session.valueFactory.createBinary(inputStream) - fileNode.setProperty(RepoService.PROPERTY_FILECONTENT, session.valueFactory.createValue(bin)) + fileNode.setProperty(OakStorage.PROPERTY_FILECONTENT, session.valueFactory.createValue(bin)) session.save() } zipEntry = zipIn.nextEntry @@ -255,15 +255,15 @@ open class RepoBackupService { } private fun getFilesPath(fileName: String): String? { - if (!fileName.contains(RepoService.NODENAME_FILES)) { + if (!fileName.contains(OakStorage.NODENAME_FILES)) { return null } var archiveName = fileName.substring(fileName.indexOf('/')) if (archiveName.startsWith("//")) { archiveName = archiveName.substring(1) } - archiveName = archiveName.substring(0, archiveName.indexOf(RepoService.NODENAME_FILES) - 1) - return "$archiveName/${RepoService.NODENAME_FILES}" + archiveName = archiveName.substring(0, archiveName.indexOf(OakStorage.NODENAME_FILES) - 1) + return "$archiveName/${OakStorage.NODENAME_FILES}" } private fun createZipEntry(archiveName: String, vararg path: String?): ZipEntry { diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt index 45428fa7bb..c99d9a5400 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt @@ -25,63 +25,55 @@ package org.projectforge.jcr import jakarta.annotation.PreDestroy import mu.KotlinLogging -import org.apache.commons.codec.digest.DigestUtils -import org.apache.jackrabbit.oak.Oak -import org.apache.jackrabbit.oak.jcr.Jcr -import org.apache.jackrabbit.oak.segment.SegmentNodeStoreBuilders -import org.apache.jackrabbit.oak.segment.file.FileStore -import org.apache.jackrabbit.oak.segment.file.FileStoreBuilder -import org.apache.jackrabbit.oak.spi.state.NodeStore -import org.projectforge.common.CryptStreamUtils -import org.projectforge.common.FormatterUtils -import org.projectforge.common.NumberOfBytes import org.springframework.stereotype.Service import java.io.File import java.io.InputStream -import java.io.OutputStream -import java.security.SecureRandom -import java.util.* -import javax.jcr.* -import kotlin.concurrent.thread +import javax.jcr.Node private val log = KotlinLogging.logger {} @Service open class RepoService { - internal lateinit var repository: Repository + private var repoStore: OakStorage? = null - internal var fileStore: FileStore? = null + val mainNodeName: String? + get() = repoStore?.mainNodeName - var fileStoreLocation: File? = null - internal set + val fileStoreLocation: File? + get() = (repoStore as? SegmentTarStorage)?.fileStoreLocation - private var nodeStore: NodeStore? = null - - internal lateinit var mainNodeName: String + open fun cleanup() { + repoStore?.cleanup() + } @PreDestroy fun shutdown() { - log.info { "Shutting down jcr repository..." } - fileStore?.let { - it.flush() - it.compactFull() - it.cleanup() - log.info { "Jcr stats: ${FileStoreInfo(this)}" } - it.close() - } - nodeStore?.let { - //log.warn { "Method not yet implemented: ${it.javaClass}.dispose()" } - /*if (it is DocumentNodeStore) { - it.dispose() - }*/ - } + log.info { "Shutting down JCR repositories..." } + repoStore?.shutdown() } /** * Should only be called by test cases if you need to initialize a repo multiple times. */ fun internalResetForJunitTestCases() { - nodeStore = null + repoStore = null + } + + /** + * @param mainNodeName All activities (working with nodes) will done under topNode. TopNode should be given for backing up and + * restoring. By default, "ProjectForge" is used. + */ + @JvmOverloads + fun init(repositoryDir: File, mainNodeName: String = "ProjectForge") { + synchronized(this) { + if (repoStore != null) { + throw IllegalArgumentException("Can't initialize segmentTarStore twice! segmentTarStore=$repoStore") + } + if (mainNodeName.isBlank()) { + throw IllegalArgumentException("Top node shouldn't be empty!") + } + repoStore = SegmentTarStorage(mainNodeName, repositoryDir) + } } /** @@ -90,12 +82,7 @@ open class RepoService { * @param relPath Sub node parent node to create if not exists. Null value results in nop. */ open fun ensureNode(parentNodePath: String?, relPath: String? = null): Node? { - relPath ?: return null - return runInSession { session -> - val node = getNode(session, parentNodePath, relPath, true) - session.save() - node - } + return repoStore!!.ensureNode(parentNodePath, relPath) } @JvmOverloads @@ -106,17 +93,11 @@ open class RepoService { value: String, ensureRelNode: Boolean = true ) { - runInSession { session -> - val node = getNode(session, parentNodePath, relPath, ensureRelNode) - node.setProperty(name, value) - session.save() - } + repoStore!!.storeProperty(parentNodePath, relPath, name, value, ensureRelNode) } open fun retrievePropertyString(parentNodePath: String?, relPath: String?, name: String): String? { - return runInSession { session -> - getNodeOrNull(session, parentNodePath, relPath, false)?.getProperty(name)?.string - } + return repoStore!!.retrievePropertyString(parentNodePath, relPath, name) } /** @@ -125,12 +106,12 @@ open class RepoService { */ @JvmOverloads open fun storeFile( - fileObject: FileObject, fileSizeChecker: FileSizeChecker, + fileObject: FileObject, + fileSizeChecker: FileSizeChecker, user: String? = null, password: String? = null, ) { - val content = fileObject.content ?: ByteArray(0) // Assuming 0 byte file if no content is given. - return storeFile(fileObject, content.inputStream(), fileSizeChecker, user, password = password) + repoStore!!.storeFile(fileObject, fileSizeChecker, user, password) } /** @@ -148,120 +129,16 @@ open class RepoService { data: Any? = null, password: String? = null, ) { - if (fileObject.size != null) { // file size already known: - fileSizeChecker.checkSize(fileObject, data) - } - val parentNodePath = fileObject.parentNodePath - val relPath = fileObject.relPath - if (parentNodePath == null || relPath == null) { - throw IllegalArgumentException("Parent node path and relPath not given. Can't determine location of file to store: $fileObject") - } - var lazyCheckSumFileObject: FileObject? = null - runInSession { session -> - val node = getNode(session, parentNodePath, relPath, true) - val filesNode = ensureNode(node, NODENAME_FILES) - val fileId = fileObject.fileId ?: createRandomId - fileObject.fileId = fileId - log.info { "Storing file: $fileObject" } - val fileNode = filesNode.addNode(fileId) - val now = Date() - if (fileObject.created == null) { - // created should only be preset for test cases. So normally, use current date. - fileObject.created = now - } - fileObject.createdByUser = user - if (fileObject.lastUpdate == null) { - // last update should only be preset for test cases. So normally, use current date. - fileObject.lastUpdate = now - } - fileObject.lastUpdate = fileObject.created - fileObject.lastUpdateByUser = user - var bin: Binary? = null - try { - if (password.isNullOrBlank()) { - bin = session.valueFactory.createBinary(content) - } else { - val inputStream = CryptStreamUtils.pipeToEncryptedInputStream(content, password) - bin = session.valueFactory.createBinary(inputStream) - fileObject.aesEncrypted = true - } - fileNode.setProperty(PROPERTY_FILECONTENT, bin) - fileObject.size = bin?.size - Integer.MAX_VALUE - } finally { - bin?.dispose() - } - // Check size again for the case, the fileObject didn't contain file size before processing the stream. - try { - fileSizeChecker.checkSize(fileObject, data) - } catch (ex: Exception) { - fileNode.remove() - throw ex - } - if (fileObject.size ?: 0 > NumberOfBytes.MEGA_BYTES * 50) { - lazyCheckSumFileObject = fileObject - fileObject.checksum = "..." - } else { - checksum(fileNode, fileObject) - } - fileObject.copyTo(fileNode) - session.save() - } - lazyCheckSumFileObject?.let { - thread { - checksum(it) - } - } + repoStore!!.storeFile(fileObject, content, fileSizeChecker, user, data, password) } - private fun checksum(fileNode: Node, fileObject: FileObject) { - // Calculate checksum for files smaller than 50MB (it's fast enough). - val startTime = System.currentTimeMillis() - // Calculate checksum - getFileInputStream(fileNode, fileObject, useEncryptedFile = true).use { istream -> - fileObject.checksum = checksum(istream) - } - FileObject.setChecksum(fileNode, fileObject.checksum) - log.info { - "Checksum of '${fileObject.fileName}' of size ${FormatterUtils.formatBytes(fileObject.size)} calculated in ${ - FormatterUtils.format( - (System.currentTimeMillis() - startTime) / 1000 - ) - }s." - } - } open fun deleteFile(fileObject: FileObject): Boolean { - return runInSession { session -> - val node = getNode(session, fileObject.parentNodePath, fileObject.relPath, false) - if (!node.hasNode(NODENAME_FILES)) { - log.error { "Can't delete file, because '$NODENAME_FILES' not found for node '${node.path}': $fileObject" } - false - } else { - val filesNode = node.getNode(NODENAME_FILES) - val fileNode = findFile(filesNode, fileObject.fileId, fileObject.fileName) - if (fileNode == null) { - log.info { "Nothing to delete, file node doesn't exit: $fileObject" } - false - } else { - fileObject.copyFrom(fileNode) - log.info { "Deleting file: $fileObject" } - fileNode.remove() - session.save() - true - } - } - } + return repoStore!!.deleteFile(fileObject) } open fun deleteNode(nodeInfo: NodeInfo): Boolean { - return runInSession { session -> - val node = getNode(session, nodeInfo.path, nodeInfo.name, false) - log.info { "Deleting node: $nodeInfo" } - node.remove() - session.save() - true - } + return repoStore!!.deleteNode(nodeInfo) } /** @@ -269,10 +146,7 @@ open class RepoService { */ @JvmOverloads open fun getFileInfos(parentNodePath: String?, relPath: String? = null): List? { - return runInSession { session -> - val filesNode = getFilesNode(session, parentNodePath, relPath) - getFileInfos(filesNode) - } + return repoStore!!.getFileInfos(parentNodePath, relPath) } /** @@ -285,15 +159,7 @@ open class RepoService { fileId: String? = null, fileName: String? = null ): FileObject? { - return runInSession { session -> - val filesNode = getFilesNode(session, parentNodePath, relPath) - val node = findFile(filesNode, fileId, fileName) - if (node != null) { - FileObject(node, parentNodePath, relPath) - } else { - null - } - } + return repoStore!!.getFileInfo(parentNodePath, relPath, fileId, fileName) } /** @@ -311,43 +177,14 @@ open class RepoService { newZipMode: ZipMode? = null, updateLastUpdateInfo: Boolean = true, ): FileObject? { - return runInSession { session -> - val node = getNode(session, fileObject.parentNodePath, fileObject.relPath, false) - if (!node.hasNode(NODENAME_FILES)) { - log.error { "Can't change file info, because '$NODENAME_FILES' not found for node '${node.path}': $fileObject" } - null - } else { - val filesNode = node.getNode(NODENAME_FILES) - val fileNode = findFile(filesNode, fileObject.fileId, fileObject.fileName) - if (fileNode == null) { - log.error { "Can't change file info, file node doesn't exit: $fileObject" } - null - } else { - var modified = false - if (!newFileName.isNullOrBlank()) { - log.info { "Changing file name to '$newFileName' for: $fileObject" } - fileNode.setProperty(PROPERTY_FILENAME, newFileName) - modified = true - } - if (newDescription != null) { - log.info { "Changing file description to '$newDescription' for: $fileObject" } - fileNode.setProperty(PROPERTY_FILEDESC, newDescription) - modified = true - } - if (newZipMode != null) { - log.info { "Changing zip encryption algorithm to '$newZipMode' for: $fileObject" } - fileNode.setProperty(PROPERTY_ZIP_MODE, newZipMode.name) - modified = true - } - if (modified && updateLastUpdateInfo) { - fileNode.setProperty(PROPERTY_LAST_UPDATE_BY_USER, user) - fileNode.setProperty(PROPERTY_LAST_UPDATE, PFJcrUtils.convertToString(Date()) ?: "") - } - session.save() - FileObject(fileNode) - } - } - } + return repoStore!!.changeFileInfo( + fileObject, + user, + newFileName, + newDescription, + newZipMode, + updateLastUpdateInfo + ) } /** @@ -355,53 +192,12 @@ open class RepoService { * @return new file info including checksum without content. */ open fun checksum(fileObject: FileObject): String? { - return runInSession { session -> - val node = getNode(session, fileObject.parentNodePath, fileObject.relPath, false) - if (!node.hasNode(NODENAME_FILES)) { - log.error { "Can't change file info, because '$NODENAME_FILES' not found for node '${node.path}': $fileObject" } - null - } else { - val filesNode = node.getNode(NODENAME_FILES) - val fileNode = findFile(filesNode, fileObject.fileId, fileObject.fileName) - if (fileNode == null) { - log.error { "Can't get or calculate file info, file node doesn't exit: $fileObject" } - null - } else { - val storedFileObject = FileObject(fileNode) - checksum(fileNode, storedFileObject) - session.save() - fileObject.checksum = storedFileObject.checksum - fileObject.checksum - } - } - } + return repoStore!!.checksum(fileObject) } @JvmOverloads open fun getNodeInfo(absPath: String, recursive: Boolean = false): NodeInfo { - return runInSession { session -> - log.info { "Getting node info of path '$absPath'..." } - val node = session.getNode(absPath) - NodeInfo(node, recursive) - } - } - - private fun getFilesNode( - sessionWrapper: SessionWrapper, - parentNodePath: String?, - relPath: String?, - ensureFilesNode: Boolean = false - ): Node? { - val parentNode = getNodeOrNull(sessionWrapper, parentNodePath, relPath, false) - if (parentNode == null) { - log.info { "Parent node '${getAbsolutePath(parentNodePath, relPath)}' doesn't exist. No files found (OK)." } - return null - } - return if (ensureFilesNode || parentNode.hasNode(NODENAME_FILES)) { - ensureNode(parentNode, NODENAME_FILES) - } else { - null - } + return repoStore!!.getNodeInfo(absPath, recursive) } internal fun getFileInfos( @@ -409,99 +205,29 @@ open class RepoService { parentNodePath: String? = null, relPath: String? = null ): List? { - filesNode ?: return null - var fileNodes: NodeIterator? = null - try { - fileNodes = filesNode.nodes - if (fileNodes == null || !fileNodes.hasNext()) { - return null - } - } catch (ex: Exception) { - log.error { "Error while reading file nodes of '${filesNode.path}': ${ex.message}" } - return null - } - val result = mutableListOf() - while (fileNodes.hasNext()) { - val node = fileNodes.nextNode() - try { - if (node.hasProperty(PROPERTY_FILENAME)) { - result.add(FileObject(node, parentNodePath ?: node.path, relPath)) - } - } catch (ex: Exception) { - log.error { "Error while reading file node '${node.path}': ${ex.message}" } - } - } - return result - } - - fun getFileInfos(nodeInfo: NodeInfo?): List? { - nodeInfo ?: return null - val fileNodes = nodeInfo.children - if (fileNodes.isNullOrEmpty()) { - return null - } - val result = mutableListOf() - fileNodes.forEach { node -> - if (node.hasProperty(PROPERTY_FILENAME)) { - result.add(FileObject(node)) - } - } - return result + return repoStore!!.getFileInfos(filesNode, parentNodePath, relPath) } internal fun findFile(filesNode: Node?, fileId: String?, fileName: String? = null): Node? { - filesNode ?: return null - if (!filesNode.hasNodes()) { - return null - } - filesNode.nodes?.let { - while (it.hasNext()) { - val node = it.nextNode() - if (node.name == fileId || PFJcrUtils.getProperty(node, PROPERTY_FILENAME)?.string == fileName) { - return node - } - } - } - return null + return repoStore!!.findFile(filesNode, fileId, fileName) } @JvmOverloads open fun retrieveFile(fileObject: FileObject, password: String? = null): Boolean { - return runInSession { session -> - val filesNode = getFilesNode(session, fileObject.parentNodePath, fileObject.relPath, false) - val node = findFile(filesNode, fileObject.fileId, fileObject.fileName) - if (node == null) { - log.warn { "File not found in repository: $fileObject" } - false - } else { - fileObject.copyFrom(node) - fileObject.content = getFileContent(node, fileObject, password) - true - } - } + return repoStore!!.retrieveFile(fileObject, password) } open fun retrieveFileInputStream(fileObject: FileObject, password: String? = null): InputStream? { - return runInSession { session -> - val filesNode = getFilesNode(session, fileObject.parentNodePath, fileObject.relPath, false) - val node = findFile(filesNode, fileObject.fileId, fileObject.fileName) - if (node == null) { - log.warn { "File not found in repository: $fileObject" } - null - } else { - getFileInputStream(node, fileObject) - } - } + return repoStore!!.retrieveFileInputStream(fileObject, password) } internal fun getFileContent( - node: Node?, fileObject: FileObject, + node: Node?, + fileObject: FileObject, password: String? = null, useEncryptedFile: Boolean = false, ): ByteArray? { - return getFileInputStream(node, fileObject, password = password, useEncryptedFile = useEncryptedFile)?.use( - InputStream::readBytes - ) + return repoStore!!.getFileContent(node, fileObject, password, useEncryptedFile) } /** @@ -516,48 +242,11 @@ open class RepoService { password: String? = null, useEncryptedFile: Boolean = false, ): InputStream? { - node ?: return null - if (!suppressLogInfo) { - log.info { "Reading file from repository '${node.path}': $fileObject..." } - } - if (!useEncryptedFile && fileObject.aesEncrypted == true && password.isNullOrBlank()) { - log.error { "File is encrypted, but no password given to decrypt in repository '${node.path}': $fileObject" } - return null - } - var binary: Binary? = null - try { - binary = node.getProperty(PROPERTY_FILECONTENT)?.binary ?: return null - return if (useEncryptedFile || password.isNullOrBlank()) { - binary.stream - } else { - try { - CryptStreamUtils.pipeToDecryptedInputStream(binary.stream, password) - } catch (ex: Exception) { - if (CryptStreamUtils.wasWrongPassword(ex)) { - log.error { "Can't decrypt and retrieve file (wrong password) in repository '${node.path}': $fileObject" } - null - } else { - throw ex - } - } - } - } finally { - binary?.dispose() - } + return repoStore!!.getFileInputStream(node, fileObject, suppressLogInfo, password, useEncryptedFile) } internal fun getFileSize(node: Node?, fileObject: FileObject, suppressLogInfo: Boolean = false): Long? { - node ?: return null - if (!suppressLogInfo) { - log.info { "Determining size of file from repository '${node.path}': '${fileObject.fileName}'..." } - } - var binary: Binary? = null - try { - binary = node.getProperty(PROPERTY_FILECONTENT)?.binary - return binary?.size - } finally { - binary?.dispose() - } + return repoStore!!.getFileSize(node, fileObject, suppressLogInfo) } internal fun getNode( @@ -566,8 +255,7 @@ open class RepoService { relPath: String? = null, ensureRelNode: Boolean = true ): Node { - return getNodeOrNull(session, parentNodePath, relPath, ensureRelNode) - ?: throw IllegalArgumentException("Can't find node ${getAbsolutePath(parentNodePath, relPath)}.") + return repoStore!!.getNode(session, parentNodePath, relPath, ensureRelNode) } internal fun getNodeOrNull( @@ -576,153 +264,19 @@ open class RepoService { relPath: String? = null, ensureRelNode: Boolean = true ): Node? { - val absolutePath = getAbsolutePath(parentNodePath) - if (!session.nodeExists(absolutePath)) { - return null - } - val parentNode = try { - session.getNode(absolutePath) - } catch (ex: Exception) { - log.error { "Can't get node '$absolutePath'. ${ex::class.java.name}: ${ex.message}." } - return null - } - return when { - ensureRelNode -> ensureNode(parentNode, relPath) - parentNode.hasNode(relPath) -> parentNode.getNode(relPath) - else -> null - } + return repoStore!!.getNodeOrNull(session, parentNodePath, relPath, ensureRelNode) } fun getAbsolutePath(nodePath: String?): String { - val path = nodePath?.removePrefix("/")?.removePrefix(mainNodeName)?.removePrefix("/") ?: "" - return "/$mainNodeName/$path" - } - - private fun getAbsolutePath(parentNode: Node, relPath: String?): String? { - val parentPath = parentNode.path - return getAbsolutePath(parentPath, relPath) - } - - fun cleanup() { - log.info { "Cleaning JCR repository up..." } - fileStore?.let { - it.flush() - it.compactFull() - it.cleanup() - } + return repoStore!!.getAbsolutePath(nodePath) } internal fun ensureNode(parentNode: Node, relPath: String?): Node { - relPath ?: return parentNode - var current: Node = parentNode - relPath.split("/").forEach { - current = if (current.hasNode(it)) { - current.getNode(it) - } else { - log.info { "Creating node ${getAbsolutePath(current, it)}." } - current.addNode(it) - } - } - return current + return repoStore!!.ensureNode(parentNode, relPath) } - private val createRandomId: String - get() { - val random = SecureRandom() - val bytes = ByteArray(PROPERTY_RANDOM_ID_LENGTH) - random.nextBytes(bytes) - val sb = StringBuilder() - for (i in 0 until PROPERTY_RANDOM_ID_LENGTH) { - sb.append(ALPHA_CHARSET[(bytes[i].toInt() and 0xFF) % PROPERTY_RANDOM_ID_LENGTH]) - } - return sb.toString() - } - internal fun runInSession(method: (sessionWrapper: SessionWrapper) -> T): T { - val session = SessionWrapper(this) - try { - return method(session) - } finally { - session.logout() - } + return repoStore!!.runInSession(method) } - /** - * @param mainNodeName All activities (working with nodes) will done under topNode. TopNode should be given for backing up and - * restoring. By default "ProjectForge" is used. - */ - @JvmOverloads - fun init(repositoryDir: File, mainNodeName: String = "ProjectForge") { - synchronized(this) { - if (nodeStore != null) { - throw IllegalArgumentException("Can't initialize repo twice! repo=$this") - } - if (mainNodeName.isBlank()) { - throw IllegalArgumentException("Top node shouldn't be empty!") - } - log.debug { "Setting system property: derby.stream.error.field=${DerbyUtil::class.java.name}.DEV_NULL" } - System.setProperty("derby.stream.error.field", "${DerbyUtil::class.java.name}.DEV_NULL") - log.info { "Initializing JCR repository with main node '$mainNodeName' in: ${repositoryDir.absolutePath}" } - this.mainNodeName = mainNodeName - - FileStoreBuilder.fileStoreBuilder(repositoryDir).build().let { fileStore -> - this.fileStore = fileStore - this.fileStoreLocation = repositoryDir - nodeStore = SegmentNodeStoreBuilders.builder(fileStore).build() - repository = Jcr(Oak(nodeStore)).createRepository() - } - - runInSession { session -> - if (!session.rootNode.hasNode(mainNodeName)) { - log.info { "Creating top level node '$mainNodeName'." } - session.rootNode.addNode(mainNodeName) - } - session.save() - } - } - } - - internal fun close(session: Session) { - session.save() - fileStore?.close() - } - - companion object { - const val NODENAME_FILES = "__FILES" - internal const val PROPERTY_FILENAME = "fileName" - internal const val PROPERTY_FILESIZE = "size" - internal const val PROPERTY_FILECONTENT = "content" - internal const val PROPERTY_CREATED = "created" - internal const val PROPERTY_CREATED_BY_USER = "createdByUser" - internal const val PROPERTY_FILEDESC = "fileDescription" - internal const val PROPERTY_LAST_UPDATE = "lastUpdate" - internal const val PROPERTY_LAST_UPDATE_BY_USER = "lastUpdateByUser" - internal const val PROPERTY_CHECKSUM = "checksum" - internal const val PROPERTY_AES_ENCRYPTED = "aesEncrypted" - internal const val PROPERTY_ZIP_MODE = "zipMode" - private const val PROPERTY_RANDOM_ID_LENGTH = 20 - private val ALPHA_CHARSET: Array = ('a'..'z').toList().toTypedArray() - - internal fun checksum(istream: InputStream?): String { - istream ?: return "" - return "SHA256: ${DigestUtils.sha256Hex(istream)}" - } - - internal fun getAbsolutePath(parentPath: String?, relPath: String?): String? { - if (parentPath == null && relPath == null) { - return null - } - parentPath ?: return relPath - relPath ?: return parentPath - return if (parentPath.endsWith("/")) "$parentPath$relPath" else "$parentPath/$relPath" - } - } - - // https://stackoverflow.com/questions/1004327/getting-rid-of-derby-log - object DerbyUtil { - @JvmField - val DEV_NULL: OutputStream = object : OutputStream() { - override fun write(b: Int) {} - } - } } diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt new file mode 100644 index 0000000000..87296f23bb --- /dev/null +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt @@ -0,0 +1,127 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition 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; version 3 of the License. +// +// This community edition 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 http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.jcr + +import mu.KotlinLogging +import org.apache.jackrabbit.oak.Oak +import org.apache.jackrabbit.oak.jcr.Jcr +import org.apache.jackrabbit.oak.segment.SegmentNodeStoreBuilders +import org.apache.jackrabbit.oak.segment.file.FileStore +import org.apache.jackrabbit.oak.segment.file.FileStoreBuilder +import org.projectforge.common.FormatterUtils +import java.io.File +import java.io.OutputStream + +private val log = KotlinLogging.logger {} + +/** + * JCR repository with segment node store and tar file store. + * @param mainNodeName The name of the top node. + * @param fileStoreLocation The location of the file store (existing directory). + * + */ +internal class SegmentTarStorage( + mainNodeName: String, + val fileStoreLocation: File, +) : OakStorage(mainNodeName) { + /** + * For information. + */ + class FileStoreInfo(repoStore: SegmentTarStorage) { + val approximateSize = FormatterUtils.formatBytes(repoStore.fileStore?.stats?.approximateSize) + val tarFileCount = repoStore.fileStore?.stats?.tarFileCount + val location = repoStore.fileStoreLocation.absolutePath + + override fun toString(): String { + return PFJcrUtils.toJson(this) + } + } + + private var fileStore: FileStore? = null + + override fun onLogout() { + fileStore?.flush() + } + + override fun afterSessionClose() { + fileStore?.close() + } + + override fun shutdown() { + log.info { "Shutting down jcr filestore repository '$mainNodeName' in ${fileStoreLocation.absolutePath}..." } + fileStore?.let { + it.flush() + it.compactFull() + it.cleanup() + log.info { "Jcr stats: ${FileStoreInfo(this)}" } + it.close() + } + nodeStore.let { + //log.warn { "Method not yet implemented: ${it.javaClass}.dispose()" } + /*if (it is DocumentNodeStore) { + it.dispose() + }*/ + } + } + + override fun cleanup() { + log.info { "Cleaning JCR repository up..." } + fileStore?.let { + it.flush() + it.compactFull() + it.cleanup() + } + } + + init { + if (mainNodeName.isBlank()) { + throw IllegalArgumentException("Top node shouldn't be empty!") + } + log.debug { "Setting system property: derby.stream.error.field=${DerbyUtil::class.java.name}.DEV_NULL" } + System.setProperty("derby.stream.error.field", "${DerbyUtil::class.java.name}.DEV_NULL") + log.info { "Initializing JCR repository with main node '$mainNodeName' in: ${fileStoreLocation.absolutePath}" } + + FileStoreBuilder.fileStoreBuilder(fileStoreLocation).build().let { fileStore -> + this.fileStore = fileStore + nodeStore = SegmentNodeStoreBuilders.builder(fileStore).build() + repository = Jcr(Oak(nodeStore)).createRepository() + } + + runInSession { session -> + if (!session.rootNode.hasNode(mainNodeName)) { + log.info { "Creating top level node '$mainNodeName'." } + session.rootNode.addNode(mainNodeName) + } + session.save() + } + } + + // https://stackoverflow.com/questions/1004327/getting-rid-of-derby-log + object DerbyUtil { + @JvmField + val DEV_NULL: OutputStream = object : OutputStream() { + override fun write(b: Int) {} + } + } +} diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SessionWrapper.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SessionWrapper.kt index 268cd5f676..890667d914 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SessionWrapper.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SessionWrapper.kt @@ -35,8 +35,8 @@ private val log = KotlinLogging.logger {} /** * Handles close and clean-up. Thin wrapper for [Session] */ -open class SessionWrapper(private val repoService: RepoService) { - val session: Session = repoService.repository.login(credentials) +open class SessionWrapper(private val repoStore: OakStorage) { + val session: Session = repoStore.repository.login(credentials) val valueFactory: ValueFactory get() = session.valueFactory @@ -57,7 +57,7 @@ open class SessionWrapper(private val repoService: RepoService) { } fun logout() { - repoService.fileStore?.flush() + repoStore.onLogout() session.save() } From 54dede5c3c7eb1572a412d8165424f05af0ca000 Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Fri, 24 Jan 2025 00:29:36 +0100 Subject: [PATCH 06/15] WIP: JCR RDB storage. --- gradle/libs.versions.toml | 1 + projectforge-application/build.gradle.kts | 1 + projectforge-jcr/build.gradle.kts | 1 + .../kotlin/org/projectforge/jcr/RDBStorage.kt | 127 ++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65cbb33646..94d081e2d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -152,6 +152,7 @@ org-apache-groovy-all = { module = "org.apache.groovy:groovy-all", version.ref = org-apache-httpcomponents-client5-httpclient5 = { module = "org.apache.httpcomponents.client5:httpclient5", version.ref = "org-apache-httpcomponents-client5-httpclient5" } org-apache-jackrabbit-oak-jcr = { module = "org.apache.jackrabbit:oak-jcr", version.ref = "org-apache-jackrabbit-oak" } org-apache-jackrabbit-oak-segment-tar = { module = "org.apache.jackrabbit:oak-segment-tar", version.ref = "org-apache-jackrabbit-oak" } +org-apache-jackrabbit-oak-store-document = { module = "org.apache.jackrabbit:oak-store-document", version.ref = "org-apache-jackrabbit-oak" } org-apache-logging-log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "org-apache-logging-log4j" } org-apache-logging-log4j-to-slf4j = { module = "org.apache.logging.log4j:log4j-to-slf4j", version.ref = "org-apache-logging-log4j" } org-apache-poi = { module = "org.apache.poi:poi", version.ref = "org-apache-poi" } diff --git a/projectforge-application/build.gradle.kts b/projectforge-application/build.gradle.kts index c937b35556..33b93f80a4 100644 --- a/projectforge-application/build.gradle.kts +++ b/projectforge-application/build.gradle.kts @@ -165,6 +165,7 @@ dependencies { implementation(libs.org.apache.httpcomponents.client5.httpclient5) implementation(libs.org.apache.jackrabbit.oak.jcr) implementation(libs.org.apache.jackrabbit.oak.segment.tar) + implementation(libs.org.apache.jackrabbit.oak.store.document) implementation(libs.org.apache.tomcat.embed.core) implementation(libs.org.apache.tomcat.embed.websocket) implementation(libs.org.apache.poi) diff --git a/projectforge-jcr/build.gradle.kts b/projectforge-jcr/build.gradle.kts index a5e572eab7..a23159157b 100644 --- a/projectforge-jcr/build.gradle.kts +++ b/projectforge-jcr/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { api(libs.net.lingala.zip4j.zip4j) api(libs.org.apache.jackrabbit.oak.jcr) api(libs.org.apache.jackrabbit.oak.segment.tar) + api(libs.org.apache.jackrabbit.oak.store.document) api(libs.org.jetbrains.kotlinx.coroutines.core) api(libs.org.jetbrains.kotlin.stdlib) testImplementation(project(":projectforge-commons-test")) diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt new file mode 100644 index 0000000000..a934ffad45 --- /dev/null +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt @@ -0,0 +1,127 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition 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; version 3 of the License. +// +// This community edition 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 http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.jcr + +import mu.KotlinLogging +import org.apache.jackrabbit.oak.Oak +import org.apache.jackrabbit.oak.plugins.document.rdb.RDBDataSourceFactory +import org.apache.jackrabbit.oak.plugins.document.rdb.RDBDocumentNodeStoreBuilder +import org.apache.jackrabbit.oak.spi.state.NodeStore +import javax.sql.DataSource + + +private val log = KotlinLogging.logger {} + +/** + * UNDER CONSTRUCTION! + * + * JCR repository with RDB storage (PostgreSQL is tested). + * ``` + * CREATE DATABASE projectforge_jcr; + * GRANT ALL PRIVILEGES ON DATABASE projectforge_jcr TO projectforge; + * ``` + * @param mainNodeName The name of the top node. + * + */ +internal class RDBStorage( + mainNodeName: String, +) : OakStorage(mainNodeName) { + override fun onLogout() { + } + + override fun afterSessionClose() { + } + + override fun shutdown() { + log.info { "Shutting down jcr RDB repository '$mainNodeName'..." } + nodeStore.let { + //log.warn { "Method not yet implemented: ${it.javaClass}.dispose()" } + /*if (it is DocumentNodeStore) { + it.dispose() + }*/ + } + } + + override fun cleanup() { + log.info { "Cleaning JCR repository up..." } + } + + init { + if (mainNodeName.isBlank()) { + throw IllegalArgumentException("Top node shouldn't be empty!") + } + log.info { "Initializing JCR repository with main node '$mainNodeName'..." } + + /* FileStoreBuilder.fileStoreBuilder(fileStoreLocation).build().let { fileStore -> + this.fileStore = fileStore + nodeStore = SegmentNodeStoreBuilders.builder(fileStore).build() + repository = Jcr(Oak(nodeStore)).createRepository() + }*/ + + runInSession { session -> + if (!session.rootNode.hasNode(mainNodeName)) { + log.info { "Creating top level node '$mainNodeName'." } + session.rootNode.addNode(mainNodeName) + } + session.save() + } + } + + // Function to create a PostgreSQL DataSource + fun createPostgresDataSource(): DataSource { + // Configure the PostgreSQL DataSource + return RDBDataSourceFactory.forJdbcUrl( + "jdbc:postgresql://localhost:5432/your_database", // Replace with your DB URL + "your_user", // Replace with your DB user + "your_password" // Replace with your DB password + ) + } + + // Function to create a NodeStore with PostgreSQL + fun createPostgresNodeStore(): NodeStore { + // Create a PostgreSQL DataSource + val dataSource = createPostgresDataSource() + + // Build the DocumentNodeStore for PostgreSQL + return RDBDocumentNodeStoreBuilder()//.newRDBDocumentNodeStoreBuilder() + .setRDBConnection(dataSource).build() + } + + // Initialize the Oak repository with PostgreSQL + fun initializeOakWithPostgres(): Oak { + // Create a NodeStore using the PostgreSQL setup + val nodeStore = createPostgresNodeStore() + + // Initialize Oak with the NodeStore + return Oak(nodeStore) + } + + fun main() { + // Initialize Oak repository + val oak = initializeOakWithPostgres() + + // Print a simple log message + println("Oak repository initialized with PostgreSQL") + } +} From 8a1a64cf59acf8080b661937e7515b459ef5f007 Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Fri, 24 Jan 2025 08:05:01 +0100 Subject: [PATCH 07/15] WIP: JCR RDB storage. --- .../kotlin/org/projectforge/jcr/OakStorage.kt | 18 ++--- .../kotlin/org/projectforge/jcr/RDBStorage.kt | 80 ++++++------------- .../org/projectforge/jcr/RepoService.kt | 17 ++++ .../org/projectforge/jcr/SegmentTarStorage.kt | 13 +-- .../org/projectforge/jcr/SessionWrapper.kt | 3 +- 5 files changed, 55 insertions(+), 76 deletions(-) diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt index d8aa1f6203..1aba631c27 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt @@ -25,6 +25,8 @@ package org.projectforge.jcr import mu.KotlinLogging import org.apache.commons.codec.digest.DigestUtils +import org.apache.jackrabbit.oak.Oak +import org.apache.jackrabbit.oak.jcr.Jcr import org.apache.jackrabbit.oak.spi.state.NodeStore import org.projectforge.common.CryptStreamUtils import org.projectforge.common.FormatterUtils @@ -43,21 +45,14 @@ private val log = KotlinLogging.logger {} */ abstract class OakStorage(val mainNodeName: String) { internal lateinit var repository: Repository + private set lateinit var nodeStore: NodeStore protected set abstract fun shutdown() - abstract fun onLogout() - - abstract fun afterSessionClose() - - internal open fun close(session: Session) { - session.save() - afterSessionClose() - } - + abstract fun afterSessionClosed() /** * @param parentNodePath Path, nodes are separated by '/', e. g. "world/germany". The nodes of this path must already exist. @@ -612,9 +607,14 @@ abstract class OakStorage(val mainNodeName: String) { return method(session) } finally { session.logout() + afterSessionClosed() } } + protected fun initRepository() { + repository = Jcr(Oak(nodeStore)).createRepository() + } + companion object { const val NODENAME_FILES = "__FILES" internal const val PROPERTY_FILENAME = "fileName" diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt index a934ffad45..911b13431b 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt @@ -24,60 +24,65 @@ package org.projectforge.jcr import mu.KotlinLogging -import org.apache.jackrabbit.oak.Oak +import org.apache.jackrabbit.oak.plugins.document.DocumentNodeStore import org.apache.jackrabbit.oak.plugins.document.rdb.RDBDataSourceFactory import org.apache.jackrabbit.oak.plugins.document.rdb.RDBDocumentNodeStoreBuilder -import org.apache.jackrabbit.oak.spi.state.NodeStore import javax.sql.DataSource - private val log = KotlinLogging.logger {} /** * UNDER CONSTRUCTION! * * JCR repository with RDB storage (PostgreSQL is tested). + * + * #### Preparation of the database (PostgreSQL): * ``` * CREATE DATABASE projectforge_jcr; * GRANT ALL PRIVILEGES ON DATABASE projectforge_jcr TO projectforge; * ``` + * + * #### Important note: + * Any existing segment store will not be migrated when switching to RDB storage! You have to export and import the data manually. + * * @param mainNodeName The name of the top node. * */ internal class RDBStorage( mainNodeName: String, + repoService: RepoService, ) : OakStorage(mainNodeName) { - override fun onLogout() { - } + private val dataSource: DataSource + private val jdbcUrl = repoService.jdbcUrl // For log messages, only. - override fun afterSessionClose() { + override fun afterSessionClosed() { + // Nothing to do. } override fun shutdown() { - log.info { "Shutting down jcr RDB repository '$mainNodeName'..." } - nodeStore.let { - //log.warn { "Method not yet implemented: ${it.javaClass}.dispose()" } - /*if (it is DocumentNodeStore) { - it.dispose() - }*/ + log.info { "Shutting down jcr RDB repository '$mainNodeName' (database='$jdbcUrl')..." } + try { + (nodeStore as? DocumentNodeStore)?.dispose() + log.info { "Repository shutdown completed." } + } catch (e: Exception) { + log.error { "Error during repository shutdown: ${e.message}" } } } override fun cleanup() { - log.info { "Cleaning JCR repository up..." } + log.info { "Cleanup job invoked, no action required for RDB repository." } } init { if (mainNodeName.isBlank()) { throw IllegalArgumentException("Top node shouldn't be empty!") } - log.info { "Initializing JCR repository with main node '$mainNodeName'..." } + log.info { "Initializing JCR repository with main node '$mainNodeName' and database='${repoService.jdbcUrl}'..." } - /* FileStoreBuilder.fileStoreBuilder(fileStoreLocation).build().let { fileStore -> - this.fileStore = fileStore - nodeStore = SegmentNodeStoreBuilders.builder(fileStore).build() - repository = Jcr(Oak(nodeStore)).createRepository() - }*/ + dataSource = + RDBDataSourceFactory.forJdbcUrl(repoService.jdbcUrl, repoService.jdbcUser, repoService.jdbcPassword) + nodeStore = RDBDocumentNodeStoreBuilder().setRDBConnection(dataSource).build() + initRepository() runInSession { session -> if (!session.rootNode.hasNode(mainNodeName)) { @@ -87,41 +92,4 @@ internal class RDBStorage( session.save() } } - - // Function to create a PostgreSQL DataSource - fun createPostgresDataSource(): DataSource { - // Configure the PostgreSQL DataSource - return RDBDataSourceFactory.forJdbcUrl( - "jdbc:postgresql://localhost:5432/your_database", // Replace with your DB URL - "your_user", // Replace with your DB user - "your_password" // Replace with your DB password - ) - } - - // Function to create a NodeStore with PostgreSQL - fun createPostgresNodeStore(): NodeStore { - // Create a PostgreSQL DataSource - val dataSource = createPostgresDataSource() - - // Build the DocumentNodeStore for PostgreSQL - return RDBDocumentNodeStoreBuilder()//.newRDBDocumentNodeStoreBuilder() - .setRDBConnection(dataSource).build() - } - - // Initialize the Oak repository with PostgreSQL - fun initializeOakWithPostgres(): Oak { - // Create a NodeStore using the PostgreSQL setup - val nodeStore = createPostgresNodeStore() - - // Initialize Oak with the NodeStore - return Oak(nodeStore) - } - - fun main() { - // Initialize Oak repository - val oak = initializeOakWithPostgres() - - // Print a simple log message - println("Oak repository initialized with PostgreSQL") - } } diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt index c99d9a5400..bd565c5d97 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt @@ -25,6 +25,7 @@ package org.projectforge.jcr import jakarta.annotation.PreDestroy import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.io.File import java.io.InputStream @@ -36,6 +37,22 @@ private val log = KotlinLogging.logger {} open class RepoService { private var repoStore: OakStorage? = null + /** + * JDBC URL for RDB storage, e.g.: `jdbc:postgresql://localhost:5432/your_database`. + * If empty, the segmentTarStore will be used. + */ + @Value("\${projectforge.jcr.rdb.jdbc.url:}") + var jdbcUrl: String? = null + private set + + @Value("\${projectforge.jcr.rdb.jdbc.user:}") + var jdbcUser: String? = null + private set + + @Value("\${projectforge.jcr.rdb.jdbc.password:}") + internal var jdbcPassword: String? = null + private set + val mainNodeName: String? get() = repoStore?.mainNodeName diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt index 87296f23bb..f1691a0256 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt @@ -60,14 +60,10 @@ internal class SegmentTarStorage( private var fileStore: FileStore? = null - override fun onLogout() { + override fun afterSessionClosed() { fileStore?.flush() } - override fun afterSessionClose() { - fileStore?.close() - } - override fun shutdown() { log.info { "Shutting down jcr filestore repository '$mainNodeName' in ${fileStoreLocation.absolutePath}..." } fileStore?.let { @@ -102,10 +98,9 @@ internal class SegmentTarStorage( System.setProperty("derby.stream.error.field", "${DerbyUtil::class.java.name}.DEV_NULL") log.info { "Initializing JCR repository with main node '$mainNodeName' in: ${fileStoreLocation.absolutePath}" } - FileStoreBuilder.fileStoreBuilder(fileStoreLocation).build().let { fileStore -> - this.fileStore = fileStore - nodeStore = SegmentNodeStoreBuilders.builder(fileStore).build() - repository = Jcr(Oak(nodeStore)).createRepository() + this.fileStore = FileStoreBuilder.fileStoreBuilder(fileStoreLocation).build().also { + nodeStore = SegmentNodeStoreBuilders.builder(it).build() + initRepository() } runInSession { session -> diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SessionWrapper.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SessionWrapper.kt index 890667d914..08e69b6430 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SessionWrapper.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SessionWrapper.kt @@ -35,7 +35,7 @@ private val log = KotlinLogging.logger {} /** * Handles close and clean-up. Thin wrapper for [Session] */ -open class SessionWrapper(private val repoStore: OakStorage) { +open class SessionWrapper(repoStore: OakStorage) { val session: Session = repoStore.repository.login(credentials) val valueFactory: ValueFactory @@ -57,7 +57,6 @@ open class SessionWrapper(private val repoStore: OakStorage) { } fun logout() { - repoStore.onLogout() session.save() } From 3d8943146c2401b4253f01ca2d385891747ef7bc Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Fri, 24 Jan 2025 10:08:22 +0100 Subject: [PATCH 08/15] WIP: JCR RDB storage. --- .../src/main/kotlin/org/projectforge/jcr/RDBStorage.kt | 9 +++++++-- .../src/main/kotlin/org/projectforge/jcr/RepoService.kt | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt index 911b13431b..9ea41d8f80 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt @@ -79,8 +79,13 @@ internal class RDBStorage( } log.info { "Initializing JCR repository with main node '$mainNodeName' and database='${repoService.jdbcUrl}'..." } - dataSource = - RDBDataSourceFactory.forJdbcUrl(repoService.jdbcUrl, repoService.jdbcUser, repoService.jdbcPassword) + try { + dataSource = + RDBDataSourceFactory.forJdbcUrl(jdbcUrl, repoService.jdbcUser, repoService.jdbcPassword) + } catch (ex: Exception) { + log.error(ex) { "Can't establish jdbc connection to '${jdbcUrl}' with user '${repoService.jdbcUser} : ${ex.message}" } + throw ex + } nodeStore = RDBDocumentNodeStoreBuilder().setRDBConnection(dataSource).build() initRepository() diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt index bd565c5d97..cee6a2641d 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt @@ -89,7 +89,11 @@ open class RepoService { if (mainNodeName.isBlank()) { throw IllegalArgumentException("Top node shouldn't be empty!") } - repoStore = SegmentTarStorage(mainNodeName, repositoryDir) + if (jdbcUrl.isNullOrBlank()) { + repoStore = SegmentTarStorage(mainNodeName, repositoryDir) + } else { + repoStore = RDBStorage(mainNodeName, this) + } } } From 0f085dd93a19838f9bba7d6c32f07011d5699f3b Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Sat, 25 Jan 2025 09:52:49 +0100 Subject: [PATCH 09/15] WIP: JCR RDB storage (restart of storage fails :-(. --- gradle/libs.versions.toml | 5 +- projectforge-application/build.gradle.kts | 1 + .../src/main/resources/application.properties | 12 +++ projectforge-jcr/build.gradle.kts | 2 + .../kotlin/org/projectforge/jcr/OakStorage.kt | 9 +- .../kotlin/org/projectforge/jcr/RDBStorage.kt | 53 ++++++----- .../kotlin/org/projectforge/jcr/RepoConfig.kt | 92 +++++++++++++++++++ .../org/projectforge/jcr/RepoService.kt | 32 +++---- .../org/projectforge/jcr/SegmentTarStorage.kt | 11 +-- 9 files changed, 160 insertions(+), 57 deletions(-) create mode 100644 projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoConfig.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94d081e2d8..82f2b373da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,8 +62,6 @@ org-hibernate-validator = "8.0.2.Final" org-hsqldb-hsqldb = "2.7.4" org-jetbrains-kotlin = "2.0.21" org-jetbrains-kotlinx-coroutines-core = "1.9.0" -org-jetbrains-kotlinx-coroutines-slf4j = "1.8.1" -org-jetbrains-intellij-deps-trove4j = "1.0.20200330" # Needed by Kotlin scripting engine. Must match the version of the Kotlin compiler. org-jfree-jfreechart = "1.5.5" org-junit-jupiter = "5.10.3" org-junit-platform-launcher = "1.10.3" @@ -74,7 +72,7 @@ org-mockito-kotlin = "5.4.0" org-mozilla-rhino = "1.7.15" org-postgresql = "42.7.4" org-reflections = "0.10.2" -org-springframework-boot = "3.3.6" +org-springframework-boot = "3.3.8" org-apache-tomcat-embed = "10.1.33" # must match spring-boot org-springframework-spring = "6.1.16" org-springframework-security = "6.4.1" @@ -198,6 +196,7 @@ org-slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "org-slf4j" } org-slf4j-jul-to-slf4j = { module = "org.slf4j:jul-to-slf4j", version.ref = "org-slf4j" } org-slf4j-jcl-over-slf4j = { module = "org.slf4j:jcl-over-slf4j", version.ref = "org-slf4j" } org-springframework-boot = { module = "org.springframework.boot:spring-boot", version.ref = "org-springframework-boot" } +org-springframework-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "org-springframework-boot" } org-springframework-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "org-springframework-boot" } org-springframework-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "org-springframework-boot" } org-springframework-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "org-springframework-boot" } diff --git a/projectforge-application/build.gradle.kts b/projectforge-application/build.gradle.kts index 33b93f80a4..3476e11ebe 100644 --- a/projectforge-application/build.gradle.kts +++ b/projectforge-application/build.gradle.kts @@ -197,6 +197,7 @@ dependencies { implementation(libs.org.postgresql) implementation(libs.org.reflections) implementation(libs.org.springframework.boot) + implementation(libs.org.springframework.boot.autoconfigure) implementation(libs.org.springframework.boot.dependencies) implementation(libs.org.springframework.boot.starter) implementation(libs.org.springframework.boot.starter.data.jpa) diff --git a/projectforge-business/src/main/resources/application.properties b/projectforge-business/src/main/resources/application.properties index 9eaa69acaf..6ef3afcac9 100644 --- a/projectforge-business/src/main/resources/application.properties +++ b/projectforge-business/src/main/resources/application.properties @@ -99,6 +99,18 @@ spring.datasource.hikari.validation-timeout=1000 # If set to true, check functioning of setup page: spring.datasource.hikari.auto-commit=false +# Second data source for Oak repository (if not given, segment tar is used). +oak.datasource.url= +oak.datasource.username= +oak.datasource.password= +oak.datasource.driver-class-name=org.postgresql.Driver +oak.datasource.hikari.maximum-pool-size=10 +oak.datasource.hikari.minimum-idle=2 +# in milliseconds +oak.datasource.hikari.connection-timeout=30000 +# in milliseconds +oak.datasource.hikari.idle-timeout=600000 + server.port=8080 server.address=localhost # "HttpOnly" flag for the session cookie. diff --git a/projectforge-jcr/build.gradle.kts b/projectforge-jcr/build.gradle.kts index a23159157b..5f39b9c659 100644 --- a/projectforge-jcr/build.gradle.kts +++ b/projectforge-jcr/build.gradle.kts @@ -16,7 +16,9 @@ dependencies { api(libs.com.fasterxml.jackson.core) api(libs.com.fasterxml.jackson.core.databind) api(libs.com.fasterxml.jackson.core.annotations) + api(libs.com.zaxxer.hikaricp) api(libs.org.springframework.spring.context) + api(libs.org.springframework.boot.autoconfigure) api(libs.io.dropwizard.metrics.core) api(libs.org.apache.jackrabbit.oak.jcr) api(libs.jakarta.annotation.api) diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt index 1aba631c27..dd364fe57e 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/OakStorage.kt @@ -45,7 +45,6 @@ private val log = KotlinLogging.logger {} */ abstract class OakStorage(val mainNodeName: String) { internal lateinit var repository: Repository - private set lateinit var nodeStore: NodeStore protected set @@ -612,7 +611,13 @@ abstract class OakStorage(val mainNodeName: String) { } protected fun initRepository() { - repository = Jcr(Oak(nodeStore)).createRepository() + runInSession { session -> + if (!session.rootNode.hasNode(mainNodeName)) { + log.info { "Creating top level node '$mainNodeName'." } + session.rootNode.addNode(mainNodeName) + } + session.save() + } } companion object { diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt index 9ea41d8f80..eee82b6308 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RDBStorage.kt @@ -23,12 +23,15 @@ package org.projectforge.jcr +import com.zaxxer.hikari.HikariDataSource import mu.KotlinLogging +import org.apache.jackrabbit.oak.Oak +import org.apache.jackrabbit.oak.jcr.Jcr import org.apache.jackrabbit.oak.plugins.document.DocumentNodeStore -import org.apache.jackrabbit.oak.plugins.document.rdb.RDBDataSourceFactory import org.apache.jackrabbit.oak.plugins.document.rdb.RDBDocumentNodeStoreBuilder import javax.sql.DataSource + private val log = KotlinLogging.logger {} /** @@ -50,51 +53,55 @@ private val log = KotlinLogging.logger {} */ internal class RDBStorage( mainNodeName: String, - repoService: RepoService, + val dataSource: DataSource, ) : OakStorage(mainNodeName) { - private val dataSource: DataSource - private val jdbcUrl = repoService.jdbcUrl // For log messages, only. - override fun afterSessionClosed() { // Nothing to do. } override fun shutdown() { - log.info { "Shutting down jcr RDB repository '$mainNodeName' (database='$jdbcUrl')..." } + log.info { "Shutting down jcr RDB repository '$mainNodeName'..." } try { (nodeStore as? DocumentNodeStore)?.dispose() log.info { "Repository shutdown completed." } } catch (e: Exception) { log.error { "Error during repository shutdown: ${e.message}" } } + if (dataSource is HikariDataSource) { + dataSource.close() + } } override fun cleanup() { log.info { "Cleanup job invoked, no action required for RDB repository." } } + internal val isRepositoryInitialized: Boolean + get() { + val connection = dataSource.connection ?: return false + try { + val stmt = connection.prepareStatement("SELECT ID FROM NODES WHERE ID like '%/jcr:system'") + val resultSet = stmt.executeQuery() + return resultSet.next() + } finally { + connection.close() + } + } + + init { if (mainNodeName.isBlank()) { throw IllegalArgumentException("Top node shouldn't be empty!") } - log.info { "Initializing JCR repository with main node '$mainNodeName' and database='${repoService.jdbcUrl}'..." } + log.info { "Initializing JCR repository with main node '$mainNodeName'..." } - try { - dataSource = - RDBDataSourceFactory.forJdbcUrl(jdbcUrl, repoService.jdbcUser, repoService.jdbcPassword) - } catch (ex: Exception) { - log.error(ex) { "Can't establish jdbc connection to '${jdbcUrl}' with user '${repoService.jdbcUser} : ${ex.message}" } - throw ex - } - nodeStore = RDBDocumentNodeStoreBuilder().setRDBConnection(dataSource).build() + nodeStore = RDBDocumentNodeStoreBuilder() + .setRDBConnection(dataSource) + //.setClusterId(1) + .build() + val oak = Oak(nodeStore) + val jcr = Jcr(oak) + repository = jcr.createRepository() initRepository() - - runInSession { session -> - if (!session.rootNode.hasNode(mainNodeName)) { - log.info { "Creating top level node '$mainNodeName'." } - session.rootNode.addNode(mainNodeName) - } - session.save() - } } } diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoConfig.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoConfig.kt new file mode 100644 index 0000000000..6730622f35 --- /dev/null +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoConfig.kt @@ -0,0 +1,92 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition 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; version 3 of the License. +// +// This community edition 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 http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.jcr + +import com.zaxxer.hikari.HikariDataSource +import jakarta.annotation.PostConstruct +import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.jdbc.DataSourceBuilder +import org.springframework.context.annotation.Configuration +import javax.sql.DataSource + +private val log = KotlinLogging.logger {} + +@Configuration +internal open class RepoConfig { + @Value("\${oak.datasource.url}") + private lateinit var dataSourceUrl: String + + @Value("\${oak.datasource.username}") + private lateinit var dataSourceUser: String + + @Value("\${oak.datasource.password}") + private lateinit var dataSourcePassword: String + + @Value("\${oak.datasource.driver-class-name}") + private lateinit var dataSourceDriver: String + + @Value("\${oak.datasource.hikari.maximum-pool-size}") + private var dataSourceMaximumPoolSize: Int = 10 + + @Value("\${oak.datasource.hikari.minimum-idle}") + private var dataSourceMinimumIdle: Int = 2 + + @Value("\${oak.datasource.hikari.connection-timeout}") + private var dataSourceConnectionTimeout: Long = 30000 + + @Value("\${oak.datasource.hikari.idle-timeout}") + private var dataSourceIdleTimeout: Long = 600000 + + internal val dataSourceConfigured: Boolean + get() = !dataSourceUrl.isBlank() + + var dataSource: DataSource? = null + private set + + @PostConstruct + private fun postConstruct() { + if (!dataSourceConfigured) { + return + } + log.info { "Creating oak datasource with url=$dataSourceUrl, user=$dataSourceUser and driver=$dataSourceDriver" } + try { + dataSource = DataSourceBuilder.create() + .url(dataSourceUrl) + .username(dataSourceUser) + .password(dataSourcePassword) + .driverClassName(dataSourceDriver) + .type(HikariDataSource::class.java) + .build().also { + it as HikariDataSource + it.minimumIdle = dataSourceMinimumIdle + it.maximumPoolSize = dataSourceMaximumPoolSize + it.connectionTimeout = dataSourceConnectionTimeout // in milliseconds (optional) + it.idleTimeout = dataSourceIdleTimeout // in milliseconds (optional) + } + } catch (e: Exception) { + log.error(e) { "Error during data source creation: ${e.message}" } + } + } +} diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt index cee6a2641d..95967cdef5 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt @@ -25,7 +25,7 @@ package org.projectforge.jcr import jakarta.annotation.PreDestroy import mu.KotlinLogging -import org.springframework.beans.factory.annotation.Value +import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import java.io.File import java.io.InputStream @@ -37,21 +37,8 @@ private val log = KotlinLogging.logger {} open class RepoService { private var repoStore: OakStorage? = null - /** - * JDBC URL for RDB storage, e.g.: `jdbc:postgresql://localhost:5432/your_database`. - * If empty, the segmentTarStore will be used. - */ - @Value("\${projectforge.jcr.rdb.jdbc.url:}") - var jdbcUrl: String? = null - private set - - @Value("\${projectforge.jcr.rdb.jdbc.user:}") - var jdbcUser: String? = null - private set - - @Value("\${projectforge.jcr.rdb.jdbc.password:}") - internal var jdbcPassword: String? = null - private set + @Autowired + private lateinit var repoConfig: RepoConfig val mainNodeName: String? get() = repoStore?.mainNodeName @@ -84,15 +71,20 @@ open class RepoService { fun init(repositoryDir: File, mainNodeName: String = "ProjectForge") { synchronized(this) { if (repoStore != null) { - throw IllegalArgumentException("Can't initialize segmentTarStore twice! segmentTarStore=$repoStore") + throw IllegalArgumentException("Can't initialize oak store twice! oak store=$repoStore") } if (mainNodeName.isBlank()) { throw IllegalArgumentException("Top node shouldn't be empty!") } - if (jdbcUrl.isNullOrBlank()) { - repoStore = SegmentTarStorage(mainNodeName, repositoryDir) + if (repoConfig.dataSourceConfigured) { + val dataSource = repoConfig.dataSource + if (dataSource == null) { + log.error { "No available data source for Oak!" } + throw IllegalArgumentException("No available data source for Oak!") + } + repoStore = RDBStorage(mainNodeName, dataSource) } else { - repoStore = RDBStorage(mainNodeName, this) + repoStore = SegmentTarStorage(mainNodeName, repositoryDir) } } } diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt index f1691a0256..d3c7b0b87c 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/SegmentTarStorage.kt @@ -100,16 +100,9 @@ internal class SegmentTarStorage( this.fileStore = FileStoreBuilder.fileStoreBuilder(fileStoreLocation).build().also { nodeStore = SegmentNodeStoreBuilders.builder(it).build() - initRepository() - } - - runInSession { session -> - if (!session.rootNode.hasNode(mainNodeName)) { - log.info { "Creating top level node '$mainNodeName'." } - session.rootNode.addNode(mainNodeName) - } - session.save() + repository = Jcr(Oak(nodeStore)).createRepository() } + initRepository() } // https://stackoverflow.com/questions/1004327/getting-rid-of-derby-log From 54fb6651f3ac204df307d0df5f440cc56a5485cd Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Sat, 25 Jan 2025 22:25:33 +0100 Subject: [PATCH 10/15] NOP --- projectforge-application/src/main/resources/i18nKeys.json | 4 ++-- .../src/main/resources/application.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/projectforge-application/src/main/resources/i18nKeys.json b/projectforge-application/src/main/resources/i18nKeys.json index 0e83f9c1b5..3111e704fb 100644 --- a/projectforge-application/src/main/resources/i18nKeys.json +++ b/projectforge-application/src/main/resources/i18nKeys.json @@ -711,7 +711,7 @@ {"i18nKey":"contextMenu.newTab","bundleName":"I18nResources","translation":"Open in new tab","translationDE":"Link in neuem Tab öffnen","usedInClasses":["org.projectforge.web.wicket.AbstractUnsecureBasePage"],"usedInFiles":[]}, {"i18nKey":"copy","bundleName":"I18nResources","translation":"Copy","translationDE":"Kopieren","usedInClasses":["org.projectforge.web.teamcal.integration.TeamCalCalendarPanel"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/gantt/GanttChartEditTreeTablePanel.html"]}, {"i18nKey":"create","bundleName":"I18nResources","translation":"Create","translationDE":"Anlegen","usedInClasses":["org.projectforge.framework.persistence.database.SchemaExport","org.projectforge.rest.GroupPagesRest","org.projectforge.rest.TimesheetFavoritesRest","org.projectforge.rest.UserPagesRest","org.projectforge.rest.calendar.CalendarServicesRest","org.projectforge.rest.poll.PollPageRest","org.projectforge.rest.task.TaskFavoritesRest","org.projectforge.ui.LayoutUtils","org.projectforge.ui.UIButton","org.projectforge.web.fibu.AbstractRechnungEditForm","org.projectforge.web.user.GroupEditForm","org.projectforge.web.wicket.AbstractEditForm"],"usedInFiles":[]}, - {"i18nKey":"created","bundleName":"I18nResources","translation":"created","translationDE":"angelegt","usedInClasses":["org.projectforge.business.address.AddressDao","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.AuftragsCacheService","org.projectforge.business.fibu.EingangsrechnungDO","org.projectforge.business.fibu.RechnungDO","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.humanresources.HRPlanningDO","org.projectforge.business.teamcal.event.model.TeamEventDO","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.flyway.dbmigration.V7_0_0_6__MigrateEmployeeAndCarryVacationDays","org.projectforge.flyway.dbmigration.V7_4_1_3__ReleaseUserPassword","org.projectforge.flyway.dbmigration.V8_0_10__EmployeeDO_weeklyHoursValidSince","org.projectforge.flyway.dbmigration.V8_0_7__FixOrder_Angebots_ErfassungsDatum","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.entities.AbstractBaseDO","org.projectforge.framework.renderer.PdfRenderer","org.projectforge.jcr.RepoService","org.projectforge.mail.SendMail","org.projectforge.plugins.datatransfer.rest.DataTransferAreaPagesRest","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.marketing.AddressCampaignValueListPage","org.projectforge.plugins.marketing.rest.AddressCampaignPagesRest","org.projectforge.plugins.memo.rest.MemoPagesRest","org.projectforge.plugins.merlin.rest.MerlinPagesRest","org.projectforge.plugins.todo.ToDoDao","org.projectforge.plugins.todo.ToDoListPage","org.projectforge.plugins.todo.rest.ToDoPagesRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.BookPagesRest","org.projectforge.rest.hr.LeaveAccountEntryPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.security.webauthn.WebAuthnEntryDO","org.projectforge.ui.UIAgGridColumnDef","org.projectforge.ui.UIAttachmentList","org.projectforge.web.gantt.GanttChartListPage"],"usedInFiles":[]}, + {"i18nKey":"created","bundleName":"I18nResources","translation":"created","translationDE":"angelegt","usedInClasses":["org.projectforge.business.address.AddressDao","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.AuftragsCacheService","org.projectforge.business.fibu.EingangsrechnungDO","org.projectforge.business.fibu.RechnungDO","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.humanresources.HRPlanningDO","org.projectforge.business.teamcal.event.model.TeamEventDO","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.flyway.dbmigration.V7_0_0_6__MigrateEmployeeAndCarryVacationDays","org.projectforge.flyway.dbmigration.V7_4_1_3__ReleaseUserPassword","org.projectforge.flyway.dbmigration.V8_0_10__EmployeeDO_weeklyHoursValidSince","org.projectforge.flyway.dbmigration.V8_0_7__FixOrder_Angebots_ErfassungsDatum","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.entities.AbstractBaseDO","org.projectforge.framework.renderer.PdfRenderer","org.projectforge.jcr.OakStorage","org.projectforge.mail.SendMail","org.projectforge.plugins.datatransfer.rest.DataTransferAreaPagesRest","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.marketing.AddressCampaignValueListPage","org.projectforge.plugins.marketing.rest.AddressCampaignPagesRest","org.projectforge.plugins.memo.rest.MemoPagesRest","org.projectforge.plugins.merlin.rest.MerlinPagesRest","org.projectforge.plugins.todo.ToDoDao","org.projectforge.plugins.todo.ToDoListPage","org.projectforge.plugins.todo.rest.ToDoPagesRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.BookPagesRest","org.projectforge.rest.hr.LeaveAccountEntryPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.security.webauthn.WebAuthnEntryDO","org.projectforge.ui.UIAgGridColumnDef","org.projectforge.ui.UIAttachmentList","org.projectforge.web.gantt.GanttChartListPage"],"usedInFiles":[]}, {"i18nKey":"createdBy","bundleName":"I18nResources","translation":"created by","translationDE":"angelegt von","usedInClasses":["org.projectforge.rest.AttachmentPageRest","org.projectforge.ui.UIAttachmentList"],"usedInFiles":[]}, {"i18nKey":"currencyConverter.percentage.help","bundleName":"I18nResources","translation":"You can enter amounts as well as percent values (e. g. 10%).","translationDE":"Es können sowohl Beträge als auch Prozentzahlen (z. B. 10%) eingegeben werden.","usedInClasses":["org.projectforge.web.fibu.RechnungCostEditTablePanel"],"usedInFiles":[]}, {"i18nKey":"currencyFormat","bundleName":"I18nResources","translation":"{0,number,,##0.00}","translationDE":"{0,number,,##0.00}","usedInClasses":[],"usedInFiles":[]}, @@ -1454,7 +1454,7 @@ {"i18nKey":"label.sendEMailNotification","bundleName":"I18nResources","translation":"Send an e-mail notification?","translationDE":"E-Mail-Benachrichtigung versenden?","usedInClasses":["org.projectforge.plugins.todo.ToDoEditForm","org.projectforge.web.fibu.AuftragEditForm"],"usedInFiles":[]}, {"i18nKey":"label.sendShortMessage","bundleName":"I18nResources","translation":"Send the assignee a text message?","translationDE":"SMS an Bearbeiter versenden?","usedInClasses":["org.projectforge.plugins.todo.ToDoEditForm"],"usedInFiles":[]}, {"i18nKey":"language","bundleName":"I18nResources","translation":"Language","translationDE":"Sprache","usedInClasses":["org.projectforge.framework.persistence.search.MyAnalysisConfigurer","org.projectforge.web.address.AddressPageSupport"],"usedInFiles":[]}, - {"i18nKey":"lastUpdate","bundleName":"I18nResources","translation":"last modification","translationDE":"letzte Änderung","usedInClasses":["org.projectforge.business.address.AddressExport","org.projectforge.business.address.AddressImageCache","org.projectforge.business.address.AddressImageDao","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.EingangsrechnungDO","org.projectforge.business.fibu.RechnungDO","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.humanresources.HRPlanningDO","org.projectforge.business.teamcal.event.TeamEventDao","org.projectforge.business.teamcal.event.model.TeamEventDO","org.projectforge.business.timesheet.TimesheetDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.framework.persistence.api.BaseDao","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.database.ReindexerRegistry","org.projectforge.framework.persistence.database.ReindexerStrategy","org.projectforge.framework.persistence.entities.AbstractBaseDO","org.projectforge.framework.persistence.user.entities.UserAuthenticationsDO","org.projectforge.jcr.RepoService","org.projectforge.plugins.datatransfer.DataTransferAreaDao","org.projectforge.plugins.datatransfer.rest.DataTransferAreaPagesRest","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.marketing.rest.AddressCampaignPagesRest","org.projectforge.plugins.memo.rest.MemoPagesRest","org.projectforge.plugins.merlin.MerlinTemplateDao","org.projectforge.plugins.skillmatrix.SkillEntryPagesRest","org.projectforge.plugins.todo.ToDoListPage","org.projectforge.rest.AddressBookPagesRest","org.projectforge.rest.AddressPagesRest","org.projectforge.rest.TeamCalPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.rest.scripting.MyScriptPagesRest","org.projectforge.rest.scripting.ScriptPagesRest","org.projectforge.security.webauthn.WebAuthnEntryDO","org.projectforge.ui.UIAgGridColumnDef","org.projectforge.ui.UIAttachmentList","org.projectforge.web.address.AddressListPage","org.projectforge.web.teamcal.admin.TeamCalListPage","org.projectforge.web.teamcal.event.TeamEventListPage","org.projectforge.web.user.UserPrefListPage"],"usedInFiles":[]}, + {"i18nKey":"lastUpdate","bundleName":"I18nResources","translation":"last modification","translationDE":"letzte Änderung","usedInClasses":["org.projectforge.business.address.AddressExport","org.projectforge.business.address.AddressImageCache","org.projectforge.business.address.AddressImageDao","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.EingangsrechnungDO","org.projectforge.business.fibu.RechnungDO","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.humanresources.HRPlanningDO","org.projectforge.business.teamcal.event.TeamEventDao","org.projectforge.business.teamcal.event.model.TeamEventDO","org.projectforge.business.timesheet.TimesheetDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.framework.persistence.api.BaseDao","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.database.ReindexerRegistry","org.projectforge.framework.persistence.database.ReindexerStrategy","org.projectforge.framework.persistence.entities.AbstractBaseDO","org.projectforge.framework.persistence.user.entities.UserAuthenticationsDO","org.projectforge.jcr.OakStorage","org.projectforge.plugins.datatransfer.DataTransferAreaDao","org.projectforge.plugins.datatransfer.rest.DataTransferAreaPagesRest","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.marketing.rest.AddressCampaignPagesRest","org.projectforge.plugins.memo.rest.MemoPagesRest","org.projectforge.plugins.merlin.MerlinTemplateDao","org.projectforge.plugins.skillmatrix.SkillEntryPagesRest","org.projectforge.plugins.todo.ToDoListPage","org.projectforge.rest.AddressBookPagesRest","org.projectforge.rest.AddressPagesRest","org.projectforge.rest.TeamCalPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.rest.scripting.MyScriptPagesRest","org.projectforge.rest.scripting.ScriptPagesRest","org.projectforge.security.webauthn.WebAuthnEntryDO","org.projectforge.ui.UIAgGridColumnDef","org.projectforge.ui.UIAttachmentList","org.projectforge.web.address.AddressListPage","org.projectforge.web.teamcal.admin.TeamCalListPage","org.projectforge.web.teamcal.event.TeamEventListPage","org.projectforge.web.user.UserPrefListPage"],"usedInFiles":[]}, {"i18nKey":"ldap","bundleName":"I18nResources","translation":"LDAP","translationDE":"LDAP","usedInClasses":["org.projectforge.business.ldap.LdapConnector","org.projectforge.framework.persistence.user.entities.GroupDO","org.projectforge.rest.GroupPagesRest","org.projectforge.rest.UserPagesRest","org.projectforge.web.user.GroupEditForm"],"usedInFiles":[]}, {"i18nKey":"ldap.gidNumber","bundleName":"I18nResources","translation":"GID number","translationDE":"GID number","usedInClasses":["org.projectforge.rest.GroupPagesRest","org.projectforge.rest.UserPagesRest","org.projectforge.web.user.GroupEditForm"],"usedInFiles":[]}, {"i18nKey":"ldap.gidNumber.alreadyInUse","bundleName":"I18nResources","translation":"GID number is already assigned to another group. The next free GID number is {0}.","translationDE":"Die GID-Nummer ist bereits an eine andere Gruppe vergeben. Die nächste freie GID-Nummer lautet: {0}.","usedInClasses":["org.projectforge.rest.GroupPagesRest","org.projectforge.web.user.GroupEditForm"],"usedInFiles":[]}, diff --git a/projectforge-business/src/main/resources/application.properties b/projectforge-business/src/main/resources/application.properties index 6ef3afcac9..1f417215fe 100644 --- a/projectforge-business/src/main/resources/application.properties +++ b/projectforge-business/src/main/resources/application.properties @@ -99,7 +99,7 @@ spring.datasource.hikari.validation-timeout=1000 # If set to true, check functioning of setup page: spring.datasource.hikari.auto-commit=false -# Second data source for Oak repository (if not given, segment tar is used). +# Experimental! Second data source for Oak repository (if not given, segment tar is used). oak.datasource.url= oak.datasource.username= oak.datasource.password= From 7cdc06ad7d066dfe62dfe67026a50d41a3d21e0f Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Sat, 25 Jan 2025 23:18:31 +0100 Subject: [PATCH 11/15] MonthlyEmployeeReport[Week] -> Kotlin --- .../business/fibu/MonthlyEmployeeReport.java | 486 ------------------ .../fibu/MonthlyEmployeeReportWeek.java | 159 ------ .../fibu/datev/EmployeeSalaryExportDao.java | 2 +- .../business/fibu/EmployeeService.kt | 2 +- .../business/fibu/MonthlyEmployeeReport.kt | 423 +++++++++++++++ .../fibu/MonthlyEmployeeReportWeek.kt | 132 +++++ .../vacation/service/VacationService.kt | 4 +- .../web/fibu/MonthlyEmployeeReportPage.java | 10 +- 8 files changed, 565 insertions(+), 653 deletions(-) delete mode 100644 projectforge-business/src/main/java/org/projectforge/business/fibu/MonthlyEmployeeReport.java delete mode 100644 projectforge-business/src/main/java/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.java create mode 100644 projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReport.kt create mode 100644 projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.kt diff --git a/projectforge-business/src/main/java/org/projectforge/business/fibu/MonthlyEmployeeReport.java b/projectforge-business/src/main/java/org/projectforge/business/fibu/MonthlyEmployeeReport.java deleted file mode 100644 index 2d59fbebdb..0000000000 --- a/projectforge-business/src/main/java/org/projectforge/business/fibu/MonthlyEmployeeReport.java +++ /dev/null @@ -1,486 +0,0 @@ -///////////////////////////////////////////////////////////////////////////// -// -// Project ProjectForge Community Edition -// www.projectforge.org -// -// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) -// -// ProjectForge is dual-licensed. -// -// This community edition 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; version 3 of the License. -// -// This community edition 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 http://www.gnu.org/licenses/. -// -///////////////////////////////////////////////////////////////////////////// - -package org.projectforge.business.fibu; - -import org.apache.commons.collections4.MapUtils; -import org.apache.commons.lang3.Validate; -import org.apache.commons.text.StringEscapeUtils; -import org.projectforge.business.PfCaches; -import org.projectforge.business.common.OutputType; -import org.projectforge.business.fibu.kost.Kost1DO; -import org.projectforge.business.fibu.kost.Kost2DO; -import org.projectforge.business.task.TaskDO; -import org.projectforge.business.task.TaskFormatter; -import org.projectforge.business.timesheet.TimesheetDO; -import org.projectforge.business.vacation.service.VacationService; -import org.projectforge.business.vacation.service.VacationStats; -import org.projectforge.common.StringHelper; -import org.projectforge.framework.calendar.Holidays; -import org.projectforge.framework.calendar.MonthHolder; -import org.projectforge.framework.calendar.WeekHolder; -import org.projectforge.framework.i18n.I18nHelper; -import org.projectforge.framework.persistence.user.entities.PFUserDO; -import org.projectforge.framework.time.PFDateTime; -import org.projectforge.framework.time.PFDay; -import org.projectforge.framework.utils.NumberHelper; -import org.projectforge.web.WicketSupport; - -import java.io.Serializable; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.*; - -/** - * Repräsentiert einen Monatsbericht eines Mitarbeiters. - * - * @author Kai Reinhard (k.reinhard@micromata.de) - */ -public class MonthlyEmployeeReport implements Serializable { - private static final long serialVersionUID = -4636357379552246075L; - - private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MonthlyEmployeeReport.class); - - /** - * ID of pseudo task, see below. - */ - static final long MAGIC_PSEUDO_TASK_ID = -42L; - - /** - * Checks if the given taskId is the Pseudo task, see below. - * @return true, if the given task id matches the magic pseudo task id. - */ - public static boolean isPseudoTask(Long taskId) { - return taskId == MAGIC_PSEUDO_TASK_ID; - } - - /** - * Pseudo task are used for team leaders for showing time sheet hours of foreign users without detailed information, - * if the team leader has no select access. - * @return Pseudo task with magic task id (-42) and title '******'. - */ - public static TaskDO createPseudoTask() { - TaskDO pseudoTask = new TaskDO(); - pseudoTask.setId(MAGIC_PSEUDO_TASK_ID); - pseudoTask.setTitle("******"); - return pseudoTask; - } - - - public static class Kost2Row implements Serializable { - private static final long serialVersionUID = -5379735557333691194L; - - public Kost2Row(final Kost2DO kost2) { - this.kost2 = kost2; - } - - /** - * XML-escaped or null if not exists. - */ - public String getProjektname() { - if (kost2 == null || kost2.getProjekt() == null) { - return null; - } - return StringEscapeUtils.escapeXml11(kost2.getProjekt().getName()); - } - - /** - * XML-escaped or null if not exists. - */ - public String getKundename() { - if (kost2 == null || kost2.getProjekt() == null || kost2.getProjekt().getKunde() == null) { - return null; - } - return StringEscapeUtils.escapeXml11(kost2.getProjekt().getKunde().getName()); - } - - /** - * XML-escaped or null if not exists. - */ - public String getKost2ArtName() { - if (kost2 == null || kost2.getKost2Art() == null) { - return null; - } - return StringEscapeUtils.escapeXml11(kost2.getKost2Art().getName()); - } - - /** - * XML-escaped or null if not exists. - */ - public String getKost2Description() { - if (kost2 == null) { - return null; - } - return StringEscapeUtils.escapeXml11(kost2.getDescription()); - } - - public Kost2DO getKost2() { - return kost2; - } - - private final Kost2DO kost2; - } - - private PFDateTime fromDate; - - private PFDateTime toDate; - - private BigDecimal numberOfWorkingDays; - - /** - * Employee can be null, if not found. As fall back, store user. - */ - private PFUserDO user; - - private EmployeeDO employee; - - private long totalGrossDuration = 0, totalNetDuration = 0; - - private BigDecimal vacationCount = BigDecimal.ZERO; - - private BigDecimal vacationPlannedCount = BigDecimal.ZERO; - - private Long kost1Id; - - private List weeks; - - /** - * Days with time sheets. - */ - private final Set bookedDays = new HashSet<>(); - - private final List unbookedDays = new ArrayList<>(); - - /** - * Key is kost2.id. - */ - private Map kost2Durations; - - /** - * Key is task.id. - */ - private Map taskDurations; - - /** - * String is formatted Kost2-String for sorting. - */ - private Map kost2Rows; - - /** - * String is formatted Task path string for sorting. - */ - private Map taskEntries; - - public static String getFormattedDuration(final long duration) { - if (duration == 0) { - return ""; - } - final BigDecimal hours = new BigDecimal(duration).divide(new BigDecimal(1000 * 60 * 60), 2, - RoundingMode.HALF_UP); - return NumberHelper.formatFraction2(hours); - } - - /** - * Don't forget to initialize: setFormatter and setUser or setEmployee. - * - * @param year - * @param month 1-based: 1 - January, ..., 12 - December - */ - public MonthlyEmployeeReport(final PFUserDO user, final int year, final Integer month) { - this.fromDate = PFDateTime.withDate(year, month, 1); - this.toDate = this.fromDate.getEndOfMonth(); - this.user = user; - setEmployee(WicketSupport.get(EmployeeCache.class).getEmployeeByUserId(user.getId())); - } - - /** - * Use only as fallback, if employee is not available. - * - * @param user - */ - public void setUser(final PFUserDO user) { - this.user = user; - } - - /** - * User will be set automatically from given employee. - * - * @param employee - */ - public void setEmployee(final EmployeeDO employee) { - this.employee = employee; - if (employee != null) { - this.user = employee.getUser(); - Kost1DO kost1 = employee.getKost1(); - if (kost1 != null) { - this.kost1Id = kost1.getId(); - } - } - } - - public void init() { - if (this.user != null) { - this.employee = WicketSupport.get(EmployeeCache.class).getEmployeeByUserId(this.user.getId()); - } - // Create the weeks: - this.weeks = new ArrayList<>(); - - int paranoiaCounter = 0; - PFDateTime date = fromDate; - do { - final MonthlyEmployeeReportWeek week = new MonthlyEmployeeReportWeek(date); - weeks.add(week); - date = date.plusWeeks(1).getBeginOfWeek(); - if (paranoiaCounter++ > 10) { - throw new RuntimeException("Endless loop protection: Please contact developer!"); - } - } while (date.isBefore(toDate)); - } - - public void addTimesheet(final TimesheetDO sheet, final boolean hasSelectAccess) { - final PFDateTime day = PFDateTime.from(sheet.getStartTime()); // not null - bookedDays.add(day.getDayOfMonth()); - for (final MonthlyEmployeeReportWeek week : weeks) { - if (week.matchWeek(sheet)) { - week.addEntry(sheet, hasSelectAccess); - return; - } - } - log.info("Ignoring time sheet which isn't inside current month: " - + getYear() - + "-" - + StringHelper.format2DigitNumber(getMonth()) - + ": " - + sheet); - - } - - public void calculate() { - Validate.notEmpty(weeks); - kost2Rows = new TreeMap<>(); - taskEntries = new TreeMap<>(); - kost2Durations = new HashMap<>(); - taskDurations = new HashMap<>(); - for (final MonthlyEmployeeReportWeek week : weeks) { - if (MapUtils.isNotEmpty(week.getKost2Entries())) { - for (final MonthlyEmployeeReportEntry entry : week.getKost2Entries().values()) { - Kost2DO kost2 = PfCaches.getInstance().getKost2IfNotInitialized(entry.getKost2()); - Objects.requireNonNull(kost2); - kost2Rows.put(kost2.getDisplayName(), new Kost2Row(kost2)); - MonthlyEmployeeReportEntry kost2Total = kost2Durations.get(entry.getKost2().getId()); - if (kost2Total == null) { - kost2Total = new MonthlyEmployeeReportEntry(entry.getKost2()); - kost2Total.addMillis(entry.getWorkFractionMillis()); - kost2Durations.put(entry.getKost2().getId(), kost2Total); - } else { - kost2Total.addMillis(entry.getWorkFractionMillis()); - } - // Travelling times etc. (see cost 2 type factor): - totalGrossDuration += entry.getMillis(); - totalNetDuration += entry.getWorkFractionMillis(); - } - } - if (MapUtils.isNotEmpty(week.getTaskEntries())) { - for (final MonthlyEmployeeReportEntry entry : week.getTaskEntries().values()) { - Objects.requireNonNull(entry.getTask()); - long taskId = entry.getTask().getId(); - if (isPseudoTask(taskId)) { - // Pseudo task (see MonthlyEmployeeReportWeek for timesheet the current user has no select access. - TaskDO pseudoTask = createPseudoTask(); - taskEntries.put(pseudoTask.getTitle(), pseudoTask); - } else { - taskEntries.put(TaskFormatter.getTaskPath(taskId, true, - OutputType.XML), - entry.getTask()); - } - MonthlyEmployeeReportEntry taskTotal = taskDurations.get(entry.getTask().getId()); - if (taskTotal == null) { - taskTotal = new MonthlyEmployeeReportEntry(entry.getTask()); - taskTotal.addMillis(entry.getMillis()); - taskDurations.put(entry.getTask().getId(), taskTotal); - } else { - taskTotal.addMillis(entry.getMillis()); - } - totalGrossDuration += entry.getMillis(); - totalNetDuration += entry.getWorkFractionMillis(); - } - } - } - final MonthHolder monthHolder = new MonthHolder(this.fromDate); - this.numberOfWorkingDays = monthHolder.getNumberOfWorkingDays(); - final Holidays holidays = Holidays.getInstance(); - for (final WeekHolder week : monthHolder.getWeeks()) { - - for (final PFDay day : week.getDays()) { - if (day.getMonth() == this.fromDate.getMonth() && holidays.isWorkingDay(day) - && !bookedDays.contains(day.getDayOfMonth())) { - unbookedDays.add(day.getDayOfMonth()); - } - } - } - final VacationService vacationService = WicketSupport.get(VacationService.class); - if (vacationService != null && this.employee != null && this.employee.getUser() != null) { - if (vacationService.hasAccessToVacationService(this.employee.getUser(), false)) { - VacationStats stats = vacationService.getVacationStats(employee); - this.vacationCount = stats.getVacationDaysLeftInYear(); // was vacationService.getAvailableVacationDaysForYearAtDate(this.employee, this.toDate.getLocalDate()); - this.vacationPlannedCount = stats.getVacationDaysInProgress(); // was vacationService.getPlandVacationDaysForYearAtDate(this.employee, this.toDate.getLocalDate()); - } - } - } - - /** - * Gets the list of unbooked working days. These are working days without time sheets of the actual user. - */ - public List getUnbookedDays() { - return unbookedDays; - } - - /** - * @return Days of month without time sheets: 03.11., 08.11., ... or null if no entries exists. - */ - public String getFormattedUnbookedDays() { - final StringBuilder buf = new StringBuilder(); - boolean first = true; - for (final Integer dayOfMonth : unbookedDays) { - if (first) { - first = false; - } else { - buf.append(", "); - } - buf.append(StringHelper.format2DigitNumber(dayOfMonth)).append(".") - .append(StringHelper.format2DigitNumber(getMonth())).append("."); - } - if (first) { - return null; - } - return buf.toString(); - - } - - /** - * Key is the displayName of Kost2DO. The Map is a TreeMap sorted by the keys. - */ - public Map getKost2Rows() { - return kost2Rows; - } - - /** - * Key is the kost2 id. - */ - public Map getKost2Durations() { - return kost2Durations; - } - - /** - * Key is the task path string of TaskDO. The Map is a TreeMap sorted by the keys. - */ - public Map getTaskEntries() { - return taskEntries; - } - - /** - * Key is the task id. - */ - public Map getTaskDurations() { - return taskDurations; - } - - public int getYear() { - return fromDate.getYear(); - } - - /** - * @return 1-January, ..., 12-December. - */ - public Integer getMonth() { - return fromDate.getMonthValue(); - } - - public List getWeeks() { - return weeks; - } - - public String getFormmattedMonth() { - return StringHelper.format2DigitNumber(getMonth()); - } - - public Date getFromDate() { - return fromDate.getUtilDate(); - } - - public Date getToDate() { - return toDate.getUtilDate(); - } - - /** - * Can be null, if not set (not available). - */ - public EmployeeDO getEmployee() { - return employee; - } - - public PFUserDO getUser() { - return user; - } - - /** - * @return Total duration in ms. - */ - public long getTotalGrossDuration() { - return totalGrossDuration; - } - - /** - * The net duration may differ from total duration (e. g. for travelling times if a fraction is defined for used cost - * 2 types). - * - * @return the netDuration in ms. - */ - public long getTotalNetDuration() { - return totalNetDuration; - } - - public String getFormattedTotalGrossDuration() { - return MonthlyEmployeeReport.getFormattedDuration(totalGrossDuration); - } - - public String getFormattedVacationCount() { - return vacationCount + " " + I18nHelper.getLocalizedMessage("day"); - } - - public String getFormattedVacationPlandCount() { - return vacationPlannedCount + " " + I18nHelper.getLocalizedMessage("day"); - } - - public String getFormattedTotalNetDuration() { - return MonthlyEmployeeReport.getFormattedDuration(totalNetDuration); - } - - public Long getKost1Id() { - return kost1Id; - } - - public BigDecimal getNumberOfWorkingDays() { - return numberOfWorkingDays; - } -} diff --git a/projectforge-business/src/main/java/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.java b/projectforge-business/src/main/java/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.java deleted file mode 100644 index 365719c301..0000000000 --- a/projectforge-business/src/main/java/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.java +++ /dev/null @@ -1,159 +0,0 @@ -///////////////////////////////////////////////////////////////////////////// -// -// Project ProjectForge Community Edition -// www.projectforge.org -// -// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) -// -// ProjectForge is dual-licensed. -// -// This community edition 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; version 3 of the License. -// -// This community edition 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 http://www.gnu.org/licenses/. -// -///////////////////////////////////////////////////////////////////////////// - -package org.projectforge.business.fibu; - -import org.apache.commons.lang3.Validate; -import org.projectforge.business.timesheet.TimesheetDO; -import org.projectforge.common.StringHelper; -import org.projectforge.framework.time.PFDateTime; - -import java.io.Serializable; -import java.util.HashMap; -import java.util.Map; - - -/** - * Repräsentiert einen Wochenbericht eines Mitarbeiters. Diese Wochenberichte sind dem MonthlyEmployeeReport zugeordnet. - * - * @author Kai Reinhard (k.reinhard@micromata.de) - */ -public class MonthlyEmployeeReportWeek implements Serializable { - private static final long serialVersionUID = 6075755848054540114L; - - private PFDateTime fromDate; - - private PFDateTime toDate; - - private long totalDuration = 0; - - private long totalGrossDuration = 0; - - /** - * Key is kost2 id. - */ - private Map kost2Entries = new HashMap<>(); - - /** - * Key is task id. - */ - private Map taskEntries = new HashMap<>(); - - /** - * FromDate will be set to the begin of week but not before first day of month. - * ToDate will be set to end of week but not after the last day of month. - * - * @param date - */ - public MonthlyEmployeeReportWeek(PFDateTime date) { - Validate.notNull(date); - this.fromDate = date.getBeginOfWeek(); - if (this.fromDate.getMonth() != date.getMonth()) { - this.fromDate = date.getBeginOfMonth(); - } - this.toDate = fromDate.getEndOfWeek(); - if (this.toDate.getMonth() != this.fromDate.getMonth()) { - this.toDate = this.fromDate.getEndOfMonth(); - } - } - - /** - * Start time of sheet must be fromDate or later and before toDate. - * - * @param sheet - */ - public boolean matchWeek(TimesheetDO sheet) { - return !sheet.getStartTime().before(fromDate.getUtilDate()) && sheet.getStartTime().before(toDate.getUtilDate()); - } - - void addEntry(TimesheetDO sheet, final boolean hasSelectAccess) { - if (!matchWeek(sheet)) { - throw new RuntimeException("Oups, given time sheet is not inside the week represented by this week object."); - } - MonthlyEmployeeReportEntry entry; - if (!hasSelectAccess) { - entry = taskEntries.get(MonthlyEmployeeReport.MAGIC_PSEUDO_TASK_ID); // -42 represents timesheets without access. - if (entry == null) { - entry = new MonthlyEmployeeReportEntry(MonthlyEmployeeReport.createPseudoTask()); - taskEntries.put(MonthlyEmployeeReport.MAGIC_PSEUDO_TASK_ID, entry); - } - } else if (sheet.getKost2Id() != null) { - entry = kost2Entries.get(sheet.getKost2Id()); - if (entry == null) { - entry = new MonthlyEmployeeReportEntry(sheet.getKost2()); - kost2Entries.put(sheet.getKost2Id(), entry); - } - } else { - entry = taskEntries.get(sheet.getTaskId()); - if (entry == null) { - entry = new MonthlyEmployeeReportEntry(sheet.getTask()); - taskEntries.put(sheet.getTaskId(), entry); - } - } - long duration = sheet.getDuration(); - entry.addMillis(duration); - totalDuration += sheet.getWorkFractionDuration(); - totalGrossDuration += duration; - } - - public String getFormattedFromDayOfMonth() { - return StringHelper.format2DigitNumber(fromDate.getDayOfMonth()); - } - - public String getFormattedToDayOfMonth() { - return StringHelper.format2DigitNumber(toDate.getDayOfMonth()); - } - - /** - * Summe aller Stunden der Woche in Millis. - */ - public long getTotalDuration() { - return totalDuration; - } - - public String getFormattedTotalDuration() { - return MonthlyEmployeeReport.getFormattedDuration(totalDuration); - } - - public String getFormattedGrossDuration() { - return MonthlyEmployeeReport.getFormattedDuration(totalGrossDuration); - } - - /** - * Return the hours assigned to the different Kost2's. The key of the map is the kost2 id. - * - * @return - */ - public Map getKost2Entries() { - return kost2Entries; - } - - /** - * Return the hours assigned to the different tasks which do not have a kost2-id. The key of the map is the task id. - * - * @return - */ - public Map getTaskEntries() { - return taskEntries; - } -} diff --git a/projectforge-business/src/main/java/org/projectforge/business/fibu/datev/EmployeeSalaryExportDao.java b/projectforge-business/src/main/java/org/projectforge/business/fibu/datev/EmployeeSalaryExportDao.java index 6b6c39865a..8b36e3a579 100644 --- a/projectforge-business/src/main/java/org/projectforge/business/fibu/datev/EmployeeSalaryExportDao.java +++ b/projectforge-business/src/main/java/org/projectforge/business/fibu/datev/EmployeeSalaryExportDao.java @@ -183,7 +183,7 @@ public byte[] export(final List list) { BigDecimal sum = BigDecimal.ZERO; int j = rows.size(); for (final Kost2Row row : rows.values()) { - final Kost2DO kost2 = row.getKost2(); + final Kost2DO kost2 = row.kost2; final MonthlyEmployeeReportEntry entry = report.getKost2Durations().get(kost2.getId()); mapping.add(ExcelColumn.KOST1, kost1.getNummer()); mapping.add(ExcelColumn.MITARBEITER, user.getFullname()); diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/EmployeeService.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/EmployeeService.kt index b82f381419..962e8d188e 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/EmployeeService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/EmployeeService.kt @@ -410,7 +410,7 @@ class EmployeeService { return employeeServiceSupport.undeleteValidSinceAttr(employee, attr, checkAccess = checkAccess) } - fun getReportOfMonth(year: Int, month: Int?, user: PFUserDO): MonthlyEmployeeReport { + fun getReportOfMonth(year: Int, month: Int, user: PFUserDO): MonthlyEmployeeReport { val monthlyEmployeeReport = MonthlyEmployeeReport(user, year, month) monthlyEmployeeReport.init() val filter = TimesheetFilter() diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReport.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReport.kt new file mode 100644 index 0000000000..725b7b2ca0 --- /dev/null +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReport.kt @@ -0,0 +1,423 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition 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; version 3 of the License. +// +// This community edition 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 http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.business.fibu + +import org.apache.commons.collections4.MapUtils +import org.apache.commons.lang3.Validate +import org.apache.commons.text.StringEscapeUtils +import org.projectforge.business.PfCaches +import org.projectforge.business.common.OutputType +import org.projectforge.business.fibu.kost.Kost2DO +import org.projectforge.business.task.TaskDO +import org.projectforge.business.task.TaskFormatter.Companion.getTaskPath +import org.projectforge.business.timesheet.TimesheetDO +import org.projectforge.business.vacation.service.VacationService +import org.projectforge.common.StringHelper +import org.projectforge.framework.calendar.Holidays +import org.projectforge.framework.calendar.MonthHolder +import org.projectforge.framework.i18n.I18nHelper.getLocalizedMessage +import org.projectforge.framework.persistence.user.entities.PFUserDO +import org.projectforge.framework.time.PFDateTime.Companion.from +import org.projectforge.framework.time.PFDateTime.Companion.withDate +import org.projectforge.framework.utils.NumberHelper.formatFraction2 +import org.projectforge.web.WicketSupport +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.Serializable +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.* + +/** + * Repräsentiert einen Monatsbericht eines Mitarbeiters. + * + * @param year + * @param month 1-based: 1 - January, ..., 12 - December + * @author Kai Reinhard (k.reinhard@micromata.de) + */ +class MonthlyEmployeeReport(user: PFUserDO, year: Int, month: Int) : Serializable { + class Kost2Row(@JvmField val kost2: Kost2DO?) : Serializable { + val projektname: String? + /** + * XML-escaped or null if not exists. + */ + get() { + if (kost2?.projekt == null) { + return null + } + return StringEscapeUtils.escapeXml11(kost2.projekt!!.name) + } + + val kundename: String? + /** + * XML-escaped or null if not exists. + */ + get() { + if (kost2?.projekt == null || kost2.projekt!!.kunde == null) { + return null + } + return StringEscapeUtils.escapeXml11(kost2.projekt!!.kunde!!.name) + } + + val kost2ArtName: String? + /** + * XML-escaped or null if not exists. + */ + get() { + if (kost2?.kost2Art == null) { + return null + } + return StringEscapeUtils.escapeXml11(kost2.kost2Art!!.name) + } + + val kost2Description: String? + /** + * XML-escaped or null if not exists. + */ + get() { + if (kost2 == null) { + return null + } + return StringEscapeUtils.escapeXml11(kost2.description) + } + + companion object { + private const val serialVersionUID = -5379735557333691194L + } + } + + private val fromDateTime = withDate(year, month, 1) + + private val toDateTime = fromDateTime.endOfMonth + + var numberOfWorkingDays: BigDecimal? = null + private set + + /** + * Use only as fallback, if employee is not available. + * + * @param user + */ + /** + * Employee can be null, if not found. As fall back, store user. + */ + @JvmField + var user: PFUserDO? + + var employee: EmployeeDO? = null + private set + + /** + * @return Total duration in ms. + */ + var totalGrossDuration: Long = 0 + private set + + /** + * The net duration may differ from total duration (e. g. for travelling times if a fraction is defined for used cost + * 2 types). + * + * @return the netDuration in ms. + */ + var totalNetDuration: Long = 0 + private set + + private var vacationCount: BigDecimal? = BigDecimal.ZERO + + private var vacationPlannedCount: BigDecimal? = BigDecimal.ZERO + + var kost1Id: Long? = null + private set + + val weeks = mutableListOf() + + /** + * Days with time sheets. + */ + private val bookedDays: MutableSet = HashSet() + + private val unbookedDays: MutableList = ArrayList() + + /** + * Key is kost2.id. + */ + val kost2Durations = mutableMapOf() + + /** + * Key is task.id. + */ + val taskDurations = mutableMapOf() + + /** + * String is formatted Kost2-String for sorting. + */ + val kost2Rows = mutableMapOf() + + /** + * String is formatted Task path string for sorting. + */ + val taskEntries = mutableMapOf() + + /** + * Don't forget to initialize: setFormatter and setUser or setEmployee. + */ + init { + this.user = user + setEmployee(WicketSupport.get(EmployeeCache::class.java).getEmployeeByUserId(user.id)) + } + + /** + * User will be set automatically from given employee. + * + * @param employee + */ + fun setEmployee(employee: EmployeeDO?) { + this.employee = employee + if (employee != null) { + this.user = employee.user + val kost1 = employee.kost1 + if (kost1 != null) { + this.kost1Id = kost1.id + } + } + } + + fun init() { + if (this.user != null) { + this.employee = WicketSupport.get(EmployeeCache::class.java).getEmployeeByUserId( + user!!.id + ) + } + // Create the weeks: + var paranoiaCounter = 0 + var date = fromDateTime + do { + val week = MonthlyEmployeeReportWeek(date) + weeks.add(week) + date = date.plusWeeks(1).beginOfWeek + if (paranoiaCounter++ > 10) { + throw RuntimeException("Endless loop protection: Please contact developer!") + } + } while (date.isBefore(toDateTime)) + } + + fun addTimesheet(sheet: TimesheetDO, hasSelectAccess: Boolean) { + val day = from(sheet.startTime!!) // not null + bookedDays.add(day.dayOfMonth) + for (week in weeks!!) { + if (week.matchWeek(sheet)) { + week.addEntry(sheet, hasSelectAccess) + return + } + } + log.info( + ("Ignoring time sheet which isn't inside current month: " + + year + + "-" + + StringHelper.format2DigitNumber(month) + + ": " + + sheet) + ) + } + + fun calculate() { + Validate.notEmpty?>(weeks) + for (week in weeks) { + if (MapUtils.isNotEmpty(week.kost2Entries)) { + for (entry in week.kost2Entries.values) { + val kost2 = PfCaches.instance.getKost2IfNotInitialized(entry.kost2) + Objects.requireNonNull(kost2) + kost2Rows[kost2!!.displayName] = Kost2Row(kost2) + var kost2Total = kost2Durations.get(entry.kost2!!.id) + if (kost2Total == null) { + kost2Total = MonthlyEmployeeReportEntry(entry.kost2) + kost2Total.addMillis(entry.workFractionMillis) + entry.kost2?.id?.let { id -> + kost2Durations[id] = kost2Total + } + } else { + kost2Total.addMillis(entry.workFractionMillis) + } + // Travelling times etc. (see cost 2 type factor): + totalGrossDuration += entry.millis + totalNetDuration += entry.workFractionMillis + } + } + if (MapUtils.isNotEmpty(week.taskEntries)) { + for (entry in week.taskEntries.values) { + Objects.requireNonNull(entry.task) + val taskId = entry.task!!.id!! + if (isPseudoTask(taskId)) { + // Pseudo task (see MonthlyEmployeeReportWeek for timesheet the current user has no select access. + val pseudoTask = createPseudoTask() + taskEntries[pseudoTask.title] = pseudoTask + } else { + taskEntries[getTaskPath( + taskId, true, + OutputType.XML + )] = entry.task + } + var taskTotal = taskDurations.get(entry.task!!.id) + if (taskTotal == null) { + taskTotal = MonthlyEmployeeReportEntry(entry.task) + taskTotal.addMillis(entry.millis) + entry.task?.id?.let { id -> + taskDurations[id] = taskTotal + } + } else { + taskTotal.addMillis(entry.millis) + } + totalGrossDuration += entry.millis + totalNetDuration += entry.workFractionMillis + } + } + } + val monthHolder = MonthHolder(this.fromDateTime) + this.numberOfWorkingDays = monthHolder.numberOfWorkingDays + val holidays = Holidays.instance + for (week in monthHolder.weeks) { + for (day in week.days) { + if (day.month == fromDateTime.month && holidays.isWorkingDay(day) + && !bookedDays.contains(day.dayOfMonth) + ) { + unbookedDays.add(day.dayOfMonth) + } + } + } + val vacationService = WicketSupport.get(VacationService::class.java) + if (vacationService != null && this.employee != null && employee!!.user != null) { + if (vacationService.hasAccessToVacationService(employee!!.user, false)) { + val stats = vacationService.getVacationStats(employee!!) + this.vacationCount = + stats.vacationDaysLeftInYear // was vacationService.getAvailableVacationDaysForYearAtDate(this.employee, this.toDate.getLocalDate()); + this.vacationPlannedCount = + stats.vacationDaysInProgress // was vacationService.getPlandVacationDaysForYearAtDate(this.employee, this.toDate.getLocalDate()); + } + } + } + + /** + * Gets the list of unbooked working days. These are working days without time sheets of the actual user. + */ + fun getUnbookedDays(): List { + return unbookedDays + } + + val formattedUnbookedDays: String? + /** + * @return Days of month without time sheets: 03.11., 08.11., ... or null if no entries exists. + */ + get() { + val buf = StringBuilder() + var first = true + for (dayOfMonth in unbookedDays) { + if (first) { + first = false + } else { + buf.append(", ") + } + buf.append(StringHelper.format2DigitNumber(dayOfMonth)).append(".") + .append(StringHelper.format2DigitNumber(month)).append(".") + } + if (first) { + return null + } + return buf.toString() + } + + val year: Int + get() = fromDateTime.year + + val month: Int + /** + * @return 1-January, ..., 12-December. + */ + get() = fromDateTime.monthValue + + val formmattedMonth: String + get() = StringHelper.format2DigitNumber(month) + + val fromDate: Date + get() = fromDateTime.utilDate + + val toDate: Date + get() = toDateTime.utilDate + + val formattedTotalGrossDuration: String + get() = getFormattedDuration(totalGrossDuration) + + val formattedVacationCount: String + get() = vacationCount.toString() + " " + getLocalizedMessage("day") + + val formattedVacationPlandCount: String + get() = vacationPlannedCount.toString() + " " + getLocalizedMessage("day") + + val formattedTotalNetDuration: String + get() = getFormattedDuration(totalNetDuration) + + companion object { + private const val serialVersionUID = -4636357379552246075L + + private val log: Logger = LoggerFactory.getLogger(MonthlyEmployeeReport::class.java) + + /** + * ID of pseudo task, see below. + */ + const val MAGIC_PSEUDO_TASK_ID: Long = -42L + + /** + * Checks if the given taskId is the Pseudo task, see below. + * @return true, if the given task id matches the magic pseudo task id. + */ + fun isPseudoTask(taskId: Long): Boolean { + return taskId == MAGIC_PSEUDO_TASK_ID + } + + /** + * Pseudo task are used for team leaders for showing time sheet hours of foreign users without detailed information, + * if the team leader has no select access. + * @return Pseudo task with magic task id (-42) and title '******'. + */ + @JvmStatic + fun createPseudoTask(): TaskDO { + val pseudoTask = TaskDO() + pseudoTask.id = MAGIC_PSEUDO_TASK_ID + pseudoTask.title = "******" + return pseudoTask + } + + + @JvmStatic + fun getFormattedDuration(duration: Long): String { + if (duration == 0L) { + return "" + } + val hours = BigDecimal(duration).divide( + BigDecimal(1000 * 60 * 60), 2, + RoundingMode.HALF_UP + ) + return formatFraction2(hours) + } + } +} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.kt new file mode 100644 index 0000000000..57be33bfaa --- /dev/null +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.kt @@ -0,0 +1,132 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition 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; version 3 of the License. +// +// This community edition 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 http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.business.fibu + +import org.projectforge.business.fibu.MonthlyEmployeeReport.Companion.createPseudoTask +import org.projectforge.business.fibu.MonthlyEmployeeReport.Companion.getFormattedDuration +import org.projectforge.business.timesheet.TimesheetDO +import org.projectforge.common.StringHelper +import org.projectforge.framework.time.PFDateTime +import java.io.Serializable + +/** + * Repräsentiert einen Wochenbericht eines Mitarbeiters. Diese Wochenberichte sind dem MonthlyEmployeeReport zugeordnet. + * + * @author Kai Reinhard (k.reinhard@micromata.de) + */ +class MonthlyEmployeeReportWeek(date: PFDateTime) : Serializable { + private var fromDate: PFDateTime + + private var toDate: PFDateTime + + /** + * Summe aller Stunden der Woche in Millis. + */ + var totalDuration: Long = 0 + private set + + private var totalGrossDuration: Long = 0 + + /** + * Key is kost2 id. + */ + val kost2Entries = mutableMapOf() + + /** + * Key is task id. + */ + val taskEntries = mutableMapOf() + + /** + * FromDate will be set to the begin of week but not before first day of month. + * ToDate will be set to end of week but not after the last day of month. + */ + init { + this.fromDate = date.beginOfWeek + if (fromDate.month != date.month) { + this.fromDate = date.beginOfMonth + } + this.toDate = fromDate.endOfWeek + if (toDate.month != fromDate.month) { + this.toDate = fromDate.endOfMonth + } + } + + /** + * Start time of sheet must be fromDate or later and before toDate. + * + * @param sheet + */ + fun matchWeek(sheet: TimesheetDO): Boolean { + return !sheet.startTime!!.before(fromDate.utilDate) && sheet.startTime!!.before(toDate.utilDate) + } + + fun addEntry(sheet: TimesheetDO, hasSelectAccess: Boolean) { + if (!matchWeek(sheet)) { + throw RuntimeException("Oups, given time sheet is not inside the week represented by this week object.") + } + var entry: MonthlyEmployeeReportEntry? = null + if (!hasSelectAccess) { + entry = taskEntries[MonthlyEmployeeReport.MAGIC_PSEUDO_TASK_ID] // -42 represents timesheets without access. + if (entry == null) { + entry = MonthlyEmployeeReportEntry(createPseudoTask()) + taskEntries[MonthlyEmployeeReport.MAGIC_PSEUDO_TASK_ID] = entry + } + } else if (sheet.kost2Id != null) { + entry = kost2Entries[sheet.kost2Id] + if (entry == null) { + entry = MonthlyEmployeeReportEntry(sheet.kost2) + kost2Entries[sheet.kost2Id!!] = entry + } + } else { + sheet.taskId?.let { taskId -> + entry = taskEntries[taskId] + if (entry == null) { + entry = MonthlyEmployeeReportEntry(sheet.task) + taskEntries[taskId] = entry!! + } + } + } + val duration = sheet.duration + entry?.addMillis(duration) + totalDuration += sheet.workFractionDuration + totalGrossDuration += duration + } + + val formattedFromDayOfMonth: String + get() = StringHelper.format2DigitNumber(fromDate.dayOfMonth) + + val formattedToDayOfMonth: String + get() = StringHelper.format2DigitNumber(toDate.dayOfMonth) + + val formattedTotalDuration: String + get() = getFormattedDuration(totalDuration) + + val formattedGrossDuration: String + get() = getFormattedDuration(totalGrossDuration) + + companion object { + private const val serialVersionUID = 6075755848054540114L + } +} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/vacation/service/VacationService.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/vacation/service/VacationService.kt index 42d8bacc55..5169ee7761 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/vacation/service/VacationService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/vacation/service/VacationService.kt @@ -343,7 +343,9 @@ open class VacationService { 2, RoundingMode.HALF_UP ) - result.workingDays += reportOfMonth.numberOfWorkingDays + reportOfMonth.numberOfWorkingDays?.let { + result.workingDays += it + } currentMonth = currentMonth.plusMonths(1) if (currentMonth > toMonth) { break // here the loop should be ended (not via paranoia setting). diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java index 580b6df04b..d210487132 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java +++ b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java @@ -286,14 +286,14 @@ private void addReport() { row.add(AttributeModifier.replace("class", "odd")); } final Kost2Row kost2Row = rowEntry.getValue(); - final Kost2DO cost2 = kost2Row.getKost2(); - addLabelCols(row, cost2, null, "kost2.nummer:" + cost2.getFormattedNumber() + "*", report.getUser(), + final Kost2DO cost2 = kost2Row.kost2; + addLabelCols(row, cost2, null, "kost2.nummer:" + cost2.getFormattedNumber() + "*", report.user, report.getFromDate().getTime(), report .getToDate().getTime()); final RepeatingView colWeekRepeater = new RepeatingView("colWeekRepeater"); row.add(colWeekRepeater); for (final MonthlyEmployeeReportWeek week : report.getWeeks()) { - final MonthlyEmployeeReportEntry entry = week.getKost2Entries().get(kost2Row.getKost2().getId()); + final MonthlyEmployeeReportEntry entry = week.getKost2Entries().get(kost2Row.kost2.getId()); colWeekRepeater.add(new Label(colWeekRepeater.newChildId(), entry != null ? entry.getFormattedDuration() : "")); } row.add(new Label("sum", report.getKost2Durations().get(cost2.getId()).getFormattedDuration())); @@ -308,7 +308,7 @@ private void addReport() { row.add(AttributeModifier.replace("class", "odd")); } final TaskDO task = rowEntry.getValue(); - addLabelCols(row, null, task, null, report.getUser(), report.getFromDate().getTime(), + addLabelCols(row, null, task, null, report.user, report.getFromDate().getTime(), report.getToDate().getTime()); final RepeatingView colWeekRepeater = new RepeatingView("colWeekRepeater"); row.add(colWeekRepeater); @@ -327,7 +327,7 @@ private void addReport() { } else { row.add(AttributeModifier.replace("class", "odd")); } - addLabelCols(row, null, null, null, report.getUser(), report.getFromDate().getTime(), + addLabelCols(row, null, null, null, report.user, report.getFromDate().getTime(), report.getToDate().getTime()).add( AttributeModifier.replace("style", "text-align: right;")); final RepeatingView colWeekRepeater = new RepeatingView("colWeekRepeater"); From 2ec065b52c3ea57ba27acffb0b2fa124cc249084 Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Sun, 26 Jan 2025 01:14:58 +0100 Subject: [PATCH 12/15] WIP: time savings by AI: TimesheetListPage, MonthlyEmployeeReport --- .../src/main/resources/i18nKeys.json | 2 +- .../business/fibu/MonthlyEmployeeReport.kt | 36 +++++++-- .../fibu/MonthlyEmployeeReportEntry.kt | 26 ++++++- .../fibu/MonthlyEmployeeReportWeek.kt | 30 +++++--- .../business/timesheet/AITimeSavings.kt | 65 +++++++++++++++++ .../business/timesheet/TimesheetDO.kt | 4 + .../business/timesheet/AITimeSavingsTest.kt | 73 +++++++++++++++++++ .../extensions/KotlinNumberExtensions.kt | 25 +++++-- .../extensions/KotlinNumberExtensionsTest.kt | 6 ++ .../web/fibu/MonthlyEmployeeReportPage.html | 4 +- .../web/fibu/MonthlyEmployeeReportPage.java | 60 +++++++++++---- .../web/timesheet/TimesheetListPage.java | 20 ++++- 12 files changed, 301 insertions(+), 50 deletions(-) create mode 100644 projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/AITimeSavings.kt create mode 100644 projectforge-business/src/test/kotlin/org/projectforge/business/timesheet/AITimeSavingsTest.kt diff --git a/projectforge-application/src/main/resources/i18nKeys.json b/projectforge-application/src/main/resources/i18nKeys.json index 3111e704fb..d258e16a08 100644 --- a/projectforge-application/src/main/resources/i18nKeys.json +++ b/projectforge-application/src/main/resources/i18nKeys.json @@ -2405,7 +2405,7 @@ {"i18nKey":"timeleft.years","bundleName":"I18nResources","translation":"in {0} years","translationDE":"in {0} Jahren","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"timeleft.years.one","bundleName":"I18nResources","translation":"in a year","translationDE":"in einem Jahr","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"timesheet","bundleName":"I18nResources","translation":"Time-sheet","translationDE":"Zeitbericht","usedInClasses":["org.projectforge.Constants","org.projectforge.business.timesheet.TimesheetFavoritesService","org.projectforge.framework.persistence.DaoConst","org.projectforge.registry.Registry","org.projectforge.rest.calendar.CalendarServicesRest","org.projectforge.rest.calendar.FullCalendarEvent","org.projectforge.rest.calendar.TimesheetEventsProvider","org.projectforge.web.calendar.TimesheetEventsProvider","org.projectforge.web.timesheet.TimesheetEditPage","org.projectforge.web.timesheet.TimesheetListPage"],"usedInFiles":[]}, - {"i18nKey":"timesheet.ai.timeSavedByAI","bundleName":"I18nResources","translation":"Time savings AI","translationDE":"KI-Ersparnis","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO","org.projectforge.rest.TimesheetPagesRest"],"usedInFiles":[]}, + {"i18nKey":"timesheet.ai.timeSavedByAI","bundleName":"I18nResources","translation":"Time savings AI","translationDE":"KI-Ersparnis","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO","org.projectforge.rest.TimesheetPagesRest","org.projectforge.web.fibu.MonthlyEmployeeReportPage","org.projectforge.web.timesheet.TimesheetListPage"],"usedInFiles":[]}, {"i18nKey":"timesheet.ai.timeSavedByAI.error.invalidPercentage","bundleName":"I18nResources","translation":"The percentage is invalid. It must be between 0 and 99.","translationDE":"Die Prozentangabe ist ungültig. Sie muss zwischen 0 und 99 liegen.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDao"],"usedInFiles":[]}, {"i18nKey":"timesheet.ai.timeSavedByAI.error.unitMissing","bundleName":"I18nResources","translation":"Please specify unit for AI savings.","translationDE":"Bitte Einheit für die KI-Ersparnis angeben.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDao"],"usedInFiles":[]}, {"i18nKey":"timesheet.ai.timeSavedByAI.info","bundleName":"I18nResources","translation":"Estimated time savings in percent or hours due to the use of AI in this time report.\nThis information is used in evaluations and reports.","translationDE":"Geschätze Zeitersparnis in Prozent oder Stunden durch den Einsatz von KI in diesem Zeitbericht.\nDiese Angabe wird in Auswertungen und Reports verwendet.","usedInClasses":["org.projectforge.business.timesheet.TimesheetDO"],"usedInFiles":[]}, diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReport.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReport.kt index 725b7b2ca0..83fda566de 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReport.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReport.kt @@ -34,6 +34,8 @@ import org.projectforge.business.task.TaskFormatter.Companion.getTaskPath import org.projectforge.business.timesheet.TimesheetDO import org.projectforge.business.vacation.service.VacationService import org.projectforge.common.StringHelper +import org.projectforge.common.extensions.formatPercent +import org.projectforge.common.extensions.isZeroOrNull import org.projectforge.framework.calendar.Holidays import org.projectforge.framework.calendar.MonthHolder import org.projectforge.framework.i18n.I18nHelper.getLocalizedMessage @@ -143,6 +145,9 @@ class MonthlyEmployeeReport(user: PFUserDO, year: Int, month: Int) : Serializabl var totalNetDuration: Long = 0 private set + var totalTimeSavedByAI: Long = 0 + private set + private var vacationCount: BigDecimal? = BigDecimal.ZERO private var vacationPlannedCount: BigDecimal? = BigDecimal.ZERO @@ -225,7 +230,7 @@ class MonthlyEmployeeReport(user: PFUserDO, year: Int, month: Int) : Serializabl fun addTimesheet(sheet: TimesheetDO, hasSelectAccess: Boolean) { val day = from(sheet.startTime!!) // not null bookedDays.add(day.dayOfMonth) - for (week in weeks!!) { + for (week in weeks) { if (week.matchWeek(sheet)) { week.addEntry(sheet, hasSelectAccess) return @@ -252,16 +257,19 @@ class MonthlyEmployeeReport(user: PFUserDO, year: Int, month: Int) : Serializabl var kost2Total = kost2Durations.get(entry.kost2!!.id) if (kost2Total == null) { kost2Total = MonthlyEmployeeReportEntry(entry.kost2) - kost2Total.addMillis(entry.workFractionMillis) + kost2Total.addMillis(entry) entry.kost2?.id?.let { id -> kost2Durations[id] = kost2Total } } else { - kost2Total.addMillis(entry.workFractionMillis) + kost2Total.addMillis(entry) } // Travelling times etc. (see cost 2 type factor): - totalGrossDuration += entry.millis + if (!entry.workFraction.isZeroOrNull()) { + totalGrossDuration += entry.millis + } totalNetDuration += entry.workFractionMillis + totalTimeSavedByAI += entry.timeimeSavedByAIMillis } } if (MapUtils.isNotEmpty(week.taskEntries)) { @@ -281,15 +289,16 @@ class MonthlyEmployeeReport(user: PFUserDO, year: Int, month: Int) : Serializabl var taskTotal = taskDurations.get(entry.task!!.id) if (taskTotal == null) { taskTotal = MonthlyEmployeeReportEntry(entry.task) - taskTotal.addMillis(entry.millis) + taskTotal.addMillis(entry) entry.task?.id?.let { id -> taskDurations[id] = taskTotal } } else { - taskTotal.addMillis(entry.millis) + taskTotal.addMillis(entry) } totalGrossDuration += entry.millis totalNetDuration += entry.workFractionMillis + totalTimeSavedByAI += entry.timeimeSavedByAIMillis } } } @@ -376,6 +385,21 @@ class MonthlyEmployeeReport(user: PFUserDO, year: Int, month: Int) : Serializabl val formattedTotalNetDuration: String get() = getFormattedDuration(totalNetDuration) + val formattedTotalTimeSavedByAI: String + get() = getFormattedDuration(totalTimeSavedByAI) + + val formattedTimeSavedByAIPercentage: String + get() { + if (totalGrossDuration == 0L) { + return "0 %" + } + val percentage = BigDecimal(totalTimeSavedByAI).multiply(BigDecimal(100)).divide( + BigDecimal(totalGrossDuration), 0, + RoundingMode.HALF_UP + ) + return percentage.formatPercent(true) + } + companion object { private const val serialVersionUID = -4636357379552246075L diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportEntry.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportEntry.kt index 105a5009ea..595ce663b4 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportEntry.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportEntry.kt @@ -26,6 +26,9 @@ package org.projectforge.business.fibu import org.projectforge.business.PfCaches.Companion.instance import org.projectforge.business.fibu.kost.Kost2DO import org.projectforge.business.task.TaskDO +import org.projectforge.business.timesheet.AITimeSavings +import org.projectforge.business.timesheet.TimesheetDO +import org.projectforge.common.extensions.isZeroOrNull import java.io.Serializable import java.math.BigDecimal @@ -49,6 +52,9 @@ class MonthlyEmployeeReportEntry : Serializable { var millis: Long = 0 private set + var timeimeSavedByAIMillis: Long = 0 + private set + constructor(kost2: Kost2DO?) { this.kost2 = kost2 } @@ -57,8 +63,14 @@ class MonthlyEmployeeReportEntry : Serializable { this.task = task } - fun addMillis(millis: Long) { - this.millis += millis + fun addMillis(entry: MonthlyEmployeeReportEntry) { + this.millis += entry.workFractionMillis + this.timeimeSavedByAIMillis += entry.timeimeSavedByAIMillis + } + + fun addMillis(timesheetDO: TimesheetDO, duration: Long) { + this.millis += duration + this.timeimeSavedByAIMillis += AITimeSavings.getTimeSavedByAIMs(timesheetDO, duration) } /** @@ -78,7 +90,15 @@ class MonthlyEmployeeReportEntry : Serializable { } val formattedDuration: String - get() = MonthlyEmployeeReport.getFormattedDuration(millis) + get() = if (workFraction.isZeroOrNull()) { + "(${MonthlyEmployeeReport.getFormattedDuration(millis)})" // Not working time. + } else { + MonthlyEmployeeReport.getFormattedDuration(millis) + } + + val getFormattedTimeSavedByAI: String + get() = MonthlyEmployeeReport.getFormattedDuration(timeimeSavedByAIMillis) + companion object { private const val serialVersionUID = 7290000602224467755L diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.kt index 57be33bfaa..fa47ce639e 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/MonthlyEmployeeReportWeek.kt @@ -25,6 +25,7 @@ package org.projectforge.business.fibu import org.projectforge.business.fibu.MonthlyEmployeeReport.Companion.createPseudoTask import org.projectforge.business.fibu.MonthlyEmployeeReport.Companion.getFormattedDuration +import org.projectforge.business.timesheet.AITimeSavings import org.projectforge.business.timesheet.TimesheetDO import org.projectforge.common.StringHelper import org.projectforge.framework.time.PFDateTime @@ -46,6 +47,9 @@ class MonthlyEmployeeReportWeek(date: PFDateTime) : Serializable { var totalDuration: Long = 0 private set + var totalTimeSavedByAI: Long = 0 + private set + private var totalGrossDuration: Long = 0 /** @@ -82,8 +86,8 @@ class MonthlyEmployeeReportWeek(date: PFDateTime) : Serializable { return !sheet.startTime!!.before(fromDate.utilDate) && sheet.startTime!!.before(toDate.utilDate) } - fun addEntry(sheet: TimesheetDO, hasSelectAccess: Boolean) { - if (!matchWeek(sheet)) { + fun addEntry(timesheet: TimesheetDO, hasSelectAccess: Boolean) { + if (!matchWeek(timesheet)) { throw RuntimeException("Oups, given time sheet is not inside the week represented by this week object.") } var entry: MonthlyEmployeeReportEntry? = null @@ -93,25 +97,26 @@ class MonthlyEmployeeReportWeek(date: PFDateTime) : Serializable { entry = MonthlyEmployeeReportEntry(createPseudoTask()) taskEntries[MonthlyEmployeeReport.MAGIC_PSEUDO_TASK_ID] = entry } - } else if (sheet.kost2Id != null) { - entry = kost2Entries[sheet.kost2Id] + } else if (timesheet.kost2Id != null) { + entry = kost2Entries[timesheet.kost2Id] if (entry == null) { - entry = MonthlyEmployeeReportEntry(sheet.kost2) - kost2Entries[sheet.kost2Id!!] = entry + entry = MonthlyEmployeeReportEntry(timesheet.kost2) + kost2Entries[timesheet.kost2Id!!] = entry } } else { - sheet.taskId?.let { taskId -> + timesheet.taskId?.let { taskId -> entry = taskEntries[taskId] if (entry == null) { - entry = MonthlyEmployeeReportEntry(sheet.task) + entry = MonthlyEmployeeReportEntry(timesheet.task) taskEntries[taskId] = entry!! } } } - val duration = sheet.duration - entry?.addMillis(duration) - totalDuration += sheet.workFractionDuration + val duration = timesheet.duration + entry?.addMillis(timesheet, duration) + totalDuration += timesheet.workFractionDuration totalGrossDuration += duration + totalTimeSavedByAI += AITimeSavings.getTimeSavedByAIMs(timesheet, duration) } val formattedFromDayOfMonth: String @@ -126,6 +131,9 @@ class MonthlyEmployeeReportWeek(date: PFDateTime) : Serializable { val formattedGrossDuration: String get() = getFormattedDuration(totalGrossDuration) + val formattedTotalTimeSavedByAI: String + get() = getFormattedDuration(totalTimeSavedByAI) + companion object { private const val serialVersionUID = 6075755848054540114L } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/AITimeSavings.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/AITimeSavings.kt new file mode 100644 index 0000000000..530d1e34d5 --- /dev/null +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/AITimeSavings.kt @@ -0,0 +1,65 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition 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; version 3 of the License. +// +// This community edition 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 http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.business.timesheet + +import org.projectforge.Constants + +object AITimeSavings { + class Stats { + var totalDurationMillis: Long = 0 + var totalTimeSavedByAIMillis: Long = 0 + fun add(timesheet: TimesheetDO) { + val duration = timesheet.duration + totalDurationMillis += duration + totalTimeSavedByAIMillis += getTimeSavedByAIMs(timesheet, duration) + } + } + + @JvmStatic + fun getTimeSavedByAIMs(timesheet: TimesheetDO): Long { + return getTimeSavedByAIMs(timesheet, timesheet.duration) + } + + /** + * Only for avoiding calculation of duration again. + * @param timesheet + * @param duration The duration of the timesheet in milliseconds. + */ + fun getTimeSavedByAIMs(timesheet: TimesheetDO, duration: Long): Long { + timesheet.timeSavedByAI?.let { + return when (timesheet.timeSavedByAIUnit) { + TimesheetDO.TimeSavedByAIUnit.HOURS -> it.toLong() * Constants.MILLIS_PER_HOUR + TimesheetDO.TimeSavedByAIUnit.PERCENTAGE -> it.toLong() * duration / 100 + else -> 0 // nothing. Shouldn't happen. + } + } + return 0L + } + + fun buildStats(timesheets: Collection): Stats { + val stats = Stats() + timesheets.forEach { stats.add(it) } + return stats + } +} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt index 37500f2006..4ee6cbfdb9 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/timesheet/TimesheetDO.kt @@ -131,6 +131,10 @@ open class TimesheetDO : DefaultBaseDO(), Comparable { @get:Column(name = "time_saved_by_ai_description", length = 1000) open var timeSavedByAIDescription: String? = null + @get:Transient + val timeSavedByAIMillis: Long + get() = AITimeSavings.getTimeSavedByAIMs(this) + @PropertyInfo(i18nKey = "timesheet.location") @UserPrefParameter(i18nKey = "timesheet.location") @FullTextField diff --git a/projectforge-business/src/test/kotlin/org/projectforge/business/timesheet/AITimeSavingsTest.kt b/projectforge-business/src/test/kotlin/org/projectforge/business/timesheet/AITimeSavingsTest.kt new file mode 100644 index 0000000000..3bb1ada7fc --- /dev/null +++ b/projectforge-business/src/test/kotlin/org/projectforge/business/timesheet/AITimeSavingsTest.kt @@ -0,0 +1,73 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition 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; version 3 of the License. +// +// This community edition 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 http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.business.timesheet + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.projectforge.Constants +import java.math.BigDecimal +import java.time.LocalDateTime +import java.time.Month +import java.time.ZoneId +import java.util.* + +class AITimeSavingsTest { + @Test + fun testBuildStats() { + val timesheets = listOf( + createTimesheet(120, BigDecimal(1), TimesheetDO.TimeSavedByAIUnit.HOURS), + createTimesheet(120, BigDecimal(50), TimesheetDO.TimeSavedByAIUnit.PERCENTAGE), + createTimesheet(60, BigDecimal(0), TimesheetDO.TimeSavedByAIUnit.PERCENTAGE), // No AI time saved. + createTimesheet(60, BigDecimal(0), TimesheetDO.TimeSavedByAIUnit.HOURS), // No AI time saved. + createTimesheet(60, BigDecimal(50), null), // No unit specified (shouldn't happen). + createTimesheet(60, null, TimesheetDO.TimeSavedByAIUnit.HOURS), // No AI time saved. + createTimesheet(60, null, TimesheetDO.TimeSavedByAIUnit.PERCENTAGE), // No AI time saved. + ) + val stats = AITimeSavings.buildStats(timesheets) + Assertions.assertEquals(9 * Constants.MILLIS_PER_HOUR, stats.totalDurationMillis, "9 hours in total.") + Assertions.assertEquals( + 2 * Constants.MILLIS_PER_HOUR, + stats.totalTimeSavedByAIMillis, + "2 hours saved by AI (1 hour + 50% of 2 hours)." + ) + } + + private fun createTimesheet( + durationMinutes: Long, + timeSavedByAI: BigDecimal?, + timeSavedByAIUnit: TimesheetDO.TimeSavedByAIUnit? + ): TimesheetDO { + return TimesheetDO().also { + it.startTime = START_TIME + it.stopTime = Date(START_TIME.time + durationMinutes * Constants.MILLIS_PER_MINUTE) + it.timeSavedByAI = timeSavedByAI + it.timeSavedByAIUnit = timeSavedByAIUnit + } + } + + companion object { + private val START_TIME = + Date.from(LocalDateTime.of(2025, Month.JANUARY, 25, 22, 43).atZone(ZoneId.of("UTC")).toInstant()) + } +} diff --git a/projectforge-common/src/main/kotlin/org/projectforge/common/extensions/KotlinNumberExtensions.kt b/projectforge-common/src/main/kotlin/org/projectforge/common/extensions/KotlinNumberExtensions.kt index 03950fda97..0581c006e4 100644 --- a/projectforge-common/src/main/kotlin/org/projectforge/common/extensions/KotlinNumberExtensions.kt +++ b/projectforge-common/src/main/kotlin/org/projectforge/common/extensions/KotlinNumberExtensions.kt @@ -105,26 +105,35 @@ fun Number?.formatBytes(locale: Locale? = null): String { /** * Formats a number given in millis to a string in the format HH:mm:ss.SSS. */ -fun Number?.formatMillis(showMillis: Boolean = true): String { +fun Number?.formatMillis(showMillis: Boolean = true, showSeconds: Boolean = true): String { this ?: return "" val millis = this.toLong() val hours = millis / (1000 * 60 * 60) - val minutes = (millis / (1000 * 60)) % 60 - val seconds = (millis / 1000) % 60 + var minutes = (millis / (1000 * 60)) % 60 + var seconds = (millis / 1000) % 60 val milliseconds = millis % 1000 - + if (!showMillis && milliseconds >= 500) { + ++seconds // Round up. + } + if (!showSeconds && seconds >= 30) { + ++minutes // Round up. + } return when { - hours > 0 -> if (showMillis) { + hours > 0 || !showSeconds -> if (showMillis && showSeconds) { String.format("%d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds) - } else { + } else if (showSeconds) { String.format("%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format("%02d:%02d", hours, minutes) } // minutes > 0 -> String.format("%02d:%02d.%03d", minutes, seconds, milliseconds) // else -> String.format("%02d.%03d", seconds, milliseconds) - else -> if (showMillis) { + else -> if (showMillis && showSeconds) { String.format("%02d:%02d.%03d", minutes, seconds, milliseconds) - } else { + } else if (showSeconds) { String.format("%02d:%02d", minutes, seconds) + } else { + String.format("%02d", minutes) } } } diff --git a/projectforge-common/src/test/kotlin/org/projectforge/common/extensions/KotlinNumberExtensionsTest.kt b/projectforge-common/src/test/kotlin/org/projectforge/common/extensions/KotlinNumberExtensionsTest.kt index 9d079bc81c..7d97c9ca78 100644 --- a/projectforge-common/src/test/kotlin/org/projectforge/common/extensions/KotlinNumberExtensionsTest.kt +++ b/projectforge-common/src/test/kotlin/org/projectforge/common/extensions/KotlinNumberExtensionsTest.kt @@ -44,6 +44,12 @@ class KotlinNumberExtensionsTest { Assertions.assertEquals("00:01.123", (1123).formatMillis()) Assertions.assertEquals("01:01.123", (61123).formatMillis()) Assertions.assertEquals("12:21.123", (12 * 60000 + 21 * 1000 + 123).formatMillis()) + + Assertions.assertEquals("12:21", (12 * 60000 + 21 * 1000 + 123).formatMillis(showMillis = false)) + Assertions.assertEquals("12:22", (12 * 60000 + 21 * 1000 + 500).formatMillis(showMillis = false), "Rounding up") + Assertions.assertEquals("00:12", (12 * 60000 + 21 * 1000 + 123).formatMillis(showSeconds = false)) + + Assertions.assertEquals("00:13", (12 * 60000 + 30 * 1000 + 500).formatMillis(showSeconds = false), "Rounding up") } @Test diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.html b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.html index 5d13c3fb68..623efb8f34 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.html +++ b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.html @@ -12,6 +12,7 @@ [cost 2, customer, project, cost 2 type] [01.-05.] + Time saved by AI @@ -22,9 +23,10 @@ [kost art] [1.50] [10.75] + [10.75] - \ No newline at end of file + diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java index d210487132..821131d595 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java +++ b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java @@ -41,6 +41,7 @@ import org.projectforge.business.fibu.kost.*; import org.projectforge.business.task.TaskDO; import org.projectforge.business.task.formatter.WicketTaskFormatter; +import org.projectforge.business.timesheet.TimesheetDao; import org.projectforge.business.user.UserGroupCache; import org.projectforge.business.vacation.service.VacationService; import org.projectforge.framework.configuration.Configuration; @@ -256,6 +257,7 @@ public void onBeforeRender() { private void addReport() { final RepeatingView headcolRepeater = new RepeatingView("headcolRepeater"); table.add(headcolRepeater); + final boolean timeSavingsByAIEnabled = WicketSupport.get(TimesheetDao.class).getTimeSavingsByAIEnabled(); if (!MapUtils.isEmpty(report.getKost2Rows())) { headcolRepeater.add(new Label(headcolRepeater.newChildId(), getString("fibu.kost2"))); headcolRepeater.add(new Label(headcolRepeater.newChildId(), getString("fibu.kunde"))); @@ -274,6 +276,7 @@ private void addReport() { + week.getFormattedToDayOfMonth() + ".")); } + table.add(new Label("timeSavedByAI" , getString("timesheet.ai.timeSavedByAI")).setVisible(timeSavingsByAIEnabled)); final RepeatingView rowRepeater = new RepeatingView("rowRepeater"); table.add(rowRepeater); int rowCounter = 0; @@ -296,7 +299,9 @@ private void addReport() { final MonthlyEmployeeReportEntry entry = week.getKost2Entries().get(kost2Row.kost2.getId()); colWeekRepeater.add(new Label(colWeekRepeater.newChildId(), entry != null ? entry.getFormattedDuration() : "")); } - row.add(new Label("sum", report.getKost2Durations().get(cost2.getId()).getFormattedDuration())); + MonthlyEmployeeReportEntry entry = report.getKost2Durations().get(cost2.getId()); + row.add(new Label("sum", entry.getFormattedDuration())); + row.add(new Label("aiTimeSavings", entry.getGetFormattedTimeSavedByAI()).setVisible(timeSavingsByAIEnabled)); } for (final Map.Entry rowEntry : report.getTaskEntries().entrySet()) { @@ -316,37 +321,29 @@ private void addReport() { final MonthlyEmployeeReportEntry entry = week.getTaskEntries().get(task.getId()); colWeekRepeater.add(new Label(colWeekRepeater.newChildId(), entry != null ? entry.getFormattedDuration() : "")); } - row.add(new Label("sum", report.getTaskDurations().get(task.getId()).getFormattedDuration())); + MonthlyEmployeeReportEntry entry = report.getTaskDurations().get(task.getId()); + row.add(new Label("sum", entry.getFormattedDuration())); + row.add(new Label("aiTimeSavings", entry.getGetFormattedTimeSavedByAI()).setVisible(timeSavingsByAIEnabled)); } { // Sum row. final WebMarkupContainer row = new WebMarkupContainer(rowRepeater.newChildId()); rowRepeater.add(row); - if (rowCounter++ % 2 == 0) { - row.add(AttributeModifier.replace("class", "even")); - } else { - row.add(AttributeModifier.replace("class", "odd")); - } addLabelCols(row, null, null, null, report.user, report.getFromDate().getTime(), report.getToDate().getTime()).add( AttributeModifier.replace("style", "text-align: right;")); - final RepeatingView colWeekRepeater = new RepeatingView("colWeekRepeater"); - row.add(colWeekRepeater); + final RepeatingView colWeekRepeater = prepareSumRow(row, rowCounter++); for (final MonthlyEmployeeReportWeek week : report.getWeeks()) { colWeekRepeater.add(new Label(colWeekRepeater.newChildId(), week.getFormattedTotalDuration())); } row.add(new Label("sum", report.getFormattedTotalNetDuration()).add(AttributeModifier.replace("style", "font-weight: bold; color:red; text-align: right;"))); + row.add(new Label("aiTimeSavings", report.getFormattedTotalTimeSavedByAI()).setVisible(timeSavingsByAIEnabled)); } if (report.getTotalGrossDuration() != report.getTotalNetDuration()) { // Gross sum row. final WebMarkupContainer row = new WebMarkupContainer(rowRepeater.newChildId()); rowRepeater.add(row); - if (rowCounter++ % 2 == 0) { - row.add(AttributeModifier.replace("class", "even")); - } else { - row.add(AttributeModifier.replace("class", "odd")); - } final Component comp = new WebMarkupContainer("cost2").setVisible(false); row.add(comp); row.add(new Label("customer", "").setVisible(false)); @@ -356,14 +353,45 @@ private void addReport() { final WebMarkupContainer tdContainer = title.findParent(WebMarkupContainer.class); tdContainer.add(AttributeModifier.replace("colspan", "4")); tdContainer.add(AttributeModifier.replace("style", "font-weight: bold; text-align: right;")); - final RepeatingView colWeekRepeater = new RepeatingView("colWeekRepeater"); - row.add(colWeekRepeater); + final RepeatingView colWeekRepeater = prepareSumRow(row, rowCounter++); for (@SuppressWarnings("unused") final MonthlyEmployeeReportWeek week : report.getWeeks()) { colWeekRepeater.add(new Label(colWeekRepeater.newChildId(), week.getFormattedGrossDuration())); } row.add(new Label("sum", report.getFormattedTotalGrossDuration()).add(AttributeModifier.replace("style", "font-weight: bold; text-align: right;"))); + row.add(new Label("aiTimeSavings", report.getFormattedTotalTimeSavedByAI()).setVisible(timeSavingsByAIEnabled)); + } + if (timeSavingsByAIEnabled){ + // Sum row time saved by AI. + final WebMarkupContainer row = new WebMarkupContainer(rowRepeater.newChildId()); + rowRepeater.add(row); + final Component comp = new WebMarkupContainer("cost2").setVisible(false); + row.add(comp); + row.add(new Label("customer", "").setVisible(false)); + row.add(new Label("project", "").setVisible(false)); + final Label title = addCostType(row, getString("timesheet.ai.timeSavedByAI")); + final WebMarkupContainer tdContainer = title.findParent(WebMarkupContainer.class); + tdContainer.add(AttributeModifier.replace("colspan", "4")); + tdContainer.add(AttributeModifier.replace("style", "font-weight: bold; text-align: right;")); + final RepeatingView colWeekRepeater = prepareSumRow(row, rowCounter++); + for (@SuppressWarnings("unused") final MonthlyEmployeeReportWeek week : report.getWeeks()) { + colWeekRepeater.add(new Label(colWeekRepeater.newChildId(), week.getFormattedTotalTimeSavedByAI())); + } + row.add(new Label("sum", report.getFormattedTotalTimeSavedByAI()).add(AttributeModifier.replace("style", + "font-weight: bold; text-align: right;"))); + row.add(new Label("aiTimeSavings", report.getFormattedTimeSavedByAIPercentage())); + } + } + + private RepeatingView prepareSumRow(final WebMarkupContainer row, int rowCounter) { + if (rowCounter % 2 == 0) { + row.add(AttributeModifier.replace("class", "even")); + } else { + row.add(AttributeModifier.replace("class", "odd")); } + final RepeatingView colWeekRepeater = new RepeatingView("colWeekRepeater"); + row.add(colWeekRepeater); + return colWeekRepeater; } @SuppressWarnings("serial") diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/timesheet/TimesheetListPage.java b/projectforge-wicket/src/main/java/org/projectforge/web/timesheet/TimesheetListPage.java index 7b24eac3ff..4946ce4be7 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/timesheet/TimesheetListPage.java +++ b/projectforge-wicket/src/main/java/org/projectforge/web/timesheet/TimesheetListPage.java @@ -43,10 +43,7 @@ import org.projectforge.business.system.SystemInfoCache; import org.projectforge.business.task.TaskDO; import org.projectforge.business.task.TaskTree; -import org.projectforge.business.timesheet.TimesheetDO; -import org.projectforge.business.timesheet.TimesheetDao; -import org.projectforge.business.timesheet.TimesheetExport; -import org.projectforge.business.timesheet.TimesheetFilter; +import org.projectforge.business.timesheet.*; import org.projectforge.business.user.UserGroupCache; import org.projectforge.business.utils.HtmlDateTimeFormatter; import org.projectforge.business.utils.HtmlHelper; @@ -221,6 +218,7 @@ public List> createColumns(final WebPage returnToPa protected static final List> createColumns( UserGroupCache userGroupCache, final WebPage page, final TimesheetFilter timesheetFilter) { + final boolean timeSavingsByAIEnabled = WicketSupport.get(TimesheetDao.class).getTimeSavingsByAIEnabled(); final List> columns = new ArrayList>(); final CellItemListener cellItemListener = new CellItemListener() { @Override @@ -311,6 +309,20 @@ public void populateItem(final Item> item, final Str item.add(label); } }); + if (timeSavingsByAIEnabled) { + columns.add(new CellItemListenerPropertyColumn(page.getString("timesheet.ai.timeSavedByAI"), + getSortable("timeSavedByAIMillis", true), + "timeSavedByAIMillis", cellItemListener) { + @Override + public void populateItem(final Item> item, final String componentId, + final IModel rowModel) { + final TimesheetDO timesheet = rowModel.getObject(); + final Label label = new Label(componentId, WicketSupport.get(HtmlDateTimeFormatter.class).getFormattedDuration(AITimeSavings.getTimeSavedByAIMs(timesheet))); + cellItemListener.populateItem(item, componentId, rowModel); + item.add(label); + } + }); + } columns.add(new CellItemListenerPropertyColumn(page.getString("timesheet.location"), getSortable("location", true), "location", cellItemListener)); From 8ebd8723c670b3ea7ec7532e1621e8441dfa0612 Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Sun, 26 Jan 2025 01:21:51 +0100 Subject: [PATCH 13/15] WIP: JCR RDB storage: Test fixed. --- .../src/main/kotlin/org/projectforge/jcr/RepoConfig.kt | 8 ++++++++ .../src/main/kotlin/org/projectforge/jcr/RepoService.kt | 2 +- .../test/kotlin/org/projectforge/jcr/RepoBackupTest.kt | 2 ++ .../src/test/kotlin/org/projectforge/jcr/RepoTest.kt | 1 + 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoConfig.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoConfig.kt index 6730622f35..415d7902b0 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoConfig.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoConfig.kt @@ -89,4 +89,12 @@ internal open class RepoConfig { log.error(e) { "Error during data source creation: ${e.message}" } } } + + companion object { + internal fun createForTests(): RepoConfig { + return RepoConfig().also { + it.dataSourceUrl = "" + } + } + } } diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt index 95967cdef5..ab3f9cbde5 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/RepoService.kt @@ -38,7 +38,7 @@ open class RepoService { private var repoStore: OakStorage? = null @Autowired - private lateinit var repoConfig: RepoConfig + internal lateinit var repoConfig: RepoConfig val mainNodeName: String? get() = repoStore?.mainNodeName diff --git a/projectforge-jcr/src/test/kotlin/org/projectforge/jcr/RepoBackupTest.kt b/projectforge-jcr/src/test/kotlin/org/projectforge/jcr/RepoBackupTest.kt index fdde55fc80..4a4cf04e49 100644 --- a/projectforge-jcr/src/test/kotlin/org/projectforge/jcr/RepoBackupTest.kt +++ b/projectforge-jcr/src/test/kotlin/org/projectforge/jcr/RepoBackupTest.kt @@ -39,6 +39,7 @@ class RepoBackupTest { init { val repoDir = testUtils.deleteAndCreateTestFile("testBackupRepo") + repoService.repoConfig = RepoConfig.createForTests() repoService.init(repoDir) repoBackupService.repoService = repoService repoBackupService.jcrCheckSanityJob = JCRCheckSanityCheckJob() @@ -81,6 +82,7 @@ class RepoBackupTest { } val repo2Service = RepoService() + repo2Service.repoConfig = RepoConfig.createForTests() val repo2BackupService = RepoBackupService() val repo2Dir = testUtils.deleteAndCreateTestFile("testBackupRepo2") repo2Service.init(repo2Dir) diff --git a/projectforge-jcr/src/test/kotlin/org/projectforge/jcr/RepoTest.kt b/projectforge-jcr/src/test/kotlin/org/projectforge/jcr/RepoTest.kt index d97d2144b7..23ee3a92aa 100644 --- a/projectforge-jcr/src/test/kotlin/org/projectforge/jcr/RepoTest.kt +++ b/projectforge-jcr/src/test/kotlin/org/projectforge/jcr/RepoTest.kt @@ -44,6 +44,7 @@ class RepoTest { init { val repoDir = testUtils.deleteAndCreateTestFile("testRepo") + repoService.repoConfig = RepoConfig.createForTests() repoService.init(repoDir) } From 9ce9f3f837f19a43ae7c2071953a68ea25e28103 Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Sun, 26 Jan 2025 01:38:51 +0100 Subject: [PATCH 14/15] WIP: time savings by AI --- .../web/fibu/MonthlyEmployeeReportPage.html | 4 ++-- .../web/fibu/MonthlyEmployeeReportPage.java | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.html b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.html index 623efb8f34..920fdb80bd 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.html +++ b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.html @@ -12,7 +12,7 @@ [cost 2, customer, project, cost 2 type] [01.-05.] - Time saved by AI + Time saved by AI @@ -23,7 +23,7 @@ [kost art] [1.50] [10.75] - [10.75] + [10.75] diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java index 821131d595..89124c8ab9 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java +++ b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/MonthlyEmployeeReportPage.java @@ -276,7 +276,7 @@ private void addReport() { + week.getFormattedToDayOfMonth() + ".")); } - table.add(new Label("timeSavedByAI" , getString("timesheet.ai.timeSavedByAI")).setVisible(timeSavingsByAIEnabled)); + table.add(new Label("timeSavedByAI", getString("timesheet.ai.timeSavedByAI")).setVisible(timeSavingsByAIEnabled)); final RepeatingView rowRepeater = new RepeatingView("rowRepeater"); table.add(rowRepeater); int rowCounter = 0; @@ -361,7 +361,7 @@ private void addReport() { "font-weight: bold; text-align: right;"))); row.add(new Label("aiTimeSavings", report.getFormattedTotalTimeSavedByAI()).setVisible(timeSavingsByAIEnabled)); } - if (timeSavingsByAIEnabled){ + if (timeSavingsByAIEnabled) { // Sum row time saved by AI. final WebMarkupContainer row = new WebMarkupContainer(rowRepeater.newChildId()); rowRepeater.add(row); @@ -370,16 +370,20 @@ private void addReport() { row.add(new Label("customer", "").setVisible(false)); row.add(new Label("project", "").setVisible(false)); final Label title = addCostType(row, getString("timesheet.ai.timeSavedByAI")); + title.add(AttributeModifier.replace("style", "text-align: right; color:purple;")); final WebMarkupContainer tdContainer = title.findParent(WebMarkupContainer.class); tdContainer.add(AttributeModifier.replace("colspan", "4")); - tdContainer.add(AttributeModifier.replace("style", "font-weight: bold; text-align: right;")); + tdContainer.add(AttributeModifier.replace("style", "text-align: right;")); final RepeatingView colWeekRepeater = prepareSumRow(row, rowCounter++); for (@SuppressWarnings("unused") final MonthlyEmployeeReportWeek week : report.getWeeks()) { - colWeekRepeater.add(new Label(colWeekRepeater.newChildId(), week.getFormattedTotalTimeSavedByAI())); + colWeekRepeater.add(new Label(colWeekRepeater.newChildId(), week.getFormattedTotalTimeSavedByAI()) + .add(AttributeModifier.replace("style", + "text-align: right; color:purple;"))); } row.add(new Label("sum", report.getFormattedTotalTimeSavedByAI()).add(AttributeModifier.replace("style", - "font-weight: bold; text-align: right;"))); - row.add(new Label("aiTimeSavings", report.getFormattedTimeSavedByAIPercentage())); + "font-weight: bold; color:purple; text-align: right;"))); + row.add(new Label("aiTimeSavings", report.getFormattedTimeSavedByAIPercentage()).add(AttributeModifier.replace("style", + "font-weight: bold; color:purple; text-align: right;"))); } } From b1d7501b818bfa92189a9766e4d9fd4b3325abe6 Mon Sep 17 00:00:00 2001 From: Kai Reinhard Date: Sun, 26 Jan 2025 21:58:50 +0100 Subject: [PATCH 15/15] NOP --- ToDo.adoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ToDo.adoc b/ToDo.adoc index 5af5e5bf31..0becbdab4e 100644 --- a/ToDo.adoc +++ b/ToDo.adoc @@ -1,4 +1,6 @@ ==== Aktuell: +- Änderungskommentar bei Usern/Gruppen (JIRA-Nummern) +- AI timesavings: Zeit bei Eingabe ausrechnen, recent ai texte als vorlage. - JCR: Tool for removing or recovering orphaned nodes. - Favoriten bei Scriptausführung für Parameter. - KI-Anteil in Zeitberichten @@ -14,6 +16,7 @@ - Kalendereinträge und Subscriptions. - LoginProtection: Increases penalty time for DAV only after 5 tries. +- Admin für Datentransfer: show details of nodes, restore orphaned etc. - Apple for Webauthn4j - Abwesenheiten - Fakturaquote Monatsbericht @@ -95,6 +98,7 @@ DROP DATABASE projectforge; CREATE DATABASE projectforge; ---- + Orderbooks importieren: [source] ----