ČSFD Compare

Show your own ratings on other users ratings list

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         ČSFD Compare
// @version      0.6.0.3
// @namespace    csfd.cz
// @description  Show your own ratings on other users ratings list
// @author       Jan Verner <[email protected]>
// @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.3';
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-]+/gi);

      // 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');

      // Remove any previously bound events on these elements
      btn.off();
      panel.off();

      // Ensure the panel starts hidden (remove 'active' class)
      panel.removeClass('active');

      let showTimer = null;
      let isBtnHovering = false;
      let isPanelHovering = false;
      let panelTriggered = false; // becomes true only after delay

      let openPanelTimeout;

      btn.on('mouseenter', function () {
        isBtnHovering = true;

        openPanelTimeout = setTimeout(() => {
          console.log('openPanelTimeout triggered');
          if (isBtnHovering && !panel.hasClass('active')) {
            panelTriggered = true;
            panel.addClass('active');

            const windowWidth = $(window).width();
            if (windowWidth <= 635) {
              panel.appendTo(document.body);
              panel.css({ top: '133px', right: '15px' });
            }
          }
        }, 500);
      });

      btn.on('mouseleave', function () {
        isBtnHovering = false;
        clearTimeout(showTimer);
        setTimeout(() => {
          if (!isBtnHovering && !isPanelHovering) {
            panelTriggered = false;
            panel.removeClass('active');
          }
        }, 200); // short delay for smooth hide
      });

      panel.on('mouseenter', function () {
        isPanelHovering = true;
        clearTimeout(showTimer);
        // Only allow panel to stay active if it was triggered already
        if (panelTriggered && !panel.hasClass('active')) {
          panel.addClass('active');
        }
      });

      panel.on('mouseleave', function () {
        isPanelHovering = false;
        setTimeout(() => {
          if (!isBtnHovering && !isPanelHovering) {
            panelTriggered = false;
            panel.removeClass('active');
          }
        }, 200);
      });
    }

    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%',
        height: 'auto',
      });
      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]+/gi);

      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: 500px;';
      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);
})();