Greasy Fork is available in English.

ČSFD Compare

Show your own ratings on other users ratings list

// ==UserScript==
// @name         ČSFD Compare
// @version      0.6.0.2
// @namespace    csfd.cz
// @description  Show your own ratings on other users ratings list
// @author       Jan Verner <SonGokussj4@centrum.cz>
// @license      GNU GPLv3
// @match        http*://www.csfd.cz/*
// @match        http*://www.csfd.sk/*
// @icon         http://img.csfd.cz/assets/b1733/images/apple_touch_icon.png
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @require      https://greasyfork.org/scripts/449554-csfd-compare-utils/code/csfd-compare-utils.js?version=1100309
// ==/UserScript==


const VERSION = 'v0.6.0.2';
const SCRIPTNAME = 'CSFD-Compare';
const SETTINGSNAME = 'CSFD-Compare-settings';
const GREASYFORK_URL = 'https://greasyfork.org/cs/scripts/425054-%C4%8Dsfd-compare';

const SETTINGSNAME_HIDDEN_BOXES = 'CSFD-Compare-hiddenBoxes';

const NUM_RATINGS_PER_PAGE = 50;  // Was 100, now it's 50...

let defaultSettings = {
  // HOME PAGE
  hiddenSections: [],
  // GLOBAL
  showControlPanelOnHover: true,
  clickableHeaderBoxes: true,
  clickableMessages: true,
  addStars: true,
  // USER
  displayMessageButton: true,
  displayFavoriteButton: true,
  hideUserControlPanel: true,
  compareUserRatings: true,
  // FILM/SERIES
  addRatingsDate: false,
  showLinkToImage: true,
  ratingsEstimate: true,
  ratingsFromFavorites: true,
  addRatingsComputedCount: true,
  hideSelectedUserReviews: false,
  hideSelectedUserReviewsList: [],
  // ACTORS
  showOnOneLine: false,
  // EXPERIMENTAL
  loadComputedRatings: false,
  addChatReplyButton: false,
};


class Api {

