From 9fb512cdabdd2059a621e5d4f74356e6abc08232 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 03:39:27 +0000 Subject: [PATCH] library: same default ports as post-install (8097/8098/8099); borrower home shows loans with due info Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 15 ++++++-- library/docker-compose.yml | 10 ++--- library/install.sh | 12 +++--- .../quickadd/__pycache__/app.cpython-313.pyc | Bin 136607 -> 136707 bytes library/quickadd/app.py | 3 +- library/quickadd/index.html | 35 +++++++++++++----- 6 files changed, 50 insertions(+), 25 deletions(-) diff --git a/library/README.md b/library/README.md index ef7a540..935ff98 100644 --- a/library/README.md +++ b/library/README.md @@ -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://:8081` | `admin` and the generated password | -| **Add books** (scan an ISBN, or use the phone camera) | `http://:8081/add/` | same login (asked once more) | -| **Public catalogue** (search only, for a kiosk or visitors) | `http://:8080` | none | +| **Your library**: add books, find a book, people, lending, labels, settings | `http://:8098` | `admin` and the generated password | +| **Add books** (scan an ISBN, or use the phone camera) | `http://:8098/add/` | same login (asked once more) | +| **Public catalogue** (search only, for a kiosk or visitors) | `http://: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. diff --git a/library/docker-compose.yml b/library/docker-compose.yml index 1153cd7..3ab7a64 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -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 diff --git a/library/install.sh b/library/install.sh index 0ecded4..67b4d6a 100755 --- a/library/install.sh +++ b/library/install.sh @@ -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 diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc index 687801fa94b64934f00435b9d2c77336355f34fc..9959cade831b564aa981cdca11f9a4c9a6878391 100644 GIT binary patch delta 3454 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 U5l-MOITY7@W|8Mm|89)`0V=Ri4FCWD diff --git a/library/quickadd/app.py b/library/quickadd/app.py index 8694ab7..0571eca 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -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 diff --git a/library/quickadd/index.html b/library/quickadd/index.html index 1223f9b..0270da4 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -201,6 +201,7 @@
+
Check outScan a card, then the books ReturnScan the books coming back @@ -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(); }