├── .gitignore ├── LICENSE ├── README.md ├── gcal.py ├── settings.py └── src ├── credentials.py ├── ics.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | settings.ini 3 | token.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Matthew Might 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 | # Calculendar 2 | 3 | A command line tool for computing availability. 4 | 5 | It currently supports the Google Calendar API. 6 | 7 | (If you want simple Google Calendar manipulation through the command line, you may want [gcalcli](https://pypi.org/project/gcalcli/).) 8 | 9 | Internally, the tool implements a Boolean calculus over calendar intervals to 10 | compute availability. This allows you to define availability as a function of 11 | several calendars. 12 | 13 | For instance, you might have a primary calendar personal calendar, but also a 14 | shared team calendar, and you will need to consider both for conflicts. 15 | 16 | You might also want to define a calendar that specifies that there are 17 | certain times of the day and days the week where you are available for 18 | appointments. Or, if a meeting must be held in a certain room, you can pull in 19 | the in-use calendar for that room and invert it to obtain its available times. 20 | 21 | For example: 22 | 23 | ``` 24 | office_availability = ~(primary_calendar | out_of_town) & workhours & weekdays 25 | 26 | phone_availability = ~primary_calendar & workhours & weekdays 27 | 28 | joint_availability = my_availability & your_availability 29 | ``` 30 | 31 | 32 | 33 | 34 | ## TODO 35 | 36 | + Extract the Boolean calculus of calendars logic into a seperate module. [Done] 37 | + Allow filtering of calendar events with `lambda` filter over events. 38 | + Implement a reasonable agenda mode output for upcoming events. 39 | + Add support for converting .ics files into calendar intervals. [Done] 40 | + Add support for Office 365 API. 41 | + Add support for computing joint availability of multiple users. 42 | 43 | ## Warnings 44 | 45 | This code needs clean-up. [In progress...] 46 | 47 | I'm releasing it under the principles of [the CRAPL](http://matt.might.net/articles/crapl/). 48 | 49 | 50 | ## Requirements 51 | 52 | You will need to create a `credentials.json` to access the Google Calendar API. 53 | 54 | You can copy the steps in the [Python Google Calendar API quickstart tutorial](https://developers.google.com/calendar/quickstart/python) to obtain this. 55 | 56 | When you save the credentials, ensure you remember the location, as it will be required. 57 | You may need to install the following packages (with pip) as well: 58 | 59 | + `oauth2client` 60 | + `arrow` 61 | + `pytz` 62 | + `icalendar` 63 | 64 | 65 | ## Usage 66 | 67 | ``` 68 | usage: gcal.py 69 | [--start START] [--end END] 70 | [--output-timezone OUTPUT_TIMEZONE] 71 | [--query-timezone QUERY_TIMEZONE] 72 | [--busy-calendars calendar-id [calendar-id ...]] 73 | [--free-calendars calendar-id [calendar-id ...]] 74 | [--input [Paths to .ics files...]] 75 | [--all] 76 | ``` 77 | 78 | The `command` can be `list_cals` or `agenda` or `available` or `import`. 79 | 80 | 81 | ## Examples 82 | 83 | ### Availability 84 | ```bash 85 | echo "Availability at office: " 86 | python gcal.py available \ 87 | --busy-calendars primary \ 88 | \ 89 | --free-calendars workhours weekday 90 | 91 | 92 | echo "Availability on phone: " 93 | python gcal.py available \ 94 | --busy-calendars primary \ 95 | --free-calendars workhours weekday 96 | ``` 97 | 98 | This might output: 99 | 100 | ``` 101 | Availability at office: 102 | May 07 @ 10:00am-11:00am 103 | May 07 @ 02:00pm-02:30pm 104 | May 15 @ 11:00am-02:00pm 105 | 106 | 107 | Availability on phone: 108 | May 07 @ 10:00am-11:00am 109 | May 07 @ 02:00pm-02:30pm 110 | May 08 @ 11:45am-12:00pm 111 | May 08 @ 03:00pm-04:30pm 112 | May 15 @ 11:00am-02:00pm 113 | 114 | ``` 115 | 116 | ### Agenda 117 | Get easy agendas: 118 | ```bash 119 | echo "Green Bay Packers Agenda: " 120 | python gcal.py agenda \ 121 | --busy-calendars nfl_9_%47reen+%42ay+%50ackers#sports@group.v.calendar.google.com 122 | 123 | ``` 124 | This would output: 125 | ```bash 126 | Green Bay Packers Agenda: 127 | 2018-10-07 1:00 PM Packers @ Lions 128 | 2018-10-15 8:15 PM 49ers @ Packers 129 | 2018-10-28 4:25 PM Packers @ Rams 130 | 2018-11-04 8:20 PM Packers @ Patriots 131 | 2018-11-11 1:00 PM Dolphins @ Packers 132 | 2018-11-15 8:20 PM Packers @ Seahawks 133 | ``` 134 | 135 | ### List 136 | The default is to only list Calendars imported into your `settings.ini` file 137 | 138 | List Out Calendars 139 | ```bash 140 | echo "My Calendars: " 141 | python gcal.py list 142 | ``` 143 | 144 | Would produce something like this: 145 | ```bash 146 | My Calendars: 147 | my_gmail@gmail.com: my_gmail@gmail.com 148 | green bay packers : nfl_9_%47reen+%42ay+%50ackers#sports@group.v.calendar.google.com 149 | contacts : #contacts@group.v.calendar.google.com 150 | ``` 151 | 152 | To ensure all google and imported calendars are listed, use `-a`: 153 | 154 | ```bash 155 | echo "My Calendars: " 156 | python gcal.py list -a 157 | ``` 158 | 159 | This would list all calendars, imported of otherwise, and then prompt you to save them 160 | ```bash 161 | My Calendars: 162 | my_gmail@gmail.com: my_gmail@gmail.com 163 | green bay packers : nfl_9_%47reen+%42ay+%50ackers#sports@group.v.calendar.google.com 164 | contacts : #contacts@group.v.calendar.google.com 165 | Would you like to import these into your settings for future use? 166 | ``` 167 | 168 | ### Importing .ICS Calendars 169 | You can now import .ics files like so: 170 | 171 | ```bash 172 | python gcal import -i ./calendar.ics 173 | ``` 174 | This would prompt you to name the file for future use, and then list it as it appears in your settings.ini 175 | ```bash 176 | What would you like to name this calendar? my_cal 177 | my_gmail@gmail.com: my_gmail@gmail.com 178 | green bay packers : nfl_9_%47reen+%42ay+%50ackers#sports@group.v.calendar.google.com 179 | contacts : #contacts@group.v.calendar.google.com 180 | my_cal : ./calendar.ics 181 | ``` 182 | -------------------------------------------------------------------------------- /gcal.py: -------------------------------------------------------------------------------- 1 | """ 2 | A tool for interacting with the Google calendar. 3 | """ 4 | # Python Version Compatibility 5 | from __future__ import print_function 6 | 7 | # For command-line arguments 8 | import argparse 9 | 10 | # Module functions 11 | import src.credentials 12 | from src.utils import * 13 | from src.utils import safe_input as input 14 | from src.ics import get_ics_calendar_events 15 | 16 | 17 | # Dates to access the next month. 18 | NOW_DATE = arrow.utcnow().date() 19 | NOW = arrow.utcnow() # right now 20 | IN30 = NOW.replace(days=+30) # in 30 days 21 | 22 | start_work = arrow.get(settings.START_WORK, "H:mm A").replace(tzinfo=settings.TIMEZONE) 23 | end_work = arrow.get(settings.END_WORK, "H:mm A").replace(tzinfo=settings.TIMEZONE) 24 | 25 | # Parse arguments 26 | parser = argparse.ArgumentParser() 27 | parser.add_argument("command", help="list | agenda | available | import") 28 | parser.add_argument("--start", default=NOW, help="start of timespan") 29 | parser.add_argument("--end", default=IN30, help="end of timespan") 30 | parser.add_argument( 31 | "--query-timezone", default=settings.TIMEZONE, help="timezone to use for output" 32 | ) 33 | parser.add_argument( 34 | "--output-timezone", default=settings.TIMEZONE, help="timezone to use for output" 35 | ) 36 | 37 | parser.add_argument( 38 | "--busy-calendars", 39 | metavar="calendar-id", 40 | default=["primary"], 41 | nargs="+", 42 | help="calendar ids to use for busy information", 43 | ) 44 | parser.add_argument( 45 | "--free-calendars", 46 | metavar="calendar-id", 47 | default=[], 48 | nargs="+", 49 | help="calendar ids to use for free information", 50 | ) 51 | parser.add_argument( 52 | "-i", "--input", default=[], nargs="+", help="paths to .ics calendars to import" 53 | ) 54 | parser.add_argument("-a", "--all", action="store_true") 55 | args = parser.parse_args() 56 | QUERY_TIMEZONE = args.query_timezone or args.output_timezone 57 | START = arrow.get(args.start) 58 | END = arrow.get(args.end) 59 | BUSY = get_calendars_from_imported(args.busy_calendars) 60 | FREE = get_calendars_from_imported(args.free_calendars) 61 | INPUT = args.input 62 | 63 | gcal_service = src.credentials.get_service() 64 | 65 | 66 | # Get the next several events: 67 | def get_calendar_events(calendarId="primary", maxResults=10): 68 | if ".ics" in calendarId: 69 | return get_ics_calendar_events(calendarId) 70 | else: 71 | events_result = ( 72 | gcal_service.events() 73 | .list( 74 | calendarId=calendarId, 75 | timeMin=START.isoformat(), 76 | maxResults=maxResults, 77 | singleEvents=True, 78 | orderBy="startTime", 79 | ) 80 | .execute() 81 | ) 82 | return events_result.get("items", []) 83 | 84 | 85 | # Returns a dict containing a cal_interval for each requested calendar: 86 | def get_freebusy( 87 | calendarIds=["primary"], timeZone=QUERY_TIMEZONE, start=START, end=END 88 | ): 89 | fb_q = { 90 | "timeMin": start.isoformat(), 91 | "timeMax": end.isoformat(), 92 | "timeZone": timeZone, 93 | "items": [{"id": id} for id in calendarIds], 94 | } 95 | freebusy_result = gcal_service.freebusy().query(body=fb_q).execute() 96 | cals = freebusy_result["calendars"] 97 | cal_index = {} 98 | for id, times in cals.items(): 99 | busy_times = times["busy"] 100 | revs = [] # events in reverse order 101 | for t in busy_times: 102 | ev_start = arrow.get(t["start"]) 103 | ev_end = arrow.get(t["end"]) 104 | ev = Event(ev_start, ev_end) 105 | # accumulate events in reverse order: 106 | revs = [ev] + revs 107 | cal_index[id] = Interval(start, revs[::-1], end) 108 | return cal_index 109 | 110 | 111 | # check to see if the calendar is synthetic; 112 | # if synthentic, will be computed against query timezone. 113 | 114 | # TODO: Add support for synthetic calendars in other timezones. 115 | 116 | 117 | def get_cal(cal_index, cal_id): 118 | if cal_id == "weekend": 119 | return cal_weekends(START, END) 120 | if cal_id == "weekday": 121 | return ~cal_weekends(START, END) 122 | if cal_id == "workhours": 123 | return cal_daily_event( 124 | START, 125 | END, 126 | start_work.hour, 127 | start_work.minute, 128 | end_work.hour, 129 | end_work.minute, 130 | ) 131 | else: 132 | return cal_index[cal_id] 133 | 134 | 135 | def list_cals(): 136 | if settings.calendars_imported and not args.all: 137 | settings.list_imported_calendars() 138 | else: 139 | imported = settings.list_imported_calendars() 140 | imported_names = [cal[0] for cal in imported] 141 | cals_result = gcal_service.calendarList().list().execute() 142 | cals = cals_result.get("items", []) 143 | for cal in cals: 144 | if cal["summary"].lower() not in imported_names: 145 | print(cal["summary"] + ": " + cal["id"]) 146 | user_response = input( 147 | "Would you like to import these into your settings for future use? " 148 | ) 149 | if "y" in user_response.lower(): 150 | for cal in cals: 151 | settings.set_calendar(cal["summary"], cal["id"]) 152 | 153 | 154 | def agenda(): 155 | events = [] 156 | for cal in BUSY: 157 | cal_events = get_calendar_events(calendarId=cal) 158 | events += cal_events 159 | if not events: 160 | print("No upcoming events found.") 161 | for event in events: 162 | start = event["start"].get("dateTime", event["start"].get("date")) 163 | a_start = arrow.get(start).format("YYYY-MM-DD h:mm A") 164 | # print(event) 165 | print(a_start, event["summary"]) 166 | 167 | 168 | def available(): 169 | calidx = get_freebusy(calendarIds=BUSY) 170 | my_busy = Interval(START, [], END) 171 | my_free = Interval(START, [Event(START, END)], END) 172 | for cal_id in BUSY: 173 | my_busy = my_busy | get_cal(calidx, cal_id) 174 | for cal_id in FREE: 175 | my_free = my_free & get_cal(calidx, cal_id) 176 | available = ~my_busy & my_free 177 | # print out availability: 178 | for ev in available.events: 179 | print(ev.human_str()) 180 | 181 | 182 | def import_cal(): 183 | if not INPUT: 184 | print("Import command requires -i or --input !") 185 | else: 186 | for path in INPUT: 187 | name = input("What would you like to name this calendar? ") 188 | settings.set_calendar(name, path) 189 | list_cals() 190 | 191 | 192 | if __name__ == "__main__": 193 | if args.command.lower() == "list": 194 | list_cals() 195 | elif args.command.lower() == "agenda": 196 | agenda() 197 | elif args.command.lower() == "available": 198 | available() 199 | elif args.command.lower() == "import": 200 | import_cal() 201 | else: 202 | print("unknown command: " + args.command) 203 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | 4 | import arrow 5 | import pytz 6 | from arrow.parser import ParserError 7 | from src.utils import safe_input as input 8 | 9 | 10 | weekday_dict = { 11 | "monday": 0, 12 | "tuesday": 1, 13 | "wednesday": 2, 14 | "thursday": 3, 15 | "friday": 4, 16 | "saturday": 5, 17 | "sunday": 6, 18 | } 19 | 20 | zones = pytz.all_timezones # list of usable timezones 21 | 22 | 23 | def confirm_input(user_input): 24 | print("I got the following: ") 25 | if type(user_input) == list: 26 | for i in user_input: 27 | print(i) 28 | else: 29 | print(user_input) 30 | confirm = input("Is this accurate? ") 31 | if "y" in confirm.lower(): 32 | return True 33 | else: 34 | return False 35 | 36 | 37 | def get_credentials_directory(location=None): 38 | """ 39 | Gets credentials directory from user input 40 | """ 41 | if not location: 42 | try: 43 | credentials = os.getenv("HOME") + "/etc/keys/google-api/" 44 | return get_credentials_directory(credentials) 45 | except TypeError: 46 | pass 47 | elif os.path.exists(os.path.join(location + "/credentials.json")): 48 | credentials = location 49 | if confirm_input(credentials): 50 | return credentials 51 | else: 52 | return get_credentials_directory() 53 | elif "credentials.json" in location: 54 | credentials = os.path.dirname(location) 55 | if confirm_input(credentials): 56 | return credentials 57 | else: 58 | return get_credentials_directory() 59 | loc = input("Path to credentials.json: ") 60 | return get_credentials_directory(location=loc) 61 | 62 | 63 | def get_timezone(zones: list): 64 | """ 65 | Gets timezone from user input 66 | """ 67 | user_input = input( 68 | 'What timezone do you reside in? (Format: "US/Pacific", "US/Eastern", etc): ' 69 | ) 70 | smaller_zones = [] 71 | if user_input not in zones: 72 | for zone in zones: 73 | if user_input.lower() in zone.lower(): 74 | smaller_zones.append(zone) 75 | if len(smaller_zones): 76 | print("Zones that match that input are: ") 77 | for zone in smaller_zones: 78 | print(zone) 79 | return get_timezone(smaller_zones) 80 | else: 81 | if confirm_input(user_input): 82 | return user_input 83 | else: 84 | return get_timezone() 85 | 86 | 87 | def get_work_hours(): 88 | """ 89 | Gets work hours from user input 90 | """ 91 | user_reply = input("What are your work hours? (Format: 9:00 AM-5:00 PM) ") 92 | user_reply_list = user_reply.split("-") 93 | for i in user_reply_list: 94 | try: 95 | arrow.get(i, "H:mm A") 96 | except ParserError: 97 | print("Invalid Output") 98 | return get_work_hours() 99 | if confirm_input(user_reply_list): 100 | return user_reply_list 101 | else: 102 | return get_work_hours() 103 | 104 | 105 | def get_weekends(): 106 | """ 107 | Gets weekends from user input 108 | """ 109 | user_reply = input("What days of the week are your weekends? ") 110 | days = weekday_dict.keys() 111 | ret = [] 112 | for day in days: 113 | if day.lower() in user_reply.lower(): 114 | ret.append(day) 115 | if confirm_input(ret): 116 | return ret 117 | else: 118 | return get_weekends() 119 | 120 | 121 | def set_weekend(parser, days: list): 122 | for day in days: 123 | parser.set("Weekend Days", day.lower(), "True") 124 | 125 | 126 | def setup_settings(): 127 | """ 128 | Create settings.ini from user input 129 | """ 130 | if not os.path.exists("settings.ini"): 131 | parser = configparser.ConfigParser(interpolation=None) 132 | parser.add_section("Settings") 133 | parser.add_section("Weekend Days") 134 | parser.add_section("Calendars") 135 | for k, v in weekday_dict.items(): 136 | parser.set("Weekend Days", k, "False") 137 | parser.set("Settings", "credentials_dir", get_credentials_directory()) 138 | weekend_days = get_weekends() 139 | set_weekend(parser, weekend_days) 140 | parser.set("Settings", "timezone", get_timezone(zones)) 141 | work_hours = get_work_hours() 142 | parser.set("Settings", "start_work", work_hours[0]) 143 | parser.set("Settings", "end_work", work_hours[1]) 144 | 145 | with open("settings.ini", "w") as ini: 146 | parser.write(ini) 147 | 148 | 149 | setup_settings() 150 | 151 | parser = configparser.ConfigParser(interpolation=None) 152 | parser.read("settings.ini") 153 | 154 | 155 | CREDENTIALS_DIR = parser.get("Settings", "credentials_dir") 156 | TIMEZONE = parser.get("Settings", "timezone") 157 | START_WORK = parser.get("Settings", "start_work") 158 | END_WORK = parser.get("Settings", "end_work") 159 | 160 | 161 | def get_weekend_num(): 162 | weekend = [] 163 | for i in parser["Weekend Days"]: 164 | if parser.getboolean("Weekend Days", i): 165 | weekend.append(weekday_dict[i]) 166 | return weekend 167 | 168 | 169 | weekend_num = get_weekend_num() 170 | 171 | calendars_imported = len( 172 | parser.options("Calendars") 173 | ) # returns 0 (False) if calendars not imported 174 | 175 | 176 | def set_calendar(cal_name, cal_id): 177 | parser.set("Calendars", cal_name, cal_id) 178 | with open("settings.ini", "w") as config: 179 | parser.write(config) 180 | 181 | 182 | def list_imported_calendars(): 183 | for cal, id in parser.items("Calendars"): 184 | print(cal, ":", id) 185 | return parser.items("Calendars") 186 | 187 | 188 | def get_imported_calendar_by_name(name: str): 189 | for cal, id in parser.items("Calendars"): 190 | if name.lower() == cal.lower() and name.lower() != "primary": 191 | return id 192 | return name # print("Calendar with name " + name + " not found") 193 | -------------------------------------------------------------------------------- /src/credentials.py: -------------------------------------------------------------------------------- 1 | # On getting API access and access to your account: 2 | 3 | # To figure out how to set up access to the Google API ('client_secret.json') 4 | # I followed the quickstart guide here: 5 | 6 | # https://developers.google.com/calendar/quickstart/python 7 | 8 | # The first time you run the script it will open a web authentication dialogue, 9 | # and then it will store your credentials in 'credentials.json' 10 | 11 | # If you want to run this script on a remote server, authenticate locally first and 12 | # then copy the these files to that remote server. 13 | 14 | # import os 15 | # from googleapiclient.discovery import build 16 | # from httplib2 import Http 17 | # from oauth2client import file, client, tools 18 | # import settings 19 | 20 | from __future__ import print_function 21 | import datetime 22 | import pickle 23 | import os.path 24 | from googleapiclient.discovery import build 25 | from google_auth_oauthlib.flow import InstalledAppFlow 26 | from google.auth.transport.requests import Request 27 | import settings 28 | 29 | SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] 30 | 31 | # Credentials should be stored in settings.CREDENTIALS_DIR 32 | def get_service(): 33 | """ 34 | This is straight out of the tutorial at: 35 | https://developers.google.com/calendar/quickstart/python 36 | """ 37 | # SCOPES = "https://www.googleapis.com/auth/calendar" 38 | # cred = os.path.join(settings.CREDENTIALS_DIR, "credentials.json") 39 | # store = file.Storage("token.json") 40 | # creds = store.get() 41 | # if not creds or creds.invalid: 42 | # flow = client.flow_from_clientsecrets(cred, SCOPES) 43 | # creds = tools.run_flow(flow, store) 44 | # service = build("calendar", "v3", http=creds.authorize(Http())) 45 | # return service 46 | 47 | 48 | creds = None 49 | # The file token.pickle stores the user's access and refresh tokens, and is 50 | # created automatically when the authorization flow completes for the first 51 | # time. 52 | 53 | # settings.ini contains the location of the credentials directory 54 | # It must contain credentials.json as acquired from the quickstart tutorial. 55 | # It will place token.pickle there after it has authenticated. 56 | 57 | token_pickle_file = os.path.join(settings.CREDENTIALS_DIR,'token.pickle') 58 | credentials_file = os.path.join(settings.CREDENTIALS_DIR,'credentials.json') 59 | 60 | if os.path.exists(token_pickle_file): 61 | with open(token_pickle_file, 'rb') as token: 62 | creds = pickle.load(token) 63 | # If there are no (valid) credentials available, let the user log in. 64 | if not creds or not creds.valid: 65 | if creds and creds.expired and creds.refresh_token: 66 | creds.refresh(Request()) 67 | else: 68 | flow = InstalledAppFlow.from_client_secrets_file( 69 | credentials_file, SCOPES) 70 | creds = flow.run_local_server() 71 | # Save the credentials for the next run 72 | with open(token_pickle_file, 'wb') as token: 73 | pickle.dump(creds, token) 74 | 75 | service = build('calendar', 'v3', credentials=creds) 76 | 77 | return service 78 | 79 | def main(): 80 | get_service() 81 | 82 | if __name__ == "__main__": 83 | main() 84 | -------------------------------------------------------------------------------- /src/ics.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import icalendar 4 | import arrow 5 | 6 | 7 | def get_ics_calendar_events(calendar: os.path): 8 | events_list = [] 9 | with open(calendar, "r") as cal: 10 | ics_cal = icalendar.Calendar.from_ical(cal.read()) 11 | for i in ics_cal.walk(): 12 | if i.name == "VEVENT": 13 | start = i["DTSTART"].dt 14 | event_dict = {"start": {}, "end": {}, "summary": ""} 15 | if type(start) == datetime.datetime: 16 | event_dict["start"]["dateTime"] = arrow.Arrow.fromdatetime( 17 | start, start.tzinfo 18 | ) 19 | try: 20 | end = i["DTEND"].dt 21 | event_dict["end"]["dateTime"] = arrow.Arrow.fromdatetime( 22 | end, end.tzinfo 23 | ) 24 | except KeyError: 25 | pass 26 | else: 27 | event_dict["start"]["date"] = arrow.Arrow.fromdate(start) 28 | event_dict["summary"] = i["summary"].to_ical().decode("utf8") 29 | events_list.append(event_dict) 30 | return events_list 31 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import arrow 4 | 5 | 6 | # A cal_event represents a single event with a start and end time. 7 | # NOTE: all times have type arrow. 8 | import settings 9 | 10 | 11 | class Event: 12 | def __init__(self, start, end): 13 | self.start = arrow.get(start) 14 | self.end = arrow.get(end) 15 | 16 | def __str__(self): 17 | return "(" + str(self.start) + "," + str(self.end) + ")" 18 | 19 | # is self entirely lesser than the other event? 20 | def __lt__(self, other): 21 | return self.end < other.start 22 | 23 | # creates the smallest event that contains both events: 24 | def join(self, other): 25 | return Event(min(self.start, other.start), max(self.end, other.end)) 26 | 27 | # does this event overlap with another event? 28 | def intersects(self, other): 29 | # check to see if neither is entirely lesser than the other 30 | # corner case: returns True if they are adjacent 31 | return not ((self < other) or (other < self)) 32 | 33 | # print a human readable string: 34 | def human_str(self): 35 | 36 | left = self.start.to(settings.TIMEZONE) 37 | right = self.end.to(settings.TIMEZONE) 38 | 39 | left_date = left.format("YYYY-MM-DD") 40 | right_date = right.format("YYYY-MM-DD") 41 | if left_date == right_date: 42 | return ( 43 | left.format("MMMM DD @ h:mma") 44 | + "-" 45 | + right.format("h:mma") 46 | + " " 47 | + settings.TIMEZONE 48 | ) 49 | else: 50 | return ( 51 | left.format("MMMM DD @ h:mma") 52 | + "-" 53 | + right.format("MMMM DD @ h:mma" + " " + settings.TIMEZONE) 54 | ) 55 | 56 | 57 | # A cal_interval represents an interval of time on a calendar 58 | # within the interval are events at specific times. 59 | class Interval: 60 | def __init__(self, start, events, end): 61 | self.start = start 62 | self.events = events # list of (start : arrow,end : arrow) 63 | self.end = end 64 | 65 | def __invert__(self): 66 | return Interval( 67 | self.start, events_complement(self.start, self.events, self.end), self.end 68 | ) 69 | 70 | def __or__(self, other): 71 | if self.start != other.start or self.end != other.end: 72 | raise Exception("won't combine calendars with different intervals") 73 | events_new = events_flatten(events_union(self.events, other.events)) 74 | return Interval(self.start, events_new, self.end) 75 | 76 | def __and__(self, other): 77 | if self.start != other.start or self.end != other.end: 78 | raise Exception("won't combine calendars with different intervals") 79 | return ~(~self | ~other) 80 | 81 | def __str__(self): 82 | return "\n".join([str(x) for x in self.events]) 83 | 84 | 85 | # Compute the complement of a sequence events between start and end. 86 | # 87 | # For example, if: 88 | # the start is 4pm 89 | # the end is 10pm 90 | # events is [(5pm,7pm)] 91 | # then the complement is: 92 | # [(4pm,5pm), (7pm,10pm)] 93 | # 94 | # This converts between "busy" and "available" 95 | def events_complement(start, events, end): 96 | if not events: 97 | if start >= end: 98 | return [] 99 | else: 100 | return [Event(start, end)] 101 | elif events[0].start <= start: 102 | return events_complement(events[0].end, events[1:], end) 103 | else: 104 | head = events[0] 105 | tail = events[1:] 106 | return [Event(start, head.start)] + events_complement(head.end, tail, end) 107 | 108 | 109 | def events_union(first_event, second_event): 110 | """ 111 | Merge in order two ordered sequence of events. 112 | """ 113 | if not first_event: 114 | return second_event 115 | elif not second_event: 116 | return first_event 117 | elif first_event[0] < second_event[0]: 118 | return [first_event[0]] + events_union(first_event[1:], second_event) 119 | elif second_event[0] < first_event[0]: 120 | return [second_event[0]] + events_union(first_event, second_event[1:]) 121 | else: 122 | joined_event = first_event[0].join(second_event[0]) 123 | return events_union([joined_event] + first_event[1:], second_event[1:]) 124 | 125 | 126 | def events_flatten(events): 127 | """ 128 | Combines all overlapping events in an ordered sequence of events 129 | """ 130 | if len(events) <= 1: 131 | return events 132 | else: 133 | a = events[0] 134 | b = events[1] 135 | if a.intersects(b): 136 | return events_flatten([a.join(b)] + events[2:]) 137 | else: 138 | return [a] + events_flatten(events[1:]) 139 | 140 | 141 | def cal_daily_event(start, end, start_hour, start_min, end_hour, end_min): 142 | """ 143 | A cal_interval with a single event every day: 144 | """ 145 | new_start = start.floor("day") 146 | new_end = end.replace(days=+1).floor("day") 147 | 148 | def events_daily_event(start, end, start_hour, start_min, end_hour, end_min): 149 | # reset start date to start of day; end date to end of day 150 | if end < start: 151 | return [] 152 | else: 153 | ev = Event( 154 | start.replace( 155 | hour=start_hour, minute=start_min, tzinfo=settings.TIMEZONE 156 | ), 157 | start.replace(hour=end_hour, minute=end_min, tzinfo=settings.TIMEZONE), 158 | ) 159 | evs = events_daily_event( 160 | start.replace(days=+1), end, start_hour, start_min, end_hour, end_min 161 | ) 162 | return [ev] + evs 163 | 164 | return Interval( 165 | start, 166 | events_daily_event( 167 | new_start, new_end, start_hour, start_min, end_hour, end_min 168 | ), 169 | end, 170 | ) 171 | 172 | 173 | def cal_weekends(start, end): 174 | """ 175 | A cal_interval where the weekends are a single event 176 | """ 177 | new_start = start.floor("day") 178 | new_end = end.replace(days=+1).floor("day") 179 | 180 | def events_weekends(start, end): 181 | # TODO: abstract this to handle different conventions for weekends 182 | if end < start: 183 | return [] 184 | elif start.weekday() in settings.weekend_num: 185 | return [ 186 | Event(start, start.replace(days=+1).floor("day")) 187 | ] + events_weekends(start.replace(days=+1), end) 188 | else: 189 | return events_weekends(start.replace(days=+1), end) 190 | 191 | return Interval(start, events_flatten(events_weekends(new_start, new_end)), end) 192 | 193 | 194 | def get_calendars_from_imported(calendars: list) -> list: 195 | """ 196 | Takes mixed list of calendars from command line and converts names to ids for later parsing 197 | """ 198 | ret = [] 199 | for cal in calendars: 200 | if ".ics" in cal or ".com" in cal: 201 | ret.append(cal) 202 | elif "primary" in cal.lower(): 203 | pass 204 | else: 205 | cal_id = settings.get_imported_calendar_by_name(cal) 206 | ret.append(cal_id) 207 | return ret 208 | 209 | 210 | def safe_input(message): 211 | """ 212 | Input is unsafe in python 2 213 | https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b322-input 214 | """ 215 | if sys.version_info[0] < 3: 216 | return raw_input(message) 217 | else: 218 | return input(message) 219 | --------------------------------------------------------------------------------