Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

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.
// ==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