diff --git a/library/.gitignore b/library/.gitignore index 46f8659..a0a4e8d 100644 --- a/library/.gitignore +++ b/library/.gitignore @@ -2,3 +2,4 @@ .env # Extra CA certificates dropped in for building behind a proxy koha/certs/*.crt +quickadd/certs/*.crt diff --git a/library/docker-compose.yml b/library/docker-compose.yml index 576a9b1..0b6c1ec 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -43,6 +43,31 @@ services: volumes: - koha-data:/var/lib/koha + # Scan an ISBN -> the book is added (Z39.50 lookup, then Koha's REST API). + quickadd: + build: + context: ./quickadd + image: library-quickadd:local + container_name: library-quickadd + restart: unless-stopped + depends_on: + koha: + condition: service_started + environment: + TZ: ${TZ:-UTC} + KOHA_API: http://koha:8081/api/v1 + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + KOHA_LIBRARY: HOME + OPAC_PORT: ${OPAC_PORT:-8080} + STAFF_PORT: ${STAFF_PORT:-8081} + QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary} + ports: + - "${QUICKADD_PORT:-8082}:8082" + volumes: + - quickadd-data:/data + volumes: db-data: koha-data: + quickadd-data: diff --git a/library/install.sh b/library/install.sh index 34091d2..a2431b0 100755 --- a/library/install.sh +++ b/library/install.sh @@ -68,6 +68,7 @@ show_status() { echo echo " Catalogue (public): http://$ip:$OPAC_PORT" echo " Staff interface: http://$ip:$STAFF_PORT" + echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)" echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)" echo } @@ -98,6 +99,7 @@ LIBRARY_NAME="${LIBRARY_NAME:-My Library}" TZ=${TZ_NOW} OPAC_PORT=$(free_port "${OPAC_PORT:-8080}") STAFF_PORT=$(free_port "${STAFF_PORT:-8081}") +QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}") ADMIN_USER=admin ADMIN_PASSWORD=$(rand 16) DB_ROOT_PASSWORD=$(rand 32) @@ -108,10 +110,15 @@ ENVEOF [ "$RUN_USER" = root ] || chown "$RUN_USER" .env else c_info "Re-using existing .env" + # Settings added in later versions: append them to an older .env. + if ! grep -q '^QUICKADD_PORT=' .env; then + echo "QUICKADD_PORT=$(free_port 8082)" >> .env + fi fi if [ -n "${EXTRA_CA:-}" ]; then cp "$EXTRA_CA" koha/certs/extra-ca.crt + cp "$EXTRA_CA" quickadd/certs/extra-ca.crt c_info "Trusting extra CA during the build: $EXTRA_CA" fi diff --git a/library/koha/bootstrap.pl b/library/koha/bootstrap.pl index dc676b7..0791c98 100755 --- a/library/koha/bootstrap.pl +++ b/library/koha/bootstrap.pl @@ -96,10 +96,17 @@ $admin->set_password({ password => $env{admin_pass}, skip_validation => 1 }); $dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK' WHERE tagfield = '942' AND tagsubfield = 'c'}); +# UPC/EAN barcodes (DVDs, CDs, games) live in 024$a, which the stock framework +# leaves unmapped - so Koha can neither search nor de-duplicate on them. Map it +# to the catalogue's `ean` column (used by the quick-add page's duplicate check). +$dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean' + WHERE tagfield = '024' AND tagsubfield = 'a' AND (kohafield IS NULL OR kohafield = '')}); + # ---- Preferences ---------------------------------------------------------- my %prefs = ( autoBarcode => 'incremental', # new items get a barcode automatically itemcallnumber => '082ab', # Dewey call number copied from the MARC record + RESTBasicAuth => 1, # lets the quick-add page use the REST API OpenLibraryCovers => 1, # cover images in the catalogue OPACSearchForTitleIn => '', BiblioDefaultView => 'normal', diff --git a/library/quickadd/Dockerfile b/library/quickadd/Dockerfile new file mode 100644 index 0000000..9998b35 --- /dev/null +++ b/library/quickadd/Dockerfile @@ -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"] diff --git a/library/quickadd/app.py b/library/quickadd/app.py new file mode 100644 index 0000000..917c19c --- /dev/null +++ b/library/quickadd/app.py @@ -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 " 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() diff --git a/library/quickadd/certs/.gitkeep b/library/quickadd/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/library/quickadd/index.html b/library/quickadd/index.html new file mode 100644 index 0000000..3da29d7 --- /dev/null +++ b/library/quickadd/index.html @@ -0,0 +1,263 @@ + + + + + +Add to library + + + +
+
+

Add to library

+ +
+ +
+ +
+ + + +
+
+
+ +

Added this session

+ + + +

Earlier

+ +
+ + + +