updates for zmanagement x2
All checks were successful
Build Org Backend / build (push) Successful in 23s

This commit is contained in:
2026-06-10 15:57:18 +01:00
parent 9df3e162ce
commit 3712e2e6f0
8 changed files with 394 additions and 6 deletions

View File

@@ -26,6 +26,17 @@ COMBINED_RUN_LOG=/home/zaine/logs/combined.log
PLAY_RPG_SAVE_DIR=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves
# Calendar sync
NEXTCLOUD_CALDAV_URL=https://nextcloud.zainezq.com/remote.php/dav/calendars/zaine/
NEXTCLOUD_CALDAV_CALENDARS=personal,zxh
NEXTCLOUD_CALDAV_USERNAME=zaine
NEXTCLOUD_CALDAV_PASSWORD=change-me-to-a-nextcloud-app-password
CALENDAR_TIMEZONE=Europe/London
CALENDAR_SYNC_DAYS_BACK=30
CALENDAR_SYNC_DAYS_FORWARD=365
# Optional: external ICS feed for work calendar
WORK_ICS_URL=
# Leave unset on private zone — timesheet API read/write without credentials
# ORG_BACKEND_API_KEY=
TIMESHEET_AUTH_REQUIRED=false

View File

@@ -91,6 +91,12 @@
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.mnode.ical4j</groupId>
<artifactId>ical4j</artifactId>
<version>4.2.5</version>
</dependency>
</dependencies>
<build>

View File

@@ -2,9 +2,11 @@ package org.zaine.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);

View File

