OMC Translator

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

  1. // ==UserScript==
  2. // @name OMC Translator
  3. // @namespace https://github.com/yuyuuuuuuuuuuuu/omc-translations
  4. // @version 1.1.0
  5. // @description Load translations for Online Math Contest. / OMCの翻訳を表示します。
  6. // @author yuyuuuuuuuuuuuu
  7. // @match https://onlinemathcontest.com/*
  8. // @grant none
  9. // @homepageURL https://github.com/yuyuuuuuuuuuuuu/omc-translations
  10. // @license MIT
  11. // ==/UserScript==
  12. ;(function() {
  13. 'use strict'
  14.  
  15. const GITHUB_USER = 'yuyuuuuuuuuuuuu'
  16. const REPO_NAME = 'omc-translations'
  17. const BRANCH = 'main'
  18.  
  19. const LANG_KEY = 'omcLang'
  20. let LANGUAGES = []
  21. let MESSAGES = {}
  22.  
  23. async function loadLanguageConfig() {
  24. const urlConfig = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}/languages/config.json`;
  25. const urlLabel = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}/languages/label.json`;
  26. try {
  27. const [confRes, labelRes] = await Promise.all([
  28. fetch(urlConfig), fetch(urlLabel)
  29. ]);
  30. const conf = await confRes.json(); // { languages: ["en", ...] }
  31. const labels = await labelRes.json(); // { en: "English 🇺🇸", ja: "日本語 🇯🇵 original", ... }
  32.  
  33. // 日本語を最初に、以降翻訳対象を順に
  34. LANGUAGES = [{ code: 'ja', label: labels['ja'] || '日本語' }];
  35. for (const code of conf.languages) {
  36. if (code !== 'ja') {
  37. LANGUAGES.push({ code, label: labels[code] || code });
  38. }
  39. }
  40. } catch (e) {
  41. console.error('Language config の読み込みに失敗:', e);
  42. // フォールバック: 日本語と英語のみ
  43. LANGUAGES = [
  44. { code: 'ja', label: '日本語' },
  45. { code: 'en', label: 'English 🇺🇸' }
  46. ];
  47. }
  48. }
  49.  
  50. async function loadMessages() {
  51. if (getLang() === 'ja') return;
  52. const urlMsg = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}` +
  53. `/languages/${getLang()}/static/messages.json`;
  54. try {
  55. MESSAGES = await fetch(urlMsg).then(r => r.json());
  56. } catch (e) {
  57. console.warn('messages.json の読み込みに失敗:', e);
  58. MESSAGES = {};
  59. }
  60. }
  61.  
  62. function getLang() {
  63. const v = localStorage.getItem(LANG_KEY);
  64. return LANGUAGES.some(l => l.code === v) ? v : 'ja';
  65. }
  66. function setLang(code) {
  67. localStorage.setItem(LANG_KEY, code);
  68. }
  69.  
  70. function addLangDropdown() {
  71. const ul = document.querySelector('.navbar-nav.mr-auto');
  72. if (!ul) return;
  73. const current = getLang();
  74. const li = document.createElement('li');
  75. li.className = 'nav-item dropdown';
  76. li.style.marginLeft = '10px';
  77.  
  78. const toggle = document.createElement('a');
  79. toggle.className = 'nav-link dropdown-toggle';
  80. toggle.href = '#';
  81. toggle.id = 'omcLangDropdown';
  82. toggle.setAttribute('role', 'button');
  83. toggle.setAttribute('data-toggle', 'dropdown');
  84. toggle.textContent = `Language: ${LANGUAGES.find(l => l.code === current).label}`;
  85.  
  86. const menu = document.createElement('div');
  87. menu.className = 'dropdown-menu';
  88. menu.setAttribute('aria-labelledby', 'omcLangDropdown');
  89.  
  90. LANGUAGES.forEach(l => {
  91. const a = document.createElement('a');
  92. a.className = 'dropdown-item';
  93. a.href = '#';
  94. a.textContent = l.label;
  95. if (l.code === current) a.style.fontWeight = 'bold';
  96. a.addEventListener('click', e => {
  97. e.preventDefault();
  98. setLang(l.code);
  99. location.reload();
  100. });
  101. menu.appendChild(a);
  102. });
  103.  
  104. li.appendChild(toggle);
  105. li.appendChild(menu);
  106. ul.appendChild(li);
  107. }
  108.  
  109. async function translateStaticUI() {
  110. if (getLang() === 'ja') return;
  111. const base = `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}` +
  112. `/languages/${getLang()}/static`;
  113. let config;
  114. try {
  115. config = await fetch(`${base}/config.json`).then(r => r.json());
  116. } catch (e) {
  117. console.error('config.json の取得に失敗:', e);
  118. return;
  119. }
  120.  
  121. const path = location.pathname;
  122. const entries = config.filter(c =>
  123. c.paths.some(p => new RegExp(`^${p}$`).test(path))
  124. );
  125. if (!entries.length) return;
  126.  
  127. const dictNames = [...new Set(entries.flatMap(e => e.dictionaries))];
  128. const dict = {};
  129. for (const name of dictNames) {
  130. try {
  131. const d = await fetch(`${base}/${name}.json`).then(r => r.json());
  132. Object.assign(dict, d);
  133. } catch (e) {
  134. console.warn(`辞書 ${name}.json の読み込みに失敗:`, e);
  135. }
  136. }
  137.  
  138. const walker = document.createTreeWalker(
  139. document.body, NodeFilter.SHOW_TEXT, null, false
  140. );
  141. let node;
  142. while (node = walker.nextNode()) {
  143. // ——— 動的コンテンツ (#problem_content, #editorial_content) は除外 ———
  144. if (node.parentElement.closest('#problem_content, #editorial_content')) {
  145. continue;
  146. }
  147.  
  148. let text = node.nodeValue;
  149. if (!text.trim()) continue;
  150. text = text.replace(/[\u00A0\u3000]/g, ' ');
  151. let replaced = text;
  152. for (const [ja, en] of Object.entries(dict)) {
  153. const key = ja.replace(/[\u00A0\u3000]/g, ' ');
  154. if (key && replaced.includes(key)) {
  155. replaced = replaced.split(key).join(en);
  156. }
  157. }
  158. if (replaced !== text) {
  159. node.nodeValue = replaced;
  160. }
  161. }
  162. }
  163.  
  164. function parseUserEditorial() {
  165. const m = location.pathname.match(
  166. /^\/contests\/([^\/]+)\/editorial\/(\d+)\/(\d+)(?:\/|$)/
  167. );
  168. return m ? { contestId: m[1], taskId: m[2], userId: m[3] } : null;
  169. }
  170.  
  171. function rawUrl(type, contestId, id) {
  172. return `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${BRANCH}` +
  173. `/languages/${getLang()}/contests/${contestId}/${type}/${id}.html`;
  174. }
  175.  
  176. function appendMessage(container, text, color) {
  177. const p = document.createElement('p');
  178. p.textContent = text;
  179. p.style.color = color;
  180. p.style.marginTop = '1em';
  181. container.appendChild(p);
  182. }
  183.  
  184. function replaceTasks() {
  185. const m = location.pathname.match(
  186. /^\/contests\/([^\/]+)\/tasks\/(\d+)(?:\/$|$)/
  187. );
  188. if (!m || getLang() === 'ja') return;
  189. const c = document.getElementById('problem_content');
  190. fetch(rawUrl('tasks', m[1], m[2]))
  191. .then(r => { if (!r.ok) throw 0; return r.text(); })
  192. .then(html => {
  193. if (!c) return;
  194. c.innerHTML = html;
  195. // 注意書きを追加
  196. if (MESSAGES.tasks) {
  197. appendMessage(c, MESSAGES.tasks, 'blue');
  198. }
  199. })
  200. .catch(() => {
  201. if (c && MESSAGES.tasks_not_done) {
  202. appendMessage(c, MESSAGES.tasks_not_done, 'orange');
  203. }
  204. });
  205. }
  206.  
  207. function replaceEditorial() {
  208. const m = location.pathname.match(
  209. /^\/contests\/([^\/]+)\/editorial\/(\d+)(?:\/$|$)/
  210. );
  211. if (!m || getLang() === 'ja' || parseUserEditorial()) return;
  212. const c = document.getElementById('editorial_content');
  213. fetch(rawUrl('editorial', m[1], m[2]))
  214. .then(r => { if (!r.ok) throw 0; return r.text(); })
  215. .then(html => {
  216. if (!c) return;
  217. c.innerHTML = html;
  218. // 注意書きを追加
  219. if (MESSAGES.editorials) {
  220. appendMessage(c, MESSAGES.editorials, 'blue');
  221. }
  222. })
  223. .catch(() => {
  224. if (c && MESSAGES.editorial_not_done) {
  225. appendMessage(c, MESSAGES.editorial_not_done, 'orange');
  226. }
  227. });
  228. }
  229.  
  230. function replaceUserEditorial() {
  231. const info = parseUserEditorial();
  232. if (!info || getLang() === 'ja') return;
  233. const c = document.getElementById('editorial_content');
  234. fetch(rawUrl('user_editorial', info.contestId, info.userId))
  235. .then(r => { if (!r.ok) throw 0; return r.text(); })
  236. .then(html => {
  237. if (!c) return;
  238. c.innerHTML = html;
  239. if (MESSAGES.user_editorial) {
  240. appendMessage(c, MESSAGES.user_editorial, 'blue');
  241. }
  242. })
  243. .catch(() => {
  244. if (c && MESSAGES.user_editorial_not_done) {
  245. appendMessage(c, MESSAGES.user_editorial_not_done, 'orange');
  246. }
  247. });
  248. }
  249.  
  250. async function main() {
  251. await loadLanguageConfig();
  252. addLangDropdown();
  253. await loadMessages();
  254. await translateStaticUI();
  255. replaceTasks();
  256. replaceUserEditorial();
  257. replaceEditorial();
  258. }
  259.  
  260. if (document.readyState === 'loading') {
  261. document.addEventListener('DOMContentLoaded', main);
  262. } else {
  263. main();
  264. }
  265.  
  266. })();