Adding SWA calendar events on iOS

One of my favorite pieces of home-grown automation is my swicsfix script, which edits the calendar event that Southwest Airlines provides after you make a reservation and adds alarms set to go off shortly before the 24-hour checkin window opens.1 It’s a great solution, especially when combined with Hazel, but I have to be at a Mac to use it. Since I often make reservations when I’m on the road with only my iPad and iPhone, I wanted an iOS-based solution. What I came up with is a combination of Workflow (which will soon be Shortcuts) and Pythonista.

Before getting into the details of the workflow and the script, let’s see how it works. We start on the Southwest webpage for the reservation. Tap on the “Add to calendar” link to bring up a popup with links to each flight in that reservation.

Southwest calendar links

In this case, it’s a round-trip reservation, so there are two links. Long-pressing on one of them brings up a popup menu of things that can be done with that link:

Long-press popup

Choosing “Share…” brings up the standard Sharing Sheet, from which we choose Workflow. That brings up a list of Action Extension workflows that accept URLs as their input.

Workflow popup

Choose “SWA to Calendar” from this list and wait for the workflow to run. The workflow ends up at Pythonista (something I want to change, but I haven’t figured out the best way yet) and soon an email will come with the calendar event for the flight as an attachment.

Emailed calendar entry

This is like any other event you get emailed. Tap on it to add it to one of your calendars.

Add emailed event to calendar

Because this is a round-trip reservation, go through the same steps for the other leg.

Now we’ll dig into the details. Here are the steps of the “SWA to Calendar” workflow.

SWA to Calendar workflow

It’s defined as an Action Extension, and it accepts URLs as input. The first step is to percent-encode the input URL. This is then used in the second step to create a new URL that will invoke Pythonista and run a script. The new URL is cut off in the screenshot because the variable at the end didn’t get word-wrapped properly. Here it is:

pythonista3://swicsfix-mail.py?root=icloud&action=run&argv=[URL Encoded Text]

where the [URL Encoded Text] part at the end shouldn’t be typed in literally. It’s a magic variable that Workflow should show at the bottom of the screen while you’re typing, and you enter it by tapping on it.

Workflow magic variables list

The last step is to open the URL just created, which will run the swicsfix-mail.py Pythonista script. Because I want this script synced to all my devices, I have it saved in iCloud, which is why the root=icloud part is in the URL above.2

Now we get to the meat of this workflow: the swicsfix-mail.py script.

python:
  1:  import requests
  2:  from icalendar import Calendar
  3:  import sys
  4:  import copy
  5:  from datetime import timedelta
  6:  import smtplib
  7:  from email.message import EmailMessage
  8:  from email.mime.text import MIMEText
  9:  import re
 10:  import keychain
 11:  
 12:  # Parameters
 13:  mailFrom = 'somebody@somewhere.com'
 14:  mailTo = 'somebody@somewhere.com'
 15:  fmServer = 'smtp.fastmail.com'
 16:  fmPort = 465
 17:  fmUser = keychain.get_password('fastmail', 'user')
 18:  fmPassword = keychain.get_password('fastmail', 'password')
 19:  
 20:  # iCalendar transformation function
 21:  def fixICS(s):
 22:    '''Fix Southwest Airlines iCalendar (ICS) text.
 23:    
 24:    Change the summary to the flight and confirmation numbers.
 25:    Delete the description and Microsoft-specific fields.
 26:    Set audible alarms to 24 hours + 15 minutes and
 27:    24 hours + 2 minutes before flight.
 28:    Return a tuple with the summary and the fixed iCalendar text.
 29:    '''
 30:      
 31:    cal = Calendar.from_ical(s)
 32:    event = cal.walk('vevent')[0]
 33:    alarm = event.walk('valarm')[0]
 34:    
 35:    # Make no changes if summary doesn't contain "Southwest Airlines".
 36:    if 'Southwest Airlines' not in event['summary']:
 37:      sys.exit()
 38:    
 39:    # The last word in the location is the flight number.
 40:    # The last word in the summary is the confirmation number.
 41:    flight = event['location'].split()[-1]
 42:    confirmation = event['summary'].split()[-1]
 43:    
 44:    # Erase the event's verbose description and rewrite its summary.
 45:    event['description'] = ''
 46:    event['summary'] = 'SW {} ({})'.format(flight, confirmation)
 47:    
 48:    # Get rid of the mistaken MS fields.
 49:    for e in ('x-microsoft-cdo-alldayevent', 'x-microsoft-cdo-busystatus'):
 50:      try:
 51:        del event[e]
 52:      except KeyError:
 53:        continue
 54:    
 55:    # Set an alarm to 24 hours, 15 minutes before the flight, rewrite its
 56:    # description, and make it audible.
 57:    alarm['trigger'].dt = timedelta(days=-1, minutes=-15)
 58:    alarm['description'] = 'Check in SW {} ({})'.format(flight, confirmation)
 59:    alarm['action'] = 'AUDIO'
 60:    alarm.add('ATTACH;VALUE=URI', 'Basso')
 61:    
 62:    # Delete any UIDs before copying.
 63:    for u in ('uid', 'x-wr-alarmuid'):
 64:      try:
 65:        del alarm[u]
 66:      except KeyError:
 67:        continue
 68:    
 69:    # Add a new alarm 24 hours, 2 minutes before the flight by copying
 70:    # the previous alarm and changing its trigger time.
 71:    newalarm = copy.deepcopy(alarm)
 72:    newalarm['trigger'].dt = timedelta(days=-1, minutes=-2)
 73:    event.add_component(newalarm)
 74:    
 75:    return event['summary'], cal.to_ical().decode()
 76:  
 77:  
 78:  # Get the iCal data by opening the URL passed in.
 79:  icalURL = sys.argv[1]
 80:  r = requests.get(icalURL)
 81:  summary, ics = fixICS(r.text)
 82:  
 83:  # Set the name of the attached file from the summary.
 84:  icsName = re.sub(r' \([^)]+\)', '', summary)
 85:  icsName = re.sub(r'[ /:]', '-', icsName) + '.ics'
 86:  
 87:  # Compose the message.
 88:  msg = EmailMessage()
 89:  msg['Subject'] = summary
 90:  msg['From'] = mailFrom
 91:  msg['To'] = mailTo
 92:  msg.set_content("Calendar entry\n")
 93:  
 94:  # Add the attachment.
 95:  ics = MIMEText(ics)
 96:  ics.add_header('Content-Disposition', 'attachment', filename=icsName)
 97:  msg.make_mixed()
 98:  msg.attach(ics)
 99:  
