library: live camera scanning over HTTPS, paste-to-fill AR reading levels (with interest level)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
@@ -243,3 +243,15 @@ Instead: the book page has an **Open AR BookFind** link (plus **Copy title** to
|
|||||||
`library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself
|
`library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself
|
||||||
(for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json`
|
(for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json`
|
||||||
in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder.
|
in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder.
|
||||||
|
|
||||||
|
### Live camera scanning (needs HTTPS)
|
||||||
|
|
||||||
|
Browsers only allow a live camera on HTTPS pages (or on the machine itself). Put the library site behind Caddy with a real
|
||||||
|
certificate and **Live scan** buttons appear: Add books (scan a stack of ISBNs one after another), Lend / return (live card or
|
||||||
|
phone-QR scan, live book check-out for the chosen person, live returns). Chrome on Android reads codes in the browser; other
|
||||||
|
browsers send small camera frames to the server's decoder a couple of times a second. On plain HTTP the photo buttons stay.
|
||||||
|
|
||||||
|
### Copying reading levels from AR BookFind
|
||||||
|
|
||||||
|
On the AR BookFind book page, select everything (Ctrl/Cmd+A), copy, and paste into the box on the book page here: the level,
|
||||||
|
points, quiz number and interest level fill in by themselves; check them and press Save.
|
||||||
|
|||||||
Binary file not shown.
@@ -1247,12 +1247,13 @@ def _num(v, maxlen=8):
|
|||||||
def op_ar_set(payload):
|
def op_ar_set(payload):
|
||||||
key = _isbn13(payload.get("isbn"))
|
key = _isbn13(payload.get("isbn"))
|
||||||
level, points, quiz = _num(payload.get("level")), _num(payload.get("points")), re.sub(r"[^0-9]", "", str(payload.get("quiz") or ""))[:8]
|
level, points, quiz = _num(payload.get("level")), _num(payload.get("points")), re.sub(r"[^0-9]", "", str(payload.get("quiz") or ""))[:8]
|
||||||
|
interest = re.sub(r"[^A-Za-z+]", "", str(payload.get("interest") or ""))[:4].upper()
|
||||||
if not key or level is None or points is None:
|
if not key or level is None or points is None:
|
||||||
return 400, {"status": "invalid", "message": "Level and points are numbers like 4.5 and 6."}
|
return 400, {"status": "invalid", "message": "Level and points are numbers like 4.5 and 6."}
|
||||||
with LOCK:
|
with LOCK:
|
||||||
d = _ar_load()
|
d = _ar_load()
|
||||||
if level or points or quiz:
|
if level or points or quiz or interest:
|
||||||
d[key] = {"level": level, "points": points, "quiz": quiz}
|
d[key] = {"level": level, "points": points, "quiz": quiz, "interest": interest}
|
||||||
else:
|
else:
|
||||||
d.pop(key, None)
|
d.pop(key, None)
|
||||||
_ar_save(d)
|
_ar_save(d)
|
||||||
@@ -1272,6 +1273,7 @@ def op_ar_import(payload):
|
|||||||
if n in head:
|
if n in head:
|
||||||
return head.index(n)
|
return head.index(n)
|
||||||
return None
|
return None
|
||||||
|
cint = col("interest", "interestlevel", "il")
|
||||||
ci, cl, cp, cq = col("isbn", "isbn13"), col("level", "arlevel", "bl", "booklevel"), col("points", "arpoints", "pts"), col("quiz", "quizno", "quiznumber")
|
ci, cl, cp, cq = col("isbn", "isbn13"), col("level", "arlevel", "bl", "booklevel"), col("points", "arpoints", "pts"), col("quiz", "quizno", "quiznumber")
|
||||||
if ci is None or (cl is None and cp is None and cq is None):
|
if ci is None or (cl is None and cp is None and cq is None):
|
||||||
return 400, {"status": "invalid", "message": "Need a header row with an isbn column and at least one of: level, points, quiz."}
|
return 400, {"status": "invalid", "message": "Need a header row with an isbn column and at least one of: level, points, quiz."}
|
||||||
@@ -1287,7 +1289,8 @@ def op_ar_import(payload):
|
|||||||
continue
|
continue
|
||||||
old = d.get(key, {})
|
old = d.get(key, {})
|
||||||
d[key] = {"level": lv or old.get("level", ""), "points": pt or old.get("points", ""),
|
d[key] = {"level": lv or old.get("level", ""), "points": pt or old.get("points", ""),
|
||||||
"quiz": re.sub(r"[^0-9]", "", get(cq))[:8] or old.get("quiz", "")}
|
"quiz": re.sub(r"[^0-9]", "", get(cq))[:8] or old.get("quiz", ""),
|
||||||
|
"interest": re.sub(r"[^A-Za-z+]", "", get(cint))[:4].upper() or old.get("interest", "")}
|
||||||
done += 1
|
done += 1
|
||||||
_ar_save(d)
|
_ar_save(d)
|
||||||
return 200, {"status": "imported", "count": done, "skipped": skipped}
|
return 200, {"status": "imported", "count": done, "skipped": skipped}
|
||||||
@@ -1301,7 +1304,7 @@ def export_csv():
|
|||||||
ar = _ar_load()
|
ar = _ar_load()
|
||||||
out = io.StringIO()
|
out = io.StringIO()
|
||||||
w = csv.writer(out)
|
w = csv.writer(out)
|
||||||
w.writerow(["title", "author", "isbn", "year", "barcode", "call number", "shelf", "collection", "status", "ar level", "ar points", "ar quiz"])
|
w.writerow(["title", "author", "isbn", "year", "barcode", "call number", "shelf", "collection", "status", "ar level", "ar points", "ar quiz", "ar interest"])
|
||||||
page = 1
|
page = 1
|
||||||
while True:
|
while True:
|
||||||
books = koha("GET", "/biblios", query={"_per_page": "200", "_page": str(page), "_order_by": "title"}) or []
|
books = koha("GET", "/biblios", query={"_per_page": "200", "_page": str(page), "_order_by": "title"}) or []
|
||||||
@@ -1316,7 +1319,7 @@ def export_csv():
|
|||||||
for i in mine:
|
for i in mine:
|
||||||
w.writerow([(b.get("title") or "").rstrip(" /:"), b.get("author") or "", isbn, b.get("copyright_date") or "",
|
w.writerow([(b.get("title") or "").rstrip(" /:"), b.get("author") or "", isbn, b.get("copyright_date") or "",
|
||||||
i.get("external_id") or "", i.get("callnumber") or "", where.get(i.get("location"), ""),
|
i.get("external_id") or "", i.get("callnumber") or "", where.get(i.get("location"), ""),
|
||||||
colls.get(i.get("collection_code"), ""), item_status(i) if i else "", a.get("level", ""), a.get("points", ""), a.get("quiz", "")])
|
colls.get(i.get("collection_code"), ""), item_status(i) if i else "", a.get("level", ""), a.get("points", ""), a.get("quiz", ""), a.get("interest", "")])
|
||||||
if len(books) < 200:
|
if len(books) < 200:
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
|
|||||||
+100
-7
@@ -135,6 +135,12 @@
|
|||||||
.cardsheet { display: grid; grid-template-columns: repeat(2, 85.6mm); gap: 6mm; justify-content: start; }
|
.cardsheet { display: grid; grid-template-columns: repeat(2, 85.6mm); gap: 6mm; justify-content: start; }
|
||||||
.cardsheet .libcard { break-inside: avoid; }
|
.cardsheet .libcard { break-inside: avoid; }
|
||||||
@media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } }
|
@media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } }
|
||||||
|
#live { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.92); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 12px; }
|
||||||
|
#live[hidden] { display: none; }
|
||||||
|
#live video { width: min(100%, 640px); max-height: 60vh; border-radius: 12px; background: #000; object-fit: cover; }
|
||||||
|
#live .frame { position: relative; width: min(100%, 640px); }
|
||||||
|
#live .frame::after { content: ''; position: absolute; inset: 18% 12%; border: 3px solid rgba(255,255,255,.85); border-radius: 12px; pointer-events: none; }
|
||||||
|
#live .msg { color: #fff; font-weight: 600; text-align: center; min-height: 3em; max-width: 640px; }
|
||||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -190,7 +196,7 @@
|
|||||||
<input id="ret" inputmode="numeric" placeholder="Scan the book's barcode" autocomplete="off">
|
<input id="ret" inputmode="numeric" placeholder="Scan the book's barcode" autocomplete="off">
|
||||||
<button class="primary" type="submit">Return</button>
|
<button class="primary" type="submit">Return</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="cam-row"><button type="button" id="retcam">Return with camera</button></div>
|
<div class="cam-row"><button type="button" id="retcam">Return with camera</button> <button type="button" id="retlive" hidden>Live return</button></div>
|
||||||
<div id="retresult" role="status" aria-live="polite" class="small" style="margin-top:8px"></div>
|
<div id="retresult" role="status" aria-live="polite" class="small" style="margin-top:8px"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="copybox">
|
<div class="copybox">
|
||||||
@@ -200,7 +206,7 @@
|
|||||||
<input id="who" type="search" placeholder="Name, or scan the library card" enterkeyhint="search" autocomplete="off">
|
<input id="who" type="search" placeholder="Name, or scan the library card" enterkeyhint="search" autocomplete="off">
|
||||||
<button type="submit">Find</button>
|
<button type="submit">Find</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="cam-row"><button type="button" id="whocam">Scan card or phone QR with camera</button>
|
<div class="cam-row"><button type="button" id="whocam">Scan card or phone QR with camera</button> <button type="button" id="wholive" hidden>Live scan</button>
|
||||||
<span class="small">Photograph the library card, or the QR picture on the borrower's phone.</span></div>
|
<span class="small">Photograph the library card, or the QR picture on the borrower's phone.</span></div>
|
||||||
<div id="whostatus" class="small"></div>
|
<div id="whostatus" class="small"></div>
|
||||||
<div id="whoresults"></div>
|
<div id="whoresults"></div>
|
||||||
@@ -262,6 +268,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="cam-row">
|
<div class="cam-row">
|
||||||
<button type="button" id="cam">Scan with camera</button>
|
<button type="button" id="cam">Scan with camera</button>
|
||||||
|
<button type="button" id="livecam" hidden>Live scan</button>
|
||||||
<input type="file" id="camfile" accept="image/*" capture="environment" hidden>
|
<input type="file" id="camfile" accept="image/*" capture="environment" hidden>
|
||||||
<span class="small">Opens your phone's camera: fill the frame with the barcode and hold steady.</span>
|
<span class="small">Opens your phone's camera: fill the frame with the barcode and hold steady.</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,6 +316,11 @@
|
|||||||
<ul class="list" id="earlier"></ul>
|
<ul class="list" id="earlier"></ul>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<div id="live" hidden role="dialog" aria-modal="true" aria-label="Camera scanner">
|
||||||
|
<div class="frame"><video id="livevideo" playsinline muted></video></div>
|
||||||
|
<div class="msg" id="livemsg" aria-live="polite"></div>
|
||||||
|
<button type="button" id="livestop" class="primary">Done</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
@@ -470,6 +482,57 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- live camera: works where the browser allows camera access (HTTPS, or the machine itself).
|
||||||
|
// Chrome/Android read codes in the browser (BarcodeDetector); everything else sends small frames to
|
||||||
|
// the server's decoder a couple of times a second. Plain-HTTP pages fall back to the photo buttons.
|
||||||
|
const liveOK = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||||
|
let liveStop = null;
|
||||||
|
// onCode(code) may return text to show; continuous keeps scanning, otherwise the first code closes it.
|
||||||
|
async function liveScan(kind, title, onCode, continuous) {
|
||||||
|
const box = $('live'), video = $('livevideo'), msg = $('livemsg');
|
||||||
|
let stream;
|
||||||
|
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 } }, audio: false }); }
|
||||||
|
catch (e) { alert('The camera is not available (' + (e.name === 'NotAllowedError' ? 'permission was refused' : e.message) + '). Use the photo button instead.'); return; }
|
||||||
|
box.hidden = false; msg.textContent = title; video.srcObject = stream; await video.play().catch(() => {});
|
||||||
|
let alive = true, busy = false, lastCode = '', lastAt = 0;
|
||||||
|
const detector = 'BarcodeDetector' in window ? new BarcodeDetector({ formats: ['qr_code', 'code_39', 'code_128', 'ean_13', 'ean_8', 'upc_a', 'upc_e'] }) : null;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const finish = () => { alive = false; stream.getTracks().forEach(t => t.stop()); video.srcObject = null; box.hidden = true; liveStop = null; };
|
||||||
|
liveStop = finish; $('livestop').onclick = finish;
|
||||||
|
const accept = async code => {
|
||||||
|
if (!code || busy) return;
|
||||||
|
if (kind === 'isbn') { code = clean(code); if (!code) return; }
|
||||||
|
const now = Date.now();
|
||||||
|
if (code === lastCode && now - lastAt < 4000) return; // the same book still in view
|
||||||
|
lastCode = code; lastAt = now; busy = true;
|
||||||
|
beep(SOUND.added);
|
||||||
|
try { const t = await onCode(code); if (t) msg.textContent = t; } catch (e) {}
|
||||||
|
busy = false;
|
||||||
|
if (!continuous) finish();
|
||||||
|
};
|
||||||
|
const clean = c => { c = String(c).replace(/[\s-]/g, '').toUpperCase(); if (!/^(\d{8,14}|\d{9}X)$/.test(c)) return ''; return c.length === 13 && c[0] === '0' ? c.slice(1) : c; };
|
||||||
|
while (alive) {
|
||||||
|
try {
|
||||||
|
if (video.readyState >= 2 && !busy) {
|
||||||
|
if (detector) {
|
||||||
|
const found = await detector.detect(video);
|
||||||
|
if (found.length) await accept(found[0].rawValue);
|
||||||
|
} else {
|
||||||
|
const k = Math.min(1, 1280 / Math.max(video.videoWidth, video.videoHeight));
|
||||||
|
canvas.width = Math.round(video.videoWidth * k); canvas.height = Math.round(video.videoHeight * k);
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const blob = await new Promise(ok => canvas.toBlob(ok, 'image/jpeg', 0.85));
|
||||||
|
const r = await fetch('api/scan?kind=' + (kind === 'isbn' ? 'isbn' : 'sticker'), { method: 'POST', headers: { 'Content-Type': 'image/jpeg', 'X-Quickadd': '1' }, body: blob });
|
||||||
|
const codes = r.ok ? (await r.json()).codes : [];
|
||||||
|
if (codes.length && alive) await accept(codes[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
await new Promise(ok => setTimeout(ok, detector ? 200 : 650));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', () => { if (liveStop) liveStop(); });
|
||||||
|
|
||||||
function noBarcode() {
|
function noBarcode() {
|
||||||
beep(SOUND.notfound);
|
beep(SOUND.notfound);
|
||||||
$('status').textContent = '';
|
$('status').textContent = '';
|
||||||
@@ -477,6 +540,8 @@
|
|||||||
el('div', { className: 'meta' }, 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.')));
|
el('div', { className: 'meta' }, 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$('livecam').hidden = !liveOK;
|
||||||
|
$('livecam').onclick = () => liveScan('isbn', 'Hold each book\'s barcode in the frame', async code => { await add(code); return 'Added - next book'; }, true);
|
||||||
$('cam').onclick = async () => {
|
$('cam').onclick = async () => {
|
||||||
const codes = await takePhoto('isbn');
|
const codes = await takePhoto('isbn');
|
||||||
if (codes === null) { $('status').textContent = ''; return; }
|
if (codes === null) { $('status').textContent = ''; return; }
|
||||||
@@ -718,19 +783,29 @@
|
|||||||
function arBox(b, m) {
|
function arBox(b, m) {
|
||||||
const ar = m.ar || {};
|
const ar = m.ar || {};
|
||||||
const f = (ph, v, w) => el('input', { type: 'text', placeholder: ph, value: v || '', 'aria-label': ph, inputMode: 'decimal', style: 'width:' + w + 'em;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
|
const f = (ph, v, w) => el('input', { type: 'text', placeholder: ph, value: v || '', 'aria-label': ph, inputMode: 'decimal', style: 'width:' + w + 'em;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
|
||||||
const lvl = f('Level', ar.level, 5), pts = f('Points', ar.points, 5), quiz = f('Quiz no.', ar.quiz, 7);
|
const lvl = f('Level', ar.level, 5), pts = f('Points', ar.points, 5), quiz = f('Quiz no.', ar.quiz, 7), inter = f('Interest (LG/MG/UG)', ar.interest, 9);
|
||||||
const wrap = el('div', { className: 'small', style: 'margin-top:10px;border-top:1px solid var(--line);padding-top:8px' });
|
const wrap = el('div', { className: 'small', style: 'margin-top:10px;border-top:1px solid var(--line);padding-top:8px' });
|
||||||
const copy = el('button', { type: 'button' }, 'Copy title');
|
const copy = el('button', { type: 'button' }, 'Copy title');
|
||||||
copy.onclick = async () => { try { await navigator.clipboard.writeText(b.title); copy.textContent = 'Copied ✓'; } catch (e) { copy.textContent = 'Copy failed'; } setTimeout(() => copy.textContent = 'Copy title', 1500); };
|
copy.onclick = async () => { try { await navigator.clipboard.writeText(b.title); copy.textContent = 'Copied ✓'; } catch (e) { copy.textContent = 'Copy failed'; } setTimeout(() => copy.textContent = 'Copy title', 1500); };
|
||||||
const open = el('a', { href: 'https://www.arbookfind.com/', target: '_blank', rel: 'noopener noreferrer' }, 'Open AR BookFind');
|
const open = el('a', { href: 'https://www.arbookfind.com/', target: '_blank', rel: 'noopener noreferrer' }, 'Open AR BookFind');
|
||||||
if (viewer) { wrap.append(el('strong', {}, 'Reading level: '), ar.level ? 'AR ' + ar.level + (ar.points ? ', ' + ar.points + ' points' : '') : 'not recorded'); return wrap; }
|
if (viewer) { wrap.append(el('strong', {}, 'Reading level: '), ar.level ? 'AR ' + ar.level + (ar.points ? ', ' + ar.points + ' points' : '') + (ar.interest ? ', interest ' + ar.interest : '') : 'not recorded'); return wrap; }
|
||||||
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
|
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
|
||||||
const msg = el('span', {});
|
const msg = el('span', {});
|
||||||
save.onclick = async () => {
|
save.onclick = async () => {
|
||||||
const r = await api('/api/ar/set', { isbn: m.isbn, level: lvl.value, points: pts.value, quiz: quiz.value });
|
const r = await api('/api/ar/set', { isbn: m.isbn, level: lvl.value, points: pts.value, quiz: quiz.value, interest: inter.value });
|
||||||
msg.textContent = r.status === 'saved' ? ' Saved ✓' : ' ' + (r.message || 'Could not save.');
|
msg.textContent = r.status === 'saved' ? ' Saved ✓' : ' ' + (r.message || 'Could not save.');
|
||||||
};
|
};
|
||||||
wrap.append(el('strong', {}, 'Reading level (AR): '), lvl, ' ', pts, ' ', quiz, ' ', save, msg,
|
// Easiest path: on AR BookFind's book page select everything (Ctrl/Cmd+A), copy, and paste it here.
|
||||||
|
const paste = el('textarea', { rows: 2, placeholder: 'Paste the AR BookFind book page here (select all, copy, paste)', 'aria-label': 'Pasted AR BookFind text',
|
||||||
|
style: 'width:100%;margin-top:6px;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
|
||||||
|
paste.oninput = () => {
|
||||||
|
const t = paste.value, g = re => (t.match(re) || [])[1] || '';
|
||||||
|
const found = { level: g(/Book\s*Level:?\s*([0-9]{1,2}(?:\.[0-9]{1,2})?)/i), points: g(/(?:AR\s*)?Points:?\s*([0-9]{1,3}(?:\.[0-9]{1,2})?)/i),
|
||||||
|
quiz: g(/Quiz\s*(?:No\.?|Number|#)\s*:?\s*([0-9]{3,6})/i), interest: g(/Interest\s*Level:?\s*(LG|MG\+?|UG)/i) };
|
||||||
|
if (found.level) lvl.value = found.level; if (found.points) pts.value = found.points; if (found.quiz) quiz.value = found.quiz; if (found.interest) inter.value = found.interest;
|
||||||
|
msg.textContent = (found.level || found.points || found.quiz) ? ' Filled in from what you pasted - check it, then Save.' : (t.trim() ? " Couldn't find a level or points in that text." : '');
|
||||||
|
};
|
||||||
|
wrap.append(el('strong', {}, 'Reading level (AR): '), lvl, ' ', pts, ' ', quiz, ' ', inter, ' ', save, msg, paste,
|
||||||
el('div', { style: 'margin-top:6px' }, open, ' (a new tab: search for the title there, then type what it shows) ', copy));
|
el('div', { style: 'margin-top:6px' }, open, ' (a new tab: search for the title there, then type what it shows) ', copy));
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
@@ -796,6 +871,17 @@
|
|||||||
}
|
}
|
||||||
$('retform').onsubmit = async ev => { ev.preventDefault(); const c = $('ret').value.trim(); $('ret').value = ''; if (c) { await doReturn(c, $('retresult')); $('ret').focus(); } };
|
$('retform').onsubmit = async ev => { ev.preventDefault(); const c = $('ret').value.trim(); $('ret').value = ''; if (c) { await doReturn(c, $('retresult')); $('ret').focus(); } };
|
||||||
|
|
||||||
|
$('retlive').hidden = $('wholive').hidden = !liveOK;
|
||||||
|
$('retlive').onclick = () => liveScan('sticker', 'Scan each returned book', async code => {
|
||||||
|
const r = await doReturn(code, $('retresult')); return $('retresult').innerText; }, true);
|
||||||
|
async function pickByCode(code) {
|
||||||
|
const r = await api('/api/patrons?q=' + encodeURIComponent(code));
|
||||||
|
const hit = (r.patrons || []).filter(p => p.card === code);
|
||||||
|
if (hit.length === 1) { location.hash = '#lend/' + hit[0].id; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$('wholive').onclick = () => liveScan('sticker', 'Hold the library card or the phone QR in the frame', async code => {
|
||||||
|
if (await pickByCode(code)) return; beep(SOUND.notfound); $('whostatus').textContent = 'No library card matches ' + code + '.'; }, false);
|
||||||
$('retcam').onclick = async () => {
|
$('retcam').onclick = async () => {
|
||||||
const codes = await takePhoto('sticker', $('retresult'));
|
const codes = await takePhoto('sticker', $('retresult'));
|
||||||
if (codes === null) { $('retresult').textContent = ''; return; }
|
if (codes === null) { $('retresult').textContent = ''; return; }
|
||||||
@@ -866,9 +952,16 @@
|
|||||||
if (!codes.length) { out.textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
|
if (!codes.length) { out.textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
|
||||||
scan.value = codes[0]; form.requestSubmit();
|
scan.value = codes[0]; form.requestSubmit();
|
||||||
};
|
};
|
||||||
|
const bookLive = el('button', { type: 'button', hidden: !liveOK }, 'Live scan books');
|
||||||
|
bookLive.onclick = () => liveScan('sticker', 'Scan each book to check out to ' + p.name, async code => {
|
||||||
|
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
|
||||||
|
openPatron(p.id, true); loadOut();
|
||||||
|
if (r.status === 'checked_out') return (r.title || code) + ' - due ' + fmtDue(r.due);
|
||||||
|
beep(SOUND.notfound); return (r.title ? r.title + ': ' : '') + (r.message || 'That did not work.');
|
||||||
|
}, true);
|
||||||
const loans = p.loans.length ? p.loans.map(l => loanRow(l, false)) : [el('div', { className: 'small' }, 'Nothing checked out to ' + p.name + ' yet.')];
|
const loans = p.loans.length ? p.loans.map(l => loanRow(l, false)) : [el('div', { className: 'small' }, 'Nothing checked out to ' + p.name + ' yet.')];
|
||||||
$('patronbox').replaceChildren(el('div', { className: 'title', style: 'margin-top:10px' }, p.name, ' ', el('span', { className: 'badge' }, p.card || 'no card')),
|
$('patronbox').replaceChildren(el('div', { className: 'title', style: 'margin-top:10px' }, p.name, ' ', el('span', { className: 'badge' }, p.card || 'no card')),
|
||||||
form, el('div', { className: 'cam-row' }, bookCam), out, el('h2', {}, 'Has out'), ...loans,
|
form, el('div', { className: 'cam-row' }, bookCam, ' ', bookLive), out, el('h2', {}, 'Has out'), ...loans,
|
||||||
el('div', { className: 'small', style: 'margin-top:8px' }, el('a', { href: '#card/' + p.id }, 'Print library card'), ' · ',
|
el('div', { className: 'small', style: 'margin-top:8px' }, el('a', { href: '#card/' + p.id }, 'Print library card'), ' · ',
|
||||||
el('a', { href: '#lend' }, 'Choose someone else')));
|
el('a', { href: '#lend' }, 'Choose someone else')));
|
||||||
if (!quiet) scan.focus();
|
if (!quiet) scan.focus();
|
||||||
|
|||||||
Reference in New Issue
Block a user