Merge events for Google Calendar

Merge same events on from calendars in Google Calendar. Fork of imightbeamy/gcal-multical-event-merge cross-browser compatible.

// ==UserScript==
// @name        Merge events for Google Calendar
// @namespace   https://greasyfork.org/en/users/6254-doublemms
// @license MIT
// @description Merge same events on from calendars in Google Calendar. Fork of imightbeamy/gcal-multical-event-merge cross-browser compatible.
// @include     https://www.google.com/calendar/*
// @include     http://www.google.com/calendar/*
// @include     https://calendar.google.com/*
// @include     http://calendar.google.com/*
// @version     1
// @grant       none
// ==/UserScript==

(function () {
  "use strict";

  /**
   * Return gradient style from multiple color inputs
   * @param {string[]} colors List of colors (eg. ['#fff', '#000'])
   * @param {number} width Grandient bars width in pixels
   * @param {number} angle Orientation angle for bars
   * @return {string} CSS style
   */
  const generateGradient = (colors, width, angle) => {
    let gradient = `repeating-linear-gradient( ${angle}deg,`;
    let pos = 0;

    colors.forEach((color) => {
      gradient += color + " " + pos + "px,";
      pos += width;
      gradient += color + " " + pos + "px,";
    });

    gradient = gradient.slice(0, -1);
    gradient += ")";

    return gradient;
  };

  /**
   * Calculate event position
   * @param {HTMLElement} event
   * @param {DOMRect} parentPosition
   * @return {DOMRect}
   */
  const calculateEventPosition = (event, parentPosition) => {
    const eventPosition = event.getBoundingClientRect();
    return {
      left: Math.max(eventPosition.left - parentPosition.left, 0),
      right: parentPosition.right - eventPosition.right,
    };
  };

  /**
   * Merge multiple events elements
   * @param {HTMLElement[]} events
   * @returns {void}
   */
  const mergeEventElements = (events) => {
    /**
     * Parse event drag source type
     * @param {HTMLElement} e 
     * @returns {number}
     */
    const dragType = (e) => parseInt(e.dataset.dragsourceType);

    events.sort((e1, e2) => dragType(e1) - dragType(e2));
    const colors = events.map(
      (event) =>
        event.style.backgroundColor || // Week day and full day events marked 'attending'
        event.style.borderColor || // Not attending or not responded week view events
        event.parentElement.style.borderColor // Timed month view events
    );

    const parentPosition = events[0].parentElement.getBoundingClientRect();
    const positions = events.map((event) => {
      event.originalPosition =
        event.originalPosition || calculateEventPosition(event, parentPosition);
      return event.originalPosition;
    });

    const eventToKeep = events.shift();
    events.forEach((event) => {
      event.style.visibility = "hidden";
    });

    if (eventToKeep.style.backgroundColor || eventToKeep.style.borderColor) {
      eventToKeep.originalStyle = eventToKeep.originalStyle || {
        backgroundImage: eventToKeep.style.backgroundImage,
        backgroundSize: eventToKeep.style.backgroundSize,
        left: eventToKeep.style.left,
        right: eventToKeep.style.right,
        visibility: eventToKeep.style.visibility,
        width: eventToKeep.style.width,
        border: eventToKeep.style.border,
        borderColor: eventToKeep.style.borderColor,
        textShadow: eventToKeep.style.textShadow,
      };
      eventToKeep.style.backgroundImage = generateGradient(colors, 10, 45);
      eventToKeep.style.backgroundSize = "initial";
      eventToKeep.style.left =
        Math.min.apply(
          Math,
          positions.map((s) => s.left)
        ) + "px";
      eventToKeep.style.right =
        Math.min.apply(
          Math,
          positions.map((s) => s.right)
        ) + "px";
      eventToKeep.style.visibility = "visible";
      eventToKeep.style.width = null;
      eventToKeep.style.border = "solid 1px #FFF";

      // Clear setting color for declined events
      eventToKeep.querySelector('[aria-hidden="true"]').style.color = null;

      const computedSpanStyle = window.getComputedStyle(
        eventToKeep.querySelector("span")
      );
      if (computedSpanStyle.color == "rgb(255, 255, 255)") {
        eventToKeep.style.textShadow = "0px 0px 2px black";
      } else {
        eventToKeep.style.textShadow = "0px 0px 2px white";
      }

      events.forEach((event) => {
        event.style.visibility = "hidden";
      });
    } else {
      const dots = eventToKeep.querySelector('[role="button"] div:first-child');
      const dot = dots.querySelector("div");
      dot.style.backgroundImage = generateGradient(colors, 4, 90);
      dot.style.width = colors.length * 4 + "px";
      dot.style.borderWidth = 0;
      dot.style.height = "8px";

      events.forEach((event) => {
        event.style.visibility = "hidden";
      });
    }
  }

  /**
   * Reset merged events
   * @param {HTMLElement[]} events
   * @returns {void}
   */
  const resetMergedEvents = (events) => {
    events.forEach((event) => {
      for (var k in event.originalStyle) {
        event.style[k] = event.originalStyle[k];
      }
      event.style.visibility = "visible";
    });
  }

  /**
   * Merge events within a given element
   * @param {HTMLElement} calendarElement
   */
  const merge = (calendarElement) => {
    const eventSets = {};
    const days = calendarElement.querySelectorAll('[role="gridcell"]');
    days.forEach((day, index) => {
      const events = Array.from(
        day.querySelectorAll(
          '[data-eventid][role="button"], [data-eventid] [role="button"]'
        )
      );
      events.forEach((event) => {
        const eventTitleEls = event.querySelectorAll('[aria-hidden="true"]');
        if (!eventTitleEls.length) {
          return;
        }
        let eventKey = Array.from(eventTitleEls)
          .map((el) => el.textContent)
          .join("")
          .replace(/\\s+/g, "");
        eventKey = index + eventKey + event.style.height;
        eventSets[eventKey] = eventSets[eventKey] || [];
        eventSets[eventKey].push(event);
      });
    });

    Object.values(eventSets).forEach((events) => {
      if (events.length > 1) {
        mergeEventElements(events);
      } else {
        resetMergedEvents(events);
      }
    });
  }

  /**
   * Observer callback which merges events on specific mutations
   * @param {MutationRecord[]} mutations List of observed mutation records
   * @return {void}
   */
  const init = (mutations) => {
    mutations &&
      mutations
        .map((mutation) => mutation.addedNodes[0] || mutation.target)
        .filter(
          (node) =>
            node.matches &&
            node.matches('[role="main"], [role="dialog"], [role="grid"]')
        )
        .map(merge);
  }

  const observer = new MutationObserver(init);
  observer.observe(document.querySelector("body"), {
    childList: true,
    subtree: true,
    attributes: true,
  });
})();