Greasy Fork is available in English.
Mark For Later, Subscribe, Download and Bookmark buttons on all work and bookmark lists
// ==UserScript==
// @name AO3: Safekeeping Buttons
// @namespace https://greasyfork.org/en/users/906106-escctrl
// @description Mark For Later, Subscribe, Download and Bookmark buttons on all work and bookmark lists
// @author escctrl
// @version 2.0
// @match *://*.archiveofourown.org/*
// @grant none
// @license GNU GPL-3.0-only
// ==/UserScript==
'use strict';
// utility to reduce verboseness
const q = (selector, node=document) => node.querySelector(selector);
const qa = (selector, node=document) => node.querySelectorAll(selector);
const ins = (n, l, html) => n.insertAdjacentHTML(l, html);
if (window.self !== window.top || // stop if the script is running in an iFrame
qa('.index.group .blurb').length === 0 ) { // stop if there's no work list
return;
}
let auth = q('head meta[name="csrf-token"]').content; // grab the authenticity token
let user = q('#greeting li.dropdown > a[href^="/users/"]')?.href // grab the username-url (only if logged in)
let pseuds = JSON.parse(sessionStorage.getItem("bmk_pseud")); // grab the list if pseuds (if already stored)
let btn = null; // placeholder for the button that was last clicked by user
let now = Date.now();
ins(q('head'), 'beforeend', `<style type="text/css">.index.group .blurb .work.actions { clear: both; }
dl.stats + .landmark + .actions, .mystery .summary + .landmark + .actions { float: right; }
.download li { padding-left: 0; }
.blurb #bookmark_form_placement { clear: both; } /* fix bookmark form border enveloping action buttons as well */
.index.group { z-index: 0; } .user.blurb { z-index: -1; } /* fix download submenu covered by following list items */
</style>`);
// hidden iframe so we don't refresh the page and lose our place
ins(q('body'), 'afterbegin', `<iframe name="hiddenframe" id="hiddenframe" style="display: none"></iframe>`);
let frame = q('#hiddenframe');
// is this the Mark For Later page, or the homepage section "is it later already"?
let MFLpage = new URLSearchParams(window.location.search).get('show') === "to-read" || window.location.pathname === "/";
for (let work of qa('.index.group .blurb.group:not(.user)')) {
let id = work.className.match(/\b(external-work|work|series)-(\d+)/);
if (id === null) continue; // bookmarks of deleted items have no ID
let mfl = true, sub = true, dl = true, bmk = true;
if (!q('body').classList.contains('logged-in')) { mfl = false; sub = false; bmk = false; } // without an account only d/l works
if (work.classList.contains('own')) { mfl = false; sub = false; } // own works can only be d/l and bookmarked
if (id[1] === "series") { mfl = false; dl = false; } // series can only be subbed and bookmarked
else if (id[1] === "external-work") { mfl = false; sub = false; dl = false; } // external works can only be bookmarked
if (q('#main').classList.contains('bookmarks-index')) bmk = false; // bookmark lists (in tags, user, collection) have a "save/saved" (or "edit" on own bmks) button already
if (!mfl && !sub && !dl && !bmk) continue;
// build the Bookmark button
// external works: only added on their /external_works/xxx page (has no standard Bookmark button). otherwise they only appear in bookmark lists with standard Save(d) buttons
bmk = !bmk ? `` : `<li class="bookmark"><button data-${id[1].slice(0,1)}id=${id[2]}>Bookmark</button></li>`;
// build the Mark for Later button. on the MFL page, build instead the Mark as Read button
mfl = !mfl ? `` : `<li class="markforlater">
<form class="button_to" method="post" target="hiddenframe" action="/works/${id[2]}/${ MFLpage ? "mark_as_read" : "mark_for_later" }">
<input type="hidden" name="_method" value="patch" autocomplete="off">
<input type="hidden" name="authenticity_token" value="${auth}">
<button type="submit">${ MFLpage ? "Mark as Read" : "Mark for Later" }</button>
</form></li>`;
// build the Download button
if (dl) {
let title = q('.heading a[href*="/works/"]', work)?.innerText.toLowerCase().replaceAll(/[^\w ]/ig, "").replaceAll(" ", "_") || null; // Mystery Works have no link yet
dl = title === null ? `` : `<li class="download"><noscript><h4 class="heading">Download</h4></noscript>
<button class="collapsed">Download</button>
<ul class="expandable secondary hidden">
<li><a href="/downloads/${id[2]}/${title}.azw3?updated_at=${now}">AZW3</a></li>
<li><a href="/downloads/${id[2]}/${title}.epub?updated_at=${now}">EPUB</a></li>
<li><a href="/downloads/${id[2]}/${title}.mobi?updated_at=${now}">MOBI</a></li>
<li><a href="/downloads/${id[2]}/${title}.pdf?updated_at=${now}">PDF</a></li>
<li><a href="/downloads/${id[2]}/${title}.html?updated_at=${now}">HTML</a></li>
</ul>
</li>`;
}
else dl = ``;
// build the Subscribe button
// Mystery Works can be subscribed to, and funnily enough, it means you get to see its title on your My Subscriptions page
sub = !sub ? `` : `<li class="subscribe">
<form class="ajax-create-destroy" id="new_subscription" data-create-value="Subscribe" data-destroy-value="Unsubscribe" action="${user}/subscriptions" accept-charset="UTF-8" method="post" target="hiddenframe">
<input type="hidden" name="authenticity_token" value="${auth}" autocomplete="off">
<input autocomplete="off" type="hidden" value="${id[2]}" name="subscription[subscribable_id]" id="subscription_subscribable_id">
<input autocomplete="off" type="hidden" value="${id[1].charAt(0).toUpperCase() + id[1].substring(1).toLowerCase()}" name="subscription[subscribable_type]" id="subscription_subscribable_type">
<input type="submit" name="commit" value="Subscribe">
</form></li>`;
if (qa(':scope > ul.actions', work).length === 0) ins(work, 'beforeend', `<ul class="actions work navigation" role="navigation"></ul>`); // add an UL to the blurb if not yet present
else qa(':scope > ul.actions', work)[0].classList.add('work','navigation'); // to make standard CSS for Download button work
ins(qa(':scope > ul.actions', work)[0], 'afterbegin', mfl + sub + bmk + dl); // add buttons to UL in the blurb
}
// remember which button was clicked last (delegated) and do something for it. async because getting pseuds may need to await a fetch
q('#main').addEventListener('click', async (e) => {
if (e.target.closest('.markforlater [type=submit], .subscribe [type=submit]')) {
qa('#bookmark_form_placement').forEach((x) => x.remove()); // remove any bookmark forms. we can't handle it if something else is pressed in between
btn = e.target;
}
else if (e.target.closest('.download button')) {
qa('#bookmark_form_placement').forEach((x) => x.remove()); // remove any bookmark forms. we can't handle it if something else is pressed in between
btn = e.target;
btn.classList.toggle('expanded');
btn.classList.toggle('collapsed');
btn.nextElementSibling.classList.toggle('hidden');
}
else if (e.target.closest('.bookmark button')) {
// bookmarks work very different. this button click only inserts the form, another inside that form is the actual submission
// to make default CSS work, we have to reuse the same element IDs -> no two forms can exist at the same time, IDs must be unique
// so right now, we need to initialize the whole bookmark form for the clicked work/series
qa('#bookmark_form_placement').forEach((x) => x.remove()); // remove any other potentially open forms
btn = e.target;
let acturl = (btn.dataset.wid !== undefined) ? `/works/${btn.dataset.wid}` :
(btn.dataset.sid !== undefined) ? `/series/${btn.dataset.sid}` : `/external_works/${btn.dataset.eid}`; // submit URL
if (pseuds === null) pseuds = await getPseuds(acturl); // grab missing pseuds from a page that shows them
ins(btn.parentElement.parentElement, 'afterend', pseuds === "[ERROR FETCHING PSEUDS]" ?
`<div>Sorry, that didn't work. Please try manually from the <a href="${acturl}/bookmarks">Bookmarks page</a>.</div>` :
`<div id="bookmark_form_placement" class="wrapper toggled"><div class="post bookmark" id="bookmark-form">
<h3 class="landmark heading">Bookmark</h3>
<form action="${acturl}/bookmarks" accept-charset="UTF-8" method="post" target="hiddenframe" id="bmktest">
<input type="hidden" name="authenticity_token" value="${auth}" autocomplete="off">
<fieldset><legend>Bookmark</legend>
<p class="close actions"><a class="bookmark_form_placement_close" href="javascript:void(0)" aria-label="cancel">×</a></p>
${pseuds}
<fieldset><legend>Write Comments</legend>
<dl>
<dt><label for="bookmark_notes">Notes</label></dt>
<dd>
<p class="footnote" id="notes-field-description-summary">The creator's summary is added automatically.</p>
<p class="footnote" id="notes-field-description-html">Plain text with limited HTML <a class="help symbol question modal modal-attached" title="Html help" href="/help/html-help.html" aria-controls="modal"><span class="symbol question"><span>?</span></span></a> Embedded images (<img> tags) will be displayed as HTML, including the image's source link and any alt text.</p>
<textarea rows="4" id="bookmark_notes" class="observe_textlength" aria-describedby="notes-field-description-summary notes-field-description-html" name="bookmark[bookmarker_notes]"></textarea>
<p class="character_counter" tabindex="0"><span id="bookmark_notes_counter" class="value" data-maxlength="5000" aria-valuenow="5000">5000</span> characters left</p>
</dd>
<dt><label for="bookmark_tag_string_autocomplete">Your tags</label></dt>
<dd>
<p class="footnote" id="tag-string-description">The creator's tags are added automatically.</p>
<input class="autocomplete" data-autocomplete-method="/autocomplete/tag?type=all" data-autocomplete-hint-text="Start typing for suggestions!" data-autocomplete-no-results-text="(No suggestions found)" data-autocomplete-min-chars="1" data-autocomplete-searching-text="Searching..." size="60" aria-describedby="tag-string-description" type="text" value="" name="bookmark[tag_string]" id="bookmark_tag_string">
<p class="character_counter">Comma separated, 150 characters per tag</p>
</dd>
<dt><label for="bookmark_collection_names_autocomplete">Add to collections</label></dt>
<dd><input class="autocomplete" data-autocomplete-method="/autocomplete/open_collection_names" data-autocomplete-hint-text="Start typing for suggestions!" data-autocomplete-no-results-text="(No suggestions found)" data-autocomplete-min-chars="1" data-autocomplete-searching-text="Searching..." size="60" type="text" name="bookmark[collection_names]" value="" id="bookmark_collection_names"></dd>
</dl>
</fieldset>
<fieldset><legend>Choose Type and Post</legend>
<p>
<input name="bookmark[private]" type="hidden" value="0" autocomplete="off"><input type="checkbox" value="1" name="bookmark[private]" id="bookmark_private"> <label for="bookmark_private">Private bookmark</label>
<input name="bookmark[rec]" type="hidden" value="0" autocomplete="off"><input type="checkbox" value="1" name="bookmark[rec]" id="bookmark_rec"> <label for="bookmark_rec">Rec</label>
</p>
<p class="submit actions"><input type="submit" name="commit" value="Create"></p>
</fieldset>
</fieldset>
</form></div></div>`);
}
// when the bookmark form is cancelled
else if (e.target.closest('a.bookmark_form_placement_close')) {
q('#bookmark_form_placement').remove();
}
});
// workaround for touch devices not seeing tooltips
q('#main').addEventListener('pointerup', (e) => {
if (e.target.closest('.markforlater [type=submit][disabled], .subscribe [type=submit][disabled], .bookmark button[disabled]')) {
if (e.target.title !== "") alert(e.target.title);
}
});
async function getPseuds(url) {
// trying to load pseuds from an /external_works/xxx page WILL fail because they have dynamically added bookmark forms. works & series have static HTML we can grab
try {
let response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error: ${response.status}`); // the response has hit an error eg. 429 retry later
else {
let txt = await response.text();
let parser = new DOMParser(); // Initialize the DOM parser
let item = q('#bookmark-form h4.heading', parser.parseFromString(txt, "text/html")).outerHTML; // grab this user's possible pseuds and store it in session
if (!item) throw new Error(`response didn't contain what we were looking for\n${txt}`); // the response has hit a different page e.g. a CF prompt
else {
sessionStorage.setItem("bmk_pseud", JSON.stringify(item));
return item; // returns text!
}
}
}
catch(error) {
// in case of any other JS errors
console.warn("[script] Safekeeping Buttons script couldn't retrieve your pseuds:", error.message);
return '[ERROR FETCHING PSEUDS]';
}
}
// form submission gets sent to iframe. catch the response in there to display success/errors
frame.addEventListener("load", () => {
let framedoc = frame.contentDocument || frame.contentWindow.document;
if (framedoc.URL === "about:blank") return; // empty document when the iframe is first created - ignore
const updateButton = (res, msg) => {
if (btn.tagName === "INPUT") btn.value = btn.value + res;
else btn.innerText = btn.innerText + res;
btn.title = msg;
btn.disabled = true;
};
let response = qa('#main > .flash.notice, #main > #error', framedoc).item(0)?.innerText;
// response contains .flash.notice -> AO3 tried to do the thing. check that it was successful
if (response !== undefined && response.match(/^(You are now following|This work was added|This work was removed|Bookmark was successfully created)/) !== null ) {
updateButton(" ✓", "");
}
// response contains #error but it just says that the bookmark already existed
else if (response !== undefined && response.match(/(You have already bookmarked that)/) !== null ) {
updateButton(" ⓘ", "This was already bookmarked, no changes were made");
}
// response that doesn't contain a .flash.notice -> an error like retry later, cloudflare, or response with .flash.error
else {
updateButton(" ✗", "That didn't work, please try manually from the works page");
}
});