File: frigcal-products/unzipped/icsfiletools.py

"""
=====================================================================================
icsfiletools.py: frigcal's iCalendar (".ics") event files interface functions.

Tools to initialize, parse, edit, backup, and save ics iCalendar file(s).
Parser and generator rely on 3rd-party icalendar package, shipped with the
standard zip file, but perhaps not in redistributions (see UserGuide.html).

Note: this file contains 3 non-ASCII Unicode characters, but they work with
Python 3.X's default UTF8 source-code encoding, without a "# -*- coding" line.
=====================================================================================
"""

# Python stdlib
import os, sys, glob, shutil, traceback, time, datetime

# some errors here (TBD: move these to frigcal.py?)
# [1.5] startup errors (parser, file) are not caught/dispayed here (in main())
from tkinter.messagebox import showerror, showwarning


# local: names used in both frigcal script and icsfiletools (avoid redundant code)
from sharednames import Configs, trace, PROGRAM, VERSION, startuperror


# 3rd-party: ensure installed now, used in this file only
try:
    import icalendar                          # [1.5] console + GUI popup
except ImportError:
    # catch missing required packages 
    errtext = ('Error trying to import 3rd-party libraries.\n\n'
        'Required icalendar and/or pytz 3rd-party packages are not installed.\n\n'
        'See Dependencies in UserGuide.html, and fetch the required code from '
        'https://pypi.python.org/pypi/icalendar and https://pypi.python.org/pypi/pytz.')
    startuperror(errtext)   # no exc info
    # plus sys.exit(1) in startuperror
    

# [1.7] alas, cannot reset icalendar's default before it's import "from" (punt!)
# icalendar.parser_tools.DEFAULT_ENCODING = Configs.icsfilesUnicodeEncoding

from icalendar.parser import unescape_char          # currently unused
from icalendar.parser import escape_char, foldline  # [2.0] for Unicode file text


#====================================================================================
# ICS events data structures: created here, used here and in frigcal.py
#====================================================================================
"""
Global calendar and event data structures.
These are created, used, and changed here; and imported and used in frigcal.py.
Classes are used to give mnemonic attr names instead of positional index/unpack.

About EventsTable:

Events from all calendars are stored in a 2-level table, indexed by date and
unique id (UID) for quick access to both displayed and parsed data.   Because
events in the table are stamped with their calendar name, there is no need to
segregate by a 3rd calendar dimension (an initial design) --  the GUI displays
the union of all calendar files' events, and a single combined table still
supports display grouping by calendar, via sorts.

Events in the table also have links back to their parsed icalendar object for
fast in-place updates in memory (files are regenerated only on exit), and GUI
month windows keep UID-indexed tables of displayed events for quick deletion.
This model assumes events have UIDs, but the iCalendar standard requires these. 

Logical model:
EventsTable index uses 2-level dictionary table nesting: [date][uid]

CalendarsTable =               # Parsed file's raw calendar data (all data)
    {icsfilename:              # key: basename of .ics file parsed
         icalendar.cal         # icalendar.cal object, from parser    
    }

EventsTable =                  # Union of all events in all ics files (used data)
    {(m, d, y):                # key: date object for event start (and display) date
        {uid:                  # key: event's required unique id, for fast deletes
            (.calendar,        # ics file name
             .summary,         # event summary text
             .description,     # event description text
             .category,        # category (just 1/first used)
             .uid,             # event's required unique id, for easy access
             .orderby,         # creation (or else mod) date: display order sort
             .veventobj)       # reference link to vevent in icalendar.cal object
        }
    }

Release [1.1] addition (a new table to minimize code impacts):
CalendarsDirty =
    {icsfilename:              # key: basename of .ics file parsed
         Boolean               # True=calendar changed since startup or latest save
    }

Subtlety: IN-PLACE CHANGE to mutable objects shared by multiple references is
crucial in this model.  EventsTable index objects are referenced from registered
GUI event handlers, and there is just one copy of each CalendarsTable parsed
icalendar array, referenced from index objects and used at file regeneration time.
"""
#------------------------------------------------------------------------------------

# in code

CalendarsTable = {}    # {icsfilebasename: icalendar.cal.Calendar()}

EventsTable = {}       # {Edate(): {uid: EventData()} ]

CalendarsDirty = {}    # {icsfilebasename: Boolean} [1.1]


