AVV/MoBY Save to Calendar

adds a button to save a connection to your calendar

// ==UserScript==
// @name         AVV/MoBY Save to Calendar
// @namespace    http://tampermonkey.net/
// @version      0.9
// @description  adds a button to save a connection to your calendar
// @author       Kiki
// @match        https://fahrtauskunft.avv-augsburg.de/sl3+/trip*
// @match        https://bahnland-bayern.de/de/moby/efa/app/trip*
// @icon         https://cdn-icons-png.flaticon.com/512/45/45533.png
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    console.debug('AVV Save to Calendar');

    window.setTimeout(startTimer, 2000);
    //window.onbeforeunlaod = function() { console.log('unload'); };

    console.debug('AVV Save to Calendar - done');
})();


function startTimer()
{
    console.debug('startTimer');
    unsafeWindow.myTimer = function()
    {
        //console.debug('myTimer');
        unsafeWindow.doIt();
        window.setTimeout(unsafeWindow.myTimer, 1000);
        //console.debug('myTimer - done');
    };
    window.setTimeout(unsafeWindow.myTimer, 1000);
    console.debug('startTimer - done');
}

unsafeWindow.doIt = function()
{
    console.debug('doIt');

    var loc = window.location.href;
    //var buttonBoxSelector = 'div > main div > div:nth-child(3) > div > div > section > div > div + section + div > div > div > button + div';
    var buttonBoxSelector = 'div#root button + div';

    if(getSiteName() == "unknown")
    {
        console.debug('url not relevant: ' + loc);
        unsafeWindow.oldUrl = null;
        return;
    }
    if(!document.querySelector(buttonBoxSelector))
    {
        console.debug('relevant element not found');
        return;
    }
    /* url change does not happen when Aktuualisieren is pressed
       we need to check presence of our own button instead
    if(unsafeWindow.oldUrl && unsafeWindow.oldUrl == loc)
    {
        console.debug('URL has not changed');
        return;
    }*/
    if(document.getElementById(myCalendarButtonId) != null)
    {
        console.debug('calendar button already created, nothing to do');
        return;
    }
    else
    {
        unsafeWindow.oldUrl = loc;
    }

    createCalendarButton(document.querySelector(buttonBoxSelector));
    console.info('Calendar button created');
}

