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 @@
- Loading…
+
+ Loading…
Loading time…
Loading description…
Loading links…
diff --git a/de.srsoftware.cal.web/src/main/resources/occ.css b/de.srsoftware.cal.web/src/main/resources/occ.css
index 1fda2f2..62c75ed 100644
--- a/de.srsoftware.cal.web/src/main/resources/occ.css
+++ b/de.srsoftware.cal.web/src/main/resources/occ.css
@@ -159,4 +159,8 @@ table#eventlist{
}
.highlight{
background: wheat;
+}
+
+.event h1{
+ margin-top: 40px;
}
\ No newline at end of file
diff --git a/de.srsoftware.cal.web/src/main/resources/script/event.js b/de.srsoftware.cal.web/src/main/resources/script/event.js
index accd70d..8db7562 100644
--- a/de.srsoftware.cal.web/src/main/resources/script/event.js
+++ b/de.srsoftware.cal.web/src/main/resources/script/event.js
@@ -20,10 +20,10 @@ async function handleEventData(response){
if (response.ok){
var event = await response.json();
document.getElementsByTagName('h1')[0].innerHTML = event.title ? event.title : '';
- document.getElementById('time').innerHTML = event.start + (event.end ? '…'+event.end : '');
+ element('time').innerHTML = ''+event.start +''+ (event.end ? '…'+event.end : '') + ' @ '+event.location;
addDescription(event);
- document.getElementById('tags').innerHTML = "Tags: "+event.tags.join(" ");
- var links = document.getElementById('links');
+ element('tags').innerHTML = "Tags: "+event.tags.join(" ");
+ var links = element('links');
links.innerHTML = "";
links.appendChild(linkList(event.links));
attachmentList(event.attachments);
@@ -78,4 +78,15 @@ function confirmDelete(){
}
}).then(handleDeleted);
}
+}
+
+function ical(){
+ const urlParams = new URLSearchParams(window.location.search);
+ var id = urlParams.get('id');
+ if (id) {
+ var elem = create('a');
+ elem.setAttribute('href',location.href.replace('/static/event','/api/event/ical'));
+ elem.setAttribute('download',`event-${id}.ics`);
+ elem.click();
+ }
}
\ No newline at end of file
diff --git a/de.srsoftware.cal.web/src/main/resources/script/index.js b/de.srsoftware.cal.web/src/main/resources/script/index.js
index fb46b76..e230008 100644
--- a/de.srsoftware.cal.web/src/main/resources/script/index.js
+++ b/de.srsoftware.cal.web/src/main/resources/script/index.js
@@ -115,19 +115,21 @@ function loadCurrentEvents(){
function openIcal(){
var url = location.href;
var query = location.search;
+ var params = new URLSearchParams(query);
+ var tags = params.get('tags');
var pos = url.indexOf('?');
if (pos>1) url = url.substring(0,pos);
if (!url.endsWith('/')) url += '/';
url += 'api/events/ical'+query;
var elem = create('a');
elem.setAttribute('href',url);
- elem.setAttribute('download','calendar.ical');
+ elem.setAttribute('download',(tags?tags.replace(',','+'):'calendar')+'.ics');
elem.click();
}
// shows the given url in an overlayed iframe
function showOverlay(url){
- var div = document.getElementById('overlay');
+ var div = element('overlay');
div.innerHTML = '';
var iframe = document.createElement('iframe');
iframe.src = url;
diff --git a/doc/openapi3_0.yaml b/doc/openapi3_0.yaml
index 1bcd0b2..c6509b3 100644
--- a/doc/openapi3_0.yaml
+++ b/doc/openapi3_0.yaml
@@ -119,6 +119,42 @@ paths:
description: server fault
summary:
store a new event in the database
+ /api/event/ical:
+ get:
+ parameters:
+ - description: the appointment id
+ in: query
+ name: id
+ required: true
+ schema:
+ example: 42
+ format: int64
+ type: number
+ responses:
+ '200':
+ description: successful operation
+ content:
+ text/calendar:
+ schema:
+ description: event in ical format
+ example: |-
+ BEGIN:VCALENDAR
+ VERSION:2.0
+ PRODID:OpenCloudCal
+ BEGIN:VEVENT
+ UID:42@cal.srsoftware.de
+ DTSTART:20241230T100757Z
+ DTEND:20241231T100757Z
+ SUMMARY:Demolition of the earth
+ END:VEVENT
+ END:VCALENDAR
+ type: string
+ '400':
+ description: invalid input
+ '500':
+ description: server fault
+ summary: get an event as ical file
+
/api/events/ical:
get:
description: |-