From 6f877d1107a4d89e430b7f3e2b9f6f410940d81f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:49:33 +0000 Subject: [PATCH] library: live camera scanning over HTTPS, paste-to-fill AR reading levels (with interest level) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 12 ++ .../quickadd/__pycache__/app.cpython-313.pyc | Bin 111723 -> 112366 bytes library/quickadd/app.py | 13 ++- library/quickadd/index.html | 107 ++++++++++++++++-- 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/library/README.md b/library/README.md index 227c702..bd1a2ad 100644 --- a/library/README.md +++ b/library/README.md @@ -243,3 +243,15 @@ Instead: the book page has an **Open AR BookFind** link (plus **Copy title** to `library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself (for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json` in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder. + +### Live camera scanning (needs HTTPS) + +Browsers only allow a live camera on HTTPS pages (or on the machine itself). Put the library site behind Caddy with a real +certificate and **Live scan** buttons appear: Add books (scan a stack of ISBNs one after another), Lend / return (live card or +phone-QR scan, live book check-out for the chosen person, live returns). Chrome on Android reads codes in the browser; other +browsers send small camera frames to the server's decoder a couple of times a second. On plain HTTP the photo buttons stay. + +### Copying reading levels from AR BookFind + +On the AR BookFind book page, select everything (Ctrl/Cmd+A), copy, and paste into the box on the book page here: the level, +points, quiz number and interest level fill in by themselves; check them and press Save. diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc index 345fff7c32fd10d63e1e73ae51912127cb6bf880..507af44bc03c34c2241a62a835c78a3a02c69779 100644 GIT binary patch delta 2589 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 diff --git a/library/quickadd/app.py b/library/quickadd/app.py index 7b7f4e2..e810082 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -1247,12 +1247,13 @@ def _num(v, maxlen=8): def op_ar_set(payload): key = _isbn13(payload.get("isbn")) level, points, quiz = _num(payload.get("level")), _num(payload.get("points")), re.sub(r"[^0-9]", "", str(payload.get("quiz") or ""))[:8] + interest = re.sub(r"[^A-Za-z+]", "", str(payload.get("interest") or ""))[:4].upper() if not key or level is None or points is None: return 400, {"status": "invalid", "message": "Level and points are numbers like 4.5 and 6."} with LOCK: d = _ar_load() - if level or points or quiz: - d[key] = {"level": level, "points": points, "quiz": quiz} + if level or points or quiz or interest: + d[key] = {"level": level, "points": points, "quiz": quiz, "interest": interest} else: d.pop(key, None) _ar_save(d) @@ -1272,6 +1273,7 @@ def op_ar_import(payload): if n in head: return head.index(n) return None + cint = col("interest", "interestlevel", "il") ci, cl, cp, cq = col("isbn", "isbn13"), col("level", "arlevel", "bl", "booklevel"), col("points", "arpoints", "pts"), col("quiz", "quizno", "quiznumber") if ci is None or (cl is None and cp is None and cq is None): return 400, {"status": "invalid", "message": "Need a header row with an isbn column and at least one of: level, points, quiz."} @@ -1287,7 +1289,8 @@ def op_ar_import(payload): continue old = d.get(key, {}) d[key] = {"level": lv or old.get("level", ""), "points": pt or old.get("points", ""), - "quiz": re.sub(r"[^0-9]", "", get(cq))[:8] or old.get("quiz", "")} + "quiz": re.sub(r"[^0-9]", "", get(cq))[:8] or old.get("quiz", ""), + "interest": re.sub(r"[^A-Za-z+]", "", get(cint))[:4].upper() or old.get("interest", "")} done += 1 _ar_save(d) return 200, {"status": "imported", "count": done, "skipped": skipped} @@ -1301,7 +1304,7 @@ def export_csv(): ar = _ar_load() out = io.StringIO() w = csv.writer(out) - w.writerow(["title", "author", "isbn", "year", "barcode", "call number", "shelf", "collection", "status", "ar level", "ar points", "ar quiz"]) + w.writerow(["title", "author", "isbn", "year", "barcode", "call number", "shelf", "collection", "status", "ar level", "ar points", "ar quiz", "ar interest"]) page = 1 while True: books = koha("GET", "/biblios", query={"_per_page": "200", "_page": str(page), "_order_by": "title"}) or [] @@ -1316,7 +1319,7 @@ def export_csv(): for i in mine: w.writerow([(b.get("title") or "").rstrip(" /:"), b.get("author") or "", isbn, b.get("copyright_date") or "", i.get("external_id") or "", i.get("callnumber") or "", where.get(i.get("location"), ""), - colls.get(i.get("collection_code"), ""), item_status(i) if i else "", a.get("level", ""), a.get("points", ""), a.get("quiz", "")]) + colls.get(i.get("collection_code"), ""), item_status(i) if i else "", a.get("level", ""), a.get("points", ""), a.get("quiz", ""), a.get("interest", "")]) if len(books) < 200: break page += 1 diff --git a/library/quickadd/index.html b/library/quickadd/index.html index 11ee74f..7fc17f9 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -135,6 +135,12 @@ .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; } } + #live { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.92); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 12px; } + #live[hidden] { display: none; } + #live video { width: min(100%, 640px); max-height: 60vh; border-radius: 12px; background: #000; object-fit: cover; } + #live .frame { position: relative; width: min(100%, 640px); } + #live .frame::after { content: ''; position: absolute; inset: 18% 12%; border: 3px solid rgba(255,255,255,.85); border-radius: 12px; pointer-events: none; } + #live .msg { color: #fff; font-weight: 600; text-align: center; min-height: 3em; max-width: 640px; } .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } @@ -190,7 +196,7 @@ -
+
@@ -200,7 +206,7 @@ -
+
Photograph the library card, or the QR picture on the borrower's phone.
@@ -262,6 +268,7 @@
+ Opens your phone's camera: fill the frame with the barcode and hold steady.
@@ -309,6 +316,11 @@
    +