Mark For Later, Subscribe, Download and Bookmark buttons on all work and bookmark lists
// ==UserScript==
// @name AO3: Safekeeping Buttons
// @namespace https://greatest.deepsurf.us/en/users/906106-escctrl
// @description Mark For Later, Subscribe, Download and Bookmark buttons on all work and bookmark lists
// @author escctrl
// @version 3.1
// @match *://*.archiveofourown.org/*
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @connect archiveofourown.org
// @require https://update.greatest.deepsurf.us/scripts/542049/1780689/AO3%3A%20Initialize%20jQueryUI.js
// @license GNU GPL-3.0-only
// ==/UserScript==
/* global q, qa, ins, $, createMenu, initGUI, lightOrDark */
'use strict';
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 cfg = 'safekeepingButtons'; // name of dialog and localstorage used throughout
const script = "Safekeeping Buttons script";
/*************** CONFIG GUI ***************/
createMenu(cfg, "Safekeeping Buttons"); // creating the AO3 menu entry
q("#opencfg_"+cfg).addEventListener("click", async function(e) {
let uiElem = await initGUI(e, cfg, "Safekeeping Buttons Configuration", 600);
if (uiElem !== false) createDialog(uiElem);
}, { once: true });
let theme = lightOrDark(window.getComputedStyle(q('body')).backgroundColor);
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; }
.advdownload svg, #${cfg} svg { width: 1em; height: 1em; display: inline-block; vertical-align: -0.1em; }
.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 */
#${cfg} { --on-dark: #0972a5; --on-light: #a7d3ff; --off-dark: #4c4c4c; --off-light: #a2a2a2;
.switch { background-color: var(--off-${theme}); }
.switch:has(input:checked) { background-color: var(--on-${theme}); }
.dlnameparts.ui-sortable { min-height: calc(${window.getComputedStyle(q('#main')).lineHeight} + 0.6em); border: 1px solid #505050; border-radius: 0.2em;
li { padding: 0.1em 0.5em; margin: 0.2em; border-radius: 0.2em; }
li.ui-sortable-placeholder { max-height: 0.1em; }
}
#dlname.ui-sortable li { background-color: var(--on-${theme}); }
#dlname.ui-sortable li:only-child { cursor: not-allowed; }
#dlparts.ui-sortable li { background-color: var(--off-${theme}); }
}
</style>`);
function createDialog(dlg) {
let formats = ['AZW3', 'EPUB', 'HTML', 'MOBI', 'PDF'].map((f) => `<option value="${f}" ${f === prefs.fmt ? "selected" : ""}>${f}</option>`);
let selParts = [], availParts = [];
let allParts = new Map([ ['S', 'Series Title'], ['W', 'Work Title'], ['C', 'Creator(s)'], ['F', 'Fandom'], ['P', 'Pairing'], ['R', 'Rating'] ]);
for (let p of prefs.title.split("-")) { // build the selected sortable in the correct order
selParts.push(`<li data-part="${p}">${allParts.get(p)}</li>`);
allParts.delete(p); // delete what's already shown as selected
}
allParts.forEach((t, s) => availParts.push(`<li data-part="${s}">${t}</li>`)); // and the rest in the unselected sortable
$(dlg).html(`<form>
<p><label class="switch"><input type="checkbox" ${prefs.adv ? "checked" : ""} name="dladv" id="dladv"><span class="slider round"></span></label>
<label for="dladv">Use Advanced Downloads</label></p>
<div id="dladvoptions" class="${ !prefs.adv ? "hidden" : "" }">
<p><label for="dlformat">Download in file format:</label>
<select name="dlformat" id="dlformat">${ formats.join("") }</select>
<a class="help symbol question modal modal-attached" style="top: 0.3em; position: relative;" href="/faq/downloading-fanworks#downloadformat" target="_blank"><span class="symbol question"><span>?</span></span></a>
<p><label>Order of work fields to use as filename:</label></p>
<ul id="dlname" class="dlnameparts">${ selParts.join("") }</ul>
<div style="text-align: center; padding: 0.2em 0;">↑ drag up to use</div>
<ul id="dlparts" class="dlnameparts">${ availParts.join("") }</ul>
</div>
</form>`);
$( "#dlname" ).sortable({ connectWith: "#dlparts", containment: `#${cfg} form`, cancel: "li:only-child" }); // don't allow last item to be moved out
$( "#dlparts" ).sortable({ connectWith: "#dlname", containment: `#${cfg} form` });
q('#dladv').addEventListener('change', (e) => { // reactive: if adv d/l not enabled, you don't see the options for it
if (e.target.checked) q('#dladvoptions').classList.remove('hidden');
else q('#dladvoptions').classList.add('hidden');
});
// the save/reset/cancel buttons and handling of storage
$(dlg).dialog('option', 'buttons', [
{
text: "Reset",
click: function() {
localStorage.removeItem(cfg);
$( this ).dialog( "close" );
location.reload();
}
},
{
text: "Cancel",
click: function() { $( this ).dialog( "close" ); }
},
{
text: "Save",
click: function() {
let form = q(`#${cfg} form`);
let to_store = {};
to_store.adv = q('#dladv', form).checked; // slider
to_store.fmt = q('#dlformat', form).value // select
to_store.title = Array.from(qa('#dlname li', form)).map(li => li.dataset.part).join('-'); // sortable
if (to_store.title === "") to_store.title = "W"; // fallback if nothing was selected
prefs = to_store; // write back to global var, so new values are used immediately
localStorage.setItem(cfg, JSON.stringify(to_store));
$( this ).dialog( "close" );
location.reload();
}
},
]);
$(dlg).dialog('open');
}
/*************** HELPERS ***************/
const auth = q('head meta[name="csrf-token"]').content; // grab the authenticity token
const 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 of pseuds (if already stored)
let btn = null; // placeholder for the button that was last clicked by user
let prefs = JSON.parse(localStorage.getItem(cfg)) || { adv: false, fmt: "PDF", title: "W" };
// 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 === "/";
// helper to turn a work's title and ID into the download link AO3 would use
const buildAO3dlLink = (title, id) => `/downloads/${id}/${title.replaceAll(/[^\w ]/ig, "").replaceAll(" ", "_")}`;
const now = "?updated_at=" + Date.now(); // timestamp added to all AO3 download links
let dlicons = {
// SVGs from Heroicons https://heroicons.com (MIT license Copyright (c) Tailwind Labs, Inc. https://github.com/tailwindlabs/heroicons/blob/master/LICENSE)
// changelog: removed xmlns, class, and linebreaks, added titles for accessibility
work: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><title>download work</title><path stroke-linecap="round" stroke-linejoin="round" d="M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15M9 12l3 3m0 0 3-3m-3 3V2.25" /></svg>`,
series: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><title>download all works in series</title><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v7.5a2.25 2.25 0 0 0 2.25 2.25h7.5a2.25 2.25 0 0 0 2.25-2.25v-7.5a2.25 2.25 0 0 0-2.25-2.25h-.75m-6 3.75 3 3m0 0 3-3m-3 3V1.5m6 9h.75a2.25 2.25 0 0 1 2.25 2.25v7.5a2.25 2.25 0 0 1-2.25 2.25h-7.5a2.25 2.25 0 0 1-2.25-2.25v-.75" /></svg>`,
succ: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><title>download successful</title><path stroke-linecap="round" stroke-linejoin="round" d="M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125v-9M10.125 2.25h.375a9 9 0 0 1 9 9v.375M10.125 2.25A3.375 3.375 0 0 1 13.5 5.625v1.5c0 .621.504 1.125 1.125 1.125h1.5a3.375 3.375 0 0 1 3.375 3.375M9 15l2.25 2.25L15 12" /></svg>`,
err: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><title>download failed</title><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>`,
abort: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><title>download cancelled</title><path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" /></svg>`,
question: `<svg viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm11.378-3.917c-.89-.777-2.366-.777-3.255 0a.75.75 0 0 1-.988-1.129c1.454-1.272 3.776-1.272 5.23 0 1.513 1.324 1.513 3.518 0 4.842a3.75 3.75 0 0 1-.837.552c-.676.328-1.028.774-1.028 1.152v.75a.75.75 0 0 1-1.5 0v-.75c0-1.279 1.06-2.107 1.875-2.502.182-.088.351-.199.503-.331.83-.727.83-1.857 0-2.584ZM12 18a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" /></svg>`,
// SVG from https://github.com/n3r4zzurr0/svg-spinners (MIT license Copyright (c) Utkarsh Verma https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE)
// changelog: animation slowed x1.5 (via iconify.design), removed xmlns, width/height, added style attribute, linebreaks
pending: `<svg viewBox="0 0 24 24" style="top: 0.2em; position: relative;"><title>downloading, please wait</title>
<circle cx="4" cy="12" r="3" fill="currentColor"><animate id="SVGKiXXedfO" attributeName="cy" begin="0;SVGgLulOGrw.end+0.375s" calcMode="spline" dur="0.9s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle>
<circle cx="12" cy="12" r="3" fill="currentColor"><animate attributeName="cy" begin="SVGKiXXedfO.begin+0.15s" calcMode="spline" dur="0.9s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle>
<circle cx="20" cy="12" r="3" fill="currentColor"><animate id="SVGgLulOGrw" attributeName="cy" begin="SVGKiXXedfO.begin+0.3s" calcMode="spline" dur="0.9s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle>
</svg>`
};
/*************** ADDING THE NEW BUTTONS ***************/
// Looping over all works on the page to build the various buttons
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; if (!prefs.adv) { dl = false; } } // series can only be subbed and bookmarked (and d/l through the advanced feature)
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="safekeepingFrame" 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 worktitle = q('.heading a[href*="/works/"]', work)?.innerText || null; // Mystery Works have no link yet
let serieslink = q('.heading a[href*="/series/"]', work)?.href || null; // if it's not a work, it might be a series which needs to be handled differently
if (worktitle) {
let ao3dl = buildAO3dlLink(worktitle, id[2]);
dl = prefs.adv ? `<li class="advdownload"><button type="button" data-href="${ao3dl}.${prefs.fmt.toLowerCase()}${now}">${dlicons.work} ${prefs.fmt}</button></li>` :
`<li class="download"><noscript><h4 class="heading">Download</h4></noscript>
<button class="collapsed">Download</button>
<ul class="expandable secondary hidden">
<li><a href="${ao3dl}.azw3${now}">AZW3</a></li>
<li><a href="${ao3dl}.epub${now}">EPUB</a></li>
<li><a href="${ao3dl}.mobi${now}">MOBI</a></li>
<li><a href="${ao3dl}.pdf${now}">PDF</a></li>
<li><a href="${ao3dl}.html${now}">HTML</a></li>
</ul>
</li>`;
}
else if (serieslink && prefs.adv) {
dl = `<li class="advdownload"><button type="button" data-href="${serieslink}">${dlicons.series} ${q('.stats dd.works', work).innerText} ${prefs.fmt}</button></li>`;
}
else dl = ``;
}
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="safekeepingFrame">
<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
}
// in addition, if this is a series page, we want a Download All button
if (window.location.pathname.startsWith("/series/") && prefs.adv) {
ins(q('#main > ul.navigation.actions'), 'beforeend',
`<li class="advdownload"><button type="button" data-href="${window.location.pathname}">${dlicons.series} ${q('dl.series.meta dd.works').innerText} ${prefs.fmt}</button></li>`);
}
/*************** EVENT HANDLERS FOR NEW BUTTONS ***************/
// Button click handler (delegated). remember which button was clicked last. 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('.advdownload 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.closest('.advdownload button');
let blurb = btn.closest('.blurb') || btn.closest('ul'); // work up to blurb, or <ul> in case of /series/ page
// add spinner to show it's working
q('svg', btn).outerHTML = dlicons.pending;
if (btn.dataset.href.includes("/downloads/")) {
let r = await fetchFile(buildMyFileName(blurb), btn.dataset.href); // piece the filename together based on prefs, download the file under that name
q('svg', btn).outerHTML = r ? dlicons.succ : dlicons.err; // status reporting
}
else if (btn.dataset.href.includes("/series/")) {
try {
qa('.advdownload button').forEach(b => { b.disabled = true; }); // disable all other dl buttons
const controller = new AbortController();
ins(btn.closest('li'), 'afterend', ` <li class="advdlcancel"><button type="button">Cancel</button></li>`);
q('li.advdlcancel button', blurb).addEventListener('click', () => {
controller.abort("Series download cancelled by user"); // when user clicks button, this is the Error reason
q('svg', btn).outerHTML = dlicons.abort; // status reporting: abort
});
// series page might be paged. page = current page we're going to read, totalpages = last page of works in the series
let page = 1, totalpages = 1, works = [];
do { // fetch at least once
if (controller.signal.aborted) throw new Error(controller.signal.reason, { cause: "abort" }); // checks in each iteration if the user has already cancelled
let txt = null;
let pageurl = page === 1 ? "" : `?page=${page}`;
// skip if we'd be requesting a Series page that we're already on (Download All button on /series/xxx page)
if (window.location.pathname + window.location.search !== btn.dataset.href + pageurl) {
let response = await fetch(btn.dataset.href + pageurl);
if (!response.ok) throw new Error(`HTTP error: ${response.status}`); // the response has hit an error eg. 429 retry later
txt = await response.text();
txt = new DOMParser().parseFromString(txt, "text/html");
}
let items = qa('.index.group .blurb.group:not(.user)', (txt || document)); // on each page, grab the work blurbs
if (!items) throw new Error(`series page ${btn.dataset.href + pageurl} didn't contain any works`); // the response has hit a different page e.g. a CF prompt
else works = [...works, ...Array.from(items)];
if (page === 1) totalpages = parseInt(q('#main ol.pagination li:nth-last-of-type(2) a', (txt || document))?.innerText || 1); // second-to-last li because last one is "next"
page++;
} while (page <= totalpages) // repeat until we have seen all pages
// get the filename parts
for (let work of works) {
if (controller.signal.aborted) throw new Error(controller.signal.reason, { cause: "abort" }); // checks in each iteration if the user has already cancelled
let worktitle = q('.heading a[href*="/works/"]', work)?.innerText || ""; // Mystery Works have no link yet
let workid = work.className.match(/\b(work)-(\d+)/);
if (worktitle !== "") { // fetch the individual work file
let r = await fetchFile(buildMyFileName(work), `${buildAO3dlLink(worktitle, workid[2])}.${prefs.fmt.toLowerCase()}${now}`);
if (r) await waitforXSeconds(3, controller.signal); // if we got the file, wait 3 sec before requesting the next
else throw new Error(`Couldn't download file ${worktitle} (/works/${workid[2]})`); // if not, stop the whole thing
}
}
q('svg', btn).outerHTML = dlicons.succ; // status reporting: success
}
catch(error) {
if (error.cause !== "abort") {
q('svg', btn).outerHTML = dlicons.err; // status reporting: failure
sendError(error);
}
}
q('li.advdlcancel').remove(); // we're done so we can remove the cancel button
qa('.advdownload button').forEach(b => { b.disabled = false; }); // re-enable all download buttons again
}
}
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 bookmark forms. we can't handle it if something else is pressed in between
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="safekeepingFrame" 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);
}
});
/*************** IFRAME FORM ACTION ***************/
// bkmk/mfl/subscribe form submissions get sent to hidden iframe so we don't refresh the page and lose our place. catch the response in there to display success/errors
ins(q('body'), 'afterbegin', `<iframe name="safekeepingFrame" id="safekeepingFrame" style="display: none"></iframe>`);
let frame = q('#safekeepingFrame');
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");
}
});
/*************** FUNCTIONS ***************/
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} couldn't retrieve your pseuds:`, error.message);
return '[ERROR FETCHING PSEUDS]';
}
}
function buildMyFileName(blurb) {
// get the parts of the filename we need
let myFileName = [];
for (let p of prefs.title.split("-")) {
let val = (function () {
switch (p) {
case "C": {
let full = q('.header a[href*="/users/"]', blurb)?.innerText || "Anon";
if (full.includes("(")) full = full.match(/\((\w+)\)/)[1]; // ignore pseuds, use username.
return full;
}
case "W": return q('.header a[href*="/works/"]', blurb)?.innerText || ""; // might be mystery work in a series
case "S": return q('.series a[href*="/series/"]', blurb)?.innerText || "";
case "F": return q('.fandoms a.tag', blurb).innerText.replaceAll(/\(.+?\)/g, ""); // remove disambig
case "P": return q('.tags.commas .relationships a.tag', blurb)?.innerText.replaceAll(/\(.+?\)/g, "") || ""; // remove disambig
case "R": return q('.rating', blurb)?.title.slice(0, 1); // first letter of rating (N, G, T, M, E)
}
})();
val = val.replaceAll(/[^\p{L}\p{M}\p{N}]/igu, "").slice(0, 20); // remove non-letters (any language allowed). insist on a max length. Android doesn't like anything beyond 50 chars!
if (p === "S" && val !== "") val += q('.work .series strong', blurb).innerText; // add series part number without cutting it off due to length
if (val !== "") myFileName.push(val);
}
return myFileName.join("-")+"."+prefs.fmt.toLowerCase();
}
function fetchFile(fileName, fileURL) {
return new Promise((resolve, reject) => {
const options = { // options are the same for async and Promise functions
method: "GET",
url: fileURL,
responseType: "blob", // ask for this to be delivered as a binary blob
onload: async function(response) {
try {
// GM function response doesn't have response.ok, so we have to check manually
if (response.status > 299 && response.status < 200) throw new Error(`Couldn't download ${fileURL}, HTTP error: ${response.status}`); // the response has hit an error eg. 429 retry later
let fileContent = await new Response(response.response).blob() // piece it back together into a whole file
// save it under our preferred filename by creating a link to the Blob (with download attribute) and clicking it
let a = document.createElement("a");
a.style = "display: none";
document.body.appendChild(a);
let objURL = window.URL.createObjectURL( new Blob([fileContent], {type: "octet/stream"}) );
a.href = objURL;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(objURL);
document.body.removeChild(a);
resolve(true);
}
catch(e) { sendError(e.message); reject(false); }
},
onerror: function(e) { sendError(`Couldn't download the requested file`); reject(false); }
};
if (typeof GM_xmlhttpRequest !== undefined) GM_xmlhttpRequest(options);
else GM.xmlHttpRequest(options);
});
}
function sendError(error) {
console.error(`${script} error - ${error}`);
alert(`Sorry, the ${script} encountered an issue: ${ error }`);
}
function waitforXSeconds(x, signal) {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, x * 1000);
signal.addEventListener('abort', () => { // stops waiting when the user cancels
clearTimeout(t);
reject(new Error(signal.reason, { cause: "abort" }));
});
});
}