From 3712e2e6f04a6832324685a58f90ea76684af892 Mon Sep 17 00:00:00 2001 From: Zaine Date: Wed, 10 Jun 2026 15:57:18 +0100 Subject: [PATCH] updates for zmanagement x2 --- misc/org-backend.env.example | 21 +- pom.xml | 6 + src/main/java/org/zaine/app/Application.java | 2 + .../app/config/CalendarSyncProperties.java | 60 ++++ .../app/controller/CalendarController.java | 12 +- .../zaine/app/dto/CalendarSyncResultDTO.java | 12 + .../repositories/CalendarEventRepository.java | 2 + .../app/service/CalendarSyncService.java | 285 ++++++++++++++++++ 8 files changed, 394 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/zaine/app/config/CalendarSyncProperties.java create mode 100644 src/main/java/org/zaine/app/dto/CalendarSyncResultDTO.java create mode 100644 src/main/java/org/zaine/app/service/CalendarSyncService.java diff --git a/misc/org-backend.env.example b/misc/org-backend.env.example index e11a249..4bc0688 100755 --- a/misc/org-backend.env.example +++ b/misc/org-backend.env.example @@ -24,8 +24,19 @@ EMACS_RUN_DIR=/home/zaine EMACS_RUN_LOG=/home/zaine/logs/emacs.log COMBINED_RUN_LOG=/home/zaine/logs/combined.log -PLAY_RPG_SAVE_DIR=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves - -# Leave unset on private zone — timesheet API read/write without credentials -# ORG_BACKEND_API_KEY= -TIMESHEET_AUTH_REQUIRED=false +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 diff --git a/pom.xml b/pom.xml index a4487b3..9980f57 100755 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,12 @@ spring-boot-starter-security + + org.mnode.ical4j + ical4j + 4.2.5 + + diff --git a/src/main/java/org/zaine/app/Application.java b/src/main/java/org/zaine/app/Application.java index c0b1300..004555a 100755 --- a/src/main/java/org/zaine/app/Application.java +++ b/src/main/java/org/zaine/app/Application.java @@ -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); diff --git a/src/main/java/org/zaine/app/config/CalendarSyncProperties.java b/src/main/java/org/zaine/app/config/CalendarSyncProperties.java new file mode 100644 index 0000000..2ed5a15 --- /dev/null +++ b/src/main/java/org/zaine/app/config/CalendarSyncProperties.java @@ -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 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 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(); + } +} diff --git a/src/main/java/org/zaine/app/controller/CalendarController.java b/src/main/java/org/zaine/app/controller/CalendarController.java index c6737d4..52bce98 100644 --- a/src/main/java/org/zaine/app/controller/CalendarController.java +++ b/src/main/java/org/zaine/app/controller/CalendarController.java @@ -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 createEvent(@RequestBody CreateCalendarEventDTO dto) { return ResponseEntity.ok(calendarService.createEvent(dto)); } + + @Operation(summary = "Fetch configured external calendars now") + @PostMapping("/sync") + public ResponseEntity syncCalendar() { + return ResponseEntity.ok(calendarSyncService.sync()); + } } diff --git a/src/main/java/org/zaine/app/dto/CalendarSyncResultDTO.java b/src/main/java/org/zaine/app/dto/CalendarSyncResultDTO.java new file mode 100644 index 0000000..f7199df --- /dev/null +++ b/src/main/java/org/zaine/app/dto/CalendarSyncResultDTO.java @@ -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 sources, + List errors) { +} diff --git a/src/main/java/org/zaine/app/repositories/CalendarEventRepository.java b/src/main/java/org/zaine/app/repositories/CalendarEventRepository.java index af3ebb4..36163db 100644 --- a/src/main/java/org/zaine/app/repositories/CalendarEventRepository.java +++ b/src/main/java/org/zaine/app/repositories/CalendarEventRepository.java @@ -10,4 +10,6 @@ public interface CalendarEventRepository extends JpaRepository findByStartsAtLessThanAndEndsAtGreaterThanOrderByStartsAtAsc( OffsetDateTime rangeEnd, OffsetDateTime rangeStart); + + void deleteBySource(String source); } diff --git a/src/main/java/org/zaine/app/service/CalendarSyncService.java b/src/main/java/org/zaine/app/service/CalendarSyncService.java new file mode 100644 index 0000000..5b9e899 --- /dev/null +++ b/src/main/java/org/zaine/app/service/CalendarSyncService.java @@ -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 sources = configuredSources(); + List syncedSources = new ArrayList<>(); + List errors = new ArrayList<>(); + int eventCount = 0; + + for (CalendarSource source : sources) { + try { + String ics = fetch(source); + List 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 configuredSources() { + List 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 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 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 syncWindow = new Period<>(from, to); + + List 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 toCalendarEvents( + String source, + VEvent event, + Period 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> 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.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) { + } +}