Repository navigation
Advanced events editor API #8522
Description
Activity
- added a parent issue
on Jun 17, 2026 - added1. to developAccepted and waiting to be taken care ofAccepted and waiting to be taken care ofenhancementNew feature requestNew feature request
on Jun 17, 2026 Note: This design proposal was drafted with AI assistance (Claude Code, model
claude-opus-4-8) and is posted for discussion. Details are subject to review and change.Proposed design: a framework-agnostic event editor plugin API
A concrete proposal for this ticket. The goal: let any Nextcloud app add custom inputs to the event editor and read/modify the event being edited — both the standard editor properties and arbitrary custom ICS properties — without depending on Calendar's internals or its frontend framework.
Scope (this iteration)
- ✅ Custom editor fields (UI injected into the event editor)
- ✅ First-class access to the editor's working model (standard properties)
- ✅ An ICS serialization hook (influence the generated iCalendar data)
- ❌ Out of scope for now: event context-menu actions, calendar-grid/view plugins, settings hooks. These can reuse the same registry later (see APIs for other apps #812).
Design decisions
- Framework-agnostic UI — plugin UI is a Web Component (custom element). Calendar only renders
<your-tag>and assigns it a context object. The plugin can be written in vanilla JS, Lit, Svelte, React, Vue — anything that compiles to a custom element. No coupling to Calendar's Vue version. (Same mechanism the Files app uses for sidebar tabs.) - Framework-agnostic registration — a tiny published package whose functions only write to a
windowglobal. Plugins never import Calendar code. - The editor model is the primary surface — plugins operate on a stable, reactive projection of the event (the same data the built-in fields edit), not the Pinia store and not (primarily) raw ICS strings.
- No restriction on property names — plugins may read/write any ICS property; docs recommend an
X-<APPID>-…prefix but it is not enforced. - Load-order-proof — a drained queue (Viewer pattern) + a live registry (Files pattern), so registration works whether the plugin script runs before or after Calendar boots.
Public API
Working package name (TBD — see open questions):
@nextcloud/calendar-plugin. Zero runtime deps; ships types + thin functions writing towindowglobals.EventComponentre-exported type-only from@nextcloud/calendar-js.Register a custom field
import { registerEventEditorField } from '@nextcloud/calendar-plugin' interface EventEditorField { id: string // unique; prefix with your app id tagName: string // custom element tag (needs a hyphen); prefix with app id order?: number // sort among plugin fields variants?: Array<'full' | 'simple'> // default ['full'] enabled?: (ctx: EventContext) => boolean // re-evaluated on change onInit?: () => void | Promise<void> // define the custom element once (lazy import) } registerEventEditorField(field: EventEditorField): void
Calendar renders the element and assigns the context as a property (
el.context = ctx) — which is what lets us hand over a rich object with callbacks rather than just string attributes.Register a UI-less ICS hook
interface EventSerializeHook { id: string // called once per VEVENT, synchronously, immediately before toICS() serialize: (component: EventComponent, ctx: EventContext, scope: SaveScope) => void } registerEventSerializeHook(hook: EventSerializeHook): void
The context — centered on the live event model
type SaveScope = 'this' | 'thisAndFuture' | 'all' interface EventContext { /** * Live view of the editor's working model — the SAME data the built-in editor edits. * Reads return the current value. Writes route through the same store actions the * built-in fields use, so the default UI updates reactively (and vice versa). */ readonly event: EventModel /** Subscribe to editor lifecycle. Returns an unsubscribe fn; auto-cleaned on close. */ on(type: 'change' | 'beforeSave' | 'close', cb: () => void): () => void /** Escape hatch for custom/advanced ICS (X-*, parameters, multi-value, sub-components). */ getProperty(name: string): string | null getPropertyValues(name: string): string[] setProperty(name: string, value: string | null): void // null deletes; marks dirty getComponent(): EventComponent // raw calendar-js component } interface EventModel { // identity / recurrence (read-only) readonly uid: string readonly isNew: boolean readonly recurring: boolean readonly instanceType: 'master' | 'occurrence' | 'exception' // standard scalar fields — READ & WRITE (write-through to the built-in UI) summary: string location: string description: string status: 'CONFIRMED' | 'TENTATIVE' | 'CANCELLED' | null accessClass: 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL' | null timeTransparency: 'OPAQUE' | 'TRANSPARENT' | null color: string | null // time — READ & WRITE allDay: boolean start: Date end: Date startTimezone: string endTimezone: string // collections — READ always; WRITE via mutators (v1: categories + attendees) readonly categories: readonly string[] addCategory(value: string): void removeCategory(value: string): void readonly attendees: readonly AttendeeView[] addAttendee(a: { email: string, commonName?: string, role?: string, rsvp?: boolean }): void removeAttendee(email: string): void // read-only in v1 (use getComponent() for advanced writes) readonly organizer: { email: string, commonName: string } | null readonly alarms: readonly AlarmView[] readonly attachments: readonly AttachmentView[] readonly recurrenceRule: RecurrenceView | null }
eventaccessors map to the reactivecalendarObjectInstancestore state (read) and store actions (write) — the same path the built-in fields use, so everything stays consistent and reactive.getComponent()/get/setPropertymap to the raw calendar-jseventComponentfor anything the model doesn't cover.Recurrence semantics
The model is O(1) per open editor — it does not grow with occurrence count. Opening an instance resolves a single component via
getObjectAtRecurrenceId()→recurrenceManager.getOccurrenceAtExactly()(src/utils/calendarObject.js:34), projected into the single reactivecalendarObjectInstance. A weekly-forever series is stored as master VEVENT + a handful of RECURRENCE-ID exceptions; other occurrences are computed, never stored. So there is no per-occurrence plugin state.- The
eventmodel andsetPropertyare scoped to the instance being edited, exactly like the built-in fields. For the common case ("add a field to this event"), a plugin needs zero recurrence awareness — the existing this-only / this-and-future save flow applies automatically (saveCalendarObjectInstance→eventComponent.createRecurrenceException(thisAndAllFuture),src/store/calendarObjectInstance.js:1519). - Reads see inherited values: an un-exceptioned occurrence is forked from the master (
primaryItem), sogetProperty()returns the master's value. - The raw accessor (
getComponent()) is the escape hatch for genuinely cross-instance / series-level work: target the master while editing one occurrence (getComponent().primaryItem), enumerate the whole series/all exceptions (parentcalendarComponent), or touch RRULE/RECURRENCE-ID structure (not in the v1 model). - For recurrence-aware plugins without dropping to raw, the context exposes
event.instanceType/event.recurringand passes the chosen save scope intobeforeSave/serialize, so a plugin can decide series-vs-occurrence placement (and observe that this-and-future creates a brand-new master with a new UID).
Runtime architecture (Calendar side)
- Registration: the
register*functions write towindow._nc_calendar_editor_fields/_nc_calendar_serialize_hooksMaps and notifywindow.OCA.Calendarif it is already up. On boot, Calendar createswindow.OCA.Calendar(façade +version+ anEventTarget), drains the queues, and dispatchesregister:*for late registrations so an open editor updates live. - Editor zone: add one extension zone to
EditFull.vue(bottom of the right column) and a compact one inEditSimple.vue(aboveSaveButtons,'simple'variants only). A small host-internal wrapper runsonInit(), creates each enabled element, assignsel.context, andwatches thecalendarObjectInstancestore to fire the context'schange— the reactivity bridge to the framework-agnostic element. - Save/serialize pass: in
saveCalendarObjectInstance(), beforeupdateCalendarObject(): emitbeforeSave, apply stagedsetPropertywrites, run allserializehooks on the (post-fork)eventComponent, thenmarkDirty()if anything changed (so theisDirty()gate does not skip the save).updateCalendarObject()then does the singletoICS()+ PUT (src/store/calendarObjects.js:107).
Loading & timing (PHP)
- Add an init-script entry point in
rspack.config.js(Viewer-stylemain+init) sowindow.OCA.Calendarexists early. ViewControllerdispatches a newOCA\Calendar\Event\LoadAdditionalScriptsEventbefore render (mirrors the Files app). Plugin apps subscribe and call\OCP\Util::addInitScript('myapp', '…')→ runs before the editor mounts, and only on calendar pages.
Correctness considerations
- Custom-property preservation: the recurrence fork on save (
createRecurrenceException) splits at the component level and preserves unknown/X-*properties (worth a regression test). However, the Duplicate event action goes throughcopyCalendarObjectInstanceIntoEventComponent()(src/models/event.js:192, called atcalendarObjectInstance.js:1563), which copies only a whitelist and would drop custom properties. Fix: carry over all otherwise-unhandled properties there. - Two writers to standard fields: since plugins can write
summary,location, etc., a plugin and the user can touch the same field. Routing through store actions keeps state consistent (last-writer-wins, UI updates live). Consider a dev-mode warning. - No namespace restriction (by design):
setProperty/getComponentcan overwrite RFC/core properties. Not enforced; docs recommend anX-<APPID>-…prefix. - Sync serialize only (v1): fetch remote data while the editor is open; write synchronously on
beforeSave. - Trust: the element runs first-party in Calendar's origin (same trust level as a Files sidebar tab); the plugin owns sanitization of values it writes to ICS that other clients/invitees will parse.
- Out of scope v1: public booking / embedded views; alarm/attachment/recurrence writes (read-only, with the raw-component escape hatch available).
Worked example
A plugin that reads standard fields (attendees) and writes a derived custom property, re-rendering when the user edits the rest of the event:
// registration (loaded via OCP\Util::addInitScript on calendar pages) import { registerEventEditorField } from '@nextcloud/calendar-plugin' registerEventEditorField({ id: 'myapp-approval', tagName: 'myapp-approval-field', order: 10, variants: ['full', 'simple'], async onInit() { const { MyApprovalField } = await import('./MyApprovalField.ts') if (!customElements.get('myapp-approval-field')) { customElements.define('myapp-approval-field', MyApprovalField) } }, })
// the custom element — no framework export class MyApprovalField extends HTMLElement { private ctx!: import('@nextcloud/calendar-plugin').EventContext connectedCallback() { const render = () => { const needsApproval = this.ctx.event.attendees.length > 10 this.innerHTML = `<label><input type="checkbox" ${needsApproval ? 'checked' : ''}> Requires manager approval (${this.ctx.event.attendees.length} guests)</label>` this.querySelector('input')!.addEventListener('change', (e) => { const on = (e.target as HTMLInputElement).checked this.ctx.setProperty('X-MYAPP-NEEDS-APPROVAL', on ? 'TRUE' : null) }) } render() this.ctx.on('change', render) // re-render when the user edits other fields } }
Prior art
- Viewer (
@nextcloud/viewer): framework-agnostic registration via a tiny npm package that writes handlers to awindow._oca_viewer_handlersMap queue, drained by the app on init (loaded viaaddInitScript). - Files (
@nextcloud/files): sidebar tabs declare atagNameand register a Web Component (customElements.define) — the framework-agnostic content pattern — plus a reactivegetFilesRegistry()EventTarget for post-load registration.
This proposal combines both: Viewer's queue + npm registration, Files' Web-Component content + reactive registry, plus a calendar-js serialize hook specific to the "influence ICS generation" requirement.
Open questions
- Distribution: in-repo
api_package/(Viewer-style, versioned with the app) first, vs. a dedicated@nextcloud/calendar-pluginrepo now. - Final package + global names (
@nextcloud/calendar-plugin,window.OCA.Calendar, the queue globals). variantsdefault —['full']only, or also'simple'(popover is space-constrained)?- Dev-mode warning when a plugin overwrites a standard/RFC property — yes/no?
- Scope of write access to standard fields in v1 (proposed: scalars + categories + attendees; alarms/attachments/recurrence read-only).
Rollout (focused, independently reviewable PRs)
- Custom-property preservation fix in the Duplicate path (
copyCalendarObjectInstanceIntoEventComponent) + unit tests. window.OCA.Calendarfaçade + queue/registry + init entry point.- Editor extension zone + the reactive context (
eventmodel) inEditFull.vue/EditSimple.vue. - Serialize /
beforeSavepass insaveCalendarObjectInstance. - Publish the API package + docs + a reference example plugin.
LoadAdditionalScriptsEvent.
Assisted-by: ClaudeCode:claude-opus-4-8
Reacted by Jonathan TrefflerFramework-agnostic UI — plugin UI is a Web Component (custom element). Calendar only renders and assigns it a context object. The plugin can be written in vanilla JS, Lit, Svelte, React, Vue — anything that compiles to a custom element. No coupling to Calendar's Vue version. (Same mechanism the Files app uses for sidebar tabs.)
In the past I encountered, issues with web components bundling a custom Vue version.
It is something that Vue does not intend to support.
See vuejs/core#14067In general libraries might break when bundled multiple times on a site.
Example Kotlin/kotlinx.coroutines#3874But there surly exist solutions from this from practitioners of Micro Frontends.
So, adding a open question: Do we want to take care of isolation between plugins and the calendar app (and between plugins and plugins)? Or do we leave the responsibility to plugin authors.
So, adding a open question: Do we want to take care of isolation between plugins and the calendar app (and between plugins and plugins)? Or do we leave the responsibility to plugin authors.
Currently multiple apps can already do load different versions of a library. For now we do plan to add further isolation.
Reacted by Christoph WurstInput from @SebastianKrupinski
- A broken calendar extension should not break the calendar app
- Log calendar extension name in related error messages
- The data model (the one we might want to expose in the context) is not reactive yet
- Migration to TypeScript is outstanding
From the last two points I conclude, that we should not directly expose our model in the
this.ctx.
So currently I strongly leaning on having a dedicated type for the data/methods we expose to the extension and fully encapsulate and keep our existing data structures internal.
The extension API can the also be independently versioned.- A broken calendar extension should not break the calendar app
Using a context property for custom components seems counterintuitive. I was directly thinking of plain element attributes (which can be reactively handled in custom components) and using events to communicate changes.
On the other hand, having the same context object/API for custom elements and hooks seems also good.
So I'm not sure about this one yet.I will take a look at currently existing solutions in Nextcloud (e.g., extension in Viewer and Files) and other web projects.
Reacted by Christoph WurstI got some use cases from a potential extension author:
- Hide existing inputs in the editor
- For example, when the extension already provides its own custom element to edit that property
- Some way to provide custom error messages
- e.g., "event could not be saved because service XY is not available"
- Some way to validate property values
- validate and indicate that it is not a valid value
- e.g., "event could not be saved because Y is not a valid option for Z"
- validate and indicate that it is not a valid value
- Some way to disable the input of specific values
- e.g., limit who can be a host to a meeting
(1) and (2) are not yet covered by the proposed extension points and would become custom extension points or some options, that can be dynamically set on
this.ctx.(2) and (4) can be covered by the custom elements because the extension author can display the validation result in custom components. But in the future, it might be interesting to have fine-grained hooks for our built-in components. Example for our attendee select field: Add custom logic to filter what attendees are allowed in a meeting and don't offer them to the user or highlight them as incompatible after the user selected them.
- Hide existing inputs in the editor
Found an additional similar extension mechanism for
registerContactsMenuAction.Reacted by Christoph Wurst- added2. developingWork in progressWork in progressand removed1. to developAccepted and waiting to be taken care ofAccepted and waiting to be taken care of
on Jul 14, 2026 Found something similar again by chance.
Extension of settings with custom components:Reacted by Christoph Wurst@nextcloud/filesseems to have the most up-to-date approach for extensions.We can use the same approach for versioning as introduced in nextcloud-libraries/nextcloud-files#1492
Also notice, that exposing and usingwindow.OCA....was considered deprecated in nextcloud-libraries/nextcloud-files#1419
Documented in https://docs.nextcloud.com/server/latest/developer_manual/digging_deeper/javascript-apis.html#global-variables as:There are also global variables that acted as APIs in the past. The use of these variables is discouraged, as they lead to script loading order problems and the dependency hell, making it hard for the server component to update libraries.
So for consistency we would build the npm package
@nextcloud/calendararoundwindow._nc_calendar_scope.v1_0for consistency.
(And during initial phase aroundwindow._nc_calendar_scope.v0_0Probably there are a few more interesting implementation details in
@nextcloud/filesworth locking closer into.
The editor extensions made by https://voxcloud.nl/roomvox/en/ (currently through patches) are an interesting use case for the editor extension API.
(Noticed while reviewing #8264)
Another use case: Limit what type of recurrences is allowed. This is use full if events are synced with other systems that support less recurrence rules then CalDav standard/our editor.
Some example:
- Allow monthly recurring events to only occur once per month.
- Allow yearly recurring events to occur only once per month.
- Disallow overlapping recurrent events that overlap over the series
Implementing this with the current proposal would be do hide the repeat selector and add custom component to handle it.
If this becomes widely needed it might we could hide/restrict features behind capability checks that Nextcloud apps can toggle using the events editor API.Talked with @SebastianKrupinski about what methods for reading and modifying events to expose for extensions.
The gist is:
- create new interfaces
- keep them minimal
Summarized reasoning:
- everything we currently use (e.g. internal objects/types, objects from nextcloud/calendar-js and ICAL.js) is to broad to give access to in the API
- prefer extending over time instead of making a large API surface that we will have to keep maintaining
Reacted by Christoph WurstAnother use case from the same corner, if it helps shape the scope: rendering the editor outside the Calendar app.
I have been prototyping a week view inside Talk that shows the events tied to your conversations. Reading them works well — Talk's dashboard API already returns title, time, location, calendar, attendee replies and attachments. Editing is where it falls apart: there is no way to reach this editor from another app, so a prototype either sends people to
/apps/calendar/...and loses the view they were in, or rebuilds a small form and writes CalDAV itself. I tried both. Neither is something I would want to ship.What I looked at before concluding that:
registerWidget('calendar_widget')mounts the real thing as a Vue component, butReferenceProvider::matchReference()only accepts a whole calendar or a public share, not a single eventEditSimpletakes onlydarkandlightBackdropas props and reads the event from the Calendar store, so it cannot be handed one from outside- the package is
"private": truewith nomainorexports, and there is nowindow.OCA.Calendar
So the gap is not "the editor cannot be extended" but "the editor cannot be placed". Those may well be the same API — an extension point that lets another app mount the editor on a given object id would cover both, and would let the host add its own action next to Save (in my case "Open conversation", which is the reason someone is in Talk rather than in Calendar).
If the interfaces stay minimal as discussed above, the piece I would need is small: mount the editor for one
objectId(+recurrenceId), and a way to know when it saved or closed. Happy to test against whatever shape you land on — I have the week view running on a Talk master build and can report back on what is missing in practice.

A parallel worth tracking: the Files app is getting the same treatment right now, driven by Teams.
nextcloud/server#63461 — "Make Files app reusable" — is a draft that lets another app render the Files view inside its own page. It resolves circles#2663 (show team-folder contents on the team page), and the Teams roadmap expects a team to come with a calendar, a Talk conversation and a collective, all surfaced in one place. So the same "render app A's view inside app B" need is arriving for Calendar from a second direction, independent of my Talk prototype.
The shape it landed on is small, which I think is good news for the "keep them minimal" line above:
renderFilesView(el: HTMLElement, viewId: string): { destroy: () => void }
Three details that seem relevant here:
embedded: true. When embedded,FilesList.vuerenders a plaindivwhere it would otherwise renderNcAppContent— the host page owns the chrome, the embed owns only its own content. For the editor the equivalent would be rendering the form without Calendar's modal shell, so the host can supply its own dialog and put its own button next to Save.- A server-side bootstrap event.
OCA\Files\Event\LoadFilesAppis dispatched by the host page to trigger the same script and initial-state loading the app's own controller does. Worth knowing that a JS-only API is not sufficient on its own; there is a PHP-side half. - An explicit teardown handle rather than a fire-and-forget mount, which is also what answers "how do I know when it closed".
One caveat, since it was raised in this thread: #63461 exposes this as
window.OCP.Files.renderFilesApp, i.e. exactly the global-variable pattern that was called discouraged above and in the developer manual. If@nextcloud/calendararoundwindow._nc_calendar_scope.v1_0is where this is heading, that is the better half of the two patterns — I mention #63461 for the shape of the call, not for how it is published.Concretely, what I would still need on top of the extension points discussed here is one entry point in the same spirit:
renderEventEditor(el, { objectId, recurrenceId? }): { destroy: () => void }
plus a way to observe saved/closed. Everything else — custom fields, validation, hiding built-in inputs — is already covered by the plugin API being designed above, and would apply to an embedded editor unchanged
Rendering editor outside of calender would be cool.
#8714 is doing much work for it.Reacted by Rikdekker
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fields🏗️ In progress
Is your feature request related to a problem? Please describe.
As a Nextcloud app developer I want to be able to extend the event editors with custom inputs and influence the ICS generation.
Describe the solution you'd like
TBD
Describe alternatives you've considered
TBD
Additional context
Part of #812