File: Frigcal/code/Frigcal--source/tools/convert_legacy_colors.py

#!/usr/bin/python3
r"""
==================================================================
Change legacy color settings, part of Frigcal 4.0.

A one-time optional step for users of Frigcal 3.0 and earlier.
Run this file and read its docs.  Edit only the map_*.py files.


========
OVERVIEW
========

Frigcal 4.0's color scheme is not backward compatible with 
prior Frigcal versions.  In 4.0, event category is always 
just the name of a preset color, which is applied to event 
overscore bars.  The former (background, foreground) color
pairs and "#RRGGBB" hex values are unsupported, and both 
calendar colors and frigcal_configs.py are unused in 4.0.

This backward incompatibility is by design: the new scheme
uses preset colors that work on all devices without syncing
a settings file or redoing customizations on each device.

To aid migration, this script can be used to convert your 
calendars' prior calendar and category color settings to 
the new predefined color names all at once.  This avoids 
manual recolorization of events in your calendars.

This script requires an installed Python 3.X and may be 
run on Android and all PCs: Windows, macOS, and Linux.  On 
Android, the app that runs this script must have permission
to use your calendars folder, via All Files Access or other;
consult your Python-capable app for more details.

This script also must be run in either the app's complete
source-code folder or the folder made by unzipping its own
zipfile, because it requires the source code of the app's 
calendar-processing tools that are not present in PC app 
installs and are not accessible in the Android app install.


=====
USAGE
=====

1) *IMPORTANT* - make a backup copy of your calendar files 
   folder first, if you want to keep your calendars' original 
   categories or experiment with different mappings.  This 
   script changes categories in calendar files in place 
   without making any backup copies.

2) Get and unzip this script's zipfile.  Either download this 
   zipfile from www.quixotely.com/Frigcal or locate it in the 
   "tools" subfolder of the app's install folder on PCs (the 
   app install folder is not user-accessible on Android).

3) Get Python 3.X (3.12 or later recommended).  On PCs, get 
   it from www.python.org or other.  On Android, use an app 
   that runs Python scripts, such as Termux or PyDroid 3.

4) Edit either or both of the following files located in this 
   script's folder to specify your color-conversion mappings:

   map_calendar_colors.py
      Gives calendar=>color mappings, as {'calendarname': 'newcolor',}

   map_category_colors.py
      Gives old=>new category mappings, as {'oldcategory': 'newcolor',}

   These files use the same Python (and JSON) dictionary syntax
   as the former frigcal_configs.py file, so you can copy/paste 
   your former settings into these files and edit them there. 
   For more info, see these files' in-file docs and examples. 

   In both files, "newcolor" is the color name you wish to use
   in 4.0 and must be one of 4.0's preset category-color names,
   which are listed in both the new_color_names.py file in this 
   script's folder and the in-app Category pulldown of the event 
   dialog.  Invalid color names are reported, and calendar or 
   category names not present in your ICS file are ignored.

   This script sets a new category/color name in your events,
   and selects from calendar and category colors as follows:

   - For events _without_ a prior category name, color is taken 
     from either the event's calendar name in map_calendar_colors.py,
     an empty '' category name in map_category_colors.py, or a 
     default gray, and in that order.  

   - For events _with_ a prior category name, color is taken from 
     either the event's category name in map_category_colors.py, 
     its calendar name in map_calendar_colors.py, or a default gray,
     and in that order.  To support script reruns, an event is 
     ignored if its category is already a 4.0 color name.

   Note that calendar colors are used in this script for legacy
   settings, but the app itself uses only category color names.
   Aso note that all calendar and color names used in this script
   are case sensitive; "my-category" will not match "My-Category".  

5) Run this script from a command line in its home folder, with
   one optional argument - the path to your calendars folder, 
   which is asked if omitted.  Abstractly:

      $ cd scriptfolderpath
      $ python3 convert_legacy_colors.py calendarfolderpath

   On macOS and Linux, these two commands are typically run in 
   Terminal.  On Windows, run in Command Prompt or other, and 
   use "py" or "py -3" instead of "python3" in the second line.
   On Android, use "python" or "python3" in a Python-capable app.
   
   On all, omit the "$" prompt, replace "scriptfolderpath" with
   this script's folder, and replace "calendarfolderpath" with 
   your calendar files' folder.  Both paths might begin with 
   "C:\Users\me" for Windows, "/Users/me" for macOS, "/home/me" 
   for Linux, "X:\home\me" for WSL, and "/sdcard for Android.

   Example run:
      $ python3 convert_legacy_colors.py ../Calendars
      Starting script...
      Starting ICS-file conversion...
      Processing ../Calendars/frigcal-default-calendar.ics
      ++Updating ../Calendars/frigcal-default-calendar.ics
      Processing ../Calendars/Holidays.ics
      ICS-file conversion complete.
      Number files processed/converted: 2/1.

6) Once the script is run, your calendar folder's ICS file will
   have new category names that are also preset color names in 
   4.0, but your ICS files are otherwise unchanged.  Copy or 
   sync your updated ICS files to all your devices as desired.


========
OPTIONAL
========

The preceding steps convert your calendar files for use in 
Frigcal 4.0.  To use them in in _both_ Frigcal 4.0 and 3.0:

- First, edit the mapping files here and run this script to 
  convert categories in your calendar files from your prior 
  3.0 names in frigcal_configs.py to 4.0's new color names,
  as described above.

- Then, edit your 3.0 frigcal_configs.py to map the new 
  category names (which are now just color names) back to 
  the colors you formerly used in frigcal_configs.py.

When you're finished, 4.0 will use the new category names in
your calendar files as color names, and 3.0 will use your 
newname-to-oldcolor mappings in frigcal_configs.py. 

Abstractly, your legacy frigcal_configs.py starts like this:
   {'oldcategoryname': 'oldcategorycolor'}

Your edits in map_category_colors.py map to new colors for 4.0:
   {'oldcategoryname': 'newcolorname'}

You edit frigcal_configs.py to map back to prior colors for 3.0:
   {'newcolorname': 'oldcategorycolor'}

The 'oldcategorycolor' in your final 3.0 frigcal_configs.py 
can be any color supported in 3.0, including names, hex strings,
and (background, foreground) tuples; only 3.0 will see them.

For example, if your 3.0 frigcal_configs.py originally had:
   {'Business': ('black', '#00ffff')}

And you've mapped this to 4.0 colors for this converter as:
   {'Business': 'cyan'}

Then simply change your 3.0 frigcal_configs.py to map back:
   {'cyan': ('black', '#00ffff')}
   
The net effect makes calendars appear in 3.0 as they did formerly
but applies the new preset colors in 4.0.  If you instead wish to
use the _same_ colors in 3.0 and 4.0, simply map new 4.0 category 
names to new 4.0 colors in your frigcal_configs.py for 3.0:
    
   category_colors = {      # category _is_ color in 4.0
       'blue': 'blue',      # map 4.0 name to color for 3.0
       'red':  'red',
       # and so on, for all the new colors your calendars use
   }

Caveat: this works best if there is a 1:1 mapping from old 
category to new color name.  If more than one old category 
maps to the same new color, you'll need to choose which old 
color to use for all of the old categories mapped to the new 
color name.  To avoid this state, use a unique new color for
each old category name when applying this script.

You can also _keep_ legacy 3.0 color settings in your 3.0
configs file for any events that are not updated by this 
script.  They won't be used in 4.0's simpler color scheme.
==================================================================
"""


