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 993e3c6..7ee1492 100644 Binary files a/library/quickadd/__pycache__/app.cpython-313.pyc and b/library/quickadd/__pycache__/app.cpython-313.pyc differ 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 @@ + +