🔥(front) remove open-calendar package

Remove deprecated open-calendar package. Functionality has
been migrated to src/features/calendar/services/dav with
improved architecture and TypeScript support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-25 20:35:48 +01:00
parent afb4e35b1d
commit 375e0ddb39
41 changed files with 0 additions and 6953 deletions

View File

@@ -1,25 +0,0 @@
# EditorConfig: http://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 2
tab_width = 2
[*.sh]
indent_style = space
indent_size = 4
tab_width = 4
[*.md]
trim_trailing_whitespace = false
[*.{jsx,js,ts,tsx}]
max_line_length = 120

View File

@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.yarn
node_modules
build
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 algoo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,97 +0,0 @@
# Open Calendar
Open Calendar is a modern web calendar frontend for CalDAV based calendars.
![Open Calendar](images/open-calendar.png)
### Key features
- [x] Configure individual calendars or multiple CalDAV servers
- [x] Display multiple calendars at the same time
- [x] Hide or show calendars and copy their URLs
- [x] Use the original calendar name and color
- [x] Show recurring events, alarms and attendees
- [x] Select timezones
- [x] Easily customize and integrate forms, notifications and rendering
### There are 3 ways to use it
1. [With just a few lines of code](#minimal-setup), you can get a ready-to-use CalDAV client web application
2. [With a bit of development](#customized-forms), you can integrate it into your web application by customizing the forms
3. [With a bit more work](#complete-integration), you can even customize all components like event rendering, notifications, etc
## Features
### Supports multiple calendars at the same time
Open Calendar can deal with many CalDAV calendars at once, and also discover calendars directly from CalDAV servers.
### Functional out of the box
Open Calendar supports all the features you would expect from a calendar client with little to no configuration: hide or show calendars or copy their URLs; drag, drop and resize events; show recurring events, alarms, attendees and more.
### Easily customizable
Open Calendar is built to be customizable and integrated into larger apps.
Events content, forms, dropdowns and even notifications can be replaced by custom ones with ease
<!-- TODO - CJ - 2025-07-09
### Well documented
Documentation for the API as well as examples can be found **Insert documentation link**
-->
## Quick start
First, install Open Calendar with the package manager of your choice (`yarn` in this case):
```bash
yarn add open-dav-calendar
```
Once this is done, you can add Open Calendar to your application at different levels:
> # 🚧 Bellow is a work in progress 🚧
### Minimal setup
With just a few lines of code, you can get a ready-to-use CalDAV client web application. All you need to do install `open-dav-calendar` and `tsdav` (for [auth functions](https://tsdav.vercel.app/docs/helpers/authHelpers)) and call `createCalendar`:
```ts
import { createCalendar } from "open-dav-calendar";
// You can install `tsdav` to access a variety of auth functions (https://tsdav.vercel.app/docs/helpers/authHelpers)
import { getBasicAuthHeaders } from "tsdav";
const serverUrl = window.prompt("server url")
const username = window.prompt("username")
const password = window.prompt("password")
createCalendar(
[{ serverUrl: serverUrl, headers: getBasicAuthHeaders({ username, password }) }],
document.getElementById("open-calendar"),
)
```
<!-- TODO - CJ - 2025-07-09
The full example is available **Insert demo module link**
-->
### Customized forms
With a bit of development, you can integrate it into your web application by customizing the forms
<!-- TODO - CJ - 2025-07-09
```ts
// Insert form ts code
```
The full example is available **Insert demo module link**
-->
### Complete integration
With a bit more work, you can even customize all components like event rendering, notifications, etc
<!-- TODO - CJ - 2025-07-09
```ts
// Insert eventBody ts code
```
The full example is available **Insert demo module link**
-->
## Architecture & development
Open Calendar is a TypeScript application relying on 3 main components:
- a Calendar rendering component: [EventCalendar](https://github.com/vkurko/calendar)
- a CalDAV client library: [tsdav](https://github.com/natelindev/tsdav)
- an ICS library: [ts-ics](https://github.com/Neuvernetzung/ts-ics)
## Need support, maintenance or features development?
Contact us at contact@algoo.fr

View File

@@ -1,16 +0,0 @@
# Open Calendar's API
You can access Open Calendar by importing the function `createCalendar` from the module `open-dav-calendar`:
```ts
import { createCalendar } from 'open-dav-calendar'
```
### `createCalendar(calendarSources, addressBookSources, target, options?, translation?)`
- `calendarSources` (list of [ServerSource]() or [CalendarSource]()): The sources to fetch the calendars from
- `addressBookSources` (list of [ServerSource](), [AddressBookSource]() or [VCardProvider]()): The sources to fetch the contacts from
- `target` ([Element](https://developer.mozilla.org/docs/Web/API/Element)): An html element that will be the parent of the calendar
- `options` ([CalendarOptions](), optional): Options for available view, custom components, ...
- `translations` (Recursive partial of [ResourceBundle](), optional):Overrides of the translation values of Open Calendar
- return value ([CalendarElement](./Interface.md#calendarelement)): The calendar object
Creates a calendar under the node `target`

View File

@@ -1,82 +0,0 @@
# Interfacing with CalDAV calendars
CalDAV is a protocole used to exchange scheduling and event informations. It,s a extension to the WebDAV protocole.
The specs for CalDAV, iCalendar (the format of `.ics` files), and vcard (`.vcf`) are defined in [RFC 4791](http://www.webdav.org/specs/rfc4791.html), [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545.html#section-8.3), [RFC 6350](https://datatracker.ietf.org/doc/html/rfc6350) respectively.
This file does not describe the entire CalDAV or CardDAV details, only what used in Open Calendar
## The structure of Calendars and AddressBooks
They are a collection of dav objects, usually represented as of directory on disk. They have common set of properties, such as `D:displayname`, `tag` (`VCALENDAR` or `VADDRESSBOOK`). Calendars may defined additional properties such as `ICAL:calendar-color`.
DAV requests in XML can be made to request the content of these collections.
Each item of the collection is identified by its URL and an `etag` or `ctag` indicating the revision. The tag changes every time the object is updated.
> On its own, a dav object is not associated with a collection (calendar or address book), this link needs to be preserved by the caller.
## The structure of `.ics` and `.vcf` files.
ics and vcf files are written in raw text an consists of components.
A components starts with a `BEGIN:<compname>` tag and ends with a `END:<compname>` tag. Components can be nested.
### `VCALENDAR` component
This components is only present in ics files and contains multiple sub-components:
- `VTIMEZONE`: Represents a timezone (e.g. `Europe/Paris`). This will allow the use of this timezone in the other components
- `VEVENT`: Represents a single calendar event. Contains all the properties of the event (title, description, ...)
#### Recurring events
Events with an `RRULE` property or a `RDATE` property are recurring events, and they occur at specific occurrences.
In this case, the events can be represented in two different ways:
1. If the occurrence has not been modified, by the 'template' `VEVENT` with the `RRULE` property.
2. Otherwise, by a different `VEVENT` component that must have the `RECURRENCE-ID` property, the original date of the occurrence
> All those components must be listed in the same ics file
> If the `DSTART` of the original event is modified, all `RECURRENCE-ID`s must be synched to this new date
#### Expanding recurrent events
As not all occurrences of a recurring event are saved (only the template one and the modified ones are), they need to be expanded to find all the occurrences to display.
This can be done by the client or by the server if the property `expand` is set in the request.
When this is done on the server, it will return a fake ics file containing all the occurrences inside a time range **WITHOUT** the template instance.
### `VCARD` component
This components is only present in vcf files and contains multiple various properties
## DAV in Open Calendar
As we've seen, this is not as simple task, and as such this not all done at once, but in two step.
1. Retrieve CalendarObjects / VCardObjects
2. Extract the events and vcards
The representation of those object evolve as they approach the interface:
- At the beginning of step 1.,we have `DAVObjects` with raw text content
- They are converted to `CalendarObject` or `VCardObject` with the raw content parsed just before step 2.
- From this we extract the `IcsEvent` and `VCardComponent`
- At the end of step 2., we have `CalendarEvent` and `AddressBookVCard` objects
## 1. Retrieve CalendarObjects / VCardObjects
> This is done in `helpers/dav-helper.ts` with [tsdav](https://github.com/natelindev/tsdav/) and [ts-ics](https://github.com/Neuvernetzung/ts-ics)
This file is used to handle requesting events, calendars, address books and vcard objects from the DAV sources.
tsdav executes the DAV queries and returns the ics and vcf files. ts-ics parses them into js objects.
> In `helpers/dav-helper.ts`, all the function starting with `dav`, from tsdav or defined in the file are the one doing the CalDAV requests. The others are a wrapper around them to parse `DAVObjects` to `CalendarObject`
## 2. Extract the events and vcards
> This is done in `calendarClient.ts`
At the end of step one, we have a list of objects containing events and vcards. The role of calendarClient is to store them and allow [CalendarElement](./User-Interface.md#calendarelement) to easily get the event and vcards inside those objects without having to think about WebDAV or storing the events, vcards, calendars itself.

View File

@@ -1,30 +0,0 @@
# User interface
Open calendar's interface is centered around [EventCalendar](https://github.com/vkurko/calendar).
## Components
As we want Open Calendar to be as customizable as possible, every component is independent and has its own designated folder.
A component is composed of a `ts` file and a `css` file.
Inside the ts file, the component is represent by a class of the same name and chunks of html as string. If it need to received arguments, the custom method `create` is used over the constructor as it can be made async.
The "management" of the DOM is done with [`Mustache`](https://github.com/janl/mustache.js) in `helpers/dom-helper.ts`.
## CalendarElement
This is the main component of Open Calendar and server as a bridge between custom components, EventCalendars and [CalendarClient](./Interfacing-with-CalDAV-and-CardDAV.md#2-extract-the-events-and-vcards).
The component will add event listeners to EventCalendars (e.g. `eventClick`), gather data from CalendarClient (e.g. `getCalendarEvent`) and call methods on custom components (e.g. `onUpdateEvent`).
It contains an instance of an EventCalendar, as well as of CalendarClient to get events and calendars. It also stores the callback, or `Handlers`, necessary to handle custom Components.
If no custom Components is specified, it will replace it with a predefined component (e.g `EventEditPopup`).
## Generic Components and css
The generic component `Popup` display as popup on the screen.
`index.css` contains generic css classes, mostly for forms

View File

@@ -1,33 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import json from '@eslint/json'
import css from '@eslint/css'
import stylistic from '@stylistic/eslint-plugin'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts}'],
plugins: { js, '@stylistic': stylistic },
rules: {
'@stylistic/indent': ['error', 2],
'@stylistic/quotes': ['error', 'single'],
'@stylistic/semi': ['error', 'never'],
'@stylistic/max-len': ['error', {
code: 120,
ignorePattern: '^import\\s.+\\sfrom\\s.+;?$',
ignoreUrls: true,
}],
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/eol-last': ['error', 'always'],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
},
extends: ['js/recommended'],
},
{ files: ['**/*.{js,mjs,cjs,ts,mts,cts}'], languageOptions: { globals: globals.browser } },
{ files: ['src/**/*.css'], plugins: { css }, language: 'css/css', extends: ['css/recommended'] },
{ files: ['locales/*/*.json'], plugins: { json }, language: 'json/json', extends: ['json/recommended'] },
tseslint.configs.recommended,
globalIgnores(['dist', 'build', 'webpack.config.js']),
])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -1,91 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Algoo - Calendar</title>
</head>
<body style="height: 100vh; margin:0; padding: 5px; box-sizing: border-box;">
<div id="open-calendar"></div>
<script type="module">
import {getBasicAuthHeaders} from 'tsdav'
import {createCalendar} from './src'
const onEventCreated = ({event, ical}) => {
console.log(ical)
}
const onEventUpdated = ({event, ical}) => {
console.log(ical)
}
const onEventDeleted = ({event, ical}) => {
console.log(ical)
}
// TODO - CJ - 2025--03-07 - Add a proper login form and ask for the server path
const username = 'user1@algoo.fr' // window.prompt('username')
const password = 'user1@algoo.fr' // window.prompt('password')
createCalendar(
[
// { serverUrl: 'http://localhost:5232', headers: getBasicAuthHeaders({ username, password }) },
// { serverUrl: 'http://localhost:7999/dav/', headers: getBasicAuthHeaders({ username, password }) },
{
calendarUrl: 'http://localhost:7999/dav/agenda/workspace/3/',
headers: getBasicAuthHeaders({username, password})
},
{
calendarUrl: 'http://localhost:7999/dav/agenda/workspace/1/',
headers: getBasicAuthHeaders({username, password})
},
// {
// calendarUrl: 'http://localhost:7999/dav/agenda/workspace/2/',
// headers: getBasicAuthHeaders({username, password})
// },
],
[
// { serverUrl: 'http://localhost:5232', headers: getBasicAuthHeaders({ username, password }) },
// { serverUrl: 'http://localhost:7999/dav/', headers: getBasicAuthHeaders({ username, password }) },
{
addressBookUrl: 'http://localhost:7999/dav/addressbook/workspace/3/',
headers: getBasicAuthHeaders({username, password})
// }, {
// addressBookUrl: 'http://localhost:7999/dav/addressbook/workspace/1/',
// headers: getBasicAuthHeaders({username, password})
// }, {
// addressBookUrl: 'http://localhost:7999/dav/addressbook/user/3/',
// headers: getBasicAuthHeaders({username, password})
}
// {
// addressBookUrl: 'http://localhost:7999/dav/addressbook/workspace/2/',
// headers: getBasicAuthHeaders({username, password})
// },
// {
// addressBookUrl: 'http://localhost:7999/dav/addressbook/workspace/3/',
// headers: getBasicAuthHeaders({username, password})
// },
// {
// fetchContacts: () => Promise.resolve([{
// name: 'zoro',
// email: 'z@z.z',
// }, {
// name: 'ant',
// email: 'ant@a.nt',
// }])
// }
],
document.getElementById('open-calendar'),
{
onEventCreated,
onEventUpdated,
onEventDeleted,
// hideVCardEmails: true,
userContact: {
email: username
},
}
)
</script>
</body>
</html>

View File

@@ -1,69 +0,0 @@
{
"name": "open-dav-calendar",
"version": "0.9.5",
"description": "A modern web calendar frontend for CalDAV based calendars",
"homepage": "https://github.com/algoo/open-calendar",
"bugs": {
"url": "https://github.com/algoo/open-calendar/issues"
},
"license": "MIT",
"files": [
"./dist/*"
],
"exports": {
".": "./dist/index.js"
},
"author": {
"name": "Algoo"
},
"browser": "./dist/index.js",
"types": "./dist/index.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/algoo/open-calendar.git"
},
"scripts": {
"dev": "vite",
"build": "yarn clean && tsc --emitDeclarationOnly && webpack --progress",
"preview": "vite preview",
"clean": "rimraf build dist",
"lint": "eslint"
},
"dependencies": {
"@event-calendar/core": "^4.4.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"autolinker": "^4.1.5",
"email-addresses": "^5.0.0",
"ical.js": "^2.2.0",
"mustache": "^4.2.0",
"timezones-ical-library": "^1.10.0",
"ts-ics": "^2.1.8",
"tsdav": "^2.1.4"
},
"devDependencies": {
"@eslint/css": "^0.9.0",
"@eslint/js": "^9.29.0",
"@eslint/json": "^0.12.0",
"@stylistic/eslint-plugin": "^5.0.0",
"@types/event-calendar__core": "^4.4.0",
"@types/mustache": "^4.2.6",
"@types/node": "^24.0.3",
"css-loader": "^7.1.2",
"dts-bundle-webpack": "^1.0.2",
"eslint": "^9.29.0",
"globals": "^16.2.0",
"node-polyfill-webpack-plugin": "^4.1.0",
"rimraf": "^6.0.1",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.35.0",
"vite": "^6.3.5",
"vite-plugin-checker": "^0.9.3",
"vite-plugin-node-polyfills": "^0.23.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View File

@@ -1,43 +0,0 @@
import ICAL from 'ical.js'
export class VCardComponent {
public component: ICAL.Component
public constructor(component: ICAL.Component) {
if (component) this.component = component
else this.component = new ICAL.Component('vcard')
}
get version() { return this._getProp('version') as string }
set version(value: string) { this._setProp('version', value) }
get uid() { return this._getProp('uid') as string }
set uid(value: string) { this._setProp('uid', value) }
get email() { return this._getProp('email') as (string | null) }
set email(value: string | null) { this._setProp('email', value) }
get name() {
return this.version.startsWith('2')
? (this._getProp('n') as string[]).filter(n => !!n).reverse().join(' ')
: this._getProp('fn') as string
}
set name(value: string) {
if (this.version.startsWith('2')) {
const [name, family] = value.split(' ', 1)
this._setProp('n', [family ?? '', name, '', '', ''])
} else {
this._setProp('fn', value)
}
}
private _setProp(name: string, value: unknown) {
this.component.updatePropertyWithValue(name, value)
}
private _getProp(name: string): unknown {
return this.component.getFirstPropertyValue(name)
}
}

View File

@@ -1,282 +0,0 @@
import { type IcsCalendar, type IcsEvent } from 'ts-ics'
import { createCalendarObject,
deleteCalendarObject,
fetchAddressBookObjects,
fetchAddressBooks,
fetchCalendarObjects,
fetchCalendars,
updateCalendarObject,
getEventObjectString,
} from './helpers/dav-helper'
import { isRRuleSourceEvent, isSameEvent, offsetDate } from './helpers/ics-helper'
import type { CalendarSource, ServerSource, AddressBookSource, VCardProvider, CalendarResponse } from './types/options'
import type { Calendar, CalendarEvent, CalendarObject, DisplayedCalendarEvent, EventUid } from './types/calendar'
import type { AddressBook, AddressBookVCard, AddressBookObject, VCard } from './types/addressbook'
import { VCardComponent } from './VCardComponent'
import { isServerSource, isVCardProvider } from './helpers/types-helper'
export class CalendarClient {
private _calendars: Calendar[] = []
// INFO - CJ - 2025-07-16 - Contains:
// objects from an ics WITHOUT an rrule
// objects with a recurrenceId generated by radicale because of `expand`
private _calendarObjects: CalendarObject[] = []
// INFO - CJ - 2025-07-16 - Contains objects from an ics WITH an rrule
private _recurringObjects: CalendarObject[] = []
private _lastFetchNumber = 0
private _addressBooks: AddressBook[] = []
private _addressBookObjects: AddressBookObject[] = []
private _vCardProviders: VCardProvider[] = []
private _providedVCards: VCard[] = []
public loadCalendars = async (sources: (ServerSource | CalendarSource)[]) => {
const calendarsPerSource = await Promise.all(sources.map(async source => {
try {
return await fetchCalendars(source)
} catch (error) {
const url = isServerSource(source) ? source.serverUrl : source.calendarUrl
console.error(`Could not fetch calendars from ${url}. ${error}`)
return []
}
}))
this._calendars = calendarsPerSource.flat()
}
public getCalendars = () => this._calendars
public getCalendarByUrl = (url: string): Calendar | undefined => {
return this._calendars.find(c => c.url === url)
}
public fetchAndLoadEvents = async (start: string, end: string): Promise<CalendarEvent[]> => {
this._lastFetchNumber++
const currentFetchNumber = this._lastFetchNumber
const allObjects = await Promise.all(
this._calendars.map(async calendar => {
try {
return await fetchCalendarObjects(calendar, { start, end }, true)
} catch (error) {
console.error(`Could not fetch events from ${calendar.url}. ${error}`)
return { calendarObjects: [], recurringObjects: [] }
}
}),
)
// NOTE - CJ - 2025-07-15 - only update the objects if this is the latest fetch
// This can happen if this fetch took more time than the last one
if (this._lastFetchNumber === currentFetchNumber) {
this._calendarObjects = allObjects.flatMap(objs => objs.calendarObjects)
this._recurringObjects = allObjects.flatMap(objs => objs.recurringObjects)
}
return this.getCalendarEvents()
}
public getCalendarEvents = (): CalendarEvent[] => {
return this.getCalendarEventsFromCalendarObjects(this._calendarObjects)
}
private getCalendarEventsFromCalendarObjects = (calendarObjects: CalendarObject[]): CalendarEvent[] => {
return calendarObjects.flatMap(co => {
const events = co.data.events ?? []
return events.map(event => ({ event, calendarUrl: co.calendarUrl }))
})
}
public getCalendarEvent = (uid: EventUid): DisplayedCalendarEvent | undefined => {
for (const calendarObject of this._calendarObjects) {
for (const event of calendarObject.data.events ?? []) {
if (!isSameEvent(event, uid)) continue
const recurringEvent = event.recurrenceId
? this.getCalendarObject(event)!.data.events!.find(e => isRRuleSourceEvent(event, e))
: undefined
return { calendarUrl: calendarObject.calendarUrl, event, recurringEvent }
}
}
return undefined
}
private getCalendarObject = (uid: IcsEvent): CalendarObject | undefined => {
for (const calendarObject of this._calendarObjects) {
for (const event of calendarObject.data.events ?? []) {
// NOTE - CJ - 2025-07-03 - Since we look are just looking for the CalendarObject and not the event,
// we just need to check the uid of any event, and not the recurrenceID
if (event.uid !== uid.uid) continue
if (!event.recurrenceId) return calendarObject
for (const recurringObject of this._recurringObjects) {
for (const event of recurringObject.data.events ?? []) {
if (event.uid === uid.uid) return recurringObject
}
}
return undefined
}
}
return undefined
}
public createEvent = async ({ calendarUrl, event }: CalendarEvent): Promise<CalendarResponse> => {
const calendar = this.getCalendarByUrl(calendarUrl)
if (!calendar) {
return {
response: new Response(null, { status: 404 }),
ical: '',
} as CalendarResponse
}
const calendarObject: IcsCalendar = {
// INFO - CJ - 2025-07-03 - prodId is a FPI (https://en.wikipedia.org/wiki/Formal_Public_Identifier)
// '+//IDN algoo.fr//NONSGML Open Calendar v0.9//EN' would also be possible
prodId: '-//algoo.fr//NONSGML Open Calendar v0.9//EN',
version: '2.0',
events: [event],
}
const response = await createCalendarObject(calendar, calendarObject)
return response
}
// FIXME - CJ - 2025/06/03 - changing an object of calendar is not supported;
public updateEvent = async ({ event }: CalendarEvent): Promise<CalendarResponse> => {
const calendarObject = this.getCalendarObject(event)
if (!calendarObject) {
return {
response: new Response(null, { status: 404 }),
ical: '',
} as CalendarResponse
}
const calendar = this.getCalendarByUrl(calendarObject.calendarUrl)!
// FIXME - CJ - 2025-07-03 - Doing a deep copy probably be a better idea and avoid further issues
const oldEvents = calendarObject.data.events ? [...calendarObject.data.events] : []
const index = calendarObject.data.events!.findIndex(e => isSameEvent(e, event))
// NOTE - CJ - 2025-07-03 - When an recurring event instance is modified for the 1st time,
// it's not present in the `events` list and needs to be added
if (event.recurrenceId && index === -1) {
calendarObject.data.events!.push(event)
} else {
event.sequence = (event.sequence ?? 0) + 1
calendarObject.data.events![index] = event
}
if (event.recurrenceRule) {
// INFO - CJ - 2025-07-03 - `recurrenceId` of modified events needs to be synced with `start` of the root event
calendarObject.data.events = calendarObject.data.events!.map(element => {
if (element === event || !isRRuleSourceEvent(element, event)) return element
const recurrenceOffset = element.recurrenceId!.value.date.getTime() - oldEvents[index].start.date.getTime()
return {
...element,
recurrenceId: { value: offsetDate(event.start, recurrenceOffset) },
} as IcsEvent
})
// INFO - CJ - 2025-07-03 - `exceptionDates` needs to be synced with `start`
event.exceptionDates = event.exceptionDates?.map(value => {
const recurrenceOffset = value.date.getTime() - oldEvents[index].start.date.getTime()
return offsetDate(event.start, recurrenceOffset)
})
}
const {response, ical } = await updateCalendarObject(calendar, calendarObject)
if (!response.ok) calendarObject.data.events = oldEvents
return {
response,
ical: event.recurrenceId
? getEventObjectString({...calendarObject!.data, events: [event]})
: ical,
}
}
public deleteEvent = async ({ event }: CalendarEvent): Promise<CalendarResponse> => {
const calendarObject = this.getCalendarObject(event)
if (!calendarObject) {
return {
response: new Response(null, { status: 404 }),
ical: '',
} as CalendarResponse
}
const calendar = this.getCalendarByUrl(calendarObject.calendarUrl)!
// FIXME - CJ - 2025-07-03 - Doing a deep copy probably be a better idea and avoid further issues
const oldEvents = calendarObject.data.events ? [...calendarObject.data.events] : undefined
// NOTE - CJ - 2025-07-18 - The ical we need when deleting an event is the one before deletion
const ical = event.recurrenceId
? getEventObjectString({...calendarObject!.data, events: [event]})
: getEventObjectString(calendarObject!.data)
// NOTE - CJ - 2025-07-03 - When removing a recurring event instance, add it to exceptionDates
if (event.recurrenceId) {
const rruleEvent = calendarObject.data.events!.find(e => isRRuleSourceEvent(event, e))!
rruleEvent.exceptionDates ??= []
rruleEvent.exceptionDates?.push(event.recurrenceId.value)
}
const index = calendarObject.data.events!.findIndex(e => isSameEvent(e, event))
if (index !== -1) {
event.sequence = (event.sequence ?? 0) + 1
calendarObject.data.events!.splice(index, 1)
}
if (event.recurrenceRule) {
calendarObject.data.events = calendarObject.data.events!.filter(e => !isRRuleSourceEvent(e, event))
}
const action = calendarObject.data.events!.length === 0 ? deleteCalendarObject : updateCalendarObject
const { response } = await action(calendar, calendarObject)
if (!response.ok) calendarObject.data.events = oldEvents
return { response, ical }
}
public loadAddressBooks = async (sources: (ServerSource | AddressBookSource | VCardProvider)[]) => {
this._vCardProviders = sources.filter(s => isVCardProvider(s))
const davSources = sources.filter(s => !isVCardProvider(s)) as (ServerSource | AddressBookSource)[]
const addressBooksPerSources = await Promise.all(davSources.map(async source => {
try {
return await fetchAddressBooks(source)
} catch (error) {
const url = isServerSource(source) ? source.serverUrl : source.addressBookUrl
console.error(`Could not fetch address books from ${url}. ${error}`)
return []
}
}))
this._addressBooks = addressBooksPerSources.flat()
}
public fetchAndLoadVCards = async (): Promise<AddressBookVCard[]> => {
const vCards = await Promise.all(
this._addressBooks.map(async book => {
try {
return await fetchAddressBookObjects(book)
} catch (error) {
console.error(`Could not fetch vcards objects from ${book.url}. ${error}`)
return []
}
}),
)
this._addressBookObjects = vCards.flat()
this._providedVCards = (await Promise.all(this._vCardProviders.flatMap(p => p.fetchContacts()))).flat()
return this.getAddressBookVCards()
}
public getAddressBookVCards = (): AddressBookVCard[] => {
return [
...this.getAddressBookVCardsFromObjects(this._addressBookObjects),
...this.getAddressBookVCardsFromProvidedContacts(this._providedVCards),
]
}
private getAddressBookVCardsFromObjects = (addressBookObjects: AddressBookObject[]): AddressBookVCard[] => {
// NOTE - CJ - 2025-07-16 - radicale does not accepts vcf files with more than one vcard component
return addressBookObjects.map(ao => ({ vCard: new VCardComponent(ao.data), addressBookUrl: ao.addressBookUrl }))
}
private getAddressBookVCardsFromProvidedContacts = (vCards: VCard[]): AddressBookVCard[] => {
return vCards.map(c => ({addressBookUrl: undefined, vCard: c}))
}
}

View File

@@ -1,7 +0,0 @@
.open-calendar__overlay {
overflow: auto;
position: fixed;
width: max-content;
z-index : 9999;
box-shadow : 0 2px 8px rgba(0,0,0,0.2);
}

View File

@@ -1,535 +0,0 @@
import { createCalendar as createEventCalendar,
DayGrid,
TimeGrid,
List,
Interaction,
destroyCalendar as destroyEventCalendar,
} from '@event-calendar/core'
import type { Calendar as EventCalendar } from '@event-calendar/core'
import '@event-calendar/core/index.css'
import {
getEventEnd,
type IcsEvent,
type IcsAttendee,
type IcsAttendeePartStatusType,
type IcsDateObject,
} from 'ts-ics'
import { EventEditPopup } from '../eventeditpopup/eventEditPopup'
import { hasCalendarHandlers, hasEventHandlers } from '../helpers/types-helper'
import { isEventAllDay, offsetDate } from '../helpers/ics-helper'
import './calendarElement.css'
import { CalendarSelectDropdown } from '../calendarselectdropdown/calendarSelectDropdown'
import { icon, library } from '@fortawesome/fontawesome-svg-core'
import { faRefresh } from '@fortawesome/free-solid-svg-icons'
import { CalendarClient } from '../calendarClient'
import { getTranslations } from '../translations'
import { EventBody } from '../eventBody/eventBody'
import { TIME_MINUTE, TIME_DAY } from '../constants'
import type { AddressBookSource,
BodyHandlers,
CalendarOptions,
CalendarSource,
VCardProvider,
DefaultComponentsOptions,
DomEvent,
EventBodyInfo,
EventChangeHandlers,
EventEditHandlers,
SelectCalendarHandlers,
SelectedCalendar,
ServerSource,
View,
} from '../types/options'
import type { CalendarEvent, EventUid } from '../types/calendar'
import type { Contact } from '../types/addressbook'
library.add(faRefresh)
const THIRTY_MINUTE = 30 * 60 * 1000
// HACK - CJ - 2025-07-03 - When an event is the whole day, the date returned by caldav is in UTC (20250627)
// but since we display the local date, it's interpreted in our timezone (20250627T000200)
// and for all day events, EC round up to the nearest day (20250628)
// In the end the event is displayed for one extra day
// Those functions correct this by "un-applying" the timezone offset
function dateToECDate(date: Date, allDay: boolean) {
if (!allDay) return date
return new Date(date.getTime() + date.getTimezoneOffset() * TIME_MINUTE)
}
function ecDateToDate(date: Date, allDay: boolean) {
if (!allDay) return date
return new Date(date.getTime() - date.getTimezoneOffset() * TIME_MINUTE)
}
export class CalendarElement {
private _client: CalendarClient
private _selectedCalendars: Set<string>
private _target: Element | null = null
private _calendar: EventCalendar | null = null
private _eventBody: EventBody | null = null
private _eventEdit: EventEditPopup | null = null
private _calendarSelect: CalendarSelectDropdown | null = null
private _calendarSelectHandlers?: SelectCalendarHandlers
private _eventEditHandlers?: EventEditHandlers
private _eventChangeHandlers?: EventChangeHandlers
private _bodyHandlers?: BodyHandlers
private _userContact?: Contact
public constructor() {
this._client = new CalendarClient()
this._selectedCalendars = new Set()
}
public create = async (
calendarSources: (ServerSource | CalendarSource)[],
addressBookSources: (ServerSource | AddressBookSource | VCardProvider)[],
target: Element,
options?: CalendarOptions,
) => {
if (this._calendar) return
await Promise.all([
this._client.loadCalendars(calendarSources),
this._client.loadAddressBooks(addressBookSources),
])
this._selectedCalendars = new Set(this._client.getCalendars().map(c => c.url))
this._eventEditHandlers = options && hasEventHandlers(options)
? {
onCreateEvent: options.onCreateEvent,
onSelectEvent: options.onSelectEvent,
onMoveResizeEvent: options.onMoveResizeEvent,
onDeleteEvent: options.onDeleteEvent,
}
: this.createDefaultEventEdit(target, options ?? {})
this._calendarSelectHandlers = options && hasCalendarHandlers(options)
? {
onClickSelectCalendars: options.onClickSelectCalendars,
}
: this.createDefaultCalendarSelectElement()
this._eventChangeHandlers = {
onEventCreated: options?.onEventCreated,
onEventUpdated: options?.onEventUpdated,
onEventDeleted: options?.onEventDeleted,
}
this.createCalendar(target, options)
this._bodyHandlers = {
getEventBody: options?.getEventBody ?? this.createDefaultEventBody(options ?? {}),
}
this._userContact = options?.userContact
}
public destroy = () => {
this.destroyCalendar()
this.destroyDefaultEventEdit()
this.destroyDefaultCalendarSelectElement()
this.destroyDefaultEventBody()
}
private createCalendar = (target: Element, options?: CalendarOptions) => {
if (this._calendar) return
target.classList.add('open-calendar')
this._target = target
this._calendar = createEventCalendar(
target,
[DayGrid, TimeGrid, List, Interaction],
{
date: options?.date,
view: options?.view ?? 'timeGridWeek',
customButtons: {
refresh: {
text: { domNodes: Array.from(icon({ prefix: 'fas', iconName: 'refresh' }).node) },
click: this.refreshEvents,
},
calendars: {
text: getTranslations().calendarElement.calendars,
click: this.onClickCalendars,
},
newEvent: {
text: getTranslations().calendarElement.newEvent,
click: this.onClickNewEvent,
},
},
slotEventOverlap: false,
headerToolbar: {
start: 'calendars,refresh newEvent prev,today,next',
center: 'title',
end: (options?.views ?? ['timeGridDay', 'timeGridWeek', 'dayGridMonth', 'listWeek']).join(','),
},
buttonText: getTranslations().calendarElement,
allDayContent: getTranslations().calendarElement.allDay,
dayMaxEvents: true,
nowIndicator: true,
firstDay: 1,
// INFO - CJ - 2025-07-03
// This member is not present in "@types/event-calendar__core"
eventResizableFromStart: options?.editable ?? true,
selectable: options?.editable ?? true,
editable: options?.editable ?? true,
eventContent: this.getEventContent,
eventClick: this.onEventClicked,
select: this.onSelectTimeRange,
eventResize: this.onChangeEventDates,
eventDrop: this.onChangeEventDates,
eventSources: [{ events: this.fetchAndLoadEvents }],
eventFilter: this.isEventVisible,
dateClick: this.onSelectDate,
},
)
}
private destroyCalendar = () => {
if (this._calendar) {
this._target!.classList.remove('open-calendar')
destroyEventCalendar(this._calendar)
}
this._calendar = null
this._target = null
}
private createDefaultEventEdit = (target: Node, options: DefaultComponentsOptions): EventEditHandlers => {
this._eventEdit ??= new EventEditPopup(target, options)
return {
onCreateEvent: this._eventEdit.onCreate,
onSelectEvent: this._eventEdit.onSelect,
onMoveResizeEvent: this._eventEdit.onMoveResize,
onDeleteEvent: this._eventEdit.onDelete,
}
}
private destroyDefaultEventEdit = () => {
this._eventEdit?.destroy()
this._eventEdit = null
}
private createDefaultCalendarSelectElement = (): SelectCalendarHandlers => {
this._calendarSelect ??= new CalendarSelectDropdown()
return {
onClickSelectCalendars: this._calendarSelect.onSelect,
}
}
private destroyDefaultCalendarSelectElement = () => {
this._calendarSelect?.destroy()
this._calendarSelect = null
}
private createDefaultEventBody = (options: DefaultComponentsOptions): (info: EventBodyInfo) => Node[] => {
this._eventBody ??= new EventBody(options)
return this._eventBody.getBody
}
private destroyDefaultEventBody = () => {
this._eventBody = null
}
private fetchAndLoadEvents = async (info: EventCalendar.FetchInfo): Promise<EventCalendar.EventInput[]> => {
const [calendarEvents] = await Promise.all([
this._client.fetchAndLoadEvents(info.startStr, info.endStr),
this._client.fetchAndLoadVCards(), // INFO - PG - 2025-09-24 - no return value
])
return calendarEvents.map(({ event, calendarUrl }) => {
const allDay = isEventAllDay(event)
return {
title: event.summary,
allDay: allDay,
start: dateToECDate(event.start.date, allDay),
end: dateToECDate(getEventEnd(event), allDay),
backgroundColor: this._client.getCalendarByUrl(calendarUrl)!.calendarColor,
extendedProps: { uid: event.uid, recurrenceId: event.recurrenceId } as EventUid,
}
})
}
private isEventVisible = (info: EventCalendar.EventFilterInfo) => {
const eventData = this._client.getCalendarEvent(info.event.extendedProps as EventUid)
if (!eventData) return false
return this._selectedCalendars.has(eventData.calendarUrl)
}
private onClickCalendars = (jsEvent: MouseEvent) => {
this._calendarSelectHandlers!.onClickSelectCalendars({
jsEvent,
selectedCalendars: this._selectedCalendars,
calendars: this._client.getCalendars(),
handleSelect: this.setCalendarVisibility,
})
}
private getEventContent = ({ event, view }: EventCalendar.EventContentInfo): EventCalendar.Content => {
const calendarEvent = this._client.getCalendarEvent(event.extendedProps as EventUid)
// NOTE - CJ - 2025-11-07 - calendarEvent can be undefined when creating events
if (calendarEvent === undefined) return {html: ''}
const calendar = this._client.getCalendarByUrl(calendarEvent.calendarUrl)!
const events = this._bodyHandlers!.getEventBody({
event: calendarEvent.event,
vCards: this._client.getAddressBookVCards(),
calendar,
view: view.type as View,
userContact: this._userContact,
})
events.forEach(n => {
if (!(n instanceof HTMLElement)) return
const ev = calendarEvent.event
const isShort = Boolean(
ev.start && ev.end && ev.start.date && ev.end.date &&
(ev.end.date.getTime() - ev.start.date.getTime()) <= THIRTY_MINUTE)
if (isShort) n.classList.add('open-calendar__event-body--small')
const ro = new ResizeObserver(() => {
if (n.scrollHeight > n.clientHeight) n.classList.add('open-calendar__event-body--small')
else if (!isShort) n.classList.remove('open-calendar__event-body--small')
})
ro.observe(n)
n.addEventListener('participation-icon-click', async (e: Event) => {
const custom = e as CustomEvent
const email: string | undefined = custom.detail?.email
if (!email || email !== this._userContact?.email) return
const ev = this._client.getCalendarEvent(event.extendedProps as EventUid)
if (!ev) return
const oldEvent = ev.event
const newEvent: IcsEvent = {
...oldEvent,
attendees: oldEvent.attendees
? oldEvent.attendees.map(a => {
if (a.email !== email) return a
const current = (a.partstat ?? 'NEEDS-ACTION') as IcsAttendeePartStatusType
const next: IcsAttendeePartStatusType =
current === 'NEEDS-ACTION' ? 'ACCEPTED'
: current === 'ACCEPTED' ? 'DECLINED'
: 'NEEDS-ACTION'
return { ...a, partstat: next } as IcsAttendee
})
: oldEvent.attendees,
} as IcsEvent
await this.handleUpdateEvent({ calendarUrl: ev.calendarUrl, event: newEvent })
})
n.addEventListener('event-edit', (jsEvent: Event) => {
const ev = this._client.getCalendarEvent(event.extendedProps as EventUid)
if (!ev) return
this._eventEditHandlers!.onSelectEvent({
jsEvent,
userContact: this._userContact,
calendars: this._client.getCalendars(),
vCards: this._client.getAddressBookVCards(),
...ev,
handleUpdate: this.handleUpdateEvent,
handleDelete: this.handleDeleteEvent,
})
})
})
return { domNodes: events }
}
private onClickNewEvent = (jsEvent: MouseEvent) => this.createEvent(jsEvent)
private onSelectDate = ({ allDay, date, jsEvent}: EventCalendar.DateClickInfo) => {
this.createEvent(jsEvent, {
start: {
date: ecDateToDate(date, allDay),
type: allDay ? 'DATE' : 'DATE-TIME',
},
})
}
private onSelectTimeRange = ({ allDay, start, end, jsEvent}: EventCalendar.SelectInfo) => {
const type = allDay ? 'DATE' : 'DATE-TIME'
this.createEvent(jsEvent, {
start: {
date: ecDateToDate(start, allDay),
type,
},
end: {
date: ecDateToDate(end, allDay),
type,
},
})
}
private createEvent = (jsEvent: DomEvent, event?: Partial<IcsEvent>) => {
const start = event?.start ?? {
date: new Date(),
type: 'DATE-TIME',
} as IcsDateObject
const newEvent = {
summary: '',
uid: '',
stamp: { date: new Date() },
start,
end: offsetDate(start, start.type == 'DATE' ? (1 * TIME_DAY) : (30 * TIME_MINUTE)),
...event,
// NOTE - CJ - 2025-07-03 - Since we specify end, duration should be undefined
duration: undefined,
}
this._eventEditHandlers!.onCreateEvent({
jsEvent,
userContact: this._userContact,
calendars: this._client.getCalendars(),
event: newEvent,
vCards: this._client.getAddressBookVCards(),
handleCreate: this.handleCreateEvent,
})
}
private onChangeEventDates = async (info: EventCalendar.EventDropInfo | EventCalendar.EventResizeInfo) => {
const uid = info.oldEvent.extendedProps as EventUid
const calendarEvent = this._client.getCalendarEvent(uid)
if (!calendarEvent) return
info.revert()
this._eventEditHandlers!.onMoveResizeEvent({
userContact: this._userContact,
jsEvent: info.jsEvent,
...calendarEvent,
start: info.event.start,
end: info.event.end,
handleUpdate: this.handleUpdateEvent,
})
}
private onEventClicked = ({ event, jsEvent}: EventCalendar.EventClickInfo) => {
const mouse = jsEvent as MouseEvent
const targetEl = jsEvent.target as HTMLElement
// Ignore clicks on status icon (handled separately)
if (targetEl?.closest('.open-calendar__event-body__status-clickable')) return
const container = targetEl?.closest('.ec-event') as HTMLElement | null
const bodyEl = container?.querySelector('.open-calendar__event-body') as HTMLElement | null
const isSmall = !!container?.querySelector('.open-calendar__event-body--small')
// For small events: first click shows overlay, click inside overlay opens edit
if (isSmall) {
const rect = container!.getBoundingClientRect()
const overlay = document.createElement('div')
overlay.className = 'open-calendar__overlay'
overlay.style.left = `${rect.left}px`
overlay.style.top = `${rect.top}px`
overlay.style.minWidth = `${rect.width}px`
const cs = getComputedStyle(container!)
overlay.style.borderRadius = cs.borderRadius
overlay.style.backgroundColor = cs.backgroundColor
overlay.style.color = cs.color
overlay.style.padding = cs.padding
// Clone body for full content
const clone = bodyEl!.cloneNode(true) as HTMLElement
clone.classList.remove('open-calendar__event-body--small')
clone.classList.add('open-calendar__event-body--expanded')
overlay.appendChild(clone)
document.body.appendChild(overlay)
// Reposition if overflowing viewport
const orect = overlay.getBoundingClientRect()
const newLeft = Math.max(8, Math.min(rect.left, window.innerWidth - orect.width - 8))
const newTop = Math.max(8, Math.min(rect.top, window.innerHeight - orect.height - 8))
overlay.style.left = `${newLeft}px`
overlay.style.top = `${newTop}px`
const onDocPointer = (ev: Event) => {
const target = ev.target as Node
if (!overlay.contains(target)) {
removeOverlay()
}
}
const removeOverlay = () => {
document.removeEventListener('click', onDocPointer)
document.removeEventListener('touchstart', onDocPointer)
overlay.remove()
}
document.addEventListener('click', onDocPointer, true)
document.addEventListener('touchstart', onDocPointer, true)
overlay.addEventListener('mouseleave', removeOverlay)
// Clicking inside overlay opens edit
overlay.addEventListener('click', () => {
removeOverlay()
const uid = event.extendedProps as EventUid
const calendarEvent = this._client.getCalendarEvent(uid)
if (!calendarEvent) return
this._eventEditHandlers!.onSelectEvent({
jsEvent,
userContact: this._userContact,
calendars: this._client.getCalendars(),
vCards: this._client.getAddressBookVCards(),
...calendarEvent,
handleUpdate: this.handleUpdateEvent,
handleDelete: this.handleDeleteEvent,
})
})
return
}
// For non-small: open edit on single click
if (mouse && mouse.detail >= 1) {
const uid = event.extendedProps as EventUid
const calendarEvent = this._client.getCalendarEvent(uid)
if (!calendarEvent) return
this._eventEditHandlers!.onSelectEvent({
jsEvent,
userContact: this._userContact,
calendars: this._client.getCalendars(),
vCards: this._client.getAddressBookVCards(),
...calendarEvent,
handleUpdate: this.handleUpdateEvent,
handleDelete: this.handleDeleteEvent,
})
return
}
const uid = event.extendedProps as EventUid
const calendarEvent = this._client.getCalendarEvent(uid)
if (!calendarEvent) return
this._eventEditHandlers!.onSelectEvent({
jsEvent,
userContact: this._userContact,
calendars: this._client.getCalendars(),
vCards: this._client.getAddressBookVCards(),
...calendarEvent,
handleUpdate: this.handleUpdateEvent,
handleDelete: this.handleDeleteEvent,
})
}
private refreshEvents = () => {
this._calendar!.refetchEvents()
}
private setCalendarVisibility = ({url: calendarUrl, selected}: SelectedCalendar) => {
const calendar = this._client.getCalendarByUrl(calendarUrl)
if (!calendar) return
if (selected) this._selectedCalendars.add(calendarUrl)
else this._selectedCalendars.delete(calendarUrl)
this.refreshEvents()
}
private handleCreateEvent = async (calendarEvent: CalendarEvent) => {
const { response, ical } = await this._client.createEvent(calendarEvent)
if (response.ok) {
this._eventChangeHandlers!.onEventCreated?.({...calendarEvent, ical})
this.refreshEvents()
}
return response
}
private handleUpdateEvent = async (calendarEvent: CalendarEvent) => {
const { response, ical } = await this._client.updateEvent(calendarEvent)
if (response.ok) {
this._eventChangeHandlers!.onEventUpdated?.({...calendarEvent, ical})
this.refreshEvents()
}
return response
}
private handleDeleteEvent = async (calendarEvent: CalendarEvent) => {
const { response, ical } = await this._client.deleteEvent(calendarEvent)
if (response.ok) {
this._eventChangeHandlers!.onEventDeleted?.({...calendarEvent, ical})
this.refreshEvents()
}
return response
}
}

View File

@@ -1,39 +0,0 @@
.open-calendar__calendar-select__container {
position: absolute;
top: 100%;
width: max-content;
z-index: 1500;
background-color: white;
/* CJ - 2025-07-03 - '--ec-button-border-color' is defined by EventCalendar */
/* FIXME - CJ - 2025-07-03 - I tried set the rule option 'allowUnknownVariables' to in 'eslint.config.mjs' but this made the config fail*/
/* eslint-disable-next-line css/no-invalid-properties */
border: 1px solid var(--ec-button-border-color);
padding: 0.375rem 0.75rem;
font-size: 1rem;
border-radius: 0.25rem;
}
.open-calendar__calendar-select__label {
display: flex;
align-items: baseline;
gap: 5px
}
.open-calendar__calendar-select__color {
border-radius: 50%;
height: 0.75rem;
width: 0.75rem;
}
/* HACK - CJ - 2025-07-03 - Added in order to make the `top: 100%` use the heigh of the parent and not of the window */
.open-calendar__calendar-select__parent {
position: relative;
}
/* HACK - CJ - 2025-07-03 - Prevents the addition of the popup to affect the style of the button (as it is not the first child anymore */
.ec-button-group .ec-calendars:not(:first-child) {
margin-left: 0;
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}

View File

@@ -1,51 +0,0 @@
import { parseHtml } from '../helpers/dom-helper'
import './calendarSelectDropdown.css'
import type { SelectCalendarsClickInfo } from '../types/options'
const html = /* html */`
<div class="open-calendar__calendar-select__container open-calendar__form">
<div class="open-calendar__form__content" >
{{#calendars}}
<label class="open-calendar__calendar-select__label" for="open-calendar__calendar-select__{{index}}">
<span class="open-calendar__calendar-select__color" style="background-color:{{calendarColor}}"> </span>
{{displayName}}
</label>
<input type="checkbox" id="open-calendar__calendar-select__{{index}}"/>
{{/calendars}}
</div>
</div>`
export class CalendarSelectDropdown {
private _container: HTMLDivElement | null = null
public constructor() {}
public destroy = () => {}
public onSelect = ({jsEvent, calendars, handleSelect, selectedCalendars }: SelectCalendarsClickInfo) => {
const target = jsEvent.target as Element
const parent = target.parentElement as Element
if (this._container) {
parent.removeChild(this._container)
parent.classList.remove('open-calendar__calendar-select__parent')
this._container = null
return
}
this._container = parseHtml<HTMLDivElement>(html, {
calendars: calendars.map((calendar, index) => ({ ...calendar, index })),
})[0]
parent.insertBefore(this._container, target)
parent.classList.add('open-calendar__calendar-select__parent')
const inputs = this._container.querySelectorAll<HTMLInputElement>('input')
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const calendar = calendars[i]
input.checked = selectedCalendars.has(calendar.url)
input.addEventListener('change', e => handleSelect({
url: calendar.url,
selected: (e.target as HTMLInputElement).checked,
}))
}
}
}

View File

@@ -1,7 +0,0 @@
export const TIME_MILLISECOND = 1
export const TIME_SECOND = 1000 * TIME_MILLISECOND
export const TIME_MINUTE = 60 * TIME_SECOND
export const TIME_HOUR = 60 * TIME_MINUTE
export const TIME_DAY = 24 * TIME_HOUR
export const attendeeUserParticipationStatusTypes = ['NEEDS-ACTION', 'ACCEPTED', 'DECLINED', 'TENTATIVE'] as const

View File

@@ -1,25 +0,0 @@
export const attendeeRoleTypes = [
'CHAIR',
'REQ-PARTICIPANT',
'OPT-PARTICIPANT',
'NON-PARTICIPANT',
] as const
export const namedRRules = [
'FREQ=DAILY',
'FREQ=WEEKLY',
'BYDAY=MO,TU,WE,TH,FR;FREQ=DAILY',
'INTERVAL=2;FREQ=WEEKLY',
'FREQ=MONTHLY',
'FREQ=YEARLY',
] as const
export const availableViews = [
'timeGridDay',
'timeGridWeek',
'dayGridMonth',
'listDay',
'listWeek',
'listMonth',
'listYear',
] as const

View File

@@ -1,143 +0,0 @@
.ec-event {
/* NOTE - CJ - 2025-07-03 - Overrides the min heigh of the event to be one line. !important is needed as this is set manually on the element */
/* eslint-disable-next-line css/no-important */
min-height: 1.5rem !important;
}
.open-calendar__event-body {
height: 100%;
width: 100%;
overflow: hidden;
--open-calendar__event-body__gap: 2px;
display: flex;
flex-direction: column;
}
.open-calendar__event-body__time {
float: left;
display: flex;
margin-right: var(--open-calendar__event-body__gap);
}
.open-calendar__event-body__icons {
float: right;
display: flex;
gap: var(--open-calendar__event-body__gap);
margin-left: var(--open-calendar__event-body__gap);
align-items: center;
height: 1.5em;
}
.open-calendar__event-body__organizer {
font-weight: bold;
color: red;
}
.open-calendar__event-body__attendee--chair {
font-weight: bold;
}
.open-calendar__event-body__attendee--req-participant {
font-weight: bold;
}
.open-calendar__event-body__attendee--non-participant {
font-style: italic;
}
.open-calendar__event-body__attendee--accepted {
color: green;
}
.open-calendar__event-body__attendee--declined {
color: gray;
}
.ec-day-grid .open-calendar__event-body__header {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.ec-day-grid :is(.open-calendar__event-body__location) {
display: none;
}
.open-calendar__event-body__attendees {
font-size: 0.8em;
margin: 0.75em 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.open-calendar__event-body__attendee-line {
display: flex;
align-items: center;
gap: 0.5em;
}
.open-calendar__event-body__attendee-status-icon {
display: inline-flex;
align-items: center;
}
.open-calendar__event-body__attendee-role-icon {
display: inline-flex;
align-items: center;
}
.open-calendar__event-body__attendee-name.organizer,
.open-calendar__event-body__attendee-name.required {
font-weight: bold;
}
.open-calendar__event-body__attendee-name.optional {
font-weight: normal;
}
.open-calendar__event-body__attendee-name.non-participant {
font-style: italic;
}
.open-calendar__event-body__attendee-line.declined {
color: gray;
text-decoration: line-through;
}
.open-calendar__event-body__attendee-status-icon__pending {
color: orange;
}
.open-calendar__event-body__attendee-status-icon__tentative {
color: orange;
}
.open-calendar__event-body__attendee-status-icon__confirmed {
color: blue;
}
.open-calendar__event-body__attendee-status-icon__declined {
color: gray;
}
.open-calendar__event-body__status-clickable {
cursor: pointer;
transition: filter 0.2s;
}
.open-calendar__event-body__status-clickable:hover {
filter: brightness(1.2);
}
.open-calendar__event-body--expanded {
max-height: none;
max-width: none;
overflow: visible;
cursor: pointer;
}
.open-calendar__event-body--small {
cursor: zoom-in;
}
.open-calendar__event-body__description {
margin-top: 0.5em;
font-size: 0.9em;
}

View File

@@ -1,203 +0,0 @@
import { escapeHtml, parseHtml } from '../helpers/dom-helper'
import Autolinker from 'autolinker'
import { icon, library } from '@fortawesome/fontawesome-svg-core'
import { faRepeat, faBell, faChalkboardUser, faUserGraduate, faUser, faUserSlash, faCircleQuestion, faSquareCheck, faXmark, faLocationDot } from '@fortawesome/free-solid-svg-icons'
import { far } from '@fortawesome/free-regular-svg-icons'
import './eventBody.css'
import { contactToMailbox, isEventAllDay, isSameContact } from '../helpers/ics-helper'
import type { IcsAttendee, IcsAttendeePartStatusType, IcsOrganizer } from 'ts-ics'
import type { DefaultComponentsOptions, EventBodyInfo, IcsAttendeeRoleType } from '../types/options'
import type { AddressBookVCard } from '../types/addressbook'
import { getTranslations } from '../translations'
library.add(
faRepeat,
faBell,
faChalkboardUser,
faUserGraduate,
faUser,
faUserSlash,
faCircleQuestion,
faSquareCheck,
faXmark,
far,
faLocationDot,
)
const addFaFw = (html: string) => html.replace('class="', 'class="fa-fw ')
const html = /*html*/`
<div class="open-calendar__event-body">
<div class="open-calendar__event-body__header">
<div class="open-calendar__event-body__time">
<b>{{time}}</b>
</div>
<div class="open-calendar__event-body__icons">
{{#icons}}{{&.}}{{/icons}}
</div>
<b>{{summary}}</b>
</div>
{{#location}}
<!-- NOTE - CJ - 2025-07-07 - location is escaped in the js as we wan to display a link -->
<div class="open-calendar__event-body__location">{{&location}}</div>
{{/location}}
<div class="open-calendar__event-body__attendees">
{{#organizer}}
<div class="open-calendar__event-body__attendee-line organizer">
<span class="open-calendar__event-body__attendee-status-icon__confirmed" title="{{t.participation_confirmed}}">
{{&organizerStatusIcon}}
</span>
<span class="open-calendar__event-body__attendee-role-icon" title="{{t.organizer}}">{{&organizerRoleIcon}}</span>
<span class="open-calendar__event-body__attendee-name organizer">{{name}}</span>
</div>
{{/organizer}}
{{#attendees}}
<div class="open-calendar__event-body__attendee-line {{declinedClass}}">
<span class="open-calendar__event-body__attendee-status-icon__{{statusClass}}" title="{{statusTitle}}">
{{&statusIcon}}
</span>
<span class="open-calendar__event-body__attendee-role-icon" title="{{roleTitle}}">{{&roleIcon}}</span>
<span class="open-calendar__event-body__attendee-name {{roleClass}}">{{name}}</span>
</div>
{{/attendees}}
</div>
{{#description}}
<div class="open-calendar__event-body__description">{{&description}}</div>
{{/description}}
</div>`
export class EventBody {
private _hideVCardEmails?: boolean
public constructor(options: DefaultComponentsOptions) {
this._hideVCardEmails = options.hideVCardEmails
}
public getBody = ({ event, vCards, userContact }: EventBodyInfo) => {
const time = event.start.date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const attendees = event.attendees ? event.attendees.map(a => this.mapAttendee(a, vCards, userContact?.email)) : []
const organizer = event.organizer ? {
mailbox: this.getAttendeeValue(vCards, event.organizer),
name: event.organizer.name ?? event.organizer.email,
organizerStatusIcon: addFaFw(icon({ prefix: 'fas', iconName: 'square-check' }).html.join('')),
organizerRoleIcon: addFaFw(icon({ prefix: 'fas', iconName: 'user-graduate' }).html.join('')),
} : undefined
const events = Array.from(parseHtml(html, {
time: isEventAllDay(event) ? undefined : time,
summary: event.summary,
icons: [
event.recurrenceId ? addFaFw(icon({ prefix: 'fas', iconName: 'repeat' }).html.join('')) : undefined,
event.alarms ? addFaFw(icon({ prefix: 'fas', iconName: 'bell' }).html.join('')) : undefined,
],
location: event.location
? [
addFaFw(icon({ prefix: 'fas', iconName: 'location-dot' }).html.join('')),
Autolinker.link(escapeHtml(event.location)),
].join(' ')
: undefined,
description: event.description || undefined,
attendees: attendees.map(att => ({
...att,
statusIcon: att.isCurrentUser
? `<span
class='open-calendar__event-body__status-clickable'
data-email='${att.email}'
title='${att.statusTitle}'
>
${att.statusIcon}
</span>`
: att.statusIcon,
})),
organizer,
t: getTranslations().eventBody,
}))
// Add click handler for current user status icon
events.forEach(event => {
if (!(event instanceof HTMLElement)) return
event.querySelectorAll('.open-calendar__event-body__status-clickable').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation()
const email = (el as HTMLElement).getAttribute('data-email')
event.dispatchEvent(new CustomEvent('participation-icon-click', {
bubbles: true,
detail: { email },
}))
})
})
})
return events
}
public getAttendeeValue(vCards: AddressBookVCard[], attendee: IcsAttendee | IcsOrganizer) {
const vCard = vCards.find(c => isSameContact(c.vCard, attendee))?.vCard
return (this._hideVCardEmails && vCard?.name) || contactToMailbox(attendee)
}
public mapAttendee = (a: IcsAttendee, vCards: AddressBookVCard[], userEmail?: string) => {
const role = ((a.role as IcsAttendeeRoleType) ?? 'NON-PARTICIPANT').toUpperCase()
const partstat = ((a.partstat as IcsAttendeePartStatusType) ?? 'NEEDS-ACTION').toUpperCase()
const t = getTranslations().eventBody
let roleIcon = ''
let roleTitle = ''
let roleClass = ''
if (role === 'CHAIR') {
roleIcon = addFaFw(icon({ prefix: 'fas', iconName: 'user-graduate' }).html.join(''))
roleTitle = t.organizer
roleClass = 'organizer'
} else if (role === 'REQ-PARTICIPANT') {
roleIcon = addFaFw(icon({ prefix: 'fas', iconName: 'user' }).html.join(''))
roleTitle = t.participation_require
roleClass = 'required'
} else if (role === 'OPT-PARTICIPANT') {
roleIcon = addFaFw(icon({ prefix: 'far', iconName: 'user' }).html.join(''))
roleTitle = t.participation_optional
roleClass = 'optional'
} else if (role === 'NON-PARTICIPANT') {
roleIcon = addFaFw(icon({ prefix: 'fas', iconName: 'user-slash' }).html.join(''))
roleTitle = t.non_participant
roleClass = 'non-participant'
}
// Status icon, color, and title
let statusIcon = ''
let statusTitle = ''
let statusClass = ''
let declinedClass = ''
const isCurrentUser = Boolean(userEmail && a.email && a.email === userEmail)
if (partstat === 'NEEDS-ACTION') {
statusIcon = addFaFw(icon({ prefix: 'fas', iconName: 'circle-question' }).html.join(''))
statusClass = 'pending'
statusTitle = t.participation_pending
} else if (partstat === 'ACCEPTED') {
statusIcon = addFaFw(icon({ prefix: 'fas', iconName: 'square-check' }).html.join(''))
statusClass = 'confirmed'
statusTitle = t.participation_confirmed
} else if (partstat === 'TENTATIVE') {
statusIcon = addFaFw(icon({ prefix: 'fas', iconName: 'square-check' }).html.join(''))
statusClass = 'tentative'
statusTitle = t.participation_confirmed_tentative
} else if (partstat === 'DECLINED') {
statusIcon = addFaFw(icon({ prefix: 'fas', iconName: 'xmark' }).html.join(''))
statusClass = 'declined'
statusTitle = t.participation_declined
declinedClass = 'declined'
}
return {
mailbox: this.getAttendeeValue(vCards, a),
name: a.name ?? a.email,
role,
partstat,
roleIcon,
roleTitle,
roleClass,
statusIcon,
statusClass,
statusTitle,
declinedClass,
isCurrentUser,
email: a.email,
}
}
}

View File

@@ -1,30 +0,0 @@
.open-calendar__event-edit__attendee {
display: flex;
gap: 0.25rem;
}
.open-calendar__event-edit__attendee>:first-child {
flex-grow: 1;
}
.open-calendar__event-edit__datetime {
display: flex;
gap: 0.25rem;
}
.open-calendar__event-edit--is-allday :is([name="start-time"],
[name="start-timezone"],
[name="end-time"],
[name="end-timezone"]) {
visibility: hidden;
}
/* NOTE - CJ - 2025-07-03 - Hide the `Delete` button we are creating an event */
.open-calendar__event-edit--create [name="delete"] {
visibility: hidden;
}
.open-calendar__event-edit--without-invite .open-calendar__event-edit__invite {
display: none;
}

View File

@@ -1,464 +0,0 @@
import {
attendeePartStatusTypes,
convertIcsRecurrenceRule,
getEventEndFromDuration,
type IcsAttendee,
type IcsAttendeePartStatusType,
type IcsDateObject,
type IcsEvent,
type IcsOrganizer,
} from 'ts-ics'
import './eventEditPopup.css'
import { Popup } from '../popup/popup'
import { parseHtml } from '../helpers/dom-helper'
import { contactToMailbox,
getRRuleString,
isEventAllDay,
isSameContact,
mailboxToContact,
offsetDate,
} from '../helpers/ics-helper'
import { tzlib_get_ical_block, tzlib_get_offset, tzlib_get_timezones } from 'timezones-ical-library'
import { getTranslations } from '../translations'
import { RecurringEventPopup } from './recurringEventPopup'
import { attendeeUserParticipationStatusTypes, TIME_MINUTE } from '../constants'
import type { AddressBookVCard, Contact, VCard } from '../types/addressbook'
import type { DefaultComponentsOptions,
DomEvent,
EventEditCallback,
EventEditCreateInfo,
EventEditDeleteInfo,
EventEditMoveResizeInfo,
EventEditSelectInfo,
} from '../types/options'
import type {Calendar, CalendarEvent} from '../types/calendar'
import { attendeeRoleTypes, namedRRules } from '../contants'
const html = /*html*/`
<form name="event" class="open-calendar__event-edit open-calendar__form">
<datalist id="open-calendar__event-edit__mailboxes">
</datalist>
<div class="open-calendar__form__content open-calendar__event-edit__event">
<label for="open-calendar__event-edit__calendar">{{t.calendar}}</label>
<select id="open-calendar__event-edit__calendar" name="calendar" required="">
</select>
<label for="open-calendar__event-edit__summary">{{t.title}}</label>
<input type="text" id="open-calendar__event-edit__summary" name="summary" required="" />
<label for="open-calendar__event-edit__location">{{t.location}}</label>
<input type="text" id="open-calendar__event-edit__location" name="location" />
<label for="open-calendar__event-edit__allday">{{t.allDay}}</label>
<input type="checkbox" id="open-calendar__event-edit__allday" name="allday" />
<label for="open-calendar__event-edit__start">{{t.start}}</label>
<div id="open-calendar__event-edit__start" class="open-calendar__event-edit__datetime">
<input type="date" name="start-date" required="" />
<input type="time" name="start-time" required="" />
<select name="start-timezone" required="">
{{#timezones}}
<option value="{{.}}">{{.}}</option>
{{/timezones}}
</select>
</div>
<label for="open-calendar__event-edit__end">{{t.end}}</label>
<div id="open-calendar__event-edit__end" class="open-calendar__event-edit__datetime">
<input type="date" name="end-date" required="" />
<input type="time" name="end-time" required="" />
<select name="end-timezone" required="">
{{#timezones}}
<option value="{{.}}">{{.}}</option>
{{/timezones}}
</select>
</div>
<label for="open-calendar__event-edit__organizer">{{t.organizer}}</label>
<div id="open-calendar__event-edit__organizer" class="open-calendar__event-edit__attendee">
<input type="text" name="organizer-mailbox" list="open-calendar__event-edit__mailboxes" />
</div>
<label for="open-calendar__event-edit__attendees">{{t.attendees}}</label>
<div id="open-calendar__event-edit__attendees" class="open-calendar__event-edit__attendees" >
<div class="open-calendar__form__list"> </div>
<button type="button">{{t.addAttendee}}</button>
</div>
<label for="open-calendar__event-edit__rrule">{{t.rrule}}</label>
<select id="open-calendar__event-edit__rrule" name="rrule">
<option value="">{{trrules.none}}</option>
{{#rrules}}
<option value="{{rule}}">{{label}}</option>
{{/rrules}}
<option class="open-calendar__event-edit__rrule__unchanged" value="">{{trrules.unchanged}}</option>
</select>
<label for="open-calendar__event-edit__description">{{t.description}}</label>
<textarea id="open-calendar__event-edit__description" name="description"> </textarea>
</div>
<div class="open-calendar__form__content open-calendar__event-edit__invite">
<label for="open-calendar__event-edit__user-participation-status">{{t.userInvite}}</label>
<select id="open-calendar__event-edit__user-participation-status" name="user-participation-status">
{{#userParticipationStatuses}}
<option value="{{key}}">{{translation}}</option>
{{/userParticipationStatuses}}
</select>
</div>
<div class="open-calendar__form__buttons">
<button name="delete" type="button">{{t.delete}}</button>
<button name="cancel" type="button">{{t.cancel}}</button>
<button name="submit" type="submit">{{t.save}}</button>
</div>
</form>`
const calendarsHtml = /*html*/`
<option value="" selected disabled hidden>{{t.chooseACalendar}}</option>
{{#calendars}}
<option value="{{url}}">{{displayName}}</option>
{{/calendars}}`
const mailboxesHtml = /*html*/`
{{#mailboxes}}
<option value="{{.}}">{{.}}</option>
{{/mailboxes}}`
const attendeeHtml = /*html*/`
<div class="open-calendar__event-edit__attendee">
<input type="text" name="attendee-mailbox" value="{{mailbox}}" list="open-calendar__event-edit__mailboxes" />
<select name="attendee-role" value="{{role}}" required>
{{#roles}}
<option value="{{key}}">{{translation}}</option>
{{/roles}}
</select>
<select name="participation-status" value="{{participationStatus}}" required disabled>
{{#participationStatuses}}
<option value="{{key}}">{{translation}}</option>
{{/participationStatuses}}
</select>
<button type="button" name="remove">X</button>
</div>`
export class EventEditPopup {
private _recurringPopup: RecurringEventPopup
private _popup: Popup
private _form: HTMLFormElement
private _calendar: HTMLSelectElement
private _mailboxes: HTMLDataListElement
private _attendees: HTMLDivElement
private _rruleUnchanged: HTMLOptionElement
private _hideVCardEmails?: boolean
private _vCardContacts: VCard[] = []
private _eventContacts: Contact[] = []
private _event?: IcsEvent
private _userContact?: Contact
private _calendarUrl?: string
private _handleSave: EventEditCallback | null = null
private _handleDelete: EventEditCallback | null = null
public constructor(target: Node, options: DefaultComponentsOptions) {
this._hideVCardEmails = options.hideVCardEmails
const timezones = tzlib_get_timezones() as string[]
this._recurringPopup = new RecurringEventPopup(target)
this._popup = new Popup(target)
this._form = parseHtml<HTMLFormElement>(html, {
t: getTranslations().eventForm,
trrules: getTranslations().rrules,
timezones: timezones,
rrules: namedRRules.map(rule => ({ rule, label: getTranslations().rrules[rule] })),
userParticipationStatuses: attendeeUserParticipationStatusTypes.map(stat => ({
key: stat,
translation: getTranslations().userParticipationStatus[stat],
})),
})[0]
this._popup.content.appendChild(this._form)
this._calendar = this._form.querySelector<HTMLSelectElement>('.open-calendar__form__content [name="calendar"]')!
this._mailboxes = this._form.querySelector<HTMLSelectElement>('#open-calendar__event-edit__mailboxes')!
this._attendees = this._form.querySelector<HTMLDivElement>(
'.open-calendar__event-edit__attendees > .open-calendar__form__list',
)!
const allday = this._form.querySelector<HTMLButtonElement>('.open-calendar__event-edit [name="allday"]')!
const addAttendee = this._form.querySelector<HTMLDivElement>('.open-calendar__event-edit__attendees > button')!
this._rruleUnchanged = this._form.querySelector<HTMLOptionElement>('.open-calendar__event-edit__rrule__unchanged')!
const cancel = this._form.querySelector<HTMLButtonElement>('.open-calendar__form__buttons [name="cancel"]')!
const remove = this._form.querySelector<HTMLButtonElement>('.open-calendar__form__buttons [name="delete"]')!
this._form.addEventListener('submit', async (e) => { e.preventDefault(); await this.save() })
allday.addEventListener('click', this.updateAllday)
addAttendee.addEventListener('click', () => this.addAttendee({ email: '' }))
cancel.addEventListener('click', this.cancel)
remove.addEventListener('click', this.delete)
}
public destroy = () => {
this._form.remove()
}
private setCalendars = (calendars: Calendar[]) => {
const calendarElements = parseHtml<HTMLOptionElement>(calendarsHtml, {
calendars,
t: getTranslations().eventForm,
})
this._calendar.innerHTML = ''
this._calendar.append(...Array.from(calendarElements))
}
private setContacts = (vCardContacts: VCard[], eventContacts: Contact[]) => {
this._vCardContacts = []
for (const contact of vCardContacts) {
if (this._vCardContacts.find(c => isSameContact(c, contact))) continue
this._vCardContacts.push(contact)
}
for (const contact of eventContacts) {
if (this._vCardContacts.find(c => isSameContact(c, contact))) continue
if (this._eventContacts.find(c => isSameContact(c, contact))) continue
this._eventContacts.push(contact)
}
const mailboxesElement = parseHtml<HTMLOptionElement>(mailboxesHtml, {
mailboxes: [
...this._vCardContacts.map(c => this.getValueFromVCard(c)),
...this._eventContacts.map(c => this.getValueFromContact(c)),
],
})
this._mailboxes.innerHTML = ''
this._mailboxes.append(...Array.from(mailboxesElement))
}
private updateAllday = (e: DomEvent) => {
this._form.classList.toggle('open-calendar__event-edit--is-allday', (e.target as HTMLInputElement).checked)
}
private addAttendee = (attendee: IcsAttendee) => {
const element = parseHtml<HTMLDivElement>(attendeeHtml, {
mailbox: this.getValueFromAttendee(attendee),
role: attendee.role || 'REQ-PARTICIPANT',
roles: attendeeRoleTypes.map(role => ({ key: role, translation: getTranslations().attendeeRoles[role] })),
participationStatus: attendee.partstat || 'NEEDS-ACTION',
participationStatuses: attendeePartStatusTypes.map(status => ({
key: status,
translation: getTranslations().participationStatus[status],
})),
t: getTranslations().eventForm,
})[0]
this._attendees.appendChild(element)
const remove = element.querySelector<HTMLButtonElement>('button')!
const role = element.querySelector<HTMLSelectElement>('select[name="attendee-role"]')!
const participationStatus = element.querySelector<HTMLSelectElement>('select[name="participation-status"]')!
remove.addEventListener('click', () => element.remove())
role.value = attendee.role || 'REQ-PARTICIPANT'
participationStatus.value = attendee.partstat || 'NEEDS-ACTION'
}
public onCreate = ({calendars, vCards, event, handleCreate, userContact}: EventEditCreateInfo) => {
this._form.classList.toggle('open-calendar__event-edit--create', true)
this._handleSave = handleCreate
this._handleDelete = null
this.open('', event, calendars, vCards, userContact)
}
public onSelect = ({
calendarUrl,
calendars,
vCards,
event,
recurringEvent,
handleDelete,
handleUpdate,
userContact,
}: EventEditSelectInfo) => {
this._form.classList.toggle('open-calendar__event-edit--create', false)
this._handleSave = handleUpdate
this._handleDelete = handleDelete
if (!recurringEvent) this.open(calendarUrl, event, calendars, vCards, userContact)
else this._recurringPopup.open(editAll => {
return this.open(
calendarUrl, editAll ? recurringEvent : event, calendars, vCards, userContact)
})
}
public onMoveResize = ({ calendarUrl, event, start, end, handleUpdate }: EventEditMoveResizeInfo) => {
const newEvent = { ...event }
const startDelta = start.getTime() - event.start.date.getTime()
newEvent.start = offsetDate(newEvent.start, startDelta)
if (event.end) {
const endDelta = end.getTime() - event.end.date.getTime()
newEvent.end = offsetDate(event.end, endDelta)
}
handleUpdate(
{ calendarUrl, event: newEvent } as CalendarEvent,
)
}
public onDelete = ({ calendarUrl, event, handleDelete}: EventEditDeleteInfo) => {
handleDelete({calendarUrl, event})
}
public open = (
calendarUrl: string,
event: IcsEvent,
calendars: Calendar[],
vCards: AddressBookVCard[],
userContact?: Contact,
) => {
this._userContact = userContact
this.setContacts(
vCards.filter(c => c.vCard.email !== null).map(c => c.vCard),
[...event.attendees ?? [], event.organizer].filter(a => a !== undefined),
)
this.setCalendars(calendars)
this._calendarUrl = calendarUrl
this._event = event
const localTzId = Intl.DateTimeFormat().resolvedOptions().timeZone
const localTzOffset = new Date().getTimezoneOffset() * TIME_MINUTE
const localStart = event.start.local ?? {
date: new Date(event.start.date.getTime() - localTzOffset),
timezone: localTzId,
}
const end = event.end ??
offsetDate(
localStart,
getEventEndFromDuration(event.start.date, event.duration).getTime() - event.start.date.getTime(),
)
const localEnd = end.local ?? {
date: new Date(end.date.getTime() - localTzOffset),
timezone: localTzId,
}
const inputs = this._form.elements;
(inputs.namedItem('calendar') as HTMLInputElement).value = calendarUrl;
(inputs.namedItem('calendar') as HTMLInputElement).disabled = event.recurrenceId !== undefined;
// FIXME - CJ - 2025/06/03 - changing an object of calendar is not supported;
(inputs.namedItem('calendar') as HTMLInputElement).disabled ||=
!this._form.classList.contains('open-calendar__event-edit--create');
(inputs.namedItem('summary') as HTMLInputElement).value = event.summary ?? '';
(inputs.namedItem('location') as HTMLInputElement).value = event.location ?? '';
(inputs.namedItem('allday') as HTMLInputElement).checked = isEventAllDay(event)
this._form.classList.toggle('open-calendar__event-edit--is-allday', isEventAllDay(event))
const startDateTime = localStart.date.toISOString().split('T');
(inputs.namedItem('start-date') as HTMLInputElement).value = startDateTime[0];
(inputs.namedItem('start-time') as HTMLInputElement).value = startDateTime[1].slice(0, 5);
(inputs.namedItem('start-timezone') as HTMLInputElement).value = localStart.timezone
const endDateTime = localEnd.date.toISOString().split('T');
(inputs.namedItem('end-date') as HTMLInputElement).value = endDateTime[0];
(inputs.namedItem('end-time') as HTMLInputElement).value = endDateTime[1].slice(0, 5);
(inputs.namedItem('end-timezone') as HTMLInputElement).value = localEnd.timezone;
// TODO - CJ - 2025-07-03 - Add rich text support
(inputs.namedItem('description') as HTMLInputElement).value = event.description ?? '';
// TODO - CJ - 2025-07-03 - Check if needs to be hidden or done differently,
// as I believe Thunderbird also adds the organizer to the attendee list;
(inputs.namedItem('organizer-mailbox') as HTMLInputElement).value = event.organizer
? this.getValueFromAttendee(event.organizer)
: ''
const rrule = getRRuleString(event.recurrenceRule)
this._rruleUnchanged.value = rrule;
(inputs.namedItem('rrule') as HTMLInputElement).value = rrule;
(inputs.namedItem('rrule') as HTMLInputElement).disabled = event.recurrenceId !== undefined
const userAttendeeInEvent = userContact !== undefined
? event.attendees?.find(a => a.email === userContact.email)
: undefined
if (userAttendeeInEvent !== undefined) {
this._form.classList.remove('open-calendar__event-edit--without-invite');
(inputs.namedItem('user-participation-status') as HTMLSelectElement).value =
userAttendeeInEvent.partstat
?? attendeeUserParticipationStatusTypes[0]
} else {
this._form.classList.add('open-calendar__event-edit--without-invite')
}
this._attendees.innerHTML = ''
for (const attendee of event.attendees ?? []) this.addAttendee(attendee)
this._popup.setVisible(true)
}
public save = async () => {
const data = new FormData(this._form)
const allDay = !!data.get('allday')
const getTimeObject = (name: string): IcsDateObject => {
const date = data.get(`${name}-date`) as string
const time = data.get(`${name}-time`) as string
const timezone = data.get(`${name}-timezone`) as string
const offset = tzlib_get_offset(timezone, date, time)
return {
date: new Date(date + (allDay ? '' : `T${time}${offset}`)),
type: allDay ? 'DATE' : 'DATE-TIME',
local: timezone === 'UTC' ? undefined : {
date: new Date(date + (allDay ? '' : `T${time}Z`)),
timezone: tzlib_get_ical_block(timezone)[1].slice(5),
tzoffset: offset,
},
}
}
const mailboxes = data.getAll('attendee-mailbox') as string[]
const roles = data.getAll('attendee-role') as string[]
const participationStatuses = data.getAll('participation-status') as string[]
const rrule = data.get('rrule') as string
const description = data.get('description') as string
const event: IcsEvent = {
...this._event!,
summary: data.get('summary') as string,
location: data.get('location') as string || undefined,
start: getTimeObject('start'),
end: getTimeObject('end'),
description: description || undefined,
descriptionAltRep: description === this._event!.description ? this._event!.descriptionAltRep : undefined,
organizer: data.get('organizer-mailbox')
? {
...this._event!.organizer,
...this.getContactFromValue(data.get('organizer-mailbox') as string),
}
: undefined,
attendees: mailboxes.map((mailbox, i) => {
const contact = this.getContactFromValue(mailbox)
return ({
...contact,
role: roles[i],
partstat: (contact.email === this._userContact?.email
? data.get('user-participation-status')
: participationStatuses[i]
) as IcsAttendeePartStatusType,
})
}) || undefined,
recurrenceRule: rrule ? convertIcsRecurrenceRule(undefined, {value: rrule}) : undefined,
// NOTE - CJ - 2025-07-03 - explicitly set `duration` to undefined as we set `end`
duration: undefined,
}
const response = await this._handleSave!({ calendarUrl: data.get('calendar') as string, event })
if (response.ok) this._popup.setVisible(false)
}
public cancel = () => {
this._popup.setVisible(false)
}
public delete = async () => {
await this._handleDelete!({ calendarUrl: this._calendarUrl!, event: this._event!})
this._popup.setVisible(false)
}
public getContactFromValue = (value: string) => {
const contact = this._vCardContacts.find(c => this.getValueFromVCard(c) === value)
return contact
// NOTE - CJ - 2025-07-17 - we need to reconstruct an object as the spread syntax does not work for properties
? { name: contact.name!, email: contact.email!}
: this._eventContacts.find(c => this.getValueFromContact(c) === value)
?? mailboxToContact(value)
}
public getValueFromAttendee = (attendee: IcsAttendee | IcsOrganizer): string => {
const vCard = this._vCardContacts.find(c => isSameContact(c, attendee))
return vCard ? this.getValueFromVCard(vCard) : this.getValueFromContact(attendee)
}
public getValueFromVCard = (contact: VCard) => (this._hideVCardEmails && contact.name) || contactToMailbox(contact)
public getValueFromContact = (contact: Contact) => contactToMailbox(contact)
}

View File

@@ -1,49 +0,0 @@
import { Popup } from '../popup/popup'
import { parseHtml } from '../helpers/dom-helper'
import { getTranslations } from '../translations'
const html = /*html*/`
<div class="open-calendar__form">
{{t.editRecurring}}
<div class="open-calendar__form__buttons">
<button name="edit-all" type="button">{{t.editAll}}</button>
<button name="edit-single" type="button">{{t.editSingle}}</button>
</div>
</div>
`
export class RecurringEventPopup {
public _handleSelect?: (editAll: boolean) => void
private _element: HTMLDivElement
private _popup: Popup
public constructor(target: Node) {
this._popup = new Popup(target)
this._element = parseHtml<HTMLDivElement>(html, { t: getTranslations().recurringForm })[0]
this._popup.content.appendChild(this._element)
const editAll = this._element.querySelector<HTMLButtonElement>('.open-calendar__form__buttons [name="edit-all"]')!
const editSingle = this._element.querySelector<HTMLButtonElement>(
'.open-calendar__form__buttons [name="edit-single"]',
)!
editAll.addEventListener('click', () => this.close(true))
editSingle.addEventListener('click', () => this.close(false))
}
public destroy = () => {
this._element.remove()
this._popup.destroy()
}
public open = (handleSelect: (editAll: boolean) => void) => {
this._handleSelect = handleSelect
this._popup.setVisible(true)
}
private close = (editAll: boolean) => {
this._popup.setVisible(false)
this._handleSelect?.(editAll)
}
}

View File

@@ -1,273 +0,0 @@
import { tzlib_get_ical_block } from 'timezones-ical-library'
import { convertIcsCalendar, convertIcsTimezone, generateIcsCalendar, type IcsCalendar } from 'ts-ics'
import { createAccount,
fetchCalendars as davFetchCalendars,
fetchCalendarObjects as davFetchCalendarObjects,
createCalendarObject as davCreateCalendarObject,
updateCalendarObject as davUpdateCalendarObject,
deleteCalendarObject as davDeleteCalendarObject,
DAVNamespaceShort,
propfind,
type DAVCalendar,
type DAVCalendarObject,
type DAVAddressBook,
fetchAddressBooks as davFetchAddressBooks,
fetchVCards as davFetchVCards,
} from 'tsdav'
import { isServerSource } from './types-helper'
import type { Calendar, CalendarObject } from '../types/calendar'
import type { CalendarSource, ServerSource, CalendarResponse, AddressBookSource } from '../types/options'
import type { AddressBook, AddressBookObject } from '../types/addressbook'
import ICAL from 'ical.js'
export function getEventObjectString(event: IcsCalendar) {
return generateIcsCalendar(event)
}
export async function fetchCalendars(source: ServerSource | CalendarSource): Promise<Calendar[]> {
if (isServerSource(source)) {
const account = await createAccount({
account: { serverUrl: source.serverUrl, accountType: 'caldav' },
headers: source.headers,
fetchOptions: source.fetchOptions,
})
const calendars = await davFetchCalendars({ account, headers: source.headers, fetchOptions: source.fetchOptions })
return calendars.map(calendar => ({ ...calendar, headers: source.headers, fetchOptions: source.fetchOptions }))
} else {
const calendar = await davFetchCalendar({
url: source.calendarUrl,
headers: source.headers,
fetchOptions: source.fetchOptions,
})
return [{ ...calendar, headers: source.headers, fetchOptions: source.fetchOptions, uid: source.calendarUid }]
}
}
export async function fetchCalendarObjects(
calendar: Calendar,
timeRange?: { start: string; end: string; },
expand?: boolean,
): Promise<{ calendarObjects: CalendarObject[], recurringObjects: CalendarObject[] }> {
const davCalendarObjects = await davFetchCalendarObjects({
calendar: calendar,
timeRange, expand,
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
const calendarObjects = davCalendarObjects.map(o => ({
url: o.url,
etag: o.etag,
data: convertIcsCalendar(undefined, o.data),
calendarUrl: calendar.url,
}))
const recurringObjectsUrls = new Set(
calendarObjects
.filter(c => c.data.events?.find(e => e.recurrenceId))
.map(c => c.url),
)
const davRecurringObjects = recurringObjectsUrls.size == 0
? []
: await davFetchCalendarObjects({
calendar: calendar,
objectUrls: Array.from(recurringObjectsUrls),
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
const recurringObjects = davRecurringObjects.map(o => ({
url: o.url,
etag: o.etag,
data: convertIcsCalendar(undefined, o.data),
calendarUrl: calendar.url,
}))
return { calendarObjects, recurringObjects }
}
export async function createCalendarObject(
calendar: Calendar,
calendarObjectData: IcsCalendar,
): Promise<CalendarResponse> {
validateTimezones(calendarObjectData)
for (const event of calendarObjectData.events ?? []) event.uid = crypto.randomUUID()
const uid = calendarObjectData.events?.[0].uid ?? crypto.randomUUID()
const iCalString = getEventObjectString(calendarObjectData)
const response = await davCreateCalendarObject({
calendar,
iCalString,
filename: `${uid}.ics`,
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
return { response, ical: iCalString }
}
export async function updateCalendarObject(
calendar: Calendar,
calendarObject: CalendarObject,
): Promise<CalendarResponse> {
validateTimezones(calendarObject.data)
const davCalendarObject: DAVCalendarObject = {
url: calendarObject.url,
etag: calendarObject.etag,
data: getEventObjectString(calendarObject.data),
}
const response = await davUpdateCalendarObject({
calendarObject: davCalendarObject,
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
return { response, ical: davCalendarObject.data }
}
export async function deleteCalendarObject(
calendar: Calendar,
calendarObject: CalendarObject,
): Promise<CalendarResponse> {
validateTimezones(calendarObject.data)
const davCalendarObject: DAVCalendarObject = {
url: calendarObject.url,
etag: calendarObject.etag,
data: getEventObjectString(calendarObject.data),
}
const response = await davDeleteCalendarObject({
calendarObject: davCalendarObject,
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
return { response, ical: davCalendarObject.data }
}
function validateTimezones(calendarObjectData: IcsCalendar) {
const calendar = calendarObjectData
const usedTimezones = calendar.events?.flatMap(e => [e.start.local?.timezone, e.end?.local?.timezone])
const wantedTzIds = new Set(usedTimezones?.filter(s => s !== undefined))
calendar.timezones ??= []
// Remove extra timezones
calendar.timezones = calendar.timezones.filter(tz => wantedTzIds.has(tz.id))
// Add missing timezones
wantedTzIds.forEach(tzid => {
if (calendar.timezones!.findIndex(t => t.id === tzid) === -1) {
calendar.timezones!.push(convertIcsTimezone(undefined, tzlib_get_ical_block(tzid)[0]))
}
})
}
// NOTE - CJ - 2025/07/03 - Inspired from https://github.com/natelindev/tsdav/blob/master/src/calendar.ts, fetchCalendars
async function davFetchCalendar(params: {
url: string,
headers?: Record<string, string>,
fetchOptions?: RequestInit
}): Promise<DAVCalendar> {
const { url, headers, fetchOptions } = params
const response = await propfind({
url,
props: {
[`${DAVNamespaceShort.CALDAV}:calendar-description`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-timezone`]: {},
[`${DAVNamespaceShort.DAV}:displayname`]: {},
[`${DAVNamespaceShort.CALDAV_APPLE}:calendar-color`]: {},
[`${DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {},
[`${DAVNamespaceShort.DAV}:resourcetype`]: {},
[`${DAVNamespaceShort.CALDAV}:supported-calendar-component-set`]: {},
[`${DAVNamespaceShort.DAV}:sync-token`]: {},
},
headers,
fetchOptions,
})
const rs = response[0]
if (!rs.ok) {
throw new Error(`Calendar ${url} does not exists. ${rs.status} ${rs.statusText}`)
}
if (Object.keys(rs.props?.resourceType ?? {}).includes('calendar')) {
throw new Error(`${url} is not a ${rs.props?.resourceType} and not a calendar`)
}
const description = rs.props?.calendarDescription
const timezone = rs.props?.calendarTimezone
return {
description: typeof description === 'string' ? description : '',
timezone: typeof timezone === 'string' ? timezone : '',
url: params.url,
ctag: rs.props?.getctag,
calendarColor: rs.props?.calendarColor,
displayName: rs.props?.displayname._cdata ?? rs.props?.displayname,
components: Array.isArray(rs.props?.supportedCalendarComponentSet.comp)
// NOTE - CJ - 2025-07-03 - comp represents an list of XML nodes in the DAVResponse format
// sc could be `<C:comp name="VEVENT" />`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? rs.props?.supportedCalendarComponentSet.comp.map((sc: any) => sc._attributes.name)
: [rs.props?.supportedCalendarComponentSet.comp?._attributes.name],
resourcetype: Object.keys(rs.props?.resourcetype),
syncToken: rs.props?.syncToken,
}
}
export async function fetchAddressBooks(source: ServerSource | AddressBookSource): Promise<AddressBook[]> {
if (isServerSource(source)) {
const account = await createAccount({
account: { serverUrl: source.serverUrl, accountType: 'caldav' },
headers: source.headers,
fetchOptions: source.fetchOptions,
})
const books = await davFetchAddressBooks({ account, headers: source.headers, fetchOptions: source.fetchOptions })
return books.map(book => ({ ...book, headers: source.headers, fetchOptions: source.fetchOptions }))
} else {
const book = await davFetchAddressBook({
url: source.addressBookUrl,
headers: source.headers,
fetchOptions: source.fetchOptions,
})
return [{ ...book, headers: source.headers, fetchOptions: source.fetchOptions, uid: source.addressBookUid }]
}
}
// NOTE - CJ - 2025/07/03 - Inspired from https://github.com/natelindev/tsdav/blob/master/src/addressBook.ts#L73
async function davFetchAddressBook(params: {
url: string,
headers?: Record<string, string>,
fetchOptions?: RequestInit
}): Promise<DAVAddressBook> {
const { url, headers, fetchOptions } = params
const response = await propfind({
url,
props: {
[`${DAVNamespaceShort.DAV}:displayname`]: {},
[`${DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {},
[`${DAVNamespaceShort.DAV}:resourcetype`]: {},
[`${DAVNamespaceShort.DAV}:sync-token`]: {},
},
headers,
fetchOptions,
})
const rs = response[0]
if (!rs.ok) {
throw new Error(`Address book ${url} does not exists. ${rs.status} ${rs.statusText}`)
}
if (Object.keys(rs.props?.resourceType ?? {}).includes('addressbook')) {
throw new Error(`${url} is not a ${rs.props?.resourceType} and not an addressbook`)
}
const displayName = rs.props?.displayname?._cdata ?? rs.props?.displayname
return {
url: url,
ctag: rs.props?.getctag,
displayName: typeof displayName === 'string' ? displayName : '',
resourcetype: Object.keys(rs.props?.resourcetype),
syncToken: rs.props?.syncToken,
}
}
export async function fetchAddressBookObjects(addressBook: AddressBook): Promise<AddressBookObject[]> {
const davVCards = await davFetchVCards({
addressBook: addressBook,
headers: addressBook.headers,
fetchOptions: addressBook.fetchOptions,
})
return davVCards.map(o => ({
url: o.url,
etag: o.etag,
data: new ICAL.Component(ICAL.parse(o.data)),
addressBookUrl: addressBook.url,
}))
}

View File

@@ -1,11 +0,0 @@
import Mustache from 'mustache'
export function parseHtml<N extends ChildNode = ChildNode>(html: string, format?: unknown): NodeListOf<N> {
html = Mustache.render(html, format)
return Document.parseHTMLUnsafe(html).body.childNodes as NodeListOf<N>
}
export function escapeHtml(html: string): string {
// NOTE - CJ - 2025-07-07 - In Mustache, {{html}} escapes html whereas {{{html}}} and {{&html}} do not
return Mustache.render('{{html}}', { html })
}

View File

@@ -1,56 +0,0 @@
import { generateIcsRecurrenceRule, type IcsDateObject, type IcsEvent, type IcsRecurrenceRule } from 'ts-ics'
import { parseOneAddress } from 'email-addresses'
import type { EventUid } from '../types/calendar'
import type { Contact, VCard } from '../types/addressbook'
export function isEventAllDay(event: IcsEvent) {
return event.start.type === 'DATE' || event.end?.type === 'DATE'
}
export function offsetDate(date: IcsDateObject, offset: number): IcsDateObject {
return {
type: date.type,
date: new Date(date.date.getTime() + offset),
local: date.local && {
date: new Date(date.local.date.getTime() + offset),
timezone: date.local.timezone,
tzoffset: date.local.tzoffset,
},
}
}
export function isSameEvent(a: EventUid, b: EventUid) {
return a.uid === b.uid && a.recurrenceId?.value.date.getTime() === b.recurrenceId?.value.date.getTime()
}
export function isRRuleSourceEvent(eventInstance: EventUid, event: EventUid) {
return eventInstance.uid === event.uid && event.recurrenceId === undefined
}
export function getRRuleString(recurrenceRule?: IcsRecurrenceRule) {
if (!recurrenceRule) return ''
return generateIcsRecurrenceRule(recurrenceRule).trim().slice(6)
}
// FIXME - CJ - 2025-07-11 - This function should only be used for display purposes
// It does not handle escape characters properly (quotes, comments)
// and parsing the result back to a contact with `mailboxToContact` may fail
// See https://datatracker.ietf.org/doc/html/rfc5322#section-3.4 the specs
export function contactToMailbox(contact: Contact | VCard): string {
return contact.name
? `${contact.name} <${contact.email}>`
: contact.email!
}
export function mailboxToContact(mailbox: string): Contact {
const parsed = parseOneAddress(mailbox)
if (parsed?.type !== 'mailbox') throw new Error(`Failed to parse mailbox '${mailbox}' `)
return {
name: parsed.name ?? undefined,
email: parsed.address,
}
}
export function isSameContact(a: Contact | VCard, b: Contact | VCard) {
return a.name === b.name && a.email === b.email
}

View File

@@ -1,22 +0,0 @@
import type { CalendarOptions,
SelectCalendarHandlers,
EventEditHandlers,
ServerSource,
VCardProvider,
} from '../types/options'
export function isServerSource(source: ServerSource | unknown): source is ServerSource {
return (source as ServerSource).serverUrl !== undefined
}
export function isVCardProvider(source: VCardProvider | unknown): source is VCardProvider {
return (source as VCardProvider).fetchContacts !== undefined
}
export function hasEventHandlers(options: CalendarOptions): options is EventEditHandlers {
return (options as EventEditHandlers).onCreateEvent !== undefined
}
export function hasCalendarHandlers(options: CalendarOptions): options is SelectCalendarHandlers {
return (options as SelectCalendarHandlers).onClickSelectCalendars !== undefined
}

View File

@@ -1,50 +0,0 @@
.open-calendar__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.open-calendar__form__content {
display: grid;
grid-template-columns: max-content max-content;
grid-gap: 5px;
align-items: baseline;
}
.open-calendar__form__content label {
text-align: right;
}
/* FIXME - CJ - 2025-07-03 - replace by something else that supports localization */
.open-calendar__form__content label::after {
content: ":";
}
.open-calendar__form__content input[type=checkbox] {
justify-self: left;
}
.open-calendar__form__list {
display: flex;
flex-direction: column;
grid-gap: 0.25rem;
margin-bottom: 0.25rem;
}
.open-calendar__form__buttons {
display: flex;
gap: 1rem;
}
.open-calendar__form__buttons>* {
flex-grow: 1;
}
.open-calendar {
font-family: sans-serif;
height: 100%;
}
.ec {
height: 100%;
}

View File

@@ -1,23 +0,0 @@
import { CalendarElement } from './calendarelement/calendarElement'
import './index.css'
import { setTranslations, type ResourceBundle} from './translations'
import type { AddressBookSource,
CalendarOptions,
CalendarSource,
VCardProvider,
RecursivePartial,
ServerSource,
} from './types/options'
export async function createCalendar(
calendarSources: (ServerSource | CalendarSource)[],
addressBookSources: (ServerSource | AddressBookSource | VCardProvider)[],
target: Element,
options?: CalendarOptions,
translations?: RecursivePartial<ResourceBundle>,
) {
if (translations) setTranslations(translations)
const calendar = new CalendarElement()
await calendar.create(calendarSources, addressBookSources, target, options)
return calendar
}

View File

@@ -1,24 +0,0 @@
.open-calendar__popup__overlay {
position: fixed;
width: 100vw;
height: 100vh;
left: 0px;
top: 0px;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.open-calendar__popup--hidden {
display: none;
}
.open-calendar__popup__frame {
width: fit-content;
height: fit-content;
background-color: white;
padding: 0.5rem;
border-radius: 10px;
}

View File

@@ -1,35 +0,0 @@
import './popup.css'
import { parseHtml } from '../helpers/dom-helper'
const html = /*html*/`
<div class="open-calendar__popup__overlay open-calendar__popup--hidden">
<div class="open-calendar__popup__frame"></div>
</div>`
export class Popup {
private _node: HTMLDivElement
public content: HTMLDivElement
constructor(target: Node) {
this._node = parseHtml<HTMLDivElement>(html)[0]
target.appendChild(this._node)
this.content = this._node.firstElementChild as HTMLDivElement
window.addEventListener('mousedown', e => {
if (this._node.classList.contains('open-calendar__popup--hidden')) return
if (e.target instanceof Element && (e.target === this.content || e.target.contains(this.content))) {
this.setVisible(false)
}
})
}
public destroy = () => {
this._node.remove()
}
setVisible = (visible: boolean) => {
this._node.classList.toggle('open-calendar__popup--hidden', !visible)
}
}

View File

@@ -1,123 +0,0 @@
import type { RecursivePartial } from './types/options'
// HACK - CJ - 2025-07-03 - Ideally, this object would have been a json file and imported with:
// `import en from 'locale/en/translation.json'`
// However, the lib used to create the declarations file `index.d.ts` thinks this is ts import
// and looks for the file `locale/en/translation.json.d.ts` which doesn't exists.
const en = {
'calendarElement': {
'timeGridDay': 'Day',
'timeGridWeek': 'Week',
'dayGridMonth': 'Month',
'listDay': 'List',
'listWeek': 'List Week',
'listMonth': 'List Month',
'listYear': 'List Year',
'today': 'Today',
'allDay': 'Daily',
'calendars': 'Calendars',
'newEvent': 'New Event',
},
'eventForm': {
'allDay': 'Daily',
'calendar': 'Calendar',
'title': 'Title',
'location': 'Location',
'start': 'Start',
'end': 'End',
'organizer': 'Organizer',
'attendees': 'Attendees',
'addAttendee': 'Add attendee',
'description': 'Description',
'delete': 'Delete',
'cancel': 'Cancel',
'save': 'Save',
'chooseACalendar': '-- Choose a calendar --',
'rrule': 'Frequency',
'userInvite': 'You were invited to this event',
},
'eventBody': {
'organizer': 'Organizer',
'participation_require': 'Required participant',
'participation_optional': 'Optional participant',
'non_participant': 'Non participant',
'participation_confirmed': 'Participation confirmed',
'participation_pending': 'Participation pending',
'participation_confirmed_tentative': 'Participation confirmed tentative',
'participation_declined': 'Participation declined',
},
'recurringForm': {
'editRecurring': 'This is a recurring event',
'editAll': 'Edit all occurrences',
'editSingle': 'Edit this occurrence only',
},
'participationStatus': {
'NEEDS-ACTION': 'Needs to answer',
'ACCEPTED': 'Accepted',
'DECLINED': 'Declined',
'TENTATIVE': 'Tentatively accepted',
'DELEGATED': 'Delegated',
},
'userParticipationStatus': {
'NEEDS-ACTION': 'Not answered',
'ACCEPTED': 'Accept',
'DECLINED': 'Decline',
'TENTATIVE': 'Accept tentatively',
},
'attendeeRoles': {
'CHAIR': 'Chair',
'REQ-PARTICIPANT': 'Required participant',
'OPT-PARTICIPANT': 'Optional participant',
'NON-PARTICIPANT': 'Non participant',
},
'rrules': {
'none': 'Never',
'unchanged': 'Keep existing',
'FREQ=DAILY': 'Daily',
'FREQ=WEEKLY': 'Weekly',
'BYDAY=MO,TU,WE,TH,FR;FREQ=DAILY': 'Workdays',
'INTERVAL=2;FREQ=WEEKLY': 'Every two week',
'FREQ=MONTHLY': 'Monthly',
'FREQ=YEARLY': 'Yearly',
},
}
export type ResourceBundle = typeof en
let translations = en
export const setTranslations = (bundle: RecursivePartial<ResourceBundle>) => translations = {
calendarElement: {
...en.calendarElement,
...bundle.calendarElement,
},
eventForm: {
...en.eventForm,
...bundle.eventForm,
},
eventBody: {
...en.eventBody,
...bundle.eventBody,
},
recurringForm: {
...en.recurringForm,
...bundle.recurringForm,
},
userParticipationStatus: {
...en.userParticipationStatus,
...bundle.userParticipationStatus,
},
participationStatus: {
...en.participationStatus,
...bundle.participationStatus,
},
attendeeRoles: {
...en.attendeeRoles,
...bundle.attendeeRoles,
},
rrules: {
...en.rrules,
...bundle.rrules,
},
}
export const getTranslations = () => translations

View File

@@ -1,30 +0,0 @@
import type { DAVAddressBook } from 'tsdav'
import ICAL from 'ical.js'
export type AddressBook = DAVAddressBook & {
headers?: Record<string, string>
uid?: unknown
}
export type AddressBookObject = {
data: ICAL.Component
etag?: string
url: string
addressBookUrl: string
}
export type VCard = {
name: string
email: string | null
}
export type AddressBookVCard = {
// INFO - 2025-07-24 - addressBookUrl is undefined when the contact is from a VCardProvider
addressBookUrl?: string
vCard: VCard
}
export type Contact = {
name?: string
email: string
}

View File

@@ -1,39 +0,0 @@
import type { IcsCalendar, IcsEvent, IcsRecurrenceId } from 'ts-ics'
import type { DAVCalendar } from 'tsdav'
// TODO - CJ - 2025-07-03 - add <TCalendarUid = any> generic
// TODO - CJ - 2025-07-03 - add options to support IcsEvent custom props
export type Calendar = DAVCalendar & {
// INFO - CJ - 2025-07-03 - Useful fields from 'DAVCalendar'
// ctag?: string
// description?: string;
// displayName?: string | Record<string, unknown>;
// calendarColor?: string
// url: string
// fetchOptions?: RequestInit
headers?: Record<string, string>
uid?: unknown
}
export type CalendarObject = {
data: IcsCalendar
etag?: string
url: string
calendarUrl: string
}
export type CalendarEvent = {
calendarUrl: string
event: IcsEvent
}
export type EventUid = {
uid: string
recurrenceId?: IcsRecurrenceId
}
export type DisplayedCalendarEvent = {
calendarUrl: string
event: IcsEvent
recurringEvent?: IcsEvent
}

View File

@@ -1,160 +0,0 @@
import type { IcsEvent } from 'ts-ics'
import type { Calendar, CalendarEvent } from './calendar'
import type { AddressBookVCard, Contact, VCard } from './addressbook'
import type { attendeeRoleTypes, availableViews } from '../contants'
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>
}
export type DomEvent = GlobalEventHandlersEventMap[keyof GlobalEventHandlersEventMap]
export type ServerSource = {
serverUrl: string
headers?: Record<string, string>
fetchOptions?: RequestInit
}
export type CalendarSource = {
calendarUrl: string
calendarUid?: unknown
headers?: Record<string, string>
fetchOptions?: RequestInit
}
export type AddressBookSource = {
addressBookUrl: string
addressBookUid?: unknown
headers?: Record<string, string>
fetchOptions?: RequestInit
}
export type VCardProvider = {
fetchContacts: () => Promise<VCard[]>
}
export type View = typeof availableViews[number]
export type IcsAttendeeRoleType = typeof attendeeRoleTypes[number]
export type SelectedCalendar = {
url: string
selected: boolean
}
export type SelectCalendarCallback = (calendar: SelectedCalendar) => void
export type SelectCalendarsClickInfo = {
jsEvent: DomEvent
calendars: Calendar[]
selectedCalendars: Set<string>
handleSelect: SelectCalendarCallback
}
export type SelectCalendarHandlers = {
onClickSelectCalendars: (info: SelectCalendarsClickInfo) => void,
}
export type EventBodyInfo = {
calendar: Calendar
vCards: AddressBookVCard[]
event: IcsEvent
view: View
userContact?: Contact
}
export type BodyHandlers = {
getEventBody: (info: EventBodyInfo) => Node[]
}
export type EventEditCallback = (event: CalendarEvent) => Promise<Response>
export type EventEditCreateInfo = {
jsEvent: DomEvent
userContact?: Contact,
event: IcsEvent
calendars: Calendar[]
vCards: AddressBookVCard[]
handleCreate: EventEditCallback
}
export type EventEditSelectInfo = {
jsEvent: DomEvent
userContact?: Contact,
calendarUrl: string
event: IcsEvent
recurringEvent?: IcsEvent
calendars: Calendar[]
vCards: AddressBookVCard[]
handleUpdate: EventEditCallback
handleDelete: EventEditCallback
}
export type EventEditMoveResizeInfo = {
jsEvent: DomEvent
calendarUrl: string
userContact?: Contact,
event: IcsEvent
recurringEvent?: IcsEvent,
start: Date,
end: Date,
handleUpdate: EventEditCallback
}
export type EventEditDeleteInfo = {
jsEvent: DomEvent
userContact?: Contact,
calendarUrl: string
event: IcsEvent
recurringEvent?: IcsEvent
handleDelete: EventEditCallback
}
export type EventEditHandlers = {
onCreateEvent: (info: EventEditCreateInfo) => void,
onSelectEvent: (info: EventEditSelectInfo) => void,
onMoveResizeEvent: (info: EventEditMoveResizeInfo) => void,
onDeleteEvent: (info: EventEditDeleteInfo) => void,
}
export type EventChangeInfo = {
calendarUrl: string
event: IcsEvent
ical: string
}
export type EventChangeHandlers = {
onEventCreated?: (info: EventChangeInfo) => void
onEventUpdated?: (info: EventChangeInfo) => void
onEventDeleted?: (info: EventChangeInfo) => void
}
export type CalendarElementOptions = {
view?: View
views?: View[]
locale?: string
date?: Date
editable?: boolean
}
export type CalendarClientOptions = {
userContact?: Contact
}
export type DefaultComponentsOptions = {
hideVCardEmails?: boolean
}
export type CalendarOptions =
// NOTE - CJ - 2025-07-03
// May define individual options or not
CalendarElementOptions
// May define individual options or not
& CalendarClientOptions
// Must define all handlers or none
& (SelectCalendarHandlers | Record<never, never>)
// Must define all handlers or none
& (EventEditHandlers | Record<never, never>)
// May define individual handlers or not
& EventChangeHandlers
// May define handlers or not, but they will be assigned a default value if they are not
& Partial<BodyHandlers>
& DefaultComponentsOptions
export type CalendarResponse = {
response: Response
ical: string
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,36 +0,0 @@
{
"include": ["src/**/*"],
"exclude": ["node_modules", "vite.config.ts"],
"compilerOptions": {
"outDir": "./dist/",
"module": "esnext",
"target": "es2020",
"lib": ["ES2020", "DOM"],
"useDefineForClassFields": true,
"skipLibCheck": true,
"types": [],
"declaration": true,
"declarationDir": "./build/",
"esModuleInterop": true,
/* Bundler mode */
"moduleResolution": "node",
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitAny": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"allowSyntheticDefaultImports": true,
/* FIXME - CJ - 2025-07-03 - Compilation worked file when I added `tsconfig-paths-webpack-plugin` but I could not find a working plugin for the .d.ts files */
// "baseUrl": "./src",
// "paths": {
// "@helpers/*": ["./helpers/*"],
// "@calendar-types": ["./types.ts"]
// },
},
}

View File

@@ -1,23 +0,0 @@
import { defineConfig } from 'vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
// INFO - CJ - 2025-07-03 - This plugin show tsc errors on vite dev
import pluginChecker from 'vite-plugin-checker'
export default defineConfig({
plugins: [
nodePolyfills(),
pluginChecker({ typescript: true }),
],
build: {
lib: {
entry: './src/index.ts',
name: 'CalendarClient',
fileName: 'index',
},
},
resolve: {
alias: {
'node-fetch': 'axios',
},
},
})

View File

@@ -1,43 +0,0 @@
const path = require('path')
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin')
const DtsBundleWebpack = require('dts-bundle-webpack')
const isProduction = process.env.NODE_ENV === 'production' || true
const name = 'open-dav-calendar'
module.exports = {
mode: isProduction ? 'production' : 'development',
entry: {
index: "./src/index.ts",
},
target: 'web',
plugins: [
new NodePolyfillPlugin(),
new DtsBundleWebpack({
name: name,
main: path.resolve(__dirname, "build/index.d.ts"),
out: path.resolve(__dirname, "dist/index.d.ts"),
})
],
module: {
rules: [{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
}, {
test: /\.css$/,
use: ['style-loader', 'css-loader']
}],
},
resolve: {
extensions: ['.ts', '.js'],
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
library: {
type: 'umd',
name: name,
umdNamedDefine: true
}
}
}

File diff suppressed because it is too large Load Diff