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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
Claude
2026-11-06 11:07:02 +00:00
parent aa7950aa9e
commit 76866e8bbc
8 changed files with 601 additions and 98 deletions
+54 -9
View File
@@ -37,9 +37,9 @@ 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 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. 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**. 2. If you already have the title, you are asked whether to add **another copy**.
3. Otherwise the record is added with the **next barcode number**, the Dewey (or LC) call 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 number from the record, the item type chosen at the top (Book, Movie, Music, Game) and the shelf
the shelf you picked. you picked.
4. Nothing found? A short form lets you type the title and it is added anyway. 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 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. are never reused, because a label may already be printed.
@@ -54,11 +54,40 @@ stack of books without looking at the screen.
Phone camera scanning is not built in (browsers only allow the camera on HTTPS or Phone camera scanning is not built in (browsers only allow the camera on HTTPS or
`localhost`). A scanner-app that "types" the code into the box works. `localhost`). A scanner-app that "types" the code into the box works.
## 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 ## Printing labels
Quick Add shows the barcode range for the session ("Barcodes 12 to 27"). In the staff From the Add books page, **Print labels** opens Koha's label printer with the barcode range of what you
interface open **Cataloging → Label creator → Print barcode range**, enter those numbers, just added filled in (you may be asked to log in to the staff interface). Pick a template and layout
and pick a template and layout: and print at 100% scale (not "fit to page") so the labels line up.
| Template | Sheet | | Template | Sheet |
|---|---| |---|---|
@@ -72,7 +101,21 @@ and pick a template and layout:
| Spine - call number | call number only | | Spine - call number | call number only |
| Barcode only | barcode | | Barcode only | barcode |
Print at 100% scale (not "fit to page") so the labels line up. ## 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 ## Finding the books
@@ -80,10 +123,12 @@ The public catalogue searches title, author, subject and ISBN, and shows each co
availability, call number and **shelving location**. availability, call number and **shelving location**.
On the Add books page, pick the **Shelf / room** before you scan; every book you add On the Add books page, pick the **Shelf / room** before you scan; every book you add
gets it, and the page remembers your choice. Change it whenever you move to another shelf. 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 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, starts with a few rooms such as Fiction, General stacks and Reference. Add your own there,
for example "Living room - bookcase 2". for example "Living room - bookcase 2". (Koha has no way to add a room from the Add books page.)
## Common tasks ## Common tasks
+4 -1
View File
@@ -37,6 +37,9 @@ services:
KOHA_ADMIN_USER: ${ADMIN_USER:-admin} KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
KOHA_ADMIN_PASS: ${ADMIN_PASSWORD} KOHA_ADMIN_PASS: ${ADMIN_PASSWORD}
KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library} KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library}
KOHA_LIBRARY_CODE: ${LIBRARY_CODE:-HOME}
KOHA_SHELVES: ${SHELVES:-}
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
ports: ports:
- "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80" - "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081" - "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
@@ -58,7 +61,7 @@ services:
KOHA_API: http://koha:8081/api/v1 KOHA_API: http://koha:8081/api/v1
ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
KOHA_LIBRARY: HOME KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
OPAC_PORT: ${OPAC_PORT:-8080} OPAC_PORT: ${OPAC_PORT:-8080}
STAFF_PORT: ${STAFF_PORT:-8081} STAFF_PORT: ${STAFF_PORT:-8081}
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary} QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
+6
View File
@@ -8,6 +8,9 @@
# #
# Optional environment variables: # Optional environment variables:
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue # 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) # 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 # EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
set -euo pipefail set -euo pipefail
@@ -106,6 +109,9 @@ if [ ! -f .env ]; then
cat > .env << ENVEOF cat > .env << ENVEOF
# Generated by install.sh - keep this file private. # Generated by install.sh - keep this file private.
LIBRARY_NAME="${LIBRARY_NAME:-My Library}" LIBRARY_NAME="${LIBRARY_NAME:-My Library}"
LIBRARY_CODE=${LIBRARY_CODE:-HOME}
SHELVES="${SHELVES:-}"
ITEM_TYPES="${ITEM_TYPES:-}"
TZ=${TZ_NOW} TZ=${TZ_NOW}
OPAC_PORT=$(free_port "${OPAC_PORT:-8080}") OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}") STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
+58 -5
View File
@@ -11,6 +11,9 @@ use Koha;
use Koha::Libraries; use Koha::Libraries;
use Koha::Patrons; use Koha::Patrons;
use Koha::Patron::Categories; use Koha::Patron::Categories;
use Koha::CirculationRules;
use Koha::ItemTypes;
use Koha::AuthorisedValues;
$| = 1; $| = 1;
my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed'; my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed';
@@ -20,6 +23,7 @@ my %env = (
admin_user => $ENV{KOHA_ADMIN_USER} || 'admin', admin_user => $ENV{KOHA_ADMIN_USER} || 'admin',
admin_pass => $admin_pass, admin_pass => $admin_pass,
library => $ENV{KOHA_LIBRARY_NAME} || 'My Library', 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 $installer = C4::Installer->new();
@@ -74,8 +78,8 @@ for my $y (sort glob("$seed_dir/*.yml")) {
# ---- Library, patron categories, owner account ---------------------------- # ---- Library, patron categories, owner account ----------------------------
Koha::Library->new({ Koha::Library->new({
branchcode => 'HOME', branchname => $env{library}, branchcode => $env{code}, branchname => $env{library},
})->store unless Koha::Libraries->find('HOME'); })->store unless Koha::Libraries->find( $env{code} );
for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) { for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) {
Koha::Patron::Category->new({ Koha::Patron::Category->new({
@@ -85,8 +89,8 @@ for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrowe
} }
my $admin = Koha::Patron->new({ my $admin = Koha::Patron->new({
cardnumber => 'ADMIN', surname => 'Librarian', userid => $env{admin_user}, cardnumber => '1000000', surname => 'Librarian', userid => $env{admin_user},
branchcode => 'HOME', categorycode => 'OWNER', flags => 1, # 1 = superlibrarian branchcode => $env{code}, categorycode => 'OWNER', flags => 1, # 1 = superlibrarian
})->store; })->store;
$admin->set_password({ password => $env{admin_pass}, skip_validation => 1 }); $admin->set_password({ password => $env{admin_pass}, skip_validation => 1 });
@@ -102,9 +106,58 @@ $dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK'
$dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean' $dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean'
WHERE tagfield = '024' AND tagsubfield = 'a' AND (kohafield IS NULL OR kohafield = '')}); 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 ---------------------------------------------------------- # ---- Preferences ----------------------------------------------------------
my %prefs = ( my %prefs = (
autoBarcode => 'incremental', # new items get a barcode automatically 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 itemcallnumber => '082ab', # Dewey call number copied from the MARC record
RESTBasicAuth => 1, # lets the quick-add page use the REST API RESTBasicAuth => 1, # lets the quick-add page use the REST API
OpenLibraryCovers => 1, # cover images in the catalogue OpenLibraryCovers => 1, # cover images in the catalogue
+2 -1
View File
@@ -81,7 +81,8 @@ if [ "$TABLES" = "0" ]; then
runuser -u "$INST-koha" -- env \ runuser -u "$INST-koha" -- env \
KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \ 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_ADMIN_USER="${KOHA_ADMIN_USER:-admin}" KOHA_ADMIN_PASS="$KOHA_ADMIN_PASS" \
KOHA_LIBRARY_NAME="${KOHA_LIBRARY_NAME:-My Library}" TZ="$TZ" \ 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 perl /opt/koha-init/bootstrap.pl
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \ 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'") -e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
+52
View File
@@ -0,0 +1,52 @@
---
description:
- "Library card sheet and layout for Tools > Patron card creator"
tables:
- creator_templates:
translatable: [ template_desc ]
multiline: [ ]
rows:
# Avery 5371 / 8871 business-card sheet: 10 cards, 2 across x 5 down.
- template_code: "Avery 5371 | 2 x 3-1/2 (10 per sheet)"
template_desc: "Library cards: 2 columns, 5 rows."
page_width: 8.5
page_height: 11
label_width: 3.5
label_height: 2
top_text_margin: 0
left_text_margin: 0
top_margin: 0.5
left_margin: 0.75
cols: 2
rows: 5
col_gap: 0
row_gap: 0
units: "INCH"
creator: "Patroncards"
- creator_layouts:
translatable: [ layout_name, layout_xml ]
multiline: [ layout_xml ]
rows:
# Name and library at the top, card number as a scannable barcode.
- layout_name: "Library card"
barcode_type: CODE39
start_label: 1
printing_type: BAR
guidebox: 0
oblique_title: 0
font: TR
font_size: 10
units: POINT
callnum_split: 0
text_justify: L
format_string: "barcode"
layout_xml:
- "<opt page_side=\"F\" units=\"POINT\">"
- " <text>&lt;firstname&gt; &lt;surname&gt;</text>"
- " <text enable=\"1\" font=\"TB\" font_size=\"14\" llx=\"14\" lly=\"118\" text_alignment=\"L\" />"
- " <text>Library card</text>"
- " <text enable=\"1\" font=\"TR\" font_size=\"9\" llx=\"14\" lly=\"100\" text_alignment=\"L\" />"
- " <barcode print=\"1\" type=\"CODE39\" llx=\"14\" lly=\"30\" height_scale=\"0.012\" width_scale=\"0.6\" text_print=\"1\" />"
- "</opt>"
creator: "Patroncards"
+298 -64
View File
@@ -13,6 +13,7 @@ import hmac
import json import json
import os import os
import re import re
import secrets
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
@@ -34,12 +35,11 @@ PORT = int(os.environ.get("PORT", "8082"))
SOURCES = [s.strip() for s in os.environ.get( SOURCES = [s.strip() for s in os.environ.get(
"QUICKADD_SOURCES", "z3950:lx2.loc.gov:210/LCDB,openlibrary").split(",") if s.strip()] "QUICKADD_SOURCES", "z3950:lx2.loc.gov:210/LCDB,openlibrary").split(",") if s.strip()]
Z3950_TIMEOUT = int(os.environ.get("Z3950_TIMEOUT", "12")) Z3950_TIMEOUT = int(os.environ.get("Z3950_TIMEOUT", "12"))
ITEM_TYPES = {"BOOK": "Book", "DVD": "Movie", "CD": "Music", "GAME": "Game", "OTHER": "Other"} DEFAULT_TYPES = {"BOOK": "Book", "DVD": "Movie", "CD": "Music", "GAME": "Game", "OTHER": "Other"}
NS = {"m": "http://www.loc.gov/MARC21/slim"} NS = {"m": "http://www.loc.gov/MARC21/slim"}
ET.register_namespace("", NS["m"]) ET.register_namespace("", NS["m"])
RECENT_FILE = os.path.join(DATA_DIR, "recent.json") RECENT_FILE = os.path.join(DATA_DIR, "recent.json")
LOCK = threading.Lock()
# ---------------------------------------------------------------- identifiers # ---------------------------------------------------------------- identifiers
@@ -254,6 +254,30 @@ def koha(method, path, body=None, ctype="application/json", headers=None, query=
raise KohaError(503, str(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": []} _LOC_CACHE = {"at": 0.0, "value": []}
@@ -293,41 +317,109 @@ def find_existing(code):
return None return None
def next_barcode(): # ------------------------------------------------------------------- settings
last = koha("GET", "/items", query={"_per_page": "1", "_order_by": "-item_id"}) SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
n = 0 COUNTER_FILE = os.path.join(DATA_DIR, "counter.json")
if last and re.fullmatch(r"\d+", last[0].get("external_id") or ""): MIN_WIDTH, MAX_WIDTH = 7, 10 # 6 all-digit Code 39 symbols can be misread as Code 32 by some scanners
n = int(last[0]["external_id"]) DEFAULT_SETTINGS = {"barcode_mode": "auto", "width": MIN_WIDTH, "location": ""}
return max(n, state_counter()) + 1 BARCODE_MODES = ("auto", "scan", "none")
LOCK = threading.RLock()
def state_counter(): def load_settings():
s = dict(DEFAULT_SETTINGS)
try: try:
return int(open(os.path.join(DATA_DIR, "barcode")).read()) s.update(json.load(open(SETTINGS_FILE)))
except (OSError, ValueError): except (OSError, ValueError):
return 0
def save_counter(n):
try:
os.makedirs(DATA_DIR, exist_ok=True)
open(os.path.join(DATA_DIR, "barcode"), "w").write(str(n))
except OSError:
pass pass
def add_item(biblio_id, itype, callnum, location=None):
"""Create an item with the next free barcode (retrying on collisions)."""
start = next_barcode()
for attempt in range(10):
bc = str(start + attempt)
try: try:
item = koha("POST", "/biblios/%s/items" % biblio_id, { s["width"] = min(max(int(s["width"]), MIN_WIDTH), MAX_WIDTH)
"external_id": bc, "item_type_id": itype, "home_library_id": LIBRARY, except (TypeError, ValueError):
"holding_library_id": LIBRARY, "callnumber": callnum or None, s["width"] = MIN_WIDTH
"location": location}) return s
save_counter(start + attempt)
return item
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: except KohaError as e:
if e.status == 409 or "Duplicate" in e.body: if e.status == 409 or "Duplicate" in e.body:
continue continue
@@ -348,8 +440,7 @@ def remember(entry):
rec = load_recent() rec = load_recent()
rec.insert(0, entry) rec.insert(0, entry)
try: try:
os.makedirs(DATA_DIR, exist_ok=True) save_json(RECENT_FILE, rec[:300])
json.dump(rec[:300], open(RECENT_FILE, "w"))
except OSError: except OSError:
pass pass
@@ -358,7 +449,7 @@ def forget(item_id):
with LOCK: with LOCK:
rec = [r for r in load_recent() if r.get("item_id") != item_id] rec = [r for r in load_recent() if r.get("item_id") != item_id]
try: try:
json.dump(rec, open(RECENT_FILE, "w")) save_json(RECENT_FILE, rec)
except OSError: except OSError:
pass pass
@@ -379,36 +470,92 @@ def lookup(code):
return None, None return None, None
def op_add(payload): 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")) code = clean_code(payload.get("code"))
itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK" itype = valid_type(payload.get("type"))
location = valid_location(payload.get("location"))
if not code: if not code:
return 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."} return "done", 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."}
location = chosen_location(payload)
with LOCK: with LOCK:
existing = find_existing(code) existing = find_existing(code)
if existing and not payload.get("copy"): if existing and not payload.get("copy"):
return 200, dict(existing, status="duplicate", code=code) return "done", 200, dict(existing, status="duplicate", code=code)
if existing: # add another copy of a known title if existing:
with LOCK: return "plan", {"code": code, "itype": itype, "location": location, "existing": existing,
item = add_item(existing["biblio_id"], itype, None, location) "xml": None, "title": existing["title"], "author": existing["author"],
entry = entry_for(code, existing["title"], existing["author"], item, existing["biblio_id"], "extra copy") "callnum": None, "source": "extra copy"}
remember(entry)
return 200, dict(entry, status="added")
xml, source = lookup(code) xml, source = lookup(code)
if not xml: if not xml:
return 200, {"status": "notfound", "code": code} return "done", 200, {"status": "notfound", "code": code}
rec = parse_marc(xml) rec = parse_marc(xml)
info = describe(rec) info = describe(rec)
ensure_identifier(rec, code) ensure_identifier(rec, code)
set_item_type(rec, itype) 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: with LOCK:
bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml", created = None
{"x-confirm-not-duplicate": "1"}) if plan["existing"]:
item = add_item(bib["id"], itype, call_number(rec, info["author"]), location) biblio_id = plan["existing"]["biblio_id"]
entry = entry_for(code, info["title"], info["author"], item, bib["id"], source) 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) remember(entry)
return 200, dict(entry, status="added") 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): def op_manual(payload):
@@ -416,18 +563,42 @@ def op_manual(payload):
if not title: if not title:
return 400, {"status": "invalid", "message": "A title is required."} return 400, {"status": "invalid", "message": "A title is required."}
code = clean_code(payload.get("code")) code = clean_code(payload.get("code"))
itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK" itype = valid_type(payload.get("type"))
location = valid_location(payload.get("location"))
rec = manual_record(code, title[:240], (payload.get("author") or "").strip()[:120], rec = manual_record(code, title[:240], (payload.get("author") or "").strip()[:120],
(payload.get("year") or "").strip()[:8], (payload.get("publisher") or "").strip()[:120]) (payload.get("year") or "").strip()[:8], (payload.get("publisher") or "").strip()[:120])
set_item_type(rec, itype) set_item_type(rec, itype)
with LOCK: plan = {"code": code or "", "itype": itype, "location": chosen_location(payload), "existing": None,
bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml", "xml": ET.tostring(rec, encoding="utf-8"), "title": title, "author": (payload.get("author") or "").strip(),
{"x-confirm-not-duplicate": "1"}) "callnum": None, "source": "typed in"}
item = add_item(bib["id"], itype, None, location) return finish_or_ask(plan)
entry = entry_for(code or "", title, payload.get("author") or "", item, bib["id"], "typed in")
remember(entry)
return 200, dict(entry, status="added") 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): def entry_for(code, title, author, item, biblio_id, source):
@@ -454,6 +625,65 @@ def op_undo(payload):
return 200, {"status": "undone"} 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)}
# ----------------------------------------------------------------------- HTTP # ----------------------------------------------------------------------- HTTP
INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read() INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read()
@@ -474,7 +704,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer") self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy", self.send_header("Content-Security-Policy",
"default-src 'self'; img-src 'self' https://covers.openlibrary.org data:; " "default-src 'self'; img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'") "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
for k, v in (extra or {}).items(): for k, v in (extra or {}).items():
self.send_header(k, v) self.send_header(k, v)
@@ -511,9 +741,11 @@ class Handler(BaseHTTPRequestHandler):
if path == "/": if path == "/":
return self._send(200, INDEX, "text/html; charset=utf-8") return self._send(200, INDEX, "text/html; charset=utf-8")
if path == "/api/state": if path == "/api/state":
return self._send(200, {"recent": load_recent()[:100], "types": ITEM_TYPES, return self._send(200, {"recent": load_recent()[:100], "types": get_item_types(),
"library": LIBRARY, "locations": get_locations(), "staff_port": STAFF_PORT, "opac_port": OPAC_PORT, "library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "opac_port": OPAC_PORT,
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]}) "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"}) self._send(404, {"error": "not found"})
def do_POST(self): def do_POST(self):
@@ -524,7 +756,9 @@ class Handler(BaseHTTPRequestHandler):
if length > 20000: if length > 20000:
return self._send(413, {"error": "too large"}) return self._send(413, {"error": "too large"})
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
ops = {"/api/add": op_add, "/api/manual": op_manual, "/api/undo": op_undo} 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) fn = ops.get(self.path)
if not fn: if not fn:
return self._send(404, {"error": "not found"}) return self._send(404, {"error": "not found"})
+129 -20
View File
@@ -68,6 +68,16 @@
ul.list .u { color: var(--muted); font-size: .9rem; } 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; } .labels { background: var(--card); border: 1px dashed var(--line); border-radius: 12px; padding: 12px; margin-top: 14px; }
.small { color: var(--muted); font-size: .92rem; } .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); } .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
</style> </style>
</head> </head>
@@ -98,11 +108,39 @@
<ul class="list" id="session"></ul> <ul class="list" id="session"></ul>
<div class="labels" id="labels" hidden> <div class="labels" id="labels" hidden>
<strong id="range"></strong> <strong id="range"></strong>
<div class="small">Print spine labels and barcodes: open the label creator, <div class="small">Print barcode labels for these: the button opens the label printer with the range filled in
choose <em>Print barcode range</em>, and enter these numbers.</div> (you may be asked to log in to the staff interface).</div>
<div class="actions"><a id="lnk-labels" href="#"><button type="button">Open label creator</button></a></div> <div class="actions"><a id="lnk-labels" href="#" target="_blank" rel="noopener"><button type="button">Print labels</button></a></div>
</div> </div>
<details class="settings" id="settings">
<summary>Barcodes and settings</summary>
<fieldset>
<legend>Barcodes on new items</legend>
<label class="opt"><input type="radio" name="bcmode" value="auto"> Number them automatically</label>
<label class="opt"><input type="radio" name="bcmode" value="scan"> I stick a pre-printed barcode on each one: ask me to scan it</label>
<label class="opt"><input type="radio" name="bcmode" value="none"> No barcodes for now: I will add them later</label>
<div class="small" style="margin-top:6px">
Digits per barcode:
<select id="width"></select>
<span id="nextwrap">&nbsp;Next barcode: <strong id="nextbc"></strong></span>
</div>
<div class="small">Same-length numbers are what Koha's label printer needs for a range, so numbers are zero-padded (0000042). Seven or more digits keeps scanners from misreading a short all-number barcode as the pharmacy "Code 32" format.</div>
</fieldset>
<div class="tool">
<strong>Items without a barcode: <span id="missing">0</span></strong>
<div class="actions"><button type="button" id="assign">Give them barcodes</button></div>
</div>
<div class="tool">
<strong>Print a sheet of barcodes ahead of time</strong>
<div class="small">Reserve a block of numbers, print the sheet, and stick the labels on whenever you like.</div>
<div class="actions"><label for="reserve-count" class="sr">How many</label>
<input type="number" id="reserve-count" min="1" max="2000" value="80">
<button type="button" id="reserve">Reserve numbers</button></div>
</div>
<div id="tool-result" aria-live="polite"></div>
</details>
<h2>Earlier</h2> <h2>Earlier</h2>
<ul class="list" id="earlier"></ul> <ul class="list" id="earlier"></ul>
</main> </main>
@@ -110,7 +148,7 @@
<script> <script>
(() => { (() => {
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
let types = {}, sessionItems = [], busy = false, lastAdded = null, shelf = localStorage.getItem('qa-shelf') || '', type = localStorage.getItem('qa-type') || 'BOOK'; let types = {}, sessionItems = [], busy = false, lastAdded = null, shelf = '', awaiting = false, staffPort = '8081', settings = {}, type = localStorage.getItem('qa-type') || 'BOOK';
// --- small audible feedback so a stack of books can be scanned without looking // --- small audible feedback so a stack of books can be scanned without looking
let actx; let actx;
@@ -128,7 +166,11 @@
const SOUND = { added: [880], duplicate: [520, 520], notfound: [300], error: [220, 180] }; const SOUND = { added: [880], duplicate: [520, 520], notfound: [300], error: [220, 180] };
const el = (tag, props = {}, ...kids) => { const el = (tag, props = {}, ...kids) => {
const n = Object.assign(document.createElement(tag), props); const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k.includes('-')) n.setAttribute(k, v); // aria-*, data-*: attributes, not JS properties
else n[k] = v;
}
for (const k of kids) n.append(k); for (const k of kids) n.append(k);
return n; return n;
}; };
@@ -181,11 +223,14 @@
function render() { function render() {
$('session').replaceChildren(...sessionItems.map(e => rowFor(e, true))); $('session').replaceChildren(...sessionItems.map(e => rowFor(e, true)));
$('count').textContent = sessionItems.length ? '(' + sessionItems.length + ')' : ''; $('count').textContent = sessionItems.length ? '(' + sessionItems.length + ')' : '';
const nums = sessionItems.map(e => parseInt(e.barcode, 10)).filter(Number.isFinite); const bcs = sessionItems.map(e => e.barcode).filter(b => b && /^\d+$/.test(b));
$('labels').hidden = nums.length === 0; $('labels').hidden = bcs.length === 0;
if (nums.length) { if (bcs.length) {
const lo = Math.min(...nums), hi = Math.max(...nums); 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; $('range').textContent = lo === hi ? 'Barcode ' + lo : 'Barcodes ' + lo + ' to ' + hi;
$('lnk-labels').href = sameLen ? printLink(lo, hi) : $('lnk-labels').href;
} }
} }
@@ -193,19 +238,22 @@
if (busy) return; busy = true; if (busy) return; busy = true;
$('status').textContent = 'Looking up ' + code + '…'; $('status').textContent = 'Looking up ' + code + '…';
try { try {
const r = await api('/api/add', Object.assign({ code, type, location: shelf }, opts)); const r = await api('/api/add', Object.assign({ code, type }, opts));
handle(r, code); handle(r, code);
} catch (e) { } catch (e) {
beep(SOUND.error); show('bad', el('div', { className: 'body' }, 'Could not reach the quick-add service.')); beep(SOUND.error); show('bad', el('div', { className: 'body' }, 'Could not reach the quick-add service.'));
} finally { busy = false; $('code').focus(); } } finally { busy = false; if (!awaiting) $('code').focus(); }
} }
function handle(r, code) { function handle(r, code) {
$('status').textContent = ''; $('status').textContent = '';
if (r.status === 'added') { if (r.status === 'added') {
beep(SOUND.added); sessionItems.unshift(r); lastAdded = r.item_id; render(); beep(SOUND.added); sessionItems.unshift(r); lastAdded = r.item_id; render();
show('ok', cover(r.code), bookBody(r, [el('div', { className: 'meta' }, 'Added as barcode ', awaiting = false; $('code').focus();
el('span', { className: 'badge' }, r.barcode), (r.source === 'extra copy' ? ' · another copy of a title you already have' : ' · from ' + r.source))])); 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') { } else if (r.status === 'duplicate') {
beep(SOUND.duplicate); beep(SOUND.duplicate);
const more = el('button', { className: 'primary', type: 'button' }, 'Add another copy'); const more = el('button', { className: 'primary', type: 'button' }, 'Add another copy');
@@ -215,6 +263,8 @@
show('warn', cover(code), bookBody(r, [el('div', { className: 'meta' }, show('warn', cover(code), bookBody(r, [el('div', { className: 'meta' },
'You already have this (' + r.copies + (r.copies === 1 ? ' copy' : ' copies') + ').'), 'You already have this (' + r.copies + (r.copies === 1 ? ' copy' : ' copies') + ').'),
el('div', { className: 'actions' }, more, skip)])); 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') { } else if (r.status === 'notfound') {
beep(SOUND.notfound); manualForm(code); beep(SOUND.notfound); manualForm(code);
} else { } else {
@@ -223,6 +273,67 @@
} }
} }
function stickerPrompt(r) {
awaiting = true;
const input = el('input', { placeholder: 'Scan the barcode sticker', autocomplete: 'off', 'aria-label': 'Barcode sticker', inputMode: 'text' });
const form = el('form', { className: 'manual' });
const cancel = el('button', { type: 'button' }, 'Cancel');
cancel.onclick = async () => { await api('/api/cancel', { token: r.token }); awaiting = false;
$('result').replaceChildren(); $('code').focus(); };
form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), cancel));
form.onsubmit = async ev => {
ev.preventDefault();
const bc = input.value.trim(); if (!bc) return;
handle(await api('/api/finish', { token: r.token, barcode: bc }), r.code);
};
const msg = r.status === 'barcode_taken' ? 'Barcode ' + r.barcode + ' is already on another item. Scan a different sticker.'
: r.status === 'barcode_invalid' ? (r.message || 'That barcode cannot be used.')
: 'Now scan the barcode sticker you put on this one.';
if (r.status !== 'need_barcode') beep(SOUND.duplicate);
show('warn', bookBody(r, [el('div', { className: 'meta' }, msg), form]));
input.focus();
}
const printLink = (from, to) => 'http://' + location.hostname + ':' + staffPort +
'/cgi-bin/koha/labels/label-print.pl?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
function rangeNote(text, from, to) {
const note = el('div', { className: 'result-note' }, text + ' ');
if (from) {
const a = el('a', { href: printLink(from, to), target: '_blank', rel: 'noopener' }, 'Print these labels');
note.append(a, el('div', { className: 'small' }, 'Pick a template and layout on the next screen (you will be asked to log in to the staff interface).'));
}
$('tool-result').replaceChildren(note);
}
function applySettings(v) {
settings = v.settings;
document.querySelectorAll('input[name=bcmode]').forEach(i => { i.checked = i.value === settings.barcode_mode; });
const w = $('width');
if (!w.options.length) for (let n = 7; n <= 10; n++) w.append(el('option', { value: n }, String(n)));
w.value = settings.width;
$('nextwrap').hidden = settings.barcode_mode !== 'auto';
$('nextbc').textContent = v.next || '';
$('missing').textContent = v.without_barcode == null ? '?' : v.without_barcode;
$('assign').disabled = !v.without_barcode;
}
async function saveSettings(patch) { applySettings(await api('/api/settings', patch)); }
document.querySelectorAll('input[name=bcmode]').forEach(i => i.onchange = () => saveSettings({ barcode_mode: i.value }));
$('width').onchange = () => saveSettings({ width: parseInt($('width').value, 10) });
$('assign').onclick = async () => {
$('assign').disabled = true;
const r = await api('/api/barcodes/assign', {});
rangeNote(r.count ? r.count + ' item(s) now have barcodes ' + r.first + ' to ' + r.last + '.' : 'Every item already has a barcode.', r.first, r.last);
saveSettings({});
};
$('reserve').onclick = async () => {
const r = await api('/api/barcodes/reserve', { count: parseInt($('reserve-count').value, 10) });
if (r.status === 'reserved') { rangeNote('Reserved ' + r.first + ' to ' + r.last + ' (' + r.count + ' labels).', r.first, r.last); saveSettings({}); }
else rangeNote(r.message || 'Could not reserve that many.');
};
function manualForm(code) { function manualForm(code) {
const f = el('form', { className: 'manual' }); const f = el('form', { className: 'manual' });
const field = (name, ph, req) => el('input', { name, placeholder: ph, required: !!req, 'aria-label': ph }); const field = (name, ph, req) => el('input', { name, placeholder: ph, required: !!req, 'aria-label': ph });
@@ -234,7 +345,7 @@
f.onsubmit = async ev => { f.onsubmit = async ev => {
ev.preventDefault(); ev.preventDefault();
const d = Object.fromEntries(new FormData(f)); const d = Object.fromEntries(new FormData(f));
handle(await api('/api/manual', Object.assign(d, { code, type, location: shelf })), code); handle(await api('/api/manual', Object.assign(d, { code, type })), code);
}; };
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, 'Not found: ' + code), 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)); el('div', { className: 'meta' }, 'None of the catalogues knew this one. Type the details and it is added anyway.'), f));
@@ -258,19 +369,17 @@
(async () => { (async () => {
const s = await api('/api/state'); const s = await api('/api/state');
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons(); types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
if (s.locations && s.locations.length) { if (s.locations && s.locations.length) {
const sel = $('shelf'); const sel = $('shelf');
sel.replaceChildren(el('option', { value: '' }, '(not set)'), sel.replaceChildren(el('option', { value: '' }, '(not set / all in one place)'),
...s.locations.map(l => el('option', { value: l.value }, l.label))); ...s.locations.map(l => el('option', { value: l.value }, l.label)));
if (![...sel.options].some(o => o.value === shelf)) shelf = ''; if (![...sel.options].some(o => o.value === shelf)) shelf = '';
sel.value = shelf; sel.value = shelf;
sel.onchange = () => { shelf = sel.value; localStorage.setItem('qa-shelf', shelf); $('code').focus(); }; sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); };
$('shelfbox').hidden = false; $('shelfbox').hidden = false;
} }
const host = location.hostname; api('/api/settings').then(applySettings);
$('lnk-opac').href = 'http://' + host + ':' + s.opac_port + '/';
$('lnk-staff').href = 'http://' + host + ':' + s.staff_port + '/';
$('lnk-labels').href = 'http://' + host + ':' + s.staff_port + '/cgi-bin/koha/labels/label-edit-range.pl';
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false))); $('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
$('code').focus(); $('code').focus();
})(); })();