class Edate:
    """
    encapsulate and name event date numbers
    """
    def __init__(self, month, day, year):
        self.month = month                          # a namedtuple may do some of this, but not all
        self.day = day
        self.year = year

    @staticmethod                                   # copy datetime.{date, datetime} object attrs 
    def from_datetime(dateobject):                  # staticmethod optional if 3.X class calls only
        return Edate(dateobject.month, dateobject.day, dateobject.year)

    def to_datetime_date(self):
        return datetime.date(**self.__dict__)       # copy attrs to a datetime.date object

    def as_tuple(self):
        return (self.month, self.day, self.year)

    def as_string(self):
        return '%02d/%02d/%4d' % self.as_tuple()    # formatted display

    # support use in dictionary key, comparisons, sort key
    
    def __hash__(self):
        return hash(self.as_tuple())                # dictionary key (else by addr)
    
    def __eq__(self, other):
        return self.as_tuple() == other.as_tuple()  # comparisons (else by addr) 

    def __lt__(self, other):                        # sort keys (else fails)
        """
        [1.4] order events on dates by year first (y-m-d), not month;
        but keep as_tuple() format (m-d-y): it's also used for displays;
        was originally: return self.as_tuple() < other.as_tuple()
        """
        return ((self.year,  self.month,  self.day) <
                (other.year, other.month, other.day))

        
class EventData:
    """
    maximal attrs set, for both icsindex and widget values
    """
    def __init__(self,
        uid='', calendar='', summary='', description='', category='', orderby='', veventobj=None):
        
        self.uid = uid                      # event unique id, required by iCalendar std
        self.calendar = calendar            # calendar file's basename (not path)
        self.summary = summary              # for event summary in month, elsewhere
        self.description = description      # for extra text in footer, edit dialog
        self.category = category            # for colorizing event summary entries
        self.orderby = orderby              # for ordering events entries initially
        self.veventobj = veventobj          # a reference to icalender object: updates, deletes

        # or: for (name, value) in kargs.update(defaults): setattr(self, name, value)
        
    def copy(self):
        return EventData(**self.__dict___)  # use attrs dict for keyword args


#====================================================================================
# ICS events file parser: creates/returns in-memory data from file(s) 
#====================================================================================

def parse_ics_files():
    """
    load events from ics file(s), once at startup;
    on any exception here, script aborts (with popup+message): no way to proceed;
    don't reinitialize tables here: they are already imported by object via 'from'!;
    any errors here are caught and displayed by the caller: the data may be bad;
    calendar paths are relative to ".": frigcal.py os.chdir()s to install folder;

    VTEXT is a str subclass, and works as is: no extra str() conversion is needed;
    icalendar uses the UTF8 iCalendar default Unicode encoding scheme throughout;
    from_ical parse auto unescapes any "\X" icalendar sequences and does Unicode decoding;
    to_ical generate auto adds "\X" escapes if needed and does Unicode encoding;
    see also the note about reading all at once here, in generate_ics_files() ahead;
    """
    global CalendarsTable, EventsTable
    trace('Parsing', Configs.icspath)
    icsfiles = glob.glob(os.path.join(Configs.icspath, '*.ics'))

    # parse/index each ics file's data
    for icsfile in icsfiles:                                        # for all ics files in folder
        trace('loading', icsfile, '...')
        icsfilenopath = os.path.basename(icsfile)                   # strip directory path prefix
        
        # read ics text: use config=ics Unicode default (bytes works too)
        unicode = icalendar.parser.DEFAULT_ENCODING                 # changeable in source code only
        calfile = open(icsfile, 'r', encoding=unicode)              # [1.7] but don't hardcode 'utf8'
        try:
            icstext = calfile.read()                                # all at once: small files
        finally:
            calfile.close()                                         # exc or not (not 'with': eibti)

        # parse ics text: 3rd party package(s), major dependency
        icsarray = icalendar.cal.Calendar.from_ical(icstext)        # .cal optional but explicit
        CalendarsTable[icsfilenopath] = icsarray                    # add to global calendars table
        CalendarsDirty[icsfilenopath] = False                       # no changes yet at startup [1.1]

        # extract data use here, index by start date and uid
        for event in icsarray.walk('VEVENT'):                       # for all events in parsed calendar

            # icalendar.prop.vDDDTypes: .dt is datetime.{date or datetime} with .m/d/y
            startdate = event['DTSTART']                # required start date
            datestamp = event['DTSTAMP']                # required modtime: display order if no created
            createdon = event.get('CREATED', None)      # optional createtime: for display order

            # icalendar.prop.vText (VText): .to_ical() is bytes, str() decodes auto
            uniqueid  = event['UID']                    # required id, for update searches
            labeltext = event.get('SUMMARY', '')        # for main label, and view/edit dialog
            extratext = event.get('DESCRIPTION', '')    # for footer, and view/edit dialog
            category  = event.get('CATEGORIES', '')     # for coloring, and view/edit dialog

            if isinstance(category, list):
                category = category[0]                  # if multiple, use just one (first) for coloring

            # vdates: .dt is a datetime.date/datetime object
            # transfer to custom date type for better control here
            eventdate = Edate.from_datetime(startdate.dt)
            if createdon:
                orderdate = Edate.from_datetime(createdon.dt)
            else:
                orderdate = Edate.from_datetime(datestamp.dt)

            # vtext: see above--ok as is, but undo odd '\X' escapes in some existing data
            labeltext = labeltext.replace('\\"', '"')
            extratext = extratext.replace('\\"', '"')       # '\"' -> '"'
            
            # [1.4] undo =5C's from file, restoring '\'; see unescape function ahead
            labeltext = backslash_unescape(labeltext)
            extratext = backslash_unescape(extratext)       # '=5C' -> '\'
            
            # index this file's event data for date by start m/d/y
            icsdata = EventData(uid=uniqueid,
                                calendar=icsfilenopath,
                                summary=labeltext,
                                description=extratext,
                                category=category,
                                orderby=orderdate,
                                veventobj=event)
            
            if eventdate not in EventsTable.keys():            # .keys(), bcause EIBTI
                EventsTable[eventdate] = {}                    # first event for this date
            EventsTable[eventdate][uniqueid] = icsdata         # add to global events union table


