A Chrome extension that gives you finer control over MyAnimeList.net scores
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

666 行
23 KiB

  1. /* Decimal Scores for MyAnimeList
  2. Copyright (C) 2014 Ben Kurtovic <ben.kurtovic@gmail.com>
  3. Distributed under the terms of the MIT License. See the LICENSE file for
  4. details.
  5. */
  6. /* -------------------------------- Globals -------------------------------- */
  7. var MAX_BUCKETS = 256;
  8. var LOADING_IMG = '<img src="http://cdn.myanimelist.net/images/xmlhttp-loader.gif" align="center">';
  9. var should_sort = window.location.href.indexOf("order=4") != -1;
  10. /* ------------------------ Miscellaneous functions ------------------------ */
  11. /* Note: complaints about modifying objects we don't own are ignored since
  12. these changes are only executed within the context of our own extension. */
  13. String.prototype.contains = function(substr) {
  14. return this.indexOf(substr) != -1;
  15. };
  16. String.prototype.cut_after = function(substr) {
  17. return this.substr(this.indexOf(substr) + substr.length)
  18. };
  19. String.prototype.cut_before = function(substr) {
  20. return this.substr(0, this.indexOf(substr));
  21. };
  22. String.prototype.cut = function(after, before) {
  23. return this.cut_after(after).cut_before(before);
  24. }
  25. function round_score(num) {
  26. num = Math.round(num * 10) / 10;
  27. if (isNaN(num))
  28. return num;
  29. if (num == Math.round(num))
  30. num += ".0";
  31. return num;
  32. }
  33. function get_anime_id_from_href(href) {
  34. var anime_id;
  35. if (href.contains("/anime/"))
  36. anime_id = href.cut_after("/anime/");
  37. else
  38. anime_id = href.cut_after("id=");
  39. if (anime_id.contains("/"))
  40. anime_id = anime_id.cut_before("/");
  41. if (anime_id.contains("&"))
  42. anime_id = anime_id.cut_before("&");
  43. return anime_id;
  44. }
  45. function get_edit_id_from_href(href) {
  46. var anime_id = href.cut_after("id=");
  47. if (anime_id.contains("&"))
  48. anime_id = anime_id.cut_before("&");
  49. return anime_id;
  50. }
  51. function get_score_from_element(elem) {
  52. var score = round_score(elem.val());
  53. if (isNaN(score) || ((score < 1 || score > 10) && score != 0)) {
  54. alert("Invalid score: must be a number between 1.0 and 10.0, or 0.");
  55. return null;
  56. }
  57. return score;
  58. }
  59. function load_score_into_element(data, anime_id, elem) {
  60. var bucket = data[(parseInt(anime_id) % MAX_BUCKETS).toString()];
  61. if (bucket !== undefined && bucket[anime_id] !== undefined)
  62. elem.text(bucket[anime_id] == 0 ? "-" : bucket[anime_id]);
  63. else {
  64. var current = parseInt(elem.text());
  65. if (!isNaN(current))
  66. elem.text(current + ".0");
  67. }
  68. }
  69. function update_shared_row_colors(row, our_pos) {
  70. var our_cell = $(row.find("td")[our_pos]);
  71. var their_cell = $(row.find("td")[our_pos == 1 ? 2 : 1]);
  72. var diff = our_cell.text() - their_cell.text();
  73. if (!diff) {
  74. row.find("td").css("background-color", "#f6f6f6");
  75. our_cell.add(their_cell).find("span").css("color", "");
  76. }
  77. else {
  78. row.find("td").css("background-color", "");
  79. our_cell.css("color", diff > 0 ? "#FF0000" : "#0000FF");
  80. their_cell.css("color", diff > 0 ? "#0000FF" : "#FF0000");
  81. }
  82. }
  83. /* --------------------------- Storage functions --------------------------- */
  84. function save_score(anime_id, score) {
  85. var bucket_id = (parseInt(anime_id) % MAX_BUCKETS).toString();
  86. chrome.storage.sync.get(bucket_id, function(data) {
  87. var bucket = data[bucket_id];
  88. if (bucket === undefined)
  89. bucket = data[bucket_id] = {};
  90. bucket[anime_id] = score;
  91. chrome.storage.sync.set(data);
  92. });
  93. }
  94. function retrieve_scores(anime_id, callback) {
  95. var bucket_id = null;
  96. if (anime_id !== null)
  97. bucket_id = (parseInt(anime_id) % MAX_BUCKETS).toString();
  98. chrome.storage.sync.get(bucket_id, function(data) {
  99. if (anime_id !== null) {
  100. var bucket = data[bucket_id];
  101. if (bucket !== undefined && bucket[anime_id] !== undefined)
  102. callback(bucket[anime_id]);
  103. else
  104. callback(null);
  105. }
  106. else
  107. callback(data);
  108. });
  109. }
  110. function remove_score(anime_id) {
  111. var bucket_id = (parseInt(anime_id) % MAX_BUCKETS).toString();
  112. chrome.storage.sync.get(bucket_id, function(data) {
  113. var bucket = data[bucket_id];
  114. if (bucket === undefined || bucket[anime_id] === undefined)
  115. return;
  116. delete bucket[anime_id];
  117. if ($.isEmptyObject(bucket))
  118. chrome.storage.sync.remove(bucket_id);
  119. else
  120. chrome.storage.sync.set(data);
  121. });
  122. }
  123. function export_scores() {
  124. chrome.storage.sync.get(null, function(dat) {
  125. var blob = new Blob([JSON.stringify(dat)], {type: "application/json"});
  126. $($("<a>")
  127. .attr("href", window.URL.createObjectURL(blob))
  128. .attr("download", "animelist_decimal_scores.json")
  129. .hide()
  130. .appendTo($("body"))[0].click()).remove();
  131. });
  132. }
  133. function validate_score_data(data) {
  134. if (!$.isPlainObject(data))
  135. throw "invalid data type: " + data;
  136. if (JSON.stringify(data).length > chrome.storage.sync.QUOTA_BYTES)
  137. throw "file too large";
  138. for (var bucket_id in data) {
  139. if (data.hasOwnProperty(bucket_id)) {
  140. if (isNaN(parseInt(bucket_id)) || bucket_id >= MAX_BUCKETS)
  141. throw "invalid bucket ID: " + bucket_id;
  142. var bucket = data[bucket_id];
  143. if (!$.isPlainObject(bucket))
  144. throw "invalid bucket type: " + bucket;
  145. for (var anime_id in bucket) {
  146. if (data.hasOwnProperty(anime_id)) {
  147. if (isNaN(parseInt(anime_id)))
  148. throw "invalid anime ID: " + anime_id;
  149. if (parseInt(anime_id) % MAX_BUCKETS != bucket_id)
  150. throw "anime is in the wrong bucket: " + anime_id;
  151. var score = parseFloat(bucket[anime_id]);
  152. if (isNaN(score))
  153. throw "score is not a number: " + score;
  154. if ((score < 1 || score > 10) && score != 0)
  155. throw "score out of range: " + score;
  156. }
  157. }
  158. }
  159. }
  160. }
  161. function import_scores(data, callback) {
  162. validate_score_data(data);
  163. chrome.storage.sync.clear(function() {
  164. chrome.storage.sync.set(data, callback);
  165. });
  166. }
  167. /* ----------------------- Event patches/injections ------------------------ */
  168. function update_list_score(anime_id) {
  169. var new_score = get_score_from_element($("#scoretext" + anime_id));
  170. if (new_score === null)
  171. return;
  172. var payload = {id: anime_id, score: Math.round(new_score)};
  173. $("#scorebutton" + anime_id).prop("disabled", true);
  174. $.post("/includes/ajax.inc.php?t=63", payload, function(data) {
  175. $("#scoreval" + anime_id).text(new_score == 0 ? "-" : new_score);
  176. $("#scoretext" + anime_id).val("");
  177. $("#scorediv" + anime_id).css("display", "none");
  178. $("#scorebutton" + anime_id).prop("disabled", false);
  179. if (should_sort)
  180. sort_list();
  181. update_list_stats();
  182. });
  183. save_score(anime_id, new_score);
  184. }
  185. function update_anime_score(anime_id, is_new) {
  186. var new_score = get_score_from_element($("#myinfo_score"));
  187. if (new_score === null)
  188. return;
  189. var t_id, payload = {score: Math.round(new_score)};
  190. payload["status"] = $("#myinfo_status").val();
  191. payload["epsseen"] = $("#myinfo_watchedeps").val();
  192. if (is_new) {
  193. payload["aid"] = anime_id;
  194. t_id = "61";
  195. }
  196. else {
  197. payload["alistid"] = anime_id;
  198. payload["aid"] = $("#myinfo_anime_id").val();
  199. payload["astatus"] = $("#myinfo_curstatus").val();
  200. t_id = "62";
  201. }
  202. $("#myinfoDisplay").html(LOADING_IMG);
  203. $.post("/includes/ajax.inc.php?t=" + t_id, payload, function(data) {
  204. if (is_new) {
  205. $("#myinfoDisplay").html("");
  206. $("#addtolist").html(data);
  207. }
  208. else
  209. $("#myinfoDisplay").html(data);
  210. });
  211. save_score(anime_id, new_score);
  212. }
  213. function submit_add_form(submit_button) {
  214. var anime_id = $("input[name='series_title']").val();
  215. if (!anime_id)
  216. return submit_button[0].click();
  217. var new_score = get_score_from_element($("#score_input"));
  218. if (new_score === null)
  219. return;
  220. $("select[name='score']").val(Math.round(new_score));
  221. save_score(anime_id, new_score);
  222. submit_button[0].click();
  223. }
  224. function submit_edit_form(anime_id, submit_type, submit_button) {
  225. if (submit_type == 2) {
  226. var new_score = get_score_from_element($("#score_input"));
  227. if (new_score === null)
  228. return;
  229. $("select[name='score']").val(Math.round(new_score));
  230. save_score(anime_id, new_score);
  231. }
  232. else if (submit_type == 3)
  233. remove_score(anime_id);
  234. submit_button[0].click();
  235. }
  236. /* ------------------------ List stats and sorting ------------------------- */
  237. function compare(row1, row2) {
  238. var r1 = $(row1).find("span[id^='scoreval']").text(),
  239. r2 = $(row2).find("span[id^='scoreval']").text();
  240. if (r1 == r2) {
  241. r1 = $(row1).find("a.animetitle span").text();
  242. r2 = $(row2).find("a.animetitle span").text();
  243. return r1 > r2 ? 1 : -1;
  244. }
  245. if (r1 == "-")
  246. return 1;
  247. if (r2 == "-")
  248. return -1;
  249. return r2 - r1;
  250. }
  251. function prepare_list() {
  252. var headers = [".header_cw", ".header_completed", ".header_onhold",
  253. ".header_dropped", ".header_ptw"];
  254. $.each(headers, function(i, header) {
  255. $(header).next()
  256. .nextUntil($(".category_totals").closest("table"))
  257. .wrapAll('<div class="list-chart-group"/>');
  258. });
  259. $(".list-chart-group table").each(function(i, row) {
  260. $(row).add($(row).next())
  261. .wrapAll('<div class="list-chart-row"/>');
  262. });
  263. $(".category_totals, #grand_totals").each(function(i, totals) {
  264. var text = $(totals).text();
  265. $(totals).empty()
  266. .append($("<span>").text(text))
  267. .append($("<span>").hide().text(text.cut("Score Dev.: ", "\n")));
  268. });
  269. }
  270. function sort_list() {
  271. $(".list-chart-group").each(function(i, group) {
  272. $(group).find(".list-chart-row").sort(compare).each(function(i, row) {
  273. $(group).append(row);
  274. });
  275. $(group).find(".list-chart-row").each(function(i, row) {
  276. $(row).find("tr").first().children().first().text(i + 1);
  277. $(row).find((i % 2) ? ".td1" : ".td2").toggleClass("td1 td2");
  278. });
  279. });
  280. }
  281. function apply_stats(elem, old_sum, new_sum, nums) {
  282. var text = elem.find(":first").text();
  283. var mean = round_score(new_sum / nums) || "0.0";
  284. var dev = parseFloat(elem.find(":first").next().text());
  285. dev = Math.round(((new_sum - old_sum) / nums + dev || 0) * 100) / 100;
  286. elem.find(":first").text(text
  287. .replace("Score: " + text.cut("Score: ", ","), "Score: " + mean)
  288. .replace("Dev.: " + text.cut("Dev.: ", "\n"), "Dev.: " + dev));
  289. }
  290. function update_list_stats() {
  291. var old_sum_all = 0, new_sum_all = 0, nums_all = 0;
  292. $(".category_totals").each(function(i, totals) {
  293. var group = $(totals).closest("table").prev();
  294. var old_sum = 0, new_sum = 0, nums = 0;
  295. group.find("span[id^='scoreval']").each(function(j, elem) {
  296. if ($(elem).text() != "-") {
  297. old_sum += parseFloat($(elem).next().text());
  298. new_sum += parseFloat($(elem).text());
  299. nums++;
  300. }
  301. });
  302. apply_stats($(totals), old_sum, new_sum, nums);
  303. old_sum_all += old_sum;
  304. new_sum_all += new_sum;
  305. nums_all += nums;
  306. });
  307. if ($("#grand_totals").length > 0)
  308. apply_stats($("#grand_totals"), old_sum_all, new_sum_all, nums_all);
  309. }
  310. /* ---------------------------- Extension hooks ---------------------------- */
  311. function hook_list() {
  312. retrieve_scores(null, function(data) {
  313. $("span[id^='scoreval']").each(function(i, elem) {
  314. var anime_id = elem.id.split("scoreval")[1];
  315. $(elem).after($("<span>").hide().text($(elem).text()));
  316. load_score_into_element(data, anime_id, $(elem));
  317. $("#scorediv" + anime_id)
  318. .after($("<div>")
  319. .attr("id", "scorediv" + anime_id)
  320. .hide()
  321. .append($('<input>')
  322. .attr("type", "text")
  323. .attr("id", "scoretext" + anime_id)
  324. .attr("size", "2")
  325. .keydown(function(a_id) {
  326. return function(ev) {
  327. if ((window.event ? window.event.keyCode : ev.which) == 13)
  328. update_list_score(a_id);
  329. else
  330. return true;
  331. }
  332. }(anime_id)))
  333. .append($("<input>")
  334. .attr("type", "button")
  335. .attr("id", "scorebutton" + anime_id)
  336. .attr("value", "Go")
  337. .click(function(a_id) {
  338. return function() { return update_list_score(a_id); }
  339. }(anime_id))))
  340. .remove();
  341. });
  342. prepare_list();
  343. if (should_sort)
  344. sort_list();
  345. update_list_stats();
  346. });
  347. }
  348. function hook_anime(anime_id) {
  349. retrieve_scores(anime_id, function(score) {
  350. var old_input = $("#myinfo_score");
  351. var old_button = $("input[name='myinfo_submit']");
  352. var is_new = old_button.attr("value") == "Add";
  353. if (!is_new && score === null)
  354. score = parseInt(old_input.val()) + ".0";
  355. old_input.after($("<span> / 10.0</span>"))
  356. .after($("<input>")
  357. .attr("type", "text")
  358. .attr("id", "myinfo_score")
  359. .attr("name", "myinfo_score")
  360. .attr("class", "inputtext")
  361. .attr("value", (score === null || score == 0) ? "" : score)
  362. .attr("size", "3"))
  363. .remove();
  364. old_button.after($("<input>")
  365. .attr("type", "button")
  366. .attr("name", "myinfo_submit")
  367. .attr("value", old_button.attr("value"))
  368. .attr("class", "inputButton")
  369. .click(function(a_id, is_new) {
  370. return function() { return update_anime_score(a_id, is_new); }
  371. }(anime_id, is_new)))
  372. .remove();
  373. });
  374. }
  375. function hook_add() {
  376. var old_input = $("select[name='score']");
  377. var old_submit = $("input[type='button'][onclick='checkValidSubmit(1)']");
  378. old_input.after($("<span> / 10.0</span>"))
  379. .after($("<input>")
  380. .attr("type", "text")
  381. .attr("id", "score_input")
  382. .attr("class", "inputtext")
  383. .attr("size", "3"))
  384. .hide();
  385. old_submit.after($("<input>")
  386. .attr("type", "button")
  387. .attr("class", "inputButton")
  388. .attr("style", old_submit.attr("style"))
  389. .attr("value", old_submit.attr("value"))
  390. .click(function(button) {
  391. return function() { return submit_add_form(button); }
  392. }(old_submit)))
  393. .hide();
  394. }
  395. function hook_edit(anime_id) {
  396. retrieve_scores(anime_id, function(score) {
  397. var old_input = $("select[name='score']");
  398. var old_edit = $("input[type='button'][onclick='checkValidSubmit(2)']");
  399. var old_delete = $("input[type='button'][onclick='checkValidSubmit(3)']");
  400. if (score === null)
  401. score = parseInt(old_input.val()) + ".0";
  402. old_input.after($("<span> / 10.0</span>"))
  403. .after($("<input>")
  404. .attr("type", "text")
  405. .attr("id", "score_input")
  406. .attr("class", "inputtext")
  407. .attr("value", score == 0 ? "" : score)
  408. .attr("size", "3"))
  409. .hide();
  410. old_edit.after($("<input>")
  411. .attr("type", "button")
  412. .attr("class", "inputButton")
  413. .attr("style", old_edit.attr("style"))
  414. .attr("value", old_edit.attr("value"))
  415. .click(function(a_id, button) {
  416. return function() { return submit_edit_form(a_id, 2, button); }
  417. }(anime_id, old_edit)))
  418. .hide();
  419. old_delete.after($("<input>")
  420. .attr("type", "button")
  421. .attr("class", "inputButton")
  422. .attr("value", old_delete.attr("value"))
  423. .click(function(a_id, button) {
  424. return function() { return submit_edit_form(a_id, 3, button); }
  425. }(anime_id, old_delete)))
  426. .hide();
  427. });
  428. }
  429. function hook_shared() {
  430. var our_profile = $("#nav a:first").attr("href"), our_pos;
  431. var profile_links = $("#content h2:first").find("a").slice(1);
  432. var shared_table = $("#content h2:first").next(), unique_table;
  433. var shared_means = shared_table.find("tr:nth-last-child(2)");
  434. var mean_score, mean_diff;
  435. if ($(profile_links[0]).attr("href") == our_profile)
  436. our_pos = 1;
  437. else if ($(profile_links[1]).attr("href") == our_profile)
  438. our_pos = 2;
  439. else
  440. return;
  441. retrieve_scores(null, function(data) {
  442. var score_sum = 0, diff_sum = 0, score_nums = 0, diff_nums = 0;
  443. shared_table.find("tr").slice(1, -2).each(function(i, row) {
  444. var anime_id = $(row).find("a").attr("href").cut("/anime/", "/");
  445. var our_cell = $($(row).find("td")[our_pos]).find("span");
  446. var their_cell = $($(row).find("td")[our_pos == 1 ? 2 : 1]);
  447. var diff_cell = $($(row).find("td")[3]);
  448. load_score_into_element(data, anime_id, our_cell);
  449. if (our_cell.text() != "-") {
  450. score_sum += parseFloat(our_cell.text());
  451. score_nums++;
  452. }
  453. if (our_cell.text() != "-" && their_cell.text() != "-") {
  454. var diff = Math.abs(our_cell.text() - their_cell.text());
  455. diff_sum += diff;
  456. diff_cell.text(round_score(diff));
  457. diff_nums++;
  458. update_shared_row_colors($(row), our_pos);
  459. }
  460. });
  461. unique_table = $($("#content h2")[our_pos]).next();
  462. unique_table.find("tr").slice(1, -1).each(function(i, row) {
  463. var anime_id = $(row).find("a").attr("href").cut("/anime/", "/");
  464. var cell = $(row).find("td:nth(1)").find("span");
  465. load_score_into_element(data, anime_id, cell);
  466. });
  467. mean_score = round_score(score_sum / score_nums);
  468. if (!isNaN(mean_score)) {
  469. $(shared_means.find("td")[our_pos]).find("span").text(mean_score);
  470. update_shared_row_colors(shared_means, our_pos);
  471. }
  472. mean_diff = Math.round(diff_sum / diff_nums * 100) / 100;
  473. if (!isNaN(mean_diff))
  474. $(shared_means.find("td")[3]).text(mean_diff);
  475. });
  476. }
  477. function hook_addtolist() {
  478. /* TODO: this entry point is unimplemented - it's rarely used and difficult
  479. to inject into, so I'm avoiding it for now. */
  480. $("<p><b>Note:</b> For the time being, anime added through this " +
  481. "interface cannot be given scores on the 10.0-point scale (the old " +
  482. "10-point system is used).</p><p>To give a more specific number, " +
  483. "simply add the anime here, then go to its own page or to your list " +
  484. "page, and update the score.</p>").insertAfter($("#stype").parent());
  485. }
  486. function hook_export() {
  487. chrome.storage.sync.getBytesInUse(null, function(usage) {
  488. usage = Math.round(usage / 1024 * 10) / 10;
  489. usage += " kB / " + chrome.storage.sync.QUOTA_BYTES / 1024 + " kB";
  490. $("#dialog td")
  491. .append($("<hr>")
  492. .css("border", "none")
  493. .css("background-color", "#AAA")
  494. .css("height", "1px"))
  495. .append($("<p>")
  496. .html("The regular list export above will only include " +
  497. "rounded scores. You can export your decimal scores " +
  498. "separately and " +
  499. '<a href="http://myanimelist.net/import.php">import ' +
  500. "them</a> later."))
  501. .append($("<div>")
  502. .attr("class", "spaceit")
  503. .html("Chrome Sync usage: " + usage))
  504. .append($("<input>")
  505. .attr("type", "submit")
  506. .attr("value", "Export Decimal Scores")
  507. .attr("class", "inputButton")
  508. .click(export_scores));
  509. });
  510. }
  511. function hook_import() {
  512. $("#content").append($("<hr>")
  513. .css("border", "none")
  514. .css("background-color", "#AAA")
  515. .css("height", "1px"))
  516. .append($("<p>")
  517. .html("You can also import decimal scores here. Doing so will " +
  518. "erase any existing decimal scores."))
  519. .append($("<div>")
  520. .attr("class", "spaceit")
  521. .append($("<input>")
  522. .attr("id", "decimal-file")
  523. .attr("size", "60")
  524. .attr("type", "file")
  525. .attr("class", "inputtext")))
  526. .append($("<input>")
  527. .attr("id", "decimal-submit")
  528. .attr("type", "submit")
  529. .attr("value", "Import Decimal Scores")
  530. .attr("class", "inputButton")
  531. .click(function() {
  532. var filelist = $("#decimal-file")[0].files, file, reader;
  533. if (filelist.length != 1)
  534. return;
  535. file = filelist[0];
  536. if (file.type != "application/json") {
  537. alert("Invalid file type: must be .json.");
  538. return;
  539. }
  540. reader = new FileReader();
  541. reader.onload = function() {
  542. try {
  543. import_scores(JSON.parse(reader.result), function() {
  544. $("#decimal-file").after("<p>Success!</p>")
  545. .remove();
  546. $("#decimal-submit").remove();
  547. });
  548. } catch (exc) {
  549. if (typeof exc === "object")
  550. alert("The file could not be parsed as JSON.");
  551. else
  552. alert("Error validating data: " + exc);
  553. }
  554. };
  555. reader.readAsText(file);
  556. }));
  557. }
  558. /* ------------------------------- Main hook ------------------------------- */
  559. $(document).ready(function() {
  560. var href = window.location.href;
  561. if (href.contains("/animelist/")) {
  562. var list_info = $("#mal_cs_otherlinks div:first");
  563. if (list_info.text() == "You are viewing your anime list")
  564. hook_list();
  565. }
  566. else if ($("#malLogin").length == 0) {
  567. if (href.contains("/anime/") || href.contains("/anime.php"))
  568. hook_anime(get_anime_id_from_href(href));
  569. else if (href.contains("/panel.php") && href.contains("go=add"))
  570. hook_add();
  571. else if (href.contains("/editlist.php") && href.contains("type=anime"))
  572. hook_edit(get_edit_id_from_href(href));
  573. else if (href.contains("/shared.php") && !href.contains("type=manga"))
  574. hook_shared();
  575. else if (href.contains("/addtolist.php"))
  576. hook_addtolist();
  577. else if (href.contains("/panel.php") && href.contains("go=export"))
  578. hook_export();
  579. else if (href.contains("/import.php"))
  580. hook_import();
  581. }
  582. });