// ==UserScript==
// @name AO3 Review + Last Chapter Shortcut + Kudos-sortable Bookmarks
// @namespace saxamaphone
// @version 2.4
// @description Adds shortcuts for last chapter and a floaty review box, and bookmark sorting by kudos and filtering by complete only. This script is maintained by Fangirlishness with permission from saxamaphone.
// @author saxamaphone, Fangirlishness
// @require http://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js
// @require http://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js
// @match http://archiveofourown.org/*
// @match https://archiveofourown.org/*
// @exclude http://archiveofourown.org/*/edit
// @exclude https://archiveofourown.org/*/edit
// @exclude http://archiveofourown.org/*/new
// @exclude https://archiveofourown.org/*/new
// @grant none
// ==/UserScript==
// From http://stackoverflow.com/a/1909997/584004
(function (jQuery, undefined) {
jQuery.fn.getCursorPosition = function() {
var el = jQuery(this).get(0);
var pos = 0;
if('selectionStart' in el) {
pos = el.selectionStart;
} else if('selection' in document) {
el.focus();
var Sel = document.selection.createRange();
var SelLength = document.selection.createRange().text.length;
Sel.moveStart('character', -el.value.length);
pos = Sel.text.length - SelLength;
}
return pos;
};
})(jQuery);
// From http://stackoverflow.com/a/841121/584004
(function (jQuery, undefined) {
jQuery.fn.selectRange = function(start, end) {
if(end === undefined) {
end = start;
}
return this.each(function() {
if('selectionStart' in this) {
this.selectionStart = start;
this.selectionEnd = end;
} else if(this.setSelectionRange) {
this.setSelectionRange(start, end);
} else if(this.createTextRange) {
var range = this.createTextRange();
range.collapse(true);
range.moveEnd('character', end);
range.moveStart('character', start);
range.select();
}
});
};
})(jQuery);
// From http://stackoverflow.com/questions/11582512/how-to-get-url-parameters-with-javascript/11582513#11582513, modified to allow [] in params
function getURLParameter(name) {
return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search.replace(/\[/g, '%5B').replace(/\]/g, '%5D')) || [null, ''])[1].replace(/\+/g, '%20')) || null;
}
function getStoryId()
{
var aMatch = window.location.pathname.match(/works\/(\d+)/);
if(aMatch !== null)
return aMatch[1];
else
return jQuery('#chapter_index li form').attr('action').match(/works\/(\d+)/)[1];
}
function getBookmarks(sNextPath, aBookmarks, oDeferred) {
jQuery.get(sNextPath, function(oData) {
aBookmarks = jQuery.merge(aBookmarks, jQuery(oData).find('li.bookmark'));
if(jQuery(oData).find('.next a').length) {
getBookmarks(jQuery(oData).find('.next').first().find('a').attr('href'), aBookmarks, oDeferred);
}
else {
jQuery("#sortable_bookmarks_loading").remove();
oDeferred.resolve();
}
});
}
jQuery(window).ready(function() {
// Process bookmarks first because of extra sorting steps. Once this is done, handle everything else
var oBookmarksProcessed = jQuery.Deferred();
// If on the bookmarks page, add option to sort by kudos
if(window.location.pathname.indexOf('/bookmarks') != -1)
{
// Wait to handle the bookmarks after they're loaded
var oBookmarksLoaded = jQuery.Deferred();
var bKudos = false, bComplete = false;
// If the search/sort/submit button is clicked and kudos are selected, change selection and save the value in local storage before calling the search
jQuery("form#bookmark-filters").find(':submit').click(function(e) {
if(jQuery('#bookmark_search_sort_column').val() == 'kudos_count') {
jQuery('#bookmark_search_sort_column').val('created_at');
localStorage.setItem('sort_by_kudos', 'true');
}
});
// Add options for Kudos sorting and Complete works only
jQuery('#bookmark_search_sort_column').append('<option value="kudos_count">Kudos</option>');
jQuery('#bookmark_search_with_notes').parent().parent().after(
//'<dt>Status</dt><dd><input id="work_search_complete" name="work_search[complete]" type="checkbox" value="1"/><label for="work_search_complete">Complete only</label></dd>');
'<li><dt>Status</dt><dd>' +
'<label for="work_search_complete">' +
'<input type="checkbox" value="1" name="work_search[complete]" id="work_search_complete">' +
'<span class="indicator" aria-hidden="true"></span><span>Complete only</span></label></dd></li>');
if(localStorage.getItem('sort_by_kudos') == 'true')
{
jQuery('#bookmark_search_sort_column').val('kudos_count');
localStorage.removeItem('sort_by_kudos');
bKudos = true;
}
if(getURLParameter('work_search%5Bcomplete%5D') == '1')
{
jQuery('#work_search_complete').attr('checked', 'checked');
bComplete = true;
}
// If either option has been selected, we perform our own process
if(bKudos || bComplete)
{
// Get bookmarks, this takes at least a few seconds so we have to wait for that to finish
var aBookmarks = [];
jQuery("ol.pagination").before('<div id="sortable_bookmarks_loading">(Loading...)</div>');
getBookmarks(window.location.href.replace(/&page=\d+/, '').replace(/&bookmark_search%5Bsort_column%5D=kudos_count/, ''), aBookmarks, oBookmarksLoaded);
jQuery.when(oBookmarksLoaded).done(function () {
if(bKudos)
{
// window.location.href.replace(/&sort_by_kudos/, '');
aBookmarks.sort(function(oA, oB) {
return (parseInt(jQuery(oB).find('dd.kudos').find('a').html()) || 0) - (parseInt(jQuery(oA).find('dd.kudos').find('a').html()) || 0);
});
}
if(bComplete)
{
jQuery.each(aBookmarks, function(iArrayIndex) {
var sChapters = jQuery(this).find('dd.chapters').html();
if(sChapters !== undefined)
{
var aChapters = sChapters.split('\/');
if(aChapters[0] != aChapters[1])
aBookmarks.splice(iArrayIndex, 1);
}
else if (jQuery(this).find('.stats').length === 0)
aBookmarks.splice(iArrayIndex, 1);
});
}
var iPage = getURLParameter('page');
if(iPage === null)
iPage = 1;
jQuery('li.bookmark').remove();
var iIndex;
var iNumBookmarks = aBookmarks.length;
for(iIndex = (iPage-1) * 20; iIndex < (iPage*20) && iIndex < iNumBookmarks; iIndex++)
{
jQuery('ol.bookmark').append(aBookmarks[iIndex]);
}
// If bookmarks are limited by Complete, change the number displayed
if(bComplete)
{
var sPrevHeading = jQuery('h2.heading').html();
jQuery('h2.heading').html(sPrevHeading.replace(/\d+ - \d+ of \d+/, (iPage-1)*20+1 + ' - ' + iIndex + ' of ' + aBookmarks.length));
// Repaginate if necessary
var iFinalPage = jQuery('ol.pagination').first().find('li').not('.previous, .next').last().text();
var iNewFinalPage = Math.ceil(iNumBookmarks/20);
if(iFinalPage > iNewFinalPage)
{
// Rules for AO3 pagination are way too complicated for me to bother replicating, so just going to remove extra pages
var aPageLinks = jQuery('ol.pagination').first().find('li');
jQuery('ol.pagination').find('li a').each(function () {
if(jQuery.isNumeric(jQuery(this).text()) && jQuery(this).text() > iNewFinalPage)
jQuery(this).parent().remove();
});
// Deactivate the last Next link if necessary
if(iPage == iNewFinalPage)
jQuery('ol.pagination').find('li.next').html('<li class="next" title="next"><span class="disabled">Next →</span></li>');
}
}
oBookmarksProcessed.resolve();
});
}
else
oBookmarksProcessed.resolve();
}
else
oBookmarksProcessed.resolve();
jQuery.when(oBookmarksProcessed).done(function() {
// Check if you're on a story or a list
// If not a story page, presume an index page (tags, collections, author, bookmarks, series) and process each work individually
if(jQuery('.header h4.heading').length)
{
// Near as I can figure, the best way of identifying actual stories in an index page is with the h4 tag with class 'heading' within a list of type 'header'
jQuery('.header h4.heading').each(function() {
var sStoryPath = jQuery(this).find('a').first().attr('href');
var oHeader = this;
// If link is from collections, get proper link
var aMatch = sStoryPath.match(/works\/(\d+)/);
if(aMatch !== null)
{
var iStoryId = aMatch[1];
jQuery.get('/works/' + iStoryId + '/navigate', function(oData) {
var sLastChapterPath = jQuery(oData).find('ol li').last().find('a').attr('href');
jQuery(oHeader).append('<a href="' + sLastChapterPath +'" title="Jump to last chapter"> »</a>');
});
}
});
}
// Review box and last chapter buttons are story-specific
else if(jQuery('ul.work') && !jQuery('ul.index').length)
{
// HTML to define layout of popup box
// Include x button to close box
var sHtml = '<p class="close actions" id="close_floaty"><a aria-label="cancel" style="display: inline-block;">×</a></p>';
// Button to insert highlighted text and for a help list
sHtml += '<ul class="actions" style="float: left; margin-top: 10px;"><li id="insert_floaty_text"><a>Insert</a></li><li id="pop_up_review_tips"><a>Review Tips</a></li></ul>';
// Textarea
sHtml += '<textarea style="margin: 5px; width: 99%;" id="floaty_textarea"></textarea>';
// Create popup box
jQuery("<div/>", {
id: "reviewTextArea",
width:600, // Change for dimensions
height:300, // Change for dimensions
css: {
backgroundColor:"#ffffff",
opacity: 0.75,
border: "thin solid black",
display: "inline-block",
"padding-right": 10,
position: "fixed",
top: 150,
right: 5
},
html: sHtml
}).resizable().draggable().appendTo("body");
// Hide the popup box by default (comment out line below if you want it to always appear by adding // before it)
jQuery('#reviewTextArea').hide();
// To close the box
jQuery('#close_floaty').click(function() {
jQuery('#reviewTextArea').hide();
});
// Anything you type in the box gets inserted into the real comment box below
jQuery('#floaty_textarea').on('input', function() {
jQuery('.comment_form').val(jQuery('#floaty_textarea').val());
});
// Add Float review box button to the top
jQuery('ul.work').prepend('<li id="floaty_review_box"><a>Floaty Review Box</a></li>');
// If the above button is clicked, display the review box
jQuery('#floaty_review_box').click(function() {
jQuery('#reviewTextArea').show();
});
// Insert highlighted/selected text into textarea when Insert button is clicked
jQuery('#insert_floaty_text').click(function() {
var sInitialText = jQuery('#floaty_textarea').val();
var iPosition = jQuery('#floaty_textarea').getCursorPosition();
var sHighlightedText = window.getSelection().toString();
var sNewText = sInitialText.substr(0, iPosition) + '<i>"' + sHighlightedText + '"</i>\n' + sInitialText.substr(iPosition);
jQuery('#floaty_textarea').val(sNewText);
jQuery('#floaty_textarea').focus();
jQuery('#floaty_textarea').selectRange(iPosition+sHighlightedText.length+10);
// Copy into real comment box
jQuery('.comment_form').val(jQuery('#floaty_textarea').val());
});
// Create the review tips box
sReviewTipsHtml = '<p class="close actions" id="close_review_tips"><a aria-label="cancel" style="display: inline-block;">×</a></p>' +
'Writers will love any love you give them. If you're looking for things to help jumpstart a review, there are lots of different things you could focus on.<br />' +
'<ul><li>Quotes you liked</li><li>Scenes you liked</li><li>What's your feeling at the end of the chapter (did it move you?)</li><li>What are you most looking forward to next?</li>' +
'<li>Do you have any predictions for the next chapters you want to share?</li><li>Did this chapter give you any questions you can't wait to find out the answers for?</li>' +
'<li>How would you describe the fic to a friend if you were recommending it?</li><li>Is there something unique about the story that you like?</li><li>Does the author have a style that really works for you?</li>' +
'<li>Did the author leave any comments in the notes that said what they wanted feedback on?</li>' +
'<li>Even if all you have are "incoherent screams of delight", and can't come up with a real comment at the moment, authors love to hear that as well</li></ul>';
jQuery("<div/>", {
id: "reviewTips",
width:600, // Change for dimensions
height:300, // Change for dimensions
css: {
backgroundColor:"#ffffff",
border: "thin solid black",
'font-size': '80%',
padding: '10px 10px 0 10px',
position: "fixed",
top: 150,
right: 620
},
html: sReviewTipsHtml
}).resizable().draggable().appendTo("body");
jQuery('#reviewTips li').css('list-style', 'circle inside none');
jQuery('#reviewTips').hide();
// Pop up list of review tips
jQuery('#pop_up_review_tips').click(function() {
jQuery('#reviewTips').show();
});
jQuery('#close_review_tips').click(function() {
jQuery('#reviewTips').hide();
});
// Before adding button for Last Chapter, make sure we're not on the last (or only) chapter already
if(jQuery('.next').length)
{
// Add button for Last Chapter
jQuery('ul.work').prepend('<li id="go_to_last_chap"><a>Last Chapter</a></li>');
// If the above button is clicked, go to last chapter
jQuery('#go_to_last_chap').click(function() {
window.location.href = '/works/' + getStoryId() + '/chapters/' + jQuery('#selected_id option').last().val();
});
}
// Adding a First Chapter button
if(jQuery('.previous').length)
{
// Add button for First Chapter
jQuery('ul.work').prepend('<li id="go_to_first_chap"><a>First Chapter</a></li>');
// If the above button is clicked, go to first chapter
jQuery('#go_to_first_chap').click(function() {
window.location.href = '/works/' + getStoryId();
});
}
}
});
});