Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1775220312 | ||
|
|
7ed2d4744c | ||
|
|
9b646b99f8 | ||
|
|
5fc7450af7 | ||
|
|
023a64b62c | ||
|
|
49c35f765b | ||
|
|
76866e8bbc | ||
|
|
aa7950aa9e | ||
|
|
e08133dfc0 | ||
|
|
24c95c91f2 | ||
|
|
2ef8f68b9b | ||
|
|
f4da2608cd | ||
|
|
f0a0c4ad40 | ||
|
|
b4c844b124 | ||
|
|
a15478b382 | ||
|
|
7244cfd918 | ||
|
|
5b79608695 |
@@ -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)
|
- **1-finger swipe** (dual mode) - Navigate within page (arrow keys)
|
||||||
- **On-screen keyboard** - Auto-shows on text fields or click keyboard icon
|
- **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
|
- **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
|
### On-Screen Keyboard
|
||||||
- **HTML-based keyboard** with full QWERTY layout
|
- **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**
|
- **IPP Everywhere support**
|
||||||
- **PDF printing** via cups-pdf
|
- **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
|
### Remote Access
|
||||||
- **VNC** - x11vnc for remote desktop
|
- **VNC** - x11vnc for remote desktop
|
||||||
- **WireGuard VPN** - Config paste support
|
- **WireGuard VPN** - Config paste support
|
||||||
@@ -766,7 +776,9 @@ smb://WORKGROUP/COMPUTER/PrinterName
|
|||||||
**Keyboard Shortcuts:**
|
**Keyboard Shortcuts:**
|
||||||
- `Ctrl+Tab` or `Ctrl+]` - Next tab
|
- `Ctrl+Tab` or `Ctrl+]` - Next tab
|
||||||
- `Ctrl+Shift+Tab` or `Ctrl+[` - Previous 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
|
- `F10` or `Ctrl+H` - Toggle hidden tabs
|
||||||
- `Escape` - Return to normal tabs (from hidden)
|
- `Escape` - Return to normal tabs (from hidden)
|
||||||
- `Ctrl+Alt+Delete` or `Ctrl+Alt+P` - Power menu
|
- `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
|
- **Repository:** https://github.com/netbirdio/netbird
|
||||||
- **Full License:** https://github.com/netbirdio/netbird/blob/main/LICENSE
|
- **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
@@ -38,7 +38,11 @@
|
|||||||
# Display/Lockout, the webui/ Node app installed as a systemd
|
# Display/Lockout, the webui/ Node app installed as a systemd
|
||||||
# service; no login of its own, put it behind your own reverse proxy
|
# 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
|
# 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/
|
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
|
||||||
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
|
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
|
||||||
# manual update, fix blank screen), Factory Reset
|
# manual update, fix blank screen), Factory Reset
|
||||||
@@ -67,6 +71,8 @@ source "$SCRIPT_DIR/lib/menu.sh"
|
|||||||
source "$SCRIPT_DIR/lib/config.sh"
|
source "$SCRIPT_DIR/lib/config.sh"
|
||||||
# shellcheck source=lib/electron.sh
|
# shellcheck source=lib/electron.sh
|
||||||
source "$SCRIPT_DIR/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
|
# shellcheck source=menus/sites.sh
|
||||||
source "$SCRIPT_DIR/menus/sites.sh"
|
source "$SCRIPT_DIR/menus/sites.sh"
|
||||||
# shellcheck source=menus/display.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"
|
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
||||||
# shellcheck source=menus/addon_webui.sh
|
# shellcheck source=menus/addon_webui.sh
|
||||||
source "$SCRIPT_DIR/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
|
# shellcheck source=menus/advanced_electron.sh
|
||||||
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
||||||
# shellcheck source=menus/advanced_upgrade.sh
|
# shellcheck source=menus/advanced_upgrade.sh
|
||||||
@@ -179,8 +187,8 @@ core_settings_menu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addons_menu_builder() {
|
addons_menu_builder() {
|
||||||
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI")
|
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)
|
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() {
|
addons_menu() {
|
||||||
|
|||||||
+41
-2
@@ -60,6 +60,7 @@ let allowNavigation='same-origin';
|
|||||||
let enablePauseButton=true;
|
let enablePauseButton=true;
|
||||||
let enableKeyboardButton=true;
|
let enableKeyboardButton=true;
|
||||||
let enableNavButton=true;
|
let enableNavButton=true;
|
||||||
|
let enableHistoryButtons=false;
|
||||||
let enablePasswordProtection=false;
|
let enablePasswordProtection=false;
|
||||||
let lockoutPassword="";
|
let lockoutPassword="";
|
||||||
let lockoutTimeout=0;
|
let lockoutTimeout=0;
|
||||||
@@ -102,6 +103,7 @@ function loadConfig(){
|
|||||||
enablePauseButton=(config.enablePauseButton!==false);
|
enablePauseButton=(config.enablePauseButton!==false);
|
||||||
enableKeyboardButton=(config.enableKeyboardButton!==false);
|
enableKeyboardButton=(config.enableKeyboardButton!==false);
|
||||||
enableNavButton=(config.enableNavButton!==false);
|
enableNavButton=(config.enableNavButton!==false);
|
||||||
|
enableHistoryButtons=(config.enableHistoryButtons===true);
|
||||||
enablePasswordProtection=(config.enablePasswordProtection===true);
|
enablePasswordProtection=(config.enablePasswordProtection===true);
|
||||||
lockoutPassword=config.lockoutPassword||"";
|
lockoutPassword=config.lockoutPassword||"";
|
||||||
lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms
|
lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms
|
||||||
@@ -122,6 +124,7 @@ function loadConfig(){
|
|||||||
console.log('[CONFIG] Navigation:',allowNavigation);
|
console.log('[CONFIG] Navigation:',allowNavigation);
|
||||||
console.log('[CONFIG] Pause button:',enablePauseButton);
|
console.log('[CONFIG] Pause button:',enablePauseButton);
|
||||||
console.log('[CONFIG] Keyboard button:',enableKeyboardButton);
|
console.log('[CONFIG] Keyboard button:',enableKeyboardButton);
|
||||||
|
console.log('[CONFIG] Back/forward buttons:',enableHistoryButtons);
|
||||||
console.log('[CONFIG] Password protection:',enablePasswordProtection);
|
console.log('[CONFIG] Password protection:',enablePasswordProtection);
|
||||||
console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes');
|
console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes');
|
||||||
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
|
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
|
||||||
@@ -1145,6 +1148,27 @@ function returnToTabs(){
|
|||||||
markActivity();
|
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(){
|
function showPinEntry(){
|
||||||
if(pinWindow&&!pinWindow.isDestroyed()){
|
if(pinWindow&&!pinWindow.isDestroyed()){
|
||||||
pinWindow.focus();
|
pinWindow.focus();
|
||||||
@@ -1526,6 +1550,11 @@ async function createWindow(){
|
|||||||
|
|
||||||
mainWindow.on('focus',()=>markActivity());
|
mainWindow.on('focus',()=>markActivity());
|
||||||
mainWindow.webContents.on('before-input-event',()=>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){
|
if(!tabs.length){
|
||||||
mainWindow.loadURL('data:text/html,<body>No Sites Configured</body>');
|
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);
|
console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton);
|
||||||
|
|
||||||
view.webContents.send('nav-button-enabled',enableNavButton);
|
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 - sending nav-button-enabled: '+enableNavButton);
|
||||||
|
|
||||||
console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url);
|
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();}
|
else if(action==='reload'){app.relaunch();app.quit();}
|
||||||
});
|
});
|
||||||
ipcMain.on('toggle-hidden',toggleHidden);
|
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('return-to-tabs',forceReturnToTabs);
|
||||||
ipcMain.on('user-activity',markActivity);
|
ipcMain.on('user-activity',markActivity);
|
||||||
ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();});
|
ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();});
|
||||||
@@ -1843,10 +1876,16 @@ async function createWindow(){
|
|||||||
globalShortcut.register('Control+Shift+Tab',()=>{prevTab();});
|
globalShortcut.register('Control+Shift+Tab',()=>{prevTab();});
|
||||||
globalShortcut.register('Control+]',()=>{nextTab();});
|
globalShortcut.register('Control+]',()=>{nextTab();});
|
||||||
globalShortcut.register('Control+[',()=>{prevTab();});
|
globalShortcut.register('Control+[',()=>{prevTab();});
|
||||||
globalShortcut.register('Alt+Right',()=>{nextTab();});
|
// The standard browser keys for switching tabs. (Alt+Left/Right used to
|
||||||
globalShortcut.register('Alt+Left',()=>{prevTab();});
|
// 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('F10',toggleHidden);
|
||||||
globalShortcut.register('Control+H',toggleHidden);
|
globalShortcut.register('Control+H',toggleHidden);
|
||||||
globalShortcut.register('Escape',forceReturnToTabs);
|
globalShortcut.register('Escape',forceReturnToTabs);
|
||||||
|
|||||||
@@ -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)=>{
|
ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
|
||||||
navButtonEnabled=enabled;
|
navButtonEnabled=enabled;
|
||||||
console.log('[NAV-BTN] Navigation button enabled: '+enabled);
|
console.log('[NAV-BTN] Navigation button enabled: '+enabled);
|
||||||
@@ -253,6 +266,56 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
document.body.appendChild(navButton);
|
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)
|
// Power button in top-right corner (follows same show/hide logic as nav button)
|
||||||
let powerButton=null;
|
let powerButton=null;
|
||||||
let powerButtonHideTimer=null;
|
let powerButtonHideTimer=null;
|
||||||
@@ -918,11 +981,23 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
if(navButtonEnabled){
|
if(navButtonEnabled){
|
||||||
showNavButton();
|
showNavButton();
|
||||||
}
|
}
|
||||||
|
showHistoryBar();
|
||||||
|
|
||||||
// Always show power button on user interaction
|
// Always show power button on user interaction
|
||||||
showPowerButton();
|
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
|
// Show pause button on any user interaction
|
||||||
const pauseButtonTriggers=['mousedown','touchstart','keydown'];
|
const pauseButtonTriggers=['mousedown','touchstart','keydown'];
|
||||||
pauseButtonTriggers.forEach(eventType=>{
|
pauseButtonTriggers.forEach(eventType=>{
|
||||||
|
|||||||
+15
-2
@@ -42,6 +42,11 @@
|
|||||||
# other addon's own scripts.
|
# other addon's own scripts.
|
||||||
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
: "${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
|
# Offline install bundle - only present on systems installed from an ISO
|
||||||
# built with `iso/build-iso.sh --offline`: a local apt repository (every
|
# built with `iso/build-iso.sh --offline`: a local apt repository (every
|
||||||
# package provisioning and the CUPS addon install) plus prebuilt
|
# package provisioning and the CUPS addon install) plus prebuilt
|
||||||
@@ -74,6 +79,7 @@ INACTIVITY_TIMEOUT=120
|
|||||||
ENABLE_PAUSE_BUTTON="true"
|
ENABLE_PAUSE_BUTTON="true"
|
||||||
ENABLE_KEYBOARD_BUTTON="true"
|
ENABLE_KEYBOARD_BUTTON="true"
|
||||||
ENABLE_NAV_BUTTON="true"
|
ENABLE_NAV_BUTTON="true"
|
||||||
|
ENABLE_HISTORY_BUTTONS="false"
|
||||||
ENABLE_PASSWORD_PROTECTION="false"
|
ENABLE_PASSWORD_PROTECTION="false"
|
||||||
LOCKOUT_PASSWORD=""
|
LOCKOUT_PASSWORD=""
|
||||||
LOCKOUT_TIMEOUT=0
|
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")
|
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")
|
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=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null)
|
||||||
[[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false"
|
[[ "$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=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null)
|
||||||
[[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false"
|
[[ "$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=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null)
|
||||||
[[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false"
|
[[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false"
|
||||||
|
|
||||||
@@ -258,6 +267,9 @@ save_config() {
|
|||||||
local nav_btn_json="true"
|
local nav_btn_json="true"
|
||||||
[[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false"
|
[[ "$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"
|
local password_json="false"
|
||||||
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
|
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
|
||||||
|
|
||||||
@@ -296,6 +308,7 @@ save_config() {
|
|||||||
--argjson enablePauseButton "$pause_btn_json" \
|
--argjson enablePauseButton "$pause_btn_json" \
|
||||||
--argjson enableKeyboardButton "$keyboard_btn_json" \
|
--argjson enableKeyboardButton "$keyboard_btn_json" \
|
||||||
--argjson enableNavButton "$nav_btn_json" \
|
--argjson enableNavButton "$nav_btn_json" \
|
||||||
|
--argjson enableHistoryButtons "$history_btn_json" \
|
||||||
--argjson enablePasswordProtection "$password_json" \
|
--argjson enablePasswordProtection "$password_json" \
|
||||||
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
|
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
|
||||||
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
|
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
|
||||||
@@ -309,7 +322,7 @@ save_config() {
|
|||||||
--arg autheliaURL "${AUTHELIA_URL:-}" \
|
--arg autheliaURL "${AUTHELIA_URL:-}" \
|
||||||
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
|
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
|
||||||
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \
|
--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
|
if [[ ${#URLS[@]} -gt 0 ]]; then
|
||||||
for idx in "${!URLS[@]}"; do
|
for idx in "${!URLS[@]}"; do
|
||||||
|
|||||||
+106
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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:
|
||||||
Executable
+168
@@ -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
|
||||||
@@ -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"]
|
||||||
Executable
+177
@@ -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}";
|
||||||
Executable
+115
@@ -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
|
||||||
@@ -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: "" }
|
||||||
@@ -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 }
|
||||||
@@ -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"
|
||||||
@@ -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><firstname> <surname></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"
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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"> 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>
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ clone_detect_addons() {
|
|||||||
command -v netbird &>/dev/null && addons+=("netbird")
|
command -v netbird &>/dev/null && addons+=("netbird")
|
||||||
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
|
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
|
||||||
webui_is_installed 2>/dev/null && addons+=("webui")
|
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
|
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
local authelia_url
|
local authelia_url
|
||||||
@@ -243,6 +244,13 @@ action_apply_clone_settings() {
|
|||||||
fi ;;
|
fi ;;
|
||||||
asterisk_intercom)
|
asterisk_intercom)
|
||||||
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
|
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)
|
authelia)
|
||||||
echo " [!] Authelia - needs a fresh login on this device" ;;
|
echo " [!] Authelia - needs a fresh login on this device" ;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
|
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
|
||||||
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
|
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
|
||||||
# squeezelite_do_uninstall, asterisk_intercom_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
|
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
|
||||||
# re-implementing removal logic for each addon a second time here - if an
|
# re-implementing removal logic for each addon a second time here - if an
|
||||||
# addon's uninstall logic changes, this picks it up automatically. Only
|
# 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 " • Remote access (VNC, WireGuard, Tailscale, Netbird)"
|
||||||
echo " • Asterisk Intercom (Baresip)"
|
echo " • Asterisk Intercom (Baresip)"
|
||||||
echo " • Web UI"
|
echo " • Web UI"
|
||||||
|
echo " • Library Catalog (Koha) and ALL the books in it"
|
||||||
echo " • LightDM and Openbox"
|
echo " • LightDM and Openbox"
|
||||||
echo " • All kiosk schedules and services"
|
echo " • All kiosk schedules and services"
|
||||||
echo " • Emergency hotspot configuration"
|
echo " • Emergency hotspot configuration"
|
||||||
@@ -90,6 +91,7 @@ action_complete_uninstall() {
|
|||||||
squeezelite_do_uninstall
|
squeezelite_do_uninstall
|
||||||
asterisk_intercom_do_uninstall purge
|
asterisk_intercom_do_uninstall purge
|
||||||
webui_do_uninstall
|
webui_do_uninstall
|
||||||
|
library_do_uninstall purge
|
||||||
|
|
||||||
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
|
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
|
||||||
power_schedule_do_remove_all
|
power_schedule_do_remove_all
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ display_status() {
|
|||||||
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
|
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
|
||||||
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
|
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
|
||||||
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
|
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
|
||||||
|
echo "Back/forward btns: $(onoff "$ENABLE_HISTORY_BUTTONS")"
|
||||||
}
|
}
|
||||||
|
|
||||||
display_menu_builder() {
|
display_menu_builder() {
|
||||||
@@ -29,6 +30,7 @@ display_menu_builder() {
|
|||||||
"Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))"
|
"Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))"
|
||||||
"Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))"
|
"Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))"
|
||||||
"Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))"
|
"Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))"
|
||||||
|
"Toggle on-screen back/forward buttons (currently: $(onoff "$ENABLE_HISTORY_BUTTONS"))"
|
||||||
)
|
)
|
||||||
MENU_HANDLERS=(
|
MENU_HANDLERS=(
|
||||||
action_set_touch_mode
|
action_set_touch_mode
|
||||||
@@ -36,6 +38,7 @@ display_menu_builder() {
|
|||||||
action_toggle_pause_button
|
action_toggle_pause_button
|
||||||
action_toggle_keyboard_button
|
action_toggle_keyboard_button
|
||||||
action_toggle_nav_button
|
action_toggle_nav_button
|
||||||
|
action_toggle_history_buttons
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,3 +134,18 @@ action_toggle_nav_button() {
|
|||||||
fi
|
fi
|
||||||
save_config
|
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
|
||||||
|
}
|
||||||
|
|||||||
Executable
+203
@@ -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 ]]
|
||||||
@@ -40,6 +40,7 @@ const SCALAR_DEFAULTS = {
|
|||||||
enablePauseButton: true,
|
enablePauseButton: true,
|
||||||
enableKeyboardButton: true,
|
enableKeyboardButton: true,
|
||||||
enableNavButton: true,
|
enableNavButton: true,
|
||||||
|
enableHistoryButtons: false,
|
||||||
enablePasswordProtection: false,
|
enablePasswordProtection: false,
|
||||||
lockoutTimeout: 0,
|
lockoutTimeout: 0,
|
||||||
lockoutAtTime: '',
|
lockoutAtTime: '',
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ displayForm.addEventListener('submit', async (e) => {
|
|||||||
enablePauseButton: fd.get('enablePauseButton') === 'on',
|
enablePauseButton: fd.get('enablePauseButton') === 'on',
|
||||||
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
|
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
|
||||||
enableNavButton: fd.get('enableNavButton') === 'on',
|
enableNavButton: fd.get('enableNavButton') === 'on',
|
||||||
|
enableHistoryButtons: fd.get('enableHistoryButtons') === 'on',
|
||||||
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
|
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
|
||||||
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
|
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
|
||||||
};
|
};
|
||||||
@@ -282,6 +283,7 @@ function populateAll(config) {
|
|||||||
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
|
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
|
||||||
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
|
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
|
||||||
displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
|
displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
|
||||||
|
displayForm.elements.enableHistoryButtons.checked = !!config.enableHistoryButtons;
|
||||||
updateHomeTabOptions(config.tabs);
|
updateHomeTabOptions(config.tabs);
|
||||||
homeTabSelect.value = String(config.homeTabIndex);
|
homeTabSelect.value = String(config.homeTabIndex);
|
||||||
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
|
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
|
<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="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="enableNavButton"> Navigation/help button</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" name="enableHistoryButtons"> On-screen back/forward buttons (top middle)</label>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Home page</legend>
|
<legend>Home page</legend>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ app.put('/api/config', (req, res) => {
|
|||||||
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
|
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
|
||||||
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
|
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
|
||||||
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton;
|
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton;
|
||||||
|
if (body.enableHistoryButtons !== undefined) patch.enableHistoryButtons = !!body.enableHistoryButtons;
|
||||||
|
|
||||||
let tabCount;
|
let tabCount;
|
||||||
if (body.tabs !== undefined) {
|
if (body.tabs !== undefined) {
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ check('saveConfig merge preserves fields this app never tracks (the previously-f
|
|||||||
assert.strictEqual(onDisk.enableNavButton, false);
|
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', () => {
|
check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => {
|
||||||
const result = saveConfig({
|
const result = saveConfig({
|
||||||
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],
|
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],
|
||||||
|
|||||||
Reference in New Issue
Block a user