Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calendar Name #442

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions open_web_calendar/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@
from flask_caching import Cache

from . import translate, version
from .convert_to_dhtmlx import ConvertToDhtmlx
from .convert_to_dhtmlx import ConvertToDhtmlxEvents
from .convert_to_ics import ConvertToICS
from .convert_to_metadata import ConvertToMetadata

# configuration
DEBUG = os.environ.get("APP_DEBUG", "true").lower() == "true"
Expand Down Expand Up @@ -210,6 +211,13 @@ def render_app_template(template, specification):
)


CALENDAR_DISPATCH = {
"events.json": ConvertToDhtmlxEvents,
"json": ConvertToMetadata,
"ics": ConvertToICS,
}


@app.route("/calendar.<ext>", methods=["GET", "OPTIONS"])
@limit_hosts(allowed_hosts=ALLOWED_HOSTS)
# use query string in cache, see https://stackoverflow.com/a/47181782/1320237
Expand All @@ -219,12 +227,9 @@ def get_calendar(ext):
specification = get_specification()
if ext == "spec":
return jsonify(specification)
if ext == "events.json":
strategy = ConvertToDhtmlx(specification, get_text_from_url)
strategy.retrieve_calendars()
return set_js_headers(strategy.merge())
if ext == "ics":
strategy = ConvertToICS(specification, get_text_from_url)
if ext in CALENDAR_DISPATCH:
Strategy = CALENDAR_DISPATCH[ext] # noqa: N806
strategy = Strategy(specification, get_text_from_url)
strategy.retrieve_calendars()
return set_js_headers(strategy.merge())
if ext == "html":
Expand Down
85 changes: 80 additions & 5 deletions open_web_calendar/conversion_base.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# SPDX-FileCopyrightText: 2024 Nicco Kunzmann and Open Web Calendar Contributors <https://open-web-calendar.quelltext.eu/>
#
# SPDX-License-Identifier: GPL-2.0-only
from __future__ import annotations

import io
import sys
import traceback
from concurrent.futures import ThreadPoolExecutor
from threading import RLock
from typing import Tuple
from urllib.parse import urljoin

import requests
Expand All @@ -19,6 +21,70 @@ def get_text_from_url(url):
return requests.get(url, timeout=10).text


INDEX_TYPE = Tuple[int, int]


class CalendarInfo:
"""Provide an easy API for calendar information."""

def __init__(self, index: INDEX_TYPE, url: str, calendar: Calendar):
"""Create a new calendar info."""
self._calendar = calendar
self._index = index
self._url = url

@property
def name(self) -> str:
"""The name of the calendar."""
name = self._calendar.get("name", self._calendar.get("x-wr-calname"))
if name is not None:
return name
return self._url.rsplit("/", 1)[-1].rsplit(".", 1)[0]

@property
def description(self) -> str:
"""The name of the calendar."""
return self._calendar.get("description", self._calendar.get("x-wr-caldesc", ""))

@property
def calendar(self) -> Calendar:
"""My calendar."""
return self._calendar

@property
def index(self) -> INDEX_TYPE:
"""The index of the calendar url.

Since one URL can have several calendars, this is multiple indices."""
return self._index

@property
def event_css_classes(self) -> list[str]:
"""The css classes for all events in this calendar."""
return [
f"CALENDAR-INDEX-{self.index[0]}",
f"CALENDAR-INDEX-{self.index[0]}-{self.index[1]}",
]

@property
def id(self) -> str:
"""Return this calendar information as JSON."""
return f"{self.index[0]}-{self.index[1]}"

def to_json(self) -> dict[str, any]:
"""Return a JSON compatible version of this information."""
return {
"id": self.id,
"type": "calendar",
"url-index": self.index[0],
"calendar-index": self.index[0],
"name": self.name,
"description": self.description,
"url": self._url,
"event-css-classes": self.event_css_classes,
}


class ConversionStrategy:
"""Base class for conversions."""

Expand Down Expand Up @@ -83,19 +149,28 @@ def retrieve_calendar(self, index_url):
try:
index, url = index_url
calendars = self.get_calendars_from_url(url)
self.collect_components_from(index, calendars)
for i, calendar in enumerate(calendars):
self.collect_components_from(CalendarInfo((index, i), url, calendar))
except:
ty, err, tb = sys.exc_info()
with self.lock:
self.components.append(self.error(ty, err, tb, url))
self.add_component(self.error(ty, err, tb, url))

def add_component(self, component):
"""Add a component to the result."""
with self.lock:
self.components.append(component)

def collect_components_from(self, index, calendars):
def collect_components_from(self, calendar_info: CalendarInfo):
"""Collect all the compenents from the calendar."""
raise NotImplementedError("to be implemented in subclasses")

def merge(self):
"""Return the flask Response for the merged calendars."""
raise NotImplementedError("to be implemented in subclasses")

def convert_error(self, err: Exception, url: str, traceback: str):
"""Convert an error."""
raise NotImplementedError("to be implemented in subclasses")


__all__ = ["ConversionStrategy", "get_text_from_url"]
__all__ = ["ConversionStrategy", "get_text_from_url", "CalendarInfo", "INDEX_TYPE"]
27 changes: 13 additions & 14 deletions open_web_calendar/convert_to_dhtmlx.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
from flask import jsonify

from .clean_html import clean_html
from .conversion_base import ConversionStrategy
from .conversion_base import CalendarInfo, ConversionStrategy


def is_date(date):
"""Whether the date is a datetime.date and not a datetime.datetime"""
return isinstance(date, datetime.date) and not isinstance(date, datetime.datetime)


