diff --git a/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Appointment.java b/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Appointment.java index 424078b..37be552 100644 --- a/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Appointment.java +++ b/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Appointment.java @@ -38,9 +38,10 @@ public interface Appointment { /** * represent this event as ical entry + * @param calendarIdentifier used to generate a unique id. pass something that is unique, but stable over time, e.g. a hostname * @return the ical string */ - String ical(); + String ical(String calendarIdentifier); /** * ID of the appointment – unique within this system diff --git a/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Coords.java b/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Coords.java index 109f511..5b8cc01 100644 --- a/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Coords.java +++ b/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Coords.java @@ -10,6 +10,18 @@ import org.json.JSONObject; * @param latitude the latitude */ public record Coords(double longitude, double latitude) { + /** + * get a string representing this coords in the ICAL format, see iCalendar spec + * @return the formatted coords + */ + public String icalFormat(){ + return "%s;%s".formatted(latitude,longitude); + } + + /** + * create a JSON object containing lat and lon of this coordinate + * @return the json object + */ public JSONObject json() { return new JSONObject(Map.of("lon", longitude, "lat", latitude)); } diff --git a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseAppointment.java b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseAppointment.java index 602626b..62cb6de 100644 --- a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseAppointment.java +++ b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseAppointment.java @@ -1,6 +1,7 @@ /* © SRSoftware 2024 */ package de.srsoftware.cal; +import static de.srsoftware.cal.Util.*; import static de.srsoftware.tools.Optionals.nullable; import de.srsoftware.cal.api.Appointment; @@ -124,10 +125,24 @@ public class BaseAppointment implements Appointment { } @Override - public String ical() { // TODO: implement - return "converting event (%s) to ical not implemented".formatted(title); + public String ical(String calendarIdentifier) { // TODO: implement + var sb = new StringBuilder(); + sb.append(contentLine(BEGIN,VEVENT)); + if (calendarIdentifier != null) sb.append(contentLine(UID,"%s@%s".formatted(id(),calendarIdentifier))); + sb.append(contentLine(DTSTART,start().format(ICAL_DATE_FORMAT).replace(" ","T"))); + end().map(end -> contentLine(DTEND,end.format(ICAL_DATE_FORMAT).replace(" ","T"))).ifPresent(sb::append); + sb.append(contentLine(SUMMARY,title())); + sb.append(contentLine(DESCRIPTION,description())); + coords().map(Coords::icalFormat).map(geo -> contentLine(GEO,geo)).ifPresent(sb::append); + if (!location().isBlank()) sb.append(contentLine(LOCATION,location())); + for (var attachment : attachments()) sb.append(contentLine("ATTACH;FMTYPE=%s".formatted(attachment.mime()),attachment.url().toString())); + for (var link : links) sb.append(contentLine("ATTACH;TITLE="+paramText(link.desciption()),link.url().toString())); + sb.append(contentLine("CLASS","PUBLIC")); + sb.append(contentLine(END,VEVENT)); + return sb.toString(); } + @Override public long id() { return id; diff --git a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/Util.java b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/Util.java index abd7aea..3ec9976 100644 --- a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/Util.java +++ b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/Util.java @@ -6,8 +6,51 @@ import static de.srsoftware.tools.Error.error; import de.srsoftware.cal.api.Coords; import de.srsoftware.tools.Payload; import de.srsoftware.tools.Result; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; public class Util { + public static final String BEGIN = "BEGIN"; + public static final String END = "END"; + public static final String DESCRIPTION = "DESCRIPTION"; + public static final String DTEND = "DTEND"; + public static final String DTSTAMP = "DTSTAMP"; + public static final String DTSTART = "DTSTART"; + public static final String GEO = "GEO"; + public static final DateTimeFormatter ICAL_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd HHmmss"); + public static final String LOCATION = "LOCATION"; + public static final String PRODID = "PRODID"; + public static final String SUMMARY = "SUMMARY"; + public static final String UID = "UID"; + public static final String VERSION = "VERSION"; + public static final String VEVENT = "VEVENT"; + public static final String VCALENDAR = "VCALENDAR"; + + private Util(){} + + /** + * formats a content line as defined in iCalendar spec + * @param key the content line key + * @param value the content line value + * @return content line formatted as described in the spec + */ + public static String contentLine(String key, String value){ + var contentLine = "%s:%s".formatted(key,value).trim(); + // escape line breaks + contentLine = contentLine.replace("\\n","\\\\n").replace("\r\n","\\n").replace("\r","\\n").replace("\n","\\n"); + var lines = new ArrayList(); + while (contentLine.length()>70){ + var pos = contentLine.lastIndexOf(" ",70); + if (pos < 10) pos = 70; + var dummy = contentLine.substring(0,pos); + lines.add(dummy); + contentLine = '\t'+contentLine.substring(pos); + } + lines.add(contentLine); + lines.add(""); + return String.join("\r\n",lines); + } + public static Result extractCoords(String coords) { if (coords == null) return error("Argument is null"); if (coords.isBlank()) return error("Argument is blank"); @@ -21,4 +64,35 @@ public class Util { return error(nfe, "Failed to parse coords from %s", coords); } } + + + + public static String paramText(String param) { + return param + .replace("\n","\\n") + .replace("\"","''") + .replace(";","/") + .replace(",","/") + .replace(":","/"); + } + + + /** + * wraps a text (list of vevents in a vcalendar, as described in th iCalendar spec + * @param ical the vevents list + * @param prodId the producer id of this icalendar + * @return the completed ical string + */ + public static Result wrapIcal(Result ical, String prodId) { + if (ical instanceof Payload payload){ + var calendar = new StringBuilder(); + calendar.append(contentLine(BEGIN,VCALENDAR)); + calendar.append(contentLine(VERSION,"2.0")); + calendar.append(contentLine(PRODID,prodId)); + calendar.append(payload.get()); + calendar.append(contentLine(END,VCALENDAR)); + return Payload.of(calendar.toString()); + } + return ical; + } } diff --git a/de.srsoftware.cal.base/src/test/java/de/srsoftware/cal/UtilTest.java b/de.srsoftware.cal.base/src/test/java/de/srsoftware/cal/UtilTest.java new file mode 100644 index 0000000..5b8bbac --- /dev/null +++ b/de.srsoftware.cal.base/src/test/java/de/srsoftware/cal/UtilTest.java @@ -0,0 +1,25 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.cal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class UtilTest { + + @Test + public void testContentLine(){ + var longText = """ +This text block, spanning several long lines, should be distributed over several lines, when passing it to the contentLine function. +Let`s see what happens… New line breaks should be introduced at roughly every 70 characters, +while the existing line breaks should be converted to '\n' escapes."""; + var expected = """ +Test:This text block, spanning several long lines, should be\r +\t distributed over several lines, when passing it to the contentLine\r +\t function.\\nLet`s see what happens… New line breaks should be\r +\t introduced at roughly every 70 characters,\\nwhile the existing line\r +\t breaks should be converted to '\\n' escapes.\r\n"""; + var result = Util.contentLine("Test",longText); + assertEquals(expected,result); + } +} diff --git a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java index dcd4592..4df565c 100644 --- a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java +++ b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java @@ -33,7 +33,7 @@ public class MariaDB implements Database { private static final String URLS = "urls"; private static final String APPOINTMENT_ATTACHMENTS = "appointment_attachments"; private static final String TAGS = "tags"; - private static Connection connection; + private Connection connection; private MariaDB(Connection conn) throws SQLException { connection = conn; @@ -180,7 +180,7 @@ public class MariaDB implements Database { public Result loadEvent(long id) { try { var rs = select(ALL).from(APPOINTMENTS).where(AID, equal(id)).exec(connection); - Result result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : NotFound.of("Failed to find appointment with id %s", id); + Result result = rs.next() ? createAppointmentOf(rs).map(this::loadExtra) : NotFound.of("Failed to find appointment with id %s", id); rs.close(); return result; } catch (SQLException e) { @@ -192,7 +192,7 @@ public class MariaDB implements Database { public Result loadEvent(String location, LocalDateTime start) { try { var rs = select(ALL).from(APPOINTMENTS).where(LOCATION, equal(location)).where(START, equal(Timestamp.valueOf(start))).exec(connection); - Result result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : error("Failed to find appointment starting %s @ %s", start, location); + Result result = rs.next() ? createAppointmentOf(rs).map(this::loadExtra) : error("Failed to find appointment starting %s @ %s", start, location); rs.close(); return result; } catch (SQLException e) { @@ -200,11 +200,11 @@ public class MariaDB implements Database { } } - private static Result loadExtra(Result res) { - return loadTags(res).map(MariaDB::loadLinks).map(MariaDB::loadAttachments); + private Result loadExtra(Result res) { + return loadTags(res).map(this::loadLinks).map(this::loadAttachments); } - private static Result loadTags(Result res) { + private Result loadTags(Result res) { if (res.optional().isEmpty()) return transform(res); BaseAppointment event = res.optional().get(); var id = event.id(); @@ -218,7 +218,7 @@ public class MariaDB implements Database { } } - private static Result loadLinks(Result res) { + private Result loadLinks(Result res) { if (res.optional().isEmpty()) return transform(res); BaseAppointment event = res.optional().get(); var id = event.id(); @@ -241,7 +241,7 @@ public class MariaDB implements Database { } } - private static Result loadAttachments(Result res) { + private Result loadAttachments(Result res) { if (res.optional().isEmpty()) return transform(res); BaseAppointment event = res.optional().get(); var id = event.id(); diff --git a/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java b/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java index 80aa0a3..deb0384 100644 --- a/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java +++ b/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java @@ -91,16 +91,21 @@ public class ApiEndpoint extends PathHandler { @Override public boolean doGet(String path, HttpExchange ex) throws IOException { + String hostname = "TODO"; // TODO + String prodId = "TODO"; return switch (path) { case "/event" -> sendContent(ex,getEvent(ex).map(ApiEndpoint::toJson).map(ApiEndpoint::httpError)); - case "/events/ical"-> sendContent(ex,eventList(ex).map(ApiEndpoint::toIcal).map(ApiEndpoint::httpError)); + case "/event/ical"-> sendContent(ex,getEvent(ex).map(event -> toIcal(event,hostname)).map(ical -> Util.wrapIcal(ical,prodId)).map(ApiEndpoint::httpError)); + case "/events/ical"-> sendContent(ex,eventList(ex).map(list -> toIcalList(list,hostname)).map(ical -> Util.wrapIcal(ical,prodId)).map(ApiEndpoint::httpError)); case "/events/json" -> sendContent(ex,eventList(ex).map(ApiEndpoint::toJsonList).map(ApiEndpoint::httpError)); case "/tags" -> listTags(ex); default -> unknownPath(ex, path); }; } - @Override + + + @Override public boolean doPatch(String path, HttpExchange ex) throws IOException { if ("/event".equals(path)) return sendContent(ex, updateEvent(ex)); return unknownPath(ex, path); @@ -260,10 +265,15 @@ public class ApiEndpoint extends PathHandler { return Payload.of(event); } - private static Result toIcal(Result> res) { + private static Result toIcal(Result res, String hostname) { + var opt = res.optional(); + return opt.isEmpty() ? transform(res) : Payload.of(opt.get().ical(hostname)); + } + + private static Result toIcalList(Result> res, String hostname) { var opt = res.optional(); if (opt.isEmpty()) return transform(res); - var list = opt.get().stream().map(Appointment::ical).collect(Collectors.joining("\n")); + var list = opt.get().stream().map(event -> event.ical(hostname)).collect(Collectors.joining("\n")); return Payload.of(list); } diff --git a/de.srsoftware.cal.web/src/main/resources/event.html b/de.srsoftware.cal.web/src/main/resources/event.html index 07728c9..5b9bc3d 100644 --- a/de.srsoftware.cal.web/src/main/resources/event.html +++ b/de.srsoftware.cal.web/src/main/resources/event.html @@ -10,12 +10,13 @@