// ==UserScript==
// @name AniList Edit Multiple Media Simultaneously
// @license MIT
// @namespace rtonne
// @match https://anilist.co/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=anilist.co
// @version 1.0
// @author Rtonne
// @description Adds the ability to select multiple manga/anime in your lists and act on them simultaneously
// @grant GM.getResourceText
// @grant GM.addStyle
// @require https://update.greasyfork.org/scripts/496874/1387742/components.js
// @require https://update.greasyfork.org/scripts/496875/1387743/helpers.js
// @resource GLOBAL_CSS https://raw.githubusercontent.com/p-laranjinha/userscripts/master/AniList%20Edit%20Multiple%20Media%20Simultaneously/global.css
// @resource PLUS_SVG https://raw.githubusercontent.com/p-laranjinha/userscripts/master/AniList%20Edit%20Multiple%20Media%20Simultaneously/plus.svg
// @resource MINUS_SVG https://raw.githubusercontent.com/p-laranjinha/userscripts/master/AniList%20Edit%20Multiple%20Media%20Simultaneously/minus.svg
// @downloadURL none
// ==/UserScript==
// REPLACE THE @require AND @resource WITH THE FOLLOWING DURING DEVELOPMENT
// AND REMEMBER TO UPDATE THE REQUIRES
// @require components.js
// @require helpers.js
// @resource GLOBAL_CSS global.css
// @resource PLUS_SVG plus.svg
// @resource MINUS_SVG minus.svg
const GLOBAL_CSS = GM.getResourceText("GLOBAL_CSS");
GM.addStyle(GLOBAL_CSS);
const PLUS_SVG = GM.getResourceText("PLUS_SVG");
const MINUS_SVG = GM.getResourceText("MINUS_SVG");
let WAS_LAST_LIST_ANIME = false;
let current_url = null;
let new_url = null;
const url_regex =
/^https:\/\/anilist.co\/user\/.+\/((animelist)|(mangalist))(\/.*)?$/;
// Using observer to run script whenever the body changes
// because anilist doesn't reload when changing page
const observer = new MutationObserver(async () => {
try {
new_url = window.location.href;
// Because anilist doesn't reload on changing url
// we have to allow the whole website and check here if we are in a list
if (!url_regex.test(new_url)) {
return;
}
// If we have actions in the banner, it's not our list and can't edit it
if (
(await waitForElements(".banner-content .actions"))[0].children.length > 0
) {
return;
}
setupButtons();
setupForm();
} catch (err) {
console.error(err);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
async function setupButtons() {
const entries = await waitForElements(".entry, .entry-card");
// If the url is different we are in a different list
// Or if the list length is different, we loaded more of the same list
if (
current_url === new_url &&
entries.length ===
document.querySelectorAll(".rtonne-anilist-multiselect-addbutton").length
) {
return;
}
current_url = new_url;
let isCard = false;
if (entries.length > 0 && entries[0].classList.contains("entry-card")) {
isCard = true;
}
entries.forEach((entry) => {
const cover = entry.querySelector(".cover");
// We return if the item already has a select button so
// there isn't an infinite loop where adding a button triggers
// the observer which adds more buttons
if (entry.querySelector(".rtonne-anilist-multiselect-addbutton")) return;
const add_button = document.createElement("div");
add_button.className = "rtonne-anilist-multiselect-addbutton edit";
add_button.innerHTML = PLUS_SVG;
// I'm appending the buttons to the cards in a different place so I can have them above long titles
if (isCard) {
entry.append(add_button);
} else {
cover.querySelector(".edit").after(add_button);
}
const remove_button = document.createElement("div");
remove_button.className = "rtonne-anilist-multiselect-removebutton edit";
remove_button.innerHTML = MINUS_SVG;
add_button.after(remove_button);
add_button.onclick = () => {
entry.className += " rtonne-anilist-multiselect-selected";
};
remove_button.onclick = () => {
entry.classList.remove("rtonne-anilist-multiselect-selected");
};
});
}
async function setupForm() {
// Check if the form needs to be made/remade
const [container] = await waitForElements(".filters-wrap");
const is_list_anime = document
.querySelector(".nav.container > a[href$='animelist']")
.classList.contains("router-link-active");
const previous_forms = document.querySelectorAll(
".rtonne-anilist-multiselect-form"
);
const previous_helps = document.querySelectorAll(
".rtonne-anilist-multiselect-form-help"
);
if (previous_forms.length > 0) {
// In case we end up with multiple forms because of asynchronicity, remove the extra ones
if (previous_forms.length > 1) {
for (let i = 0; i < previous_forms.length - 1; i++) {
previous_forms[i].remove();
previous_helps[i].remove();
}
}
// If we change from anime to manga or vice versa, redo the form
if (WAS_LAST_LIST_ANIME !== is_list_anime) {
for (let i = 0; i < previous_forms.length; i++) {
previous_forms[i].remove();
previous_helps[i].remove();
}
} else {
return;
}
}
WAS_LAST_LIST_ANIME = is_list_anime;
// Choose what status and score to use in the form
let status_options = [
"Reading",
"Plan to read",
"Completed",
"Rereading",
"Paused",
"Dropped",
];
if (is_list_anime) {
status_options = [
"Watching",
"Plan to read",
"Completed",
"Rewatching",
"Paused",
"Dropped",
];
}
let score_step = 1,
score_max;
const [element_with_score_type] = await waitForElements(
".content.container > .medialist"
);
if (element_with_score_type.classList.contains("POINT_10_DECIMAL")) {
score_step = 0.5;
score_max = 10;
} else if (element_with_score_type.classList.contains("POINT_100")) {
score_max = 100;
} else if (element_with_score_type.classList.contains("POINT_10")) {
score_max = 10;
} else if (element_with_score_type.classList.contains("POINT_5")) {
score_max = 5;
} else {
// if (element_with_score_type.classList.contains("POINT_3"))
score_max = 3;
}
// Create the form container
let previous_form = document.querySelector(
".rtonne-anilist-multiselect-form"
);
if (previous_form) {
return;
}
const form = document.createElement("div");
form.className = "rtonne-anilist-multiselect-form";
form.style.display = "none";
container.append(form);
// We get custom_lists and advanced_scores after creating the form so we can do it only once
let custom_lists = [];
while (true) {
const first_media_id = Number(
document
.querySelector(".entry .title > a, .entry-card .title > a")
.href.split("/")[4]
);
const custom_lists_response = await getDataFromEntries(
[first_media_id],
"customLists"
);
if (custom_lists_response.errors) {
const error_message = `An error occurred while getting the available custom lists. Please look at the console for more information. Do you want to retry or cancel the request?`;
if (await createErrorPopup(error_message)) {
document.body.className += " rtonne-anilist-multiselect-form-failed";
return;
}
} else {
custom_lists = custom_lists_response.data[0]
? Object.keys(custom_lists_response.data[0])
: [];
break;
}
}
let advanced_scores = [];
while (true) {
const first_media_id = Number(
document
.querySelector(".entry .title > a, .entry-card .title > a")
.href.split("/")[4]
);
const is_advanced_scores_enabled = await isAdvancedScoringEnabled();
if (is_advanced_scores_enabled.errors) {
const error_message = `An error occurred while getting if advanced scores are enabled. Please look at the console for more information. Do you want to retry or cancel the request?`;
if (await createErrorPopup(error_message)) {
document.body.className += " rtonne-anilist-multiselect-form-failed";
return;
}
} else if (
(is_list_anime && is_advanced_scores_enabled.data.anime) ||
(!is_list_anime && is_advanced_scores_enabled.data.manga)
) {
const advanced_scores_response = await getDataFromEntries(
[first_media_id],
"advancedScores"
);
if (advanced_scores_response.errors) {
const error_message = `An error occurred while getting the available advanced scores. Please look at the console for more information. Do you want to retry or cancel the request?`;
if (await createErrorPopup(error_message)) {
document.body.className += " rtonne-anilist-multiselect-form-failed";
return;
}
} else {
advanced_scores = advanced_scores_response.data[0]
? Object.keys(advanced_scores_response.data[0])
: [];
break;
}
} else {
break;
}
}
// Create the form contents
const help = document.createElement("div");
help.className = "rtonne-anilist-multiselect-form-help";
help.innerHTML =
"ⓘ Because values can be empty, there are 2 ways to enable them. The first one is via an Enable checkbox;" +
" the second one is using indeterminate checkboxes, where a dark square and strikethrough text means they're not enabled." +
"
ⓘ Batch updating is done whenever possible. The following cases require individual updates:" +
" choosing some but not all advanced scores; choosing one or more custom lists; adding or removing from favourites; deleting.";
help.style.width = "100%";
help.style.paddingTop = "20px";
help.style.fontSize = "smaller";
help.style.display = "none";
form.after(help);
const status_container = document.createElement("div");
status_container.id = "rtonne-anilist-multiselect-status-input";
status_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(status_container);
const status_label = document.createElement("label");
status_label.innerText = "Status";
status_container.append(status_label);
const status_enabled_checkbox = createCheckbox(status_container, "Enabled");
const status_input = createSelectInput(status_container, status_options);
const score_container = document.createElement("div");
score_container.id = "rtonne-anilist-multiselect-score-input";
score_container.className = "rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(score_container);
const score_label = document.createElement("label");
score_label.innerText = "Score";
score_container.append(score_label);
const score_enabled_checkbox = createCheckbox(score_container, "Enabled");
const score_input = createNumberInput(score_container, score_max, score_step);
/** @type {HTMLInputElement[]} */
let advanced_scores_enabled_checkboxes = [];
/** @type {HTMLInputElement[]} */
let advanced_scores_inputs = [];
if (advanced_scores.length > 0) {
for (const advanced_score of advanced_scores) {
const advanced_score_container = document.createElement("div");
advanced_score_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(advanced_score_container);
const advanced_score_label = document.createElement("label");
advanced_score_label.innerHTML = `${advanced_score} (Advanced Score)`;
advanced_score_label.style.wordBreak = "break-all";
advanced_score_container.append(advanced_score_label);
advanced_scores_enabled_checkboxes.push(
createCheckbox(advanced_score_container, "Enabled")
);
advanced_scores_inputs.push(
createNumberInput(advanced_score_container, 100, 0)
);
}
}
/**
* Collection of progress inputs.
* Changes depending on if the list is for anime or manga.
* @type {{
* episode_enabled_checkbox: HTMLInputElement,
* episode_input: HTMLInputElement,
* rewatches_enabled_checkbox: HTMLInputElement,
* rewatches_input: HTMLInputElement,
* } | {
* chapter_enabled_checkbox: HTMLInputElement,
* chapter_input: HTMLInputElement,
* volume_enabled_checkbox: HTMLInputElement,
* volume_input: HTMLInputElement,
* rereads_enabled_checkbox: HTMLInputElement,
* rereads_input: HTMLInputElement,
* }}
*/
const progress_inputs = (() => {
const result = {};
if (is_list_anime) {
const episode_container = document.createElement("div");
episode_container.id = "rtonne-anilist-multiselect-episode-input";
episode_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(episode_container);
const episode_label = document.createElement("label");
episode_label.innerText = "Episode Progress";
episode_container.append(episode_label);
result.episode_enabled_checkbox = createCheckbox(
episode_container,
"Enabled"
);
result.episode_input = createNumberInput(episode_container);
const rewatches_container = document.createElement("div");
rewatches_container.id = "rtonne-anilist-multiselect-rewatches-input";
rewatches_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(rewatches_container);
const rewatches_label = document.createElement("label");
rewatches_label.innerText = "Total Rewatches";
rewatches_container.append(rewatches_label);
result.rewatches_enabled_checkbox = createCheckbox(
rewatches_container,
"Enabled"
);
result.rewatches_input = createNumberInput(rewatches_container);
} else {
const chapter_container = document.createElement("div");
chapter_container.id = "rtonne-anilist-multiselect-episode-input";
chapter_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(chapter_container);
const chapter_label = document.createElement("label");
chapter_label.innerText = "Chapter Progress";
chapter_container.append(chapter_label);
result.chapter_enabled_checkbox = createCheckbox(
chapter_container,
"Enabled"
);
result.chapter_input = createNumberInput(chapter_container);
const volume_container = document.createElement("div");
volume_container.id = "rtonne-anilist-multiselect-episode-input";
volume_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(volume_container);
const volume_label = document.createElement("label");
volume_label.innerText = "Volume Progress";
volume_container.append(volume_label);
result.volume_enabled_checkbox = createCheckbox(
volume_container,
"Enabled"
);
result.volume_input = createNumberInput(volume_container);
const rereads_container = document.createElement("div");
rereads_container.id = "rtonne-anilist-multiselect-rewatches-input";
rereads_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(rereads_container);
const rereads_label = document.createElement("label");
rereads_label.innerText = "Total Rereads";
rereads_container.append(rereads_label);
result.rereads_enabled_checkbox = createCheckbox(
rereads_container,
"Enabled"
);
result.rereads_input = createNumberInput(rereads_container);
}
return result;
})();
const start_date_container = document.createElement("div");
start_date_container.id = "rtonne-anilist-multiselect-start-date-input";
start_date_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(start_date_container);
const start_date_label = document.createElement("label");
start_date_label.innerText = "Start Date";
start_date_container.append(start_date_label);
const start_date_enabled_checkbox = createCheckbox(
start_date_container,
"Enabled"
);
const start_date_input = createDateInput(start_date_container);
const finish_date_container = document.createElement("div");
finish_date_container.id = "rtonne-anilist-multiselect-finish-date-input";
finish_date_container.className =
"rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(finish_date_container);
const finish_date_label = document.createElement("label");
finish_date_label.innerText = "Finish Date";
finish_date_container.append(finish_date_label);
const finish_date_enabled_checkbox = createCheckbox(
finish_date_container,
"Enabled"
);
const finish_date_input = createDateInput(finish_date_container);
const notes_container = document.createElement("div");
notes_container.id = "rtonne-anilist-multiselect-notes-input";
notes_container.className = "rtonne-anilist-multiselect-has-enabled-checkbox";
form.append(notes_container);
const notes_label = document.createElement("label");
notes_label.innerText = "Notes";
notes_container.append(notes_label);
const notes_enabled_checkbox = createCheckbox(notes_container, "Enabled");
const notes_input = createTextarea(notes_container);
/** @type {HTMLInputElement|null} */
let hide_from_status_list_checkbox;
/** @type {HTMLInputElement[]} */
let custom_lists_checkboxes = [];
if (custom_lists.length > 0) {
const custom_lists_container = document.createElement("div");
custom_lists_container.id = "rtonne-anilist-multiselect-custom-lists-input";
form.append(custom_lists_container);
const custom_lists_label = document.createElement("label");
custom_lists_label.innerText = "Custom Lists";
custom_lists_container.append(custom_lists_label);
for (const custom_list of custom_lists) {
custom_lists_checkboxes.push(
createIndeterminateCheckbox(custom_lists_container, custom_list)
);
}
const custom_lists_separator = document.createElement("div");
custom_lists_separator.style.width = "100%";
custom_lists_separator.style.marginBottom = "6px";
custom_lists_separator.style.borderBottom =
"solid 1px rgba(var(--color-text-lighter),.3)";
custom_lists_container.append(custom_lists_separator);
hide_from_status_list_checkbox = createIndeterminateCheckbox(
custom_lists_container,
"Hide from status lists"
);
}
const other_actions_container = document.createElement("div");
other_actions_container.id = "rtonne-anilist-multiselect-other-actions-input";
form.append(other_actions_container);
const other_actions_label = document.createElement("label");
other_actions_label.innerText = "Other Actions";
other_actions_container.append(other_actions_label);
const private_checkbox = createIndeterminateCheckbox(
other_actions_container,
"Private"
);
const favourite_checkbox = createIndeterminateCheckbox(
other_actions_container,
"Favourite"
);
const delete_checkbox = createCheckbox(other_actions_container, "Delete");
const deselect_all_button = createDangerButton(form, "Deselect All Entries");
const confirm_button = createButton(form, "Confirm");
new MutationObserver(() => {
if (
delete_checkbox.checked ||
status_enabled_checkbox.checked ||
(advanced_scores.length > 0 &&
advanced_scores_enabled_checkboxes.some((e) => e.checked)) ||
score_enabled_checkbox.checked ||
(is_list_anime &&
(progress_inputs.episode_enabled_checkbox.checked ||
progress_inputs.rewatches_enabled_checkbox.checked)) ||
(!is_list_anime &&
(progress_inputs.chapter_enabled_checkbox.checked ||
progress_inputs.volume_enabled_checkbox.checked ||
progress_inputs.rereads_enabled_checkbox.checked)) ||
start_date_enabled_checkbox.checked ||
finish_date_enabled_checkbox.checked ||
notes_enabled_checkbox.checked ||
(custom_lists.length > 0 &&
(custom_lists_checkboxes.some((e) => !e.indeterminate) ||
!hide_from_status_list_checkbox.indeterminate)) ||
!private_checkbox.indeterminate ||
!favourite_checkbox.indeterminate
) {
confirm_button.style.display = "unset";
} else {
confirm_button.style.display = "none";
}
}).observe(form, {
childList: true,
subtree: true,
attributeFilter: ["class"],
});
const currently_selected_label = document.createElement("label");
currently_selected_label.style.alignSelf = "center";
currently_selected_label.style.color = "rgb(var(--color-blue))";
form.append(currently_selected_label);
deselect_all_button.onclick = () => {
const selected_entries = document.querySelectorAll(
".entry.rtonne-anilist-multiselect-selected, .entry-card.rtonne-anilist-multiselect-selected"
);
for (const entry of selected_entries) {
entry.classList.remove("rtonne-anilist-multiselect-selected");
}
};
confirm_button.onclick = () => {
let action_list = "";
let values_to_be_changed = {};
if (!delete_checkbox.checked) {
if (status_enabled_checkbox.checked) {
action_list += `