AO3: [Wrangling] Comment on tags without leaving bins!!!

Comment on tags via a popup modal, which even has some text formatting options!!

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AO3: [Wrangling] Comment on tags without leaving bins!!!
// @description  Comment on tags via a popup modal, which even has some text formatting options!!
// @version      2.0.0

// @author       owlwinter
// @namespace    N/A
// @license      MIT license

// @match        *://*.archiveofourown.org/tags/*/wrangle?*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    //Important: If you use iconify, you'll need to set this to be true once installed!!
    const ICONIFY = false;

    //Checks if using dark mode
    const darkmode = window.getComputedStyle(document.body).backgroundColor == 'rgb(51, 51, 51)'

    //This will load FontAwesome so the icons will properly render
    var font_awesome_icons = document.createElement('script');
    font_awesome_icons.setAttribute('src', 'https://use.fontawesome.com/ed555db3cc.js');
    document.getElementsByTagName('head')[0].appendChild(font_awesome_icons);

    var fa_icons_css = document.createElement('style');
    fa_icons_css.setAttribute('type', 'text/css');
    fa_icons_css.innerHTML = ".comment-formatting, ul.actions { font-family: FontAwesome, Lucida Grande, Lucida Sans Unicode;}"
    document.getElementsByTagName('head')[0].appendChild(fa_icons_css);


    //If the user is in an empty bin, nothing will happen
    if (document.getElementById("wrangulator") == null) {
        return
    }

    //Grabbing the link connected to the edit button
    const actionsbuttons = document.getElementById("wrangulator").querySelectorAll("td > ul.actions")
    const array = a => Array.prototype.slice.call(a, 0)
    const get_url = function get_url(label) {
        // This will return the link if iconify is enabled
        const a = label.parentElement.parentElement.querySelector("ul.actions > li[title='Edit'] > a");
        if (a) {
            return a.href;
        }
        // If there's no iconify, we'll stick with the default path
        const buttons = label.parentElement.parentElement.querySelectorAll("ul.actions > li > a");
        return array(buttons).filter(b => b.innerText == "Edit")[0].href;
    }

    //Adding a comment button after the tag options
    for (const buttonset of actionsbuttons) {
        //UW Tag Snooze Buttons script support
        if (buttonset.querySelector('a').href == "") {
            continue
        }

        //And so begins our decent into madness
        //here be dragons, but I'll do my best to comment them all
        const newli = document.createElement("li")
        newli.title = "Add Comment"
        const button = document.createElement("a");
        button.style.textAlign = "center"

        //If the user has iconify set to be true, we'll show a very cute comment+ icon
        //so they can keep using iconify if they so wish, but there's not two identical icons
        //you are welcome, iconify users
        button.textContent = ICONIFY ? "\u{f086} \u{f067}" : "Comment";
        button.href = "#";
        newli.appendChild(button)

        buttonset.appendChild(newli)
        //If you want the "Works" button to be last, replace that with the following line:
        //buttonset.insertBefore(newli, buttonset.children[buttonset.childElementCount -2])

        //When any of the comment buttons have been clicked
        button.addEventListener("click", (e) => {
            e.preventDefault()

            //If there's already a comment box modal open, close out of it
            if (document.getElementById("commentbox_id") != null) {
                document.body.removeChild(document.getElementById("commentbox_id"))
            }

            //Creating the comment box modal
            const newdiv = document.createElement("div")
            newdiv.id = "commentbox_id"
            newdiv.style.position = "fixed"
            newdiv.style.top = "25%"
            newdiv.style.left = "25%"
            newdiv.style.width = "50%"
            if (darkmode) {
                //...heh
                newdiv.style.backgroundColor = "#696969"
            } else {
                newdiv.style.backgroundColor = "rgb(221, 221, 221)"
            }
            newdiv.style.border = "1px solid black"
            newdiv.style.padding = "5px"
            //the most important part of course ! ;)
            newdiv.style.borderRadius = "5px"

            //This chunk is for the text above the comment text box
            //the following set of divs and spans is SUCH a mess I KNOW I am so sorry i regret it too
            //But anyways I spent like three hours making this still be pretty when you make the webpage thinner or wider
            //so pls admire that at least once, just for me <3
            const titlediv = document.createElement("div")
            titlediv.setAttribute("style", "margin-bottom: 5px;");
            const newdivtitle = document.createTextNode("Comment on tag: ")
            const title = document.createElement("span")

            //Adding the tag's text and then becasue we are cool, making it a hyperlink
            const label = buttonset.parentElement.parentElement.firstElementChild.getElementsByTagName("label")[0]
            const tag_title = document.createElement("a")
            tag_title.target = "_blank"
            tag_title.innerText = label.innerText;
            tag_title.href = get_url(label)
            if (darkmode) {
                tag_title.style.color = "white"
            } else {
                tag_title.style.color = "cornflowerblue"
            }
            let pseud_id = null;
            title.appendChild(tag_title);
            title.style.fontStyle = "italic";
            titlediv.appendChild(newdivtitle)
            titlediv.appendChild(title)

            //The html formatting options we're offering - bold, italics, underline etc
            //a lot of that part was based on the AO3: Comment Formatting Options script by dusty
            //https://greasyfork.org/en/scripts/31400-ao3-comment-formatting-options

            //Feel free to customize the below to suit your wrangling needs!!!
            //The format is button_name: [["Tooltip", "Text on button or fontawesome icon number"], ["What shows up before selected text", "What shows up after selected text"]],
            //For example, try adding the following:
            //ffu: [["freeform for you", "FF"], ["Freeform for you: ", ""]]
            //Also add a comma after every line except for the last one!
            var commentFormatting = document.createElement("ul");
            var commentFormattingOptions = {
                bold_text: [["Bold", "\u{f032}"], ["<strong>", "</strong>"]],
                italic_text: [["Italic", "\u{f033}"], ["<em>", "</em>"]],
                underline_text: [["Underline", "\u{f0cd}"], ["<u>", "</u>"]],
                strike_text: [["Strikethrough", "\u{f0cc}"], ["<s>", "</s>"]],
                insert_link: [["Insert Link", "\u{f0c1}"], ['<a href="">', "</a>"]],
                insert_image: [["Insert Image", "\u{f03e}"], ['<img src="">']],
                blockquote_text: [["Blockquote", "\u{f10d}"], ["<blockquote>", "</blockquote>"]]
            }
            commentFormatting.id = "comment_formatting"
            commentFormatting.setAttribute("class", "actions comment-formatting");
            commentFormatting.setAttribute("style", "float: left; text-align: left; margin-bottom: 3px;");

            //Setting up each button for the html options we are offering
            for (let key in commentFormattingOptions) {
                var commentFormattingOptionItem = document.createElement("li");
                var commentFormattingOptionLink = document.createElement("a");

                commentFormattingOptionItem.setAttribute("class", key);
                commentFormattingOptionItem.setAttribute("title", commentFormattingOptions[key][0][0]);
                commentFormattingOptionItem.style.paddingLeft = "0px"
                commentFormattingOptionItem.style.paddingRight = "2px"
                commentFormattingOptionItem.style.fontSize = "80%"
                commentFormattingOptionItem.style.margin = "0"

                commentFormattingOptionLink.textContent = commentFormattingOptions[key][0][1];
                commentFormattingOptionLink.setAttribute("style", "margin: 1px;");

                commentFormattingOptionItem.appendChild(commentFormattingOptionLink);
                commentFormatting.appendChild(commentFormattingOptionItem);

                //the actual magic when you click each html options button
                commentFormattingOptionLink.addEventListener("click", (e) => {
                    e.preventDefault()

                    //the beginning and the end of the text the user is highlighting, and the value of that text
                    var caretPos = commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").selectionStart;
                    var caretEnd = commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").selectionEnd;
                    var textAreaTxt = commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").value;

                    var formatToAdd
                    var highlightingtext

                    if (caretPos == caretEnd) {
                        //if the user isn't highlighting any text (ie their cursor is just chilling)
                        formatToAdd = commentFormattingOptions[key][1].join("");
                        highlightingtext = false
                    } else {
                        //if the user is highlighting text
                        var textAreaHighlight = textAreaTxt.slice(caretPos, caretEnd);
                        formatToAdd = commentFormattingOptions[key][1].join(textAreaHighlight);
                        highlightingtext = true
                    }

                    //adding the html formatting!!
                    commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").value = textAreaTxt.substring(0, caretPos) + formatToAdd + textAreaTxt.substring(caretEnd);
                    commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").focus();

                    //this took a hot minute to figure out how to do
                    if (highlightingtext) {
                        //If the user is highlighting text (ie they want to bold the word 'thing'), the cursor will move to after the closing html tag
                        //so they can just continue typing the next word
                        commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").selectionStart = caretEnd + commentFormattingOptions[key][1][0].length + commentFormattingOptions[key][1][1].length
                        commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").selectionEnd = caretEnd + commentFormattingOptions[key][1][0].length + commentFormattingOptions[key][1][1].length
                    } else {
                        //if the user is not highlighting text, we'll put the cursor in the middle of the html tags
                        //so that they can type what it is they want bolded, italicized etc
                        commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").selectionStart = caretPos + commentFormattingOptions[key][1][0].length
                        commentFormattingOptionLink.parentElement.parentElement.parentElement.querySelector("TextArea").selectionEnd = caretPos + commentFormattingOptions[key][1][0].length
                    }
                });
            }

            //The textbox the user can type their comment in
            const textinput = document.createElement("textarea");
            textinput.style.width = "98%"
            textinput.style.height = "250px"
            textinput.style.display = "block"
            textinput.style.resize = "none"
            textinput.style.marginTop = "5px"
            //again, the most important part ! ;)
            textinput.style.borderRadius = "3px"

            //the cancel/save button part
            const buttondiv = document.createElement("div")
            const savebutton = document.createElement("button");
            savebutton.style.textAlign = "center"

            //OK SO THIS WAS!!! A PAIN!!! AND A HALF!!!! TO FIGURE OUT!!!!!!!!!
            //But!!!! The tag ID that we have immediate access to is NOT the same as the tag ID  wanted in the POST request to actually send the comment!!!!
            //So we have to go grab the correct tag ID
            //BUT! that takes a small amount of time
            //SO! we make the 'comment' button say 'Loading' until that ID is figured out (and also disable that button)
            //then afterwords we change it to say "comment"!
            //the actual place we grab the correct tag ID is a bit later, just wanted to explain why it starts as 'Loading' here
            savebutton.textContent = "Loading...";
            savebutton.disabled = true;

            //When the save button is clicked
            savebutton.addEventListener("click", (e) => {
                savebutton.disabled = true;

                //don't want empty comments
                if (textinput.value.length == 0) {
                    alert("Brevity is the soul of wit, but we need your comment to have text in it.")
                    //We re-enable the button after any error message shows up so that the user can edit their comment and attempt to do better
                    savebutton.textContent = "Comment";
                    savebutton.disabled = false;
                    return
                }

                //THIS WAS ANOTHER PAIN AND A HALF TO FIGURE OUT, MY GOD
                //So basically, when we submit the comment
                //The character count doesn't include the paragraph tags: <p> and </p>
                //So something that is one paragraph and the maximum of 10000 characters is actually 10007 characters and will make the surver angry at us
                //So what we do, is grab the number of paragraphs in the user's text
                //and multiply that by 7 (the character count of each '<p></p>' that is added)
                //then we add THAT to the length of the user text
                //and BOOM!!! the actual length of what we are submitting
                //so now we can accurately tell the user if their text is too long
                var paragraphhtmllen = textinput.value.replace(/\n$/gm, '').split(/\n/).length * 7;
                var textinputlengthactual = textinput.value.length + paragraphhtmllen
                if (textinputlengthactual >= 10000) {
                    alert("Comment is too long; please restrict to 10000 characters or less, including <p></p> tags.")
                    savebutton.textContent = "Comment";
                    savebutton.disabled = false;
                    return
                }

                //what actually submits the comment!!
                const xhr2 = new XMLHttpRequest();
                xhr2.onreadystatechange = function xhr_onreadystatechange() {
                    if (xhr2.readyState == xhr2.DONE) {
                        if (xhr2.status == 200) {
                            //So we can get a 200 OK status but still have an error !!!!!!!
                            //So we check if the response has an error in it
                            //And if so, pass the error up to the user
                            let error = xhr2.responseXML.documentElement.querySelector("#error")
                            if (error) {
                                alert(error.innerText);
                                savebutton.textContent = "Comment";
                                savebutton.disabled = false;
                            } else {
                                //happy path!!
                                //Change the button text to say 'commented' to show the user that their comment was submitted
                                //Then remove the comment modal after half a second
                                savebutton.textContent = "Commented!";
                                setTimeout(function(){
                                    if (newdiv.parentElement != null) {
                                        document.body.removeChild(newdiv)
                                    }
                                }, 500);
                            }
                        } else if (xhr2.status == 429) {
                            // go to ao3 jail do not pass go do not collect $200
                            // honestly tho if anyone ever submits so many comments that they'd get rate limited
                            // i'd just be impressed
                            alert("Rate limited. Sorry :(")
                        } else {
                            // .....less happy path
                            alert("Error - check console for details.")
                            console.log(xhr2)
                        }
                    }
                }

                //grabbing everything that we need in order to post the comment
                //for exampe, what's in the textfield
                const fd = new FormData()
                fd.set("comment[comment_content]", textinput.value)
                fd.set("tag_id", buttonset.parentElement.parentElement.firstElementChild.getElementsByTagName("label")[0].innerText);
                fd.set("controller_name", "comments")
                fd.set("comment[pseud_id]", pseud_id)

                //Copy auth token from the current page
                fd.set("authenticity_token", document.getElementsByName("authenticity_token")[0].value)
                xhr2.open("POST", "/comments")
                xhr2.responseType = "document"

                //And off we go!
                xhr2.send(fd)
                savebutton.textContent = "Commenting...";
            })

            //The cancel button
            const cancelbutton = document.createElement("button");
            cancelbutton.style.textAlign = "center"
            cancelbutton.textContent = "Cancel";
            cancelbutton.style.marginRight = "5px"

            //When the user clicks 'cancel,' we close out of the comment box
            cancelbutton.addEventListener("click", (e) => {
                if (newdiv.parentElement != null) {
                    document.body.removeChild(newdiv)
                }
            })

            //Adding cancel/save buttons to the same div and right justifying them
            buttondiv.appendChild(cancelbutton)
            buttondiv.appendChild(savebutton)
            buttondiv.style.textAlign = "right"
            buttondiv.style.marginTop = "5px"

            //Adding everything to the comment popup!!
            newdiv.appendChild(titlediv)
            newdiv.appendChild(commentFormatting)
            newdiv.appendChild(textinput)
            newdiv.appendChild(buttondiv)

            //This is the bizzare thing we have to do in order to get the ACTUAL tag ID that we need
            //when submitting the comment - see comments above savebutton.textContent lines for more details
            const xhr = new XMLHttpRequest();
            xhr.onreadystatechange = function xhr_onreadystatechange() {
                if (xhr.readyState == xhr.DONE ) {
                    if (xhr.status == 200) {
                        //THIS WAS AN ABSOLUTE PAIN
                        //AN. ABSOLUTE. PAIN.
                        //AN ABSOLUTE PAIN!!!!!!!! to figure out
                        //But the page is actually different if the user commenting has a pseud:
                        //If a user has pseuds, they'll see a dropdown menu (a "select" element) - if they don't, there is a hidden "input" element.
                        // the * will catch them both!
                        const pseud_id_elem = xhr.responseXML.documentElement.querySelector("*[name='comment[pseud_id]']")
                        pseud_id = pseud_id_elem.value
                        if (pseud_id_elem.tagName == "SELECT") {
                            //Makes a dropdown menu that lets the user select which pseud to comment from
                            const options = pseud_id_elem.options
                            const select = document.createElement("select")
                            array(options).forEach(o => {
                                const option = document.createElement("option")
                                option.value = o.value
                                option.innerText = o.innerText
                                select.prepend(option);
                            });
                            select.value = pseud_id_elem.value;
                            select.addEventListener("change", () => {
                                pseud_id = select.value;
                            });
                            commentFormatting.appendChild(select)
                        }
                        savebutton.textContent = "Comment";
                        savebutton.disabled = false;
                    } else {
                        alert("Something broke, sorry :( - check the console")
                        console.log(xhr)
                    }
                }
            }
            const comments_url = get_url(label).replace(/\/edit$/, "/comments")
            xhr.open("GET", comments_url)
            xhr.responseType = "document"
            xhr.send()

            document.body.appendChild(newdiv)

            //After the modal pops up, start with the textfield selected so you can type right away
            newdiv.querySelector("textarea").select()
        })
    }
    // Your code here...
})();