├── 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 | 
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 |
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 |
88 |
--------------------------------------------------------------------------------