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:
@@ -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
@@ -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
@@ -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"})
|
||||
|
||||
|
||||
@@ -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 + '/';
|
||||
|
||||
Reference in New Issue
Block a user