/////////////////////////////////////////////////////////////
var myCalendarButtonId = 'myCalendarButtonId';
function createCalendarButton(where)
{
    console.debug('createCalendarButton');

    var btn = where.firstChild.cloneNode(true);
    var newSvg = createElementFromHTML('<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 506.49"><path fill-rule="nonzero" d="M294.24 17.11C294.24 7.69 303.53 0 315.1 0s20.87 7.65 20.87 17.11v74.85c0 9.42-9.3 17.11-20.87 17.11s-20.86-7.65-20.86-17.11V17.11zm145.02 345.22v19.94c0 4.69-3.91 8.61-8.6 8.61h-34.24v34.26c0 4.67-3.92 8.59-8.61 8.59h-19.96c-4.67 0-8.59-3.86-8.59-8.59v-34.26h-34.27c-4.69 0-8.61-3.87-8.61-8.61v-19.94c0-4.74 3.88-8.61 8.61-8.61h34.27v-34.26c0-4.73 3.86-8.59 8.59-8.59h19.96c4.74 0 8.61 3.92 8.61 8.59v34.26h34.24c4.74 0 8.6 3.97 8.6 8.61zm-61.44-124.22c36.98 0 70.56 15.04 94.83 39.35C496.96 301.7 512 335.25 512 372.31c0 36.99-15.04 70.56-39.3 94.83-24.32 24.31-57.89 39.35-94.88 39.35-37.03 0-70.56-15.04-94.84-39.3-24.32-24.27-39.34-57.86-39.34-94.88 0-37.06 15.04-70.61 39.31-94.89l.69-.63c24.24-23.9 57.53-38.68 94.18-38.68zm78.74 55.41c-20.09-20.11-47.96-32.58-78.74-32.58-30.5 0-58.14 12.25-78.19 32.02l-.55.6c-20.15 20.14-32.62 48-32.62 78.75s12.46 58.6 32.61 78.75c20.1 20.13 47.98 32.6 78.75 32.6 30.76 0 58.65-12.47 78.77-32.58 20.11-20.12 32.58-48.01 32.58-78.77 0-30.75-12.47-58.61-32.61-78.79zM56.81 242.28c-1.18 0-2.24-5.2-2.24-11.57 0-6.38.94-11.53 2.24-11.53h56.94c1.19 0 2.25 5.2 2.25 11.53 0 6.39-.94 11.57-2.25 11.57H56.81zm90.78 0c-1.19 0-2.24-5.2-2.24-11.57 0-6.38.93-11.53 2.24-11.53h56.94c1.18 0 2.24 5.2 2.24 11.53 0 6.39-.94 11.57-2.24 11.57h-56.94zm90.77 0c-1.18 0-2.24-5.2-2.24-11.57 0-6.38.93-11.53 2.24-11.53h56.94c1.18 0 2.24 5.15 2.24 11.49a175.09 175.09 0 0 0-16.44 11.61h-42.74zM56.94 308.52c-1.18 0-2.24-5.2-2.24-11.57 0-6.39.93-11.58 2.24-11.58h56.94c1.18 0 2.24 5.19 2.24 11.58 0 6.37-.93 11.57-2.24 11.57H56.94zm90.77 0c-1.18 0-2.24-5.2-2.24-11.57 0-6.39.93-11.58 2.24-11.58h56.94c1.18 0 2.24 5.19 2.24 11.58 0 6.37-.93 11.57-2.24 11.57h-56.94zM57.06 374.8c-1.18 0-2.24-5.2-2.24-11.59 0-6.36.94-11.56 2.24-11.56H114c1.19 0 2.25 5.2 2.25 11.56 0 6.39-.94 11.59-2.25 11.59H57.06zm90.78 0c-1.19 0-2.25-5.2-2.25-11.59 0-6.36.94-11.56 2.25-11.56h56.94c1.18 0 2.24 5.2 2.24 11.56 0 6.39-.94 11.59-2.24 11.59h-56.94zM106.83 17.11c0-9.42 9.29-17.11 20.86-17.11s20.86 7.65 20.86 17.11v74.85c0 9.42-9.32 17.11-20.86 17.11-11.57 0-20.86-7.65-20.86-17.11V17.11zM22.98 163.64h397.39V77.46c0-5.79-4.73-10.51-10.52-10.51h-38.1c-6.39 0-11.57-5.2-11.57-11.57 0-6.38 5.18-11.58 11.57-11.58h38.1c18.59 0 33.7 15.12 33.7 33.71v136.81c-7.59-2.62-15.41-4.73-23.44-6.29v-21.38h.26H22.98v223.16c0 5.78 4.72 10.51 10.51 10.51h188.86c2.15 8.02 4.86 15.84 8.12 23.36H33.71C15.13 443.68 0 428.61 0 410.02V77.55c0-18.6 15.1-33.71 33.71-33.71h40.67c6.38 0 11.58 5.21 11.58 11.57 0 6.39-5.2 11.59-11.58 11.59H33.71c-5.79 0-10.53 4.7-10.53 10.51v86.16h-.2v-.03zm158.94-96.69c-6.37 0-11.57-5.2-11.57-11.57 0-6.38 5.2-11.58 11.57-11.58h77.55c6.39 0 11.59 5.2 11.59 11.58 0 6.37-5.2 11.57-11.59 11.57h-77.55z"/></svg>');
    var oldSvg = btn.querySelector('path');
    var svgClass = oldSvg.getAttribute("class");
    oldSvg.replaceWith(newSvg);
    // we need to add the original class to the new SVG
    newSvg.setAttribute("class", svgClass)

    // todo: change click event handler

    where.insertBefore(btn, where.firstChild);

    btn.firstChild.setAttribute("title", "Fahrt in den Kalender eintragen");
    btn.firstChild.setAttribute("aria-label", "Fahrt in den Kalender eintragen");
    btn.firstChild.onclick = addToCalendar;
    btn.id = myCalendarButtonId;
}

function createElementFromHTML(htmlString)
{
  var div = document.createElement('div');
  div.innerHTML = htmlString.trim();

  // Change this to div.childNodes to support multiple top-level nodes.
  return div.firstChild;
}

//////////////////////////////////////////////////////////
function getSiteName()
{
  var loc = window.location.href;

  if(loc.match(/https:\/\/fahrtauskunft\.avv-augsburg\.de\/sl3\+\/trip\/\d+\?/))
  {
      return "avv";
  }
  else if(loc.match(/https:\/\/bahnland-bayern\.de\/de\/moby\/efa\/app\/trip\/\d+\?/))
  {
      return "moby";
  }
  else
  {
      return "unknown";
  }
}

