CF Contest Information Viewer

Add information about the contest, such as contest times, to the sidebar.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         CF Contest Information Viewer
// @namespace    https://twitter.com/kymn_
// @version      0.1
// @description  Add information about the contest, such as contest times, to the sidebar.
// @author       keymoon
// @match        https://codeforces.com/contest/*
// @match        https://codeforces.com/gym/*
// @grant        none
// ==/UserScript==

//#region LS
function getLSCache(key, defaultObj){
    const str = localStorage.getItem(key);
    return !str ? defaultObj : JSON.parse(str);
}

function setLSCache(key, obj){
    localStorage.setItem(key, JSON.stringify(obj));
}
//#endregion

//#region settings
const settingsCacheKey = "__cfciv_settings";

const dataKeys = 
[
    'id',
    'name',
    'type',
    'phase',
    'frozen',
    'durationSeconds',
    'startTimeSeconds',
    'relativeTimeSeconds',
    'preparedBy',
    'websiteUrl',
    'description',
    'difficulty',
    'kind',
    'icpcRegion',
    'country',
    'city',
    'season'
];

const defaultSettings = 
{
    id: true,
    name: false,
    type: false,
    phase: false,
    frozen: false,
    durationSeconds: true,
    startTimeSeconds: false,
    relativeTimeSeconds: false,
    preparedBy: false,
    websiteUrl: false,
    description: false,
    difficulty: false,
    kind: false,
    icpcRegion: false,
    country: false,
    city: false,
    season: false
};

const shouldTrue = 
{
    id: true,
    name: false,
    type: false,
    phase: false,
    frozen: false,
    durationSeconds: false,
    startTimeSeconds: false,
    relativeTimeSeconds: false,
    preparedBy: false,
    websiteUrl: false,
    description: false,
    difficulty: false,
    kind: false,
    icpcRegion: false,
    country: false,
    city: false,
    season: false
};

function validateSettings(settings){
    for (const key of dataKeys){
        if (!settings.hasOwnProperty(key)) return false;
        if (shouldTrue[key] && !settings[key]) return false;
    }
    return true;
}

function getSettings(){
    return getLSCache(settingsCacheKey, defaultSettings);
}

function setSettings(settings){
    if (!validateSettings(settings)) throw new Error("invalid settings");
    setLSCache(settingsCacheKey, settings);
}
//#endregion

//#region contests
const contestsCacheKey = "__cfciv_contests";

function formatContestsData(data){
    const settings = getSettings();
    for (const item of data){
        for (const key of dataKeys){
            if (!settings[key] && item.hasOwnProperty(key)) delete item[key];
        }
    }
    return data;
}

function fetchContestsAsync(){
    const contestApiURL = "https://codeforces.com/api/contest.list?gym=false";
    const gymApiURL = "https://codeforces.com/api/contest.list?gym=true";
    function _fetchContestsAsync(url){
        return new Promise((resolve, reject) => {
            const req = new XMLHttpRequest();
            req.open("GET", url, true);
            req.onload = () => {
                if (req.status >= 400) reject("can't fetch data : status code is ${req.status}");
                const obj = JSON.parse(req.responseText);
                if (obj.status != "OK") reject(`api status is ${obj.status}`);
                resolve(obj.result);
            };
            req.onerror = () => {
                reject("can't fetch data : Error connecting to server.");
            };
            req.send();
        });
    }
    return Promise.all([_fetchContestsAsync(contestApiURL), _fetchContestsAsync(gymApiURL)]).then((values) => {
        return values[0].concat(values[1]);
    });
}

async function getContestsAsync(){
    let data = getLSCache(contestsCacheKey, undefined);
    if (!data) {
        await refreshContestsAsync();
        data = getLSCache(contestsCacheKey, undefined);
        if (!data) throw new Error("refresh failed");
    }
    formatContestsData(data);
    return data;
}

function setContests(data){
    formatContestsData(data);
    setLSCache(contestsCacheKey, data);
}

async function refreshContestsAsync(){
    setContests(await fetchContestsAsync());
}
//#endregion

//#region ui
function defaultParser(data){
    return data.toString();
}

function durationParser(sec){
    const grans = [60, 60, 24];
    const unit = ["sec(s)", "min(s)", "hour(s)", "day(s)"];
    const resarr = [sec];
    for (const gran of grans){
        var elem = resarr.pop();
        resarr.push(elem % gran);
        resarr.push(Math.floor(elem / gran));
    }
    let res = "";
    for (let i = 0; i < unit.length; i++){
        if (resarr[i] == 0) continue;
        res = `${resarr[i]} ${unit[i]},` + res;
    }
    if (res == "") res = "0 sec(s),";
    return res.substr(0, res.length - 1);
}

