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) {
+ }
+}