A Chrome extension that gives you finer control over MyAnimeList.net scores
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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