Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/meson.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:

env:
BUILD_TYPE: Release
TZ: Europe/Amsterdam


jobs:
Expand Down
95 changes: 95 additions & 0 deletions ical.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#include "ical.hh"

#include <string_view>

#include <fmt/chrono.h>

#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<string, string> &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", "<html><body>" + f->second + "</body></html>");
}

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;
}
14 changes: 14 additions & 0 deletions ical.hh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#pragma once

#include <string>
#include <unordered_map>

/*
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<std::string, std::string> &activiteit);
83 changes: 83 additions & 0 deletions icaltest.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#include "doctest.h"
#include <string>

#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<string, string> 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", "<br />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:<html><body><br />In reactie op onderstaa\r\n"
" nde e-mailprocedure met een verzoek van het lid Neijenhuis (D66)...</\r\n"
" body></html>\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<string, string> 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, " ");
}
}

4 changes: 2 additions & 2 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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])

2 changes: 1 addition & 1 deletion partials/activiteit.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<hblock>
<h4 x-text="activiteit['meta'].onderwerp"></h4>
<h5 x-text="activiteit['meta'].soort"></h5>
<p><span x-text="activiteit['meta'].nummer"></span> <span x-html="makeBell($data, haveMonitor);"></span>
<p><a x-bind:href="'activiteit.ics?nummer=' + activiteit['meta'].nummer" x-text="activiteit['meta'].nummer"></a> <span x-html="makeBell($data, haveMonitor);"></span>
<template x-if="activiteit['meta'].besloten == 'true'">
<span style="font-size: 150%;" data-tooltip="Besloten activiteit">🔒</span>
</template>
Expand Down
22 changes: 22 additions & 0 deletions tkserv.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading