Bookmark a work directly from any page and go back from bookmarking to browsing quickly
// ==UserScript==
// @name AO3 Bookmark Improver
// @namespace http://tampermonkey.net/
// @version 1.2
// @license MIT
// @description Bookmark a work directly from any page and go back from bookmarking to browsing quickly
// @author sunkitten_shash
// @match https://archiveofourown.org/*
// @match http://archiveofourown.org/*
// @require https://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// ==/UserScript==
// Much of the popup settings code heavily references BrickGrass' Blanket Permission Highlighter
// (https://github.com/BrickGrass/Blanket-Permission-Highlighter)
// TODO: better working timeouts
// catching bookmarks on update
// also catching bookmarks in the background when you're viewing an already bookmarked work
// also possibly querying for them when saving new one?
const USE_SAVED_SCROLL_POS = true;
(function () {
"use strict";
// ---HTML AND CSS---
// Styles for settings menu
const css = `
#bookmark-settings {
position: fixed;
z-index: 21;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
}
#bookmark-settings-content {
background-color: #fff;
color: #2a2a2a;
margin: 10% auto;
padding: 1em;
width: 500px;
}
#bookmark-settings-content form {
margin: 1em auto;
}
#bookmark-settings a {
color: #111;
}
#bookmark-settings a:hover {
color: #999;
}
#bookmark-settings .progress {
color: green;
font-size: .75rem;
}
#bookmark-settings button {
background: #eee;
color: #444;
width: auto;
font-size: 100%;
line-height: 1.286;
height: 1.286em;
vertical-align: middle;
display: inline-block;
padding: 0.25em 0.75em;
white-space: nowrap;
overflow: visible;
position: relative;
text-decoration: none;
border: 1px solid #bbb;
border-bottom: 1px solid #aaa;
background-image: -moz-linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
background-image: -webkit-linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
background-image: -o-linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
background-image: -ms-linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
background-image: linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
border-radius: 0.25em;
box-shadow: none;
}
@media only screen and (max-width: 625px) {
#bookmark-settings-content {
width: 80%;
}
}`;
const bookmark_settings_html = `
<div id="bookmark-settings">
<div id="bookmark-settings-content">
<h2>Ao3 Bookmark Improver Settings</h2>
<br><br>
<button id="bookmark-update">Update bookmarks list</button>
<p>For if you have created a lot of bookmarks in a different browser or when not running this script</p><br>
<button id="bookmark-clear">Clear bookmarks list</button>
<p>Clear your entire bookmarks list, for if you have deleted a lot of bookmarks, switched users, or the list seems wrong</p>
<button id="bookmark-settings-close">Close</button>
</div>
</div>`;
// ---GLOBAL VARIABLES---
// variable that saves the scroll position on a page
var scrollPos = 0;
// cutoff variable for updating bookmarks
let endOfPage = false;
// abstracting out getting/setting the bookmark id lists into functions doesn't work
// therefore their names are global variables for easier switching to new variables
let workListName = "bookmarkWorkIds";
let bookmarkListName = "bookmarkBookmarkIds";
// ---SETTINGS PAGE CODE---
GM.registerMenuCommand("AO3 Bookmark Improver Settings", function () {
const settings_menu_exists = $("#bookmark-settings").length;
if (settings_menu_exists) {
console.log("settings already open");
return;
}
$("body").prepend(bookmark_settings_html);
$("#bookmark-update").click(updateBookmarkList);
$("#bookmark-clear").click(clearBookmarks);
$("#bookmark-settings-close").click(settings_close);
});
// close the settings dialog
function settings_close() {
$("#bookmark-settings").remove();
window.location.reload();
}
// clear your entire bookmarks list
async function clearBookmarks() {
GM.setValue(workListName, []);
GM.setValue(bookmarkListName, []);
console.log("clear bookmarks");
$("#bookmark-clear").after(
`<p id="bookmark-clear-feedback" class="progress">Bookmark list cleared!</p>`,
);
await new Promise((resolve) => setTimeout(resolve, 5000));
$("#bookmark-clear-feedback").remove();
}
async function getNewBookmarks(
page,
bookmarkWorkIds,
bookmarkBookmarkIds,
newBookmarksNum = 0,
) {
let workLinks = $(
"li[role=article] > .header > .heading:first-child > a:not([href*=users]):not([href*=gifts])",
page,
);
let bookmarkLinks = $(
`li[role=article] > .own > .actions > li > a:contains("Edit")`,
page,
);
if (workLinks.length !== 0) {
let work_id = "";
// for each of the links in the page
// see if the work id is already in your bookmarks
// if it's not, add it to bookmarks
for (let i = 0, workLink; i < workLinks.length; i++) {
workLink = $(workLinks[i]);
work_id = workLink.attr("href").split("/")[2];
let bookmark_id = $(bookmarkLinks[i]).attr("href").split("/")[2];
// if this work_id is not in the bookmarks list
if (!bookmarkWorkIds.includes(work_id)) {
bookmarkWorkIds.push(work_id);
bookmarkBookmarkIds.push(bookmark_id);
newBookmarksNum++;
}
} // end for loop
console.log("new bookmarks num: " + newBookmarksNum);
await GM.setValue(workListName, bookmarkWorkIds);
await GM.setValue(bookmarkListName, bookmarkBookmarkIds);
// if you have more than 10 deleted or unrevelead bookmarks in a page, well, sucks to be you I guess
// otherwise good chance you've exhausted the bookmarks that you need to update, good job
} // endif
return newBookmarksNum;
}
// Goes through all of the user's bookmarks until it reaches the end or until there's < n (n being 10 here)
// new bookmarks on a page, at which point it figures it's updated enough and terminates
// This runs slowly. There's a 5 second pause in between requesting each page, and if there's an error, it waits
// 5 minutes (since it assumes that means rate limiting)
async function updateBookmarkList() {
// get your userId from the little greeting in the corner
let userId = $("#greeting > .user > .dropdown > .dropdown-toggle")
.attr("href")
.split("/")[2];
let pageNum = 1;
let newBookmarksNum = 0;
endOfPage = false;
let bookmarkWorkIds = await GM.getValue(workListName, []);
let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);
console.log("initial bookmarks length: " + bookmarkWorkIds.length);
let errorCount = 0;
$("button#bookmark-update").after(
`<p id="bookmark-update-progress" class="progress">On page ${pageNum}...</p>`,
);
// loop to go through all the bookmark pages that have new-to-us bookmarks
while (!endOfPage) {
//while (counter < 3) {
// didn't feel like wrapping the entire thing in a timeout or making another function for it
// bc I'm lazy, so here's just a timeout for 5 secs
// this doesn't usually run into rate limiting for me
await new Promise((resolve) => setTimeout(resolve, 5000));
try {
await $.get(
`https://archiveofourown.org/users/${userId}/bookmarks?page=${pageNum}`,
async (data) => {
console.log(pageNum);
/*if ($("#bookmark-update-progress").length > 0) {
console.log("already there");
console.log($("#bookmark-update-progress"));
$("#bookmark-update-progress").innerText = `page num: ${pageNum}`;
} else {
console.log("not there");
$("button#bookmark-update").after(`<p id="bookmark-update-progress" style="font-size:.75rem; color:green">page num: ${pageNum}</p>`);
}*/
$("#bookmark-update-progress").text(`On page ${pageNum}...`);
console.log(`On page ${pageNum}...`);
//$("button#bookmark-update").after(`<p id="bookmark-update-progress" style="font-size:.5rem">page num: ${pageNum}</p>`);
//console.log($("button#bookmark-update"));
await getNewBookmarks(
data,
bookmarkWorkIds,
bookmarkBookmarkIds,
newBookmarksNum,
);
if (newBookmarksNum < 10) {
console.log(
`Terminating bookmarks list update on ${pageNum} with ${newBookmarksNum} new bookmarks`,
);
endOfPage = true;
}
pageNum++;
newBookmarksNum = 0;
},
); // end the get thingy
} catch (e) {
// end try
// if there's an error, wait five minutes cause it's probably rate limiting you
errorCount++;
console.log("Error requesting bookmarks page: " + e);
await new Promise((resolve) => setTimeout(resolve, 300000));
console.log("timeout resolved, trying again");
}
if (endOfPage) {
console.log("Done updating bookmarks list");
$("#bookmark-update-progress").text("Done updating bookmarks!");
break;
}
if (errorCount > 5) {
console.log(`Too many failures, quitting before page ${pageNum}`);
endOfPage = true;
}
} // end while loop
await GM.setValue(workListName, bookmarkWorkIds);
await GM.setValue(bookmarkListName, bookmarkBookmarkIds);
await new Promise((resolve) => setTimeout(resolve, 5000));
$("#bookmark-update-progress").remove();
}
async function doFunctions(url) {
if (url.includes("archiveofourown.org/bookmarks/")) {
handleNewBookmark(url);
}
let userId = $("#greeting > .user > .dropdown > .dropdown-toggle")
.attr("href")
.split("/")[2];
// this is the slightly nuclear option: if it's one of your pages, just don't show the bookmark button at all
// it also eliminates all bookmark pages and lists of a user's collections (but not the list of works in the collection)
// the reason we're eliminating all your pages is that they already have the edit navigation
// section, so there's duplication
if (
!url.includes("/bookmarks") &&
!(url.includes("users") && url.includes("collection"))
) {
if (url.includes(userId)) {
addYourSaveButtons(url);
} else {
addSaveButtons(url);
}
}
if (url.includes(userId) && url.includes("/bookmarks")) {
addGetFromPageButton();
}
}
// get bookmarks from the page of your bookmarks that you are currently on and save them
// this is good if you are getting very rate-limited
async function getBookmarksFromPage() {
console.log("get bookmarks from page");
let bookmarkWorkIds = await GM.getValue(workListName, []);
let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);
console.log("initial bookmarks length: " + bookmarkWorkIds.length);
const addBookmarksBtn = $("[id=bookmark_improver_get_bookmarks")[0];
addBookmarksBtn.innerText = "Getting bookmarks...";
await getNewBookmarks(document.body, bookmarkWorkIds, bookmarkBookmarkIds);
// TODO: how to disable?
addBookmarksBtn.innerText = "Done!";
}
async function addGetFromPageButton() {
const bookmarkExternalBtn = $("ul.navigation.actions li").filter(
(index, el) => {
return $(el).text() === "Bookmark External Work";
},
)[0];
console.log({ bookmarkExternalBtn });
const button = document.createElement("button");
button.onclick = getBookmarksFromPage;
button.className = "actions";
console.log({ button });
button.innerText = "Get bookmarks from this page";
button.id = "bookmark_improver_get_bookmarks";
const listItem = document.createElement("li");
$(listItem).append(button);
console.log({ listItem });
$(bookmarkExternalBtn).after(listItem);
}
// adds buttons to create bookmarks on pages w/ your works
async function addYourSaveButtons(url) {
let bookmarkWorkIds = await GM.getValue(workListName, []);
let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);
for (
var i = 0,
link,
links = $(
"li[role=article] > .header > .heading:first-child > a:not([href*=users]):not([href*=gifts])",
);
i < links.length;
i++
) {
let link = $(links[i]);
let work_id = link.attr("href").split("/")[2];
let btnText = "Save";
let urlModifier = `works/${work_id}/bookmarks/new`;
if (bookmarkWorkIds.includes(work_id)) {
let index = bookmarkWorkIds.indexOf(work_id);
let bookmark_id = bookmarkBookmarkIds[index];
btnText = "Saved";
urlModifier = `bookmarks/${bookmark_id}/edit`;
}
link
.closest(".header")
.nextAll(".stats")
.after(
`<ul class="actions" role="navigation"> <li> <a id="bookmark_form_trigger_` +
work_id +
`" data-remote="true" href="https://archiveofourown.org/` +
urlModifier +
`">${btnText}</a> </li> </ul>`,
);
// when you click on this work's bookmark form trigger, it adds a div after it and loads in the bookmark form part
// of the bookmark page
$("#bookmark_form_trigger_" + work_id).click(function () {
link
.closest(".header")
.nextAll(".actions")
.after("<div id='bookmark_ext_div'></div>");
$("#bookmark_ext_div").load(
`https://archiveofourown.org/${urlModifier} #bookmark-form`,
() => {
$("legend:contains('Bookmark')").after(
`<p class="close actions"><a id="bookmark-form-close">×</a></p>`,
);
$("#bookmark-form-close").click(() =>
$("#bookmark_ext_div").remove(),
);
},
);
});
}
$("a[id^=bookmark_form_trigger]").click(saveScrollPos);
}
// adds the buttons to create bookmarks
async function addSaveButtons(url) {
let bookmarkWorkIds = await GM.getValue(workListName, []);
let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);
for (
var i = 0, link, links = $("li.work[role=article]");
i < links.length;
i++
) {
let link = $(links[i]);
link.addClass("bookmark");
}
// go through all of the works on the page
// and add the save/saved button and its functionality
for (
var i = 0,
link,
links = $(
"li[role=article]:not([id*=bookmark]) > .header > .heading:first-child > a:not([href*=users]):not([href*=gifts])",
);
i < links.length;
i++
) {
let link = $(links[i]);
let work_id = link.attr("href").split("/")[2];
let btnText = "Save";
let urlModifier = `works/${work_id}/bookmarks/new`;
// if this is already bookmarked
// then we need to indicate that and put in the proper link to edit the bookmark
if (bookmarkWorkIds.includes(work_id)) {
let index = bookmarkWorkIds.indexOf(work_id);
let bookmark_id = bookmarkBookmarkIds[index];
btnText = "Saved";
urlModifier = `bookmarks/${bookmark_id}/edit`;
}
link
.closest(".header")
.nextAll(".stats")
.after(
`<ul class="actions" role="navigation"> <li> <a id="bookmark_form_trigger_` +
work_id +
`" data-remote="true" href="https://archiveofourown.org/` +
urlModifier +
`">${btnText}</a> </li> </ul>`,
);
// when you click on this work's bookmark form trigger, it adds a div after it and loads in the bookmark form part
// of the bookmark page
$("#bookmark_form_trigger_" + work_id).click(function () {
link
.closest(".header")
.nextAll(".actions")
.after("<div id='bookmark_ext_div'></div>");
$("#bookmark_ext_div").load(
`https://archiveofourown.org/${urlModifier} #bookmark-form`,
() => {
$("legend:contains('Bookmark')").after(
`<p class="close actions"><a id="bookmark-form-close">×</a></p>`,
);
$("#bookmark-form-close").click(() =>
$("#bookmark_ext_div").remove(),
);
},
);
});
} // end for loop
// after we've loaded in all our bookmark form triggers, set the onclick to save the scroll position for the back button
$("a[id^=bookmark_form_trigger]").click(saveScrollPos);
} // end addSaveButtons
// on the dedicated bookmark page (usually when creating/updating a bookmark)
// first of all, if it has the flash notice to show that it's a newly created bookmark
// then get both the work id and the bookmark id and add them to the global list of created bookmarks
// second, it also adds the back button to get back to where you were browsing
async function handleNewBookmark(url) {
let bookmarkWorkIds = await GM.getValue(workListName, []);
let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);
let links = $(
"li[role=article] > .header > .heading:first-child > a:not([href*=users])",
);
let link = $(links[0]);
let work_id = link.attr("href").split("/")[2];
let bookmark_id = url.split("/")[4];
if (!bookmarkWorkIds.includes(work_id)) {
console.log("this is a new work bookmarked!");
bookmarkWorkIds.push(work_id);
bookmarkBookmarkIds.push(bookmark_id);
GM.setValue(workListName, bookmarkWorkIds);
GM.setValue(bookmarkListName, bookmarkBookmarkIds);
}
// back button
addButton();
}
// add back button which redirects to the previous page
// for "you created a new bookmark" or "you updated this bookmark" pages
function addButton() {
$(".bookmarks-show > .navigation").prepend(
`<li><a href="${document.referrer}" id="backButton">← Go Back</a></li>`,
);
}
// scroll to saved position on page
async function scrollToPos() {
scrollPos = await GM.getValue("scroll");
window.scrollTo(0, scrollPos);
}
// save the current position that the page is scrolled to
function saveScrollPos() {
scrollPos = window.scrollY;
GM.setValue("scroll", scrollPos);
}
// when the page loads
$(document).ready(function () {
let url = window.location.href;
// add custom CSS for settings menu
let head = document.getElementsByTagName("head")[0];
if (head) {
let style = document.createElement("style");
style.setAttribute("type", "text/css");
style.textContent = css;
head.appendChild(style);
}
// if we're coming from a bookmarks page and the setting is set, scroll to your previous position on the page
if (
document.referrer.includes("archiveofourown.org/bookmarks/") &&
USE_SAVED_SCROLL_POS
) {
scrollToPos();
}
doFunctions(url);
});
})();