print('Starting script...')
import os, sys, glob, json, traceback


# load user-defined color maps: error messages per python

from map_calendar_colors import calendar_colors as calendar_map
from map_category_colors import category_colors as category_map


# validate and use run folder

if os.path.exists(os.path.join('..', 'icalendar')):
    # running in tools folder of dev tree or full source-code package
    sys.path.append('..')

elif os.path.exists('icalendar'):
    # running in the unzip folder of this script's own online zipfile
    sys.path.append('.')

else:
    print('This script does not have access to its required files.')
    print('Please consult its top-of-file docs and try again.')
    sys.exit(1)

import icalendar                              # required iCalendar parser/generator


# get and validate predefined colors

# new 4.0 colors, copied from app's settings.py; this redundancy is 
# a bad idea in general, but importing settings.py requires a host 
# of other modules and a settings-file load, and getting this from 
# the app's settings file requires users to first run the app once.

from new_color_names import newcategorycolors    # user-visible list

for (map, name) in [(category_map, 'category'), (calendar_map, 'calendar')]:
    if any(color not in newcategorycolors for color in map.values()):
        print('New colors can be only predefined category names.')
        print(f'Please edit map_{name}_colors.py\'s values and try again.')
        sys.exit(1)


# get and validate calendars-folder path

calendars_path = (                                # e.g., C:\Users\me\Frigcal
    sys.argv[1] if len(sys.argv) > 1 else         # e.g., /Users/me/Frigcal
    input('Path to your calendars folder? '))     # e.g., z:\home\me\Frigcal

try:
    if not os.path.isdir(calendars_path):
        print('Calendars path is not a valid directory.')
        print('Please check the path and try again.')
        sys.exit(1)
except Exception:
    # anything but sys.exit()!
    print('Cannot access your calendars path.')
    print('Please check permissions and try again.')
    sys.exit(1)

icspaths = glob.glob(os.path.join(calendars_path, '*.ics'))
if not icspaths:
    print('There are no ICS files in the calendars path.')
    print('Please check the path and try again.')
    sys.exit(1)


# convert calendar files

def convert_one_ics_file(icspath):
    print('Processing', icspath)

    default_color = 'gray'
    calendar = os.path.basename(icspath)

    # read ics data
    icsfile = open(icspath, 'r', encoding='utf8')
    icstext = icsfile.read()
    icsfile.close()

    # parse ics data 
    icsarray = icalendar.cal.Calendar.from_ical(icstext)    # str, not bytes

    # change categories
    anychanged = False
    for event in icsarray.walk('VEVENT'):
        category = event.get('CATEGORIES', None)
        if isinstance(category, list):
            category = category[0]    # uses first

        if category in ['', None]:
            # calendar has precedence
            if calendar in calendar_map.keys():
                newcolor = calendar_map[calendar]
            elif '' in category_map.keys():
                newcolor = category_map['']
            else:
                newcolor = default_color
            if category == '': 
                del event['CATEGORIES'] 
            event.add('CATEGORIES', newcolor)
            anychanged = True

        elif category in newcategorycolors:
            # assume already changed
            pass

        else:
            # category has precedence           
            if category in category_map.keys():
                newcolor = category_map[category]
            elif calendar in calendar_map.keys():
                newcolor = calendar_map[calendar]
            else:
                newcolor = default_color
            del event['CATEGORIES'] 
            event.add('CATEGORIES', newcolor)
            anychanged = True

    if anychanged:
        # generate ics data
        print('++Updating', icspath)
        newicstext = icsarray.to_ical()

        # save ics data
        icsfile = open(icspath, 'wb')    # bytes, not str
        icsfile.write(newicstext)
        icsfile.close()

    return anychanged   # True/False == 1/0
        

# process calendar files

print('Starting ICS-file conversion...')    

numprocessed = numconverted = 0
for icspath in icspaths:
    # for each ics file in calendars folder
    try:
        numconverted += convert_one_ics_file(icspath)
    except Exception as E:
        print('**Failed to convert and skipped', icspath)
        print('..Exception:  ', E)
        print('..Python info:', sys.exc_info())
        traceback.print_exc()   # show traceback too
    else:
        numprocessed += 1 

print('ICS-file conversion complete.')
print(f'Number files processed/converted: {numprocessed}/{numconverted}.')