OMC Translator

Load translations for Online Math Contest. / OMCの翻訳を表示します。

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         OMC Translator
// @namespace    https://github.com/yuyuuuuuuuuuuuu/omc-translations
// @version      1.1.0
// @description  Load translations for Online Math Contest. / OMCの翻訳を表示します。
// @author       yuyuuuuuuuuuuuu
// @match        https://onlinemathcontest.com/*
// @grant        none
// @homepageURL  https://github.com/yuyuuuuuuuuuuuu/omc-translations
// @license      MIT
// ==/UserScript==
;(function() {
  'use strict'

  const GITHUB_USER = 'yuyuuuuuuuuuuuu'
  const REPO_NAME   = 'omc-translations'
  const BRANCH      = 'main'

  const LANG_KEY = 'omcLang'
  let LANGUAGES = []
  let MESSAGES = {}

  async function loadLanguageConfig() {
    const urlConfig = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}/languages/config.json`;
    const urlLabel  = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}/languages/label.json`;
    try {
      const [confRes, labelRes] = await Promise.all([
        fetch(urlConfig), fetch(urlLabel)
      ]);
      const conf   = await confRes.json();   // { languages: ["en", ...] }
      const labels = await labelRes.json();  // { en: "English 🇺🇸", ja: "日本語 🇯🇵 original", ... }

      // 日本語を最初に、以降翻訳対象を順に
      LANGUAGES = [{ code: 'ja', label: labels['ja'] || '日本語' }];
      for (const code of conf.languages) {
        if (code !== 'ja') {
          LANGUAGES.push({ code, label: labels[code] || code });
        }
      }
    } catch (e) {
      console.error('Language config の読み込みに失敗:', e);
      // フォールバック: 日本語と英語のみ
      LANGUAGES = [
        { code: 'ja', label: '日本語' },
        { code: 'en', label: 'English 🇺🇸' }
      ];
    }
  }

  async function loadMessages() {
    if (getLang() === 'ja') return;
    const urlMsg = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}` +
                   `/languages/${getLang()}/static/messages.json`;
    try {
      MESSAGES = await fetch(urlMsg).then(r => r.json());
    } catch (e) {
      console.warn('messages.json の読み込みに失敗:', e);
      MESSAGES = {};
    }
  }

  function getLang() {
    const v = localStorage.getItem(LANG_KEY);
    return LANGUAGES.some(l => l.code === v) ? v : 'ja';
  }
  function setLang(code) {
    localStorage.setItem(LANG_KEY, code);
  }

  function addLangDropdown() {
    const ul = document.querySelector('.navbar-nav.mr-auto');
    if (!ul) return;
    const current = getLang();
    const li = document.createElement('li');
    li.className = 'nav-item dropdown';
    li.style.marginLeft = '10px';

    const toggle = document.createElement('a');
    toggle.className = 'nav-link dropdown-toggle';
    toggle.href = '#';
    toggle.id = 'omcLangDropdown';
    toggle.setAttribute('role', 'button');
    toggle.setAttribute('data-toggle', 'dropdown');
    toggle.textContent = `Language: ${LANGUAGES.find(l => l.code === current).label}`;

    const menu = document.createElement('div');
    menu.className = 'dropdown-menu';
    menu.setAttribute('aria-labelledby', 'omcLangDropdown');

    LANGUAGES.forEach(l => {
      const a = document.createElement('a');
      a.className = 'dropdown-item';
      a.href = '#';
      a.textContent = l.label;
      if (l.code === current) a.style.fontWeight = 'bold';
      a.addEventListener('click', e => {
        e.preventDefault();
        setLang(l.code);
        location.reload();
      });
      menu.appendChild(a);
    });

    li.appendChild(toggle);
    li.appendChild(menu);
    ul.appendChild(li);
  }

  async function translateStaticUI() {
    if (getLang() === 'ja') return;
    const base = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}` +
                 `/languages/${getLang()}/static`;
    let config;
    try {
      config = await fetch(`${base}/config.json`).then(r => r.json());
    } catch (e) {
      console.error('config.json の取得に失敗:', e);
      return;
    }

    const path = location.pathname;
    const entries = config.filter(c =>
      c.paths.some(p => new RegExp(`^${p}$`).test(path))
    );
    if (!entries.length) return;

    const dictNames = [...new Set(entries.flatMap(e => e.dictionaries))];
    const dict = {};
    for (const name of dictNames) {
      try {
        const d = await fetch(`${base}/${name}.json`).then(r => r.json());
        Object.assign(dict, d);
      } catch (e) {
        console.warn(`辞書 ${name}.json の読み込みに失敗:`, e);
      }
    }

    const walker = document.createTreeWalker(
      document.body, NodeFilter.SHOW_TEXT, null, false
    );
    let node;
    while (node = walker.nextNode()) {
      // ——— 動的コンテンツ (#problem_content, #editorial_content) は除外 ———
      if (node.parentElement.closest('#problem_content, #editorial_content')) {
        continue;
      }

      let text = node.nodeValue;
      if (!text.trim()) continue;
      text = text.replace(/[\u00A0\u3000]/g, ' ');
      let replaced = text;
      for (const [ja, en] of Object.entries(dict)) {
        const key = ja.replace(/[\u00A0\u3000]/g, ' ');
        if (key && replaced.includes(key)) {
          replaced = replaced.split(key).join(en);
        }
      }
      if (replaced !== text) {
        node.nodeValue = replaced;
      }
    }
  }

  function parseUserEditorial() {
    const m = location.pathname.match(
      /^\/contests\/([^\/]+)\/editorial\/(\d+)\/(\d+)(?:\/|$)/
    );
    return m ? { contestId: m[1], taskId: m[2], userId: m[3] } : null;
  }

  function rawUrl(type, contestId, id) {
    return `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}` +
           `/languages/${getLang()}/contests/${contestId}/${type}/${id}.html`;
  }

  function appendMessage(container, text, color) {
    const p = document.createElement('p');
    p.textContent = text;
    p.style.color = color;
    p.style.marginTop = '1em';
    container.appendChild(p);
  }

  function replaceTasks() {
    const m = location.pathname.match(
      /^\/contests\/([^\/]+)\/tasks\/(\d+)(?:\/$|$)/
    );
    if (!m || getLang() === 'ja') return;
    const c = document.getElementById('problem_content');
    fetch(rawUrl('tasks', m[1], m[2]))
      .then(r => { if (!r.ok) throw 0; return r.text(); })
      .then(html => {
        if (!c) return;
        c.innerHTML = html;
        // 注意書きを追加
        if (MESSAGES.tasks) {
          appendMessage(c, MESSAGES.tasks, 'blue');
        }
      })
      .catch(() => {
        if (c && MESSAGES.tasks_not_done) {
          appendMessage(c, MESSAGES.tasks_not_done, 'orange');
        }
      });
  }

  function replaceEditorial() {
    const m = location.pathname.match(
      /^\/contests\/([^\/]+)\/editorial\/(\d+)(?:\/$|$)/
    );
    if (!m || getLang() === 'ja' || parseUserEditorial()) return;
    const c = document.getElementById('editorial_content');
    fetch(rawUrl('editorial', m[1], m[2]))
      .then(r => { if (!r.ok) throw 0; return r.text(); })
      .then(html => {
        if (!c) return;
        c.innerHTML = html;
        // 注意書きを追加
        if (MESSAGES.editorials) {
          appendMessage(c, MESSAGES.editorials, 'blue');
        }
      })
      .catch(() => {
        if (c && MESSAGES.editorial_not_done) {
          appendMessage(c, MESSAGES.editorial_not_done, 'orange');
        }
      });
  }

  function replaceUserEditorial() {
    const info = parseUserEditorial();
    if (!info || getLang() === 'ja') return;
    const c = document.getElementById('editorial_content');
    fetch(rawUrl('user_editorial', info.contestId, info.userId))
      .then(r => { if (!r.ok) throw 0; return r.text(); })
      .then(html => {
        if (!c) return;
        c.innerHTML = html;
        if (MESSAGES.user_editorial) {
          appendMessage(c, MESSAGES.user_editorial, 'blue');
        }
      })
      .catch(() => {
        if (c && MESSAGES.user_editorial_not_done) {
          appendMessage(c, MESSAGES.user_editorial_not_done, 'orange');
        }
      });
  }

  async function main() {
    await loadLanguageConfig();
    addLangDropdown();
    await loadMessages();
    await translateStaticUI();
    replaceTasks();
    replaceUserEditorial();
    replaceEditorial();
  }

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

})();