Codeforces Problemset Filter

Allows filtering Codeforces problems by multiple tags with an OR logic.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Codeforces Problemset Filter
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Allows filtering Codeforces problems by multiple tags with an OR logic.
// @author       You
// @match        *://codeforces.com/problemset*
// @match        *://codeforces.com/problemset/page/*
// @grant        none
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // --- Settings ---
  const API_URL = 'https://xan-ca.github.io/cf-api/enriched_problems.json';
  const CACHE_KEY = 'cf_problems_cache';
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
  const STORAGE_KEY_FILTER = 'cf_selected_category';
  const SIDEBOX_ID = 'cf-filter-sidebox';

  const CATEGORIES = [
    { value: 'all', label: 'All Problems' },
    { value: 'Div. 1', label: 'Div. 1' },
    { value: 'Div. 2', label: 'Div. 2' },
    { value: 'Div. 3', label: 'Div. 3' },
    { value: 'Div. 4', label: 'Div. 4' },
    { value: 'Educational', label: 'Educational' },
    { value: 'Global', label: 'Global Round' },
    { value: 'ICPC', label: 'ICPC' },
  ];

  // --- Data Fetching and Caching ---
  async function fetchAndCache() {
    console.log('[CF Filter] Fetching fresh data from API…');
    const response = await fetch(API_URL);
    if (!response.ok) {
      throw new Error(`API fetch failed: ${response.status}`);
    }

    const json = await response.json();
    const problems = json.result ?? json;

    const problemMap = {};
    for (const p of problems) {
      const key = `${p.contestId}${p.index}`;
      problemMap[key] = p;
    }

    const payload = {
      timestamp: Date.now(),
      problemMap,
    };

    localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
    console.log(`[CF Filter] Cached ${Object.keys(problemMap).length} problems.`);
    return problemMap;
  }

  async function getProblemMap() {
    try {
      const cachedStr = localStorage.getItem(CACHE_KEY);
      if (cachedStr) {
        const cached = JSON.parse(cachedStr);
        if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
          console.log('[CF Filter] Serving from cache.');
          return cached.problemMap;
        }
      }
    } catch (e) {
      console.warn('[CF Filter] Error reading cache from localStorage:', e);
    }
    return fetchAndCache();
  }

  // --- Filter State Management ---
  async function getSavedFilter() {
    try {
      const val = localStorage.getItem(STORAGE_KEY_FILTER);
      if (!val) return [];
      const parsed = JSON.parse(val);
      if (val === 'all') return [];
      if (typeof parsed === 'string') return [parsed]; // legacy storage fallback
      if (Array.isArray(parsed)) return parsed;
    } catch (err) {
      // Handle case where raw string might be stored instead of JSON
      const val = localStorage.getItem(STORAGE_KEY_FILTER);
      if (val && val !== 'all') return [val];
    }
    return [];
  }

  function saveFilter(values) {
    localStorage.setItem(STORAGE_KEY_FILTER, JSON.stringify(values));
  }

  // --- DOM Utilities ---
  function queryAny(...selectors) {
    for (const sel of selectors) {
      try {
        const el = document.querySelector(sel);
        if (el) return el;
      } catch (e) {}
    }
    return null;
  }

  function getProblemId(row) {
    const cell = row.querySelector('td:first-child');
    if (!cell) return '';
    return cell.innerText.replace(/\s+/g, '').trim();
  }

  function findProblemsTable() {
    return queryAny(
      'table.problems',
      '.problemset table',
      '#pageContent table',
      'table[class*="problem"]',
      'main table',
      'table'
    );
  }

  // --- UI Construction ---
  function buildSidebox() {
    if (!document.getElementById('cf-filter-styles')) {
      const style = document.createElement('style');
      style.id = 'cf-filter-styles';
      style.textContent = `
        .cf-filter-opts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
        .cf-filter-lbl { cursor: pointer; user-select: none; }
        .cf-filter-cb { display: none; }
        .cf-filter-txt {
          display: inline-block;
          padding: 4px 8px;
          border: 1px solid #b9b9b9;
          border-radius: 4px;
          background: #f8f8f8;
          color: #222;
          font-size: 11px;
          font-family: inherit;
          transition: all 0.1s ease-in-out;
        }
        .cf-filter-lbl:hover .cf-filter-txt {
          background: #e8e8e8;
        }
        .cf-filter-cb:checked + .cf-filter-txt {
          background: #e1eefc;
          border-color: #3b5998;
          color: #3b5998;
          font-weight: bold;
        }
        #cf-filter-clear {
          width: 100%;
          padding: 5px;
          font-size: 12px;
          cursor: pointer;
          border: 1px solid #b9b9b9;
          background: #eee;
          border-radius: 4px;
          color: #333;
          transition: background 0.1s;
        }
        #cf-filter-clear:hover { background: #ddd; }
      `;
      document.head.appendChild(style);
    }

    const sidebox = document.createElement('div');
    sidebox.id = SIDEBOX_ID;
    sidebox.className = 'sidebox';

    const opts = CATEGORIES.filter(c => c.value !== 'all')
      .map(
        c =>
          '<label class="cf-filter-lbl">' +
          '<input type="checkbox" value="' + c.value + '" class="cf-filter-cb">' +
          '<span class="cf-filter-txt">' + c.label + '</span>' +
          '</label>'
      )
      .join('');

    sidebox.innerHTML =
      '<div class="roundbox">' +
      '<div class="caption titled" style="font-weight:bold;padding:6px 8px;border-bottom:1px solid #b9b9b9;">' +
      '&#9776; Filter Categories' +
      '</div>' +
      '<div class="content" style="padding:10px;">' +
      '<div class="cf-filter-opts">' +
      opts +
      '</div>' +
      '<div>' +
      '<button id="cf-filter-clear">Clear Filters (Show All)</button>' +
      '</div>' +
      '</div>' +
      '</div>';

    return sidebox;
  }

  function injectSidebox() {
    if (document.getElementById(SIDEBOX_ID)) {
      return document.getElementById(SIDEBOX_ID);
    }

    const sidebar = queryAny(
      '._rightVertical',
      '.right-sidebar',
      '#sidebar',
      '.sidebar',
      '[class*="rightVertical"]',
      '[class*="right"]',
      'td.right-sidebar',
      '.roundbox-list'
    );

    const sidebox = buildSidebox();

    if (sidebar) {
      const firstChild = sidebar.firstElementChild;
      if (firstChild) sidebar.insertBefore(sidebox, firstChild);
      else sidebar.appendChild(sidebox);
    } else {
      console.warn('[CF Filter] No sidebar found — using floating widget');
      sidebox.style.cssText =
        'position:fixed;top:80px;right:12px;z-index:99999;width:185px;box-shadow:0 2px 10px rgba(0,0,0,0.2);border-radius:4px;background:#fff;border:1px solid #ddd;';
      document.body.appendChild(sidebox);
    }

    return sidebox;
  }

  // --- Main Logic ---
  function applyFilter(selectedCategories, problemMap) {
    const table = findProblemsTable();
    if (!table) return;

    const rows = table.querySelectorAll('tr');

    for (const row of rows) {
      if (!row.querySelector('td')) continue;
      const id = getProblemId(row);
      if (!id) continue;

      if (!selectedCategories || selectedCategories.length === 0) {
        row.style.display = '';
        continue;
      }

      const problem = problemMap[id];
      if (!problem) {
        row.style.display = '';
        continue;
      }

      const matches =
        Array.isArray(problem.categories) &&
        selectedCategories.some(c => problem.categories.includes(c));
      
      if (matches) {
        row.style.display = '';
      } else {
        row.style.display = 'none';
      }
    }
  }

  async function init() {
    console.log('[CF Filter Userscript] Script injected and running!');
    const path = window.location.pathname;
    if (!/^(\/problemset\/?|\/problemset\/page\/\d+\/?)$/.test(path)) {
      console.log('[CF Filter Userscript] Not a problemset page, aborting.');
      return;
    }

    if (!findProblemsTable()) {
      console.log('[CF Filter Userscript] Could not find the problems table! Check if Codeforces changed their layout.');
      return;
    }

    const sidebox = injectSidebox();
    if (!sidebox) return;

    let problemMap = null;
    try {
      problemMap = await getProblemMap();
    } catch (err) {
      console.error('[CF Filter] Error fetching problem map:', err);
      return;
    }

    let savedCategories = await getSavedFilter();

    const checkboxes = document.querySelectorAll('.cf-filter-cb');
    const updateCheckboxes = () => {
      checkboxes.forEach(cb => {
        cb.checked = savedCategories.includes(cb.value);
      });
    };
    updateCheckboxes();

    const applyAndSave = () => {
      saveFilter(savedCategories);
      applyFilter(savedCategories, problemMap);
    };

    applyAndSave();

    checkboxes.forEach(cb => {
      cb.addEventListener('change', () => {
        if (cb.checked) {
          if (!savedCategories.includes(cb.value)) savedCategories.push(cb.value);
        } else {
          savedCategories = savedCategories.filter(v => v !== cb.value);
        }
        applyAndSave();
      });
    });

    document.getElementById('cf-filter-clear').addEventListener('click', () => {
      savedCategories = [];
      updateCheckboxes();
      applyAndSave();
    });
  }

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