├── requirements.txt ├── LICENSE ├── .gitignore ├── auth.py ├── config.py.example ├── README.md └── ical_to_gcal_sync.py /requirements.txt: -------------------------------------------------------------------------------- 1 | arrow==1.2.3 2 | cachetools==5.2.0 3 | certifi==2024.7.4 4 | charset-normalizer==2.0.12 5 | DateTime==4.7 6 | google-api-core==2.10.2 7 | google-api-python-client==2.65.0 8 | google-auth==2.13.0 9 | google-auth-httplib2==0.1.0 10 | google-auth-oauthlib==0.6.0 11 | googleapis-common-protos==1.56.4 12 | httplib2==0.22.0 13 | icalendar==6.1.3 14 | icalevents==0.2.1 15 | idna==3.7 16 | oauthlib==3.2.2 17 | protobuf==4.25.8 18 | pyasn1==0.4.8 19 | pyasn1-modules==0.2.8 20 | pyparsing==2.4.7 21 | python-dateutil==2.9.0.post0 22 | pytz==2024.2 23 | requests==2.32.4 24 | requests-oauthlib==1.3.1 25 | rsa==4.9 26 | six==1.16.0 27 | tzdata==2025.2 28 | uritemplate==4.1.1 29 | urllib3==2.6.0 30 | zope.interface==5.5.0 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Andrew Ramsay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Sensitive files 3 | 4 | /client_secret.json 5 | /credentials.pckl 6 | /config.py 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | env/ 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *,cover 53 | .hypothesis/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # IPython Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | -------------------------------------------------------------------------------- /auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import sys 4 | 5 | from googleapiclient.discovery import build 6 | from google_auth_oauthlib.flow import InstalledAppFlow 7 | from google.auth.transport.requests import Request 8 | from google.oauth2.credentials import Credentials 9 | 10 | logger = logging.getLogger(__name__) 11 | logger.setLevel(logging.DEBUG) 12 | 13 | def auth_with_calendar_api(config): 14 | if config.get('LOGFILE', None): 15 | handler = logging.FileHandler(filename=config['LOGFILE'], mode='a') 16 | else: 17 | handler = logging.StreamHandler(sys.stderr) 18 | handler.setFormatter(logging.Formatter('%(asctime)s|[%(levelname)s] %(message)s')) 19 | logger.addHandler(handler) 20 | 21 | # this file stores your access and refresh tokens, and is 22 | # created automatically when the auth flow succeeeds for 23 | # the first time. 24 | creds = None 25 | if os.path.exists(config['CREDENTIAL_PATH']): 26 | try: 27 | logger.info('Loading cached credentials') 28 | creds = Credentials.from_authorized_user_file(config['CREDENTIAL_PATH'], [config['SCOPES']]) 29 | except Exception as e: 30 | logger.warning(f'Failed to load cached credentials, exception was "{str(e)}"') 31 | os.unlink(config['CREDENTIAL_PATH']) 32 | 33 | if not creds or not creds.valid: 34 | if creds and creds.expired and creds.refresh_token: 35 | logger.info('Refreshing credentials') 36 | creds.refresh(Request()) 37 | else: 38 | logger.info('Credentials need manually approved!') 39 | flow = InstalledAppFlow.from_client_secrets_file(config['CLIENT_SECRET_FILE'], 40 | [config['SCOPES']]) 41 | creds = flow.run_local_server(port=0) 42 | 43 | # save credentials if successful 44 | with open(config['CREDENTIAL_PATH'], 'w') as token: 45 | token.write(creds.to_json()) 46 | 47 | service = build('calendar', 'v3', credentials=creds) 48 | return service 49 | 50 | -------------------------------------------------------------------------------- /config.py.example: -------------------------------------------------------------------------------- 1 | # iCal feeds. More than one feed can be specified. 2 | # 3 | # 'source': The iCal feed URL for the events that should be synced to the Google Calendar. 4 | # Note that the syncing is one-way only. 5 | # 6 | # 'destination': The ID of the calendar to use for iCal events, should be of the form 7 | # 'ID@group.calendar.google.com', check the calendar settings page to find it. 8 | # All calendars should be writable with the same google credenatials 9 | # (can also be 'primary' to use the default calendar) 10 | # 11 | # 'files': If it is True then 'source' is the path to a folder full of ics files. 12 | # 13 | # Feeds can be of mixed types 14 | 15 | ICAL_FEEDS = [ 16 | {'source': '', 'destination': '', 'files': False}, 17 | # ... 18 | ] 19 | 20 | # Authentication information for the ical feeds. 21 | # The same credentials are used for all feeds where 'files' is False 22 | # If the feed does not require authentication or if FILES is true, it should be left to None 23 | ICAL_FEED_USER = None 24 | ICAL_FEED_PASS = None 25 | 26 | # If the iCalendar server is using a self signed ssl certificate, certificate checking must be disabled. 27 | # This option applies to all feeds where 'files' is False 28 | # If unsure left it to True 29 | ICAL_FEED_VERIFY_SSL_CERT = True 30 | 31 | # Must use the OAuth scope that allows write access 32 | SCOPES = 'https://www.googleapis.com/auth/calendar' 33 | 34 | # API secret stored in this file 35 | CLIENT_SECRET_FILE = 'ical_to_gcal_sync_client_secret.json' 36 | 37 | # Location to store API credentials 38 | CREDENTIAL_PATH = 'ical_to_gcal_sync_credentials.json' 39 | 40 | # Application name for the Google Calendar API 41 | APPLICATION_NAME = 'ical_to_gcal_sync' 42 | 43 | # File to use for logging output 44 | LOGFILE = 'ical_to_gcal_sync.log' 45 | 46 | # Time to pause between successive API calls that may trigger rate-limiting protection 47 | API_SLEEP_TIME = 0.10 48 | 49 | # Integer value >= 0 50 | # Controls the timespan within which events from ICAL_FEED will be processed 51 | # by the script. 52 | # 53 | # For example, setting a value of 14 would sync all events from the current 54 | # date+time up to a point 14 days in the future. 55 | # 56 | # If you want to sync all events available from the feed this is also possible 57 | # by setting the value to 0, *BUT* be aware that due to an API limitation in the 58 | # icalevents module which prevents an open-ended timespan being used, this will 59 | # actually be equivalent to "all events up to a year from now" (to sync events 60 | # further into the future than that, set a sufficiently high number of days). 61 | ICAL_DAYS_TO_SYNC = 0 62 | 63 | # Integer value >= 0 64 | # Controls how many days in the past to query/update from Google and the ical source. 65 | # If you want to only worry about today's events and future events, set to 0, 66 | # otherwise set to a positive number (e.g. 30) to include that many days in the past. 67 | # Any events outside of that time-range will be untouched in the Google Calendar. 68 | # If the ical source doesn't include historical events, then this will mean deleting 69 | # a rolling number of days historical calendar entries from Google Calendar as the script 70 | # runs 71 | PAST_DAYS_TO_SYNC = 30 72 | 73 | # Restore deleted events 74 | # If this is set to True, then events that have been deleted from the Google Calendar 75 | # will be restored by this script - otherwise they will be left deleted, but will 76 | # be updated - just Google won't show them 77 | RESTORE_DELETED_EVENTS = True 78 | 79 | # function to modify events coming from the ical source before they get compared 80 | # to the Google Calendar entries and inserted/deleted 81 | # 82 | # this function should modify the events in-place and return either True (keep) or 83 | # False (delete/skip) the event from the Calendar. If this returns False on an event 84 | # that is already in the Google Calendar, the event will be deleted from the Google 85 | # Calendar 86 | import icalevents 87 | def EVENT_PREPROCESSOR(ev: icalevents.icalparser.Event) -> bool: 88 | # include all entries by default 89 | # see README.md for examples of rules that make changes/skip 90 | return True 91 | 92 | # Sometimes you can encounter an annoying situation where events have been fully deleted 93 | # (by manually emptying the "Bin" for the calendar), but attempting to add new events with 94 | # the same UIDs will continue to fail. Inserts will produce a 409 "already exists" error, 95 | # and updtes will produce a 403 "Forbidden" error. Probably because the events are still 96 | # stored somewhere even though they are no longer visible to the API or through the web 97 | # interface. 98 | # 99 | # If you run into this situation and don't want to create a fresh calendar, you can try 100 | # setting this value to something other than an empty string. It will be used as a prefix 101 | # for new event UIDs, so changing it from the default will prevent the same IDs from being 102 | # reused and allow them to be inserted as normal. 103 | # 104 | # NOTE: Characters allowed in the ID are those used in base32hex encoding, i.e. lowercase 105 | # letters a-v and digits 0-9, see section 3.1.2 in RFC2938 106 | EVENT_ID_PREFIX = '' 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This script can be used to periodically pull events from an iCal feed and insert them into a selected Google Calendar using the API for that service. 2 | 3 | Why do this instead of importing the iCal URL straight into GCal? The rate at which GCal refreshes iCal feeds is glacially slow, typically somewhere between 1-2 days. This has been the case for the best part of a decade now and Google show absolutely no interest in providing a sensible way to even trigger a manual refresh (e.g. https://productforums.google.com/forum/#!msg/calendar/iXp8fZfgU2E/wK9Qf6nfI48J). This script is a simple way to work around that limitation - it's not much use to me if I add an event to my todo list and then check my calendar the next day and forget about it because it hasn't been synced from the associated iCal feed yet. 4 | 5 | I've been running this script on an RPi as a cronjob and it's working well for me. I'm putting the code here in case it's useful to anyone similarly frustrated with Google Calendar and its handling of iCal feeds. Note that it's not particularly polished or well-packaged, and importantly doesn't try to handle all possible types of iCal events. It only does the minimum I needed for my own workflow. 6 | 7 | ## Using the script 8 | 9 | > NOTE: requires Python 3.7+ 10 | 11 | 1. Copy `config.py.example` to a new file `config.py` or a custom file (see *Multiple Configurations* below) 12 | 2. Modify the value of `ICAL_FEEDS` to configure your calendars. It should contain a list with one or more entries where each entry is a dict with the following structure: 13 | ```python 14 | ICAL_FEEDS = [ 15 | { 16 | # source of calendar events. normally this is an iCal feed URL, but you can also use a local path 17 | # containing .ics files as a data source instead (in that case set 'files' to True) 18 | 'source': '', 19 | # the ID of the Google calendar to insert events into. this can be "primary" if you want to use the 20 | # default main calendar, or a 'longID@group.calendar.google.com' string for secondary calendars. You 21 | # can find the long calendar ID on its settings page. 22 | 'destination': '', 23 | # set to False if source is a URL, True if it's a local path 24 | 'files': False, 25 | }, 26 | ] 27 | ``` 28 | 3. If your iCal feed is password protected you should also set the variables `ICAL_FEED_USER` and `ICAL_FEED_PASS` appropriately. 29 | 4. Create and activate a virtualenv and then run `pip install -r requirements.txt` 30 | 5. Go through the process of registering an app in the Google Calendar API dashboard in order to obtain an OAuth client ID. This process is described at https://developers.google.com/google-apps/calendar/quickstart/python. It's important to select "Desktop app" for the OAuth "Application Type" field. Once the credentials are created, download the JSON file, rename it to `ical_to_gcal_sync_client_secret.json` and place it in the same location as the script. 31 | 6. Until recently you could leave your Google Cloud project in "testing" mode and the OAuth flow would work indefinitely. However as [described here](https://support.google.com/cloud/answer/10311615#publishing-status&zippy=%2Ctesting) any tokens for apps in this mode will now expire after 7 days, including refresh tokens. To avoid having to manually re-auth every time this happens, go to [your OAuth consent page configuration](https://console.cloud.google.com/apis/credentials/consent) and set the "Publishing status" to "Production". This will display a warning that you need to do a lot of verification steps, but things still seem to work if you ignore the warnings. 32 | 7. Run the script. This should trigger the OAuth2 authentication process and prompt you to allow the app you created in step 5 to access your calendars. If successful it should store the credentials in `ical_to_gcal_sync_credentials.json`. 33 | 8. Subsequent runs of the script should not require any further interaction unless the credentials are invalidated/changed. 34 | 35 | ## OAuth workarounds 36 | 37 | If you're running the script on a headless device you may have some issues with step 7 above. It normally attempts to auto-open a browser to allow you to authorise the request, and will print a URL to visit instead if it can't find a browser/display. You can visit this URL from another device, but the final step in the auth flow is an HTTP request sent back to the server started by `auth.py`. This will fail when you're using another device because the URL it redirects your browser to will be `http://localhost:port/...`. To workaround this there are a few options: 38 | - clone the repo to another machine, run the auth flow there, and then copy the saved credentials file to the headless device 39 | - copy the `http://localhost:port/...` URL and then in an SSH session on the headless device run `curl ` 40 | - alternatively if your headless device has open ports, you can modify the `run_local_server()` line in auth.py to have the Google redirect point to a hostname other than `localhost`. See the `host` and `port` parameters in the [documentation](https://google-auth-oauthlib.readthedocs.io/en/latest/reference/google_auth_oauthlib.flow.html#google_auth_oauthlib.flow.InstalledAppFlow.run_local_server) 41 | 42 | ## Multiple Configurations / Alternate Config Location 43 | 44 | If you want to specify an alternate location for the config.py file, use the environment variable CONFIG_PATH: 45 | 46 | ```bash 47 | CONFIG_PATH='/path/to/my-custom-config.py' python ical_to_gcal_sync.py 48 | ``` 49 | 50 | ## Rewriting Events / Skipping Events 51 | 52 | If you specify a function in the config file called EVENT_PREPROESSOR, you can use that 53 | function to rewrite or even skip events from being synced to the Google Calendar. 54 | 55 | Some example rewrite rules: 56 | 57 | ```python 58 | import icalevents 59 | def EVENT_PREPROCESSOR(ev: icalevents.icalparser.Event) -> bool: 60 | from datetime import timedelta 61 | 62 | # Skip Bob's out of office messages 63 | if ev.summary == "Bob OOO": 64 | return False 65 | 66 | # Skip gaming events when we're playing Monopoly 67 | if ev.summary == "Gaming" and "Monopoly" in ev.description: 68 | return False 69 | 70 | # convert fire drill events to all-day events 71 | if ev.summary == "Fire Drill": 72 | ev.all_day = True 73 | ev.start = ev.start.replace(hour=0, minute=0, second=0) 74 | ev.end = ev.start + timedelta(days=1) 75 | 76 | # include all other entries 77 | return True 78 | ``` 79 | -------------------------------------------------------------------------------- /ical_to_gcal_sync.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import logging 4 | import time 5 | import string 6 | import re 7 | import sys 8 | import os 9 | 10 | import googleapiclient 11 | import arrow 12 | from googleapiclient import errors 13 | from icalevents.icalevents import events 14 | from dateutil.tz import gettz 15 | 16 | from datetime import datetime, timezone, timedelta 17 | 18 | from auth import auth_with_calendar_api 19 | from pathlib import Path 20 | import urllib3 21 | from urllib3.exceptions import InsecureRequestWarning 22 | 23 | config = {} 24 | config_path=os.environ.get('CONFIG_PATH', 'config.py') 25 | exec(Path(config_path).read_text(), config) 26 | 27 | logger = logging.getLogger(__name__) 28 | logger.setLevel(logging.DEBUG) 29 | if config.get('LOGFILE', None): 30 | handler = logging.FileHandler(filename=config['LOGFILE'], mode='a') 31 | else: 32 | handler = logging.StreamHandler(sys.stderr) 33 | handler.setFormatter(logging.Formatter('%(asctime)s|[%(levelname)s] %(message)s')) 34 | logger.addHandler(handler) 35 | 36 | DEFAULT_TIMEDELTA = timedelta(days=365) 37 | 38 | def get_current_events_from_files(path): 39 | 40 | """Retrieves data from iCal files. Assumes that the files are all 41 | *.ics files located in a single directory. 42 | 43 | Returns the parsed list of events or None if an error occurs. 44 | """ 45 | 46 | from glob import glob 47 | from os.path import join 48 | 49 | event_ics = glob(join(path, '*.ics')) 50 | 51 | logger.debug('> Found {} local .ics files in {}'.format(len(event_ics), join(path, '*.ics'))) 52 | if len(event_ics) > 0: 53 | ics = event_ics[0] 54 | logger.debug('> Loading file {}'.format(ics)) 55 | cal = get_current_events(feed_url_or_path=ics, files=True) 56 | logger.debug('> Found {} new events'.format(len(cal))) 57 | for ics in event_ics[1:]: 58 | logger.debug('> Loading file {}'.format(ics)) 59 | evt = get_current_events(feed_url_or_path=ics, files=True) 60 | if len(evt) > 0: 61 | cal.extend(evt) 62 | logger.debug('> Found {} new events'.format(len(evt))) 63 | return cal 64 | else: 65 | return None 66 | 67 | def get_current_events(feed_url_or_path, files): 68 | """Retrieves data from an iCal feed and returns a list of icalevents 69 | Event objects 70 | 71 | Returns the parsed list of events or None if an error occurs. 72 | """ 73 | 74 | events_end = datetime.now() 75 | if config.get('ICAL_DAYS_TO_SYNC', 0) == 0: 76 | # default to 1 year ahead 77 | events_end += DEFAULT_TIMEDELTA 78 | else: 79 | # add on a number of days 80 | events_end += timedelta(days=config['ICAL_DAYS_TO_SYNC']) 81 | 82 | try: 83 | if files: 84 | cal = events(file=feed_url_or_path, end=events_end) 85 | else: 86 | headers = {} 87 | if config.get('ICAL_FEED_USER') and config.get('ICAL_FEED_PASS'): 88 | headers = urllib3.make_headers(basic_auth='{}:{}'.format(config.get('ICAL_FEED_USER'), config.get('ICAL_FEED_PASS'))) 89 | 90 | if not config.get('ICAL_FEED_VERIFY_SSL_CERT', True): 91 | urllib3.disable_warnings(InsecureRequestWarning) 92 | http = urllib3.PoolManager(cert_reqs='CERT_NONE', headers=headers) 93 | else: 94 | http = urllib3.PoolManager(headers=headers) 95 | 96 | cal = events(feed_url_or_path, start=datetime.now()-timedelta(days=config.get('PAST_DAYS_TO_SYNC', 0)), end=events_end, http=http) 97 | except Exception as e: 98 | logger.error('> Error retrieving iCal data ({})'.format(e)) 99 | return None 100 | 101 | return cal 102 | 103 | def get_gcal_events(calendar_id, service, from_time=None): 104 | """Retrieves the current set of Google Calendar events from the selected 105 | user calendar. If from_time is not specified, includes events from all-time. 106 | 107 | Returns a dict containing the event(s) existing in the calendar. 108 | """ 109 | 110 | # The list() method returns a dict containing various metadata along with the actual calendar entries (if any). 111 | # It is not guaranteed to return all available events in a single call, and so may need called multiple times 112 | # until it indicates no more events are available, signalled by the absence of "nextPageToken" in the result dict 113 | 114 | logger.debug('Retrieving Google Calendar events') 115 | 116 | # make an initial call, if this returns all events we don't need to do anything else,,, 117 | events_result = service.events().list(calendarId=calendar_id, 118 | timeMin=from_time, 119 | singleEvents=True, 120 | orderBy='startTime', 121 | showDeleted=True).execute() 122 | 123 | events = events_result.get('items', []) 124 | # if nextPageToken is NOT in the dict, this should be everything 125 | if 'nextPageToken' not in events_result: 126 | logger.info('> Found {:d} upcoming events in Google Calendar (single page)'.format(len(events))) 127 | return events 128 | 129 | # otherwise keep calling the method, passing back the nextPageToken each time 130 | while 'nextPageToken' in events_result: 131 | token = events_result['nextPageToken'] 132 | events_result = service.events().list(calendarId=calendar_id, 133 | timeMin=from_time, 134 | pageToken=token, 135 | singleEvents=True, 136 | orderBy='startTime', 137 | showDeleted=True).execute() 138 | newevents = events_result.get('items', []) 139 | events.extend(newevents) 140 | logger.debug('> Found {:d} events on new page, {:d} total'.format(len(newevents), len(events))) 141 | 142 | logger.info('> Found {:d} upcoming events in Google Calendar (multi page)'.format(len(events))) 143 | return events 144 | 145 | def get_gcal_datetime(py_datetime, gcal_timezone): 146 | py_datetime = py_datetime.astimezone(gettz(gcal_timezone)) 147 | return {u'dateTime': py_datetime.strftime('%Y-%m-%dT%H:%M:%S%z'), 'timeZone': gcal_timezone} 148 | 149 | def get_gcal_date(py_datetime): 150 | return {u'date': py_datetime.strftime('%Y-%m-%d')} 151 | 152 | def create_id(uid, begintime, endtime, prefix=''): 153 | """ Converts ical UUID, begin and endtime to a valid Gcal ID 154 | 155 | Characters allowed in the ID are those used in base32hex encoding, i.e. lowercase letters a-v and digits 0-9, see section 3.1.2 in RFC2938 156 | Te length of the ID must be between 5 and 1024 characters 157 | https://developers.google.com/resources/api-libraries/documentation/calendar/v3/python/latest/calendar_v3.events.html 158 | 159 | Returns: 160 | ID 161 | """ 162 | allowed_chars = string.ascii_lowercase[:22] + string.digits 163 | return prefix + re.sub('[^{}]'.format(allowed_chars), '', uid.lower()) + str(arrow.get(begintime).int_timestamp) + str(arrow.get(endtime).int_timestamp) 164 | 165 | if __name__ == '__main__': 166 | mandatory_configs = ['CREDENTIAL_PATH', 'ICAL_FEEDS', 'APPLICATION_NAME'] 167 | for mandatory in mandatory_configs: 168 | if not config.get(mandatory) or config[mandatory][0] == '<': 169 | logger.error("Must specify a non-blank value for %s in the config file" % mandatory) 170 | sys.exit(1) 171 | 172 | # setting up Google Calendar API for use 173 | logger.debug('> Loading credentials') 174 | service = auth_with_calendar_api(config) 175 | 176 | # dateime instance representing the start of the current day (UTC) 177 | today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) 178 | 179 | for feed in config.get('ICAL_FEEDS'): 180 | logger.info('> Processing source %s' % feed['source']) 181 | 182 | # retrieve events from Google Calendar, starting from beginning of current day 183 | logger.info('> Retrieving events from Google Calendar') 184 | gcal_events = get_gcal_events(calendar_id=feed['destination'], service=service, from_time=(today-timedelta(days=config.get('PAST_DAYS_TO_SYNC', 0))).isoformat()) 185 | 186 | # retrieve events from the iCal feed 187 | if feed['files']: 188 | logger.info('> Retrieving events from local folder') 189 | ical_cal = get_current_events_from_files(feed['source']) 190 | else: 191 | logger.info('> Retrieving events from iCal feed') 192 | ical_cal = get_current_events(feed_url_or_path=feed['source'], files=False) 193 | 194 | if ical_cal is None: 195 | sys.exit(-1) 196 | 197 | # convert iCal event list into a dict indexed by (converted) iCal UID 198 | ical_events = {} 199 | 200 | for ev in ical_cal: 201 | # explicitly set any events with no timezone to use UTC (icalevents 202 | # doesn't seem to do this automatically like ics.py) 203 | if ev.start.tzinfo is None: 204 | ev.start = ev.start.replace(tzinfo=timezone.utc) 205 | if ev.end is not None and ev.end.tzinfo is None: 206 | ev.end = ev.end.replace(tzinfo=timezone.utc) 207 | 208 | try: 209 | if 'EVENT_PREPROCESSOR' in config: 210 | keep = config['EVENT_PREPROCESSOR'](ev) 211 | if not keep: 212 | logger.debug("Skipping event %s - EVENT_PREPROCESSOR returned false" % (str(ev))) 213 | continue 214 | 215 | except Exception as ex: 216 | logger.error("Error processing entry (%s, %s) - leaving as-is" % (str(ev), str(ex))) 217 | 218 | ical_events[create_id(ev.uid, ev.start, ev.end, config.get('EVENT_ID_PREFIX', ''))] = ev 219 | 220 | logger.debug('> Collected {:d} iCal events'.format(len(ical_events))) 221 | 222 | # retrieve the Google Calendar object itself 223 | gcal_cal = service.calendars().get(calendarId=feed['destination']).execute() 224 | 225 | logger.info('> Processing Google Calendar events...') 226 | gcal_event_ids = [ev['id'] for ev in gcal_events] 227 | 228 | # first check the set of Google Calendar events against the list of iCal 229 | # events. Any events in Google Calendar that are no longer in iCal feed 230 | # get deleted. Any events still present but with changed start/end times 231 | # get updated. 232 | for gcal_event in gcal_events: 233 | eid = gcal_event['id'] 234 | 235 | if eid not in ical_events: 236 | # if a gcal event has been deleted from iCal, also delete it from gcal. 237 | # Apparently calling delete() only marks an event as "deleted" but doesn't 238 | # remove it from the calendar, so it will continue to stick around. 239 | # If you keep seeing messages about events being deleted here, you can 240 | # try going to the Google Calendar site, opening the options menu for 241 | # your calendar, selecting "View bin" and then clicking "Empty bin 242 | # now" to completely delete these events. 243 | try: 244 | # already marked as deleted, so it's in the "trash" or "bin" 245 | if gcal_event['status'] == 'cancelled': continue 246 | 247 | logger.info(u'> Deleting event "{}" from Google Calendar...'.format(gcal_event.get('summary', ''))) 248 | service.events().delete(calendarId=feed['destination'], eventId=eid).execute() 249 | time.sleep(config['API_SLEEP_TIME']) 250 | except googleapiclient.errors.HttpError: 251 | pass # event already marked as deleted 252 | else: 253 | ical_event = ical_events[eid] 254 | gcal_begin = arrow.get(gcal_event['start'].get('dateTime', gcal_event['start'].get('date'))) 255 | gcal_end = arrow.get(gcal_event['end'].get('dateTime', gcal_event['end'].get('date'))) 256 | 257 | gcal_has_location = bool(gcal_event.get('location')) 258 | ical_has_location = bool(ical_event.location) 259 | 260 | gcal_has_description = 'description' in gcal_event 261 | ical_has_description = ical_event.description is not None 262 | 263 | # event name can be left unset, in which case there's no summary field 264 | gcal_name = gcal_event.get('summary', None) 265 | log_name = '' if gcal_name is None else gcal_name 266 | 267 | times_differ = gcal_begin != ical_event.start or gcal_end != ical_event.end 268 | titles_differ = gcal_name != ical_event.summary 269 | locs_differ = gcal_has_location != ical_has_location and gcal_event.get('location') != ical_event.location 270 | descs_differ = gcal_has_description != ical_has_description and (gcal_event.get('description') != ical_event.description) 271 | 272 | needs_undelete = config.get('RESTORE_DELETED_EVENTS', False) and gcal_event['status'] == 'cancelled' 273 | 274 | changes = [] 275 | if times_differ: changes.append("start/end times") 276 | if titles_differ: changes.append("titles") 277 | if locs_differ: changes.append("locations") 278 | if descs_differ: changes.append("descriptions") 279 | if needs_undelete: changes.append("undeleted") 280 | 281 | # check if the iCal event has a different: start/end time, name, location, 282 | # or description, and if so sync the changes to the GCal event 283 | if needs_undelete or times_differ or titles_differ or locs_differ or descs_differ: 284 | logger.info(u'> Updating event "{}" due to changes: {}'.format(log_name, ", ".join(changes))) 285 | delta = ical_event.end - ical_event.start 286 | # all-day events handled slightly differently 287 | # TODO multi-day events? 288 | if delta.days >= 1: 289 | gcal_event['start'] = get_gcal_date(ical_event.start) 290 | gcal_event['end'] = get_gcal_date(ical_event.end) 291 | else: 292 | gcal_event['start'] = get_gcal_datetime(ical_event.start, gcal_cal['timeZone']) 293 | if ical_event.end is not None: 294 | gcal_event['end'] = get_gcal_datetime(ical_event.end, gcal_cal['timeZone']) 295 | 296 | # if the event was deleted, the status will be 'cancelled' - this restores it 297 | gcal_event['status'] = 'confirmed' 298 | 299 | gcal_event['summary'] = ical_event.summary 300 | gcal_event['description'] = ical_event.description 301 | if feed['files']: 302 | url_feed = 'https://events.from.ics.files.com' 303 | else: 304 | url_feed = feed['source'] 305 | gcal_event['source'] = {'title': 'Imported from ical_to_gcal_sync.py', 'url': url_feed} 306 | gcal_event['location'] = ical_event.location 307 | 308 | service.events().update(calendarId=feed['destination'], eventId=eid, body=gcal_event).execute() 309 | time.sleep(config['API_SLEEP_TIME']) 310 | 311 | # now add any iCal events not already in the Google Calendar 312 | logger.info('> Processing iCal events...') 313 | for ical_id, ical_event in ical_events.items(): 314 | if ical_id not in gcal_event_ids: 315 | gcal_event = {} 316 | gcal_event['summary'] = ical_event.summary 317 | gcal_event['id'] = ical_id 318 | gcal_event['description'] = ical_event.description 319 | if feed['files']: 320 | url_feed = 'https://events.from.ics.files.com' 321 | else: 322 | url_feed = feed['source'] 323 | gcal_event['source'] = {'title': 'Imported from ical_to_gcal_sync.py', 'url': url_feed} 324 | gcal_event['location'] = ical_event.location 325 | 326 | # check if no time specified in iCal, treat as all day event if so 327 | delta = ical_event.end - ical_event.start 328 | # TODO multi-day events? 329 | if delta.days >= 1: 330 | gcal_event['start'] = get_gcal_date(ical_event.start) 331 | logger.info(u'iCal all-day event {} to be added at {}'.format(ical_event.summary, ical_event.start)) 332 | if ical_event.end is not None: 333 | gcal_event['end'] = get_gcal_date(ical_event.end) 334 | else: 335 | gcal_event['start'] = get_gcal_datetime(ical_event.start, gcal_cal['timeZone']) 336 | logger.info(u'iCal event {} to be added at {}'.format(ical_event.summary, ical_event.start)) 337 | if ical_event.end is not None: 338 | gcal_event['end'] = get_gcal_datetime(ical_event.end, gcal_cal['timeZone']) 339 | 340 | logger.info('Adding iCal event called "{}", starting {}'.format(ical_event.summary, gcal_event['start'])) 341 | 342 | try: 343 | time.sleep(config['API_SLEEP_TIME']) 344 | service.events().insert(calendarId=feed['destination'], body=gcal_event).execute() 345 | except Exception as ei: 346 | logger.warning("Error inserting: %s (%s)" % (gcal_event['id'], str(ei))) 347 | time.sleep(config['API_SLEEP_TIME']) 348 | try: 349 | service.events().update(calendarId=feed['destination'], eventId=gcal_event['id'], body=gcal_event).execute() 350 | except Exception as ex: 351 | logger.error("Error updating: %s (%s)" % (gcal_event['id'], ex)) 352 | 353 | logger.info('> Processing of source %s completed' % feed['source']) 354 | --------------------------------------------------------------------------------