Last active
March 4, 2026 23:20
-
-
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
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 → 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