100:  # Send the message through FastMail.
101:  server = smtplib.SMTP_SSL(fmServer, port=fmPort)
102:  server.login(fmUser, fmPassword)
103:  server.send_message(msg)
104:  server.quit()

I’m not going to explain the fixICS function because it’s basically the same code as in the aforelinked post, just wrapped into a function. It takes the iCalendar text supplied by Southwest and returns a tuple with the “corrected” iCalendar text and the event summary.

Lines 12–17 establish parameters used later in the script. If you want to make your own version of this, you’ll have to change all of these to what fits your situation. The mailFrom and mailTo variables are the From and To entries in the email the script sends; they don’t have to be the same. The rest of the variables in this section are specific to FastMail, the email server I use. If you use another email server, you’ll have to change the server address, the port, and the login information. You may also have to change a function call down near the bottom of the script, which we’ll get to in a bit.

I use the Pythonista keychain library to avoid having the login information explicitly saved in the script. That, of course, means I have to put the login information into the keychain before running this script. It’s a short script:

python:
1:  import keychain
2:  
3:  keychain.set_password('fastmail', 'user', 'myusername')
4:  keychain.set_password('fastmail', 'password', 's3cr3tp455w0rd')

You could even do this interactively from the Pythonista console.

The script starts its real work on Line 78. It takes the URL passed in through the argv parameter defined in the second Workflow step and saves it in the icalURL variable. This URL is the for the Southwest link we long-pressed. Line 79 uses the Requests library to follow that link to the iCalendar text from Southwest. Line 80 calls fixICS to run convert it into the form I like. The summary (which we’ll use for the email subject line and the attachment file name) is saved in the summary variable, and the converted iCalendar text is stored in the ics variable.

The summary variable looks like this:

SW 1234 (WX84R4)

with the flight number first and then the confirmation number in parentheses. If it’s a flight with a connection, the two flights are separated by a slash:

SW 1234/5678 (WX84R4)

Lines 83–and 84 use this to create the attachment file name, icsName. Basically, they get rid of the confirmation number and change all spaces, slashes, and colons to hyphens. As far as I know, flight numbers never have colons, but thirty-plus years of dealing with Macs makes me paranoid about the possibility of a colon in a file name. Just as twenty-plus years of using Unix makes me paranoid about slashes.

Lines 87–97 create the email message. Lines 88–91 define the From and To addresses, the Subject, and the body of the message. Lines 94–97 define the iCalendar text in ics as a MIME type and add it to the message as an attachment.

Finally, Lines 100–103 send the message. Line 100, which opens a connection to the email SMTP server, assumes the server uses SSL authentication and encryption. This is true for FastMail and many other email servers but might not be true for yours, so you may need to change this line. The other ways of connecting to an SMTP server are described in the Python smtplib library documentation.

This script took a long time to write, not because it was especially complicated, but because I didn’t want to use email to add the event. I spent most of my time trying to serve the ICS data via HTTP. The attempts mostly looked like this:

python:
from http.server import BaseHTTPRequestHandler, HTTPServer
import webbrowser

class MyHandler(BaseHTTPRequestHandler):
  def do_GET(s):
    """Respond to a GET request."""
    s.send_response(200)
    s.send_header("Content-Type", "text/calendar")
    s.end_headers()
    s.wfile.write(ics.encode())
  def log_message(self, format, *args):
    return

# Start the server and open Safari.
port = 8888
httpd = HTTPServer(('127.0.0.1', port), MyHandler)
webbrowser.get('Safari').open_new_tab('http://localhost:{}'.format(port))
httpd.handle_request()

The idea was use Python to create a local HTTP server on port 8888 that would deliver the iCalendar data when called. The webbrowser.get line would then open Safari to make the call. When this worked, it was really slick, because there was no messing around with Mail. The script would put me in Safari and a popup would appear to allow me to add the event to my calendar (similar to the popup that appears when I tap the event attachment in Mail).

Unfortunately, it didn’t always work. The popup that allowed me to add the event to my calendar would often disappear as soon as it appeared. I tried different HTTP headers, different ways of calling Safari through webbrowser—nothing worked consistently. I even uploaded the iCalendar file to the leancrew server and linked Safari to that static file. That, too, would work sometimes but not always. So after much fruitless effort, I fell back to the email solution, which is clumsy but reliable.

If you have any suggestions for an HTTP-based solution, I’m all ears. In the meantime, I have something that works.


  1. Yes, Southwest now has early bird checkin that makes these alarms less useful, but I seldom use it. 

  2. It’s also the reason I couldn’t use Workflow’s “Run Script” action as the last step. It only knows how to run scripts saved in the local area, not in iCloud.