#!/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 " 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()