  async getCurrentPageRatings(url) {

    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Accept': 'application/json, text/plain, */*',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        Ids: [9499, 563036, 123],
      }),
    });
    console.log("response", response);
    const data = await response.json();
    console.log("data", data);
    return data;
  }
}

/**
 * Check if settings are valid. If not, reset them.
 * Return either unmodified or modified settings
 * @param {*} settings - LocalStorage settings current value
 * @param {string} settingsName - Settings Name
 */
async function checkSettingsValidity(settings, settingsName) {

  if (settingsName === SETTINGSNAME_HIDDEN_BOXES) {
    const isArray = Array.isArray(settings);
    let keysValid = true;
    settings.forEach(element => {
      const keys = Object.keys(element);
      if (keys.length !== 2) {
        keysValid = false;
      }
    });

    if (!isArray || !keysValid) {
      settings = defaultSettings.hiddenSections;
      localStorage.setItem(SETTINGSNAME_HIDDEN_BOXES, JSON.stringify(settings));
    }
  }
  return settings;
}

/**
 * This function returns a promise that will resolve after "t" milliseconds
 */
function delay(t) {
  return new Promise(resolve => {
    setTimeout(resolve, t);
  });
}

async function getSettings(settingsName = SETTINGSNAME) {
  if (!localStorage[settingsName]) {
    if (settingsName === SETTINGSNAME_HIDDEN_BOXES) {
      defaultSettings = [];
    }
    console.log(`ADDDING DEFAULTS: ${defaultSettings}`);
    localStorage.setItem(settingsName, JSON.stringify(defaultSettings));
    return defaultSettings;
  } else {
    return JSON.parse(localStorage[settingsName]);
  }
}

async function refreshTooltips() {
  try {
    tippy('[data-tippy-content]', {
      // interactive: true,
      popperOptions: { modifiers: { computeStyle: { gpuAcceleration: false } } }
    });
  } catch (err) {
    console.log("Error: refreshTooltips():", err);
  }
}

/**
 * Take a list of dictionaries and return merged dictionary
 * @param {*} list
 * @returns
 */
async function mergeDict(list) {
  const merged = list.reduce(function (r, o) {
    Object.keys(o).forEach(function (k) { r[k] = o[k]; });
    return r;
  }, {});
  return merged;
}

async function onHomepage() {
  let check = false;
  if (document.location.pathname === '/') {
    check = true;
  }
  return check;
}

(async () => {
  "use strict";
  /* globals jQuery, $, waitForKeyElements */
  /* jshint -W069 */
  /* jshint -W083 */
  /* jshint -W075 */


  class Csfd {

    constructor(csfdPage) {
      this.csfdPage = csfdPage;
      this.stars = {};
      this.storageKey = undefined;
      this.userUrl = undefined;
      this.endPageNum = 0;
      this.userRatingsCount = 0;
      this.userRatingsUrl = undefined;
      this.localStorageRatingsCount = 0;
      this.settings = undefined;

      this.RESULT = {};

      // Ignore the ads... Make 'hodnoceni' table wider.
      // TODO: Toto do hodnoceni!
      $('.column.column-80').attr('class', '.column column-90');
    }

    async isLoggedIn() {
      const $profile = $('.profile.initialized');
      return $profile.length > 0;
    }

    /**
     * @async
     * @returns {Promise<string>} - User URL (e.g. /uzivatel/123456-adam-strong/)
     */
    async getCurrentUser() {
      let loggedInUser = $('.profile.initialized').attr('href');

      if (loggedInUser !== undefined) {
        if (loggedInUser.length == 1) {
          loggedInUser = loggedInUser[0];
        }
      }

      if (typeof loggedInUser === 'undefined') {
        console.log("Trying again...");

        // [OLD Firefox] workaround (the first returns undefined....?)
        let profile = document.querySelectorAll('.profile');
        if (profile.length == 0) {
          return undefined;
        }
        loggedInUser = profile[0].getAttribute('href');

        if (typeof loggedInUser === 'undefined') {
          console.error(`${SCRIPTNAME}: Can't find logged in username...`);
          throw (`${SCRIPTNAME}: exit`);  // TODO: Popup informing user
        }
      }
      return loggedInUser;
    }

    /**
     * @async
     * @returns {Promise<string>} - Username (e.g. adam-strong)
     */
    async getUsername() {
      const userHref = await this.getCurrentUser();
      if (userHref === undefined) {
        return undefined;
      }
      // get 'songokussj'   from '/uzivatel/78145-songokussj/'    with regex
      // get 'sans-sourire' from '/uzivatel/714142-sans-sourire/' with regex
      const foundMatch = userHref.match(new RegExp(/\/(\d+-(.*)+)\//));
      if (foundMatch.length == 3) {
        return foundMatch[2];
      }
      return undefined;
    }

    getStars() {
      // TODO: remove this function and use getLocalStorageRatings() instead
      if (!localStorage[this.storageKey] || localStorage[this.storageKey] === 'undefined') {
        return {};
      }
      return JSON.parse(localStorage[this.storageKey]);
    }

    async getLocalStorageRatings() {
      if (!localStorage[this.storageKey] || localStorage[this.storageKey] === 'undefined') {
        return {};
      }
      return JSON.parse(localStorage[this.storageKey]);
    }

    /**
     * Get ratings from LocalStorage and return the count of:
     * - normally rated (user clicked on rating)
     * - and computed ratings (not shown in user ratings)
     *
     * @returns {Promise<Object<string, number>>} `{ computed: int, rated: int }`
     */
    async getLocalStorageRatingsCount() {
      const ratings = await this.getLocalStorageRatings();
      const computedCount = Object.values(ratings).filter(rating => rating.computed).length;
      const ratedCount = Object.keys(ratings).length - computedCount;
      return {
        computed: computedCount,
        rated: ratedCount,
      };
    }

    /**
     *
     * @returns {str} Current movie: `<MovieId>-<MovieUrlTitle>`
     *
     * Example:
     * - https://www.csfd.sk/film/739784-star-trek-lower-decks/prehlad/ --> `739784-star-trek-lower-decks`
     * - https://www.csfd.cz/film/1032817-naomi/1032819-don-t-believe-everything-you-think/recenze/ --> `1032819-don-t-believe-everything-you-think`
     */
    getCurrentFilmUrl() {
      const foundMatch = $('meta[property="og:url"]').attr('content').match(/\d+-[\w-]+/ig);

      // TODO: getCurrentFilmUrl by melo vrátit film URL ne jen cast... ne?
      if (!foundMatch) {
        console.error("TODO: getCurrentFilmUrl() Film URL wasn't found...");
        throw (`${SCRIPTNAME} Exiting...`);
      }
      return foundMatch[foundMatch.length - 1];
    }

    /**
     *
     * @returns {str} Current movie: https://www.csfd.sk/film/739784-star-trek-lower-decks/recenze/
     *
     */
    getCurrentFilmFullUrl() {
      const foundMatch = $('meta[property="og:url"]').attr('content');

      // TODO: getCurrentFilmFullUrl by melo vrátit film URL ne jen cast... ne?
      if (!foundMatch) {
        console.error("TODO: getCurrentFilmFullUrl() Film URL wasn't found...");
        return "";
      }
      return foundMatch;
    }

    /**
     * Return current movie Type (film, serial, episode)
     *
     * @returns {str} Current movie type: film, serial, episode, movie, ...
     */
    getCurrentFilmType() {
      const foundTypes = $(".film-header span.type");
      let foundMatch = "";

      // No "type" found
      if (foundTypes.length === 0) {
        return "movie";

        // One span.type found ... (film), (serial), ...
      } else if (foundTypes.length === 1) {
        foundMatch = $(foundTypes).text();

        // Multiple span.type found, get the one containing "(" and ")"
      } else if (foundTypes.length > 1) {
        foundTypes.each(function (index, element) {
          if ($(element).text().includes("(")) {
            foundMatch = $(element).text().toLowerCase();
          }
        });
      }
      // Strip foundMatch from "(" and ")"
      foundMatch = foundMatch.replace(/[\(\)]/g, '');

      // Convert to english (film, serial, movie, series, ...)
      foundMatch = this.getShowTypeFromType(foundMatch);

      return foundMatch;
    }

    /**
     * from property `og:title` extract the movie year `'Movie Title (2019)' --> 2019`
     *
     * @returns {str} Current movie year
     */
    getCurrentFilmYear() {
      const match = $('meta[property="og:title"]').attr('content').match(/\((\d+)\)/);
      if (match.length === 2) {
        const year = match[1];
        return year;
      }
      return "";
    }

    /**
     *
     * @param {html} content
     * @returns {bool} `true` if current movie rating is computed, `false` otherwise
     */
    async isCurrentFilmComputed(content = null) {
      const $computedStars = content === null ? $('.star.active.computed') : $(content).find('.star.active.computed');

      if ($computedStars.length > 0) {
        return true;
      }

      const secondTry = await this.isCurrentFilmRatingComputed();
      if (secondTry) {
        return true;
      }

      return false;
    }

    async isCurrentFilmRatingComputed() {
      const $computedStars = this.csfdPage.find(".current-user-rating .star-rating.computed");

      if ($computedStars.length !== 0) { return true; }

      return false;
    }

    getCurrentFilmComputedCount(content = null) {
      const $curUserRating = content === null ? this.csfdPage.find('li.current-user-rating') : content.find('li.current-user-rating');
      const countedText = $($curUserRating).find('span[title]').attr('title');
      // split by :
      const counted = countedText?.split(':')[1]?.trim();
      return counted;
    }

    async getCurrentFilmComputed() {
      const result = await this.getComputedRatings(this.csfdPage);
      return result;
    }


    async updateInLocalStorage(ratingsObject) {
      // Check if film is in LocalStorage
      const filmUrl = this.getCurrentFilmUrl();
      const filmId = await this.getMovieIdFromHref(filmUrl);
      const myRating = this.stars[filmId] || undefined;

      // Item not in LocalStorage, add it then!
      if (myRating === undefined) {
        // Item not in LocalStorage, add
        this.stars[filmId] = ratingsObject;
        localStorage.setItem(this.storageKey, JSON.stringify(this.stars));
        return true;
      }

      if (myRating.rating !== ratingsObject.rating || myRating.computedCount !== ratingsObject.computedCount) {
        console.log(`⚙️ ~ Csfd ~ updateInLocalStorage ~ Updating item...`);
        this.stars[filmId] = ratingsObject;
        localStorage.setItem(this.storageKey, JSON.stringify(this.stars));
        return true;
      }

      // Item in LocalStorage, everything is fine
      // console.log(`✅ ~ Csfd ~ updateInLocalStorage ~ Item in LocalStorage, everything is fine`);
      return false;
    }

    async removeFromLocalStorage() {
      // Check if film is in LocalStorage
      const filmUrl = this.getCurrentFilmUrl();
      const filmId = await this.getMovieIdFromHref(filmUrl);
      const item = this.stars[filmId];

      // Item not in LocalStorage, everything is fine
      if (item === undefined) {
        return false;
      }

      // Item in LocalStorage, delete it from local dc
      delete this.stars[filmId];

      // And resave it to LocalStorage
      localStorage.setItem(this.storageKey, JSON.stringify(this.stars));

      return true;
    }

    /**
     * Get movie rating from current or given page
     * @param {html} content
     * @returns {Promise<{rating: string, computedFrom: string, computed: boolean}>}
     */
    async getCurrentFilmRating(content = null) {
      const currentRatingIsComputed = await this.isCurrentFilmComputed(content);

      if (currentRatingIsComputed) {
        const { ratingCount, computedFromText } = content === null ? await this.getCurrentFilmComputed() : await this.getComputedRatings(content);

        return {
          rating: ratingCount,
          computedFrom: computedFromText,
          computed: true,
        };
      }

      const $activeStars = this.csfdPage.find(".star.active");

      // No rating
      if ($activeStars.length === 0) {
        return {
          rating: "",
          computedFrom: "",
          computed: false,
        };
      }

      // Rating "odpad" or "1"
      if ($activeStars.length === 1) {
        if ($activeStars.attr('data-rating') === "0") {
          return {
            rating: "0",
            computedFrom: "",
            computed: false,
          };
        }
      }

      // Rating "1" to "5"
      return {
        rating: $activeStars.length,
        computedFrom: "",
        computed: false,
      };

    }

    async getCurrentUserRatingsCount() {
      return $.get(this.userRatingsUrl)
        .then(function (data) {
          const count = $(data).find('.box-user-rating span.count').text().replace(/[\s()]/g, '');
          if (count) {
            return parseInt(count);
          }
          return 0;
        });
    }

    async fillMissingSettingsKeys() {
      let settings = await getSettings();

      let currentKeys = Object.keys(settings);
      let defaultKeys = Object.keys(defaultSettings);
      for (const defaultKey of defaultKeys) {
        let exists = currentKeys.includes(defaultKey);
        if (!exists) {
          settings[defaultKey] = defaultSettings[defaultKey];
        }
      }
      localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
    }

    async checkForOldLocalstorageRatingKeys() {
      const ratings = this.getStars();
      const keys = Object.keys(ratings);
      for (const key of keys) {
        if (key.includes("/")) {
          alert(`
            CSFD-Compare

            Byl nalezen starý způsob ukládání hodnocení do LocalStorage!
            Prosím, smažte staré hodnocení a znovu je nahrajte.

            CC -> Smazat Uložená hodnocení`
          );
          return null;
        }
      }
    }

    /**
     * $content should be URL with computed star ratings. Not manualy rated. \
     * Then, it will return dict with `computed stars` and text `"computed from episodes: X"`
     *
     * @param {str} $content HTML content of a page
     * @returns {Promise<{'ratingCount': int, 'computedFromText': str}>}
     *
     * Example: \
     * `{ ratingCount: 4, computedFromText: 'spocteno z episod': 2 }`
     */
    async getComputedRatings($content) {
      // Get current user rating
      const $curUserRating = $($content).find('li.current-user-rating');
      const $starsSpan = $($curUserRating).find('span.stars');
      const starCount = await csfd.getStarCountFromSpanClass($starsSpan);

      // Get 'Spocteno z episod' text
      const $countedText = $($curUserRating).find('span[title]').attr('title');

      // // Get this movieId and possible parentId
      // const filmUrl = await csfd.getFilmUrlFromHtml($content);
      // let [movieId, parentId] = await csfd.getMovieIdParentIdFromUrl(filmUrl);

      // Resulting dictionary
      const result = {
        'ratingCount': starCount,
        'computedFromText': $countedText,
        // 'movieId': movieId,
        // 'parentId': parentId
      };
      return result;
    }

    async loadInitialSettings() {

      // GLOBAL
      $('#chkControlPanelOnHover').attr('checked', settings.showControlPanelOnHover);
      $('#chkClickableHeaderBoxes').attr('checked', settings.clickableHeaderBoxes);
      $('#chkClickableMessages').attr('checked', settings.clickableMessages);
      $('#chkAddStars').attr('checked', settings.addStars);

      // USER
      $('#chkDisplayMessageButton').attr('checked', settings.displayMessageButton);
      $('#chkDisplayFavoriteButton').attr('checked', settings.displayFavoriteButton);
      $('#chkHideUserControlPanel').attr('checked', settings.hideUserControlPanel);
      $('#chkCompareUserRatings').attr('checked', settings.compareUserRatings);

      // FILM/SERIES
      $('#chkAddRatingsDate').attr('checked', settings.addRatingsDate);
      $('#chkShowLinkToImage').attr('checked', settings.showLinkToImage);
      $('#chkRatingsEstimate').attr('checked', settings.ratingsEstimate);
      $('#chkRatingsFromFavorites').attr('checked', settings.ratingsFromFavorites);
      $('#chkAddRatingsComputedCount').attr('checked', settings.addRatingsComputedCount);
      $('#chkHideSelectedUserReviews').attr('checked', settings.hideSelectedUserReviews);
      settings.hideSelectedUserReviews || $('#txtHideSelectedUserReviews').parent().hide();
      // if (settings.hideSelectedUserReviews === false) { $('#txtHideSelectedUserReviews').parent().hide(); }
      if (settings.hideSelectedUserReviewsList !== undefined) { $('#txtHideSelectedUserReviews').val(settings.hideSelectedUserReviewsList.join(', ')); }

      // ACTORS
      $('#chkShowOnOneLine').attr('checked', settings.showOnOneLine);

      // EXPERIMENTAL
      $('#chkLoadComputedRatings').attr('checked', settings.loadComputedRatings);
      $('#chkAddChatReplyButton').attr('checked', settings.addChatReplyButton);
    }

    async addSettingsEvents() {
      // HOME PAGE

      // GLOBAL
      $('#chkControlPanelOnHover').on('change', function () {
        settings.showControlPanelOnHover = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkClickableHeaderBoxes').on('change', function () {
        settings.clickableHeaderBoxes = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkClickableMessages').on('change', function () {
        settings.clickableMessages = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkAddStars').on('change', function () {
        settings.addStars = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      // USER
      $('#chkDisplayMessageButton').on('change', function () {
        settings.displayMessageButton = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkDisplayFavoriteButton').on('change', function () {
        settings.displayFavoriteButton = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkHideUserControlPanel').on('change', function () {
        settings.hideUserControlPanel = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkCompareUserRatings').on('change', function () {
        settings.compareUserRatings = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      // FILM/SERIES
      $('#chkShowLinkToImage').on('change', function () {
        settings.showLinkToImage = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkRatingsEstimate').on('change', function () {
        settings.ratingsEstimate = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkRatingsFromFavorites').on('change', function () {
        settings.ratingsFromFavorites = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkAddRatingsDate').on('change', function () {
        settings.addRatingsDate = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkAddRatingsComputedCount').on('change', function () {
        settings.addRatingsComputedCount = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      $('#chkHideSelectedUserReviews').on('change', function () {
        settings.hideSelectedUserReviews = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
        $('#txtHideSelectedUserReviews').parent().toggle();
      });

      $('#txtHideSelectedUserReviews').on('change', function () {
        let ignoredUsers = this.value.replace(/\s/g, '').split(",");
        settings.hideSelectedUserReviewsList = ignoredUsers;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup(`Ignorovaní uživatelé:\n${ignoredUsers.join(', ')}`, 4);
      });

      // ACTORS
      $('#chkShowOnOneLine').on('change', function () {
        settings.showOnOneLine = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

      // EXPERIMENTAL
      $('#chkLoadComputedRatings').on('change', function () {
        settings.loadComputedRatings = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });
      $('#chkAddChatReplyButton').on('change', function () {
        settings.addChatReplyButton = this.checked;
        localStorage.setItem(SETTINGSNAME, JSON.stringify(settings));
        Glob.popup("Nastavení uloženo (obnovte stránku)", 2);
      });

    }

    async onPageOtherUserHodnoceni() {
      if ((location.href.includes('/hodnoceni') || location.href.includes('/hodnotenia')) && location.href.includes('/uzivatel/')) {
        if (!location.href.includes(this.userUrl)) {
          return true;
        }
      }
      return false;
    }

    async onPageOtherUser() {
      if (location.href.includes('/uzivatel/')) {
        if (!location.href.includes(this.userUrl)) {
          return true;
        }
      }
      return false;
    }

    async onPageDiskuze() {
      if (location.href.includes('/diskuze/') || location.href.includes('/diskusie')) {
        return true;
      }
      return false;
    }

    async onPersonalFavorite() {
      if (location.href.includes('/soukromne/oblubene/') || location.href.includes('/soukrome/oblibene/')) {
        if (!location.href.includes(this.userUrl)) {
          return true;
        }
      }
      return false;
    }

    async notOnUserPage() {
      if (location.href.includes('/uzivatel/') && location.href.includes(this.userUrl)) {
        return false;
      }
      return true;
    }

    exportRatings() {
      localStorage.setItem(this.storageKey, JSON.stringify(this.stars));
    }

    async addStars() {
      if (location.href.includes('/zebricky/') || location.href.includes('/rebricky/')) {
        return;
      }
      let starsCss = { marginLeft: "5px" };
      // On UserPage or PersonalFavorite page, modify the CSS by adding solid red border outline
      if (await this.onPageOtherUser() || await this.onPersonalFavorite()) {
        starsCss = {
          marginLeft: "5px",
          borderWidth: "1px",
          borderStyle: "solid",
          borderColor: "#c78888",
          borderRadius: "5px",
          padding: "0px 5px",
        };
      }

      let $links = $('a.film-title-name');
      for (const $link of $links) {
        const href = $($link).attr('href');
        const movieId = await this.getMovieIdFromHref(href);

        const res = this.stars[movieId];
        if (res === undefined) {
          continue;
        }
        const $sibl = $($link).closest('td').siblings('.rating,.star-rating-only');
        if ($sibl.length !== 0) {
          continue;
        }
        const starClass = res.rating !== 0 ? `stars-${res.rating}` : `trash`;
        const starText = res.rating !== 0 ? "" : "odpad!";
        const className = res.computed ? "star-rating computed" : "star-rating";
        const title = res.computed ? res.computedFromText : res.date;

        // Construct the HTML
        const $starSpan = $("<span>", {
          'class': className,
          html: `<span class="stars ${starClass}" title="${title}">${starText}</span>`
        }).css(starsCss);

        // Add the HTML
        $($link).after($starSpan);

        // If the rating is computed, add SUP element indicating from how many ratings it was computed
        if (res.computed) {
          const $numSpan = $("<span>", {
            'html': `<sup> (${res.computedCount})</sup>`
          }).css({
            'font-size': '13px',
            'color': '#7b7b7b'
          });
          $starSpan.find('span').after($numSpan);
        }
      }
    }

    /**
     * Adds a column to another user's ratings page with the user's rating
     *
     * @returns {None}
     */
    addRatingsColumn() {
      const starsDict = this.getStars();
      const lcRatingsCount = Object.keys(starsDict).length;

      // No ratings in LocalStorage, do nothing
      if (lcRatingsCount === 0) { return; }

      const $page = this.csfdPage;
      const $tbl = $page.find('#snippet--ratings table tbody');

      $tbl.find('tr').each(async function () {
        const $row = $(this);
        const href = $row.find('a.film-title-name').attr('href');
        const movieId = await csfd.getMovieIdFromHref(href);
        const myRating = starsDict[movieId];

        let $span = "";
        if (myRating?.rating === 0) {
          $span = `<span class="stars trash">odpad!</span>`;
        } else {
          if (myRating?.computed) {
            $span = `<span class="stars stars-${myRating?.rating}" title="${myRating?.computedFromText}"></span>`;
          } else {
            $span = `<span class="stars stars-${myRating?.rating}" title="${myRating?.date}"></span>`;
          }
        }

        // Color the rating to red (star-rating) or black (star-rating computed) if computed
        const className = myRating?.computed ? "star-rating computed" : "star-rating";

        // Build the HTML for computed rating SUP element: e.g. (3)
        const $computedSup = `
          <span style="position: relative;">
            <sup style="position: absolute; top: -1px; left: -2px; color: var(--color-grey-light2)">
              (${myRating?.computedCount})
            </sup>
          </span>
        `;

        const $currentUserSpan = `
          <span class="${className}">
            ${$span}
            ${myRating?.computed ? $computedSup : ""}
          </span>
        `;

        const $currentUserTd = $row.find('td:nth-child(2)')
        $currentUserTd.after(`
                    <td class="star-rating-only">
                        ${$currentUserSpan}
                    </td>
        `);
      });
    }

    async openControlPanelOnHover() {
      const btn = $('.button-control-panel');
      const panel = $('#dropdown-control-panel');
      $(btn).on('mouseover', () => {
        if (!panel.hasClass('active')) {
          panel.addClass('active');
          let windowWidth = $(window).width();
          if (windowWidth <= 635) {
            panel.appendTo(document.body);
            panel.css("top", "133px");
            panel.css("right", "15px");
          }
        }
      });
      $(btn).on('mouseleave', () => {
        if (panel.hasClass('active')) panel.removeClass('active');
      });
      $(panel).on('mouseover', () => {
        if (!panel.hasClass('active')) panel.addClass('active');
      });
      $(panel).on('mouseleave', () => {
        if (panel.hasClass('active')) panel.removeClass('active');
      });

    }

    async addWarningToUserProfile() {
      const ratingCountOk = this.isRatingCountOk();
      if (ratingCountOk) return;

      $(".csfd-compare-menu").append(`
                <div class='counter'>
                    <span><b>!</b></span>
                </div>
            `);
    }

    async refreshButtonNew(ratingsInLS, curUserRatings) {
      const ratingCountOk = await this.isRatingCountOk();
      if (ratingCountOk) return;

      const $button = $('<button>', {
        id: 'refr-ratings-button',
        "class": 'csfd-compare-reload',
        html: `<center>
                 <b> >> Načíst hodnocení (new) << </b> <br />
               </center>`,
      }).css({
        textTransform: "initial",
        fontSize: "0.9em",
        padding: "5px",
        border: "4px solid whitesmoke",
        borderRadius: "8px",
        width: "-moz-available",
        width: "-webkit-fill-available",
        width: "100%",
      });
      const $div = $('<div>', {
        html: $button,
      });
      $('.csfd-compare-settings').after($div);

      let forceUpdate = ratingsInLS > curUserRatings ? true : false;

      $($button).on("click", async function () {
        console.debug("refreshing ratings");
        const csfd = new Csfd($('div.page-content'));
        if (forceUpdate === true) {
          if (!confirm(`Pro jistotu bych obnovil VŠECHNA hodnocení... Důvod: počet tvých je [${ratingsInLS}], ale v databázi je uloženo více: [${curUserRatings}]. Souhlasíš?`)) {
            forceUpdate = false;
          }
        }
        csfd.refreshAllRatingsNew(csfd, forceUpdate);
      });
    }

    async badgesComponent(ratingsInLS, curUserRatings, computedRatings) {
      // TODO" Tohle už teď bude fungovat, jen to zakomponovat...
      return "<b>ahoj</b>";
    }

    displayMessageButton() {
      let userHref = $('#dropdown-control-panel li a.ajax').attr('href');
      if (userHref === undefined) {
        console.log("fn displayMessageButton(): can't find user href, exiting function...");
        return;
      }

      let button = document.createElement("button");
      button.setAttribute("data-tippy-content", $('#dropdown-control-panel li a.ajax')[0].text);
      button.setAttribute("style", "float: right; border-radius: 5px;");
      button.innerHTML = `
                <a class="ajax"
                    rel="contentModal"
                    data-mfp-src="#panelModal"
                    href="${userHref}"><i class="icon icon-messages"></i></a>
            `;
      $(".user-profile-content > h1").append(button);
    }

    async displayFavoriteButton() {
      let favoriteButton = $('#snippet--menuFavorite > a');
      if (favoriteButton.length !== 1) {
        console.log("fn displayFavoriteButton(): can't find user href, exiting function...");
        return;
      }
      let tooltipText = favoriteButton[0].text;
      let addRemoveIndicator = "+";
      if (tooltipText.includes("Odebrat") || tooltipText.includes("Odobrať")) {
        addRemoveIndicator = "-";
      }

      let button = document.createElement("button");
      button.setAttribute("style", "float: right; border-radius: 5px; margin: 0px 5px;");
      button.setAttribute("data-tippy-content", tooltipText);
      button.innerHTML = `
                <a class="ajax"
                    rel="contentModal"
                    data-mfp-src="#panelModal"
                    href="${favoriteButton.attr('href')}">
                        <span id="add-remove-indicator" style="font-size: 1.5em; color: white;">${addRemoveIndicator}</span>
                        <i class="icon icon-favorites"></i>
                </a>
            `;
      $(".user-profile-content > h1").append(button);

      $(button).on('click', async function () {
        if (addRemoveIndicator == "+") {
          $('#add-remove-indicator')[0].innerText = '-';
          button._tippy.setContent("Odebrat z oblíbených");
        } else {
          $('#add-remove-indicator')[0].innerText = '+';
          button._tippy.setContent("Přidat do oblíbených");
        }
        await refreshTooltips();
      });
    }

    hideUserControlPanel() {
      let panel = $('.button-control-panel:not(.small)');
      if (panel.length !== 1) { return; }
      panel.hide();
    }

    async showLinkToImageOnSmallMoviePoster() {
      let $film = this.csfdPage.find('.film-posters');
      let $img = $film.find('img');
      let src = $img.attr('src');
      let width = $img.attr('width');

      let $div = $(`<div>`, { "class": 'link-to-image' })
        .css({
          'position': 'absolute',
          'right': '0px',
          'bottom': '0px',
          'display': 'none',
          'z-index': '999',
          'padding-left': '0.5em',
          'padding-right': '0.5em',
          'margin-bottom': '0.5em',
          'margin-right': '0.5em',
          'background-color': 'rgba(255, 245, 245, 0.85)',
          'border-radius': '5px 0px',
          'font-weight': 'bold'
        })
        .html(`<a href="${src}">w${width}</a>`);

      $film.find('a').after($div);

      $film.on('mouseover', () => {
        $div.show("fast");
      });
      $film.on('mouseleave', () => {
        $div.hide("fast");
      });
    }

    /**
     * Show link for all possible picture sizes
     */
    async showLinkToImageOnOtherGalleryImages() {

      let $pictures = this.csfdPage.find('.gallery-item picture');

      let pictureIdx = 0;
      for (const $picture of $pictures) {
        let obj = {};

        let src = $($picture).find('img').attr('src').replace(/cache[/]resized[/]w\d+[/]/g, '');

        obj['100 %'] = src;

        let $sources = $($picture).find('source');

        for (const $source of $sources) {

          const srcset = $($source).attr('srcset');

          if (srcset === undefined) { continue; }

          let attributeText = srcset.replace(/\dx/g, '').replace(/\s/g, '');
          let links = attributeText.split(',');

          for (const link of links) {
            const match = link.match(/[/]w(\d+)/);
            if (match !== null) {
              if (match.length === 2) {
                const width = match[1];
                obj[width] = link;
              }
            }
          }
        }

        let idx = 0;
        for (const item in obj) {

          let $div = $(`<div>`, { "class": `link-to-image-gallery picture-idx-${pictureIdx}` })
            .css({
              'position': 'absolute',
              'right': '0px',
              'bottom': '0px',
              'display': 'none',
              'z-index': '999',
              'padding-left': '0.5em',
              'padding-right': '0.5em',
              'margin-bottom': `${0.5 + (idx * 2)}em`,
              'margin-right': '0.5em',
              'background-color': 'rgba(255, 245, 245, 0.75)',
              'border-radius': '5px 0px',
              'font-weight': 'bold'
            })
            .html(`<a href="${obj[item]}">${item}</a>`);

          $($picture).find('img').after($div);
          $($picture).attr('data-idx', pictureIdx);
          $($picture).parent().css({ position: 'relative' });  // need to have this for absolute position to work

          idx += 1;
        }

        pictureIdx += 1;

        $($picture).on('mouseover', () => {
          const pictureIdx = $($picture).attr('data-idx');
          $(`.link-to-image-gallery.picture-idx-${pictureIdx}`).show("fast");
        });
        $($picture).on('mouseleave', () => {
          const pictureIdx = $($picture).attr('data-idx');
          $(`.link-to-image-gallery.picture-idx-${pictureIdx}`).hide("fast");
        });
      }
    }

    /**
     * If film has been rated by user favorite people, make an average and display it
     * under the normal rating as: oblíbení: X %
     *
     * @returns null
     */
    async ratingsFromFavorites() {
      let $ratingSpans = this.csfdPage.find('li.favored:not(.current-user-rating) .star-rating .stars');

      // No favorite people ratings found
      if ($ratingSpans.length === 0) { return; }

      let ratingNumbers = [];
      for (let $span of $ratingSpans) {
        let num = this.getNumberFromRatingSpan($($span));
        num = num * 20;
        ratingNumbers.push(num);
      }
      let average = (array) => array.reduce((a, b) => a + b) / array.length;
      const ratingAverage = Math.round(average(ratingNumbers));

      let $ratingAverage = this.csfdPage.find('.box-rating-container div.film-rating-average');
      $ratingAverage.html(`
                <span style="position: absolute;">${$ratingAverage.text()}</span>
                <span style="position: relative; top: 25px; font-size: 0.3em; font-weight: 600;">oblíbení: ${ratingAverage} %</span>
            `);

    }
    /**
     * When there is less than 10 ratings on a movie, csfd waits with the rating.
     * This computes the rating from those less than 10 and shows it.
     *
     * @returns null
     */
    async ratingsEstimate() {

      // Find rating-average element
      let $ratingAverage = this.csfdPage.find('.box-rating-container .film-rating-average');

      // Not found, exit fn()
      if ($ratingAverage.length !== 1) { return; }

      // Get the text
      let curRating = $ratingAverage.text().replace(/\s/g, '');

      // If the text if anything than '?%', exit fn()
      if (!curRating.includes('?%')) { return; }

      // Get all other users ratings
      let $userRatings = this.csfdPage.find('section.others-rating .star-rating');

      // If no ratings in other ratings, exit fn()
      if ($userRatings.length === 0) { return; }

      // Fill the list with ratings as numbers
      let ratingNumbers = [];
      for (const $userRating of $userRatings) {
        let $ratingSpan = $($userRating).find('.stars');
        let num = this.getNumberFromRatingSpan($ratingSpan);
        // Transform number to percentage (0 -> 0 %, 1 -> 20 %, 2 -> 40 %...)
        num = num * 20;
        ratingNumbers.push(num);
      }

      // Compute the average
      let average = (array) => array.reduce((a, b) => a + b) / array.length;
      const ratingAverage = Math.round(average(ratingNumbers));

      // Rewrite the displayed rating
      const bgcolor = this.getRatingColor(ratingAverage);
      $ratingAverage
        .text(`${ratingAverage} %`)
        .css({ color: '#fff', backgroundColor: bgcolor })
        .attr('title', `spočteno z hodnocení: ${$userRatings.length}`);
    }
    /**
     * Depending on the percent number, return a color as a string representation
     * 0-29 black; 30-69 blue; 70-100 red
     *
     * @param {int} ratingPercent
     * @returns {string} representation of colour
     */
    getRatingColor(ratingPercent) {
      switch (true) {
        case (ratingPercent < 29):
          return "#535353";
        case (ratingPercent >= 30 && ratingPercent < 69):
          return "#658db4";
        case (ratingPercent >= 70):
          return "#ba0305";
        default:
          return "#d2d2d2";
      }
    }
    /**
     * From jquery! $span csfd element class (.stars stars-4) return the ratings number (4)
     *
     * @param {jquery} $span
     * @returns int in range 0-5
     */
    getNumberFromRatingSpan($span) {
      // TODO: využít tuto funkci i při načítání hodnocení do LS
      let rating = 0;
      for (let stars = 0; stars <= 5; stars++) {
        if ($span.hasClass('stars-' + stars)) {
          rating = stars;
        }
      }
      return rating;
    }
    /**
     * Show clickable link to the absolute url of the image mouse is hovering above.
     *
     * Works with:
     * - Small Movie Poster
     * - Movie Gallery Images
     */
    async showLinkToImage() {
      this.showLinkToImageOnSmallMoviePoster();
      this.showLinkToImageOnOtherGalleryImages();
    }


    async doSomethingNew(url) {
      let data = await $.get(url);
      const $rows = $(data).find('#snippet--ratings tr');

      let dc = {};
      const parentIds = [];
      const seriesIds = [];

      // Process each row of the rating page
      // $row = <>ItemName | ItemUrl | (year) | (type) | (Detail) | Rating | Date</>
      for (const $row of $rows) {

        const name = $($row).find('td.name a').attr('href');  // /film/697624-love-death-robots/800484-zakazane-ovoce/
        const filmInfo = $($row).find('td.name > h3 > span > span');  // (2007)(série)(S02) // (2021)(epizoda)(S02E05)

        const [showType, showYear, parentName, [movieId, parentId]] = await Promise.all([
          csfd.getShowType(filmInfo),
          csfd.getShowYear(filmInfo),
          csfd.getParentNameFromUrl(name),
          csfd.getMovieIdParentIdFromUrl(name),
        ]);

        // If the show is a SEASON, it's parent is a SERIES and ID is in the URL
        if (showType === 'season') {
          // If parentId is not in parentIds, add it to the list
          if (!parentIds.includes(parentName)) {
            // console.debug(`[ DEBUG ] Adding parentName to [PARENT Ids]: ${parentName}`);
            parentIds.push(parentName);
          }
        }
        // If the show is a EPISODE, it's parent is a SEASON but the ID is not in the URL
        // We need to get the ID from the parentName (SERIES) content and then grab the SEASON IDs there
        else if (showType === 'episode') {
          // If parentId is not in parentIds, add it to the list
          if (!seriesIds.includes(parentName)) {
            // console.debug(`[ DEBUG ] Adding parentName to [SERIES Ids]: ${parentName}`);
            parentIds.push(parentName);
            seriesIds.push(parentName);
          }
        }

        // Get the RATING from the stars and the DATE
        const $ratings = $($row).find('span.stars');
        const rating = await csfd.getStarCountFromSpanClass($ratings);
        const date = $($row).find('td.date-only').text().replace(/[\s]/g, '');

        dc[movieId] = {
          'url': name,
          'fullUrl': location.origin + name,
          'rating': rating,
          'date': date,
          'type': showType,
          'year': showYear,
          'parentName': parentName,
          'parentId': parentId,
          'computed': false,
          'computedCount': "",
          'computedFromText': "",
          'lastUpdate': this.getCurrentDateTime(),
        };
      }
      return dc;
    }

    async getAllPagesNew(force = false) {
      const url = location.origin.endsWith('sk') ? `${this.userUrl}hodnotenia` : `${this.userUrl}hodnoceni`;
      const $content = await $.get(url);
      const $href = $($content).find(`.pagination a:not(.page-next):not(.page-prev):last`);
      const maxPageNum = $href.text();
      this.userRatingsCount = await this.getCurrentUserRatingsCount();

      const allUrls = [];
      // for (let idx = 1; idx <= 1; idx++) {  // TODO: DEBUG
      for (let idx = 1; idx <= maxPageNum; idx++) {
        const url = location.origin.endsWith('sk') ? `${this.userUrl}hodnotenia/?page=${idx}` : `${this.userUrl}hodnoceni/?page=${idx}`;
        allUrls.push(url);
      }

      const savedRatingsCount = Object.keys(this.stars).length;

      // If we don't have any ratings saved, load them all in chunks
      if (savedRatingsCount !== 0) {
        console.log(`Načíst jen chybějící hodnocení...`);

        let dict = this.stars;
        let ls = force ? [] : [dict];
        for (let idx = 1; idx <= maxPageNum; idx++) {
          console.log(`[ DEBUG ] iterating over idx <= ${maxPageNum}`);
          const onlyRated = Object.values(dict).filter((item) => item.computed === false);
          if (!force) if (onlyRated.length >= this.userRatingsCount) break;

          console.log(`Načítám hodnocení ${idx}/${maxPageNum} stránek`);
          Glob.popup(`Načítám hodnocení ${idx}/${maxPageNum} stránek`, 1, 200, 0);

          const url = location.origin.endsWith('sk') ? `${this.userUrl}hodnotenia/?page=${idx}` : `${this.userUrl}hodnoceni/?page=${idx}`;
          const res = await this.doSomethingNew(url);
          ls.push(res);
          if (!force) dict = await mergeDict(ls);
        }
        if (force) dict = await mergeDict(ls);
        return dict;
      }

      const chunkSize = 5;

      // Divide the urls into chunks of X (to not overload the browser)
      const chunks = [];
      while (allUrls.length) {
        chunks.push(allUrls.splice(0, chunkSize));
      }

      Glob.popup(`Načítám hodnocení...`, 2, 200, 0);

      // TODO: Debug
      // const limitedChunks = chunks.slice(0, 1);

      // Load the chunks in parallel
      let contents = [];
      let chunkDone = 0;
      for (const chunk of chunks) {
        Glob.popup(`Načítám hodnocení... ${chunkDone + chunk.length * NUM_RATINGS_PER_PAGE}/${this.userRatingsCount}`, 5, 200, 0);
        const content = await Promise.all(chunk.map(url => $.get(url)));
        contents.push(content);
        chunkDone += chunk.length * NUM_RATINGS_PER_PAGE;
      }

      // Process the content of each rating page
      let dc = {};
      const parentIds = [];
      const seriesIds = [];

      for (const content of contents) {
        for (const data of content) {
          const $rows = $(data).find('#snippet--ratings tr');

          // Process each row of the rating page
          // $row = <>ItemName | ItemUrl | (year) | (type) | (Detail) | Rating | Date</>
          for (const $row of $rows) {

            const name = $($row).find('td.name a').attr('href');  // /film/697624-love-death-robots/800484-zakazane-ovoce/
            const filmInfo = $($row).find('td.name > h3 > span > span');  // (2007)(série)(S02) // (2021)(epizoda)(S02E05)

            const [showType, showYear, parentName, [movieId, parentId]] = await Promise.all([
              csfd.getShowType(filmInfo),
              csfd.getShowYear(filmInfo),
              csfd.getParentNameFromUrl(name),
              csfd.getMovieIdParentIdFromUrl(name),
            ]);

            // If the show is a SEASON, it's parent is a SERIES and ID is in the URL
            if (showType === 'season') {
              // If parentId is not in parentIds, add it to the list
              if (!parentIds.includes(parentName)) {
                // console.debug(`[ DEBUG ] Adding parentName to [PARENT Ids]: ${parentName}`);
                parentIds.push(parentName);
              }
            }
            // If the show is a EPISODE, it's parent is a SEASON but the ID is not in the URL
            // We need to get the ID from the parentName (SERIES) content and then grab the SEASON IDs there
            else if (showType === 'episode') {
              // If parentId is not in parentIds, add it to the list
              if (!seriesIds.includes(parentName)) {
                // console.debug(`[ DEBUG ] Adding parentName to [SERIES Ids]: ${parentName}`);
                parentIds.push(parentName);
                seriesIds.push(parentName);
              }
            }

            // Get the RATING from the stars and the DATE
            const $ratings = $($row).find('span.stars');
            const rating = await csfd.getStarCountFromSpanClass($ratings);
            const date = $($row).find('td.date-only').text().replace(/[\s]/g, '');

            dc[movieId] = {
              'url': name,
              'fullUrl': location.origin + name,
              'rating': rating,
              'date': date,
              'type': showType,
              'year': showYear,
              'parentName': parentName,
              'parentId': parentId,
              'computed': false,
              'computedCount': "",
              'computedFromText': "",
              'lastUpdate': this.getCurrentDateTime(),
            };

          }
        }
      }

      if (settings.loadComputedRatings === false) {
        return dc;
      } else {
        // TODO: Load computed ratings
        return dc;
      }
    }

    /**
     * @param {<span>} filmInfo Combination of 0-3 `<span>` elements
     * @returns {int} `YYYY` (year) if it exists in filmInfo[0], `????` otherwise
     *
     * Example:
     * - (2007)(série)(S02) --> 2007
     * - (2021) --> 2021
     * - --> ????
     */
    async getShowYear(filmInfo) {
      const showYear = (filmInfo.length >= 1 ? $(filmInfo[0]).text().slice(1, -1) : '????');
      return parseInt(showYear);
    }

    /**
     * Return show type in 'english' language. Works for SK an CZ.
     *
     * @param {<span>} filmInfo Combination of 0-3 `<span>` elements
     * @returns {str} `showType` if it exists in filmInfo[1], `movie` otherwise
     *
     * Posible values: `movie`, `tv movie`, `serial`, `series`, `episode`
     *
     * Example:
     * - (2007)(série)(S02) --> series
     * - (2021)(epizoda)(S02E01) --> episode
     * - (2019) --> movie
     */
    async getShowType(filmInfo) {
      const showType = (filmInfo.length > 1 ? $(filmInfo[1]).text().slice(1, -1) : 'film');

      switch (showType) {
        case "epizoda": case "epizóda":
          return 'episode';

        case "série": case "séria":
          return 'season';

        case "seriál":
          return 'series';

        case "TV film":
          return 'tv movie';

        case 'film':
          return 'movie';

        default:
          return showType;
      }
    }

    /**
     * Return show type in 'english' language. Works for SK an CZ.
     *
     * @param {str} showType seriál, série, epizoda, film, ...
     * @returns {str} `showType`
     *
     * Posible returned values: `movie`, `tv movie`, `serial`, `series`, `episode`
     *
     * Example:
     * - série --> series
     * - epizoda --> episode
     * - film --> movie
     */
    getShowTypeFromType(showType) {

      showType = showType.toLowerCase();

      switch (showType) {
        case "epizoda": case "epizóda":
          return 'episode';

        case "série": case "séria":
          return 'season';

        case "seriál":
          return 'series';

        case "tv film":
          return 'tv movie';

        case 'film':
          return 'movie';

        default:
          return showType;
      }
    }

    /**
     * Get star count from span with stars class
     *
     * @param {"<span>"} $starsSpan $span with class of 'stars-X' or 'trash' type.
     * @returns {int} `0` if trash; `1-5` if stars-X
     *
     * Example: \
     *    `<span class="stars stars-4">` --> `4`\
     *    `<span class='stars trash'>` --> `0`
     */
    async getStarCountFromSpanClass($starsSpan) {
      let rating = 0;
      for (let stars = 0; stars <= 5; stars++) {
        if ($starsSpan.hasClass('stars-' + stars)) {
          rating = stars;
        }
      }
      return rating;
    }

    /**
     * Return **relative** parent name from episode name
     *
     * @param {string} name relative URL of episode name
     * @returns relative URL of parent name
     *
     * Example: \
     * `/film/697624-love-death-robots/800484-zakazane-ovoce/` --> `/film/697624-love-death-robots/`
     * `/film/697624-love-death-robots/` --> `""`
     */
    async getParentNameFromUrl(name) {
      const splitted = name.slice(0, -1).split("/");
      splitted.pop();
      const parentName = splitted.length > 2 ? splitted.join("/") + "/" : "";
      return parentName;
    }

    /**
     *
     * @param {str} href csfd link for movie/series/episode
     * @returns {Promise<str>} Movie ID number
     *
     * Example:
     * - href = '/film/774319-zhoubne-zlo/' --> '774319'
     * - href = '/film/1058697-devadesatky/1121972-epizoda-6/' --> '1121972'
     * - href = '1058697-devadesatky' --> '1058697'
     * - href = 'nothing-here' --> null
     */
    async getMovieIdFromHref(href) {
      if (!href) { return null; }
      const found_groups = href.match(/(\d)+-[-\w]+/ig);

      if (!found_groups) { return null; }
      const movieIds = found_groups.map(x => x.split("-")[0]);

      return movieIds[movieIds.length - 1];
    }

    /**
     * Extract MovieId, possibly ParentId from csfd URL address
     *
     * @param {str} url csfd movie URL
     * @returns {{MovieId: str, ParentId: str}}
     *
     * Example: \
     * - `/film/697624-love-death-robots/800484-zakazane-ovoce/` --> `{'MovieId': '800484', 'ParentId': '697624'}`
     * - `/film/697624-love-death-robots` --> `{'MovieId': '697624', 'ParentId': ''}`
     * - `/film/` --> `{'MovieId': '', 'ParentId': ''}`
     * - `/uzivatel/78145-songokussj/prehled/` --> `{'MovieId': '', 'ParentId': ''}`
     */
    async getMovieIdParentIdFromUrl(url) {
      if (!url.includes('/film/')) {
        // return { 'movieId': '', 'parentId': '' };
        return ['', ''];
      }
      let [firstResult, secondResult] = url.matchAll(/\/(\d+)-/g);
      if (firstResult === undefined && secondResult === undefined) {
        // return { 'movieId': '', 'parentId': '' };
        return ['', ''];
      }
      if (secondResult === undefined) {
        // return { 'movieId': firstResult[1], 'parentId': '' };
        return [firstResult[1], ''];
      }
      return [secondResult[1], firstResult[1]];
    }

    async refreshAllRatingsNew(csfd, force = false) {
      // Start timer
      const start = performance.now();

      await csfd.initializeClassVariables();
      csfd.stars = await this.getAllPagesNew(force);

      this.exportRatings();

      // Stop timer
      const end = performance.now();
      const time = (end - start) / 1000;
      console.debug(`Time: ${time} seconds`);

      // Refresh page
      location.reload();

      Glob.popup(`Vaše hodnocení byla načtena.<br>Obnovte stránku.`, 4, 200);
    }

    async removableHomeBoxes() {
      const boxSettingsName = 'CSFD-Compare-hiddenBoxes';
      const settings = await getSettings(boxSettingsName);

      $('.box-header').each(async function (index, value) {
        const $section = $(this).closest('section');
        $section.attr('data-box-id', index);

        if (settings.some(x => x.boxId == index)) {
          $section.hide();
        }

        const $btnHideBox = $('<a>', {
          'class': 'hide-me button',
          href: 'javascript:void(0)',
          html: `Skrýt`
        }).css({
          margin: 'auto',
          marginLeft: '10px',
          backgroundColor: '#7b0203',
          display: 'none',
        });

        let $h2 = $(this).find('h2');
        if ($h2.length === 0) {
          $h2 = $(this).find('p');
          $(this).css({ 'padding-right': '0px' });
          $h2.after($btnHideBox[0]);
          return;
          // }
        }

        $h2.append($btnHideBox[0]);
      });

      $('.box-header').on('mouseover', async function () {
        $(this).find('.hide-me').show();
      }).on('mouseout', async function () {
        $(this).find('.hide-me').hide();
      });

      $('.hide-me').on('click', async function (event) {
        const $section = $(event.target).closest('section');
        const boxId = $section.data('box-id');
        let boxName = $section.find('h2').first().text().replace(/\n|\t|Skrýt/g, "");  // clean from '\t', '\n'
        if (boxName === '') {
          boxName = $section.find('p').first().text().replace(/\n|\t|Skrýt/g, "");
        }
        const dict = { boxId: boxId, boxName: boxName };
        const settings = await getSettings(SETTINGSNAME_HIDDEN_BOXES);
        if (!settings.includes(dict)) {
          settings.push(dict);
          localStorage.setItem(boxSettingsName, JSON.stringify(settings));
          csfd.addHideSectionButton(boxId, boxName);
        }
        $section.hide();
      });
    }

    showOnOneLine() {
      const $sections = $(`div.creator-filmography`).find(`section`);
      let $nooverflowH3 = $sections.find(`h3.film-title-nooverflow`);
      $nooverflowH3.css({
        "display": "inline-block",
        "white-space": "nowrap",
        "text-overflow": "ellipsis",
        "overflow": "hidden",
        "max-width": "230px",
      });
      const $filmTitleNameA = $nooverflowH3.find(`a.film-title-name`);
      $filmTitleNameA.css({
        "white-space": "nowrap",
      });
      $filmTitleNameA.each(function () {
        const $this = $(this);
        $this.attr("title", $this.text());
      });
    }

    addHideSectionButton(boxId, boxName) {
      let $button = `
                <button class="restore-hidden-section" data-box-id="${boxId}" title="${boxName}"
                    style="border-radius: 4px;
                           margin: 1px;
                           max-width: 60px;
                           text-transform: capitalize;
                           overflow: hidden;
                           text-overflow: ellipsis;"
                >${boxName}</button>
            `;
      let $div = $(`div.hidden-sections`);
      $div.append($button);
    }

    /**
     * Creates a <span> element with a tooltip.
     *
     * @param {str} url imgur/github url of the image (screenshot)
     * @param {str} description description of the image
     * @returns {str} html code of the image
     */
    helpImageComponent(url, description) {
      const $span = $(`
                <span class="help-hover-image"
                      data-description="${description}"
                      data-img-url="${url}"><a href="${url}" target="_blank">💬</a></span>
            `).css({
        "cursor": "pointer",
        "color": "rgba(255, 255, 255, 0.8)",
      })
      return $span.get(0).outerHTML;
    }

    settingsPanelComponent() {
      const $div = $(`
        <article class="article" style="padding: 5px 10px;">
          <section>
            <div class="article-section">
              <button id="btnResetSettings" class="settings-button" style="border-radius: 4px;" title="Resetuje uložená nastavení (NE hodnocení)">Reset nastavení</button>
              <button id="btnRemoveSavedRatings" class="settings-button" style="border-radius: 4px;" title="Smaže všechna uložená hodnocení!">Smazat uložená hodnocení</button>
            </div>
          </section>
        </article>
      `)

      return $div.get(0).outerHTML;
    }

    async addSettingsPanel() {
      let dropdownStyle = 'right: 150px; width: max-content; max-width: 370px;';
      let disabled = '';
      let needToLoginTooltip = '';
      let needToLoginStyle = '';

      if (!await this.isLoggedIn()) {
        dropdownStyle = 'right: 50px; width: max-content;';
        disabled = 'disabled';
        needToLoginTooltip = `data-tippy-content="Funguje jen po přihlášení do CSFD"`;
        needToLoginStyle = 'color: grey;';
      }

      let button = document.createElement('li');
      // button.classList.add('active');  // TODO: Debug - Nonstop zobrazení CC Menu
      let resetLabelStyle = "-webkit-transition: initial; transition: initial; font-weight: initial; display: initial !important;";

      // Add box-id attribute to .box-header(s)
      $('.box-header').each(async function (index, value) {
        let $section = $(this).closest('section');
        $section.attr('data-box-id', index);
      });

      // Build array of buttons for un-hiding sections
      let resultDisplayArray = [];
      let hiddenBoxesArray = await getSettings(SETTINGSNAME_HIDDEN_BOXES);
      hiddenBoxesArray = await checkSettingsValidity(hiddenBoxesArray, SETTINGSNAME_HIDDEN_BOXES);
      hiddenBoxesArray.sort((a, b) => a - b);  // sort by numbers
      hiddenBoxesArray.forEach(element => {
        let boxId = element.boxId;
        let boxName = element.boxName.replace(/\n|\t/g, "");  // clean text of '\n' and '\t';
        resultDisplayArray.push(`
                    <button class="restore-hidden-section" data-box-id="${boxId}" title="${boxName}"
                            style="border-radius: 4px;
                                   margin: 1px;
                                   max-width: 60px;
                                   text-transform: capitalize;
                                   overflow: hidden;
                                   text-overflow: ellipsis;"
                    >
                        ${boxName}
                    </button>
                `);
      });

      const { computed: computed_ratings, rated: rated_ratings } = await this.getLocalStorageRatingsCount();
      const current_ratings = await this.getCurrentUserRatingsCount();

      button.innerHTML = `
                <a href="javascript:void()" class="user-link initialized csfd-compare-menu">CC</a>
                <div class="dropdown-content notifications" style="${dropdownStyle}">

                    <div class="dropdown-content-head csfd-compare-settings">

                        <h2>CSFD-Compare</h2>

                        <img src="https://i.imgur.com/1A2fPca.png" style="width: 32px; height: 32px; position: absolute; left: -17px; top: -9px; opacity: 85%;" />
                        <img src="https://i.imgur.com/1A2fPca.png" style="width: 32px; height: 32px; position: absolute; left: 340px; top: -9px; opacity: 85%;" />

                        <span class="badge" id="cc-control-panel-rating-count" title="Počet načtených/celkových červených hodnocení" style="margin-left: 10px; font-size: 0.7rem; font-weight: bold; background-color: #aa2c16; color: white; padding: 2px 4px; border-radius: 6px; cursor: help;">
                            ${rated_ratings} / ${current_ratings}
                        </span>

                        <span class="badge" id="cc-control-panel-computed-count" title="Počet načtených vypočtených hodnocení" style="margin-left: 10px; font-size: 0.7rem; font-weight: bold; background-color: #393939; color: white; padding: 2px 4px; border-radius: 6px; cursor: help;">
                            ${computed_ratings}
                        </span>

                        <span style="float: right; font-size: 0.7rem; margin-top: 0.2rem;">
                            <a id="script-version" href="${GREASYFORK_URL}">${VERSION}</a>
                        </span>
                    </div>

                    ${csfd.settingsPanelComponent()}

                    <article class="article" style="padding: 5px 10px;">
                        <h2 class="article-header">Domácí stránka - skryté panely</h2>
                        <section>
                            <div class="article-content">
                                <div class="hidden-sections" style="max-width: fit-content;">${resultDisplayArray.join("")}</div>
                            </div>
                        </section>
                    </article>

                    <article class="article" style="padding: 5px 10px;">
                        <h2 class="article-header">Globální</h2>
                        <section>
                            <div class="article-content">
                                <input type="checkbox" id="chkClickableHeaderBoxes" name="clickable-header-boxes">
                                <label for="chkClickableHeaderBoxes" style="${resetLabelStyle}">Boxy s tlačítkem "VÍCE" jsou klikatelné celé</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/8AwhbGK.png", "Boxy s tlačítkem 'VÍCE' jsou klikatelné celé, ne pouze na tlačítko 'VÍCE'")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkClickableMessages" name="clickable-messages" ${disabled}>
                                <label for="chkClickableMessages" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}>Klikatelné zprávy (bez tlačítka "více...")</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/ettGHsH.png", "Zprávy lze otevřít kliknutím kamkoli na zprávu, ne pouze na 'více...'")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkAddStars" name="add-stars" ${disabled}>
                                <label for="chkAddStars" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}>Přidat hvězdičky hodnocení u viděných filmů/seriálů</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/aTrSU2X.png", "Přidá hvězdy hodnocení u viděných filmů/seriálů")}
                            </div>
                        </section>
                    </article>

                    <article class="article" style="padding: 5px 10px;">
                        <h2 class="article-header">Uživatelé</h2>
                        <section>
                            <div class="article-content">
                                <input type="checkbox" id="chkControlPanelOnHover" name="control-panel-on-hover">
                                <label for="chkControlPanelOnHover" style="${resetLabelStyle}">Otevřít ovládací panel přejetím myší</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/N2hfkZ6.png", "Otevřít ovládací panel přejetím myší")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkCompareUserRatings" name="compare-user-ratings" ${disabled}>
                                <label for="chkCompareUserRatings" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}>Porovnat uživatelská hodnocení s mými</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/cDX0JaX.png", "Přidá sloupec pro porovnání hodnocení s mými hodnoceními")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkHideUserControlPanel" name="chide-user-control-panel" ${disabled}>
                                <label for="chkHideUserControlPanel" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}>Skrýt ovládací panel</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/KLzFqxM.png", "Skryje ovládací panel uživatele, další funkce lze poté zobrazit pomocí nastavení níže")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkDisplayMessageButton" name="display-message-button" ${disabled}>
                                <label for="chkDisplayMessageButton" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}> ↳ Přidat tlačítko odeslání zprávy</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/N1JuzYk.png", "Zobrazení tlačítka pro odeslání zprávy")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkDisplayFavoriteButton" name="display-favorite-button" ${disabled}>
                                <label for="chkDisplayFavoriteButton" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}> ↳ Přidat tlačítko přidat/odebrat z oblíbených</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/vbnFpEU.png", "Zobrazení tlačítka pro přidání/odebrání z oblíbených")}
                            </div>
                        </section>
                    </article>

                    <article class="article" style="padding: 5px 10px;">
                        <h2 class="article-header">Film/Seriál</h2>
                        <section>
                            <div class="article-content">
                                <input type="checkbox" id="chkShowLinkToImage" name="show-link-to-image">
                                <label for="chkShowLinkToImage" style="${resetLabelStyle}"}>Zobrazit odkazy na obrázcích</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/a2Av3AK.png", "Přidá vpravo odkazy na všechny možné velikosti, které jsou k dispozici")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkRatingsEstimate" name="ratings-estimate">
                                <label for="chkRatingsEstimate" style="${resetLabelStyle}">Vypočtení % při počtu hodnocení pod 10</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/qGAhXog.png", "Ukáže % hodnocení i u filmů s méně než 10 hodnoceními")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkRatingsFromFavorites" name="ratings-from-favorites" ${disabled}>
                                <label for="chkRatingsFromFavorites" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}>Zobrazit hodnocení z průměru oblíbených</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/ol88F6z.png", "Zobrazí % hodnocení od přidaných oblíbených uživatelů")}
                                </div>
                                <div class="article-content">
                                <input type="checkbox" id="chkAddRatingsComputedCount" name="compare-user-ratings" ${disabled}>
                                <label for="chkAddRatingsComputedCount" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}>Zobrazit spočteno ze sérií</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/KtpT81X.png", "Pokud je hodnocení 'vypočteno', zobrazí 'spočteno ze sérií/episod'")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkAddRatingsDate" name="add-ratings" ${disabled}>
                                <label for="chkAddRatingsDate" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}>Zobrazit datum hodnocení</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/CHpBDxK.png", "Zobrazí datum hodnocení <br>!!! Pozor !!! pere se s pluginem ČSFD Extended - v tomto případě ponechte vypnuté")}
                            </div>
                            <div class="article-content">
                                <input type="checkbox" id="chkHideSelectedUserReviews" name="hide-selected-user-reviews">
                                <label for="chkHideSelectedUserReviews" style="${resetLabelStyle}">Skrýt recenze lidí</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/k6GGE9K.png", "Skryje recenze zvolených uživatelů oddělených čárkou: POMO, kOCOUR")}
                                <div>
                                    <input type="textbox" id="txtHideSelectedUserReviews" name="hide-selected-user-reviews-list">
                                    <label style="${resetLabelStyle}">(např: POMO, golfista)</label>
                                </div>
                            </div>
                        </section>
                    </article>

                    <article class="article" style="padding: 5px 10px;">
                        <h2 class="article-header">Herci</h2>
                        <section>
                            <div class="article-content">
                                <input type="checkbox" id="chkShowOnOneLine" name="show-on-one-line">
                                <label for="chkShowOnOneLine" style="${resetLabelStyle}"}>Filmy na jednom řádku</label>
                                ${csfd.helpImageComponent("https://i.imgur.com/IPXzclo.png", "Donutí zobrazit název filmu na jeden řádek")}
                            </div>
                        </section>
                    </article>

                    <article class="article" style="padding: 5px 10px;">
                        <h2 class="article-header">!! Experimentální !!</h2>
                        <section>
                          <div class="article-content">
                              <input type="checkbox" id="chkLoadComputedRatings" name="control-panel-on-hover" disabled>
                              <label for="chkLoadComputedRatings" style="${resetLabelStyle}"><del>Přinačíst vypočtená (černá) hodnocení</del></label>
                          </div>
                          <div class="article-content">
                              <input type="checkbox" id="chkAddChatReplyButton" name="control-panel-on-hover" ${disabled}>
                              <label for="chkAddChatReplyButton" style="${resetLabelStyle} ${needToLoginStyle}" ${needToLoginTooltip}>Přidat v diskuzích možnost odpovědět na sebe</label>
                          </div>
                        </section>
                    </article>

                </div>
            `;
      $('.header-bar').prepend(button);

      await refreshTooltips();

      // Show help image on hover
      $(".help-hover-image").on('mouseenter', function (e) {
        const url = $(this).attr("data-img-url");
        const description = $(this).attr("data-description");
        $("body").append(
          `<p id='image-when-hovering-text'><img src='${url}'/><br>${description}</p>`
        );
        $("#image-when-hovering-text").css({
          position: "absolute",
          top: (e.pageY + 5) + "px",
          left: (e.pageX + 25) + "px",
          zIndex: "9999",
          backgroundColor: "white",
          padding: "5px",
          border: "1px solid #6a6a6a",
          borderRadius: "5px"
        }).fadeIn("fast");
      }).on('mouseleave', function () {
        $("#image-when-hovering-text").remove();
      });

      $(".help-hover-image").on('mousemove', function (e) {
        $("#image-when-hovering-text")
          .css("top", (e.pageY + 5) + "px")
          .css("left", (e.pageX + 25) + "px");
      });

      // Show() the section and remove the number from localStorage
      $(".hidden-sections").on("click", ".restore-hidden-section", async function () {
        let $element = $(this);
        let sectionId = $element.attr("data-box-id");

        // Remove from localStorage
        let hiddenBoxesArray = await getSettings(SETTINGSNAME_HIDDEN_BOXES);
        hiddenBoxesArray = hiddenBoxesArray.filter(item => item.boxId !== parseInt(sectionId));
        let settingsName = "CSFD-Compare-hiddenBoxes";
        localStorage.setItem(settingsName, JSON.stringify(hiddenBoxesArray));

        // Show section
        let $section = $(`section[data-box-id="${sectionId}"`);
        $section.show();

        // Remove button
        $element.remove();
      });

      // TODO: DEBUG - zakomentovat pro nonstop zobrazení CC Menu
      // Don't hide settings popup when mouse leaves within interval of 0.2s
      let timer;
      $(button).on("mouseover", function () {
        if (timer) {
          clearTimeout(timer);
          timer = null;
        }
        if (!$(button).hasClass("active")) {
          $(button).addClass("active");
        }
      });

      $(button).on("mouseleave", function () {
        if ($(button).hasClass("active")) {
          timer = setTimeout(() => {
            $(button).removeClass("active");
          }, 200);
        }
      });

      $(button).find("#btnResetSettings").on("click", async function () {
        console.debug("Resetting 'CSFD-Compare-settings' settings...");
        localStorage.removeItem("CSFD-Compare-settings");
        location.reload();
      });

      $(button).find("#btnRemoveSavedRatings").on("click", async function () {
        const username = await csfd.getUsername();

        if (!username) {
          alert("Nejprve se přihlašte.");
          return;
        }

        if (!confirm(`Opravdu chcete smazat uložená hodnocení uživatele ${username}?`)) {
          return;
        }

        console.debug(`Removing saved ratings for user '${username}'...`);
        localStorage.removeItem(`CSFD-Compare_${username}`);
        location.reload();
      });
    }

    async checkAndUpdateCurrentRating() {
      const { rating, computedFrom, computed } = await this.getCurrentFilmRating();
      const currentFilmDateAdded = await this.getCurrentFilmDateAdded();

      const filmUrl = await this.getCurrentFilmUrl();
      const filmId = await this.getMovieIdFromHref(filmUrl);

      // In case user removed rating, we need to remove it from the LC
      if (rating === "") {
        console.info("☠️ No rating on current page but record in LC => Removing record...");
        const removed = await this.removeFromLocalStorage();
        if (removed) {
          console.info("☠️ Removed record from LC.");
          await this.updateControlPanelRatingCount();
        }
      } else {
        // Check if current page rating corresponds with that in LocalStorage, if not, update it
        const filmFullUrl = this.getCurrentFilmFullUrl();
        const type = this.getCurrentFilmType();
        const year = this.getCurrentFilmYear();
        const lastUpdate = this.getCurrentDateTime()

        const ratingsObject = {
          url: filmUrl,
          fullUrl: filmFullUrl,
          rating: rating,
          date: currentFilmDateAdded,
          type: type,
          year: year,
          computed: computed,
          computedCount: computed ? this.getCurrentFilmComputedCount() : "",
          computedFromText: computed ? computedFrom : "",
          lastUpdate: lastUpdate,
        };
        const updated = await this.updateInLocalStorage(ratingsObject);
        if (updated) {
          console.info("✅ Updated record in LC.");
          await this.updateControlPanelRatingCount();
        }
      }

    }

    /**
     * Returns current DateTime, e.g. 11.10.2022 1:49:42
     * @returns {str} DateTime in format DD.MM.YYYY hh:mm:ss
     */
    getCurrentDateTime() {
      const d = new Date
      const dateFormat = [
        d.getDate(),
        d.getMonth() + 1,
        d.getFullYear()
      ].join('.') + ' ' + [
        d.getHours(),
        d.getMinutes(),
        d.getSeconds()
      ].join(':');
      return dateFormat
    }

    /**
     * When user wants to open message, he needs to click on 'více' link.
     * This removes the 'více' link and enables to click on the message.
     *
     * @returns {None}
     */
    clickableMessages() {
      const $messagesBox = $('.dropdown-content.messages');
      const $moreSpan = $messagesBox.find('.span-more-small');
      if ($moreSpan.length < 1) { return; }

      for (const $span of $moreSpan) {

        // Hide "... více" button
        $($span).hide();

        const $content = $($span).closest('.article-content');
        const $article = $content.closest('article');
        $content.on('hover', function () {
          $article.css('background-color', '#e1e0e0');
        }, function () {
          $article.css('background-color', 'initial');
        });

        const href = $($span).find('a').attr('href');
        $content.wrap(`<a href="${href}"></a>`);
      }
    }

    async clickableHeaderBoxes() {
      // CLICKABLE HEADER BUTTONS
      $(".user-link.wantsee").on("click", function () {
        location.href = "/chci-videt/";
      });
      $(".user-link.favorites").on("click", function () {
        location.href = "/soukrome/oblibene/";  // TODO: Toto pry nefunguje
      });
      $(".user-link.messages").on("click", function () {
        location.href = "/posta/";
      });

      // CLICKABLE HEADER DIVS
      const headers = $('.dropdown-content-head,.box-header');
      for (const div of headers) {
        const btn = $(div).find('a.button');

        if (btn.length === 0) { continue; }
        if (!["více", "viac"].includes(btn[0].text.toLowerCase())) { continue; }

        $(div).wrap(`<a href="${btn.attr('href')}"></a>`);

        const h2 = $(div).find('h2');
        const spanCount = h2.find('span.count');
        $(div)
          .on('mouseover', () => {
            $(div).css({ backgroundColor: '#ba0305' });
            $(h2[0]).css({ backgroundColor: '#ba0305', color: '#fff' });
            if (spanCount.length == 1) { spanCount[0].style.color = '#fff'; }
          })
          .on('mouseout', () => {
            if ($(div).hasClass('dropdown-content-head')) {
              $(div).css({ backgroundColor: '#ececec' });
            } else {
              $(div).css({ backgroundColor: '#e3e3e3' });
            }
            $(h2[0]).css({ backgroundColor: 'initial', color: 'initial' });
            if (spanCount.length == 1) { spanCount[0].style.color = 'initial'; }
          });
      }
    }

    hideSelectedUserReviews() {
      let articleHeaders = $('.article-header-review-name');
      for (const element of articleHeaders) {
        let userTitle = $(element).find('.user-title-name');
        if (userTitle.length != 1) { continue; }
        let ignoredUser = settings.hideSelectedUserReviewsList.includes(userTitle[0].text);
        if (!ignoredUser) { continue; }
        $(element).closest('article').hide();
      }
    }

    /**
     *
     * @returns {Promise<{rating: string, computedFrom: string, computed: boolean}>}
     */
    async getCurrentFilmDateAdded() {
      let ratingText = this.csfdPage.find('span.stars-rating.initialized').attr('title');
      if (ratingText === undefined) {
        // Grab the rating date from mobile-rating
        ratingText = this.csfdPage.find('.mobile-film-rating-detail a span').attr('title');
        if (ratingText === undefined) {
          return;
        }
      }
      let match = ratingText.match("[0-9]{2}[.][0-9]{2}[.][0-9]{4}");
      if (match !== null) {
        let ratingDate = match[0];
        return ratingDate;
      }
      return undefined;
    }

    async addRatingsDate() {
      // Grab the rating date from stars-rating
      let ratingText = $('span.stars-rating.initialized').attr('title');
      if (ratingText === undefined) {
        // Grab the rating date from mobile-rating
        ratingText = $('.mobile-film-rating-detail a span').attr('title');
        if (ratingText === undefined) {
          return;
        }
      }
      let match = ratingText.match("[0-9]{2}[.][0-9]{2}[.][0-9]{4}");
      if (match !== null) {
        let ratingDate = match[0];
        let $myRatingCaption = $('.my-rating h3');
        $myRatingCaption.html(`${$myRatingCaption.text()}<br>${ratingDate}`);
      }
    }

    /**
     * From the title of .current-user-rating span get 'spocteno ze serii: x'
     * and add it bellow the 'Moje hodnoceni' text
     */
    async addRatingsComputedCount() {
      let $computedStars = $('.star.active.computed');
      let isComputed = $computedStars.length != 0;
      if (!isComputed) { return; }
      let fromRatingsText = this.csfdPage.find('.current-user-rating > span').attr('title');
      if (fromRatingsText === undefined) {
        return;
      }
      let $myRatingCaption = $('.my-rating h3');
      $myRatingCaption.html(`${$myRatingCaption.text()}<br>${fromRatingsText}`);
    }

    async checkForUpdate() {
      let pageHtml = await $.get(GREASYFORK_URL);
      let version = $(pageHtml).find('dd.script-show-version > span').text();
      return version;
    }

    async getChangelog() {
      let pageHtml = await $.get(`${GREASYFORK_URL}/versions`);
      let versionDateTime = $(pageHtml).find('.version-date').first().attr('datetime');
      let versionNumber = $(pageHtml).find('.version-number a').first().text();
      let versionDate = versionDateTime.substring(0, 10);
      let versionTime = versionDateTime.substring(11, 16);
      let changelogText = `
                <div style="font-size: 0.8rem; line-height: 1.5;">${versionDate} ${versionTime} (${versionNumber})<br>
                    <hr>
                    ${$(pageHtml).find('.version-changelog').html()}
                </div>
            `;
      return changelogText;
    }

    async initializeClassVariables() {
      this.userUrl = await this.getCurrentUser();
      const username = await this.getUsername();
      this.storageKey = `${SCRIPTNAME}_${username}`;
      this.userRatingsUrl = location.origin.endsWith('sk') ? `${this.userUrl}/hodnotenia` : `${this.userUrl}/hodnoceni`;
      this.stars = this.getStars();
    }

    async updateControlPanelRatingCount() {
      const { computed, rated } = await csfd.getLocalStorageRatingsCount();
      const current_ratings = await this.getCurrentUserRatingsCount();

      const $ratingsSpan = $('#cc-control-panel-rating-count');
      const $computedSpan = $('#cc-control-panel-computed-count');

      $ratingsSpan.text(`${rated} / ${current_ratings}`);
      $computedSpan.text(`${computed}`);
    }

    async isRatingCountOk() {
      const { rated, computed } = await csfd.getLocalStorageRatingsCount();
      const current_ratings = await this.getCurrentUserRatingsCount();
      return rated === current_ratings
    }

    /**
     * For some reason, IMDb button to link current film does not have icon. This function adds it.
     *
     * @returns {Promise<void>}
     */
    async addImdbIcon() {
      const $image = $('<img>', {
        // src: 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/171_Imdb_logo_logos-512.png',
        src: 'https://images.squarespace-cdn.com/content/v1/57c984f1cd0f68cf4beeb2cf/1472911999963-KH5AM2AU675ZGJUJEGQV/imdb+logo.png',
        alt: 'IMDB',
        title: 'IMDB',
        style: 'width: 26px; height: 26px; mix-blend-mode: darken;',
        class: 'imdb-icon',
      });
      const $imdbI = $('a.button-big.button-imdb i');
      $imdbI.css({ 'opacity': '1', 'background-color': '#f5c518' });
      $imdbI.append($image);
    }

    /**
     * On discussion pages, add a button to reply to user's own comments.
     *
     * Limitations:
     *  - If user replies to his own comment, he can't reply to the first someone else's comment.
     *  - Can't reply to multiple user's own comments at once.
     *
     * @returns {Promise<void>}
     */
    async addChatReplyButton() {

      // Get all divs with class 'icon-control' containing <i> with class 'icon-reply' in them (working reply buttons)
      const replyIconControlElements = $('.icon-control:has(i.icon-reply)');
      const $firstWorkingReplyIconControl = replyIconControlElements.first();
      const missingIconElements = $('.icon-control').not($(':has(i.icon-reply'));

      // Copy the working reply button and add it to the missingIconElements
      missingIconElements.each(async (index, element) => {

        // Clone working IconControl and remove potential '<a>' element whose child element has 'i.icon-trash'
        const $replyIconControlClone = $firstWorkingReplyIconControl.clone();
        $replyIconControlClone.find('a:has(i.icon-trash)').remove();

        const $replyIconCloneHref = $replyIconControlClone.find('a');  // TODO: not trash
        const $userTitle = element.closest('.article-content').querySelector('a.user-title-name');

        const username = $userTitle.text;
        const userId = $userTitle.href.split("/")[4].split("-")[0]
        const postId = element.closest('article').getAttribute('id').split('-')[2];

        $replyIconCloneHref.attr({
          'data-nick': username,
          'data-id': userId,
          'data-post': postId,
        });

        const $replyIconClone = $replyIconControlClone.find('i.icon-reply');
        $replyIconClone.on('click', () => {
          const $originalHref = $firstWorkingReplyIconControl.find('a');

          // Save original state
          const originalState = {
            'data-nick': $originalHref.attr('data-nick'),
            'data-id': $originalHref.attr('data-id'),
            'data-post': $originalHref.attr('data-post'),
          };

          // Edit the working reply button to contain the correct data
          $originalHref.attr({
            'data-nick': username,
            'data-id': userId,
            'data-post': postId,
          });

          // Trigger the click on the working reply button
          $originalHref.find('i.icon-reply').trigger('click');

          // Return the working reply button to its original state
          $originalHref.attr({
            'data-nick': originalState['data-nick'],
            'data-id': originalState['data-id'],
            'data-post': originalState['data-post'],
          });
        });

        $(element).append($replyIconCloneHref);
      });
    }
  }

  // ============================================================================================
  // SCRIPT START
  // ============================================================================================
  await delay(20);  // Greasemonkey workaround, wait a little bit for page to somehow load
  console.debug("CSFD-Compare - Script started")
  let csfd = new Csfd($('div.page-content'));


  // =================================
  // LOAD SETTINGS
  // =================================
  await csfd.fillMissingSettingsKeys();

  const settings = await getSettings();
  await csfd.addSettingsPanel();
  await csfd.loadInitialSettings();
  await csfd.addSettingsEvents();


  // =================================
  // GLOBAL
  // =================================
  csfd.addImdbIcon();

  if (settings.clickableHeaderBoxes) { csfd.clickableHeaderBoxes(); }
  if (settings.showControlPanelOnHover) { csfd.openControlPanelOnHover(); }

  // Film/Series page
  if (location.href.includes('/film/') || location.href.includes('/tvurce/') || location.href.includes('/tvorca/')) {
    if (settings.hideSelectedUserReviews) { csfd.hideSelectedUserReviews(); }
    if (settings.showLinkToImage) { csfd.showLinkToImage(); }
    if (settings.ratingsEstimate) { csfd.ratingsEstimate(); }
    if (settings.ratingsFromFavorites) { csfd.ratingsFromFavorites(); }
  }

  // =================================
  // Page - Tvurce
  // =================================
  if (location.href.includes('/tvurce/') || location.href.includes('/tvorca/')) {
    if (settings.showOnOneLine) { csfd.showOnOneLine(); }
  }


  // =================================
  // Page - Homepage
  // =================================
  if (await onHomepage()) { csfd.removableHomeBoxes(); }

  // =================================
  // NOT LOGGED IN
  // =================================
  if (!await csfd.isLoggedIn()) {
    // User page
    if (location.href.includes('/uzivatel/')) {
      if (settings.hideUserControlPanel) { csfd.hideUserControlPanel(); }
    }
  }


  // =================================
  // LOGGED IN
  // =================================
  if (await csfd.isLoggedIn()) {
    // Global settings without category
    await csfd.initializeClassVariables();

    csfd.checkForOldLocalstorageRatingKeys();

    // Update user ratings count
    await csfd.updateControlPanelRatingCount();

    // =================================
    // Page - Diskuze
    // =================================
    if (await csfd.onPageDiskuze() && settings.addChatReplyButton) {
      csfd.addChatReplyButton()
    }

    if (settings.addStars && await csfd.notOnUserPage()) { csfd.addStars(); }

    let ratingsInLocalStorage = 0;
    let computedRatingsInLocalStorage = 0;
    let currentUserRatingsCount = 0;
    if (settings.addStars || settings.compareUserRatings) {
      const { computed, rated } = await csfd.getLocalStorageRatingsCount();
      ratingsInLocalStorage = rated;
      computedRatingsInLocalStorage = computed;
      currentUserRatingsCount = await csfd.getCurrentUserRatingsCount();
      if (ratingsInLocalStorage !== currentUserRatingsCount) {
        csfd.refreshButtonNew(ratingsInLocalStorage, currentUserRatingsCount, computedRatingsInLocalStorage);
        csfd.badgesComponent(ratingsInLocalStorage, currentUserRatingsCount, computedRatingsInLocalStorage);
        await csfd.addWarningToUserProfile();
      } else {
        csfd.userRatingsCount = currentUserRatingsCount;
      }
    }

    // =================================
    // Header modifications
    // =================================
    if (settings.clickableMessages) { csfd.clickableMessages(); }

    // =================================
    // Page - Film
    // =================================
    if (location.href.includes('/film/')) {
      if (settings.addRatingsDate) { csfd.addRatingsDate(); }
      if (settings.addRatingsComputedCount) { csfd.addRatingsComputedCount(); }

      // Dynamic LocalStorage update on Film/Series in case user changes ratings
      await csfd.checkAndUpdateCurrentRating();
    }

    // =================================
    // Page - Other User
    // =================================
    if (await csfd.onPageOtherUser()) {
      if (settings.displayMessageButton) { csfd.displayMessageButton(); }
      if (settings.displayFavoriteButton) { csfd.displayFavoriteButton(); }
      if (settings.hideUserControlPanel) { csfd.hideUserControlPanel(); }
      if (await csfd.onPageOtherUserHodnoceni()) {
        if (settings.compareUserRatings) { csfd.addRatingsColumn(); }
      }
    }
  }

  // let t0 = performance.now();
  // const $siteHtml = await $.get(GREASYFORK_URL);
  // let t1 = performance.now();
  // console.log("Call to 'await $.get(GREASYFORK_URL)' took " + (t1 - t0) + " ms.");

  // =================================
  // Check for update
  // =================================
  // If not already in session storage, get new version from greasyfork and display changelog over version link
  let updateCheckJson = sessionStorage.updateChecked !== undefined ? JSON.parse(sessionStorage.updateChecked) : {};
  let $verLink = $('#script-version');
  if (Object.keys(updateCheckJson).length !== 0) {
    const difference = (Date.now() - updateCheckJson.lastCheck) / 60 / 60 / 60;
    const curVersion = VERSION.replace('v', '');
    // If more than 5 minutes, check for update
    if (difference >= 5) {
      let version = await csfd.checkForUpdate();
      let changelogText = await csfd.getChangelog();
      updateCheckJson.changelogText = changelogText;
      $verLink.attr("data-tippy-content", changelogText);
      if (version !== curVersion) {
        updateCheckJson.newVersion = true;
        updateCheckJson.newVersionNumber = version;

        let versionText = `${$verLink.text()} (Update v${version})`;
        $verLink.text(versionText);
        updateCheckJson.versionText = versionText;
      } else {
        updateCheckJson.newVersion = false;
        updateCheckJson.versionText = VERSION;
      }
      updateCheckJson.lastCheck = Date.now();
      sessionStorage.updateChecked = JSON.stringify(updateCheckJson);
    } else {
      if (updateCheckJson.newVersion === true) {
        if (updateCheckJson.newVersionNumber === curVersion) {
          $verLink.text(`v${curVersion}`);
        } else {
          const versionText = `${$verLink.text()} (Update v${updateCheckJson.newVersionNumber})`;
          $verLink.text(versionText);
        }
        $verLink.attr("data-tippy-content", updateCheckJson.changelogText);
      } else {
        $verLink.attr("data-tippy-content", updateCheckJson.changelogText);
      }
      // $('#script-version')
      //     .text(updateCheckJson.versionText)
      //     .attr("data-tippy-content", updateCheckJson.changelogText);
    }

  } else {
    let version = await csfd.checkForUpdate();
    let curVersion = VERSION.replace('v', '');
    if (version !== curVersion) {
      updateCheckJson.newVersion = true;
      let $verLink = $('#script-version');
      let versionText = `${$verLink.text()} (Update v${version})`;
      updateCheckJson.versionText = versionText;
      updateCheckJson.newVersionNumber = version;
      let changelogText = await csfd.getChangelog();
      $verLink.text(versionText);
      updateCheckJson.changelogText = changelogText;
      $verLink.attr("data-tippy-content", changelogText);
    } else {
      updateCheckJson.changelogText = await csfd.getChangelog();
      updateCheckJson.newVersion = false;
      updateCheckJson.versionText = VERSION;
      $('#script-version').attr("data-tippy-content", updateCheckJson.changelogText);
    }
    updateCheckJson.lastCheck = Date.now();
    sessionStorage.updateChecked = JSON.stringify(updateCheckJson);
  }

  // Call TippyJs constructor
  await refreshTooltips();

  // =================================
  // TEST
  // =================================
  // const api = new Api();
  // const url = 'https://csfdb.noirgoku.eu/api/v1/movies/byids/';
  // const res = await api.getCurrentPageRatings(url);
  // console.log("CURRENT PAGE RATINGS");
  // console.log(res);

})();