AO3: Safekeeping Buttons

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==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 (&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);
    }
});

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");
    }
});