updates for zmanagement x2
All checks were successful
Build Org Backend / build (push) Successful in 23s
All checks were successful
Build Org Backend / build (push) Successful in 23s
This commit is contained in:
@@ -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
|
||||
|
||||
6
pom.xml
6
pom.xml
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
12
src/main/java/org/zaine/app/dto/CalendarSyncResultDTO.java
Normal file
12
src/main/java/org/zaine/app/dto/CalendarSyncResultDTO.java
Normal 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) {
|
||||
}
|
||||
@@ -10,4 +10,6 @@ public interface CalendarEventRepository extends JpaRepository<CalendarEvent, Lo
|
||||
List<CalendarEvent> findByStartsAtLessThanAndEndsAtGreaterThanOrderByStartsAtAsc(
|
||||
OffsetDateTime rangeEnd,
|
||||
OffsetDateTime rangeStart);
|
||||
|
||||
void deleteBySource(String source);
|
||||
}
|
||||
|
||||
285
src/main/java/org/zaine/app/service/CalendarSyncService.java
Normal file
285
src/main/java/org/zaine/app/service/CalendarSyncService.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user