AO3 Bookmark Improver

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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