// ==UserScript==
// @name AO3: [Wrangling] View and Post Comments from the Bin
// @namespace https://greasyfork.org/en/users/906106-escctrl
// @description Loads a preview of top-level comments (such as translations) and lets you comment on the tag
// @author escctrl
// @version 2.5
// @match *://*.archiveofourown.org/tags/*/wrangle?*
// @grant none
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @require https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
// @require https://update.greasyfork.icu/scripts/491888/1355841/Light%20or%20Dark.js
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/532650/AO3%3A%20%5BWrangling%5D%20View%20and%20Post%20Comments%20from%20the%20Bin.user.js
// @updateURL https://update.greasyfork.icu/scripts/532650/AO3%3A%20%5BWrangling%5D%20View%20and%20Post%20Comments%20from%20the%20Bin.meta.js
// ==/UserScript==
/* eslint-disable no-multi-spaces */
/* global jQuery, lightOrDark */
(function($) {
'use strict';
if ($('#wrangulator').length === 0) return; // bow out in an empty bin
/***** INITIALIZE THE COMMENTS DIALOG *****/
let dlg = "#peekTopLevelCmt";
// prepare the HTML framework within the new dialog including the textarea
$("#main").append(`
`);
// adding the jQuery stylesheet to style the dialog, and fixing the interference of AO3's styling
if(document.head.querySelector('link[href$="/jquery-ui.css"]') === null) {
// if the background is dark, use the dark UI theme to match
let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "dark-hive" : "base";
$("head").append(``);
}
$("head").append(``);
// optimizing the size of the GUI in case it's a mobile device
let dialogwidth = window.visualViewport.width;
dialogwidth = dialogwidth > 700 ? 700 : dialogwidth * 0.9;
let dialogheight = window.visualViewport.height;
dialogheight = dialogheight * 0.7;
$(dlg).dialog({
appendTo: "#main",
modal: false,
autoOpen: false,
resizable: false,
width: dialogwidth,
maxHeight: dialogheight,
title: "Tag Comment Threads",
close: function() {
// reset everything including the data-tagname attribute
resetDialog();
$(dlg).find('textarea')[0].dataset.tagname = "";
$(dlg).data('openedOnTag', "");
}
});
// we add a button the Manage header to load all comments on this page at once
$('#wrangulator').find('thead th').filter(function(ix, el) { return $(this).text() === "Manage"; })
.append(` `);
/***** BUTTON EVENTS *****/
// when user clicks the "Load all Comments" button
$('#wrangulator').on('click','#load-toplvlcmt', function(e) {
e.preventDefault();
loadAllTopLevelComments();
});
// we co-opt the /comments link and instead of opening the page, we give a little additional dialog
$('#wrangulator').on('click','a[href$="/comments"]', async function(e) {
e.preventDefault();
let row = $(e.target).parents('tr')[0];
if (row.dataset.cmttoptevel === "unknown") {
let resolved = await loadTopLevelComments(row); // if we have no data for this tag yet, we load it
if (resolved === "failed") { // if XHR failed, we have probably encountered a 403 or 500 error. don't open the dialog
alert('Top Level Comments could be loaded, we encountered errors trying to load the Comment page.');
return;
}
}
$(dlg).find('textarea').prop('value', ''); // empty the textbox content from whatever was there before
viewTopLevelComments(e.currentTarget); // then we write out the dialog
});
// not a button event, but if the window resizes the dialog would move off of the screen
$(window).on('resize', function(e) {
// optimizing the size of the GUI in case it's a mobile device
let dialogwidth = window.visualViewport.width;
dialogwidth = dialogwidth > 700 ? 700 : dialogwidth * 0.9;
let dialogheight = window.visualViewport.height;
dialogheight = dialogheight * 0.7;
let target = $(dlg).dialog("option", "position").of; // find the current target button
// reposition and resize the dialog
$(dlg).dialog("option", "position", { my: "left top", at: "left bottom", of: target } )
.dialog("option", "width", dialogwidth)
.dialog("option", "maxHeight", dialogheight);
});
/***** RETRIEVE TOP-LEVEL COMMENTS *****/
// on pageload, silently loop over all tags on the page
// check if we already have the tag stored
// grab the user's pseuds from storage
let pseuds = JSON.parse(sessionStorage.getItem("cmt_pseud") || null);
$(dlg).find('#pseud-toplvlcmt').html(pseuds);
// after a couple of seconds we can be reasonably sure other scripts would have added their comments buttons
let timeout = setTimeout(() => {
let cmtButtonExists = $('#wrangulator').find('a[href$="/comments"]').length;
if (!cmtButtonExists) console.log('added Comments buttons after 2 sec because none were present from other scripts. if your script is slower, increase the wait period.');
let rows = $('#wrangulator').find('tbody tr').toArray();
for (let row of rows) {
if (!cmtButtonExists) {
// failsafe: if there are no comment buttons from another script, add them
let edit = $(row).find('a[href$="/edit"]').last();
$(edit).parent().after(`
`);
}
// retrieve what we already have in storage for this tag
let tagname = $(row).find('th label').text();
let cmtTopLevel = JSON.parse(sessionStorage.getItem("cmt_" + tagname) || null);
if (cmtTopLevel !== null) { // if we found something in storage
cmtTopLevel = new Map(cmtTopLevel);
// and then we should put it somewhere so we know not to ask again on click
row.dataset.cmttoptevel = "stored";
}
else row.dataset.cmttoptevel = "unknown";
}
}, 2 * 1000); // << WAIT PERIOD FOR OTHER SCRIPTS TO HAVE ADDED COMMENT BUTTONS. increase the standard 2 to 5 if your script is slower
async function loadAllTopLevelComments() {
$('#load-toplvlcmt').attr('disabled', true) // stop button from being clicked again
.find('.spin').css('display', 'inline-block'); // loading indicator
let rows = $('#wrangulator').find('tbody tr').toArray();
let rowsToDo = $(rows).filter( function() { return this.dataset.cmttoptevel === "unknown"; } ).toArray(); // only worry about not stored items
// when clicking the button, loop over all tags on the page
for (let row of rowsToDo) {
let resolved = await loadTopLevelComments(row); // grab the top level comments from the page
if (resolved === "loaded") await waitforXSeconds(2); // if XHR succeeded, creates a x-seconds wait period between function calls
else { // if XHR failed, we have probably encountered a 403 or 500 error. don't try more pages
alert('Not all Top Level Comments could be loaded, we encountered errors trying to load the Comment pages.');
break;
}
}
$('#load-toplvlcmt').text('All Comments Loaded') // change text
.find('.spin').css('display', 'none'); // remove loading indicator
}
function waitforXSeconds(x) {
return new Promise((resolve) => {
setTimeout(() => {
resolve("");
}, x * 1000);
});
}
/***** COMMON FUNCTION FOR BACKGROUND PAGELOADS *****/
function loadTopLevelComments(row) {
return new Promise((resolve) => {
let target = $(row).find('[href$="/comments"]')[0];
let tagname = $(row).find('th label').text();
let xhr = $.ajax({ url: $(target).prop('href'), type: 'GET' })
.fail(function(xhr, status) {
console.warn(`Top level comments for ${tagname} could not be loaded due to response error:`, status);
resolve("failed");
}).done(function(response) {
// in case we're served a code:200 page that doesn't actually contain the comments page, we quit
if ($(response).find('#feedback').length === 0) {
console.warn(`Top level comments for ${tagname} could not be loaded because response didn't contain the #feedback`);
resolve("failed"); return;
}
// grab this user's possible pseuds and store it in session
pseuds = $(response).find('#add_comment h4.heading')[0].outerHTML;
sessionStorage.setItem("cmt_pseud", JSON.stringify(pseuds));
let cmtStorage = parseTopLevelComments(response);
sessionStorage.setItem("cmt_" + tagname, JSON.stringify([...cmtStorage])); // store all comments in session
row.dataset.cmttoptevel = "stored"; // set this tag from unknown to stored, so we don't try to load it again
resolve("loaded");
});
});
}
function parseTopLevelComments(response) {
// grab top level comments
let cmtTopLevel = $(response).find('#comments_placeholder > ol.thread > li.comment').toArray();
// we'll store the comments in a Map to ensure insertion order while being able to later use an object key to reference the item
let cmtStorage = new Map();
for (let [i, v] of cmtTopLevel.entries()) {
let commentID = $(v).find('li[id^="edit_comment_link_"')?.prop('id') || ""; // grab the CSS ID off the Edit button (our comment if it exists)
commentID = commentID === "" ? 'c'+i : commentID.match(/\d+/)[0]; // if there's no ID, just count to 20, otherwise grab the commentID
let by = $(v).find('.byline a').text(); // name of the user who posted the comment
let content = $(v).find('.userstuff').html().trim(); // content of the post
cmtStorage.set(commentID, [by, content]); // put the comment into our storage
}
return cmtStorage;
}
/***** DISPLAY THE COMMENTS DIALOG *****/
function viewTopLevelComments(target) {
let tagname = $(target).parents('tr').first().find('th label').text();
let taglink = $(target).prop('href');
$(dlg).dialog("option", "title", tagname ); // dialog title shows tagname for which it was opened
$(dlg).data('openedOnTag', target); // store button from which this dialog was opened
// create a link to the plain Comments page
let iconExternalLink = ``;
iconExternalLink = `Open Comment Page ${iconExternalLink}`;
$('#toplvlcmt-pagelink').html(iconExternalLink);
// create the preview of the first 20 comments
let comments = new Map(JSON.parse(sessionStorage.getItem("cmt_" + tagname)));
let content = (comments.size === 0) ? `
There are no comments on this tag yet.
` : "";
for (let [id, comment] of comments.entries()) {
let editButton = !id.startsWith('c') ? ` ` : "";
content += `
by ${ comment[0] }${ editButton }
${ comment[1] }
`;
}
$(dlg).find('#toplvlcmt-placeholder').html(content); // write comments to the dialog
// a textbox to leave a new comment - works with the "Comment Formatting & Preview" script
let cmtNew = $(dlg).find('#add-toplvlcmt textarea')[0];
cmtNew.dataset.tagname = encodeURIComponent(tagname);
cmtNew.dataset.commentid = "";
$(dlg).find('#pseud-toplvlcmt').html(pseuds);
$(dlg).dialog("option", "position", { my: "left top", at: "left bottom", of: target } ) // position the dialog at the clicked button
.dialog('open'); // finally, open the dialog
}
/***** SUBMIT A NEW COMMENT *****/
$('#main').on('click', `${dlg} button.submitComment`, function(e) {
e.preventDefault();
$(dlg).find('.submitComment').attr('disabled', true) // stop button from being clicked again
.find('.spin').css('display', 'inline-block').html("(submitting)"); // loading indicator
let tagname = decodeURIComponent($(dlg).find('textarea').attr('data-tagname'));
let target = $(dlg).data('openedOnTag');
// collect various input for commenting
let cmtData = new FormData();
cmtData.set('comment[comment_content]', $(dlg).find('textarea').prop('value'));
cmtData.set('comment[pseud_id]', $(dlg).find('[name="comment[pseud_id]"]').prop('value')); // either a hidden or a