AO3 Bookmark Improver

Bookmark a work directly from any page and go back from bookmarking to browsing quickly

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

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

(I already have a user script manager, let me install it!)

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.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         AO3 Bookmark Improver
// @namespace    http://tampermonkey.net/
// @version      1.2
// @license      MIT
// @description  Bookmark a work directly from any page and go back from bookmarking to browsing quickly
// @author       sunkitten_shash
// @match        https://archiveofourown.org/*
// @match        http://archiveofourown.org/*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// ==/UserScript==

// Much of the popup settings code heavily references BrickGrass' Blanket Permission Highlighter
// (https://github.com/BrickGrass/Blanket-Permission-Highlighter)

// TODO: better working timeouts
// catching bookmarks on update
// also catching bookmarks in the background when you're viewing an already bookmarked work
// also possibly querying for them when saving new one?

const USE_SAVED_SCROLL_POS = true;

(function () {
  "use strict";

  // ---HTML AND CSS---

  // Styles for settings menu
  const css = `
    #bookmark-settings {
        position: fixed;
        z-index: 21;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        overflow: auto;
        background-color: rgba(0, 0, 0, 0.4);
    }
    #bookmark-settings-content {
        background-color: #fff;
        color: #2a2a2a;
        margin: 10% auto;
        padding: 1em;
        width: 500px;
    }
    #bookmark-settings-content form {
        margin: 1em auto;
    }
    #bookmark-settings a {
        color: #111;
    }
    #bookmark-settings a:hover {
        color: #999;
    }
    #bookmark-settings .progress {
        color: green;
        font-size: .75rem;
    }
    #bookmark-settings button {
        background: #eee;
        color: #444;
        width: auto;
        font-size: 100%;
        line-height: 1.286;
        height: 1.286em;
        vertical-align: middle;
        display: inline-block;
        padding: 0.25em 0.75em;
        white-space: nowrap;
        overflow: visible;
        position: relative;
        text-decoration: none;
        border: 1px solid #bbb;
        border-bottom: 1px solid #aaa;
        background-image: -moz-linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
        background-image: -webkit-linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
        background-image: -o-linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
        background-image: -ms-linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
        background-image: linear-gradient(#fff 2%,#ddd 95%,#bbb 100%);
        border-radius: 0.25em;
        box-shadow: none;
    }
    @media only screen and (max-width: 625px) {
        #bookmark-settings-content {
            width: 80%;
        }
    }`;

  const bookmark_settings_html = `
    <div id="bookmark-settings">
        <div id="bookmark-settings-content">
            <h2>Ao3 Bookmark Improver Settings</h2>
            <br><br>
            <button id="bookmark-update">Update bookmarks list</button>
            <p>For if you have created a lot of bookmarks in a different browser or when not running this script</p><br>
            <button id="bookmark-clear">Clear bookmarks list</button>
            <p>Clear your entire bookmarks list, for if you have deleted a lot of bookmarks, switched users, or the list seems wrong</p>
            <button id="bookmark-settings-close">Close</button>
        </div>
    </div>`;

  // ---GLOBAL VARIABLES---
  // variable that saves the scroll position on a page
  var scrollPos = 0;

  // cutoff variable for updating bookmarks
  let endOfPage = false;

  // abstracting out getting/setting the bookmark id lists into functions doesn't work
  // therefore their names are global variables for easier switching to new variables
  let workListName = "bookmarkWorkIds";
  let bookmarkListName = "bookmarkBookmarkIds";

  // ---SETTINGS PAGE CODE---
  GM.registerMenuCommand("AO3 Bookmark Improver Settings", function () {
    const settings_menu_exists = $("#bookmark-settings").length;
    if (settings_menu_exists) {
      console.log("settings already open");
      return;
    }

    $("body").prepend(bookmark_settings_html);

    $("#bookmark-update").click(updateBookmarkList);
    $("#bookmark-clear").click(clearBookmarks);

    $("#bookmark-settings-close").click(settings_close);
  });

  // close the settings dialog
  function settings_close() {
    $("#bookmark-settings").remove();

    window.location.reload();
  }

  // clear your entire bookmarks list
  async function clearBookmarks() {
    GM.setValue(workListName, []);
    GM.setValue(bookmarkListName, []);
    console.log("clear bookmarks");
    $("#bookmark-clear").after(
      `<p id="bookmark-clear-feedback" class="progress">Bookmark list cleared!</p>`,
    );
    await new Promise((resolve) => setTimeout(resolve, 5000));
    $("#bookmark-clear-feedback").remove();
  }

  async function getNewBookmarks(
    page,
    bookmarkWorkIds,
    bookmarkBookmarkIds,
    newBookmarksNum = 0,
  ) {
    let workLinks = $(
      "li[role=article] > .header > .heading:first-child > a:not([href*=users]):not([href*=gifts])",
      page,
    );
    let bookmarkLinks = $(
      `li[role=article] > .own > .actions > li > a:contains("Edit")`,
      page,
    );
    if (workLinks.length !== 0) {
      let work_id = "";

      // for each of the links in the page
      // see if the work id is already in your bookmarks
      // if it's not, add it to bookmarks
      for (let i = 0, workLink; i < workLinks.length; i++) {
        workLink = $(workLinks[i]);
        work_id = workLink.attr("href").split("/")[2];
        let bookmark_id = $(bookmarkLinks[i]).attr("href").split("/")[2];
        // if this work_id is not in the bookmarks list
        if (!bookmarkWorkIds.includes(work_id)) {
          bookmarkWorkIds.push(work_id);
          bookmarkBookmarkIds.push(bookmark_id);
          newBookmarksNum++;
        }
      } // end for loop
      console.log("new bookmarks num: " + newBookmarksNum);
      await GM.setValue(workListName, bookmarkWorkIds);
      await GM.setValue(bookmarkListName, bookmarkBookmarkIds);
      // if you have more than 10 deleted or unrevelead bookmarks in a page, well, sucks to be you I guess
      // otherwise good chance you've exhausted the bookmarks that you need to update, good job
    } // endif

    return newBookmarksNum;
  }

  // Goes through all of the user's bookmarks until it reaches the end or until there's < n (n being 10 here)
  // new bookmarks on a page, at which point it figures it's updated enough and terminates
  // This runs slowly. There's a 5 second pause in between requesting each page, and if there's an error, it waits
  // 5 minutes (since it assumes that means rate limiting)
  async function updateBookmarkList() {
    // get your userId from the little greeting in the corner
    let userId = $("#greeting > .user > .dropdown > .dropdown-toggle")
      .attr("href")
      .split("/")[2];
    let pageNum = 1;
    let newBookmarksNum = 0;
    endOfPage = false;

    let bookmarkWorkIds = await GM.getValue(workListName, []);
    let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);
    console.log("initial bookmarks length: " + bookmarkWorkIds.length);

    let errorCount = 0;

    $("button#bookmark-update").after(
      `<p id="bookmark-update-progress" class="progress">On page ${pageNum}...</p>`,
    );
    // loop to go through all the bookmark pages that have new-to-us bookmarks
    while (!endOfPage) {
      //while (counter < 3) {
      // didn't feel like wrapping the entire thing in a timeout or making another function for it
      // bc I'm lazy, so here's just a timeout for 5 secs
      // this doesn't usually run into rate limiting for me
      await new Promise((resolve) => setTimeout(resolve, 5000));
      try {
        await $.get(
          `https://archiveofourown.org/users/${userId}/bookmarks?page=${pageNum}`,
          async (data) => {
            console.log(pageNum);
            /*if ($("#bookmark-update-progress").length > 0) {
                        console.log("already there");
                        console.log($("#bookmark-update-progress"));
                        $("#bookmark-update-progress").innerText = `page num: ${pageNum}`;
                    } else {
                        console.log("not there");
                        $("button#bookmark-update").after(`<p id="bookmark-update-progress" style="font-size:.75rem; color:green">page num: ${pageNum}</p>`);
                    }*/
            $("#bookmark-update-progress").text(`On page ${pageNum}...`);
            console.log(`On page ${pageNum}...`);
            //$("button#bookmark-update").after(`<p id="bookmark-update-progress" style="font-size:.5rem">page num: ${pageNum}</p>`);
            //console.log($("button#bookmark-update"));
            await getNewBookmarks(
              data,
              bookmarkWorkIds,
              bookmarkBookmarkIds,
              newBookmarksNum,
            );
            if (newBookmarksNum < 10) {
              console.log(
                `Terminating bookmarks list update on ${pageNum} with ${newBookmarksNum} new bookmarks`,
              );
              endOfPage = true;
            }
            pageNum++;
            newBookmarksNum = 0;
          },
        ); // end the get thingy
      } catch (e) {
        // end try
        // if there's an error, wait five minutes cause it's probably rate limiting you
        errorCount++;
        console.log("Error requesting bookmarks page: " + e);
        await new Promise((resolve) => setTimeout(resolve, 300000));
        console.log("timeout resolved, trying again");
      }

      if (endOfPage) {
        console.log("Done updating bookmarks list");
        $("#bookmark-update-progress").text("Done updating bookmarks!");
        break;
      }

      if (errorCount > 5) {
        console.log(`Too many failures, quitting before page ${pageNum}`);
        endOfPage = true;
      }
    } // end while loop
    await GM.setValue(workListName, bookmarkWorkIds);
    await GM.setValue(bookmarkListName, bookmarkBookmarkIds);
    await new Promise((resolve) => setTimeout(resolve, 5000));
    $("#bookmark-update-progress").remove();
  }

  async function doFunctions(url) {
    if (url.includes("archiveofourown.org/bookmarks/")) {
      handleNewBookmark(url);
    }

    let userId = $("#greeting > .user > .dropdown > .dropdown-toggle")
      .attr("href")
      .split("/")[2];
    // this is the slightly nuclear option: if it's one of your pages, just don't show the bookmark button at all
    // it also eliminates all bookmark pages and lists of a user's collections (but not the list of works in the collection)
    // the reason we're eliminating all your pages is that they already have the edit navigation
    // section, so there's duplication
    if (
      !url.includes("/bookmarks") &&
      !(url.includes("users") && url.includes("collection"))
    ) {
      if (url.includes(userId)) {
        addYourSaveButtons(url);
      } else {
        addSaveButtons(url);
      }
    }

    if (url.includes(userId) && url.includes("/bookmarks")) {
      addGetFromPageButton();
    }
  }

  // get bookmarks from the page of your bookmarks that you are currently on and save them
  // this is good if you are getting very rate-limited
  async function getBookmarksFromPage() {
    console.log("get bookmarks from page");

    let bookmarkWorkIds = await GM.getValue(workListName, []);
    let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);
    console.log("initial bookmarks length: " + bookmarkWorkIds.length);

    const addBookmarksBtn = $("[id=bookmark_improver_get_bookmarks")[0];
    addBookmarksBtn.innerText = "Getting bookmarks...";

    await getNewBookmarks(document.body, bookmarkWorkIds, bookmarkBookmarkIds);

    // TODO: how to disable?
    addBookmarksBtn.innerText = "Done!";
  }

  async function addGetFromPageButton() {
    const bookmarkExternalBtn = $("ul.navigation.actions li").filter(
      (index, el) => {
        return $(el).text() === "Bookmark External Work";
      },
    )[0];
    console.log({ bookmarkExternalBtn });
    const button = document.createElement("button");
    button.onclick = getBookmarksFromPage;
    button.className = "actions";
    console.log({ button });
    button.innerText = "Get bookmarks from this page";
    button.id = "bookmark_improver_get_bookmarks";
    const listItem = document.createElement("li");
    $(listItem).append(button);
    console.log({ listItem });
    $(bookmarkExternalBtn).after(listItem);
  }

  // adds buttons to create bookmarks on pages w/ your works
  async function addYourSaveButtons(url) {
    let bookmarkWorkIds = await GM.getValue(workListName, []);
    let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);

    for (
      var i = 0,
        link,
        links = $(
          "li[role=article] > .header > .heading:first-child > a:not([href*=users]):not([href*=gifts])",
        );
      i < links.length;
      i++
    ) {
      let link = $(links[i]);
      let work_id = link.attr("href").split("/")[2];
      let btnText = "Save";
      let urlModifier = `works/${work_id}/bookmarks/new`;
      if (bookmarkWorkIds.includes(work_id)) {
        let index = bookmarkWorkIds.indexOf(work_id);
        let bookmark_id = bookmarkBookmarkIds[index];
        btnText = "Saved";
        urlModifier = `bookmarks/${bookmark_id}/edit`;
      }
      link
        .closest(".header")
        .nextAll(".stats")
        .after(
          `<ul class="actions" role="navigation"> <li> <a id="bookmark_form_trigger_` +
            work_id +
            `" data-remote="true" href="https://archiveofourown.org/` +
            urlModifier +
            `">${btnText}</a> </li> </ul>`,
        );

      // when you click on this work's bookmark form trigger, it adds a div after it and loads in the bookmark form part
      // of the bookmark page
      $("#bookmark_form_trigger_" + work_id).click(function () {
        link
          .closest(".header")
          .nextAll(".actions")
          .after("<div id='bookmark_ext_div'></div>");
        $("#bookmark_ext_div").load(
          `https://archiveofourown.org/${urlModifier} #bookmark-form`,
          () => {
            $("legend:contains('Bookmark')").after(
              `<p class="close actions"><a id="bookmark-form-close">×</a></p>`,
            );

            $("#bookmark-form-close").click(() =>
              $("#bookmark_ext_div").remove(),
            );
          },
        );
      });
    }

    $("a[id^=bookmark_form_trigger]").click(saveScrollPos);
  }

  // adds the buttons to create bookmarks
  async function addSaveButtons(url) {
    let bookmarkWorkIds = await GM.getValue(workListName, []);
    let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);

    for (
      var i = 0, link, links = $("li.work[role=article]");
      i < links.length;
      i++
    ) {
      let link = $(links[i]);
      link.addClass("bookmark");
    }

    // go through all of the works on the page
    // and add the save/saved button and its functionality
    for (
      var i = 0,
        link,
        links = $(
          "li[role=article]:not([id*=bookmark]) > .header > .heading:first-child > a:not([href*=users]):not([href*=gifts])",
        );
      i < links.length;
      i++
    ) {
      let link = $(links[i]);
      let work_id = link.attr("href").split("/")[2];
      let btnText = "Save";
      let urlModifier = `works/${work_id}/bookmarks/new`;
      // if this is already bookmarked
      // then we need to indicate that and put in the proper link to edit the bookmark
      if (bookmarkWorkIds.includes(work_id)) {
        let index = bookmarkWorkIds.indexOf(work_id);
        let bookmark_id = bookmarkBookmarkIds[index];
        btnText = "Saved";
        urlModifier = `bookmarks/${bookmark_id}/edit`;
      }
      link
        .closest(".header")
        .nextAll(".stats")
        .after(
          `<ul class="actions" role="navigation"> <li> <a id="bookmark_form_trigger_` +
            work_id +
            `" data-remote="true" href="https://archiveofourown.org/` +
            urlModifier +
            `">${btnText}</a> </li> </ul>`,
        );

      // when you click on this work's bookmark form trigger, it adds a div after it and loads in the bookmark form part
      // of the bookmark page
      $("#bookmark_form_trigger_" + work_id).click(function () {
        link
          .closest(".header")
          .nextAll(".actions")
          .after("<div id='bookmark_ext_div'></div>");
        $("#bookmark_ext_div").load(
          `https://archiveofourown.org/${urlModifier} #bookmark-form`,
          () => {
            $("legend:contains('Bookmark')").after(
              `<p class="close actions"><a id="bookmark-form-close">×</a></p>`,
            );

            $("#bookmark-form-close").click(() =>
              $("#bookmark_ext_div").remove(),
            );
          },
        );
      });
    } // end for loop

    // after we've loaded in all our bookmark form triggers, set the onclick to save the scroll position for the back button
    $("a[id^=bookmark_form_trigger]").click(saveScrollPos);
  } // end addSaveButtons

  // on the dedicated bookmark page (usually when creating/updating a bookmark)
  // first of all, if it has the flash notice to show that it's a newly created bookmark
  // then get both the work id and the bookmark id and add them to the global list of created bookmarks
  // second, it also adds the back button to get back to where you were browsing
  async function handleNewBookmark(url) {
    let bookmarkWorkIds = await GM.getValue(workListName, []);
    let bookmarkBookmarkIds = await GM.getValue(bookmarkListName, []);

    let links = $(
      "li[role=article] > .header > .heading:first-child > a:not([href*=users])",
    );
    let link = $(links[0]);
    let work_id = link.attr("href").split("/")[2];
    let bookmark_id = url.split("/")[4];

    if (!bookmarkWorkIds.includes(work_id)) {
      console.log("this is a new work bookmarked!");
      bookmarkWorkIds.push(work_id);
      bookmarkBookmarkIds.push(bookmark_id);
      GM.setValue(workListName, bookmarkWorkIds);
      GM.setValue(bookmarkListName, bookmarkBookmarkIds);
    }
    // back button
    addButton();
  }

  // add back button which redirects to the previous page
  // for "you created a new bookmark" or "you updated this bookmark" pages
  function addButton() {
    $(".bookmarks-show > .navigation").prepend(
      `<li><a href="${document.referrer}" id="backButton">← Go Back</a></li>`,
    );
  }

  // scroll to saved position on page
  async function scrollToPos() {
    scrollPos = await GM.getValue("scroll");
    window.scrollTo(0, scrollPos);
  }

  // save the current position that the page is scrolled to
  function saveScrollPos() {
    scrollPos = window.scrollY;
    GM.setValue("scroll", scrollPos);
  }

  // when the page loads
  $(document).ready(function () {
    let url = window.location.href;

    // add custom CSS for settings menu
    let head = document.getElementsByTagName("head")[0];
    if (head) {
      let style = document.createElement("style");
      style.setAttribute("type", "text/css");
      style.textContent = css;
      head.appendChild(style);
    }

    // if we're coming from a bookmarks page and the setting is set, scroll to your previous position on the page
    if (
      document.referrer.includes("archiveofourown.org/bookmarks/") &&
      USE_SAVED_SCROLL_POS
    ) {
      scrollToPos();
    }

    doFunctions(url);
  });
})();