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
+15
View File
@@ -427,6 +427,15 @@ Both can be used at the same time — they serve different purposes:
- **IPP Everywhere support**
- **PDF printing** via cups-pdf
### Library Catalog (Koha)
- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in
- **Add books by scanning** - a Quick Add page: scan or type an ISBN and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines
- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library`
- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access
- **Uninstall keeps your books** unless you also choose to delete the data (typed `DELETE` confirmation); Complete Uninstall removes everything
- Needs roughly 2 GB RAM and 3 GB disk; the first build takes 5-10 minutes. See [`library/README.md`](library/README.md) for the stack itself, and run `bash tests/addon_library.test.sh` for the add-on's tests
### Remote Access
- **VNC** - x11vnc for remote desktop
- **WireGuard VPN** - Config paste support
@@ -1342,7 +1351,13 @@ This project bundles or installs several open-source components under their resp
- **Repository:** https://github.com/netbirdio/netbird
- **Full License:** https://github.com/netbirdio/netbird/blob/main/LICENSE
### Koha (Library Catalog add-on)
- **License:** GPL-3.0-or-later
- **Source:** https://koha-community.org
- **Packages:** installed from the Koha project's own Debian repository (https://debian.koha-community.org) inside a Docker image built on your machine
### MariaDB and Docker (Library Catalog add-on)
- **MariaDB** - GPL-2.0 (https://mariadb.org); **Docker Engine** - Apache-2.0 (https://www.docker.com)
---
+142
View File
@@ -0,0 +1,142 @@
# Home library: Koha + Quick Add (Docker)
A self-hosted library catalogue you can set up in one command:
```bash
sudo ./install.sh
```
It installs Docker if it is missing, generates the passwords, builds the images,
starts everything and prints the addresses and your login. Running it again is safe;
it never overwrites your `.env` (passwords) or your data.
| What | Address | Login |
|---|---|---|
| **Catalogue** (public search, no login) | `http://<this machine>:8080` | none |
| **Staff interface** (cataloguing, labels, Z39.50) | `http://<this machine>:8081` | `admin` and the generated password |
| **Add books** (scan an ISBN) | `http://<this machine>:8082` | same login |
Ports move up to the next free number if one is taken; the final addresses are printed
at the end and by `sudo ./install.sh --status`.
## What it is made of
* **Koha**: the open-source library system, built from the Koha project's own Debian
packages (no third-party Koha images). Installed and configured automatically; there is
no web installer to click through.
* **Quick Add** (`quickadd/`): a small page that turns "scan an ISBN" into "book added".
* **MariaDB**: the database.
Needs about 2 GB of RAM and 3 GB of disk. The first build takes 5-10 minutes.
## Adding books
Open **Add books** and scan (or type) an ISBN, then Enter. A USB or Bluetooth barcode
scanner works as a keyboard, so just scan.
1. The ISBN is looked up on the Library of Congress over **Z39.50**. If that server cannot
be reached (many networks block port 210), **Open Library** over HTTPS is used instead.
2. If you already have the title, you are asked whether to add **another copy**.
3. Otherwise the record is added with the **next barcode number**, the Dewey (or LC) call
number from the record, the item type chosen at the top (Book, Movie, Music, Game) and
the shelf you picked.
4. Nothing found? A short form lets you type the title and it is added anyway.
5. **Undo** removes an item (and the record, if it was the only copy). Freed barcode numbers
are never reused, because a label may already be printed.
DVDs and CDs work by scanning the **UPC** under the barcode; they are matched against what you
own the same way ISBNs are. Few UPCs are in the free catalogues, so expect to type a title
for many of them.
The page beeps: high for added, double for a duplicate, low for not found. You can scan a
stack of books without looking at the screen.
Phone camera scanning is not built in (browsers only allow the camera on HTTPS or
`localhost`). A scanner-app that "types" the code into the box works.
## Printing labels
Quick Add shows the barcode range for the session ("Barcodes 12 to 27"). In the staff
interface open **Cataloging → Label creator → Print barcode range**, enter those numbers,
and pick a template and layout:
| Template | Sheet |
|---|---|
| Avery 5160, 1 x 2-5/8 | 30 per sheet; spine label + barcode |
| Avery 5167, 1/2 x 1-3/4 | 80 per sheet; barcode only |
| Demco spine, 1 x 1.5 | 50 per sheet; call number on up to four lines |
| Layout | Prints |
|---|---|
| Spine + barcode | call number (split over lines) with the barcode below |
| Spine - call number | call number only |
| Barcode only | barcode |
Print at 100% scale (not "fit to page") so the labels line up.
## Finding the books
The public catalogue searches title, author, subject and ISBN, and shows each copy's
availability, call number and **shelving location**.
On the Add books page, pick the **Shelf / room** before you scan; every book you add
gets it, and the page remembers your choice. Change it whenever you move to another shelf.
The list comes from the staff interface (*Administration → Authorized values → LOC*), which
starts with a few rooms such as Fiction, General stacks and Reference. Add your own there,
for example "Living room - bookcase 2".
## Common tasks
```bash
sudo ./install.sh --status # addresses, login, container health
sudo ./install.sh --update # rebuild with the latest Koha release (your data is kept)
sudo ./install.sh --stop # stop everything
sudo ./install.sh --start # start again
sudo docker logs -f library-koha
```
Settings live in `.env` (created on first run, mode 600). Useful ones:
| Setting | Meaning |
|---|---|
| `BIND_ADDR` | `0.0.0.0` (default) reachable from the network, `127.0.0.1` this machine only. Docker bypasses `ufw`, so this is the setting that actually limits access |
| `OPAC_PORT`, `STAFF_PORT`, `QUICKADD_PORT` | published ports |
| `QUICKADD_SOURCES` | lookup order, e.g. `z3950:lx2.loc.gov:210/LCDB,openlibrary`. Add more `z3950:host:port/database` entries |
| `LIBRARY_NAME` | name shown on the catalogue (first install only) |
After editing `.env` run `sudo docker compose up -d`.
## Behind a proxy or on a locked-down network
* Building behind a TLS-inspecting proxy: `sudo EXTRA_CA=/path/to/ca.crt ./install.sh`.
* Docker Hub limits anonymous pulls (HTTP 429). The installer retries with a growing pause.
* Z39.50 needs outbound TCP port 210. Without it, Open Library is used automatically.
## Backups
Your books live in two Docker volumes: `library_db-data` (the catalogue) and
`library_koha-data` (search index, uploads). Back up the database with:
```bash
sudo docker exec library-db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction koha_library' > library-backup.sql
```
The search index is rebuilt automatically if it is lost.
## Security notes
* The staff interface and Quick Add are protected by the generated password, over plain HTTP.
Keep them on a trusted network, or set `BIND_ADDR=127.0.0.1` and put a reverse proxy with
HTTPS in front.
* Quick Add uses the staff `admin` login to talk to Koha's API.
## Layout
```
install.sh one-command setup and --status/--update/--stop/--start
docker-compose.yml db + koha + quickadd
koha/ Dockerfile (Koha from the project's Debian repo), entrypoint,
bootstrap.pl (headless install), seed/ (item types, Z39.50
servers, label templates/layouts)
quickadd/ the scan-to-add service (Python standard library + yaz)
```
+11 -1
View File
@@ -41,7 +41,13 @@ ensure_docker() {
docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; }
}
port_in_use() { ss -tlnH "sport = :$1" 2>/dev/null | grep -q .; }
port_in_use() {
if command -v ss >/dev/null 2>&1; then
ss -tlnH "sport = :$1" 2>/dev/null | grep -q .
else # minimal systems without iproute2
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
fi
}
free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; }
rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; }
lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; }
@@ -64,6 +70,10 @@ show_status() {
# shellcheck disable=SC1091
. ./.env
local ip; ip="$(lan_ip)"; ip="${ip:-localhost}"
if [ "${BIND_ADDR:-0.0.0.0}" = "127.0.0.1" ]; then
ip="localhost"
echo " (Limited to this machine: BIND_ADDR=127.0.0.1 in .env)"
fi
dc ps
echo
echo " Catalogue (public): http://$ip:$OPAC_PORT"
+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 + '/';