17 Commits
Author SHA1 Message Date
Claude 1775220312 Page back/forward: Alt+Left/Right, mouse side buttons, optional on-screen buttons
Alt+Left/Right now go back/forward within the page (the standard browser
keys) instead of switching sites; sites switch with Ctrl+Tab, Ctrl+[ ] and
the standard Ctrl+PageUp/PageDown. New enableHistoryButtons option (off by
default) shows half-transparent back/forward buttons at the top middle.
Settable from the terminal menu and the Web UI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 16:47:53 +00:00
Outis 7ed2d4744c Merge pull request #130 from outis1one/claude/optimistic-clarke-27gpk1
Claude/optimistic clarke 27gpk1
2026-11-06 11:04:55 -05:00
Outis 9b646b99f8 Merge pull request #129 from outis1one/claude/pensive-wright-ko0ipl
Claude/pensive wright ko0ipl
2026-11-06 11:04:28 -05:00
Claude 5fc7450af7 library: scan with a phone camera (Quick Add)
A 'Scan with camera' button opens the phone's own camera app; the photo is shrunk
on the phone and read on the server with zbarimg, then goes through the normal add
flow (also available on the sticker step). Works over plain HTTP on iPhone and
Android - no HTTPS needed.

- /api/scan: JPEG/PNG only (checked by magic bytes before ImageMagick sees it),
  12 MB cap, login required, 20 s timeout, temp files cleaned up
- UPC-A reads (zbar reports 13 digits with a leading zero) normalised to the 12
  digits printed on the item so duplicate checks match
- tests/gen_photos.py + tests/check_scan.py: phone-like photos (tilt, blur, noise,
  low light, upside down, EXIF rotation, small, UPC, Code 39 sticker); 10/10 read
  at the size the page sends

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 13:12:58 +00:00
Claude 023a64b62c library: Dockerfile fails loudly and verifies the image
A '|| true' at the end of the install chain swallowed any earlier failure, so a
transient/blocked apt step produced a successful build with no Koha in it (found
when an update rebuilt without the proxy CA). Steps are now separate, memcached and
cron are installed explicitly, and a final step refuses to produce an image that is
missing koha-create, memcached, cron, apache2ctl, zebrasrv, ... Quick Add's image
verifies yaz and python3 the same way.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 11:18:10 +00:00
Claude 49c35f765b library: proxy URL overrides, restore Quick Add header links
- OPAC_URL / STAFF_URL let Quick Add build correct links behind a reverse proxy
- restore the Catalogue/Staff header links (lost when the init block was
  rewritten) and verify them in the browser test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 11:09:11 +00:00
Claude 76866e8bbc 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
2026-11-06 11:07:02 +00:00
Claude aa7950aa9e library: shelf picker in Quick Add, docs, status/ss fixes; kiosk README entry
- Quick Add: choose the shelf/room (Koha LOC values) once; every scanned item
  gets it and the choice is remembered; invalid values are ignored
- install.sh --status shows localhost in loopback mode; port probing works
  without ss
