From 2491e4fbf036dcd6745a69da3aa997c79c73bd2c Mon Sep 17 00:00:00 2001
From: Stephan Richter <s.richter@srsoftware.de>
Date: Thu, 2 Jan 2025 12:28:14 +0100
Subject: [PATCH] added importer for FHaus and Psychochor

Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
---
 de.srsoftware.cal.app/build.gradle.kts        |   2 +-
 de.srsoftware.cal.base/build.gradle.kts       |   2 +-
 .../java/de/srsoftware/cal/BaseImporter.java  |  17 +-
 .../de/srsoftware/cal/SinglePageImporter.java |  47 +++++
 .../src/main/java/de/srsoftware/cal/Util.java |   4 +-
 de.srsoftware.cal.importer/build.gradle.kts   |   2 +-
 .../cal/importer/jena/CosmicDawn.java         |   7 -
 .../srsoftware/cal/importer/jena/FHaus.java   | 170 +++++++++++++++++
 .../cal/importer/jena/Psychochor.java         | 180 ++++++++++++++++++
 .../cal/importer/jena/Rosenkeller.java        |  14 +-
 10 files changed, 421 insertions(+), 24 deletions(-)
 create mode 100644 de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/SinglePageImporter.java
 create mode 100644 de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/FHaus.java
 create mode 100644 de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Psychochor.java

diff --git a/de.srsoftware.cal.app/build.gradle.kts b/de.srsoftware.cal.app/build.gradle.kts
index 1f3cdc7..989a252 100644
--- a/de.srsoftware.cal.app/build.gradle.kts
+++ b/de.srsoftware.cal.app/build.gradle.kts
@@ -14,6 +14,6 @@ dependencies {
     implementation("de.srsoftware:tools.logging:1.0.3")
     implementation("de.srsoftware:tools.plugin:1.0.1")
     implementation("de.srsoftware:tools.util:1.3.0")
-    implementation("de.srsoftware:tools.web:1.3.11")
+    implementation("de.srsoftware:tools.web:1.3.12")
     implementation("com.mysql:mysql-connector-j:9.1.0")
 }
diff --git a/de.srsoftware.cal.base/build.gradle.kts b/de.srsoftware.cal.base/build.gradle.kts
index 2fda983..1502bf1 100644
--- a/de.srsoftware.cal.base/build.gradle.kts
+++ b/de.srsoftware.cal.base/build.gradle.kts
@@ -5,6 +5,6 @@ dependencies {
 
     implementation("de.srsoftware:tools.optionals:1.0.0")
     implementation("de.srsoftware:tools.util:1.3.0")
-    implementation("de.srsoftware:tools.web:1.3.11")
+    implementation("de.srsoftware:tools.web:1.3.12")
     implementation("org.json:json:20240303")
 }