class ConvertToDhtmlx(ConversionStrategy):
class ConvertToDhtmlxEvents(ConversionStrategy):
"""Convert events to dhtmlx. This conforms to a stratey pattern.

- timeshift_minutes is the timeshift specified by the calendar
Expand Down Expand Up @@ -66,7 +66,7 @@ def date_to_string(self, date):
viewed_date = date.astimezone(self.timezone)
return viewed_date.strftime("%Y-%m-%d %H:%M")

def convert_ical_event(self, calendar_index, calendar_event):
def convert_ical_event(self, calendar_info: CalendarInfo, calendar_event):
start = calendar_event["DTSTART"].dt
end = calendar_event.get("DTEND", calendar_event["DTSTART"]).dt
if is_date(start) and is_date(end) and end == start:
Expand Down Expand Up @@ -104,7 +104,8 @@ def convert_ical_event(self, calendar_index, calendar_event):
"categories": self.get_event_categories(calendar_event),
"css-classes": ["event"]
+ self.get_event_classes(calendar_event)
+ [f"CALENDAR-INDEX-{calendar_index}"],
+ calendar_info.event_css_classes,
"calendar": calendar_info.id,
}

def convert_error(self, error, url, tb_s):
Expand Down Expand Up @@ -164,16 +165,14 @@ def clean_html(self, html):
def merge(self):
return jsonify(self.components)

def collect_components_from(self, calendar_index, calendars):
def collect_components_from(self, calendar_info: CalendarInfo):
# see https://stackoverflow.com/a/16115575/1320237
for calendar in calendars:
events = recurring_ical_events.of(calendar).between(
self.from_date, self.to_date
)
with self.lock:
for event in events:
json_event = self.convert_ical_event(calendar_index, event)
self.components.append(json_event)
events = recurring_ical_events.of(calendar_info.calendar).between(
self.from_date, self.to_date
)
for event in events:
json_event = self.convert_ical_event(calendar_info, event)
self.add_component(json_event)

def get_event_classes(self, event) -> list[str]:
"""Return the CSS classes that should be used for the event styles."""
Expand All @@ -194,4 +193,4 @@ def get_event_categories(self, event) -> list[str]:
return categories.cats if categories is not None else []


__all__ = ["ConvertToDhtmlx"]
__all__ = ["ConvertToDhtmlxEvents"]
22 changes: 10 additions & 12 deletions open_web_calendar/convert_to_ics.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from icalendar import Calendar, Event, Timezone
from icalendar.prop import vDDDTypes

from .conversion_base import ConversionStrategy
from .conversion_base import CalendarInfo, ConversionStrategy


class ConvertToICS(ConversionStrategy):
Expand All @@ -26,18 +26,16 @@ def is_timezone(self, component):
"""Whether a component is an event."""
return isinstance(component, Timezone)

def collect_components_from(self, calendar_index, calendars):
for calendar in calendars:
for component in calendar.walk():
if self.is_event(component):
def collect_components_from(self, calendar_info: CalendarInfo):
for component in calendar_info.calendar.walk():
if self.is_event(component):
self.add_component(component)
if self.is_timezone(component):
tzid = component.get("TZID")
if tzid and tzid not in self.timezones:
with self.lock:
self.components.append(component)
if self.is_timezone(component):
tzid = component.get("TZID")
if tzid and tzid not in self.timezones:
with self.lock:
self.components.append(component)
self.timezones.add(tzid)
self.add_component(component)
self.timezones.add(tzid)

def convert_error(self, error, url, tb_s):
"""Create an error which can be used by the dhtmlx scheduler."""
Expand Down
41 changes: 41 additions & 0 deletions open_web_calendar/convert_to_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: 2024 Nicco Kunzmann and Open Web Calendar Contributors <https://open-web-calendar.quelltext.eu/>
#
# SPDX-License-Identifier: GPL-2.0-only
from __future__ import annotations

from flask import jsonify

from .conversion_base import CalendarInfo, ConversionStrategy


class ConvertToMetadata(ConversionStrategy):
"""Convert the specification into metadata information about the calendars given."""

def created(self):
"""This instance is created."""
self.components = {"calendars": {}, "errors": {}}

def collect_components_from(self, calendar_info: CalendarInfo):
self.add_component(calendar_info.to_json())

def add_component(self, component: dict):
with self.lock:
kind = f"{component['type']}s"
self.components[kind][component["id"]] = component

def convert_error(self, err: Exception, url: str, traceback: str):
"""Convert an error."""
return {
"type": "error",
"id": url,
"url": url,
"traceback": traceback,
"error": str(err),
}

def merge(self):
"""Return the response."""
return jsonify(self.components)


__all__ = ["ConvertToMetadata"]
35 changes: 35 additions & 0 deletions open_web_calendar/features/calendars/calendar-name-rfc-7986.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
NAME:RFC 7986 compatible calendar
DESCRIPTION:This is a later version with attributes
X-APPLE-CALENDAR-COLOR:#e78074
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20190303T111937
DTSTAMP:20190303T111937
LAST-MODIFIED:20190303T111937
UID:UYDQSG9TH4DE0WM3QFL2J
SUMMARY:test1
DTSTART;TZID=Europe/Berlin:20190304T080000
DTEND;TZID=Europe/Berlin:20190304T083000
END:VEVENT
END:VCALENDAR
35 changes: 35 additions & 0 deletions open_web_calendar/features/calendars/calendar-x-wr-calname.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:old calendar description
X-WR-CALDESC: This calendar uses non-standard descriptions
X-APPLE-CALENDAR-COLOR:#e78074
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20190303T111937
DTSTAMP:20190303T111937
LAST-MODIFIED:20190303T111937
UID:UYDQSG9TH4DE0WM3QFL2J
SUMMARY:test1
DTSTART;TZID=Europe/Berlin:20190304T080000
DTEND;TZID=Europe/Berlin:20190304T083000
END:VEVENT
END:VCALENDAR
Loading
Loading