/* © SRSoftware 2026 */ package de.srsoftware.cal; import static de.srsoftware.tools.Tag.STYLE; import static de.srsoftware.tools.container.Container.transform; import static de.srsoftware.tools.container.Error.error; import static java.lang.System.Logger.Level.DEBUG; import static java.lang.System.Logger.Level.WARNING; import de.srsoftware.cal.api.Attachment; import de.srsoftware.cal.api.Coords; import de.srsoftware.tools.*; import de.srsoftware.tools.container.Container; import de.srsoftware.tools.container.Payload; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.regex.Pattern; 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 URL = "URL"; public static final String VERSION = "VERSION"; public static final String VEVENT = "VEVENT"; public static final String VCALENDAR = "VCALENDAR"; public static final Pattern GERMAN_DATE_PATTERN = Pattern.compile("^\\D*(\\d\\d?)\\.(\\d\\d?)\\.(\\d{4})\\D"); public static final Pattern GERMAN_DATE_WITHOUT_YEAR = Pattern.compile("(\\d\\d?)\\.(\\d\\d?)"); public static final Pattern GERMAN_DATE_PATTERN_LONG = Pattern.compile("(\\d\\d?)\\.?\\s*(\\w+)\\s+(\\d{4})\\D"); public static final Pattern GERMAN_TIME_PATTERN = Pattern.compile("(\\d\\d?):(\\d\\d?)(:(\\d\\d?))?\\D"); private static final Pattern BG_IMAGE_URL = Pattern.compile("background(-image)?:\\surl\\('?([^)]+)'?\\)"); private static final System.Logger LOG = System.getLogger(Util.class.getSimpleName()); private Util(){} public static Container combine(Container date, Container time) { if (date.optional().isEmpty())return transform(date); if (time.optional().isEmpty())return transform(time); return Payload.of(LocalDateTime.of(date.optional().get(),time.optional().get())); } /** * 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 void dump(Tag tag){ try { Files.writeString(Path.of("/tmp/dump.txt"),tag.toString(4)); } catch (IOException e) { throw new RuntimeException(e); } } public static Container extractCoords(String coords) { if (coords == null) return error("Argument is null"); if (coords.isBlank()) return error("Argument is blank"); var parts = coords.split(","); if (parts.length != 2) return error("Argument has invalid format: %s", coords); try { var lat = Double.parseDouble(parts[0].trim()); var lon = Double.parseDouble(parts[1].trim()); return Payload.of(new Coords(lat, lon)); } catch (NumberFormatException nfe) { return error(nfe, "Failed to parse coords from %s", coords); } } public static Container open(Container url) { var opt = url.optional(); if (opt.isEmpty()) return transform(url); try { var conn = (HttpURLConnection) opt.get().openConnection(); conn.setRequestProperty("Accept","*/*"); conn.setRequestProperty("Host",opt.get().getHost()); conn.setRequestProperty("User-Agent","OpenCloudCal/0.1"); return Payload.of(conn.getInputStream()); } catch (IOException e) { return error(e, "Failed to open %s", url, e); } } public static String paramText(String param) { return param .replace("\n","\\n") .replace("\"","''") .replace(";","/") .replace(",","/") .replace(":","/"); } public static Container parseGermanDate(String s){ var match = GERMAN_DATE_PATTERN.matcher(" "+s.trim()+" "); if (match.find()){ var day = Integer.parseInt(match.group(1)); var month = Integer.parseInt(match.group(2)); var year = Integer.parseInt(match.group(3)); return Payload.of(LocalDate.of(year,month,day)); } return error("Failed to find date"); } public static Container parseLongGermanDate(String s){ var match = GERMAN_DATE_PATTERN_LONG.matcher(" "+s+" "); if (match.find()){ var day = Integer.parseInt(match.group(1)); var res = toNumericMonth(match.group(2)); var month = res.optional(); if (month.isEmpty()) return transform(res); var year = Integer.parseInt(match.group(3)); return Payload.of(LocalDate.of(year,month.get(),day)); } return error("Failed to find date"); } public static Container parseGermanTime(String s){ var match = GERMAN_TIME_PATTERN.matcher(" "+s.trim()+" "); if (match.find()){ var hour = Integer.parseInt(match.group(1)); var minute = Integer.parseInt(match.group(2)); var sec = match.group(4); var second = sec == null ? 0 : Integer.parseInt(sec); return Payload.of(LocalTime.of(hour,minute,second)); } return error("Failed to find time"); } public static Container parseXML(Container inputStream) { var opt = inputStream.optional(); return opt.isEmpty() ? transform((inputStream)) : XMLParser.parse(opt.get()); } public static Container preload(Container inputStream) { var opt = inputStream.optional(); if (opt.isEmpty()) return transform(inputStream); try { return Payload.of(XMLParser.preload(opt.get())); } catch (IOException e) { return error(e, "Failed to buffer data from %s", inputStream); } } public static Container toNumericMonth(String month) { month = month.toLowerCase(); if (month.startsWith("ja")) return Payload.of(1); if (month.startsWith("f")) return Payload.of(2); if ("may".equals(month) || "mai".equals(month)) return Payload.of(5); if (month.startsWith("m")) return Payload.of(3); if (month.startsWith("ap")) return Payload.of(4); if (month.startsWith("jun")) return Payload.of(6); if (month.startsWith("jul")) return Payload.of(7); if (month.startsWith("au")) return Payload.of(8); if (month.startsWith("s")) return Payload.of(9); if (month.startsWith("o")) return Payload.of(10); if (month.startsWith("n")) return Payload.of(11); if (month.startsWith("d")) return Payload.of(12); return error("Failed to recognize \"%s\" as a month!", month); } /** * 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 Container wrapIcal(Container 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; } public static Container extractBackgroundImage(Tag tag, String baseUrl) { var style = tag.get(STYLE); if (style != null){ var matcher = BG_IMAGE_URL.matcher(style); if (matcher.find()) { var link = matcher.group(2); return Payload.of(link.contains("://") ? link : baseUrl+link); } } return error("Failed to findbackground image url in %s",tag); } public static Container toAttachment(Container urlResult) { var opt = urlResult.optional(); if (opt.isEmpty()) return transform(urlResult); try { var mime = MimeType.guessMime(opt.get()); return Payload.of(new Attachment(opt.get(), mime)); } catch (Exception e) { LOG.log(WARNING, "Failed to read mime type of {0}", opt.get()); return error("Failed to read mime type of %s", opt.get()); } } public static Container parseInt(String s){ try { return Payload.of(Integer.parseInt(s)); } catch (NumberFormatException e){ return error(e,"Failed to parse %s as integer!",s); } } public static Container url(Container urlResult) { if (urlResult.optional().isEmpty()) return transform(urlResult); var url = urlResult.optional().get(); try { return Payload.of(new URI(url).toURL()); } catch (Exception e) { return error(e, "Failed to create URL of %s", url); } } public static Container parseGermanDateWithoutYear(String string) { var matcher = GERMAN_DATE_WITHOUT_YEAR.matcher(string); if (matcher.find()){ int day = Integer.parseInt(matcher.group(1)); int mon = Integer.parseInt(matcher.group(2)); var now = LocalDate.now(); var date = LocalDate.of(now.getYear(),mon,day); if (date.isBefore(now)) date = date.withYear(now.getYear()+1); return Payload.of(date); } return error("Failed to parse date from %s",string); } }