AO3: Safekeeping Buttons

Mark For Later, Subscribe, Download and Bookmark buttons on all work and bookmark lists

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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 (&lt;img&gt; 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" }));
        });
    });
}