AO3: Safekeeping Buttons

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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