library: shelf picker in Quick Add, docs, status/ss fixes; kiosk README entry

- Quick Add: choose the shelf/room (Koha LOC values) once; every scanned item
  gets it and the choice is remembered; invalid values are ignored
- install.sh --status shows localhost in loopback mode; port probing works
  without ss
- library/README.md; kiosk Readme.md add-on section and licenses

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-06 10:33:58 +00:00
parent e08133dfc0
commit aa7950aa9e
5 changed files with 221 additions and 13 deletions
+31 -7
View File
@@ -254,6 +254,26 @@ def koha(method, path, body=None, ctype="application/json", headers=None, query=
raise KohaError(503, str(e))
_LOC_CACHE = {"at": 0.0, "value": []}
def get_locations():
"""Shelving locations (Koha authorised-value category LOC), cached for 30 s."""
if time.time() - _LOC_CACHE["at"] < 30:
return _LOC_CACHE["value"]
try:
vals = koha("GET", "/authorised_value_categories/LOC/authorised_values") or []
_LOC_CACHE["value"] = [{"value": v["value"], "label": v.get("description") or v["value"]} for v in vals]
_LOC_CACHE["at"] = time.time()
except KohaError:
pass
return _LOC_CACHE["value"]
def valid_location(code):
return code if code and any(l["value"] == code for l in get_locations()) else None
def find_existing(code):
variants = {code}
if len(code) == 13:
@@ -296,7 +316,7 @@ def save_counter(n):
pass
def add_item(biblio_id, itype, callnum):
def add_item(biblio_id, itype, callnum, location=None):
"""Create an item with the next free barcode (retrying on collisions)."""
start = next_barcode()
for attempt in range(10):
@@ -304,7 +324,8 @@ def add_item(biblio_id, itype, callnum):
try:
item = koha("POST", "/biblios/%s/items" % biblio_id, {
"external_id": bc, "item_type_id": itype, "home_library_id": LIBRARY,
"holding_library_id": LIBRARY, "callnumber": callnum or None})
"holding_library_id": LIBRARY, "callnumber": callnum or None,
"location": location})
save_counter(start + attempt)
return item
except KohaError as e:
@@ -361,6 +382,7 @@ def lookup(code):
def op_add(payload):
code = clean_code(payload.get("code"))
itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK"
location = valid_location(payload.get("location"))
if not code:
return 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."}
with LOCK:
@@ -369,7 +391,7 @@ def op_add(payload):
return 200, dict(existing, status="duplicate", code=code)
if existing: # add another copy of a known title
with LOCK:
item = add_item(existing["biblio_id"], itype, None)
item = add_item(existing["biblio_id"], itype, None, location)
entry = entry_for(code, existing["title"], existing["author"], item, existing["biblio_id"], "extra copy")
remember(entry)
return 200, dict(entry, status="added")
@@ -383,7 +405,7 @@ def op_add(payload):
with LOCK:
bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml",
{"x-confirm-not-duplicate": "1"})
item = add_item(bib["id"], itype, call_number(rec, info["author"]))
item = add_item(bib["id"], itype, call_number(rec, info["author"]), location)
entry = entry_for(code, info["title"], info["author"], item, bib["id"], source)
remember(entry)
return 200, dict(entry, status="added")
@@ -395,13 +417,14 @@ def op_manual(payload):
return 400, {"status": "invalid", "message": "A title is required."}
code = clean_code(payload.get("code"))
itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK"
location = valid_location(payload.get("location"))
rec = manual_record(code, title[:240], (payload.get("author") or "").strip()[:120],
(payload.get("year") or "").strip()[:8], (payload.get("publisher") or "").strip()[:120])
set_item_type(rec, itype)
with LOCK:
bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml",
{"x-confirm-not-duplicate": "1"})
item = add_item(bib["id"], itype, None)
item = add_item(bib["id"], itype, None, location)
entry = entry_for(code or "", title, payload.get("author") or "", item, bib["id"], "typed in")
remember(entry)
return 200, dict(entry, status="added")
@@ -410,7 +433,8 @@ def op_manual(payload):
def entry_for(code, title, author, item, biblio_id, source):
return {"time": int(time.time()), "code": code, "title": title, "author": author,
"barcode": item.get("external_id"), "callnumber": item.get("callnumber") or "",
"item_id": item.get("item_id"), "biblio_id": biblio_id, "source": source}
"item_id": item.get("item_id"), "biblio_id": biblio_id, "source": source,
"location": next((l["label"] for l in get_locations() if l["value"] == item.get("location")), "")}
def op_undo(payload):
@@ -488,7 +512,7 @@ class Handler(BaseHTTPRequestHandler):
return self._send(200, INDEX, "text/html; charset=utf-8")
if path == "/api/state":
return self._send(200, {"recent": load_recent()[:100], "types": ITEM_TYPES,
"library": LIBRARY, "staff_port": STAFF_PORT, "opac_port": OPAC_PORT,
"library": LIBRARY, "locations": get_locations(), "staff_port": STAFF_PORT, "opac_port": OPAC_PORT,
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
self._send(404, {"error": "not found"})
+22 -5
View File
@@ -32,6 +32,10 @@
border-radius: 999px; background: var(--card); font-weight: 600; }
.types input:checked + span { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.types input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
.shelf { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
.shelf label { font-weight: 600; }
.shelf select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--card); color: var(--ink); max-width: 100%; }
.scan { display: flex; gap: 8px; }
.scan input { flex: 1; font-size: 1.5rem; padding: 14px 16px; border: 2px solid var(--line);
border-radius: 12px; background: var(--card); color: var(--ink); min-width: 0; }
@@ -77,6 +81,10 @@
</header>
<fieldset class="types" id="types" style="border:0;padding:0" aria-label="What are you adding?"></fieldset>
<div class="shelf" id="shelfbox" hidden>
<label for="shelf">Shelf / room</label>
<select id="shelf"></select>
</div>
<form class="scan" id="scan" autocomplete="off">
<label class="sr" for="code">ISBN or barcode</label>
@@ -102,7 +110,7 @@
<script>
(() => {
const $ = id => document.getElementById(id);
let types = {}, sessionItems = [], busy = false, lastAdded = null, type = localStorage.getItem('qa-type') || 'BOOK';
let types = {}, sessionItems = [], busy = false, lastAdded = null, shelf = localStorage.getItem('qa-shelf') || '', type = localStorage.getItem('qa-type') || 'BOOK';
// --- small audible feedback so a stack of books can be scanned without looking
let actx;
@@ -145,7 +153,7 @@
function bookBody(r, extra) {
return el('div', { className: 'body' },
el('div', { className: 'title' }, r.title || ''),
el('div', { className: 'meta' }, [r.author, r.callnumber && ('Call no. ' + r.callnumber)].filter(Boolean).join(' · ')),
el('div', { className: 'meta' }, [r.author, r.callnumber && ('Call no. ' + r.callnumber), r.location].filter(Boolean).join(' · ')),
...extra);
}
@@ -153,7 +161,7 @@
const li = el('li', {},
el('span', { className: 'badge' }, e.barcode || '–'),
el('span', { className: 't' }, el('div', {}, e.title), el('div', { className: 'u' },
[e.author, e.callnumber, e.source].filter(Boolean).join(' · '))));
[e.author, e.callnumber, e.location, e.source].filter(Boolean).join(' · '))));
if (withUndo) {
const b = el('button', { type: 'button' }, 'Undo');
b.onclick = async () => {
@@ -185,7 +193,7 @@
if (busy) return; busy = true;
$('status').textContent = 'Looking up ' + code + '…';
try {
const r = await api('/api/add', Object.assign({ code, type }, opts));
const r = await api('/api/add', Object.assign({ code, type, location: shelf }, opts));
handle(r, code);
} catch (e) {
beep(SOUND.error); show('bad', el('div', { className: 'body' }, 'Could not reach the quick-add service.'));
@@ -226,7 +234,7 @@
f.onsubmit = async ev => {
ev.preventDefault();
const d = Object.fromEntries(new FormData(f));
handle(await api('/api/manual', Object.assign(d, { code, type })), code);
handle(await api('/api/manual', Object.assign(d, { code, type, location: shelf })), code);
};
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, 'Not found: ' + code),
el('div', { className: 'meta' }, 'None of the catalogues knew this one. Type the details and it is added anyway.'), f));
@@ -250,6 +258,15 @@
(async () => {
const s = await api('/api/state');
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
if (s.locations && s.locations.length) {
const sel = $('shelf');
sel.replaceChildren(el('option', { value: '' }, '(not set)'),
...s.locations.map(l => el('option', { value: l.value }, l.label)));
if (![...sel.options].some(o => o.value === shelf)) shelf = '';
sel.value = shelf;
sel.onchange = () => { shelf = sel.value; localStorage.setItem('qa-shelf', shelf); $('code').focus(); };
$('shelfbox').hidden = false;
}
const host = location.hostname;
$('lnk-opac').href = 'http://' + host + ':' + s.opac_port + '/';
$('lnk-staff').href = 'http://' + host + ':' + s.staff_port + '/';