function dateParser(sec){
    var date = new Date(sec * 1000);
    return date.toLocaleString();
}

const parsers = 
{
    id: defaultParser,
    name: defaultParser,
    type: defaultParser,
    phase: defaultParser,
    frozen: defaultParser,
    durationSeconds: durationParser,
    startTimeSeconds: dateParser,
    relativeTimeSeconds: durationParser,
    preparedBy: defaultParser,
    websiteUrl: defaultParser,
    description: defaultParser,
    difficulty: defaultParser,
    kind: defaultParser,
    icpcRegion: defaultParser,
    country: defaultParser,
    city: defaultParser,
    season: defaultParser
};

const names = 
{
    id: "id",
    name: "name",
    type: "type",
    phase: "phase",
    frozen: "frozen",
    durationSeconds: "duration",
    startTimeSeconds: "startTime",
    relativeTimeSeconds: "relativeTime",
    preparedBy: "preparedBy",
    websiteUrl: "websiteUrl",
    description: "description",
    difficulty: "difficulty",
    kind: "kind",
    icpcRegion: "icpcRegion",
    country: "country",
    city: "city",
    season: "season"
};

// since there is no user input, we can use rough escape
function escapeHTML(str) {
    return str.replace(/&/g, '&amp;')
              .replace(/</g, '&lt;')
              .replace(/>/g, '&gt;')
              .replace(/"/g, '&quot;')
              .replace(/'/g, '&#039;');
}

const divid = 'cfciv_elem';
function addElement(contest){
    const sidebar = document.getElementById("sidebar");
    if (!sidebar) return;

    const div = `<div id="${divid}" class="roundbox sidebox sidebar-menu" style=""></div>`
    sidebar.insertAdjacentHTML('beforeend', div);
    updateElement(contest);
}

async function applySettingsAsync(settings){
    setSettings(settings);
    await refreshContestsAsync();
    const currentContest = await getCurrentContestAsync();
    updateElement(currentContest);
}

function updateElement(contest){
    function getInfoRow(key, value){
        const name = names[key];
        const parsedval = parsers[key](value);
        return `<li><span>${escapeHTML(name)} : ${escapeHTML(parsedval)}</span><span style="float: right;"></span><div style="clear: both;"></div></li>`;
    }

    const checkboxIDPrefix = "cfciv_settings_checkbox_"
    function getSettingRow(key, state){
        const name = names[key];
        return (
`<div>
    <input id="${checkboxIDPrefix}${key}" type="checkbox" name="${key}" ${state ? "checked" : ""} ${shouldTrue[key] ? "disabled" : ""}>
    <label for="${key}">${name}</label>
</div>`
);
    }

    const div = document.getElementById(divid);
    if (!div) return;

    const infolist = [];
    for (const key in contest){
        infolist.push(getInfoRow(key, contest[key]));
    }

    const settinglist = [];
    const setting = getSettings();
    for (const key in setting){
        settinglist.push(getSettingRow(key, setting[key]));
    }

    const applyButtonID = 'cfciv_settings_applybtn';
    const innerhtml = 
`<div class="roundbox-lt">&nbsp;</div>
<div class="roundbox-rt">&nbsp;</div>
<div class="caption titled">→ Contest Information</div>
<ul>${infolist.join('')}</ul>
<details style="margin:1em;">
    <summary>Settings</summary>
    <div style="margin:1em;font-size:0.8em;">
    You can choose which information to display. Some information may not be present in all contests.<br>
    Click the apply button when you are done with your settings. It may take some time to reload the information.
    </div>
    <div style="margin:0.5em 1em;">
        ${settinglist.join('')}
        <button id=${applyButtonID} style="margin:0.5em">apply</button>
    </div>
</details>`; 

    div.innerHTML = innerhtml;

    const elem = document.getElementById(applyButtonID);
    elem.onclick = async () => {
        const settings = getSettings();
        for (const key in settings){
            const elem = document.getElementById(checkboxIDPrefix + key);
            settings[key] = elem.checked;
            document.getElementById(checkboxIDPrefix + key).disabled = true;
        }
        applySettingsAsync(settings);
    };
}
//#endregion

//#region util
function getContestID(){
    return parseInt(document.location.href.split('/')[4]);
}

async function getCurrentContestAsync(){
    const contestID = getContestID();
    const contests = await getContestsAsync();
    const contest = contests.filter(x => x.id == contestID)[0];
    return contest;
}
//#endregion

(async function() {
    'use strict';
    let contest = await getCurrentContestAsync();
    if (!contest){
        await refreshContestsAsync();
        contest = await getCurrentContestAsync();
        if (!contest) throw new Error("can't find contest information");
    }
    addElement(contest);
})();