Instantly share code, notes, and snippets.
Last active
March 8, 2026 03:36
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save RedHatter/c716ecb4b7f84872c644126977a3fb3d to your computer and use it in GitHub Desktop.
Greasemonkey script for Hardcover.app to copy "Authors & Contributions" from one edition to another.
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: Copy authors | |
| // @namespace https://gist.github.com/RedHatter/ | |
| // @match https://hardcover.app/* | |
| // @version 1.1 | |
| // @author Ava Johnson (ava.johnson@zohomail.com) | |
| // @description Copies "Authors & Contributions" from one edition to another. | |
| // @license MIT | |
| // @downloadURL https://gist.github.com/RedHatter/c716ecb4b7f84872c644126977a3fb3d/raw/hardcover-app-copy-authors.user.js | |
| // @grant GM.getValue | |
| // @grant GM.setValue | |
| // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2 | |
| // ==/UserScript== | |
| let authorization = null | |
| async function getAuthorization() { | |
| if (authorization) return authorization | |
| authorization = await GM.getValue("authorization") | |
| if (!authorization) { | |
| authorization = prompt( | |
| '"Hardcover.app: Copy author" needs your API key from https://hardcover.app/account/api in order to function. Please paste it below.', | |
| ).trim() | |
| authorization = authorization.startsWith("Bearer ") ? authorization : "Bearer " + authorization | |
| if (!authorization) return | |
| GM.setValue("authorization", authorization) | |
| } | |
| return authorization | |
| } | |
| async function getAuthors() { | |
| const authorization = await getAuthorization() | |
| const editionId = prompt( | |
| "WARNING: This will erase any unsaved changes.\n\nWhat is the ID of the edition you would like to copy from?", | |
| await GM.getValue("editionId"), | |
| ) | |
| if (!editionId) return | |
| GM.setValue("editionId", editionId) | |
| const data = JSON.stringify({ | |
| query: `{ | |
| editions(where: {id: {_eq: ${editionId}}}) { | |
| contributions { | |
| author_id | |
| contribution | |
| } | |
| } | |
| }`, | |
| }) | |
| 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 in getAuthors", json.errors) | |
| return | |
| } | |
| return json.data.editions[0].contributions | |
| } | |
| async function updateAuthors(contributions) { | |
| const authorization = await getAuthorization() | |
| const url = document.location.href | |
| const editionId = parseInt( | |
| url.substring(url.indexOf("/editions/") + "/editions/".length, url.length - "/edit".length), | |
| 10, | |
| ) | |
| const data = JSON.stringify({ | |
| query: ` | |
| mutation ($editionId: Int!, $contributions: [ContributionInputType!]!) { | |
| update_edition(edition: {dto: {contributions: $contributions}}, id: $editionId) { | |
| errors | |
| } | |
| }`, | |
| variables: { | |
| contributions, | |
| editionId, | |
| }, | |
| }) | |
| 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 in updateAuthors", json.errors) | |
| return | |
| } | |
| } | |
| function buildButton() { | |
| const button = document.createElement("button") | |
| button.addEventListener("click", async () => { | |
| const loader = document.createElement("div") | |
| loader.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="animate-reversespin dark:text-gray-400 text-gray-700 w-10 h-10" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.6m15.3 2A8 8 0 0 0 4.6 9m0 0H9m11 11v-5h-.6m0 0a8 8 0 0 1-15.3-2m15.3 2H15"/></svg>` | |
| loader.className = "fixed inset-0 flex items-center justify-center z-100" | |
| loader.style.backgroundColor = "rgba(0, 0, 0, 0.8)" | |
| document.body.append(loader) | |
| try { | |
| const contributions = await getAuthors() | |
| if (!contributions) return | |
| await updateAuthors(contributions) | |
| document.location.reload() | |
| } finally { | |
| loader.remove() | |
| } | |
| }) | |
| button.append("Copy") | |
| button.className = | |
| "cursor-pointer transition-all underline-offset-2 text-gray-800 dark:text-gray-100 gap-2 text-sm" | |
| return button | |
| } | |
| const pattern = /^https:\/\/hardcover.app(?:\/books\/[^/]+)?\/editions\/[^/]+\/edit$/ | |
| let url = null | |
| window.navigation.addEventListener("navigate", (e) => { | |
| if (e.destination.url === url) return | |
| url = e.destination.url | |
| if (!pattern.test(url)) return | |
| VM.observe(document.body, () => { | |
| const element = document | |
| .evaluate( | |
| '//label[contains(., "Authors & Contributions")]', | |
| document, | |
| null, | |
| XPathResult.ANY_TYPE, | |
| null, | |
| ) | |
| .iterateNext() | |
| if (!element) return false | |
| element.parentNode.nextSibling.prepend(buildButton()) | |
| return true | |
| }) | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment