Skip to content

feat: add to calendar and subscribe to event calendars - #568

Merged
ashleycaselli merged 14 commits into
masterfrom
440-add-calendar-event
Jul 29, 2026
Merged

feat: add to calendar and subscribe to event calendars#568
ashleycaselli merged 14 commits into
masterfrom
440-add-calendar-event

Conversation

@ashleycaselli

Copy link
Copy Markdown
Member

Closes #440.

An Event space can be added to a calendar; a space that contains Event sub-spaces — a Program, typically — can be subscribed to, so the whole series arrives and keeps up as events are added or rescheduled. Both are offered by one dropdown next to the space title, and served by one endpoint, /calendar.ics.

Approach

The partial implementation on this branch matched a rendered date range (2026-07-14, 16:00–17:00 (UTC+02:00)) against a regex inside QueryResultTable and attached a dropdown to whatever produced it, scraping the location from another row by hardcoded column name. That fired on any view whose SPARQL happened to format a date that way and missed every event formatted differently, so it is replaced by a model-driven menu reading schema:startDate/endDate/location from the space's root definition.

Why a subscription, and what it can promise

A downloaded .ics is a snapshot — nothing re-fetches it, so a later change never arrives. A subscribed feed (webcal:) is re-fetched by the client, and a changed event is recognised as an update rather than a duplicate only because each VEVENT carries:

  • a UID derived from the event space's IRI, never randomised;
  • a SEQUENCE from the root nanopub's creation timestamp in whole minutes — rescheduling means republishing, which means a later timestamp and a higher sequence.

The feed advertises REFRESH-INTERVAL/X-PUBLISHED-TTL, but these are hints. Refresh cadence and whether a change is surfaced are the client's decision: Apple Calendar refreshes as often as every 5 min and flags changed events; Outlook polls every few hours silently; Google ignores the hints and commonly takes 8–24 h. Genuinely prompt notification would need iTIP/iMIP (attendee e-mail) or CalDAV — out of scope, but the stable UID and monotonic sequence are exactly its groundwork. Per-client detail in docs/calendar-integration.md.

Bugs found while testing against live data

  • Event pages served an empty calendar, and feeds silently dropped events. Space.getType() comes from the get-spaces ?type column, which emits one row per npx:hasNanopubType; a space declared in the same nanopub as its preset assignment publishes three, and the representative row need not be the semantic one (…/officehours/20260715 reports ActivatedPresetAssignment). Fixed with Space.isOfType, reading the assertion. The Incubator Office Hours feed went from 6 events to 9.
  • ;jsessionid= was landing in every emitted URL. Wicket adds it when the client has not returned a cookie — exactly a calendar client fetching a feed. A subscribe URL carrying one breaks when the session expires and leaks a live session identifier to anyone it is shared with.
  • URL;VALUE=URI was TEXT-escaped, writing \; and \, into addresses. URI is not a TEXT value type in RFC 5545.

Testing

803 tests pass. Each of the nine commits test-compiles independently. Feeds verified against live data on a local instance and parsed clean by Python's icalendar (unique UIDs, SEQUENCE >= 0, DTEND > DTSTART).

