library: renamed A Room for Books; multi-tags; borrower sign-in (card number + password) with My books, renew and change password; AR pop-up/frame

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
Claude
2026-11-07 01:04:45 +00:00
parent 6f877d1107
commit 442b45c2e0
5 changed files with 447 additions and 40 deletions
+20 -1
View File
@@ -1,4 +1,4 @@
# Home library: Koha + Quick Add (Docker) # A Room for Books: a home library (Koha + the A Room for Books site, in Docker)
A self-hosted library catalogue you can set up in one command: A self-hosted library catalogue you can set up in one command:
@@ -255,3 +255,22 @@ browsers send small camera frames to the server's decoder a couple of times a se
On the AR BookFind book page, select everything (Ctrl/Cmd+A), copy, and paste into the box on the book page here: the level, 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. points, quiz number and interest level fill in by themselves; check them and press Save.
### Tags, and borrowers signing in
- **Tags**: any number per book (to read, kids, signed...). Add and remove them on the book page; click one to see every
book that has it; the Library page can filter by tag. They are stored in `tags.json` in the Add books data volume, which
the usual backups of that folder include.
- **Borrowers sign in**: every person gets a login, **their library card number + a password**. Adding someone shows
their password once (a different easy one like `maple482`, or the same one for everyone: People -> *Sign-in passwords for new
people*). *Give everyone a sign-in* covers people added earlier; *New password* resets one person. A borrower who signs in
sees the library and a **My books** page: what they have out, when each is due (or overdue), **Renew**, and **Change my
password**. They cannot lend, edit or see other people. Passwords are Koha's own patron passwords; this stack relaxes Koha's
default strength rule (4+ characters instead of 8 with upper/lower/digit) once, at install or update.
Ten wrong guesses from one address locks sign-in attempts from it for five minutes.
### Reading levels: pop-up or in-page AR BookFind
The book page's AR box has **Open beside this page** (a pop-up window next to the book) and **Show it here** (AR BookFind inside the
page; some browsers block its cookies inside another page, then use the pop-up). Either copies the title first so you can paste
it into AR's search; AR's own page cannot be pre-filled from here.
+29 -9
View File
@@ -7,6 +7,7 @@ use Modern::Perl;
use C4::Context; use C4::Context;
use Koha::Patrons; use Koha::Patrons;
use Koha::Patron::Categories; use Koha::Patron::Categories;
use Koha::Caches;
my $dbh = C4::Context->dbh; my $dbh = C4::Context->dbh;
# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha: # The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha:
@@ -20,16 +21,35 @@ if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) {
$cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count; $cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count;
} }
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool). # Patron logins use short, easy-to-remember passwords (a home library, not a bank): Koha's defaults
my ($have) = $dbh->selectrow_array(q{SELECT COUNT(*) FROM additional_contents WHERE code = 'library_tools'}); # (8+ characters, upper+lower+digit) are relaxed once, unless an admin has changed them since.
unless ($have) { my $flag = '/var/lib/koha/' . ( $ENV{KOHA_INSTANCE} || 'library' ) . '/.password-policy-set';
my $html = '<div class="main-heading"><h3>Library tools</h3></div><ul class="list-unstyled">' unless ( -e $flag ) {
. '<li><a class="btn btn-primary" href="/add/#find">Find a book</a></li>' my ($strong) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'RequireStrongPassword'});
. '<li><a href="/add/">Add books (scan an ISBN)</a></li></ul>'; my ($minlen) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'minPasswordLength'});
if ( ( $strong // 1 ) == 1 && ( $minlen // 8 ) == 8 ) {
$dbh->do(q{UPDATE systempreferences SET value = 0 WHERE variable = 'RequireStrongPassword'});
$dbh->do(q{UPDATE systempreferences SET value = 4 WHERE variable = 'minPasswordLength'});
say "ensure: patron passwords may be short (4+ characters, no strength rule)";
Koha::Caches->get_instance->flush_all; # preferences are cached; direct SQL edits need this
}
if ( open my $fh, '>', $flag ) { close $fh }
}
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool). Rewritten each start.
my $html = '<div class="main-heading"><h3>A Room for Books</h3></div><ul class="list-unstyled">'
. '<li><a class="btn btn-primary" href="/add/">Open A Room for Books</a> (library, lend / return, people, add books)</li>'
. '<li><a href="/add/#find">Find a book</a></li></ul>';
my ($id) = $dbh->selectrow_array(q{SELECT id FROM additional_contents WHERE code = 'library_tools'});
unless ($id) {
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number) $dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)}); VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' ); $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang) $dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html ); VALUES (?, 'A Room for Books', ?, 'default')}, undef, $id, $html );
say "ensure: added the staff home 'Library tools' block"; say "ensure: added the staff home 'A Room for Books' block";
}
else {
$dbh->do( q{UPDATE additional_contents_localizations SET title = 'A Room for Books', content = ? WHERE additional_content_id = ? AND lang = 'default'},
undef, $html, $id );
} }
Binary file not shown.
+254 -12
View File
@@ -9,6 +9,7 @@ A tiny web app that sits next to Koha. For each ISBN/UPC it:
Standard library only; Z39.50 is done by shelling out to yaz-client. Standard library only; Z39.50 is done by shelling out to yaz-client.
""" """
import base64 import base64
import hashlib
import hmac import hmac
import json import json
import os import os
@@ -353,7 +354,7 @@ def find_existing(code):
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
COUNTER_FILE = os.path.join(DATA_DIR, "counter.json") COUNTER_FILE = os.path.join(DATA_DIR, "counter.json")
MIN_WIDTH, MAX_WIDTH = 7, 10 # 6 all-digit Code 39 symbols can be misread as Code 32 by some scanners MIN_WIDTH, MAX_WIDTH = 7, 10 # 6 all-digit Code 39 symbols can be misread as Code 32 by some scanners
DEFAULT_SETTINGS = {"barcode_mode": "auto", "width": MIN_WIDTH, "location": ""} DEFAULT_SETTINGS = {"barcode_mode": "auto", "width": MIN_WIDTH, "location": "", "patron_password_mode": "generated", "patron_password": ""}
BARCODE_MODES = ("auto", "scan", "none") BARCODE_MODES = ("auto", "scan", "none")
LOCK = threading.RLock() LOCK = threading.RLock()
@@ -388,6 +389,12 @@ def clean_settings(payload, current):
s["width"] = min(max(int(payload["width"]), MIN_WIDTH), MAX_WIDTH) s["width"] = min(max(int(payload["width"]), MIN_WIDTH), MAX_WIDTH)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
if payload.get("patron_password_mode") in ("generated", "same"):
s["patron_password_mode"] = payload["patron_password_mode"]
if "patron_password" in payload:
pw = str(payload["patron_password"] or "").strip()
if pw == "" or 4 <= len(pw) <= 40:
s["patron_password"] = pw
if "location" in payload: if "location" in payload:
loc = payload["location"] or "" loc = payload["location"] or ""
if loc == "" or valid_location(loc): if loc == "" or valid_location(loc):
@@ -763,7 +770,7 @@ def _shape_book(b, items, where):
shelves = sorted({where.get(i.get("location"), "") for i in mine} - {""}) shelves = sorted({where.get(i.get("location"), "") for i in mine} - {""})
colls = {c["value"]: c["label"] for c in get_collections()} colls = {c["value"]: c["label"] for c in get_collections()}
collections = sorted({colls.get(i.get("collection_code"), "") for i in mine} - {""}) 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 "", return {"tags": _tags_load().get(str(b["biblio_id"]), []), "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 "", "year": b.get("copyright_date") or "", "isbn": b.get("isbn") or "", "type": b.get("item_type") or "",
"copies": len(mine), "out": out, "shelves": shelves, "copies": len(mine), "out": out, "shelves": shelves,
"callnumber": next((i.get("callnumber") for i in mine if i.get("callnumber")), "") or ""} "callnumber": next((i.get("callnumber") for i in mine if i.get("callnumber")), "") or ""}
@@ -774,7 +781,7 @@ def op_library(qs):
def one(k): def one(k):
return (qs.get(k) or [""])[0] return (qs.get(k) or [""])[0]
term = re.sub(r"[%_\\]", "", one("q")).strip()[:80] term = re.sub(r"[%_\\]", "", one("q")).strip()[:80]
shelf, itype, status, coll = one("shelf"), one("type"), one("status"), one("collection") shelf, itype, status, coll, tag = one("shelf"), one("type"), one("status"), one("collection"), one("tag")
sort = one("sort") or "title" sort = one("sort") or "title"
try: try:
page = max(1, int(one("page") or 1)) page = max(1, int(one("page") or 1))
@@ -784,6 +791,11 @@ def op_library(qs):
if term: if term:
like = {"-like": "%" + term + "%"} like = {"-like": "%" + term + "%"}
conds.append({"-or": [{"title": like}, {"author": like}, {"isbn": like}, {"ean": like}]}) conds.append({"-or": [{"title": like}, {"author": like}, {"isbn": like}, {"ean": like}]})
if tag:
ids_t = sorted(int(k) for k, v in _tags_load().items() if any(t.lower() == tag.lower() for t in v))
if not ids_t:
return {"books": [], "page": page, "more": False}
conds.append({"biblio_id": ids_t})
order = {"title": "title", "author": "author", "new": "-biblio_id"}.get(sort, "title") order = {"title": "title", "author": "author", "new": "-biblio_id"}.get(sort, "title")
item_q = {} item_q = {}
if shelf == "-": if shelf == "-":
@@ -860,7 +872,8 @@ def op_book(biblio_id, staff):
"isbn": b.get("isbn") or "", "publisher": b.get("publisher") or "", "pages": b.get("pages") or "", "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 "", "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, "series": b.get("series_title") or "", "type": b.get("item_type") or "", "copies": copies,
"shelves": get_locations(), "collections": get_collections()} "shelves": get_locations(), "collections": get_collections(),
"tags": _tags_load().get(str(b["biblio_id"]), []), "all_tags": tag_counts()}
def op_item_update(payload): def op_item_update(payload):
@@ -903,6 +916,9 @@ def op_book_delete(payload):
koha("DELETE", "/items/%d" % i["item_id"]) koha("DELETE", "/items/%d" % i["item_id"])
forget(i["item_id"]) forget(i["item_id"])
koha("DELETE", "/biblios/%d" % biblio_id) koha("DELETE", "/biblios/%d" % biblio_id)
d = _tags_load()
if d.pop(str(biblio_id), None) is not None:
save_json(TAGS_FILE, d)
return 200, {"status": "deleted"} return 200, {"status": "deleted"}
@@ -1093,7 +1109,15 @@ def op_patron_add(payload):
if e.status == 409 or "already exists" in e.body: 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)} return 200, {"status": "duplicate", "message": "Someone named %s is already in the library." % _patron_name(body)}
raise raise
return 200, dict(_shape_patron(p), status="added") out = dict(_shape_patron(p), status="added")
if not payload.get("nologin"):
pw = new_password()
try:
set_patron_password(p["patron_id"], pw)
out["password"] = pw
except KohaError:
out["password"] = ""
return 200, out
# ------------------------------------------------ new shelves / collections, QR codes, book info # ------------------------------------------------ new shelves / collections, QR codes, book info
@@ -1326,6 +1350,183 @@ def export_csv():
return out.getvalue().encode("utf-8-sig") return out.getvalue().encode("utf-8-sig")
# ------------------------------------------------------------------ tags
TAGS_FILE = os.path.join(DATA_DIR, "tags.json")
def _tags_load():
try:
d = json.load(open(TAGS_FILE))
return d if isinstance(d, dict) else {}
except (OSError, ValueError):
return {}
def clean_tags(raw):
out, seen = [], set()
for t in (raw if isinstance(raw, list) else []):
t = re.sub(r"\s+", " ", str(t)).strip(" ,;#")[:30]
if t and t.lower() not in seen:
seen.add(t.lower())
out.append(t)
return out[:20]
def tag_counts():
counts = {}
for tags in _tags_load().values():
for t in tags:
counts[t] = counts.get(t, 0) + 1
return [{"tag": t, "count": n} for t, n in sorted(counts.items(), key=lambda x: x[0].lower())]
def op_tags_set(payload):
bid = payload.get("biblio_id")
if not isinstance(bid, int):
return 400, {"status": "invalid"}
tags = clean_tags(payload.get("tags"))
with LOCK:
d = _tags_load()
if tags:
d[str(bid)] = tags
else:
d.pop(str(bid), None)
save_json(TAGS_FILE, d)
return 200, {"status": "saved", "tags": tags, "all": tag_counts()}
# ------------------------------------------------------- patron logins / my books
LOGINS_FILE = os.path.join(DATA_DIR, "logins.json") # patron ids that have a login set up
PW_WORDS = ["maple", "river", "cedar", "tiger", "lemon", "panda", "otter", "robin", "sunny", "cloud", "apple", "grape",
"daisy", "heron", "frost", "amber", "coral", "ember", "fable", "ivory", "brook", "pearl", "tulip", "wheat"]
_LOGIN_FAILS = {} # client address -> [count, window start]
_LOGIN_OK = {} # (user, sha256(pw)) -> (expires, patron_id)
def new_password():
s = load_settings()
if s.get("patron_password_mode") == "same" and s.get("patron_password"):
return s["patron_password"]
return secrets.choice(PW_WORDS) + "%03d" % secrets.randbelow(1000)
def _logins():
try:
d = json.load(open(LOGINS_FILE))
return d if isinstance(d, list) else []
except (OSError, ValueError):
return []
def _mark_login(pid):
with LOCK:
d = _logins()
if pid not in d:
d.append(pid)
save_json(LOGINS_FILE, d)
def set_patron_password(pid, pw):
koha("POST", "/patrons/%d/password" % pid, {"password": pw, "password_2": pw})
_mark_login(pid)
def patron_login(user, pw, addr):
"""Check a patron's card number / userid and password with Koha. Returns the patron id or None.
Failures are counted per address so a password cannot be guessed at speed."""
if not re.fullmatch(r"[A-Za-z0-9._@-]{1,40}", user or "") or not pw or len(pw) > 100:
return None
now = time.time()
fails = _LOGIN_FAILS.get(addr)
if fails and now - fails[1] < 300 and fails[0] >= 10:
return None
key = (user, hashlib.sha256(pw.encode()).hexdigest())
hit = _LOGIN_OK.get(key)
if hit and hit[0] > now:
return hit[1]
pid = None
try:
r = koha("POST", "/auth/password/validation", {"identifier": user, "password": pw})
pid = (r or {}).get("patron_id")
except KohaError:
pid = None
if pid:
_LOGIN_OK[key] = (now + 60, pid)
_LOGIN_FAILS.pop(addr, None)
return pid
f = _LOGIN_FAILS.get(addr)
if not f or now - f[1] >= 300:
f = _LOGIN_FAILS[addr] = [0, now]
f[0] += 1
return None
def op_me(pid):
p = koha("GET", "/patrons/%d" % pid)
cos = koha("GET", "/checkouts", query={"q": json.dumps({"patron_id": pid}), "_per_page": "200", "_order_by": "due_date"},
headers={"x-koha-embed": "item"}) or []
return {"name": _patron_name(p), "card": p.get("cardnumber") or "", "loans": _loan_rows(cos)}
def op_me_renew(pid, payload):
cid = payload.get("checkout_id")
if not isinstance(cid, int):
return 400, {"status": "invalid"}
co = koha("GET", "/checkouts/%d" % cid)
if not co or co.get("patron_id") != pid:
return 403, {"status": "error", "message": "That is not one of your books."}
return op_renew({"checkout_id": cid})
def op_me_password(pid, addr, payload):
new = str(payload.get("new") or "")
if not 4 <= len(new) <= 40:
return 400, {"status": "invalid", "message": "Use 4 to 40 characters."}
p = koha("GET", "/patrons/%d" % pid)
if not patron_login(p.get("cardnumber") or p.get("userid") or "", str(payload.get("old") or ""), addr):
return 403, {"status": "error", "message": "The current password is not right."}
try:
set_patron_password(pid, new)
except KohaError as e:
return 200, {"status": "error", "message": "Koha would not accept that password: " + e.body[:100]}
_LOGIN_OK.clear()
return 200, {"status": "saved"}
def op_reset_login(payload):
"""Give someone a (new) login password and show it once."""
pid = payload.get("patron_id")
if not isinstance(pid, int):
return 400, {"status": "invalid"}
p = koha("GET", "/patrons/%d" % pid)
pw = new_password()
set_patron_password(pid, pw)
_LOGIN_OK.clear()
return 200, {"status": "saved", "name": _patron_name(p), "card": p.get("cardnumber") or "", "password": pw}
def op_logins_all(payload):
"""Logins for everyone who has none yet (people added before logins existed). Passwords are shown once."""
have = set(_logins())
made, page = [], 1
while True:
res = koha("GET", "/patrons", query={"q": json.dumps({"category_id": PATRON_CATEGORY}), "_per_page": "100", "_page": str(page)}) or []
for p in res:
if p["patron_id"] in have:
continue
pw = new_password()
try:
set_patron_password(p["patron_id"], pw)
except KohaError:
continue
made.append({"name": _patron_name(p), "card": p.get("cardnumber") or "", "password": pw})
if len(res) < 100:
break
page += 1
_LOGIN_OK.clear()
return 200, {"status": "done", "made": made}
# ------------------------------------------------------------ photo scanning # ------------------------------------------------------------ photo scanning
MAX_IMAGE = 12 * 1024 * 1024 MAX_IMAGE = 12 * 1024 * 1024
ZBAR_TIMEOUT = 20 ZBAR_TIMEOUT = 20
@@ -1426,7 +1627,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Referrer-Policy", "no-referrer") self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy", self.send_header("Content-Security-Policy",
"default-src 'self'; img-src 'self' https: data:; " "default-src 'self'; img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'") "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; frame-src https://www.arbookfind.com")
for k, v in (extra or {}).items(): for k, v in (extra or {}).items():
self.send_header(k, v) self.send_header(k, v)
self.end_headers() self.end_headers()
@@ -1435,6 +1636,8 @@ class Handler(BaseHTTPRequestHandler):
def _role(self): def _role(self):
"""'admin', 'viewer' (read-only guest) or None. Sets self.via to 'koha' for a staff-site login.""" """'admin', 'viewer' (read-only guest) or None. Sets self.via to 'koha' for a staff-site login."""
self.via = None self.via = None
self.patron_id = None
self.addr = self.client_address[0]
if not PASSWORD: if not PASSWORD:
return None return None
header = self.headers.get("Authorization", "") header = self.headers.get("Authorization", "")
@@ -1448,7 +1651,20 @@ class Handler(BaseHTTPRequestHandler):
is_admin = hmac.compare_digest(ub, USER.encode()) & hmac.compare_digest(pb, PASSWORD.encode()) is_admin = hmac.compare_digest(ub, USER.encode()) & hmac.compare_digest(pb, PASSWORD.encode())
is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \ is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \
& hmac.compare_digest(pb, VIEWER_PASSWORD.encode()) & hmac.compare_digest(pb, VIEWER_PASSWORD.encode())
return "admin" if is_admin else "viewer" if is_viewer else None if is_admin:
return "admin"
if is_viewer:
return "viewer"
# Otherwise: a patron signing in with their card number (or userid) and password.
addr = self.client_address[0]
if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()):
addr = self.headers.get("X-Forwarded-For", "").split(",")[-1].strip() or addr
self.addr = addr
pid = patron_login(u, p, addr)
if pid:
self.patron_id = pid
return "patron"
return None
# No Basic login: accept a staff-site login, but only for requests that came through the staff # No Basic login: accept a staff-site login, but only for requests that came through the staff
# site's web server (it overwrites X-Quickadd-Proxy with the shared secret on every request). # site's web server (it overwrites X-Quickadd-Proxy with the shared secret on every request).
if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()): if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()):
@@ -1483,12 +1699,12 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/healthz": if self.path == "/healthz":
return self._send(200, b"ok", "text/plain") return self._send(200, b"ok", "text/plain")
path = self.path.split("?")[0] path = self.path.split("?")[0]
if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search", "/api/library") and not path.startswith(("/api/book/", "/api/more/"))): if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search", "/api/library", "/api/me") and not path.startswith(("/api/book/", "/api/more/"))):
return return
if path == "/": if path == "/":
return self._send(200, INDEX, "text/html; charset=utf-8") return self._send(200, INDEX, "text/html; charset=utf-8")
if path == "/api/state" and self.role == "viewer": if path == "/api/state" and self.role in ("viewer", "patron"):
return self._send(200, {"role": "viewer", "library": LIBRARY, "recent": [], "types": {}, "locations": [], return self._send(200, {"role": self.role, "library_name": library_name(), "library": LIBRARY, "recent": [], "types": {}, "locations": [],
"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":
@@ -1510,6 +1726,13 @@ class Handler(BaseHTTPRequestHandler):
except KohaError as e: except KohaError as e:
return self._send(404 if e.status == 404 else 502, {"status": "error", "message": 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]}) "Koha is not answering yet - try again in a minute." if e.status == 503 else "Failed: %s" % e.body[:120]})
if path == "/api/me":
if self.role != "patron":
return self._send(403, {"status": "error", "message": "Sign in with a library card to see your books."})
try:
return self._send(200, op_me(self.patron_id))
except KohaError:
return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."})
if path == "/api/export.csv": if path == "/api/export.csv":
try: try:
body = export_csv() body = export_csv()
@@ -1534,7 +1757,7 @@ class Handler(BaseHTTPRequestHandler):
try: try:
if path == "/api/library": if path == "/api/library":
return self._send(200, dict(op_library(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)), return self._send(200, dict(op_library(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)),
shelves=get_locations(), collections=get_collections(), types=get_item_types())) shelves=get_locations(), collections=get_collections(), types=get_item_types(), tags=tag_counts()))
m = re.match(r"^/api/book/(\d{1,9})$", path) m = re.match(r"^/api/book/(\d{1,9})$", path)
if not m: if not m:
return self._send(404, {"error": "not found"}) return self._send(404, {"error": "not found"})
@@ -1553,6 +1776,25 @@ class Handler(BaseHTTPRequestHandler):
self._send(404, {"error": "not found"}) self._send(404, {"error": "not found"})
def do_POST(self): def do_POST(self):
if self.path.startswith("/api/me/"):
if not self._guard(need_admin=False):
return
if self.role != "patron":
return self._send(403, {"status": "error", "message": "Sign in with a library card first."})
try:
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(min(length, 2000)) or b"{}")
if self.path == "/api/me/renew":
code, body = op_me_renew(self.patron_id, payload)
elif self.path == "/api/me/password":
code, body = op_me_password(self.patron_id, self.addr, payload)
else:
return self._send(404, {"error": "not found"})
return self._send(code, body)
except KohaError:
return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."})
except ValueError:
return self._send(400, {"status": "invalid"})
if not self._guard(): if not self._guard():
return return
try: try:
@@ -1575,7 +1817,7 @@ class Handler(BaseHTTPRequestHandler):
"/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/value/add": op_value_add, "/api/ar/set": op_ar_set, "/api/ar/import": op_ar_import, "/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/tags/set": op_tags_set, "/api/patron/reset-login": op_reset_login, "/api/logins/all": op_logins_all, "/api/ar/set": op_ar_set, "/api/ar/import": op_ar_import, "/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"})
+144 -18
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Library</title> <title>A Room for Books</title>
<style> <style>
:root { :root {
--bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2; --bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2;
@@ -147,9 +147,10 @@
<body> <body>
<main> <main>
<header> <header>
<h1 id="title">Library</h1> <h1 id="title">A Room for Books</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-mine" href="#mine" hidden>My books</a>
<a id="tab-lend" href="#lend">Lend / return</a> <a id="tab-lend" href="#lend">Lend / return</a>
<a id="tab-people" href="#people">People</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>
@@ -166,6 +167,7 @@
</form> </form>
<div class="filters"> <div class="filters">
<label class="sr" for="f-shelf">Shelf</label><select id="f-shelf"></select> <label class="sr" for="f-shelf">Shelf</label><select id="f-shelf"></select>
<label class="sr" for="f-tag">Tag</label><select id="f-tag"></select>
<label class="sr" for="f-coll">Collection</label><select id="f-coll"></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-type">Format</label><select id="f-type"></select>
<label class="sr" for="f-status">Status</label><select id="f-status"> <label class="sr" for="f-status">Status</label><select id="f-status">
@@ -228,7 +230,16 @@
</form> </form>
<div id="personresult" aria-live="polite"></div> <div id="personresult" aria-live="polite"></div>
</details> </details>
<p><a href="#cards"><button type="button">Print everyone's cards</button></a></p> <p><a href="#cards"><button type="button">Print everyone's cards</button></a>
<button type="button" id="alllogins">Give everyone a sign-in</button></p>
<details class="settings"><summary>Sign-in passwords for new people</summary>
<fieldset><label class="opt"><input type="radio" name="pwmode" value="generated"> A different easy password for each person (like maple482)</label>
<label class="opt"><input type="radio" name="pwmode" value="same"> The same password for everyone:
<input type="text" id="pwsame" maxlength="40" placeholder="e.g. readmore" style="font:inherit;padding:4px 8px"></label>
<div class="small">People sign in to see their own books with their library card number and this password; they can change it themselves. Existing passwords are never changed by this setting.</div>
<div class="actions"><button type="button" id="pwsave">Save</button> <span id="pwsaved" class="small"></span></div></fieldset>
</details>
<div id="loginresult" aria-live="polite"></div>
<form class="find-form" id="peopleform" autocomplete="off"> <form class="find-form" id="peopleform" autocomplete="off">
<label class="sr" for="pq">Search people</label> <label class="sr" for="pq">Search people</label>
<input id="pq" type="search" placeholder="Search by name or card number" enterkeyhint="search"> <input id="pq" type="search" placeholder="Search by name or card number" enterkeyhint="search">
@@ -242,6 +253,20 @@
<div id="cardbox"></div> <div id="cardbox"></div>
</section> </section>
<section id="view-mine" hidden>
<div class="copybox"><strong id="mename"></strong> <span class="badge" id="mecard"></span>
<div class="small">What you have borrowed, and when it is due.</div></div>
<div id="meloans" aria-live="polite"></div>
<details class="settings" id="mepw"><summary>Change my password</summary>
<form class="manual" id="mepwform" autocomplete="off">
<input type="password" name="old" placeholder="Current password" autocomplete="current-password" required aria-label="Current password">
<input type="password" name="new" placeholder="New password (4 or more characters)" autocomplete="new-password" required minlength="4" aria-label="New password">
<div class="actions"><button class="primary" type="submit">Change password</button></div>
</form>
<div id="mepwresult" class="small" aria-live="polite"></div>
</details>
</section>
<section id="view-cards" hidden> <section id="view-cards" hidden>
<p class="noprint"><a href="#people">&larr; Back to people</a> &nbsp; <button type="button" id="printcards" class="primary">Print these cards</button> <p class="noprint"><a href="#people">&larr; Back to people</a> &nbsp; <button type="button" id="printcards" class="primary">Print these cards</button>
<span class="small" id="cardsnote"></span></p> <span class="small" id="cardsnote"></span></p>
@@ -653,7 +678,7 @@
// ---- under the staff site (/add/) everything is same-origin // ---- under the staff site (/add/) everything is same-origin
const embedded = location.pathname.startsWith('/add/'); const embedded = location.pathname.startsWith('/add/');
let viewer = false; // the read-only guest login: search only let viewer = false, isPatron = false; // the read-only guest login: search only
// ---- views: Library / Book / Add books // ---- views: Library / Book / Add books
const TINTS = ['#5b6f63', '#6b5b73', '#73655b', '#4f6a7a', '#7a5252', '#6a6f3f', '#4a5f7a', '#7a6a3a']; const TINTS = ['#5b6f63', '#6b5b73', '#73655b', '#4f6a7a', '#7a5252', '#6a6f3f', '#4a5f7a', '#7a6a3a'];
@@ -671,7 +696,7 @@
} }
let libPage = 1, libShelves = [], libCollections = [], libLoaded = false, filtersReady = false; 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, const filters = () => ({ q: $('q').value.trim(), shelf: $('f-shelf').value, collection: $('f-coll').value, tag: $('f-tag').value, type: $('f-type').value,
status: $('f-status').value, sort: $('f-sort').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('&'); }; const filterQuery = () => { const f = filters(); return Object.entries(f).filter(([, v]) => v).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&'); };
@@ -685,6 +710,11 @@
return a; return a;
} }
function fillTagFilter(tags) {
const cur = $('f-tag').value;
$('f-tag').replaceChildren(el('option', { value: '' }, 'Any tag'), ...tags.map(t => el('option', { value: t.tag }, t.tag + ' (' + t.count + ')')));
$('f-tag').value = [...$('f-tag').options].some(o => o.value === cur) ? cur : '';
}
let libSeq = 0; let libSeq = 0;
async function loadLibrary(reset) { async function loadLibrary(reset) {
const seq = ++libSeq; // a newer search makes an older, slower answer irrelevant const seq = ++libSeq; // a newer search makes an older, slower answer irrelevant
@@ -697,6 +727,7 @@
if (seq !== libSeq) return; if (seq !== libSeq) return;
if (!r.books) { $('libstatus').textContent = r.message || 'Koha is not answering yet - try again in a minute.'; return; } if (!r.books) { $('libstatus').textContent = r.message || 'Koha is not answering yet - try again in a minute.'; return; }
if (r.collections) libCollections = r.collections; if (r.collections) libCollections = r.collections;
if (r.tags) fillTagFilter(r.tags);
if (!filtersReady && r.shelves) { if (!filtersReady && r.shelves) {
filtersReady = true; filtersReady = true;
libShelves = r.shelves; libShelves = r.shelves;
@@ -711,8 +742,8 @@
$('more').hidden = !r.more; $('more').hidden = !r.more;
const shown = grid.children.length; const shown = grid.children.length;
const f = filters(); const f = filters();
$('libstatus').textContent = shown ? shown + (r.more ? '+' : '') + (shown === 1 ? ' book' : ' books') + (f.q || f.shelf || f.collection || f.type || f.status ? ' match' : '') : $('libstatus').textContent = shown ? shown + (r.more ? '+' : '') + (shown === 1 ? ' book' : ' books') + (f.q || f.shelf || f.collection || f.tag || 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.')); (f.q || f.shelf || f.collection || f.tag || f.type || f.status ? 'No books match.' : (viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.'));
} }
$('arimport').onclick = async () => { $('arimport').onclick = async () => {
const f = $('arfile').files[0]; if (!f) { $('arresult').textContent = 'Choose a CSV file first.'; return; } const f = $('arfile').files[0]; if (!f) { $('arresult').textContent = 'Choose a CSV file first.'; return; }
@@ -721,7 +752,7 @@
}; };
$('more').onclick = () => { libPage += 1; loadLibrary(false); }; $('more').onclick = () => { libPage += 1; loadLibrary(false); };
$('findform').onsubmit = ev => { ev.preventDefault(); loadLibrary(true); }; $('findform').onsubmit = ev => { ev.preventDefault(); loadLibrary(true); };
for (const id of ['f-shelf', 'f-coll', 'f-type', 'f-status', 'f-sort']) $(id).onchange = () => loadLibrary(true); for (const id of ['f-shelf', 'f-coll', 'f-tag', 'f-type', 'f-status', 'f-sort']) $(id).onchange = () => loadLibrary(true);
// ---- one book // ---- one book
async function loadBook(id) { async function loadBook(id) {
@@ -740,6 +771,7 @@
b.summary ? el('p', {}, b.summary) : '')); b.summary ? el('p', {}, b.summary) : ''));
box.replaceChildren(head); box.replaceChildren(head);
const refresh = () => loadBook(id); const refresh = () => loadBook(id);
box.append(tagBox(b));
const moreBox = el('div', { className: 'copybox' }, el('div', { className: 'small' }, 'Looking for more about this book…')); const moreBox = el('div', { className: 'copybox' }, el('div', { className: 'small' }, 'Looking for more about this book…'));
box.append(moreBox); box.append(moreBox);
api('/api/more/' + id).then(m => fillMore(moreBox, b, m)).catch(() => moreBox.remove()); api('/api/more/' + id).then(m => fillMore(moreBox, b, m)).catch(() => moreBox.remove());
@@ -788,6 +820,19 @@
const copy = el('button', { type: 'button' }, 'Copy title'); const copy = el('button', { type: 'button' }, 'Copy title');
copy.onclick = async () => { try { await navigator.clipboard.writeText(b.title); copy.textContent = 'Copied ✓'; } catch (e) { copy.textContent = 'Copy failed'; } setTimeout(() => copy.textContent = 'Copy title', 1500); }; copy.onclick = async () => { try { await navigator.clipboard.writeText(b.title); copy.textContent = 'Copied ✓'; } catch (e) { copy.textContent = 'Copy failed'; } setTimeout(() => copy.textContent = 'Copy title', 1500); };
const open = el('a', { href: 'https://www.arbookfind.com/', target: '_blank', rel: 'noopener noreferrer' }, 'Open AR BookFind'); const open = el('a', { href: 'https://www.arbookfind.com/', target: '_blank', rel: 'noopener noreferrer' }, 'Open AR BookFind');
const copyTitle = async () => { try { await navigator.clipboard.writeText(b.title); } catch (e) {} };
const arPop = el('button', { type: 'button' }, 'Open beside this page');
arPop.onclick = async () => { await copyTitle();
const w = Math.min(560, Math.floor(screen.availWidth / 2)), h = screen.availHeight - 80;
window.open('https://www.arbookfind.com/', 'arbookfind', 'popup=yes,width=' + w + ',height=' + h + ',left=' + (screen.availWidth - w) + ',top=40'); };
const arHolder = el('div', {});
const arFrame = el('button', { type: 'button' }, 'Show it here');
arFrame.onclick = async () => { await copyTitle();
if (arHolder.firstChild) { arHolder.replaceChildren(); arFrame.textContent = 'Show it here'; return; }
arFrame.textContent = 'Hide it';
arHolder.replaceChildren(el('iframe', { src: 'https://www.arbookfind.com/', title: 'AR BookFind', loading: 'lazy', referrerPolicy: 'no-referrer',
style: 'width:100%;height:520px;border:1px solid var(--line);border-radius:8px;margin-top:8px;background:#fff' }),
el('div', { className: 'small' }, 'If it keeps asking who you are, your browser is blocking its cookies inside another page: use "Open beside this page" instead.')); };
if (viewer) { wrap.append(el('strong', {}, 'Reading level: '), ar.level ? 'AR ' + ar.level + (ar.points ? ', ' + ar.points + ' points' : '') + (ar.interest ? ', interest ' + ar.interest : '') : 'not recorded'); return wrap; } if (viewer) { wrap.append(el('strong', {}, 'Reading level: '), ar.level ? 'AR ' + ar.level + (ar.points ? ', ' + ar.points + ' points' : '') + (ar.interest ? ', interest ' + ar.interest : '') : 'not recorded'); return wrap; }
const save = el('button', { type: 'button', className: 'primary' }, 'Save'); const save = el('button', { type: 'button', className: 'primary' }, 'Save');
const msg = el('span', {}); const msg = el('span', {});
@@ -806,10 +851,45 @@
msg.textContent = (found.level || found.points || found.quiz) ? ' Filled in from what you pasted - check it, then Save.' : (t.trim() ? " Couldn't find a level or points in that text." : ''); msg.textContent = (found.level || found.points || found.quiz) ? ' Filled in from what you pasted - check it, then Save.' : (t.trim() ? " Couldn't find a level or points in that text." : '');
}; };
wrap.append(el('strong', {}, 'Reading level (AR): '), lvl, ' ', pts, ' ', quiz, ' ', inter, ' ', save, msg, paste, wrap.append(el('strong', {}, 'Reading level (AR): '), lvl, ' ', pts, ' ', quiz, ' ', inter, ' ', save, msg, paste,
el('div', { style: 'margin-top:6px' }, open, ' (a new tab: search for the title there, then type what it shows) ', copy)); el('div', { style: 'margin-top:6px' }, open, ' ', arPop, ' ', arFrame, ' ', copy),
el('div', { className: 'small' }, 'Search for the title there, select the whole book page, copy, and paste it below. (The title is copied for you when you open it.)'), arHolder);
return wrap; return wrap;
} }
// Tags: any number per book, free words ("to read", "kids", "signed"...). Click one to see every book with it.
function tagBox(b) {
const box = el('div', { className: 'copybox' });
let tags = [...(b.tags || [])];
const all = (b.all_tags || []).map(t => t.tag);
const draw = () => {
const chips = tags.map(t => {
const c = el('span', { className: 'chip', style: 'margin:0 6px 6px 0;display:inline-block' });
const a = el('a', { href: '#library', style: 'text-decoration:none' }, t);
a.onclick = () => { setTimeout(() => { $('f-tag').value = t; loadLibrary(true); }, 0); };
c.append(a);
if (!viewer) { const x = el('button', { type: 'button', 'aria-label': 'Remove tag ' + t, style: 'border:0;background:none;padding:0 0 0 6px;cursor:pointer;color:inherit' }, '×');
x.onclick = () => save(tags.filter(y => y !== t)); c.append(x); }
return c;
});
const parts = [el('strong', {}, 'Tags: '), ...(chips.length ? chips : [el('span', { className: 'small' }, viewer ? 'none' : 'none yet ')])];
if (!viewer) {
const list = el('datalist', { id: 'taglist' }, ...all.filter(t => !tags.includes(t)).map(t => el('option', { value: t })));
const inp = el('input', { type: 'text', placeholder: 'Add a tag, press Enter', 'aria-label': 'Add a tag', maxLength: 30,
style: 'font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink);min-width:12em' });
inp.setAttribute('list', 'taglist');
inp.onkeydown = ev => { if (ev.key === 'Enter') { ev.preventDefault(); const v = inp.value.trim(); if (v) save([...tags, v]); } };
parts.push(list, inp);
}
box.replaceChildren(...parts);
};
const save = async next => {
const r = await api('/api/tags/set', { biblio_id: b.id, tags: next });
if (r.status === 'saved') { tags = r.tags; draw(); fillTagFilter(r.all || []); }
};
draw();
return box;
}
function fillMore(box, b, m) { function fillMore(box, b, m) {
const parts = []; const parts = [];
if (m.description && !b.summary) parts.push(el('p', {}, m.description), el('div', { className: 'small' }, 'Description from Open Library.')); if (m.description && !b.summary) parts.push(el('p', {}, m.description), el('div', { className: 'small' }, 'Description from Open Library.'));
@@ -989,9 +1069,30 @@
await findPeople(term, $('peoplelist'), p => { await findPeople(term, $('peoplelist'), p => {
const lend = el('a', { href: '#lend/' + p.id }, el('button', { type: 'button' }, 'Lend / returns')); 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')); const card = el('a', { href: '#card/' + p.id }, el('button', { type: 'button' }, 'Print card'));
return [lend, card]; const reset = el('button', { type: 'button' }, 'New password');
reset.onclick = async () => { if (!confirm('Give ' + p.name + ' a new sign-in password?')) return; const r = await api('/api/patron/reset-login', { patron_id: p.id });
$('loginresult').replaceChildren(el('div', { className: 'result-note' }, r.status === 'saved' ? r.name + ': sign in with card number ' + r.card + ' and password ' : 'Could not do that.', r.password ? el('strong', {}, r.password) : '')); };
return [lend, card, reset];
}); });
} }
$('alllogins').onclick = async () => {
if (!confirm('Create a sign-in for everyone who does not have one yet? You will see each password once.')) return;
const r = await api('/api/logins/all', {});
if (r.status !== 'done') { $('loginresult').textContent = r.message || 'Could not do that.'; return; }
$('loginresult').replaceChildren(r.made.length ? el('div', { className: 'result-note' }, el('strong', {}, r.made.length + ' sign-ins created - write these down or print this page:'),
...r.made.map(x => el('div', {}, x.name + ' - card ' + x.card + ' - password ', el('strong', {}, x.password)))) : el('div', { className: 'small' }, 'Everyone already has a sign-in.'));
};
let pwModeLoaded = false;
async function loadPwSettings() {
if (pwModeLoaded) return; const v = await api('/api/settings'); const st = v.settings || {}; pwModeLoaded = true;
document.querySelectorAll('input[name=pwmode]').forEach(i => { i.checked = i.value === (st.patron_password_mode || 'generated'); });
$('pwsame').value = st.patron_password || '';
}
$('pwsave').onclick = async () => {
const mode = document.querySelector('input[name=pwmode]:checked').value;
const r = await api('/api/settings', { patron_password_mode: mode, patron_password: $('pwsame').value });
$('pwsaved').textContent = r.settings && r.settings.patron_password_mode === mode ? 'Saved ✓' + (mode === 'same' && !r.settings.patron_password ? ' (enter a password of 4+ characters)' : '') : 'Could not save.';
};
$('peopleform').onsubmit = ev => { ev.preventDefault(); loadPeople($('pq').value.trim()); }; $('peopleform').onsubmit = ev => { ev.preventDefault(); loadPeople($('pq').value.trim()); };
$('personform').onsubmit = async ev => { $('personform').onsubmit = async ev => {
ev.preventDefault(); ev.preventDefault();
@@ -999,7 +1100,7 @@
const r = await api('/api/patron/add', Object.assign(Object.fromEntries(new FormData(f)), confirm ? { confirm: true } : {})); const r = await api('/api/patron/add', Object.assign(Object.fromEntries(new FormData(f)), confirm ? { confirm: true } : {}));
if (r.status === 'added') { if (r.status === 'added') {
f.reset(); f.reset();
$('personresult').replaceChildren(el('div', { className: 'result-note' }, r.name + ' added with library card number ', el('span', { className: 'badge' }, r.card), '. ', $('personresult').replaceChildren(el('div', { className: 'result-note' }, r.name + ' added with library card number ', el('span', { className: 'badge' }, r.card), r.password ? ' (sign-in: card number + password ' : '', r.password ? el('strong', {}, r.password) : '', r.password ? ')' : '', '. ',
el('a', { href: '#card/' + r.id }, 'Print the card'), ' or ', el('a', { href: '#lend/' + r.id }, 'check something out'))); el('a', { href: '#card/' + r.id }, 'Print the card'), ' or ', el('a', { href: '#lend/' + r.id }, 'check something out')));
loadPeople(''); loadPeople('');
} else if (r.status === 'duplicate') { } else if (r.status === 'duplicate') {
@@ -1056,28 +1157,53 @@
}; };
$('printcard').onclick = () => window.print(); $('printcard').onclick = () => window.print();
async function loadMine() {
let r; try { r = await api('/api/me'); } catch (e) { r = {}; }
if (!r.loans) { $('meloans').textContent = r.message || 'Could not load your books.'; return; }
$('mename').textContent = r.name; $('mecard').textContent = r.card;
const today = new Date(); today.setHours(12, 0, 0, 0);
const rows = r.loans.map(l => {
const days = Math.round((new Date(l.due + 'T12:00:00') - today) / 86400000);
const when = l.overdue ? 'Overdue by ' + (-days) + (days === -1 ? ' day' : ' days') : days === 0 ? 'Due today' : 'Due ' + fmtDue(l.due) + ' (in ' + days + (days === 1 ? ' day)' : ' days)');
const ren = el('button', { type: 'button' }, 'Renew');
ren.onclick = async () => { ren.disabled = true; const x = await api('/api/me/renew', { checkout_id: l.checkout_id });
if (x.status === 'renewed') loadMine(); else { ren.disabled = false; alert(x.message || 'That one cannot be renewed.'); } };
return el('div', { className: 'loan' }, el('span', { className: 't' }, el('a', { href: '#book/' + l.biblio_id }, l.title), el('div', { className: 'small' }, l.author)),
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, when), ren);
});
$('meloans').replaceChildren(...(rows.length ? rows : [el('div', { className: 'card ok' }, el('div', { className: 'body' }, 'You have nothing checked out right now.'))]));
}
$('mepwform').onsubmit = async ev => {
ev.preventDefault(); const f = ev.target; const r = await api('/api/me/password', Object.fromEntries(new FormData(f)));
$('mepwresult').textContent = r.status === 'saved' ? 'Password changed. Next time your browser asks, use the new one.' : (r.message || 'Could not change it.');
if (r.status === 'saved') f.reset();
};
function route() { function route() {
const h = location.hash.replace(/^#/, ''); const h = location.hash.replace(/^#/, '');
let m, view = 'library'; let m, view = 'library';
if ((m = h.match(/^book\/(\d+)$/))) view = 'book'; if ((m = h.match(/^book\/(\d+)$/))) view = 'book';
else if (h === 'mine' && isPatron) view = 'mine';
else if (viewer) view = 'library'; else if (viewer) view = 'library';
else if (h === 'add') view = 'add'; else if (h === 'add') view = 'add';
else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend'; else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
else if (h === 'people') view = 'people'; else if (h === 'people') view = 'people';
else if ((m = h.match(/^card\/(\d+)$/))) view = 'card'; else if ((m = h.match(/^card\/(\d+)$/))) view = 'card';
else if (h === 'cards') view = 'cards'; else if (h === 'cards') view = 'cards';
for (const v of ['library', 'book', 'add', 'lend', 'people', 'card', 'cards']) $('view-' + v).hidden = v !== view; for (const v of ['library', 'book', 'add', 'lend', 'people', 'card', 'cards', 'mine']) $('view-' + v).hidden = v !== view;
const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people', cards: 'people' }[view]; const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people', cards: 'people', mine: 'mine' }[view];
for (const t of ['library', 'add', 'lend', 'people']) $('tab-' + t).classList.toggle('on', t === tab); for (const t of ['library', 'add', 'lend', 'people', 'mine']) $('tab-' + t).classList.toggle('on', t === tab);
$('tab-mine').hidden = !isPatron;
for (const t of ['add', 'lend', 'people']) $('tab-' + t).hidden = viewer; for (const t of ['add', 'lend', 'people']) $('tab-' + t).hidden = viewer;
$('csvtools').hidden = viewer; $('csvtools').hidden = viewer;
const names = { library: 'Library', book: 'Library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards' }; const names = { library: 'The library', book: 'The library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards', mine: 'My books' };
$('title').textContent = names[view]; document.title = names[view]; document.title = names[view] + ' - A Room for Books';
if (view === 'book') loadBook(m[1]); if (view === 'book') loadBook(m[1]);
else if (view === 'mine') loadMine();
else if (view === 'add') $('code').focus(); else if (view === 'add') $('code').focus();
else if (view === 'lend') { loadOut(); curPatron = null; $('patronbox').replaceChildren(); else if (view === 'lend') { loadOut(); curPatron = null; $('patronbox').replaceChildren();
if (m && m[1]) openPatron(m[1]); else { $('whoresults').replaceChildren(); $('who').focus(); } } if (m && m[1]) openPatron(m[1]); else { $('whoresults').replaceChildren(); $('who').focus(); } }
else if (view === 'people') { loadPeople($('pq').value.trim()); } else if (view === 'people') { loadPeople($('pq').value.trim()); loadPwSettings(); }
else if (view === 'card') loadCard(m[1]); else if (view === 'card') loadCard(m[1]);
else if (view === 'cards') loadCards(); else if (view === 'cards') loadCards();
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); } else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
@@ -1087,7 +1213,7 @@
(async () => { (async () => {
const s = await api('/api/state'); const s = await api('/api/state');
viewer = s.role === 'viewer'; viewer = s.role !== 'admin'; isPatron = s.role === 'patron';
types = s.types || {}; if (!types[type]) type = 'BOOK'; if (!viewer) typeButtons(); types = s.types || {}; if (!types[type]) type = 'BOOK'; if (!viewer) typeButtons();
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || ''; staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
staffBase = s.staff_url || ''; staffBase = s.staff_url || '';