diff --git a/Readme.md b/Readme.md index fa8839b..d80533f 100644 --- a/Readme.md +++ b/Readme.md @@ -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) --- diff --git a/library/README.md b/library/README.md new file mode 100644 index 0000000..35a4bac --- /dev/null +++ b/library/README.md @@ -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://:8080` | none | +| **Staff interface** (cataloguing, labels, Z39.50) | `http://:8081` | `admin` and the generated password | +| **Add books** (scan an ISBN) | `http://: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) +``` diff --git a/library/install.sh b/library/install.sh index a12cc0e..2a312a5 100755 --- a/library/install.sh +++ b/library/install.sh @@ -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" diff --git a/library/quickadd/app.py b/library/quickadd/app.py index 917c19c..cb0cdca 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -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"}) diff --git a/library/quickadd/index.html b/library/quickadd/index.html index 3da29d7..da8b107 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -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 @@
+
@@ -102,7 +110,7 @@