//////////////////////////////////////////////////////////
function addToCalendar()
{
    console.info('addToCalendar');

    // main info
    var mainInfo = document.querySelector('section >h2 + div > div > div:has( > p)') || document.querySelector('section >h2 + div > div > div > div:has( > p)');
    var divs = mainInfo.querySelectorAll('p');
    var from = divs[0].innerText;
    var to = divs[1].innerText;
    from = from.replace(/^.*?:/g, 'Fahrt von');
    to = to.replace(/^.*?:/g, 'nach');
    var x = mainInfo.querySelectorAll('p')[2].innerText.matchAll(/(Abfahrt|Ankunft) am ([0-9\.]*) um ([0-9:]*)/gm);
    x = Array.from(x)[0];
    var dt = x[2];
    // here we still have a bug: if the user asked for an arrival time, the date of departure may actually be the previous day
    var tm = x[3];
    var ySelector = '';
    switch(getSiteName())
    {
      case "avv":
            //ySelector = 'main section section div > p:not([style*="color"]):not([aria-hidden])';
            ySelector = 'div[id^="trip-overview-description-"] > div > div > div > div > span';
            break;
      case"moby":
            ySelector = 'div[id*="trip-overview-description"] div[style*="flex-direction"] > span';
            break;
      default:
            console.error('Unknown site name: ' + getSiteName());
            return; // should never happen
    }
    var y = document.querySelectorAll(ySelector);
    var tStart = innerTextWithoutChildren(y[0]);
    var tEnd = innerTextWithoutChildren(y[1]);
    // duration is split across multiple tags, if over 59 minutes. One for hours, one for minutes. Todo: combine (text, not numbers!)
    // but it is not used anyway, so dump it
    // var duration = y[2].innerText;
    console.info(from + ' ' + to + ' ' + dt + ' ' + tStart + '-' + tEnd);

    var description = "";
    unsafeWindow.bAbAn = false; // false: ab, true: an

    // step-wise info
    divs = document.querySelectorAll('div:has(>h3) > div > div > div > div > div');
    divs.forEach((div) => {
        //console.log(div);
        var cls = div.getAttribute("class");
        var p = div.querySelector('div.' + cls + ' > div > div > p,div.' + cls + ' > div > div > span');
        if(p != null && div.querySelector('div.' + cls + ' > div > div >div > div > div > p') != null && div.querySelector('div.' + cls + ' > div > div > div > svg') == null)
        {
            // departure or arrival step
            var time = p.innerText;
            var station = div.querySelector('div.' + cls + ' > div > div >div > div > div > p').innerText;
            var platform = ""; // no platform can happen
            if(div.querySelector('div.' + cls + ' > div > div > div > div > div:nth-child(2) > p') != null)
            {
                platform = div.querySelector('div.' + cls + ' > div > div > div > div > div:nth-child(2) > p').innerText;
            }

            var means = "";
            if(div.nextSibling != null)
            {
                div.nextSibling.querySelectorAll('div > div > div > span:not(:has(+ p))').forEach(function(item) { means += item.innerText + " "; });
                div.nextSibling.querySelectorAll('div > div > div > span:has(+ p)').forEach(function(item) { means += item.nextSibling.innerText + " "; });
                means = means.trim();
            }

            description = description + genAbAn() + ' ' + time + ' ' + station + ' ' + platform + ' ' + means + '\r\n';
        }
        else
        {
            var walkElements = div.querySelectorAll('div.' + cls + ' > div:nth-child(2) > div > div:nth-child(2) > div + button > span > div > div >div > p');
            //
            if(walkElements.length > 0)
            {
                //walk/wait step
                walkElements.forEach((walk) => {
                    description = description + walk.innerText + '\r\n';
                });
            }
            else if(div.querySelector('div.' + cls + ' > div:nth-child(2) > div > div:nth-child(2) > div > div > p') != null)
            {
                // means of transport step
                means = div.querySelector('div.' + cls + ' > div:nth-child(2) > div > div:nth-child(2) > div > div > p').innerText;
                var directionElement = div.querySelector('div.' + cls + ' > div:nth-child(2) > div > div:nth-child(2) > div > div > p + div > p,div.' + cls + ' > div:nth-child(2) > div > div:nth-child(2) > div > div > p + div > span');
                var direction = "";
                if(directionElement != null)
                {
                    direction = directionElement.innerText;
                }
                //var direction = div.querySelector('div.' + cls + ' > div:nth-child(2) > div > div:nth-child(2) > div > div > p + div > p,div.' + cls + ' > div:nth-child(2) > div > div:nth-child(2) > div > div > p + div > span').innerText;
                var durationElement = div.querySelector('div.' + cls + ' > div:nth-child(2) > div > div:nth-child(2) > div + button p');
                var duration = "";
                if(durationElement != null)
                {
                    duration = durationElement.innerText;
                }
                description = description + '  ' + means + ' ' + direction + ' ' + duration + '\r\n';
            }
        }
    });

    console.info(description);

    //console.info('start: ' + dt + ' - ' + tStart);
    //console.info('makeDate: ' + makeDate(dt, tStart));

    ical_download(from + ' ' + to, makeDate(dt, tStart), makeDate(dt, tEnd), description);
}

