diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc
new file mode 100644
index 0000000..2b023ab
Binary files /dev/null and b/library/quickadd/__pycache__/app.cpython-313.pyc differ
diff --git a/library/quickadd/app.py b/library/quickadd/app.py
index 3d4ad68..eb74e25 100644
--- a/library/quickadd/app.py
+++ b/library/quickadd/app.py
@@ -731,6 +731,167 @@ def op_search(term):
"year": b.get("copyright_date") or "", "copies": by_bib.get(b["biblio_id"], [])} for b in biblios]}
+# ------------------------------------------------------------ library / book page
+PAGE_SIZE = 48
+ITEM_FILTER_CAP = 2000
+
+
+def _shape_book(b, items, where):
+ mine = [i for i in items if i["biblio_id"] == b["biblio_id"]]
+ out = sum(1 for i in mine if item_status(i) == "Checked out")
+ shelves = sorted({where.get(i.get("location"), "") for i in mine} - {""})
+ return {"id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "",
+ "year": b.get("copyright_date") or "", "isbn": b.get("isbn") or "", "type": b.get("item_type") or "",
+ "copies": len(mine), "out": out, "shelves": shelves,
+ "callnumber": next((i.get("callnumber") for i in mine if i.get("callnumber")), "") or ""}
+
+
+def op_library(qs):
+ """The Library grid: filter by shelf / format / status, search, sort, page."""
+ def one(k):
+ return (qs.get(k) or [""])[0]
+ term = re.sub(r"[%_\\]", "", one("q")).strip()[:80]
+ shelf, itype, status = one("shelf"), one("type"), one("status")
+ sort = one("sort") or "title"
+ try:
+ page = max(1, int(one("page") or 1))
+ except ValueError:
+ page = 1
+ conds = []
+ if term:
+ like = {"-like": "%" + term + "%"}
+ conds.append({"-or": [{"title": like}, {"author": like}, {"isbn": like}, {"ean": like}]})
+ order = {"title": "title", "author": "author", "new": "-biblio_id"}.get(sort, "title")
+ item_q = {}
+ if shelf == "-":
+ item_q["location"] = None
+ elif shelf:
+ item_q["location"] = shelf
+ if itype:
+ item_q["item_type_id"] = itype
+ if status == "out":
+ item_q["checked_out_date"] = {"!=": None}
+ elif status == "in":
+ item_q["checked_out_date"] = None
+ query = {"_order_by": order}
+ if item_q:
+ its = koha("GET", "/items", query={"q": json.dumps(item_q), "_per_page": str(ITEM_FILTER_CAP)}) or []
+ ids = sorted({i["biblio_id"] for i in its})
+ if not ids:
+ return {"books": [], "page": page, "more": False}
+ conds.append({"biblio_id": ids})
+ if conds:
+ query["q"] = json.dumps(conds[0] if len(conds) == 1 else {"-and": conds})
+ query.update({"_per_page": str(PAGE_SIZE + 1), "_page": str(page)})
+ books = koha("GET", "/biblios", query=query) or []
+ more = len(books) > PAGE_SIZE
+ books = books[:PAGE_SIZE]
+ if not books:
+ return {"books": [], "page": page, "more": False}
+ ids = [b["biblio_id"] for b in books]
+ items = koha("GET", "/items", query={"q": json.dumps({"biblio_id": ids}), "_per_page": "1000"}) or []
+ where = {l["value"]: l["label"] for l in get_locations()}
+ return {"books": [_shape_book(b, items, where) for b in books], "page": page, "more": more}
+
+
+def _checkout_info(item_ids, names):
+ """item_id -> {who, due} for items currently out (staff only)."""
+ if not item_ids:
+ return {}
+ cos = koha("GET", "/checkouts", query={"q": json.dumps({"item_id": item_ids}), "_per_page": "100"}) or []
+ out = {}
+ for c in cos:
+ who = ""
+ if names:
+ try:
+ p = koha("GET", "/patrons/%s" % c["patron_id"])
+ who = ("%s %s" % (p.get("firstname") or "", p.get("surname") or "")).strip()
+ except KohaError:
+ pass
+ out[c["item_id"]] = {"who": who, "patron_id": c["patron_id"], "due": (c.get("due_date") or "")[:10],
+ "checkout_id": c["checkout_id"]}
+ return out
+
+
+def op_book(biblio_id, staff):
+ b = koha("GET", "/biblios/%d" % biblio_id)
+ items = koha("GET", "/biblios/%d/items" % biblio_id) or []
+ where = {l["value"]: l["label"] for l in get_locations()}
+ out = _checkout_info([i["item_id"] for i in items if item_status(i) == "Checked out"], staff)
+ copies = []
+ for i in items:
+ c = {"item_id": i["item_id"], "barcode": i.get("external_id") or "", "callnumber": i.get("callnumber") or "",
+ "shelf": i.get("location") or "", "shelf_label": where.get(i.get("location"), ""),
+ "status": item_status(i), "type": i.get("item_type_id") or ""}
+ if staff and c["status"] == "Checked out":
+ c["out"] = out.get(i["item_id"])
+ copies.append(c)
+ return {"id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "subtitle": b.get("subtitle") or "",
+ "author": b.get("author") or "", "year": b.get("copyright_date") or b.get("publication_year") or "",
+ "isbn": b.get("isbn") or "", "publisher": b.get("publisher") or "", "pages": b.get("pages") or "",
+ "edition": b.get("edition_statement") or "", "summary": b.get("abstract") or b.get("notes") or "",
+ "series": b.get("series_title") or "", "type": b.get("item_type") or "", "copies": copies,
+ "shelves": get_locations()}
+
+
+def op_item_update(payload):
+ item_id = payload.get("item_id")
+ if not isinstance(item_id, int):
+ return 400, {"status": "invalid"}
+ with LOCK:
+ it = koha("GET", "/items/%d" % item_id)
+ if "shelf" in payload:
+ it["location"] = valid_location(payload.get("shelf")) or None
+ if "callnumber" in payload:
+ it["callnumber"] = (str(payload.get("callnumber") or "").strip()[:80]) or None
+ # Koha's update route rejects the computed fields the GET adds.
+ for k in [k for k in it if k.startswith("effective_")] + [
+ "checkouts_count", "holds_count", "renewals_count", "localuse", "timestamp", "call_number_sort",
+ "bookable", "extended_subfields"]:
+ it.pop(k, None)
+ koha("PUT", "/biblios/%d/items/%d" % (it["biblio_id"], item_id), it)
+ return 200, {"status": "saved"}
+
+
+def op_item_delete(payload):
+ item_id, biblio_id = payload.get("item_id"), payload.get("biblio_id")
+ if not isinstance(item_id, int) or not isinstance(biblio_id, int):
+ return 400, {"status": "invalid"}
+ return op_undo({"item_id": item_id, "biblio_id": biblio_id})
+
+
+def op_book_delete(payload):
+ biblio_id = payload.get("biblio_id")
+ if not isinstance(biblio_id, int):
+ return 400, {"status": "invalid"}
+ with LOCK:
+ items = koha("GET", "/biblios/%d/items" % biblio_id) or []
+ if any(item_status(i) == "Checked out" for i in items):
+ return 409, {"status": "error", "message": "A copy is checked out - return it first."}
+ for i in items:
+ koha("DELETE", "/items/%d" % i["item_id"])
+ forget(i["item_id"])
+ koha("DELETE", "/biblios/%d" % biblio_id)
+ return 200, {"status": "deleted"}
+
+
+def op_item_copy(payload):
+ """Another copy of a book already in the library (new barcode per the barcode setting)."""
+ biblio_id = payload.get("biblio_id")
+ if not isinstance(biblio_id, int):
+ return 400, {"status": "invalid"}
+ with LOCK:
+ b = koha("GET", "/biblios/%d" % biblio_id)
+ items = koha("GET", "/biblios/%d/items" % biblio_id) or []
+ base = items[0] if items else {}
+ mode = load_settings()["barcode_mode"]
+ if mode == "scan":
+ mode = "auto" # no scanner step here: number it, or leave it blank in 'none' mode
+ item = add_item(biblio_id, base.get("item_type_id") or valid_type(b.get("item_type")), base.get("callnumber"),
+ valid_location(payload.get("shelf")) or base.get("location"), mode)
+ return 200, {"status": "added", "item_id": item["item_id"], "barcode": item.get("external_id") or ""}
+
+
# ------------------------------------------------------------ photo scanning
MAX_IMAGE = 12 * 1024 * 1024
ZBAR_TIMEOUT = 20
@@ -888,7 +1049,7 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/healthz":
return self._send(200, b"ok", "text/plain")
path = self.path.split("?")[0]
- if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search")):
+ if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search", "/api/library") and not path.startswith("/api/book/")):
return
if path == "/":
return self._send(200, INDEX, "text/html; charset=utf-8")
@@ -902,6 +1063,19 @@ 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/library" or path.startswith("/api/book/"):
+ try:
+ if path == "/api/library":
+ return self._send(200, dict(op_library(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)),
+ shelves=get_locations(), types=get_item_types()))
+ m = re.match(r"^/api/book/(\d{1,9})$", path)
+ if not m:
+ return self._send(404, {"error": "not found"})
+ return self._send(200, op_book(int(m.group(1)), self.role == "admin"))
+ except KohaError as e:
+ return self._send(404 if e.status == 404 else 502, {"status": "error", "message":
+ "Koha is not answering yet - try again in a minute." if e.status == 503
+ else "Not found." if e.status == 404 else "Failed: %s" % e.body[:120]})
if path == "/api/search":
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
try:
@@ -931,7 +1105,9 @@ class Handler(BaseHTTPRequestHandler):
payload = json.loads(self.rfile.read(length) or b"{}")
ops = {"/api/add": op_add, "/api/manual": op_manual, "/api/undo": op_undo,
"/api/finish": op_finish, "/api/cancel": op_cancel, "/api/settings": op_settings,
- "/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign}
+ "/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign,
+ "/api/item/update": op_item_update, "/api/item/delete": op_item_delete,
+ "/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete}
fn = ops.get(self.path)
if not fn:
return self._send(404, {"error": "not found"})
diff --git a/library/quickadd/index.html b/library/quickadd/index.html
index b813688..5a73fb9 100644
--- a/library/quickadd/index.html
+++ b/library/quickadd/index.html
@@ -3,7 +3,7 @@
-Add to library
+Library
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -459,37 +502,143 @@
const embedded = location.pathname.startsWith('/add/');
let viewer = false; // the read-only guest login: search only
- // ---- views: Add books / Find a book
- function showView() {
- const find = viewer || location.hash === '#find';
- $('tab-add').hidden = viewer;
- $('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();
+ // ---- views: Library / Book / Add books
+ const TINTS = ['#5b6f63', '#6b5b73', '#73655b', '#4f6a7a', '#7a5252', '#6a6f3f', '#4a5f7a', '#7a6a3a'];
+ const tint = t => TINTS[[...(t || '')].reduce((n, c) => (n * 31 + c.charCodeAt(0)) % 997, 7) % TINTS.length];
+ function art(b, cls) {
+ const a = el('span', { className: cls || 'art' }, b.title);
+ a.style.setProperty('--tint', tint(b.title));
+ if (b.isbn && /^\d{10,13}$/.test(b.isbn.replace(/\D/g, '').slice(0, 13))) {
+ const img = el('img', { alt: '', hidden: true, loading: 'lazy' });
+ img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
+ img.src = 'https://covers.openlibrary.org/b/isbn/' + b.isbn.replace(/\D/g, '').slice(0, 13) + '-M.jpg?default=false';
+ a.append(img);
+ }
+ return a;
}
- 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.')]),
- viewer ? '' : 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')))));
+ let libPage = 1, libShelves = [], libLoaded = false;
+ const filters = () => ({ q: $('q').value.trim(), shelf: $('f-shelf').value, type: $('f-type').value,
+ status: $('f-status').value, sort: $('f-sort').value });
+ const filterQuery = () => { const f = filters(); return Object.entries(f).filter(([, v]) => v).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&'); };
+
+ function tile(b) {
+ const a = el('a', { className: 'tile', href: '#book/' + b.id });
+ const picture = art(b);
+ if (b.copies > 1) picture.append(el('span', { className: 'num' }, '×' + b.copies));
+ if (!viewer && b.out) picture.append(el('span', { className: 'flag' }, b.out === b.copies ? 'Out' : b.out + ' out'));
+ else if (viewer && b.out === b.copies && b.copies) picture.append(el('span', { className: 'flag' }, 'Out'));
+ a.append(picture, el('div', { className: 'tt' }, b.title), el('div', { className: 'aa' }, b.author));
+ return a;
}
- $('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); };
+
+ let libSeq = 0;
+ async function loadLibrary(reset) {
+ const seq = ++libSeq; // a newer search makes an older, slower answer irrelevant
+ if (reset) libPage = 1;
+ const grid = $('grid');
+ if (reset) grid.replaceChildren();
+ $('libstatus').textContent = 'Loading…';
+ let r;
+ try { r = await api('/api/library?' + filterQuery() + '&page=' + libPage); } catch (e) { r = {}; }
+ if (seq !== libSeq) return;
+ if (!r.books) { $('libstatus').textContent = r.message || 'Koha is not answering yet - try again in a minute.'; return; }
+ if (!libShelves.length && r.shelves) {
+ libShelves = r.shelves;
+ $('f-shelf').replaceChildren(el('option', { value: '' }, 'Any shelf'), el('option', { value: '-' }, '(no shelf set)'),
+ ...r.shelves.map(l => el('option', { value: l.value }, l.label)));
+ $('f-type').replaceChildren(el('option', { value: '' }, 'Any format'),
+ ...Object.entries(r.types || {}).map(([k, v]) => el('option', { value: k }, v)));
+ }
+ grid.append(...r.books.map(tile));
+ $('more').hidden = !r.more;
+ const shown = grid.children.length;
+ const f = filters();
+ $('libstatus').textContent = shown ? shown + (r.more ? '+' : '') + (shown === 1 ? ' book' : ' books') + (f.q || f.shelf || f.type || f.status ? ' match' : '') :
+ (f.q || f.shelf || f.type || f.status ? 'No books match.' : (viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.'));
+ }
+ $('more').onclick = () => { libPage += 1; loadLibrary(false); };
+ $('findform').onsubmit = ev => { ev.preventDefault(); loadLibrary(true); };
+ for (const id of ['f-shelf', 'f-type', 'f-status', 'f-sort']) $(id).onchange = () => loadLibrary(true);
+
+ // ---- one book
+ async function loadBook(id) {
+ const box = $('bookbox');
+ box.replaceChildren(el('div', { className: 'small' }, 'Loading…'));
+ let b;
+ try { b = await api('/api/book/' + id); } catch (e) { b = {}; }
+ if (!b.copies) { box.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, b.message || 'Book not found.'))); return; }
+ document.title = b.title;
+ if (b.shelves && b.shelves.length) libShelves = b.shelves;
+ const facts = [b.author, b.year, b.publisher, b.pages, b.edition, b.series].filter(Boolean);
+ const head = el('div', { className: 'bookhead' }, art(b),
+ el('div', { className: 'body' }, el('h2', {}, b.title), b.subtitle ? el('div', { className: 'meta' }, b.subtitle) : '',
+ el('div', { className: 'meta' }, facts.join(' · ')), b.isbn ? el('div', { className: 'small' }, 'ISBN ' + b.isbn) : '',
+ b.summary ? el('p', {}, b.summary) : ''));
+ box.replaceChildren(head);
+ const refresh = () => loadBook(id);
+ for (const c of b.copies) box.append(copyBox(b, c, refresh));
+ if (!b.copies.length) box.append(el('div', { className: 'small' }, 'No copies recorded.'));
+ if (!viewer) {
+ const more = el('button', { type: 'button' }, 'Add another copy');
+ more.onclick = async () => { more.disabled = true; const r = await api('/api/item/copy', { biblio_id: b.id }); if (r.status === 'added') refresh(); else more.disabled = false; };
+ const del = el('button', { type: 'button', className: 'danger' }, 'Delete this book');
+ del.onclick = async () => {
+ if (!confirm('Delete "' + b.title + '" and all its copies from the library?')) return;
+ const r = await api('/api/book/delete', { biblio_id: b.id });
+ if (r.status === 'deleted') location.hash = '#library'; else alert(r.message || 'Could not delete it.');
+ };
+ const full = el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.id, target: '_blank', rel: 'noopener' }, 'Open the full record in Koha');
+ box.append(el('div', { className: 'actions' }, more, del), el('p', { className: 'small' }, full, ' (edit title, author and other details there)'));
+ }
+ }
+
+ function copyBox(b, c, refresh) {
+ const box = el('div', { className: 'copybox' });
+ const out = c.status === 'Checked out';
+ const status = el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status +
+ (out && c.out ? (c.out.who ? ' to ' + c.out.who : '') + (c.out.due ? ', due ' + c.out.due : '') : ''));
+ box.append(el('div', {}, c.barcode ? el('span', { className: 'badge' }, c.barcode) : el('span', { className: 'small' }, 'no barcode yet'), ' ', status));
+ if (viewer) {
+ box.append(el('div', { className: 'meta' }, [c.shelf_label || 'no shelf set', c.callnumber && 'Call no. ' + c.callnumber].filter(Boolean).join(' · ')));
+ return box;
+ }
+ const sel = el('select', { 'aria-label': 'Shelf' }, el('option', { value: '' }, '(no shelf set)'),
+ ...libShelves.map(l => el('option', { value: l.value }, l.label)));
+ sel.value = c.shelf;
+ const cn = el('input', { type: 'text', value: c.callnumber, placeholder: 'Call number', 'aria-label': 'Call number', maxLength: 80 });
+ const save = el('button', { type: 'button', className: 'primary' }, 'Save');
+ save.onclick = async () => {
+ save.disabled = true;
+ const r = await api('/api/item/update', { item_id: c.item_id, shelf: sel.value, callnumber: cn.value });
+ save.textContent = r.status === 'saved' ? 'Saved ✓' : 'Failed'; setTimeout(() => { save.textContent = 'Save'; save.disabled = false; }, 1500);
+ };
+ const rm = el('button', { type: 'button', className: 'danger' }, 'Remove copy');
+ rm.disabled = out;
+ rm.onclick = async () => {
+ if (!confirm('Remove this copy' + (c.barcode ? ' (barcode ' + c.barcode + ')' : '') + '?')) return;
+ const r = await api('/api/item/delete', { item_id: c.item_id, biblio_id: b.id });
+ if (r.status === 'undone') { if (b.copies.length <= 1) location.hash = '#library'; else refresh(); }
+ };
+ box.append(el('div', { className: 'row' }, sel, cn, save, rm));
+ return box;
+ }
+
+ function route() {
+ const h = location.hash.replace(/^#/, '');
+ const m = h.match(/^book\/(\d+)$/);
+ let view = m ? 'book' : (h === 'add' && !viewer) ? 'add' : 'library';
+ $('view-library').hidden = view !== 'library'; $('view-book').hidden = view !== 'book'; $('view-add').hidden = view !== 'add';
+ $('tab-library').classList.toggle('on', view !== 'add'); $('tab-add').classList.toggle('on', view === 'add');
+ $('tab-add').hidden = viewer;
+ document.title = view === 'add' ? 'Add books' : (libraryName || 'Library');
+ $('title').textContent = view === 'add' ? 'Add books' : (libraryName || 'Library');
+ if (view === 'book') loadBook(m[1]);
+ else if (view === 'add') $('code').focus();
+ else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
+ }
+ window.addEventListener('hashchange', route);
+ let libraryName = '';
(async () => {
const s = await api('/api/state');
@@ -514,7 +663,7 @@
}
if (!viewer) api('/api/settings').then(applySettings);
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
- showView();
+ route();
})();
})();