feat: add to calendar and subscribe to event calendars - #568
Merged
Conversation
…le Calendar integration
…r (through .ics file or Google calendar)
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>
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
approved these changes
Jul 29, 2026
tkuhn
left a comment
Contributor
There was a problem hiding this comment.
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.
Contributor
|
🎉 This PR is included in version 5.8.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 insideQueryResultTableand 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 readingschema:startDate/endDate/locationfrom the space's root definition.Why a subscription, and what it can promise
A downloaded
.icsis 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 eachVEVENTcarries:UIDderived from the event space's IRI, never randomised;SEQUENCEfrom 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 indocs/calendar-integration.md.Bugs found while testing against live data
Space.getType()comes from the get-spaces?typecolumn, which emits one row pernpx: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/20260715reportsActivatedPresetAssignment). Fixed withSpace.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=URIwas 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'sicalendar(unique UIDs,SEQUENCE >= 0,DTEND > DTSTART).Not covered by tests, worth a look during review:
webcal:handoff to a calendar application (Apple Calendar subscribes towebcal://localhost:37373/…with no tunnel; Google/Outlook need a public URL).Two decisions for review
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 inCalendarMenu.forSpaceto narrow it.Known limitations
subEventSpacesis where that becomes recursive.X-Forwarded-Proto. Behind a TLS-terminating proxy this needs checking: thewebcal:link survives (its scheme is replaced regardless), but the Outlook subscribe link and copy-feed-URL entry would hand out anhttpaddress.getTypeLabel()/getTypeEmoji()wrong for such spaces in listings. Not addressed here.🤖 Generated with Claude Code