library: barcode modes, patrons/lending, install-time shelves and item types

Quick Add:
- barcode choices: automatic (zero-padded, >= 7 digits), scan a pre-printed
  sticker per item, or none for now; bulk 'give barcodes to items without one';
  reserve a block of numbers and print the sheet ahead of time (link opens
  Koha's range printer prefilled)
- default shelf saved on the server (shared by every device)
- item types follow Koha; CSP allows cover redirects; aria attrs fixed

Koha bootstrap:
- default lending rule (3 weeks, 25 items, 3 renewals, no fines) - a fresh
  Koha refuses every checkout without one
- automatic library-card numbers (start at 1000001), fines off
- Koha's own auto-barcode off (one numbering scheme)
- patron card template (Avery 5371) and layout with a scannable barcode
- LIBRARY_CODE, SHELVES and ITEM_TYPES honoured at first install

Why 7 digits: decoded printed sheets - 7-digit Code 39 read back 40/40, 6-digit
all-numeric symbols were reinterpreted as Code 32 for ~1 in 10 values.

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-06 11:07:02 +00:00
parent aa7950aa9e
commit 76866e8bbc
8 changed files with 601 additions and 98 deletions
+129 -20
View File
@@ -68,6 +68,16 @@
ul.list .u { color: var(--muted); font-size: .9rem; }
.labels { background: var(--card); border: 1px dashed var(--line); border-radius: 12px; padding: 12px; margin-top: 14px; }
.small { color: var(--muted); font-size: .92rem; }
details.settings { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 14px; margin: 18px 0 0; }
details.settings summary { cursor: pointer; font-weight: 700; }
details.settings fieldset { border: 0; padding: 0; margin: 12px 0; }
details.settings legend { font-weight: 700; padding: 0; margin-bottom: 4px; }
details.settings label.opt { display: block; padding: 4px 0; }
details.settings .tool { border-top: 1px solid var(--line); padding: 12px 0 4px; }
details.settings select, details.settings input[type=number] { font: inherit; padding: 6px 8px; border: 1px solid var(--line);
border-radius: 8px; background: var(--card); color: var(--ink); }
details.settings input[type=number] { width: 6em; }
.result-note { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--ok-bg); border: 1px solid var(--ok); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
</style>
</head>
@@ -98,11 +108,39 @@
<ul class="list" id="session"></ul>
<div class="labels" id="labels" hidden>
<strong id="range"></strong>
<div class="small">Print spine labels and barcodes: open the label creator,
choose <em>Print barcode range</em>, and enter these numbers.</div>
<div class="actions"><a id="lnk-labels" href="#"><button type="button">Open label creator</button></a></div>
<div class="small">Print barcode labels for these: the button opens the label printer with the range filled in
(you may be asked to log in to the staff interface).</div>
<div class="actions"><a id="lnk-labels" href="#" target="_blank" rel="noopener"><button type="button">Print labels</button></a></div>
</div>
<details class="settings" id="settings">
<summary>Barcodes and settings</summary>
<fieldset>
<legend>Barcodes on new items</legend>
<label class="opt"><input type="radio" name="bcmode" value="auto"> Number them automatically</label>
<label class="opt"><input type="radio" name="bcmode" value="scan"> I stick a pre-printed barcode on each one: ask me to scan it</label>
<label class="opt"><input type="radio" name="bcmode" value="none"> No barcodes for now: I will add them later</label>
<div class="small" style="margin-top:6px">
Digits per barcode:
<select id="width"></select>
<span id="nextwrap">&nbsp;Next barcode: <strong id="nextbc"></strong></span>
</div>
<div class="small">Same-length numbers are what Koha's label printer needs for a range, so numbers are zero-padded (0000042). Seven or more digits keeps scanners from misreading a short all-number barcode as the pharmacy "Code 32" format.</div>
</fieldset>
<div class="tool">
<strong>Items without a barcode: <span id="missing">0</span></strong>
<div class="actions"><button type="button" id="assign">Give them barcodes</button></div>
</div>
<div class="tool">
<strong>Print a sheet of barcodes ahead of time</strong>
<div class="small">Reserve a block of numbers, print the sheet, and stick the labels on whenever you like.</div>
<div class="actions"><label for="reserve-count" class="sr">How many</label>
<input type="number" id="reserve-count" min="1" max="2000" value="80">
<button type="button" id="reserve">Reserve numbers</button></div>
</div>
<div id="tool-result" aria-live="polite"></div>
</details>
<h2>Earlier</h2>
<ul class="list" id="earlier"></ul>
</main>
@@ -110,7 +148,7 @@
<script>
(() => {
const $ = id => document.getElementById(id);
let types = {}, sessionItems = [], busy = false, lastAdded = null, shelf = localStorage.getItem('qa-shelf') || '', type = localStorage.getItem('qa-type') || 'BOOK';
let types = {}, sessionItems = [], busy = false, lastAdded = null, shelf = '', awaiting = false, staffPort = '8081', settings = {}, type = localStorage.getItem('qa-type') || 'BOOK';
// --- small audible feedback so a stack of books can be scanned without looking
let actx;
@@ -128,7 +166,11 @@
const SOUND = { added: [880], duplicate: [520, 520], notfound: [300], error: [220, 180] };
const el = (tag, props = {}, ...kids) => {
const n = Object.assign(document.createElement(tag), props);
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k.includes('-')) n.setAttribute(k, v); // aria-*, data-*: attributes, not JS properties
else n[k] = v;
}
for (const k of kids) n.append(k);
return n;
};
@@ -181,11 +223,14 @@
function render() {
$('session').replaceChildren(...sessionItems.map(e => rowFor(e, true)));
$('count').textContent = sessionItems.length ? '(' + sessionItems.length + ')' : '';
const nums = sessionItems.map(e => parseInt(e.barcode, 10)).filter(Number.isFinite);
$('labels').hidden = nums.length === 0;
if (nums.length) {
const lo = Math.min(...nums), hi = Math.max(...nums);
const bcs = sessionItems.map(e => e.barcode).filter(b => b && /^\d+$/.test(b));
$('labels').hidden = bcs.length === 0;
if (bcs.length) {
const sameLen = bcs.every(b => b.length === bcs[0].length);
const sorted = [...bcs].sort((a, b) => (a.length - b.length) || (a < b ? -1 : 1));
const lo = sorted[0], hi = sorted[sorted.length - 1];
$('range').textContent = lo === hi ? 'Barcode ' + lo : 'Barcodes ' + lo + ' to ' + hi;
$('lnk-labels').href = sameLen ? printLink(lo, hi) : $('lnk-labels').href;
}
}
@@ -193,19 +238,22 @@
if (busy) return; busy = true;
$('status').textContent = 'Looking up ' + code + '…';
try {
const r = await api('/api/add', Object.assign({ code, type, location: shelf }, opts));
const r = await api('/api/add', Object.assign({ code, type }, opts));
handle(r, code);
} catch (e) {
beep(SOUND.error); show('bad', el('div', { className: 'body' }, 'Could not reach the quick-add service.'));
} finally { busy = false; $('code').focus(); }
} finally { busy = false; if (!awaiting) $('code').focus(); }
}
function handle(r, code) {
$('status').textContent = '';
if (r.status === 'added') {
beep(SOUND.added); sessionItems.unshift(r); lastAdded = r.item_id; render();
show('ok', cover(r.code), bookBody(r, [el('div', { className: 'meta' }, 'Added as barcode ',
el('span', { className: 'badge' }, r.barcode), (r.source === 'extra copy' ? ' · another copy of a title you already have' : ' · from ' + r.source))]));
awaiting = false; $('code').focus();
show('ok', cover(r.code), bookBody(r, [el('div', { className: 'meta' },
r.barcode ? 'Added as barcode ' : 'Added (no barcode yet)',
r.barcode ? el('span', { className: 'badge' }, r.barcode) : '',
(r.source === 'extra copy' ? ' · another copy of a title you already have' : ' · from ' + r.source))]));
} else if (r.status === 'duplicate') {
beep(SOUND.duplicate);
const more = el('button', { className: 'primary', type: 'button' }, 'Add another copy');
@@ -215,6 +263,8 @@
show('warn', cover(code), bookBody(r, [el('div', { className: 'meta' },
'You already have this (' + r.copies + (r.copies === 1 ? ' copy' : ' copies') + ').'),
el('div', { className: 'actions' }, more, skip)]));
} else if (r.status === 'need_barcode' || r.status === 'barcode_taken' || r.status === 'barcode_invalid') {
stickerPrompt(r);
} else if (r.status === 'notfound') {
beep(SOUND.notfound); manualForm(code);
} else {
@@ -223,6 +273,67 @@
}
}
function stickerPrompt(r) {
awaiting = true;
const input = el('input', { placeholder: 'Scan the barcode sticker', autocomplete: 'off', 'aria-label': 'Barcode sticker', inputMode: 'text' });
const form = el('form', { className: 'manual' });
const cancel = el('button', { type: 'button' }, 'Cancel');
cancel.onclick = async () => { await api('/api/cancel', { token: r.token }); awaiting = false;
$('result').replaceChildren(); $('code').focus(); };
form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), cancel));
form.onsubmit = async ev => {
ev.preventDefault();
const bc = input.value.trim(); if (!bc) return;
handle(await api('/api/finish', { token: r.token, barcode: bc }), r.code);
};
const msg = r.status === 'barcode_taken' ? 'Barcode ' + r.barcode + ' is already on another item. Scan a different sticker.'
: r.status === 'barcode_invalid' ? (r.message || 'That barcode cannot be used.')
: 'Now scan the barcode sticker you put on this one.';
if (r.status !== 'need_barcode') beep(SOUND.duplicate);
show('warn', bookBody(r, [el('div', { className: 'meta' }, msg), form]));
input.focus();
}
const printLink = (from, to) => 'http://' + location.hostname + ':' + staffPort +
'/cgi-bin/koha/labels/label-print.pl?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
function rangeNote(text, from, to) {
const note = el('div', { className: 'result-note' }, text + ' ');
if (from) {
const a = el('a', { href: printLink(from, to), target: '_blank', rel: 'noopener' }, 'Print these labels');
note.append(a, el('div', { className: 'small' }, 'Pick a template and layout on the next screen (you will be asked to log in to the staff interface).'));
}
$('tool-result').replaceChildren(note);
}
function applySettings(v) {
settings = v.settings;
document.querySelectorAll('input[name=bcmode]').forEach(i => { i.checked = i.value === settings.barcode_mode; });
const w = $('width');
if (!w.options.length) for (let n = 7; n <= 10; n++) w.append(el('option', { value: n }, String(n)));
w.value = settings.width;
$('nextwrap').hidden = settings.barcode_mode !== 'auto';
$('nextbc').textContent = v.next || '';
$('missing').textContent = v.without_barcode == null ? '?' : v.without_barcode;
$('assign').disabled = !v.without_barcode;
}
async function saveSettings(patch) { applySettings(await api('/api/settings', patch)); }
document.querySelectorAll('input[name=bcmode]').forEach(i => i.onchange = () => saveSettings({ barcode_mode: i.value }));
$('width').onchange = () => saveSettings({ width: parseInt($('width').value, 10) });
$('assign').onclick = async () => {
$('assign').disabled = true;
const r = await api('/api/barcodes/assign', {});
rangeNote(r.count ? r.count + ' item(s) now have barcodes ' + r.first + ' to ' + r.last + '.' : 'Every item already has a barcode.', r.first, r.last);
saveSettings({});
};
$('reserve').onclick = async () => {
const r = await api('/api/barcodes/reserve', { count: parseInt($('reserve-count').value, 10) });
if (r.status === 'reserved') { rangeNote('Reserved ' + r.first + ' to ' + r.last + ' (' + r.count + ' labels).', r.first, r.last); saveSettings({}); }
else rangeNote(r.message || 'Could not reserve that many.');
};
function manualForm(code) {
const f = el('form', { className: 'manual' });
const field = (name, ph, req) => el('input', { name, placeholder: ph, required: !!req, 'aria-label': ph });
@@ -234,7 +345,7 @@
f.onsubmit = async ev => {
ev.preventDefault();
const d = Object.fromEntries(new FormData(f));
handle(await api('/api/manual', Object.assign(d, { code, type, location: shelf })), code);
handle(await api('/api/manual', Object.assign(d, { code, type })), code);
};
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, 'Not found: ' + code),
el('div', { className: 'meta' }, 'None of the catalogues knew this one. Type the details and it is added anyway.'), f));
@@ -258,19 +369,17 @@
(async () => {
const s = await api('/api/state');
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
if (s.locations && s.locations.length) {
const sel = $('shelf');
sel.replaceChildren(el('option', { value: '' }, '(not set)'),
sel.replaceChildren(el('option', { value: '' }, '(not set / all in one place)'),
...s.locations.map(l => el('option', { value: l.value }, l.label)));
if (![...sel.options].some(o => o.value === shelf)) shelf = '';
sel.value = shelf;
sel.onchange = () => { shelf = sel.value; localStorage.setItem('qa-shelf', shelf); $('code').focus(); };
sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); };
$('shelfbox').hidden = false;
}
const host = location.hostname;
$('lnk-opac').href = 'http://' + host + ':' + s.opac_port + '/';
$('lnk-staff').href = 'http://' + host + ':' + s.staff_port + '/';
$('lnk-labels').href = 'http://' + host + ':' + s.staff_port + '/cgi-bin/koha/labels/label-edit-range.pl';
api('/api/settings').then(applySettings);
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
$('code').focus();
})();