#====================================================================================
# 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 = CalendarsTable[widgetdata.calendar]
    icscalendar.add_component(newvevent)         # add to parsed data's list-like object
    CalendarsDirty[widgetdata.calendar] = True   # change: backup and write file on close [1.1]

    # new used-data index
    datenow  = datetime.date.today()             # datetime.date(y,m,d)
    edatenow = 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 EventsTable.keys():
        EventsTable[edate] = {}
    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 exit, one is added then (not here);
    """
    # delete from icalendar event object              
    icscalendar = CalendarsTable[icsdata.calendar]
    vevent = icsdata.veventobj                     # link: avoid rewalk search
    icscalendar.subcomponents.remove(vevent)       # assumes object compares work!
    CalendarsDirty[icsdata.calendar] = True        # change: backup and write file on close [1.1]

    # delete from used-data index
    uid = icsdata.uid
    del EventsTable[edate][uid]        # remove known event from date table
    if not EventsTable[edate]:         # also remove date if events now empty     
        del 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)
    CalendarsDirty[icsdata.calendar] = True        # change: backup and write file on close [1.1]

    # update used-data index (in-place!)
    uid = icsdata.uid
    cidata = 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;
    """
    vevent = icsdata.veventobj                 # linked vevent object
    update_vevent_summary(vevent, summary)     # update icalendar data (in-place!)
    CalendarsDirty[icsdata.calendar] = True    # change: backup and write file on close [1.1] 


#------------------------------------------------------------------------------------

