library: same default ports as post-install (8097/8098/8099); borrower home shows loans with due info

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 03:39:27 +00:00
parent f0a9728016
commit 9fb512cdab
6 changed files with 50 additions and 25 deletions
+11 -4
View File
@@ -12,9 +12,9 @@ it never overwrites your `.env` (passwords) or your data.
| What | Address | Login |
|---|---|---|
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8081` | `admin` and the generated password |
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8081/add/` | same login (asked once more) |
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8080` | none |
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8098` | `admin` and the generated password |
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8098/add/` | same login (asked once more) |
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8097` | none |
For home use you only need the first address: sign in to **Your library**, and use the **Add books**
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
@@ -24,7 +24,7 @@ staff sites the same URL shapes, so they cannot share one address; Add books can
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`. Add books is also reachable directly on its own port
(`8082`), which is handy behind a proxy but not needed otherwise.
(`8099`), which is handy behind a proxy but not needed otherwise.
## What it is made of
@@ -290,3 +290,10 @@ points, quiz number and interest level fill in by themselves; check them and pre
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.
### Same ports on every install
New installs of this stack use the same three ports whether you install it from the kiosk add-on or from `ubuntu-post-install`:
**8098** is the one to use (the Room for Books home page, with Koha behind the menu), 8099 is Add books direct, and 8097 is the
public catalogue, which stays closed. If one is taken, the next free port is used and the installer prints it. Existing installs
keep the ports they already have.
+5 -5
View File
@@ -46,8 +46,8 @@ services:
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
ports:
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8080}:80"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8097}:80"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8098}:8081"
volumes:
- koha-data:/var/lib/koha
@@ -67,13 +67,13 @@ services:
VIEWER_USER: ${VIEWER_USER:-guest}
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
OPAC_PORT: ${OPAC_PORT:-8080}
STAFF_PORT: ${STAFF_PORT:-8081}
OPAC_PORT: ${OPAC_PORT:-8097}
STAFF_PORT: ${STAFF_PORT:-8098}
OPAC_URL: ${OPAC_URL:-}
STAFF_URL: ${STAFF_URL:-}
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
ports:
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8082}:8082"
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8099}:8082"
volumes:
- quickadd-data:/data
+6 -6
View File
@@ -11,7 +11,7 @@
# LIBRARY_CODE=HOME short code for the library (letters/digits)
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only)
# ITEM_TYPES="CD=Compact disc" extra item types (first install only)
# OPAC_PORT=8080 STAFF_PORT=8081 preferred host ports (next free is used)
# OPAC_PORT=8097 STAFF_PORT=8098 preferred host ports (next free is used)
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
set -euo pipefail
@@ -117,9 +117,9 @@ LIBRARY_CODE=${LIBRARY_CODE:-HOME}
SHELVES="${SHELVES:-}"
ITEM_TYPES="${ITEM_TYPES:-}"
TZ=${TZ_NOW}
OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
OPAC_PORT=$(free_port "${OPAC_PORT:-8097}")
STAFF_PORT=$(free_port "${STAFF_PORT:-8098}")
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8099}")
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
@@ -139,7 +139,7 @@ else
c_info "Re-using existing .env"
# Settings added in later versions: append them to an older .env.
if ! grep -q '^QUICKADD_PORT=' .env; then
echo "QUICKADD_PORT=$(free_port 8082)" >> .env
echo "QUICKADD_PORT=$(free_port 8099)" >> .env
fi
if ! grep -q '^VIEWER_PASSWORD=' .env; then
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
@@ -170,7 +170,7 @@ with_retry dc up -d
c_info "First start installs the database - waiting until Koha reports healthy..."
for i in $(seq 1 180); do
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' library-koha 2>/dev/null || echo missing)"
if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8082}/healthz" 2>/dev/null; then READY=1; break; fi
if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8099}/healthz" 2>/dev/null; then READY=1; break; fi
# One restart is tolerated (Docker's restart policy); a crash loop is not.
if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then
c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1
Binary file not shown.
+2 -1
View File
@@ -985,7 +985,8 @@ def _loan_rows(cos):
due = (c.get("due_date") or "")[:10]
rows.append({"checkout_id": c["checkout_id"], "patron_id": c["patron_id"], "barcode": it.get("external_id") or "",
"biblio_id": it.get("biblio_id"), "title": t[0], "author": t[1], "isbn": t[2], "due": due,
"overdue": bool(due) and due < today, "renewals": c.get("renewals_count") or 0})
"overdue": bool(due) and due < today, "renewals": c.get("renewals_count") or 0,
"checkout_date": (c.get("checkout_date") or "")[:10]})
return rows
+26 -9
View File
@@ -201,6 +201,7 @@
<button class="primary" type="submit">Look up</button>
</form>
<div id="homeresults" aria-live="polite"></div>
<div id="homemine" hidden></div>
<div class="bigacts" id="homeacts">
<a class="bigact" href="#lend"><strong>Check out</strong><span>Scan a card, then the books</span></a>
<a class="bigact" href="#return"><strong>Return</strong><span>Scan the books coming back</span></a>
@@ -1243,21 +1244,37 @@
};
$('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;
// A borrower's loans: title, who wrote it, when it was borrowed, when it is due (days left / overdue), renewals, Renew.
function renderLoans(target, r, reload) {
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);
if (x.status === 'renewed') reload(); else { ren.disabled = false; alert(x.message || 'That one cannot be renewed (the limit is reached, or someone is waiting for it).'); } };
const facts = ['Borrowed ' + fmtDue(l.checkout_date), l.renewals ? 'renewed ' + (l.renewals === 1 ? 'once' : l.renewals + ' times') : ''].filter(Boolean).join(' · ');
return el('div', { className: 'loan' }, el('span', { className: 't' }, el('a', { href: '#book/' + l.biblio_id }, l.title),
el('div', { className: 'small' }, [l.author, facts].filter(Boolean).join(' · '))),
el('span', { className: 'chip ' + (l.overdue ? 'late' : days <= 3 ? 'out' : 'in') }, when), ren);
});
$('meloans').replaceChildren(...(rows.length ? rows : [el('div', { className: 'card ok' }, el('div', { className: 'body' }, 'You have nothing checked out right now.'))]));
const late = r.loans.filter(l => l.overdue).length;
const head = r.loans.length ? 'You have ' + r.loans.length + (r.loans.length === 1 ? ' book' : ' books') + ' out' + (late ? ', ' + late + ' overdue' : '') + '.' : 'You have nothing checked out right now.';
target.replaceChildren(el('div', { className: 'card ' + (late ? 'warn' : 'ok') }, el('div', { className: 'body' }, el('strong', {}, head))), ...rows);
}
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;
renderLoans($('meloans'), r, loadMine);
}
async function loadHomeMine() {
let r; try { r = await api('/api/me'); } catch (e) { return; }
if (!r.loans) return;
const box = $('homemine'); box.hidden = false;
const inner = el('div', {});
box.replaceChildren(el('h2', {}, 'Hello, ' + r.name), inner);
renderLoans(inner, r, loadHomeMine);
}
$('mepwform').onsubmit = async ev => {
ev.preventDefault(); const f = ev.target; const r = await api('/api/me/password', Object.fromEntries(new FormData(f)));
@@ -1324,7 +1341,7 @@
const names = { home: 'Home', library: 'Browse', book: 'Book', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards', mine: 'My books' };
document.title = (view === 'home' ? '' : names[view] + ' - ') + 'Room for Books';
window.scrollTo(0, 0);
if (view === 'home') { $('homeacts').hidden = viewer; $('patronacts').hidden = !isPatron; loadRecent(); if (h === 'find') $('hq').focus(); }
if (view === 'home') { $('homeacts').hidden = viewer; $('patronacts').hidden = !isPatron; $('homemine').hidden = !isPatron; if (isPatron) loadHomeMine(); loadRecent(); if (h === 'find') $('hq').focus(); }
else if (view === 'book') loadBook(m[1]);
else if (view === 'mine') loadMine();
else if (view === 'add') { if (h === 'settings') { $('settings').open = true; $('settings').scrollIntoView(); } else $('code').focus(); }