library: Lend / return, People and printable library cards in the Library site
Checkout via Koha REST, check-in through a secret-guarded script that runs Koha's AddReturn, renewals, overdue list, add-a-person with auto card number, Code 39 library card page. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
@@ -207,3 +207,20 @@ quickadd/ the scan-to-add service (Python standard library + yaz)
|
|||||||
## Signing in once
|
## Signing in once
|
||||||
|
|
||||||
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
|
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
|
||||||
|
|
||||||
|
## Everyday use (the Library site at `/add/`)
|
||||||
|
|
||||||
|
One page for the day-to-day, so you rarely need Koha's own screens:
|
||||||
|
|
||||||
|
- **Library**: a cover grid of everything you own. Search by title, author or ISBN; filter by shelf, format
|
||||||
|
(book/DVD/CD) and status (on the shelf / checked out); sort by title, author or newest. Click a book for its
|
||||||
|
page: copies with barcode, status, who has it and when it is due; change a copy's shelf or call number,
|
||||||
|
add another copy, remove a copy, or delete the book. (Title/author edits are one click away in Koha.)
|
||||||
|
- **Lend / return**: scan or type a library card number (or search by name), then scan books to check them out;
|
||||||
|
"Return a book" takes a scan with no person needed; **Renew** extends a loan; the list below shows everything
|
||||||
|
that is out, overdue ones flagged. Check-in goes through Koha's own check-in code, so holds and rules still apply.
|
||||||
|
- **People**: add someone with just a name (the card number is assigned automatically), search, and
|
||||||
|
**Print card** for a library card with a scannable Code 39 barcode.
|
||||||
|
- **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).
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ 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).
|
# 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
|
COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
|
||||||
RUN chmod 0755 /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 bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
||||||
COPY seed/ /opt/koha-init/seed/
|
COPY seed/ /opt/koha-init/seed/
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/perl
|
||||||
|
# Installed as .../intranet/cgi-bin/quickadd-return.pl (URL /cgi-bin/koha/quickadd-return.pl).
|
||||||
|
#
|
||||||
|
# Koha's REST API has no "check in" call, so the Add books / Lend page asks this small script to do it
|
||||||
|
# with Koha's own C4::Circulation::AddReturn (the same code the staff Check in screen runs: it clears
|
||||||
|
# the loan, handles holds and fines rules). Same shared-secret guard as quickadd-session: only callers
|
||||||
|
# that know the secret get an answer.
|
||||||
|
use Modern::Perl;
|
||||||
|
use CGI qw( -utf8 );
|
||||||
|
use JSON qw( encode_json );
|
||||||
|
use Digest::SHA qw( sha256_hex );
|
||||||
|
use C4::Context;
|
||||||
|
use C4::Circulation qw( AddReturn );
|
||||||
|
use Koha::Items;
|
||||||
|
|
||||||
|
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 $barcode = $q->param('barcode') // '';
|
||||||
|
reply( $q, '200 OK', { status => 'invalid' } ) unless $barcode =~ /\A[\x21-\x7e]{1,40}\z/;
|
||||||
|
|
||||||
|
my $item = Koha::Items->find( { barcode => $barcode } );
|
||||||
|
reply( $q, '200 OK', { status => 'unknown_barcode' } ) unless $item;
|
||||||
|
|
||||||
|
# AddReturn needs to know which branch it is happening at.
|
||||||
|
my $branch = $item->homebranch;
|
||||||
|
my ( $doreturn, $messages, $borrower, $was_out );
|
||||||
|
eval {
|
||||||
|
C4::Context->set_userenv( 0, 0, 'quickadd', 'Quick', 'Add', $branch, $branch, 0, '', '' );
|
||||||
|
$was_out = $item->checkout ? 1 : 0;
|
||||||
|
( $doreturn, $messages, undef, $borrower ) = AddReturn( $barcode, $branch );
|
||||||
|
1;
|
||||||
|
} or reply( $q, '200 OK', { status => 'failed', error => "$@" } );
|
||||||
|
reply(
|
||||||
|
$q, '200 OK',
|
||||||
|
{
|
||||||
|
status => $doreturn && $was_out ? 'returned' : ( $doreturn || exists $messages->{NotIssued} ) ? 'not_out' : 'failed',
|
||||||
|
patron => $borrower ? ( join ' ', grep { length } $borrower->{firstname}, $borrower->{surname} ) : '',
|
||||||
|
messages => [ sort keys %{ $messages || {} } ],
|
||||||
|
}
|
||||||
|
);
|
||||||
Binary file not shown.
+185
-2
@@ -32,6 +32,7 @@ PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
|||||||
# X-Quickadd-Proxy; we then ask Koha whether the browser's login cookie is a staff user who may edit
|
# X-Quickadd-Proxy; we then ask Koha whether the browser's login cookie is a staff user who may edit
|
||||||
# the catalogue. Unset = feature off (only the Basic logins below work).
|
# the catalogue. Unset = feature off (only the Basic logins below work).
|
||||||
SSO_SECRET = os.environ.get("SSO_SECRET", "")
|
SSO_SECRET = os.environ.get("SSO_SECRET", "")
|
||||||
|
KOHA_RETURN_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/quickadd-return.pl"
|
||||||
KOHA_SESSION_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/svc/quickadd-session"
|
KOHA_SESSION_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/svc/quickadd-session"
|
||||||
VIEWER_USER = os.environ.get("VIEWER_USER", "")
|
VIEWER_USER = os.environ.get("VIEWER_USER", "")
|
||||||
VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "")
|
VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "")
|
||||||
@@ -892,6 +893,174 @@ def op_item_copy(payload):
|
|||||||
return 200, {"status": "added", "item_id": item["item_id"], "barcode": item.get("external_id") or ""}
|
return 200, {"status": "added", "item_id": item["item_id"], "barcode": item.get("external_id") or ""}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ lending / people
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
PATRON_CATEGORY = os.environ.get("PATRON_CATEGORY", "FRIEND")
|
||||||
|
|
||||||
|
|
||||||
|
def _patron_name(p):
|
||||||
|
return ("%s %s" % (p.get("firstname") or "", p.get("surname") or "")).strip() or p.get("cardnumber") or "?"
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_patron(p):
|
||||||
|
return {"id": p["patron_id"], "name": _patron_name(p), "card": p.get("cardnumber") or "",
|
||||||
|
"email": p.get("email") or "", "phone": p.get("phone") or ""}
|
||||||
|
|
||||||
|
|
||||||
|
def op_patron_search(term):
|
||||||
|
term = re.sub(r"[%_\\]", "", term or "").strip()[:60]
|
||||||
|
if not term:
|
||||||
|
res = koha("GET", "/patrons", query={"_per_page": "50", "_order_by": "surname"}) or []
|
||||||
|
else:
|
||||||
|
like = {"-like": "%" + term + "%"}
|
||||||
|
conds = [{"surname": like}, {"firstname": like}, {"cardnumber": {"-like": term + "%"}}]
|
||||||
|
parts = term.split()
|
||||||
|
if len(parts) == 2: # "Jane Smith"
|
||||||
|
conds.append({"-and": [{"firstname": {"-like": parts[0] + "%"}}, {"surname": {"-like": parts[1] + "%"}}]})
|
||||||
|
res = koha("GET", "/patrons", query={"q": json.dumps({"-or": conds}), "_per_page": "25", "_order_by": "surname"}) or []
|
||||||
|
return {"patrons": [_shape_patron(p) for p in res]}
|
||||||
|
|
||||||
|
|
||||||
|
def _loan_rows(cos):
|
||||||
|
"""Checkouts (with embedded item) -> rows with titles and an overdue flag."""
|
||||||
|
if not cos:
|
||||||
|
return []
|
||||||
|
bib_ids = sorted({c["item"]["biblio_id"] for c in cos if c.get("item")})
|
||||||
|
titles = {}
|
||||||
|
if bib_ids:
|
||||||
|
for b in koha("GET", "/biblios", query={"q": json.dumps({"biblio_id": bib_ids}), "_per_page": str(len(bib_ids) + 1)}) or []:
|
||||||
|
titles[b["biblio_id"]] = ((b.get("title") or "").rstrip(" /:"), b.get("author") or "", b.get("isbn") or "")
|
||||||
|
today = _dt.date.today().isoformat()
|
||||||
|
rows = []
|
||||||
|
for c in cos:
|
||||||
|
it = c.get("item") or {}
|
||||||
|
t = titles.get(it.get("biblio_id"), ("(unknown)", "", ""))
|
||||||
|
due = (c.get("due_date") or "")[:10]
|
||||||
|
rows.append({"checkout_id": c["checkout_id"], "patron_id": c["patron_id"], "barcode": it.get("external_id") or "",
|
||||||
|
"biblio_id": it.get("biblio_id"), "title": t[0], "author": t[1], "isbn": t[2], "due": due,
|
||||||
|
"overdue": bool(due) and due < today, "renewals": c.get("renewals_count") or 0})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def op_patron(patron_id):
|
||||||
|
p = koha("GET", "/patrons/%d" % patron_id)
|
||||||
|
cos = koha("GET", "/checkouts", query={"q": json.dumps({"patron_id": patron_id}), "_per_page": "200",
|
||||||
|
"_order_by": "due_date"}, headers={"x-koha-embed": "item"}) or []
|
||||||
|
d = _shape_patron(p)
|
||||||
|
d["loans"] = _loan_rows(cos)
|
||||||
|
d["category"] = p.get("category_id") or ""
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def op_out():
|
||||||
|
"""Everything checked out right now, soonest due first (overdue on top)."""
|
||||||
|
cos = koha("GET", "/checkouts", query={"_per_page": "500", "_order_by": "due_date"}, headers={"x-koha-embed": "item"}) or []
|
||||||
|
rows = _loan_rows(cos)
|
||||||
|
names = {}
|
||||||
|
for pid in sorted({r["patron_id"] for r in rows}):
|
||||||
|
try:
|
||||||
|
names[pid] = _patron_name(koha("GET", "/patrons/%d" % pid))
|
||||||
|
except KohaError:
|
||||||
|
names[pid] = "?"
|
||||||
|
for r in rows:
|
||||||
|
r["who"] = names.get(r["patron_id"], "?")
|
||||||
|
return {"loans": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _item_by_barcode(code):
|
||||||
|
code = (code or "").strip()
|
||||||
|
if not code or len(code) > 40:
|
||||||
|
return None
|
||||||
|
res = koha("GET", "/items", query={"q": json.dumps({"external_id": code}), "_per_page": "1"}) or []
|
||||||
|
return res[0] if res else None
|
||||||
|
|
||||||
|
|
||||||
|
def _title_of(biblio_id):
|
||||||
|
try:
|
||||||
|
return (koha("GET", "/biblios/%d" % biblio_id).get("title") or "").rstrip(" /:")
|
||||||
|
except KohaError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def op_checkout(payload):
|
||||||
|
pid, code = payload.get("patron_id"), payload.get("barcode")
|
||||||
|
if not isinstance(pid, int):
|
||||||
|
return 400, {"status": "invalid", "message": "Pick the person first."}
|
||||||
|
it = _item_by_barcode(code)
|
||||||
|
if not it:
|
||||||
|
return 200, {"status": "unknown", "message": "No book has the barcode %s." % str(code)[:40]}
|
||||||
|
title = _title_of(it["biblio_id"])
|
||||||
|
if item_status(it) == "Checked out":
|
||||||
|
return 200, {"status": "already_out", "title": title, "barcode": it["external_id"],
|
||||||
|
"message": "That one is already checked out. Scan it as a return first."}
|
||||||
|
try:
|
||||||
|
co = koha("POST", "/checkouts", {"patron_id": pid, "item_id": it["item_id"]})
|
||||||
|
except KohaError as e:
|
||||||
|
try:
|
||||||
|
msg = json.loads(e.body).get("error") or ""
|
||||||
|
except ValueError:
|
||||||
|
msg = ""
|
||||||
|
return 200, {"status": "refused", "title": title, "message": msg or "Koha would not allow that checkout."}
|
||||||
|
return 200, {"status": "checked_out", "title": title, "barcode": it["external_id"], "due": (co.get("due_date") or "")[:10]}
|
||||||
|
|
||||||
|
|
||||||
|
def op_return(payload):
|
||||||
|
code = str(payload.get("barcode") or "").strip()
|
||||||
|
if not SSO_SECRET:
|
||||||
|
return 200, {"status": "error", "message": "Returns need SSO_SECRET in .env (run the installer's Update)."}
|
||||||
|
req = urllib.request.Request(KOHA_RETURN_URL + "?" + urllib.parse.urlencode({"barcode": code}),
|
||||||
|
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 the return - try again."}
|
||||||
|
it = _item_by_barcode(code) if res.get("status") in ("returned", "not_out") else None
|
||||||
|
res["title"] = _title_of(it["biblio_id"]) if it else ""
|
||||||
|
return 200, res
|
||||||
|
|
||||||
|
|
||||||
|
def op_renew(payload):
|
||||||
|
cid = payload.get("checkout_id")
|
||||||
|
if not isinstance(cid, int):
|
||||||
|
return 400, {"status": "invalid"}
|
||||||
|
try:
|
||||||
|
co = koha("POST", "/checkouts/%d/renewal" % cid)
|
||||||
|
except KohaError as e:
|
||||||
|
try:
|
||||||
|
msg = json.loads(e.body).get("error") or ""
|
||||||
|
except ValueError:
|
||||||
|
msg = ""
|
||||||
|
return 200, {"status": "refused", "message": msg or "That one can't be renewed (limit reached or someone is waiting)."}
|
||||||
|
return 200, {"status": "renewed", "due": (co.get("due_date") or "")[:10]}
|
||||||
|
|
||||||
|
|
||||||
|
_NAME_CACHE = {"at": 0.0, "value": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def library_name():
|
||||||
|
if time.time() - _NAME_CACHE["at"] > 300:
|
||||||
|
try:
|
||||||
|
_NAME_CACHE["value"] = koha("GET", "/libraries/%s" % urllib.parse.quote(LIBRARY)).get("name") or ""
|
||||||
|
except (KohaError, AttributeError):
|
||||||
|
pass
|
||||||
|
_NAME_CACHE["at"] = time.time()
|
||||||
|
return _NAME_CACHE["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def op_patron_add(payload):
|
||||||
|
def clean(k, n=60):
|
||||||
|
return re.sub(r"\s+", " ", str(payload.get(k) or "")).strip()[:n]
|
||||||
|
surname, first = clean("surname"), clean("firstname")
|
||||||
|
if not surname:
|
||||||
|
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)
|
||||||
|
return 200, dict(_shape_patron(p), status="added")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ photo scanning
|
# ------------------------------------------------------------ photo scanning
|
||||||
MAX_IMAGE = 12 * 1024 * 1024
|
MAX_IMAGE = 12 * 1024 * 1024
|
||||||
ZBAR_TIMEOUT = 20
|
ZBAR_TIMEOUT = 20
|
||||||
@@ -1058,11 +1227,24 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"settings": {}, "staff_port": STAFF_PORT, "staff_url": STAFF_URL,
|
"settings": {}, "staff_port": STAFF_PORT, "staff_url": STAFF_URL,
|
||||||
"opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": []})
|
"opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": []})
|
||||||
if path == "/api/state":
|
if path == "/api/state":
|
||||||
return self._send(200, {"role": "admin", "recent": load_recent()[:100], "types": get_item_types(),
|
return self._send(200, {"role": "admin", "library_name": library_name(), "recent": load_recent()[:100], "types": get_item_types(),
|
||||||
"library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT,
|
"library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT,
|
||||||
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
|
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
return self._send(200, settings_view())
|
return self._send(200, settings_view())
|
||||||
|
if path in ("/api/patrons", "/api/out") or path.startswith("/api/patron/"):
|
||||||
|
try:
|
||||||
|
if path == "/api/out":
|
||||||
|
return self._send(200, op_out())
|
||||||
|
if path == "/api/patrons":
|
||||||
|
return self._send(200, op_patron_search((urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("q") or [""])[0]))
|
||||||
|
m = re.match(r"^/api/patron/(\d{1,9})$", path)
|
||||||
|
if not m:
|
||||||
|
return self._send(404, {"error": "not found"})
|
||||||
|
return self._send(200, op_patron(int(m.group(1))))
|
||||||
|
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/library" or path.startswith("/api/book/"):
|
if path == "/api/library" or path.startswith("/api/book/"):
|
||||||
try:
|
try:
|
||||||
if path == "/api/library":
|
if path == "/api/library":
|
||||||
@@ -1107,7 +1289,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"/api/finish": op_finish, "/api/cancel": op_cancel, "/api/settings": op_settings,
|
"/api/finish": op_finish, "/api/cancel": op_cancel, "/api/settings": op_settings,
|
||||||
"/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign,
|
"/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign,
|
||||||
"/api/item/update": op_item_update, "/api/item/delete": op_item_delete,
|
"/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/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}
|
||||||
fn = ops.get(self.path)
|
fn = ops.get(self.path)
|
||||||
if not fn:
|
if not fn:
|
||||||
return self._send(404, {"error": "not found"})
|
return self._send(404, {"error": "not found"})
|
||||||
|
|||||||
+211
-8
@@ -121,6 +121,16 @@
|
|||||||
.copybox .row { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 8px; }
|
.copybox .row { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 8px; }
|
||||||
.copybox select, .copybox input[type=text] { font: inherit; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); min-width: 0; }
|
.copybox select, .copybox input[type=text] { font: inherit; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); min-width: 0; }
|
||||||
button.danger { color: var(--bad); border-color: var(--bad); }
|
button.danger { color: var(--bad); border-color: var(--bad); }
|
||||||
|
.person { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); }
|
||||||
|
.person .t { flex: 1; min-width: 180px; }
|
||||||
|
.loan { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.loan .t { flex: 1; min-width: 160px; overflow-wrap: anywhere; }
|
||||||
|
.chip.late { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); }
|
||||||
|
.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; }
|
||||||
|
@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); }
|
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -130,6 +140,8 @@
|
|||||||
<h1 id="title">Library</h1>
|
<h1 id="title">Library</h1>
|
||||||
<nav class="tabs" aria-label="Sections">
|
<nav class="tabs" aria-label="Sections">
|
||||||
<a id="tab-library" href="#library" class="on">Library</a>
|
<a id="tab-library" href="#library" class="on">Library</a>
|
||||||
|
<a id="tab-lend" href="#lend">Lend / return</a>
|
||||||
|
<a id="tab-people" href="#people">People</a>
|
||||||
<a id="tab-add" href="#add">Add books</a>
|
<a id="tab-add" href="#add">Add books</a>
|
||||||
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
|
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
|
||||||
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
|
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
|
||||||
@@ -155,6 +167,55 @@
|
|||||||
<div class="actions"><button type="button" id="more" hidden>Show more</button></div>
|
<div class="actions"><button type="button" id="more" hidden>Show more</button></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="view-lend" hidden>
|
||||||
|
<div class="copybox">
|
||||||
|
<strong>Return a book</strong>
|
||||||
|
<form class="scan" id="retform" autocomplete="off" style="margin-top:8px">
|
||||||
|
<label class="sr" for="ret">Barcode to return</label>
|
||||||
|
<input id="ret" inputmode="numeric" placeholder="Scan the book's barcode" autocomplete="off">
|
||||||
|
<button class="primary" type="submit">Return</button>
|
||||||
|
</form>
|
||||||
|
<div id="retresult" role="status" aria-live="polite" class="small" style="margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="copybox">
|
||||||
|
<strong>Check out to…</strong>
|
||||||
|
<form class="find-form" id="whoform" autocomplete="off">
|
||||||
|
<label class="sr" for="who">Name or library card number</label>
|
||||||
|
<input id="who" type="search" placeholder="Name, or scan the library card" enterkeyhint="search" autocomplete="off">
|
||||||
|
<button type="submit">Find</button>
|
||||||
|
</form>
|
||||||
|
<div id="whoresults"></div>
|
||||||
|
<div id="patronbox"></div>
|
||||||
|
</div>
|
||||||
|
<h2>Checked out right now <span id="outcount" class="small"></span></h2>
|
||||||
|
<div id="outlist"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-people" hidden>
|
||||||
|
<details class="settings" id="newperson" open>
|
||||||
|
<summary>Add a person</summary>
|
||||||
|
<form class="manual" id="personform" autocomplete="off">
|
||||||
|
<input name="firstname" placeholder="First name" aria-label="First name">
|
||||||
|
<input name="surname" placeholder="Last name" required aria-label="Last name">
|
||||||
|
<input name="phone" placeholder="Phone (optional)" aria-label="Phone">
|
||||||
|
<input name="email" type="email" placeholder="Email (optional)" aria-label="Email">
|
||||||
|
<div class="actions"><button class="primary" type="submit">Add person</button></div>
|
||||||
|
</form>
|
||||||
|
<div id="personresult" aria-live="polite"></div>
|
||||||
|
</details>
|
||||||
|
<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">
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
<div id="peoplelist"></div>
|
||||||
|
</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>
|
||||||
|
<div id="cardbox"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="view-book" hidden>
|
<section id="view-book" hidden>
|
||||||
<p><a href="#library" id="back">← Back to the library</a></p>
|
<p><a href="#library" id="back">← Back to the library</a></p>
|
||||||
<div id="bookbox" aria-live="polite"></div>
|
<div id="bookbox" aria-live="polite"></div>
|
||||||
@@ -624,21 +685,162 @@
|
|||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- lend / return
|
||||||
|
const fmtDue = d => d ? new Date(d + 'T12:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
|
||||||
|
let curPatron = null, lendBusy = false;
|
||||||
|
|
||||||
|
function say(box, kind, text) { box.replaceChildren(el('div', { className: 'card ' + kind }, el('div', { className: 'body' }, text))); }
|
||||||
|
|
||||||
|
async function doReturn(code, box) {
|
||||||
|
box.textContent = 'Returning…';
|
||||||
|
let r; try { r = await api('/api/return', { barcode: code }); } catch (e) { r = { status: 'error', message: 'Could not reach the service.' }; }
|
||||||
|
if (r.status === 'returned') { beep(SOUND.added); say(box, 'ok', 'Returned: ' + (r.title || code) + (r.patron ? ' (from ' + r.patron + ')' : '')); }
|
||||||
|
else if (r.status === 'not_out') { beep(SOUND.duplicate); say(box, 'warn', (r.title || code) + ' was not checked out.'); }
|
||||||
|
else if (r.status === 'unknown_barcode') { beep(SOUND.notfound); say(box, 'warn', 'No book has the barcode ' + code + '.'); }
|
||||||
|
else { beep(SOUND.error); say(box, 'bad', r.message || 'Could not return that.'); }
|
||||||
|
loadOut(); if (curPatron) openPatron(curPatron.id, true);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
$('retform').onsubmit = async ev => { ev.preventDefault(); const c = $('ret').value.trim(); $('ret').value = ''; if (c) { await doReturn(c, $('retresult')); $('ret').focus(); } };
|
||||||
|
|
||||||
|
async function loadOut() {
|
||||||
|
let r; try { r = await api('/api/out'); } catch (e) { return; }
|
||||||
|
if (!r.loans) return;
|
||||||
|
$('outcount').textContent = r.loans.length ? '(' + r.loans.length + ')' : '';
|
||||||
|
const late = r.loans.filter(l => l.overdue).length;
|
||||||
|
if (!r.loans.length) { $('outlist').replaceChildren(el('div', { className: 'small' }, 'Nothing is checked out.')); return; }
|
||||||
|
$('outlist').replaceChildren(...(late ? [el('div', { className: 'small' }, late + ' overdue')] : []), ...r.loans.map(l => loanRow(l, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loanRow(l, showWho) {
|
||||||
|
const ret = el('button', { type: 'button' }, 'Return');
|
||||||
|
ret.onclick = async () => { ret.disabled = true; await doReturn(l.barcode, $('retresult')); };
|
||||||
|
const ren = el('button', { type: 'button' }, 'Renew');
|
||||||
|
ren.onclick = async () => {
|
||||||
|
ren.disabled = true; const r = await api('/api/renew', { checkout_id: l.checkout_id });
|
||||||
|
if (r.status === 'renewed') { loadOut(); if (curPatron) openPatron(curPatron.id, true); } else { ren.disabled = false; alert(r.message); }
|
||||||
|
};
|
||||||
|
const a = el('a', { href: '#book/' + l.biblio_id }, l.title);
|
||||||
|
return el('div', { className: 'loan' }, el('span', { className: 'badge' }, l.barcode),
|
||||||
|
el('span', { className: 't' }, a, showWho ? el('div', { className: 'small' }, l.who) : ''),
|
||||||
|
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, (l.overdue ? 'Overdue, was due ' : 'Due ') + fmtDue(l.due)), ret, ren);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPatron(id, quiet) {
|
||||||
|
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
|
||||||
|
if (!p.name) { $('patronbox').textContent = 'Could not load that person.'; return; }
|
||||||
|
curPatron = p;
|
||||||
|
$('whoresults').replaceChildren();
|
||||||
|
const scan = el('input', { placeholder: 'Scan a book to check out', inputMode: 'numeric', autocomplete: 'off', 'aria-label': 'Barcode to check out' });
|
||||||
|
const form = el('form', { className: 'scan' }, scan, el('button', { className: 'primary', type: 'submit' }, 'Check out'));
|
||||||
|
const out = (quiet && document.getElementById('coresult')) || el('div', { id: 'coresult', role: 'status', className: 'small', style: 'margin-top:8px' });
|
||||||
|
form.onsubmit = async ev => {
|
||||||
|
ev.preventDefault(); const code = scan.value.trim(); scan.value = ''; if (!code || lendBusy) return; lendBusy = true;
|
||||||
|
try {
|
||||||
|
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
|
||||||
|
if (r.status === 'checked_out') { beep(SOUND.added); say(out, 'ok', (r.title || code) + ' - due ' + fmtDue(r.due)); }
|
||||||
|
else if (r.status === 'already_out') {
|
||||||
|
beep(SOUND.duplicate);
|
||||||
|
const rb = el('button', { type: 'button', className: 'primary' }, 'Return it, then check it out');
|
||||||
|
rb.onclick = async () => { rb.disabled = true; await doReturn(code, out); const r2 = await api('/api/checkout', { patron_id: p.id, barcode: code });
|
||||||
|
if (r2.status === 'checked_out') say(out, 'ok', (r2.title || code) + ' - due ' + fmtDue(r2.due)); await openPatron(p.id, true); loadOut(); };
|
||||||
|
out.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, (r.title || code) + ' is already checked out. ', rb)));
|
||||||
|
} else { beep(SOUND.notfound); say(out, 'bad', r.message || 'That did not work.'); }
|
||||||
|
} 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 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,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPeople(term, into, withActions) {
|
||||||
|
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(term)); } catch (e) { r = {}; }
|
||||||
|
if (!r.patrons) { into.textContent = (r && r.message) || 'Search failed.'; return []; }
|
||||||
|
if (!r.patrons.length) { into.replaceChildren(el('div', { className: 'small' }, term ? 'Nobody matches "' + term + '".' : 'No people yet.')); return []; }
|
||||||
|
into.replaceChildren(...r.patrons.map(p => el('div', { className: 'person' },
|
||||||
|
el('span', { className: 't' }, el('strong', {}, p.name), el('div', { className: 'small' }, [p.card, p.phone, p.email].filter(Boolean).join(' · '))),
|
||||||
|
...withActions(p))));
|
||||||
|
return r.patrons;
|
||||||
|
}
|
||||||
|
$('whoform').onsubmit = async ev => {
|
||||||
|
ev.preventDefault(); const term = $('who').value.trim(); if (!term) return;
|
||||||
|
const list = await findPeople(term, $('whoresults'), p => { const b = el('button', { type: 'button', className: 'primary' }, 'Choose');
|
||||||
|
b.onclick = () => { location.hash = '#lend/' + p.id; }; return [b]; });
|
||||||
|
const exact = list.filter(p => p.card === term);
|
||||||
|
if (exact.length === 1) { $('who').value = ''; location.hash = '#lend/' + exact[0].id; } // a scanned card goes straight through
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- people
|
||||||
|
async function loadPeople(term) {
|
||||||
|
await findPeople(term, $('peoplelist'), p => {
|
||||||
|
const lend = el('a', { href: '#lend/' + p.id }, el('button', { type: 'button' }, 'Lend / returns'));
|
||||||
|
const card = el('a', { href: '#card/' + p.id }, el('button', { type: 'button' }, 'Print card'));
|
||||||
|
return [lend, card];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('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)));
|
||||||
|
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 $('personresult').replaceChildren(el('div', { className: 'card bad' }, el('div', { className: 'body' }, r.message || 'Could not add that person.')));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- printable library card (Code 39 barcode drawn as SVG)
|
||||||
|
const C39 = { '0': 'nnnwwnwnn', '1': 'wnnwnnnnw', '2': 'nnwwnnnnw', '3': 'wnwwnnnnn', '4': 'nnnwwnnnw', '5': 'wnnwwnnnn',
|
||||||
|
'6': 'nnwwwnnnn', '7': 'nnnwnnwnw', '8': 'wnnwnnwnn', '9': 'nnwwnnwnn', '*': 'nwnnwnwnn' };
|
||||||
|
function code39(text) {
|
||||||
|
const W = 3; let x = 10, bars = '';
|
||||||
|
for (const ch of '*' + text + '*') {
|
||||||
|
const pat = C39[ch]; if (!pat) continue;
|
||||||
|
for (let i = 0; i < 9; i++) { const w = pat[i] === 'w' ? W : 1; if (i % 2 === 0) bars += '<rect x="' + x + '" y="0" width="' + w + '" height="50"/>'; x += w; }
|
||||||
|
x += 1; // narrow gap between characters
|
||||||
|
}
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
$('printcard').onclick = () => window.print();
|
||||||
|
|
||||||
function route() {
|
function route() {
|
||||||
const h = location.hash.replace(/^#/, '');
|
const h = location.hash.replace(/^#/, '');
|
||||||
const m = h.match(/^book\/(\d+)$/);
|
let m, view = 'library';
|
||||||
let view = m ? 'book' : (h === 'add' && !viewer) ? 'add' : 'library';
|
if ((m = h.match(/^book\/(\d+)$/))) view = 'book';
|
||||||
$('view-library').hidden = view !== 'library'; $('view-book').hidden = view !== 'book'; $('view-add').hidden = view !== 'add';
|
else if (viewer) view = 'library';
|
||||||
$('tab-library').classList.toggle('on', view !== 'add'); $('tab-add').classList.toggle('on', view === 'add');
|
else if (h === 'add') view = 'add';
|
||||||
$('tab-add').hidden = viewer;
|
else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
|
||||||
document.title = view === 'add' ? 'Add books' : (libraryName || 'Library');
|
else if (h === 'people') view = 'people';
|
||||||
$('title').textContent = view === 'add' ? 'Add books' : (libraryName || 'Library');
|
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];
|
||||||
|
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' };
|
||||||
|
$('title').textContent = names[view]; document.title = names[view];
|
||||||
if (view === 'book') loadBook(m[1]);
|
if (view === 'book') loadBook(m[1]);
|
||||||
else if (view === 'add') $('code').focus();
|
else if (view === 'add') $('code').focus();
|
||||||
|
else if (view === 'lend') { loadOut(); curPatron = null; $('patronbox').replaceChildren();
|
||||||
|
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 (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
|
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', route);
|
window.addEventListener('hashchange', route);
|
||||||
let libraryName = '';
|
let libraryDisplay = '';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const s = await api('/api/state');
|
const s = await api('/api/state');
|
||||||
@@ -663,6 +865,7 @@
|
|||||||
}
|
}
|
||||||
if (!viewer) api('/api/settings').then(applySettings);
|
if (!viewer) api('/api/settings').then(applySettings);
|
||||||
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||||||
|
libraryDisplay = s.library_name || '';
|
||||||
route();
|
route();
|
||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user