AtCoder Listing Tasks

[問題]タブをクリックすると、各問題のページに移動できるドロップダウンリストを表示します。

Ekde 2023/07/08. Vidu La ĝisdata versio.

  1. // ==UserScript==
  2. // @name AtCoder Listing Tasks
  3. // @namespace https://github.com/luuguas/AtCoderListingTasks
  4. // @version 1.5
  5. // @description [問題]タブをクリックすると、各問題のページに移動できるドロップダウンリストを表示します。
  6. // @description:en Click [Tasks] tab to open a drop-down list linked to each task.
  7. // @author luuguas
  8. // @license Apache-2.0
  9. // @supportURL https://github.com/luuguas/AtCoderListingTasks/issues
  10. // @match https://atcoder.jp/contests/*
  11. // @exclude https://atcoder.jp/contests/
  12. // @exclude https://atcoder.jp/contests/?*
  13. // @exclude https://atcoder.jp/contests/archive*
  14. // @grant none
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. 'use strict';
  19.  
  20. //AtCoderに標準で読み込まれているjQueryを使用
  21. let $ = window.jQuery;
  22.  
  23. const CONTEST_URL = 'https://atcoder.jp/contests';
  24. const ID_PREFIX = 'userscript-ACLT';
  25. const PRE = 'ACLT';
  26. const CONTEST_REGULAR = ['abc', 'arc', 'agc', 'ahc', 'past', 'joi', 'jag'];
  27. const CONTEST_PERMANENT = ['practice', 'APG4b', 'abs', 'practice2', 'typical90', 'math-and-algorithm', 'tessoku-book'];
  28. const ATONCE_TAB_MAX = 20;
  29.  
  30. const CSS = `
  31. .${PRE}-dropdown {
  32. max-height: 890%;
  33. overflow: visible auto;
  34. }
  35. .${PRE}-label {
  36. width: 100%;
  37. margin: 0px;
  38. padding: 3px 10px;
  39. clear: both;
  40. font-weight: normal;
  41. white-space: nowrap;
  42. }
  43. .${PRE}-checkbox {
  44. margin: 0px !important;
  45. vertical-align: middle;
  46. }
  47.  
  48. .${PRE}-option {
  49. margin: 5px 0px 15px 10px;
  50. }
  51. .${PRE}-flex {
  52. display: flex;
  53. align-items: center;
  54. }
  55. .${PRE}-select-all {
  56. height: 30px;
  57. }
  58. .${PRE}-select-specify {
  59. height: 35px;
  60. }
  61. .${PRE}-radio {
  62. padding-right: 15px;
  63. }
  64. .${PRE}-disabled {
  65. opacity: 0.65;
  66. }
  67. .${PRE}-caution {
  68. margin-left: 15px;
  69. color: red;
  70. }
  71. .${PRE}-toggle {
  72. min-width: 55px;
  73. }
  74. .${PRE}-caret {
  75. margin-left: 5px !important;
  76. }
  77. .${PRE}-list {
  78. max-height: 800%;
  79. overflow: visible auto;
  80. }
  81. .${PRE}-target {
  82. background-color: #e0e0e0;
  83. }
  84. .${PRE}-target:hover {
  85. background-color: #e0e0e0 !important;
  86. }
  87. .${PRE}-between {
  88. padding: 0px 5px;
  89. }
  90. `;
  91. const TEXT = {
  92. newTab: { 'ja': '新しいタブで開く', 'en': 'Open in a new tab' },
  93. allTasks: { 'ja': '問題一覧', 'en': 'Task Table' },
  94. loadingFailed: { 'ja': '(読み込み失敗)', 'en': '(Loading Failed)' },
  95. atOnce: { 'ja': 'まとめて開く', 'en': 'Open at once' },
  96. modalDiscription: { 'ja': '複数の問題をまとめて開きます。', 'en': 'Open several tasks at once.' },
  97. cancel: { 'ja': 'キャンセル', 'en': 'Cancel' },
  98. all: { 'ja': 'すべて', 'en': 'All' },
  99. specify: { 'ja': '範囲を指定', 'en': 'Specify the range' },
  100. caution: { 'ja': `※一度に開くことのできるタブは ${ATONCE_TAB_MAX} 個までです。`, 'en': `*Up to ${ATONCE_TAB_MAX} tabs can be open at once.` },
  101. reverse: { 'ja': '逆順で開く', 'en': 'Open in reverse order' },
  102. modalInfo: { 'ja': 'が開かれます。(ポップアップがブロックされた場合は許可してください。)', 'en': 'will be opened. (If pop-ups are blocked, please allow them.)' },
  103. aTab: { 'ja': '個のタブ', 'en': 'tab ' },
  104. tabs: { 'ja': '個のタブ', 'en': 'tabs ' },
  105. };
  106.  
  107. const DB_NAME = 'UserScript_ACLT_Database';
  108. const DB_VERSION = 1;
  109. const STORE_NAME = { option: 'option', problemList: 'problemList' };
  110. const STORE_INFO = [{ storeName: STORE_NAME.option, keyPath: 'name' }, { storeName: STORE_NAME.problemList, keyPath: 'contestName' }];
  111.  
  112. const REMOVE_INTERVAL = 1 * 60 * 60 * 1000;
  113. const REMOVE_BASE = 10 * 24 * 60 * 60 * 1000;
  114. const ACCESS_INTERVAL = 1 * 60 * 1000;
  115.  
  116. /*IndexedDBを扱うクラス
  117. https://github.com/luuguas/IndexedDBManager */
  118. let IDBManager = function (databaseName) { this.database = null; this.databaseName = databaseName; };
  119. IDBManager.prototype = {
  120. openDatabase(storeInfos, version) {
  121. return new Promise((resolve, reject) => {
  122. if (this.database !== null) { resolve(null); return; }
  123. if (typeof window.indexedDB === 'undefined') { reject('IndexedDB is not supported.'); return; }
  124. let openRequest = window.indexedDB.open(this.databaseName, version);
  125. openRequest.onupgradeneeded = (event) => {
  126. let database = event.target.result;
  127. let m = new Map();
  128. for (let name of database.objectStoreNames) m.set(name, { status: 1, keyPath: null });
  129. for (let info of storeInfos) {
  130. if (m.get(info.storeName)) m.set(info.storeName, { status: 2, keyPath: info.keyPath });
  131. else m.set(info.storeName, { status: 0, keyPath: info.keyPath });
  132. }
  133. for (let [name, info] of m) {
  134. if (info.status === 0) database.createObjectStore(name, { keyPath: info.keyPath });
  135. else if (info.status === 1) database.deleteObjectStore(name);
  136. }
  137. console.info('Database was created or upgraded.');
  138. };
  139. openRequest.onerror = (event) => { this.database = null; reject(`Failed to get database. (${event.target.error})`); };
  140. openRequest.onsuccess = (event) => { this.database = event.target.result; resolve(null); };
  141. });
  142. },
  143. isOpened() { return this.database !== null; },
  144. getData(storeName, key) {
  145. return new Promise((resolve, reject) => {
  146. if (!this.isOpened()) { reject('Database is not loaded.'); return; }
  147. let trans = this.database.transaction(storeName, 'readonly');
  148. let getRequest = trans.objectStore(storeName).get(key);
  149. getRequest.onerror = (event) => { reject(`Failed to get data. (${event.target.error})`); };
  150. getRequest.onsuccess = (event) => {
  151. if (event.target.result) resolve(event.target.result);
  152. else resolve(null);
  153. };
  154. });
  155. },
  156. getAllMatchedData(storeName, filter) {
  157. return new Promise((resolve, reject) => {
  158. if (!this.isOpened()) { reject('Database is not loaded.'); return; }
  159. let trans = this.database.transaction(storeName, 'readonly');
  160. let cursorRequest = trans.objectStore(storeName).openCursor();
  161. let res = [];
  162. cursorRequest.onerror = (event) => { reject(`Failed to get cursor. (${event.target.error})`); };
  163. cursorRequest.onsuccess = (event) => {
  164. let cursor = event.target.result;
  165. if (cursor) {
  166. if (filter(cursor.value)) res.push(cursor.value);
  167. cursor.continue();
  168. }
  169. else resolve(res);
  170. };
  171. });
  172. },
  173. setData(storeName, data) {
  174. return new Promise((resolve, reject) => {
  175. if (!this.isOpened()) { reject('Database is not loaded.'); return; }
  176. let trans = this.database.transaction(storeName, 'readwrite');
  177. let setRequest = trans.objectStore(storeName).put(data);
  178. setRequest.onerror = (event) => { reject(`Failed to set data. (${event.target.error})`); };
  179. setRequest.onsuccess = (event) => { resolve(null); };
  180. });
  181. },
  182. deleteData(storeName, key) {
  183. return new Promise((resolve, reject) => {
  184. if (!this.isOpened()) { reject('Database is not loaded.'); return; }
  185. let trans = this.database.transaction(storeName, 'readwrite');
  186. let deleteRequest = trans.objectStore(storeName).delete(key);
  187. deleteRequest.onerror = (event) => { reject(`Failed to delete data. (${event.target.error})`); };
  188. deleteRequest.onsuccess = (event) => { resolve(null); };
  189. });
  190. },
  191. };
  192.  
  193. /* 設定や問題リストの読み込み・保存をするクラス */
  194. let Setting = function () {
  195. this.problemList = null;
  196. this.newTab = null;
  197. this.lastRemove = null;
  198. this.reverse = null;
  199. this.atOnceSetting = null;
  200. this.atOnce = {
  201. begin: 0,
  202. end: 0,
  203. };
  204. this.lang = null;
  205. this.contestName = null;
  206. this.contestCategory = null;
  207. this.db = new IDBManager(DB_NAME);
  208. this.dbExists = false;
  209. };
  210. Setting.prototype = {
  211. openDB: async function () {
  212. try {
  213. await this.db.openDatabase(STORE_INFO, DB_VERSION);
  214. }
  215. catch (err) {
  216. console.warn('[AtCoder Listing Tasks] ' + err);
  217. }
  218. this.dbExists = this.db.isOpened();
  219. },
  220. requestList: function (contestName) {
  221. return new Promise((resolve, reject) => {
  222. let xhr = new XMLHttpRequest();
  223. xhr.responseType = 'document';
  224. xhr.onreadystatechange = function () {
  225. if (xhr.readyState === 4) {
  226. if (xhr.status === 200) {
  227. let result = $(xhr.responseXML);
  228. let problem_node = result.find('#contest-nav-tabs + .col-sm-12');
  229. let problem_list = problem_node.find('tbody tr');
  230. //問題リストを抽出して配列に格納
  231. let list = [];
  232. problem_list.each((idx, val) => {
  233. let td = $(val).children('td');
  234. list.push({
  235. url: td[0].firstChild.getAttribute('href'),
  236. diff: td[0].firstChild.textContent,
  237. name: td[1].firstChild.textContent,
  238. });
  239. });
  240. resolve(list);
  241. }
  242. else {
  243. resolve(null);
  244. }
  245. }
  246. };
  247. //https://atcoder.jp/contests/***/tasksのページ情報をリクエスト
  248. xhr.open('GET', `${CONTEST_URL}/${contestName}/tasks`, true);
  249. xhr.send(null);
  250. });
  251. },
  252. loadData: async function () {
  253. if (this.dbExists) {
  254. //データベースから情報を読み込む
  255. let resArray = await Promise.all([
  256. this.db.getData(STORE_NAME.problemList, this.contestName),
  257. this.db.getData(STORE_NAME.option, 'newTab'),
  258. this.db.getData(STORE_NAME.option, 'lastRemove'),
  259. this.db.getData(STORE_NAME.option, 'atOnce'),
  260. this.db.getData(STORE_NAME.option, 'reverse')
  261. ]);
  262. let setTasks = [];
  263. let now = Date.now();
  264. //設定を格納
  265. if (resArray[1] !== null) {
  266. this.newTab = resArray[1].value;
  267. }
  268. else {
  269. this.newTab = false;
  270. setTasks.push(this.db.setData(STORE_NAME.option, { name: 'newTab', value: this.newTab }));
  271. }
  272. if (resArray[2] !== null) {
  273. this.lastRemove = resArray[2].value;
  274. }
  275. else {
  276. this.lastRemove = now;
  277. setTasks.push(this.db.setData(STORE_NAME.option, { name: 'lastRemove', value: this.lastRemove }));
  278. }
  279. if (resArray[3] !== null) {
  280. this.atOnceSetting = resArray[3].value;
  281. }
  282. else {
  283. this.atOnceSetting = {};
  284. setTasks.push(this.db.setData(STORE_NAME.option, { name: 'atOnce', value: {} }));
  285. }
  286. if (resArray[4] !== null) {
  287. this.reverse = resArray[4].value;
  288. }
  289. else {
  290. this.reverse = false;
  291. setTasks.push(this.db.setData(STORE_NAME.option, { name: 'reverse', value: false }));
  292. }
  293. //問題リストを格納
  294. if (resArray[0] !== null) {
  295. this.problemList = resArray[0].list;
  296. //lastAccessが現在時刻からACCESS_INTERVAL以上前なら更新する
  297. if (now - resArray[0].lastAccess >= ACCESS_INTERVAL) {
  298. setTasks.push(this.db.setData(STORE_NAME.problemList, { contestName: this.contestName, list: this.problemList, lastAccess: now }));
  299. }
  300. }
  301. else {
  302. this.problemList = await this.requestList(this.contestName);
  303. if (this.problemList !== null) {
  304. setTasks.push(this.db.setData(STORE_NAME.problemList, { contestName: this.contestName, list: this.problemList, lastAccess: now }));
  305. }
  306. }
  307. //情報を更新
  308. await Promise.all(setTasks);
  309. }
  310. else {
  311. this.problemList = await this.requestList(this.contestName);
  312. this.newTab = false;
  313. this.lastRemove = null;
  314. this.atOnceSetting = {};
  315. this.reverse = false;
  316. }
  317. },
  318. saveData: async function (name, value) {
  319. if (!this.dbExists) {
  320. return;
  321. }
  322. await this.db.setData(STORE_NAME.option, { name, value });
  323. },
  324. removeOldData: async function () {
  325. if (!this.dbExists) {
  326. return;
  327. }
  328. let now = Date.now();
  329. if (now - this.lastRemove < REMOVE_INTERVAL) {
  330. return;
  331. }
  332. //最終アクセスが現在時刻より一定以上前の問題リストを削除する
  333. let oldData = await this.db.getAllMatchedData(STORE_NAME.problemList, (data) => { return now - data.lastAccess >= REMOVE_BASE; });
  334. if (oldData.length !== 0) {
  335. let deleteTasks = [];
  336. for (let data of oldData) {
  337. deleteTasks.push(this.db.deleteData(STORE_NAME.problemList, data.contestName));
  338. }
  339. await Promise.all(deleteTasks);
  340. }
  341. //lastRemoveを更新する
  342. this.lastRemove = now;
  343. await this.db.setData(STORE_NAME.option, { name: 'lastRemove', value: this.lastRemove });
  344. },
  345. getLanguage: function () {
  346. this.lang = 'ja';
  347. let content_language = $('meta[http-equiv="Content-Language"]');
  348. if (content_language.length !== 0 && content_language.attr('content') === 'en') {
  349. this.lang = 'en';
  350. }
  351. },
  352. getContestName: function () {
  353. this.contestName = window.location.href.split('/')[4];
  354. //ハッシュ(#?)があれば取り除く
  355. let hash = this.contestName.search(/[#\?]/);
  356. if (hash !== -1) {
  357. this.contestName = this.contestName.slice(0, hash);
  358. }
  359. },
  360. getContestCategoryAndAtOnce: function () {
  361. if (this.problemList === null) {
  362. return;
  363. }
  364. //コンテストの種類を取得
  365. let got = false;
  366. if (!got) {
  367. for (let category of CONTEST_REGULAR) {
  368. if (this.contestName.startsWith(category)) {
  369. this.contestCategory = category + '-' + (this.problemList.length).toString();
  370. got = true;
  371. break;
  372. }
  373. }
  374. }
  375. if (!got) {
  376. for (let category of CONTEST_PERMANENT) {
  377. if (this.contestName.startsWith(category)) {
  378. this.contestCategory = category;
  379. got = true;
  380. break;
  381. }
  382. }
  383. }
  384. if (!got) {
  385. this.contestCategory = 'other';
  386. }
  387. //atOnceの設定
  388. if (this.atOnceSetting[this.contestCategory]) {
  389. this.atOnce = this.atOnceSetting[this.contestCategory];
  390. }
  391. else {
  392. this.atOnce.begin = 0;
  393. this.atOnce.end = Math.min(ATONCE_TAB_MAX - 1, this.problemList.length - 1);
  394. }
  395. },
  396. };
  397.  
  398. /* DOM操作およびスクリプト全体の動作を管理するクラス */
  399. let Launcher = function () {
  400. this.setting = new Setting();
  401. this.dropdownList = {
  402. begin: null,
  403. end: null,
  404. };
  405. this.listChanged = {
  406. begin: true,
  407. end: true,
  408. };
  409. this.isAll = true;
  410. };
  411. Launcher.prototype = {
  412. loadSetting: async function () {
  413. this.setting.getContestName();
  414. await this.setting.openDB();
  415. await this.setting.loadData();
  416. this.setting.getLanguage();
  417. this.setting.getContestCategoryAndAtOnce();
  418. },
  419. attachId: function () {
  420. let tabs = $('#contest-nav-tabs');
  421. if (tabs.length === 0) {
  422. return false;
  423. }
  424. let tasks_tab = tabs.find('a[href$="tasks"]');
  425. if (tasks_tab.length === 0) {
  426. return false;
  427. }
  428. else {
  429. tasks_tab.attr('id', `${ID_PREFIX}-tab`);
  430. return true;
  431. }
  432. },
  433. addCss: function () {
  434. let style = $('<style>', { id: `${ID_PREFIX}-style`, html: CSS });
  435. $('head').append(style);
  436. },
  437. changeToDropdown: function () {
  438. let tasks_tab = $(`#${ID_PREFIX}-tab`);
  439. tasks_tab.attr({
  440. 'class': 'dropdown-toggle',
  441. 'data-toggle': 'dropdown',
  442. 'href': '#',
  443. 'role': 'button',
  444. 'aria-haspopup': 'true',
  445. 'aria-expanded': 'false',
  446. });
  447. tasks_tab.append($('<span>', { class: 'caret' }));
  448. tasks_tab.parent().append($('<ul>', { class: `dropdown-menu ${PRE}-dropdown` }));
  449. },
  450. addList: function () {
  451. let dropdown_menu = $(`#${ID_PREFIX}-tab`).parent().children('.dropdown-menu');
  452. /* [問題一覧]の追加 */
  453. let all_tasks = $('<a>', { href: `${CONTEST_URL}/${this.setting.contestName}/tasks` });
  454. all_tasks.append($('<span>', { class: 'glyphicon glyphicon-list' }).attr('aria-hidden', 'true'));
  455. all_tasks.append(document.createTextNode(' ' + TEXT.allTasks[this.setting.lang]));
  456. //チェックボックスにチェックが付いていたら新しいタブで開く
  457. all_tasks[0].addEventListener('click', { handleEvent: this.changeNewTabAttr, setting: this.setting });
  458. dropdown_menu.append($('<li>').append(all_tasks));
  459. /* [まとめて開く]の追加 */
  460. if (this.setting.problemList !== null) {
  461. let at_once = $('<a>');
  462. at_once.append($('<span>', { class: 'glyphicon glyphicon-sort-by-attributes-alt' }).attr('aria-hidden', 'true'));
  463. at_once.append(document.createTextNode(' ' + TEXT.atOnce[this.setting.lang] + '...'));
  464. at_once[0].addEventListener('click', (e) => {
  465. $(`#${ID_PREFIX}-modal`).modal('show');
  466. });
  467. dropdown_menu.append($('<li>').append(at_once));
  468. }
  469. /* [新しいタブで開く]の追加 */
  470. let label = $('<label>', { class: `${PRE}-label` });
  471. label.css('color', all_tasks.css('color')); //[問題一覧]から色情報を取得
  472. let checkbox = $('<input>', { type: 'checkbox', class: `${PRE}-checkbox` });
  473. //チェックボックスはチェック状態をストレージと同期
  474. checkbox.prop('checked', this.setting.newTab);
  475. checkbox.on('click', (e) => {
  476. this.setting.newTab = e.currentTarget.checked;
  477. if (this.setting.dbExists) {
  478. this.setting.saveData('newTab', this.setting.newTab);
  479. }
  480. });
  481. label.append(checkbox);
  482. label.append(document.createTextNode(' ' + TEXT.newTab[this.setting.lang]));
  483. dropdown_menu.prepend($('<li>').append(label));
  484. //チェックボックスが押された場合はドロップダウンリストを非表示にしない
  485. dropdown_menu.on('click', (e) => {
  486. if (e.target === label[0]) {
  487. e.stopPropagation();
  488. }
  489. });
  490. /* 分割線の追加 */
  491. dropdown_menu.append($('<li>', { class: 'divider' }));
  492. /* 各問題の追加 */
  493. if (this.setting.problemList !== null) {
  494. //リストを追加
  495. for (let data of this.setting.problemList) {
  496. let a = $('<a>', { href: data.url, text: `${data.diff} - ${data.name}` });
  497. //チェックボックスにチェックが付いていたら新しいタブで開く
  498. a[0].addEventListener('click', { handleEvent: this.changeNewTabAttr, setting: this.setting });
  499. dropdown_menu.append($('<li>').append(a));
  500. }
  501. }
  502. else {
  503. //エラー情報を追加
  504. let a = $('<a>', { text: TEXT.loadingFailed[this.setting.lang] });
  505. dropdown_menu.append($('<li>').append(a));
  506. }
  507. },
  508. changeNewTabAttr: function (e) {
  509. let a = e.currentTarget;
  510. if (this.setting.newTab) {
  511. a.target = '_blank';
  512. a.rel = 'noopener noreferrer';
  513. }
  514. else {
  515. a.target = '_self';
  516. a.rel = '';
  517. }
  518. },
  519. addModal: function () {
  520. if (this.setting.problemList === null) {
  521. return;
  522. }
  523. let modal = $('<div>', { id: `${ID_PREFIX}-modal`, class: 'modal fade', tabindex: '-1', role: 'dialog' });
  524. /* header */
  525. let header = $('<div>', { class: 'modal-header' });
  526. let x = $('<button>', { type: 'button', class: 'close', 'data-dismiss': 'modal', 'aria-label': 'Close' });
  527. x.append($('<span>', { 'aria-hidden': true, text: '×' }));
  528. header.append(x);
  529. header.append($('<h4>', { class: 'modal-title', text: TEXT.atOnce[this.setting.lang] }));
  530. /* body */
  531. let body = $('<div>', { class: 'modal-body' });
  532. body.append($('<p>', { text: TEXT.modalDiscription[this.setting.lang] }));
  533. let modalInfo = $('<p>');
  534. let option = $('<div>', { class: `${PRE}-option` });
  535. //ラジオボタン
  536. let all = $('<div>', { class: `${PRE}-flex ${PRE}-select-all` });
  537. let specify = $('<div>', { class: `${PRE}-flex ${PRE}-select-specify` });
  538. let label_all = $('<label>', { class: `${PRE}-label-radio` });
  539. let radio_all = $('<input>', { type: 'radio', name: 'open-type' });
  540. let label_specify = label_all.clone(true);
  541. let radio_specify = radio_all.clone(true);
  542. if (this.setting.atOnce.begin === 0 && this.setting.atOnce.end === this.setting.problemList.length - 1) {
  543. this.isAll = true;
  544. }
  545. else {
  546. this.isAll = false;
  547. }
  548. radio_all.prop('checked', this.isAll);
  549. radio_specify.prop('checked', !this.isAll);
  550. label_all.append(radio_all, document.createTextNode(TEXT.all[this.setting.lang]));
  551. label_specify.append(radio_specify, document.createTextNode(TEXT.specify[this.setting.lang] + ':'));
  552. let caution = $('<span>', { class: `${PRE}-caution` });
  553. if (this.setting.problemList.length > ATONCE_TAB_MAX) {
  554. radio_all.prop('disabled', true);
  555. label_all.addClass(`${PRE}-disabled`);
  556. caution.text(TEXT.caution[this.setting.lang]);
  557. }
  558. all.append($('<div>', { class: `radio ${PRE}-radio` }).append(label_all, caution));
  559. specify.append($('<div>', { class: `radio ${PRE}-radio` }).append(label_specify));
  560. //[範囲を選択]用のドロップダウン
  561. let select_begin = $('<div>', { class: `btn-group` });
  562. let begin_button = $('<button>', { class: `btn btn-default dropdown-toggle ${PRE}-toggle`, 'data-toggle': 'dropdown', 'aria-expanded': 'false', text: 'A', disabled: this.isAll });
  563. begin_button.append($('<span>', { class: `caret ${PRE}-caret` }));
  564. let begin_list = $('<ul>', { class: `dropdown-menu ${PRE}-list` });
  565. $.each(this.setting.problemList, (idx, data) => {
  566. begin_list.append($('<li>').append($('<a>', { text: `${data.diff} - ${data.name}`, 'data-index': (idx).toString() })));
  567. });
  568. select_begin.append(begin_button, begin_list);
  569. let select_end = select_begin.clone(true);
  570. let end_list = select_end.find('ul');
  571. let end_button = select_end.find('button');
  572. let between = $('<span>', { text: '−', class: `${PRE}-between` });
  573. //初期表示の設定
  574. begin_button.html(`${this.setting.problemList[this.setting.atOnce.begin].diff}<span class="caret ${PRE}-caret"></span>`);
  575. end_button.html(`${this.setting.problemList[this.setting.atOnce.end].diff}<span class="caret ${PRE}-caret"></span>`);
  576. this.dropdownList.begin = begin_list.find('a');
  577. this.dropdownList.end = end_list.find('a');
  578. this.dropdownList.begin.eq(this.setting.atOnce.begin).addClass(`${PRE}-target`);
  579. this.dropdownList.end.eq(this.setting.atOnce.end).addClass(`${PRE}-target`);
  580. this.setModalInfo(modalInfo, this.setting, this.isAll);
  581. //ラジオボタンを切り替えたときの動作
  582. radio_all.on('change', (e) => {
  583. this.isAll = true;
  584. begin_button.prop('disabled', true);
  585. end_button.prop('disabled', true);
  586. between.addClass(`${PRE}-disabled`);
  587. this.setModalInfo(modalInfo, this.setting, this.isAll);
  588. });
  589. radio_specify.on('change', (e) => {
  590. this.isAll = false;
  591. begin_button.prop('disabled', false);
  592. end_button.prop('disabled', false);
  593. between.removeClass(`${PRE}-disabled`);
  594. this.setModalInfo(modalInfo, this.setting, this.isAll);
  595. });
  596. //リストを開いたときの動作
  597. select_begin.on('shown.bs.dropdown', (e) => {
  598. if (this.listChanged.begin) {
  599. begin_list.scrollTop(26 * (this.setting.atOnce.begin - 2));
  600. this.listChanged.begin = false;
  601. }
  602. });
  603. select_end.on('shown.bs.dropdown', (e) => {
  604. if (this.listChanged.end) {
  605. end_list.scrollTop(26 * (this.setting.atOnce.end - 2));
  606. this.listChanged.end = false;
  607. }
  608. });
  609. //リストで選択したときの動作
  610. begin_list[0].addEventListener('click', { handleEvent: this.changeRange, that: this, begin_button, end_button, modalInfo, isBegin: true });
  611. end_list[0].addEventListener('click', { handleEvent: this.changeRange, that: this, begin_button, end_button, modalInfo, isBegin: false });
  612. specify.append(select_begin, between, select_end);
  613. //[逆順で開く]チェックボックス
  614. let reverse = $('<div>', { class: 'checkbox' });
  615. let label_reverse = $('<label>');
  616. let check_reverse = $('<input>', { type: 'checkbox', name: 'reverse' });
  617. check_reverse.prop('checked', this.setting.reverse);
  618. check_reverse.on('click', (e) => {
  619. this.setting.reverse = e.currentTarget.checked;
  620. });
  621. label_reverse.append(check_reverse, document.createTextNode(TEXT.reverse[this.setting.lang]));
  622. reverse.append(label_reverse);
  623. //組み立て
  624. option.append(all, specify, reverse);
  625. body.append(option);
  626. body.append(modalInfo);
  627. /* footer */
  628. let footer = $('<div>', { class: 'modal-footer' });
  629. let cancel = $('<button>', { type: 'button', class: 'btn btn-default', 'data-dismiss': 'modal', text: TEXT.cancel[this.setting.lang] });
  630. let open = $('<button>', { type: 'button', class: 'btn btn-primary', text: TEXT.atOnce[this.setting.lang] });
  631. open.on('click', (e) => {
  632. //設定を保存
  633. this.setting.saveData('reverse', this.setting.reverse);
  634. if (this.setting.contestCategory !== 'other') {
  635. this.setting.atOnceSetting[this.setting.contestCategory] = {};
  636. if (this.isAll) {
  637. this.setting.atOnceSetting[this.setting.contestCategory].begin = 0;
  638. this.setting.atOnceSetting[this.setting.contestCategory].end = this.setting.problemList.length - 1;
  639. }
  640. else {
  641. this.setting.atOnceSetting[this.setting.contestCategory] = this.setting.atOnce;
  642. }
  643. this.setting.saveData('atOnce', this.setting.atOnceSetting);
  644. }
  645. //タブを開く
  646. let blank = window.open('about:blank'); //ポップアップブロック用
  647. let idx = null;
  648. if (this.isAll) {
  649. if (!this.setting.reverse) {
  650. idx = 0;
  651. while (idx <= this.setting.problemList.length - 1) {
  652. window.open(this.setting.problemList[idx].url, '_blank', 'noopener, noreferrer');
  653. ++idx;
  654. }
  655. }
  656. else {
  657. idx = this.setting.problemList.length - 1;
  658. while (idx >= 0) {
  659. window.open(this.setting.problemList[idx].url, '_blank', 'noopener, noreferrer');
  660. --idx;
  661. }
  662. }
  663. }
  664. else {
  665. if (!this.setting.reverse) {
  666. idx = this.setting.atOnce.begin;
  667. while (idx <= this.setting.atOnce.end) {
  668. window.open(this.setting.problemList[idx].url, '_blank', 'noopener, noreferrer');
  669. ++idx;
  670. }
  671. }
  672. else {
  673. idx = this.setting.atOnce.end;
  674. while (idx >= this.setting.atOnce.begin) {
  675. window.open(this.setting.problemList[idx].url, '_blank', 'noopener, noreferrer');
  676. --idx;
  677. }
  678. }
  679. }
  680. modal.modal('hide');
  681. blank.close();
  682. });
  683. footer.append(cancel, open);
  684. /* モーダルウィンドウを追加 */
  685. let dialog = $('<div>', { class: 'modal-dialog', role: 'document' });
  686. let content = $('<div>', { class: 'modal-content' });
  687. content.append(header, body, footer);
  688. modal.append(dialog.append(content));
  689. $('#main-div').before(modal);
  690. },
  691. changeRange: function (e) {
  692. if (e.target.tagName !== 'A') {
  693. return;
  694. }
  695. let atOnce = this.that.setting.atOnce;
  696. let idx = Number($(e.target).attr('data-index'));
  697. if (this.isBegin) {
  698. this.that.changeSelect(this.that, this.begin_button, idx, true);
  699. if (atOnce.end < atOnce.begin) {
  700. this.that.changeSelect(this.that, this.end_button, idx, false);
  701. }
  702. else if (atOnce.end >= atOnce.begin + ATONCE_TAB_MAX) {
  703. this.that.changeSelect(this.that, this.end_button, idx + ATONCE_TAB_MAX - 1, false);
  704. }
  705. }
  706. else {
  707. this.that.changeSelect(this.that, this.end_button, idx, false);
  708. if (atOnce.begin > atOnce.end) {
  709. this.that.changeSelect(this.that, this.begin_button, idx, true);
  710. }
  711. if (atOnce.begin <= atOnce.end - ATONCE_TAB_MAX) {
  712. this.that.changeSelect(this.that, this.begin_button, idx - ATONCE_TAB_MAX + 1, true);
  713. }
  714. }
  715. this.that.setModalInfo(this.modalInfo, this.that.setting, this.that.isAll);
  716. },
  717. changeSelect: function (that, button, idx, isBegin) {
  718. let problemList = that.setting.problemList;
  719. let atOnce = that.setting.atOnce;
  720. let dropdownList = that.dropdownList;
  721. if (isBegin) {
  722. dropdownList.begin.eq(atOnce.begin).removeClass(`${PRE}-target`);
  723. atOnce.begin = idx;
  724. dropdownList.begin.eq(idx).addClass(`${PRE}-target`);
  725. that.listChanged.begin = true;
  726. }
  727. else {
  728. dropdownList.end.eq(atOnce.end).removeClass(`${PRE}-target`);
  729. atOnce.end = idx;
  730. dropdownList.end.eq(idx).addClass(`${PRE}-target`);
  731. that.listChanged.end = true;
  732. }
  733. button.html(`${problemList[idx].diff}<span class="caret ${PRE}-caret"></span>`);
  734. },
  735. setModalInfo: function (modalInfo, setting, isAll) {
  736. let text = '';
  737. if (isAll) {
  738. text += (setting.problemList.length).toString();
  739. text += ' ';
  740. if (setting.problemList.length === 1) {
  741. text += TEXT.aTab[setting.lang];
  742. }
  743. else {
  744. text += TEXT.tabs[setting.lang];
  745. }
  746. }
  747. else {
  748. text += (setting.atOnce.end - setting.atOnce.begin + 1).toString();
  749. text += ' ';
  750. if (setting.atOnce.end === setting.atOnce.begin) {
  751. text += TEXT.aTab[setting.lang];
  752. }
  753. else {
  754. text += TEXT.tabs[setting.lang];
  755. }
  756. }
  757. text += TEXT.modalInfo[setting.lang];
  758. modalInfo.text(text);
  759. },
  760. launch: async function () {
  761. let tabExists = this.attachId();
  762. //[問題]タブがない場合は終了
  763. if (!tabExists) {
  764. console.log('[AtCoder Listing Tasks] [Tasks] Tab isn\'t exist.');
  765. return;
  766. }
  767. await this.loadSetting();
  768. this.addCss();
  769. this.changeToDropdown();
  770. this.addList();
  771. this.addModal();
  772. await this.setting.removeOldData();
  773. if (this.setting.problemList !== null) {
  774. console.log('[AtCoder Listing Tasks] Succeeded!');
  775. }
  776. else {
  777. console.warn('[AtCoder Listing Tasks] Failed...');
  778. }
  779. },
  780. };
  781.  
  782. /* スクリプトを実行 */
  783. let launcher = new Launcher();
  784. launcher.launch();
  785.  
  786. })();