Not covered by tests, worth a look during review:

  • the menu's placement and appearance in the space header;
  • an actual webcal: handoff to a calendar application (Apple Calendar subscribes to webcal://localhost:37373/… with no tunnel; Google/Outlook need a public URL).

Two decisions for review

  1. The subscribe menu appears on any space with Event sub-spaces, not only Program. Add "add to calendar" and "subscribe to calendar" #440 says Program; a Consortium or Community with events seemed equally entitled, and the check is type-agnostic. One-line change in CalendarMenu.forSpace to narrow it.
  2. The menu is shown to everyone who can see the space, not only to declared participants. Add "add to calendar" and "subscribe to calendar" #440 frames it as following participation; the date is the least private thing about an event, and someone deciding whether to take part is who needs it.

Known limitations

  • Sub-events are collected one level deep — a Program → Track → Event nesting will not appear. subEventSpaces is where that becomes recursive.
  • Scheme and host come from the incoming request, and nothing in the app reads X-Forwarded-Proto. Behind a TLS-terminating proxy this needs checking: the webcal: link survives (its scheme is replaced regardless), but the Outlook subscribe link and copy-feed-URL entry would hand out an http address.
  • The multi-type wrinkle also makes getTypeLabel()/getTypeEmoji() wrong for such spaces in listings. Not addressed here.

🤖 Generated with Claude Code

ashleycaselli and others added 14 commits July 20, 2026 17:56
Space.getType() comes from the ?type column of the get-spaces query, which
emits one row per npx:hasNanopubType of the declaring nanopub. A root
definition carrying several types — a space declared in the same nanopub as
its preset assignment publishes Event, PresetAssignment and
ActivatedPresetAssignment — yields several rows, of which reduceByRef keeps
one as the representative. That representative need not be the semantic type:
.../officehours/20260715 reports ActivatedPresetAssignment.

Add Space.isOfType(IRI), reading the rdf:type statements of the
root-definition assertion, which lists them all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Expose schema:location, and distinguish a bare xsd:date from an
xsd:dateTime in schema:startDate/endDate. A date denotes a day rather than
an instant, so a multi-day event declared that way is an all-day event;
treating it as a timed one would place it at midnight. schema:endDate stays
inclusive here — callers that need iCalendar's exclusive DTEND adjust it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Map an event space to a VEVENT and serialise it as text/calendar.

The UID is derived from the event space's IRI and never randomised, and the
SEQUENCE from the creation timestamp of its root-definition nanopub in whole
minutes. Calendar clients match an incoming event to one they already hold by
UID, and apply it only if the sequence advanced; without both, a re-fetched
event is a duplicate rather than an update, so a rescheduled event could
never reach a subscriber.

Timed events are written in UTC; all-day events use DATE values with the
exclusive DTEND iCalendar requires. Content lines are folded at 75 octets,
counting UTF-8 bytes and never splitting a character.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two kinds of link, and the difference is what the user gets: the add-event
URLs hand a single event to a web calendar as a one-off copy, while the
subscribe URLs point the calendar at a feed it keeps re-fetching. Only the
latter can carry a later rescheduling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add Utils.absolutePageUrl for URLs that outlive the request — embedded in a
downloaded file, handed to a third-party service to fetch, or shown to the
user to copy.

Wicket appends ;jsessionid= whenever the client has not returned a cookie,
which is exactly the case for a machine fetching a feed. Such a URL is bound
to one visitor's session: it stops working when the session expires, and
sharing it hands out a live session identifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An Event space yields its own event; any other space yields every direct
sub-space of type Event that has a start date. Past events stay in the feed,
since an event disappearing from one makes some clients delete it from the
user's calendar along with their history of the series.

One URL serves both uses. Fetched once (&download=1) it is a snapshot;
subscribed to, it is re-fetched by the client and keeps up with rescheduled
events. The mount path ends in .ics because some clients decide whether a URL
is subscribable from its extension rather than its content type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A labelled dropdown next to the space title: "Add to calendar" on an Event,
"Subscribe to events" on a space containing Events, both when a space is
both, and nothing at all otherwise.

Shown to everyone who can see the space rather than only to declared
participants — the date is the least private thing about an event, and
someone deciding whether to take part is exactly who needs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded by the model-driven menu on the space page. The cell matched a
rendered date range against a regex and attached a calendar dropdown to
whatever produced it, then scraped the location from another row of the same
response by hardcoded column name — so it fired on any view whose SPARQL
happened to format a date that way, and missed every event formatted
differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers where the event data comes from, why the UID must be stable and the
SEQUENCE monotonic, and what a URL subscription can and cannot promise — per
client, since the refresh cadence and whether a change is surfaced at all are
the client's decision, not the feed's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ashleycaselli ashleycaselli linked an issue Jul 28, 2026 that may be closed by this pull request
@ashleycaselli
ashleycaselli requested a review from tkuhn July 28, 2026 15:08
@tkuhn

tkuhn commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Awesome! :)

The subscribe for a program works to link it to Google calendar, but then I don't see any events (which makes sense, since it's on localhost and Google Calendar doesn't have access to that).

@tkuhn tkuhn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good!

But couldn't properly test, as this would require a live instance?

I think I'd call it "add to calendar" also for "subscribe to events". I think it's clearer if it has "calendar" in it.

@ashleycaselli
ashleycaselli merged commit 60aa73a into master Jul 29, 2026
8 checks passed
@ashleycaselli
ashleycaselli deleted the 440-add-calendar-event branch July 29, 2026 07:06
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 5.8.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add "add to calendar" and "subscribe to calendar"

2 participants