// ==UserScript==
// @name AO3: Chapter Shortcuts
// @version 2.6
// @description Add shortcuts for first and last chapters on AO3 works. Customize the latest chapter symbol on work titles.
// @author BlackBatCat
// @license MIT
// @match *://archiveofourown.org/
// @match *://archiveofourown.org/tags/*
// @match *://archiveofourown.org/works*
// @match *://archiveofourown.org/works?*
// @match *://archiveofourown.org/chapters/*
// @match *://archiveofourown.org/users/*
// @match *://archiveofourown.org/collections*
// @match *://archiveofourown.org/bookmarks*
// @match *://archiveofourown.org/series/*
// @require https://update.greasyfork.icu/scripts/554170/1693013/AO3%3A%20Menu%20Helpers%20Library%20v2.js?v=2.1.6
// @grant none
// @namespace https://greasyfork.org/users/1498004
// @downloadURL https://update.greasyfork.icu/scripts/549571/AO3%3A%20Chapter%20Shortcuts.user.js
// @updateURL https://update.greasyfork.icu/scripts/549571/AO3%3A%20Chapter%20Shortcuts.meta.js
// ==/UserScript==
(function () {
"use strict";
// Wait for library to load
if (!window.AO3MenuHelpers) {
console.error("[AO3: Chapter Shortcuts] Menu Helpers library not loaded!");
return;
}
const helpers = window.AO3MenuHelpers;
// --- SETTINGS STORAGE ---
const CHAPTER_SHORTCUTS_CONFIG_KEY = "ao3_chapter_shortcuts_config";
const DEFAULT_CHAPTER_SHORTCUTS_CONFIG = {
lastChapterSymbol: "»",
hideMenuOptions: false,
enableBottomButtons: true,
hideEntireWorkButton: false,
hideShareButton: false,
hideDownloadButton: false,
};
let CHAPTER_SHORTCUTS_CONFIG = { ...DEFAULT_CHAPTER_SHORTCUTS_CONFIG };
function loadChapterShortcutsConfig() {
try {
const saved = localStorage.getItem(CHAPTER_SHORTCUTS_CONFIG_KEY);
if (saved) {
CHAPTER_SHORTCUTS_CONFIG = {
...DEFAULT_CHAPTER_SHORTCUTS_CONFIG,
...JSON.parse(saved),
};
}
} catch (e) {
console.error("Error loading config:", e);
}
}
function saveChapterShortcutsConfig() {
try {
localStorage.setItem(
CHAPTER_SHORTCUTS_CONFIG_KEY,
JSON.stringify(CHAPTER_SHORTCUTS_CONFIG)
);
} catch (e) {
console.error("Error saving config:", e);
}
}
// --- SETTINGS MENU ---
function showChapterShortcutsMenu() {
// Remove any existing dialogs
helpers.removeAllDialogs();
// Create dialog
const dialog = helpers.createDialog("🏃🏻 Chapter Shortcuts 🏃🏻", {
maxWidth: "500px",
});
// Add separator
const separator = document.createElement("hr");
separator.style.cssText =
"margin: 10px 0; border: none; border-top: 1px solid inherit;";
dialog.appendChild(separator);
// Create preset buttons section
const presetGroup = helpers.createSettingGroup();
presetGroup.appendChild(
helpers.createLabel("Choose a symbol for the Last Chapter button:")
);
const presetSymbols = ["»", "➼", "➺", "✦", "♥", "✿", "ɞɞ"];
const presetButtons = presetSymbols.map((symbol) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "preset-symbol";
btn.dataset.symbol = symbol;
btn.textContent = symbol;
btn.style.cssText =
"font-family: inherit; font-size: inherit; color: inherit;";
return btn;
});
const buttonContainer = helpers.createHorizontalLayout(presetButtons, {
gap: "10px",
});
buttonContainer.style.marginBottom = "10px";
presetGroup.appendChild(buttonContainer);
dialog.appendChild(presetGroup);
// Create custom input
const customInput = helpers.createTextInput({
id: "custom-symbol",
label: "Or enter your own:",
value: CHAPTER_SHORTCUTS_CONFIG.lastChapterSymbol,
placeholder: "",
});
customInput.querySelector("#custom-symbol").maxLength = 4;
dialog.appendChild(customInput);
// Add preset button click handlers
presetButtons.forEach((btn) => {
btn.addEventListener("click", () => {
document.getElementById("custom-symbol").value = btn.dataset.symbol;
});
});
// Create enable bottom buttons checkbox
const enableBottomCheckbox = helpers.createCheckbox({
id: "enable-bottom-buttons",
label: "Enable bottom navigation buttons",
checked: CHAPTER_SHORTCUTS_CONFIG.enableBottomButtons,
});
dialog.appendChild(enableBottomCheckbox);
// Create hide buttons subsettings
const hideButtonsSubsettings = helpers.createSubsettings();
const hideEntireWorkCheckbox = helpers.createCheckbox({
id: "hide-entire-work-button",
label: "Entire Work",
checked: CHAPTER_SHORTCUTS_CONFIG.hideEntireWorkButton,
});
hideButtonsSubsettings.appendChild(hideEntireWorkCheckbox);
const hideShareCheckbox = helpers.createCheckbox({
id: "hide-share-button",
label: "Share",
checked: CHAPTER_SHORTCUTS_CONFIG.hideShareButton,
});
hideButtonsSubsettings.appendChild(hideShareCheckbox);
const hideDownloadCheckbox = helpers.createCheckbox({
id: "hide-download-button",
label: "Download",
checked: CHAPTER_SHORTCUTS_CONFIG.hideDownloadButton,
});
hideButtonsSubsettings.appendChild(hideDownloadCheckbox);
// Create hide buttons conditional checkbox with nested options
const hideButtonsCheckbox = helpers.createConditionalCheckbox({
id: "hide-buttons-option",
label: "Hide buttons on work pages",
checked: CHAPTER_SHORTCUTS_CONFIG.hideEntireWorkButton || CHAPTER_SHORTCUTS_CONFIG.hideShareButton || CHAPTER_SHORTCUTS_CONFIG.hideDownloadButton,
subsettings: hideButtonsSubsettings,
});
dialog.appendChild(hideButtonsCheckbox);
// Create hide menu checkbox
const hideMenuCheckbox = helpers.createCheckbox({
id: "hide-menu-option",
label: "Hide menu option",
checked: CHAPTER_SHORTCUTS_CONFIG.hideMenuOptions,
});
dialog.appendChild(hideMenuCheckbox);
// Create button group
const buttons = helpers.createButtonGroup([
{
text: "Save",
id: "chapter-shortcuts-save",
primary: true,
onClick: () => {
CHAPTER_SHORTCUTS_CONFIG.lastChapterSymbol =
helpers.getValue("custom-symbol") || "»";
CHAPTER_SHORTCUTS_CONFIG.hideMenuOptions = helpers.getValue("hide-menu-option");
CHAPTER_SHORTCUTS_CONFIG.enableBottomButtons = helpers.getValue("enable-bottom-buttons");
CHAPTER_SHORTCUTS_CONFIG.hideEntireWorkButton = helpers.getValue("hide-entire-work-button");
CHAPTER_SHORTCUTS_CONFIG.hideShareButton = helpers.getValue("hide-share-button");
CHAPTER_SHORTCUTS_CONFIG.hideDownloadButton = helpers.getValue("hide-download-button");
saveChapterShortcutsConfig();
dialog.remove();
addChapterButtons(true);
hideWorkPageButtons();
},
},
{
text: "Cancel",
id: "chapter-shortcuts-cancel",
onClick: () => {
dialog.remove();
},
},
]);
dialog.appendChild(buttons);
// Add to page
document.body.appendChild(dialog);
// Close on background click
dialog.addEventListener("click", (e) => {
if (e.target === dialog) dialog.remove();
});
}
// --- HIDE WORK PAGE BUTTONS ---
function hideWorkPageButtons() {
// Hide Entire Work button
if (CHAPTER_SHORTCUTS_CONFIG.hideEntireWorkButton) {
document.querySelectorAll("li.chapter.entire").forEach((el) => {
el.style.display = "none";
});
}
// Hide Share button
if (CHAPTER_SHORTCUTS_CONFIG.hideShareButton) {
document
.querySelectorAll('a.modal[title="Share Work"]')
.forEach((el) => {
el.parentElement.style.display = "none";
});
}
// Hide Download button
if (CHAPTER_SHORTCUTS_CONFIG.hideDownloadButton) {
document.querySelectorAll("li.download").forEach((el) => {
el.style.display = "none";
});
}
}
// --- GET STORY ID ---
function getStoryId() {
const match = window.location.pathname.match(/works\/(\d+)/);
if (match !== null) {
return match[1];
}
const chapterForm = document.querySelector("#chapter_index li form");
if (chapterForm && chapterForm.getAttribute("action")) {
const actionMatch = chapterForm
.getAttribute("action")
.match(/works\/(\d+)/);
if (actionMatch) {
return actionMatch[1];
}
}
return null;
}
// --- ADD CHAPTER BUTTONS & LINKS ---
function addChapterButtons(forceRerender = false) {
// Remove any previous custom links/buttons if rerendering
if (forceRerender) {
document
.querySelectorAll("#go_to_last_chap, #go_to_first_chap, #go_to_last_chap_bottom, #go_to_first_chap_bottom")
.forEach((el) => el.remove());
document
.querySelectorAll(".ao3-last-chapter-link")
.forEach((el) => el.remove());
}
// Check if we're on a work page with chapter navigation
const workNav = document.querySelector("ul.work");
const navList = document.querySelector("ul.work.navigation.actions");
const indexList = document.querySelector("ul.index");
const hasNext = navList && navList.querySelector("li.next");
const hasPrev = navList && navList.querySelector("li.previous");
if (workNav && !indexList) {
// Insert First Chapter button before Last Chapter button (top nav)
let firstChapterBtn = null;
let lastChapterBtn = null;
if (hasPrev) {
firstChapterBtn = document.createElement("li");
firstChapterBtn.id = "go_to_first_chap";
firstChapterBtn.innerHTML = "First Chapter";
firstChapterBtn.addEventListener("click", function () {
window.location.href = `/works/${getStoryId()}`;
});
workNav.prepend(firstChapterBtn);
}
if (hasNext) {
lastChapterBtn = document.createElement("li");
lastChapterBtn.id = "go_to_last_chap";
lastChapterBtn.innerHTML = `Last Chapter`;
lastChapterBtn.addEventListener("click", function () {
const select = document.querySelector("#selected_id");
if (select && select.options.length > 0) {
const lastChapterId =
select.options[select.options.length - 1].value;
window.location.href = `/works/${getStoryId()}/chapters/${lastChapterId}`;
}
});
if (firstChapterBtn && firstChapterBtn.nextSibling) {
firstChapterBtn.insertAdjacentElement('afterend', lastChapterBtn);
} else {
workNav.prepend(lastChapterBtn);
}
}
}
// Insert bottom navigation buttons using the beta approach
const actionsUl = document.querySelector('#feedback ul.actions');
if (actionsUl && CHAPTER_SHORTCUTS_CONFIG.enableBottomButtons && workNav && !indexList) {
// Remove any previously added bottom buttons
actionsUl.querySelectorAll('#go_to_first_chap_bottom, #go_to_last_chap_bottom').forEach(el => el.remove());
const topLi = actionsUl.querySelector('li a[href="#main"]');
if (topLi && topLi.parentElement) {
let insertAfter = topLi.parentElement;
// Always insert First Chapter before Last Chapter
let firstChapterBtn = null;
let lastChapterBtn = null;
if (hasPrev) {
firstChapterBtn = document.createElement("li");
firstChapterBtn.id = "go_to_first_chap_bottom";
firstChapterBtn.innerHTML = "First Chapter";
firstChapterBtn.addEventListener("click", function () {
window.location.href = `/works/${getStoryId()}`;
});
insertAfter.insertAdjacentElement('afterend', firstChapterBtn);
insertAfter = firstChapterBtn;
}
if (hasNext) {
lastChapterBtn = document.createElement("li");
lastChapterBtn.id = "go_to_last_chap_bottom";
lastChapterBtn.innerHTML = `Last Chapter`;
lastChapterBtn.addEventListener("click", function () {
const select = document.querySelector("#selected_id");
if (select && select.options.length > 0) {
const lastChapterId = select.options[select.options.length - 1].value;
window.location.href = `/works/${getStoryId()}/chapters/${lastChapterId}`;
}
});
insertAfter.insertAdjacentElement('afterend', lastChapterBtn);
insertAfter = lastChapterBtn;
}
}
}
// Add last chapter links to work listings
if (document.querySelector(".header h4.heading")) {
const headings = document.querySelectorAll(".header h4.heading");
headings.forEach((heading) => {
const link = heading.querySelector("a");
if (link) {
const storyPath = link.getAttribute("href");
const match = storyPath.match(/works\/(\d+)/);
if (match) {
const storyId = match[1];
fetch(`/works/${storyId}/navigate`)
.then((response) => response.text())
.then((data) => {
const parser = new DOMParser();
const doc = parser.parseFromString(data, "text/html");
const lastChapterLink = doc.querySelector("ol li:last-child a");
if (lastChapterLink) {
const lastChapterPath = lastChapterLink.getAttribute("href");
const lastChapterEl = document.createElement("a");
lastChapterEl.href = lastChapterPath;
lastChapterEl.title = "Jump to last chapter";
lastChapterEl.textContent = ` ${
CHAPTER_SHORTCUTS_CONFIG.lastChapterSymbol || "»"
}`;
lastChapterEl.className = "ao3-last-chapter-link";
heading.appendChild(lastChapterEl);
}
})
.catch((error) =>
console.error("Error fetching chapter data:", error)
);
}
}
});
}
}
// --- INITIALIZATION ---
loadChapterShortcutsConfig();
// Show startup message
console.log("[AO3: Chapter Shortcuts] loaded.");
// Add to shared menu using library helper (conditionally)
const isMainPage = window.location.href === "https://archiveofourown.org/" || window.location.href === "https://archiveofourown.org";
if (!CHAPTER_SHORTCUTS_CONFIG.hideMenuOptions || isMainPage) {
helpers.addToSharedMenu({
id: "opencfg_chapter_shortcuts",
text: "Chapter Shortcuts",
onClick: showChapterShortcutsMenu,
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
addChapterButtons();
hideWorkPageButtons();
setupBottomNavObserver();
});
} else {
addChapterButtons();
hideWorkPageButtons();
setupBottomNavObserver();
}
// Setup observer for bottom navigation
function setupBottomNavObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
// Check if the added node is the bottom nav ul or contains it
const bottomNav = node.querySelector && node.querySelector('ul.actions a[href="#main"]') ? node.querySelector('ul.actions') :
node.matches && node.matches('ul.actions a[href="#main"]') ? node : null;
if (bottomNav) {
// Add bottom buttons if enabled and on work page
const workNav = document.querySelector("ul.work");
const indexList = document.querySelector("ul.index");
if (workNav && !indexList && CHAPTER_SHORTCUTS_CONFIG.enableBottomButtons) {
const topLink = bottomNav.querySelector('a[href="#main"]');
if (topLink) {
const topLi = topLink.parentElement;
const navList = document.querySelector("ul.work.navigation.actions");
const hasNext = navList && navList.querySelector("li.next");
const hasPrev = navList && navList.querySelector("li.previous");
// Add First Chapter button if not on the first chapter
if (hasPrev) {
const firstChapterBtnBottom = document.createElement("li");
firstChapterBtnBottom.id = "go_to_first_chap_bottom";
firstChapterBtnBottom.innerHTML = "First Chapter";
firstChapterBtnBottom.addEventListener("click", function () {
window.location.href = `/works/${getStoryId()}`;
});
topLi.after(firstChapterBtnBottom);
}
// Add Last Chapter button if not on the last chapter
if (hasNext) {
const lastChapterBtnBottom = document.createElement("li");
lastChapterBtnBottom.id = "go_to_last_chap_bottom";
lastChapterBtnBottom.innerHTML = `Last Chapter`;
lastChapterBtnBottom.addEventListener("click", function () {
const select = document.querySelector("#selected_id");
if (select && select.options.length > 0) {
const lastChapterId =
select.options[select.options.length - 1].value;
window.location.href = `/works/${getStoryId()}/chapters/${lastChapterId}`;
}
});
// Insert after first chapter or after top
const firstBtn = bottomNav.querySelector("#go_to_first_chap_bottom");
if (firstBtn) {
firstBtn.after(lastChapterBtnBottom);
} else {
topLi.after(lastChapterBtnBottom);
}
}
}
}
}
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
})();