remindme_caldav/remindme_caldav.py

401 lines
16 KiB
Python

from pathlib import Path
from icalendar import Calendar, Event
import toml, argparse, os, sys, hashlib, json, pytz, glob, os, time
from dateutil.relativedelta import relativedelta
from dateutil.rrule import rrulestr
import datetime as dt
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import email_alert, xmpp_alert
from pprint import pprint
import humanfriendly
from pathlib import Path
import argparse
import textwrap
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="A simple calendar alerting daemon written in Python")
parser.add_argument('--config', type=str, help='Path to config file. Must be .toml')
parser.add_argument('--loglevel', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], default='INFO', help='Set the logging level')
args = parser.parse_args()
if args.config is None:
raise ValueError("No config file provided")
return args
def read_file(filename):
try:
return Path(filename).read_text()
except FileNotFoundError as e:
print(f"Error: The specified file does not exist. {e}")
sys.exit(1) # Exit with error code
def parse_toml(content):
try:
return toml.loads(content)
except Exception as e:
print("Error: Failed to parse TOML file.")
print(e)
sys.exit(1) # Exit with error code
class DateTimeEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, (dt.datetime, dt.timedelta)):
return str(o) # Convert the datetime or timedelta object to a string
return super().default(o)
class FileChangeHandler(FileSystemEventHandler):
"""
`FileChangeHandler` is a custom event handler for the
`watchdog.observers.Observer` class that handles file system events such as
file modifications, deletions and creations.
It inherits from `watchdog.events.FileSystemEventHandler` providing methods
to handle these events: `on_modified`, `on_deleted`, and `on_created`. Each
method is overridden for specific functionality when a file system event
occurs.
The class also includes a method `calculate_event_hash` that generates an
MD5 hash for each event dictionary based on its contents. This is used to
track changes in the events and determine if they have been modified or
deleted.
For example, when a file is modified:
- It reads the content of the file, parses it into an event dictionary
using `calendar_parser`.
- Calculates the hash for the event and checks if there's already an
existing event with the same UID in `event_list`.
- If there is one, it compares the new hash with the old hash. If they
differ, it prints that the event has been modified or deleted. Otherwise,
it prints that the event hasn't been modified.
For file deletion and creation, similar operations are performed but
without comparison of hashes.
"""
def on_modified(self, event):
print(f"File modified: {event.src_path}")
if not event.is_directory:
try:
with open(event.src_path, 'r') as f:
cal_str = f.read()
except Exception as e:
print("Not a valid file: {}. Error: {}".format(event.src_path, e))
return
try:
event_dict = calendar_parser(cal_str)
except Exception as i:
print("Failed to parse calendar event at: {}.\n Error:\n{}".format(event.src_path,i))
return
# Handle the modified event
self.handle_modified(old_event=None, event_dict=event_dict)
def on_deleted(self, event):
print(f"File deleted: {event.src_path}")
if not event.is_directory:
uid = os.path.splitext(os.path.basename(event.src_path))[0] # Get the UID from the file path without extension
self.handle_modified(old_event=None, event_dict={"uid": uid}, remove=True)
def on_created(self, event):
print(f"File created: {event.src_path}")
if not event.is_directory:
try:
with open(event.src_path, 'r') as f:
cal_str = f.read()
except Exception as e:
print("Not a valid file: {}. Error: {}".format(event.src_path, e))
return
try:
event_dict = calendar_parser(cal_str)
except Exception as i:
print("Failed to parse calendar event at: {}.\n Error:\n{}".format(event.src_path,i))
return
self.handle_modified(old_event=None, event_dict=event_dict)
def handle_modified(self, old_event, event_dict, remove=False):
if not remove:
for i, old_event in enumerate(event_list):
if old_event["uid"] == event_dict["uid"]:
old_hash = old_event["hash"]
new_hash = self.calculate_event_hash(event_dict)
if new_hash != old_hash:
print("Event with UID {} has been modified or deleted".format(old_event["uid"]))
event_list[i] = event_dict
else:
print("Event with UID {} hasn't been modified".format(old_event["uid"]))
break
else:
event_list.append(event_dict)
else: # If remove is True, remove the event from the list
for i, old_event in enumerate(event_list):
if old_event["uid"] == event_dict["uid"]:
print("Event with UID {} has been deleted".format(old_event["uid"]))
del event_list[i]
break
def calculate_event_hash(self, event):
return hashlib.md5(json.dumps(event, sort_keys=True, cls=DateTimeEncoder).encode()).hexdigest()
def generate_recurring_event_dates(dtstart, rrule):
"""
Generate recurring event dates based on a start date and an RRULE.
This function takes in a start date (`dtstart`) and an RRULE (`rrule`),
which is used to generate future dates according to the rules specified by
the RRULE.
If no count rule is present, it generates a date array from `dtstart` to
the current datetime, then adds an arbitrary number of dates into the
future (here it's 10). This is done because generating a date array using
the rrulestr function without a count rule will return a very large number
of elements.
The function returns a dictionary containing information about the
recurring event:
- `recur_dates`: A list of future dates generated by the RRULE.
- `infinite_recur`: Boolean indicating whether the recurrence is infinite.
- `recur_freq`: The frequency of the recurrence (e.g., 'DAILY').
- `recur_interval`: The interval between each occurrence of the event.
- `n_recur_dates_left`: The number of future dates left.
Parameters:
dtstart (datetime): The start date of the recurring event.
rrule (rrule): The RRULE object specifying the recurrence rules.
Returns:
dict: A dictionary containing information about the recurring event
dates, frequency, interval and count.
"""
recur_info = {
"recur_dates": [dtstart],
"infinite_recur": False,
"recur_freq": None,
"recur_interval": None,
"n_recur_dates_left": None
}
if rrule is None:
return recur_info
rule_str = "RRULE:{}".format(rrule.to_ical().decode('utf-8'))
start_date = dtstart
infinite_recur = False
freq = rrule.get('FREQ')[0]
count = rrule.get("COUNT")
interval = rrule.get('INTERVAL')[0]
current_date = dt.datetime.now().replace(tzinfo=pytz.UTC)
if count is None:# or until is not None:
delta = None
if freq == "DAILY":
delta = relativedelta(days=interval)
elif freq == "MONTHLY":
delta = relativedelta(months=interval)
elif freq == "YEARLY":
delta = relativedelta(years=interval)
count = 0
origin_date = start_date
while origin_date < current_date:
count += interval
origin_date += delta*interval
rule_str += ";COUNT={}".format(count+10)
infinite_recur = True
ruleset = rrulestr(rule_str, dtstart=start_date)
recur_dates = [None]
n_recur = None
dates = list(ruleset) # Generate future dates according to the rules
recur_dates = [i for i in dates if i >= current_date]
n_recur = "inf" if infinite_recur is True else len(recur_dates)
recur_info["recur_dates"] = recur_dates
recur_info["infinite_recur"] = infinite_recur
recur_info["recur_freq"] = freq
recur_info["recur_interval"] = interval
recur_info["n_recur_dates_left"] = n_recur
return recur_info
def calendar_parser(cal_str):
# Parse the calendar
cal = Calendar.from_ical(cal_str)
# Iterate over each event in the calendar
for event in cal.walk('vevent'):
event_info = {
"uid": None,
"dtstart": "",
"exdate": [],
"summary": None,
"description": None,
"location": None,
"rrule": None
}
# Catch errors for missing components
for info in event_info:
try:
event_info[info] = event[info]
except Exception:
pass
uid = str(event_info["uid"])
dtstart = event_info["dtstart"].dt
exdates = event_info["exdate"]
if exdates is not []:
if isinstance(exdates, list):
exdates = [i.dts.dt.replace(tzinfo=pytz.UTC) for i in exdates]
else:
exdates = [exdates.dts[0].dt.replace(tzinfo=pytz.UTC)]
summary = event["summary"]
description = event_info["description"]
location = event_info["location"]
rrule = event_info["rrule"]
# Ensure dates are always as datetime
if isinstance(dtstart, dt.datetime):
dtstart = dtstart.replace(tzinfo=pytz.UTC)
else:
dtstart = dt.datetime.combine(dtstart, dt.time.min).replace(tzinfo=pytz.UTC)
# Get recurring events if they exist
recur_info = generate_recurring_event_dates(dtstart, rrule)
event_dates = recur_info["recur_dates"]
# Remove exdates
event_dates = [i for i in event_dates if i not in exdates]
valarms = []
for subcomponent in event.walk("valarm"):
valarm = Event.from_ical(subcomponent.to_ical())
timedelta = valarm["trigger"].dt
valarms.append(timedelta)
event_dict = {
"uid": uid,
"dtstart": dtstart,
"summary": summary,
"description": description,
"location": location,
"event_dates": event_dates,
"recur_info": "Recur freq: {}, Recur interval: {}, N dates left: {}".format(
recur_info["recur_freq"],
recur_info["recur_interval"],
recur_info["n_recur_dates_left"]
),
"valarms": valarms,
"alert_history": []
}
handler = FileChangeHandler() # Create an instance of the FileChangeHandler class
new_hash = handler.calculate_event_hash(event_dict) # Calculate the hash of the event dictionary
event_dict["hash"] = new_hash # Store the hash in the event dictionary
return event_dict
def get_next_alert(event, current_time):
event_dates = event["event_dates"]
valarm_deltas = event["valarms"]
if event_dates == [] or current_time > event_dates[-1]:
return None, None
next_event = [i for i in event_dates if i >= current_time][0]
next_alert_list = [next_event + i for i in valarm_deltas]
next_alert = min(next_alert_list)
return next_alert - dt.timedelta(seconds=5), next_event
def process_alert(current_time, next_alert, next_event, event, config):
if current_time >= next_alert and next_alert < next_alert + dt.timedelta(seconds=15):
if len(event["alert_history"]) == 0:
print("First alert for '{}' detected".format(event["summary"]))
event["alert_history"] = [{"timestamp_alert_triggered": current_time, "alert_defintition_time": next_alert}]
elif next_alert in [i["alert_defintition_time"] for i in event["alert_history"]]:
return # continue is not needed here as it's the end of function
else:
print("Posting alert for {}!".format(event["summary"]))
event["alert_history"].append({"timestamp_alert_triggered": current_time, "alert_defintition_time": next_alert})
#xmpp_alert.send_xmpp(event["summary"], next_alert, next_event, args.config)
email_alert.send_email(event, next_alert, next_event, config)
xmpp_alert.send_xmpp(event, next_alert, next_event, config)
with open("alert_history", 'a') as f:
f.write(str(event)) # write expects a str not dict
return
def create_event_list(files):
event_list = [] # List to hold dictionaries for each event
for file in files:
with open(file, 'r') as f:
cal_str = f.read()
event_dict = calendar_parser(cal_str)
event_list.append(event_dict)
return event_list
def start_observer(cal_dir):
observer = Observer()
handler = FileChangeHandler()
observer.schedule(handler, cal_dir, recursive=True)
observer.start()
return observer
def main_loop(event_list, config, cal_dir):
observer = start_observer(cal_dir)
try:
while True:
with open("status", 'w') as f:
#Refresh the status file
f.write("")
current_time = dt.datetime.now().replace(tzinfo=pytz.UTC)
for event in event_list:
next_alert, next_event = get_next_alert(event, current_time)
if next_alert == None:
continue
event_delta = next_alert-current_time
total_seconds = event_delta.total_seconds()
human_readable_time = humanfriendly.format_timespan(total_seconds)
monitor_status = f"""\
Current time: {current_time}
Monitoring: {event["summary"]}
Event date: {next_event}
Recur Dates: {[str(i) for i in event["event_dates"]]}
Next alert on: {next_alert} in {human_readable_time}
Recur info: {event["recur_info"]}
Alert history: {event["alert_history"]}\n"""
monitor_status = textwrap.dedent(monitor_status)
with open("status", 'a') as f:
# Write the output to the file
f.write(monitor_status)
f.write("\n")
process_alert(current_time, next_alert, next_event, event, config)
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
# Main script execution starts here
def main():
args = parse_args()
print(args)
content = read_file(args.config)
config = parse_toml(content)
cal_dir = Path(config["app"]["calendar_dir"])
if not cal_dir.is_dir():
print(f"The provided path to .ics files does not exist: '{cal_dir}'")
sys.exit(1) # Exit with error code
files = list(cal_dir.glob('*.ics'))
event_list = create_event_list(files)
main_loop(event_list, config, cal_dir)
if __name__ == "__main__":
main()