/* © SRSoftware 2026 */ package de.srsoftware.cal; import static de.srsoftware.cal.Util.extractCoords; import static de.srsoftware.cal.db.Fields.*; import static de.srsoftware.cal.db.Fields.AID; import static de.srsoftware.tools.Optionals.nullIfEmpty; import static de.srsoftware.tools.Optionals.nullable; import static de.srsoftware.tools.container.Container.transform; import static de.srsoftware.tools.container.Error.error; import static java.time.format.DateTimeFormatter.ISO_DATE_TIME; import com.sun.net.httpserver.HttpExchange; import de.srsoftware.cal.api.Appointment; import de.srsoftware.cal.api.Link; import de.srsoftware.cal.db.Database; import de.srsoftware.cal.db.NotFound; import de.srsoftware.tools.HttpError; import de.srsoftware.tools.Path; import de.srsoftware.tools.PathHandler; import de.srsoftware.tools.container.Container; import de.srsoftware.tools.container.Payload; import java.io.IOException; import java.time.LocalDateTime; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.json.JSONObject; public class ApiEndpoint extends PathHandler { private static final String ATTACHMENTS = "attachments"; private static final String LINKS = "links"; private static final String ID = "id"; private static final String TAGS = "tags"; private static final String PAST = "past"; private static final Pattern DATE_TIME = Pattern.compile("(\\d{4})-(\\d\\d?)(-(\\d\\d?)([ T](\\d\\d?):(\\d\\d?)(:(\\d\\d?))?)?)?"); private static final Pattern PAST_PATTERN = Pattern.compile("(\\d+)([m|y])|(all)"); private static final String OFFSET = "offset"; private static final String COUNT = "count"; private final Database db; public ApiEndpoint(Database db) { this.db = db; } private Container createEvent(HttpExchange ex) { try { var res = toEvent(json(ex)); var opt = res.optional(); if (opt.isEmpty()) return transform(res); var event = opt.get(); var title = getHeader(ex, TITLE); if (title.isEmpty()) return HttpError.of(400,"Missing title header"); if (!title.get().equals(event.title())) return HttpError.of(400,"Title mismatch!"); String hostname = hostname(ex); String urlTemplate = hostname+"/static/event?id={}"; return db.add(opt.get()).map(ev -> toJson(ev,urlTemplate)); } catch (IOException e) { return error(e, "Failed to read event data from request body"); } } private Container delete(Path path, HttpExchange ex) { var params = queryParam(ex); var id = params.get(ID); if (id == null) return HttpError.of(400, "Id missing"); long aid = 0; try { aid = Long.parseLong(""+id); } catch (Exception e) { return HttpError.of(404, "%s is not a valid appointment id!", id); } var opt = getHeader(ex, "title"); if (opt.isEmpty()) return HttpError.of(412, "title missing"); var title = opt.get(); return db.loadEvent(aid).map(event -> deleteEvent(event, title)).map(ApiEndpoint::httpError); } private Container deleteEvent(Container eventResult, String title) { var opt = eventResult.optional(); if (opt.isEmpty()) return transform(eventResult); var event = opt.get(); if (!title.equals(event.title())) return HttpError.of(412, "title mismatch"); return db.removeAppointment(event.id()); } @Override public boolean doDelete(Path path, HttpExchange ex) throws IOException { if (path.equals("/event")) return sendContent(ex, delete(path, ex)); return unknownPath(ex, path); } @Override public boolean doGet(Path p, HttpExchange ex) throws IOException { String hostname = hostname(ex); String urlTemplate = hostname+"/static/event?id={}"; String prodId = "OpenCloudCal@"+hostname.split("://",2)[1]; ex.getResponseHeaders().add("Access-Control-Allow-Origin","*"); var path = p.toString(); return switch (path) { case "event" -> sendContent(ex,(Container)getEvent(ex).map(event -> toJson(event, urlTemplate)).map(ApiEndpoint::httpError)); case "event/ical"-> sendContent(ex,(Container)getEvent(ex).map(event -> toIcal(event, hostname, urlTemplate)).map(ical -> Util.wrapIcal(ical,prodId)).map(ApiEndpoint::httpError)); case "events/ical"-> sendContent(ex,(Container)eventList(ex).map(list -> toIcalList(list, hostname, urlTemplate)).map(ical -> Util.wrapIcal(ical,prodId)).map(ApiEndpoint::httpError)); case "events/json" -> sendContent(ex,(Container)eventList(ex).map(list -> toJsonList(list,urlTemplate)).map(ApiEndpoint::httpError)); case "tags" -> listTags(ex); default -> unknownPath(ex, p); }; } @Override public boolean doPatch(Path path, HttpExchange ex) throws IOException { if (path.equals("/event")) return sendContent(ex, updateEvent(ex)); return unknownPath(ex, path); } @Override public boolean doPost(Path path, HttpExchange ex) throws IOException { if (path.equals("/event")) return sendContent(ex, createEvent(ex)); return unknownPath(ex, path); } private Container> eventList(HttpExchange ex) { var param = queryParam(ex); var tags = nullable((String)param.get(TAGS)).stream() .flatMap(s -> Arrays.stream(s.split(","))) .map(s -> s.trim().toLowerCase()) .toList(); Container start = parseDate(param.get(START)); if (start == null) start = parsePast(param.get(PAST)); if (start instanceof de.srsoftware.tools.container.Error err) return err.transform(); var startDate = (start == null) ? null: start.optional().orElse(null); Integer offset = null; var o = param.get(OFFSET); if (o instanceof String s) try { offset = Integer.parseInt(s); } catch (NumberFormatException e) { return HttpError.of(400,"Offset (offset=%s) is not a number!", o); } Integer count = null; o = param.get(COUNT); if (o instanceof String s) try { count = Integer.parseInt(s); } catch (NumberFormatException e) { return HttpError.of(400,"Count (count=%s) is not a number!", o); } Container end = parseDate(param.get(END)); var endDate = (end == null) ? null: end.optional().orElse(null); return db.list(startDate, endDate, count, offset, tags).map(res -> filterByTags(res, tags)); } private Container> filterByTags(Container> res, List tags) { if (tags == null || tags.isEmpty()) return res; var opt = res.optional(); if (opt.isEmpty()) return transform(res); var list = opt.get().stream().filter(event -> tagsMatch(event, tags)).toList(); return Payload.of(list); } private Container getEvent(HttpExchange ex) { var params = queryParam(ex); var o = params.get(ID); if (!(o instanceof String s)) return HttpError.of(400,"id parameter missing!"); try { return db.loadEvent(Long.parseLong(s)); } catch (NumberFormatException e) { return HttpError.of(400,e, "Illegal format for id parameter (%s)", o); } } private static Container httpError(Container res) { if (res instanceof NotFound notFound) return HttpError.of(404, notFound.message()); return res; } private boolean listTags(HttpExchange ex) throws IOException { var params = queryParam(ex); var infix = nullIfEmpty((String)params.get("infix")); if (infix == null) return sendContent(ex, HttpError.of(400,"No infix set in method call parameters")); var res = db.findTags(infix).map(ApiEndpoint::sortTags); return sendContent(ex, res); } public static Container parseDate(Object o) { if (!(o instanceof String s)) return null; var matcher = DATE_TIME.matcher(s); if (matcher.find()) { int year = Integer.parseInt(matcher.group(1)); int month = Integer.parseInt(matcher.group(2)); int day = nullable(matcher.group(4)).map(Integer::parseInt).orElse(1); int hour = nullable(matcher.group(6)).map(Integer::parseInt).orElse(0); int minute = nullable(matcher.group(7)).map(Integer::parseInt).orElse(0); int second = nullable(matcher.group(9)).map(Integer::parseInt).orElse(0); return Payload.of(LocalDateTime.of(year, month, day, hour, minute, second)); } return error("invalid date time format: %s", s); } public static Container parsePast(Object o) { var start = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0); if (!(o instanceof String s)) return Payload.of(start); var matcher = PAST_PATTERN.matcher(s); if (matcher.find()) { int num = nullable(matcher.group(1)).map(Integer::parseInt).orElse(0); var g2 = nullable(matcher.group(2)).orElse(""); switch (g2) { case "m": start = start.minusMonths(num); break; case "y": start = start.minusYears(num); break; } var all = matcher.group(3); if ("all".equals(all)) return null; return Payload.of(start); } return error("invalid past format: %s", s); } private static Container> sortTags(Container> listResult) { if (listResult.optional().isEmpty()) return listResult; List list = listResult.optional().get(); while (list.size() > 15) { int longest = list.stream().map(String::length).reduce(0, Integer::max); var subset = list.stream().filter(s -> s.length() < longest).toList(); if (subset.size() < 3) return Payload.of(list); list = subset; } return Payload.of(list); } private static boolean tagsMatch(Appointment event, List tags) { return new HashSet<>(event.tags().stream().map(String::toLowerCase).toList()).containsAll(tags); } private Container toEvent(JSONObject json) { var description = json.has(DESCRIPTION) ? nullIfEmpty(json.getString(DESCRIPTION)) : null; var title = json.has(TITLE) ? nullIfEmpty(json.getString(TITLE)) : null; if (title == null) return error("title missing"); var start = json.has(START) ? nullIfEmpty(json.getString(START)) : null; if (start == null) return error("start missing"); var startDate = nullable(start).map(dt -> LocalDateTime.parse(dt, ISO_DATE_TIME)).orElse(null); var end = json.has(END) ? nullIfEmpty(json.getString(END)) : null; var endDate = nullable(end).map(dt -> LocalDateTime.parse(dt, ISO_DATE_TIME)).orElse(null); var location = json.has(LOCATION) ? json.getString(LOCATION) : null; if (location == null) return error("location missing"); var aid = json.has(AID) ? json.getLong(AID) : 0; var event = new BaseAppointment(aid, title, description, startDate, endDate, location); if (json.has(ATTACHMENTS)) { json.getJSONArray(ATTACHMENTS).forEach(att -> { Payload // .of(att.toString()) .map(Util::url) .map(Util::toAttachment) .optional() .ifPresent(event::add); }); } if (json.has(COORDS)) extractCoords(json.getString(COORDS)).optional().ifPresent(event::coords); if (json.has(LINKS)) json.getJSONArray(LINKS).forEach(o -> { if (o instanceof JSONObject j) toLink(j).optional().ifPresent(event::addLinks); }); if (json.has(TAGS)) json.getJSONArray(TAGS).forEach(o -> event.tags(o.toString())); return Payload.of(event); } private static Container toIcal(Container res, String hostname, String urlTemplate) { var opt = res.optional(); return opt.isEmpty() ? transform(res) : Payload.of(opt.get().ical(hostname, urlTemplate)); } private static Container toIcalList(Container> res, String hostname, String urlTemplate) { var opt = res.optional(); if (opt.isEmpty()) return transform(res); var list = opt.get().stream().map(event -> event.ical(hostname, urlTemplate)).collect(Collectors.joining("\n")); return Payload.of(list); } private static Container toJson(Container res, String urlTemplate) { var opt = res.optional(); return opt.isEmpty() ? transform(res) : Payload.of(opt.get().json(urlTemplate)); } private static Container> toJsonList(Container> res, String urlTemplate) { var opt = res.optional(); if (opt.isEmpty()) return transform(res); var list = opt.get().stream().map(event -> event.json(urlTemplate)).toList(); return Payload.of(list); } protected static Container toLink(JSONObject json) { try { var description = json.getString(DESCRIPTION); return Payload.of(json.getString(URL)) .map(Util::url) .map(url -> BaseImporter.link(url, description)); } catch (Exception e) { return error(e, "Failed to create link from %s", json); } } private boolean unknownPath(HttpExchange ex, Path path) throws IOException { return sendContent(ex, HttpError.of(404, "%s is not known to this API", path)); } private Container updateEvent(HttpExchange ex) { try { var res = toEvent(json(ex)); var opt = res.optional(); if (opt.isEmpty()) return transform(res); var event = opt.get(); var title = getHeader(ex, TITLE); if (title.isEmpty()) return HttpError.of(400,"Missing title header"); if (!title.get().equals(event.title())) return HttpError.of(400,"Title mismatch!"); String hostname = hostname(ex); String urlTemplate = hostname+"/static/event?id={}"; return db.update(opt.get()).map(ev -> toJson(ev,urlTemplate)); } catch (IOException e) { return error(e, "Failed to read event data from request body"); } } }