WME History Enhancer

Enhances map object history entries

// ==UserScript==
// @name                WME History Enhancer
// @namespace           http://greasemonkey.chizzum.com
// @description         Enhances map object history entries
// @include             https://*.waze.com/*editor*
// @include             https://editor-beta.waze.com/*
// @include             https://beta.waze.com/*
// @exclude             https://www.waze.com/user/*editor/*
// @exclude             https://www.waze.com/*/user/*editor/*
// @grant               none
// @version             1.5
// ==/UserScript==

/*
=======================================================================================================================
Bug fixes - MUST BE CLEARED BEFORE RELEASE
=======================================================================================================================


=======================================================================================================================
Things to be checked
=======================================================================================================================

*/

/* JSHint Directives */
/* globals W: true */
/* globals I18n: */
/* globals trustedTypes: */
/* jshint bitwise: false */
/* jshint eqnull: true */
/* jshint esversion: 8 */

const WHE =
{
   showDebugOutput: false,
   enhanceHistoryItemID: null,
   enhanceHistoryItemType: null,
   itemHistoryDetails: null,
   itemHistoryLoaded: false,
   prevWazeBitsPresent: null,
   wazeBitsPresent: 0,

   ModifyHTML: function(htmlIn)
   {
      if(typeof trustedTypes === "undefined")
      {
         return htmlIn;
      }
      else
      {
         const escapeHTMLPolicy = trustedTypes.createPolicy("forceInner", {createHTML: (to_escape) => to_escape});
         return escapeHTMLPolicy.createHTML(htmlIn);
      }
   },
   AddLog: function(logtext)
   {
      if(WHE.showDebugOutput) console.log('WHE: '+Date()+' '+logtext);
   },
   GetRestrictionLanes: function(disposition)
   {
      let retval = '';
      if(disposition == 1) retval += 'All lanes';
      else if(disposition == 2) retval += 'Left lane';
      else if(disposition == 3) retval += 'Middle lane';
      else if(disposition == 4) retval += 'Right lane';
      else retval += ' - ';
      return retval;
   },
   GetRestrictionLaneType: function(laneType)
   {
      let retval = '';
      if(laneType === null) retval += ' - ';
      else
      {
         if(laneType == 1) retval += 'HOV';
         else if(laneType == 2) retval += 'HOT';
         else if(laneType == 3) retval += 'Express';
         else if(laneType == 4) retval += 'Bus lane';
         else if(laneType == 5) retval += 'Fast lane';
         else retval += ' - ';
      }
      return retval;
   },
   GetDirectionString: function(isForward)
   {
      if(isForward === true)
      {
         return 'A-B';
      }
      else
      {
         return 'B-A';
      }
   },
   GetVehicleDescription: function(vehicleType)
   {
      let retval = null;
      let i18nLookup = null;
      if(vehicleType === 0) i18nLookup = "TRUCK";
      else if(vehicleType === 256) i18nLookup = "PUBLIC_TRANSPORTATION";
      else if(vehicleType === 272) i18nLookup = "TAXI";
      else if(vehicleType === 288) i18nLookup = "BUS";
      else if(vehicleType === 512) i18nLookup = "RV";
      else if(vehicleType === 768) i18nLookup = "TOWING_VEHICLE";
      else if(vehicleType === 1024) i18nLookup = "MOTORCYCLE";
      else if(vehicleType === 1280) i18nLookup = "PRIVATE";
      else if(vehicleType === 1536) i18nLookup = "HAZARDOUS_MATERIALS";
      else if(vehicleType === 1792) i18nLookup = "CAV";
      else if(vehicleType === 1808) i18nLookup = "EV";
      else if(vehicleType === 1824) i18nLookup = "HYBRID";
      else if(vehicleType === 1840) i18nLookup = "CLEAN_FUEL";
      if(i18nLookup !== null)
      {
         retval = I18n.lookup("restrictions.vehicle_types."+i18nLookup);
      }
      return retval;
   },
   FormatTBR: function(tbrObj)
   {
      let retval = '';
      if(tbrObj.description !== null)
      {
         retval += '&nbsp;&nbsp;Reason: ' + tbrObj.description + '<br>';
      }

      if(tbrObj.timeFrames.length > 0)
      {
         retval += '&nbsp;&nbsp;Dates: ';
         if(tbrObj.timeFrames[0].startDate === null)
         {
            retval += 'all dates';
         }
         else
         {
            retval += tbrObj.timeFrames[0].startDate + ' to ' + tbrObj.timeFrames[0].endDate;
         }
         retval += '<br>';

         retval += '&nbsp;&nbsp;Days: ';
         if(tbrObj.timeFrames[0].weekdays & (1<<0)) retval += 'S';
         else retval += '-';
         if(tbrObj.timeFrames[0].weekdays & (1<<1)) retval += 'M';
         else retval += '-';
         if(tbrObj.timeFrames[0].weekdays & (1<<2)) retval += 'T';
         else retval += '-';
         if(tbrObj.timeFrames[0].weekdays & (1<<3)) retval += 'W';
         else retval += '-';
         if(tbrObj.timeFrames[0].weekdays & (1<<4)) retval += 'T';
         else retval += '-';
         if(tbrObj.timeFrames[0].weekdays & (1<<5)) retval += 'F';
         else retval += '-';
         if(tbrObj.timeFrames[0].weekdays & (1<<6)) retval += 'S';
         else retval += '-';
         retval += '<br>';

         retval += '&nbsp;Timespan: ';
         if(tbrObj.timeFrames[0].fromTime === null)
         {
            retval += 'all day';
         }
         else
         {
            retval += tbrObj.timeFrames[0].fromTime + ' to ' + tbrObj.timeFrames[0].toTime;
         }
         retval += '<br>';
      }

      let vtLength = 0;
      if(tbrObj.driveProfiles.BLOCKED !== undefined)
      {
         vtLength = tbrObj.driveProfiles.BLOCKED[0].vehicleTypes.length;
         if(vtLength > 0)
         {
            retval += '&nbsp;Vehicle types prohibited:<br>';
            for(let i=0; i<vtLength; i++)
            {
               retval += '&nbsp;&nbsp;'+WHE.GetVehicleDescription(tbrObj.driveProfiles.BLOCKED[0].vehicleTypes[i])+'<br>';
            }
         }
      }
      else if(tbrObj.driveProfiles.FREE !== undefined)
      {
         vtLength = tbrObj.driveProfiles.FREE[0].vehicleTypes.length;
         if(vtLength > 0)
         {
            retval += '&nbsp;Vehicle types allowed:<br>';
            for(let i=0; i<vtLength; i++)
            {
               retval += '&nbsp;&nbsp;'+WHE.GetVehicleDescription(tbrObj.driveProfiles.FREE[0].vehicleTypes[i])+'<br>';
            }
         }
      }
      else if(tbrObj.defaultType === "BLOCKED")
      {
         retval += '&nbsp;Blocked for all vehicle types<br>';
      }

      if(tbrObj.defaultType === "DIFFICULT")
      {
         retval += '&nbsp;Difficult Turn<br>';
      }
      return retval;
   },
   // IsObject and CompareTBRs modified from original code at
   // https://dmitripavlutin.com/how-to-compare-objects-in-javascript/
   IsObject: function(object) 
   {
      return object != null && typeof object === 'object';
   },
   CompareTBRs: function(tbr1, tbr2)
   {
      let retval = true;
      const keys1 = Object.keys(tbr1);
      const keys2 = Object.keys(tbr2);
      if (keys1.length !== keys2.length) 
      {
         retval = false;
      }
      else
      {
         for (const key of keys1) 
         {
            const val1 = tbr1[key];
            const val2 = tbr2[key];
            const areObjects = WHE.IsObject(val1) && WHE.IsObject(val2);
            if (areObjects && !WHE.CompareTBRs(val1, val2) || !areObjects && val1 !== val2) 
            {
               retval = false;
               break;
            }
         }
      }
      return retval;
   },
   FormatTBRDetails: function(tbrObj)
   {
      let retval = '';

      let hasOld = ((tbrObj.oldValue !== undefined) && (tbrObj.oldValue.restrictions !== undefined) && (tbrObj.oldValue.restrictions.length > 0));
      let hasNew = ((tbrObj.newValue !== undefined) && (tbrObj.newValue.restrictions !== undefined) && (tbrObj.newValue.restrictions.length > 0));

      if((hasOld === true) || (hasNew === true))
      {
         retval += '<i>TBR ';
         if(hasOld === false)
         {
            retval += 'Added:<br>';
            for(let i = 0; i < tbrObj.newValue.restrictions.length; ++i)
            {
               if(i > 0)
               {
                  retval += '<br>';
               }
               retval += WHE.FormatTBR(tbrObj.newValue.restrictions[i]);
            }
         }
         else if (hasNew === false)
         {
            retval += 'Deleted:<br>';
            for(let i = 0; i < tbrObj.oldValue.restrictions.length; ++i)
            {
               if(i > 0)
               {
                  retval += '<br>';
               }
               retval += WHE.FormatTBR(tbrObj.oldValue.restrictions[i]);
            }
         }
         else
         {
            retval += 'Changed:<br>';

            let oldStillPresent = [];
            let newStillPresent = [];
            for(let i = 0; i < tbrObj.oldValue.restrictions.length; ++i)
            {
               for(let j = 0; j < tbrObj.newValue.restrictions.length; ++j)
               {
                  if(WHE.CompareTBRs(tbrObj.oldValue.restrictions[i], tbrObj.newValue.restrictions[j]) == true)
                  {
                     oldStillPresent.push(i);
                     newStillPresent.push(j);
                  }
               }
            }

            let tbrsShown = 0;
            for(let i = 0; i < tbrObj.oldValue.restrictions.length; ++i)
            {
               if(oldStillPresent.indexOf(i) == -1)
               {
                  if(tbrsShown == 0)
                  {
                     retval += 'Removed:<br>';
                  }
                  else
                  {
                     retval += '<br>';
                  }
                  retval += WHE.FormatTBR(tbrObj.oldValue.restrictions[i]);
                  ++tbrsShown;
               }
            }
            if(tbrsShown > 0)
            {
               retval += '<br>';
            }
            tbrsShown = 0;
            for(let i = 0; i < tbrObj.newValue.restrictions.length; ++i)
            {
               if(newStillPresent.indexOf(i) == -1)
               {
                  if(tbrsShown == 0)
                  {
                     retval += 'Added:<br>';
                  }
                  else
                  {
                     retval += '<br>';
                  }
                  retval += WHE.FormatTBR(tbrObj.newValue.restrictions[i]);
                  ++tbrsShown;
               }
            }
         }
         retval += '</i>';
      }
      else
      {
         // not a TBR history entry...
      }

      return retval;
   },
   FormatClosureReason: function(rData)
   {
      let retval = "";
      if(rData == null)
      {
         retval = "<i>not provided</i>";
      }
      else
      {
         retval = rData;
      }
      return retval;
   },
   FormatClosureMTE: function(mData)
   {
      let retval = "";
      if(mData == null)
      {
         retval = "<i>not provided</i>";
      }
      else if(W.model.majorTrafficEvents.objects[mData] === undefined)
      {
         retval = "<i>data not available</i>";
      }
      else
      {
         retval = W.model.majorTrafficEvents.objects[mData].attributes.names[0].value;
      }
      return retval;
   },
   FormatClosureDetails: function(cObjA, cObjB)
   {
      let retval = '';
      if(cObjB === null)
      {
         retval += 'Reason: ' + WHE.FormatClosureReason(cObjA.reason) + '<br>';
         retval += 'MTE: ' + WHE.FormatClosureMTE(cObjA.eventId) + '<br>';
         retval += 'From: ' + cObjA.startDate + '<br>';
         retval += 'To: ' + cObjA.endDate + '<br>';
         retval += 'Direction: ' + WHE.GetDirectionString(cObjA.forward) + '<br>';
         retval += 'Ignore traffic: ' + cObjA.permanent;
      }
      else
      {
         if(cObjA.reason !== cObjB.reason)
         {
            retval += 'Reason: ' + WHE.FormatClosureReason(cObjA.reason);
            retval += ' <i>\>\>\> ' + WHE.FormatClosureReason(cObjB.reason) + '</i><br>';
         }
         if(cObjA.eventId !== cObjB.eventId)
         {
            retval += 'MTE: ' + WHE.FormatClosureMTE(cObjA.eventId);
            retval += ' <i>\>\>\> ' + WHE.FormatClosureMTE(cObjB.eventId) + '</i><br>';
         }
         if(cObjA.startDate !== cObjB.startDate)
         {
            retval += 'From: ' + cObjA.startDate;
            retval += ' <i>\>\>\> ' + cObjB.startDate + '</i><br>';
         }
         if(cObjA.endDate !== cObjB.endDate)
         {
            retval += 'To: ' + cObjA.endDate;
            retval += ' <i>\>\>\> ' + cObjB.endDate + '<i><br>';
         }
         if(cObjA.forward !== cObjB.forward)
         {
            retval += 'Direction: ' + WHE.GetDirectionString(cObjA.forward);
            retval += ' <i>\>\>\> ' + WHE.GetDirectionString(cObjB.forward) + '</i><br>';
         }
         if(cObjA.permanent !== cObjB.permanent)
         {
            retval += 'Ignore traffic: ' + cObjA.permanent;
            retval += ' <i>\>\>\> ' + cObjB.permanent + '</i><br>';
         }
      }
      return retval;
   },
   GetTIOString: function(tioValue)
   {
      let retval = I18n.lookup("turn_tooltip.instruction_override.no_opcode");
      if(tioValue !== null)
      {
         retval = I18n.lookup("turn_tooltip.instruction_override.opcodes." + tioValue);
      }
      return retval;
   },
   SegmentHistoryNameString: function(segID)
   {
      let retval = '';
      if(W.model.segments.objects[segID] !== undefined)
      {
         if(W.model.segments.objects[segID].attributes.primaryStreetID !== undefined)
         {
            const sID = W.model.segments.objects[segID].attributes.primaryStreetID;
            const sName = W.model.streets.objects[sID].attributes.name;
            if((sName === null) || (sName === ''))
            {
               retval += 'unnamed segment';
            }
            else
            {
               retval += sName;
            }
         }
         else
         {
            retval += 'unnamed segment';
         }
      }
      else
      {
         retval += 'unknown segment';
      }
      retval += ' (ID ' + segID + ')';
      return retval;
   },
   VenueHistoryFormatChanges: function(vObj, showExtendedDetails)
   {
      let tHTML = '';
      if(vObj.type === "IMAGE")
      {
         tHTML += '<br>Image update';
      }
      else if(vObj.type === "REQUEST")
      {
         if(vObj.subType === "UPDATE")
         {
            if(vObj.changedVenue !== undefined)
            {
               if(vObj.changedVenue.categories !== undefined)
               {
                  tHTML += '<br>Category update';

                  if(showExtendedDetails === true)
                  {
                     tHTML += "<ul>";
                     for(let j = 0; j < vObj.changedVenue.categories.length; ++j)
                     {
                        tHTML += '<li>'+vObj.changedVenue.categories[j];
                     }
                     tHTML += "</ul>";
                  }
               }
               if(vObj.changedVenue.entryExitPoints !== undefined)
               {
                  tHTML += '<br>Entry/exit point change';
               }
               if(vObj.changedVenue.description !== undefined)
               {
                  tHTML += '<br>Description change';
                  if(showExtendedDetails === true)
                  {
                     tHTML += '<ul><li>' + vObj.changedVenue.description + "</ul>";
                  }
               }
               if(vObj.changedVenue.name !== undefined)
               {
                  tHTML += '<br>Name change';
                  if(showExtendedDetails === true)
                  {
                     tHTML += '<ul><li>' + vObj.changedVenue.name + "</ul>";
                  }
               }
               if(vObj.changedVenue.openingHours !== undefined)
               {
                  tHTML += '<br>Opening hours change';
                  if(showExtendedDetails === true)
                  {
                  }
               }
               if(vObj.changedVenue.url !== undefined)
               {
                  tHTML += '<br>URL change';
                  if(showExtendedDetails === true)
                  {
                  }
               }
               if(vObj.changedVenue.services !== undefined)
               {
                  tHTML += '<br>Services change';
                  if(showExtendedDetails === true)
                  {
                     tHTML += '<ul>';
                     for(let i = 0; i < vObj.changedVenue.services.length; ++i)
                     {
                        tHTML += '<li>' + I18n.lookup('venues.services')[vObj.changedVenue.services[i]];
                     }
                     tHTML += '</ul>';
                  }
               }
               if(vObj.changedVenue.phone !== undefined)
               {
                  tHTML += '<br>Phone number change';
                  if(showExtendedDetails === true)
                  {
                  }
               }
               if(vObj.changedVenue.aliases !== undefined)
               {
                  tHTML += '<br>Alternate name change';
                  if(showExtendedDetails === true)
                  {
                     tHTML += '<ul>';
                     for(let i = 0; i < vObj.changedVenue.aliases.length; ++i)
                     {
                        tHTML += '<li>' + vObj.changedVenue.aliases[i];
                     }
                     tHTML += '</ul>';
                  }
               }
               if(vObj.changedVenue.brand !== undefined)
               {
                  tHTML += '<br>Brand change';
                  if(showExtendedDetails === true)
                  {
                  }
               }
            }
         }
         else if(vObj.subType === "FLAG")
         {
            tHTML += '<br>Flagged place';
         }
      }

      if(tHTML === '')
      {
         tHTML += '<br>No details';
      }
      return tHTML;
   },
   ParseHistoryObject_Venue: function(tObj)
   {
      let tHTML = '';
      //// Placeholder for now...
      //let aType = tObj.actionType;

      return tHTML;
   },
   ParseHistoryObject_VenueUpdateRequest: function(tObj)
   {
      let tHTML = '';
      let aType = tObj.actionType;

      if(aType === "DELETE")
      {
         if(tObj.oldValue.approve === true)
         {
            tHTML += '<b>Approved:</b>';
            tHTML += WHE.VenueHistoryFormatChanges(tObj.oldValue, false);
         }
         else if(tObj.oldValue.approve === false)
         {
            tHTML += '<b>Rejected:</b>';
            tHTML += WHE.VenueHistoryFormatChanges(tObj.oldValue, true);
         }
         else
         {
            tHTML += '<b>Closed, no further details</b>';
            // older venueUpdateRequest objects don't have fully populated oldValues...
         }
      }
      else if(aType === "ADD")
      {
         tHTML += '<b>Editor change pending approval:</b> ';
         tHTML += WHE.VenueHistoryFormatChanges(tObj.newValue, true);
      }

      return tHTML;
   },
   GetStreetBits: function(segID)
   {
      let retval = null;

      if(segID != undefined)
      {
         let sIdx = -1;
         let cIdx = -1;
         let stIdx = -1;
         for(let i = 0; i < WHE.itemHistoryDetails.streets.length; ++i)
         {
            if(WHE.itemHistoryDetails.streets[i].id == segID)
            {
               sIdx = i;
               break;
            }
         }
         
         const cityID = WHE.itemHistoryDetails.streets[sIdx].cityID;
         for(let i = 0; i < WHE.itemHistoryDetails.cities.length; ++i)
         {
            if(WHE.itemHistoryDetails.cities[i].id == cityID)
            {
               cIdx = i;
               break;
            }
         }

         const stateID = WHE.itemHistoryDetails.cities[cIdx].stateID;
         for(let i = 0; i < WHE.itemHistoryDetails.states.length; ++i)
         {
            if(WHE.itemHistoryDetails.states[i].id == stateID)
            {
               stIdx = i;
               break;
            }
         }

         let streetName = "";
         if(sIdx != -1)
         {
            streetName = WHE.itemHistoryDetails.streets[sIdx].name;
         }
         if(streetName == "")
         {
            streetName = "(none)";
         }

         let cityName = "";
         if(cIdx != -1)
         {
            cityName = WHE.itemHistoryDetails.cities[cIdx].name;
         }
         if(cityName == "")
         {
            cityName = "(none)";
         }
         let stateName = "";
         if(stIdx != -1)
         {
            stateName = WHE.itemHistoryDetails.states[stIdx].name;
         }
         if(stateName == "")
         {
            stateName = "(none)";
         }

         retval = [];
         retval.push(streetName);
         retval.push(cityName);
         retval.push(stateName);
      }

      return retval;
   },
   FormatSegmentNameDetails: function(tObj)
   {
      let oldStreetBits = null;
      let newStreetBits = null;

      let retval = "";
      const bitIDs = ["Street: ", "City: ", "County: "];

      if(tObj.oldValue != undefined)
      {
         oldStreetBits = WHE.GetStreetBits(tObj.oldValue.primaryStreetID);
      }
      if(tObj.newValue != undefined)
      {
         newStreetBits = WHE.GetStreetBits(tObj.newValue.primaryStreetID);
      }

      if(oldStreetBits != newStreetBits)
      {
         if(oldStreetBits == null)
         {
            retval += "Added:<br>";
            for(let i = 0; i < 3; ++i)
            {
               if(newStreetBits[i] != "(none)")
               {
                  retval += "&nbsp;";
                  retval += bitIDs[i] + newStreetBits[i]+"<br>";
               }
            }
         }
         else if(newStreetBits == null)
         {
            retval += "Deleted: "+oldStreetBits[0]+', '+oldStreetBits[1]+', '+oldStreetBits[2];
         }
         else
         {
            retval += "Changed:<br>";
            for(let i = 0; i < 3; ++i)
            {
               if(oldStreetBits[i] != newStreetBits[i])
               {
                  retval += "&nbsp;";
                  retval += bitIDs[i] + oldStreetBits[i]+" >> "+newStreetBits[i]+"<br>";
               }
            }
         }
      }
      return retval;
   },
   ParseHistoryObject_Segment: function(tObj)
   {
      let tHTML = '';
      let aType = tObj.actionType;
      
      if(aType === "UPDATE")
      {
         tHTML += WHE.FormatSegmentNameDetails(tObj);
         tHTML += WHE.FormatTBRDetails(tObj);
      }

      return tHTML;
   },
   ParseHistoryObject_RoadClosure: function(tObj)
   {
      let tHTML = '';
      let aType = tObj.actionType;

      let cObjA = null;
      let cObjB = null;

      tHTML += '<b>Road closure:</b> ';
      if(aType === "ADD")
      {
         tHTML += 'added<br>';
         cObjA = tObj.newValue;
      }
      else if(aType === "DELETE")
      {
         tHTML += 'deleted<br>';
         cObjA = tObj.oldValue;
      }
      else if(aType === "UPDATE")
      {
         tHTML += 'edited<br>';
         cObjA = tObj.oldValue;
         cObjB = tObj.newValue;
      }
      tHTML += 'ID: ' + tObj.objectID + '<br>';
      tHTML += WHE.FormatClosureDetails(cObjA, cObjB);

      return tHTML;
   },
   GetTurnAngleString: function(angle)
   {
      let retval = I18n.lookup('lanes.override.angles')[angle];
      if(retval == undefined)
      {
         retval = 'unknown angle';
      }
      return retval;
   },
   GetLanesString: function(lanesFrom, lanesTo)
   {
      let retval = '';
      if(lanesFrom == lanesTo)
      {
         retval = 'lane '+lanesFrom;
      }
      else
      {
         retval = 'lanes '+lanesFrom+'-'+lanesTo;
      }
      return retval;
   },
   GetGuidanceModeString: function(gMode)
   {
      let retval;
      if(gMode == 0)
      {
         retval = "Waze Selected";
      }
      else if(gMode == 1)
      {
         retval = "View Only";
      }
      else if(gMode == 2)
      {
         retval = "View and Hear";
      }
      else
      {
         retval = "";
      }
      return retval;
   },
   GetLaneGuidanceUpdateString: function(tObj)
   {
      let tHTML = '<br><i>';
      if(tObj.oldValue.lanes == undefined)
      {
         tHTML += 'Lane guidance added: ';
         let lanesFrom = tObj.newValue.lanes.fromLaneIndex + 1;
         let lanesTo = tObj.newValue.lanes.toLaneIndex + 1;
         tHTML += WHE.GetLanesString(lanesFrom, lanesTo);
         let turnAngle = tObj.newValue.lanes.laneArrowAngle;
         if(tObj.newValue.lanes.angleOverride != undefined)
         {
            turnAngle = tObj.newValue.lanes.angleOverride;
         }
         tHTML += ' '+WHE.GetTurnAngleString(turnAngle);
      }
      else if(tObj.newValue.lanes == undefined)
      {
         tHTML += 'Lane guidance removed';
      }
      else
      {
         tHTML += 'Lane guidance changed: ';

         let lanesFromNew = tObj.newValue.lanes.fromLaneIndex + 1;
         let lanesToNew = tObj.newValue.lanes.toLaneIndex + 1;
         let lanesFromOld = tObj.oldValue.lanes.fromLaneIndex + 1;
         let lanesToOld = tObj.oldValue.lanes.toLaneIndex + 1;
         tHTML += WHE.GetLanesString(lanesFromOld, lanesToOld);
         let lanesSame = ((lanesFromNew == lanesFromOld) && (lanesToNew == lanesToOld));

         let turnAngleNew = tObj.newValue.lanes.laneArrowAngle;
         if(tObj.newValue.lanes.angleOverride != undefined)
         {
            turnAngleNew = tObj.newValue.lanes.angleOverride;
         }
         let turnAngleOld = tObj.oldValue.lanes.laneArrowAngle;
         if(tObj.oldValue.lanes.angleOverride != undefined)
         {
            turnAngleOld = tObj.oldValue.lanes.angleOverride;
         }
         if((turnAngleOld != turnAngleNew) || (lanesSame == false))
         {
            tHTML += ' '+WHE.GetTurnAngleString(turnAngleOld)+' > ';
            if(lanesSame == false)
            {
               tHTML += WHE.GetLanesString(lanesFromNew, lanesToNew);
               tHTML += ' ';
            }
            tHTML += WHE.GetTurnAngleString(turnAngleNew);
         }

         let gModeNew = tObj.newValue.lanes.guidanceMode;
         let gModeOld = tObj.oldValue.lanes.guidanceMode;
         if(gModeOld != gModeNew)
         {
            tHTML += ' '+WHE.GetGuidanceModeString(gModeOld)+' > '+WHE.GetGuidanceModeString(gModeNew);
         }
      }
      tHTML += '</i>';

      return tHTML;
   },
   ParseHistoryObject_NodeConnection: function(tObj)
   {
      let tHTML = '';
      let aType = tObj.actionType;

      let outboundTR = (tObj.objectID.fromSegID === WHE.enhanceHistoryItemID);
      if(outboundTR === true)
      {
         tHTML += '<b>Outbound turn:</b> ';
      }
      else
      {
         tHTML += '<b>Inbound turn:</b> ';
      }
      if(aType == "DELETE") tHTML += 'disabled';
      else if(aType == "ADD") tHTML += 'enabled';
      tHTML += ' from ';

      if(outboundTR === true)
      {
         tHTML += 'node ';
         if(tObj.objectID.fromSegFwd === true)
         {
            tHTML += 'B';
         }
         else
         {
            tHTML += 'A';
         }
         tHTML += ' to ';
         tHTML += WHE.SegmentHistoryNameString(tObj.objectID.toSegID);
      }
      else
      {
         tHTML += WHE.SegmentHistoryNameString(tObj.objectID.fromSegID);
         tHTML += ' to node ';
         if(tObj.objectID.toSegFwd === true)
         {
            tHTML += 'A';
         }
         else
         {
            tHTML += 'B';
         }
      }
      tHTML += '<br>';

      if(aType === "UPDATE")
      {
         if((tObj.oldValue !== undefined) && (tObj.newValue !== undefined))
         {
            if(tObj.oldValue.instructionOpCode !== tObj.newValue.instructionOpCode)
            {
               tHTML += '<i>Instruction Override changed from '+WHE.GetTIOString(tObj.oldValue.instructionOpCode)+' to '+WHE.GetTIOString(tObj.newValue.instructionOpCode)+'</i><br>';
            }
            if(tObj.oldValue.lanes !== tObj.newValue.lanes)
            {
               tHTML += WHE.GetLaneGuidanceUpdateString(tObj);
            }
            if((tObj.oldValue.turnGuidance != null) && (tObj.newValue.turnGuidance == null))
            {
               tHTML += '<i>Turn guidance deleted</i><br>';
            }
         }
      }
      else if(aType === "ADD")
      {
         if(tObj.newValue !== undefined)
         {
            if(tObj.newValue.instructionOpCode !== null)
            {
               tHTML += '<i>Instruction Override set to ' + WHE.GetTIOString(tObj.newValue.instructionOpCode)+'</i><br>';
            }
         }
      }
      else if(aType === "DELETE")
      {
         if(tObj.oldValue !== undefined)
         {
            if(tObj.oldValue.instructionOpCode !== null)
            {
            }
         }
      }
      if(aType === "UPDATE")
      {
         tHTML += WHE.FormatTBRDetails(tObj);
         if((tObj.oldValue.navigable !== null) && (tObj.oldValue.navigable !== undefined))
         {
            if((tObj.newValue.navigable !== null) && (tObj.newValue.navigable !== undefined))
            {
               if((tObj.oldValue.navigable === false) && (tObj.newValue.navigable === true))
               {
                  tHTML += '<br><i>Turn enabled</i>';
               }
               else if((tObj.oldValue.navigable === true) && (tObj.newValue.navigable === false))
               {
                  tHTML += '<br><i>Turn disabled</i>';
               }
            }
         }

      }
      else if(aType === "ADD")
      {
         tHTML += WHE.FormatTBRDetails(tObj);
      }

      return tHTML;  
   },
   HistoryEntryToAdjust: function(lObj)
   {
      let retval;

      if
      (
         (lObj.getElementsByClassName('ca-geometry').length > 0) ||
         (lObj.getElementsByClassName('ca-roadType').length > 0) ||
         (lObj.getElementsByClassName('ca-fwdLaneCount').length > 0) ||
         (lObj.getElementsByClassName('ca-revLaneCount').length > 0)
      )
      {
         // For any history entry with one of these classes, the native details are sufficient, so don't touch them at all...
         retval = null;
      }
      else if (lObj.getElementsByClassName('turn-preview').length > 0)
      {
         // For turn previews, edit just the name so that it more clearly indicates which turn the preview relates to...
         retval = lObj.getElementsByClassName('ro-name')[0];
      }
      else
      {
         // For all other history entries, nuke the whole thing and replace with our own details...
         retval = lObj;
      }

      return retval;
   },
   EditPanelChanged: function()
   {
      let objType = W.selectionManager.getSelectedDataModelObjects()[0]?.type;
      if((objType == "segment") || (objType == "venue"))
      {
         if((document.querySelector('.toggleHistory') != null) && (document.querySelector('#wheHideHistoryBits') == null))
         {
            document.querySelector('.elementHistoryContainer').style.display='block';
            let hhbToggle = document.createElement('label');
            hhbToggle.id = "wheHideHistoryBits";
            let hhbText = "";
            if(objType == "segment")
            {
               hhbText = "Hide closures?";
            }
            else if(objType == "venue")
            {
               hhbText = "Hide rejected updates?"
            }
            hhbToggle.innerHTML = "<input type='checkbox' id='whe_cbHideHistoryBits' />" + hhbText;
            document.querySelector(".elementHistoryContainer").insertBefore(hhbToggle, null);

            document.querySelector('#whe_cbHideHistoryBits').addEventListener('click', WHE.UpdateHistoryEntries, true);
            document.querySelector('.toggleHistory').addEventListener('click', WHE.WaitHistoryShown, true);
         }
      }
   },
   WaitHistoryShown: function()
   {
      if(document.querySelector('.toggleHistory').innerText == "View History")
      {
         window.setTimeout(WHE.WaitHistoryShown, 100);
      }
      else
      {
         WHE.UpdateHistoryEntries();
      }
   },
   FinaliseInit: function()
   {
      let MO_EditPanel = new MutationObserver(WHE.EditPanelChanged);
      MO_EditPanel.observe(document.querySelector('#edit-panel'),{childList: true, subtree: true});

      WHE.AddInterceptor();
   },
   ArrayPushUnique: function(arr, obj)
   {
      let doPush = true;
      let sObj = JSON.stringify(obj);

      for(const i of arr)
      {
         if(JSON.stringify(i) == sObj)
         {
            doPush = false;
            break;
         }
      }
      if(doPush === true)
      {
         arr.push(obj);
      }
      return arr;
   },
   ParseHistoryResponse: function(body)
   {
      WHE.AddLog('history response received...');

      if(W.selectionManager.getSelectedDataModelObjects().length === 1)
      {
         WHE.enhanceHistoryItemID = W.selectionManager.getSelectedDataModelObjects()[0].attributes.id;
         WHE.AddLog('itemID = '+WHE.enhanceHistoryItemID);

         for(const t of body.streets.objects)
         {
            WHE.ArrayPushUnique(WHE.itemHistoryDetails.streets, t);
         }
         for(const t of body.cities.objects)
         {
            WHE.ArrayPushUnique(WHE.itemHistoryDetails.cities, t);
         }
         for(const t of body.states.objects)
         {
            WHE.ArrayPushUnique(WHE.itemHistoryDetails.states, t);
         }
         for(const t of body.countries.objects)
         {
            WHE.ArrayPushUnique(WHE.itemHistoryDetails.countries, t);
         }
         for(const t of body.users.objects)
         {
            WHE.ArrayPushUnique(WHE.itemHistoryDetails.users, t);
         }      
         for(const t of body.transactions.objects)
         {
            WHE.itemHistoryDetails.transactions.push(t);
         }
         WHE.UpdateHistoryEntries();
      }
      else
      {
         WHE.AddLog('selected item count != 1, which is odd...');
         WHE.enhanceHistoryItemID = null;
      }
   },
   ProcessNativeHistoryEntry: function(lObj, listEntries)
   {
      let newLObj = WHE.HistoryEntryToAdjust(lObj);
      if(newLObj !== null)
      {
         if(newLObj.getElementsByClassName('ca-name').length > 0)
         {
            // Keep the caption part of any entry where it's stored within the tx-changed
            // element rather than outside of it - this seems to be done for anything
            // where the caption is something other than "Allowed" or "Disallowed"
            listEntries.push(newLObj.childNodes[1]);
         }
         else
         {
            // For entries where the caption is outside the element, we can overwrite the
            // whole thing...
            listEntries.push(newLObj);
         }
      }
   },
   UpdateHistoryEntries: function()
   {
      if(document.getElementsByClassName('historyContent').length === 1)
      {
         let heContainer = document.getElementsByClassName('historyContent')[0];

         if(heContainer.style.display === "")
         {
            let historyLength = WHE.itemHistoryDetails.transactions.length;
            WHE.AddLog("found "+historyLength+" history entries");
            let tHTML;
            let hideBits = document.querySelector('#whe_cbHideHistoryBits').checked;
            let objType = W.selectionManager.getSelectedDataModelObjects()[0]?.type;
              
            for(let i = 0; i < historyLength; ++i)
            {
               let heEntry = heContainer.getElementsByClassName('tx-item')[i];
               if(heEntry.getElementsByClassName('tx-item-header')[0].getElementsByClassName('tx-item-toggle-icon').length == 1)
               {
                  if(heEntry.classList.contains('tx-item-expanded') === false)
                  {
                     heEntry.getElementsByClassName('tx-item-header')[0].getElementsByClassName('tx-item-toggle-icon')[0].click();
                  }

                  let historyEntry = heEntry.getElementsByClassName('tx-item-content')[0];
                  let listEntries = [];
                  let listEntryIdx = 0;

                  if(objType === 'segment')
                  {
                     if(historyEntry.getElementsByClassName('main-changes-list').length > 0)
                     {
                        let lObj = historyEntry.getElementsByClassName('main-changes-list')[0];
                        WHE.ProcessNativeHistoryEntry(lObj, listEntries);
                     }
                  }
                  if(historyEntry.getElementsByClassName('related-objects-list').length > 0)
                  {
                     for(let rol of historyEntry.getElementsByClassName('related-objects-list'))
                     {
                        for(let lObj of rol.getElementsByTagName('wz-caption'))
                        {
                           WHE.ProcessNativeHistoryEntry(lObj, listEntries);
                        }
                     }
                  }

                  let hObj = WHE.itemHistoryDetails.transactions[i];
                  let uIdx = -1;
                  let aIdx = -1;
                  let dIdx = -1;
                  let s;
                  for(s = 0; s < hObj.objects.length; ++s)
                  {
                     if((uIdx == -1) && (hObj.objects[s].actionType == "UPDATE"))
                     {
                        uIdx = s;
                     }
                     if((aIdx == -1) && (hObj.objects[s].actionType == "ADD"))
                     {
                        aIdx = s;
                     }
                     if((dIdx == -1) && (hObj.objects[s].actionType == "DELETE"))
                     {
                        dIdx = s;
                     }
                  }
                  let tList = [];
                  if(uIdx != -1)
                  {
                     for(s = 0; s < hObj.objects.length; ++s)
                     {
                        if(hObj.objects[s].actionType == "UPDATE")
                        {
                           tList.push(hObj.objects[s]);
                        }
                     }
                  }
                  if((aIdx != -1) && (aIdx < dIdx))
                  {
                     for(s = 0; s < hObj.objects.length; ++s)
                     {
                        if(hObj.objects[s].actionType == "ADD")
                        {
                           tList.push(hObj.objects[s]);
                        }
                     }
                     for(s = 0; s < hObj.objects.length; ++s)
                     {
                        if(hObj.objects[s].actionType == "DELETE")
                        {
                           tList.push(hObj.objects[s]);
                        }
                     }
                  }
                  else
                  {
                     for(s = 0; s < hObj.objects.length; ++s)
                     {
                        if(hObj.objects[s].actionType == "DELETE")
                        {
                           tList.push(hObj.objects[s]);
                        }
                     }
                     for(s = 0; s < hObj.objects.length; ++s)
                     {
                        if(hObj.objects[s].actionType == "ADD")
                        {
                           tList.push(hObj.objects[s]);
                        }
                     }
                  }
                  
                  if(WHE.showDebugOutput === true)
                  {   
                     console.debug(tList);
                     console.debug(listEntries);
                  }

                  let hiddenBits = 0;
                  for(let k = 0; k < tList.length; ++k)
                  {
                     tHTML = '';
                     let tObj = tList[k];
                     let oType = tObj.objectType;

                     if(oType === "nodeConnection")
                     {
                        tHTML += WHE.ParseHistoryObject_NodeConnection(tObj);
                     }
                     else if(oType === "roadClosure")
                     {
                        tHTML += WHE.ParseHistoryObject_RoadClosure(tObj);
                     }
                     else if(oType === "venue")
                     {
                        tHTML += WHE.ParseHistoryObject_Venue(tObj);
                     }
                     else if(oType === "venueUpdateRequest")
                     {
                        tHTML += WHE.ParseHistoryObject_VenueUpdateRequest(tObj);
                     }
                     else if(oType === "segment")
                     {
                        tHTML += WHE.ParseHistoryObject_Segment(tObj);
                     }

                     if(listEntries.length > listEntryIdx)
                     {
                        if(tHTML !== '')
                        {
                           if(listEntries[listEntryIdx] !== undefined)
                           {
                              listEntries[listEntryIdx].innerHTML = WHE.ModifyHTML(tHTML + '<br>');
                              
                              if(oType === 'roadClosure')
                              {
                                 let pObj = listEntries[listEntryIdx];
                                 WHE.FindCard(pObj, hideBits);
                              }
                              else if(oType === 'venueUpdateRequest')
                              {
                                 // Hide/unhide the individual transaction entries for rejected PURs
                                 if(tObj?.oldValue?.approve === false)
                                 {
                                    let pObj = listEntries[listEntryIdx];   
                                    if(hideBits === true)
                                    {
                                       pObj.style.display = "none";
                                    }
                                    else
                                    {
                                       pObj.style.display = "";
                                    }
                                    ++hiddenBits;
                                 }
                              }
                           }
                           ++listEntryIdx;
                        }
                     }
                  }

                  // Venue entry cards may contain multiple transactions, so we only want to hide/restore
                  // them if all of the transaction entries have also been hidden/restored (i.e. whenever
                  // tList.length = hiddenBits)... 
                  if(objType === "venue")
                  {
                     if(tList.length === hiddenBits)
                     {
                        // -1 as we've already incremented listEntryIdx...
                        let pObj = listEntries[listEntryIdx - 1];
                        WHE.FindCard(pObj, hideBits);
                     }
                  }
               }
            }
         }
      }
   },
   FindCard: function(pObj, hideBits)
   {
      let foundCard = false;
      while(foundCard === false)
      {
         pObj = pObj.parentElement;
         foundCard = (pObj.tagName == "WZ-CARD");
      }
      if(hideBits === true)
      {
         pObj.style.display = "none";
      }
      else
      {
         pObj.style.display = "";
      }
   },
   AddInterceptor: function()
   {
      WHE.AddLog('Adding interceptor functions...');

      // intercept fetch() so we can detect when requests are made for object history details, and
      // grab copies of the responses - this both enables us to know when the history is being
      // viewed (requests are only made when the user clicks View History...), and avoids the need
      // to perform our own requests to get the same data (as URO+ used to do in the original
      // iteration of this code...)

      // https://stackoverflow.com/questions/45425169/intercept-fetch-api-requests-and-responses-in-javascript  
      const origFetch = window.fetch;
      window.fetch = async (...args) => 
      {
         let [resource, config ] = args;

         // as we're handling everything that goes via fetch(), we let all requests through as-is,
         // except for the ones related to history fetches - these requests always include a
         // reference to "ElementHistory" in their URLs...
         if(resource.url !== undefined)
         {
            if(resource.url.indexOf('ElementHistory') != -1)
            {
               WHE.AddLog('object history being viewed...');
               if(resource.url.indexOf('&till=') == -1)
               {
                  WHE.AddLog('first history entries requested, resetting tracking vars...');
                  WHE.enhanceHistoryItemID = null;
                  WHE.itemHistoryDetails = {streets: [], cities: [], states: [], countries: [], users: [], transactions: []};
               }
            }
         }

         const response = await origFetch(resource, config);

         // we also let all responses through as-is, except for the ones related to history fetches...
         if(response.url !== undefined)
         {
            if(response.url.indexOf('ElementHistory') != -1)
            {
               response
               .clone()
               .json()
               .then(body => WHE.ParseHistoryResponse(body));
            }
         }

         return response;
      };
   },
   Initialise: function()
   {
      if(document.getElementsByClassName("sandbox").length > 0)
      {
         WHE.AddLog('WME practice mode detected, script is disabled...');
         return;
      }

      if(document.location.href.indexOf('user') !== -1)
      {
         WHE.AddLog('User profile page detected, script is disabled...');
         return;
      }

      if(typeof W === 'undefined')
      {
         window.setTimeout(WHE.Initialise, 100);
         return;
      }

      if (W.userscripts?.state?.isReady)
      {
         WHE.FinaliseInit();
      } 
      else 
      {
         document.addEventListener("wme-ready", WHE.FinaliseInit, {once: true});
      }
   }
};
WHE.Initialise();