From 76866e8bbcf5eee18c9a113811bca8222a52428b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 11:07:02 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 63 ++++- library/docker-compose.yml | 5 +- library/install.sh | 6 + library/koha/bootstrap.pl | 63 ++++- library/koha/entrypoint.sh | 3 +- library/koha/seed/40-patron-cards.yml | 52 ++++ library/quickadd/app.py | 358 +++++++++++++++++++++----- library/quickadd/index.html | 149 +++++++++-- 8 files changed, 601 insertions(+), 98 deletions(-) create mode 100644 library/koha/seed/40-patron-cards.yml diff --git a/library/README.md b/library/README.md index 35a4bac..019e2b3 100644 --- a/library/README.md +++ b/library/README.md @@ -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 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 the **next barcode number**, 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. +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. @@ -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 `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 -Quick Add shows the barcode range for the session ("Barcodes 12 to 27"). In the staff -interface open **Cataloging → Label creator → Print barcode range**, enter those numbers, -and pick a template and layout: +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 | |---|---| @@ -72,7 +101,21 @@ and pick a template and layout: | Spine - call number | call number only | | 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 @@ -80,10 +123,12 @@ The public catalogue searches title, author, subject and ISBN, and shows each co availability, call number and **shelving location**. 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 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 diff --git a/library/docker-compose.yml b/library/docker-compose.yml index df54865..c9ce8b5 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -37,6 +37,9 @@ services: 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" @@ -58,7 +61,7 @@ services: KOHA_API: http://koha:8081/api/v1 ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD} - KOHA_LIBRARY: HOME + KOHA_LIBRARY: ${LIBRARY_CODE:-HOME} OPAC_PORT: ${OPAC_PORT:-8080} STAFF_PORT: ${STAFF_PORT:-8081} QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary} diff --git a/library/install.sh b/library/install.sh index 2a312a5..60ce5ac 100755 --- a/library/install.sh +++ b/library/install.sh @@ -8,6 +8,9 @@ # # 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 @@ -106,6 +109,9 @@ if [ ! -f .env ]; then 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}") diff --git a/library/koha/bootstrap.pl b/library/koha/bootstrap.pl index 0791c98..d75b535 100755 --- a/library/koha/bootstrap.pl +++ b/library/koha/bootstrap.pl @@ -11,6 +11,9 @@ 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'; @@ -20,6 +23,7 @@ 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(); @@ -74,8 +78,8 @@ for my $y (sort glob("$seed_dir/*.yml")) { # ---- Library, patron categories, owner account ---------------------------- Koha::Library->new({ - branchcode => 'HOME', branchname => $env{library}, -})->store unless Koha::Libraries->find('HOME'); + 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({ @@ -85,8 +89,8 @@ for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrowe } my $admin = Koha::Patron->new({ - cardnumber => 'ADMIN', surname => 'Librarian', userid => $env{admin_user}, - branchcode => 'HOME', categorycode => 'OWNER', flags => 1, # 1 = superlibrarian + 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 }); @@ -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' 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 => '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 RESTBasicAuth => 1, # lets the quick-add page use the REST API OpenLibraryCovers => 1, # cover images in the catalogue diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index e318eab..a99f0ef 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -81,7 +81,8 @@ if [ "$TABLES" = "0" ]; then 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}" 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 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'") diff --git a/library/koha/seed/40-patron-cards.yml b/library/koha/seed/40-patron-cards.yml new file mode 100644 index 0000000..5177b3c --- /dev/null +++ b/library/koha/seed/40-patron-cards.yml @@ -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: + - "" + - " <firstname> <surname>" + - " " + - " Library card" + - " " + - " " + - "" + creator: "Patroncards" diff --git a/library/quickadd/app.py b/library/quickadd/app.py index cb0cdca..89c99b6 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -13,6 +13,7 @@ import hmac import json import os import re +import secrets import subprocess import tempfile import threading @@ -34,12 +35,11 @@ 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")) -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"} ET.register_namespace("", NS["m"]) RECENT_FILE = os.path.join(DATA_DIR, "recent.json") -LOCK = threading.Lock() # ---------------------------------------------------------------- identifiers @@ -254,6 +254,30 @@ def koha(method, path, body=None, ctype="application/json", headers=None, query= 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": []} @@ -293,41 +317,109 @@ def find_existing(code): return None -def next_barcode(): - last = koha("GET", "/items", query={"_per_page": "1", "_order_by": "-item_id"}) - n = 0 - if last and re.fullmatch(r"\d+", last[0].get("external_id") or ""): - n = int(last[0]["external_id"]) - return max(n, state_counter()) + 1 +# ------------------------------------------------------------------- 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 state_counter(): +def load_settings(): + s = dict(DEFAULT_SETTINGS) try: - return int(open(os.path.join(DATA_DIR, "barcode")).read()) + s.update(json.load(open(SETTINGS_FILE))) 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 + try: + s["width"] = min(max(int(s["width"]), MIN_WIDTH), MAX_WIDTH) + except (TypeError, ValueError): + s["width"] = MIN_WIDTH + return s -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) +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: - item = koha("POST", "/biblios/%s/items" % biblio_id, { - "external_id": bc, "item_type_id": itype, "home_library_id": LIBRARY, - "holding_library_id": LIBRARY, "callnumber": callnum or None, - "location": location}) - save_counter(start + attempt) - return item + 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 @@ -348,8 +440,7 @@ def remember(entry): rec = load_recent() rec.insert(0, entry) try: - os.makedirs(DATA_DIR, exist_ok=True) - json.dump(rec[:300], open(RECENT_FILE, "w")) + save_json(RECENT_FILE, rec[:300]) except OSError: pass @@ -358,7 +449,7 @@ def forget(item_id): with LOCK: rec = [r for r in load_recent() if r.get("item_id") != item_id] try: - json.dump(rec, open(RECENT_FILE, "w")) + save_json(RECENT_FILE, rec) except OSError: pass @@ -379,36 +470,92 @@ def lookup(code): 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")) - itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK" - location = valid_location(payload.get("location")) + itype = valid_type(payload.get("type")) 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: existing = find_existing(code) if existing and not payload.get("copy"): - return 200, dict(existing, status="duplicate", code=code) - if existing: # add another copy of a known title - with LOCK: - item = add_item(existing["biblio_id"], itype, None, location) - entry = entry_for(code, existing["title"], existing["author"], item, existing["biblio_id"], "extra copy") - remember(entry) - return 200, dict(entry, status="added") + 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 200, {"status": "notfound", "code": code} + 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: - bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml", - {"x-confirm-not-duplicate": "1"}) - item = add_item(bib["id"], itype, call_number(rec, info["author"]), location) - entry = entry_for(code, info["title"], info["author"], item, bib["id"], source) + 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 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): @@ -416,18 +563,42 @@ def op_manual(payload): if not title: return 400, {"status": "invalid", "message": "A title is required."} code = clean_code(payload.get("code")) - itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK" - location = valid_location(payload.get("location")) + 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) - with LOCK: - bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml", - {"x-confirm-not-duplicate": "1"}) - item = add_item(bib["id"], itype, None, location) - entry = entry_for(code or "", title, payload.get("author") or "", item, bib["id"], "typed in") - remember(entry) - return 200, dict(entry, status="added") + 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): @@ -454,6 +625,65 @@ def op_undo(payload): 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 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("Referrer-Policy", "no-referrer") 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'") for k, v in (extra or {}).items(): self.send_header(k, v) @@ -511,9 +741,11 @@ class Handler(BaseHTTPRequestHandler): 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": ITEM_TYPES, - "library": LIBRARY, "locations": get_locations(), "staff_port": STAFF_PORT, "opac_port": OPAC_PORT, + return self._send(200, {"recent": load_recent()[:100], "types": get_item_types(), + "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]}) + if path == "/api/settings": + return self._send(200, settings_view()) self._send(404, {"error": "not found"}) def do_POST(self): @@ -524,7 +756,9 @@ class Handler(BaseHTTPRequestHandler): 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} + 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"}) diff --git a/library/quickadd/index.html b/library/quickadd/index.html index da8b107..d43b9f6 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -68,6 +68,16 @@ ul.list .u { color: var(--muted); font-size: .9rem; } .labels { background: var(--card); border: 1px dashed var(--line); border-radius: 12px; padding: 12px; margin-top: 14px; } .small { color: var(--muted); font-size: .92rem; } + details.settings { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 14px; margin: 18px 0 0; } + details.settings summary { cursor: pointer; font-weight: 700; } + details.settings fieldset { border: 0; padding: 0; margin: 12px 0; } + details.settings legend { font-weight: 700; padding: 0; margin-bottom: 4px; } + details.settings label.opt { display: block; padding: 4px 0; } + details.settings .tool { border-top: 1px solid var(--line); padding: 12px 0 4px; } + details.settings select, details.settings input[type=number] { font: inherit; padding: 6px 8px; border: 1px solid var(--line); + border-radius: 8px; background: var(--card); color: var(--ink); } + details.settings input[type=number] { width: 6em; } + .result-note { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--ok-bg); border: 1px solid var(--ok); } .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } @@ -98,11 +108,39 @@ +
+ Barcodes and settings +
+ Barcodes on new items + + + +
+ Digits per barcode: + +  Next barcode: +
+
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.
+
+
+ Items without a barcode: 0 +
+
+
+ Print a sheet of barcodes ahead of time +
Reserve a block of numbers, print the sheet, and stick the labels on whenever you like.
+
+ +
+
+
+
+

Earlier

@@ -110,7 +148,7 @@