🔥(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