From 7f230c8e73201c6d99ef797d8a00180d8deec5ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:27:27 +0000 Subject: [PATCH] library: collections, QR library cards (phone scan), card sheets, book info from Open Library, add shelves/collections from the UI Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 10 + library/koha/Dockerfile | 4 +- library/koha/quickadd-value.pl | 42 +++++ library/quickadd/Dockerfile | 4 +- .../quickadd/__pycache__/app.cpython-313.pyc | Bin 89393 -> 102983 bytes library/quickadd/app.py | 176 +++++++++++++++++- library/quickadd/index.html | 160 +++++++++++++--- 7 files changed, 362 insertions(+), 34 deletions(-) create mode 100644 library/koha/quickadd-value.pl diff --git a/library/README.md b/library/README.md index d99d497..4c0b5ae 100644 --- a/library/README.md +++ b/library/README.md @@ -224,3 +224,13 @@ One page for the day-to-day, so you rarely need Koha's own screens: - **Add books**: scan an ISBN (or use the phone camera), as before. The read-only `guest` login only sees the Library and book pages (no copy status names, no editing, no lending). + +### Collections, QR cards, more about a book + +- **Collections** (Kids, Cookbooks, ...) are per copy, shown on the book page next to the shelf, and filterable on the + Library page. Choose **+ New collection...** (or **+ New shelf...**) in the dropdown to add one: no Koha screens needed. +- **Library cards** carry a Code 39 barcode and a **QR code** of the card number. **Save as picture** makes an image to text + or AirDrop to a phone; on Lend / return, **Scan card or phone QR with camera** reads either one from a photo. + **Print everyone's cards** (People) lays out a sheet of cards. +- **More about a book**: a description and subjects from Open Library, plus look-up links (Goodreads, LibraryThing, + WorldCat). **AR BookFind** is a link only: Renaissance's terms forbid automated access, so nothing is fetched from it. diff --git a/library/koha/Dockerfile b/library/koha/Dockerfile index df6c195..bfe15bd 100644 --- a/library/koha/Dockerfile +++ b/library/koha/Dockerfile @@ -42,8 +42,8 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s # The endpoint Quick Add uses to recognise a signed-in staff user (see the file header). COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session # (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it) -COPY quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-return.pl -RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl +COPY quickadd-return.pl quickadd-value.pl /usr/share/koha/intranet/cgi-bin/ +RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-value.pl COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/ COPY seed/ /opt/koha-init/seed/ diff --git a/library/koha/quickadd-value.pl b/library/koha/quickadd-value.pl new file mode 100644 index 0000000..71c3a6a --- /dev/null +++ b/library/koha/quickadd-value.pl @@ -0,0 +1,42 @@ +#!/usr/bin/perl +# Installed as .../intranet/cgi-bin/quickadd-value.pl (URL /cgi-bin/koha/quickadd-value.pl). +# +# Koha's REST API cannot add shelves/rooms (authorised value category LOC) or collections (CCODE), +# so the Library site asks this script to, with Koha's own Koha::AuthorisedValue class. Same shared-secret +# guard as quickadd-session / quickadd-return; only the two categories below can be touched. +use Modern::Perl; +use CGI qw( -utf8 ); +use JSON qw( encode_json ); +use Digest::SHA qw( sha256_hex ); +use Koha::AuthorisedValues; +use Koha::AuthorisedValue; + +my $q = CGI->new; + +sub reply { + my ( $q, $status, $data ) = @_; + print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ), + encode_json($data); + exit; +} + +my $secret = ''; +if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh } +my $given = $ENV{HTTP_X_QUICKADD_SECRET} // ''; +reply( $q, '403 Forbidden', { status => 'forbidden' } ) + unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret); + +my $cat = $q->param('category') // ''; +my $value = $q->param('value') // ''; +my $desc = $q->param('description') // ''; +utf8::decode($desc); +reply( $q, '200 OK', { status => 'invalid' } ) + unless $cat =~ /\A(?:LOC|CCODE)\z/ && $value =~ /\A[A-Z0-9_-]{1,20}\z/ && length($desc) && length($desc) <= 80; + +my $out = eval { + my $exists = Koha::AuthorisedValues->search( { category => $cat, authorised_value => $value } )->count; + return { status => 'exists' } if $exists; + Koha::AuthorisedValue->new( { category => $cat, authorised_value => $value, lib => $desc, lib_opac => $desc } )->store; + { status => 'added' }; +}; +reply( $q, '200 OK', $out // { status => 'failed', error => "$@" } ); diff --git a/library/quickadd/Dockerfile b/library/quickadd/Dockerfile index 5ff5b3f..4a6dde1 100644 --- a/library/quickadd/Dockerfile +++ b/library/quickadd/Dockerfile @@ -5,7 +5,7 @@ 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 zbar-tools tzdata \ + && apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \ && rm -rf /var/lib/apt/lists/* # Optional: drop extra CA certificates (*.crt) into ./certs/ before building if @@ -13,7 +13,7 @@ RUN apt-get update \ COPY certs/ /usr/local/share/ca-certificates/extra/ RUN update-ca-certificates -RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null +RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null && command -v qrencode >/dev/null RUN useradd --system --uid 10001 --home-dir /app quickadd \ && mkdir -p /app /data && chown quickadd /data diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc index 993e3c682960b6876488e158f598df23c596459b..7ee1492cec76079e8847be327ff69854d6ecda56 100644 GIT binary patch delta 26983 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 HNr3+gd9k-Q diff --git a/library/quickadd/app.py b/library/quickadd/app.py index 3aef505..58aed23 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -306,6 +306,26 @@ def get_locations(): return _LOC_CACHE["value"] +_COLL_CACHE = {"at": 0.0, "value": []} + + +def get_collections(): + """Collections (Koha authorised-value category CCODE): 'Kids', 'Cookbooks', ... one per copy.""" + if time.time() - _COLL_CACHE["at"] < 30: + return _COLL_CACHE["value"] + try: + vals = koha("GET", "/authorised_value_categories/CCODE/authorised_values") or [] + _COLL_CACHE["value"] = [{"value": v["value"], "label": v.get("description") or v["value"]} for v in vals] + _COLL_CACHE["at"] = time.time() + except KohaError: + pass + return _COLL_CACHE["value"] + + +def valid_collection(code): + return code if code and any(c["value"] == code for c in get_collections()) else None + + def valid_location(code): return code if code and any(l["value"] == code for l in get_locations()) else None @@ -741,7 +761,9 @@ def _shape_book(b, items, where): mine = [i for i in items if i["biblio_id"] == b["biblio_id"]] out = sum(1 for i in mine if item_status(i) == "Checked out") shelves = sorted({where.get(i.get("location"), "") for i in mine} - {""}) - return {"id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "", + colls = {c["value"]: c["label"] for c in get_collections()} + collections = sorted({colls.get(i.get("collection_code"), "") for i in mine} - {""}) + return {"collections": collections, "id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "", "year": b.get("copyright_date") or "", "isbn": b.get("isbn") or "", "type": b.get("item_type") or "", "copies": len(mine), "out": out, "shelves": shelves, "callnumber": next((i.get("callnumber") for i in mine if i.get("callnumber")), "") or ""} @@ -752,7 +774,7 @@ def op_library(qs): def one(k): return (qs.get(k) or [""])[0] term = re.sub(r"[%_\\]", "", one("q")).strip()[:80] - shelf, itype, status = one("shelf"), one("type"), one("status") + shelf, itype, status, coll = one("shelf"), one("type"), one("status"), one("collection") sort = one("sort") or "title" try: page = max(1, int(one("page") or 1)) @@ -770,6 +792,10 @@ def op_library(qs): item_q["location"] = shelf if itype: item_q["item_type_id"] = itype + if coll == "-": + item_q["collection_code"] = None + elif coll: + item_q["collection_code"] = coll if status == "out": item_q["checked_out_date"] = {"!=": None} elif status == "in": @@ -823,6 +849,8 @@ def op_book(biblio_id, staff): for i in items: c = {"item_id": i["item_id"], "barcode": i.get("external_id") or "", "callnumber": i.get("callnumber") or "", "shelf": i.get("location") or "", "shelf_label": where.get(i.get("location"), ""), + "collection": i.get("collection_code") or "", + "collection_label": {c["value"]: c["label"] for c in get_collections()}.get(i.get("collection_code"), ""), "status": item_status(i), "type": i.get("item_type_id") or ""} if staff and c["status"] == "Checked out": c["out"] = out.get(i["item_id"]) @@ -832,7 +860,7 @@ def op_book(biblio_id, staff): "isbn": b.get("isbn") or "", "publisher": b.get("publisher") or "", "pages": b.get("pages") or "", "edition": b.get("edition_statement") or "", "summary": b.get("abstract") or b.get("notes") or "", "series": b.get("series_title") or "", "type": b.get("item_type") or "", "copies": copies, - "shelves": get_locations()} + "shelves": get_locations(), "collections": get_collections()} def op_item_update(payload): @@ -843,6 +871,8 @@ def op_item_update(payload): it = koha("GET", "/items/%d" % item_id) if "shelf" in payload: it["location"] = valid_location(payload.get("shelf")) or None + if "collection" in payload: + it["collection_code"] = valid_collection(payload.get("collection")) or None if "callnumber" in payload: it["callnumber"] = (str(payload.get("callnumber") or "").strip()[:80]) or None # Koha's update route rejects the computed fields the GET adds. @@ -1057,10 +1087,130 @@ def op_patron_add(payload): return 400, {"status": "invalid", "message": "A name is needed."} body = {"surname": surname, "firstname": first or None, "category_id": PATRON_CATEGORY, "library_id": LIBRARY, "email": clean("email", 100) or None, "phone": clean("phone", 30) or None} - p = koha("POST", "/patrons", body) + try: + p = koha("POST", "/patrons", body, headers={"x-confirm-not-duplicate": "1"} if payload.get("confirm") else None) + except KohaError as e: + if e.status == 409 or "already exists" in e.body: + return 200, {"status": "duplicate", "message": "Someone named %s is already in the library." % _patron_name(body)} + raise return 200, dict(_shape_patron(p), status="added") +# ------------------------------------------------ new shelves / collections, QR codes, book info +KOHA_VALUE_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/quickadd-value.pl" + + +def op_value_add(payload): + """Add a shelf/room or a collection (Koha's REST API cannot; a small guarded Koha script does).""" + kind = payload.get("kind") + cat = {"shelf": "LOC", "collection": "CCODE"}.get(kind) + name = re.sub(r"\s+", " ", str(payload.get("name") or "")).strip()[:60] + if not cat or not name: + return 400, {"status": "invalid", "message": "A name is needed."} + if not SSO_SECRET: + return 200, {"status": "error", "message": "Adding these needs SSO_SECRET in .env (run the installer's Update)."} + existing = get_locations() if cat == "LOC" else get_collections() + for v in existing: # same name again: hand back the one that exists + if v["label"].strip().lower() == name.lower(): + return 200, {"status": "added", "value": v["value"], "label": v["label"]} + base = re.sub(r"[^A-Z0-9]+", "", name.upper())[:12] or "X" + have = {v["value"] for v in existing} + code, n = base, 2 + while code in have: + code = base[:10] + str(n) + n += 1 + req = urllib.request.Request(KOHA_VALUE_URL + "?" + urllib.parse.urlencode({"category": cat, "value": code, "description": name}), + headers={"X-Quickadd-Secret": SSO_SECRET}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + res = json.loads(r.read()) + except (urllib.error.URLError, OSError, ValueError): + return 502, {"status": "error", "message": "Koha did not answer - try again."} + if res.get("status") != "added": + return 200, {"status": "error", "message": "Koha would not add that."} + _LOC_CACHE["at"] = _COLL_CACHE["at"] = 0.0 + return 200, {"status": "added", "value": code, "label": name} + + +def qr_svg(text): + if not re.fullmatch(r"[A-Za-z0-9-]{1,40}", text or ""): + raise ValueError("bad text") + return subprocess.run(["qrencode", "-t", "SVG", "-m", "2", "-s", "8", "-l", "M", "-o", "-", text], + capture_output=True, timeout=10, check=True).stdout + + +MORE_FILE = os.path.join(DATA_DIR, "more.json") +MORE_TTL = 30 * 86400 + + +def _http_json(url): + req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "library-quickadd/1 (home library)"}) + with urllib.request.urlopen(req, timeout=8) as r: + return json.loads(r.read()) + + +def _ol_text(v): + if isinstance(v, dict): + v = v.get("value") + return re.sub(r"\s+", " ", v).strip() if isinstance(v, str) else "" + + +def op_more(biblio_id): + """Extra details from Open Library (description, subjects) plus look-up links, cached on disk. + AR BookFind is a link only: its terms forbid automated access, so nothing is fetched from it.""" + b = koha("GET", "/biblios/%d" % biblio_id) + isbn = re.sub(r"[^0-9Xx]", "", (b.get("isbn") or "").split(" ")[0].split("|")[0]) + title = (b.get("title") or "").rstrip(" /:") + out = {"links": [], "description": "", "subjects": [], "first_published": ""} + q = urllib.parse.quote(title + " " + (b.get("author") or "")) + if isbn: + out["links"] = [ + {"label": "Open Library", "url": "https://openlibrary.org/isbn/" + isbn}, + {"label": "Goodreads", "url": "https://www.goodreads.com/book/isbn/" + isbn}, + {"label": "LibraryThing", "url": "https://www.librarything.com/isbn/" + isbn}, + {"label": "WorldCat", "url": "https://search.worldcat.org/search?q=bn%3A" + isbn}] + out["links"].append({"label": "AR BookFind (reading level)", "url": "https://www.arbookfind.com/", "note": "search for: " + title}) + if not isbn: + return out + with LOCK: + try: + cache = json.load(open(MORE_FILE)) + except (OSError, ValueError): + cache = {} + hit = cache.get(isbn) + if hit and time.time() - hit["at"] < (MORE_TTL if hit["ok"] else 3600): + return dict(out, **hit["data"]) + data, ok = {}, False + try: + ed = _http_json("https://openlibrary.org/isbn/%s.json" % isbn) + work = (ed.get("works") or [{}])[0].get("key") + data["description"] = _ol_text(ed.get("description")) + data["subjects"] = [] + if work: + w = _http_json("https://openlibrary.org%s.json" % work) + data["description"] = _ol_text(w.get("description")) or data["description"] + data["subjects"] = [str(x) for x in (w.get("subjects") or [])][:12] + data["first_published"] = str(w.get("first_publish_date") or "") + out["links"][0] = {"label": "Open Library", "url": "https://openlibrary.org" + work} + data["description"] = data["description"].split("----------")[0].strip()[:1500] + ok = True + except Exception: + pass + if ok: + data["link0"] = out["links"][0] + cache[isbn] = {"at": time.time(), "ok": ok, "data": data} + with LOCK: + try: + tmp = MORE_FILE + ".tmp" + json.dump(cache, open(tmp, "w")) + os.replace(tmp, MORE_FILE) + except OSError: + pass + if ok and data.get("link0"): + out["links"][0] = data.pop("link0") + return dict(out, **data) + + # ------------------------------------------------------------ photo scanning MAX_IMAGE = 12 * 1024 * 1024 ZBAR_TIMEOUT = 20 @@ -1245,11 +1395,25 @@ class Handler(BaseHTTPRequestHandler): except KohaError as e: return self._send(404 if e.status == 404 else 502, {"status": "error", "message": "Koha is not answering yet - try again in a minute." if e.status == 503 else "Failed: %s" % e.body[:120]}) + if path == "/api/qr": + try: + svg = qr_svg((urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("t") or [""])[0]) + except (ValueError, OSError, subprocess.SubprocessError): + return self._send(400, b"bad", "text/plain") + return self._send(200, svg, "image/svg+xml") + if path.startswith("/api/more/"): + m = re.match(r"^/api/more/(\d{1,9})$", path) + if not m: + return self._send(404, {"error": "not found"}) + try: + return self._send(200, op_more(int(m.group(1)))) + except KohaError: + return self._send(502, {"status": "error"}) if path == "/api/library" or path.startswith("/api/book/"): try: if path == "/api/library": return self._send(200, dict(op_library(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)), - shelves=get_locations(), types=get_item_types())) + shelves=get_locations(), collections=get_collections(), types=get_item_types())) m = re.match(r"^/api/book/(\d{1,9})$", path) if not m: return self._send(404, {"error": "not found"}) @@ -1290,7 +1454,7 @@ class Handler(BaseHTTPRequestHandler): "/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign, "/api/item/update": op_item_update, "/api/item/delete": op_item_delete, "/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete, - "/api/checkout": op_checkout, "/api/return": op_return, "/api/renew": op_renew, "/api/patron/add": op_patron_add} + "/api/value/add": op_value_add, "/api/checkout": op_checkout, "/api/return": op_return, "/api/renew": op_renew, "/api/patron/add": op_patron_add} fn = ops.get(self.path) if not fn: return self._send(404, {"error": "not found"}) diff --git a/library/quickadd/index.html b/library/quickadd/index.html index 6ef0527..e3a3830 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -129,7 +129,11 @@ .libcard { width: 85.6mm; height: 54mm; border: 1px solid #888; border-radius: 3mm; padding: 5mm; background: #fff; color: #000; display: flex; flex-direction: column; justify-content: space-between; font-family: system-ui, sans-serif; } .libcard .lib { font-size: 11pt; font-weight: 700; } .libcard .nm { font-size: 14pt; font-weight: 700; } - .libcard svg { width: 100%; height: 16mm; } .libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; } + .libcard .codes { display: flex; gap: 3mm; align-items: center; } .libcard .bc { flex: 1; min-width: 0; } + .libcard .bc svg { width: 100%; height: 14mm; display: block; } .libcard img.qr { width: 19mm; height: 19mm; flex: none; } + .libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; } + .cardsheet { display: grid; grid-template-columns: repeat(2, 85.6mm); gap: 6mm; justify-content: start; } + .cardsheet .libcard { break-inside: avoid; } @media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } } .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } @@ -156,6 +160,7 @@
+ @@ -175,6 +180,7 @@ +
@@ -184,6 +190,9 @@ +
+ Photograph the library card, or the QR picture on the borrower's phone.
+
@@ -203,6 +212,7 @@
+
@@ -212,10 +222,16 @@ + +