File: Frigcal/code/Frigcal--source/icsfiletools.py

"""
=====================================================================================
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'''