Last active
March 17, 2026 03:37
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", | |
| "formatter": { | |
| "indentStyle": "space", | |
| "lineWidth": 120 | |
| }, | |
| "javascript": { | |
| "formatter": { | |
| "semicolons": "asNeeded" | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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