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.

653 lines
23 KiB

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