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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
+170
-6
@@ -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"})
|
||||
|
||||
+136
-24
@@ -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); }
|
||||
</style>
|
||||
@@ -156,6 +160,7 @@
|
||||
</form>
|
||||
<div class="filters">
|
||||
<label class="sr" for="f-shelf">Shelf</label><select id="f-shelf"></select>
|
||||
<label class="sr" for="f-coll">Collection</label><select id="f-coll"></select>
|
||||
<label class="sr" for="f-type">Format</label><select id="f-type"></select>
|
||||
<label class="sr" for="f-status">Status</label><select id="f-status">
|
||||
<option value="">Any status</option><option value="in">On the shelf</option><option value="out">Checked out</option></select>
|
||||
@@ -175,6 +180,7 @@
|
||||
<input id="ret" inputmode="numeric" placeholder="Scan the book's barcode" autocomplete="off">
|
||||
<button class="primary" type="submit">Return</button>
|
||||
</form>
|
||||
<div class="cam-row"><button type="button" id="retcam">Return with camera</button></div>
|
||||
<div id="retresult" role="status" aria-live="polite" class="small" style="margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="copybox">
|
||||
@@ -184,6 +190,9 @@
|
||||
<input id="who" type="search" placeholder="Name, or scan the library card" enterkeyhint="search" autocomplete="off">
|
||||
<button type="submit">Find</button>
|
||||
</form>
|
||||
<div class="cam-row"><button type="button" id="whocam">Scan card or phone QR with camera</button>
|
||||
<span class="small">Photograph the library card, or the QR picture on the borrower's phone.</span></div>
|
||||
<div id="whostatus" class="small"></div>
|
||||
<div id="whoresults"></div>
|
||||
<div id="patronbox"></div>
|
||||
</div>
|
||||
@@ -203,6 +212,7 @@
|
||||
</form>
|
||||
<div id="personresult" aria-live="polite"></div>
|
||||
</details>
|
||||
<p><a href="#cards"><button type="button">Print everyone's cards</button></a></p>
|
||||
<form class="find-form" id="peopleform" autocomplete="off">
|
||||
<label class="sr" for="pq">Search people</label>
|
||||
<input id="pq" type="search" placeholder="Search by name or card number" enterkeyhint="search">
|
||||
@@ -212,10 +222,16 @@
|
||||
</section>
|
||||
|
||||
<section id="view-card" hidden>
|
||||
<p class="noprint"><a href="#people">← Back to people</a> <button type="button" id="printcard" class="primary">Print this card</button></p>
|
||||
<p class="noprint"><a href="#people">← Back to people</a> <button type="button" id="printcard" class="primary">Print this card</button> <button type="button" id="savecard">Save as picture (to send to a phone)</button></p>
|
||||
<div id="cardbox"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-cards" hidden>
|
||||
<p class="noprint"><a href="#people">← Back to people</a> <button type="button" id="printcards" class="primary">Print these cards</button>
|
||||
<span class="small" id="cardsnote"></span></p>
|
||||
<div class="cardsheet" id="cardsheet"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-book" hidden>
|
||||
<p><a href="#library" id="back">← Back to the library</a></p>
|
||||
<div id="bookbox" aria-live="polite"></div>
|
||||
@@ -426,13 +442,14 @@
|
||||
}
|
||||
|
||||
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
|
||||
function takePhoto(kind) {
|
||||
function takePhoto(kind, statusEl) {
|
||||
statusEl = statusEl || $('status');
|
||||
return new Promise(resolve => {
|
||||
const input = $('camfile');
|
||||
input.onchange = async () => {
|
||||
const f = input.files[0]; input.value = '';
|
||||
if (!f) return resolve(null);
|
||||
$('status').textContent = 'Reading the barcode…';
|
||||
statusEl.textContent = 'Reading the barcode…';
|
||||
try {
|
||||
const blob = await shrink(f);
|
||||
const r = await fetch('api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg', 'X-Quickadd': '1' }, body: blob });
|
||||
@@ -578,8 +595,8 @@
|
||||
return a;
|
||||
}
|
||||
|
||||
let libPage = 1, libShelves = [], libLoaded = false;
|
||||
const filters = () => ({ q: $('q').value.trim(), shelf: $('f-shelf').value, type: $('f-type').value,
|
||||
let libPage = 1, libShelves = [], libCollections = [], libLoaded = false, filtersReady = false;
|
||||
const filters = () => ({ q: $('q').value.trim(), shelf: $('f-shelf').value, collection: $('f-coll').value, type: $('f-type').value,
|
||||
status: $('f-status').value, sort: $('f-sort').value });
|
||||
const filterQuery = () => { const f = filters(); return Object.entries(f).filter(([, v]) => v).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&'); };
|
||||
|
||||
@@ -604,8 +621,12 @@
|
||||
try { r = await api('/api/library?' + filterQuery() + '&page=' + libPage); } catch (e) { r = {}; }
|
||||
if (seq !== libSeq) return;
|
||||
if (!r.books) { $('libstatus').textContent = r.message || 'Koha is not answering yet - try again in a minute.'; return; }
|
||||
if (!libShelves.length && r.shelves) {
|
||||
if (r.collections) libCollections = r.collections;
|
||||
if (!filtersReady && r.shelves) {
|
||||
filtersReady = true;
|
||||
libShelves = r.shelves;
|
||||
$('f-coll').replaceChildren(el('option', { value: '' }, 'Any collection'), el('option', { value: '-' }, '(no collection)'),
|
||||
...(r.collections || []).map(l => el('option', { value: l.value }, l.label)));
|
||||
$('f-shelf').replaceChildren(el('option', { value: '' }, 'Any shelf'), el('option', { value: '-' }, '(no shelf set)'),
|
||||
...r.shelves.map(l => el('option', { value: l.value }, l.label)));
|
||||
$('f-type').replaceChildren(el('option', { value: '' }, 'Any format'),
|
||||
@@ -615,12 +636,12 @@
|
||||
$('more').hidden = !r.more;
|
||||
const shown = grid.children.length;
|
||||
const f = filters();
|
||||
$('libstatus').textContent = shown ? shown + (r.more ? '+' : '') + (shown === 1 ? ' book' : ' books') + (f.q || f.shelf || f.type || f.status ? ' match' : '') :
|
||||
(f.q || f.shelf || f.type || f.status ? 'No books match.' : (viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.'));
|
||||
$('libstatus').textContent = shown ? shown + (r.more ? '+' : '') + (shown === 1 ? ' book' : ' books') + (f.q || f.shelf || f.collection || f.type || f.status ? ' match' : '') :
|
||||
(f.q || f.shelf || f.collection || f.type || f.status ? 'No books match.' : (viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.'));
|
||||
}
|
||||
$('more').onclick = () => { libPage += 1; loadLibrary(false); };
|
||||
$('findform').onsubmit = ev => { ev.preventDefault(); loadLibrary(true); };
|
||||
for (const id of ['f-shelf', 'f-type', 'f-status', 'f-sort']) $(id).onchange = () => loadLibrary(true);
|
||||
for (const id of ['f-shelf', 'f-coll', 'f-type', 'f-status', 'f-sort']) $(id).onchange = () => loadLibrary(true);
|
||||
|
||||
// ---- one book
|
||||
async function loadBook(id) {
|
||||
@@ -631,6 +652,7 @@
|
||||
if (!b.copies) { box.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, b.message || 'Book not found.'))); return; }
|
||||
document.title = b.title;
|
||||
if (b.shelves && b.shelves.length) libShelves = b.shelves;
|
||||
if (b.collections) libCollections = b.collections;
|
||||
const facts = [b.author, b.year, b.publisher, b.pages, b.edition, b.series].filter(Boolean);
|
||||
const head = el('div', { className: 'bookhead' }, art(b),
|
||||
el('div', { className: 'body' }, el('h2', {}, b.title), b.subtitle ? el('div', { className: 'meta' }, b.subtitle) : '',
|
||||
@@ -638,6 +660,9 @@
|
||||
b.summary ? el('p', {}, b.summary) : ''));
|
||||
box.replaceChildren(head);
|
||||
const refresh = () => loadBook(id);
|
||||
const moreBox = el('div', { className: 'copybox' }, el('div', { className: 'small' }, 'Looking for more about this book…'));
|
||||
box.append(moreBox);
|
||||
api('/api/more/' + id).then(m => fillMore(moreBox, b, m)).catch(() => moreBox.remove());
|
||||
for (const c of b.copies) box.append(copyBox(b, c, refresh));
|
||||
if (!b.copies.length) box.append(el('div', { className: 'small' }, 'No copies recorded.'));
|
||||
if (!viewer) {
|
||||
@@ -654,6 +679,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A shelf/collection chooser whose last entry adds a new one (Koha's own screens are not needed).
|
||||
function valueSelect(kind, label, none, list, current) {
|
||||
const sel = el('select', { 'aria-label': label });
|
||||
const fill = (items, cur) => { sel.replaceChildren(el('option', { value: '' }, none), ...items.map(l => el('option', { value: l.value }, l.label)),
|
||||
el('option', { value: '__new' }, '+ New ' + kind + '…')); sel.value = cur || ''; };
|
||||
fill(list, current);
|
||||
let last = current || '';
|
||||
sel.onchange = async () => {
|
||||
if (sel.value !== '__new') { last = sel.value; return; }
|
||||
const name = prompt('Name of the new ' + kind + ':');
|
||||
if (!name || !name.trim()) { sel.value = last; return; }
|
||||
const r = await api('/api/value/add', { kind, name });
|
||||
if (r.status !== 'added') { alert(r.message || 'Could not add that.'); sel.value = last; return; }
|
||||
const items = (kind === 'shelf' ? libShelves : libCollections); items.push({ value: r.value, label: r.label });
|
||||
fill(items, r.value); last = r.value;
|
||||
};
|
||||
return sel;
|
||||
}
|
||||
|
||||
function fillMore(box, b, m) {
|
||||
const parts = [];
|
||||
if (m.description && !b.summary) parts.push(el('p', {}, m.description), el('div', { className: 'small' }, 'Description from Open Library.'));
|
||||
if (m.subjects && m.subjects.length) parts.push(el('div', { style: 'margin:8px 0' }, ...m.subjects.map(t => el('span', { className: 'chip', style: 'margin:0 6px 6px 0;display:inline-block' }, t))));
|
||||
if (m.first_published) parts.push(el('div', { className: 'small' }, 'First published: ' + m.first_published));
|
||||
const row = el('div', { className: 'small', style: 'margin-top:8px' }, 'Look it up: ');
|
||||
(m.links || []).forEach((l, i) => { if (i) row.append(' · '); row.append(el('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer' }, l.label)); });
|
||||
parts.push(row);
|
||||
if ((m.links || []).some(l => l.note)) parts.push(el('div', { className: 'small' }, 'AR BookFind has no public feed and its terms forbid automated look-ups, so open it and search for "' + b.title + '" yourself.'));
|
||||
box.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
function copyBox(b, c, refresh) {
|
||||
const box = el('div', { className: 'copybox' });
|
||||
const out = c.status === 'Checked out';
|
||||
@@ -664,14 +720,13 @@
|
||||
box.append(el('div', { className: 'meta' }, [c.shelf_label || 'no shelf set', c.callnumber && 'Call no. ' + c.callnumber].filter(Boolean).join(' · ')));
|
||||
return box;
|
||||
}
|
||||
const sel = el('select', { 'aria-label': 'Shelf' }, el('option', { value: '' }, '(no shelf set)'),
|
||||
...libShelves.map(l => el('option', { value: l.value }, l.label)));
|
||||
sel.value = c.shelf;
|
||||
const sel = valueSelect('shelf', 'Shelf', '(no shelf set)', libShelves, c.shelf);
|
||||
const csel = valueSelect('collection', 'Collection', '(no collection)', libCollections, c.collection);
|
||||
const cn = el('input', { type: 'text', value: c.callnumber, placeholder: 'Call number', 'aria-label': 'Call number', maxLength: 80 });
|
||||
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
save.disabled = true;
|
||||
const r = await api('/api/item/update', { item_id: c.item_id, shelf: sel.value, callnumber: cn.value });
|
||||
const r = await api('/api/item/update', { item_id: c.item_id, shelf: sel.value, collection: csel.value, callnumber: cn.value });
|
||||
save.textContent = r.status === 'saved' ? 'Saved ✓' : 'Failed'; setTimeout(() => { save.textContent = 'Save'; save.disabled = false; }, 1500);
|
||||
};
|
||||
const rm = el('button', { type: 'button', className: 'danger' }, 'Remove copy');
|
||||
@@ -681,7 +736,8 @@
|
||||
const r = await api('/api/item/delete', { item_id: c.item_id, biblio_id: b.id });
|
||||
if (r.status === 'undone') { if (b.copies.length <= 1) location.hash = '#library'; else refresh(); }
|
||||
};
|
||||
box.append(el('div', { className: 'row' }, sel, cn, save, rm));
|
||||
const lab = el('a', { href: printLink(c.barcode, c.barcode), target: '_blank', rel: 'noopener' }, el('button', { type: 'button' }, 'Print label'));
|
||||
box.append(el('div', { className: 'row' }, sel, csel, cn, save, c.barcode ? lab : '', rm));
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -703,6 +759,23 @@
|
||||
}
|
||||
$('retform').onsubmit = async ev => { ev.preventDefault(); const c = $('ret').value.trim(); $('ret').value = ''; if (c) { await doReturn(c, $('retresult')); $('ret').focus(); } };
|
||||
|
||||
$('retcam').onclick = async () => {
|
||||
const codes = await takePhoto('sticker', $('retresult'));
|
||||
if (codes === null) { $('retresult').textContent = ''; return; }
|
||||
if (!codes.length) { $('retresult').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
|
||||
doReturn(codes[0], $('retresult'));
|
||||
};
|
||||
$('whocam').onclick = async () => {
|
||||
const codes = await takePhoto('sticker', $('whostatus'));
|
||||
if (codes === null) { $('whostatus').textContent = ''; return; }
|
||||
$('whostatus').textContent = '';
|
||||
if (!codes.length) { $('whostatus').textContent = "Couldn't read a code from that photo - move closer and try again."; return; }
|
||||
const r = await api('/api/patrons?q=' + encodeURIComponent(codes[0]));
|
||||
const hit = (r.patrons || []).filter(p => p.card === codes[0]);
|
||||
if (hit.length === 1) { beep(SOUND.added); location.hash = '#lend/' + hit[0].id; }
|
||||
else { beep(SOUND.notfound); $('whostatus').textContent = 'No library card matches ' + codes[0] + '.'; }
|
||||
};
|
||||
|
||||
async function loadOut() {
|
||||
let r; try { r = await api('/api/out'); } catch (e) { return; }
|
||||
if (!r.loans) return;
|
||||
@@ -749,9 +822,16 @@
|
||||
} catch (e) { say(out, 'bad', 'Could not reach the service.'); }
|
||||
lendBusy = false; await openPatron(p.id, true); loadOut(); document.querySelector('#patronbox form input').focus();
|
||||
};
|
||||
const bookCam = el('button', { type: 'button' }, 'Scan book with camera');
|
||||
bookCam.onclick = async () => {
|
||||
const codes = await takePhoto('sticker', out);
|
||||
if (codes === null) { out.textContent = ''; return; }
|
||||
if (!codes.length) { out.textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
|
||||
scan.value = codes[0]; form.requestSubmit();
|
||||
};
|
||||
const loans = p.loans.length ? p.loans.map(l => loanRow(l, false)) : [el('div', { className: 'small' }, 'Nothing checked out to ' + p.name + ' yet.')];
|
||||
$('patronbox').replaceChildren(el('div', { className: 'title', style: 'margin-top:10px' }, p.name, ' ', el('span', { className: 'badge' }, p.card || 'no card')),
|
||||
form, out, el('h2', {}, 'Has out'), ...loans,
|
||||
form, el('div', { className: 'cam-row' }, bookCam), out, el('h2', {}, 'Has out'), ...loans,
|
||||
el('div', { className: 'small', style: 'margin-top:8px' }, el('a', { href: '#card/' + p.id }, 'Print library card'), ' · ',
|
||||
el('a', { href: '#lend' }, 'Choose someone else')));
|
||||
if (!quiet) scan.focus();
|
||||
@@ -785,12 +865,17 @@
|
||||
$('peopleform').onsubmit = ev => { ev.preventDefault(); loadPeople($('pq').value.trim()); };
|
||||
$('personform').onsubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
const f = ev.target, r = await api('/api/patron/add', Object.fromEntries(new FormData(f)));
|
||||
const f = ev.target, confirm = f.dataset.confirm === '1'; delete f.dataset.confirm;
|
||||
const r = await api('/api/patron/add', Object.assign(Object.fromEntries(new FormData(f)), confirm ? { confirm: true } : {}));
|
||||
if (r.status === 'added') {
|
||||
f.reset();
|
||||
$('personresult').replaceChildren(el('div', { className: 'result-note' }, r.name + ' added with library card number ', el('span', { className: 'badge' }, r.card), '. ',
|
||||
el('a', { href: '#card/' + r.id }, 'Print the card'), ' or ', el('a', { href: '#lend/' + r.id }, 'check something out')));
|
||||
loadPeople('');
|
||||
} else if (r.status === 'duplicate') {
|
||||
const again = el('button', { type: 'button', className: 'primary' }, 'Add another person with this name');
|
||||
again.onclick = () => { f.requestSubmit && (f.dataset.confirm = '1'); f.requestSubmit(); };
|
||||
$('personresult').replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, r.message + ' ', again)));
|
||||
} else $('personresult').replaceChildren(el('div', { className: 'card bad' }, el('div', { className: 'body' }, r.message || 'Could not add that person.')));
|
||||
};
|
||||
|
||||
@@ -806,14 +891,39 @@
|
||||
}
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + (x + 9) + ' 50" preserveAspectRatio="none" fill="#000" role="img" aria-label="Barcode ' + text + '">' + bars + '</svg>';
|
||||
}
|
||||
function cardEl(p) {
|
||||
const svg = el('div', { className: 'bc' });
|
||||
svg.innerHTML = code39(p.card);
|
||||
const qr = el('img', { className: 'qr', alt: 'QR code ' + p.card, src: 'api/qr?t=' + encodeURIComponent(p.card) });
|
||||
return el('div', { className: 'libcard' }, el('div', { className: 'lib' }, libraryDisplay || 'Library card'), el('div', { className: 'nm' }, p.name),
|
||||
el('div', {}, el('div', { className: 'codes' }, svg, qr), el('div', { className: 'no' }, p.card)));
|
||||
}
|
||||
async function loadCard(id) {
|
||||
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
|
||||
if (!p.name) { $('cardbox').textContent = 'Could not load that person.'; return; }
|
||||
const lib = el('div', { className: 'libcard' }, el('div', { className: 'lib' }, libraryDisplay || 'Library card'), el('div', { className: 'nm' }, p.name),
|
||||
el('div', {}, el('div', { id: 'cardsvg' }), el('div', { className: 'no' }, p.card)));
|
||||
$('cardbox').replaceChildren(lib);
|
||||
$('cardsvg').innerHTML = code39(p.card);
|
||||
curCard = p;
|
||||
$('cardbox').replaceChildren(cardEl(p));
|
||||
}
|
||||
let curCard = null;
|
||||
async function loadCards() {
|
||||
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(($('pq').value || '').trim())); } catch (e) { r = {}; }
|
||||
const list = (r.patrons || []).filter(p => p.card);
|
||||
$('cardsheet').replaceChildren(...list.map(cardEl));
|
||||
$('cardsnote').textContent = list.length ? list.length + ' card' + (list.length === 1 ? '' : 's') + (($('pq').value || '').trim() ? ' (matching your search)' : '') : 'Nobody to print yet.';
|
||||
}
|
||||
$('printcards').onclick = () => window.print();
|
||||
// A picture of the QR (name + number under it) the person can keep on their phone and hold up to be scanned.
|
||||
$('savecard').onclick = async () => {
|
||||
if (!curCard) return;
|
||||
const svgText = await (await fetch('api/qr?t=' + encodeURIComponent(curCard.card), { headers: { 'X-Quickadd': '1' } })).text();
|
||||
const img = new Image(); await new Promise((ok, no) => { img.onload = ok; img.onerror = no; img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText); });
|
||||
const c = document.createElement('canvas'); c.width = 640; c.height = 780; const g = c.getContext('2d');
|
||||
g.fillStyle = '#fff'; g.fillRect(0, 0, c.width, c.height); g.fillStyle = '#000'; g.textAlign = 'center';
|
||||
g.font = 'bold 34px sans-serif'; g.fillText(libraryDisplay || 'Library card', 320, 60);
|
||||
g.imageSmoothingEnabled = false; g.drawImage(img, 70, 90, 500, 500);
|
||||
g.font = 'bold 40px sans-serif'; g.fillText(curCard.name, 320, 650); g.font = '34px monospace'; g.fillText(curCard.card, 320, 705);
|
||||
const a = document.createElement('a'); a.href = c.toDataURL('image/png'); a.download = 'library-card-' + curCard.card + '.png'; a.click();
|
||||
};
|
||||
$('printcard').onclick = () => window.print();
|
||||
|
||||
function route() {
|
||||
@@ -825,11 +935,12 @@
|
||||
else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
|
||||
else if (h === 'people') view = 'people';
|
||||
else if ((m = h.match(/^card\/(\d+)$/))) view = 'card';
|
||||
for (const v of ['library', 'book', 'add', 'lend', 'people', 'card']) $('view-' + v).hidden = v !== view;
|
||||
const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people' }[view];
|
||||
else if (h === 'cards') view = 'cards';
|
||||
for (const v of ['library', 'book', 'add', 'lend', 'people', 'card', 'cards']) $('view-' + v).hidden = v !== view;
|
||||
const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people', cards: 'people' }[view];
|
||||
for (const t of ['library', 'add', 'lend', 'people']) $('tab-' + t).classList.toggle('on', t === tab);
|
||||
for (const t of ['add', 'lend', 'people']) $('tab-' + t).hidden = viewer;
|
||||
const names = { library: 'Library', book: 'Library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card' };
|
||||
const names = { library: 'Library', book: 'Library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards' };
|
||||
$('title').textContent = names[view]; document.title = names[view];
|
||||
if (view === 'book') loadBook(m[1]);
|
||||
else if (view === 'add') $('code').focus();
|
||||
@@ -837,6 +948,7 @@
|
||||
if (m && m[1]) openPatron(m[1]); else { $('whoresults').replaceChildren(); $('who').focus(); } }
|
||||
else if (view === 'people') { loadPeople($('pq').value.trim()); }
|
||||
else if (view === 'card') loadCard(m[1]);
|
||||
else if (view === 'cards') loadCards();
|
||||
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
|
||||
}
|
||||
window.addEventListener('hashchange', route);
|
||||
|
||||
Reference in New Issue
Block a user