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:
+54
-9
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'")
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Library card sheet and layout for Tools > Patron card creator"
|
||||||
|
tables:
|
||||||
|
- creator_templates:
|
||||||
|
translatable: [ template_desc ]
|
||||||
|
multiline: [ ]
|
||||||
|
rows:
|
||||||
|
# Avery 5371 / 8871 business-card sheet: 10 cards, 2 across x 5 down.
|
||||||
|
- template_code: "Avery 5371 | 2 x 3-1/2 (10 per sheet)"
|
||||||
|
template_desc: "Library cards: 2 columns, 5 rows."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 3.5
|
||||||
|
label_height: 2
|
||||||
|
top_text_margin: 0
|
||||||
|
left_text_margin: 0
|
||||||
|
top_margin: 0.5
|
||||||
|
left_margin: 0.75
|
||||||
|
cols: 2
|
||||||
|
rows: 5
|
||||||
|
col_gap: 0
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Patroncards"
|
||||||
|
|
||||||
|
- creator_layouts:
|
||||||
|
translatable: [ layout_name, layout_xml ]
|
||||||
|
multiline: [ layout_xml ]
|
||||||
|
rows:
|
||||||
|
# Name and library at the top, card number as a scannable barcode.
|
||||||
|
- layout_name: "Library card"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BAR
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TR
|
||||||
|
font_size: 10
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 0
|
||||||
|
text_justify: L
|
||||||
|
format_string: "barcode"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt page_side=\"F\" units=\"POINT\">"
|
||||||
|
- " <text><firstname> <surname></text>"
|
||||||
|
- " <text enable=\"1\" font=\"TB\" font_size=\"14\" llx=\"14\" lly=\"118\" text_alignment=\"L\" />"
|
||||||
|
- " <text>Library card</text>"
|
||||||
|
- " <text enable=\"1\" font=\"TR\" font_size=\"9\" llx=\"14\" lly=\"100\" text_alignment=\"L\" />"
|
||||||
|
- " <barcode print=\"1\" type=\"CODE39\" llx=\"14\" lly=\"30\" height_scale=\"0.012\" width_scale=\"0.6\" text_print=\"1\" />"
|
||||||
|
- "</opt>"
|
||||||
|
creator: "Patroncards"
|
||||||
+298
-64
@@ -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
@@ -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"> 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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user