【Python 3】CalDAVを用いたスケジュール通知の実装

# はじめに

コーディングをしていると、スケジュールのことが気になってしまうことが多いため、スケジュールの通知機能が必要になった。  

# はまりどころ

CalDAVを使用してカレンダーから予定を取得する際には、タイムゾーンに注意が必要である。
なぜなら、CalDAVから送られてくる日時は、サービスごとにタイムゾーンが異なると思われるためである。
例えば、`DTSTART`や`DTSTAMP`の末尾に`Z`が付いている場合はUTCであることを示す。

# 工夫

1. **カレンダーの取得回数の制限**:  
       CalDAVサーバに負担をかけないように配慮するため、カレンダーの取得頻度を制限することが重要である。  
       具体的には、以下のような工夫を行った。  
   
   - **12時間ごとの取得**: 
       カレンダーの予定を12時間に一度取得するように設定した。  
       これにより、サーバへのリクエスト回数を減らし、サーバの負荷を軽減することができる。  

2. **リマインダー機能の実装**:  
    約15分前、約5分前、約1分前で通知を行うリマインダー機能を実装した。  
    これにより、予定を見逃すことを防ぎ、事前に準備をする時間を確保できるようにしている。  

# 環境

- Python 3.12.8
- Windows 10 22H2

# 必要なライブラリ

以下のコマンドで必要なライブラリをインストールする。

```cmd
py -m pip install caldav==1.4.0
py -m pip install plyer==2.1.0
```

# サンプルコード

以下に、カレンダーから予定を取得し、トースト通知を行うサンプルコードを示す。

```python
import time
import datetime
from caldav import DAVClient, Principal, Calendar, Sequence, CalendarObjectResource
from plyer import notification

URL: str = 'https://example.com/'
USERNAME: str = 'Hoge'
PASSWORD: str = 'Fuga'

def fetch_schedules(schedules : dict[str, datetime.datetime]) -> None:
    """
    CalDAVサーバから予定を取得する。

    Args:
        schedules (dict[str, datetime.datetime]): 予定を格納する辞書。キーは予定のタイトル、値は開始時刻。
    """
    with DAVClient(url=URL, username=USERNAME, password=PASSWORD) as client:
        principal: Principal = client.principal()
        calendars: list[Calendar] = principal.calendars()

        now_utc: datetime = datetime.datetime.now(datetime.timezone.utc)
        end_time: datetime = now_utc + datetime.timedelta(days=1)

        for calendar in calendars:
            try:
                events: Sequence[CalendarObjectResource] = calendar.date_search(start=now_utc, end=end_time, expand=True)
                for event in events:
                    d : dict[str, str] = {}
                    for event_data in event.data.splitlines():
                        pair: list[str] = event_data.split(":")
                        if len(pair) >= 2: 
                            d[pair[0]] = pair[1]
                    if 'SUMMARY' in d and 'DTSTART' in d:
                        key: str = d['SUMMARY']
                        start_time_str: str = d['DTSTART']
                        start_time: datetime = datetime.datetime.strptime(start_time_str, '%Y%m%dT%H%M%SZ')
                        start_time: datetime = start_time.replace(tzinfo=datetime.timezone.utc)
                        schedules[key] = start_time
            except Exception as e:
                notification.notify(title="カレンダー取得 失敗", message=f"カレンダーの取得に失敗しました。: {str(e)}", timeout=10)
                exit(1)

def check_notifications(schedules : dict[str, datetime.datetime]) -> None:
    """
    取得したスケジュールを確認し、通知を行う。

    Args:
        schedules (dict[str, datetime.datetime]): 予定を格納する辞書。キーは予定のタイトル、値は開始時刻。
    """
    now_utc: datetime = datetime.datetime.now(datetime.timezone.utc)

    for k, start_time in schedules.items():
        event_time: datetime = start_time

        # 各通知タイミングを計算
        fifteen_minutes_before: datetime = event_time - datetime.timedelta(minutes=15)
        fourteen_minutes_before: datetime = event_time - datetime.timedelta(minutes=14)
        five_minutes_before: datetime = event_time - datetime.timedelta(minutes=5)
        six_minutes_before: datetime = event_time - datetime.timedelta(minutes=6)
        one_minute_before: datetime = event_time - datetime.timedelta(minutes=1)

        # トーストを表示する条件
        is_fifteen_minutes_before: bool = fifteen_minutes_before >= now_utc >= fourteen_minutes_before
        is_five_minutes_before: bool = five_minutes_before >= now_utc >= six_minutes_before
        is_one_minute_before: bool = event_time >= now_utc >= one_minute_before

        if is_fifteen_minutes_before or is_five_minutes_before or is_one_minute_before:
            notification.notify(title=k, message=f"予定「{k}」", timeout=10)

def main() -> None:
    schedules : dict[str, datetime.datetime] = {}
    
    while True:
        fetch_schedules(schedules) 
        
        for _ in range(12 * 60):
            check_notifications(schedules)
            time.sleep(60)

if __name__ == "__main__":
    main()
```

# さいごに

CalDAVを使用してカレンダーから予定を取得し、指定した時間の前に通知を行う機能を実装した。  
この機能により、安心してコーディングを行えるようになった。  

# 参考文献

https://qiita.com/masaki12-s/items/905365c9d5f68b7efcd8

https://tarenagashi.hatenablog.jp/entry/2022/02/24/214743