Skip to content

Advanced events editor API #8522

Description

@ChristophWurst

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

Activity

  1. ChristophWurst commented on Jun 17, 2026

    @ChristophWurst
    MemberAuthor

    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

    1. 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.)
    2. Framework-agnostic registration — a tiny published package whose functions only write to a window global. Plugins never import Calendar code.
    3. 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.
    4. No restriction on property names — plugins may read/write any ICS property; docs recommend an X-<APPID>-… prefix but it is not enforced.
    5. 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 to window globals. EventComponent re-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
    }

    event accessors map to the reactive calendarObjectInstance store state (read) and store actions (write) — the same path the built-in fields use, so everything stays consistent and reactive. getComponent()/get/setProperty map to the raw calendar-js eventComponent for 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 reactive calendarObjectInstance. 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 event model and setProperty are 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), so getProperty() 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 (parent calendarComponent), 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.recurring and passes the chosen save scope into beforeSave/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 to window._nc_calendar_editor_fields / _nc_calendar_serialize_hooks Maps and notify window.OCA.Calendar if it is already up. On boot, Calendar creates window.OCA.Calendar (façade + version + an EventTarget), drains the queues, and dispatches register:* 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 in EditSimple.vue (above SaveButtons, 'simple' variants only). A small host-internal wrapper runs onInit(), creates each enabled element, assigns el.context, and watches the calendarObjectInstance store to fire the context's change — the reactivity bridge to the framework-agnostic element.
    • Save/serialize pass: in saveCalendarObjectInstance(), before updateCalendarObject(): emit beforeSave, apply staged setProperty writes, run all serialize hooks on the (post-fork) eventComponent, then markDirty() if anything changed (so the isDirty() gate does not skip the save). updateCalendarObject() then does the single toICS() + PUT (src/store/calendarObjects.js:107).

    Loading & timing (PHP)

    • Add an init-script entry point in rspack.config.js (Viewer-style main + init) so window.OCA.Calendar exists early.
    • ViewController dispatches a new OCA\Calendar\Event\LoadAdditionalScriptsEvent before 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 through copyCalendarObjectInstanceIntoEventComponent() (src/models/event.js:192, called at calendarObjectInstance.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/getComponent can overwrite RFC/core properties. Not enforced; docs recommend an X-<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 a window._oca_viewer_handlers Map queue, drained by the app on init (loaded via addInitScript).
    • Files (@nextcloud/files): sidebar tabs declare a tagName and register a Web Component (customElements.define) — the framework-agnostic content pattern — plus a reactive getFilesRegistry() 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

    1. Distribution: in-repo api_package/ (Viewer-style, versioned with the app) first, vs. a dedicated @nextcloud/calendar-plugin repo now.
    2. Final package + global names (@nextcloud/calendar-plugin, window.OCA.Calendar, the queue globals).
    3. variants default — ['full'] only, or also 'simple' (popover is space-constrained)?
    4. Dev-mode warning when a plugin overwrites a standard/RFC property — yes/no?
    5. Scope of write access to standard fields in v1 (proposed: scalars + categories + attendees; alarms/attachments/recurrence read-only).

    Rollout (focused, independently reviewable PRs)

    1. Custom-property preservation fix in the Duplicate path (copyCalendarObjectInstanceIntoEventComponent) + unit tests.
    2. window.OCA.Calendar façade + queue/registry + init entry point.
    3. Editor extension zone + the reactive context (event model) in EditFull.vue / EditSimple.vue.
    4. Serialize / beforeSave pass in saveCalendarObjectInstance.
    5. Publish the API package + docs + a reference example plugin.
    6. LoadAdditionalScriptsEvent.

    Assisted-by: ClaudeCode:claude-opus-4-8

  2. odzhychko commented on Jun 30, 2026

    @odzhychko
    Contributor

    Framework-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#14067

    In general libraries might break when bundled multiple times on a site.
    Example Kotlin/kotlinx.coroutines#3874

    But 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.

  3. odzhychko commented on Jul 6, 2026

    @odzhychko
    Contributor

    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.

  4. odzhychko commented on Jul 6, 2026

    @odzhychko
    Contributor

    Input 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.

  5. odzhychko commented on Jul 6, 2026

    @odzhychko
    Contributor

    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.

  6. odzhychko commented on Jul 6, 2026

    @odzhychko
    Contributor

    I got some use cases from a potential extension author:

    1. Hide existing inputs in the editor
      • For example, when the extension already provides its own custom element to edit that property
    2. Some way to provide custom error messages
      • e.g., "event could not be saved because service XY is not available"
    3. 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"
    4. 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.

  7. odzhychko commented on Jul 13, 2026

    @odzhychko
    Contributor

    Found an additional similar extension mechanism for registerContactsMenuAction.

  8. added and removed
    1. to developAccepted and waiting to be taken care of
    on Jul 14, 2026
  9. odzhychko commented on Jul 20, 2026

    @odzhychko
    Contributor

    Found something similar again by chance.
    Extension of settings with custom components:

    https://github.com/nextcloud/recommendations/blob/f366d52feac013b418455e8306b6823b08307ae3/src/main.js#L38-L42

  10. odzhychko commented on Jul 20, 2026

    @odzhychko
    Contributor

    @nextcloud/files seems 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 using window.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/calendar around window._nc_calendar_scope.v1_0 for consistency.
    (And during initial phase around window._nc_calendar_scope.v0_0

    Probably there are a few more interesting implementation details in @nextcloud/files worth locking closer into.

  11. moved this from 📄 To do to 🏗️ In progress in 💌 📅 👥 Groupware teamon Jul 23, 2026
  12. moved this from 🏗️ In progress to 📄 To do in 💌 📅 👥 Groupware teamon Aug 3, 2026
  13. odzhychko commented on Aug 3, 2026

    @odzhychko
    Contributor
    Image

    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)

  14. odzhychko commented on Aug 7, 2026

    @odzhychko
    Contributor

    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.

  15. moved this from 📄 To do to 🏗️ In progress in 💌 📅 👥 Groupware teamon Aug 7, 2026
  16. odzhychko commented on Aug 21, 2026

    @odzhychko
    Contributor

    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
  17. Rikdekker commented on Sep 20, 2026

    @Rikdekker
    Contributor

    Another 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, but ReferenceProvider::matchReference() only accepts a whole calendar or a public share, not a single event
    • EditSimple takes only dark and lightBackdrop as props and reads the event from the Calendar store, so it cannot be handed one from outside
    • the package is "private": true with no main or exports, and there is no window.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.

    Image Image
  18. Rikdekker commented on Sep 20, 2026

    @Rikdekker
    Contributor

    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.vue renders a plain div where it would otherwise render NcAppContent — 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\LoadFilesApp is 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/calendar around window._nc_calendar_scope.v1_0 is 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

  19. odzhychko commented on Sep 21, 2026

    @odzhychko
    Contributor

    Rendering editor outside of calender would be cool.
    #8714 is doing much work for it.

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

Metadata

Metadata

Assignees

Labels

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions