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 @@
-
Print spine labels and barcodes: open the label creator,
- choose Print barcode range, and enter these numbers.
-
+
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).
+
+
+ Barcodes and settings
+
+
+
+
+
+
Earlier
@@ -110,7 +148,7 @@