all repos — mycal @ 56e04818a38a01991561ee774d7e67009bbaf2ab

private calendar anonymiser

mycal.py (view raw)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
from icalendar.cal import Calendar, FreeBusy
import icalendar

import requests
from flask import Flask
from datetime import date, datetime, timedelta, time, timezone
import zoneinfo
from dataclasses import dataclass
from danoan.toml_dataclass import TomlDataClassIO
from os import environ

@dataclass
class CalendarConfig(TomlDataClassIO):
    file: str = ""
    url: str = ""

@dataclass
class Config(TomlDataClassIO):
    name: str
    timezone: str
    calendar: CalendarConfig

def load_config(file_path):
    with open(file_path, "r") as fr:
        return Config.read(fr)

app = Flask(__name__)

config = load_config(environ.get("CONFIG_FILE", "config.toml"))
tz = zoneinfo.ZoneInfo(config.timezone)

def fetch_calendar(calendar_url):
    return requests.get(calendar_url).content

def read_calendar_file(calendar_file):
    with open(calendar_file, 'rb') as f:
        return f.read()

def get_calendar(calendar_config):
    if calendar_config.file != "":
        return read_calendar_file(calendar_config.file)
    else:
        if calendar_config.url != "":
            return fetch_calendar(calendar_config.url)
        raise ValueError("Calendar URL not configured.")

def fixup_date(dt):
    if type(dt) == date:
        return datetime.combine(dt, time.min, tzinfo=tz)
    elif dt.tzinfo is None:
        return dt.replace(tzinfo=tz)
    else:
        return dt.astimezone(tz)

def to_utc(dt):
    return dt.astimezone(tz=timezone.utc)

def looks_tentative(component):
    summary = str(component.get('summary'))
    return component.get('status') == 'TENTATIVE' or 'TBD' in summary or summary.endswith("?")

@app.route(f'/{str.lower(config.name)}.ics')
def index():
    today = date.today()
    start_of_week = today - timedelta(days=today.weekday())
    start_date = datetime.combine(start_of_week, time.min, tzinfo=tz)
    end_date = start_date + timedelta(days=30)

    output = Calendar()
    output.add('prodid', '-//Calendar Anonymiser//alin.ovh//')
    output.add('version', '2.0')

    # Parse with icalendar
    try:
        input = Calendar.from_ical(get_calendar(config.calendar))
        for component in input.walk():
            if component.name == "VEVENT":
                dtstart = fixup_date(component.get('dtstart').dt)
                dtend = fixup_date(component.get('dtend', component.get('dtstart')).dt)
                dtstamp = fixup_date(component.get('dtstamp').dt)
                if dtstart >= start_date and dtend <= end_date:
                    busy = FreeBusy(uid=component.get('uid'))
                    vp = icalendar.prop.vPeriod([ to_utc(d) for d in [dtstart, dtend] ])

                    if looks_tentative(component):
                        vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_TENTATIVE
                    else:
                        vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_UNAVAILABLE

                    busy.add('freebusy', vp)
                    busy.add('summary', f'{config.name} Busy')
                    busy.add('dtstart', dtstart)
                    busy.add('dtend', dtend)
                    busy.add('dtstamp', dtstamp)
                    output.add_component(busy)
        return output.to_ical(), { "Content-Type": "text/plain" }
    except Exception as e:
        print("error:", e)
        return f"Error parsing with icalendar: {str(e)}", 500

if __name__ == '__main__':
    app.run(debug=True)