"""
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):
    # TBD: there seems no way to change apart from deleting and adding anew
    if name in vevent:
        del vevent[name]      # optional items may be missing (categories)
    vevent.add(name, value)   # use field-specific library type encoding


"""
[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;
"""

# warn of default just once if missing
retainLiteralBackslashes = Configs.retainLiteralBackslashes

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('\\'), '\\')


def update_vevent_summary(vevent, summary):
    """
    update exiting vevent object's summary only in-place
    isolate icalendar library api details here
    """
    summary = backslash_escape(summary)            # [1.4] '\' -> '=5C'?
    replace_vevent_property(vevent, 'SUMMARY', summary)
    

def update_vevent(vevent, summary, description, category):
    """
    update exiting vevent object in-place
    isolate icalendar library api details here
    """  
    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;
    isolate icalendar library api details here;
    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


#====================================================================================
# ICS events file generator: save in-memory data to ics file(s)
#====================================================================================

def generate_ics_files():
    """"
    store events in ics file(s), once at exit (and only after good backup);
    
    [1.5] note: calendar files are written here, and read in the parse function, all
    at once with .read() and .write(all), not in chunks via .read(N) and .write(part);
    these files are relatively small (the developer's largest is 876K for 12 years),
    and arbitrary buffer sizes should work on all platforms under Python 3.X today;
    an old Windows issue precluded writing buffers > 64M on network drives, but is
    fixed in recent libs (and 64M equates to 897 comparable years of events for the
    developer!); for more details and a chunk-based coding, see docetc\__chunkio__.py;
    """
    trace('Generating', Configs.icspath)
    for icsfilename in CalendarsTable.keys():                 #  .keys() optional but explicit
        if not CalendarsDirty[icsfilename]:
            # no changes since startup or save: don't backup/write this file [1.1]
            continue
        
        try:
            icsfilepath = os.path.join(Configs.icspath, icsfilename)
            icscalendar = CalendarsTable[icsfilename]

            if not icscalendar.walk('VEVENT'):    # a list
                # if no events remain after deletes, add a required one now (rare but true)
                vevent = new_vevent(                                  # fix name error [1.5]
                    dtstart=None,
                    uid=icalendar_unique_id() + '-' + icsfilename,    # make unique if > one [1.5]
                    summary='%s-generated event' % PROGRAM,
                    description='Required sole event generated.\n'    # add pointer text [1.5]
                                'To completely remove this calendar from the GUI,\n'
                                'delete its ics file from your Calendars folder.',
                    category='_dark red')                             # [2.0] color? (else white)
                icscalendar.add_component(vevent)                     # was 'system internal'

            trace('writing', icsfilepath, '...')
            text = icscalendar.to_ical()                       # big dependency, this!
            #tofile = open(icsfile, 'w', encoding='utf8')      # use ics default encoding?
            tofile = open(icsfilepath, 'wb')                   # actually, text is bytes not str
            try:
                tofile.write(text)                             # all at once: small files
            finally:
                tofile.close()                                 # flush, exc or not (with?: eibti)
            CalendarsDirty[icsfilename] = False                # no changes since last saved [1.1]

        except:
            # console message + popup dialog, skip file but keep going ([1.4] help pointer)
            traceback.print_exc()
            showerror('%s: Generate Error' % PROGRAM,
                'Error while generating file "%s".\n\n'
                'Prior version retained in Backups.\n'
                'See "Handling Backup/Save Errors" in "?" help for more details.\n\n'
                'Python exception text follows:\n\n%s\n%s'
                % (icsfilename, sys.exc_info()[0], sys.exc_info()[1]))
                

#====================================================================================
# ICS events file backup: copy ics file(s) to backup folder
#====================================================================================

def backup_ics_files(maxbkps=Configs.maxbackups):
    """
    backup ics files to subdir prior to saving new data (always!)
    automatically prunes oldest version(s) of files in Backups if needed;
    
    TBD: should backups no longer matching a calendar name in .. be removed?
    if not, renames can leave old backups, but users can delete these too,
    and it's not clear that deleting backups for a prior file is warranted;
    """
    trace('Backing up', Configs.icspath)
    allbackupspath = os.path.join(Configs.icspath, 'Backups')
    if not os.path.exists(allbackupspath):
        try:
            os.mkdir(allbackupspath)    # make backup folder if needed
        except:
            # initial mkdir failure
            taceback.print_exc()
            trace(sys.exc_info())
            showerror('%s: Backup Error' % PROGRAM,
                      'Error creating Backup subfolder: save cancelled.')
            return False

    for icsfilename in CalendarsTable.keys():
        if not CalendarsDirty[icsfilename]:
            # no changes since startup or save: don't backup/write this file [1.1]
            continue

        try:
            # trim backups folder if needed
            # keeps most recent N-1 of each, based on sort order of bkp names

            basename = icsfilename
            backuppatt  = 'date*-time*--' + basename
            currbackups = glob.glob(os.path.join(allbackupspath, backuppatt))
            currbackups.sort(reverse=True)
            prunes = currbackups[(Configs.maxbackups - 1):]      # earliest last
            for prunee in prunes:                                # globs have paths
                trace('pruning', prunee)
                try:                                             # [1.5] file deletes can fail:
                    os.remove(prunee)                            # but don't cancel bkp/save
                except:
                    traceback.print_exc()                        # or trace(sys.exc_info())
                    showwarning('%s: Pruning Error' % PROGRAM,
                       'Error while pruning file:\n"%s"\n\n'
                       'File skipped, but backup/save continued.\n\n'
                       "If not deleted on next backup/save, check this file's permissions."
                        % prunee)

            # transfer current file to back folder/name
            # TBD: move is quick and may be less error prone,
            # but copy retains original file in case save fails

            pathname = os.path.join(Configs.icspath, icsfilename)
            datetimestamp = time.strftime('date%y%m%d-time%H%M%S')          # add date/time prefix
            backupname = '%s--%s' % (datetimestamp, basename)
            backuppath = os.path.join(allbackupspath, backupname)
            trace('saving', pathname, '\n\tto', backuppath)
            shutil.copy2(pathname, backuppath)                              # copy content (write)
                                       
            # OR: os.rename(icsfile, backuppath)                            # or move quickly?
            # OR: open(backuppath, 'wb').write(open(icsfile, 'rb').read())  # or manual copy?
            
        except:
            # on any and first failure for this file ([1.4] help pointer)
            traceback.print_exc()
            showerror('%s: Backup Error' % PROGRAM,
                   'Error while backing up file "%s".\n\n'
                   'Save cancelled, prior backups retained.\n'
                   'See "Handling Backup/Save Errors" in "?" help for more details.\n\n'
                   'Python exception text follows:\n\n%s\n%s'
                    % (icsfilename, sys.exc_info()[0], sys.exc_info()[1]))
            return False  # give up now: won't save, so no reason to backup any others
    
    return True  # True = all worked, False = failed and stopped


#====================================================================================
# ICS events file creation: default file, or new files on request
#====================================================================================

# 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"""


# [2.0] when the default event is generated in first run, also make
# an event that gives the text of the Unicode cheatsheet file; this
# has been too obscure to find, and seems important to underscore
initunicode_eventtext = \
"""BEGIN:VEVENT
DTSTART;VALUE=DATE:%(dtstart)s
SUMMARY:Unicode cheatsheet
DESCRIPTION:%(filetext)s
CATEGORIES:_wheat
CREATED:%(created)s
DTSTAMP:%(dtstamp)s
UID:%(uid)s
X-%(program)s-VERSION:%(version)s
END:VEVENT"""


def init_default_ics_file(forcename=''):
    """
    if forcename is passed in, make a new calendar file with basename = forcename;
    else make a default calendar file if no .ics is present on first (or later) startup;
    any errors here are caught and displayed by the caller: the path setting may be bad;
    """
    if forcename or not glob.glob(os.path.join(Configs.icspath, '*.ics')):
        basename = forcename or ('%s-default-calendar.ics' % PROGRAM)
        initfilepath = os.path.join(Configs.icspath, basename)
        trace('Creating new calendar file:', initfilepath)          # not 'initial' [1.5]
        initfile = open(initfilepath, 'w', encoding='utf8')         # icalendar default

        # initial file creation notice event
        datetimenow = icalendar_datetime_now()
        initeventtext = initfile_eventtext % dict(
            dtstart=icalendar_date_now(),
            created=datetimenow,
            dtstamp=datetimenow,
            uid=icalendar_unique_id(),
            program=PROGRAM.upper(),
            version=VERSION,
            category='+blues' if not forcename else '+greens',
            calname='default' if not forcename else '"%s"' % forcename)

        # [2.0] echo Unicode cheatsheet file as an event too, if first run        
        if not forcename:
            try:
                unicodefilename = os.path.join('docetc', 'unicode-cheat-sheet.txt')
                unicodefiletext = open(unicodefilename, encoding='utf8').read()
                unicodefiletext = icalendar.parser.escape_char(unicodefiletext)
                unicodefiletext = icalendar.parser.foldline(unicodefiletext)
            except:
                pass  # punt on any file/encoding error: just a convenience
            else:
                datetimenow = icalendar_datetime_now()
                initeventtext += '\n' + initunicode_eventtext % dict(
                    dtstart=icalendar_date_now(),
                    created=datetimenow,
                    dtstamp=datetimenow,
                    uid=icalendar_unique_id() + '-unicodefile',   # else same second=id!
                    program=PROGRAM.upper(),
                    version=VERSION,
                    filetext=unicodefiletext)

        # insert event(s) into calendar structure
        initfiletext = initfile_calendartext % dict(
            program=PROGRAM,
            version=VERSION,
            event=initeventtext)
            
        initfile.write(initfiletext)
        initfile.close()


def ask_init_default_ics_file():
    # this is imported and used by the makecalendar.py script
    try:
        basename = input('Name of calendar file to create (without .ics)? ' )
    except EOFError:
        basename = ''
    if basename:
        init_default_ics_file(basename + '.ics')


def icalendar_datetime_now():
    # get formatted current date/time
    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():
    # get formatted current date
    now = datetime.date.today()               # datetime.date(2014, 9, 7)
    return icalendar.vDate(now).to_ical().decode(encoding='utf8')    # str '20140907'
    
def icalendar_unique_id():
    """
    get globally unique string: prog+date+time+processid
    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 = 'pid%s' % str(os.getpid())
    return PROGRAM + '-' + datetimestamp + '-' + processid



[Home page] Books Code Blog Python Author Train Find ©M.Lutz