├── .gitignore ├── LICENSE ├── README.md ├── account_info_example.py ├── helper.py ├── requirements.txt └── ticktick-gcalendar.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.token-oauth 2 | /.idea/ 3 | /credentials_google.json 4 | /*.list 5 | /token.json 6 | /venv/ 7 | /account_info.py 8 | /bidict_ticktick_gcalendar.dict 9 | /data/ 10 | /logs/ 11 | /__pycache__/ 12 | /ticktick/ 13 | 14 | *.bk 15 | *.sh 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Vicente Balmaseda 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TickTick-GoogleCalendar-sync 2 | 3 | This script provides 2-way sync between TickTick and Google Calendar. 4 | 5 | To provide "continuous" 2-way sync, it can be programmed to run every few minutes using crontab or any other program that allows running Python scripts. 6 | 7 | ## Installation 8 | 9 | 1. Create conda environment 10 | ```bash 11 | conda create -n tick python=3.9.5 12 | conda activate tick 13 | ``` 14 | 15 | 2. Clone the repository 16 | ```bash 17 | git clone https://github.com/vibalcam/ticktick-gcalendar-py.git 18 | ``` 19 | 20 | 3. Install the required packages 21 | ```bash 22 | pip install -r requirements.txt 23 | ``` 24 | 25 | 4. Install [ticktick_py fork](https://github.com/vibalcam/ticktick_py) (i.e., clone and install requirements). This fork solves a minor unresolved issue in the [original repository](https://github.com/lazeroffmichael/ticktick-py) (as of April 13, 2024) 26 | ```bash 27 | git clone https://github.com/vibalcam/ticktick_py.git 28 | pip install -r ticktick_py/requirements.txt 29 | ``` 30 | 31 | 5. Meet requirements (see below) 32 | 33 | ## Requirements 34 | 35 | Meet the following requirements: 36 | - ticktick-py: https://github.com/vibalcam/ticktick_py 37 | - google calendar python: https://developers.google.com/tasks/quickstart/python 38 | - Set up info in account_info.py (you can use account_info_example.py as template and info from -p option to get TickTick calendar ids) 39 | 40 | ## Usage 41 | 42 | 1. Check TickTick lists' ids 43 | ```bash 44 | python ticktick-gcalendar.py -p 45 | ``` 46 | 2. Set up account_info.py 47 | 3. Renew tokens 48 | ```bash 49 | python ticktick-gcalendar.py -r 50 | ``` 51 | 4. Sync Google Calendar and TickTick 52 | ```bash 53 | python ticktick-gcalendar.py 54 | ``` 55 | 56 | After syncing, the program uses a few files to save the current state of synchronization. 57 | If these files are removed, the script will perform a full synchronization the next time it is run. 58 | 59 | ### Reset Synchronization 60 | 61 | To reset the synchronization, first remove all the synchronized events in Google Calendar. 62 | This can be done by running 63 | ```bash 64 | python ticktick-gcalendar.py --delete_all_gcal 65 | ``` 66 | 67 | ### Fix Errors 68 | 69 | It may happen that the script is not able to synchronize an event. 70 | To force synchronization of a specific event, we first need to remove it from memory and then sync again. 71 | The event ids can be obtained by looking at the error message after synchronizing. 72 | ```bash 73 | # to remove a google calendar event (-rg) 74 | python ticktick-gcalendar.py --remove_gcal 'gcal_event_id' 75 | # to remove a google calendar event (-rt) 76 | python ticktick-gcalendar.py --remove_tick 'tick_event_id' 77 | # sync to force synchronize the removed events 78 | python ticktick-gcalendar.py 79 | ``` 80 | 81 | ## Features 82 | 83 | It uses the package ticktick-py and Google Calendar for python to sync between Ticktick and Google Calendar. 84 | 85 | It is set up so the sync occurs in the following way: 86 | - It syncs the changes in the selected lists of Ticktick to a specific calendar in Google Calendar 87 | - It syncs the selected calendars in Google Calendar to a specific calendar in Ticktick 88 | 89 | ## Warnings 90 | 91 | - Tested on python 3.9.5 92 | - Inbox is excluded by default (to change this look at ticktick-gcalendar.py in TickTickApi.Task __init__ method, line 305) 93 | - You might need to run the renew option, which regenerates the Google API token, every few weeks due to Google Calendar not accepting the old API token 94 | - Recurrent events: 95 | - Google recurrent events have not been tested (thus might result in unexpected behaviour) 96 | - For TickTick recurrent events, only the next event is added to Google Calendar 97 | 98 | ## Packages 99 | 100 | - ticktick-py (https://github.com/lazeroffmichael/ticktick-py) 101 | - Google calendar for python (https://developers.google.com/tasks/quickstart/python) 102 | -------------------------------------------------------------------------------- /account_info_example.py: -------------------------------------------------------------------------------- 1 | # actual information should be placed in a file named account_info.py 2 | 3 | GOOGLE = { 4 | 'TOKEN_FILENAME': 'token.json', 5 | 'SCOPES': ['https://www.googleapis.com/auth/calendar.events'], 6 | 'credentials': 'credentials_google.json', 7 | } 8 | 9 | GOOGLE_INFO = { 10 | 'old_filename': 'google_calendar.list', 11 | # TODO MUST CHANGE TO ACTUAL VALUES 12 | # google calendars that WILL be synced 13 | 'calendar_ids': ['fasdfadfafdsa@group.calendar.google.com'], # calendar id, 14 | # TODO MUST CHANGE TO ACTUAL VALUES 15 | # google calendar where events added in ticktick will be added 16 | 'default_project_id': 'fdsafasdffas@group.calendar.google.com', 17 | } 18 | 19 | # TODO COMPLETE WITH API LOGIN INFORMATION 20 | TICKTICK = { 21 | 'CLIENT_ID': '', 22 | 'CLIENT_SECRET': '', 23 | 'REDIRECT_URI': 'http://127.0.0.1:8080', # change if different redirect ui 24 | 'TOKEN_FILENAME': '.token-oauth', 25 | 'USERNAME': '', 26 | 'PWD': '', 27 | } 28 | 29 | TICKTICK_INFO = { 30 | 'old_filename': 'tick_tasks.list', 31 | # TODO MUST CHANGE TO ACTUAL VALUES 32 | # projects that WILL NOT be synced 33 | 'EXCLUDED_PROJECTS': ['4fsd5a64fa65sd4f'], # excluded projects ids 34 | # TODO MUST CHANGE TO ACTUAL VALUES 35 | # ticktick project where events added in google will be added 36 | 'default_project_id': '4f56dsa4f65as4df65a', 37 | } 38 | -------------------------------------------------------------------------------- /helper.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from ast import literal_eval 3 | from os import path 4 | from typing import List 5 | 6 | 7 | def load_dict_from_file(file_name: str): 8 | loaded = None 9 | if path.isfile(file_name): 10 | # with open(file_name, 'r') as tasks_file: 11 | # loaded = dict(literal_eval(tasks_file.read())) 12 | with open(file_name, 'rb') as tasks_file: 13 | loaded = pickle.load(tasks_file) 14 | return loaded 15 | 16 | 17 | def save_dict_to_file(file_name: str, obj: dict): 18 | # with open(file_name, 'w') as tasks_file: 19 | # tasks_file.write(str(obj)) 20 | with open(file_name, 'wb') as tasks_file: 21 | pickle.dump(obj, tasks_file) 22 | 23 | 24 | class BiDict(dict): 25 | def __init__(self, *args, **kwargs): 26 | super(BiDict, self).__init__(*args, **kwargs) 27 | self.inverse = {} 28 | for key, value in self.items(): 29 | self.inverse.setdefault(value, []).append(key) 30 | 31 | def __setitem__(self, key, value): 32 | if key in self: 33 | self.inverse[self[key]].remove(key) 34 | super(BiDict, self).__setitem__(key, value) 35 | self.inverse.setdefault(value, []).append(key) 36 | 37 | def __delitem__(self, key): 38 | value = self[key] 39 | self.inverse.setdefault(value, []).remove(key) 40 | if value in self.inverse and not self.inverse[value]: 41 | del self.inverse[value] 42 | super(BiDict, self).__delitem__(key) 43 | 44 | def get_inverse(self, value) -> List: 45 | return self.inverse[value] 46 | 47 | def save(self, file_name: str): 48 | with open(file_name, 'w') as tasks_file: 49 | tasks_file.write(str(self)) 50 | # with open(file_name, 'wb') as tasks_file: 51 | # pickle.dump(dict(self), tasks_file) 52 | 53 | @staticmethod 54 | def load(file_name: str): 55 | if path.isfile(file_name): 56 | with open(file_name, 'r') as tasks_file: 57 | loaded = BiDict(literal_eval(tasks_file.read())) 58 | # with open(file_name, 'rb') as tasks_file: 59 | # loaded = BiDict(pickle.load(tasks_file)) 60 | else: 61 | raise Exception(f"{file_name} does not exist") 62 | return loaded 63 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytz 2 | # ticktick-py 3 | google-api-python-client 4 | google-auth-httplib2 5 | google-auth-oauthlib 6 | -------------------------------------------------------------------------------- /ticktick-gcalendar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | from abc import abstractmethod, ABC 5 | from datetime import datetime, date, timedelta 6 | from os import path 7 | from typing import Dict, List, Union, Tuple, Optional 8 | 9 | import pytz 10 | from google.auth.transport.requests import Request 11 | from google.oauth2.credentials import Credentials 12 | from google_auth_oauthlib.flow import InstalledAppFlow 13 | from googleapiclient.discovery import build 14 | 15 | from account_info import GOOGLE, GOOGLE_INFO, TICKTICK, TICKTICK_INFO # account information 16 | from helper import load_dict_from_file, save_dict_to_file, BiDict 17 | from ticktick_py.ticktick.api import TickTickClient # Main Interface 18 | from ticktick_py.ticktick.oauth2 import OAuth2 # OAuth2 Manager 19 | 20 | DEBUG = False 21 | 22 | 23 | def do_on_exception(e: Exception): 24 | if DEBUG: 25 | raise e 26 | else: 27 | print("ErrorUnexpected:") 28 | print(e) 29 | 30 | 31 | def gcalendar_get_datetime(event_time: Dict) -> Tuple[datetime, bool]: 32 | if 'dateTime' in event_time: 33 | return datetime.fromisoformat(event_time['dateTime']), False 34 | elif 'date' in event_time: 35 | return datetime.strptime(event_time['date'], '%Y-%m-%d').replace(tzinfo=pytz.UTC), True 36 | else: 37 | raise Exception('event date does not contain date nor dateTime') 38 | 39 | 40 | def date_to_gcalendar(d: Union[date, datetime]): 41 | if d.__class__ == date: 42 | return d.isoformat() 43 | else: 44 | return d.strftime('%Y-%m-%dT%H:%M:%SZ') 45 | 46 | 47 | def ticktick_get_datetime(event_time: Dict, start: bool) -> Tuple[datetime, bool]: 48 | key = 'startDate' if start else 'dueDate' 49 | if 'startDate' in event_time: 50 | return pytz.timezone(event_time['timeZone']).localize(datetime.fromisoformat(event_time[key][:-5])), \ 51 | event_time.get('isAllDay', False) 52 | else: 53 | raise Exception(f"event date does not contain {key}") 54 | 55 | 56 | def get_timezone_name(d: datetime): 57 | return {tz.zone for tz in map(pytz.timezone, pytz.all_timezones_set) if 58 | d.astimezone(tz).utcoffset() == d.utcoffset()}.pop() 59 | 60 | 61 | class Api(ABC): 62 | class Task(dict): 63 | def __init__(self, task: Dict, properties: List[str] = None): 64 | super().__init__(task) 65 | self.properties = properties 66 | self._simplified = None 67 | 68 | def __setitem__(self, key, value): 69 | super().__setitem__(key, value) 70 | self._simplified = None 71 | 72 | @property 73 | def simplified(self) -> dict: 74 | if self._simplified is None: 75 | self._simplified = {k: self.get(k, None) for k in self.properties} 76 | return self._simplified 77 | 78 | @property 79 | @abstractmethod 80 | def title(self) -> str: 81 | return "" 82 | 83 | @abstractmethod 84 | def __hash__(self): 85 | pass 86 | 87 | def __eq__(self, other): 88 | if not isinstance(other, self.__class__): 89 | return False 90 | return self.simplified == other.simplified if self.get_update_compare() else hash(self) == hash(other) 91 | 92 | @staticmethod 93 | @abstractmethod 94 | def set_update_compare(compare: bool): 95 | pass 96 | 97 | @staticmethod 98 | @abstractmethod 99 | def get_update_compare() -> bool: 100 | pass 101 | 102 | def __init__(self): 103 | self.old_tasks = None 104 | pass 105 | 106 | @abstractmethod 107 | def get_client(self): 108 | pass 109 | 110 | @abstractmethod 111 | def get_tasks(self) -> Dict[str, Task]: 112 | """ Get task and get old task must return the same type""" 113 | pass 114 | 115 | def get_old_tasks(self, file_name: str = None) -> Dict[str, Task]: 116 | """ Get task and get old task must return the same type""" 117 | if self.old_tasks is None: 118 | self.old_tasks = load_dict_from_file(file_name) 119 | if self.old_tasks is None: 120 | self.old_tasks = {} 121 | return self.old_tasks 122 | 123 | def change_tasks(self, task: Optional[Task], delete: bool = False, delete_id: str = None): 124 | if delete: 125 | task_id = task['id'] if delete_id is None else delete_id 126 | self.get_tasks().pop(task_id, None) 127 | self.get_old_tasks().pop(task_id, None) 128 | else: 129 | self.get_tasks()[task['id']] = task 130 | self.get_old_tasks()[task['id']] = task 131 | 132 | def save_old_tasks(self, file_name: str): 133 | save_dict_to_file(file_name, self.get_old_tasks()) 134 | 135 | 136 | class GCalendarApi(Api): 137 | PROPERTIES = [ 138 | "id", 139 | "summary", 140 | "description", 141 | "start", 142 | "end", 143 | ] 144 | 145 | class Task(Api.Task): 146 | UPDATE_COMPARE = True 147 | 148 | @staticmethod 149 | def set_update_compare(compare: bool): 150 | GCalendarApi.Task.UPDATE_COMPARE = compare 151 | 152 | @staticmethod 153 | def get_update_compare() -> bool: 154 | return GCalendarApi.Task.UPDATE_COMPARE 155 | 156 | def __init__(self, task: Dict, properties: List[str] = None): 157 | if properties is None: 158 | properties = GCalendarApi.PROPERTIES 159 | super().__init__(task, properties) 160 | 161 | @property 162 | def title(self) -> str: 163 | return self.get('summary', "") 164 | 165 | def __hash__(self): 166 | return hash(self['id']) 167 | 168 | def __init__(self, renew: bool = False, credentials=GOOGLE, info: Dict = GOOGLE_INFO): 169 | super(GCalendarApi, self).__init__() 170 | self.calendar_ids = info['calendar_ids'] 171 | self.default_calendar_id = info['default_project_id'] 172 | self.old_filename = info['old_filename'] 173 | creds = None 174 | if path.exists(credentials['TOKEN_FILENAME']): 175 | creds = Credentials.from_authorized_user_file(credentials['TOKEN_FILENAME'], credentials['SCOPES']) 176 | # If there are no (valid) credentials available, let the user log in. 177 | if not creds or not creds.valid: 178 | if renew: 179 | flow = InstalledAppFlow.from_client_secrets_file( 180 | credentials['credentials'], credentials['SCOPES']) 181 | creds = flow.run_local_server(port=0) 182 | elif creds and creds.expired and creds.refresh_token: 183 | creds.refresh(Request()) 184 | else: 185 | raise Exception("Renewal for google needed: add with renew true") 186 | # Save the credentials for the next run 187 | print(f"Saving Google renewed credentials as {credentials['TOKEN_FILENAME']}...") 188 | with open(credentials['TOKEN_FILENAME'], 'w') as token: 189 | token.write(creds.to_json()) 190 | 191 | self.service = build('calendar', 'v3', credentials=creds) 192 | 193 | self.events = {} 194 | for calendarId in self.calendar_ids: 195 | events_result = self.service.events().list(calendarId=calendarId, singleEvents=False).execute() 196 | self.events.update({k['id']: self.Task(k) for k in events_result.get('items', [])}) 197 | 198 | def get_client(self): 199 | return self.service.events() 200 | 201 | def get_tasks(self) -> Dict[str, Task]: 202 | return self.events 203 | 204 | def get_old_tasks(self, file_name: str = None) -> Dict[str, Task]: 205 | if file_name is None: 206 | file_name = self.old_filename 207 | return super().get_old_tasks(file_name) 208 | 209 | def save_old_tasks(self, file_name: str = None): 210 | if file_name is None: 211 | file_name = self.old_filename 212 | super().save_old_tasks(file_name) 213 | 214 | def build_event(self, summary: str, start: Union[date, datetime], end: Union[date, datetime], 215 | description: str = "", event=None): 216 | """ 217 | start and end use date for allday and datetime otherwise 218 | :return type same asn event type 219 | """ 220 | if event is None: 221 | event = {} 222 | # summary, description, end.date, end.dateTime, end.timeZone, recurrence 223 | event['summary'] = summary 224 | event['description'] = description 225 | event['start'] = {} 226 | event['end'] = {} 227 | if start.__class__ == date: 228 | event['start']['date'] = date_to_gcalendar(start) 229 | event['end']['date'] = date_to_gcalendar(end) 230 | else: 231 | event['start']['dateTime'] = date_to_gcalendar(start) 232 | event['start']['timeZone'] = get_timezone_name(start) 233 | event['end']['dateTime'] = date_to_gcalendar(end) 234 | event['end']['timeZone'] = get_timezone_name(end) 235 | return event 236 | 237 | def update(self, task: Dict, calendar_id: str = None): 238 | if calendar_id is None: 239 | calendar_id = self.default_calendar_id 240 | task = self.get_client().update(calendarId=calendar_id, eventId=task['id'], body=task).execute() 241 | self.change_tasks(task if isinstance(task, GCalendarApi.Task) else GCalendarApi.Task(task)) 242 | 243 | def insert(self, event: Dict, calendar_id: str = None) -> Task: 244 | if calendar_id is None: 245 | calendar_id = self.default_calendar_id 246 | added = self.Task(self.get_client().insert(calendarId=calendar_id, body=event).execute()) 247 | self.change_tasks(added) 248 | return added 249 | 250 | def delete(self, event_id: str, calendar_id: str = None): 251 | """If event is given, then the event_id is taken from there""" 252 | if calendar_id is None: 253 | calendar_id = self.default_calendar_id 254 | self.get_client().delete(calendarId=calendar_id, eventId=event_id).execute() 255 | self.change_tasks(None, delete=True, delete_id=event_id) 256 | 257 | 258 | class TickTickApi(Api): 259 | PROPERTIES = [ 260 | "id", 261 | "title", 262 | "isAllDay", 263 | "content", 264 | "desc", 265 | "dueDate", # Task due date time in "yyyy-MM-dd'T'HH:mm:ssZ" Example : "2019-11-13T03:00:00+0000" 266 | "repeat", # Recurring rules of task Example : "RRULE:FREQ=DAILY;INTERVAL=1" 267 | "startDate", # Start date time in "yyyy-MM-dd'T'HH:mm:ssZ" Example : "2019-11-13T03:00:00+0000" 268 | "status", # Task completion status Value : Normal: 0, Completed: 1 269 | "timeZone", 270 | ] 271 | 272 | class Task(Api.Task): 273 | UPDATE_COMPARE = True 274 | 275 | @staticmethod 276 | def set_update_compare(compare: bool): 277 | TickTickApi.Task.UPDATE_COMPARE = compare 278 | 279 | @staticmethod 280 | def get_update_compare() -> bool: 281 | return TickTickApi.Task.UPDATE_COMPARE 282 | 283 | def __init__(self, task: Dict, properties: List[str] = None): 284 | if properties is None: 285 | properties = TickTickApi.PROPERTIES 286 | super().__init__(task, properties) 287 | 288 | @property 289 | def title(self) -> str: 290 | return self.get('title', "") 291 | 292 | def __hash__(self): 293 | return hash(self['id']) 294 | 295 | def __init__(self, renew: bool = False, credentials=TICKTICK, info=TICKTICK_INFO): 296 | super(TickTickApi, self).__init__() 297 | if not (renew or path.isfile(credentials['TOKEN_FILENAME'])): 298 | raise Exception("Renew for ticktick needed: run with renew true") 299 | 300 | auth_client = OAuth2(client_id=credentials['CLIENT_ID'], 301 | client_secret=credentials['CLIENT_SECRET'], 302 | redirect_uri=credentials['REDIRECT_URI']) 303 | self.client = TickTickClient(credentials['USERNAME'], credentials['PWD'], auth_client) 304 | self.old_filename = info['old_filename'] 305 | self.default_project_id = info['default_project_id'] 306 | # change this to include all tasks and exclude tasks from projects if want to include inbox 307 | self.tasks = {} 308 | for project in self.client.state['projects']: 309 | if project['id'] not in info['EXCLUDED_PROJECTS']: 310 | self.tasks.update({k['id']: self.Task(k) for k in self.client.task.get_from_project(project['id'])}) 311 | # for project in self.client.state['projects']: 312 | # if project['id'] not in info['EXCLUDED_PROJECTS']: 313 | # tasks.extend(self.client.task.get_from_project(project['id'])) 314 | # self.tasks = {k['id']: self.Task(k) for k in tasks} 315 | 316 | def get_client(self): 317 | return self.client 318 | 319 | def get_tasks(self) -> Dict[str, Task]: 320 | return self.tasks 321 | 322 | def get_old_tasks(self, file_name: str = None) -> Dict[str, Task]: 323 | if file_name is None: 324 | file_name = self.old_filename 325 | return super().get_old_tasks(file_name) 326 | 327 | def save_old_tasks(self, file_name: str = None): 328 | if file_name is None: 329 | file_name = self.old_filename 330 | super().save_old_tasks(file_name) 331 | 332 | def build_task(self, title: str, content: str, start: datetime, end: datetime, all_day: bool, time_zone: str, 333 | task=None, project_id: str = None): 334 | if task is None: 335 | if project_id is None: 336 | project_id = self.default_project_id 337 | 338 | task = self.get_client().task.builder( 339 | title=title, 340 | content=content, 341 | projectId=project_id, 342 | allDay=all_day, 343 | startDate=start, 344 | dueDate=end, 345 | timeZone=time_zone 346 | ) 347 | else: 348 | task['title'] = title 349 | task['isAllDay'] = all_day 350 | task['content'] = content 351 | task['dueDate'] = end 352 | task['startDate'] = start 353 | task['timeZone'] = time_zone 354 | return task 355 | 356 | def update(self, task: Task): 357 | self.get_client().task.update(task) 358 | task = self.get_client().get_by_id(task['id'], search='tasks') 359 | self.change_tasks(TickTickApi.Task(task)) 360 | 361 | def insert(self, task: Task) -> Task: 362 | task_id = self.get_client().task.create(task)['id'] 363 | added = TickTickApi.Task(self.get_client().get_by_id(task_id, search='tasks')) 364 | self.change_tasks(added) 365 | return added 366 | 367 | def delete(self, task: Task): 368 | self.get_client().task.delete(task) 369 | self.change_tasks(task, delete=True) 370 | 371 | def complete(self, task: Task): 372 | self.get_client().task.complete(task) 373 | self.change_tasks(task, delete=True) 374 | 375 | 376 | class Diff(ABC): 377 | def __init__(self, api: Api): 378 | old = set(api.get_old_tasks().values()) 379 | tasks = set(api.get_tasks().values()) 380 | api.__class__.Task.set_update_compare(False) 381 | self.added = tasks - old 382 | self.deleted = old - tasks 383 | api.__class__.Task.set_update_compare(True) 384 | self.updated = tasks - old - self.added 385 | 386 | 387 | class TickTickDiff(Diff): 388 | def __init__(self, api: TickTickApi): 389 | super().__init__(api) 390 | self.api = api 391 | 392 | def sync_gcalendar(self, gcalendar_api: GCalendarApi, bidict_tick_gcalendar: BiDict[str, str]): 393 | gcal_tasks = gcalendar_api.get_tasks() 394 | # Update 395 | while self.updated: 396 | task = self.updated.pop() 397 | print(f"Update {self.__class__}: {task.title}") 398 | try: 399 | id_gcal = bidict_tick_gcalendar.get(task['id'], None) 400 | if id_gcal is None: 401 | self.added.add(task) 402 | continue 403 | task_gcal = gcal_tasks[id_gcal] 404 | if 'startDate' not in task or 'dueDate' not in task: 405 | gcalendar_api.delete(id_gcal) 406 | self.api.change_tasks(task) 407 | del bidict_tick_gcalendar[task['id']] 408 | continue 409 | start, all_day = ticktick_get_datetime(task, True) 410 | end, all_day = ticktick_get_datetime(task, False) 411 | # if all_day: # fix for time in ticktick 412 | # start += timedelta(days=1) 413 | # end += timedelta(days=1) 414 | 415 | task_gcal = gcalendar_api.build_event( 416 | summary=task['title'], 417 | start=start.date() if all_day else start, 418 | end=end.date() if all_day else end, 419 | description=task.get('content', None), 420 | event=task_gcal 421 | ) 422 | gcalendar_api.update(task_gcal) 423 | self.api.change_tasks(task) 424 | except Exception as e: 425 | self.api.get_tasks().pop(task['id'], None) 426 | do_on_exception(e) 427 | 428 | # Insert 429 | while self.added: 430 | task = self.added.pop() 431 | print(f"Add {self.__class__}: {task.title}") 432 | try: 433 | if 'startDate' not in task or 'dueDate' not in task: 434 | self.api.change_tasks(task) 435 | continue 436 | start, all_day = ticktick_get_datetime(task, True) 437 | end, all_day = ticktick_get_datetime(task, False) 438 | # if all_day: # fix for time in ticktick 439 | # start += timedelta(days=1) 440 | # end += timedelta(days=1) 441 | 442 | added_id = gcalendar_api.insert(gcalendar_api.build_event( 443 | summary=task['title'], 444 | start=start.date() if all_day else start, 445 | end=end.date() if all_day else end, 446 | description=task.get('content', None) 447 | ))['id'] 448 | self.api.change_tasks(task) 449 | bidict_tick_gcalendar[task['id']] = added_id 450 | except Exception as e: 451 | self.api.get_tasks().pop(task['id'], None) 452 | do_on_exception(e) 453 | 454 | # Delete 455 | while self.deleted: 456 | task = self.deleted.pop() 457 | print(f"Delete {self.__class__}: {task.title}") 458 | try: 459 | if 'startDate' not in task or 'dueDate' not in task: 460 | self.api.change_tasks(task, delete=True) 461 | continue 462 | gcal_id = bidict_tick_gcalendar[task['id']] 463 | 464 | gcalendar_api.delete(gcal_id) 465 | self.api.change_tasks(task, delete=True) 466 | del bidict_tick_gcalendar[task['id']] 467 | except Exception as e: 468 | self.api.get_tasks().pop(task['id'], None) 469 | do_on_exception(e) 470 | 471 | 472 | class GCalendarDiff(Diff): 473 | def __init__(self, api: GCalendarApi): 474 | super().__init__(api) 475 | self.api = api 476 | 477 | def sync_ticktick(self, ticktick_api: TickTickApi, bidict_tick_gcalendar: BiDict[str, str]): 478 | now = datetime.utcnow().replace(tzinfo=pytz.UTC) 479 | tick = ticktick_api.get_client().task 480 | tick_tasks = ticktick_api.get_tasks() 481 | 482 | # Updated 483 | while self.updated: 484 | task = self.updated.pop() 485 | print(f"Update {self.__class__}: {task.title}") 486 | try: 487 | id_tick = bidict_tick_gcalendar.inverse.get(task['id'], None) 488 | if id_tick is None: 489 | self.added.add(task) 490 | continue 491 | task_tick = tick_tasks[id_tick[0]] 492 | start, all_day = gcalendar_get_datetime(task['start']) 493 | end, _ = gcalendar_get_datetime(task['end']) 494 | time_zone = get_timezone_name(start) 495 | tick_date = tick.dates(start=start, due=end, tz=time_zone) 496 | if all_day: # fix for time in ticktick 497 | end -= timedelta(days=1) 498 | if start < now: # if after, then delete 499 | tick.delete(task_tick) 500 | self.api.change_tasks(task) 501 | del bidict_tick_gcalendar[task_tick['id']] 502 | continue 503 | task_tick = ticktick_api.build_task( 504 | title=task.get('summary', ""), 505 | all_day=all_day, 506 | content=task.get('description', ""), 507 | end=tick_date['dueDate'], 508 | start=tick_date['startDate'], 509 | time_zone=time_zone, 510 | task=task_tick 511 | ) 512 | 513 | ticktick_api.update(task_tick) 514 | self.api.change_tasks(task) 515 | except Exception as e: 516 | self.api.get_tasks().pop(task['id'], None) 517 | do_on_exception(e) 518 | 519 | # Insert 520 | while self.added: 521 | task = self.added.pop() 522 | print(f"Add {self.__class__}: {task.title}") 523 | try: 524 | start, all_day = gcalendar_get_datetime(task['start']) 525 | end, _ = gcalendar_get_datetime(task['end']) 526 | time_zone = get_timezone_name(start) 527 | if all_day: # fix for time in ticktick 528 | end -= timedelta(days=1) 529 | if start < now: 530 | self.api.change_tasks(task) 531 | continue 532 | 533 | added_id = ticktick_api.insert(ticktick_api.build_task( 534 | title=task.get('summary', ""), 535 | content=task.get('description', ""), 536 | all_day=all_day, 537 | start=start, 538 | end=end, 539 | time_zone=time_zone 540 | ))['id'] 541 | self.api.change_tasks(task) 542 | bidict_tick_gcalendar[added_id] = task['id'] 543 | except Exception as e: 544 | self.api.get_tasks().pop(task['id'], None) 545 | do_on_exception(e) 546 | 547 | # Delete 548 | while self.deleted: 549 | task = self.deleted.pop() 550 | print(f"Delete {self.__class__}: {task.title}") 551 | try: 552 | task_tick_id = bidict_tick_gcalendar.get_inverse(task['id'])[0] 553 | 554 | task_tick = tick_tasks[task_tick_id] 555 | ticktick_api.complete(task_tick) 556 | self.api.change_tasks(task, delete=True) 557 | del bidict_tick_gcalendar[task_tick_id] 558 | except Exception as e: 559 | self.api.get_tasks().pop(task['id'], None) 560 | do_on_exception(e) 561 | 562 | 563 | def main(args): 564 | if not path.exists("data"): 565 | os.makedirs("data") 566 | 567 | tick = TickTickApi(renew=args.renew) 568 | gtasks = GCalendarApi(renew=args.renew) 569 | if args.renew: 570 | return 571 | bidict_path = 'data/bidict_ticktick_gcalendar.dict' 572 | 573 | if args.tick_print: 574 | print(tick.get_client().state['projects']) 575 | return 576 | 577 | if path.isfile(bidict_path): 578 | bidict_ticktick_gcalendar = BiDict.load(bidict_path) 579 | else: 580 | bidict_ticktick_gcalendar = BiDict() 581 | 582 | if args.remove_tick is not None: 583 | del tick.get_old_tasks()[args.remove_tick] 584 | gcal_id = bidict_ticktick_gcalendar.pop(args.remove_tick, None) 585 | if gcal_id is not None: 586 | del gtasks.get_old_tasks()[gcal_id] 587 | print(f"Deleted id {args.remove_tick}") 588 | bidict_ticktick_gcalendar.save(bidict_path) 589 | gtasks.save_old_tasks() 590 | tick.save_old_tasks() 591 | return 592 | elif args.remove_gcal is not None: 593 | del gtasks.get_old_tasks()[args.remove_gcal] 594 | tick_id = bidict_ticktick_gcalendar.inverse.get(args.remove_gcal, None) 595 | if tick_id is not None: 596 | del bidict_ticktick_gcalendar[tick_id] 597 | del tick.get_old_tasks()[tick_id] 598 | print(f"Deleted id {args.remove_gcal}") 599 | bidict_ticktick_gcalendar.save(bidict_path) 600 | gtasks.save_old_tasks() 601 | tick.save_old_tasks() 602 | return 603 | elif args.delete_all_gcal: 604 | for event_id in list(gtasks.get_tasks().keys()): 605 | gtasks.delete(event_id) 606 | print("You can now delete the data folder") 607 | return 608 | 609 | try: 610 | GCalendarDiff(gtasks).sync_ticktick(tick, bidict_ticktick_gcalendar) 611 | TickTickDiff(tick).sync_gcalendar(gtasks, bidict_ticktick_gcalendar) 612 | except Exception as e: 613 | raise e 614 | finally: 615 | bidict_ticktick_gcalendar.save(bidict_path) 616 | gtasks.save_old_tasks() 617 | tick.save_old_tasks() 618 | 619 | 620 | if __name__ == "__main__": 621 | import argparse 622 | parser = argparse.ArgumentParser() 623 | 624 | parser.add_argument('-r', '--renew', action='store_true', help="Used to renew credentials") 625 | parser.add_argument('-p', '--tick_print', action='store_true') 626 | parser.add_argument('-rt', '--remove_tick', type=str, default=None, help="Used to delete a TickTick event by id") 627 | parser.add_argument('-rg', '--remove_gcal', type=str, default=None, help="Used to delete a GCalendar event by id") 628 | parser.add_argument('-dg', '--delete_all_gcal', action='store_true', 629 | help="WARNING: deletes all syncronized events. Useful to reset.") 630 | 631 | arguments = parser.parse_args() 632 | main(arguments) 633 | --------------------------------------------------------------------------------