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

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

// ==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...
})();