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: diff --git a/ical.cc b/ical.cc new file mode 100644 index 0000000..19d72d4 --- /dev/null +++ b/ical.cc @@ -0,0 +1,95 @@ +#include "ical.hh" + +#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) { + 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")) { + 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);