From 1e2737244a6e3ed900558f7c704957246e42e38e Mon Sep 17 00:00:00 2001 From: KaeroDot Date: Fri, 20 Mar 2026 15:44:07 +0100 Subject: [PATCH] add: support for multiple ICS and Google calendars. urlib3 version requirements had to be fixed. Also removed deprecated .utcnow() --- README.md | 25 ++++++++++++++-- calendar_providers/google.py | 23 +++++++++------ calendar_providers/ics.py | 9 +++--- env.sh.sample | 23 +++++++++++++++ requirements.txt | 2 +- screen-calendar-get.py | 56 +++++++++++++++++++++++++----------- 6 files changed, 105 insertions(+), 33 deletions(-) mode change 100755 => 100644 env.sh.sample diff --git a/README.md b/README.md index 04263639..d8572e59 100644 --- a/README.md +++ b/README.md @@ -219,9 +219,9 @@ in the pre-2014 section. For example, this is the file for Dublin: ## Pick a Calendar provider -You can use Google Calendar or Outlook Calendar to display events. +You can use up to 5 Google Calendars, up to 5 iCalendars, CalDav and Outlook Calendar to display events. Events will be sorted across the calendars according the time of start. -### Google Calendar +### Google Calendars The script will by default get its info from your primary Google Calendar. If you need to pick a specific calendar you will need its ID. To get its ID, open up [Google Calendar](https://calendar.google.com) and go to the settings for your preferred calendar. Under the 'Integrate Calendar' section you will see a Calendar ID which looks like `xyz12345@group.calendar.google.com`. Set that value in `env.sh` @@ -250,6 +250,15 @@ Copy the URL it was trying to go to (eg: http://localhost:8080/...) and in anoth On the first screen you should see the auth flow complete, and a new `token.pickle` file appears. The script should now be able to run in the future without prompting required. +#### Additional Google Calendars +You can also add up to 4 additional Google calendars by setting `GOOGLE_CALENDAR_ID_2` etc. in the `env.sh` file. Additional calendars will use `token_google_2.pickle` etc. for their tokens. + +```bash +export GOOGLE_CALENDAR_ID_2=second_email_address@gmail.com +export GOOGLE_CALENDAR_ID_3=some_group@group.calendar.google.com +export GOOGLE_CALENDAR_ID_4=other_group@group.calendar.google.com +export GOOGLE_CALENDAR_ID_5=third_email_address@gmail.com +``` ### Outlook Calendar @@ -265,7 +274,7 @@ Copy the ID of the calendar you want, and add it to env.sh like so: Note that if you set an Outlook Calendar ID, the Google Calendar will be ignored. -### ICS Calendar +### ICS Calendars ICS is simple, get the ICS URL for a calendar, and place it in `env.sh`. @@ -273,6 +282,16 @@ ICS is simple, get the ICS URL for a calendar, and place it in `env.sh`. There is no username/password support. +#### Additional ICS Calendars +You can also add up to 4 additional ICS calendars by setting `ICS_CALENDAR_URL_2` etc. in the `env.sh` file. + +```bash +export ICS_CALENDAR_URL_2=https://calendar.google.com/calendar/ical/ht3jlfaac5lfd6263ulfh4tql8%40group.calendar.google.com/public/basic.ics +export ICS_CALENDAR_URL_3=https://ics.calendarlabs.com/35/72771a5e/Australia_Holidays.ics +export ICS_CALENDAR_URL_4=https://calendar.google.com/calendar/ical/xxxxxxxxxxxx/xxxxxxxxxxxxxx/basic.ics +export ICS_CALENDAR_URL_5=https://calendar.google.com/calendar/ical/xxxxxxxxxxxx/xxxxxxxxxxxxxx/basic.ics +``` + ### CalDav Calendar For CalDav you will need the CalDav URL, username, and password. diff --git a/calendar_providers/google.py b/calendar_providers/google.py index 202a93f7..5d028260 100644 --- a/calendar_providers/google.py +++ b/calendar_providers/google.py @@ -13,15 +13,19 @@ class GoogleCalendar(BaseCalendarProvider): - def __init__(self, google_calendar_id, max_event_results, from_date, to_date): + def __init__(self, google_calendar_id, max_event_results, from_date, to_date, index): self.max_event_results = max_event_results self.from_date = from_date self.to_date = to_date self.google_calendar_id = google_calendar_id + self.index = index def get_google_credentials(self): - - google_token_pickle = 'token.pickle' + if self.index == 1: + # for backward compatibility, the first calendar will use the old token filename + google_token_pickle = "token.pickle" + else: + google_token_pickle = "token_google_{}.pickle".format(self.index) google_api_scopes = ['https://www.googleapis.com/auth/calendar.readonly'] @@ -61,19 +65,19 @@ def get_google_credentials(self): def get_calendar_events(self) -> list[CalendarEvent]: calendar_events = [] - google_calendar_pickle = 'cache_calendar.pickle' + google_calendar_pickle = 'cache_google_{}.pickle'.format(self.index) service = build('calendar', 'v3', credentials=self.get_google_credentials(), cache_discovery=False) events_result = None if is_stale(os.getcwd() + "/" + google_calendar_pickle, ttl): - logging.debug("Pickle is stale, calling the Calendar API") + logging.debug("Cache of Google calendar {} is stale, calling the Calendar API".format(self.index)) # Call the Calendar API events_result = service.events().list( calendarId=self.google_calendar_id, - timeMin=self.from_date.isoformat() + 'Z', + timeMin=self.from_date.isoformat(), timeZone=google_calendar_timezone, maxResults=self.max_event_results, singleEvents=True, @@ -82,8 +86,9 @@ def get_calendar_events(self) -> list[CalendarEvent]: for event in events_result.get('items', []): if event['start'].get('date'): is_all_day = True - start_date = datetime.datetime.strptime(event['start'].get('date'), "%Y-%m-%d") - end_date = datetime.datetime.strptime(event['end'].get('date'), "%Y-%m-%d") + # .isoformat() produces valid RFC3339 with timezone + start_date = datetime.date.fromisoformat(event['start'].get('date')) + end_date = datetime.date.fromisoformat(event['end'].get('date')) # Google Calendar marks the 'end' of all-day-events as # the day _after_ the last day. eg, Today's all day event ends tomorrow! # So subtract a day @@ -101,7 +106,7 @@ def get_calendar_events(self) -> list[CalendarEvent]: pickle.dump(calendar_events, cal) else: - logging.info("Found in cache") + logging.info("Google calendar {} found in cache.".format(self.index)) with open(google_calendar_pickle, 'rb') as cal: calendar_events = pickle.load(cal) diff --git a/calendar_providers/ics.py b/calendar_providers/ics.py index fefa3340..dcd97794 100644 --- a/calendar_providers/ics.py +++ b/calendar_providers/ics.py @@ -14,17 +14,18 @@ class ICSCalendar(BaseCalendarProvider): - def __init__(self, ics_calendar_url, max_event_results, from_date, to_date): + def __init__(self, ics_calendar_url, max_event_results, from_date, to_date, index): self.ics_calendar_url = ics_calendar_url self.max_event_results = max_event_results self.from_date = from_date self.to_date = to_date + self.index = index def get_calendar_events(self) -> list[CalendarEvent]: calendar_events = [] - ics_calendar_pickle = 'cache_ics.pickle' + ics_calendar_pickle = "cache_ics_{}.pickle".format(self.index) if is_stale(os.getcwd() + "/" + ics_calendar_pickle, ttl): - logging.debug("Pickle is stale, fetching ICS Calendar") + logging.debug("Pickle is stale, fetching ICS Calendar {}".format(self.index)) ics_events = icalevents.icalevents.events(self.ics_calendar_url, start=self.from_date, end=self.to_date, tzinfo=get_localzone(), strict=True, sort=True) @@ -45,7 +46,7 @@ def get_calendar_events(self) -> list[CalendarEvent]: with open(ics_calendar_pickle, 'wb') as cal: pickle.dump(calendar_events, cal) else: - logging.info("Found in cache") + logging.info("ICS calendar {} found in cache".format(self.index)) with open(ics_calendar_pickle, 'rb') as cal: calendar_events = pickle.load(cal) diff --git a/env.sh.sample b/env.sh.sample old mode 100755 new mode 100644 index 2ab359e0..66822490 --- a/env.sh.sample +++ b/env.sh.sample @@ -31,10 +31,33 @@ export WEATHER_FORMAT=CELSIUS export GOOGLE_CALENDAR_ID=primary # If your Google Calendar is a family calendar or doesn't allow setting timezones # export GOOGLE_CALENDAR_TIME_ZONE_NAME=Asia/Kuala_Lumpur +# Optional second Google Calendar, name credential file as "credentials_2.json": +# export GOOGLE_CALENDAR_ID_2=second google calendar ID +# export GOOGLE_CALENDAR_TIME_ZONE_NAME_2=Asia/Kuala_Lumpur +# Optional third Google Calendar, name credential file as "credentials_3.json": +# export GOOGLE_CALENDAR_ID_3=third google calendar ID +# export GOOGLE_CALENDAR_TIME_ZONE_NAME_3=Asia/Kuala_Lumpur +# Optional fourth Google Calendar, name credential file as "credentials_4.json": +# export GOOGLE_CALENDAR_ID_4=fourth google calendar ID +# export GOOGLE_CALENDAR_TIME_ZONE_NAME_4=Asia/Kuala_Lumpur +# Optional fifth Google Calendar, name credential file as "credentials_5.json": +# export GOOGLE_CALENDAR_ID_5=fifth google calendar ID +# export GOOGLE_CALENDAR_TIME_ZONE_NAME_5=Asia/Kuala_Lumpur + # Or if you use Outlook Calendar, use python3 outlook_util.py to get available Calendar IDs # export OUTLOOK_CALENDAR_ID=AQMkAxyz... + # Or if you use ICS Calendar, # export ICS_CALENDAR_URL=https://calendar.google.com/calendar/ical/xxxxxxxxxxxx/xxxxxxxxxxxxxx/basic.ics +# Optional second ICS calendar: example - moon quarters: +# export ICS_CALENDAR_URL_2=https://calendar.google.com/calendar/ical/ht3jlfaac5lfd6263ulfh4tql8%40group.calendar.google.com/public/basic.ics +# Optional third ICS calendar: example - Australian hollidays: +# export ICS_CALENDAR_URL_3=https://ics.calendarlabs.com/35/72771a5e/Australia_Holidays.ics +# Optional fourth ICS calendar: +# export ICS_CALENDAR_URL_4=fourth ICS calendar URL +# Optional fifth ICS calendar: +# export ICS_CALENDAR_URL_5=fifth ICS calendar URL + # Or if you have a CalDave calendar # export CALDAV_CALENDAR_URL=https://nextcloud.example.com/remote.php/dav/principals/users/123456/ # export CALDAV_USERNAME=username diff --git a/requirements.txt b/requirements.txt index 8185ef99..20eb7897 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ tinycss2==1.2.1 tzdata==2025.2 tzlocal==4.2 uritemplate==4.1.1 -urllib3==2.5.0 +urllib3 vobject==0.9.6.1 webencodings==0.5.1 zope.interface==5.5.1 diff --git a/screen-calendar-get.py b/screen-calendar-get.py index 3210b54e..8a4d5aef 100755 --- a/screen-calendar-get.py +++ b/screen-calendar-get.py @@ -17,7 +17,13 @@ # note: increasing this will require updates to the SVG template to accommodate more events max_event_results = 10 -google_calendar_id = os.getenv("GOOGLE_CALENDAR_ID", "primary") +# get data from first Google calendar: +google_calendar_ids = [] +google_calendar_ids.append(os.getenv("GOOGLE_CALENDAR_ID", None)) +# Get data from optional additional Google calendars, up to 5: +for id in range(2, 6): + google_calendar_ids.append(os.getenv("GOOGLE_CALENDAR_ID_{}".format(id), None)) + outlook_calendar_id = os.getenv("OUTLOOK_CALENDAR_ID", None) caldav_calendar_url = os.getenv('CALDAV_CALENDAR_URL', None) @@ -25,7 +31,12 @@ caldav_password = os.getenv("CALDAV_PASSWORD", None) caldav_calendar_id = os.getenv("CALDAV_CALENDAR_ID", None) -ics_calendar_url = os.getenv("ICS_CALENDAR_URL", None) +# get data from first ics calendar: +ics_calendar_urls = [] +ics_calendar_urls.append(os.getenv("ICS_CALENDAR_URL", None)) +# Get data from optional additional ics calendars, up to 5: +for id in range(2, 6): + ics_calendar_urls.append(os.getenv("ICS_CALENDAR_URL_{}".format(id), None)) ttl = float(os.getenv("CALENDAR_TTL", 1 * 60 * 60)) @@ -78,28 +89,41 @@ def main(): output_svg_filename = 'screen-output-weather.svg' - today_start_time = datetime.datetime.utcnow() + today_start_time = datetime.datetime.now().astimezone() if os.getenv("CALENDAR_INCLUDE_PAST_EVENTS_FOR_TODAY", "0") == "1": - today_start_time = datetime.datetime.combine(datetime.datetime.utcnow(), datetime.datetime.min.time()) + today_start_time = datetime.datetime.combine(datetime.datetime.now().astimezone(), datetime.time.min).astimezone() oneyearlater_iso = (datetime.datetime.now().astimezone() + datetime.timedelta(days=365)).astimezone() + # Initiate calendar providers array: + providers = [] if outlook_calendar_id: logging.info("Fetching Outlook Calendar Events") - provider = OutlookCalendar(outlook_calendar_id, max_event_results, today_start_time, oneyearlater_iso) - elif caldav_calendar_url: + providers.append(OutlookCalendar(outlook_calendar_id, max_event_results, today_start_time, oneyearlater_iso,)) + if caldav_calendar_url: logging.info("Fetching Caldav Calendar Events") - provider = CalDavCalendar(caldav_calendar_url, caldav_calendar_id, max_event_results, + providers.append(CalDavCalendar(caldav_calendar_url, caldav_calendar_id, max_event_results, today_start_time, oneyearlater_iso, caldav_username, caldav_password) - elif ics_calendar_url: - logging.info("Fetching ics Calendar Events") - today_start_time = datetime.datetime.now().astimezone() - provider = ICSCalendar(ics_calendar_url, max_event_results, today_start_time, oneyearlater_iso) - else: - logging.info("Fetching Google Calendar Events") - provider = GoogleCalendar(google_calendar_id, max_event_results, today_start_time, oneyearlater_iso) - - calendar_events = provider.get_calendar_events() + ) + for index, ics_calendar_url in enumerate(ics_calendar_urls): + if ics_calendar_url: + logging.info("Fetching events from ICS calendar number {}".format(index + 1)) + providers.append(ICSCalendar(ics_calendar_url, max_event_results, today_start_time, oneyearlater_iso, index + 1)) + for index, google_calendar_id in enumerate(google_calendar_ids): + if google_calendar_id: + logging.info("Fetching events from Google calendar number {}".format(index + 1)) + providers.append(GoogleCalendar(google_calendar_id, max_event_results, today_start_time, oneyearlater_iso, index + 1)) + + calendar_events = [] + for provider in providers: + calendar_events = calendar_events + provider.get_calendar_events() + # sort events by start date: normalise date/datetime mix so comparisons work + def sort_key(event): + s = event.start + if isinstance(s, datetime.datetime): + return s if s.tzinfo else s.replace(tzinfo=datetime.timezone.utc) + return datetime.datetime(s.year, s.month, s.day, tzinfo=datetime.timezone.utc) + calendar_events = sorted(calendar_events, key=sort_key) output_dict = get_formatted_calendar_events(calendar_events) # XML escape for safety