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:
2024-12-31 00:02:14 +01:00
parent 10ad309d8c
commit 4822320961
28 changed files with 874 additions and 542 deletions
+2 -2
View File
@@ -5,8 +5,8 @@ dependencies {
implementation(project(":de.srsoftware.cal.base"))
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.util:1.2.3")
implementation("de.srsoftware:tools.util:1.3.0")
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 id="new_event">
<button onclick="window.location = '/static/edit'">Create new event</button>
<button onclick="openIcal()">ical</button>
</span>
<table id="eventlist">
<tr class="head">
@@ -1,3 +1,7 @@
function create(type){
return document.createElement(type);
}
function element(id){
return document.getElementById(id);
}
@@ -170,8 +170,9 @@ async function saveEvent(){
var location = element('location').value;
var start = element('start').value;
var aid = element('aid').value;
var event = {
aid : element('aid').value,
aid : aid ? aid : 0,
title : element('title').value,
description : element('description').value,
location : element('location').value,
@@ -182,11 +183,12 @@ async function saveEvent(){
coords: element('coords').value,
attachments: getAttachments()
};
fetch('/api/event/edit',{
method: 'POST',
fetch('/api/event',{
method: aid ? 'PATCH' : 'POST', // if aid is set, we do an update. otherwise save new
body: JSON.stringify(event),
headers: {
'Content-Type' : 'appication/json'
'Content-Type' : 'appication/json',
'title': event.title
}
}).then(handleSave);
}
@@ -71,11 +71,11 @@ function confirmDelete(){
const urlParams = new URLSearchParams(window.location.search);
var id = urlParams.get('id');
if (confirm(`Do you really want to delete "${title}"?`)){
fetch('/api/event?aid='+id,{
fetch('/api/event?id='+id,{
method: 'DELETE',
body: JSON.stringify({
headers: {
title: title
})
}
}).then(handleDeleted);
}
}
@@ -3,6 +3,7 @@ var end = null;
var tags = new Set();
var highlight = null;
// add a cell to the given row, put link to event(id) with given content
function addCell(row,content,id){
var a = document.createElement('a');
if (content){
@@ -12,8 +13,9 @@ function addCell(row,content,id){
row.insertCell().appendChild(a);
}
// add a row to the table, fill with event data from json
function addRow(json){
var table = document.getElementById('eventlist');
var table = element('eventlist');
var row = table.insertRow(1);
row.id = json.id;
if (json.tags){
@@ -28,6 +30,7 @@ function addRow(json){
row.appendChild(createTags(json.tags));
}
function createTags(tagList){
var td = document.createElement('td');
tagList.forEach(val => {
@@ -40,8 +43,9 @@ function createTags(tagList){
return td;
}
// fetch events in the time range, then call handleEvents
function fetchEvents(start, end){
var path = '/api/events/list';
var path = '/api/events/json';
if (start) {
path += '?start='+start;
if (end) path+= '&end='+end;
@@ -73,6 +77,7 @@ function fetchLastYear(){
fetchEvents(start,end);
}
// add the events fetched with the latest request to the table
async function handleEvents(response){
if (response.ok){
var json = await response.json();
@@ -88,9 +93,16 @@ async function handleEvents(response){
}
}
// called when page is loaded
function loadCurrentEvents(){
let params = new URLSearchParams(location.search);
highlight = params.get('id');
var tagString = params.get('tags');
if (tagString){
tagString.split(',').forEach(t => {
tags.add(t.trim());
});
}
if (start == null){
var now = new Date();
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){
var div = document.getElementById('overlay');
div.innerHTML = '';
@@ -115,19 +141,22 @@ function showOverlay(url){
div.appendChild(closeBtn);
}
// adds or removes tag from set, updates shown tags
function toggleTag(tag){
if (tags.has(tag)){
tags.delete(tag);
} else {
tags.add(tag);
}
updateUrl();
updateTagVisibility();
}
// creates a button for each tag in tags set
function updateTagVisibility(){
var selection = document.getElementById('tag_selection');
selection.innerHTML = 'Selected Tags: ';
selection.style.display = tags.size > 0 ? 'inline' : 'none';
selection.style.display = tags.size > 0 ? 'block' : 'none';
tags.forEach(tag => {
var btn = document.createElement('button');
btn.onclick = e => toggleTag(tag);
@@ -145,4 +174,18 @@ function updateTagVisibility(){
});
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());
}
}