Skip to content

Instantly share code, notes, and snippets.

@tclancy
Last active March 4, 2026 23:20
Show Gist options
  • Select an option

  • Save tclancy/6d1f6955c0d788febac296280e87c353 to your computer and use it in GitHub Desktop.

Select an option

Save tclancy/6d1f6955c0d788febac296280e87c353 to your computer and use it in GitHub Desktop.
Provides Book and Author Search Links Automagically in Hardcover for the Dover, NH Public Library
// ==UserScript==
// @name Hardcover → Dover Library Linker
// @namespace https://github.com/tclancy/hardcover-library-linker
// @version 1.0.0
// @description Adds Dover Public Library search links next to book titles and authors on Hardcover want-to-read page
// @author Tom Clancy
// @match https://hardcover.app/@*/books/*
// @match https://hardcover.app/books/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ── Library search URL builder ──────────────────────────────────────────────
const LIBRARY_BASE = 'https://librarycatalog.dover.nh.gov/cgi-bin/koha/opac-search.pl';
function libraryTitleUrl(title) {
return LIBRARY_BASE + '?q=' + encodeURIComponent(title) + '&limit=itype:BK';
}
function libraryAuthorUrl(author) {
// Koha prefers "Last, First" for author searches
const formatted = formatAuthorForSearch(author);
return LIBRARY_BASE + '?q=au%3A%22' + encodeURIComponent(formatted) + '%22';
}
function formatAuthorForSearch(name) {
// If already "Last, First" leave it alone
if (name.includes(',')) return name.trim();
// Convert "First Last" → "Last, First"
const parts = name.trim().split(/\s+/);
if (parts.length < 2) return name.trim();
const last = parts[parts.length - 1];
const first = parts.slice(0, -1).join(' ');
return last + ', ' + first;
}
// ── Link injection ───────────────────────────────────────────────────────────
const INJECTED_ATTR = 'data-dover-injected';
const LINK_STYLE = [
'display:inline-block',
'margin-left:5px',
'padding:1px 5px',
'font-size:0.7em',
'line-height:1.4',
'background:#e8f4ec',
'color:#2a6049',
'border:1px solid #9dc8b0',
'border-radius:3px',
'text-decoration:none',
'vertical-align:middle',
'white-space:nowrap',
'font-weight:normal',
].join(';');
function makeLibLink(href, label) {
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = label;
a.setAttribute('style', LINK_STYLE);
a.setAttribute('title', 'Search Dover Public Library');
return a;
}
function injectAfter(el, link) {
el.parentNode.insertBefore(link, el.nextSibling);
}
// ── Selector strategies ──────────────────────────────────────────────────────
//
// Hardcover is a React + Inertia.js SPA with Tailwind CSS.
// Class names may be hashed/purged so we find elements by their href patterns:
// - Book links: href="/books/<slug>"
// - Author links: href="/authors/<slug>" or href="/contributors/<slug>"
//
// Each anchor is the canonical "title" or "author" element. We inject a
// library link immediately after each anchor we haven't touched yet.
function injectBookLinks() {
// Book title links
const bookAnchors = document.querySelectorAll(
'a[href^="/books/"]:not([' + INJECTED_ATTR + '])'
);
bookAnchors.forEach((anchor) => {
const title = anchor.textContent.trim();
if (!title || title.length < 2) return;
anchor.setAttribute(INJECTED_ATTR, '1');
const link = makeLibLink(libraryTitleUrl(title), '📚 lib');
injectAfter(anchor, link);
});
// Author links
const authorAnchors = document.querySelectorAll(
'a[href^="/authors/"]:not([' + INJECTED_ATTR + ']), a[href^="/contributors/"]:not([' + INJECTED_ATTR + '])'
);
authorAnchors.forEach((anchor) => {
const author = anchor.textContent.trim();
if (!author || author.length < 2) return;
anchor.setAttribute(INJECTED_ATTR, '1');
const link = makeLibLink(libraryAuthorUrl(author), '📚 lib');
injectAfter(anchor, link);
});
}
// ── MutationObserver: handles SPA navigation and lazy-loaded content ─────────
let debounceTimer = null;
function scheduleInject() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(injectBookLinks, 300);
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
scheduleInject();
break;
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// ── Initial run ──────────────────────────────────────────────────────────────
// Run once after DOM is ready; MutationObserver handles everything after that.
injectBookLinks();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment