overhauled API:
- wrote openapi schema - re-implemented api endpoint following openapi schema - intensified and improved working with Result objects Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
description = "OpenCloudCal : API"
|
description = "OpenCloudCal : API"
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("de.srsoftware:tools.util:1.2.3")
|
implementation("de.srsoftware:tools.util:1.3.0")
|
||||||
implementation("org.json:json:20240303")
|
implementation("org.json:json:20240303")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ public interface Appointment {
|
|||||||
*/
|
*/
|
||||||
Optional<LocalDateTime> end();
|
Optional<LocalDateTime> end();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* represent this event as ical entry
|
||||||
|
* @return the ical string
|
||||||
|
*/
|
||||||
|
String ical();
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ID of the appointment – unique within this system
|
* ID of the appointment – unique within this system
|
||||||
* @return the appointment`s id
|
* @return the appointment`s id
|
||||||
@@ -49,6 +57,12 @@ public interface Appointment {
|
|||||||
*/
|
*/
|
||||||
JSONObject json();
|
JSONObject json();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* set of Links that point to related information
|
||||||
|
* @return set of links
|
||||||
|
*/
|
||||||
|
Set<Link> links();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* descriptive text of the location, e.g. address
|
* descriptive text of the location, e.g. address
|
||||||
* @return location text
|
* @return location text
|
||||||
@@ -74,10 +88,4 @@ public interface Appointment {
|
|||||||
* @return the title
|
* @return the title
|
||||||
*/
|
*/
|
||||||
String title();
|
String title();
|
||||||
|
|
||||||
/**
|
|
||||||
* set of Links that point to related information
|
|
||||||
* @return set of links
|
|
||||||
*/
|
|
||||||
Set<Link> urls();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation("de.srsoftware:configuration.api:1.0.0")
|
implementation("de.srsoftware:configuration.api:1.0.0")
|
||||||
implementation("de.srsoftware:configuration.json:1.0.0")
|
implementation("de.srsoftware:configuration.json:1.0.0")
|
||||||
implementation("de.srsoftware:tools.http:1.0.4")
|
implementation("de.srsoftware:tools.http:1.2.1")
|
||||||
implementation("de.srsoftware:tools.logging:1.0.2")
|
implementation("de.srsoftware:tools.logging:1.0.2")
|
||||||
implementation("de.srsoftware:tools.web:1.3.8")
|
implementation("de.srsoftware:tools.web:1.3.8")
|
||||||
implementation("com.mysql:mysql-connector-j:9.1.0")
|
implementation("com.mysql:mysql-connector-j:9.1.0")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package de.srsoftware.cal.app;
|
|||||||
import static java.lang.System.Logger.Level.*;
|
import static java.lang.System.Logger.Level.*;
|
||||||
|
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
import de.srsoftware.cal.ApiHandler;
|
import de.srsoftware.cal.ApiEndpoint;
|
||||||
import de.srsoftware.cal.IndexHandler;
|
import de.srsoftware.cal.IndexHandler;
|
||||||
import de.srsoftware.cal.StaticHandler;
|
import de.srsoftware.cal.StaticHandler;
|
||||||
import de.srsoftware.cal.db.Database;
|
import de.srsoftware.cal.db.Database;
|
||||||
@@ -56,7 +56,15 @@ public class Application {
|
|||||||
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
|
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||||
var staticPages = new StaticHandler(staticPath).bindPath("/static").on(server);
|
var staticPages = new StaticHandler(staticPath).bindPath("/static").on(server);
|
||||||
new IndexHandler(staticPages).bindPath("/").on(server);
|
new IndexHandler(staticPages).bindPath("/").on(server);
|
||||||
new ApiHandler(db).bindPath(("/api")).on(server);
|
new ApiEndpoint(db).bindPath(("/api")).on(server);
|
||||||
|
|
||||||
server.start();
|
server.start();
|
||||||
|
// TODO: schedule imports
|
||||||
|
// TODO: update URL, when tags are selected
|
||||||
|
// TODO: allow list start time as URL param
|
||||||
|
// TODO: provide ICAL and WEBDAV links
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scheduleImports() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ dependencies {
|
|||||||
implementation(project(":de.srsoftware.cal.api"))
|
implementation(project(":de.srsoftware.cal.api"))
|
||||||
|
|
||||||
implementation("de.srsoftware:tools.optionals:1.0.0")
|
implementation("de.srsoftware:tools.optionals:1.0.0")
|
||||||
implementation("de.srsoftware:tools.util:1.2.3")
|
implementation("de.srsoftware:tools.util:1.3.0")
|
||||||
implementation("de.srsoftware:tools.web:1.3.8")
|
implementation("de.srsoftware:tools.web:1.3.8")
|
||||||
implementation("org.json:json:20240303")
|
implementation("org.json:json:20240303")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ public class BaseAppointment implements Appointment {
|
|||||||
return nullable(end);
|
return nullable(end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String ical() {
|
||||||
|
return "converting event (%s) to ical not implemented".formatted(title);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long id() {
|
public long id() {
|
||||||
return id;
|
return id;
|
||||||
@@ -140,7 +145,7 @@ public class BaseAppointment implements Appointment {
|
|||||||
json.put("start", start().format(DATE_TIME));
|
json.put("start", start().format(DATE_TIME));
|
||||||
json.put("tags", tags());
|
json.put("tags", tags());
|
||||||
json.put("title", title());
|
json.put("title", title());
|
||||||
json.put("links", urls().stream().map(Link::json).toList());
|
json.put("links", links().stream().map(Link::json).toList());
|
||||||
|
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
@@ -191,7 +196,7 @@ public class BaseAppointment implements Appointment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Set<Link> urls() {
|
public Set<Link> links() {
|
||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/* © SRSoftware 2024 */
|
/* © SRSoftware 2024 */
|
||||||
package de.srsoftware.cal;
|
package de.srsoftware.cal;
|
||||||
|
|
||||||
|
import static de.srsoftware.tools.Error.error;
|
||||||
import static de.srsoftware.tools.Result.transform;
|
import static de.srsoftware.tools.Result.transform;
|
||||||
|
import static java.lang.System.Logger.Level.WARNING;
|
||||||
|
|
||||||
import de.srsoftware.cal.api.*;
|
import de.srsoftware.cal.api.*;
|
||||||
import de.srsoftware.tools.*;
|
import de.srsoftware.tools.*;
|
||||||
@@ -22,6 +24,7 @@ import java.util.Optional;
|
|||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public abstract class BaseImporter implements Importer {
|
public abstract class BaseImporter implements Importer {
|
||||||
|
private static final System.Logger LOG = System.getLogger(BaseImporter.class.getSimpleName());
|
||||||
private static final String SHA256 = "SHA-256";
|
private static final String SHA256 = "SHA-256";
|
||||||
private final MessageDigest digest;
|
private final MessageDigest digest;
|
||||||
|
|
||||||
@@ -61,13 +64,13 @@ public abstract class BaseImporter implements Importer {
|
|||||||
if (titleTag.optional().isEmpty()) return transform(titleTag);
|
if (titleTag.optional().isEmpty()) return transform(titleTag);
|
||||||
var inner = titleTag.optional().flatMap(tag -> tag.inner(2));
|
var inner = titleTag.optional().flatMap(tag -> tag.inner(2));
|
||||||
if (inner.isPresent()) return Payload.of(inner.get());
|
if (inner.isPresent()) return Payload.of(inner.get());
|
||||||
return Error.of("No description found");
|
return error("No description found");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract Result<Tag> extractDescriptionTag(Tag eventTag);
|
protected abstract Result<Tag> extractDescriptionTag(Tag eventTag);
|
||||||
|
|
||||||
protected Result<Coords> extractCoords(Tag eventTag) {
|
protected Result<Coords> extractCoords(Tag eventTag) {
|
||||||
return Error.of("not implemented");
|
return error("not implemented");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -177,8 +180,8 @@ public abstract class BaseImporter implements Importer {
|
|||||||
Result<Tag> titleTag = extractTitleTag(eventTag);
|
Result<Tag> titleTag = extractTitleTag(eventTag);
|
||||||
if (titleTag.optional().isEmpty()) return transform(titleTag);
|
if (titleTag.optional().isEmpty()) return transform(titleTag);
|
||||||
var inner = titleTag.optional().flatMap(tag -> tag.inner(2));
|
var inner = titleTag.optional().flatMap(tag -> tag.inner(2));
|
||||||
if (inner.isPresent()) return Payload.of(inner.get());
|
return inner.isPresent() ? Payload.of(inner.get()) :
|
||||||
return Error.of("No title found");
|
error("No title found");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract Result<Tag> extractTitleTag(Tag eventTag);
|
protected abstract Result<Tag> extractTitleTag(Tag eventTag);
|
||||||
@@ -203,18 +206,20 @@ public abstract class BaseImporter implements Importer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected static <T> Result<T> invalidParameter(Result<?> result) {
|
protected static <T> Result<T> invalidParameter(Result<?> result) {
|
||||||
return Error.format("Invalid parameter: %s", result.getClass().getSimpleName());
|
return error("Invalid parameter: %s", result.getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static Result<Link> link(Result<URL> url, String text) {
|
protected static Result<Link> link(Result<URL> url, String text) {
|
||||||
var opt = url.optional();
|
var opt = url.optional();
|
||||||
if (opt.isEmpty()) return transform(url);
|
if (opt.isEmpty()) return transform(url);
|
||||||
return Payload.of(new Link(opt.get(),text));
|
return Payload.of(new Link(opt.get(), text));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected Result<Appointment> loadEvent(Result<URL> urlResult) {
|
protected Result<Appointment> loadEvent(Result<URL> urlResult) {
|
||||||
var link = urlResult //
|
var link = urlResult //
|
||||||
.optional().map(url -> new Link(url, "Event-Seite")).orElse(null);
|
.optional()
|
||||||
|
.map(url -> new Link(url, "Event-Seite"))
|
||||||
|
.orElse(null);
|
||||||
return urlResult //
|
return urlResult //
|
||||||
.map(this::open)
|
.map(this::open)
|
||||||
.map(this::preload)
|
.map(this::preload)
|
||||||
@@ -229,7 +234,7 @@ public abstract class BaseImporter implements Importer {
|
|||||||
try {
|
try {
|
||||||
return Payload.of(payload.get().openConnection().getInputStream());
|
return Payload.of(payload.get().openConnection().getInputStream());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return Error.of("Failed to open %s".formatted(payload), e);
|
return error(e, "Failed to open %s", payload, e);
|
||||||
}
|
}
|
||||||
case Error<URL> error:
|
case Error<URL> error:
|
||||||
return error.transform();
|
return error.transform();
|
||||||
@@ -256,7 +261,7 @@ public abstract class BaseImporter implements Importer {
|
|||||||
try {
|
try {
|
||||||
return Payload.of(XMLParser.preload(payload.get()));
|
return Payload.of(XMLParser.preload(payload.get()));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return Error.of("Failed to buffer data from %s".formatted(payload), e);
|
return error(e, "Failed to buffer data from %s", payload);
|
||||||
}
|
}
|
||||||
case Error<InputStream> error:
|
case Error<InputStream> error:
|
||||||
return error.transform();
|
return error.transform();
|
||||||
@@ -274,8 +279,8 @@ public abstract class BaseImporter implements Importer {
|
|||||||
var mime = opt.get().openConnection().getContentType();
|
var mime = opt.get().openConnection().getContentType();
|
||||||
return Payload.of(new Attachment(opt.get(), mime));
|
return Payload.of(new Attachment(opt.get(), mime));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
LOG.log(WARNING, "Failed to read mime type of {0}", opt.get());
|
||||||
return Error.format("Failed to read mime type of %s", opt.get());
|
return error("Failed to read mime type of %s", opt.get());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +299,7 @@ public abstract class BaseImporter implements Importer {
|
|||||||
if (month.startsWith("o")) return Payload.of(10);
|
if (month.startsWith("o")) return Payload.of(10);
|
||||||
if (month.startsWith("n")) return Payload.of(11);
|
if (month.startsWith("n")) return Payload.of(11);
|
||||||
if (month.startsWith("d")) return Payload.of(12);
|
if (month.startsWith("d")) return Payload.of(12);
|
||||||
return Error.format("Failed to recognize \"%s\" as a month!", month);
|
return error("Failed to recognize \"%s\" as a month!", month);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -304,7 +309,7 @@ public abstract class BaseImporter implements Importer {
|
|||||||
try {
|
try {
|
||||||
return Payload.of(new URI(url).toURL());
|
return Payload.of(new URI(url).toURL());
|
||||||
} catch (MalformedURLException | URISyntaxException e) {
|
} catch (MalformedURLException | URISyntaxException e) {
|
||||||
return Error.of("Failed to create URL of %s".formatted(url), e);
|
return error(e, "Failed to create URL of %s", url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
/* © SRSoftware 2024 */
|
/* © SRSoftware 2024 */
|
||||||
package de.srsoftware.cal;
|
package de.srsoftware.cal;
|
||||||
|
|
||||||
|
import static de.srsoftware.tools.Error.error;
|
||||||
|
|
||||||
import de.srsoftware.cal.api.Coords;
|
import de.srsoftware.cal.api.Coords;
|
||||||
import de.srsoftware.tools.Error;
|
|
||||||
import de.srsoftware.tools.Payload;
|
import de.srsoftware.tools.Payload;
|
||||||
import de.srsoftware.tools.Result;
|
import de.srsoftware.tools.Result;
|
||||||
|
|
||||||
public class Util {
|
public class Util {
|
||||||
public static Result<Coords> extractCoords(String coords) {
|
public static Result<Coords> extractCoords(String coords) {
|
||||||
if (coords == null) return de.srsoftware.tools.Error.of("Argument is null");
|
if (coords == null) return error("Argument is null");
|
||||||
if (coords.isBlank()) return de.srsoftware.tools.Error.of("Argument is blank");
|
if (coords.isBlank()) return error("Argument is blank");
|
||||||
var parts = coords.split(",");
|
var parts = coords.split(",");
|
||||||
if (parts.length != 2) return de.srsoftware.tools.Error.format("Argument has invalid format: %s", coords);
|
if (parts.length != 2) return error("Argument has invalid format: %s", coords);
|
||||||
try {
|
try {
|
||||||
var lat = Double.parseDouble(parts[0].trim());
|
var lat = Double.parseDouble(parts[0].trim());
|
||||||
var lon = Double.parseDouble(parts[1].trim());
|
var lon = Double.parseDouble(parts[1].trim());
|
||||||
return Payload.of(new Coords(lon, lat));
|
return Payload.of(new Coords(lon, lat));
|
||||||
} catch (NumberFormatException nfe) {
|
} catch (NumberFormatException nfe) {
|
||||||
return Error.of("Failed to parse coords from %s".formatted(coords), nfe);
|
return error(nfe, "Failed to parse coords from %s", coords);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ dependencies {
|
|||||||
|
|
||||||
implementation("de.srsoftware:tools.jdbc:1.1.3")
|
implementation("de.srsoftware:tools.jdbc:1.1.3")
|
||||||
implementation("de.srsoftware:tools.optionals:1.0.0")
|
implementation("de.srsoftware:tools.optionals:1.0.0")
|
||||||
implementation("de.srsoftware:tools.util:1.2.3")
|
implementation("de.srsoftware:tools.util:1.3.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,9 @@
|
|||||||
package de.srsoftware.cal.db;
|
package de.srsoftware.cal.db;
|
||||||
|
|
||||||
import de.srsoftware.cal.api.Appointment;
|
import de.srsoftware.cal.api.Appointment;
|
||||||
import de.srsoftware.tools.Error;
|
|
||||||
import de.srsoftware.tools.Result;
|
import de.srsoftware.tools.Result;
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,19 +24,11 @@ public interface Database {
|
|||||||
* list appointments unfiltered
|
* list appointments unfiltered
|
||||||
* @param count the maximum number of appointments to return
|
* @param count the maximum number of appointments to return
|
||||||
* @param offset the number of appointments to skip
|
* @param offset the number of appointments to skip
|
||||||
* @return the list of appointments fetched from the db
|
|
||||||
* @throws SQLException if the appointments cannot be fetched from the DB
|
|
||||||
*/
|
|
||||||
public List<Appointment> list(Integer count, Integer offset) throws SQLException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* list appointments unfiltered
|
|
||||||
* @param from restrict appointments to times after this date time
|
* @param from restrict appointments to times after this date time
|
||||||
* @param till restrict appointments to times before this date time
|
* @param till restrict appointments to times before this date time
|
||||||
* @return list of appointments in this time span
|
* @return list of appointments in this time span
|
||||||
* @throws SQLException if the appointments cannot be fetched from the DB
|
|
||||||
*/
|
*/
|
||||||
public List<Appointment> list(LocalDateTime from, LocalDateTime till) throws SQLException;
|
public Result<List<Appointment>> list(LocalDateTime from, LocalDateTime till, Integer count, Integer offset);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* list appointments
|
* list appointments
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.srsoftware.cal.db;
|
|||||||
|
|
||||||
import static de.srsoftware.cal.db.Fields.*;
|
import static de.srsoftware.cal.db.Fields.*;
|
||||||
import static de.srsoftware.cal.db.Fields.ALL;
|
import static de.srsoftware.cal.db.Fields.ALL;
|
||||||
|
import static de.srsoftware.tools.Error.error;
|
||||||
import static de.srsoftware.tools.Optionals.*;
|
import static de.srsoftware.tools.Optionals.*;
|
||||||
import static de.srsoftware.tools.Result.transform;
|
import static de.srsoftware.tools.Result.transform;
|
||||||
import static de.srsoftware.tools.jdbc.Condition.*;
|
import static de.srsoftware.tools.jdbc.Condition.*;
|
||||||
@@ -14,7 +15,6 @@ import de.srsoftware.cal.Util;
|
|||||||
import de.srsoftware.cal.api.Appointment;
|
import de.srsoftware.cal.api.Appointment;
|
||||||
import de.srsoftware.cal.api.Attachment;
|
import de.srsoftware.cal.api.Attachment;
|
||||||
import de.srsoftware.cal.api.Link;
|
import de.srsoftware.cal.api.Link;
|
||||||
import de.srsoftware.tools.Error;
|
|
||||||
import de.srsoftware.tools.Payload;
|
import de.srsoftware.tools.Payload;
|
||||||
import de.srsoftware.tools.Result;
|
import de.srsoftware.tools.Result;
|
||||||
import de.srsoftware.tools.jdbc.Query;
|
import de.srsoftware.tools.jdbc.Query;
|
||||||
@@ -69,7 +69,7 @@ public class MariaDB implements Database {
|
|||||||
Appointment saved = null;
|
Appointment saved = null;
|
||||||
if (keys.next()) saved = appointment.clone(keys.getLong(1));
|
if (keys.next()) saved = appointment.clone(keys.getLong(1));
|
||||||
keys.close();
|
keys.close();
|
||||||
if (saved == null) return Error.of("Insert query did not return appointment id!");
|
if (saved == null) return error("Insert query did not return appointment id!");
|
||||||
|
|
||||||
{ // link to attachments
|
{ // link to attachments
|
||||||
var attachments = saved.attachments();
|
var attachments = saved.attachments();
|
||||||
@@ -83,7 +83,7 @@ public class MariaDB implements Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{ // link to links
|
{ // link to links
|
||||||
var links = saved.urls();
|
var links = saved.links();
|
||||||
InsertQuery assignQuery = null;
|
InsertQuery assignQuery = null;
|
||||||
for (var link : links) {
|
for (var link : links) {
|
||||||
var urlId = getOrCreateUrl(link.url());
|
var urlId = getOrCreateUrl(link.url());
|
||||||
@@ -106,7 +106,7 @@ public class MariaDB implements Database {
|
|||||||
return Payload.of(saved);
|
return Payload.of(saved);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
LOG.log(ERROR, "Failed to store appointment", e);
|
LOG.log(ERROR, "Failed to store appointment", e);
|
||||||
return Error.of("Failed to store appointment", e);
|
return error(e, "Failed to store appointment");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,28 +149,43 @@ public class MariaDB implements Database {
|
|||||||
rs.close();
|
rs.close();
|
||||||
return Payload.of(results);
|
return Payload.of(results);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
return Error.format("failed to gather tags from DB.", e);
|
return error(e, "failed to gather tags from DB.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Appointment> list(Integer count, Integer offset) throws SQLException {
|
public Result<List<Appointment>> list(LocalDateTime from, LocalDateTime till, Integer count, Integer offset) {
|
||||||
|
var query = Query //
|
||||||
|
.select("appointments.*", "GROUP_CONCAT(keyword) AS tags")
|
||||||
|
.from(APPOINTMENTS)
|
||||||
|
.leftJoin(AID, "appointment_tags", AID)
|
||||||
|
.leftJoin("tid", "tags", "tid")
|
||||||
|
.groupBy(AID)
|
||||||
|
.sort("start DESC");
|
||||||
|
if (from != null) query.where(START, moreThan(Timestamp.valueOf(from)));
|
||||||
|
if (till != null) query.where(END, lessThan(Timestamp.valueOf(till)));
|
||||||
|
if (count != null) query.limit(count);
|
||||||
|
if (offset != null) query.skip(offset);
|
||||||
|
try {
|
||||||
|
var results = query.exec(connection);
|
||||||
var list = new ArrayList<Appointment>();
|
var list = new ArrayList<Appointment>();
|
||||||
var results = select(ALL).from(APPOINTMENTS).sort("start").exec(connection);
|
|
||||||
while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add);
|
while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add);
|
||||||
results.close();
|
results.close();
|
||||||
return list;
|
return Payload.of(list);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
return SqlError.of(e, "Failed to fetch appointments from database!");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result<Appointment> loadEvent(long id) {
|
public Result<Appointment> loadEvent(long id) {
|
||||||
try {
|
try {
|
||||||
var rs = select(ALL).from(APPOINTMENTS).where(AID, equal(id)).exec(connection);
|
var rs = select(ALL).from(APPOINTMENTS).where(AID, equal(id)).exec(connection);
|
||||||
Result<Appointment> result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : Error.format("Failed to find appointment with id %s", id);
|
Result<Appointment> result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : NotFound.of("Failed to find appointment with id %s", id);
|
||||||
rs.close();
|
rs.close();
|
||||||
return result;
|
return result;
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
return Error.of("Failed to load appointment with id = %s".formatted(id), e);
|
return SqlError.of(e, "Failed to load appointment with id = %s", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,11 +193,11 @@ public class MariaDB implements Database {
|
|||||||
public Result<Appointment> loadEvent(String location, LocalDateTime start) {
|
public Result<Appointment> loadEvent(String location, LocalDateTime start) {
|
||||||
try {
|
try {
|
||||||
var rs = select(ALL).from(APPOINTMENTS).where(LOCATION, equal(location)).where(START, equal(Timestamp.valueOf(start))).exec(connection);
|
var rs = select(ALL).from(APPOINTMENTS).where(LOCATION, equal(location)).where(START, equal(Timestamp.valueOf(start))).exec(connection);
|
||||||
Result<Appointment> result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : Error.format("Failed to find appointment starting %s @ %s".formatted(start, location));
|
Result<Appointment> result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : error("Failed to find appointment starting %s @ %s", start, location);
|
||||||
rs.close();
|
rs.close();
|
||||||
return result;
|
return result;
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
return Error.of("Failed to load appointment starting %s @ %s".formatted(start, location), e);
|
return error(e, "Failed to load appointment starting %s @ %s", start, location);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +215,7 @@ public class MariaDB implements Database {
|
|||||||
rs.close();
|
rs.close();
|
||||||
return Payload.of(event);
|
return Payload.of(event);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
return Error.of("Failed to load tags for appointment %s".formatted(id), e);
|
return error(e, "Failed to load tags for appointment %s", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +238,7 @@ public class MariaDB implements Database {
|
|||||||
rs.close();
|
rs.close();
|
||||||
return Payload.of(event);
|
return Payload.of(event);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
return Error.of("Failed to load tags for appointment %s".formatted(id), e);
|
return error(e, "Failed to load tags for appointment %s", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +261,7 @@ public class MariaDB implements Database {
|
|||||||
rs.close();
|
rs.close();
|
||||||
return Payload.of(event);
|
return Payload.of(event);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
return Error.of("Failed to load tags for appointment %s".formatted(id), e);
|
return error(e, "Failed to load tags for appointment %s".formatted(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +269,7 @@ public class MariaDB implements Database {
|
|||||||
var id = results.getInt(AID);
|
var id = results.getInt(AID);
|
||||||
var title = results.getString(TITLE);
|
var title = results.getString(TITLE);
|
||||||
var description = results.getString(DESCRIPTION);
|
var description = results.getString(DESCRIPTION);
|
||||||
if (allEmpty(title, description)) return Error.format("Title and Description of appointment %s are empty", id);
|
if (allEmpty(title, description)) return error("Title and Description of appointment %s are empty", id);
|
||||||
var start = results.getTimestamp(START).toLocalDateTime();
|
var start = results.getTimestamp(START).toLocalDateTime();
|
||||||
var end = nullable(results.getTimestamp(END)).map(Timestamp::toLocalDateTime).orElse(null);
|
var end = nullable(results.getTimestamp(END)).map(Timestamp::toLocalDateTime).orElse(null);
|
||||||
var location = results.getString(LOCATION);
|
var location = results.getString(LOCATION);
|
||||||
@@ -275,24 +290,6 @@ public class MariaDB implements Database {
|
|||||||
return Payload.of(appointment);
|
return Payload.of(appointment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<Appointment> list(LocalDateTime from, LocalDateTime till) throws SQLException {
|
|
||||||
var list = new ArrayList<Appointment>();
|
|
||||||
var query = Query //
|
|
||||||
.select("appointments.*", "GROUP_CONCAT(keyword) AS tags")
|
|
||||||
.from(APPOINTMENTS)
|
|
||||||
.leftJoin(AID, "appointment_tags", AID)
|
|
||||||
.leftJoin("tid", "tags", "tid")
|
|
||||||
.groupBy(AID)
|
|
||||||
.sort("start DESC");
|
|
||||||
nullable(from).ifPresent(start -> query.where("start", moreThan(start)));
|
|
||||||
nullable(till).ifPresent(end -> query.where("end", lessThan(end)));
|
|
||||||
|
|
||||||
var results = query.exec(connection);
|
|
||||||
while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add);
|
|
||||||
results.close();
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Appointment> listByTags(Set<String> tags, Integer count, Integer offset) {
|
public List<Appointment> listByTags(Set<String> tags, Integer count, Integer offset) {
|
||||||
@@ -302,13 +299,13 @@ public class MariaDB implements Database {
|
|||||||
@Override
|
@Override
|
||||||
public Result<Long> removeAppointment(long id) {
|
public Result<Long> removeAppointment(long id) {
|
||||||
try {
|
try {
|
||||||
Query.delete().from(APPOINTMENTS).where(AID,equal(id)).execute(connection);
|
Query.delete().from(APPOINTMENTS).where(AID, equal(id)).execute(connection);
|
||||||
Query.delete().from(APPOINTMENT_TAGS).where(AID,equal(id)).execute(connection);
|
Query.delete().from(APPOINTMENT_TAGS).where(AID, equal(id)).execute(connection);
|
||||||
Query.delete().from(APPOINTMENT_ATTACHMENTS).where(AID,equal(id)).execute(connection);
|
Query.delete().from(APPOINTMENT_ATTACHMENTS).where(AID, equal(id)).execute(connection);
|
||||||
Query.delete().from(APPOINTMENT_URLS).where(AID,equal(id)).execute(connection);
|
Query.delete().from(APPOINTMENT_URLS).where(AID, equal(id)).execute(connection);
|
||||||
return Payload.of(id);
|
return Payload.of(id);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
return Error.of("Failed to delete event %s".formatted(id),e);
|
return SqlError.of(e, "Failed to delete event %s", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +324,7 @@ public class MariaDB implements Database {
|
|||||||
|
|
||||||
return Payload.of(event);
|
return Payload.of(event);
|
||||||
} catch (SQLException sqle) {
|
} catch (SQLException sqle) {
|
||||||
return Error.of("Failed to update database entry", sqle);
|
return error(sqle, "Failed to update database entry");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* © SRSoftware 2024 */
|
||||||
|
package de.srsoftware.cal.db;
|
||||||
|
|
||||||
|
import de.srsoftware.tools.Error;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class NotFound<None> extends Error<None> {
|
||||||
|
public NotFound(String message, Map<String, Object> data, Collection<Exception> exceptions) {
|
||||||
|
super(message, data, exceptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> NotFound<T> of(String message, Object... fills) {
|
||||||
|
return new NotFound<>(message.formatted(fills), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <NewType> NotFound<NewType> transform() {
|
||||||
|
return new NotFound<>(message(), data(), exceptions());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* © SRSoftware 2024 */
|
||||||
|
package de.srsoftware.cal.db;
|
||||||
|
|
||||||
|
import de.srsoftware.tools.Error;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class SqlError<None> extends Error<None> {
|
||||||
|
public SqlError(String message, Map<String, Object> data, Collection<Exception> exceptions) {
|
||||||
|
super(message, null, exceptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SQLException exception() {
|
||||||
|
return exceptions() //
|
||||||
|
.stream()
|
||||||
|
.filter(ex -> ex instanceof SQLException)
|
||||||
|
.map(SQLException.class ::cast)
|
||||||
|
.findAny()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> SqlError<T> of(SQLException e, String message, Object... fills) {
|
||||||
|
return new SqlError<>(message.formatted(fills), null, List.of(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <NewType> SqlError<NewType> transform() {
|
||||||
|
return new SqlError<>(message(), data(), exceptions());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@ dependencies {
|
|||||||
implementation(project(":de.srsoftware.cal.api"))
|
implementation(project(":de.srsoftware.cal.api"))
|
||||||
implementation(project(":de.srsoftware.cal.base"))
|
implementation(project(":de.srsoftware.cal.base"))
|
||||||
implementation("de.srsoftware:tools.optionals:1.0.0")
|
implementation("de.srsoftware:tools.optionals:1.0.0")
|
||||||
implementation("de.srsoftware:tools.util:1.2.3")
|
implementation("de.srsoftware:tools.util:1.3.0")
|
||||||
implementation("de.srsoftware:tools.web:1.3.8")
|
implementation("de.srsoftware:tools.web:1.3.8")
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
/* © SRSoftware 2024 */
|
/* © SRSoftware 2024 */
|
||||||
package de.srsoftware.cal.importer.jena;
|
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.Result.transform;
|
||||||
import static de.srsoftware.tools.TagFilter.*;
|
import static de.srsoftware.tools.TagFilter.*;
|
||||||
|
|
||||||
import de.srsoftware.cal.BaseImporter;
|
import de.srsoftware.cal.BaseImporter;
|
||||||
import de.srsoftware.tools.*;
|
import de.srsoftware.tools.*;
|
||||||
import de.srsoftware.tools.Error;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -31,12 +31,12 @@ public class Kassablanca extends BaseImporter {
|
|||||||
protected Result<Tag> extractDescriptionTag(Tag eventTag) {
|
protected Result<Tag> extractDescriptionTag(Tag eventTag) {
|
||||||
var list = eventTag.find(attributeHas("class", "se-content"));
|
var list = eventTag.find(attributeHas("class", "se-content"));
|
||||||
if (list.size() == 1) return Payload.of(list.getFirst());
|
if (list.size() == 1) return Payload.of(list.getFirst());
|
||||||
return Error.of("Failed to find description tag");
|
return error("Failed to find description tag");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Result<Tag> extractEndTag(Tag eventTag) {
|
protected Result<Tag> extractEndTag(Tag eventTag) {
|
||||||
return Error.format("end date not supported");
|
return error("end date not supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -44,7 +44,7 @@ public class Kassablanca extends BaseImporter {
|
|||||||
if (pageResult.optional().isEmpty()) return transform(pageResult);
|
if (pageResult.optional().isEmpty()) return transform(pageResult);
|
||||||
var list = pageResult.optional().get().find(attributeEquals("class", APPOINTMENT_TAG_ID));
|
var list = pageResult.optional().get().find(attributeEquals("class", APPOINTMENT_TAG_ID));
|
||||||
if (list.size() == 1) return Payload.of(list.getFirst());
|
if (list.size() == 1) return Payload.of(list.getFirst());
|
||||||
return Error.format("Could not find tag with id \"%s\"", APPOINTMENT_TAG_ID);
|
return error("Could not find tag with id \"%s\"", APPOINTMENT_TAG_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -85,7 +85,7 @@ public class Kassablanca extends BaseImporter {
|
|||||||
protected Result<Tag> extractStartTag(Tag eventTag) {
|
protected Result<Tag> extractStartTag(Tag eventTag) {
|
||||||
List<Tag> tags = eventTag.find(attributeEquals("class", "se-header"));
|
List<Tag> tags = eventTag.find(attributeEquals("class", "se-header"));
|
||||||
if (tags.size() == 1) return Payload.of(tags.getFirst());
|
if (tags.size() == 1) return Payload.of(tags.getFirst());
|
||||||
return Error.of("Failed to find event time information");
|
return error("Failed to find event time information");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -97,7 +97,7 @@ public class Kassablanca extends BaseImporter {
|
|||||||
protected Result<Tag> extractTitleTag(Tag eventTag) {
|
protected Result<Tag> extractTitleTag(Tag eventTag) {
|
||||||
var list = eventTag.find(ofType("h1"));
|
var list = eventTag.find(ofType("h1"));
|
||||||
if (list.size() == 1) return Payload.of(list.getFirst());
|
if (list.size() == 1) return Payload.of(list.getFirst());
|
||||||
return Error.of("Failed to find title tag");
|
return error("Failed to find title tag");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -117,7 +117,7 @@ public class Kassablanca extends BaseImporter {
|
|||||||
var date = LocalDateTime.of(year, month, day, hour, minute);
|
var date = LocalDateTime.of(year, month, day, hour, minute);
|
||||||
return Payload.of(date);
|
return Payload.of(date);
|
||||||
}
|
}
|
||||||
return Error.of("Could not recognize start date/time");
|
return error("Could not recognize start date/time");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
/* © SRSoftware 2024 */
|
/* © SRSoftware 2024 */
|
||||||
package de.srsoftware.cal.importer.jena;
|
package de.srsoftware.cal.importer.jena;
|
||||||
|
|
||||||
|
import static de.srsoftware.tools.Error.error;
|
||||||
import static de.srsoftware.tools.Optionals.nullable;
|
import static de.srsoftware.tools.Optionals.nullable;
|
||||||
import static de.srsoftware.tools.Result.transform;
|
import static de.srsoftware.tools.Result.transform;
|
||||||
import static de.srsoftware.tools.TagFilter.*;
|
import static de.srsoftware.tools.TagFilter.*;
|
||||||
|
|
||||||
import de.srsoftware.cal.BaseImporter;
|
import de.srsoftware.cal.BaseImporter;
|
||||||
import de.srsoftware.tools.Error;
|
|
||||||
import de.srsoftware.tools.Payload;
|
import de.srsoftware.tools.Payload;
|
||||||
import de.srsoftware.tools.Result;
|
import de.srsoftware.tools.Result;
|
||||||
import de.srsoftware.tools.Tag;
|
import de.srsoftware.tools.Tag;
|
||||||
@@ -42,12 +42,12 @@ public class Rosenkeller extends BaseImporter {
|
|||||||
.stream()
|
.stream()
|
||||||
.findAny();
|
.findAny();
|
||||||
if (opt.isPresent()) return Payload.of(opt.get());
|
if (opt.isPresent()) return Payload.of(opt.get());
|
||||||
return Error.of("Failed to find description tag");
|
return error("Failed to find description tag");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Result<Tag> extractEndTag(Tag eventTag) {
|
protected Result<Tag> extractEndTag(Tag eventTag) {
|
||||||
return Error.of("extractEndTag(…) not supported");
|
return error("extractEndTag(…) not supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -55,7 +55,7 @@ public class Rosenkeller extends BaseImporter {
|
|||||||
if (pageResult.optional().isEmpty()) return transform(pageResult);
|
if (pageResult.optional().isEmpty()) return transform(pageResult);
|
||||||
var list = pageResult.optional().get().find(attributeEquals("id", APPOINTMENT_TAG_ID));
|
var list = pageResult.optional().get().find(attributeEquals("id", APPOINTMENT_TAG_ID));
|
||||||
if (list.size() == 1) return Payload.of(list.getFirst());
|
if (list.size() == 1) return Payload.of(list.getFirst());
|
||||||
return Error.format("Could not find tag with id \"%s\"", APPOINTMENT_TAG_ID);
|
return error("Could not find tag with id \"%s\"", APPOINTMENT_TAG_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -88,7 +88,7 @@ public class Rosenkeller extends BaseImporter {
|
|||||||
protected Result<Tag> extractStartTag(Tag eventTag) {
|
protected Result<Tag> extractStartTag(Tag eventTag) {
|
||||||
List<Tag> list = eventTag.find(attributeEquals("class", "tribe-event-date-start"));
|
List<Tag> list = eventTag.find(attributeEquals("class", "tribe-event-date-start"));
|
||||||
if (list.size() == 1) return Payload.of(list.getFirst());
|
if (list.size() == 1) return Payload.of(list.getFirst());
|
||||||
return Error.of("Failed to locate start tag");
|
return error("Failed to locate start tag");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -100,12 +100,12 @@ public class Rosenkeller extends BaseImporter {
|
|||||||
protected Result<Tag> extractTitleTag(Tag eventTag) {
|
protected Result<Tag> extractTitleTag(Tag eventTag) {
|
||||||
var list = eventTag.find(attributeEndsWith("class", "single-event-title"));
|
var list = eventTag.find(attributeEndsWith("class", "single-event-title"));
|
||||||
if (list.size() == 1) return Payload.of(list.getFirst());
|
if (list.size() == 1) return Payload.of(list.getFirst());
|
||||||
return Error.of("Failed to find title tag");
|
return error("Failed to find title tag");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Result<LocalDateTime> parseEndDate(String text) {
|
protected Result<LocalDateTime> parseEndDate(String text) {
|
||||||
return Error.of("parseEndDate(…) not supported");
|
return error("parseEndDate(…) not supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -123,7 +123,7 @@ public class Rosenkeller extends BaseImporter {
|
|||||||
if (date.isBefore(now)) date = date.plusYears(1);
|
if (date.isBefore(now)) date = date.plusYears(1);
|
||||||
return Payload.of(date);
|
return Payload.of(date);
|
||||||
}
|
}
|
||||||
return Error.format("Failed to recognize a date in \"%s\"", text);
|
return error("Failed to recognize a date in \"%s\"", text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ dependencies {
|
|||||||
implementation(project(":de.srsoftware.cal.base"))
|
implementation(project(":de.srsoftware.cal.base"))
|
||||||
implementation(project(":de.srsoftware.cal.db"))
|
implementation(project(":de.srsoftware.cal.db"))
|
||||||
|
|
||||||
implementation("de.srsoftware:tools.http:1.0.4")
|
implementation("de.srsoftware:tools.http:1.2.1")
|
||||||
implementation("de.srsoftware:tools.optionals:1.0.0")
|
implementation("de.srsoftware:tools.optionals:1.0.0")
|
||||||
implementation("de.srsoftware:tools.util:1.2.3")
|
implementation("de.srsoftware:tools.util:1.3.0")
|
||||||
implementation("org.json:json:20240303")
|
implementation("org.json:json:20240303")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/* © SRSoftware 2024 */
|
||||||
|
package de.srsoftware.cal;
|
||||||
|
|
||||||
|
import static de.srsoftware.cal.db.Fields.*;
|
||||||
|
import static de.srsoftware.cal.db.Fields.AID;
|
||||||
|
import static de.srsoftware.tools.Error.error;
|
||||||
|
import static de.srsoftware.tools.Optionals.nullIfEmpty;
|
||||||
|
import static de.srsoftware.tools.Optionals.nullable;
|
||||||
|
import static de.srsoftware.tools.Result.transform;
|
||||||
|
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.PathHandler;
|
||||||
|
import de.srsoftware.tools.Payload;
|
||||||
|
import de.srsoftware.tools.Result;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean doDelete(String path, HttpExchange ex) throws IOException {
|
||||||
|
if ("/event".equals(path)) return sendContent(ex, delete(path, ex));
|
||||||
|
return unknownPath(ex, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean doGet(String path, HttpExchange ex) throws IOException {
|
||||||
|
return switch (path) {
|
||||||
|
case "/event" -> sendContent(ex,getEvent(ex).map(ApiEndpoint::toJson).map(ApiEndpoint::httpError));
|
||||||
|
case "/events/json" -> sendContent(ex,eventList(ex).map(ApiEndpoint::toJsonList).map(ApiEndpoint::httpError));
|
||||||
|
case "/events/ical"-> sendContent(ex,eventList(ex).map(ApiEndpoint::toIcal).map(ApiEndpoint::httpError));
|
||||||
|
case "/tags" -> listTags(ex);
|
||||||
|
default -> unknownPath(ex,path);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean listTags(HttpExchange ex) throws IOException {
|
||||||
|
var params = queryParam(ex);
|
||||||
|
var infix = params.get("infix");
|
||||||
|
if (infix == null) return sendContent(ex, error("No infix set in method call parameters"));
|
||||||
|
var res = db.findTags(infix).map(ApiEndpoint::sortTags);
|
||||||
|
return sendContent(ex, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<List<String>> sortTags(Result<List<String>> listResult) {
|
||||||
|
if (listResult.optional().isEmpty()) return listResult;
|
||||||
|
List<String> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean doPatch(String path, HttpExchange ex) throws IOException {
|
||||||
|
if ("/event".equals(path)) return sendContent(ex, updateEvent(ex));
|
||||||
|
return unknownPath(ex, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean doPost(String path, HttpExchange ex) throws IOException {
|
||||||
|
if ("/event".equals(path)) return sendContent(ex, newEvent(ex));
|
||||||
|
return unknownPath(ex, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<JSONObject> newEvent(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 error("Missing title header");
|
||||||
|
if (!title.get().equals(event.title())) return error("Title mismatch!");
|
||||||
|
return db.add(opt.get()).map(ApiEndpoint::toJson);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return error(e,"Failed to read event data from request body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<JSONObject> 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 error("Missing title header");
|
||||||
|
if (!title.get().equals(event.title())) return error("Title mismatch!");
|
||||||
|
return db.update(opt.get()).map(ApiEndpoint::toJson);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return error(e,"Failed to read event data from request body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<Appointment> getEvent(HttpExchange ex) {
|
||||||
|
var params = queryParam(ex);
|
||||||
|
var o = params.get(ID);
|
||||||
|
if (o == null) return error("id parameter missing!");
|
||||||
|
try {
|
||||||
|
long id = Long.parseLong(o);
|
||||||
|
return db.loadEvent(id);
|
||||||
|
} catch (NumberFormatException e){
|
||||||
|
return error(e,"Illegal format for id parameter (%s)",o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<BaseAppointment> 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(BaseImporter::url)
|
||||||
|
.map(BaseImporter::toAttachment)
|
||||||
|
.optional()
|
||||||
|
.ifPresent(event::add);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static Result<Link> toLink(JSONObject json) {
|
||||||
|
try {
|
||||||
|
var description = json.getString(DESCRIPTION);
|
||||||
|
return Payload.of(json.getString(URL)).map(BaseImporter::url).map(url -> BaseImporter.link(url, description));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return error(e, "Failed to create link from %s", json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<String> toIcal(Result<List<Appointment>> res) {
|
||||||
|
var opt = res.optional();
|
||||||
|
if (opt.isEmpty()) return transform(res);
|
||||||
|
var list = opt.get().stream().map(Appointment::ical).collect(Collectors.joining("\n"));
|
||||||
|
return Payload.of(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<List<JSONObject>> toJsonList(Result<List<Appointment>> res) {
|
||||||
|
var opt = res.optional();
|
||||||
|
if (opt.isEmpty()) return transform(res);
|
||||||
|
var list = opt.get().stream().map(Appointment::json).toList();
|
||||||
|
return Payload.of(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<JSONObject> toJson(Result<? extends Appointment> res) {
|
||||||
|
var opt = res.optional();
|
||||||
|
if (opt.isEmpty()) return transform(res);
|
||||||
|
return Payload.of(opt.get().json());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<List<Appointment>> eventList(HttpExchange ex) {
|
||||||
|
var param = queryParam(ex);
|
||||||
|
var tags = nullable(param.get(TAGS)) //
|
||||||
|
.stream()
|
||||||
|
.flatMap(s -> Arrays.stream(s.split(",")))
|
||||||
|
.map(s -> s.trim().toLowerCase())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Result<LocalDateTime> start = parseDate(param.get(START));
|
||||||
|
if (start == null) start = parsePast(param.get(PAST));
|
||||||
|
if (start instanceof de.srsoftware.tools.Error<LocalDateTime> err) return err.transform();
|
||||||
|
var startDate = start == null ? null : start.optional().orElse(null);
|
||||||
|
Integer offset = null;
|
||||||
|
var o = param.get(OFFSET);
|
||||||
|
if (o != null) try {
|
||||||
|
offset = Integer.parseInt(o);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return error("Offset (offset=%s) is not a number!", o);
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer count = null;
|
||||||
|
o = param.get(COUNT);
|
||||||
|
if (o != null) try {
|
||||||
|
count = Integer.parseInt(o);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return error("Count (count=%s) is not a number!", o);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.list(startDate, null, count, offset).map(res -> filterByTags(res, tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<List<Appointment>> filterByTags(Result<List<Appointment>> res, List<String> 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 static boolean tagsMatch(Appointment event, List<String> tags) {
|
||||||
|
return new HashSet<>(event.tags().stream().map(String::toLowerCase).toList()).containsAll(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<LocalDateTime> parsePast(String s) {
|
||||||
|
var start = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||||
|
if (s == null) return Payload.of(start);
|
||||||
|
var matcher = PAST_PATTERN.matcher(s);
|
||||||
|
if (matcher.find()) {
|
||||||
|
for (int i = 0; i <= matcher.groupCount(); i++) System.out.printf("%s: %s\n", i, matcher.group(i));
|
||||||
|
|
||||||
|
int num = nullable(matcher.group(1)).map(Integer::parseInt).orElse(0);
|
||||||
|
switch (nullable(matcher.group(2)).orElse("")) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<LocalDateTime> parseDate(String s) {
|
||||||
|
if (s == null) 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result<Long> delete(String 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(400, "%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 Result<Long> deleteEvent(Result<Appointment> 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean unknownPath(HttpExchange ex, String path) throws IOException {
|
||||||
|
return sendContent(ex, HttpError.of(404, "%s is not known to this API", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> Result<T> httpError(Result<T> res) {
|
||||||
|
if (res instanceof NotFound<T> notFound) return HttpError.of(404, notFound.message());
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
/* © SRSoftware 2024 */
|
|
||||||
package de.srsoftware.cal;
|
|
||||||
|
|
||||||
import static de.srsoftware.cal.db.Fields.*;
|
|
||||||
import static de.srsoftware.tools.Optionals.*;
|
|
||||||
import static java.lang.System.*;
|
|
||||||
import static java.lang.System.Logger.Level.WARNING;
|
|
||||||
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.tools.*;
|
|
||||||
import de.srsoftware.tools.Error;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
public class ApiHandler extends PathHandler {
|
|
||||||
private static final Logger LOG = getLogger(ApiHandler.class.getSimpleName());
|
|
||||||
private static final String ATTACHMENTS = "attachments";
|
|
||||||
private static final String LINKS = "links";
|
|
||||||
private static final String TAGS = "tags";
|
|
||||||
private final Database db;
|
|
||||||
|
|
||||||
public ApiHandler(Database db) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean doGet(String path, HttpExchange ex) throws IOException {
|
|
||||||
var params = queryParam(ex);
|
|
||||||
return switch (path) {
|
|
||||||
case "/event" -> loadEvent(ex,params);
|
|
||||||
case "/events/list" -> listEvents(ex,params);
|
|
||||||
case "/tags" -> listTags(ex,params);
|
|
||||||
default -> PathHandler.notFound(ex);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean doDelete(String path, HttpExchange ex) throws IOException {
|
|
||||||
var params = queryParam(ex);
|
|
||||||
return switch (path) {
|
|
||||||
case "/event" -> delete(ex,params);
|
|
||||||
default -> PathHandler.notFound(ex);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean delete(HttpExchange ex, Map<String,String> params) throws IOException {
|
|
||||||
var aid = params.get(AID);
|
|
||||||
if (aid == null) return sendContent(ex,Error.of("Missing appointment id"));
|
|
||||||
long id = 0;
|
|
||||||
try {
|
|
||||||
id = Long.parseLong(aid);
|
|
||||||
} catch (Exception e){
|
|
||||||
return sendContent(ex,Error.format("%s is not a valid id!",aid));
|
|
||||||
}
|
|
||||||
var json = json(ex);
|
|
||||||
var title = json.has(TITLE) ? nullIfEmpty(json.getString(TITLE)) :
|
|
||||||
null;
|
|
||||||
if (title == null) return sendContent(ex, Error.of("Missing appointment title"));
|
|
||||||
var res = db.loadEvent(id);
|
|
||||||
if (res.optional().isEmpty()) return sendContent(ex, res);
|
|
||||||
var event = res.optional().get();
|
|
||||||
if (!title.equals(event.title())) return sendContent(ex, Error.of("Title mismatch!"));
|
|
||||||
var del = db.removeAppointment(id);
|
|
||||||
if (del.optional().isEmpty()) return sendContent(ex,del);
|
|
||||||
return sendContent(ex,Map.of("deleted",del.optional().get()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean doPost(String path, HttpExchange ex) throws IOException {
|
|
||||||
return switch (path) {
|
|
||||||
case "/event/edit" -> editEvent(ex);
|
|
||||||
default -> PathHandler.notFound(ex);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// spotless:off
|
|
||||||
private boolean editEvent(HttpExchange ex) throws IOException {
|
|
||||||
var json = json(ex);
|
|
||||||
Optional<Appointment> existingAppointment = Optional.empty();
|
|
||||||
Long aid = json.has(AID) ? json.getLong(AID) : null;
|
|
||||||
if (aid != null) { // load appointment by aid
|
|
||||||
existingAppointment = db.loadEvent(aid).optional();
|
|
||||||
} else { // try to load appointment by location @ time
|
|
||||||
var location = json.has(LOCATION) ? json.getString(LOCATION) : null;
|
|
||||||
var start = json.has(START) ? LocalDateTime.parse(json.getString(START)) : null;
|
|
||||||
if (allSet(location, start)) {
|
|
||||||
existingAppointment = db.loadEvent(location, start).optional();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (existingAppointment.isPresent()) {
|
|
||||||
var event = existingAppointment.get();
|
|
||||||
json.put(AID,event.id());
|
|
||||||
return update(ex,toEvent(json));
|
|
||||||
}
|
|
||||||
return createEvent(ex, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// spotless:on
|
|
||||||
|
|
||||||
private Result<BaseAppointment> 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.of("title missing");
|
|
||||||
var start = json.has(START) ? nullIfEmpty(json.getString(START)) : null;
|
|
||||||
if (start == null) return Error.of("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.of("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(BaseImporter::url)
|
|
||||||
.map(BaseImporter::toAttachment)
|
|
||||||
.optional()
|
|
||||||
.ifPresent(event::add);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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 boolean createEvent(HttpExchange ex, JSONObject json) throws IOException {
|
|
||||||
var eventRes = toEvent(json);
|
|
||||||
if (eventRes.optional().isPresent()) {
|
|
||||||
return sendContent(ex, db.add(eventRes.optional().get()).map(ApiHandler::toJson));
|
|
||||||
}
|
|
||||||
return sendContent(ex, eventRes);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static Result<Link> toLink(JSONObject json) {
|
|
||||||
try {
|
|
||||||
var description = json.getString(DESCRIPTION);
|
|
||||||
return Payload.of(json.getString(URL)).map(BaseImporter::url).map(url -> BaseImporter.link(url, description));
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
return Error.of("Failed to create link from %s".formatted(json), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean update(HttpExchange ex, Result<BaseAppointment> event) throws IOException {
|
|
||||||
if (event.optional().isPresent()) return sendContent(ex, db.update(event.optional().get()).map(ApiHandler::toJson));
|
|
||||||
return sendContent(ex, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean listTags(HttpExchange ex, Map<String, String> params) throws IOException {
|
|
||||||
var infix = params.get("infix");
|
|
||||||
if (infix == null) return sendContent(ex, Error.of("No infix set in method call parameters"));
|
|
||||||
var res = db.findTags(infix).map(ApiHandler::sortTags);
|
|
||||||
return sendContent(ex, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Result<List<String>> sortTags(Result<List<String>> listResult) {
|
|
||||||
if (listResult.optional().isEmpty()) return listResult;
|
|
||||||
List<String> 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 boolean listEvents(HttpExchange ex, Map<String, String> params) throws IOException {
|
|
||||||
var start = nullable(params.get("start")).map(ApiHandler::toLocalDateTime).orElse(null);
|
|
||||||
var end = nullable(params.get("end")).map(ApiHandler::toLocalDateTime).orElse(null);
|
|
||||||
try {
|
|
||||||
return PathHandler.sendContent(ex, db.list(start, end).stream().map(Appointment::json).toList());
|
|
||||||
} catch (SQLException e) {
|
|
||||||
LOG.log(WARNING, "Failed to fetch events (start = {0}, end = {1}!", start, end, e);
|
|
||||||
}
|
|
||||||
return PathHandler.notFound(ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean loadEvent(HttpExchange ex, Map<String, String> params) throws IOException {
|
|
||||||
var id = params.get("id");
|
|
||||||
if (id != null) try {
|
|
||||||
return PathHandler.sendContent(ex, db.loadEvent(Long.parseLong(id)).map(ApiHandler::toJson));
|
|
||||||
} catch (NumberFormatException | IOException nfe) {
|
|
||||||
return PathHandler.sendContent(ex, Error.format("%s is not a numeric event id!", id));
|
|
||||||
}
|
|
||||||
return PathHandler.sendContent(ex, Error.of("ID missing"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Result<JSONObject> toJson(Result<Appointment> res) {
|
|
||||||
var opt = res.optional();
|
|
||||||
if (opt.isEmpty()) return Result.transform(res);
|
|
||||||
return Payload.of(opt.get().json());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static LocalDateTime toLocalDateTime(String dateString) {
|
|
||||||
try {
|
|
||||||
return LocalDate.parse(dateString + "-01", DateTimeFormatter.ISO_LOCAL_DATE).atTime(0, 0);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span id="new_event">
|
<span id="new_event">
|
||||||
<button onclick="window.location = '/static/edit'">Create new event</button>
|
<button onclick="window.location = '/static/edit'">Create new event</button>
|
||||||
|
<button onclick="openIcal()">ical</button>
|
||||||
</span>
|
</span>
|
||||||
<table id="eventlist">
|
<table id="eventlist">
|
||||||
<tr class="head">
|
<tr class="head">
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
function create(type){
|
||||||
|
return document.createElement(type);
|
||||||
|
}
|
||||||
|
|
||||||
function element(id){
|
function element(id){
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
}
|
}
|
||||||
@@ -170,8 +170,9 @@ async function saveEvent(){
|
|||||||
|
|
||||||
var location = element('location').value;
|
var location = element('location').value;
|
||||||
var start = element('start').value;
|
var start = element('start').value;
|
||||||
|
var aid = element('aid').value;
|
||||||
var event = {
|
var event = {
|
||||||
aid : element('aid').value,
|
aid : aid ? aid : 0,
|
||||||
title : element('title').value,
|
title : element('title').value,
|
||||||
description : element('description').value,
|
description : element('description').value,
|
||||||
location : element('location').value,
|
location : element('location').value,
|
||||||
@@ -182,11 +183,12 @@ async function saveEvent(){
|
|||||||
coords: element('coords').value,
|
coords: element('coords').value,
|
||||||
attachments: getAttachments()
|
attachments: getAttachments()
|
||||||
};
|
};
|
||||||
fetch('/api/event/edit',{
|
fetch('/api/event',{
|
||||||
method: 'POST',
|
method: aid ? 'PATCH' : 'POST', // if aid is set, we do an update. otherwise save new
|
||||||
body: JSON.stringify(event),
|
body: JSON.stringify(event),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type' : 'appication/json'
|
'Content-Type' : 'appication/json',
|
||||||
|
'title': event.title
|
||||||
}
|
}
|
||||||
}).then(handleSave);
|
}).then(handleSave);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ function confirmDelete(){
|
|||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
var id = urlParams.get('id');
|
var id = urlParams.get('id');
|
||||||
if (confirm(`Do you really want to delete "${title}"?`)){
|
if (confirm(`Do you really want to delete "${title}"?`)){
|
||||||
fetch('/api/event?aid='+id,{
|
fetch('/api/event?id='+id,{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
body: JSON.stringify({
|
headers: {
|
||||||
title: title
|
title: title
|
||||||
})
|
}
|
||||||
}).then(handleDeleted);
|
}).then(handleDeleted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ var end = null;
|
|||||||
var tags = new Set();
|
var tags = new Set();
|
||||||
var highlight = null;
|
var highlight = null;
|
||||||
|
|
||||||
|
// add a cell to the given row, put link to event(id) with given content
|
||||||
function addCell(row,content,id){
|
function addCell(row,content,id){
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
if (content){
|
if (content){
|
||||||
@@ -12,8 +13,9 @@ function addCell(row,content,id){
|
|||||||
row.insertCell().appendChild(a);
|
row.insertCell().appendChild(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add a row to the table, fill with event data from json
|
||||||
function addRow(json){
|
function addRow(json){
|
||||||
var table = document.getElementById('eventlist');
|
var table = element('eventlist');
|
||||||
var row = table.insertRow(1);
|
var row = table.insertRow(1);
|
||||||
row.id = json.id;
|
row.id = json.id;
|
||||||
if (json.tags){
|
if (json.tags){
|
||||||
@@ -28,6 +30,7 @@ function addRow(json){
|
|||||||
row.appendChild(createTags(json.tags));
|
row.appendChild(createTags(json.tags));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createTags(tagList){
|
function createTags(tagList){
|
||||||
var td = document.createElement('td');
|
var td = document.createElement('td');
|
||||||
tagList.forEach(val => {
|
tagList.forEach(val => {
|
||||||
@@ -40,8 +43,9 @@ function createTags(tagList){
|
|||||||
return td;
|
return td;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetch events in the time range, then call handleEvents
|
||||||
function fetchEvents(start, end){
|
function fetchEvents(start, end){
|
||||||
var path = '/api/events/list';
|
var path = '/api/events/json';
|
||||||
if (start) {
|
if (start) {
|
||||||
path += '?start='+start;
|
path += '?start='+start;
|
||||||
if (end) path+= '&end='+end;
|
if (end) path+= '&end='+end;
|
||||||
@@ -73,6 +77,7 @@ function fetchLastYear(){
|
|||||||
fetchEvents(start,end);
|
fetchEvents(start,end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add the events fetched with the latest request to the table
|
||||||
async function handleEvents(response){
|
async function handleEvents(response){
|
||||||
if (response.ok){
|
if (response.ok){
|
||||||
var json = await response.json();
|
var json = await response.json();
|
||||||
@@ -88,9 +93,16 @@ async function handleEvents(response){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// called when page is loaded
|
||||||
function loadCurrentEvents(){
|
function loadCurrentEvents(){
|
||||||
let params = new URLSearchParams(location.search);
|
let params = new URLSearchParams(location.search);
|
||||||
highlight = params.get('id');
|
highlight = params.get('id');
|
||||||
|
var tagString = params.get('tags');
|
||||||
|
if (tagString){
|
||||||
|
tagString.split(',').forEach(t => {
|
||||||
|
tags.add(t.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
if (start == null){
|
if (start == null){
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var year = now.getFullYear();
|
var year = now.getFullYear();
|
||||||
@@ -100,6 +112,20 @@ function loadCurrentEvents(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openIcal(){
|
||||||
|
var url = location.href;
|
||||||
|
var query = location.search;
|
||||||
|
var pos = url.indexOf('?');
|
||||||
|
if (pos>1) url = url.substring(0,pos);
|
||||||
|
if (!url.endsWith('/')) url += '/';
|
||||||
|
url += 'api/ical'+query;
|
||||||
|
var elem = create('a');
|
||||||
|
elem.setAttribute('href',url);
|
||||||
|
elem.setAttribute('download','calendar.ical');
|
||||||
|
elem.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// shows the given url in an overlayed iframe
|
||||||
function showOverlay(url){
|
function showOverlay(url){
|
||||||
var div = document.getElementById('overlay');
|
var div = document.getElementById('overlay');
|
||||||
div.innerHTML = '';
|
div.innerHTML = '';
|
||||||
@@ -115,19 +141,22 @@ function showOverlay(url){
|
|||||||
div.appendChild(closeBtn);
|
div.appendChild(closeBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adds or removes tag from set, updates shown tags
|
||||||
function toggleTag(tag){
|
function toggleTag(tag){
|
||||||
if (tags.has(tag)){
|
if (tags.has(tag)){
|
||||||
tags.delete(tag);
|
tags.delete(tag);
|
||||||
} else {
|
} else {
|
||||||
tags.add(tag);
|
tags.add(tag);
|
||||||
}
|
}
|
||||||
|
updateUrl();
|
||||||
updateTagVisibility();
|
updateTagVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// creates a button for each tag in tags set
|
||||||
function updateTagVisibility(){
|
function updateTagVisibility(){
|
||||||
var selection = document.getElementById('tag_selection');
|
var selection = document.getElementById('tag_selection');
|
||||||
selection.innerHTML = 'Selected Tags: ';
|
selection.innerHTML = 'Selected Tags: ';
|
||||||
selection.style.display = tags.size > 0 ? 'inline' : 'none';
|
selection.style.display = tags.size > 0 ? 'block' : 'none';
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.onclick = e => toggleTag(tag);
|
btn.onclick = e => toggleTag(tag);
|
||||||
@@ -146,3 +175,17 @@ function updateTagVisibility(){
|
|||||||
tr.style.display = all ? 'table-row' : 'none';
|
tr.style.display = all ? 'table-row' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateUrl(){
|
||||||
|
var url = location.href;
|
||||||
|
var pos = url.indexOf('?');
|
||||||
|
if (pos>0) url = url.substring(0,pos);
|
||||||
|
var params = [];
|
||||||
|
if (highlight) params.push('id='+highlight);
|
||||||
|
if (start) params.push('start='+start);
|
||||||
|
var tagString = Array.from(tags).join(',');
|
||||||
|
if (tagString) params.push('tags='+tagString);
|
||||||
|
var query = params.join('&');
|
||||||
|
if (query) url += '?'+query;
|
||||||
|
window.history.pushState(null,"",url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* © SRSoftware 2024 */
|
||||||
|
import static de.srsoftware.cal.ApiEndpoint.parseDate;
|
||||||
|
import static java.time.LocalDateTime.of;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class ParseDateTest {
|
||||||
|
@Test
|
||||||
|
public void testFull() {
|
||||||
|
assertEquals(of(2024, 12, 11, 10, 9, 8), parseDate("2024-12-11T10:09:08").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 11, 10, 9, 8), parseDate("2024-12-11 10:09:08").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 11, 10, 9, 8), parseDate("2024-12-11T10:9:8").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 11, 10, 9, 8), parseDate("2024-12-11 10:9:8").optional().orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNoSecond() {
|
||||||
|
assertEquals(of(2024, 12, 11, 10, 9), parseDate("2024-12-11T10:09").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 11, 10, 9), parseDate("2024-12-11 10:09").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 11, 10, 9), parseDate("2024-12-11T10:9").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 11, 8, 9), parseDate("2024-12-11 8:9").optional().orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNoTime() {
|
||||||
|
assertEquals(of(2024, 12, 11, 0, 0), parseDate("2024-12-11").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 11, 0, 0), parseDate("2024-12-11").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 5, 0, 0), parseDate("2024-12-5").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 5, 0, 0), parseDate("2024-12-5").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 6, 5, 0, 0), parseDate("2024-6-5").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 6, 5, 0, 0), parseDate("2024-6-5").optional().orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNoDay() {
|
||||||
|
assertEquals(of(2024, 12, 1, 0, 0), parseDate("2024-12").optional().orElse(null));
|
||||||
|
assertEquals(of(2024, 12, 1, 0, 0), parseDate("2024-12").optional().orElse(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/* © SRSoftware 2024 */
|
||||||
|
import static de.srsoftware.cal.ApiEndpoint.parsePast;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class ParsePastTest {
|
||||||
|
@Test
|
||||||
|
public void testMonth() {
|
||||||
|
var expected = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||||
|
assertEquals(expected, parsePast(null).optional().orElse(null));
|
||||||
|
assertEquals(expected.minusMonths(1), parsePast("1m").optional().orElse(null));
|
||||||
|
assertEquals(expected.minusMonths(2), parsePast("2m").optional().orElse(null));
|
||||||
|
assertEquals(expected.minusMonths(12), parsePast("12m").optional().orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testYear() {
|
||||||
|
var expected = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||||
|
assertEquals(expected, parsePast(null).optional().orElse(null));
|
||||||
|
assertEquals(expected.minusYears(1), parsePast("1y").optional().orElse(null));
|
||||||
|
assertEquals(expected.minusYears(2), parsePast("2y").optional().orElse(null));
|
||||||
|
assertEquals(expected.minusYears(12), parsePast("12y").optional().orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAll() {
|
||||||
|
assertEquals(null, parsePast("all"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDefault() {
|
||||||
|
var expected = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||||
|
assertEquals(expected, parsePast(null).optional().get());
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
-105
@@ -13,110 +13,6 @@ servers:
|
|||||||
- url: https://cal.srsoftware.de
|
- url: https://cal.srsoftware.de
|
||||||
- url: http://localhost:8080
|
- url: http://localhost:8080
|
||||||
paths:
|
paths:
|
||||||
/api/events/json:
|
|
||||||
get:
|
|
||||||
description: |-
|
|
||||||
Get a list of events from the server in JSON format.
|
|
||||||
|
|
||||||
Filters may be applied by using request parameters.
|
|
||||||
parameters:
|
|
||||||
- description: Filter keywords. Only events having all provided tags are listed
|
|
||||||
example: magrathea,heartofgold
|
|
||||||
in: query
|
|
||||||
name: tags
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
format: comma-separated values
|
|
||||||
type: string
|
|
||||||
- description: start time. Only events after this date time are returned
|
|
||||||
example: 2024-12-30T10:32
|
|
||||||
in: query
|
|
||||||
name: start
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
format: date-time
|
|
||||||
type: string
|
|
||||||
- description: |-
|
|
||||||
Return past events, if set. Allowed values:
|
|
||||||
* all → return all events
|
|
||||||
* [n]m – return events including the last n months
|
|
||||||
* [n]y - return events including the last n years
|
|
||||||
example: 2m
|
|
||||||
in: query
|
|
||||||
name: past
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: successful operation
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/AppointmentList'
|
|
||||||
'400':
|
|
||||||
description: invalid input
|
|
||||||
'500':
|
|
||||||
description: server fault
|
|
||||||
summary: Fetch event list
|
|
||||||
/api/events/ical:
|
|
||||||
get:
|
|
||||||
description: |-
|
|
||||||
Get a list of events from the server in ICAL format.
|
|
||||||
|
|
||||||
Filters may be applied by using request parameters.
|
|
||||||
parameters:
|
|
||||||
- description: Filter keywords. Only events having all provided tags are listed
|
|
||||||
example: magrathea,heartofgold
|
|
||||||
in: query
|
|
||||||
name: tags
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
format: comma-separated values
|
|
||||||
type: string
|
|
||||||
- description: start time. Only events after this date time are returned
|
|
||||||
example: 2024-12-30T10:32
|
|
||||||
in: query
|
|
||||||
name: start
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
format: date-time
|
|
||||||
type: string
|
|
||||||
- description: |-
|
|
||||||
Return past events, if set. Allowed values:
|
|
||||||
* all → return all events
|
|
||||||
* [n]m – return events including the last n months
|
|
||||||
* [n]y - return events including the last n years
|
|
||||||
example: 2m
|
|
||||||
in: query
|
|
||||||
name: past
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: successful operation
|
|
||||||
content:
|
|
||||||
text/calendar:
|
|
||||||
schema:
|
|
||||||
description: event list 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: Fetch event list
|
|
||||||
/api/event:
|
/api/event:
|
||||||
delete:
|
delete:
|
||||||
description: Deletes the specified element from the list of events.
|
description: Deletes the specified element from the list of events.
|
||||||
@@ -139,6 +35,8 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: successfull operation
|
description: successfull operation
|
||||||
|
'400':
|
||||||
|
description: id missing
|
||||||
'404':
|
'404':
|
||||||
description: invalid id (no such event)
|
description: invalid id (no such event)
|
||||||
'412':
|
'412':
|
||||||
@@ -219,7 +117,155 @@ paths:
|
|||||||
description: invalid input
|
description: invalid input
|
||||||
'500':
|
'500':
|
||||||
description: server fault
|
description: server fault
|
||||||
summary: store a new event in the database
|
summary:
|
||||||
|
store a new event in the database
|
||||||
|
/api/events/ical:
|
||||||
|
get:
|
||||||
|
description: |-
|
||||||
|
Get a list of events from the server in ICAL format.
|
||||||
|
|
||||||
|
Filters may be applied by using request parameters.
|
||||||
|
parameters:
|
||||||
|
- description: Filter keywords. Only events having all provided tags are listed
|
||||||
|
example: magrathea,heartofgold
|
||||||
|
in: query
|
||||||
|
name: tags
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
format: comma-separated values
|
||||||
|
type: string
|
||||||
|
- description: start time. Only events after this date time are returned
|
||||||
|
example: 2024-12-30T10:32
|
||||||
|
in: query
|
||||||
|
name: start
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
- description: |-
|
||||||
|
Return past events, if set. Allowed values:
|
||||||
|
* all → return all events
|
||||||
|
* [n]m – return events including the last n months
|
||||||
|
* [n]y - return events including the last n years
|
||||||
|
example: 2m
|
||||||
|
in: query
|
||||||
|
name: past
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
text/calendar:
|
||||||
|
schema:
|
||||||
|
description: event list 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: Fetch event list
|
||||||
|
/api/events/json:
|
||||||
|
get:
|
||||||
|
description: |-
|
||||||
|
Get a list of events from the server in JSON format.
|
||||||
|
|
||||||
|
Filters may be applied by using request parameters.
|
||||||
|
parameters:
|
||||||
|
- description: Maximum number of appointments to return
|
||||||
|
example: 100
|
||||||
|
in: query
|
||||||
|
name: count
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
format: int32
|
||||||
|
type: number
|
||||||
|
- description: skip number of appointments when listing
|
||||||
|
example: 50
|
||||||
|
in: query
|
||||||
|
name: offset
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
format: int32
|
||||||
|
type: number
|
||||||
|
- description: |-
|
||||||
|
Return past events, if set. Allowed values:
|
||||||
|
* all → return all events
|
||||||
|
* [n]m – return events including the last n months
|
||||||
|
* [n]y - return events including the last n years
|
||||||
|
example: 2m
|
||||||
|
in: query
|
||||||
|
name: past
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: start time. Only events after this date time are returned
|
||||||
|
example: 2024-12-30T10:32
|
||||||
|
in: query
|
||||||
|
name: start
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
- description: Filter keywords. Only events having all provided tags are listed
|
||||||
|
example: magrathea,heartofgold
|
||||||
|
in: query
|
||||||
|
name: tags
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
format: comma-separated values
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AppointmentList'
|
||||||
|
'400':
|
||||||
|
description: invalid input
|
||||||
|
'500':
|
||||||
|
description: server fault
|
||||||
|
summary:
|
||||||
|
Fetch event list
|
||||||
|
/api/tags:
|
||||||
|
get:
|
||||||
|
description: list tags
|
||||||
|
parameters:
|
||||||
|
- description: substring to search in tags
|
||||||
|
example: rock
|
||||||
|
in: query
|
||||||
|
name: infix
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
description: keywords that describe the event. may be used to filter events
|
||||||
|
example:
|
||||||
|
- GlamRock
|
||||||
|
- HardRock
|
||||||
|
- Rockabilly
|
||||||
|
- RockNRoll
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
Appointment:
|
Appointment:
|
||||||
|
|||||||
Reference in New Issue
Block a user