diff --git a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseImporter.java b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseImporter.java
index b1749c5..afae574 100644
--- a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseImporter.java
+++ b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseImporter.java
@@ -13,6 +13,8 @@ import de.srsoftware.tools.*;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.time.LocalDate;
@@ -25,10 +27,11 @@ import java.util.function.Predicate;
 import java.util.stream.Stream;
 
 public abstract class BaseImporter implements Importer {
+	public static final System.Logger LOG = System.getLogger(BaseImporter.class.getSimpleName());
 	private static final String	   SHA256 = "SHA-256";
 	private final MessageDigest	   digest;
 
-	protected BaseImporter() throws NoSuchAlgorithmException {
+	public BaseImporter() throws NoSuchAlgorithmException {
 		digest = MessageDigest.getInstance(SHA256);
 	}
 
@@ -66,7 +69,7 @@ public abstract class BaseImporter implements Importer {
 		Result<Tag> descriptionTag = extractDescriptionTag(eventTag);
 		if (descriptionTag.optional().isEmpty()) return transform(descriptionTag);
 		Tag tag = descriptionTag.optional().get();
-		tag.find(t -> t.is("iframe")).forEach(Tag::remove);
+		tag.find(ofType("iframe")).forEach(Tag::remove);
 		var inner = tag.inner(2);
 		return inner.isPresent() ? Payload.of(inner.get()) : error("No description found");
 	}
@@ -126,6 +129,9 @@ public abstract class BaseImporter implements Importer {
 
 		long id = 0;
 
+		// wird vor extractDescription ausgeführt, da extractDescription das DOM verändert
+		var coords = extractCoords(eventTag);
+
 		var titleResult = extractTitle(eventTag);
 		if (titleResult.optional().isEmpty()) return transform(titleResult);
 		String title = titleResult.optional().get();
@@ -151,7 +157,7 @@ public abstract class BaseImporter implements Importer {
 			.addLinks(eventPage)
 			.tags(extractTags(eventTag));
 
-		extractCoords(eventTag).optional().ifPresent(event::coords);
+		coords.optional().ifPresent(event::coords);
 
 		return Payload.of(event);
 	}
@@ -205,7 +211,7 @@ public abstract class BaseImporter implements Importer {
 	protected Result<String> extractLocation(Tag eventTag) {
         Result<Tag> locationTag = extractLocationTag(eventTag);
         if (locationTag.optional().isEmpty()) return transform(locationTag);
-        return Payload.of(locationTag.optional().get().toString(2));
+        return Payload.of(locationTag.optional().get().strip());
     }
 
     protected Result<Tag> extractLocationTag(Tag eventTag){
@@ -257,7 +263,7 @@ public abstract class BaseImporter implements Importer {
 	protected abstract List<String> extractTags(Tag eventTag);
 
     protected Result<String> extractTitle(Tag eventTag) {
-        Result<Tag> titleTag = extractTitleTag(eventTag);
+		Result<Tag> titleTag = extractTitleTag(eventTag);
         if (titleTag.optional().isEmpty()) return transform(titleTag);
         var inner = titleTag.optional().flatMap(tag -> tag.inner(2));
         return inner.isPresent() ? Payload.of(inner.get().trim()) : error("No title found");
@@ -283,6 +289,7 @@ public abstract class BaseImporter implements Importer {
 		return urls  //
 			.map(Util::url)
 			.map(this::loadEvent)
+			.peek(res ->  { if (res.optional().isEmpty()) LOG.log(System.Logger.Level.WARNING,res); })
 			.flatMap(result -> result.optional().stream());
 	}
 
diff --git a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/SinglePageImporter.java b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/SinglePageImporter.java
new file mode 100644
index 0000000..3329c04
--- /dev/null
+++ b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/SinglePageImporter.java
@@ -0,0 +1,47 @@
+/* © SRSoftware 2024 */
+package de.srsoftware.cal;
+
+import static de.srsoftware.cal.Util.url;
+
+import de.srsoftware.cal.api.Appointment;
+import de.srsoftware.cal.api.Link;
+import de.srsoftware.tools.Payload;
+import de.srsoftware.tools.Result;
+import de.srsoftware.tools.Tag;
+import java.security.NoSuchAlgorithmException;
+import java.util.List;
+import java.util.stream.Stream;
+
+public abstract class SinglePageImporter extends BaseImporter{
+
+	public SinglePageImporter() throws NoSuchAlgorithmException {
+		super();
+	}
+
+	@Override
+	public Stream<Appointment> fetch() {
+		var	       programPage    = Payload.of(programURL());
+		var eventLink = programPage  //
+				.map(Util::url).optional()
+				.map(url -> new Link(url, "Event-Seite"))
+				.orElse(null);
+
+		Stream<Result<Tag>> eventTags = url(programPage).map(this::open).map(this::preload)
+				.map(this::parseXML)
+				.map(this::extractEventTags)
+				.stream();
+
+		return eventTags.map(tagResult -> extractEvent(tagResult, eventLink))
+				.peek(res -> {
+					if (res.optional().isEmpty()) LOG.log(System.Logger.Level.WARNING, res);
+				})
+				.flatMap(res -> res.optional().stream());
+	}
+
+	protected abstract Result<List<Tag>> extractEventTags(Result<Tag> tagResult);
+
+	@Override
+	protected Result<List<String>> extractEventUrls(Result<Tag> programPage) {
+		return null; // not needed by this fetch implementation
+	}
+}
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 e28c04a..cce6833 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
@@ -11,9 +11,7 @@ import de.srsoftware.cal.api.Coords;
 import de.srsoftware.tools.Payload;
 import de.srsoftware.tools.Result;
 import de.srsoftware.tools.Tag;
-import java.net.MalformedURLException;
 import java.net.URI;
-import java.net.URISyntaxException;
 import java.net.URL;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
@@ -190,7 +188,7 @@ public class Util {
 		var url = urlResult.optional().get();
 		try {
 			return Payload.of(new URI(url).toURL());
-		} catch (MalformedURLException | URISyntaxException e) {
+		} catch (Exception e) {
 			return error(e, "Failed to create URL of %s", url);
 		}
 	}
diff --git a/de.srsoftware.cal.importer/build.gradle.kts b/de.srsoftware.cal.importer/build.gradle.kts
index e3684ff..299ee9a 100644
--- a/de.srsoftware.cal.importer/build.gradle.kts
+++ b/de.srsoftware.cal.importer/build.gradle.kts
@@ -5,5 +5,5 @@ dependencies {
     implementation(project(":de.srsoftware.cal.base"))
     implementation("de.srsoftware:tools.optionals:1.0.0")
     implementation("de.srsoftware:tools.util:1.3.0")
-    implementation("de.srsoftware:tools.web:1.3.11")
+    implementation("de.srsoftware:tools.web:1.3.12")
 }
diff --git a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/CosmicDawn.java b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/CosmicDawn.java
index b7a219a..21b6a27 100644
--- a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/CosmicDawn.java
+++ b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/CosmicDawn.java
@@ -16,8 +16,6 @@ import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.security.NoSuchAlgorithmException;
 import java.time.LocalDate;
 import java.time.LocalTime;
@@ -85,11 +83,6 @@ private static final String DEFAULT_LOCATION = "Cosmic Dawn e.V., Spitzweidenweg
 	protected Result<List<String>> extractEventUrls(Result<Tag> programPage) {
 		var page = programPage.optional();
 		if (page.isEmpty()) return transform(programPage);
-		try {
-			Files.writeString(Path.of("/tmp/test.txt"),page.get().toString(2));
-		} catch (IOException e) {
-			throw new RuntimeException(e);
-		}
 		var list = page.get().find(attributeEquals("class","event_listings_main"));
 		var urlList = list.stream()
 				.flatMap(tag -> tag.find(IS_ANCHOR).stream())
diff --git a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/FHaus.java b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/FHaus.java
new file mode 100644
index 0000000..41f8c87
--- /dev/null
+++ b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/FHaus.java
@@ -0,0 +1,170 @@
+/* © SRSoftware 2024 */
+package de.srsoftware.cal.importer.jena;
+
+import static de.srsoftware.cal.Util.parseGermanTime;
+import static de.srsoftware.tools.Error.error;
+import static de.srsoftware.tools.Result.transform;
+import static de.srsoftware.tools.Tag.*;
+import static de.srsoftware.tools.TagFilter.*;
+
+import de.srsoftware.cal.BaseImporter;
+import de.srsoftware.cal.api.Coords;
+import de.srsoftware.tools.Payload;
+import de.srsoftware.tools.Result;
+import de.srsoftware.tools.Tag;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Predicate;
+
+public class FHaus extends BaseImporter {
+
+	// TODO: verwendet den gleichen Typ Kalender wie der Rosenkeller – evtl. kann man diese beiden in einer Abstrakten Superklasse zusammenführen
+
+	public static final Coords DEFAULT_COORDS = new Coords(50.9293, 11.58228);
+	public static final String DEFAULT_LOCATION = "F-Haus, Krautgasse 14, 07743 Jena";
+	public FHaus() throws NoSuchAlgorithmException {
+		super();
+	}
+
+	@Override
+	protected String baseUrl() {
+		return "https://www.f-haus.de";
+	}
+
+	@Override
+	public String description() {
+		return "Importer für Events des Jenaer F-Haus";
+	}
+
+	@Override
+	protected Predicate<Tag> extractAttachmentsFilter() {
+		return attributeEquals(ID,"tribe-events-content");
+	}
+
+	@Override
+	protected Predicate<Tag> extractDescriptionFilter() {
+		return attributeContains(CLASS,"single-event-description");
+	}
+
+	@Override
+	protected Result<Coords> extractCoords(Tag eventTag) {
+		return Payload.of(DEFAULT_COORDS);
+	}
+
+	@Override
+	protected Predicate<Tag> extractEndDateFilter() {
+		return attributeHas(CLASS,"tribe-events-abbr").and(attributeContains(CLASS,"tribe-events-end").and(attributeContains(CLASS,"date")));
+	}
+
+	@Override
+	protected Predicate<Tag> extractEndTimeFilter() {
+		return attributeHas(CLASS,"tribe-events-abbr").and(attributeContains(CLASS,"tribe-events-end").and(attributeContains(CLASS,"time")));
+	}
+
+	@Override
+	protected Predicate<Tag> extractEventTagFilter() {
+		return attributeEquals(ID,"tribe-events-content");
+	}
+
+	@Override
+	protected Result<List<String>> extractEventUrls(Result<Tag> programPage) {
+		var opt = programPage.optional();
+		if (opt.isEmpty()) return transform(programPage);
+		List<String> urls = opt.get().find(attributeEquals(CLASS,"tribe-events-calendar-list"))
+				.stream().flatMap(tag -> tag.find(IS_ANCHOR).stream())
+				.map(tag -> tag.get(HREF))
+				.filter(Objects::nonNull)
+				.filter(url -> url.contains("/event/"))
+				.map(url -> url.contains("://") ? url : baseUrl()+url)
+				.distinct()
+				.toList();
+		return Payload.of(urls);
+	}
+
+	@Override
+	protected Predicate<Tag> extractLinksFilter() {
+		return attributeContains(CLASS,"single-event-description");
+	}
+
+	@Override
+	protected Result<String> extractLocation(Tag eventTag) {
+		return Payload.of(DEFAULT_LOCATION);
+	}
+
+	@Override
+	protected Predicate<Tag> extractLocationFilter() {
+		return null;
+	}
+
+	protected Result<LocalDate> extractStartDate(Tag eventTag) {
+		Result<Tag> startDateTag = extractStartDateTag(eventTag);
+		var opt = startDateTag.optional();
+		if (opt.isEmpty()) return transform(startDateTag);
+		return parseStartDate(opt.get().get(TITLE));
+	}
+
+	protected Result<Tag> extractStartDateTag(Tag eventTag) {
+		var list = eventTag.find(extractStartDateFilter());
+		if (list.isEmpty()) {
+			return error("Failed to find start date tag");
+		}
+		return Payload.of(list.getFirst());
+	}
+
+	@Override
+	protected Predicate<Tag> extractStartDateFilter() {
+		return attributeHas(CLASS,"tribe-events-abbr").and(attributeContains(CLASS,"tribe-events-start").and(attributeContains(CLASS,"date")));
+	}
+
+	@Override
+	protected Predicate<Tag> extractStartTimeFilter() {
+		return attributeHas(CLASS,"tribe-events-abbr").and(attributeContains(CLASS,"tribe-events-start").and(attributeContains(CLASS,"time")));
+	}
+
+	@Override
+	protected List<String> extractTags(Tag eventTag) {
+		return List.of("F-Haus", "Jena");
+	}
+
+	@Override
+	protected Predicate<Tag> extractTitleFilter() {
+		return ofType("h1");
+	}
+
+	@Override
+	protected Result<LocalDate> parseEndDate(String string) {
+		try {
+			return Payload.of(LocalDate.parse(string, DateTimeFormatter.ISO_DATE));
+		} catch (Exception e){
+			return error(e,"Failed to parse date: %s",string);
+		}
+	}
+
+	@Override
+	protected Result<LocalTime> parseEndTime(String string) {
+		return parseGermanTime(string);
+	}
+
+	@Override
+	protected Result<LocalDate> parseStartDate(String string) {
+		try {
+			return Payload.of(LocalDate.parse(string, DateTimeFormatter.ISO_DATE));
+		} catch (Exception e){
+			return error(e,"Failed to parse date: %s",string);
+		}
+	}
+
+	@Override
+	protected Result<LocalTime> parseStartTime(String string) {
+		return parseGermanTime(string);
+	}
+
+	@Override
+	protected String programURL() {
+		return baseUrl()+"/cms/events";
+	}
+}
diff --git a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Psychochor.java b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Psychochor.java
new file mode 100644
index 0000000..eb10d2b
--- /dev/null
+++ b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Psychochor.java
@@ -0,0 +1,180 @@
+/* © SRSoftware 2024 */
+package de.srsoftware.cal.importer.jena;
+
+import static de.srsoftware.tools.Error.error;
+import static de.srsoftware.tools.Result.transform;
+import static de.srsoftware.tools.Tag.CLASS;
+import static de.srsoftware.tools.Tag.ID;
+import static de.srsoftware.tools.TagFilter.*;
+
+import de.srsoftware.cal.SinglePageImporter;
+import de.srsoftware.cal.Util;
+import de.srsoftware.cal.api.Coords;
+import de.srsoftware.tools.Payload;
+import de.srsoftware.tools.Result;
+import de.srsoftware.tools.Tag;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+public class Psychochor extends SinglePageImporter {
+
+	private static final Pattern DATE_TIME_PATTERN = Pattern.compile("(\\d{4}).*(\\d\\d+)\\W*([a-zA-Z]+)\\W*(\\d\\d?):(\\d\\d?)");
+	private static final Pattern LATITUDE = Pattern.compile("!3d(-?\\d+\\.\\d{1,5})");
+	private static final Pattern LONGITUDE = Pattern.compile("!2d(-?\\d+\\.\\d{1,5})");
+
+	public Psychochor() throws NoSuchAlgorithmException {
+		super();
+	}
+
+	@Override
+	protected String baseUrl() {
+		return "https://www.psycho-chor.de";
+	}
+
+	@Override
+	public String description() {
+		return "Importer für Events des Jenaer Psychochors";
+	}
+
+	@Override
+	protected Predicate<Tag> extractAttachmentsFilter() {
+		return attributeEquals("itemprop","description");
+	}
+
+	@Override
+	protected Predicate<Tag> extractDescriptionFilter() {
+		return attributeEquals("itemprop","description");
+	}
+
+	@Override
+	protected Result<Coords> extractCoords(Tag eventTag) {
+		var list = eventTag.find(ofType("iframe"));
+		if (list.isEmpty()) return error("No iframe found");
+		return list.stream().map(iframe -> iframe.get("data-src-cmplz"))
+				.filter(Objects::nonNull)
+				.map(this::parseCoords).findAny()
+				.orElseGet(() -> error("No coordinates found!"));
+	}
+
+	private Result<Coords> parseCoords(String s) {
+		var latitude = LATITUDE.matcher(s);
+		var longitude = LONGITUDE.matcher(s);
+		if (latitude.find() && longitude.find()){
+			double lat = Double.parseDouble(latitude.group(1));
+			double lon = Double.parseDouble(longitude.group(1));
+			return Payload.of(new Coords(lat,lon));
+		}
+		return error("Failed to parse coords from %s",s);
+	}
+
+	@Override
+	protected Predicate<Tag> extractEndDateFilter() {
+		return null;
+	}
+
+	@Override
+	protected Predicate<Tag> extractEndTimeFilter() {
+		return null;
+	}
+
+	@Override
+	protected Predicate<Tag> extractEventTagFilter() {
+		return null;
+	}
+
+	@Override
+	protected Result<List<Tag>> extractEventTags(Result<Tag> tagResult) {
+		var opt = tagResult.optional();
+		if (opt.isEmpty()) return transform(tagResult);
+
+		List<Tag> eventTags = opt.get().find(attributeEquals(ID, "evcal_list")).stream()
+				.flatMap(tag -> tag.find(attributeStartsWith(ID, "event_")).stream())
+				.toList();
+		return Payload.of(eventTags);
+	}
+
+	@Override
+	protected Predicate<Tag> extractLinksFilter() {
+		return attributeEquals("itemprop","description");
+	}
+
+	@Override
+	protected Predicate<Tag> extractLocationFilter() {
+		return attributeContains(CLASS,"evcal_location");
+	}
+
+	@Override
+	protected Predicate<Tag> extractStartDateFilter() {
+		return attributeContains(CLASS,"evo_start");
+	}
+
+	@Override
+	protected Predicate<Tag> extractStartTimeFilter() {
+		return attributeContains(CLASS,"evo_start");
+	}
+
+	@Override
+	protected List<String> extractTags(Tag eventTag) {
+		var eventTags = new ArrayList<String>();
+		eventTags.add("Psychochor");
+		eventTags.add("Jena");
+		eventTag.find(attributeEquals("data-filter","event_type")).stream().map(Tag::strip).forEach(eventTags::add);
+		return eventTags;
+	}
+
+	@Override
+	protected Predicate<Tag> extractTitleFilter() {
+		return attributeHas(CLASS,"evcal_event_title");
+	}
+
+	@Override
+	protected Result<LocalDate> parseEndDate(String string) {
+		return null;
+	}
+
+	@Override
+	protected Result<LocalTime> parseEndTime(String string) {
+		return null;
+	}
+
+	@Override
+	protected Result<LocalDate> parseStartDate(String string) {
+		var matcher = DATE_TIME_PATTERN.matcher(string);
+		if (matcher.find()){
+			int year = Integer.parseInt(matcher.group(1));
+			var res = Util.toNumericMonth(matcher.group(3));
+			if (res.optional().isEmpty()) return transform(res);
+			int month = res.optional().get();
+			int day = Integer.parseInt(matcher.group(2));
+			return Payload.of(LocalDate.of(year,month,day));
+		}
+		return error("Failed to parse date from %s",string);
+	}
+
+	@Override
+	protected Result<LocalTime> parseStartTime(String string) {
+		var matcher = DATE_TIME_PATTERN.matcher(string);
+		if (matcher.find()){
+			int hour = Integer.parseInt(matcher.group(4));
+			int min = Integer.parseInt(matcher.group(5));
+			return Payload.of(LocalTime.of(hour,min));
+		}
+		return error("Failed to parse date from %s",string);
+	}
+
+	@Override
+	protected String programURL() {
+		return baseUrl()+"/de/events";
+	}
+}
diff --git a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Rosenkeller.java b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Rosenkeller.java
index 1bed557..136bca7 100644
--- a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Rosenkeller.java
+++ b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Rosenkeller.java
@@ -1,18 +1,17 @@
 /* © SRSoftware 2024 */
 package de.srsoftware.cal.importer.jena;
 
+import static de.srsoftware.cal.Util.parseGermanTime;
 import static de.srsoftware.tools.Error.error;
 import static de.srsoftware.tools.Result.transform;
 import static de.srsoftware.tools.Tag.*;
 import static de.srsoftware.tools.TagFilter.*;
 
 import de.srsoftware.cal.BaseImporter;
-import de.srsoftware.cal.Util;
 import de.srsoftware.cal.api.Coords;
 import de.srsoftware.tools.Payload;
 import de.srsoftware.tools.Result;
 import de.srsoftware.tools.Tag;
-import de.srsoftware.tools.TagFilter;
 import java.security.NoSuchAlgorithmException;
 import java.time.LocalDate;
 import java.time.LocalTime;
@@ -21,6 +20,9 @@ import java.util.List;
 import java.util.function.Predicate;
 
 public class Rosenkeller extends BaseImporter {
+
+	// TODO: verwendet den gleichen Typ Kalender wie das F-Haus – evtl. kann man diese beiden in einer Abstrakten Superklasse zusammenführen
+
 	private static final String  BASE_URL	= "https://rosenkeller.org";
 	private static final String  DEFAULT_LOCATION	= "Rosenkeller, Johannisstr. 13, 07743 Jena";
 	private static final Coords COORDS = new Coords(50.92945, 11.58491);
@@ -40,7 +42,7 @@ public class Rosenkeller extends BaseImporter {
 
 	@Override
 	protected Predicate<Tag> extractAttachmentsFilter() {
-		return TagFilter.attributeEquals(ID,"tribe-events-content");
+		return attributeEquals(ID,"tribe-events-content");
 	}
 
 	@Override
@@ -72,7 +74,7 @@ public class Rosenkeller extends BaseImporter {
 
 	@Override
 	protected Predicate<Tag> extractEventTagFilter() {
-		return TagFilter.attributeEquals(ID,"tribe-events-content");
+		return attributeEquals(ID,"tribe-events-content");
 	}
 
 	@Override
@@ -150,7 +152,7 @@ public class Rosenkeller extends BaseImporter {
 
 	@Override
 	protected Result<LocalTime> parseEndTime(String string) {
-		return Util.parseGermanTime(string);
+		return parseGermanTime(string);
 	}
 
 	@Override
@@ -164,7 +166,7 @@ public class Rosenkeller extends BaseImporter {
 
 	@Override
 	protected Result<LocalTime> parseStartTime(String string) {
-		return Util.parseGermanTime(string);
+		return parseGermanTime(string);
 	}
 
 	@Override