├── images ├── headerimage.jpg └── patreon_button.svg ├── notioncalendarsync ├── __init__.py ├── notion_api.py └── google_calendar.py ├── .github └── FUNDING.yml ├── setup.py ├── README.md ├── requirements.txt ├── LICENSE └── .gitignore /images/headerimage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glowingkitty/Notion-Calendar-Sync/HEAD/images/headerimage.jpg -------------------------------------------------------------------------------- /notioncalendarsync/__init__.py: -------------------------------------------------------------------------------- 1 | from .google_calendar import GoogleCalendar 2 | from .notion_api import Notion 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: glowingkitty # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: marcoEDU # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="notioncalendarsync", # Replace with your own username 8 | version="1.0.8", 9 | author="Marco", 10 | author_email=None, 11 | description="The unofficial Notion API extended with the option to sync your Notion events to Google Calendar.", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/glowingkitty/Notion-Calendar-Sync", 15 | packages=setuptools.find_packages(), 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | ], 21 | python_requires='>=3.6', 22 | install_requires=[ 23 | 'notion', 24 | 'google-api-python-client', 25 | 'google-auth-httplib2', 26 | 'google-auth-oauthlib' 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Notion-Calendar-Sync](https://raw.githubusercontent.com/marcoEDU/Notion-Calendar-Sync/master/images/headerimage.jpg "Notion-Calendar-Sync") 2 | 3 | The unofficial Notion API extended with the option to sync your Notion events to Google Calendar. 4 | 5 | Want to support the development and stay updated? 6 | 7 | Become a Patreon Donate using Liberapay 8 | 9 | 10 | ## Installation 11 | 12 | ``` 13 | pip install notioncalendarsync 14 | ``` 15 | 16 | ## Usage 17 | 18 | YOUR NOTION TOKEN: 19 | 20 | Obtain the `token_v2` value by inspecting your browser cookies on a logged-in session on Notion.so 21 | 22 | ``` 23 | from notioncalendarsync import Notion 24 | 25 | Notion(token_v2="{{ YOUR NOTION TOKEN }}").add_events_to_google_calendar('{{ NOTION COLLECTION URL }}',timezone='Europe/Berlin') 26 | ``` -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appnope==0.1.0 2 | autopep8==1.5.2 3 | backcall==0.1.0 4 | beautifulsoup4==4.9.0 5 | bs4==0.0.1 6 | cached-property==1.5.1 7 | cachetools==4.1.0 8 | certifi==2020.4.5.1 9 | chardet==3.0.4 10 | commonmark==0.9.1 11 | decorator==4.4.2 12 | dictdiffer==0.8.1 13 | google-api-core==1.17.0 14 | google-api-python-client==1.8.2 15 | google-auth==1.14.2 16 | google-auth-httplib2==0.0.3 17 | google-auth-oauthlib==0.4.1 18 | googleapis-common-protos==1.51.0 19 | httplib2==0.17.3 20 | idna==2.9 21 | ipython==7.14.0 22 | ipython-genutils==0.2.0 23 | jedi==0.17.0 24 | notion==0.0.25 25 | oauthlib==3.1.0 26 | parso==0.7.0 27 | pexpect==4.8.0 28 | pickleshare==0.7.5 29 | prompt-toolkit==3.0.5 30 | protobuf==3.11.3 31 | ptyprocess==0.6.0 32 | pyasn1==0.4.8 33 | pyasn1-modules==0.2.8 34 | pycodestyle==2.5.0 35 | Pygments==2.6.1 36 | python-slugify==4.0.0 37 | pytz==2020.1 38 | requests==2.23.0 39 | requests-oauthlib==1.3.0 40 | rsa==4.0 41 | six==1.14.0 42 | soupsieve==2.0 43 | text-unidecode==1.3 44 | traitlets==4.3.3 45 | tzlocal==2.1 46 | uritemplate==3.0.1 47 | urllib3==1.25.9 48 | wcwidth==0.1.9 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Marco 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 | -------------------------------------------------------------------------------- /notioncalendarsync/notion_api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from notion.client import NotionClient 4 | 5 | from notioncalendarsync.google_calendar import GoogleCalendar 6 | 7 | 8 | class Notion(NotionClient): 9 | def events(self, collection_url): 10 | return [NotionCalendarEntry(page=event).details for event in self.get_block(collection_url).collection.get_rows() if event.date and event.status != 'Completed'] 11 | 12 | def add_events_to_google_calendar(self, collection_url, timezone): 13 | events = self.events(collection_url) 14 | for event in events: 15 | GoogleCalendar().create_or_update_event( 16 | title=event['title'], 17 | date_start=event['date_start'], 18 | date_end=event['date_end'], 19 | timezone=timezone, 20 | description=event['description'] 21 | ) 22 | 23 | 24 | class NotionCalendarEntry(): 25 | def __init__(self, token_v2=None, page_url=None, page=None): 26 | self.page = page if page else NotionClient( 27 | token_v2=token_v2).get_block(page_url) 28 | 29 | self.date_start = self.page.date.start 30 | self.details = { 31 | 'url': self.url, 32 | 'title': self.page.title, 33 | 'description': self.description, 34 | 'date_start': self.date_start, 35 | 'date_end': self.date_end 36 | } 37 | 38 | @property 39 | def date_end(self): 40 | if self.page.date.end: 41 | return self.page.date.end 42 | elif hasattr(self.page.date.start, 'hour'): 43 | return self.page.date.start+timedelta(hours=1) 44 | else: 45 | return self.page.date.start 46 | 47 | @property 48 | def url(self): 49 | return self.page.get_browseable_url() 50 | 51 | @property 52 | def description(self): 53 | try: 54 | return self.page.children[0].title 55 | except: 56 | return None 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | *.pickle 10 | credentials* 11 | pyvenv/ 12 | .vscode/ 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | -------------------------------------------------------------------------------- /notioncalendarsync/google_calendar.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | # If modifying these scopes, delete the file token.pickle. 4 | import os 5 | import os.path 6 | import pickle 7 | import sys 8 | from datetime import datetime, timedelta 9 | 10 | from google.auth.transport.requests import Request 11 | from google_auth_oauthlib.flow import InstalledAppFlow 12 | from googleapiclient.discovery import build 13 | 14 | dirname = os.path.dirname(__file__).replace('/notioncalendarsync', '') 15 | 16 | 17 | class GoogleCalendar(): 18 | def __init__(self): 19 | self.check_credentials() 20 | 21 | def check_credentials(self): 22 | self.scopes = ['https://www.googleapis.com/auth/calendar'] 23 | self.creds = None 24 | # The file token.pickle stores the user's access and refresh tokens, and is 25 | # created automatically when the authorization flow completes for the first 26 | # time. 27 | if os.path.exists(os.path.join(dirname, 'token.pickle')): 28 | with open(os.path.join(dirname, 'token.pickle'), 'rb') as token: 29 | self.creds = pickle.load(token) 30 | # If there are no (valid) credentials available, let the user log in. 31 | if not self.creds or not self.creds.valid: 32 | if self.creds and self.creds.expired and self.creds.refresh_token: 33 | self.creds.refresh(Request()) 34 | else: 35 | flow = InstalledAppFlow.from_client_secrets_file( 36 | os.path.join(dirname, 'credentials.json'), self.scopes) 37 | self.creds = flow.run_local_server(port=0) 38 | # Save the credentials for the next run 39 | with open(os.path.join(dirname, 'token.pickle'), 'wb') as token: 40 | pickle.dump(self.creds, token) 41 | 42 | self.service = build('calendar', 'v3', credentials=self.creds) 43 | 44 | def create_or_update_event(self, title, date_start, date_end, timezone, description=None): 45 | # check if event already exists 46 | existing_event = self.event_exists(title) 47 | if existing_event: 48 | # if yes, check if event is unchanged 49 | if self.event_unchanged(existing_event, title, date_start, date_end, 50 | timezone, description) == False: 51 | self.update_event(existing_event, title, date_start, date_end, 52 | timezone, description) 53 | 54 | else: 55 | # else create new event 56 | self.create_event(title, date_start, date_end, 57 | timezone, description) 58 | 59 | def update_event(self, existing_event, title, date_start, date_end, timezone, description=None): 60 | event_result = self.service.events().update(calendarId='primary', 61 | eventId=existing_event['id'], 62 | body={ 63 | "summary": title, 64 | "description": description, 65 | "start": {"dateTime": date_start.strftime('%Y-%m-%dT%H:%M:%SZ'), "timeZone": timezone} if hasattr(date_start, 'hour') else {'date': str(date_start)}, 66 | "end": {"dateTime": date_end.strftime('%Y-%m-%dT%H:%M:%SZ'), "timeZone": timezone} if hasattr(date_end, 'hour') else {'date': str(date_end)}, 67 | } 68 | ).execute() 69 | 70 | print("updated event") 71 | print("id: ", event_result['id']) 72 | print( 73 | "title: {} -> {}".format(existing_event['summary'], event_result['summary'])) 74 | print("starts at: {} -> {}".format( 75 | existing_event['start']['dateTime'] if 'dateTime' in existing_event['start'] else existing_event['start']['date'], event_result['start']['dateTime'] if 'dateTime' in event_result['start'] else event_result['start']['date'])) 76 | print("ends at: {} -> {}".format( 77 | existing_event['end']['dateTime'] if 'dateTime' in existing_event['end'] else existing_event['end']['date'], event_result['end']['dateTime'] if 'dateTime' in event_result['end'] else event_result['end']['date'])) 78 | print() 79 | 80 | def create_event(self, title, date_start, date_end, timezone, description=None): 81 | event_result = self.service.events().insert(calendarId='primary', 82 | body={ 83 | "summary": title, 84 | "description": description, 85 | "start": {"dateTime": date_start.strftime('%Y-%m-%dT%H:%M:%SZ'), "timeZone": timezone} if hasattr(date_start, 'hour') else {'date': str(date_start)}, 86 | "end": {"dateTime": date_end.strftime('%Y-%m-%dT%H:%M:%SZ'), "timeZone": timezone} if hasattr(date_end, 'hour') else {'date': str(date_end)}, 87 | } 88 | ).execute() 89 | 90 | print("created event") 91 | print("id: ", event_result['id']) 92 | print("title: ", event_result['summary']) 93 | print("starts at: ", event_result['start']['dateTime'] 94 | if 'dateTime' in event_result['start'] else event_result['start']['date']) 95 | print("ends at: ", event_result['end']['dateTime'] 96 | if 'dateTime' in event_result['end'] else event_result['end']['date']) 97 | print() 98 | 99 | def get_events(self, maxResults=200): 100 | now = (datetime.utcnow()-timedelta(days=30)).isoformat() + 'Z' 101 | events_result = self.service.events().list(calendarId='primary', timeMin=now, 102 | maxResults=maxResults, singleEvents=True, 103 | orderBy='startTime').execute() 104 | events = events_result.get('items', []) 105 | 106 | if not events: 107 | print('No upcoming events found.') 108 | return events 109 | 110 | def event_unchanged(self, existing_event, title, date_start, date_end, timezone, description): 111 | if existing_event['summary'] != title: 112 | return False 113 | 114 | if 'dateTime' in existing_event['start']: 115 | if existing_event['start']['dateTime'] != date_start.strftime('%Y-%m-%dT%H:%M:%SZ'): 116 | return False 117 | else: 118 | if existing_event['start']['date'] != str(date_start): 119 | return False 120 | 121 | if 'dateTime' in existing_event['end']: 122 | if existing_event['end']['dateTime'] != date_end.strftime('%Y-%m-%dT%H:%M:%SZ'): 123 | return False 124 | else: 125 | if existing_event['end']['date'] != str(date_end): 126 | return False 127 | 128 | if 'timeZone' in existing_event['start'] and existing_event['start']['timeZone'] != timezone: 129 | return False 130 | 131 | if 'description' in existing_event and existing_event['description'] != description: 132 | return False 133 | 134 | print('Event unchanged') 135 | return True 136 | 137 | def event_exists(self, title): 138 | events = self.get_events() 139 | for event in events: 140 | if event['summary'] == title: 141 | return event 142 | else: 143 | return False 144 | -------------------------------------------------------------------------------- /images/patreon_button.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 29 | 31 | 52 | 55 | 62 | 68 | 74 | 80 | 86 | 87 | 88 | --------------------------------------------------------------------------------