Nexus / Shikimori rating based on users scores

// ==UserScript==
// @name         Shikimori rating based on users scores
// @namespace    https://shikimori.one/
// @description  This script displays on the page rating of anime, manga or light novel, calculated by Shikimori users scores
// @version      1.0.6
// @author       Nexus <https://shikimori.one/Nexus>
// @match        https://shikimori.org/*
// @match        https://shikimori.one/*
// @grant        none
// @updateURL https://openuserjs.org/meta/Nexus/Shikimori_rating_based_on_users_scores.meta.js
// @downloadURL https://openuserjs.org/install/Nexus/Shikimori_rating_based_on_users_scores.user.js
// @copyright 2022, Nexus (https://openuserjs.org/users/Nexus)
// @license      MIT
// ==/UserScript==

// Configuration

var DEFAULT_LOCALE = 'ru';
var FALLBACK_LOCALE = 'en';

var AVAILABLE_PAGES = ['animes', 'mangas', 'ranobe'];

var locales = {
    ru: {
        errors: {
            'no-data': 'Недостаточно данных',
        },

        'based-on-scores': {
            mal: 'На основе оценок MAL',
            local: 'На основе :number оценок Shikimori'
        },

        scores: {
            1: 'Хуже некуда',
            2: 'Ужасно',
            3: 'Очень плохо',
            4: 'Плохо',
            5: 'Более-менее',
            6: 'Нормально',
            7: 'Хорошо',
            8: 'Отлично',
            9: 'Великолепно',
            10: 'Эпик вин!'
        }
    },
    en: {
        errors: {
            'no-data': 'Not enough data',
        },

        'based-on-scores': {
            mal: 'Based on MAL scores',
            local: 'Based on :number Shikimori users scores'
        },

        scores: {
            1: 'Worst Ever',
            2: 'Terrible',
            3: 'Very Bad',
            4: 'Bad',
            5: 'So-so',
            6: 'Fine',
            7: 'Good',
            8: 'Excellent',
            9: 'Great',
            10: 'Masterpiece!'
        }
    },
};

// =====

var CUSTOM_RATING_CONTAINER_ID = 'rating-by-users-score';
var availableLocales = Object.keys(locales);

function getLocale() {
    var locale = String(
        document.querySelector('body').getAttribute('data-locale')
    ).trim().toLowerCase();

    return (locale && availableLocales.indexOf(locale) >= 0) ? locale : DEFAULT_LOCALE;
}

function trans(key, replace, locale) {
    var originalKey = key;

    key = String(key || '');
    replace = (typeof replace === 'object' && !Array.isArray(replace)) ? replace : {};
    locale = String(locale || getLocale());

    var section = key.split('.');
    if (section.length === 2) {
        key = section[1];
        section = section[0];
    } else {
        section = null;
    }

    var messages = locales[locale];
    var sectionMessages = section ? messages[section] : messages;

    if (key in sectionMessages) {
        var result = sectionMessages[key];
        Object.keys(replace).forEach(function (key) {
            result = result.replace(':' + key, replace[key]);
        });

        return result;
    }

    if (locale !== FALLBACK_LOCALE) {
        return trans(originalKey, replace, FALLBACK_LOCALE);
    }

    return key;
}

function makeElement(message, className, tag) {
    var element = document.createElement(tag || 'p');
    element.className = className;
    element.textContent = message;

    return element;
}

function injectErrorMessage(parent, message, shouldClearContainer, tag) {
    var container = makeElement(message, 'b-nothing_here', tag);
    container.style.textAlign = 'center';
    container.style.color = '#7b8084';
    container.style.marginTop = '15px';

    if (shouldClearContainer !== false) {
        parent.innerHTML = '';
    }

    parent.appendChild(container);
}

function injectCaption(container, message, tag) {
    var destinationContainer = container.querySelector('.stars-container');
    var messageContainer = makeElement(message, 'score-notice score-source', tag || 'i');
    messageContainer.style.display = 'block';
    messageContainer.style.whiteSpace = 'normal';
    messageContainer.style.marginTop = '-6px';
    messageContainer.style.textAlign = 'left';
    messageContainer.style.color = '#7b8084';
    messageContainer.style.fontSize = '10px';
    if (destinationContainer.offsetWidth) {
        messageContainer.style.maxWidth = destinationContainer.offsetWidth + 'px';
    }

    destinationContainer.appendChild(messageContainer);
}

function jsonParseSilent(json) {
    try {
        if (typeof json !== 'string') {
            return null;
        }

        return JSON.parse(json);
    } catch (e) {
        return null;
    }
}

function updateRatingDetails(container, newScore) {
    var changeScoreClasses = function (container) {
        container.className = container.className.replace(/score-\d/i, 'score-' + Math.round(newScore));
    };

    var scoreContainer = container.querySelector('.text-score');
    var scoreValueContainer = scoreContainer.querySelector('.score-value');
    if (scoreValueContainer) {
        scoreValueContainer.textContent = newScore;
        changeScoreClasses(scoreValueContainer);
    }

    var scoreTextRepresentationContainer = scoreContainer.querySelector('.score-notice');
    if (scoreTextRepresentationContainer) {
        scoreTextRepresentationContainer.textContent = newScore ? trans('scores.' + Math.round(newScore)) : '';
    }

    var scoreStarsContainer = container.querySelector('.stars-container .stars.score');
    if (scoreStarsContainer) {
        scoreStarsContainer.style.color = '#456';
        changeScoreClasses(scoreStarsContainer);
    }
}

function onReady(callable) {
    document.addEventListener('page:load', callable);
    document.addEventListener('turbolinks:load', callable);

    if (document.readyState !== 'loading') {
        callable();
    } else {
        document.addEventListener('DOMContentLoaded', callable);
    }
}

onReady(function () {
    'use strict';
    
    if (!window || !window.location) {
        return; // invalid environment
    }
    
    var uriFragments = String(window.location.pathname || '').split('/').map(function (fragment) {
        return fragment.trim();
    }).filter(Boolean);
    if (AVAILABLE_PAGES.indexOf(uriFragments[0]) === -1) {
        return;
    }

    var customRatingContainer = document.getElementById(CUSTOM_RATING_CONTAINER_ID);
    var defaultRatingContainer = document.querySelector('.scores > .b-rate');
    var scoresContainer = document.getElementById('rates_scores_stats');
    var scoresData = jsonParseSilent(
        scoresContainer ? scoresContainer.getAttribute('data-stats') : null
    );

    if (customRatingContainer || !defaultRatingContainer || !scoresData) {
        return;
    }

    customRatingContainer = defaultRatingContainer.cloneNode(true);
    customRatingContainer.id = CUSTOM_RATING_CONTAINER_ID;
    customRatingContainer.style.marginTop = '15px';
    defaultRatingContainer.parentNode.appendChild(customRatingContainer);

    if (!Array.isArray(scoresData) || !scoresData.length) {
        injectErrorMessage(customRatingContainer, trans('errors.no-data'));

        return;
    }

    var totalScore = 0, totalVotesNumber = 0;
    scoresData.forEach(function (item) {
        totalScore += +item[1] * +item[0];
        totalVotesNumber += +item[1];
    });

    var score = (totalScore / totalVotesNumber).toFixed(2);
    score = score < 0 ? 0 : score;

    updateRatingDetails(customRatingContainer, score);

    injectCaption(defaultRatingContainer, trans('based-on-scores.mal'));
    injectCaption(customRatingContainer, trans('based-on-scores.local', {number: totalVotesNumber}));
});