library: one address - Add books inside the staff site, plus Find a book
- Apache in the Koha container serves Quick Add at /add/ on the staff site (KOHA_QUICKADD_URL); koha now starts after quickadd so the name resolves - Quick Add: relative API paths so it works at / and /add/; Add books / Find a book tabs; search by title, author or ISBN showing each copy's shelf and status (/api/search); Manage and Public catalogue links - staff home gets a 'Library tools' block (HTML customizations, how Koha 26.05 stores it) linking to Add books - installer, kiosk add-on and READMEs advertise one main address; the public catalogue stays on its own port (Koha gives public and staff sites the same URL shapes, so they cannot share one address) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
+11
-4
@@ -12,12 +12,19 @@ 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 |
|
||||
| **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 |
|
||||
|
||||
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
|
||||
shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The
|
||||
public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and
|
||||
staff sites the same URL shapes, so they cannot share one address; Add books can, and does.)
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
## What it is made of
|
||||
|
||||
|
||||
@@ -30,8 +30,11 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
quickadd:
|
||||
condition: service_started
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
KOHA_QUICKADD_URL: http://quickadd:8082
|
||||
KOHA_DB_HOST: db
|
||||
KOHA_DB_PASS: ${DB_PASSWORD}
|
||||
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
@@ -53,9 +56,6 @@ services:
|
||||
image: library-quickadd:local
|
||||
container_name: library-quickadd
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
koha:
|
||||
condition: service_started
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
KOHA_API: http://koha:8081/api/v1
|
||||
|
||||
+4
-4
@@ -79,10 +79,10 @@ show_status() {
|
||||
fi
|
||||
dc ps
|
||||
echo
|
||||
echo " Catalogue (public): http://$ip:$OPAC_PORT"
|
||||
echo " Staff interface: http://$ip:$STAFF_PORT"
|
||||
echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)"
|
||||
echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
|
||||
echo " Your library: http://$ip:$STAFF_PORT"
|
||||
echo " sign in here: add books at /add/ (or the 'Add books' button), find a book, lending, labels, people"
|
||||
echo " Public catalogue: http://$ip:$OPAC_PORT (search only - for a kiosk or visitors)"
|
||||
echo " Login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env; Add books asks once more with the same login)"
|
||||
echo
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,19 @@ Koha::CirculationRules->set_rules({
|
||||
},
|
||||
});
|
||||
|
||||
# ---- Staff home page: a "Library tools" block with a link to the Add books page ----
|
||||
# (Koha 26.05 stores these in the HTML customizations tool, not in a preference.)
|
||||
{
|
||||
my $html = '<div class="main-heading"><h3>Library tools</h3></div><ul class="list-unstyled">'
|
||||
. '<li><a class="btn btn-primary" href="/add/">Add books (scan an ISBN)</a></li>'
|
||||
. '<li><a href="/add/#find">Find a book</a></li></ul>';
|
||||
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
|
||||
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
|
||||
my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
|
||||
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
|
||||
VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html );
|
||||
}
|
||||
|
||||
# ---- Preferences ----------------------------------------------------------
|
||||
my %prefs = (
|
||||
autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request)
|
||||
|
||||
@@ -56,6 +56,20 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
|
||||
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
|
||||
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
|
||||
|
||||
# One address: serve the Add books page (Quick Add) inside the staff site at /add/.
|
||||
# (The staff and public catalogue sites cannot share one address: Koha gives both the
|
||||
# same URL shapes. The staff site is the natural home for a home library.)
|
||||
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
|
||||
cat > /etc/koha/apache-quickadd.conf << APACHE
|
||||
<IfModule mod_proxy_http.c>
|
||||
RedirectMatch ^/add\$ /add/
|
||||
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
|
||||
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
|
||||
</IfModule>
|
||||
APACHE
|
||||
sed -i 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|&\n\1Include /etc/koha/apache-quickadd.conf|' "/etc/apache2/sites-available/$INST.conf"
|
||||
fi
|
||||
|
||||
# Run Apache itself as the instance user instead of mpm_itk's per-vhost
|
||||
# uid switching (which needs extra capabilities and is pointless here:
|
||||
# Plack does the real work as that user already).
|
||||
|
||||
@@ -687,6 +687,42 @@ def op_assign(payload):
|
||||
"last": fmt_barcode(first + len(items) - 1, s)}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- search
|
||||
def item_status(it):
|
||||
if it.get("lost_status"):
|
||||
return "Lost"
|
||||
if it.get("withdrawn"):
|
||||
return "Withdrawn"
|
||||
if it.get("checked_out_date"):
|
||||
return "Checked out"
|
||||
if it.get("damaged_status"):
|
||||
return "Damaged"
|
||||
return "On shelf"
|
||||
|
||||
|
||||
def op_search(term):
|
||||
"""Find books by title, author or ISBN; each result lists its copies with shelf and status."""
|
||||
term = re.sub(r"[%_\\]", "", term or "").strip()[:80]
|
||||
if len(term) < 2:
|
||||
return {"results": []}
|
||||
like = {"-like": "%" + term + "%"}
|
||||
q = {"-or": [{"title": like}, {"author": like}, {"isbn": like}, {"ean": like}]}
|
||||
biblios = koha("GET", "/biblios", query={"q": json.dumps(q), "_per_page": "25", "_order_by": "title"}) or []
|
||||
if not biblios:
|
||||
return {"results": []}
|
||||
ids = [b["biblio_id"] for b in biblios]
|
||||
items = koha("GET", "/items", query={"q": json.dumps({"biblio_id": ids}), "_per_page": "500"}) or []
|
||||
where = {l["value"]: l["label"] for l in get_locations()}
|
||||
by_bib = {}
|
||||
for it in items:
|
||||
by_bib.setdefault(it["biblio_id"], []).append({
|
||||
"barcode": it.get("external_id"), "callnumber": it.get("callnumber") or "",
|
||||
"location": where.get(it.get("location"), ""), "status": item_status(it)})
|
||||
return {"results": [{
|
||||
"biblio_id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "",
|
||||
"year": b.get("copyright_date") or "", "copies": by_bib.get(b["biblio_id"], [])} for b in biblios]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ photo scanning
|
||||
MAX_IMAGE = 12 * 1024 * 1024
|
||||
ZBAR_TIMEOUT = 20
|
||||
@@ -801,6 +837,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
|
||||
if path == "/api/settings":
|
||||
return self._send(200, settings_view())
|
||||
if path == "/api/search":
|
||||
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
try:
|
||||
return self._send(200, op_search((qs.get("q") or [""])[0]))
|
||||
except KohaError as e:
|
||||
return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."
|
||||
if e.status == 503 else "Search failed: %s" % e.body[:120]})
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
|
||||
@@ -44,6 +44,20 @@
|
||||
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
|
||||
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
nav.tabs { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; }
|
||||
nav.tabs a { color: var(--accent); text-decoration: none; font-weight: 600; padding: 6px 12px; border-radius: 999px; }
|
||||
nav.tabs a.on { background: var(--accent); color: var(--accent-ink); }
|
||||
nav.tabs a.ext::after { content: ' \2197'; font-size: .85em; }
|
||||
.find-form { display: flex; gap: 8px; margin: 10px 0; }
|
||||
.find-form input { flex: 1; font-size: 1.3rem; padding: 12px 14px; border: 2px solid var(--line); border-radius: 12px;
|
||||
background: var(--card); color: var(--ink); min-width: 0; }
|
||||
.hit { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
|
||||
.hit .t { font-weight: 700; font-size: 1.05rem; overflow-wrap: anywhere; }
|
||||
.hit a { color: var(--accent); }
|
||||
.copy { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 6px 0 0; }
|
||||
.chip { border-radius: 999px; padding: 1px 10px; font-size: .88rem; font-weight: 600; border: 1px solid var(--line); }
|
||||
.chip.in { background: var(--ok-bg); border-color: var(--ok); color: var(--ok); }
|
||||
.chip.out { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); }
|
||||
.cam-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0 0; }
|
||||
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
|
||||
@@ -85,12 +99,26 @@
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Add to library</h1>
|
||||
<nav aria-label="Library links">
|
||||
<a id="lnk-opac" href="#">Catalogue</a><a id="lnk-staff" href="#">Staff</a>
|
||||
<h1 id="title">Add to library</h1>
|
||||
<nav class="tabs" aria-label="Sections">
|
||||
<a id="tab-add" href="#add" class="on">Add books</a>
|
||||
<a id="tab-find" href="#find">Find a book</a>
|
||||
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
|
||||
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section id="view-find" hidden>
|
||||
<form class="find-form" id="findform" autocomplete="off">
|
||||
<label class="sr" for="q">Title, author or ISBN</label>
|
||||
<input id="q" type="search" placeholder="Title, author or ISBN" enterkeyhint="search">
|
||||
<button class="primary" type="submit">Find</button>
|
||||
</form>
|
||||
<div id="found" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-add">
|
||||
|
||||
<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>
|
||||
@@ -149,6 +177,7 @@
|
||||
|
||||
<h2>Earlier</h2>
|
||||
<ul class="list" id="earlier"></ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@@ -182,7 +211,7 @@
|
||||
};
|
||||
|
||||
async function api(path, body) {
|
||||
const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body) } : {});
|
||||
return r.json();
|
||||
}
|
||||
@@ -300,7 +329,7 @@
|
||||
$('status').textContent = 'Reading the barcode…';
|
||||
try {
|
||||
const blob = await shrink(f);
|
||||
const r = await fetch('/api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob });
|
||||
const r = await fetch('api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob });
|
||||
resolve(r.ok ? (await r.json()).codes : []);
|
||||
} catch (e) { resolve([]); }
|
||||
};
|
||||
@@ -351,7 +380,7 @@
|
||||
}
|
||||
|
||||
let staffBase = '';
|
||||
const staffRoot = () => staffBase || ('http://' + location.hostname + ':' + staffPort);
|
||||
const staffRoot = () => staffBase || (location.pathname.startsWith('/add/') ? location.origin : 'http://' + location.hostname + ':' + staffPort);
|
||||
const printLink = (from, to) => staffRoot() +
|
||||
'/cgi-bin/koha/labels/label-print.pl?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
|
||||
|
||||
@@ -424,6 +453,40 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- under the staff site (/add/) everything is same-origin
|
||||
const embedded = location.pathname.startsWith('/add/');
|
||||
|
||||
// ---- views: Add books / Find a book
|
||||
function showView() {
|
||||
const find = location.hash === '#find';
|
||||
$('view-add').hidden = find; $('view-find').hidden = !find;
|
||||
$('tab-add').classList.toggle('on', !find); $('tab-find').classList.toggle('on', find);
|
||||
$('title').textContent = find ? 'Find a book' : 'Add to library';
|
||||
document.title = find ? 'Find a book' : 'Add to library';
|
||||
if (find) $('q').focus(); else $('code').focus();
|
||||
}
|
||||
window.addEventListener('hashchange', showView);
|
||||
|
||||
async function find(term) {
|
||||
const box = $('found');
|
||||
if (term.trim().length < 2) { box.replaceChildren(el('div', { className: 'small' }, 'Type at least two letters.')); return; }
|
||||
box.replaceChildren(el('div', { className: 'small' }, 'Searching…'));
|
||||
let r;
|
||||
try { r = await api('/api/search?q=' + encodeURIComponent(term)); } catch (e) { r = { status: 'error' }; }
|
||||
if (!r.results) { box.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, r.message || 'Search failed.'))); return; }
|
||||
if (!r.results.length) { box.replaceChildren(el('div', { className: 'small' }, 'No matches for "' + term + '".')); return; }
|
||||
box.replaceChildren(...r.results.map(b => el('div', { className: 'hit' },
|
||||
el('div', { className: 't' }, b.title), el('div', { className: 'meta' }, [b.author, b.year].filter(Boolean).join(' · ')),
|
||||
...(b.copies.length ? b.copies.map(c => el('div', { className: 'copy' },
|
||||
c.barcode ? el('span', { className: 'badge' }, c.barcode) : '',
|
||||
el('span', {}, c.location || 'no shelf set'), c.callnumber ? el('span', { className: 'meta' }, c.callnumber) : '',
|
||||
el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status)))
|
||||
: [el('div', { className: 'small' }, 'No copies recorded.')]),
|
||||
el('div', { className: 'small', style: 'margin-top:6px' },
|
||||
el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.biblio_id, target: '_blank', rel: 'noopener' }, 'Open full record')))));
|
||||
}
|
||||
$('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); };
|
||||
|
||||
(async () => {
|
||||
const s = await api('/api/state');
|
||||
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
|
||||
@@ -443,7 +506,7 @@
|
||||
}
|
||||
api('/api/settings').then(applySettings);
|
||||
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||||
$('code').focus();
|
||||
showView();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user