the python CalDAV synced calendar
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

333 lines
11 KiB

2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
  1. #!/usr/bin/python
  2. # if you're reading the source code for this (oof), feel free to suggest improvements for this, or, well, anything above or below this comment (as long as it's not just "rewrite this entire thing in C++ for me because i think python bad", "idk how but optimize stuff kthx". just don't be a dick, ok? thanks).
  3. import configparser
  4. import datetime
  5. from datetime import date, timedelta
  6. import calendar
  7. import caldav
  8. import argparse
  9. import os
  10. import json
  11. config = configparser.ConfigParser()
  12. Version = "%(prog)s 0.1"
  13. configpath = os.getenv("HOME")+"/.config/decal.conf"
  14. config.read(configpath)
  15. #define arguments
  16. today = date.today()
  17. parser = argparse.ArgumentParser(description="Cal with events.")
  18. parser.add_argument("year",
  19. action="store",
  20. default=today.year,
  21. nargs="?",
  22. type=int)
  23. parser.add_argument("month",
  24. action="store",
  25. default=today.month,
  26. nargs="?",
  27. type=int)
  28. parser.add_argument("day",
  29. action="store",
  30. default=today.day,
  31. nargs="?",
  32. type=int)
  33. parser.add_argument("-V", "--version",
  34. action="version",
  35. version=Version,
  36. help="Display the version of the program")
  37. parser.add_argument("--json",
  38. action="store_true",
  39. help="Dump events output to json")
  40. parser.add_argument("--create",
  41. action="store_true",
  42. help="Create a new event")
  43. parser.add_argument("--calendar",
  44. action="append",
  45. help="Specify a calendar (or multiple calendars) to sync from")
  46. parser.add_argument("-d",
  47. action="store_true",
  48. help="Show event details for selected day")
  49. parser.add_argument("-m",
  50. action="store_true",
  51. help="Show event details for entire month")
  52. parser.add_argument("-1",
  53. action="store_true",
  54. help="show only a single month (default)")
  55. parser.add_argument("-3",
  56. action="store_true",
  57. help="show three months spanning the date")
  58. parser.add_argument("-y",
  59. action="store_true",
  60. help="show the whole year")
  61. parser.add_argument("-n",
  62. action="store",
  63. type=int,
  64. help="show n months")
  65. parser.add_argument("-s","--sync",
  66. action="store_true",
  67. help="sync the calendar cache")
  68. args = vars(parser.parse_args())
  69. # some stubs for now
  70. if args["create"]:
  71. print("Not implemented")
  72. exit(0)
  73. #check some stuff, do some warnings, initiate the config, etc.
  74. if not os.path.exists(configpath):
  75. config['DEFAULT'] = {'uri': 'your caldav server here',
  76. 'user': 'your username here',
  77. 'password': 'your pass here',
  78. 'cache':os.getenv('HOME')+'/.cache/decal.json',
  79. 'cacheEnabled':1,
  80. 'syncAfter':1}
  81. print("Creating an empty config in ~/.config/decal.conf")
  82. with open(configpath,'w') as configfile:
  83. config.write(configfile)
  84. configfile.close()
  85. print("To properly utilize decal, please fill out the fields in the config")
  86. exit(1)
  87. for arg in ("user","password","uri"):
  88. if not arg in config['DEFAULT']:
  89. print("The config is incomplete, please check the \""+arg+"\" field")
  90. exit(1)
  91. if config['DEFAULT']['uri'] == "your caldav server here":
  92. print("To properly utilize decal, please fill out the fields in the config")
  93. exit(1)
  94. #actual works begins here
  95. #generate the actual calendar, line by line, output an array of lines.
  96. #it works trust me, idk what is happening in this one but it works.
  97. def gencal(year,month,firstweekday=6,cell_modifier=lambda d: d,append_year=True):
  98. cal = calendar.Calendar(firstweekday=firstweekday)
  99. lines = [""]*6
  100. monthstart = False
  101. counter = 0
  102. for curdate in cal.itermonthdates(year,month):
  103. lines[counter//7]
  104. day = str(curdate)[-2:]
  105. if day == "01":
  106. monthstart = not monthstart
  107. if monthstart:
  108. lines[counter//7] += cell_modifier(day)
  109. else:
  110. lines[counter//7] += " "
  111. lines[counter//7] +=" "
  112. counter+=1
  113. weeklines = ["Mo","Tu","We","Th","Fr","Sa","Su"]
  114. for times in range(firstweekday):
  115. weeklines.append(weeklines.pop(0))
  116. lines.insert(0," ".join(weeklines)+" ")
  117. lines.insert(0,date(year,month,1).strftime("%B %Y").center(21))
  118. lines[-1] += " "*(21-len(lines[-1]))
  119. return lines
  120. # introduce some generic color names (idk how this will work, ig through parsing summaries? maybe? we'll figure that out ig)
  121. color_names = {
  122. "red":"0;31",
  123. "green":"0;32",
  124. "brown":"0;33",
  125. "orange":"0;33",
  126. "blue":"0;34",
  127. "purple":"0;35",
  128. "cyan":"0;36",
  129. "yellow":"1;33",
  130. "white":"1;37",
  131. "blink":"5",
  132. "bold":"1",
  133. "italic":"3",
  134. "underline":"4",
  135. "inverse":"7",
  136. "strikethrough":"9",
  137. "light red":"1;31",
  138. "light green":"1;32",
  139. "light blue":"1;34",
  140. "light purple":"1;36",
  141. "light cyan":"0;37"
  142. }
  143. # add a function to colorize terminal text
  144. def colorize(text,c):
  145. if c in color_names:
  146. return "\033["+color_names[c]+"m"+text+"\033[0m"
  147. return "\033[38;2;"+str(c[0])+";"+str(c[1])+";"+str(c[2])+"m"+text+"\033[0m"
  148. #calculate offset in months
  149. def span(year,month,offset):
  150. return year+((month+offset-1)//12),((month+offset-1)%12)+1
  151. #get 2 date values - start value to scan from and end value to scan up to.
  152. def getbounds(y,m,offset):
  153. start = date(y,m,1)
  154. postnextmonth = span(y,m,offset)
  155. nextmonth = span(postnextmonth[0],postnextmonth[1],-1)
  156. end = date(nextmonth[0],
  157. nextmonth[1],
  158. (date(postnextmonth[0],postnextmonth[1],1)-timedelta(days=1)).day)
  159. return start,end
  160. # generator for year/month pairs
  161. def ympairs(startdate,offset,dstart = False):
  162. if dstart == True:
  163. startdate = span(startdate[0],startdate[1],-1)
  164. for offset in range(offset):
  165. yield span(startdate[0],startdate[1],offset)
  166. return
  167. # we calculate start/end dates and an offset for later use according to arguments
  168. start = None
  169. end = None
  170. offset = None
  171. if args["1"]:
  172. start,end = getbounds(args["year"],args["month"],1)
  173. offset = 1
  174. elif args["3"]:
  175. y,m = span(args["year"],args["month"],-1)
  176. start,end = getbounds(y,m,3)
  177. offset = 3
  178. elif args["n"]:
  179. start,end = getbounds(args["year"],args["month"],args["n"])
  180. offset = args["n"]
  181. else:
  182. start,end = getbounds(args["year"],args["month"],1)
  183. offset = 1
  184. # aggregate selected calendars
  185. def aggregateCalendars(calendars):
  186. calendars2 = []
  187. cals = config['DEFAULT']["calendars"].split(",")
  188. for cal in calendars:
  189. if cal.name in cal:
  190. calendars2.append(cal)
  191. return calendars2
  192. def daysOfEvent(event):
  193. event = event.vobject_instance.vevent.contents
  194. curdate = event["dtstart"][0].value
  195. enddate = event["dtend"][0].value
  196. while curdate <= enddate:
  197. if type(curdate) == datetime.datetime:
  198. yield str(curdate.date())
  199. else:
  200. yield str(curdate)
  201. curdate += timedelta(days=1)
  202. return
  203. def jsonifyEvent(event):
  204. event = event.vobject_instance.vevent.contents
  205. evdata = {
  206. "uid": event["uid"][0].value,
  207. "dtstart": str(event["dtstart"][0].value),
  208. "dtend": str(event["dtend"][0].value)
  209. }
  210. # may or may not be there, idk why. vobject format is really weird
  211. for key in ["summary","description","status"]:
  212. if key in event:
  213. evdata[key] = event[key][0].value
  214. return evdata
  215. def generateDateTree(calendars):
  216. events = {}
  217. for cal in calendars:
  218. events_fetched = cal.date_search(start,end)
  219. for event in events_fetched:
  220. for date in daysOfEvent(event):
  221. if not date in events:
  222. events[date] = []
  223. events[date].append(jsonifyEvent(event))
  224. return events
  225. cache = None
  226. if "cache" in config['DEFAULT']:
  227. if os.path.exists(config['DEFAULT']['cache']):
  228. with open(config['DEFAULT']['cache'],"r") as file:
  229. cache = json.loads(file.read())
  230. def updateCriteria():
  231. if not ("cacheEnabled" in config["DEFAULT"]):
  232. return False
  233. if (not (config['DEFAULT']["cacheEnabled"] == "1")):
  234. return False
  235. if not cache:
  236. return True
  237. date = datetime.datetime.strptime(cache["lastsync"],"%Y-%m-%d")
  238. if args["sync"]:
  239. return True
  240. return (date.date() <= today - timedelta(days=int(config['DEFAULT']["syncAfter"])))
  241. #Update cache if update criteria are met.
  242. if updateCriteria():
  243. # connect to the DAV and receive calendars
  244. client = caldav.DAVClient(url = config['DEFAULT']['uri'],
  245. username = config['DEFAULT']['user'],
  246. password = config['DEFAULT']['password'])
  247. principal = client.principal()
  248. calendars = principal.calendars()
  249. if "calendars" in config['DEFAULT']:
  250. calendars = aggregateCalendars(calendars)
  251. cache = generateDateTree(calendars)
  252. cache["lastsync"] = str(today)
  253. with open(config['DEFAULT']['cache'],"w") as file:
  254. file.write(json.dumps(cache))
  255. events = cache
  256. if args["json"]:
  257. print(json.dumps(events))
  258. exit(0)
  259. # and now we're just generating calendar lines
  260. cal_prints = []
  261. selected_date = date(args['year'],args['month'],args['day'])
  262. for year,month in ympairs((args['year'],args['month']),offset,dstart=args['3']):
  263. # a function to colorize cells in a more or less generic way
  264. def lambdafunc(cell):
  265. day = date(year,month,int(cell))
  266. if str(day) in events:
  267. event = events[str(day)]
  268. uid = event[0]["uid"].encode()
  269. cell = colorize(cell,uid)
  270. if day == selected_date:
  271. cell = colorize(cell,"inverse")
  272. return cell
  273. cal_prints.append(gencal(year,
  274. month,
  275. cell_modifier=lambdafunc))
  276. # function to print 3 calendar lines at a time
  277. def printlines(c1,c2,c3):
  278. for count in range(len(c1)):
  279. line = ""
  280. line += c1.pop(0)+" "
  281. if len(c2) > 0: line += c2.pop(0)+" "
  282. if len(c3) > 0: line += c3.pop(0)+" "
  283. print(line)
  284. # array padding
  285. for count in range(3-(len(cal_prints)%3)):
  286. cal_prints.append([])
  287. # finally, print the fucking calendar
  288. for cal in range(0,len(cal_prints),3):
  289. printlines(cal_prints[cal],cal_prints[cal+1],cal_prints[cal+2])
  290. def printDailyEvents(events,evtrack=[]):
  291. for event in events:
  292. if not event in evtrack:
  293. uid = event["uid"].encode()
  294. print(colorize(event["dtstart"]+" - "+event["dtend"],uid)+":\n- "+event["summary"])
  295. evtrack.append(event)
  296. evtrack = []
  297. if args["d"]:
  298. printDailyEvents(events[str(selected_date)],evtrack=evtrack)
  299. exit(0)
  300. if args["m"]:
  301. for day in range(*calendar.monthrange(args['year'],args['month'])):
  302. key = str(date(args['year'],args['month'],day))
  303. if key in events:
  304. printDailyEvents(events[key],evtrack=evtrack)