- library/README.md; kiosk Readme.md add-on section and licenses

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 10:33:58 +00:00
Claude e08133dfc0 library: BIND_ADDR for loopback-only mode, real readiness check
- BIND_ADDR (default 0.0.0.0) on every published port; 127.0.0.1 keeps the
  stack reachable from the host only (Docker bypasses ufw, so this is the
  actual control the kiosk addon's 'this kiosk only' choice uses)
- Koha serves its web-installer page with HTTP 200 before the database
  exists, so 'port answers' was not 'ready': the entrypoint now writes
  /run/koha-ready, the healthcheck requires it, and install.sh waits for
  healthy + the quick-add health endpoint

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 10:30:41 +00:00
Claude 24c95c91f2 Add Library Catalog addon, lib/docker.sh and tests
- lib/docker.sh: shared Docker/Compose tooling (on-demand install via apt or
  get.docker.com, compose wrapper, container state/health, HTTP wait)
- menus/addon_library.sh: install/update/uninstall the Koha + Quick Add stack
  from library/ to /opt/kiosk-library, add the catalogue as a kiosk page
  (duration 0), choose kiosk-only vs LAN access, show addresses and login
- wire into install.sh menu, complete_uninstall and clone_settings
- tests/addon_library.test.sh: 41 checks against a stub sudo/docker and a
  fixture stack (no root, Docker or network needed)

Note: the 'network access' choice relies on a BIND_ADDR setting in the
library stack, added in a follow-up commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 10:29:22 +00:00
Claude 2ef8f68b9b library: add Quick Add page (scan an ISBN, the book is added)
A small standard-library Python service next to Koha:
- looks the code up via Z39.50 (yaz) then Open Library over HTTPS
- checks Koha for an existing copy (ISBN-10/13 and UPC/EAN aware)
- adds the record + an item with the next barcode via Koha's REST API
- scanner-friendly UI: auto-focus, audible feedback, duplicate card with
  'add another copy', manual entry when nothing is found, undo, and the
  barcode range to print labels for
Also: enable RESTBasicAuth, map 024$a to the ean column, wire the service,
port and credentials into compose/install.sh.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 10:24:54 +00:00
Claude f4da2608cd library/install.sh: retry builds on Docker Hub rate limits (HTTP 429)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 07:07:56 +00:00
Claude f0a0c4ad40 library: seed label templates/layouts, default item type, fix Apache setup
- Seed Avery 5160/5167 and Demco spine templates plus Spine, Spine+barcode
  and Barcode-only layouts (Dewey call number split across lines)
- Default 942$c (item type) to BOOK so a Z39.50 import saves without edits
- Enable Plack includes in addition to the plain ones (static files/rewrites)
- Swap Apache from mpm_itk to prefork after koha-create (itk can't switch
  users in a container and returned HTTP 500 on every page)
- Drop unneeded SETUID/SETGID caps

Verified live: staff login, Z39.50 search/import against a local server,
save + auto barcode, label PDF with Dewey spine + barcode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:51:50 +00:00
Claude b4c844b124 library: pass PERL5LIB to bootstrap (koha-shell used to set it)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:30:12 +00:00
Claude a15478b382 library: run bootstrap via runuser, keep mpm_itk, add SETUID/SETGID caps
- koha-shell -c wraps the command in single quotes, which broke the env
  assignments; use runuser + env instead and verify tables were created
- koha-create requires mpm_itk, so keep it and grant the caps it needs
- stop any Apache started by koha-create before running it in foreground

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:29:27 +00:00
Claude 7244cfd918 library: fix entrypoint (Apache user, Listen 8081, start daemons each boot) and crash detection
- Run Apache as the instance user instead of mpm_itk AssignUserID
- Add Listen for the staff port; start zebra/indexer/worker/plack explicitly
- Tolerate koha-plack --enable returning non-zero under set -e
- install.sh: only fail on a crash loop, not a single restart

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:20:45 +00:00
Claude 5b79608695 Add library/: one-command Dockerized Koha home library (WIP)
Compose stack (MariaDB + Koha built from the Koha project's Debian
packages), headless bootstrap replacing the web installer, seeded item
types and LoC Z39.50 server, and an install.sh that installs Docker,
generates passwords and waits for the site. End-to-end testing in progress.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 06:13:25 +00:00
34 changed files with 3254 additions and 9 deletions
+19 -1
View File
@@ -249,6 +249,7 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
- **1-finger swipe** (dual mode) - Navigate within page (arrow keys)
- **On-screen keyboard** - Auto-shows on text fields or click keyboard icon
- **Navigation menu** - Click key icon (top-left) to access site list and gesture cheat sheet
- **Back/forward buttons** (optional, off by default) - two half-transparent ◀ ▶ buttons at the top middle of the screen, for kiosks with no keyboard or mouse side buttons. They appear on a tap, click or mouse movement and fade after a few seconds. Turn on in Core Settings → Display & Interaction, or the Web UI.
### On-Screen Keyboard
- **HTML-based keyboard** with full QWERTY layout
@@ -427,6 +428,15 @@ Both can be used at the same time — they serve different purposes:
- **IPP Everywhere support**
- **PDF printing** via cups-pdf
### Library Catalog (Koha)
- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in
- **Add books by scanning** - a Quick Add page: scan or type an ISBN (or tap "Scan with camera" on a phone) and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines
- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library`
- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access
- **Uninstall keeps your books** unless you also choose to delete the data (typed `DELETE` confirmation); Complete Uninstall removes everything
- Needs roughly 2 GB RAM and 3 GB disk; the first build takes 5-10 minutes. See [`library/README.md`](library/README.md) for the stack itself, and run `bash tests/addon_library.test.sh` for the add-on's tests
### Remote Access
- **VNC** - x11vnc for remote desktop
- **WireGuard VPN** - Config paste support
@@ -766,7 +776,9 @@ smb://WORKGROUP/COMPUTER/PrinterName
**Keyboard Shortcuts:**
- `Ctrl+Tab` or `Ctrl+]` - Next tab
- `Ctrl+Shift+Tab` or `Ctrl+[` - Previous tab
- `Alt+Right/Left` - Next/Previous tab
- `Ctrl+PageDown` / `Ctrl+PageUp` - Next/Previous tab (the standard browser keys)
- `Alt+Right` / `Alt+Left` - Page forward / back within the current site (the standard browser keys; they used to switch tabs)
- Mouse side buttons (back/forward) - the same page forward/back
- `F10` or `Ctrl+H` - Toggle hidden tabs
- `Escape` - Return to normal tabs (from hidden)
- `Ctrl+Alt+Delete` or `Ctrl+Alt+P` - Power menu
@@ -1344,7 +1356,13 @@ This project bundles or installs several open-source components under their resp
- **Repository:** https://github.com/netbirdio/netbird
- **Full License:** https://github.com/netbirdio/netbird/blob/main/LICENSE
### Koha (Library Catalog add-on)
- **License:** GPL-3.0-or-later
- **Source:** https://koha-community.org
- **Packages:** installed from the Koha project's own Debian repository (https://debian.koha-community.org) inside a Docker image built on your machine
### MariaDB and Docker (Library Catalog add-on)
- **MariaDB** - GPL-2.0 (https://mariadb.org); **Docker Engine** - Apache-2.0 (https://www.docker.com)
---
+11 -3
View File
@@ -38,7 +38,11 @@
# Display/Lockout, the webui/ Node app installed as a systemd
# service; no login of its own, put it behind your own reverse proxy
# with Authelia forward-auth if it needs to be reachable beyond a
# trusted LAN - see the file header).
# trusted LAN - see the file header), Library Catalog (menus/
# addon_library.sh - a self-hosted Koha library catalogue in Docker with
# Z39.50 lookup, label printing and a scan-to-add page, shown on the
# kiosk as a public search terminal; installs Docker on demand via
# lib/docker.sh).
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
# manual update, fix blank screen), Factory Reset
@@ -67,6 +71,8 @@ source "$SCRIPT_DIR/lib/menu.sh"
source "$SCRIPT_DIR/lib/config.sh"
# shellcheck source=lib/electron.sh
source "$SCRIPT_DIR/lib/electron.sh"
# shellcheck source=lib/docker.sh
source "$SCRIPT_DIR/lib/docker.sh"
# shellcheck source=menus/sites.sh
source "$SCRIPT_DIR/menus/sites.sh"
# shellcheck source=menus/display.sh
@@ -97,6 +103,8 @@ source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
# shellcheck source=menus/addon_webui.sh
source "$SCRIPT_DIR/menus/addon_webui.sh"
# shellcheck source=menus/addon_library.sh
source "$SCRIPT_DIR/menus/addon_library.sh"
# shellcheck source=menus/advanced_electron.sh
source "$SCRIPT_DIR/menus/advanced_electron.sh"
# shellcheck source=menus/advanced_upgrade.sh
@@ -179,8 +187,8 @@ core_settings_menu() {
}
addons_menu_builder() {
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI")
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu)
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI" "Library Catalog (Koha)")
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu addon_library_menu)
}
addons_menu() {
+41 -2
View File
@@ -60,6 +60,7 @@ let allowNavigation='same-origin';
let enablePauseButton=true;
let enableKeyboardButton=true;
let enableNavButton=true;
let enableHistoryButtons=false;
let enablePasswordProtection=false;
let lockoutPassword="";
let lockoutTimeout=0;
@@ -102,6 +103,7 @@ function loadConfig(){
enablePauseButton=(config.enablePauseButton!==false);
enableKeyboardButton=(config.enableKeyboardButton!==false);
enableNavButton=(config.enableNavButton!==false);
enableHistoryButtons=(config.enableHistoryButtons===true);
enablePasswordProtection=(config.enablePasswordProtection===true);
lockoutPassword=config.lockoutPassword||"";
lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms
@@ -122,6 +124,7 @@ function loadConfig(){
console.log('[CONFIG] Navigation:',allowNavigation);
console.log('[CONFIG] Pause button:',enablePauseButton);
console.log('[CONFIG] Keyboard button:',enableKeyboardButton);
console.log('[CONFIG] Back/forward buttons:',enableHistoryButtons);
console.log('[CONFIG] Password protection:',enablePasswordProtection);
console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes');
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
@@ -1145,6 +1148,27 @@ function returnToTabs(){
markActivity();
}
// Page back/forward in the site that's on screen (Alt+Left / Alt+Right, the
// mouse's side buttons, and the optional on-screen buttons). Not while the
// screen is locked or a dialog (PIN, WiFi, power...) is open on top - the
// keys would silently navigate the page hidden behind it.
function historyGo(direction){
if(isLockedOut)return;
for(const w of [pinWindow,wifiWindow,promptWindow,pauseWindow]){
if(w&&!w.isDestroyed())return;
}
const view=showingHidden?hiddenViews[currentHiddenIndex]:views[currentIndex];
if(!view||!view.webContents||view.webContents.isDestroyed())return;
const wc=view.webContents;
const nav=wc.navigationHistory;
if(direction==='back'){
if(nav?nav.canGoBack():wc.canGoBack()){nav?nav.goBack():wc.goBack();}
}else{
if(nav?nav.canGoForward():wc.canGoForward()){nav?nav.goForward():wc.goForward();}
}
markActivity();
}
function showPinEntry(){
if(pinWindow&&!pinWindow.isDestroyed()){
pinWindow.focus();
@@ -1526,6 +1550,11 @@ async function createWindow(){
mainWindow.on('focus',()=>markActivity());
mainWindow.webContents.on('before-input-event',()=>markActivity());
// Mouse side buttons (back/forward). Reported by the window on Linux.
mainWindow.on('app-command',(event,command)=>{
if(command==='browser-backward')historyGo('back');
else if(command==='browser-forward')historyGo('forward');
});
if(!tabs.length){
mainWindow.loadURL('data:text/html,<body>No Sites Configured</body>');
@@ -1611,6 +1640,7 @@ async function createWindow(){
console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton);
view.webContents.send('nav-button-enabled',enableNavButton);
view.webContents.send('history-buttons-enabled',enableHistoryButtons);
console.log('[MAIN] Page loaded - sending nav-button-enabled: '+enableNavButton);
console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url);
@@ -1654,6 +1684,9 @@ async function createWindow(){
else if(action==='reload'){app.relaunch();app.quit();}
});
ipcMain.on('toggle-hidden',toggleHidden);
ipcMain.on('history-nav',(event,direction)=>{
if(direction==='back'||direction==='forward')historyGo(direction);
});
ipcMain.on('return-to-tabs',forceReturnToTabs);
ipcMain.on('user-activity',markActivity);
ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();});
@@ -1843,9 +1876,15 @@ async function createWindow(){
globalShortcut.register('Control+Shift+Tab',()=>{prevTab();});
globalShortcut.register('Control+]',()=>{nextTab();});
globalShortcut.register('Control+[',()=>{prevTab();});
globalShortcut.register('Alt+Right',()=>{nextTab();});
globalShortcut.register('Alt+Left',()=>{prevTab();});
// The standard browser keys for switching tabs. (Alt+Left/Right used to
// switch sites; they are now page back/forward, below.)
globalShortcut.register('Control+PageDown',()=>{nextTab();});
globalShortcut.register('Control+PageUp',()=>{prevTab();});
}
// Standard browser keys for page back/forward.
globalShortcut.register('Alt+Left',()=>{historyGo('back');});
globalShortcut.register('Alt+Right',()=>{historyGo('forward');});
globalShortcut.register('F10',toggleHidden);
globalShortcut.register('Control+H',toggleHidden);
+75
View File
@@ -115,6 +115,19 @@ ipcRenderer.on('keyboard-button-enabled',(event,enabled)=>{
}
});
// On-screen back/forward buttons (top middle, half transparent) - an
// option for screens with no keyboard or mouse side buttons.
let historyButtonsEnabled=false;
let historyBar=null;
let historyBarHideTimer=null;
ipcRenderer.on('history-buttons-enabled',(event,enabled)=>{
historyButtonsEnabled=enabled===true;
console.log('[HISTORY-BTN] Back/forward buttons enabled: '+historyButtonsEnabled);
if(historyBar&&!historyButtonsEnabled){
historyBar.style.display='none';
}
});
ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
navButtonEnabled=enabled;
console.log('[NAV-BTN] Navigation button enabled: '+enabled);
@@ -253,6 +266,56 @@ window.addEventListener('DOMContentLoaded',()=>{
document.body.appendChild(navButton);
}
// Back/forward buttons, top middle. The corners are often all taken by the
// menu, pause, keyboard and power buttons, so these sit between them.
const HISTORY_BAR_HIDE_DELAY=5000;
function createHistoryBar(){
if(historyBar)return;
historyBar=document.createElement('div');
historyBar.id='electron-history-bar';
historyBar.style.cssText=`
position:fixed;top:12px;left:50%;transform:translateX(-50%);
display:none;gap:14px;z-index:999999;opacity:0.5;
transition:opacity 0.15s;user-select:none;
`;
const makeButton=(label,direction,path)=>{
const b=document.createElement('div');
b.title=label;
b.innerHTML='<svg width="30" height="30" viewBox="0 0 24 24" fill="white"><path d="'+path+'"/></svg>';
b.style.cssText=`
width:56px;height:56px;background:rgba(44,62,80,0.9);
border:2px solid rgba(255,255,255,0.9);border-radius:50%;
display:flex;align-items:center;justify-content:center;cursor:pointer;
`;
b.addEventListener('click',(e)=>{
e.preventDefault();
e.stopPropagation();
ipcRenderer.send('history-nav',direction);
showHistoryBar();
});
return b;
};
historyBar.append(
makeButton('Back','back','M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z'),
makeButton('Forward','forward','M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z')
);
historyBar.addEventListener('mouseenter',()=>{historyBar.style.opacity='0.9';});
historyBar.addEventListener('mouseleave',()=>{historyBar.style.opacity='0.5';});
document.body.appendChild(historyBar);
}
function showHistoryBar(){
if(!historyButtonsEnabled)return;
if(!historyBar)createHistoryBar();
if(!historyBar)return;
historyBar.style.display='flex';
if(historyBarHideTimer)clearTimeout(historyBarHideTimer);
historyBarHideTimer=setTimeout(()=>{
if(historyBar)historyBar.style.display='none';
historyBarHideTimer=null;
},HISTORY_BAR_HIDE_DELAY);
}
// Power button in top-right corner (follows same show/hide logic as nav button)
let powerButton=null;
let powerButtonHideTimer=null;
@@ -918,11 +981,23 @@ window.addEventListener('DOMContentLoaded',()=>{
if(navButtonEnabled){
showNavButton();
}
showHistoryBar();
// Always show power button on user interaction
showPowerButton();
}
// Back/forward buttons also appear on plain mouse movement, for screens
// with a mouse but no touch (the other buttons wait for a click/keypress).
let lastHistoryMove=0;
document.addEventListener('mousemove',()=>{
if(!historyButtonsEnabled)return;
const now=Date.now();
if(now-lastHistoryMove<300)return;
lastHistoryMove=now;
showHistoryBar();
},{passive:true,capture:true});
// Show pause button on any user interaction
const pauseButtonTriggers=['mousedown','touchstart','keydown'];
pauseButtonTriggers.forEach(eventType=>{
+15 -2
View File
@@ -42,6 +42,11 @@
# other addon's own scripts.
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
# Library Catalog addon (menus/addon_library.sh): the Koha + Quick Add Docker
# stack is deployed here from this checkout's library/ directory. Root-owned
# on purpose - it holds the generated database/admin passwords in .env.
: "${LIBRARY_DIR:=/opt/kiosk-library}"
# Offline install bundle - only present on systems installed from an ISO
# built with `iso/build-iso.sh --offline`: a local apt repository (every
# package provisioning and the CUPS addon install) plus prebuilt
@@ -74,6 +79,7 @@ INACTIVITY_TIMEOUT=120
ENABLE_PAUSE_BUTTON="true"
ENABLE_KEYBOARD_BUTTON="true"
ENABLE_NAV_BUTTON="true"
ENABLE_HISTORY_BUTTONS="false"
ENABLE_PASSWORD_PROTECTION="false"
LOCKOUT_PASSWORD=""
LOCKOUT_TIMEOUT=0
@@ -198,7 +204,7 @@ load_existing_config() {
ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin")
SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual")
local pause_btn keyboard_btn nav_btn password_enabled boot_password
local pause_btn keyboard_btn nav_btn history_btn password_enabled boot_password
pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false"
@@ -208,6 +214,9 @@ load_existing_config() {
nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false"
history_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableHistoryButtons // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$history_btn" == "true" ]] && ENABLE_HISTORY_BUTTONS="true" || ENABLE_HISTORY_BUTTONS="false"
password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false"
@@ -258,6 +267,9 @@ save_config() {
local nav_btn_json="true"
[[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false"
local history_btn_json="false"
[[ "$ENABLE_HISTORY_BUTTONS" == "true" ]] && history_btn_json="true"
local password_json="false"
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
@@ -296,6 +308,7 @@ save_config() {
--argjson enablePauseButton "$pause_btn_json" \
--argjson enableKeyboardButton "$keyboard_btn_json" \
--argjson enableNavButton "$nav_btn_json" \
--argjson enableHistoryButtons "$history_btn_json" \
--argjson enablePasswordProtection "$password_json" \
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
@@ -309,7 +322,7 @@ save_config() {
--arg autheliaURL "${AUTHELIA_URL:-}" \
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enableHistoryButtons:$enableHistoryButtons,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
if [[ ${#URLS[@]} -gt 0 ]]; then
for idx in "${!URLS[@]}"; do
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
################################################################################
# lib/docker.sh - Docker / Compose tooling shared by every addon that runs a
# containerised service (the Library Catalog addon is the first).
#
# Everything here goes through `sudo docker ...`: the kiosk admin runs
# install.sh as a regular user and is deliberately NOT put in the `docker`
# group (docker-group membership is root-equivalent).
#
# Depends on: lib/menu.sh (log_* helpers) being sourced first.
################################################################################
# Overridable so tests can substitute stubs for the real binaries.
: "${DOCKER_BIN:=docker}"
docker_is_installed() {
command -v "$DOCKER_BIN" >/dev/null 2>&1 \
&& sudo "$DOCKER_BIN" compose version >/dev/null 2>&1
}
docker_daemon_running() {
sudo "$DOCKER_BIN" info >/dev/null 2>&1
}
# Install Docker Engine + the Compose plugin if they are missing.
#
# Ubuntu 24.04+ ships both in its own archive (docker.io, docker-compose-v2):
# preferred because it also works through the offline apt bundle's mirror and
# gets security updates with everything else. On releases without
# docker-compose-v2 (22.04), fall back to Docker's official install script.
docker_ensure_installed() {
if docker_is_installed; then
docker_daemon_running || sudo systemctl enable --now docker >/dev/null 2>&1 || true
log_success "Docker $(sudo "$DOCKER_BIN" --version 2>/dev/null | awk '{print $3}' | tr -d ,) is installed"
return 0
fi
log_info "Installing Docker..."
if ! sudo apt-get update -qq; then
log_error "apt update failed - check the network/package sources and try again"
return 1
fi
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 \
|| { log_error "Installing docker.io / docker-compose-v2 failed"; return 1; }
else
log_info "This Ubuntu release has no docker-compose-v2 package - using Docker's install script"
have_internet 2>/dev/null || { log_error "No internet connection - can't download Docker"; return 1; }
local script
script=$(mktemp)
if ! curl -fsSL https://get.docker.com -o "$script"; then
rm -f "$script"
log_error "Could not download Docker's install script"
return 1
fi
sudo sh "$script" || { rm -f "$script"; log_error "Docker's install script failed"; return 1; }
rm -f "$script"
fi
sudo systemctl enable --now docker >/dev/null 2>&1 || true
if ! docker_is_installed || ! docker_daemon_running; then
log_error "Docker was installed but is not working - check: sudo systemctl status docker"
return 1
fi
log_success "Docker installed"
}
# docker_compose DIR args... -> sudo docker compose --project-directory DIR args...
docker_compose() {
local dir="$1"; shift
sudo "$DOCKER_BIN" compose --project-directory "$dir" "$@"
}
# docker_container_state NAME -> running | exited | restarting | ... | missing
docker_container_state() {
sudo "$DOCKER_BIN" inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo "missing"
}
# docker_container_health NAME -> healthy | unhealthy | starting | none | missing
docker_container_health() {
sudo "$DOCKER_BIN" inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$1" 2>/dev/null \
|| echo "missing"
}
# docker_wait_http URL [TIMEOUT_SECONDS]
# Polls until URL answers 2xx/3xx; returns 1 on timeout.
docker_wait_http() {
local url="$1" timeout="${2:-300}" waited=0
while (( waited < timeout )); do
if curl -fsS -o /dev/null -m 5 "$url" 2>/dev/null; then
return 0
fi
sleep 5
waited=$((waited + 5))
done
return 1
}
# docker_free_port START -> prints the first TCP port >= START nothing listens on.
docker_free_port() {
local port="$1"
while ss -tlnH "sport = :${port}" 2>/dev/null | grep -q .; do
port=$((port + 1))
done
echo "$port"
}
+5
View File
@@ -0,0 +1,5 @@
# Generated by install.sh - contains passwords
.env
# Extra CA certificates dropped in for building behind a proxy
koha/certs/*.crt
quickadd/certs/*.crt
+198
View File
@@ -0,0 +1,198 @@
# Home library: Koha + Quick Add (Docker)
A self-hosted library catalogue you can set up in one command:
```bash
sudo ./install.sh
```
It installs Docker if it is missing, generates the passwords, builds the images,
starts everything and prints the addresses and your login. Running it again is safe;
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 |
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`.
## What it is made of
* **Koha**: the open-source library system, built from the Koha project's own Debian
packages (no third-party Koha images). Installed and configured automatically; there is
no web installer to click through.
* **Quick Add** (`quickadd/`): a small page that turns "scan an ISBN" into "book added".
* **MariaDB**: the database.
Needs about 2 GB of RAM and 3 GB of disk. The first build takes 5-10 minutes.
## Adding books
Open **Add books** and scan (or type) an ISBN, then Enter. A USB or Bluetooth barcode
scanner works as a keyboard, so just scan.
1. The ISBN is looked up on the Library of Congress over **Z39.50**. If that server cannot
be reached (many networks block port 210), **Open Library** over HTTPS is used instead.
2. If you already have the title, you are asked whether to add **another copy**.
3. Otherwise the record is added with a barcode (see **Barcodes** below), the Dewey (or LC) call
number from the record, the item type chosen at the top (Book, Movie, Music, Game) and the shelf
you picked.
4. Nothing found? A short form lets you type the title and it is added anyway.
5. **Undo** removes an item (and the record, if it was the only copy). Freed barcode numbers
are never reused, because a label may already be printed.
DVDs and CDs work by scanning the **UPC** under the barcode; they are matched against what you
own the same way ISBNs are. Few UPCs are in the free catalogues, so expect to type a title
for many of them.
The page beeps: high for added, double for a duplicate, low for not found. You can scan a
stack of books without looking at the screen.
### Scanning with a phone camera
Open the Add books page in your phone's browser and tap **Scan with camera**. Your phone's own camera app
opens; take a photo with the barcode filling the frame, and the book is looked up and added just as if you had
typed it. In "scan a pre-printed sticker" mode the same button appears on the sticker step.
* It works over plain HTTP on both iPhone and Android: the photo is taken by the phone's camera app, shrunk on
the phone, and read on the server (`zbarimg`). Only JPEG and PNG photos are accepted.
* It reads ISBN-13 and ISBN-10 barcodes, and UPC/EAN codes on discs and games.
* Tips: get close so the barcode fills most of the frame, keep it flat and well lit, and hold steady. If it
can't read the photo you are told, and nothing is added.
* A live viewfinder that reads continuously would need HTTPS (browsers only allow that for secure pages); a
Bluetooth scanner paired to the phone also works, since it types into the page.
## Barcodes: your choice
Barcodes are optional. Open **Barcodes and settings** at the bottom of the Add books page:
| Choice | What happens |
|---|---|
| **Number them automatically** (default) | Each new item gets the next number, zero-padded to 7 digits (`0000042`). |
| **I stick a pre-printed barcode on each one** | After the ISBN is recognised you scan the sticker on the book. A sticker that is already in use is refused. |
| **No barcodes for now** | Items are added without one; the catalogue still shows them. |
Later, in the same panel:
* **Give them barcodes** numbers every item that has none, in one go, and shows the range to print.
* **Print a sheet of barcodes ahead of time** reserves a block (say 80 numbers), and the link opens
Koha's label printer with that range filled in. Print the sheet, and stick the labels on whenever you like.
Reserved numbers are never handed out again.
Good to know:
* Koha's range printer needs both ends to be the same length, so barcodes are fixed-width digits. They are
**at least 7 digits** on purpose: 6-digit all-number Code 39 barcodes can be misread as the pharmacy
"Code 32" format by some scanners (checked with a decoder: 7 digits read back perfectly, 6 did not).
* Prefixes (such as `HOME-0042`) are not offered: Koha's range printer cannot print them.
* Barcode types Koha can print: Code 39 (default, read by every scanner), Code 39 with a Modulo-43 or
Modulo-10 check digit (needs a scanner set up for check digits), EAN-13 (exactly 12-13 digits),
COOP 2 of 5 and Industrial 2 of 5. Change it under *Cataloging → Label creator → Label layouts*.
* Koha's own automatic barcode is switched off so there is one numbering scheme. Items added in the staff
interface have no barcode until you press **Give them barcodes**.
## Printing labels
From the Add books page, **Print labels** opens Koha's label printer with the barcode range of what you
just added filled in (you may be asked to log in to the staff interface). Pick a template and layout
and print at 100% scale (not "fit to page") so the labels line up.
| Template | Sheet |
|---|---|
| Avery 5160, 1 x 2-5/8 | 30 per sheet; spine label + barcode |
| Avery 5167, 1/2 x 1-3/4 | 80 per sheet; barcode only |
| Demco spine, 1 x 1.5 | 50 per sheet; call number on up to four lines |
| Layout | Prints |
|---|---|
| Spine + barcode | call number (split over lines) with the barcode below |
| Spine - call number | call number only |
| Barcode only | barcode |
## People and lending
Lending is set up for a home library: 3-week loans, up to 25 items, 3 renewals, **no fines**.
Change it under *Administration → Circulation and fines rules*.
1. **Add a person**: staff interface → *Patrons → New patron → Friend / borrower*. Only the name is
required; the **library card number is assigned automatically** (`1000001`, `1000002`, ...). Numbers
start well away from item barcodes so a scan can never mean both.
2. **Print the card**: *Tools → Patron card creator → New batch*, add the patron's number, *Export card
batch*, template **Avery 5371**, layout **Library card**. The card carries the name and a scannable barcode.
3. **Check out**: *Circulation → Check out*. Scan the person's card, then scan each book. The due date appears
on screen.
4. **Check in**: *Circulation → Check in*, scan the book.
A USB or Bluetooth scanner types the number and presses Enter, so it works in all of these boxes.
## Finding the books
The public catalogue searches title, author, subject and ISBN, and shows each copy's
availability, call number and **shelving location**.
On the Add books page, pick the **Shelf / room** before you scan; every book you add
gets it. The choice is saved on the server, so every device shares it. If all your books are in one
place, leave it on *(not set / all in one place)*; nothing is shown and nothing needs changing. Change it
whenever you move to another shelf.
The list comes from the staff interface (*Administration → Authorized values → LOC*), which
starts with a few rooms such as Fiction, General stacks and Reference. Add your own there,
for example "Living room - bookcase 2". (Koha has no way to add a room from the Add books page.)
## Common tasks
```bash
sudo ./install.sh --status # addresses, login, container health
sudo ./install.sh --update # rebuild with the latest Koha release (your data is kept)
sudo ./install.sh --stop # stop everything
sudo ./install.sh --start # start again
sudo docker logs -f library-koha
```
Settings live in `.env` (created on first run, mode 600). Useful ones:
| Setting | Meaning |
|---|---|
| `BIND_ADDR` | `0.0.0.0` (default) reachable from the network, `127.0.0.1` this machine only. Docker bypasses `ufw`, so this is the setting that actually limits access |
| `OPAC_PORT`, `STAFF_PORT`, `QUICKADD_PORT` | published ports |
| `QUICKADD_SOURCES` | lookup order, e.g. `z3950:lx2.loc.gov:210/LCDB,openlibrary`. Add more `z3950:host:port/database` entries |
| `LIBRARY_NAME` | name shown on the catalogue (first install only) |
After editing `.env` run `sudo docker compose up -d`.
## Behind a proxy or on a locked-down network
* Building behind a TLS-inspecting proxy: `sudo EXTRA_CA=/path/to/ca.crt ./install.sh`.
* Docker Hub limits anonymous pulls (HTTP 429). The installer retries with a growing pause.
* Z39.50 needs outbound TCP port 210. Without it, Open Library is used automatically.
## Backups
Your books live in two Docker volumes: `library_db-data` (the catalogue) and
`library_koha-data` (search index, uploads). Back up the database with:
```bash
sudo docker exec library-db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction koha_library' > library-backup.sql
```
The search index is rebuilt automatically if it is lost.
## Security notes
* The staff interface and Quick Add are protected by the generated password, over plain HTTP.
Keep them on a trusted network, or set `BIND_ADDR=127.0.0.1` and put a reverse proxy with
HTTPS in front.
* Quick Add uses the staff `admin` login to talk to Koha's API.
## Layout
```
install.sh one-command setup and --status/--update/--stop/--start
docker-compose.yml db + koha + quickadd
koha/ Dockerfile (Koha from the project's Debian repo), entrypoint,
bootstrap.pl (headless install), seed/ (item types, Z39.50
servers, label templates/layouts)
quickadd/ the scan-to-add service (Python standard library + yaz)
```
+78
View File
@@ -0,0 +1,78 @@
name: library
services:
db:
image: mariadb:10.11
container_name: library-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: koha_library
MARIADB_USER: koha_library
MARIADB_PASSWORD: ${DB_PASSWORD}
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--innodb-buffer-pool-size=128M"]
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 20
koha:
build:
context: ./koha
args:
KOHA_SUITE: ${KOHA_SUITE:-stable}
image: library-koha:local
container_name: library-koha
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
TZ: ${TZ:-UTC}
KOHA_DB_HOST: db
KOHA_DB_PASS: ${DB_PASSWORD}
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
KOHA_ADMIN_PASS: ${ADMIN_PASSWORD}
KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library}
KOHA_LIBRARY_CODE: ${LIBRARY_CODE:-HOME}
KOHA_SHELVES: ${SHELVES:-}
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
ports:
- "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
volumes:
- koha-data:/var/lib/koha
# Scan an ISBN -> the book is added (Z39.50 lookup, then Koha's REST API).
quickadd:
build:
context: ./quickadd
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
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
OPAC_PORT: ${OPAC_PORT:-8080}
STAFF_PORT: ${STAFF_PORT:-8081}
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"
volumes:
- quickadd-data:/data
volumes:
db-data:
koha-data:
quickadd-data:
+168
View File
@@ -0,0 +1,168 @@
#!/bin/bash
# One-command setup for a self-hosted home library (Koha) on Ubuntu/Debian.
#
# sudo ./install.sh # install Docker if needed, build, start
# sudo ./install.sh --status # show URLs and container health
# sudo ./install.sh --update # rebuild with the latest Koha and restart
# sudo ./install.sh --stop | --start
#
# Optional environment variables:
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue
# 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)
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
c_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
c_ok() { printf '\033[0;32m[ OK ]\033[0m %s\n' "$*"; }
c_warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
c_err() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; }
[ "$(id -u)" = 0 ] || { c_err "Run with sudo: sudo $0 $*"; exit 1; }
RUN_USER="${SUDO_USER:-root}"
# --- Docker -------------------------------------------------------------------
ensure_docker() {
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
c_ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) with Compose is installed"
else
c_info "Installing Docker (official get.docker.com script)..."
command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq curl ca-certificates; }
curl -fsSL https://get.docker.com | sh
docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin
c_ok "Docker installed"
fi
systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$RUN_USER" != root ] && ! id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker; then
usermod -aG docker "$RUN_USER" && c_info "Added $RUN_USER to the docker group (log out/in to use docker without sudo)"
fi
docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; }
}
port_in_use() {
if command -v ss >/dev/null 2>&1; then
ss -tlnH "sport = :$1" 2>/dev/null | grep -q .
else # minimal systems without iproute2
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
fi
}
free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; }
rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; }
lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; }
dc() { docker compose --project-directory "$HERE" "$@"; }
# Docker Hub rate-limits anonymous pulls (HTTP 429), which is common on shared
# IPs; retry with a growing pause instead of failing the whole install.
with_retry() {
local n=1 max=5 wait=15
until "$@"; do
[ "$n" -ge "$max" ] && { c_err "Failed after $max attempts: $*"; return 1; }
c_warn "Attempt $n failed (Docker Hub rate limit or network?) - retrying in ${wait}s..."
sleep "$wait"; n=$((n + 1)); wait=$((wait * 2))
done
}
show_status() {
[ -f .env ] || { c_err "Not installed yet - run: sudo ./install.sh"; exit 1; }
# shellcheck disable=SC1091
. ./.env
local ip; ip="$(lan_ip)"; ip="${ip:-localhost}"
if [ "${BIND_ADDR:-0.0.0.0}" = "127.0.0.1" ]; then
ip="localhost"
echo " (Limited to this machine: BIND_ADDR=127.0.0.1 in .env)"
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
}
case "${1:-}" in
--status) show_status; exit 0 ;;
--stop) dc stop; exit 0 ;;
--start) dc start; exit 0 ;;
--update)
ensure_docker
with_retry dc build --pull --no-cache koha
dc up -d
show_status; exit 0 ;;
"" ) ;;
*) c_err "Unknown option: $1"; exit 1 ;;
esac
ensure_docker
# --- Settings (.env is created once and never overwritten) ------------------------
if [ ! -f .env ]; then
c_info "Generating settings and passwords..."
TZ_NOW="$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo UTC)"
umask 077
cat > .env << ENVEOF
# Generated by install.sh - keep this file private.
LIBRARY_NAME="${LIBRARY_NAME:-My Library}"
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}")
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
ADMIN_USER=admin
ADMIN_PASSWORD=$(rand 16)
DB_ROOT_PASSWORD=$(rand 32)
DB_PASSWORD=$(rand 32)
KOHA_SUITE=stable
ENVEOF
umask 022
[ "$RUN_USER" = root ] || chown "$RUN_USER" .env
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
fi
if ! grep -q '^BIND_ADDR=' .env; then
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
fi
fi
if [ -n "${EXTRA_CA:-}" ]; then
cp "$EXTRA_CA" koha/certs/extra-ca.crt
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
c_info "Trusting extra CA during the build: $EXTRA_CA"
fi
# --- Build and start ---------------------------------------------------------------
c_info "Building the Koha image (first time: 5-10 minutes, downloads ~1 GB)..."
with_retry dc build
c_info "Starting..."
with_retry dc up -d
# --- Wait until the site answers ---------------------------------------------------
# shellcheck disable=SC1091
. ./.env
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
# 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
fi
sleep 5
done
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
c_ok "Your library is running."
echo
show_status
+50
View File
@@ -0,0 +1,50 @@
# Koha (open-source library system) built from the Koha project's own Debian
# packages - no third-party Koha images involved.
#
# KOHA_SUITE stable (default) | oldstable | unstable
# KEEP_TRANSLATIONS no (default) trims ~650 MB of non-English .po files;
# set "yes" if you want to install other languages later
FROM debian:bookworm-slim
ARG KOHA_SUITE=stable
ARG KEEP_TRANSLATIONS=no
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg tzdata
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
# you are behind a TLS-inspecting proxy. Empty by default.
COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates
# Each step stands alone and fails the build on error: a swallowed failure here once produced a
# "successful" image with no Koha in it. memcached and cron are named explicitly rather than
# trusted to arrive as dependencies of koha-common.
RUN curl -fsSL https://debian.koha-community.org/koha/gpg.asc | gpg --dearmor -o /usr/share/keyrings/koha.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/koha.gpg] https://debian.koha-community.org/koha ${KOHA_SUITE} main bookworm" \
> /etc/apt/sources.list.d/koha.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends koha-common mariadb-client memcached cron
RUN a2enmod rewrite headers proxy_http cgi deflate >/dev/null \
&& (a2dissite 000-default >/dev/null 2>&1 || true)
RUN if [ "$KEEP_TRANSLATIONS" != "yes" ]; then rm -rf /usr/share/koha/misc/translator/po; fi \
&& rm -rf /var/lib/apt/lists/* /usr/share/doc/*
# Refuse to produce an image that cannot run.
RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-schema apache2ctl memcached cron mariadb zebrasrv; do \
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
done
COPY bootstrap.pl entrypoint.sh /opt/koha-init/
COPY seed/ /opt/koha-init/seed/
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
EXPOSE 80 8081
VOLUME ["/var/lib/koha"]
HEALTHCHECK --interval=10s --timeout=10s --start-period=300s --retries=6 \
CMD test -f /run/koha-ready && curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1
ENTRYPOINT ["/opt/koha-init/entrypoint.sh"]
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/perl
# Headless replacement for Koha's web installer + onboarding wizard.
# Run as the instance user: koha-shell -c "perl bootstrap.pl" <instance>
#
# Mirrors what /cgi-bin/koha/installer/install.pl does step by step (so a
# future Koha upgrade keeps working), then seeds a home-library setup.
use Modern::Perl;
use C4::Context;
use C4::Installer;
use Koha;
use Koha::Libraries;
use Koha::Patrons;
use Koha::Patron::Categories;
use Koha::CirculationRules;
use Koha::ItemTypes;
use Koha::AuthorisedValues;
$| = 1;
my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed';
my $admin_pass = $ENV{KOHA_ADMIN_PASS};
die "KOHA_ADMIN_PASS not set\n" unless defined $admin_pass && length $admin_pass;
my %env = (
admin_user => $ENV{KOHA_ADMIN_USER} || 'admin',
admin_pass => $admin_pass,
library => $ENV{KOHA_LIBRARY_NAME} || 'My Library',
code => uc( ( $ENV{KOHA_LIBRARY_CODE} || 'HOME' ) =~ s/[^A-Za-z0-9]//gr ) || 'HOME',
);
my $installer = C4::Installer->new();
my $dbh = C4::Context->dbh;
my $intranet = C4::Context->config('intranetdir');
my $data = "$intranet/installer/data/mysql";
# Already installed? (the entrypoint checks too; this is a safety net)
my ($tables) = $dbh->selectrow_array(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()");
if ($tables) { say "bootstrap: database already has $tables tables - nothing to do."; exit 0; }
say "bootstrap: loading database schema...";
if ( my $err = $installer->load_db_schema() ) { die "schema load failed: $err\n"; }
$installer->set_marcflavour_syspref('marc21');
# Same ordering rules the web installer applies (sysprefs first, userflags last).
my @files = (
sort( glob("$data/en/mandatory/*.sql"), glob("$data/en/mandatory/*.yml") ),
sort( glob("$data/en/marcflavour/marc21/mandatory/*.yml"), glob("$data/en/marcflavour/marc21/mandatory/*.sql") ),
);
# Optional data worth having: matching rules, "Fast add" framework (a short
# cataloguing form - title/author/ISBN/call number/barcode), MARC-coded values.
for my $f (qw(
marcflavour/marc21/optional/marc21_default_matching_rules.yml
marcflavour/marc21/optional/marc21_sample_fastadd_framework.yml
optional/marc21_holdings_coded_values.yml
optional/marc21_relatorterms.yml
optional/auth_val.yml
optional/csv_profiles.yml
)) {
push @files, "$data/en/$f" if -f "$data/en/$f";
}
say "bootstrap: loading ", scalar(@files), " framework / data files...";
my ( undef, $list ) = $installer->load_sql_in_order( 'en', [], @files );
for my $lvl (@$list) {
for my $f ( @{ $lvl->{fwklist} } ) {
say " ! $f->{fwkname}: $f->{error}" if $f->{error};
}
}
$installer->set_version_syspref();
$installer->set_languages_syspref('en');
# ---- Home-library seed data (YAML in the same format Koha ships) ----------
for my $y (sort glob("$seed_dir/*.yml")) {
my $err = $installer->load_sql($y);
say " seed $y: ", ( $err ? "ERROR $err" : 'ok' );
}
# ---- Library, patron categories, owner account ----------------------------
Koha::Library->new({
branchcode => $env{code}, branchname => $env{library},
})->store unless Koha::Libraries->find( $env{code} );
for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) {
Koha::Patron::Category->new({
categorycode => $c->[0], description => $c->[1], category_type => $c->[2],
enrolmentperiod => 999, overduenoticerequired => 0,
})->store unless Koha::Patron::Categories->find( $c->[0] );
}
my $admin = Koha::Patron->new({
cardnumber => '1000000', surname => 'Librarian', userid => $env{admin_user},
branchcode => $env{code}, categorycode => 'OWNER', flags => 1, # 1 = superlibrarian
})->store;
$admin->set_password({ password => $env{admin_pass}, skip_validation => 1 });
# ---- Make "add a book" need as few decisions as possible -------------------
# The item type (942$c) is mandatory in Koha's frameworks; default it to Book so
# a Z39.50 import can be saved without touching that field.
$dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK'
WHERE tagfield = '942' AND tagsubfield = 'c'});
# UPC/EAN barcodes (DVDs, CDs, games) live in 024$a, which the stock framework
# leaves unmapped - so Koha can neither search nor de-duplicate on them. Map it
# to the catalogue's `ean` column (used by the quick-add page's duplicate check).
$dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean'
WHERE tagfield = '024' AND tagsubfield = 'a' AND (kohafield IS NULL OR kohafield = '')});
# ---- Extra item types and shelves chosen at install time -------------------
# KOHA_ITEM_TYPES / KOHA_SHELVES: "CODE=Description;CODE=Description". Koha has no
# API for adding shelves later, so they are declared here (more can be added in
# Administration -> Authorized values -> LOC).
sub pairs {
my ($raw) = @_;
return map { my ( $c, $d ) = split /=/, $_, 2; $c =~ s/[^A-Za-z0-9_]//g; ( uc $c, ( $d // $c ) =~ s/^\s+|\s+$//gr ) }
grep { /\S/ } split /;/, ( $raw // '' );
}
{
my @t = pairs( $ENV{KOHA_ITEM_TYPES} );
while ( my ( $c, $d ) = splice @t, 0, 2 ) {
next unless length $c;
Koha::ItemType->new( { itemtype => substr( $c, 0, 10 ), description => $d } )->store
unless Koha::ItemTypes->find( substr( $c, 0, 10 ) );
}
my @s = pairs( $ENV{KOHA_SHELVES} );
while ( my ( $c, $d ) = splice @s, 0, 2 ) {
next unless length $c;
Koha::AuthorisedValue->new( { category => 'LOC', authorised_value => substr( $c, 0, 80 ), lib => $d, lib_opac => $d } )->store
unless Koha::AuthorisedValues->search( { category => 'LOC', authorised_value => substr( $c, 0, 80 ) } )->count;
}
}
# ---- Lending rules --------------------------------------------------------
# A fresh Koha refuses every checkout ("No circulation rule is defined for this
# patron and itemtype combination") until a default rule exists. One catch-all
# rule for a home library: 3 weeks, 25 items, 3 renewals, no fines.
Koha::CirculationRules->set_rules({
branchcode => undef, categorycode => undef, itemtype => undef,
rules => {
issuelength => 21, lengthunit => 'days', maxissueqty => 25, maxonsiteissueqty => '',
renewalsallowed => 3, renewalperiod => 21, norenewalbefore => '', noautorenewalbefore => '',
auto_renew => 0, no_auto_renewal_after => '', no_auto_renewal_after_hard_limit => '',
unseen_renewals_allowed => '',
fine => 0, finedays => 0, firstremind => 0, chargeperiod => 0, chargeperiod_charge_at => 0,
overduefinescap => '', cap_fine_to_replacement_price => 0, rentaldiscount => 0,
maxsuspensiondays => '', suspension_chargeperiod => 1,
hardduedate => '', hardduedatecompare => -1,
reservesallowed => 5, holds_per_record => 1, holds_per_day => '', onshelfholds => 1,
opacitemholds => 'N', decreaseloanholds => '', article_requests => 'no',
recalls_allowed => undef, recalls_per_record => undef, on_shelf_recalls => undef,
recall_due_date_interval => undef, recall_overdue_fine => undef,
recall_shelf_time => undef, holds_pickup_period => undef,
},
});
# ---- Preferences ----------------------------------------------------------
my %prefs = (
autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request)
autoMemberNum => 1, # new patrons get the next library-card number automatically
finesMode => 'off', # a home library does not charge overdue fines
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
RESTBasicAuth => 1, # lets the quick-add page use the REST API
OpenLibraryCovers => 1, # cover images in the catalogue
OPACSearchForTitleIn => '',
BiblioDefaultView => 'normal',
OpacPublic => 1,
opacuserlogin => 0, # catalogue is browse-only; no patron logins needed
OpacMaintenance => 0,
ExpireReservesMaxPickUpDelay => 0,
AllowOfflineCirculation => 0,
marcflavour => 'MARC21',
LibraryName => $env{library},
timezone => $ENV{TZ} || 'UTC',
);
C4::Context->set_preference( $_, $prefs{$_} ) for sort keys %prefs;
say "bootstrap: done. Staff login: $env{admin_user}";
View File
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# Container entrypoint: (re)creates the Koha instance config from environment
# variables on every start, installs the database on the first start, then runs
# Apache in the foreground. All durable state lives in MariaDB and /var/lib/koha.
set -euo pipefail
INST="${KOHA_INSTANCE:-library}"
DB_HOST="${KOHA_DB_HOST:-db}"
DB_NAME="${KOHA_DB_NAME:-koha_library}"
DB_USER="${KOHA_DB_USER:-koha_library}"
: "${KOHA_DB_PASS:?KOHA_DB_PASS is required}"
: "${KOHA_ADMIN_PASS:?KOHA_ADMIN_PASS is required}"
STAFF_PORT=8081
export TZ="${TZ:-UTC}"
log() { echo "[koha-init] $*"; }
# Marker the container healthcheck waits for. Before the database exists Koha
# serves its web-installer page with HTTP 200, so "port answers" is not "ready".
rm -f /run/koha-ready
# --- 1. memcached (Koha caches sysprefs and sessions in it) -----------------
memcached -u memcache -m 64 -d
cron
# --- 2. wait for MariaDB ----------------------------------------------------
log "waiting for database at $DB_HOST..."
for i in $(seq 1 120); do
mariadb -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" -e 'select 1' >/dev/null 2>&1 && break
[ "$i" = 120 ] && { log "database never became ready"; exit 1; }
sleep 2
done
# --- 3. instance config (regenerated each time a container is created) ------
if [ ! -d "/etc/koha/sites/$INST" ]; then
log "creating Koha instance '$INST'..."
sed -i \
-e 's|^DOMAIN=.*|DOMAIN=""|' \
-e 's|^INTRASUFFIX=.*|INTRASUFFIX=""|' \
-e "s|^INTRAPORT=.*|INTRAPORT=\"$STAFF_PORT\"|" \
/etc/koha/koha-sites.conf
echo "$INST:$DB_USER:$KOHA_DB_PASS:$DB_NAME:$DB_HOST" > /etc/koha/passwd
koha-create --use-db --dbhost "$DB_HOST" --timezone "$TZ" \
--memcached-servers 127.0.0.1:11211 "$INST" 2>&1 | grep -v AH00558 || true
# No RabbitMQ in this stack: workers fall back to polling the database.
CONF="/etc/koha/sites/$INST/koha-conf.xml"
perl -0pi -e 's|\s*<message_broker>.*?</message_broker>||s' "$CONF"
# Serve both interfaces through Plack (much faster than plain CGI).
SITE="/etc/apache2/sites-available/$INST.conf"
# The *-plack.conf files add the proxy rules on top of the plain ones (which
# still provide static files and rewrites), so enable them in addition.
sed -i \
-e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf| Include /etc/koha/apache-shared-opac-plack.conf|' \
-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
# 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).
# koha-create insists on mpm_itk being enabled, but inside a container it can
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every
# page). Now that the instance exists, swap to plain prefork.
rm -f /etc/apache2/mods-enabled/mpm_itk.*
a2enmod mpm_prefork >/dev/null 2>&1 || true
sed -i '/AssignUserID/d' "$SITE"
sed -i -e "s/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER=$INST-koha/" \
-e "s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP=$INST-koha/" /etc/apache2/envvars
grep -q "^Listen $STAFF_PORT\b" /etc/apache2/ports.conf || echo "Listen $STAFF_PORT" >> /etc/apache2/ports.conf
grep -q '^ServerName' /etc/apache2/apache2.conf || echo 'ServerName localhost' >> /etc/apache2/apache2.conf
fi
chown -R "$INST-koha:$INST-koha" "/var/lib/koha/$INST" "/var/log/koha/$INST" 2>/dev/null || true
# --- 4. install or upgrade the database -------------------------------------
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
if [ "$TABLES" = "0" ]; then
log "first start: installing Koha (about a minute)..."
# (not koha-shell -c: it wraps the command in single quotes of its own)
runuser -u "$INST-koha" -- env \
KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
KOHA_ADMIN_USER="${KOHA_ADMIN_USER:-admin}" KOHA_ADMIN_PASS="$KOHA_ADMIN_PASS" \
KOHA_LIBRARY_NAME="${KOHA_LIBRARY_NAME:-My Library}" KOHA_LIBRARY_CODE="${KOHA_LIBRARY_CODE:-HOME}" \
KOHA_SHELVES="${KOHA_SHELVES:-}" KOHA_ITEM_TYPES="${KOHA_ITEM_TYPES:-}" TZ="$TZ" \
perl /opt/koha-init/bootstrap.pl
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
[ "$TABLES" -gt 0 ] || { log "ERROR: installation did not create the database"; exit 1; }
FIRST_RUN=1
else
log "existing database found - applying any pending upgrades..."
koha-upgrade-schema "$INST" || log "WARNING: koha-upgrade-schema reported a problem (see output above)"
FIRST_RUN=0
fi
# --- 5. background services (their `daemon` processes die with the container,
# so they are started explicitly on every start) ------------------------
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
log "building search index..."
koha-rebuild-zebra -f -v "$INST" >/dev/null 2>&1 || true
fi
for svc in koha-zebra koha-indexer koha-worker koha-plack; do
"$svc" --restart "$INST" >/dev/null 2>&1 || "$svc" --start "$INST" >/dev/null 2>&1 \
|| log "WARNING: could not start $svc"
done
# koha-create / koha-plack may have started Apache already; run it in the foreground instead.
apache2ctl -k stop >/dev/null 2>&1 || true
for i in 1 2 3 4 5; do pgrep -x apache2 >/dev/null || break; sleep 1; done
rm -f /var/run/apache2/apache2.pid
touch /run/koha-ready
log "ready - library catalogue on :80, staff interface on :$STAFF_PORT"
exec apache2ctl -D FOREGROUND
+13
View File
@@ -0,0 +1,13 @@
---
description:
- "Item types for a home library: books, movies, music, games"
tables:
- itemtypes:
translatable: [ description ]
multiline: []
rows:
- { itemtype: BOOK, description: "Book", rentalcharge: 0, notforloan: 0, imageurl: "bridge/book.png", summary: "" }
- { itemtype: DVD, description: "Movie (DVD/Blu-ray)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/dvd.png", summary: "" }
- { itemtype: CD, description: "Music (CD/vinyl)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/sound.png", summary: "" }
- { itemtype: GAME, description: "Game", rentalcharge: 0, notforloan: 0, imageurl: "bridge/computer_file.png", summary: "" }
- { itemtype: OTHER, description: "Other", rentalcharge: 0, notforloan: 0, imageurl: "bridge/kit.png", summary: "" }
+9
View File
@@ -0,0 +1,9 @@
---
description:
- "Public Z39.50 servers used by Cataloging > Z39.50/SRU search"
tables:
- z3950servers:
translatable: [ servername ]
multiline: []
rows:
- { host: "lx2.loc.gov", port: 210, db: LCDB, userid: "", password: "", servername: "LIBRARY OF CONGRESS", checked: 1, rank: 1, syntax: USMARC, encoding: utf8, recordtype: biblio }
+116
View File
@@ -0,0 +1,116 @@
---
description:
- "Label sheets and layouts for Tools > Labels (spine labels and barcodes)"
tables:
- creator_templates:
translatable: [ template_desc ]
multiline: [ ]
rows:
# Avery 5160 / 8160 - the common 30-up address sheet; fits a spine label
# plus barcode on one label.
- template_code: "Avery 5160 | 1 x 2-5/8 (30 per sheet)"
template_desc: "3 columns, 10 rows. Spine label with barcode."
page_width: 8.5
page_height: 11
label_width: 2.63
label_height: 1
top_text_margin: 0.139
left_text_margin: 0.1
top_margin: 0.35
left_margin: 0.23
cols: 3
rows: 10
col_gap: 0.13
row_gap: 0
units: "INCH"
creator: "Labels"
# Avery 5167 / 8167 - 80-up small labels, good for barcode-only stickers.
- template_code: "Avery 5167 | 1/2 x 1-3/4 (80 per sheet)"
template_desc: "4 columns, 20 rows. Barcode-only stickers."
page_width: 8.5
page_height: 11
label_width: 1.75
label_height: 0.5
top_text_margin: 0.1
left_text_margin: 0.05
top_margin: 0.5
left_margin: 0.3
cols: 4
rows: 20
col_gap: 0.3
row_gap: 0
units: "INCH"
creator: "Labels"
# Demco 1" x 1.5" spine labels (Koha's own sample measurements).
- template_code: "Demco spine | 1 x 1.5 (50 per sheet)"
template_desc: "5 columns, 10 rows. Dewey / call number on up to four lines."
page_width: 8.5
page_height: 11
label_width: 1.5
label_height: 1
top_text_margin: 0.236
left_text_margin: 0
top_margin: 0.5
left_margin: 0.25
cols: 5
rows: 10
col_gap: 0.0625
row_gap: 0
units: "INCH"
creator: "Labels"
- creator_layouts:
translatable: [ layout_name, layout_xml ]
multiline: [ layout_xml ]
rows:
# callnum_split splits "613.2 SMI" onto separate lines the way spine
# labels are normally read (Dewey/LC aware).
- layout_name: "Spine - call number"
barcode_type: CODE39
start_label: 1
printing_type: BIB
guidebox: 0
oblique_title: 0
font: TB
font_size: 11
units: POINT
callnum_split: 1
text_justify: C
format_string: "itemcallnumber"
layout_xml:
- "<opt></opt>"
creator: "Labels"
- layout_name: "Spine + barcode"
barcode_type: CODE39
start_label: 1
printing_type: BIBBAR
guidebox: 0
oblique_title: 0
font: TB
font_size: 10
units: POINT
callnum_split: 1
text_justify: C
format_string: "itemcallnumber"
layout_xml:
- "<opt></opt>"
creator: "Labels"
- layout_name: "Barcode only"
barcode_type: CODE39
start_label: 1
printing_type: BAR
guidebox: 0
oblique_title: 0
font: TR
font_size: 10
units: POINT
callnum_split: 0
text_justify: L
format_string: "barcode"
layout_xml:
- "<opt></opt>"
creator: "Labels"
+52
View File
@@ -0,0 +1,52 @@
---
description:
- "Library card sheet and layout for Tools > Patron card creator"
tables:
- creator_templates:
translatable: [ template_desc ]
multiline: [ ]
rows:
# Avery 5371 / 8871 business-card sheet: 10 cards, 2 across x 5 down.
- template_code: "Avery 5371 | 2 x 3-1/2 (10 per sheet)"
template_desc: "Library cards: 2 columns, 5 rows."
page_width: 8.5
page_height: 11
label_width: 3.5
label_height: 2
top_text_margin: 0
left_text_margin: 0
top_margin: 0.5
left_margin: 0.75
cols: 2
rows: 5
col_gap: 0
row_gap: 0
units: "INCH"
creator: "Patroncards"
- creator_layouts:
translatable: [ layout_name, layout_xml ]
multiline: [ layout_xml ]
rows:
# Name and library at the top, card number as a scannable barcode.
- layout_name: "Library card"
barcode_type: CODE39
start_label: 1
printing_type: BAR
guidebox: 0
oblique_title: 0
font: TR
font_size: 10
units: POINT
callnum_split: 0
text_justify: L
format_string: "barcode"
layout_xml:
- "<opt page_side=\"F\" units=\"POINT\">"
- " <text>&lt;firstname&gt; &lt;surname&gt;</text>"
- " <text enable=\"1\" font=\"TB\" font_size=\"14\" llx=\"14\" lly=\"118\" text_alignment=\"L\" />"
- " <text>Library card</text>"
- " <text enable=\"1\" font=\"TR\" font_size=\"9\" llx=\"14\" lly=\"100\" text_alignment=\"L\" />"
- " <barcode print=\"1\" type=\"CODE39\" llx=\"14\" lly=\"30\" height_scale=\"0.012\" width_scale=\"0.6\" text_print=\"1\" />"
- "</opt>"
creator: "Patroncards"
+28
View File
@@ -0,0 +1,28 @@
# Quick Add: scan an ISBN, the book is in the catalogue. Standard-library Python
# plus yaz (the Z39.50 client). Same Debian base as the Koha image, so it adds
# one small layer instead of another base-image pull.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools tzdata \
&& rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
# you are behind a TLS-inspecting proxy. Empty by default.
COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
RUN useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data
COPY app.py index.html /app/
USER quickadd
WORKDIR /app
EXPOSE 8082
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8082/healthz', timeout=3).status==200 else 1)"
CMD ["python3", "/app/app.py"]
+847
View File
@@ -0,0 +1,847 @@
#!/usr/bin/env python3
"""Quick Add - scan an ISBN, the book is in the catalogue.
A tiny web app that sits next to Koha. For each ISBN/UPC it:
1. looks the item up (Z39.50 servers first, then Open Library over HTTPS),
2. checks Koha for an existing copy,
3. adds the record + an item (next free barcode) through Koha's REST API.
Standard library only; Z39.50 is done by shelling out to yaz-client.
"""
import base64
import hmac
import json
import os
import re
import secrets
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/")
USER = os.environ.get("ADMIN_USER", "admin")
PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME")
STAFF_PORT = os.environ.get("STAFF_PORT", "8081")
OPAC_PORT = os.environ.get("OPAC_PORT", "8080")
# Set these when the site is behind a reverse proxy (different hostnames than host:port).
OPAC_URL = os.environ.get("OPAC_URL", "").rstrip("/")
STAFF_URL = os.environ.get("STAFF_URL", "").rstrip("/")
DATA_DIR = os.environ.get("DATA_DIR", "/data")
PORT = int(os.environ.get("PORT", "8082"))
SOURCES = [s.strip() for s in os.environ.get(
"QUICKADD_SOURCES", "z3950:lx2.loc.gov:210/LCDB,openlibrary").split(",") if s.strip()]
Z3950_TIMEOUT = int(os.environ.get("Z3950_TIMEOUT", "12"))
DEFAULT_TYPES = {"BOOK": "Book", "DVD": "Movie", "CD": "Music", "GAME": "Game", "OTHER": "Other"}
NS = {"m": "http://www.loc.gov/MARC21/slim"}
ET.register_namespace("", NS["m"])
RECENT_FILE = os.path.join(DATA_DIR, "recent.json")
# ---------------------------------------------------------------- identifiers
def clean_code(raw):
code = re.sub(r"[\s-]", "", raw or "").upper()
return code if re.fullmatch(r"[0-9]{8,14}|[0-9]{9}[0-9X]", code) else None
def isbn10_to_13(i10):
body = "978" + i10[:9]
s = sum(int(c) * (1 if n % 2 == 0 else 3) for n, c in enumerate(body))
return body + str((10 - s % 10) % 10)
def isbn13_to_10(i13):
if not i13.startswith("978"):
return None
body = i13[3:12]
s = sum(int(c) * (10 - n) for n, c in enumerate(body))
chk = (11 - s % 11) % 11
return body + ("X" if chk == 10 else str(chk))
def is_isbn(code):
return len(code) == 10 or (len(code) == 13 and code[:3] in ("978", "979"))
# -------------------------------------------------------------------- Z39.50
def z3950_search(spec, code):
"""spec = host:port/db. Returns MARCXML string or None."""
host_port_db = spec
attrs = ["@attr 1=7"] if is_isbn(code) else ["@attr 1=1007"]
queries = [code]
if len(code) == 13 and is_isbn(code):
alt = isbn13_to_10(code)
if alt:
queries.append(alt)
for q in queries:
with tempfile.TemporaryDirectory() as tmp:
dump = os.path.join(tmp, "rec.mrc")
script = "format usmarc\nfind %s %s\nshow 1\nquit\n" % (attrs[0], q)
try:
out = subprocess.run(
["yaz-client", "-m", dump, host_port_db], input=script,
capture_output=True, text=True, timeout=Z3950_TIMEOUT).stdout
except (subprocess.TimeoutExpired, OSError):
return None
m = re.search(r"Number of hits: (\d+)", out)
if not m or int(m.group(1)) == 0 or not os.path.exists(dump):
if "Connection refused" in out or "Connecting...error" in out or "Unable to" in out:
return None
continue
raw = open(dump, "rb").read()
args = ["yaz-marcdump", "-o", "marcxml"]
if len(raw) > 9 and raw[9:10] == b" ": # MARC-8 -> UTF-8
args += ["-f", "MARC-8", "-t", "UTF-8", "-l", "9=97"]
res = subprocess.run(args + [dump], capture_output=True, text=True, timeout=10)
if res.returncode == 0 and "<record" in res.stdout:
return res.stdout
return None
# --------------------------------------------------------------- Open Library
def openlibrary_search(code):
if not is_isbn(code):
return None
url = ("https://openlibrary.org/api/books?bibkeys=ISBN:%s&format=json&jscmd=data"
% urllib.parse.quote(code))
try:
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "library-quickadd/1.0"}), timeout=10) as r:
data = json.load(r).get("ISBN:" + code)
except (urllib.error.URLError, OSError, ValueError):
return None
if not data or not data.get("title"):
return None
rec = ET.Element("{%s}record" % NS["m"])
ET.SubElement(rec, "{%s}leader" % NS["m"]).text = "00000nam a2200000 a 4500"
def field(tag, subs, i1=" ", i2=" "):
subs = [(c, str(v)) for c, v in subs if v]
if not subs: # an empty datafield is invalid MARC
return
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=i2)
for c, v in subs:
ET.SubElement(df, "{%s}subfield" % NS["m"], code=c).text = v
field("020", [("a", code)])
dewey = (data.get("classifications") or {}).get("dewey_decimal_class") or []
if dewey:
field("082", [("a", dewey[0])], "0", "4")
lcc = (data.get("classifications") or {}).get("lc_classifications") or []
if lcc:
field("050", [("a", lcc[0])], " ", "4")
authors = data.get("authors") or []
if authors:
field("100", [("a", authors[0].get("name"))], "1")
title = data["title"] + (": " + data["subtitle"] if data.get("subtitle") else "")
field("245", [("a", title)], "1" if authors else "0", "0")
pubs = [p.get("name") for p in data.get("publishers") or []]
field("264", [("b", pubs[0] if pubs else None), ("c", data.get("publish_date"))], " ", "1")
if data.get("number_of_pages"):
field("300", [("a", "%s pages" % data["number_of_pages"])])
for subj in (data.get("subjects") or [])[:6]:
field("650", [("a", subj.get("name"))], " ", "0")
return ET.tostring(rec, encoding="unicode")
# ----------------------------------------------------------- MARC XML helpers
def parse_marc(xml_text):
root = ET.fromstring(xml_text)
rec = root if root.tag.endswith("record") else root.find(".//m:record", NS)
return rec
def sub(rec, tag, code):
for df in rec.findall("m:datafield[@tag='%s']" % tag, NS):
for sf in df.findall("m:subfield[@code='%s']" % code, NS):
if sf.text:
return sf.text.strip()
return ""
def describe(rec):
title = " ".join(x for x in (sub(rec, "245", "a"), sub(rec, "245", "b")) if x).rstrip(" /:;,")
author = sub(rec, "100", "a").rstrip(",.") or sub(rec, "110", "a").rstrip(",.")
year = re.search(r"\d{4}", sub(rec, "264", "c") or sub(rec, "260", "c") or "")
return {"title": title or "(untitled)", "author": author, "year": year.group(0) if year else "",
"publisher": (sub(rec, "264", "b") or sub(rec, "260", "b")).rstrip(" ,:;")}
def call_number(rec, author):
dewey = sub(rec, "082", "a").replace("/", "")
if dewey:
cutter = sub(rec, "082", "b")
if not cutter and author:
cutter = re.sub(r"[^A-Za-z]", "", author.split(",")[0])[:3].upper()
return (dewey + " " + cutter).strip()
lc = sub(rec, "050", "a")
if lc:
return (lc + " " + sub(rec, "050", "b")).strip()
return ""
def set_item_type(rec, itype):
for df in rec.findall("m:datafield[@tag='942']", NS):
rec.remove(df)
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag="942", ind1=" ", ind2=" ")
ET.SubElement(df, "{%s}subfield" % NS["m"], code="c").text = itype
def ensure_identifier(rec, code):
"""Make sure the scanned code is on the record, so scanning it again is
recognised as a duplicate (a Z39.50 hit for a UPC often lacks the UPC)."""
wanted = {code}
if len(code) == 13 and code[:3] == "978":
wanted.add(isbn13_to_10(code) or code)
elif len(code) == 10:
wanted.add(isbn10_to_13(code))
for tag in ("020", "024"):
for df in rec.findall("m:datafield[@tag='%s']" % tag, NS):
for sf in df.findall("m:subfield[@code='a']", NS):
if re.sub(r"[^0-9X]", "", (sf.text or "").upper())[:13] in wanted:
return
tag, i1 = ("020", " ") if is_isbn(code) else ("024", "1")
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=" ")
ET.SubElement(df, "{%s}subfield" % NS["m"], code="a").text = code
def manual_record(code, title, author, year, publisher):
rec = ET.Element("{%s}record" % NS["m"])
ET.SubElement(rec, "{%s}leader" % NS["m"]).text = "00000nam a2200000 a 4500"
def field(tag, subs, i1=" ", i2=" "):
subs = [(c, str(v)) for c, v in subs if v]
if not subs: # an empty datafield is invalid MARC
return
df = ET.SubElement(rec, "{%s}datafield" % NS["m"], tag=tag, ind1=i1, ind2=i2)
for c, v in subs:
ET.SubElement(df, "{%s}subfield" % NS["m"], code=c).text = v
if code:
field("020" if is_isbn(code) else "024", [("a", code)], " " if is_isbn(code) else "1")
if author:
field("100", [("a", author)], "1")
field("245", [("a", title)], "1" if author else "0", "0")
field("264", [("b", publisher), ("c", year)], " ", "1")
return rec
# ------------------------------------------------------------------ Koha API
class KohaError(Exception):
def __init__(self, status, body):
super().__init__("Koha API %s: %s" % (status, body[:200]))
self.status, self.body = status, body
def koha(method, path, body=None, ctype="application/json", headers=None, query=None):
url = KOHA_API + path + ("?" + urllib.parse.urlencode(query) if query else "")
data = body if isinstance(body, bytes) else (json.dumps(body).encode() if body is not None else None)
h = {"Authorization": "Basic " + base64.b64encode(("%s:%s" % (USER, PASSWORD)).encode()).decode(),
"Accept": "application/json"}
if data is not None:
h["Content-Type"] = ctype
h.update(headers or {})
try:
with urllib.request.urlopen(urllib.request.Request(url, data=data, method=method, headers=h), timeout=30) as r:
raw = r.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
raise KohaError(e.code, e.read().decode("utf8", "replace"))
except (urllib.error.URLError, OSError) as e:
raise KohaError(503, str(e))
_TYPE_CACHE = {"at": 0.0, "value": dict(DEFAULT_TYPES)}
def get_item_types():
"""Item types as Koha has them (so types added at install or later in Koha
appear here), cached for 60 s; falls back to the built-in five."""
if time.time() - _TYPE_CACHE["at"] < 60:
return _TYPE_CACHE["value"]
try:
vals = koha("GET", "/item_types", query={"_per_page": "100"}) or []
found = {v["item_type_id"]: v.get("description") or v["item_type_id"] for v in vals}
if found:
_TYPE_CACHE["value"] = found
_TYPE_CACHE["at"] = time.time()
except KohaError:
pass
return _TYPE_CACHE["value"]
def valid_type(code):
types = get_item_types()
return code if code in types else ("BOOK" if "BOOK" in types else next(iter(types)))
_LOC_CACHE = {"at": 0.0, "value": []}
def get_locations():
"""Shelving locations (Koha authorised-value category LOC), cached for 30 s."""
if time.time() - _LOC_CACHE["at"] < 30:
return _LOC_CACHE["value"]
try:
vals = koha("GET", "/authorised_value_categories/LOC/authorised_values") or []
_LOC_CACHE["value"] = [{"value": v["value"], "label": v.get("description") or v["value"]} for v in vals]
_LOC_CACHE["at"] = time.time()
except KohaError:
pass
return _LOC_CACHE["value"]
def valid_location(code):
return code if code and any(l["value"] == code for l in get_locations()) else None
def find_existing(code):
variants = {code}
if len(code) == 13:
alt = isbn13_to_10(code)
if alt:
variants.add(alt)
elif len(code) == 10:
variants.add(isbn10_to_13(code))
for v in variants:
q = {"-or": [{"isbn": {"-like": "%" + v + "%"}}, {"ean": {"-like": "%" + v + "%"}}]}
res = koha("GET", "/biblios", query={"q": json.dumps(q), "_per_page": "1"})
if res:
b = res[0]
items = koha("GET", "/biblios/%s/items" % b["biblio_id"]) or []
return {"biblio_id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"),
"author": b.get("author") or "", "copies": len(items)}
return None
# ------------------------------------------------------------------- settings
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
COUNTER_FILE = os.path.join(DATA_DIR, "counter.json")
MIN_WIDTH, MAX_WIDTH = 7, 10 # 6 all-digit Code 39 symbols can be misread as Code 32 by some scanners
DEFAULT_SETTINGS = {"barcode_mode": "auto", "width": MIN_WIDTH, "location": ""}
BARCODE_MODES = ("auto", "scan", "none")
LOCK = threading.RLock()
def load_settings():
s = dict(DEFAULT_SETTINGS)
try:
s.update(json.load(open(SETTINGS_FILE)))
except (OSError, ValueError):
pass
try:
s["width"] = min(max(int(s["width"]), MIN_WIDTH), MAX_WIDTH)
except (TypeError, ValueError):
s["width"] = MIN_WIDTH
return s
def save_json(path, data):
os.makedirs(DATA_DIR, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f)
os.replace(tmp, path)
def clean_settings(payload, current):
s = dict(current)
if payload.get("barcode_mode") in BARCODE_MODES:
s["barcode_mode"] = payload["barcode_mode"]
if "width" in payload:
try:
s["width"] = min(max(int(payload["width"]), MIN_WIDTH), MAX_WIDTH)
except (TypeError, ValueError):
pass
if "location" in payload:
loc = payload["location"] or ""
if loc == "" or valid_location(loc):
s["location"] = loc
return s
# ------------------------------------------------------------------ barcodes
# Fixed-width digits ("000042"): Koha's "Print barcode range" needs both ends
# the same length, and it cannot take a prefix, so neither is offered.
def fmt_barcode(n, s):
return "%0*d" % (s["width"], n)
def scan_max():
"""Highest all-digit barcode already in Koha (paged; only when needed)."""
mx, page = 0, 1
while True:
items = koha("GET", "/items", query={"_per_page": "100", "_page": str(page), "_order_by": "item_id"}) or []
for it in items:
bc = it.get("external_id") or ""
if bc.isdigit():
mx = max(mx, int(bc))
if len(items) < 100:
return mx
page += 1
def read_counter():
try:
return int(json.load(open(COUNTER_FILE))["n"])
except (OSError, ValueError, KeyError, TypeError):
n = scan_max() # first use (or lost state): start after what exists
save_json(COUNTER_FILE, {"n": n})
return n
def peek_next():
with LOCK:
return read_counter() + 1
def reserve(count):
"""Reserve `count` consecutive numbers and return the first. Reserved
numbers are never handed out again - a label may already be printed."""
with LOCK:
n = read_counter()
save_json(COUNTER_FILE, {"n": n + count})
return n + 1
def add_item(biblio_id, itype, callnum, location, mode, scanned=None):
s = load_settings()
base = {"item_type_id": itype, "home_library_id": LIBRARY, "holding_library_id": LIBRARY,
"callnumber": callnum or None, "location": location or None}
path = "/biblios/%s/items" % biblio_id
if mode == "none":
return koha("POST", path, base)
if mode == "scan":
return koha("POST", path, dict(base, external_id=scanned))
for _ in range(25):
bc = fmt_barcode(reserve(1), s)
try:
return koha("POST", path, dict(base, external_id=bc))
except KohaError as e:
if e.status == 409 or "Duplicate" in e.body:
continue
raise
raise KohaError(409, "could not find a free barcode")
# --------------------------------------------------------------------- state
def load_recent():
try:
return json.load(open(RECENT_FILE))
except (OSError, ValueError):
return []
def remember(entry):
with LOCK:
rec = load_recent()
rec.insert(0, entry)
try:
save_json(RECENT_FILE, rec[:300])
except OSError:
pass
def forget(item_id):
with LOCK:
rec = [r for r in load_recent() if r.get("item_id") != item_id]
try:
save_json(RECENT_FILE, rec)
except OSError:
pass
# ----------------------------------------------------------------- operations
def lookup(code):
"""Try each configured source; returns (xml, source_name) or (None, None)."""
for src in SOURCES:
xml = None
if src.startswith("z3950:"):
xml = z3950_search(src[len("z3950:"):], code)
name = "Z39.50 " + src.split(":", 1)[1].split("/")[0].split(":")[0]
elif src == "openlibrary":
xml = openlibrary_search(code)
name = "Open Library"
if xml:
return xml, name
return None, None
PENDING = {} # token -> (expires, plan): waiting for a scanned sticker
PENDING_TTL = 1800
def prune_pending():
now = time.time()
for k in [k for k, (exp, _) in PENDING.items() if exp < now]:
del PENDING[k]
def chosen_location(payload):
"""The shelf for this add: an explicit choice wins, else the saved default."""
if "location" in payload:
return valid_location(payload.get("location"))
return valid_location(load_settings().get("location"))
def make_plan(payload):
"""Look the code up. Returns ("done", http_status, body) or ("plan", plan)."""
code = clean_code(payload.get("code"))
itype = valid_type(payload.get("type"))
if not code:
return "done", 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."}
location = chosen_location(payload)
with LOCK:
existing = find_existing(code)
if existing and not payload.get("copy"):
return "done", 200, dict(existing, status="duplicate", code=code)
if existing:
return "plan", {"code": code, "itype": itype, "location": location, "existing": existing,
"xml": None, "title": existing["title"], "author": existing["author"],
"callnum": None, "source": "extra copy"}
xml, source = lookup(code)
if not xml:
return "done", 200, {"status": "notfound", "code": code}
rec = parse_marc(xml)
info = describe(rec)
ensure_identifier(rec, code)
set_item_type(rec, itype)
return "plan", {"code": code, "itype": itype, "location": location, "existing": None,
"xml": ET.tostring(rec, encoding="utf-8"), "title": info["title"], "author": info["author"],
"callnum": call_number(rec, info["author"]), "source": source}
def commit(plan, scanned=None):
mode = load_settings()["barcode_mode"]
with LOCK:
created = None
if plan["existing"]:
biblio_id = plan["existing"]["biblio_id"]
else:
bib = koha("POST", "/biblios", plan["xml"], "application/marcxml+xml", {"x-confirm-not-duplicate": "1"})
biblio_id = created = bib["id"]
try:
item = add_item(biblio_id, plan["itype"], plan["callnum"], plan["location"], mode, scanned)
except KohaError:
if created: # don't leave an item-less record behind
try:
koha("DELETE", "/biblios/%s" % created)
except KohaError:
pass
raise
entry = entry_for(plan["code"], plan["title"], plan["author"], item, biblio_id, plan["source"])
remember(entry)
return dict(entry, status="added")
def need_barcode(plan):
token = secrets.token_urlsafe(12)
prune_pending()
PENDING[token] = (time.time() + PENDING_TTL, plan)
return 200, {"status": "need_barcode", "token": token, "code": plan["code"],
"title": plan["title"], "author": plan["author"]}
def finish_or_ask(plan):
if load_settings()["barcode_mode"] == "scan":
return need_barcode(plan)
return 200, commit(plan)
def op_add(payload):
kind, *rest = make_plan(payload)
if kind == "done":
return rest[0], rest[1]
return finish_or_ask(rest[0])
def op_manual(payload):
title = (payload.get("title") or "").strip()
if not title:
return 400, {"status": "invalid", "message": "A title is required."}
code = clean_code(payload.get("code"))
itype = valid_type(payload.get("type"))
rec = manual_record(code, title[:240], (payload.get("author") or "").strip()[:120],
(payload.get("year") or "").strip()[:8], (payload.get("publisher") or "").strip()[:120])
set_item_type(rec, itype)
plan = {"code": code or "", "itype": itype, "location": chosen_location(payload), "existing": None,
"xml": ET.tostring(rec, encoding="utf-8"), "title": title, "author": (payload.get("author") or "").strip(),
"callnum": None, "source": "typed in"}
return finish_or_ask(plan)
STICKER_RE = re.compile(r"[A-Za-z0-9.$/+% -]{1,32}")
def op_finish(payload):
"""Second step of 'scan a pre-printed sticker' mode."""
token = payload.get("token")
barcode = (payload.get("barcode") or "").strip()
prune_pending()
entry = PENDING.get(token)
if not entry:
return 200, {"status": "error", "message": "That one timed out - scan the ISBN again."}
if not STICKER_RE.fullmatch(barcode):
return 200, {"status": "barcode_invalid", "token": token, "message": "That barcode has characters Koha can't use."}
try:
result = commit(entry[1], barcode)
except KohaError as e:
if e.status == 409 or "Duplicate" in e.body:
return 200, {"status": "barcode_taken", "token": token, "barcode": barcode}
raise
PENDING.pop(token, None)
return 200, result
def op_cancel(payload):
PENDING.pop(payload.get("token"), None)
return 200, {"status": "cancelled"}
def entry_for(code, title, author, item, biblio_id, source):
return {"time": int(time.time()), "code": code, "title": title, "author": author,
"barcode": item.get("external_id"), "callnumber": item.get("callnumber") or "",
"item_id": item.get("item_id"), "biblio_id": biblio_id, "source": source,
"location": next((l["label"] for l in get_locations() if l["value"] == item.get("location")), "")}
def op_undo(payload):
item_id = payload.get("item_id")
biblio_id = payload.get("biblio_id")
if not isinstance(item_id, int) or not isinstance(biblio_id, int):
return 400, {"status": "invalid"}
with LOCK:
koha("DELETE", "/items/%d" % item_id)
remaining = koha("GET", "/biblios/%d/items" % biblio_id) or []
if not remaining: # drop the now-empty record too
try:
koha("DELETE", "/biblios/%d" % biblio_id)
except KohaError:
pass
forget(item_id)
return 200, {"status": "undone"}
# ----------------------------------------------------------- settings / tools
def items_without_barcode():
found, page = [], 1
while len(found) < 5000:
chunk = koha("GET", "/items", query={"q": json.dumps({"external_id": None}), "_per_page": "100",
"_page": str(page), "_order_by": "item_id"}) or []
found += chunk
if len(chunk) < 100:
break
page += 1
return found
def settings_view():
s = load_settings()
try:
missing = len(items_without_barcode())
except KohaError:
missing = None
return {"settings": s, "next": fmt_barcode(peek_next(), s) if s["barcode_mode"] == "auto" else None,
"without_barcode": missing, "locations": get_locations()}
def op_settings(payload):
with LOCK:
s = clean_settings(payload, load_settings())
save_json(SETTINGS_FILE, s)
return 200, settings_view()
def op_reserve(payload):
try:
count = int(payload.get("count"))
except (TypeError, ValueError):
count = 0
if not 1 <= count <= 2000:
return 400, {"status": "invalid", "message": "Choose between 1 and 2000."}
s = load_settings()
first = reserve(count)
return 200, {"status": "reserved", "count": count, "first": fmt_barcode(first, s),
"last": fmt_barcode(first + count - 1, s)}
def op_assign(payload):
"""Give a barcode to every item that has none."""
s = load_settings()
with LOCK:
items = items_without_barcode()
if not items:
return 200, {"status": "assigned", "count": 0}
first = reserve(len(items))
for i, it in enumerate(items):
koha("PUT", "/biblios/%s/items/%s" % (it["biblio_id"], it["item_id"]), {
"external_id": fmt_barcode(first + i, s), "item_type_id": it["item_type_id"],
"home_library_id": it["home_library_id"], "holding_library_id": it["holding_library_id"]})
return 200, {"status": "assigned", "count": len(items), "first": fmt_barcode(first, s),
"last": fmt_barcode(first + len(items) - 1, s)}
# ------------------------------------------------------------ photo scanning
MAX_IMAGE = 12 * 1024 * 1024
ZBAR_TIMEOUT = 20
def image_kind(data):
"""Only JPEG and PNG are accepted: the decoder goes through ImageMagick, which
has a long history of bugs in rarely used formats, so everything else is refused
before it gets anywhere near it."""
if data[:3] == b"\xff\xd8\xff":
return "jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
return None
def decode_photo(data, kind):
"""Read barcodes from a photo with zbarimg. kind 'isbn' returns codes usable as an
ISBN/UPC (ISBNs first); kind 'sticker' returns anything Koha could store as a barcode."""
ext = image_kind(data)
if not ext:
raise ValueError("not a JPEG or PNG image")
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "photo." + ext)
with open(path, "wb") as f:
f.write(data)
try:
out = subprocess.run(["zbarimg", "--raw", "-q", path], capture_output=True, text=True,
timeout=ZBAR_TIMEOUT).stdout
except (subprocess.TimeoutExpired, OSError):
return []
codes = []
for line in out.splitlines():
line = line.strip()
if not line:
continue
if kind == "sticker":
ok = line if STICKER_RE.fullmatch(line) else None
else:
ok = clean_code(line)
# zbar reports a UPC-A (12 digits) as a 13-digit EAN with a leading zero.
# Use the 12 digits printed on the item, as a keyboard scanner would type
# them, so lookups and duplicate checks match.
if ok and len(ok) == 13 and ok[0] == "0":
ok = ok[1:]
if ok and ok not in codes:
codes.append(ok)
if kind != "sticker": # book ISBNs (978/979) before UPCs and short codes
codes.sort(key=lambda c: 0 if (len(c) == 13 and c[:3] in ("978", "979")) else 1 if len(c) == 10 else 2)
return codes
# ----------------------------------------------------------------------- HTTP
INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read()
class Handler(BaseHTTPRequestHandler):
server_version = "QuickAdd/1.0"
def log_message(self, fmt, *args):
pass
def _send(self, code, body, ctype="application/json", extra=None):
if not isinstance(body, bytes):
body = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy",
"default-src 'self'; img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def _authed(self):
if not PASSWORD:
return False
header = self.headers.get("Authorization", "")
if header.startswith("Basic "):
try:
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
ok_u = hmac.compare_digest(u.encode(), USER.encode())
ok_p = hmac.compare_digest(p.encode(), PASSWORD.encode())
return ok_u and ok_p
except (ValueError, UnicodeDecodeError):
return False
return False
def _guard(self):
if not self._authed():
self._send(401, b"Login required", "text/plain",
{"WWW-Authenticate": 'Basic realm="Library quick add"'})
return False
return True
def do_GET(self):
if self.path == "/healthz":
return self._send(200, b"ok", "text/plain")
if not self._guard():
return
path = self.path.split("?")[0]
if path == "/":
return self._send(200, INDEX, "text/html; charset=utf-8")
if path == "/api/state":
return self._send(200, {"recent": load_recent()[:100], "types": get_item_types(),
"library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT,
"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())
self._send(404, {"error": "not found"})
def do_POST(self):
if not self._guard():
return
try:
if self.path.split("?")[0] == "/api/scan":
length = int(self.headers.get("Content-Length", "0"))
if not 0 < length <= MAX_IMAGE:
return self._send(413, {"status": "error", "message": "That photo is too large."})
kind = "sticker" if "kind=sticker" in self.path else "isbn"
try:
codes = decode_photo(self.rfile.read(length), kind)
except ValueError:
return self._send(415, {"status": "error", "message": "Please send a JPEG or PNG photo."})
return self._send(200, {"status": "ok", "codes": codes})
length = int(self.headers.get("Content-Length", "0"))
if length > 20000:
return self._send(413, {"error": "too large"})
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}
fn = ops.get(self.path)
if not fn:
return self._send(404, {"error": "not found"})
code, body = fn(payload)
self._send(code, body)
except KohaError as e:
msg = ("Koha is still starting up - try again in a minute." if e.status == 503
else "Koha refused that: %s" % e.body[:160])
self._send(502, {"status": "error", "message": msg})
except (ValueError, ET.ParseError):
self._send(400, {"status": "invalid", "message": "Bad request."})
except Exception as e: # keep the scanner loop alive whatever happens
self._send(500, {"status": "error", "message": "Unexpected error: %s" % e})
if __name__ == "__main__":
if not PASSWORD:
raise SystemExit("ADMIN_PASSWORD is required")
os.makedirs(DATA_DIR, exist_ok=True)
print("quick add listening on :%d sources=%s" % (PORT, SOURCES), flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
View File
+451
View File
@@ -0,0 +1,451 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Add to library</title>
<style>
:root {
--bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2;
--accent: #2f6b4f; --accent-ink: #ffffff; --ok: #1f7a4a; --warn: #8a5a00; --bad: #a3321f;
--ok-bg: #e6f3ec; --warn-bg: #fbf1dc; --bad-bg: #f9e5e1; --focus: #1a5fb4;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #151a17; --card: #1e2521; --ink: #e8ece9; --muted: #a2ada6; --line: #333c36;
--accent: #5bb58a; --accent-ink: #0d1a13; --ok: #6fd3a0; --warn: #e8bd6a; --bad: #f08a78;
--ok-bg: #1c3226; --warn-bg: #352c15; --bad-bg: #3a2220; --focus: #7ab0ff;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 760px; margin: 0 auto; padding: 16px; }
header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; justify-content: space-between; }
h1 { font-size: 1.35rem; margin: 8px 0; }
nav a { color: var(--accent); margin-right: 14px; text-decoration: none; font-weight: 600; }
nav a:hover { text-decoration: underline; }
.types { display: flex; gap: 6px; margin: 10px 0 14px; flex-wrap: wrap; }
.types label { cursor: pointer; }
.types input { position: absolute; opacity: 0; }
.types span { display: inline-block; padding: 8px 16px; border: 1px solid var(--line);
border-radius: 999px; background: var(--card); font-weight: 600; }
.types input:checked + span { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.types input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
.shelf { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
.shelf label { font-weight: 600; }
.shelf select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--card); color: var(--ink); max-width: 100%; }
.scan { display: flex; gap: 8px; }
.scan input { flex: 1; font-size: 1.5rem; padding: 14px 16px; border: 2px solid var(--line);
border-radius: 12px; background: var(--card); color: var(--ink); min-width: 0; }
.scan input:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); }
button { font: inherit; cursor: pointer; border-radius: 10px; border: 1px solid var(--line);
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; }
.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;
padding: 14px; margin: 10px 0; display: flex; gap: 14px; }
.card.ok { border-color: var(--ok); background: var(--ok-bg); }
.card.warn { border-color: var(--warn); background: var(--warn-bg); }
.card.bad { border-color: var(--bad); background: var(--bad-bg); }
.cover { width: 64px; height: 92px; border-radius: 4px; background: var(--line); flex: none; object-fit: cover; }
.cover[hidden] { display: none; }
.body { flex: 1; min-width: 0; }
.title { font-weight: 700; font-size: 1.1rem; overflow-wrap: anywhere; }
.meta { color: var(--muted); overflow-wrap: anywhere; }
.badge { display: inline-block; background: var(--ink); color: var(--bg); border-radius: 6px;
padding: 1px 9px; font-weight: 700; font-variant-numeric: tabular-nums; }
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
form.manual { display: grid; gap: 8px; margin-top: 8px; }
form.manual input { padding: 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--card); color: var(--ink); font: inherit; width: 100%; }
h2 { font-size: 1.05rem; margin: 24px 0 6px; }
ul.list { list-style: none; margin: 0; padding: 0; }
ul.list li { display: flex; gap: 10px; align-items: center; padding: 9px 2px; border-bottom: 1px solid var(--line); }
ul.list .t { flex: 1; min-width: 0; overflow-wrap: anywhere; }
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>
<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>
</nav>
</header>
<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>
<select id="shelf"></select>
</div>
<form class="scan" id="scan" autocomplete="off">
<label class="sr" for="code">ISBN or barcode</label>
<input id="code" inputmode="numeric" placeholder="Scan or type an ISBN" autofocus>
<button class="primary" type="submit">Add</button>
</form>
<div class="cam-row">
<button type="button" id="cam">Scan with camera</button>
<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>
</div>
<div id="status" role="status" aria-live="polite"></div>
<div id="result"></div>
<h2>Added this session <span id="count" class="small"></span></h2>
<ul class="list" id="session"></ul>
<div class="labels" id="labels" hidden>
<strong id="range"></strong>
<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>
<script>
(() => {
const $ = id => document.getElementById(id);
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;
function beep(freqs) {
try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
let t = actx.currentTime;
for (const f of freqs) {
const o = actx.createOscillator(), g = actx.createGain();
o.frequency.value = f; g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
o.connect(g); g.connect(actx.destination); o.start(t); o.stop(t + 0.17); t += 0.18;
}
} catch (e) {}
}
const SOUND = { added: [880], duplicate: [520, 520], notfound: [300], error: [220, 180] };
const el = (tag, props = {}, ...kids) => {
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;
};
async function api(path, body) {
const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) } : {});
return r.json();
}
function cover(code) {
const img = el('img', { className: 'cover', alt: '', hidden: true });
if (code && /^\d{10,13}$/.test(code)) {
img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
img.src = 'https://covers.openlibrary.org/b/isbn/' + code + '-M.jpg?default=false';
}
return img;
}
function show(kind, ...parts) { $('result').replaceChildren(el('div', { className: 'card ' + kind }, ...parts)); }
function bookBody(r, extra) {
return el('div', { className: 'body' },
el('div', { className: 'title' }, r.title || ''),
el('div', { className: 'meta' }, [r.author, r.callnumber && ('Call no. ' + r.callnumber), r.location].filter(Boolean).join(' · ')),
...extra);
}
function rowFor(e, withUndo) {
const li = el('li', {},
el('span', { className: 'badge' }, e.barcode || '–'),
el('span', { className: 't' }, el('div', {}, e.title), el('div', { className: 'u' },
[e.author, e.callnumber, e.location, e.source].filter(Boolean).join(' · '))));
if (withUndo) {
const b = el('button', { type: 'button' }, 'Undo');
b.onclick = async () => {
b.disabled = true;
const r = await api('/api/undo', { item_id: e.item_id, biblio_id: e.biblio_id });
if (r.status === 'undone') {
sessionItems = sessionItems.filter(x => x.item_id !== e.item_id); render();
if (lastAdded === e.item_id) $('result').replaceChildren();
$('status').textContent = 'Removed "' + e.title + '".';
} else b.disabled = false;
};
li.append(b);
}
return li;
}
function render() {
$('session').replaceChildren(...sessionItems.map(e => rowFor(e, true)));
$('count').textContent = sessionItems.length ? '(' + sessionItems.length + ')' : '';
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;
}
}
async function add(code, opts = {}) {
if (busy) return; busy = true;
$('status').textContent = 'Looking up ' + code + '…';
try {
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; 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();
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');
more.onclick = () => add(code, { copy: true });
const skip = el('button', { type: 'button' }, 'Skip');
skip.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
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 {
beep(SOUND.error);
show('bad', el('div', { className: 'body' }, r.message || 'Something went wrong.'));
}
}
// ---- phone camera: take a photo, shrink it, have the server read the barcode
async function shrink(file, max = 2400) {
let bmp;
try { bmp = await createImageBitmap(file, { imageOrientation: 'from-image' }); }
catch (e) { bmp = await new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = URL.createObjectURL(file); }); }
const w = bmp.width, h = bmp.height, k = Math.min(1, max / Math.max(w, h));
const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(h * k);
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height);
return new Promise(ok => c.toBlob(ok, 'image/jpeg', 0.9));
}
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
function takePhoto(kind) {
return new Promise(resolve => {
const input = $('camfile');
input.onchange = async () => {
const f = input.files[0]; input.value = '';
if (!f) return resolve(null);
$('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 });
resolve(r.ok ? (await r.json()).codes : []);
} catch (e) { resolve([]); }
};
input.click();
});
}
function noBarcode() {
beep(SOUND.notfound);
$('status').textContent = '';
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, "Couldn't read a barcode from that photo"),
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.')));
}
$('cam').onclick = async () => {
const codes = await takePhoto('isbn');
if (codes === null) { $('status').textContent = ''; return; }
if (!codes.length) return noBarcode();
add(codes[0]);
};
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(); };
const camBtn = el('button', { type: 'button' }, 'Scan sticker with camera');
camBtn.onclick = async () => {
const codes = await takePhoto('sticker');
if (codes === null) return;
if (!codes.length) { beep(SOUND.notfound); $('status').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
handle(await api('/api/finish', { token: r.token, barcode: codes[0] }), r.code);
};
form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), camBtn, 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();
}
let staffBase = '';
const staffRoot = () => staffBase || ('http://' + location.hostname + ':' + staffPort);
const printLink = (from, to) => staffRoot() +
'/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 });
const submit = el('button', { className: 'primary', type: 'submit' }, 'Add it anyway');
const cancel = el('button', { type: 'button' }, 'Cancel');
cancel.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
f.append(field('title', 'Title', true), field('author', 'Author'), field('year', 'Year'),
field('publisher', 'Publisher'), el('div', { className: 'actions' }, submit, cancel));
f.onsubmit = async ev => {
ev.preventDefault();
const d = Object.fromEntries(new FormData(f));
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));
f.querySelector('input').focus();
}
$('scan').onsubmit = ev => {
ev.preventDefault();
const code = $('code').value.trim(); $('code').value = '';
if (code) add(code);
};
function typeButtons() {
$('types').replaceChildren(...Object.entries(types).map(([k, label]) => {
const i = el('input', { type: 'radio', name: 'type', value: k, checked: k === type });
i.onchange = () => { type = k; localStorage.setItem('qa-type', k); $('code').focus(); };
return el('label', {}, i, el('span', {}, label));
}));
}
(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 || '';
staffBase = s.staff_url || '';
$('lnk-opac').href = s.opac_url ? s.opac_url + '/' : 'http://' + location.hostname + ':' + s.opac_port + '/';
$('lnk-staff').href = staffRoot() + '/';
$('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl';
if (s.locations && s.locations.length) {
const sel = $('shelf');
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; saveSettings({ location: shelf }); $('code').focus(); };
$('shelfbox').hidden = false;
}
api('/api/settings').then(applySettings);
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
$('code').focus();
})();
})();
</script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
"""Regression check for photo scanning: generate phone-like photos of real barcodes and make
sure the running Quick Add reads them (at the size the page sends, 2400 px).
python3 -m venv /tmp/qa-venv && /tmp/qa-venv/bin/pip install zxing-cpp pillow
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/gen_photos.py
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/check_scan.py http://127.0.0.1:8082 admin PASSWORD
"""
import base64, io, json, os, sys, urllib.request
from PIL import Image, ImageOps
url, user, pw = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
D = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
def scan(data, kind):
req = urllib.request.Request(f"{url}/api/scan?kind={kind}", data=data, method="POST",
headers={"Content-Type": "image/jpeg", "Authorization": auth})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)["codes"]
def shrunk(path, m=2400): # what the page does before uploading
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
k = min(1, m / max(im.size))
im = im.resize((round(im.width * k), round(im.height * k)), Image.LANCZOS)
b = io.BytesIO(); im.save(b, "JPEG", quality=90); return b.getvalue()
bad = 0
for n in sorted(f for f in os.listdir(D) if f.endswith(".jpg")):
base = n[:-4]
exp_file = os.path.join(D, base + ".expect")
exp = open(exp_file).read() if os.path.exists(exp_file) else None
codes = scan(shrunk(os.path.join(D, n)), "sticker" if base.startswith("sticker") else "isbn")
ok = (codes[:1] == [exp]) if exp else (codes == [])
bad += not ok
print(("ok " if ok else "FAIL ") + f"{base:20s} expected {exp!s:14s} got {codes[:2]}")
sys.exit(1 if bad else 0)
+77
View File
@@ -0,0 +1,77 @@
"""Generate phone-photo-like test images containing real barcodes."""
import random, io, os
import zxingcpp
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
OUT = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
os.makedirs(OUT, exist_ok=True)
random.seed(7)
def barcode(fmt, text, scale=4, height=90):
bc = zxingcpp.write_barcode(fmt, text, width=0, height=height, quiet_zone=10)
img = Image.fromarray(bc.__array__() if hasattr(bc, "__array__") else bc).convert("RGB")
return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
def scene(code_img, w=3024, h=4032, tilt=0, blur=0.0, contrast=1.0, bright=1.0, noise=0, quality=72, exif=None, size_frac=0.45):
"""Paste the barcode (on a white label) onto a coloured, busy 'book cover'."""
bg = Image.new("RGB", (w, h), (random.randint(20, 90), random.randint(30, 80), random.randint(60, 140)))
d = ImageDraw.Draw(bg)
for _ in range(60): # clutter: stripes, blobs, text-ish bars
x, y = random.randint(0, w), random.randint(0, h)
d.rectangle([x, y, x + random.randint(40, 600), y + random.randint(8, 60)],
fill=tuple(random.randint(0, 255) for _ in range(3)))
label = code_img.copy()
k = (w * size_frac) / label.width
label = label.resize((int(label.width * k), int(label.height * k)), Image.LANCZOS)
pad = Image.new("RGB", (label.width + 60, label.height + 60), "white")
pad.paste(label, (30, 30))
if tilt:
pad = pad.rotate(tilt, expand=True, resample=Image.BICUBIC, fillcolor="white")
bg.paste(pad, ((w - pad.width) // 2 + random.randint(-80, 80), (h - pad.height) // 2 + random.randint(-300, 300)))
if blur:
bg = bg.filter(ImageFilter.GaussianBlur(blur))
bg = ImageEnhance.Contrast(bg).enhance(contrast)
bg = ImageEnhance.Brightness(bg).enhance(bright)
if noise:
px = bg.load()
for _ in range(noise):
x, y = random.randint(0, w - 1), random.randint(0, h - 1)
v = random.randint(0, 255); px[x, y] = (v, v, v)
buf = io.BytesIO()
kw = {"quality": quality}
if exif is not None:
e = Image.Exif(); e[0x0112] = exif; kw["exif"] = e
bg.save(buf, "JPEG", **kw)
return buf.getvalue()
E13 = zxingcpp.BarcodeFormat.EAN13
UPCA = zxingcpp.BarcodeFormat.UPCA
C39 = zxingcpp.BarcodeFormat.Code39
cases = {
# name: (expected, bytes)
"isbn_clean": ("9780345339683", scene(barcode(E13, "9780345339683"))),
"isbn_tilt8": ("9780345339683", scene(barcode(E13, "9780345339683"), tilt=8, blur=1.2)),
"isbn_tilt_neg12": ("9780441172719", scene(barcode(E13, "9780441172719"), tilt=-12, blur=1.5, contrast=0.8)),
"isbn_dim_noisy": ("9780345339683", scene(barcode(E13, "9780345339683"), blur=1.4, contrast=0.7, bright=0.7, noise=60000, quality=55)),
"isbn_small": ("9780441172719", scene(barcode(E13, "9780441172719"), size_frac=0.22, blur=1.0)),
"isbn_exif_rot90": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(90, expand=True), exif=6)),
"isbn_upside_down": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(180, expand=True))),
"upc_a": ("036000291452", scene(barcode(UPCA, "036000291452"), tilt=5, blur=1.0)),
}
for name, (exp, data) in cases.items():
open(f"{OUT}/{name}.jpg", "wb").write(data)
open(f"{OUT}/{name}.expect", "w").write(exp)
# sticker (Code 39, 7 digits) and a photo with no barcode at all
open(f"{OUT}/sticker_c39.jpg", "wb").write(scene(barcode(C39, "0000042", scale=3), tilt=4, blur=1.0, size_frac=0.5))
open(f"{OUT}/sticker_c39.expect", "w").write("0000042")
blank = Image.new("RGB", (3024, 4032), (90, 60, 40)); ImageDraw.Draw(blank).rectangle([400, 900, 2600, 1300], fill=(200, 180, 60))
b = io.BytesIO(); blank.save(b, "JPEG", quality=80); open(f"{OUT}/no_barcode.jpg", "wb").write(b.getvalue())
print("wrote", len(os.listdir(OUT)) // 2, "photos to", OUT)
for n in sorted(os.listdir(OUT)):
if n.endswith(".jpg"):
print(f" {n:22s} {os.path.getsize(os.path.join(OUT, n)) / 1e6:5.1f} MB")
+319
View File
@@ -0,0 +1,319 @@
#!/bin/bash
################################################################################
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
# catalogue (Koha) running in Docker, shown on the kiosk as a public
# search terminal.
#
# What gets installed (all from this checkout's library/ directory, deployed to
# $LIBRARY_DIR - see library/README.md for the stack itself):
# - Koha: the catalogue ("OPAC", public search, no login) and the staff
# interface (cataloguing, Z39.50 lookup, label printing).
# - Quick Add: a one-box "scan an ISBN, the book is added" page.
# - MariaDB.
# Docker itself is installed on demand (lib/docker.sh).
#
# On the kiosk the catalogue is added as one of the Sites (tabs) with
# duration 0 (stay put - it's a search terminal, not a rotating display).
# Staff and Quick Add are meant for the librarian's own device; whether they
# are reachable from the LAN or only from this kiosk is the "Network access"
# choice below. NOTE: Docker publishes ports straight through iptables and
# bypasses ufw, so "this kiosk only" is enforced by binding the published
# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules.
#
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
################################################################################
LIBRARY_CATALOGUE_TAB_NAME="Library catalogue"
################################################################################
# Detection / state
################################################################################
library_env_get() {
sudo grep -m1 "^$1=" "$LIBRARY_DIR/.env" 2>/dev/null | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'
}
library_is_installed() {
[[ -f "$LIBRARY_DIR/docker-compose.yml" ]] || return 1
[[ "$(docker_container_state library-koha)" != "missing" ]]
}
library_is_running() {
[[ "$(docker_container_state library-koha)" == "running" ]]
}
library_catalogue_url() {
echo "http://localhost:$(library_env_get OPAC_PORT)/"
}
# True when the kiosk's page list already shows the catalogue.
library_tab_exists() {
local url idx
url="$(library_catalogue_url)"
for idx in "${!URLS[@]}"; do
[[ "${URLS[$idx]}" == "$url" ]] && return 0
done
return 1
}
addon_library_status() {
if ! library_is_installed; then
echo "Library Catalog: not installed"
return
fi
local state health ip
state="$(docker_container_state library-koha)"
health="$(docker_container_health library-koha)"
ip="$(get_ip_address)"
echo "Library Catalog: ${state}${health:+ ($health)}"
echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)"
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
echo " Network: this kiosk only"
else
echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/"
fi
}
addon_library_menu_builder() {
if library_is_installed; then
load_existing_config
local tab_label="Show the catalogue on the kiosk"
library_tab_exists && tab_label="Stop showing the catalogue on the kiosk"
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
else
MENU_LABELS=("Install Library Catalog")
MENU_HANDLERS=(action_library_install)
fi
}
addon_library_menu() {
run_menu "LIBRARY CATALOG" addon_library_menu_builder addon_library_status
}
################################################################################
# Helpers
################################################################################
# Copy library/ from this checkout to $LIBRARY_DIR. Never overwrites the
# deployed .env (passwords) even if a stray one exists in the checkout.
library_sync_files() {
local src="${SCRIPT_DIR}/library"
if [[ ! -f "$src/docker-compose.yml" ]]; then
log_error "library/ not found in this checkout ($src) - re-clone or git pull"
return 1
fi
sudo mkdir -p "$LIBRARY_DIR" || return 1
tar -C "$src" --exclude=./.env --exclude='*.crt' -cf - . | sudo tar -C "$LIBRARY_DIR" -xf - || return 1
sudo chmod 755 "$LIBRARY_DIR/install.sh"
}
library_add_tab() {
load_existing_config
if library_tab_exists; then
log_info "The catalogue is already one of the kiosk's pages."
return 0
fi
URLS+=("$(library_catalogue_url)")
DURS+=("0")
USERS+=("")
PASSES+=("")
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
save_config
}
# Drop every page that points at the catalogue's local address.
library_remove_tab() {
load_existing_config
local url idx
url="$(library_catalogue_url)"
local -a nu=() nd=() nus=() np=() nn=()
for idx in "${!URLS[@]}"; do
[[ "${URLS[$idx]}" == "$url" ]] && continue
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
done
if [[ "${#nu[@]}" -eq "${#URLS[@]}" ]]; then
return 0
fi
URLS=("${nu[@]}"); DURS=("${nd[@]}"); USERS=("${nus[@]}"); PASSES=("${np[@]}"); NAMES=("${nn[@]}")
HOME_TAB_INDEX=-1
save_config
}
# library_set_bind ADDR - persist BIND_ADDR in .env and recreate the containers.
library_set_bind() {
local addr="$1"
if sudo grep -q '^BIND_ADDR=' "$LIBRARY_DIR/.env" 2>/dev/null; then
sudo sed -i "s|^BIND_ADDR=.*|BIND_ADDR=${addr}|" "$LIBRARY_DIR/.env"
else
echo "BIND_ADDR=${addr}" | sudo tee -a "$LIBRARY_DIR/.env" >/dev/null
fi
docker_compose "$LIBRARY_DIR" up -d
}
library_warn_if_low_memory() {
local total_mb
total_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)
if [[ "$total_mb" -gt 0 && "$total_mb" -lt 3000 ]]; then
log_warning "This machine has about ${total_mb} MB of RAM. The catalogue needs roughly 2 GB on top of the kiosk itself - it may be slow."
fi
}
################################################################################
# Actions
################################################################################
action_library_install() {
echo
echo "This installs a library catalogue (Koha) with Z39.50 book lookup, label"
echo "printing and a scan-to-add page. It needs Docker (installed for you if"
echo "missing), about 3 GB of disk space and ~2 GB of RAM. The first build takes"
echo "5-10 minutes."
echo
library_warn_if_low_memory
local library_name
library_name=$(ask_text "Library name (shown on the catalogue)" "My Library")
[[ -z "$library_name" ]] && library_name="My Library"
local bind="0.0.0.0"
echo
echo "Who should be able to open the catalogue, staff interface and Quick Add"
echo "page besides this kiosk?"
echo " - Yes: any device on your network (use it from your own laptop/phone;"
echo " the staff pages are protected by a generated password)."
echo " - No: only this kiosk. Safest, but you can only manage the library here."
if ! ask_yes_no "Allow other devices on the network?" "y"; then
bind="127.0.0.1"
fi
docker_ensure_installed || { pause; return 1; }
library_sync_files || { pause; return 1; }
echo
log_info "Building and starting the stack - this takes a while on the first run..."
if ! sudo env LIBRARY_NAME="$library_name" BIND_ADDR="$bind" "$LIBRARY_DIR/install.sh"; then
log_error "The library stack failed to start - see: sudo docker logs library-koha"
pause
return 1
fi
if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then
library_add_tab
fi
echo
log_success "Library Catalog installed"
pause
}
action_library_show_access() {
local ip user pass
ip="$(get_ip_address)"
user="$(library_env_get ADMIN_USER)"
pass="$(library_env_get ADMIN_PASSWORD)"
echo
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
ip="localhost"
echo "(Network access is limited to this kiosk.)"
fi
echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/"
echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/"
echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/"
echo " Login (staff and add books): ${user} / ${pass}"
echo
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
pause
}
action_library_toggle_tab() {
load_existing_config
if library_tab_exists; then
library_remove_tab
else
library_add_tab
fi
pause
}
action_library_network() {
local current
current="$(library_env_get BIND_ADDR)"
echo
echo "Currently: $([[ "$current" == "127.0.0.1" ]] && echo "this kiosk only" || echo "other devices on the network can connect")"
echo
if ask_yes_no "Allow other devices on the network?" "$([[ "$current" == "127.0.0.1" ]] && echo n || echo y)"; then
library_set_bind "0.0.0.0" && log_success "Other devices on the network can now connect"
else
library_set_bind "127.0.0.1" && log_success "Only this kiosk can connect now"
fi
pause
}
action_library_start() {
docker_compose "$LIBRARY_DIR" start && log_success "Started"
pause
}
action_library_stop() {
docker_compose "$LIBRARY_DIR" stop && log_success "Stopped (the kiosk page will show an error until you start it again)"
pause
}
action_library_update() {
echo
echo "This rebuilds the images with the latest Koha release. Your catalogue data is kept."
if ! ask_yes_no "Update now?" "y"; then
return 0
fi
docker_ensure_installed || { pause; return 1; }
library_sync_files || { pause; return 1; }
if sudo "$LIBRARY_DIR/install.sh" --update; then
log_success "Updated"
else
log_error "Update failed - see: sudo docker logs library-koha"
fi
pause
}
# library_do_uninstall [purge]
# no argument: stop and remove the containers but KEEP the data volumes and
# $LIBRARY_DIR/.env - the database password is baked into the
# database volume, so a later reinstall needs the same .env.
# purge: also delete the data (all books!), the images and $LIBRARY_DIR.
library_do_uninstall() {
local mode="${1:-}"
library_remove_tab
if [[ -f "$LIBRARY_DIR/docker-compose.yml" ]]; then
if [[ "$mode" == "purge" ]]; then
docker_compose "$LIBRARY_DIR" down -v --rmi local 2>/dev/null || true
else
docker_compose "$LIBRARY_DIR" down 2>/dev/null || true
fi
fi
if [[ "$mode" == "purge" ]]; then
sudo rm -rf "$LIBRARY_DIR"
log_success "Library Catalog and all its data removed"
else
log_success "Library Catalog stopped and removed (your books are kept)"
fi
}
action_library_uninstall() {
echo
echo "This stops and removes the catalogue containers."
if ask_yes_no "Also DELETE all catalogue data (every book you have added)?" "n"; then
local confirm
confirm=$(ask_text "Type DELETE to confirm" "")
if [[ "$confirm" != "DELETE" ]]; then
echo "Cancelled"
pause
return 0
fi
library_do_uninstall purge
else
library_do_uninstall
echo "Your books are kept; install again from this menu to bring them back."
fi
pause
}
+8
View File
@@ -65,6 +65,7 @@ clone_detect_addons() {
command -v netbird &>/dev/null && addons+=("netbird")
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
webui_is_installed 2>/dev/null && addons+=("webui")
library_is_installed 2>/dev/null && addons+=("library")
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
local authelia_url
@@ -243,6 +244,13 @@ action_apply_clone_settings() {
fi ;;
asterisk_intercom)
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
library)
if library_is_installed; then
echo " [x] Library Catalog - already installed (the books themselves are not cloned)"
else
echo " [ ] Library Catalog - not installed, install via Addons (the catalogue page is cloned, the books are not)"
missing_any=true
fi ;;
authelia)
echo " [!] Authelia - needs a fresh login on this device" ;;
*)
+3 -1
View File
@@ -7,7 +7,7 @@
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
# webui_do_uninstall, power_schedule_do_remove_all,
# webui_do_uninstall, library_do_uninstall, power_schedule_do_remove_all,
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
# re-implementing removal logic for each addon a second time here - if an
# addon's uninstall logic changes, this picks it up automatically. Only
@@ -59,6 +59,7 @@ action_complete_uninstall() {
echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)"
echo " • Asterisk Intercom (Baresip)"
echo " • Web UI"
echo " • Library Catalog (Koha) and ALL the books in it"
echo " • LightDM and Openbox"
echo " • All kiosk schedules and services"
echo " • Emergency hotspot configuration"
@@ -90,6 +91,7 @@ action_complete_uninstall() {
squeezelite_do_uninstall
asterisk_intercom_do_uninstall purge
webui_do_uninstall
library_do_uninstall purge
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
power_schedule_do_remove_all
+18
View File
@@ -20,6 +20,7 @@ display_status() {
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
echo "Back/forward btns: $(onoff "$ENABLE_HISTORY_BUTTONS")"
}
display_menu_builder() {
@@ -29,6 +30,7 @@ display_menu_builder() {
"Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))"
"Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))"
"Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))"
"Toggle on-screen back/forward buttons (currently: $(onoff "$ENABLE_HISTORY_BUTTONS"))"
)
MENU_HANDLERS=(
action_set_touch_mode
@@ -36,6 +38,7 @@ display_menu_builder() {
action_toggle_pause_button
action_toggle_keyboard_button
action_toggle_nav_button
action_toggle_history_buttons
)
}
@@ -131,3 +134,18 @@ action_toggle_nav_button() {
fi
save_config
}
# Page back/forward: Alt+Left / Alt+Right and the mouse's side buttons
# always work; this adds clickable buttons (top middle, half transparent,
# shown on mouse movement or a tap) for screens with no keyboard or mouse
# buttons. Off by default.
action_toggle_history_buttons() {
if [[ "$ENABLE_HISTORY_BUTTONS" == "true" ]]; then
ENABLE_HISTORY_BUTTONS="false"
log_warning "On-screen back/forward buttons disabled"
else
ENABLE_HISTORY_BUTTONS="true"
log_success "On-screen back/forward buttons enabled"
fi
save_config
}
+203
View File
@@ -0,0 +1,203 @@
#!/bin/bash
################################################################################
# tests/addon_library.test.sh - tests for menus/addon_library.sh + lib/docker.sh
#
# Runs entirely in a scratch directory with a stub `sudo`, a stub `docker` and
# a fixture stack standing in for library/, so it needs no root, no Docker and
# no network - and cannot touch a real kiosk. (The real stack is exercised by
# library/install.sh itself.)
#
# bash tests/addon_library.test.sh
################################################################################
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
PASS=0 FAIL=0
ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; }
check() { # check "description" command...
local d="$1"; shift
if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi
}
# ---- stubs --------------------------------------------------------------------
mkdir -p "$T/bin"
cat > "$T/bin/sudo" << 'EOF'
#!/bin/bash
# Stub sudo: drop "-u USER" and run the command as-is.
if [[ "$1" == "-u" ]]; then shift 2; fi
exec "$@"
EOF
cat > "$T/bin/docker" << 'EOF'
#!/bin/bash
# Stub docker: logs every call; container state comes from $STUB_STATE.
echo "$*" >> "$STUB_LOG"
case "$1" in
--version) echo "Docker version 99.0.0, build stub"; exit 0 ;;
info) exit 0 ;;
compose)
[[ "$2" == "version" ]] && { echo "Docker Compose version v9"; exit 0; }
exit 0 ;;
inspect)
st="$(cat "$STUB_STATE" 2>/dev/null || echo missing)"
[[ "$st" == "missing" ]] && exit 1
case "$*" in *Health*) echo healthy ;; *) echo "$st" ;; esac
exit 0 ;;
esac
exit 0
EOF
chmod +x "$T/bin/sudo" "$T/bin/docker"
export PATH="$T/bin:$PATH"
export STUB_LOG="$T/docker.log" STUB_STATE="$T/state"
echo missing > "$STUB_STATE"; : > "$STUB_LOG"
# ---- fixture "checkout" with a stub library stack ----------------------------
FIX="$T/checkout"
mkdir -p "$FIX/library/koha/certs"
echo "name: library" > "$FIX/library/docker-compose.yml"
echo "SHOULD-NOT-BE-COPIED" > "$FIX/library/.env"
echo "cert" > "$FIX/library/koha/certs/proxy.crt"
echo "koha-file" > "$FIX/library/koha/Dockerfile"
cat > "$FIX/library/install.sh" << 'EOF'
#!/bin/bash
# Stub of library/install.sh: records how it was called and writes a .env.
HERE="$(cd "$(dirname "$0")" && pwd)"
echo "install.sh $* LIBRARY_NAME=${LIBRARY_NAME:-} BIND_ADDR=${BIND_ADDR:-}" >> "$STUB_LOG"
[[ "${1:-}" == "--update" ]] && exit 0
cat > "$HERE/.env" << ENV
LIBRARY_NAME="${LIBRARY_NAME:-}"
OPAC_PORT=8080
STAFF_PORT=8081
QUICKADD_PORT=8082
ADMIN_USER=admin
ADMIN_PASSWORD=s3cretPASS
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
ENV
echo running > "$STUB_STATE"
EOF
# ---- environment the addon expects -------------------------------------------
export KIOSK_USER="$(id -un)"
export KIOSK_HOME="$T/home" KIOSK_DIR="$T/home/kiosk-app" CONFIG_PATH="$T/home/kiosk-app/config.json"
export LIBRARY_DIR="$T/opt-library"
SCRIPT_DIR="$FIX"
mkdir -p "$KIOSK_DIR"
cat > "$CONFIG_PATH" << 'EOF'
{"tabs":[{"url":"https://example.com","duration":60,"username":"","password":"","name":"Example"}],
"homeTabIndex":-1,"inactivityTimeout":120,"swipeMode":"dual","allowNavigation":"same-origin","somethingUntracked":"keep-me"}
EOF
# shellcheck disable=SC1090
source "$REPO/lib/menu.sh"
source "$REPO/lib/config.sh"
source "$REPO/lib/docker.sh"
source "$REPO/menus/addon_library.sh"
export KIOSK_UNATTENDED=0
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; }
# ============================================================================
echo "== lib/docker.sh"
check "docker_is_installed sees the stub" docker_is_installed
check "container state: missing" test "$(docker_container_state library-koha)" = missing
echo running > "$STUB_STATE"
check "container state: running" test "$(docker_container_state library-koha)" = running
check "container health: healthy" test "$(docker_container_health library-koha)" = healthy
echo missing > "$STUB_STATE"
check "docker_free_port returns a number" test -n "$(docker_free_port 20000)"
# ============================================================================
echo "== sync files"
mkdir -p "$LIBRARY_DIR"; echo "DEPLOYED-ENV" > "$LIBRARY_DIR/.env"
library_sync_files
check "copies the stack files" test -f "$LIBRARY_DIR/koha/Dockerfile"
check "never overwrites a deployed .env" test "$(cat "$LIBRARY_DIR/.env")" = DEPLOYED-ENV
check "does not copy certificates" test ! -e "$LIBRARY_DIR/koha/certs/proxy.crt"
rm -rf "$LIBRARY_DIR"
# ============================================================================
echo "== not installed"
if library_is_installed; then bad "library_is_installed false before install"; else ok "library_is_installed false before install"; fi
addon_library_menu_builder
check "offers only Install" test "${MENU_LABELS[*]}" = "Install Library Catalog"
check "status says not installed" test "$(addon_library_status)" = "Library Catalog: not installed"
# ============================================================================
echo "== install action (kiosk-only network, show on kiosk)"
# answers: library name, allow-network=n, show-on-kiosk=y, <Enter> for pause
printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.out" 2>&1
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
check "is_installed is now true" library_is_installed
check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs)
check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs)
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
addon_library_status > "$T/status.out"
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
# ============================================================================
echo "== menu while installed"
addon_library_menu_builder
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}"
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
echo "== tab toggling is idempotent"
library_add_tab >/dev/null 2>&1
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs)
addon_library_menu_builder
check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}"
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1
# ============================================================================
echo "== network access"
: > "$STUB_LOG"
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
check ".env BIND_ADDR is now 0.0.0.0" test "$(library_env_get BIND_ADDR)" = 0.0.0.0
check "containers were recreated" grep -q 'compose --project-directory .* up -d' "$STUB_LOG"
printf 'n\n\n' | action_library_network >/dev/null 2>&1 # back to kiosk-only
check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR)" = 127.0.0.1
# ============================================================================
echo "== access screen"
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
check "shows the staff login" grep -q 'admin / s3cretPASS' "$T/access.out"
check "shows quick-add address" grep -q 'Add books.*:8082' "$T/access.out"
# ============================================================================
echo "== update"
: > "$STUB_LOG"
printf 'y\n\n' | action_library_update >/dev/null 2>&1
check "runs install.sh --update" grep -q 'install.sh --update' "$STUB_LOG"
check "update keeps the .env" test "$(library_env_get ADMIN_PASSWORD)" = s3cretPASS
# ============================================================================
echo "== uninstall keeps the books by default"
: > "$STUB_LOG"
printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0
echo "== purge needs the typed confirmation"
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
check "wrong confirmation cancels" test -f "$LIBRARY_DIR/.env"
: > "$STUB_LOG"
library_add_tab >/dev/null 2>&1
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
check "install dir removed" test ! -e "$LIBRARY_DIR"
check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0
check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs)
echo
echo "passed: $PASS failed: $FAIL"
[[ "$FAIL" -eq 0 ]]
+1
View File
@@ -40,6 +40,7 @@ const SCALAR_DEFAULTS = {
enablePauseButton: true,
enableKeyboardButton: true,
enableNavButton: true,
enableHistoryButtons: false,
enablePasswordProtection: false,
lockoutTimeout: 0,
lockoutAtTime: '',
+2
View File
@@ -173,6 +173,7 @@ displayForm.addEventListener('submit', async (e) => {
enablePauseButton: fd.get('enablePauseButton') === 'on',
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
enableNavButton: fd.get('enableNavButton') === 'on',
enableHistoryButtons: fd.get('enableHistoryButtons') === 'on',
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
};
@@ -282,6 +283,7 @@ function populateAll(config) {
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
displayForm.elements.enableHistoryButtons.checked = !!config.enableHistoryButtons;
updateHomeTabOptions(config.tabs);
homeTabSelect.value = String(config.homeTabIndex);
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
+1
View File
@@ -95,6 +95,7 @@
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
<label class="checkbox"><input type="checkbox" name="enableHistoryButtons"> On-screen back/forward buttons (top middle)</label>
<fieldset>
<legend>Home page</legend>
+1
View File
@@ -70,6 +70,7 @@ app.put('/api/config', (req, res) => {
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton;
if (body.enableHistoryButtons !== undefined) patch.enableHistoryButtons = !!body.enableHistoryButtons;
let tabCount;
if (body.tabs !== undefined) {
+8
View File
@@ -75,6 +75,14 @@ check('saveConfig merge preserves fields this app never tracks (the previously-f
assert.strictEqual(onDisk.enableNavButton, false);
});
check('enableHistoryButtons defaults off and round-trips', () => {
fs.rmSync(process.env.CONFIG_PATH, { force: true });
assert.strictEqual(loadConfig().enableHistoryButtons, false);
saveConfig({ enableHistoryButtons: true });
assert.strictEqual(JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')).enableHistoryButtons, true);
assert.strictEqual(loadConfig().enableHistoryButtons, true);
});
check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => {
const result = saveConfig({
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],