@@ -0,0 +1,60 @@
package org.zaine.app.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.List;
@Component
public class CalendarSyncProperties {
private final String nextcloudBaseUrl;
private final String nextcloudUsername;
private final String nextcloudPassword;
private final List<String> nextcloudCalendarIds;
private final String workIcsUrl;
private final ZoneId zoneId;
private final int daysBack;
private final int daysForward;
public CalendarSyncProperties(
@Value("${calendar.nextcloud.base-url:${NEXTCLOUD_CALDAV_URL:}}") String nextcloudBaseUrl,
@Value("${calendar.nextcloud.username:${NEXTCLOUD_CALDAV_USERNAME:}}") String nextcloudUsername,
@Value("${calendar.nextcloud.password:${NEXTCLOUD_CALDAV_PASSWORD:}}") String nextcloudPassword,
@Value("${calendar.nextcloud.calendar-ids:${NEXTCLOUD_CALDAV_CALENDARS:personal,zxh}}") String nextcloudCalendarIds,
@Value("${calendar.work.ics-url:${WORK_ICS_URL:}}") String workIcsUrl,
@Value("${calendar.timezone:${CALENDAR_TIMEZONE:Europe/London}}") String timezone,
@Value("${calendar.sync.days-back:${CALENDAR_SYNC_DAYS_BACK:30}}") int daysBack,
@Value("${calendar.sync.days-forward:${CALENDAR_SYNC_DAYS_FORWARD:365}}") int daysForward) {
this.nextcloudBaseUrl = trim(nextcloudBaseUrl);
this.nextcloudUsername = trim(nextcloudUsername);
this.nextcloudPassword = trim(nextcloudPassword);
this.nextcloudCalendarIds = Arrays.stream(nextcloudCalendarIds.split(","))
.map(String::trim)
.filter(value -> !value.isBlank())
.toList();
this.workIcsUrl = trim(workIcsUrl);
this.zoneId = ZoneId.of(timezone);
this.daysBack = daysBack;
this.daysForward = daysForward;
}
public String getNextcloudBaseUrl() { return nextcloudBaseUrl; }
public String getNextcloudUsername() { return nextcloudUsername; }
public String getNextcloudPassword() { return nextcloudPassword; }
public List<String> getNextcloudCalendarIds() { return nextcloudCalendarIds; }
public String getWorkIcsUrl() { return workIcsUrl; }
public ZoneId getZoneId() { return zoneId; }
public int getDaysBack() { return daysBack; }
public int getDaysForward() { return daysForward; }
public boolean hasNextcloudCredentials() {
return !nextcloudBaseUrl.isBlank() && !nextcloudUsername.isBlank() && !nextcloudPassword.isBlank();
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -12,8 +12,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.zaine.app.dto.CalendarEventDTO;
import org.zaine.app.dto.CalendarSyncResultDTO;
import org.zaine.app.dto.CreateCalendarEventDTO;
import org.zaine.app.service.CalendarService;
import org.zaine.app.service.CalendarSyncService;
import java.time.LocalDate;
import java.util.List;
@@ -24,9 +26,11 @@ import java.util.List;
public class CalendarController {
private final CalendarService calendarService;
private final CalendarSyncService calendarSyncService;
public CalendarController(CalendarService calendarService) {
public CalendarController(CalendarService calendarService, CalendarSyncService calendarSyncService) {
this.calendarService = calendarService;
this.calendarSyncService = calendarSyncService;
}
@Operation(summary = "Get today's calendar events")
@@ -62,4 +66,10 @@ public class CalendarController {
public ResponseEntity<CalendarEventDTO> createEvent(@RequestBody CreateCalendarEventDTO dto) {
return ResponseEntity.ok(calendarService.createEvent(dto));
}
@Operation(summary = "Fetch configured external calendars now")
@PostMapping("/sync")
public ResponseEntity<CalendarSyncResultDTO> syncCalendar() {
return ResponseEntity.ok(calendarSyncService.sync());
}
}

View File

@@ -0,0 +1,12 @@
package org.zaine.app.dto;
import java.time.OffsetDateTime;
import java.util.List;
public record CalendarSyncResultDTO(
OffsetDateTime syncedAt,
int sourceCount,
int eventCount,
List<String> sources,
List<String> errors) {
}

View File

@@ -10,4 +10,6 @@ public interface CalendarEventRepository extends JpaRepository<CalendarEvent, Lo
List<CalendarEvent> findByStartsAtLessThanAndEndsAtGreaterThanOrderByStartsAtAsc(
OffsetDateTime rangeEnd,
OffsetDateTime rangeStart);
void deleteBySource(String source);
}

View File

@@ -0,0 +1,285 @@
package org.zaine.app.service;
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.Period;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.component.CalendarComponent;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.property.DateProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.zaine.app.config.CalendarSyncProperties;
import org.zaine.app.dto.CalendarSyncResultDTO;
import org.zaine.app.model.CalendarEvent;
import org.zaine.app.repositories.CalendarEventRepository;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.Temporal;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
@Service
public class CalendarSyncService {
private static final Logger logger = Logger.getLogger(CalendarSyncService.class.getName());
private final CalendarEventRepository calendarEventRepository;
private final CalendarSyncProperties properties;
private final HttpClient httpClient;
public CalendarSyncService(
CalendarEventRepository calendarEventRepository,
CalendarSyncProperties properties) {
this.calendarEventRepository = calendarEventRepository;
this.properties = properties;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
@Scheduled(initialDelayString = "${calendar.sync.initial-delay-ms:15000}",
fixedDelayString = "${calendar.sync.fixed-delay-ms:1800000}")
public void scheduledSync() {
try {
CalendarSyncResultDTO result = sync();
logger.info("Calendar sync complete: " + result.eventCount() + " events from " + result.sourceCount() + " sources");
} catch (Exception ex) {
logger.log(Level.WARNING, "Calendar sync failed", ex);
}
}
@Transactional
public CalendarSyncResultDTO sync() {
List<CalendarSource> sources = configuredSources();
List<String> syncedSources = new ArrayList<>();
List<String> errors = new ArrayList<>();
int eventCount = 0;
for (CalendarSource source : sources) {
try {
String ics = fetch(source);
List<CalendarEvent> events = parse(source.name(), ics);
calendarEventRepository.deleteBySource(source.name());
calendarEventRepository.saveAll(events);
syncedSources.add(source.name());
eventCount += events.size();
} catch (Exception ex) {
logger.log(Level.WARNING, "Failed to sync calendar source " + source.name(), ex);
errors.add(source.name() + ": " + ex.getMessage());
}
}
return new CalendarSyncResultDTO(OffsetDateTime.now(), syncedSources.size(), eventCount, syncedSources, errors);
}
private List<CalendarSource> configuredSources() {
List<CalendarSource> sources = new ArrayList<>();
if (properties.hasNextcloudCredentials()) {
for (String calendarId : properties.getNextcloudCalendarIds()) {
sources.add(new CalendarSource(
"nextcloud-" + calendarId,
nextcloudExportUrl(calendarId),
properties.getNextcloudUsername(),
properties.getNextcloudPassword()));
}
}
if (!properties.getWorkIcsUrl().isBlank()) {
sources.add(new CalendarSource("work", properties.getWorkIcsUrl(), "", ""));
}
return sources;
}
private String nextcloudExportUrl(String calendarId) {
String baseUrl = properties.getNextcloudBaseUrl().endsWith("/")
? properties.getNextcloudBaseUrl()
: properties.getNextcloudBaseUrl() + "/";
return baseUrl + calendarId + "?export";
}
private String fetch(CalendarSource source) throws IOException, InterruptedException {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(source.url()))
.GET()
.timeout(Duration.ofSeconds(60))
.header("Accept", "text/calendar,*/*");
if (!source.username().isBlank() || !source.password().isBlank()) {
String credentials = source.username() + ":" + source.password();
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
requestBuilder.header("Authorization", "Basic " + encoded);
}
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException("Calendar fetch returned HTTP " + response.statusCode());
}
return response.body();
}
private List<CalendarEvent> parse(String source, String ics) throws IOException, ParserException {
CalendarBuilder builder = new CalendarBuilder();
Calendar calendar = builder.build(new ByteArrayInputStream(ics.getBytes(StandardCharsets.UTF_8)));
ZoneId zoneId = properties.getZoneId();
ZonedDateTime from = LocalDate.now(zoneId).minusDays(properties.getDaysBack()).atStartOfDay(zoneId);
ZonedDateTime to = LocalDate.now(zoneId).plusDays(properties.getDaysForward()).plusDays(1).atStartOfDay(zoneId);
Period<ZonedDateTime> syncWindow = new Period<>(from, to);
List<CalendarEvent> events = new ArrayList<>();
for (CalendarComponent component : calendar.getComponents()) {
if (component instanceof VEvent event) {
events.addAll(toCalendarEvents(source, event, syncWindow, zoneId));
}
}
return events.stream()
.filter(event -> event.getStartsAt().isBefore(to.toOffsetDateTime())
&& event.getEndsAt().isAfter(from.toOffsetDateTime()))
.sorted(Comparator.comparing(CalendarEvent::getStartsAt))
.toList();
}
private List<CalendarEvent> toCalendarEvents(
String source,
VEvent event,
Period<ZonedDateTime> syncWindow,
ZoneId zoneId) {
String uid = event.getUid().map(Property::getValue).orElseGet(() -> BigInteger.valueOf(event.hashCode()).abs().toString());
String title = propertyValue(event, Property.SUMMARY, "Untitled event");
String description = propertyValue(event, Property.DESCRIPTION, null);
String location = propertyValue(event, Property.LOCATION, null);
boolean allDay = isAllDay(event);
OffsetDateTime originalStart = event.getStartDate()
.map(DateProperty::getDate)
.map(value -> toOffsetDateTime(value, zoneId, false))
.orElse(null);
OffsetDateTime originalEnd = event.getEndDate()
.map(DateProperty::getDate)
.map(value -> toOffsetDateTime(value, zoneId, allDay))
.orElse(null);
if (originalStart == null) {
return List.of();
}
Duration duration = originalEnd == null || !originalEnd.isAfter(originalStart)
? Duration.ofHours(1)
: Duration.between(originalStart, originalEnd);
Set<Period<Temporal>> periods;
try {
periods = event.calculateRecurrenceSet(syncWindow);
} catch (RuntimeException ex) {
logger.log(Level.WARNING, "Failed to expand recurrence for " + uid + "; using original event only", ex);
periods = Set.of();
}
if (periods.isEmpty()) {
CalendarEvent calendarEvent = buildEvent(
source,
uid,
originalStart,
originalStart.plus(duration),
title,
description,
location,
allDay);
return List.of(calendarEvent);
}
return periods.stream()
.map(period -> {
OffsetDateTime startsAt = toOffsetDateTime(period.getStart(), zoneId, false);
OffsetDateTime endsAt = toOffsetDateTime(period.getEnd(), zoneId, allDay);
if (!endsAt.isAfter(startsAt)) {
endsAt = startsAt.plus(duration);
}
return buildEvent(source, uid, startsAt, endsAt, title, description, location, allDay);
})
.toList();
}
private CalendarEvent buildEvent(
String source,
String uid,
OffsetDateTime startsAt,
OffsetDateTime endsAt,
String title,
String description,
String location,
boolean allDay) {
CalendarEvent event = new CalendarEvent();
event.setExternalId(source + ":" + uid + ":" + startsAt.toInstant());
event.setTitle(title);
event.setDescription(description);
event.setLocation(location);
event.setStartsAt(startsAt);
event.setEndsAt(endsAt);
event.setAllDay(allDay);
event.setSource(source);
return event;
}
private String propertyValue(VEvent event, String propertyName, String fallback) {
return event.<Property>getProperty(propertyName)
.map(Property::getValue)
.map(String::trim)
.filter(value -> !value.isBlank())
.orElse(fallback);
}
private boolean isAllDay(VEvent event) {
return event.getStartDate()
.map(DateProperty::getDate)
.map(value -> value instanceof LocalDate)
.orElse(false);
}
private OffsetDateTime toOffsetDateTime(Temporal temporal, ZoneId zoneId, boolean allDayEnd) {
if (temporal instanceof ZonedDateTime value) {
return value.toOffsetDateTime();
}
if (temporal instanceof OffsetDateTime value) {
return value;
}
if (temporal instanceof LocalDateTime value) {
return value.atZone(zoneId).toOffsetDateTime();
}
if (temporal instanceof LocalDate value) {
LocalDate date = allDayEnd ? value : value;
return date.atStartOfDay(zoneId).toOffsetDateTime();
}
if (temporal instanceof Instant value) {
return value.atZone(zoneId).toOffsetDateTime();
}
return ZonedDateTime.from(temporal).withZoneSameInstant(zoneId).toOffsetDateTime();
}
private record CalendarSource(String name, String url, String username, String password) {
}
}