#!/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 secrets 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")) DEFAULT_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") # ---------------------------------------------------------------- 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 " (expires, plan): waiting for a scanned sticker PENDING_TTL = 1800 def prune_pending(): now = time.time() for k in [k for k, (exp, _) in PENDING.items() if exp < now]: del PENDING[k] def chosen_location(payload): """The shelf for this add: an explicit choice wins, else the saved default.""" if "location" in payload: return valid_location(payload.get("location")) return valid_location(load_settings().get("location")) def make_plan(payload): """Look the code up. Returns ("done", http_status, body) or ("plan", plan).""" code = clean_code(payload.get("code")) itype = valid_type(payload.get("type")) if not code: return "done", 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."} location = chosen_location(payload) with LOCK: existing = find_existing(code) if existing and not payload.get("copy"): return "done", 200, dict(existing, status="duplicate", code=code) if existing: return "plan", {"code": code, "itype": itype, "location": location, "existing": existing, "xml": None, "title": existing["title"], "author": existing["author"], "callnum": None, "source": "extra copy"} xml, source = lookup(code) if not xml: return "done", 200, {"status": "notfound", "code": code} rec = parse_marc(xml) info = describe(rec) ensure_identifier(rec, code) set_item_type(rec, itype) return "plan", {"code": code, "itype": itype, "location": location, "existing": None, "xml": ET.tostring(rec, encoding="utf-8"), "title": info["title"], "author": info["author"], "callnum": call_number(rec, info["author"]), "source": source} def commit(plan, scanned=None): mode = load_settings()["barcode_mode"] with LOCK: created = None if plan["existing"]: biblio_id = plan["existing"]["biblio_id"] else: bib = koha("POST", "/biblios", plan["xml"], "application/marcxml+xml", {"x-confirm-not-duplicate": "1"}) biblio_id = created = bib["id"] try: item = add_item(biblio_id, plan["itype"], plan["callnum"], plan["location"], mode, scanned) except KohaError: if created: # don't leave an item-less record behind try: koha("DELETE", "/biblios/%s" % created) except KohaError: pass raise entry = entry_for(plan["code"], plan["title"], plan["author"], item, biblio_id, plan["source"]) remember(entry) return dict(entry, status="added") def need_barcode(plan): token = secrets.token_urlsafe(12) prune_pending() PENDING[token] = (time.time() + PENDING_TTL, plan) return 200, {"status": "need_barcode", "token": token, "code": plan["code"], "title": plan["title"], "author": plan["author"]} def finish_or_ask(plan): if load_settings()["barcode_mode"] == "scan": return need_barcode(plan) return 200, commit(plan) def op_add(payload): kind, *rest = make_plan(payload) if kind == "done": return rest[0], rest[1] return finish_or_ask(rest[0]) 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 = valid_type(payload.get("type")) 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) plan = {"code": code or "", "itype": itype, "location": chosen_location(payload), "existing": None, "xml": ET.tostring(rec, encoding="utf-8"), "title": title, "author": (payload.get("author") or "").strip(), "callnum": None, "source": "typed in"} return finish_or_ask(plan) STICKER_RE = re.compile(r"[A-Za-z0-9.$/+% -]{1,32}") def op_finish(payload): """Second step of 'scan a pre-printed sticker' mode.""" token = payload.get("token") barcode = (payload.get("barcode") or "").strip() prune_pending() entry = PENDING.get(token) if not entry: return 200, {"status": "error", "message": "That one timed out - scan the ISBN again."} if not STICKER_RE.fullmatch(barcode): return 200, {"status": "barcode_invalid", "token": token, "message": "That barcode has characters Koha can't use."} try: result = commit(entry[1], barcode) except KohaError as e: if e.status == 409 or "Duplicate" in e.body: return 200, {"status": "barcode_taken", "token": token, "barcode": barcode} raise PENDING.pop(token, None) return 200, result def op_cancel(payload): PENDING.pop(payload.get("token"), None) return 200, {"status": "cancelled"} 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, "location": next((l["label"] for l in get_locations() if l["value"] == item.get("location")), "")} 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"} # ----------------------------------------------------------- settings / tools def items_without_barcode(): found, page = [], 1 while len(found) < 5000: chunk = koha("GET", "/items", query={"q": json.dumps({"external_id": None}), "_per_page": "100", "_page": str(page), "_order_by": "item_id"}) or [] found += chunk if len(chunk) < 100: break page += 1 return found def settings_view(): s = load_settings() try: missing = len(items_without_barcode()) except KohaError: missing = None return {"settings": s, "next": fmt_barcode(peek_next(), s) if s["barcode_mode"] == "auto" else None, "without_barcode": missing, "locations": get_locations()} def op_settings(payload): with LOCK: s = clean_settings(payload, load_settings()) save_json(SETTINGS_FILE, s) return 200, settings_view() def op_reserve(payload): try: count = int(payload.get("count")) except (TypeError, ValueError): count = 0 if not 1 <= count <= 2000: return 400, {"status": "invalid", "message": "Choose between 1 and 2000."} s = load_settings() first = reserve(count) return 200, {"status": "reserved", "count": count, "first": fmt_barcode(first, s), "last": fmt_barcode(first + count - 1, s)} def op_assign(payload): """Give a barcode to every item that has none.""" s = load_settings() with LOCK: items = items_without_barcode() if not items: return 200, {"status": "assigned", "count": 0} first = reserve(len(items)) for i, it in enumerate(items): koha("PUT", "/biblios/%s/items/%s" % (it["biblio_id"], it["item_id"]), { "external_id": fmt_barcode(first + i, s), "item_type_id": it["item_type_id"], "home_library_id": it["home_library_id"], "holding_library_id": it["holding_library_id"]}) return 200, {"status": "assigned", "count": len(items), "first": fmt_barcode(first, s), "last": fmt_barcode(first + len(items) - 1, s)} # ----------------------------------------------------------------------- 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: 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": get_item_types(), "library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "opac_port": OPAC_PORT, "sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]}) if path == "/api/settings": return self._send(200, settings_view()) 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, "/api/finish": op_finish, "/api/cancel": op_cancel, "/api/settings": op_settings, "/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign} 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()