From cec1d94cfb2b4b89adbda264ca17b38cbcb86023 Mon Sep 17 00:00:00 2001 From: Wander Nauta Date: Sun, 17 May 2026 19:52:07 +0200 Subject: [PATCH 1/3] add ics (iCalendar) export for activiteiten This adds an endpoint to retrieve a single Activiteit in iCalendar format. Some calendaring applications have both "Add by URL" and "Import from file" features. This should work with both. The former may be more convenient as it will change if the Activiteit is updated. The current version directly exposes the aanvangstijd, eindtijd, nummer, noot, onderwerp, soort, voortouwNaam and zaalnaam fields. A link to the berthub.eu instance is provided to give users a way to quickly find the remaining information (betrokkenen, agendapunten, bijlagen). --- ical.cc | 102 +++++++++++++++++++++++++++++++++++++++ ical.hh | 14 ++++++ icaltest.cc | 83 +++++++++++++++++++++++++++++++ meson.build | 4 +- partials/activiteit.html | 2 +- tkserv.cc | 22 +++++++++ 6 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 ical.cc create mode 100644 ical.hh create mode 100644 icaltest.cc diff --git a/ical.cc b/ical.cc new file mode 100644 index 0000000..5d75cc0 --- /dev/null +++ b/ical.cc @@ -0,0 +1,102 @@ +#include "ical.hh" + +#include +#include + +#include + +#include "peglib.h" // for UTF-8 encode/decode +#include "support.hh" // for deHTML + +using std::string; +using std::string_view; +using std::unordered_map; + +std::string ical(const unordered_map &act) { + string out; + + auto emit = [&out](string_view key8, string_view val8, string_view sep8 = ":") { + size_t anchor = out.length(); + out += key8; + out += sep8; + + // Lines SHOULD be folded to not exceed 75 octets, but not in the middle + // of an UTF-8 multi-octet sequence (RFC5545 3.1); go through UTF-32. + std::u32string val32 = peg::decode(val8.data(), val8.size()); + + for (char32_t ch : val32) { + if ((out.length() - anchor) >= 70) { + out += "\r\n "; + anchor = out.length() - 1; + } + + if (ch == '\n') { + out += "\n"; + } else if (ch == ',' || ch == '\\' || ch == ';') { + out += '\\'; + out += ch; + } else { + out += peg::encode_codepoint(ch); + } + } + + out += "\r\n"; + }; + + auto emitDt = [&emit](string_view key, const string &localTimestamp) { + std::chrono::local_seconds t; + std::chrono::sys_seconds utc; + std::istringstream ss(localTimestamp); + + std::chrono::from_stream(ss, "%Y-%m-%dT%H:%M:%S", t); + utc = std::chrono::current_zone()->to_sys(t); + + emit(key, fmt::format("{:%Y%m%dT%H%M%S}Z", utc)); + }; + + if (act.contains("nummer") && act.contains("aanvangstijd") && act.contains("eindtijd") && act.contains("bijgewerkt")) { + emit("BEGIN", "VCALENDAR"); + emit("VERSION", "2.0"); + emit("PRODID", "https://berthub.eu/tkconv"); + emit("BEGIN", "VEVENT"); + + if (auto f = act.find("onderwerp"); f != act.end() && !f->second.empty()) { + emit("SUMMARY", f->second); + } else { + emit("SUMMARY", "Activiteit " + act.at("nummer")); + } + + if (auto f = act.find("noot"); f != act.end() && !f->second.empty()) { + emit("DESCRIPTION", deHTML(f->second)); + emit("X-ALT-DESC;FMTTYPE=text/html", "" + f->second + ""); + } + + emitDt("DTSTART", act.at("aanvangstijd")); + emitDt("DTEND", act.at("eindtijd")); + emitDt("DTSTAMP", act.at("bijgewerkt")); + + if (auto f = act.find("zaalnaam"); f != act.end() && !f->second.empty()) { + emit("LOCATION", f->second); + } + + if (auto f = act.find("voortouwNaam"); f != act.end() && !f->second.empty()) { + string cn = f->second; + replaceSubstring(cn, "\"", ""); + + // Email address is not very useful here but unfortunately mandatory + emit("ORGANIZER", "CN=\"" + cn + "\":mailto:contact@tweedekamer.nl", ";"); + } + + emit("URL", "https://berthub.eu/tkconv/activiteit.html?nummer=" + act.at("nummer")); + emit("UID", "https://berthub.eu/tkconv/activiteit.html?nummer=" + act.at("nummer")); + + if (auto f = act.find("soort"); f != act.end() && !f->second.empty()) { + emit("CATEGORIES", f->second); + } + + emit("END", "VEVENT"); + emit("END", "VCALENDAR"); + } + + return out; +} diff --git a/ical.hh b/ical.hh new file mode 100644 index 0000000..4af99df --- /dev/null +++ b/ical.hh @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +/* +Geef een beschrijving van een activiteit in iCalendar-formaat, geschikt voor +gebruik in agendasoftware. + +De velden aanvangstijd, bijgewerkt, eindtijd, nummer zijn verplicht. + +De velden noot, onderwerp, soort, voortouwNaam, zaalnaam zijn optioneel. +*/ +std::string ical(const std::unordered_map &activiteit); diff --git a/icaltest.cc b/icaltest.cc new file mode 100644 index 0000000..65dce10 --- /dev/null +++ b/icaltest.cc @@ -0,0 +1,83 @@ +#include "doctest.h" +#include + +#include "ical.hh" + +using std::string; +using std::unordered_map; + +TEST_CASE("iCal voor lege activiteit") { + CHECK_EQ(ical({}), ""); +} + +TEST_CASE("iCal voor representatieve activiteit") { + unordered_map activiteit = { + {"nummer", "2026A03861"}, + {"aanvangstijd", "2026-05-15T12:00:00"}, + {"eindtijd", "2026-05-15T12:00:00"}, + {"soort", "E-mailprocedure"}, + {"onderwerp", "Verzoek van het lid Neijenhuis (D66), mede namens VVD, GroenLinks-PvdA, CDA en JA21, tot uitstel van de inbrengdatum voor het verslag over het wetsvoorstel Wet BAZ (36912)"}, + {"noot", "
In reactie op onderstaande e-mailprocedure met een verzoek van het lid Neijenhuis (D66)..."}, + {"voortouwNaam", "vaste commissie voor Sociale Zaken en Werkgelegenheid"}, + {"zaalnaam", "Fictieve voorbeeldzaal"}, + {"bijgewerkt", "2026-04-10T10:55:25.3870000"} + }; + + string expected = + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:https://berthub.eu/tkconv\r\n" + "BEGIN:VEVENT\r\n" + "SUMMARY:Verzoek van het lid Neijenhuis (D66)\\, mede namens VVD\\, Groen\r\n" + " Links-PvdA\\, CDA en JA21\\, tot uitstel van de inbrengdatum voor het v\r\n" + " erslag over het wetsvoorstel Wet BAZ (36912)\r\n" + "DESCRIPTION: In reactie op onderstaande e-mailprocedure met een verzoe\r\n" + " k van het lid Neijenhuis (D66)...\r\n" + "X-ALT-DESC;FMTTYPE=text/html:
In reactie op onderstaa\r\n" + " nde e-mailprocedure met een verzoek van het lid Neijenhuis (D66)...\r\n" + "DTSTART:20260515T100000Z\r\n" + "DTEND:20260515T100000Z\r\n" + "DTSTAMP:20260410T085525Z\r\n" + "LOCATION:Fictieve voorbeeldzaal\r\n" + "ORGANIZER;CN=\"vaste commissie voor Sociale Zaken en Werkgelegenheid\":m\r\n" + " ailto:contact@tweedekamer.nl\r\n" + "URL:https://berthub.eu/tkconv/activiteit.html?nummer=2026A03861\r\n" + "UID:https://berthub.eu/tkconv/activiteit.html?nummer=2026A03861\r\n" + "CATEGORIES:E-mailprocedure\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n"; + + string actual = ical(activiteit); + + CHECK_EQ(expected, actual); +} + +TEST_CASE("iCal met UTF-8 randgeval") { + unordered_map activiteit = { + {"nummer", "1900A0001"}, + {"aanvangstijd", "2026-05-15T12:00:00"}, + {"eindtijd", "2026-05-15T12:00:00"}, + {"zaalnaam", "ééééééééééééééééééééééééééééééééé"}, + {"bijgewerkt", "2026-04-10T10:55:25.3870000"} + }; + + for (int i = 0; i < 4; i++) { + string actual = ical(activiteit); + size_t loc = actual.find("LOCATION:"); + + // Het resultaat moet een LOCATION bevatten + REQUIRE_NE(loc, std::string::npos); + + // Regel mag niet langer zijn dan 75 octets + REQUIRE_NE(actual.find("\r\n", loc), std::string::npos); + CHECK_LT(actual.find("\r\n", loc) - loc, 75); + + // De beide bytes van é mogen niet zijn opgeknipt... + CHECK_NE(actual.find("é\r\n é", loc), std::string::npos); + + // ...ook niet als we het geheel één positie opschuiven + activiteit["zaalnaam"].insert(0, " "); + } +} + diff --git a/meson.build b/meson.build index e84603f..a195552 100644 --- a/meson.build +++ b/meson.build @@ -83,7 +83,7 @@ executable('oppull', 'oppull.cc', 'support.cc', 'siphash.cc', executable('tkserv', 'tkserv.cc', 'support.cc', 'siphash.cc', 'sws.cc', 'users.cc', 'scanmon.cc', 'search.cc', - 'enrich.cc', + 'enrich.cc', 'ical.cc', dependencies: [sqlitedep, json_dep, simplesockets_dep, fmt_dep, cpphttplib, sqlitewriter_dep, pugi_dep, argparse_dep, vcs_dep, bcryptcpp_dep]) @@ -103,6 +103,6 @@ executable('playground', 'playground.cc', 'support.cc', 'siphash.cc', # argparse_dep, vcs_dep]) -executable('testrunner', 'testrunner.cc', 'support.cc', 'siphash.cc', 'search.cc', 'meta.cc', +executable('testrunner', 'testrunner.cc', 'ical.cc', 'icaltest.cc', 'support.cc', 'siphash.cc', 'search.cc', 'meta.cc', dependencies: [sqlitedep, json_dep, fmt_dep, sqlitedep, sqlitewriter_dep, doctest_dep, cpphttplib, simplesockets_dep]) diff --git a/partials/activiteit.html b/partials/activiteit.html index 152fdff..1fc7fd8 100644 --- a/partials/activiteit.html +++ b/partials/activiteit.html @@ -18,7 +18,7 @@

-

+

diff --git a/tkserv.cc b/tkserv.cc index 7385247..78ee060 100644 --- a/tkserv.cc +++ b/tkserv.cc @@ -18,6 +18,7 @@ #include "thingpool.hh" #include "sws.hh" #include "search.hh" +#include "ical.hh" using namespace std; void addTkUserManagement(SimpleWebSystem& sws, const std::string& mailserver, @@ -1568,6 +1569,27 @@ int main(int argc, char** argv) res.set_content(e.render_file("./partials/activiteit.html", data), "text/html"); }); + sws.d_svr.Get("/activiteit.ics", [&tp](const httplib::Request &req, httplib::Response &res) { + string nummer = req.get_param_value("nummer"); + auto db = tp.getLease(); + const auto acts = db->query("select a.aanvangstijd aanvangstijd, a.bijgewerkt bijgewerkt, a.eindtijd eindtijd, a.nummer nummer, a.noot noot, a.onderwerp onderwerp, a.soort soort, a.voortouwNaam voortouwNaam, z.naam as zaalnaam from Activiteit a left join Reservering r on r.activiteitId=a.id left join Zaal z on z.id=r.zaalId where a.nummer=?", {nummer}); + + if(acts.empty()) { + res.status=404; + res.set_content("Activiteit niet gevonden", "text/plain"); + return; + } + + const auto &act = acts[0]; + + // Thunderbird uses the file basename as the name of the imported calendar, so pick something recognizable + string fn = fmt::format("{} {}.ics", act.at("nummer"), act.at("onderwerp")); + replaceSubstring(fn, "\"", ""); + + res.set_header("Content-Disposition", "attachment; filename=\"" + fn + "\""); + res.set_content(ical(act), "text/calendar"); + }); + sws.d_svr.Get("/activiteiten.html", [&tp](const httplib::Request &req, httplib::Response &res) { // from 4 days ago into the future string dlim = getDateDBFormat(time(0)-4*86400); From 2715ffc05cab3b7bd9874f9251c1877723aa0fc1 Mon Sep 17 00:00:00 2001 From: Wander Nauta Date: Sun, 17 May 2026 20:27:34 +0200 Subject: [PATCH 2/3] use support.cc timestamp helpers instead of std::chrono The std::chrono::from_stream function is still very new. --- ical.cc | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/ical.cc b/ical.cc index 5d75cc0..19d72d4 100644 --- a/ical.cc +++ b/ical.cc @@ -1,6 +1,5 @@ #include "ical.hh" -#include #include #include @@ -44,14 +43,8 @@ std::string ical(const unordered_map &act) { }; auto emitDt = [&emit](string_view key, const string &localTimestamp) { - std::chrono::local_seconds t; - std::chrono::sys_seconds utc; - std::istringstream ss(localTimestamp); - - std::chrono::from_stream(ss, "%Y-%m-%dT%H:%M:%S", t); - utc = std::chrono::current_zone()->to_sys(t); - - emit(key, fmt::format("{:%Y%m%dT%H%M%S}Z", utc)); + time_t ts = getTstamp(localTimestamp); + emit(key, fmt::format("{:%Y%m%dT%H%M%S}Z", fmt::gmtime(ts))); }; if (act.contains("nummer") && act.contains("aanvangstijd") && act.contains("eindtijd") && act.contains("bijgewerkt")) { From dab0e3097a4fbd17baf6d75b7835263d353cd057 Mon Sep 17 00:00:00 2001 From: Wander Nauta Date: Sun, 17 May 2026 20:36:53 +0200 Subject: [PATCH 3/3] Use Europe/Amsterdam timezone in build and test --- .github/workflows/meson.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/meson.yml b/.github/workflows/meson.yml index 897ff27..ea119f2 100644 --- a/.github/workflows/meson.yml +++ b/.github/workflows/meson.yml @@ -8,6 +8,7 @@ on: env: BUILD_TYPE: Release + TZ: Europe/Amsterdam jobs: