""" ===================================================================================== icsfiletools.py: icalendar-object interfaces [4.0] This module is largely replaced by the new storage.py, but its global lower-level update tools remain here to minimize clutter. ===================================================================================== """ from common import APPNAME, VERSION, time, datetime, os import icalendar from icalendar.parser import unescape_char # currently unused from icalendar.parser import escape_char, foldline # [2.0] for Unicode file text # app-global calendar/events indexes (circular import) import storage # for CalendarsTable, CalendarsDirty, EventsTable, Edate #==================================================================================== # ICS events edits: update in-memory data structures (run from event edit callbacks) #==================================================================================== def add_event_data(edate, widgetdata): """ Add new-or-pasted event to data structures (GUI updates GUI). Uses new widgetdata (GUI) with new uid, edate is true date. """ newuid = widgetdata.uid # new icalendar event object newvevent = new_vevent( uid=newuid, summary=widgetdata.summary, dtstart=edate.to_datetime_date(), description=widgetdata.description, category=widgetdata.category) icscalendar = storage.CalendarsTable[widgetdata.calendar] icscalendar.add_component(newvevent) # add to parsed data's list-like object storage.CalendarsDirty[widgetdata.calendar] = True # change: backup and write file on close # new app used-data index entry datenow = datetime.date.today() # datetime.date(y,m,d) edatenow = storage.Edate.from_datetime(datenow) # to own date for compares in sorts newicsdata = widgetdata # no .copy() needed: made anew in dialog newicsdata.veventobj = newvevent # link for update/delete later in this session newicsdata.orderby = edatenow # set for window refills later in this session if edate not in storage.EventsTable.keys(): storage.EventsTable[edate] = {} storage.EventsTable[edate][newuid] = newicsdata def delete_event_data(edate, icsdata): """ Delete event from data structures (GUI deletes GUI). Uses existing icsdata (index). If no more vevents on Save, one is added then (not here). """ # delete from icalendar event object icscalendar = storage.CalendarsTable[icsdata.calendar] vevent = icsdata.veventobj # link: avoid rewalk search icscalendar.subcomponents.remove(vevent) # assumes object compares work! storage.CalendarsDirty[icsdata.calendar] = True # change: backup and write file on close # delete from app used-data index uid = icsdata.uid del storage.EventsTable[edate][uid] # remove known event from date table if not storage.EventsTable[edate]: # also remove date if events now empty del storage.EventsTable[edate] def update_event_data(edate, icsdata, widgetdata): """ Update event in data structures (GUI updates GUI). Uses both icsdata (index) and widgetdata (gui). IN-PLACE change via references to shared, single objects is crucial for both icscalendar and index items, as index objects are registered for events, and there's just one icscalender object used at file regenerations time. In fact, cidata IS icsdata here: it could be updated more directly than currently done here. """ # update icalendar event object (in-place!) vevent = icsdata.veventobj # link: avoid rewalk search update_vevent( # assumes in-place changes work! vevent, widgetdata.summary, widgetdata.description, widgetdata.category) storage.CalendarsDirty[icsdata.calendar] = True # change: backup and write file on close [1.1] # update app used-data index (in-place!) uid = icsdata.uid cidata = storage.EventsTable[edate][uid] cidata.summary = widgetdata.summary cidata.description = widgetdata.description cidata.category = widgetdata.category assert cidata is icsdata # sanity check def update_event_summary(icsdata, summary): """ Update exiting vevent object's summary only, in-place. Used by Return key callback for inline summary field edits. TBD: this is probably unused in FC 4.0 - no Return handler? """ vevent = icsdata.veventobj # linked vevent object update_vevent_summary(vevent, summary) # update icalendar data (in-place!) storage.CalendarsDirty[icsdata.calendar] = True # change: backup and write file on close [1.1] #==================================================================================== # icalendar API interfaces #==================================================================================== """ Interface with the icalendar API to set/update data in its objects. obj.add(field, value) converts to internal types based on field + value. obj['field'] = val does not convert, and can leave some as invalid text. """ def replace_vevent_property(vevent, name, value): """ There seems no way to change apart from deleting and adding anew (tbd) """ if name in vevent: del vevent[name] # optional items may be missing (categories) vevent.add(name, value) # use field-specific library type encoding def update_vevent_summary(vevent, summary): """ Update existing vevent object's summary only, in-place. TBD: this is probably unused in FC 4.0 - no Return handler? """ summary = backslash_escape(summary) # [1.4] '\' -> '=5C'? replace_vevent_property(vevent, 'SUMMARY', summary) def update_vevent(vevent, summary, description, category): """ Update existing vevent object's properties, in-place. """ timenow = datetime.datetime.today() # datetime.datetime(y,m,d,h,m,s,ms) summary = backslash_escape(summary) # [1.4] '\' -> '=5C'? description = backslash_escape(description) replace_vevent_property(vevent, 'SUMMARY', summary) replace_vevent_property(vevent, 'DESCRIPTION', description) replace_vevent_property(vevent, 'CATEGORIES', category) # just one here replace_vevent_property(vevent, 'DTSTAMP', timenow) # set new modtime: raw dt def new_vevent(uid, summary, dtstart, description, category): """ Make new vevent object for new event, via icalendar api. dtstart is a datetime.date object for clicked date on Add dialogs, else None when making a new event on current date. """ datenow = datetime.date.today() # datetime.date(y,m,d) timenow = datetime.datetime.today() # datetime.datetime(y,m,d,h,m,s,ms) summary = backslash_escape(summary) # [1.4] '\' -> '=5C'? description = backslash_escape(description) vevent = icalendar.Event() vevent.add('UID', uid) vevent.add('SUMMARY', summary) # or [key]=val (see above) vevent.add('DTSTART', dtstart or datenow) # event's start date vevent.add('DTSTAMP', timenow) # vevent mod time vevent.add('CREATED', timenow) # vevent creation time vevent.add('DESCRIPTION', description) # a vtext, but str works here vevent.add('CATEGORIES', category ) # or a list (but frigcal uses just 1) return vevent def new_vcalendar(): """ On New, make new vcalendar object in memory, via icalendar api. A required sole event is added on Save if required (not here). """ vcalendar = icalendar.Calendar() vcalendar.add('VERSION', '2.0') # iCalendar version vcalendar.add('PRODID', f'{APPNAME} {VERSION}') # app version return vcalendar r""" ------------------------------------------------------------------------------ [1.4] workaround for unwanted transforms in the underlying icalendar lib: else these wind up dropping their literal '\' in transit to/from the file, and a 2-char literal '\n' in a text field is changed to a 1-char newline; to verify, enter text [a\nb\nc\;d\,e"f\"g] in a Summary or Description: its backslashes should save and load intact (all are mutated/dropped by the underlying lib without this patch); or, in Python, where \\ means \: >>> text = 'a\nb\nc\;d\,e"f\"g' # escapes interpreted >>> text = 'a\\nb\\nc\\;d\\,e"f\\"g' # each '\\' is 1 '\' >>> e = text.replace('\\', '=%2X' % ord('\\')) >>> e 'a=5Cnb=5Cnc=5C;d=5C,e"f=5C"g' >>> u = text.replace('=%2X' % ord('\\'), '\\') >>> u 'a\\nb\\nc\\;d\\,e"f\\"g' TBD: better solution in ics lib? this patch seems a hack, but the ics lib is undocumented and convoluted, and literal backslashes seem likely to be very rare in user text; an earlier version escaped just [r'\n', r'\;', r'\,', r'\"']; *CAVEAT*: this risks file portability, as it uses quoted-printable notation which may not be recognized by other calendar programs (a non-issue if there are no '\' or frigcal is the sole file user) --> made patch switchable in Configs; ------------------------------------------------------------------------------ """ # CROSS-MODULE GLOBAL HACK: set by App on startup and Settings # screen on changes because this file did not become class based retainLiteralBackslashes = True def backslash_escape(text): """ Escape text from gui: all '\' -> '=5C' ? """ if not retainLiteralBackslashes: return text else: return text.replace('\\', '=%2X' % ord('\\')) def backslash_unescape(text): """ Unescape text from file: all '=5C' -> '\' ? """ if not retainLiteralBackslashes: return text else: return text.replace('=%2X' % ord('\\'), '\\') #==================================================================================== # Event property generators #==================================================================================== def icalendar_datetime_now(): """ Formatted current date/time (str) """ now = datetime.datetime.today() # datetime.datetime(2014, 9, 7, 8, 48, 26, 277682) fmt = icalendar.vDatetime(now).to_ical() # bytes [str(fmt) is no-op, str(now)='2014-09-07' + time] return fmt.decode(encoding='utf8') # str '20140907T084826' ['yyyymmddThhmmss' (TBD: +'Z'?)] def icalendar_date_now(): """ Formatted current date (str) """ now = datetime.date.today() # datetime.date(2014, 9, 7) return icalendar.vDate(now).to_ical().decode(encoding='utf8') # str '20140907' def icalendar_unique_id(): """ Globally unique string: app+date+time+processid (str) This is unique only for this second: amend if required (see generate, init). There is an alternative uid generator in icalendar.tools which uses random. """ datetimestamp = time.strftime('date%y%m%d-time%H%M%S') processid = f'pid{os.getpid()}' return f'{APPNAME}-{datetimestamp}-{processid}' #==================================================================================== # [DEFUNCT] ICS events file creation: default file, or new files on request #==================================================================================== # [4.0] No longer used, because new-calendar and folder-path are now GUI ops. # Instead of making a default calendar file, initial runs and folder changes # both do a folder-path ask and load, and the load expects the user to followup # with a New if no calendar exists in the folder. New makes a new calendar in # memory only, and expects the user to Save to write the calendar's ICS file. '''OBSOLETE # make a new .ics iCalendar file with one event: a truly empty file does not work; # see UserGuide.html for iCalendar support model, the standard, and example content; # icalendar API can make these strings too (see new_vevent() above), but text is easy; # this logic is also used by makenewcalendar.py script, with name input at console, # but not by empty-calendar logic in generate code above (uses new_vevent() API); # calendar template: # requires 'version' + 'prodid', and at least 1 nested component initfile_calendartext = \ """BEGIN:VCALENDAR VERSION:2.0 PRODID:%(program)s %(version)s %(event)s END:VCALENDAR""" # nested event component template: # requires 'uid' + 'dtstamp' (+ 'dtstart' if no cal 'method') # [2.0] use Unicode symbols and make this event more descriptive # [2.0] use preset colored category: if it doesn't exist, uses white initfile_eventtext = \ """BEGIN:VEVENT DTSTART;VALUE=DATE:%(dtstart)s SUMMARY:✓ Calendar created DESCRIPTION:☞ Your %(calname)s iCalendar ics file was generated ☜ \\nIt is stored in your Calendars folder\\, and can now be selected in the \\nCalendar pulldown of event-edit dialogs opened on day clicks and pastes.\\n CATEGORIES:%(category)s CREATED:%(created)s DTSTAMP:%(dtstamp)s UID:%(uid)s X-%(program)s-VERSION:%(version)s END:VEVENT""" OBSOLETE'''