MyAnimeList (MAL) Track Missing Relations

Allows to find missing relations and entries with wrong episode/chapter count

// ==UserScript==
// @name        MyAnimeList (MAL) Track Missing Relations
// @namespace   https://greasyfork.org/users/7517
// @description Allows to find missing relations and entries with wrong episode/chapter count
// @icon        http://i.imgur.com/b7Fw8oH.png
// @version     8.5.3
// @author      akarin
// @include     /^https?:\/\/myanimelist\.net\/profile/
// @grant       none
// @noframes
// ==/UserScript==

/* eslint-env es6, browser, jquery */
/* jshint esversion: 6, browser: true, jquery: true */

(function ($) {
  'use strict';

  if ($('#malLogin').length > 0) {
    return;
  }

  const mal = {
    title: 'Missing Relations',
    name: '',
    type: 'anime',
    forceUpdate: false,
    forceFrom: 0,
    forceTo: 0
  };

  const USER_STATUS = {
    IN_PROCESS: 1, COMPLETED: 2, ON_HOLD: 3, DROPPED: 4, PLAN_TO: 6
  };

  const SERIES_STATUS = {
    IN_PROCESS: 1, COMPLETED: 2, NOT_YET: 3
  };

  const OPTS = {
    CACHE_VERSION: '8.3',
    EXPAND_LONG: 100,
    EXPAND_LONG_R: 101,
    HIDE_NOTYET: 102,
    HIDE_AIRING: 103
  };

  class Cache {
    constructor (name, username) {
      this.name = name;
      this.username = username;
      this.objects = {
        left: true,
        graph: false,
        title: false,
        airing: false,
        status: true,
        wrong: true,
        hidden: true
      };
      this.data = {};
      this.init();
    }

    init (id) {
      id = parseInt(id);
      Object.keys(this.objects).forEach(obj => {
        if (isNaN(id)) {
          this.data[obj] = {};
        } else {
          delete this.data[obj][id];
        }
      });
    }

    load (obj) {
      if (obj == null) {
        Object.keys(this.objects).forEach(obj => this.load(obj));
      } else {
        this.data[obj] = this.loadValue('mal.entries.' + obj, this.data[obj], mal.type, this.objects[obj]);
      }
    }

    save (obj) {
      if (obj == null) {
        Object.keys(this.objects).forEach(obj => this.save(obj));
      } else {
        this.saveValue('mal.entries.' + obj, this.data[obj], mal.type, this.objects[obj]);
      }
    }

    clear (id) {
      const hidden = Object.prototype.hasOwnProperty.call(this.data, 'hidden') ? this.data.hidden : {};
      this.init(id);
      this.data.hidden = hidden;
    }

    encodeKey (key, type, userobj) {
      const username = userobj === true ? this.username : '';
      return this.name + '#' + OPTS.CACHE_VERSION + '#' + username + '#' + type + '#' + key;
    }

    loadValue (key, value, type, userobj) {
      try {
        let result = JSON.parse(localStorage.getItem(this.encodeKey(key, type, userobj)));
        if (result == null) {
          result = JSON.parse(localStorage.getItem(this.encodeKey(key, type, !userobj)));
          if (result != null) {
            console.log(`Migrate cache key "${key}"`);
            this.clearValue(key, type, !userobj);
            this.saveValue(key, result, type, userobj);
          }
        }
        return result == null ? value : result;
      } catch (err) {
        console.error(err);
        return value;
      }
    }

    saveValue (key, value, type, userobj) {
      localStorage.setItem(this.encodeKey(key, type, userobj), JSON.stringify(value));
    }

    clearValue (key, type, userobj) {
      localStorage.removeItem(this.encodeKey(key, type, userobj));
    }

    export (type) {
      const json = {};
      Object.keys(this.objects).forEach(obj => {
        json[obj] = this.loadValue('mal.entries.' + obj, {}, type, this.objects[obj]);
      });
      return json;
    }

    import (type, json) {
      if (json !== Object(json)) {
        return;
      }
      Object.keys(this.objects).forEach(obj => {
        this.saveValue('mal.entries.' + obj, json[obj], type, this.objects[obj]);
      });
    }
  }

  class MalData {
    constructor (username, type, offset) {
      this.username = username;
      this.type = type;
      this.offset = offset;
      this.running = false;
      this.data = {};
      this.size = 0;
    }

    clear () {
      this.running = false;
      this.data = {};
      this.size = 0;
    }

    load (callbacks, filter, offset) {
      if (!this.running) {
        return;
      }

      const hasFilter = Array.isArray(filter) && filter.length > 0;

      $.ajax({
        url: '/' + this.type + 'list/' + this.username + '/load.json?status=7&offset=' + offset,
        dataType: 'json'
      })
        .done((data) => {
          if (Array.isArray(data) && data.length > 0) {
            data.forEach((entry) => {
              this.data[entry[this.type + '_id']] = hasFilter ? Object.keys(entry)
                .filter(key => filter.includes(key))
                .reduce((obj, key) => {
                  obj[key] = entry[key];
                  return obj;
                }, {}) : entry;
            });

            this.size += data.length;
            if (Object.prototype.hasOwnProperty.call(callbacks, 'onNext')) {
              callbacks.onNext(this.size);
            }

            this.load(callbacks, filter, offset + this.offset);
          } else {
            this.running = false;
            if (Object.prototype.hasOwnProperty.call(callbacks, 'onFinish')) {
              callbacks.onFinish(Object.assign({}, this.data));
            }
            this.clear();
          }
        })
        .fail(() => {
          this.clear();
          if (Object.prototype.hasOwnProperty.call(callbacks, 'onError')) {
            callbacks.onError();
          }
        });
    }

    populate (callbacks, filter) {
      if (this.running) {
        return;
      }

      this.clear();
      this.running = true;
      this.load(callbacks, filter, 0);
    }
  }

  function toHtmlId (str) {
    return String(str).trim().toLowerCase().replace(/\s/g, '_').replace(/[^\w]/g, '_');
  }

  function toHtmlStr (str) {
    return String(str).trim().replace(/"/g, '"');
  }

  mal.settings = {
    ajaxDelay: 3000,
    windowWidth: 700,
    windowHeight: 820,
    footerHeight: 88,
    footerSwitchHeight: 10,

    availableRelations: [
      'Alternative Setting', 'Alternative Version', 'Character', 'Full Story', 'Other',
      'Parent Story', 'Prequel', 'Sequel', 'Side Story', 'Spin-off', 'Summary'
    ],
    availableStatus: {
      anime: ['Watching', 'Completed', 'On-Hold', 'Dropped', 'Plan to Watch'],
      manga: ['Reading', 'Completed', 'On-Hold', 'Dropped', 'Plan to Read']
    },
    otherSettings: {
      anime: [
        { id: OPTS.EXPAND_LONG, text: 'Expand long lists (both)', footer: true, def: false, val: false },
        { id: OPTS.EXPAND_LONG_R, text: 'Expand long lists (right)', footer: true, def: false, val: false },
        { id: OPTS.HIDE_NOTYET, text: 'Hide unaired entries (from your list)', footer: false, def: false, val: false },
        { id: OPTS.HIDE_AIRING, text: 'Hide airing entries (from your list)', footer: false, def: false, val: false }
      ],
      manga: [
        { id: OPTS.EXPAND_LONG, text: 'Expand long lists (both)', footer: true, def: false, val: false },
        { id: OPTS.EXPAND_LONG_R, text: 'Expand long lists (right)', footer: true, def: false, val: false },
        { id: OPTS.HIDE_NOTYET, text: 'Hide unpublished entries (from your list)', footer: false, def: false, val: false },
        { id: OPTS.HIDE_AIRING, text: 'Hide publishing entries (from your list)', footer: false, def: false, val: false }
      ]
    },

    excludedRelations: { anime: [], manga: [] },
    excludedStatus: {
      left: { anime: [], manga: [] },
      right: { anime: [], manga: [] }
    },

    load: function () {
      ['anime', 'manga'].forEach(function (type) {
        mal.settings.excludedRelations[type] = ['Adaptation'];
        mal.settings.availableRelations.forEach(function (val) {
          const id = 'mr_xr' + type[0] + '_' + toHtmlId(val);
          const flag = mal.cache.loadValue(id, 'false', 'global');
          if (flag === 'true' || flag === true) {
            mal.settings.excludedRelations[type].push(val);
          }
        });

        ['left', 'right'].forEach(function (status) {
          mal.settings.excludedStatus[status][type] = ['Empty Status'];
          mal.settings.availableStatus[type].forEach(function (val) {
            const id = 'mr_xs' + status[0] + type[0] + '_' + toHtmlId(val);
            const flag = mal.cache.loadValue(id, 'false', 'global');
            if (flag === 'true' || flag === true) {
              mal.settings.excludedStatus[status][type].push(val);
            }
          });
        });

        mal.settings.otherSettings[type].forEach(function (opt) {
          const id = 'mr_xo' + type[0] + '_' + opt.id;
          const flag = mal.cache.loadValue(id, opt.def === true ? 'true' : 'false', 'global');
          opt.val = (flag === 'true' || flag === true);
        });
      });
    },

    export: function (type) {
      const json = {};

      json.relations = {};
      mal.settings.availableRelations.forEach((val) => {
        const flag = mal.cache.loadValue('mr_xr' + type[0] + '_' + toHtmlId(val), 'false', 'global');
        json.relations[val] = (flag === 'true' || flag === true);
      });

      ['left', 'right'].forEach((status) => {
        json['hide_' + status] = {};
        mal.settings.availableStatus[type].forEach((val) => {
          const flag = mal.cache.loadValue('mr_xs' + status[0] + type[0] + '_' + toHtmlId(val), 'false', 'global');
          json['hide_' + status][val] = (flag === 'true' || flag === true);
        });
      });

      {
        const flag = mal.cache.loadValue('mr_xsr' + type[0] + '_empty_status', 'false', 'global');
        json.hide_right.empty_status = (flag === 'true' || flag === true);
      }

      json.other = {};
      mal.settings.otherSettings[type].forEach((opt) => {
        const flag = mal.cache.loadValue('mr_xo' + type[0] + '_' + opt.id, opt.def === true ? 'true' : 'false', 'global');
        json.other[opt.id] = (flag === 'true' || flag === true);
      });

      {
        const flag = mal.cache.loadValue('mr_xo' + type[0] + '_quick_settings', 'false', 'global');
        json.other.quick_settings = (flag === 'true' || flag === true);
      }

      return json;
    },

    import: function (type, json) {
      if (json !== Object(json)) {
        return;
      }

      {
        const key = 'relations';
        const keys = Object.prototype.hasOwnProperty.call(json, key) ? Object.keys(json[key]) : [];
        keys.forEach((val) => {
          const flag = json.relations[val] === true ? 'true' : 'false';
          mal.cache.saveValue('mr_xr' + type[0] + '_' + toHtmlId(val), flag, 'global');
        });
      }

      ['left', 'right'].forEach((status) => {
        const key = 'hide_' + status;
        const keys = Object.prototype.hasOwnProperty.call(json, key) ? Object.keys(json[key]) : [];
        keys.forEach((val) => {
          const flag = json['hide_' + status][val] === true ? 'true' : 'false';
          mal.cache.saveValue('mr_xs' + status[0] + type[0] + '_' + toHtmlId(val), flag, 'global');
        });
      });

      if (Object.prototype.hasOwnProperty.call(json, 'hide_right') && json.hide_right === Object(json.hide_right)) {
        const flag = json.hide_right.empty_status === true ? 'true' : 'false';
        mal.cache.saveValue('mr_xsr' + type[0] + '_empty_status', flag, 'global');
      }

      {
        const key = 'other';
        const keys = Object.prototype.hasOwnProperty.call(json, key) ? Object.keys(json[key]) : [];
        keys.forEach((id) => {
          const flag = json.other[id] === true ? 'true' : 'false';
          mal.cache.saveValue('mr_xo' + type[0] + '_' + id, flag, 'global');
        });
      }

      if (Object.prototype.hasOwnProperty.call(json, 'other') && json.other === Object(json.other)) {
        const flag = json.other.quick_settings === true ? 'true' : 'false';
        mal.cache.saveValue('mr_xo' + type[0] + '_quick_settings', flag, 'global');
      }
    },

    reset: function () {
      ['anime', 'manga'].forEach(function (type) {
        mal.settings.availableRelations.forEach(function (val) {
          mal.cache.saveValue('mr_xr' + type[0] + '_' + toHtmlId(val), 'false', 'global');
        });

        ['left', 'right'].forEach(function (status) {
          mal.settings.availableStatus[type].forEach(function (val) {
            mal.cache.saveValue('mr_xs' + status[0] + type[0] + '_' + toHtmlId(val), 'false', 'global');
          });
        });
        mal.cache.saveValue('mr_xsr' + type[0] + '_empty_status', 'false', 'global');

        mal.settings.otherSettings[type].forEach(function (opt) {
          opt.val = opt.def === true;
          mal.cache.saveValue('mr_xo' + type[0] + '_' + opt.id, opt.def === true ? 'true' : 'false', 'global');
        });
        mal.cache.saveValue('mr_xo' + type[0] + '_quick_settings', 'false', 'global');
      });
    }
  };

  $.fn.myfancybox = function (onstart) {
    return $(this).click(function () {
      mal.fancybox.show(onstart);
    });
  };

  mal.fancybox = {
    body: $('<div id="mr_fancybox_inner">'),
    outer: $('<div id="mr_fancybox_outer">'),
    wrapper: $('<div id="mr_fancybox_wrapper">'),

    init: function (el) {
      mal.fancybox.outer.hide().append(mal.fancybox.body).insertAfter(el);
      mal.fancybox.wrapper.hide().click(mal.fancybox.hide).insertAfter(el);
    },

    show: function (onstart) {
      mal.fancybox.body.children().hide();
      if (!onstart()) {
        mal.fancybox.hide();
        return;
      }
      mal.fancybox.wrapper.show();
      mal.fancybox.outer.show();
    },

    hide: function () {
      mal.fancybox.outer.hide();
      mal.fancybox.wrapper.hide();
    }
  };

  mal.entries = {
    status: {
      updating: false,
      total: 0,
      done: 0,
      fail: 0,
      skip: 0,

      clear: function () {
        mal.entries.status.total = 0;
        mal.entries.status.done = 0;
        mal.entries.status.fail = 0;
        mal.entries.status.skip = 0;
      }
    },

    updating: function (strict) {
      return (strict && mal.entries.status.updating) ||
            ((mal.entries.status.done + mal.entries.status.fail + mal.entries.status.skip) < mal.entries.status.total);
    },

    update: function (rescan) {
      let entrylist = [];
      let rightlist = [];
      const processed = {};
      const excludedRelationsRe = new RegExp('^(' + mal.settings.excludedRelations[mal.type].join('|') + ')$', 'i');

      function setRelations (idLeft, idRight) {
        [{ x: idLeft, y: idRight }, { x: idRight, y: idLeft }].forEach(function (rel) {
          const graph = mal.cache.data.graph[rel.x];
          if (!Array.isArray(graph)) {
            mal.cache.data.graph[rel.x] = [rel.y];
          } else if (!graph.includes(rel.y)) {
            graph.push(rel.y);
          }
        });
      }

      function getRelations (idLeft, data) {
        const graph = mal.cache.data.graph[idLeft];
        if (!Array.isArray(graph)) {
          mal.cache.data.graph[idLeft] = [];
        }

        $('#dialog .normal_header + form > br ~ div > small', data).each(function () {
          const val = $('input[id^="relationGen"]', this).val();
          if (!val.match(/^\d+\s\(/) || val.match(/^\d+\s\(\)$/)) {
            console.log(`Empty relation /${mal.type}/${idLeft}`);
            return;
          }

          const idRight = parseInt(val.match(/\d+/)[0]);
          if (!isNaN(idRight) && !$('select[name^="relationTypeId"] option:selected', this).text().match(excludedRelationsRe)) {
            setRelations(idLeft, idRight);
            if (processed[idRight] !== true && !rightlist.includes(idRight)) {
              rightlist.push(idRight);
            }
          }
        });
      }

      function loadRight () {
        if (mal.entries.updating(false)) {
          return;
        }

        rightlist = rightlist.filter(id => processed[id] !== true);

        mal.content.status.done = mal.entries.status.done;
        mal.content.status.fail = mal.entries.status.fail;
        mal.content.status.skip = mal.entries.status.skip;
        mal.content.status.total = mal.entries.status.total;

        mal.entries.status.clear();
        mal.entries.status.total = rightlist.length;
        mal.content.status.update();

        function fetchRight () {
          if (rightlist.length === 0) {
            mal.content.status.update();
            mal.content.list.update(true);
            return;
          }

          const id = parseInt(rightlist.shift());
          processed[id] = true;

          setTimeout(function () {
            $.ajax({
              url: '/dbchanges.php?' + mal.type[0] + 'id=' + id + '&t=relations',
              dataType: 'html'
            })
              .done(function (data) {
                mal.cache.data.title[id] = toHtmlStr($('#dialog .normal_header > a', data).text());
                const length = rightlist.length;
                getRelations(id, data);
                mal.entries.status.total += rightlist.length - length;
                mal.entries.status.done += 1;
                mal.content.status.update();
                fetchRight();
              })
              .fail(function () {
                console.log(`Failed to update /${mal.type}/${id}`);
                mal.entries.status.fail += 1;
                mal.content.status.update();
                fetchRight();
              });
          }, mal.settings.ajaxDelay);
        }

        fetchRight();
      }

      function loadLeft () {
        if (mal.entries.updating(false)) {
          return;
        }

        mal.entries.status.clear();
        mal.entries.status.total = entrylist.length;
        mal.content.status.update();

        Object.keys(mal.cache.data.graph).map(id => parseInt(id)).filter(id => !isNaN(id)).forEach(id => {
          const graph = mal.cache.data.graph[id];
          if (Array.isArray(graph)) {
            processed[id] = true;
          } else {
            delete mal.cache.data.graph[id];
          }
        });

        entrylist.forEach(entry => {
          const id = entry.id;
          const graph = mal.cache.data.graph[id];
          if (Array.isArray(graph)) {
            mal.cache.data.left[id] = true;
            processed[id] = true;
          } else {
            delete mal.cache.data.left[id];
            delete mal.cache.data.graph[id];
          }
        });

        entrylist = entrylist.filter(function (entry) {
          if (processed[entry.id] === true) {
            mal.entries.status.skip += 1;
            mal.content.status.update();
            return false;
          }
          return true;
        });

        function fetchLeft () {
          if (entrylist.length === 0) {
            loadRight();
            return;
          }

          const id = parseInt(entrylist.shift().id);
          processed[id] = true;

          setTimeout(function () {
            $.ajax({
              url: '/dbchanges.php?' + mal.type[0] + 'id=' + id + '&t=relations',
              dataType: 'html'
            })
              .done(function (data) {
                mal.cache.data.left[id] = true;
                getRelations(id, data);
                mal.entries.status.done += 1;
                mal.content.status.update();
                fetchLeft();
              })
              .fail(function () {
                console.log(`Failed to update /${mal.type}/${id}`);
                mal.entries.status.fail += 1;
                mal.content.status.update();
                fetchLeft();
              });
          }, mal.settings.ajaxDelay);
        }

        fetchLeft();
      }

      function loadUserList () {
        mal.entries.status.updating = true;
        mal.content.status.clear();
        mal.content.status.body.text('Loading...');

        mal.loader[mal.type].populate({
          onFinish: (data) => {
            $.each(data, (id, entry) => {
              const isAnime = mal.type === 'anime';
              const isManga = !isAnime;

              const title = entry[mal.type + '_title'];
              const status = parseInt(entry[isAnime ? 'anime_airing_status' : 'manga_publishing_status']);
              const episodes = isAnime ? parseInt(entry.anime_num_episodes) : 0;
              const chapters = isManga ? parseInt(entry.manga_num_chapters) : 0;
              const volumes = isManga ? parseInt(entry.manga_num_volumes) : 0;
              const userStatus = parseInt(entry.status);
              const userEpisodes = isAnime ? parseInt(entry.num_watched_episodes) : 0;
              const userChapters = isManga ? parseInt(entry.num_read_chapters) : 0;
              const userVolumes = isManga ? parseInt(entry.num_read_volumes) : 0;
              const rewatching = parseInt(entry[isAnime ? 'is_rewatching' : 'is_rereading']);

              // [ 1, 2, 3, 4, 6 ] --> [ 0, 1, 2, 3, 4 ]
              let userStatusID = userStatus - (userStatus === USER_STATUS.PLAN_TO ? 2 : 1);
              if (userStatusID < 0 || userStatusID >= mal.settings.availableStatus[mal.type].length) {
                userStatusID = -1;
              }

              const listEntry = {
                id: parseInt(id),
                title: toHtmlStr(title),
                correct: !(userStatusID < 0 ||
                  (userStatus !== USER_STATUS.PLAN_TO && status === SERIES_STATUS.NOT_YET) ||
                  (userStatus === USER_STATUS.COMPLETED && rewatching === 0 &&
                    (status !== SERIES_STATUS.COMPLETED || episodes !== userEpisodes ||
                    chapters !== userChapters || volumes !== userVolumes)) ||
                  (userStatus !== USER_STATUS.COMPLETED && status === SERIES_STATUS.COMPLETED &&
                    ((episodes > 0 && userEpisodes >= episodes) ||
                    (volumes > 0 && userVolumes >= volumes) ||
                    (chapters > 0 && userChapters >= chapters)))
                )
              };

              mal.cache.data.title[listEntry.id] = listEntry.title;
              mal.cache.data.airing[listEntry.id] = status;

              if (userStatusID !== -1) {
                mal.cache.data.status[listEntry.id] = userStatusID;
              }

              if (!listEntry.correct) {
                mal.cache.data.wrong[listEntry.id] = true;
              }

              entrylist.push(listEntry);
            });

            loadLeft();
          },
          onNext: (count) => {
            mal.content.status.body.html('Loading... (' + count + ' entries)');
          },
          onError: () => {
            mal.content.status.body.html('Loading... (failed)');
            mal.entries.status.updating = false;
          }
        }, [
          mal.type + '_title', // title
          'status', // userStatus
          'num_watched_episodes', // userEpisodes
          'num_read_chapters', // userChapters
          'num_read_volumes', // userVolumes
          'anime_num_episodes', // episodes
          'manga_num_chapters', // chapters
          'manga_num_volumes', // volumes
          'anime_airing_status', // status
          'manga_publishing_status', // status
          'is_rewatching', // rewatching
          'is_rereading' // rewatching
        ]);
      }

      if (!mal.entries.updating(true)) {
        mal.entries.status.updating = true;
        mal.content.status.clear();
        mal.content.status.body.text('Loading...');

        if (rescan) {
          mal.entries.status.clear();
          mal.cache.clear();
        } else {
          if (mal.forceUpdate) {
            for (let id = mal.forceFrom; id <= mal.forceTo; ++id) {
              if (Object.prototype.hasOwnProperty.call(mal.cache.data.left, id)) {
                console.log(`Force update /${mal.type}/${id} - "${mal.cache.data.title[id]}"`);
                mal.cache.clear(id);
              }
            }
          }
          mal.cache.data.airing = {};
          mal.cache.data.status = {};
          mal.cache.data.wrong = {};
        }

        mal.content.list.body.empty()
          .append('<p id="mr_information">' + (rescan ? 'Recalculating' : 'Updating') + ' missing relations...</p>')
          .append('<input type="hidden" id="mr_list_type" value="' + mal.type + '">');

        loadUserList();
      }
    },

    getTitle: function (id) {
      const result = Object.prototype.hasOwnProperty.call(mal.cache.data.title, id) ? String(mal.cache.data.title[id]) : '';
      return result.length > 0 ? result : '?';
    },

    getStatus: function (id) {
      const result = parseInt(mal.cache.data.status[id]);
      if (isNaN(result) || result < 0 || result >= mal.settings.availableStatus[mal.type].length) {
        return '';
      }
      return mal.settings.availableStatus[mal.type][result];
    },

    getGraph: function () {
      const graph = {};
      const used = {};

      function dfs (id, result) {
        id = parseInt(id);
        if (isNaN(id)) {
          return result;
        }

        used[id] = true;
        result.push(id);

        mal.cache.data.graph[id]
          .map(id => parseInt(id))
          .filter(id => !isNaN(id) && Array.isArray(mal.cache.data.graph[id]))
          .forEach(id => {
            if (used[id] !== true) {
              dfs(id, result);
            }
          });

        return result;
      }

      Object.keys(mal.cache.data.graph)
        .map(id => parseInt(id))
        .filter(id => !isNaN(id) && Array.isArray(mal.cache.data.graph[id]))
        .forEach(id => {
          if (used[id] !== true) {
            const result = dfs(id, []);
            if (result.length > 1) {
              graph[id] = result;
            }
          }
        });

      return graph;
    },

    comparator: function (a, b) {
      const aTitle = mal.entries.getTitle(a).toLowerCase();
      const bTitle = mal.entries.getTitle(b).toLowerCase();
      return aTitle.localeCompare(bTitle);
    }
  };

  mal.content = {
    body: $('<div class="mr_body mr_body_list">'),

    show: function (type) {
      if (type !== mal.type) {
        if (!mal.entries.updating(true)) {
          mal.content.status.body.empty();
        } else {
          alert('Updating in process!');
          return false;
        }
      }

      const listType = $('#mr_list_type', mal.content.body);
      mal.type = type;
      $('#mr_links_settings > #mr_link_switch', mal.content.body)
        .text(mal.type === 'anime' ? 'Manga' : 'Anime');

      if (listType.length === 0 || listType.val() !== mal.type) {
        mal.settings.load();
        mal.cache.load();
        mal.content.list.update(false);
      }

      mal.content.body.show();
      return true;
    },

    status: {
      body: $('<span id="mr_status_msg">'),
      done: 0,
      fail: 0,
      skip: 0,
      total: 0,

      update: function () {
        if (mal.content.status.total > 0 && mal.entries.status.total === 0) {
          mal.content.status.set(mal.content.status.done + mal.content.status.fail + mal.content.status.skip,
            mal.content.status.fail, mal.content.status.skip, mal.content.status.total);
        } else if (mal.content.status.total === 0 && mal.entries.status.total > 0) {
          mal.content.status.set(mal.entries.status.done + mal.entries.status.fail + mal.entries.status.skip,
            mal.entries.status.fail, mal.entries.status.skip, mal.entries.status.total);
        } else if (mal.content.status.total > 0 && mal.entries.status.total > 0) {
          mal.content.status.set((mal.content.status.done + mal.content.status.fail + mal.content.status.skip) + '+' +
          (mal.entries.status.done + mal.entries.status.fail + mal.entries.status.skip),
          mal.content.status.fail + '+' + mal.entries.status.fail,
          mal.content.status.skip + '+' + mal.entries.status.skip,
          mal.content.status.total + '+' + mal.entries.status.total);
        } else {
          mal.content.status.set(0, 0, 0, 0);
        }
      },

      set: function (done, fail, skip, total) {
        done = 'Done: <b><span style="color: green;">' + done + '</span></b>';
        fail = 'Failed: <b><span style="color: #c32;">' + fail + '</span></b>';
        skip = 'Skipped: <b><span style="color: gray;">' + skip + '</span></b>';
        total = 'Total: <b><span style="color: #444;">' + total + '</span></b>';
        mal.content.status.body.html(done + ' <small>(' + fail + ', ' + skip + ')</small> - ' + total);
      },

      clear: function () {
        mal.content.status.body.empty();
        mal.content.status.done = 0;
        mal.content.status.fail = 0;
        mal.content.status.skip = 0;
        mal.content.status.total = 0;
      }
    },

    footer: {
      body: $('<div class="mr_footer">'),
      footerSwitch: $('<div class="mr_footer_switch" title="Show/hide quick settings">').click(function () {
        mal.content.footer.toggle(!mal.content.list.body.hasClass('mr_has_footer'));
      }),

      show: function () {
        mal.content.footer.update();
        mal.content.footer.body.show();
        mal.content.list.body.addClass('mr_has_footer');
      },

      hide: function () {
        mal.content.footer.body.hide().empty();
        mal.content.list.body.removeClass('mr_has_footer');
      },

      toggle: function (state) {
        if (state) {
          mal.content.footer.show();
        } else {
          mal.content.footer.hide();
        }
        mal.cache.saveValue('mr_xo' + mal.type[0] + '_quick_settings', state.toString(), 'global');
      },

      update: function () {
        const table = $('<table class="mr_footer_table" border="0" cellpadding="0" cellspacing="0" width="100%"><tr>' +
            '<td class="mr_footer_td mr_footer_td_left"></td>' +
            '<td class="mr_footer_td mr_footer_td_right"></td>' +
            '<td class="mr_footer_td mr_footer_td_other"></td>' +
            '</tr></table>');

        const tableIgnoreLeft = $('<table class="relTable" border="0" cellpadding="0" cellspacing="0" width="100%"><thead><tr>' +
            '<th colspan="2">Treat as missing relations:</th></tr><tbody></tbody></table>');

        const tableIgnoreRight = $('<table class="relTable" border="0" cellpadding="0" cellspacing="0" width="100%"><thead><tr>' +
            '<th colspan="2">Hide from missing relations:</th></tr><tbody></tbody></table>');

        const tableOther = $('<table class="relTable" border="0" cellpadding="0" cellspacing="0" width="100%"><thead><tr>' +
            '<th>Other settings:</th></tr><tbody></tbody></table>');

        const getCbSetting = function (str, id, state) {
          id = toHtmlId(id);
          const flag = mal.cache.loadValue(id, state.toString(), 'global');
          return $('<div class="mr_checkbox">')
            .append($('<input name="' + id + '" id="mr_footer_cb_' + id + '" type="checkbox">')
              .prop('checked', flag === 'true' || flag === true)
              .change(function () {
                const idCb = this.id.replace(/^mr_footer_cb_/, '');
                mal.cache.saveValue(idCb, this.checked.toString(), 'global');
                mal.settings.load();
                mal.content.list.update(false);
              }))
            .append('<label for="mr_footer_cb_' + id + '">' + str + '</label>');
        };

        // Add ignore left & right statuses
        const tbodyLeft = $('tbody', tableIgnoreLeft);
        const tbodyRight = $('tbody', tableIgnoreRight);
        let trLeft = $();
        let trRight = $();

        $.each(mal.settings.availableStatus[mal.type], function (i, status) {
          if (i % 2 > 0) {
            $('<td class="mr_td_right">')
              .append(getCbSetting(status, 'mr_xsl' + mal.type[0] + '_' + status, false))
              .appendTo(trLeft);
            $('<td class="mr_td_right">')
              .append(getCbSetting(status, 'mr_xsr' + mal.type[0] + '_' + status, false))
              .appendTo(trRight);

            trLeft = $();
            trRight = $();
          } else {
            trLeft = $('<tr class="mr_tr_data">').appendTo(tbodyLeft);
            trRight = $('<tr class="mr_tr_data">').appendTo(tbodyRight);

            $('<td class="mr_td_left">')
              .append(getCbSetting(status, 'mr_xsl' + mal.type[0] + '_' + status, false))
              .appendTo(trLeft);
            $('<td class="mr_td_left">')
              .append(getCbSetting(status, 'mr_xsr' + mal.type[0] + '_' + status, false))
              .appendTo(trRight);
          }
        });

        // Add 'Other' status
        let td;
        if (trRight.length === 0) {
          trRight = $('<tr class="mr_tr_data">').appendTo(tbodyRight);
          td = $('<td class="mr_td_left">');
        } else {
          td = $('<td class="mr_td_right">');
        }
        td.append(getCbSetting('Other', 'mr_xsr' + mal.type[0] + '_empty_status', false))
          .appendTo(trRight);

        // Add other settings
        const tbody = $('tbody', tableOther);
        mal.settings.otherSettings[mal.type].forEach(function (opt) {
          if (!opt.footer) {
            return;
          }
          $('<tr class="mr_tr_data">')
            .append($('<td class="mr_td_left">')
              .append(getCbSetting(opt.text, 'mr_xo' + mal.type[0] + '_' + opt.id, false)))
            .appendTo(tbody);
        });

        $('.mr_footer_td_left', table).append(tableIgnoreLeft);
        $('.mr_footer_td_right', table).append(tableIgnoreRight);
        $('.mr_footer_td_other', table).append(tableOther);
        mal.content.footer.body.empty().append(table);
      }
    },

    list: {
      body: $('<div class="mr_list">'),

      update: function (save) {
        if (mal.entries.updating(false)) {
          return;
        }

        mal.entries.status.updating = false;

        if (save) {
          mal.cache.save();
        }

        $('.mr_body_title', mal.content.body)
          .text(mal.title + ' — ' + (mal.type === 'anime' ? 'Anime' : 'Manga') + ' · ' + mal.name);

        const opts = {
          expandLong: false,
          expandLongR: false,
          quickSettings: false,
          hideEmpty: false,
          hideNotYet: false,
          hideAiring: false
        };

        mal.settings.otherSettings[mal.type].forEach(function (opt) {
          switch (opt.id) {
            case OPTS.EXPAND_LONG:
              opts.expandLong = opt.val;
              break;
            case OPTS.EXPAND_LONG_R:
              opts.expandLongR = opt.val;
              break;
            case OPTS.HIDE_NOTYET:
              opts.hideNotYet = opt.val;
              break;
            case OPTS.HIDE_AIRING:
              opts.hideAiring = opt.val;
              break;
          }
        });

        {
          const flag = mal.cache.loadValue('mr_xsr' + mal.type[0] + '_empty_status', 'false', 'global');
          opts.hideEmpty = (flag === 'true' || flag === true);
        }

        {
          const flag = mal.cache.loadValue('mr_xo' + mal.type[0] + '_quick_settings', 'false', 'global');
          opts.quickSettings = (flag === 'true' || flag === true);
        }

        const listType = $('<input type="hidden" id="mr_list_type" value="' + mal.type + '">');
        const graph = mal.entries.getGraph();
        const wrong = Object.keys(mal.cache.data.wrong).map(id => parseInt(id)).filter(id => mal.cache.data.wrong[id] === true);

        if (wrong.length === 0 && Object.keys(graph).length === 0) {
          mal.content.list.body.empty().append('<p id="mr_information">No missing relations found.</p>').append(listType);
          mal.content.footer.toggle(opts.quickSettings);
          return;
        }

        const table = $('<table class="relTable" border="0" cellpadding="0" cellspacing="0" width="100%"><thead><tr>' +
            '<th>Entries in your list:</th><th>Found missing relations:</th></tr></table>');

        const undel = $('<div id="mr_undelete"><p id="mr_undelete_msg" style="display: none;">' +
            'There are <span id="mr_undelete_num" style="font-weight: bold;"></span> hidden relations. ' +
            '<a href="javascript:void(0);" title="Show hidden relations" onclick="window.showHiddenRelations();">Show them</a></p></div>');

        const tfoot = $('<tfoot><tr><td class="mr_td_left"><div class="mr_count"><span></span></div></td>' +
            '<td class="mr_td_right"><div class="mr_count"><span></span></div></td></tr></tfoot>');

        const getEntryLink = function (id, title) {
          const hint = mal.entries.getStatus(id) || '';
          const status = hint.length > 0 ? toHtmlId(hint) : 'none';
          const tooltip = hint.length > 0 ? (' title="' + hint + '"') : '';
          const url = '/' + mal.type + '/' + id + '/' + title
            .replace(/[)(]/g, '')
            .replace(/[^\w\d-]/g, ' ')
            .replace(/\s/g, '_')
            .replace(/^_+/, '')
            .replace(/_+$/, '');
          return $('<a title="' + title + '" href="' + url + '" target="_blank">' + title + '</a>')
            .prepend('<i class="mr_icon mr_icon-' + status + '"' + tooltip + '>');
        };

        const getLiLeft = function (id) {
          return $('<li>').append(getEntryLink(id, mal.entries.getTitle(id)));
        };

        const getLiRight = function (id) {
          const btnHide = $('<div class="mr_hide"><span><a href="javascript:void(0);" ' +
              'title="Hide this relation" onclick="window.hideRelation(' + id + ');">x</a></span></div>');
          return $('<li>').append(btnHide).append(getEntryLink(id, mal.entries.getTitle(id)));
        };

        const getMoreLink = function () {
          return $('<td colspan="2">').append(
            $('<a class="mr_more" href="javascript:void(0);">show more</a>').click(function () {
              const tr = $(this).closest('tr');
              tr.prev('.mr_tr_data').removeClass('mr_tr_collapsed');
              tr.remove();
            })
          );
        };

        // red relations
        if (wrong.length > 0) {
          const ul = $('<ul>');
          let size = 0;

          wrong.sort(mal.entries.comparator).forEach(function (id) {
            getLiLeft(id).prop('id', 'mr_li_red_' + id).appendTo(ul);
            size += 1;
          });

          const warning = $('<div class="mr_warning"><span>Wrong status or ' +
            (mal.type === 'anime' ? 'episode' : 'volume/chapter') + ' count</span></div>');

          const rbodyRed = $('<tbody>');
          const trRed = $('<tr class="mr_tr_data">')
            .append($('<td class="mr_td_left">').append(ul))
            .append($('<td class="mr_td_right">').append(warning))
            .appendTo(rbodyRed);

          if (!opts.expandLong && size > 6) {
            trRed.addClass('mr_tr_collapsed');
            $('<tr class="mr_tr_more">').append(getMoreLink()).insertAfter(trRed);
          }

          rbodyRed.appendTo(table);
        }

        const excludedStatusLeftRe = new RegExp('^(' + mal.settings.excludedStatus.left[mal.type].join('|') + ')$', 'i');
        const excludedStatusRightRe = new RegExp('^(' + mal.settings.excludedStatus.right[mal.type].join('|') + ')$', 'i');

        // normal relations
        Object.keys(graph).sort(mal.entries.comparator).forEach(function (key) {
          const ulLeft = $('<ul>');
          const ulRight = $('<ul>');
          let lSize = 0;
          let rSize = 0;

          graph[key].sort(mal.entries.comparator).forEach(function (id) {
            const status = mal.entries.getStatus(id) || '';
            if (mal.cache.data.left[id] === true) {
              if (!status.match(excludedStatusLeftRe)) {
                if (!(opts.hideNotYet && mal.cache.data.airing[id] === SERIES_STATUS.NOT_YET) &&
                    !(opts.hideAiring && mal.cache.data.airing[id] === SERIES_STATUS.IN_PROCESS)) {
                  getLiLeft(id).prop('id', 'mr_li_' + id).appendTo(ulLeft);
                  lSize += 1;
                }
              } else if (!status.match(excludedStatusRightRe)) {
                if (!(opts.hideNotYet && mal.cache.data.airing[id] === SERIES_STATUS.NOT_YET) &&
                    !(opts.hideAiring && mal.cache.data.airing[id] === SERIES_STATUS.IN_PROCESS)) {
                  getLiRight(id).prop('id', 'mr_li_' + id).appendTo(ulRight);
                  rSize += 1;
                }
              }
            } else if (!(opts.hideEmpty && status.length === 0)) {
              if (!(opts.hideNotYet && mal.cache.data.airing[id] === SERIES_STATUS.NOT_YET) &&
                  !(opts.hideAiring && mal.cache.data.airing[id] === SERIES_STATUS.IN_PROCESS)) {
                getLiRight(id).prop('id', 'mr_li_' + id).appendTo(ulRight);
                rSize += 1;
              }
            }
          });

          if (lSize > 0 && rSize > 0) {
            const tbody = $('<tbody>');
            const tr = $('<tr class="mr_tr_data">')
              .append($('<td class="mr_td_left">').append(ulLeft))
              .append($('<td class="mr_td_right">').append(ulRight))
              .appendTo(tbody);

            if (!opts.expandLong && (lSize > 6 || rSize > 6) && !(opts.expandLongR && (lSize <= 6 || rSize > 6))) {
              tr.addClass('mr_tr_collapsed');
              $('<tr class="mr_tr_more">').append(getMoreLink()).insertAfter(tr);
            }

            tbody.appendTo(table);
          }
        });

        mal.content.list.body.empty()
          .append(undel)
          .append(table.append(tfoot))
          .append(listType);

        mal.content.list.updateHiddenRelations();
        mal.content.list.updateLineCount();
        mal.content.footer.toggle(opts.quickSettings);
      },

      updateLineCount: function () {
        const totalLeft = $('.relTable td.mr_td_left li', mal.content.list.body).length;
        const visibleLeft = $('.relTable tbody:not([style*="display: none"]) td.mr_td_left li', mal.content.list.body).length;

        $('tfoot td.mr_td_left .mr_count span', mal.content.list.body)
          .text('Total: ' + totalLeft + ', Visible: ' + visibleLeft);

        const totalRight = $('.relTable td.mr_td_right li', mal.content.list.body).length;
        const visibleRight = $('.relTable td.mr_td_right li:not([style*="display: none"])', mal.content.list.body).length;

        $('tfoot td.mr_td_right .mr_count span', mal.content.list.body)
          .text('Total: ' + totalRight + ', Visible: ' + visibleRight);
      },

      hideRelation: function (id, save) {
        id = parseInt(id);
        if (isNaN(id)) {
          return;
        }

        const li = $('td.mr_td_right li[id="mr_li_' + id + '"]', mal.content.list.body);
        if (li.length === 0) {
          delete mal.cache.data.hidden[id];
          if (save) {
            mal.cache.saveHiddenRelations();
          }
          return;
        }

        const row = li.hide().closest('tbody');
        const lSize = $('td.mr_td_left li', row).length;
        const rSize = $('td.mr_td_right li:not([style*="display: none;"])', row).length;

        row.toggle(rSize > 0);
        if (lSize <= 6 && rSize <= 6) {
          $('a.mr_more', row).trigger('click');
        }

        mal.cache.data.hidden[id] = true;
        if (save) {
          mal.cache.saveValue('mal.entries.hidden', mal.cache.data.hidden, mal.type, true);
          const count = Object.keys(mal.cache.data.hidden).length;
          $('#mr_undelete_num', mal.content.list.body).text(count);
          $('#mr_undelete_msg', mal.content.list.body).toggle(count > 0);
        }
      },

      showHiddenRelations: function (save) {
        $('#mr_undelete_msg', mal.content.list.body).hide();
        $('li[id^="mr_li_"]', mal.content.list.body).show();
        $('tbody', mal.content.list.body).show();
        if (save) {
          mal.cache.data.hidden = {};
          mal.cache.saveValue('mal.entries.hidden', mal.cache.data.hidden, mal.type, true);
        }
      },

      updateHiddenRelations: function () {
        mal.content.list.showHiddenRelations(false);
        $.each(mal.cache.data.hidden, function (id, val) {
          if (val === true) {
            mal.content.list.hideRelation(id, false);
          }
        });

        const count = Object.keys(mal.cache.data.hidden).length;
        $('#mr_undelete_num', mal.content.list.body).text(count);
        $('#mr_undelete_msg', mal.content.list.body).toggle(count > 0);
        mal.cache.saveValue('mal.entries.hidden', mal.cache.data.hidden, mal.type, true);
      }
    },

    storage: {
      body: $('<div class="mr_body mr_body_storage">' +
              '<textarea class="mr_textarea"></textarea><div class="mr_buttons"></div></div>'),

      update: function () {
        $('.mr_body_title', mal.content.storage.body).text(mal.title + ' — Import');

        const textarea = $('.mr_textarea', mal.content.storage.body).empty();
        textarea.val(JSON.stringify({
          name: mal.cache.name,
          version: OPTS.CACHE_VERSION,
          username: mal.cache.username,
          anime: {
            settings: mal.settings.export('anime'),
            cache: mal.cache.export('anime')
          },
          manga: {
            settings: mal.settings.export('manga'),
            cache: mal.cache.export('manga')
          }
        }));

        const buttons = $('.mr_buttons', mal.content.storage.body).empty();

        if (mal.entries.updating(true)) {
          buttons
            .append('<div class="mr_warning_bottom">The storage can\'t be changed during relations calculation!</div>')
            .append($('<input class="inputButton" value="OK" type="button">').click(function () {
              mal.fancybox.show(function () {
                mal.content.body.show();
                return true;
              });
            }))
            .insertAfter(textarea);
        } else {
          buttons
            .append($('<input class="inputButton" value="Import" type="button">').click(function () {
              mal.content.storage.import();
            }))
            .append($('<input class="inputButton" value="Cancel" type="button">').click(function () {
              textarea.val('');
              mal.fancybox.show(function () {
                mal.content.body.show();
                return true;
              });
            }))
            .append($('<input class="inputButton" value="Reset" type="button">').click(function () {
              mal.content.storage.update();
            }));
        }

        mal.content.storage.body.show();
      },

      import: function () {
        const textarea = $('.mr_textarea', mal.content.storage.body);
        const text = textarea.val();

        let json = null;
        try {
          json = JSON.parse(text);
        } catch (err) {
          console.error(err);
          return;
        }

        if (json === Object(json) &&
            String(json.name) === mal.cache.name &&
            String(json.version) === OPTS.CACHE_VERSION) {
          ['anime', 'manga'].forEach((type) => {
            if (json[type] == null) {
              return;
            }
            if (json[type].settings === Object(json[type].settings)) {
              mal.settings.import(type, json[type].settings);
            }
            if (json[type].cache === Object(json[type].cache)) {
              mal.cache.import(type, json[type].cache);
            }
          });

          textarea.val('');
          mal.fancybox.show(function () {
            mal.settings.load();
            mal.cache.load();
            mal.content.list.update(false);
            mal.content.body.show();
            return true;
          });
        }
      }
    },

    settings: {
      body: $('<div class="mr_body mr_body_settings"><div class="mr_list"></div><div class="mr_buttons"></div></div>'),

      update: function () {
        $('.mr_body_title', mal.content.settings.body).text(mal.title + ' — Settings');

        const tableExclude = $('<table class="relTable" border="0" cellpadding="0" cellspacing="0" width="100%"><thead><tr>' +
            '<th>Exclude anime relations from scan:</th><th>Exclude manga relations from scan:</th></tr><tbody></tbody></table>');

        const tableIgnoreLeft = $('<table class="relTable" border="0" cellpadding="0" cellspacing="0" width="100%"><thead><tr>' +
            '<th>Treat anime entries as missing relations:</th><th>Treat manga entries as missing relations:</th></tr><tbody></tbody></table>');

        const tableIgnoreRight = $('<table class="relTable" border="0" cellpadding="0" cellspacing="0" width="100%"><thead><tr>' +
            '<th>Hide anime entries from missing relations:</th><th>Hide manga entries from missing relations:</th></tr><tbody></tbody></table>');

        const tableOther = $('<table class="relTable" border="0" cellpadding="0" cellspacing="0" width="100%"><thead><tr>' +
            '<th>Other anime settings:</th><th>Other manga settings:</th></tr><tbody></tbody></table>');

        const getCbSetting = function (str, id, state) {
          id = toHtmlId(id);
          const flag = mal.cache.loadValue(id, state.toString(), 'global');
          return $('<div class="mr_checkbox">')
            .append($('<input name="' + id + '" id="' + id + '" type="checkbox">')
              .prop('checked', flag === 'true' || flag === true))
            .append('<label for="' + id + '">' + str + '</label>');
        };

        // Add exclude relations
        {
          const tbody = $('tbody', tableExclude);
          mal.settings.availableRelations.forEach(function (rel) {
            $('<tr class="mr_tr_data">')
              .append($('<td class="mr_td_left">').append(getCbSetting(rel, 'mr_xra_' + rel, false)))
              .append($('<td class="mr_td_right">').append(getCbSetting(rel, 'mr_xrm_' + rel, false)))
              .appendTo(tbody);
          });

          // Add ignore left & right statuses
          const tbodyLeft = $('tbody', tableIgnoreLeft);
          const tbodyRight = $('tbody', tableIgnoreRight);

          $.each(mal.settings.availableStatus.anime, function (i, statusA) {
            const statusM = mal.settings.availableStatus.manga[i];

            $('<tr class="mr_tr_data">')
              .append($('<td class="mr_td_left">').append(getCbSetting(statusA, 'mr_xsla_' + statusA, false)))
              .append($('<td class="mr_td_right">').append(getCbSetting(statusM, 'mr_xslm_' + statusM, false)))
              .appendTo(tbodyLeft);

            $('<tr class="mr_tr_data">')
              .append($('<td class="mr_td_left">').append(getCbSetting(statusA, 'mr_xsra_' + statusA, false)))
              .append($('<td class="mr_td_right">').append(getCbSetting(statusM, 'mr_xsrm_' + statusM, false)))
              .appendTo(tbodyRight);
          });

          $('<tr class="mr_tr_data">')
            .append($('<td class="mr_td_left">').append(getCbSetting('Other', 'mr_xsra_empty_status', false)))
            .append($('<td class="mr_td_right">').append(getCbSetting('Other', 'mr_xsrm_empty_status', false)))
            .appendTo(tbodyRight);
        }

        // Add other settings
        {
          const tbody = $('tbody', tableOther);
          $.each(mal.settings.otherSettings.anime, function (i, optA) {
            const optM = mal.settings.otherSettings.manga[i];

            $('<tr class="mr_tr_data">')
              .append($('<td class="mr_td_left">').append(getCbSetting(optA.text, 'mr_xoa_' + optA.id, false)))
              .append($('<td class="mr_td_right">').append(getCbSetting(optM.text, 'mr_xom_' + optM.id, false)))
              .appendTo(tbody);
          });
        }

        const list = $('.mr_list', mal.content.settings.body).empty()
          .append(tableExclude)
          .append(tableIgnoreLeft)
          .append(tableIgnoreRight)
          .append(tableOther);

        const buttons = $('.mr_buttons', mal.content.settings.body).empty();

        if (mal.entries.updating(true)) {
          buttons
            .append('<div class="mr_warning_bottom">The settings can\'t be changed during relations calculation!</div>')
            .append($('<input class="inputButton" value="OK" type="button">').click(function () {
              mal.fancybox.show(function () {
                mal.content.body.show();
                return true;
              });
            }))
            .insertAfter(list);
        } else {
          buttons
            .append($('<input class="inputButton" value="Save" type="button">').click(function () {
              $('input[type="checkbox"]', mal.content.settings.body).each(function () {
                mal.cache.saveValue(this.id, this.checked.toString(), 'global');
              });
              mal.settings.load();
              mal.fancybox.show(function () {
                mal.content.list.update(false);
                mal.content.body.show();
                return true;
              });
            }))
            .append($('<input class="inputButton" value="Cancel" type="button">').click(function () {
              mal.fancybox.show(function () {
                mal.content.body.show();
                return true;
              });
            }))
            .append($('<input class="inputButton" value="Reset" type="button">').click(function () {
              if (!confirm('Reset all settings?')) {
                return;
              }
              mal.settings.reset();
              mal.settings.load();
              mal.fancybox.show(function () {
                mal.content.list.update(false);
                mal.content.body.show();
                return true;
              });
            }));
        }

        mal.content.settings.body.show();
      }
    }
  };

  window.hideRelation = function (id) {
    mal.content.list.hideRelation(id, true);
    mal.content.list.updateLineCount();
  };

  window.showHiddenRelations = function () {
    mal.content.list.showHiddenRelations(true);
    mal.content.list.updateLineCount();
  };

  function main () {
    mal.name = $('.user-profile .user-function .icon-user-function#comment').prop('href').match(/\/([^/]+)#lastcomment$/)[1].trim();
    mal.cache = new Cache('mal_track_missing_relations', mal.name);
    mal.fancybox.init('#contentWrapper');

    const panel = $('<div class="mr_panel">').append(mal.content.status.body).prependTo(mal.content.body);
    const links = $('<div class="mr_links">').prependTo(panel);

    $('<span id="mr_links_settings">').prependTo(links)
      .append($('<a href="javascript:void(0);" title="Switch lists" id="mr_link_switch">Manga</a>').click(function () {
        if (mal.entries.updating(true)) {
          alert('Updating in process!');
        } else {
          mal.fancybox.show(function () {
            return mal.content.show(mal.type === 'anime' ? 'manga' : 'anime');
          });
        }
      }))
      .append(' - ').append($('<a href="javascript:void(0);" title="Import script data">Import</a>').myfancybox(function () {
        mal.content.storage.update();
        return true;
      }))
      .append(' - ').append($('<a href="javascript:void(0);" title="Change calculation settings">Settings</a>').myfancybox(function () {
        mal.content.settings.update();
        return true;
      }))
      .append(' - ').append($('<a href="javascript:void(0);" title="Recalculate missing relations">Rescan</a>').click(function () {
        if (mal.entries.updating(true)) {
          alert('Updating in process!');
        } else if (confirm('Recalculate missing relations?')) {
          mal.entries.update(true);
        }
      }))
      .append(' - ').append($('<a href="javascript:void(0);" title="Find new missing relations">Update</a>').click(function () {
        if (mal.entries.updating(true)) {
          alert('Updating in process!');
        } else if (confirm('Find new missing relations?')) {
          mal.entries.update(false);
        }
      }));

    mal.content.body
      .prepend('<h2 class="mr_body_title">' + mal.title + '</h2>')
      .append(mal.content.list.body)
      .append(mal.content.footer.body.hide())
      .append(mal.content.footer.footerSwitch)
      .appendTo(mal.fancybox.body);

    mal.content.storage.body
      .prepend('<h2 class="mr_body_title">' + mal.title + '</h2>')
      .appendTo(mal.fancybox.body);

    mal.content.settings.body
      .prepend('<h2 class="mr_body_title">' + mal.title + '</h2>')
      .appendTo(mal.fancybox.body);

    mal.loader = {};
    ['anime', 'manga'].forEach(function (type) {
      mal.loader[type] = new MalData(mal.name, type, 300);

      const el = $('.profile .user-statistics .user-statistics-stats .updates.' + type + ' > h5 > a[href*="/history/"]');
      if (el.length > 0) {
        el.text(el.text().replace(/^(Anime|Manga)\sHistory$/i, 'History'));
      }

      $('<a class="floatRightHeader ff-Verdana mr4" href="javascript:void(0);">' + mal.title + '</a>').myfancybox(function () {
        return mal.content.show(type);
      })
        .appendTo('.profile .user-statistics .user-statistics-stats .updates.' + type + ' > h5')
        .before('<span class="floatRightHeader ff-Verdana mr4">-</span>');
    });
  }

  $('<style type="text/css">').html(
    'div#mr_fancybox_wrapper { position: fixed; width: 100%; height: 100%; top: 0; left: 0; background: rgba(102, 102, 102, 0.3); z-index: 99990; }' +
    'div#mr_fancybox_inner { width: ' + mal.settings.windowWidth + 'px !important; height: ' + mal.settings.windowHeight + 'px !important; overflow: hidden; }' +
    'div#mr_fancybox_outer { position: absolute; display: block; width: auto; height: auto; padding: 10px; border-radius: 8px; top: 80px; left: 50%; margin-top: 0 !important; margin-left: ' + (-mal.settings.windowWidth / 2) + 'px !important; background: #fff; box-shadow: 0 0 15px rgba(32, 32, 32, 0.4); z-index: 99991; }' +
    'div.mr_body { text-align: center; width: 100%; height: 100%; padding: 42px 0 0; box-sizing: border-box; }' +
    'div.mr_body.mr_body_list { padding-top: 65px; }' +
    'div.mr_body a, div.mr_body a:visited { color: #1969cb; text-decoration: none; }' +
    'div.mr_body a:hover { color: #2d7de0; text-decoration: underline; }' +
    'div.mr_body .mr_body_title { position: absolute; top: 10px; left: 10px; width: ' + mal.settings.windowWidth + 'px; font-size: 16px; font-weight: normal; text-align: center; margin: 0; border: 0; }' +
    'div.mr_body .mr_body_title:after { content: ""; position: absolute; left: 0; bottom: -14px; width: 100%; height: 8px; border-top: 1px solid #eee; background: center bottom no-repeat radial-gradient(#f6f6f6, #fff 70%); background-size: 100% 16px; }' +
    'div.mr_body .mr_panel { position: absolute; top: 50px; left: 10px; text-align: left; width: ' + mal.settings.windowWidth + 'px; height: 2em; margin: 0 0 1em; }' +
    'div.mr_body .mr_links { float: right; }' +
    'div.mr_body p#mr_information { margin: 10px 0; }' +
    'div.mr_body #mr_undelete { background-color: #fff; padding: 0; margin: 0; }' +
    'div.mr_body #mr_undelete_msg { margin: 4px 0 6px; font-weight: normal; text-align: center; line-height: 20px; font-size: 11px; }' +
    'div.mr_body .mr_list { width: 100%; height: ' + (mal.settings.windowHeight - mal.settings.footerSwitchHeight - 65) + 'px; overflow-x: hidden; overflow-y: auto; margin: 0 auto; border: 1px solid #eee; box-sizing: border-box; }' +
    'div.mr_body .relTable { border: none; }' +
    'div.mr_body .relTable thead { background-color: #f5f5f5; }' +
    'div.mr_body .relTable th { background-color: transparent; width: 50%; padding: 5px 0 5px 6px; font-size: 12px; font-weight: bold; text-align: left; line-height: 20px !important; box-shadow: none; cursor: default; }' +
    'div.mr_body .relTable tbody { background-color: #fff; }' +
    'div.mr_body.mr_body_list .mr_list .relTable th { padding-left: 26px; }' +
    'div.mr_body.mr_body_list .mr_list .relTable tbody:hover { background-color: #f5f5f5; }' +
    'div.mr_body.mr_body_list .mr_list .relTable tbody tr:first-of-type td { box-shadow: 0px 1em 1em -1em #ddd inset; }' +
    'div.mr_body .relTable td { background-color: transparent; width: 50%; padding: 5px 0 5px 6px; font-size: 13px; font-weight: normal; text-align: left; line-height: 20px !important; vertical-align: top; }' +
    'div.mr_body .relTable td div span { line-height: 20px !important; }' +
    'div.mr_body .relTable td ul { list-style-type: none; margin: 0; padding: 0; }' +
    'div.mr_body .relTable tr.mr_tr_collapsed td ul { height: 100px; overflow-y: hidden; }' +
    'div.mr_body .relTable td ul li { width: 100%; padding: 0; margin: 0; }' +
    'div.mr_body .relTable td ul li > a { display: block; width: ' + (mal.settings.windowWidth / 2 - 40) + 'px !important; line-height: 20px !important; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-decoration: none !important; transition: all 0.2s; }' +
    'div.mr_body .relTable td.mr_td_left ul li > a { width: ' + (mal.settings.windowWidth / 2 - 25) + 'px !important; }' +
    'div.mr_body .relTable i.mr_icon { display: inline-block; vertical-align: text-top; line-height: 16px; width: 16px; height: 16px; margin: 1px 4px 0 0; background-repeat: no-repeat; background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFUAAAAQCAMAAABQvsO3AAABI1BMVEUAAAD///8ZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZacsZactvPr9pAAAAYXRSTlMAAAsMDQ4PFhcaGx4gJCYnKSwuMDE3PEBBRUlKTE1UVldZWltdX2FiZGZoaWpsbnByc3R3eHl9foKEh4mLjY+QlJebnJ6foaOkq6yvs7S1uru8v8DDxMbJys/T2d3n6PH7ZwpR9wAAAXZJREFUeNq11FlTwjAUBeAjpSAuKLjvuCMgWlEUN5BFURG1LlVKwf//Kwy5SSbOyEtn+CaZ5GRuD29gaBDQ2z5cCfiH/9bCASdbV3T+W0/kQW51f1oDCSiB/QDfSng+kZgJQ7mUB7nX6a1GqXkIqHtpmG0DZKFUPW8yF3EIG5tgNjdk1Jds3TFgVGzbXgXJ2SRHv1HIDAPXLJ7ZBQOcUZ4FxssiYVtfovXUqUfqjuPcySHjjiWZo3kDy3MrLN9g+rEelSN7e2oeyWTSYZIctW61yH0QUoQ/ROgOIMNzFcHa5wS44EMq9aA+SAOteLzFL2lqNRvtnoYJyXziL0/qZYTn7O53Y1JOsMukGsgC7ampNr9kqRXmi+d5LyaUokeKEBZrveh+rUE4smKuG7OOQCzL8hiLE60IvXffQ5Aoj/55iy3lcuujUD7Q7Hab7CDH+pKtCFW0Usp89/U69sOMvYLk9SVafXCfO8yzC9LR+W99E/r9uwzCL22kXUBCr0XlAAAAAElFTkSuQmCC); }' +
    'div.mr_body .relTable i.mr_icon-none { background-image: none; }' +
    'div.mr_body .relTable i.mr_icon-watching { background-position: -34px 0; }' +
    'div.mr_body .relTable i.mr_icon-reading { background-position: -34px 0; }' +
    'div.mr_body .relTable i.mr_icon-completed { background-position: 0 0; }' +
    'div.mr_body .relTable i.mr_icon-on_hold { background-position: -51px 0; }' +
    'div.mr_body .relTable i.mr_icon-dropped { background-position: -17px 0; }' +
    'div.mr_body .relTable i.mr_icon-plan_to_watch { background-position: -68px 0; }' +
    'div.mr_body .relTable i.mr_icon-plan_to_read { background-position: -68px 0; }' +
    'div.mr_body .relTable tfoot td { border-top: 1px solid #f5f5f5; }' +
    'div.mr_body .relTable td .mr_count { color: #666; font-size: 11px; font-weight: normal; text-align: left; padding-left: 20px; }' +
    'div.mr_body .relTable td .mr_warning { width: ' + (mal.settings.windowWidth / 2 - 75) + 'px; color: #c56; font-size: 12px; font-weight: bold; text-align: left; padding-left: 20px; }' +
    'div.mr_body .relTable td .mr_hide { display: inline-block !important; width: 15px; float: right; text-align: left; font-size: 11px; }' +
    'div.mr_body .relTable td .mr_hide a { color: #888 !important; line-height: 20px !important; font-style: normal !important; text-decoration: none !important; }' +
    'div.mr_body .relTable tr.mr_tr_more td { padding: 0 0 2px 0; }' +
    'div.mr_body .relTable td .mr_more { display: block !important; text-align: center; color: #c0c0c0 !important; font-style: normal !important; font-size: 0.9em; text-decoration: none !important; }' +
    'div.mr_body .relTable .mr_checkbox > * { vertical-align: middle; font-size: 11px; cursor: pointer; }' +
    'div.mr_body .relTable .mr_comment { background-color: #f6f6f6; border: 1px solid #ebebeb; font-size: 11px; line-height: 16px; padding: 1px 4px; }' +
    'div.mr_body .mr_list.mr_has_footer { height: ' + (mal.settings.windowHeight - mal.settings.footerHeight - mal.settings.footerSwitchHeight - 65) + 'px; }' +
    'div.mr_body .mr_footer { position: absolute; bottom: ' + (10 + mal.settings.footerSwitchHeight) + 'px; width: ' + mal.settings.windowWidth + 'px; height: ' + mal.settings.footerHeight + 'px; overflow: hidden; border: 1px solid #eee; border-width: 0 1px; box-sizing: border-box; }' +
    'div.mr_body .mr_footer .mr_footer_td { vertical-align: top; padding: 2px 0 0; width: 37%; }' +
    'div.mr_body .mr_footer .mr_footer_td:first-of-type { padding-left: 6px; }' +
    'div.mr_body .mr_footer .mr_footer_td_other { width: 26%; }' +
    'div.mr_body .mr_footer .relTable { color: #323232; margin-top: 2px; }' +
    'div.mr_body .mr_footer .relTable thead { background-color: transparent; }' +
    'div.mr_body .mr_footer .relTable th { padding: 0 0 3px; font-size: 11px; line-height: 16px !important; }' +
    'div.mr_body .mr_footer .relTable td { padding: 0; }' +
    'div.mr_body .mr_footer_switch { position: absolute; bottom: 10px; width: ' + mal.settings.windowWidth + 'px; height: ' + mal.settings.footerSwitchHeight + 'px; border: 1px solid #eee; border-width: 0 1px 1px; cursor: pointer; box-sizing: border-box; }' +
    'div.mr_body .mr_footer_switch:hover { background-color: #f5f5f5; }' +
    'div.mr_body .mr_list.mr_has_footer ~ .mr_footer_switch { border-width: 0 1px 1px; }' +
    'div.mr_body .mr_footer_switch::after { content: "···"; font-size: 10px; text-align: center; line-height: ' + mal.settings.footerSwitchHeight + 'px; height: ' + mal.settings.footerSwitchHeight + 'px; overflow: hidden; color: #555; }' +
    'div.mr_body.mr_body_settings .relTable { margin-bottom: 10px; }' +
    'div.mr_body.mr_body_settings .relTable td { padding: 0 0 0 6px; }' +
    'div.mr_body.mr_body_settings .mr_list { height: ' + (mal.settings.windowHeight - 75) + 'px !important; }' +
    'div.mr_body .mr_buttons { position: absolute; bottom: 10px; width: ' + mal.settings.windowWidth + 'px; text-align: center; padding: 5px 0 0; box-sizing: border-box; }' +
    'div.mr_body .mr_buttons > .inputButton { margin: 2px 5px !important; font-size: 12px; }' +
    'div.mr_body .mr_warning_bottom { display: inline-block; font-size: 12px; font-weight: bold; color: #c56; margin-bottom: 2px; }' +
    'div.mr_body.mr_body_storage .mr_textarea { width: 100% !important; height: ' + (mal.settings.windowHeight - 75) + 'px !important; resize: none; overflow: auto; margin: 0 auto; padding: 2px; border: 1px solid #eee; box-sizing: border-box; font-family: Consolas, Monospace; font-size: 11px; }'
  ).appendTo('head');

  main();
}(jQuery));