Last active
April 11, 2026 23:14
-
-
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.4 | |
| // @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}}, order_by: {id: asc}) { | |
| 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(., "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 = "self-end text-sm dark:text-gray-400 text-gray-700 hover:underline cursor-pointer" | |
| const params = new URLSearchParams(window.location.search) | |
| const remaining = parseInt(params.get("remaining"), 10) || 0 | |
| next.append(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 unchecked = resolved.filter(Boolean) | |
| const index = (parseInt(params.get("index"), 10) || 0) % unchecked.length || 0 | |
| if (unchecked[index]) { | |
| document.location.href = (await GM.getValue(editionId)) | |
| ? `/editions/${unchecked[index]}/edit?index=${index}&remaining=${unchecked.length - 1}` | |
| : `/editions/${unchecked[index]}/edit?index=${index + 1}&remaining=${unchecked.length}` | |
| } else { | |
| alert("All editions edited") | |
| document.location.href = link.href | |
| } | |
| }) | |
| const parent = button.parentNode.parentNode | |
| parent.classList.add("flex") | |
| parent.classList.add("flex-col") | |
| button.parentNode.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\/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