🔥(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:
@@ -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
|
||||
26
src/frontend/packages/open-calendar/.gitignore
vendored
26
src/frontend/packages/open-calendar/.gitignore
vendored
@@ -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?
|
||||
@@ -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.
|
||||
@@ -1,97 +0,0 @@
|
||||
# Open Calendar
|
||||
|
||||
Open Calendar is a modern web calendar frontend for CalDAV based calendars.
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
// },
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user