function innerTextWithoutChildren(element)
{
    return [].reduce.call(element.childNodes, function(a, b) { return a + (b.nodeType === 3 ? b.textContent : ''); }, '');
}

function genAbAn()
{
    var retVal = (unsafeWindow.bAbAn) ? "an" : "ab";
    unsafeWindow.bAbAn = !unsafeWindow.bAbAn;
    return retVal;
}


////////////////////////////////////////////////////////////
// helper functions to create and download ical
// based on https://gist.github.com/dudewheresmycode/ff1d364c1c6d787fe7ea

function ical_download(eventName, dtStart, dtEnd, description)
{
  //name of file to download as
  const fileName = 'fahrt.ics';

  var now = new Date();
  var ics_lines = [
  "BEGIN:VCALENDAR",
  "X-LOTUS-CHARSET:UTF-8",
  "VERSION:2.0",
  "PRODID:https://fahrtauskunft.avv-augsburg.de/",
  "METHOD:PUBLISH",
  "BEGIN:VTIMEZONE",
  "TZID:Europe/Berlin",
  "X-LIC-LOCATION:Europe/Berlin",
  "BEGIN:DAYLIGHT",
  "TZOFFSETFROM:+0100",
  "TZOFFSETTO:+0200",
  "TZNAME:CEST",
  "DTSTART:19700329T020000",
  "RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3",
  "END:DAYLIGHT",
  "BEGIN:STANDARD",
  "TZOFFSETFROM:+0200",
  "TZOFFSETTO:+0100",
  "TZNAME:CET",
  "DTSTART:19701025T030000",
  "RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10",
  "END:STANDARD",
  "END:VTIMEZONE",
  "BEGIN:VEVENT",
  "UID:fahrt-" + now.getTime() + "@avv-augsburg.com",
  "CLASS:PUBLIC",
  "SUMMARY:" + eventName,
  "DTSTART;TZID=Europe/Berlin:" + ISOdateString(dtStart),
  "DTEND;TZID=Europe/Berlin:" + ISOdateString(dtEnd),
  "DTSTAMP:" + ISOdateString(now),
  "DESCRIPTION:" + description.replace(/\r\n/g, '\\n').replace(/\n/g, '\\n'),
  "LAST-MODIFIED:" + ISOdateString(now),
  "END:VEVENT",
  "END:VCALENDAR"
  ];

  //var dlurl = 'data:text/calendar;base64,' + btoa(ics_lines.join('\r\n'));
  var dlurl = 'data:text/calendar;base64,' + b64EncodeUnicode(ics_lines.join('\r\n'));

  try
  {
    saveCalendar(dlurl, fileName);
  }
  catch(e)
  {
    console.error(e);
  }
}

// instead of btoa(), we use this, because the strings are unicode
function b64EncodeUnicode(str) {
    // first we use encodeURIComponent to get percent-encoded Unicode,
    // then we convert the percent encodings into raw bytes which
    // can be fed into btoa.
    return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
        function toSolidBytes(match, p1) {
            return String.fromCharCode('0x' + p1);
    }));
}

//iso date for ical formats
function ISOdateString(d)
{
  if(typeof d != 'object' || d.constructor.name != 'Date')
  {
    throw new Error('Parameter is not a date!');
  }
  else
  {
    return d.getFullYear() + zeroPadding(d.getMonth() + 1) + zeroPadding(d.getDate()) + 'T' + zeroPadding(d.getHours()) + zeroPadding(d.getMinutes()) + zeroPadding(d.getSeconds());
  }
}

//zero padding for data fixes
function zeroPadding(s)
{
  return ("0"+s).slice(-2);
}

function saveCalendar(fileURL, fileName)
{
  var save = document.createElement('a');
  save.href = fileURL;
  save.target = '_blank';
  save.download = fileName || 'unknown';

  var evt = new MouseEvent('click',
  {
      //'view': window,
      'bubbles': true,
      'cancelable': false
  });
  save.dispatchEvent(evt);
}

function makeDate(sDate, sTime)
{
  var dateParts = sDate.split(".");
  var timeParts = sTime.split(":");

  // month is 0-based, that's why we need dataParts[1] - 1
  var retVal = new Date(+dateParts[2], dateParts[1] - 1, +dateParts[0], +timeParts[0], +timeParts[1]);
  return retVal;
}