Skip to content

Instantly share code, notes, and snippets.

@RedHatter
Last active March 17, 2026 03:37
Show Gist options
  • Select an option

  • Save RedHatter/081d7efe9ec89969b6a3cba049dc3444 to your computer and use it in GitHub Desktop.

Select an option

Save RedHatter/081d7efe9ec89969b6a3cba049dc3444 to your computer and use it in GitHub Desktop.
Automatically adds a checkmark to editions that have already been edited and saved. Adds a button that navigates to the next unchecked edition. Adds a button to manually add a checkmark to books.
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"formatter": {
"indentStyle": "space",
"lineWidth": 120
},
"javascript": {
"formatter": {
"semicolons": "asNeeded"
}
}
}
// ==UserScript==
// @name Hardcover.app: Edition checkmark
// @namespace https://gist.github.com/RedHatter/
// @match https://hardcover.app/*
// @version 1.8.3
// @author Ava Johnson (ava.johnson@zohomail.com)
// @description Automatically adds a checkmark to editions that have already been edited and saved. Adds a button that navigates to the next unchecked edition. Adds a button to manually add a checkmark to books.
// @license MIT
// @downloadURL https://gist.github.com/RedHatter/081d7efe9ec89969b6a3cba049dc3444/raw/hardcover-app-edition-checkmark.user.js
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
// ==/UserScript==
async function getEditions(editionId) {
let authorization = await GM.getValue("authorization")
if (!authorization) {
authorization = prompt(
"Your API key from https://hardcover.app/account/api is needed in order to fetch the editions list. Please paste it below.",
)?.trim()
if (!authorization) return
authorization = authorization.startsWith("Bearer ") ? authorization : "Bearer " + authorization
GM.setValue("authorization", authorization)
}
const data = JSON.stringify({
query: `{
editions(where: {id: {_eq: ${editionId}}}) {
book {
editions(where: {canonical_id: {_is_null: true}}) {
id
}
}
}
}`,
})
const response = await fetch("https://api.hardcover.app/v1/graphql", {
method: "post",
body: data,
headers: {
"Content-Type": "application/json",
"Content-Length": data.length,
authorization,
},
})
const json = await response.json()
if (json.errors) {
console.error("GraphQL errors", json.errors)
return
}
return json.data.editions[0].book.editions
}
function editPage(editionId) {
const button = document
.evaluate('//button[contains(., "Update Edition")]', document, null, XPathResult.ANY_TYPE, null)
.iterateNext()
const link = document
.evaluate('//a[contains(., "Back to Editions")]', document, null, XPathResult.ANY_TYPE, null)
.iterateNext()
if (!button || !link) return false
button.addEventListener("click", () => GM.setValue(editionId, true))
const next = document.createElement("a")
next.className = "text-sm dark:text-gray-400 text-gray-700 hover:underline cursor-pointer"
const remaining = new URLSearchParams(window.location.search).get("remaining")
next.append(remaining && remaining !== "0" ? `Next edition (${remaining}) →` : "Next edition →")
next.addEventListener("click", async () => {
const editions = await getEditions(editionId)
const resolved = await Promise.all(
editions.map(async (edition) =>
(await GM.getValue(edition.id)) || edition.id.toString() === editionId ? undefined : edition.id,
),
)
const checked = resolved.filter(Boolean)
if (checked[0]) {
document.location.href = `/editions/${checked[0]}/edit?remaining=${checked.length - 1}`
} else {
alert("All editions edited")
document.location.href = link.href
}
})
link.parentNode.className = "flex justify-between"
link.parentNode.append(next)
return true
}
function editionsPage() {
const links = document.querySelectorAll("a[href*='/editions/']")
if (!links.length) return false
for (const link of links) {
if (link.href.endsWith("/new")) continue
const edition = link.href.substring(link.href.indexOf("/editions/") + "/editions/".length)
GM.getValue(edition, false).then((checked) => {
if (checked) link.parentNode.append(" ✓")
})
}
return true
}
function bookPage(slug) {
const elements = document.querySelectorAll("button[aria-label='Change status']")
if (!elements.length) return false
for (const button of document.querySelectorAll(".greasemonkey-book-checkmark")) {
button.remove()
}
for (let element of elements) {
const button = document.createElement("button")
button.append("✓")
button.className =
"greasemonkey-book-checkmark cursor-pointer rounded-lg active:translate-y-1 transition-all text-foreground hover:bg-tertiary p-2 mx-2"
button.addEventListener("click", async () => {
if (await GM.getValue(slug, false)) {
GM.deleteValue(slug)
button.style.opacity = "0.5"
} else {
GM.setValue(slug, true)
button.style.opacity = "1"
}
})
do {
element = element.parentNode
} while (!(element.classList.length === 0 || element.className === "flex"))
element.classList.add("flex")
element.append(button)
GM.getValue(slug, false).then((checked) => {
if (!checked) button.style.opacity = "0.5"
})
}
return true
}
function listPage() {
const links = document.querySelectorAll("a.no-underline[href*='/books/']")
if (!links.length) return false
for (const link of links) {
const slug = link.href.substring(link.href.indexOf("/books/") + "/books/".length)
GM.getValue(slug, false).then((checked) => {
if (!checked) return
let p = link
do {
p = p.parentNode
} while (!p.classList.contains("grow"))
p = p.querySelector("p.font-semibold")
if (p.querySelector("span.checkmark")) return
const span = document.createElement("span")
span.className = "checkmark text-gray-900 dark:text-white"
span.append("✓")
p.classList.remove("mt-2")
p.parentNode.classList.remove("items-end")
p.prepend(span, document.createElement("br"))
})
}
return true
}
function searchPage() {
const links = document.querySelectorAll("a[href^='/books/']")
if (!links.length) return false
for (const link of links) {
const slug = link.href.substring(link.href.indexOf("/books/") + "/books/".length)
GM.getValue(slug, false).then((checked) => {
if (checked) link.querySelector("span.text-lg").append(" ✓")
})
}
return true
}
const bookPattern = /^https:\/\/hardcover\.app\/books\/(?<slug>[^/]+)(?:\/[^/]+)?$/
const listPattern = /^https:\/\/hardcover\.app\/(?:authors|series|@[^/]+\/lists)\/[^/]+$/
const searchPattern = /^https:\/\/hardcover\.app\/search\?q=[^/]+$/
const editPattern = /^https:\/\/hardcover\.app(?:\/books\/[^/]+)?\/editions\/(?<edition>[^/]+)\/edit[^/]*$/
const editionsPattern = /^https:\/\/hardcover\.app\/books\/[^/]+\/editions[^/]*(?:\/[^/]+)?$/
let url = null
window.navigation.addEventListener("navigate", (e) => {
if (e.destination.url === url) return
url = e.destination.url
const slug = bookPattern.exec(url)?.groups.slug
if (slug) {
VM.observe(document.body, () => bookPage(slug))
} else if (listPattern.test(url)) {
VM.observe(document.body, listPage)
} else if (searchPattern.test(url)) {
VM.observe(document.body, searchPage)
}
const edition = editPattern.exec(url)?.groups.edition
if (edition) {
VM.observe(document.body, () => editPage(edition))
} else if (editionsPattern.test(url)) {
VM.observe(document.body, editionsPage)
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment