library: add Quick Add page (scan an ISBN, the book is added)

A small standard-library Python service next to Koha:
- looks the code up via Z39.50 (yaz) then Open Library over HTTPS
- checks Koha for an existing copy (ISBN-10/13 and UPC/EAN aware)
- adds the record + an item with the next barcode via Koha's REST API
- scanner-friendly UI: auto-focus, audible feedback, duplicate card with
  'add another copy', manual entry when nothing is found, undo, and the
  barcode range to print labels for
Also: enable RESTBasicAuth, map 024$a to the ean column, wire the service,
port and credentials into compose/install.sh.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
Claude
2026-11-06 10:24:54 +00:00
parent f4da2608cd
commit 2ef8f68b9b
8 changed files with 853 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Quick Add: scan an ISBN, the book is in the catalogue. Standard-library Python
# plus yaz (the Z39.50 client). Same Debian base as the Koha image, so it adds
# one small layer instead of another base-image pull.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz tzdata \
&& rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
# you are behind a TLS-inspecting proxy. Empty by default.
COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates
RUN useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data
COPY app.py index.html /app/
USER quickadd
WORKDIR /app
EXPOSE 8082
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8082/healthz', timeout=3).status==200 else 1)"
CMD ["python3", "/app/app.py"]
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/env python3
"""Quick Add - scan an ISBN, the book is in the catalogue.
A tiny web app that sits next to Koha. For each ISBN/UPC it:
1. looks the item up (Z39.50 servers first, then Open Library over HTTPS),
2. checks Koha for an existing copy,
3. adds the record + an item (next free barcode) through Koha's REST API.
Standard library only; Z39.50 is done by shelling out to yaz-client.
"""
import base64
import hmac
import json
import os
import re
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/")
USER = os.environ.get("ADMIN_USER", "admin")
PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME")
STAFF_PORT = os.environ.get("STAFF_PORT", "8081")
OPAC_PORT = os.environ.get("OPAC_PORT", "8080")
DATA_DIR = os.environ.get("DATA_DIR", "/data")
PORT = int(os.environ.get("PORT", "8082"))
SOURCES = [s.strip() for s in os.environ.get(
"QUICKADD_SOURCES", "z3950:lx2.loc.gov:210/LCDB,openlibrary").split(",") if s.strip()]
Z3950_TIMEOUT = int(os.environ.get("Z3950_TIMEOUT", "12"))
ITEM_TYPES = {"BOOK": "Book", "DVD": "Movie", "CD": "Music", "GAME": "Game", "OTHER": "Other"}
NS = {"m": "http://www.loc.gov/MARC21/slim"}
ET.register_namespace("", NS["m"])
RECENT_FILE = os.path.join(DATA_DIR, "recent.json")
LOCK = threading.Lock()
# ---------------------------------------------------------------- identifiers
def clean_code(raw):
code = re.sub(r"[\s-]", "", raw or "").upper()
return code if re.fullmatch(r"[0-9]{8,14}|[0-9]{9}[0-9X]", code) else None
def isbn10_to_13(i10):
body = "978" + i10[:9]
s = sum(int(c) * (1 if n % 2 == 0 else 3) for n, c in enumerate(body))
return body + str((10 - s % 10) % 10)
def isbn13_to_10(i13):
if not i13.startswith("978"):
return None
body = i13[3:12]
s = sum(int(c) * (10 - n) for n, c in enumerate(body))
chk = (11 - s % 11) % 11
return body + ("X" if chk == 10 else str(chk))
def is_isbn(code):
return len(code) == 10 or (len(code) == 13 and code[:3] in ("978", "979"))
# -------------------------------------------------------------------- Z39.50
def z3950_search(spec, code):
"""spec = host:port/db. Returns MARCXML string or None."""
host_port_db = spec
attrs = ["@attr 1=7"] if is_isbn(code) else ["@attr 1=1007"]
queries = [code]
if len(code) == 13 and is_isbn(code):
alt = isbn13_to_10(code)
if alt:
queries.append(alt)
for q in queries:
with tempfile.TemporaryDirectory() as tmp:
dump = os.path.join(tmp, "rec.mrc")
script = "format usmarc\nfind %s %s\nshow 1\nquit\n" % (attrs[0], q)
try:
out = subprocess.run(
["yaz-client", "-m", dump, host_port_db], input=script,
capture_output=True, text=True, timeout=Z3950_TIMEOUT).stdout
except (subprocess.TimeoutExpired, OSError):
return None
m = re.search(r"Number of hits: (\d+)", out)
if not m or int(m.group(1)) == 0 or not os.path.exists(dump):
if "Connection refused" in out or "Connecting...error" in out or "Unable to" in out:
return None
continue
raw = open(dump, "rb").read()
args = ["yaz-marcdump", "-o", "marcxml"]
if len(raw) > 9 and raw[9:10] == b" ": # MARC-8 -> UTF-8
args += ["-f", "MARC-8", "-t", "UTF-8", "-l", "9=97"]
res = subprocess.run(args + [dump], capture_output=True, text=True, timeout=10)
if res.returncode == 0 and "<record" in res.stdout:
return res.stdout
return None
# --------------------------------------------------------------- Open Library
def openlibrary_search(code):
if not is_isbn(code):
return None
url = ("https://openlibrary.org/api/books?bibkeys=ISBN:%s&format=json&jscmd=data"
% urllib.parse.quote(code))
try:
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "library-quickadd/1.0"}), timeout=10) as r:
data = json.load(r).get("ISBN:" + code)
except (urllib.error.URLError, OSError, ValueError):
return None
if not data or not data.get("title"):
return None
rec = ET.Element("{%s}record" % NS["m"])
ET.SubElement(rec, "{%s}leader" % NS["m"]).text = "00000nam a2200000 a 4500"
def field(tag, subs, i1=" ", i2=" "):
subs = [(c, str(v)) for c, v in subs if v]
if not subs: # an empty datafield is invalid MARC
return
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=i2)
for c, v in subs:
ET.SubElement(df, "{%s}subfield" % NS["m"], code=c).text = v
field("020", [("a", code)])
dewey = (data.get("classifications") or {}).get("dewey_decimal_class") or []
if dewey:
field("082", [("a", dewey[0])], "0", "4")
lcc = (data.get("classifications") or {}).get("lc_classifications") or []
if lcc:
field("050", [("a", lcc[0])], " ", "4")
authors = data.get("authors") or []
if authors:
field("100", [("a", authors[0].get("name"))], "1")
title = data["title"] + (": " + data["subtitle"] if data.get("subtitle") else "")
field("245", [("a", title)], "1" if authors else "0", "0")
pubs = [p.get("name") for p in data.get("publishers") or []]
field("264", [("b", pubs[0] if pubs else None), ("c", data.get("publish_date"))], " ", "1")
if data.get("number_of_pages"):
field("300", [("a", "%s pages" % data["number_of_pages"])])
for subj in (data.get("subjects") or [])[:6]:
field("650", [("a", subj.get("name"))], " ", "0")
return ET.tostring(rec, encoding="unicode")
# ----------------------------------------------------------- MARC XML helpers
def parse_marc(xml_text):
root = ET.fromstring(xml_text)
rec = root if root.tag.endswith("record") else root.find(".//m:record", NS)
return rec
def sub(rec, tag, code):
for df in rec.findall("m:datafield[@tag='%s']" % tag, NS):
for sf in df.findall("m:subfield[@code='%s']" % code, NS):
if sf.text:
return sf.text.strip()
return ""
def describe(rec):
title = " ".join(x for x in (sub(rec, "245", "a"), sub(rec, "245", "b")) if x).rstrip(" /:;,")
author = sub(rec, "100", "a").rstrip(",.") or sub(rec, "110", "a").rstrip(",.")
year = re.search(r"\d{4}", sub(rec, "264", "c") or sub(rec, "260", "c") or "")
return {"title": title or "(untitled)", "author": author, "year": year.group(0) if year else "",
"publisher": (sub(rec, "264", "b") or sub(rec, "260", "b")).rstrip(" ,:;")}
def call_number(rec, author):
dewey = sub(rec, "082", "a").replace("/", "")
if dewey:
cutter = sub(rec, "082", "b")
if not cutter and author:
cutter = re.sub(r"[^A-Za-z]", "", author.split(",")[0])[:3].upper()
return (dewey + " " + cutter).strip()
lc = sub(rec, "050", "a")
if lc:
return (lc + " " + sub(rec, "050", "b")).strip()
return ""
def set_item_type(rec, itype):
for df in rec.findall("m:datafield[@tag='942']", NS):
rec.remove(df)
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag="942", ind1=" ", ind2=" ")
ET.SubElement(df, "{%s}subfield" % NS["m"], code="c").text = itype
def ensure_identifier(rec, code):
"""Make sure the scanned code is on the record, so scanning it again is
recognised as a duplicate (a Z39.50 hit for a UPC often lacks the UPC)."""
wanted = {code}
if len(code) == 13 and code[:3] == "978":
wanted.add(isbn13_to_10(code) or code)
elif len(code) == 10:
wanted.add(isbn10_to_13(code))
for tag in ("020", "024"):
for df in rec.findall("m:datafield[@tag='%s']" % tag, NS):
for sf in df.findall("m:subfield[@code='a']", NS):
if re.sub(r"[^0-9X]", "", (sf.text or "").upper())[:13] in wanted:
return
tag, i1 = ("020", " ") if is_isbn(code) else ("024", "1")
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=" ")
ET.SubElement(df, "{%s}subfield" % NS["m"], code="a").text = code
def manual_record(code, title, author, year, publisher):
rec = ET.Element("{%s}record" % NS["m"])
ET.SubElement(rec, "{%s}leader" % NS["m"]).text = "00000nam a2200000 a 4500"
def field(tag, subs, i1=" ", i2=" "):
subs = [(c, str(v)) for c, v in subs if v]
if not subs: # an empty datafield is invalid MARC
return
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=i2)
for c, v in subs:
ET.SubElement(df, "{%s}subfield" % NS["m"], code=c).text = v
if code:
field("020" if is_isbn(code) else "024", [("a", code)], " " if is_isbn(code) else "1")
if author:
field("100", [("a", author)], "1")
field("245", [("a", title)], "1" if author else "0", "0")
field("264", [("b", publisher), ("c", year)], " ", "1")
return rec
# ------------------------------------------------------------------ Koha API
class KohaError(Exception):
def __init__(self, status, body):
super().__init__("Koha API %s: %s" % (status, body[:200]))
self.status, self.body = status, body
def koha(method, path, body=None, ctype="application/json", headers=None, query=None):
url = KOHA_API + path + ("?" + urllib.parse.urlencode(query) if query else "")
data = body if isinstance(body, bytes) else (json.dumps(body).encode() if body is not None else None)
h = {"Authorization": "Basic " + base64.b64encode(("%s:%s" % (USER, PASSWORD)).encode()).decode(),
"Accept": "application/json"}
if data is not None:
h["Content-Type"] = ctype
h.update(headers or {})
try:
with urllib.request.urlopen(urllib.request.Request(url, data=data, method=method, headers=h), timeout=30) as r:
raw = r.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
raise KohaError(e.code, e.read().decode("utf8", "replace"))
except (urllib.error.URLError, OSError) as e:
raise KohaError(503, str(e))
def find_existing(code):
variants = {code}
if len(code) == 13:
alt = isbn13_to_10(code)
if alt:
variants.add(alt)
elif len(code) == 10:
variants.add(isbn10_to_13(code))
for v in variants:
q = {"-or": [{"isbn": {"-like": "%" + v + "%"}}, {"ean": {"-like": "%" + v + "%"}}]}
res = koha("GET", "/biblios", query={"q": json.dumps(q), "_per_page": "1"})
if res:
b = res[0]
items = koha("GET", "/biblios/%s/items" % b["biblio_id"]) or []
return {"biblio_id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"),
"author": b.get("author") or "", "copies": len(items)}
return None
def next_barcode():
last = koha("GET", "/items", query={"_per_page": "1", "_order_by": "-item_id"})
n = 0
if last and re.fullmatch(r"\d+", last[0].get("external_id") or ""):
n = int(last[0]["external_id"])
return max(n, state_counter()) + 1
def state_counter():
try:
return int(open(os.path.join(DATA_DIR, "barcode")).read())
except (OSError, ValueError):
return 0
def save_counter(n):
try:
os.makedirs(DATA_DIR, exist_ok=True)
open(os.path.join(DATA_DIR, "barcode"), "w").write(str(n))
except OSError:
pass
def add_item(biblio_id, itype, callnum):
"""Create an item with the next free barcode (retrying on collisions)."""
start = next_barcode()
for attempt in range(10):
bc = str(start + attempt)
try:
item = koha("POST", "/biblios/%s/items" % biblio_id, {
"external_id": bc, "item_type_id": itype, "home_library_id": LIBRARY,
"holding_library_id": LIBRARY, "callnumber": callnum or None})
save_counter(start + attempt)
return item
except KohaError as e:
if e.status == 409 or "Duplicate" in e.body:
continue
raise
raise KohaError(409, "could not find a free barcode")
# --------------------------------------------------------------------- state
def load_recent():
try:
return json.load(open(RECENT_FILE))
except (OSError, ValueError):
return []
def remember(entry):
with LOCK:
rec = load_recent()
rec.insert(0, entry)
try:
os.makedirs(DATA_DIR, exist_ok=True)
json.dump(rec[:300], open(RECENT_FILE, "w"))
except OSError:
pass
def forget(item_id):
with LOCK:
rec = [r for r in load_recent() if r.get("item_id") != item_id]
try:
json.dump(rec, open(RECENT_FILE, "w"))
except OSError:
pass
# ----------------------------------------------------------------- operations
def lookup(code):
"""Try each configured source; returns (xml, source_name) or (None, None)."""
for src in SOURCES:
xml = None
if src.startswith("z3950:"):
xml = z3950_search(src[len("z3950:"):], code)
name = "Z39.50 " + src.split(":", 1)[1].split("/")[0].split(":")[0]
elif src == "openlibrary":
xml = openlibrary_search(code)
name = "Open Library"
if xml:
return xml, name
return None, None
def op_add(payload):
code = clean_code(payload.get("code"))
itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK"
if not code:
return 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."}
with LOCK:
existing = find_existing(code)
if existing and not payload.get("copy"):
return 200, dict(existing, status="duplicate", code=code)
if existing: # add another copy of a known title
with LOCK:
item = add_item(existing["biblio_id"], itype, None)
entry = entry_for(code, existing["title"], existing["author"], item, existing["biblio_id"], "extra copy")
remember(entry)
return 200, dict(entry, status="added")
xml, source = lookup(code)
if not xml:
return 200, {"status": "notfound", "code": code}
rec = parse_marc(xml)
info = describe(rec)
ensure_identifier(rec, code)
set_item_type(rec, itype)
with LOCK:
bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml",
{"x-confirm-not-duplicate": "1"})
item = add_item(bib["id"], itype, call_number(rec, info["author"]))
entry = entry_for(code, info["title"], info["author"], item, bib["id"], source)
remember(entry)
return 200, dict(entry, status="added")
def op_manual(payload):
title = (payload.get("title") or "").strip()
if not title:
return 400, {"status": "invalid", "message": "A title is required."}
code = clean_code(payload.get("code"))
itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK"
rec = manual_record(code, title[:240], (payload.get("author") or "").strip()[:120],
(payload.get("year") or "").strip()[:8], (payload.get("publisher") or "").strip()[:120])
set_item_type(rec, itype)
with LOCK:
bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml",
{"x-confirm-not-duplicate": "1"})
item = add_item(bib["id"], itype, None)
entry = entry_for(code or "", title, payload.get("author") or "", item, bib["id"], "typed in")
remember(entry)
return 200, dict(entry, status="added")
def entry_for(code, title, author, item, biblio_id, source):
return {"time": int(time.time()), "code": code, "title": title, "author": author,
"barcode": item.get("external_id"), "callnumber": item.get("callnumber") or "",
"item_id": item.get("item_id"), "biblio_id": biblio_id, "source": source}
def op_undo(payload):
item_id = payload.get("item_id")
biblio_id = payload.get("biblio_id")
if not isinstance(item_id, int) or not isinstance(biblio_id, int):
return 400, {"status": "invalid"}
with LOCK:
koha("DELETE", "/items/%d" % item_id)
remaining = koha("GET", "/biblios/%d/items" % biblio_id) or []
if not remaining: # drop the now-empty record too
try:
koha("DELETE", "/biblios/%d" % biblio_id)
except KohaError:
pass
forget(item_id)
return 200, {"status": "undone"}
# ----------------------------------------------------------------------- HTTP
INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read()
class Handler(BaseHTTPRequestHandler):
server_version = "QuickAdd/1.0"
def log_message(self, fmt, *args):
pass
def _send(self, code, body, ctype="application/json", extra=None):
if not isinstance(body, bytes):
body = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy",
"default-src 'self'; img-src 'self' https://covers.openlibrary.org data:; "
"style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def _authed(self):
if not PASSWORD:
return False
header = self.headers.get("Authorization", "")
if header.startswith("Basic "):
try:
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
ok_u = hmac.compare_digest(u.encode(), USER.encode())
ok_p = hmac.compare_digest(p.encode(), PASSWORD.encode())
return ok_u and ok_p
except (ValueError, UnicodeDecodeError):
return False
return False
def _guard(self):
if not self._authed():
self._send(401, b"Login required", "text/plain",
{"WWW-Authenticate": 'Basic realm="Library quick add"'})
return False
return True
def do_GET(self):
if self.path == "/healthz":
return self._send(200, b"ok", "text/plain")
if not self._guard():
return
path = self.path.split("?")[0]
if path == "/":
return self._send(200, INDEX, "text/html; charset=utf-8")
if path == "/api/state":
return self._send(200, {"recent": load_recent()[:100], "types": ITEM_TYPES,
"library": LIBRARY, "staff_port": STAFF_PORT, "opac_port": OPAC_PORT,
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
self._send(404, {"error": "not found"})
def do_POST(self):
if not self._guard():
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length > 20000:
return self._send(413, {"error": "too large"})
payload = json.loads(self.rfile.read(length) or b"{}")
ops = {"/api/add": op_add, "/api/manual": op_manual, "/api/undo": op_undo}
fn = ops.get(self.path)
if not fn:
return self._send(404, {"error": "not found"})
code, body = fn(payload)
self._send(code, body)
except KohaError as e:
msg = ("Koha is still starting up - try again in a minute." if e.status == 503
else "Koha refused that: %s" % e.body[:160])
self._send(502, {"status": "error", "message": msg})
except (ValueError, ET.ParseError):
self._send(400, {"status": "invalid", "message": "Bad request."})
except Exception as e: # keep the scanner loop alive whatever happens
self._send(500, {"status": "error", "message": "Unexpected error: %s" % e})
if __name__ == "__main__":
if not PASSWORD:
raise SystemExit("ADMIN_PASSWORD is required")
os.makedirs(DATA_DIR, exist_ok=True)
print("quick add listening on :%d sources=%s" % (PORT, SOURCES), flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
View File
+263
View File
@@ -0,0 +1,263 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Add to library</title>
<style>
:root {
--bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2;
--accent: #2f6b4f; --accent-ink: #ffffff; --ok: #1f7a4a; --warn: #8a5a00; --bad: #a3321f;
--ok-bg: #e6f3ec; --warn-bg: #fbf1dc; --bad-bg: #f9e5e1; --focus: #1a5fb4;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #151a17; --card: #1e2521; --ink: #e8ece9; --muted: #a2ada6; --line: #333c36;
--accent: #5bb58a; --accent-ink: #0d1a13; --ok: #6fd3a0; --warn: #e8bd6a; --bad: #f08a78;
--ok-bg: #1c3226; --warn-bg: #352c15; --bad-bg: #3a2220; --focus: #7ab0ff;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 760px; margin: 0 auto; padding: 16px; }
header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; justify-content: space-between; }
h1 { font-size: 1.35rem; margin: 8px 0; }
nav a { color: var(--accent); margin-right: 14px; text-decoration: none; font-weight: 600; }
nav a:hover { text-decoration: underline; }
.types { display: flex; gap: 6px; margin: 10px 0 14px; flex-wrap: wrap; }
.types label { cursor: pointer; }
.types input { position: absolute; opacity: 0; }
.types span { display: inline-block; padding: 8px 16px; border: 1px solid var(--line);
border-radius: 999px; background: var(--card); font-weight: 600; }
.types input:checked + span { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.types input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
.scan { display: flex; gap: 8px; }
.scan input { flex: 1; font-size: 1.5rem; padding: 14px 16px; border: 2px solid var(--line);
border-radius: 12px; background: var(--card); color: var(--ink); min-width: 0; }
.scan input:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); }
button { font: inherit; cursor: pointer; border-radius: 10px; border: 1px solid var(--line);
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
padding: 14px; margin: 10px 0; display: flex; gap: 14px; }
.card.ok { border-color: var(--ok); background: var(--ok-bg); }
.card.warn { border-color: var(--warn); background: var(--warn-bg); }
.card.bad { border-color: var(--bad); background: var(--bad-bg); }
.cover { width: 64px; height: 92px; border-radius: 4px; background: var(--line); flex: none; object-fit: cover; }
.cover[hidden] { display: none; }
.body { flex: 1; min-width: 0; }
.title { font-weight: 700; font-size: 1.1rem; overflow-wrap: anywhere; }
.meta { color: var(--muted); overflow-wrap: anywhere; }
.badge { display: inline-block; background: var(--ink); color: var(--bg); border-radius: 6px;
padding: 1px 9px; font-weight: 700; font-variant-numeric: tabular-nums; }
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
form.manual { display: grid; gap: 8px; margin-top: 8px; }
form.manual input { padding: 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--card); color: var(--ink); font: inherit; width: 100%; }
h2 { font-size: 1.05rem; margin: 24px 0 6px; }
ul.list { list-style: none; margin: 0; padding: 0; }
ul.list li { display: flex; gap: 10px; align-items: center; padding: 9px 2px; border-bottom: 1px solid var(--line); }
ul.list .t { flex: 1; min-width: 0; overflow-wrap: anywhere; }
ul.list .u { color: var(--muted); font-size: .9rem; }
.labels { background: var(--card); border: 1px dashed var(--line); border-radius: 12px; padding: 12px; margin-top: 14px; }
.small { color: var(--muted); font-size: .92rem; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
</style>
</head>
<body>
<main>
<header>
<h1>Add to library</h1>
<nav aria-label="Library links">
<a id="lnk-opac" href="#">Catalogue</a><a id="lnk-staff" href="#">Staff</a>
</nav>
</header>
<fieldset class="types" id="types" style="border:0;padding:0" aria-label="What are you adding?"></fieldset>
<form class="scan" id="scan" autocomplete="off">
<label class="sr" for="code">ISBN or barcode</label>
<input id="code" inputmode="numeric" placeholder="Scan or type an ISBN, then Enter" autofocus>
<button class="primary" type="submit">Add</button>
</form>
<div id="status" role="status" aria-live="polite"></div>
<div id="result"></div>
<h2>Added this session <span id="count" class="small"></span></h2>
<ul class="list" id="session"></ul>
<div class="labels" id="labels" hidden>
<strong id="range"></strong>
<div class="small">Print spine labels and barcodes: open the label creator,
choose <em>Print barcode range</em>, and enter these numbers.</div>
<div class="actions"><a id="lnk-labels" href="#"><button type="button">Open label creator</button></a></div>
</div>
<h2>Earlier</h2>
<ul class="list" id="earlier"></ul>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
let types = {}, sessionItems = [], busy = false, lastAdded = null, type = localStorage.getItem('qa-type') || 'BOOK';
// --- small audible feedback so a stack of books can be scanned without looking
let actx;
function beep(freqs) {
try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
let t = actx.currentTime;
for (const f of freqs) {
const o = actx.createOscillator(), g = actx.createGain();
o.frequency.value = f; g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
o.connect(g); g.connect(actx.destination); o.start(t); o.stop(t + 0.17); t += 0.18;
}
} catch (e) {}
}
const SOUND = { added: [880], duplicate: [520, 520], notfound: [300], error: [220, 180] };
const el = (tag, props = {}, ...kids) => {
const n = Object.assign(document.createElement(tag), props);
for (const k of kids) n.append(k);
return n;
};
async function api(path, body) {
const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) } : {});
return r.json();
}
function cover(code) {
const img = el('img', { className: 'cover', alt: '', hidden: true });
if (code && /^\d{10,13}$/.test(code)) {
img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
img.src = 'https://covers.openlibrary.org/b/isbn/' + code + '-M.jpg?default=false';
}
return img;
}
function show(kind, ...parts) { $('result').replaceChildren(el('div', { className: 'card ' + kind }, ...parts)); }
function bookBody(r, extra) {
return el('div', { className: 'body' },
el('div', { className: 'title' }, r.title || ''),
el('div', { className: 'meta' }, [r.author, r.callnumber && ('Call no. ' + r.callnumber)].filter(Boolean).join(' · ')),
...extra);
}
function rowFor(e, withUndo) {
const li = el('li', {},
el('span', { className: 'badge' }, e.barcode || '–'),
el('span', { className: 't' }, el('div', {}, e.title), el('div', { className: 'u' },
[e.author, e.callnumber, e.source].filter(Boolean).join(' · '))));
if (withUndo) {
const b = el('button', { type: 'button' }, 'Undo');
b.onclick = async () => {
b.disabled = true;
const r = await api('/api/undo', { item_id: e.item_id, biblio_id: e.biblio_id });
if (r.status === 'undone') {
sessionItems = sessionItems.filter(x => x.item_id !== e.item_id); render();
if (lastAdded === e.item_id) $('result').replaceChildren();
$('status').textContent = 'Removed "' + e.title + '".';
} else b.disabled = false;
};
li.append(b);
}
return li;
}
function render() {
$('session').replaceChildren(...sessionItems.map(e => rowFor(e, true)));
$('count').textContent = sessionItems.length ? '(' + sessionItems.length + ')' : '';
const nums = sessionItems.map(e => parseInt(e.barcode, 10)).filter(Number.isFinite);
$('labels').hidden = nums.length === 0;
if (nums.length) {
const lo = Math.min(...nums), hi = Math.max(...nums);
$('range').textContent = lo === hi ? 'Barcode ' + lo : 'Barcodes ' + lo + ' to ' + hi;
}
}
async function add(code, opts = {}) {
if (busy) return; busy = true;
$('status').textContent = 'Looking up ' + code + '…';
try {
const r = await api('/api/add', Object.assign({ code, type }, opts));
handle(r, code);
} catch (e) {
beep(SOUND.error); show('bad', el('div', { className: 'body' }, 'Could not reach the quick-add service.'));
} finally { busy = false; $('code').focus(); }
}
function handle(r, code) {
$('status').textContent = '';
if (r.status === 'added') {
beep(SOUND.added); sessionItems.unshift(r); lastAdded = r.item_id; render();
show('ok', cover(r.code), bookBody(r, [el('div', { className: 'meta' }, 'Added as barcode ',
el('span', { className: 'badge' }, r.barcode), (r.source === 'extra copy' ? ' · another copy of a title you already have' : ' · from ' + r.source))]));
} else if (r.status === 'duplicate') {
beep(SOUND.duplicate);
const more = el('button', { className: 'primary', type: 'button' }, 'Add another copy');
more.onclick = () => add(code, { copy: true });
const skip = el('button', { type: 'button' }, 'Skip');
skip.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
show('warn', cover(code), bookBody(r, [el('div', { className: 'meta' },
'You already have this (' + r.copies + (r.copies === 1 ? ' copy' : ' copies') + ').'),
el('div', { className: 'actions' }, more, skip)]));
} else if (r.status === 'notfound') {
beep(SOUND.notfound); manualForm(code);
} else {
beep(SOUND.error);
show('bad', el('div', { className: 'body' }, r.message || 'Something went wrong.'));
}
}
function manualForm(code) {
const f = el('form', { className: 'manual' });
const field = (name, ph, req) => el('input', { name, placeholder: ph, required: !!req, 'aria-label': ph });
const submit = el('button', { className: 'primary', type: 'submit' }, 'Add it anyway');
const cancel = el('button', { type: 'button' }, 'Cancel');
cancel.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
f.append(field('title', 'Title', true), field('author', 'Author'), field('year', 'Year'),
field('publisher', 'Publisher'), el('div', { className: 'actions' }, submit, cancel));
f.onsubmit = async ev => {
ev.preventDefault();
const d = Object.fromEntries(new FormData(f));
handle(await api('/api/manual', Object.assign(d, { code, type })), code);
};
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, 'Not found: ' + code),
el('div', { className: 'meta' }, 'None of the catalogues knew this one. Type the details and it is added anyway.'), f));
f.querySelector('input').focus();
}
$('scan').onsubmit = ev => {
ev.preventDefault();
const code = $('code').value.trim(); $('code').value = '';
if (code) add(code);
};
function typeButtons() {
$('types').replaceChildren(...Object.entries(types).map(([k, label]) => {
const i = el('input', { type: 'radio', name: 'type', value: k, checked: k === type });
i.onchange = () => { type = k; localStorage.setItem('qa-type', k); $('code').focus(); };
return el('label', {}, i, el('span', {}, label));
}));
}
(async () => {
const s = await api('/api/state');
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
const host = location.hostname;
$('lnk-opac').href = 'http://' + host + ':' + s.opac_port + '/';
$('lnk-staff').href = 'http://' + host + ':' + s.staff_port + '/';
$('lnk-labels').href = 'http://' + host + ':' + s.staff_port + '/cgi-bin/koha/labels/label-edit-range.pl';
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
$('code').focus();
})();
})();
</script>
</body>
</html>