Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af833a39d | ||
|
|
442b45c2e0 | ||
|
|
6f877d1107 | ||
|
|
3d02d28a8c | ||
|
|
7f230c8e73 | ||
|
|
b784d98c9d | ||
|
|
d01919e2ac | ||
|
|
10e991e158 | ||
|
|
770a6a1dfb | ||
|
|
522a33b127 |
+83
-5
@@ -1,4 +1,4 @@
|
||||
# Home library: Koha + Quick Add (Docker)
|
||||
# Room for Books: a home library (Koha + the Room for Books site, in Docker)
|
||||
|
||||
A self-hosted library catalogue you can set up in one command:
|
||||
|
||||
@@ -12,12 +12,19 @@ it never overwrites your `.env` (passwords) or your data.
|
||||
|
||||
| What | Address | Login |
|
||||
|---|---|---|
|
||||
| **Catalogue** (public search, no login) | `http://<this machine>:8080` | none |
|
||||
| **Staff interface** (cataloguing, labels, Z39.50) | `http://<this machine>:8081` | `admin` and the generated password |
|
||||
| **Add books** (scan an ISBN) | `http://<this machine>:8082` | same login |
|
||||
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8081` | `admin` and the generated password |
|
||||
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8081/add/` | same login (asked once more) |
|
||||
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8080` | none |
|
||||
|
||||
For home use you only need the first address: sign in to **Your library**, and use the **Add books**
|
||||
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
|
||||
shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The
|
||||
public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and
|
||||
staff sites the same URL shapes, so they cannot share one address; Add books can, and does.)
|
||||
|
||||
Ports move up to the next free number if one is taken; the final addresses are printed
|
||||
at the end and by `sudo ./install.sh --status`.
|
||||
at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port
|
||||
(`8082`), which is handy behind a proxy but not needed otherwise.
|
||||
|
||||
## What it is made of
|
||||
|
||||
@@ -196,3 +203,74 @@ koha/ Dockerfile (Koha from the project's Debian repo), entrypoi
|
||||
servers, label templates/layouts)
|
||||
quickadd/ the scan-to-add service (Python standard library + yaz)
|
||||
```
|
||||
|
||||
## Signing in once
|
||||
|
||||
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
|
||||
|
||||
## Everyday use (the Library site at `/add/`)
|
||||
|
||||
One page for the day-to-day, so you rarely need Koha's own screens:
|
||||
|
||||
- **Library**: a cover grid of everything you own. Search by title, author or ISBN; filter by shelf, format
|
||||
(book/DVD/CD) and status (on the shelf / checked out); sort by title, author or newest. Click a book for its
|
||||
page: copies with barcode, status, who has it and when it is due; change a copy's shelf or call number,
|
||||
add another copy, remove a copy, or delete the book. (Title/author edits are one click away in Koha.)
|
||||
- **Lend / return**: scan or type a library card number (or search by name), then scan books to check them out;
|
||||
"Return a book" takes a scan with no person needed; **Renew** extends a loan; the list below shows everything
|
||||
that is out, overdue ones flagged. Check-in goes through Koha's own check-in code, so holds and rules still apply.
|
||||
- **People**: add someone with just a name (the card number is assigned automatically), search, and
|
||||
**Print card** for a library card with a scannable Code 39 barcode.
|
||||
- **Add books**: scan an ISBN (or use the phone camera), as before.
|
||||
|
||||
The read-only `guest` login only sees the Library and book pages (no copy status names, no editing, no lending).
|
||||
|
||||
### Collections, QR cards, more about a book
|
||||
|
||||
- **Collections** (Kids, Cookbooks, ...) are per copy, shown on the book page next to the shelf, and filterable on the
|
||||
Library page. Choose **+ New collection...** (or **+ New shelf...**) in the dropdown to add one: no Koha screens needed.
|
||||
- **Library cards** carry a Code 39 barcode and a **QR code** of the card number. **Save as picture** makes an image to text
|
||||
or AirDrop to a phone; on Lend / return, **Scan card or phone QR with camera** reads either one from a photo.
|
||||
**Print everyone's cards** (People) lays out a sheet of cards.
|
||||
- **More about a book**: a description and subjects from Open Library, plus look-up links (Goodreads, LibraryThing,
|
||||
WorldCat). **AR BookFind** is a link only: Renaissance's terms forbid automated access, so nothing is fetched from it.
|
||||
|
||||
### Reading levels (AR) and spreadsheets
|
||||
|
||||
AR BookFind has no public feed or import, and its terms forbid automated look-ups, so this site does not fetch from it.
|
||||
Instead: the book page has an **Open AR BookFind** link (plus **Copy title** to paste into its search) and boxes to record the
|
||||
**level, points and quiz number** you find there (the read-only guest sees them). **Library page -> Export / import** downloads
|
||||
`library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself
|
||||
(for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json`
|
||||
in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder.
|
||||
|
||||
### Live camera scanning (needs HTTPS)
|
||||
|
||||
Browsers only allow a live camera on HTTPS pages (or on the machine itself). Put the library site behind Caddy with a real
|
||||
certificate and **Live scan** buttons appear: Add books (scan a stack of ISBNs one after another), Lend / return (live card or
|
||||
phone-QR scan, live book check-out for the chosen person, live returns). Chrome on Android reads codes in the browser; other
|
||||
browsers send small camera frames to the server's decoder a couple of times a second. On plain HTTP the photo buttons stay.
|
||||
|
||||
### Copying reading levels from AR BookFind
|
||||
|
||||
On the AR BookFind book page, select everything (Ctrl/Cmd+A), copy, and paste into the box on the book page here: the level,
|
||||
points, quiz number and interest level fill in by themselves; check them and press Save.
|
||||
|
||||
### Tags, and borrowers signing in
|
||||
|
||||
- **Tags**: any number per book (to read, kids, signed...). Add and remove them on the book page; click one to see every
|
||||
book that has it; the Library page can filter by tag. They are stored in `tags.json` in the Add books data volume, which
|
||||
the usual backups of that folder include.
|
||||
- **Borrowers sign in**: every person gets a login, **their library card number + a password**. Adding someone shows
|
||||
their password once (a different easy one like `maple482`, or the same one for everyone: People -> *Sign-in passwords for new
|
||||
people*). *Give everyone a sign-in* covers people added earlier; *New password* resets one person. A borrower who signs in
|
||||
sees the library and a **My books** page: what they have out, when each is due (or overdue), **Renew**, and **Change my
|
||||
password**. They cannot lend, edit or see other people. Passwords are Koha's own patron passwords; this stack relaxes Koha's
|
||||
default strength rule (4+ characters instead of 8 with upper/lower/digit) once, at install or update.
|
||||
Ten wrong guesses from one address locks sign-in attempts from it for five minutes.
|
||||
|
||||
### Reading levels: pop-up or in-page AR BookFind
|
||||
|
||||
The book page's AR box has **Open beside this page** (a pop-up window next to the book) and **Show it here** (AR BookFind inside the
|
||||
page; some browsers block its cookies inside another page, then use the pop-up). Either copies the title first so you can paste
|
||||
it into AR's search; AR's own page cannot be pre-filled from here.
|
||||
|
||||
@@ -30,8 +30,12 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
quickadd:
|
||||
condition: service_started
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
KOHA_QUICKADD_URL: http://quickadd:8082
|
||||
KOHA_SSO_SECRET: ${SSO_SECRET:-}
|
||||
KOHA_DB_HOST: db
|
||||
KOHA_DB_PASS: ${DB_PASSWORD}
|
||||
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
@@ -41,7 +45,8 @@ services:
|
||||
KOHA_SHELVES: ${SHELVES:-}
|
||||
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
|
||||
ports:
|
||||
- "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80"
|
||||
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
|
||||
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8080}:80"
|
||||
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
|
||||
volumes:
|
||||
- koha-data:/var/lib/koha
|
||||
@@ -53,14 +58,14 @@ services:
|
||||
image: library-quickadd:local
|
||||
container_name: library-quickadd
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
koha:
|
||||
condition: service_started
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
KOHA_API: http://koha:8081/api/v1
|
||||
SSO_SECRET: ${SSO_SECRET:-}
|
||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
VIEWER_USER: ${VIEWER_USER:-guest}
|
||||
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
|
||||
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
|
||||
OPAC_PORT: ${OPAC_PORT:-8080}
|
||||
STAFF_PORT: ${STAFF_PORT:-8081}
|
||||
|
||||
+20
-4
@@ -79,10 +79,14 @@ show_status() {
|
||||
fi
|
||||
dc ps
|
||||
echo
|
||||
echo " Catalogue (public): http://$ip:$OPAC_PORT"
|
||||
echo " Staff interface: http://$ip:$STAFF_PORT"
|
||||
echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)"
|
||||
echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
|
||||
echo " Your library: http://$ip:$STAFF_PORT"
|
||||
echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people"
|
||||
echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
|
||||
echo " Read-only login: ${VIEWER_USER:-guest} / ${VIEWER_PASSWORD:-?} (to hand out, with this address: http://$ip:$STAFF_PORT/add/)"
|
||||
echo " It opens the Find a book page: search only, it cannot add or change anything."
|
||||
if [ "${OPAC_BIND:-127.0.0.1}" != "127.0.0.1" ]; then
|
||||
echo " Public catalogue: http://$ip:$OPAC_PORT (search only, asks for a library card login)"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -117,8 +121,14 @@ OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
|
||||
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
|
||||
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
|
||||
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
|
||||
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=$(rand 16)
|
||||
VIEWER_USER=guest
|
||||
VIEWER_PASSWORD=$(rand 14)
|
||||
# Lets the library site vouch for a signed-in staff user to the Add books page (no second login).
|
||||
SSO_SECRET=$(rand 40)
|
||||
DB_ROOT_PASSWORD=$(rand 32)
|
||||
DB_PASSWORD=$(rand 32)
|
||||
KOHA_SUITE=stable
|
||||
@@ -131,6 +141,12 @@ else
|
||||
if ! grep -q '^QUICKADD_PORT=' .env; then
|
||||
echo "QUICKADD_PORT=$(free_port 8082)" >> .env
|
||||
fi
|
||||
if ! grep -q '^VIEWER_PASSWORD=' .env; then
|
||||
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
|
||||
fi
|
||||
grep -q '^SSO_SECRET=' .env || echo "SSO_SECRET=$(rand 40)" >> .env
|
||||
# Existing installs keep their current public-catalogue behaviour.
|
||||
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
|
||||
if ! grep -q '^BIND_ADDR=' .env; then
|
||||
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
|
||||
fi
|
||||
|
||||
@@ -39,7 +39,13 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s
|
||||
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
||||
done
|
||||
|
||||
COPY bootstrap.pl entrypoint.sh /opt/koha-init/
|
||||
# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header).
|
||||
COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
|
||||
# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it)
|
||||
COPY quickadd-return.pl quickadd-value.pl /usr/share/koha/intranet/cgi-bin/
|
||||
RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-value.pl
|
||||
|
||||
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
||||
COPY seed/ /opt/koha-init/seed/
|
||||
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
||||
|
||||
|
||||
@@ -160,11 +160,11 @@ my %prefs = (
|
||||
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
|
||||
OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login
|
||||
OpenLibraryCovers => 1, # cover images in the catalogue
|
||||
OPACSearchForTitleIn => '',
|
||||
BiblioDefaultView => 'normal',
|
||||
OpacPublic => 1,
|
||||
opacuserlogin => 0, # catalogue is browse-only; no patron logins needed
|
||||
opacuserlogin => 1,
|
||||
OpacMaintenance => 0,
|
||||
ExpireReservesMaxPickUpDelay => 0,
|
||||
AllowOfflineCirculation => 0,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/perl
|
||||
# Idempotent housekeeping, run on EVERY container start (after the first-time install or
|
||||
# the schema upgrade), so existing installs pick these up too:
|
||||
# * removal of the Koha guest account an earlier version created (the guest login is Quick Add only)
|
||||
# * the "Library tools" block on the staff home page
|
||||
use Modern::Perl;
|
||||
use C4::Context;
|
||||
use Koha::Patrons;
|
||||
use Koha::Patron::Categories;
|
||||
use Koha::Caches;
|
||||
|
||||
my $dbh = C4::Context->dbh;
|
||||
# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha:
|
||||
# Koha's own "catalogue" permission also allows editing label layouts and templates, so a
|
||||
# Koha account cannot be made truly read-only. Remove any guest account an earlier version created.
|
||||
for my $p ( Koha::Patrons->search( { categorycode => 'VIEWER' } )->as_list ) {
|
||||
say "ensure: removing the old Koha guest account '" . $p->userid . "'";
|
||||
$p->delete;
|
||||
}
|
||||
if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) {
|
||||
$cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count;
|
||||
}
|
||||
|
||||
# Patron logins use short, easy-to-remember passwords (a home library, not a bank): Koha's defaults
|
||||
# (8+ characters, upper+lower+digit) are relaxed once, unless an admin has changed them since.
|
||||
my $flag = '/var/lib/koha/' . ( $ENV{KOHA_INSTANCE} || 'library' ) . '/.password-policy-set';
|
||||
unless ( -e $flag ) {
|
||||
my ($strong) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'RequireStrongPassword'});
|
||||
my ($minlen) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'minPasswordLength'});
|
||||
if ( ( $strong // 1 ) == 1 && ( $minlen // 8 ) == 8 ) {
|
||||
$dbh->do(q{UPDATE systempreferences SET value = 0 WHERE variable = 'RequireStrongPassword'});
|
||||
$dbh->do(q{UPDATE systempreferences SET value = 4 WHERE variable = 'minPasswordLength'});
|
||||
say "ensure: patron passwords may be short (4+ characters, no strength rule)";
|
||||
Koha::Caches->get_instance->flush_all; # preferences are cached; direct SQL edits need this
|
||||
}
|
||||
if ( open my $fh, '>', $flag ) { close $fh }
|
||||
}
|
||||
|
||||
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool). Rewritten each start.
|
||||
my $html = '<div class="main-heading"><h3>Room for Books</h3></div><ul class="list-unstyled">'
|
||||
. '<li><a class="btn btn-primary" href="/add/">Open Room for Books</a> (library, lend / return, people, add books)</li>'
|
||||
. '<li><a href="/add/#find">Find a book</a></li></ul>';
|
||||
my ($id) = $dbh->selectrow_array(q{SELECT id FROM additional_contents WHERE code = 'library_tools'});
|
||||
unless ($id) {
|
||||
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
|
||||
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
|
||||
$id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
|
||||
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
|
||||
VALUES (?, 'Room for Books', ?, 'default')}, undef, $id, $html );
|
||||
say "ensure: added the staff home 'Room for Books' block";
|
||||
}
|
||||
else {
|
||||
$dbh->do( q{UPDATE additional_contents_localizations SET title = 'Room for Books', content = ? WHERE additional_content_id = ? AND lang = 'default'},
|
||||
undef, $html, $id );
|
||||
}
|
||||
@@ -56,12 +56,40 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
|
||||
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
|
||||
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
|
||||
|
||||
# Run Apache itself as the instance user instead of mpm_itk's per-vhost
|
||||
# uid switching (which needs extra capabilities and is pointless here:
|
||||
# Plack does the real work as that user already).
|
||||
# One address: serve the Add books page (Quick Add) inside the staff site at /add/.
|
||||
# (The staff and public catalogue sites cannot share one address: Koha gives both the
|
||||
# same URL shapes. The staff site is the natural home for a home library.)
|
||||
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
|
||||
# Single sign-on for staff: when KOHA_SSO_SECRET is set, this web server tells Quick Add (via a
|
||||
# secret header nobody outside can forge: it is overwritten here on every request) that the
|
||||
# request came through the staff site, and Quick Add then asks Koha whether the browser's login
|
||||
# cookie belongs to a staff user who may edit the catalogue. Without the secret, or if anything
|
||||
# about that fails, Quick Add just asks for its own login as before.
|
||||
SSO_BLOCK=""
|
||||
if [ -n "${KOHA_SSO_SECRET:-}" ]; then
|
||||
install -m 0640 -o root -g "$INST-koha" /dev/null /etc/koha/quickadd-sso.secret
|
||||
printf '%s' "$KOHA_SSO_SECRET" > /etc/koha/quickadd-sso.secret
|
||||
SSO_BLOCK=" <IfModule mod_headers.c>
|
||||
<Location /add/>
|
||||
RequestHeader set X-Quickadd-Proxy \"$KOHA_SSO_SECRET\"
|
||||
</Location>
|
||||
</IfModule>"
|
||||
fi
|
||||
cat > /etc/koha/apache-quickadd.conf << APACHE
|
||||
<IfModule mod_proxy_http.c>
|
||||
RedirectMatch ^/add\$ /add/
|
||||
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
|
||||
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
|
||||
${SSO_BLOCK}
|
||||
</IfModule>
|
||||
APACHE
|
||||
sed -i 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|&\n\1Include /etc/koha/apache-quickadd.conf|' "/etc/apache2/sites-available/$INST.conf"
|
||||
fi
|
||||
|
||||
# koha-create insists on mpm_itk being enabled, but inside a container it can
|
||||
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every
|
||||
# page). Now that the instance exists, swap to plain prefork.
|
||||
# page). Now that the instance exists, swap to plain prefork; Apache runs as the
|
||||
# instance user (Plack does the real work as that user already).
|
||||
rm -f /etc/apache2/mods-enabled/mpm_itk.*
|
||||
a2enmod mpm_prefork >/dev/null 2>&1 || true
|
||||
sed -i '/AssignUserID/d' "$SITE"
|
||||
@@ -94,6 +122,11 @@ else
|
||||
FIRST_RUN=0
|
||||
fi
|
||||
|
||||
# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup)
|
||||
runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
|
||||
perl /opt/koha-init/ensure.pl \
|
||||
|| log "WARNING: ensure.pl reported a problem (see above)"
|
||||
|
||||
# --- 5. background services (their `daemon` processes die with the container,
|
||||
# so they are started explicitly on every start) ------------------------
|
||||
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/perl
|
||||
# Installed as .../intranet/cgi-bin/quickadd-return.pl (URL /cgi-bin/koha/quickadd-return.pl).
|
||||
#
|
||||
# Koha's REST API has no "check in" call, so the Add books / Lend page asks this small script to do it
|
||||
# with Koha's own C4::Circulation::AddReturn (the same code the staff Check in screen runs: it clears
|
||||
# the loan, handles holds and fines rules). Same shared-secret guard as quickadd-session: only callers
|
||||
# that know the secret get an answer.
|
||||
use Modern::Perl;
|
||||
use CGI qw( -utf8 );
|
||||
use JSON qw( encode_json );
|
||||
use Digest::SHA qw( sha256_hex );
|
||||
use C4::Context;
|
||||
use C4::Circulation qw( AddReturn );
|
||||
use Koha::Items;
|
||||
|
||||
my $q = CGI->new;
|
||||
|
||||
sub reply {
|
||||
my ( $q, $status, $data ) = @_;
|
||||
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
|
||||
encode_json($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
my $secret = '';
|
||||
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
|
||||
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
|
||||
reply( $q, '403 Forbidden', { status => 'forbidden' } )
|
||||
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
|
||||
|
||||
my $barcode = $q->param('barcode') // '';
|
||||
reply( $q, '200 OK', { status => 'invalid' } ) unless $barcode =~ /\A[\x21-\x7e]{1,40}\z/;
|
||||
|
||||
my $item = Koha::Items->find( { barcode => $barcode } );
|
||||
reply( $q, '200 OK', { status => 'unknown_barcode' } ) unless $item;
|
||||
|
||||
# AddReturn needs to know which branch it is happening at.
|
||||
my $branch = $item->homebranch;
|
||||
my ( $doreturn, $messages, $borrower, $was_out );
|
||||
eval {
|
||||
C4::Context->set_userenv( 0, 0, 'quickadd', 'Quick', 'Add', $branch, $branch, 0, '', '' );
|
||||
$was_out = $item->checkout ? 1 : 0;
|
||||
( $doreturn, $messages, undef, $borrower ) = AddReturn( $barcode, $branch );
|
||||
1;
|
||||
} or reply( $q, '200 OK', { status => 'failed', error => "$@" } );
|
||||
reply(
|
||||
$q, '200 OK',
|
||||
{
|
||||
status => $doreturn && $was_out ? 'returned' : ( $doreturn || exists $messages->{NotIssued} ) ? 'not_out' : 'failed',
|
||||
patron => $borrower ? ( join ' ', grep { length } $borrower->{firstname}, $borrower->{surname} ) : '',
|
||||
messages => [ sort keys %{ $messages || {} } ],
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/perl
|
||||
# Installed as .../intranet/cgi-bin/svc/quickadd-session (URL /cgi-bin/koha/svc/quickadd-session).
|
||||
#
|
||||
# Lets the Add books page (Quick Add) ask: "is this browser signed in to the staff site as
|
||||
# someone allowed to edit the catalogue?" so staff do not have to log in a second time.
|
||||
#
|
||||
# It wraps Koha's own C4::Auth::check_cookie_auth (documented for exactly this: services that
|
||||
# are invoked by pages authenticated in the usual way) and only answers callers that present a
|
||||
# shared secret, so it cannot be used to probe sessions from outside. The browser's address is
|
||||
# passed in explicitly, so Koha's "session is tied to the IP that logged in" rule keeps working.
|
||||
use Modern::Perl;
|
||||
use CGI qw( -utf8 );
|
||||
use JSON qw( encode_json );
|
||||
use Digest::SHA qw( sha256_hex );
|
||||
use C4::Auth qw( check_cookie_auth );
|
||||
|
||||
my $q = CGI->new;
|
||||
|
||||
# Takes the CGI object as an argument: under Plack this file runs wrapped in a sub, where a named
|
||||
# sub would otherwise see a stale copy of a file-level variable.
|
||||
sub reply {
|
||||
my ( $q, $status, $data ) = @_;
|
||||
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
|
||||
encode_json($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
# 1. The caller must know the shared secret (a file only the Koha user and root can read).
|
||||
my $secret = '';
|
||||
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
|
||||
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
|
||||
reply( $q, '403 Forbidden', { status => 'forbidden' } )
|
||||
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
|
||||
|
||||
# 2. Plausible-looking inputs only.
|
||||
my $sid = $q->param('sid') // '';
|
||||
my $ip = $q->param('ip') // '';
|
||||
reply( $q, '200 OK', { status => 'failed' } )
|
||||
unless $sid =~ /\A[A-Za-z0-9]{16,64}\z/ && $ip =~ /\A[0-9A-Fa-f:.]{3,45}\z/;
|
||||
|
||||
# 3. Ask Koha. Staff who may edit the catalogue (superlibrarians always may) are let in.
|
||||
my ( $status, $session ) = check_cookie_auth( $sid, { editcatalogue => 'edit_catalogue' }, { remote_addr => $ip } );
|
||||
reply( $q, '200 OK', { status => 'ok', user => scalar $session->param('id') } ) if $status eq 'ok' && $session;
|
||||
reply( $q, '200 OK', { status => $status || 'failed' } );
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/perl
|
||||
# Installed as .../intranet/cgi-bin/quickadd-value.pl (URL /cgi-bin/koha/quickadd-value.pl).
|
||||
#
|
||||
# Koha's REST API cannot add shelves/rooms (authorised value category LOC) or collections (CCODE),
|
||||
# so the Library site asks this script to, with Koha's own Koha::AuthorisedValue class. Same shared-secret
|
||||
# guard as quickadd-session / quickadd-return; only the two categories below can be touched.
|
||||
use Modern::Perl;
|
||||
use CGI qw( -utf8 );
|
||||
use JSON qw( encode_json );
|
||||
use Digest::SHA qw( sha256_hex );
|
||||
use Koha::AuthorisedValues;
|
||||
use Koha::AuthorisedValue;
|
||||
|
||||
my $q = CGI->new;
|
||||
|
||||
sub reply {
|
||||
my ( $q, $status, $data ) = @_;
|
||||
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
|
||||
encode_json($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
my $secret = '';
|
||||
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
|
||||
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
|
||||
reply( $q, '403 Forbidden', { status => 'forbidden' } )
|
||||
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
|
||||
|
||||
my $cat = $q->param('category') // '';
|
||||
my $value = $q->param('value') // '';
|
||||
my $desc = $q->param('description') // '';
|
||||
utf8::decode($desc);
|
||||
reply( $q, '200 OK', { status => 'invalid' } )
|
||||
unless $cat =~ /\A(?:LOC|CCODE)\z/ && $value =~ /\A[A-Z0-9_-]{1,20}\z/ && length($desc) && length($desc) <= 80;
|
||||
|
||||
my $out = eval {
|
||||
my $exists = Koha::AuthorisedValues->search( { category => $cat, authorised_value => $value } )->count;
|
||||
return { status => 'exists' } if $exists;
|
||||
Koha::AuthorisedValue->new( { category => $cat, authorised_value => $value, lib => $desc, lib_opac => $desc } )->store;
|
||||
{ status => 'added' };
|
||||
};
|
||||
reply( $q, '200 OK', $out // { status => 'failed', error => "$@" } );
|
||||
@@ -5,7 +5,7 @@ FROM debian:bookworm-slim
|
||||
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools tzdata \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
||||
@@ -13,7 +13,7 @@ RUN apt-get update \
|
||||
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||
RUN update-ca-certificates
|
||||
|
||||
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
|
||||
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null && command -v qrencode >/dev/null
|
||||
|
||||
RUN useradd --system --uid 10001 --home-dir /app quickadd \
|
||||
&& mkdir -p /app /data && chown quickadd /data
|
||||
|
||||
Binary file not shown.
+1011
-17
File diff suppressed because it is too large
Load Diff
+806
-14
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Add to library</title>
|
||||
<title>Room for Books</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2;
|
||||
@@ -44,6 +44,21 @@
|
||||
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
|
||||
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
nav.tabs { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; }
|
||||
nav.tabs a { color: var(--accent); text-decoration: none; font-weight: 600; padding: 6px 12px; border-radius: 999px; }
|
||||
nav.tabs a.on { background: var(--accent); color: var(--accent-ink); }
|
||||
nav.tabs a.ext::after { content: ' \2197'; font-size: .85em; }
|
||||
.find-form { display: flex; gap: 8px; margin: 10px 0; }
|
||||
.find-form input { flex: 1; font-size: 1.3rem; padding: 12px 14px; border: 2px solid var(--line); border-radius: 12px;
|
||||
background: var(--card); color: var(--ink); min-width: 0; }
|
||||
.hit { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
|
||||
.hit .t { font-weight: 700; font-size: 1.05rem; overflow-wrap: anywhere; }
|
||||
a { color: var(--accent); }
|
||||
.hit a { color: var(--accent); }
|
||||
.copy { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 6px 0 0; }
|
||||
.chip { border-radius: 999px; padding: 1px 10px; font-size: .88rem; font-weight: 600; border: 1px solid var(--line); }
|
||||
.chip.in { background: var(--ok-bg); border-color: var(--ok); color: var(--ok); }
|
||||
.chip.out { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); }
|
||||
.cam-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0 0; }
|
||||
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
|
||||
@@ -79,18 +94,192 @@
|
||||
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); }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 14px; }
|
||||
.filters select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); max-width: 100%; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 14px 12px; margin: 6px 0 16px; }
|
||||
.tile { display: block; text-decoration: none; color: inherit; }
|
||||
.tile .art { position: relative; aspect-ratio: 2 / 3; border-radius: 6px; overflow: hidden; border: 1px solid var(--line);
|
||||
display: flex; align-items: flex-end; padding: 8px; font-weight: 700; font-size: .88rem; line-height: 1.2;
|
||||
color: #fff; background: var(--tint, #5b6f63); box-shadow: 0 1px 3px rgba(0,0,0,.18); overflow-wrap: anywhere; }
|
||||
.tile .art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: var(--card); }
|
||||
.tile .art img[hidden] { display: none; }
|
||||
.tile:hover .art, .tile:focus-visible .art { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
.tile .tt { margin-top: 6px; font-weight: 600; font-size: .9rem; line-height: 1.25; overflow-wrap: anywhere;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.tile .aa { color: var(--muted); font-size: .82rem; overflow-wrap: anywhere; }
|
||||
.tile .flag { position: absolute; top: 6px; right: 6px; z-index: 1; font-size: .72rem; padding: 1px 8px; border-radius: 999px;
|
||||
background: var(--warn-bg); color: var(--warn); border: 1px solid var(--warn); font-weight: 700; }
|
||||
.tile .num { position: absolute; top: 6px; left: 6px; z-index: 1; font-size: .72rem; padding: 1px 7px; border-radius: 999px;
|
||||
background: rgba(0,0,0,.65); color: #fff; font-weight: 700; }
|
||||
.bookhead { display: flex; gap: 16px; flex-wrap: wrap; margin: 8px 0 14px; }
|
||||
.bookhead .art { width: 140px; aspect-ratio: 2 / 3; border-radius: 6px; overflow: hidden; border: 1px solid var(--line); position: relative;
|
||||
background: var(--tint, #5b6f63); color: #fff; font-weight: 700; padding: 10px; display: flex; align-items: flex-end; flex: none; overflow-wrap: anywhere; }
|
||||
.bookhead .art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: var(--card); }
|
||||
.bookhead .art img[hidden] { display: none; }
|
||||
.bookhead h2 { margin: 0 0 4px; font-size: 1.4rem; }
|
||||
.copybox { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
|
||||
.copybox .row { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 8px; }
|
||||
.copybox select, .copybox input[type=text] { font: inherit; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); min-width: 0; }
|
||||
button.danger { color: var(--bad); border-color: var(--bad); }
|
||||
.person { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); }
|
||||
.person .t { flex: 1; min-width: 180px; }
|
||||
.loan { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.loan .t { flex: 1; min-width: 160px; overflow-wrap: anywhere; }
|
||||
.chip.late { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); }
|
||||
.libcard { width: 85.6mm; height: 54mm; border: 1px solid #888; border-radius: 3mm; padding: 5mm; background: #fff; color: #000;
|
||||
display: flex; flex-direction: column; justify-content: space-between; font-family: system-ui, sans-serif; }
|
||||
.libcard .lib { font-size: 11pt; font-weight: 700; } .libcard .nm { font-size: 14pt; font-weight: 700; }
|
||||
.libcard .codes { display: flex; gap: 3mm; align-items: center; } .libcard .bc { flex: 1; min-width: 0; }
|
||||
.libcard .bc svg { width: 100%; height: 14mm; display: block; } .libcard img.qr { width: 19mm; height: 19mm; flex: none; }
|
||||
.libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; }
|
||||
.cardsheet { display: grid; grid-template-columns: repeat(2, 85.6mm); gap: 6mm; justify-content: start; }
|
||||
.cardsheet .libcard { break-inside: avoid; }
|
||||
@media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } }
|
||||
#live { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.92); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 12px; }
|
||||
#live[hidden] { display: none; }
|
||||
#live video { width: min(100%, 640px); max-height: 60vh; border-radius: 12px; background: #000; object-fit: cover; }
|
||||
#live .frame { position: relative; width: min(100%, 640px); }
|
||||
#live .frame::after { content: ''; position: absolute; inset: 18% 12%; border: 3px solid rgba(255,255,255,.85); border-radius: 12px; pointer-events: none; }
|
||||
#live .msg { color: #fff; font-weight: 600; text-align: center; min-height: 3em; max-width: 640px; }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Add to library</h1>
|
||||
<nav aria-label="Library links">
|
||||
<a id="lnk-opac" href="#">Catalogue</a><a id="lnk-staff" href="#">Staff</a>
|
||||
<h1 id="title">Room for Books</h1>
|
||||
<nav class="tabs" aria-label="Sections">
|
||||
<a id="tab-library" href="#library" class="on">Library</a>
|
||||
<a id="tab-mine" href="#mine" hidden>My books</a>
|
||||
<a id="tab-lend" href="#lend">Lend / return</a>
|
||||
<a id="tab-people" href="#people">People</a>
|
||||
<a id="tab-add" href="#add">Add books</a>
|
||||
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
|
||||
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section id="view-library" hidden>
|
||||
<form class="find-form" id="findform" autocomplete="off">
|
||||
<label class="sr" for="q">Title, author or ISBN</label>
|
||||
<input id="q" type="search" placeholder="Search title, author or ISBN" enterkeyhint="search">
|
||||
<button class="primary" type="submit">Search</button>
|
||||
</form>
|
||||
<div class="filters">
|
||||
<label class="sr" for="f-shelf">Shelf</label><select id="f-shelf"></select>
|
||||
<label class="sr" for="f-tag">Tag</label><select id="f-tag"></select>
|
||||
<label class="sr" for="f-coll">Collection</label><select id="f-coll"></select>
|
||||
<label class="sr" for="f-type">Format</label><select id="f-type"></select>
|
||||
<label class="sr" for="f-status">Status</label><select id="f-status">
|
||||
<option value="">Any status</option><option value="in">On the shelf</option><option value="out">Checked out</option></select>
|
||||
<label class="sr" for="f-sort">Sort</label><select id="f-sort">
|
||||
<option value="title">Sort: title</option><option value="author">Sort: author</option><option value="new">Sort: newest first</option></select>
|
||||
</div>
|
||||
<div id="libstatus" class="small" aria-live="polite"></div>
|
||||
<div class="grid" id="grid"></div>
|
||||
<div class="actions"><button type="button" id="more" hidden>Show more</button></div>
|
||||
<details class="settings" id="csvtools">
|
||||
<summary>Export / import</summary>
|
||||
<div class="tool"><strong>Export your books</strong>
|
||||
<div class="small">A spreadsheet (CSV) with one row per copy: title, author, ISBN, barcode, shelf, collection, status and any reading level on file.</div>
|
||||
<div class="actions"><a href="api/export.csv" download><button type="button">Download library.csv</button></a></div></div>
|
||||
<div class="tool"><strong>Import reading levels</strong>
|
||||
<div class="small">A CSV with a header row and columns <code>isbn</code>, <code>level</code>, <code>points</code> (and optionally <code>quiz</code>). Existing levels for the same ISBN are updated.</div>
|
||||
<div class="actions"><input type="file" id="arfile" accept=".csv,text/csv"><button type="button" id="arimport">Import</button></div>
|
||||
<div id="arresult" class="small" aria-live="polite"></div></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="view-lend" hidden>
|
||||
<div class="copybox">
|
||||
<strong>Return a book</strong>
|
||||
<form class="scan" id="retform" autocomplete="off" style="margin-top:8px">
|
||||
<label class="sr" for="ret">Barcode to return</label>
|
||||
<input id="ret" inputmode="numeric" placeholder="Scan the book's barcode" autocomplete="off">
|
||||
<button class="primary" type="submit">Return</button>
|
||||
</form>
|
||||
<div class="cam-row"><button type="button" id="retcam">Return with camera</button> <button type="button" id="retlive" hidden>Live return</button></div>
|
||||
<div id="retresult" role="status" aria-live="polite" class="small" style="margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="copybox">
|
||||
<strong>Check out to…</strong>
|
||||
<form class="find-form" id="whoform" autocomplete="off">
|
||||
<label class="sr" for="who">Name or library card number</label>
|
||||
<input id="who" type="search" placeholder="Name, or scan the library card" enterkeyhint="search" autocomplete="off">
|
||||
<button type="submit">Find</button>
|
||||
</form>
|
||||
<div class="cam-row"><button type="button" id="whocam">Scan card or phone QR with camera</button> <button type="button" id="wholive" hidden>Live scan</button>
|
||||
<span class="small">Photograph the library card, or the QR picture on the borrower's phone.</span></div>
|
||||
<div id="whostatus" class="small"></div>
|
||||
<div id="whoresults"></div>
|
||||
<div id="patronbox"></div>
|
||||
</div>
|
||||
<h2>Checked out right now <span id="outcount" class="small"></span></h2>
|
||||
<div id="outlist"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-people" hidden>
|
||||
<details class="settings" id="newperson" open>
|
||||
<summary>Add a person</summary>
|
||||
<form class="manual" id="personform" autocomplete="off">
|
||||
<input name="firstname" placeholder="First name" aria-label="First name">
|
||||
<input name="surname" placeholder="Last name" required aria-label="Last name">
|
||||
<input name="phone" placeholder="Phone (optional)" aria-label="Phone">
|
||||
<input name="email" type="email" placeholder="Email (optional)" aria-label="Email">
|
||||
<div class="actions"><button class="primary" type="submit">Add person</button></div>
|
||||
</form>
|
||||
<div id="personresult" aria-live="polite"></div>
|
||||
</details>
|
||||
<p><a href="#cards"><button type="button">Print everyone's cards</button></a>
|
||||
<button type="button" id="alllogins">Give everyone a sign-in</button></p>
|
||||
<details class="settings"><summary>Sign-in passwords for new people</summary>
|
||||
<fieldset><label class="opt"><input type="radio" name="pwmode" value="generated"> A different easy password for each person (like maple482)</label>
|
||||
<label class="opt"><input type="radio" name="pwmode" value="same"> The same password for everyone:
|
||||
<input type="text" id="pwsame" maxlength="40" placeholder="e.g. readmore" style="font:inherit;padding:4px 8px"></label>
|
||||
<div class="small">People sign in to see their own books with their library card number and this password; they can change it themselves. Existing passwords are never changed by this setting.</div>
|
||||
<div class="actions"><button type="button" id="pwsave">Save</button> <span id="pwsaved" class="small"></span></div></fieldset>
|
||||
</details>
|
||||
<div id="loginresult" aria-live="polite"></div>
|
||||
<form class="find-form" id="peopleform" autocomplete="off">
|
||||
<label class="sr" for="pq">Search people</label>
|
||||
<input id="pq" type="search" placeholder="Search by name or card number" enterkeyhint="search">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div id="peoplelist"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-card" hidden>
|
||||
<p class="noprint"><a href="#people">← Back to people</a> <button type="button" id="printcard" class="primary">Print this card</button> <button type="button" id="savecard">Save as picture (to send to a phone)</button></p>
|
||||
<div id="cardbox"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-mine" hidden>
|
||||
<div class="copybox"><strong id="mename"></strong> <span class="badge" id="mecard"></span>
|
||||
<div class="small">What you have borrowed, and when it is due.</div></div>
|
||||
<div id="meloans" aria-live="polite"></div>
|
||||
<details class="settings" id="mepw"><summary>Change my password</summary>
|
||||
<form class="manual" id="mepwform" autocomplete="off">
|
||||
<input type="password" name="old" placeholder="Current password" autocomplete="current-password" required aria-label="Current password">
|
||||
<input type="password" name="new" placeholder="New password (4 or more characters)" autocomplete="new-password" required minlength="4" aria-label="New password">
|
||||
<div class="actions"><button class="primary" type="submit">Change password</button></div>
|
||||
</form>
|
||||
<div id="mepwresult" class="small" aria-live="polite"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="view-cards" hidden>
|
||||
<p class="noprint"><a href="#people">← Back to people</a> <button type="button" id="printcards" class="primary">Print these cards</button>
|
||||
<span class="small" id="cardsnote"></span></p>
|
||||
<div class="cardsheet" id="cardsheet"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-book" hidden>
|
||||
<p><a href="#library" id="back">← Back to the library</a></p>
|
||||
<div id="bookbox" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-add">
|
||||
|
||||
<fieldset class="types" id="types" style="border:0;padding:0" aria-label="What are you adding?"></fieldset>
|
||||
<div class="shelf" id="shelfbox" hidden>
|
||||
<label for="shelf">Shelf / room</label>
|
||||
@@ -104,6 +293,7 @@
|
||||
</form>
|
||||
<div class="cam-row">
|
||||
<button type="button" id="cam">Scan with camera</button>
|
||||
<button type="button" id="livecam" hidden>Live scan</button>
|
||||
<input type="file" id="camfile" accept="image/*" capture="environment" hidden>
|
||||
<span class="small">Opens your phone's camera: fill the frame with the barcode and hold steady.</span>
|
||||
</div>
|
||||
@@ -149,7 +339,13 @@
|
||||
|
||||
<h2>Earlier</h2>
|
||||
<ul class="list" id="earlier"></ul>
|
||||
</section>
|
||||
</main>
|
||||
<div id="live" hidden role="dialog" aria-modal="true" aria-label="Camera scanner">
|
||||
<div class="frame"><video id="livevideo" playsinline muted></video></div>
|
||||
<div class="msg" id="livemsg" aria-live="polite"></div>
|
||||
<button type="button" id="livestop" class="primary">Done</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
@@ -182,8 +378,10 @@
|
||||
};
|
||||
|
||||
async function api(path, body) {
|
||||
const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body) } : {});
|
||||
// X-Quickadd marks our own requests: a staff-site login (cookie) is only accepted for changes
|
||||
// that carry it, which a page on another website cannot add (cross-site request forgery).
|
||||
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Quickadd': '1' },
|
||||
body: JSON.stringify(body) } : { headers: { 'X-Quickadd': '1' } });
|
||||
return r.json();
|
||||
}
|
||||
|
||||
@@ -291,16 +489,17 @@
|
||||
}
|
||||
|
||||
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
|
||||
function takePhoto(kind) {
|
||||
function takePhoto(kind, statusEl) {
|
||||
statusEl = statusEl || $('status');
|
||||
return new Promise(resolve => {
|
||||
const input = $('camfile');
|
||||
input.onchange = async () => {
|
||||
const f = input.files[0]; input.value = '';
|
||||
if (!f) return resolve(null);
|
||||
$('status').textContent = 'Reading the barcode…';
|
||||
statusEl.textContent = 'Reading the barcode…';
|
||||
try {
|
||||
const blob = await shrink(f);
|
||||
const r = await fetch('/api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob });
|
||||
const r = await fetch('api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg', 'X-Quickadd': '1' }, body: blob });
|
||||
resolve(r.ok ? (await r.json()).codes : []);
|
||||
} catch (e) { resolve([]); }
|
||||
};
|
||||
@@ -308,6 +507,57 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---- live camera: works where the browser allows camera access (HTTPS, or the machine itself).
|
||||
// Chrome/Android read codes in the browser (BarcodeDetector); everything else sends small frames to
|
||||
// the server's decoder a couple of times a second. Plain-HTTP pages fall back to the photo buttons.
|
||||
const liveOK = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||
let liveStop = null;
|
||||
// onCode(code) may return text to show; continuous keeps scanning, otherwise the first code closes it.
|
||||
async function liveScan(kind, title, onCode, continuous) {
|
||||
const box = $('live'), video = $('livevideo'), msg = $('livemsg');
|
||||
let stream;
|
||||
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 } }, audio: false }); }
|
||||
catch (e) { alert('The camera is not available (' + (e.name === 'NotAllowedError' ? 'permission was refused' : e.message) + '). Use the photo button instead.'); return; }
|
||||
box.hidden = false; msg.textContent = title; video.srcObject = stream; await video.play().catch(() => {});
|
||||
let alive = true, busy = false, lastCode = '', lastAt = 0;
|
||||
const detector = 'BarcodeDetector' in window ? new BarcodeDetector({ formats: ['qr_code', 'code_39', 'code_128', 'ean_13', 'ean_8', 'upc_a', 'upc_e'] }) : null;
|
||||
const canvas = document.createElement('canvas');
|
||||
const finish = () => { alive = false; stream.getTracks().forEach(t => t.stop()); video.srcObject = null; box.hidden = true; liveStop = null; };
|
||||
liveStop = finish; $('livestop').onclick = finish;
|
||||
const accept = async code => {
|
||||
if (!code || busy) return;
|
||||
if (kind === 'isbn') { code = clean(code); if (!code) return; }
|
||||
const now = Date.now();
|
||||
if (code === lastCode && now - lastAt < 4000) return; // the same book still in view
|
||||
lastCode = code; lastAt = now; busy = true;
|
||||
beep(SOUND.added);
|
||||
try { const t = await onCode(code); if (t) msg.textContent = t; } catch (e) {}
|
||||
busy = false;
|
||||
if (!continuous) finish();
|
||||
};
|
||||
const clean = c => { c = String(c).replace(/[\s-]/g, '').toUpperCase(); if (!/^(\d{8,14}|\d{9}X)$/.test(c)) return ''; return c.length === 13 && c[0] === '0' ? c.slice(1) : c; };
|
||||
while (alive) {
|
||||
try {
|
||||
if (video.readyState >= 2 && !busy) {
|
||||
if (detector) {
|
||||
const found = await detector.detect(video);
|
||||
if (found.length) await accept(found[0].rawValue);
|
||||
} else {
|
||||
const k = Math.min(1, 1280 / Math.max(video.videoWidth, video.videoHeight));
|
||||
canvas.width = Math.round(video.videoWidth * k); canvas.height = Math.round(video.videoHeight * k);
|
||||
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(ok => canvas.toBlob(ok, 'image/jpeg', 0.85));
|
||||
const r = await fetch('api/scan?kind=' + (kind === 'isbn' ? 'isbn' : 'sticker'), { method: 'POST', headers: { 'Content-Type': 'image/jpeg', 'X-Quickadd': '1' }, body: blob });
|
||||
const codes = r.ok ? (await r.json()).codes : [];
|
||||
if (codes.length && alive) await accept(codes[0]);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
await new Promise(ok => setTimeout(ok, detector ? 200 : 650));
|
||||
}
|
||||
}
|
||||
window.addEventListener('hashchange', () => { if (liveStop) liveStop(); });
|
||||
|
||||
function noBarcode() {
|
||||
beep(SOUND.notfound);
|
||||
$('status').textContent = '';
|
||||
@@ -315,6 +565,8 @@
|
||||
el('div', { className: 'meta' }, 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.')));
|
||||
}
|
||||
|
||||
$('livecam').hidden = !liveOK;
|
||||
$('livecam').onclick = () => liveScan('isbn', 'Hold each book\'s barcode in the frame', async code => { await add(code); return 'Added - next book'; }, true);
|
||||
$('cam').onclick = async () => {
|
||||
const codes = await takePhoto('isbn');
|
||||
if (codes === null) { $('status').textContent = ''; return; }
|
||||
@@ -351,7 +603,7 @@
|
||||
}
|
||||
|
||||
let staffBase = '';
|
||||
const staffRoot = () => staffBase || ('http://' + location.hostname + ':' + staffPort);
|
||||
const staffRoot = () => staffBase || (location.pathname.startsWith('/add/') ? location.origin : 'http://' + location.hostname + ':' + staffPort);
|
||||
const printLink = (from, to) => staffRoot() +
|
||||
'/cgi-bin/koha/labels/label-print.pl?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
|
||||
|
||||
@@ -424,13 +676,552 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- under the staff site (/add/) everything is same-origin
|
||||
const embedded = location.pathname.startsWith('/add/');
|
||||
let viewer = false, isPatron = false; // the read-only guest login: search only
|
||||
|
||||
// ---- views: Library / Book / Add books
|
||||
const TINTS = ['#5b6f63', '#6b5b73', '#73655b', '#4f6a7a', '#7a5252', '#6a6f3f', '#4a5f7a', '#7a6a3a'];
|
||||
const tint = t => TINTS[[...(t || '')].reduce((n, c) => (n * 31 + c.charCodeAt(0)) % 997, 7) % TINTS.length];
|
||||
function art(b, cls) {
|
||||
const a = el('span', { className: cls || 'art' }, b.title);
|
||||
a.style.setProperty('--tint', tint(b.title));
|
||||
if (b.isbn && /^\d{10,13}$/.test(b.isbn.replace(/\D/g, '').slice(0, 13))) {
|
||||
const img = el('img', { alt: '', hidden: true, loading: 'lazy' });
|
||||
img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
|
||||
img.src = 'https://covers.openlibrary.org/b/isbn/' + b.isbn.replace(/\D/g, '').slice(0, 13) + '-M.jpg?default=false';
|
||||
a.append(img);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
let libPage = 1, libShelves = [], libCollections = [], libLoaded = false, filtersReady = false;
|
||||
const filters = () => ({ q: $('q').value.trim(), shelf: $('f-shelf').value, collection: $('f-coll').value, tag: $('f-tag').value, type: $('f-type').value,
|
||||
status: $('f-status').value, sort: $('f-sort').value });
|
||||
const filterQuery = () => { const f = filters(); return Object.entries(f).filter(([, v]) => v).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&'); };
|
||||
|
||||
function tile(b) {
|
||||
const a = el('a', { className: 'tile', href: '#book/' + b.id });
|
||||
const picture = art(b);
|
||||
if (b.copies > 1) picture.append(el('span', { className: 'num' }, '×' + b.copies));
|
||||
if (!viewer && b.out) picture.append(el('span', { className: 'flag' }, b.out === b.copies ? 'Out' : b.out + ' out'));
|
||||
else if (viewer && b.out === b.copies && b.copies) picture.append(el('span', { className: 'flag' }, 'Out'));
|
||||
a.append(picture, el('div', { className: 'tt' }, b.title), el('div', { className: 'aa' }, b.author));
|
||||
return a;
|
||||
}
|
||||
|
||||
function fillTagFilter(tags) {
|
||||
const cur = $('f-tag').value;
|
||||
$('f-tag').replaceChildren(el('option', { value: '' }, 'Any tag'), ...tags.map(t => el('option', { value: t.tag }, t.tag + ' (' + t.count + ')')));
|
||||
$('f-tag').value = [...$('f-tag').options].some(o => o.value === cur) ? cur : '';
|
||||
}
|
||||
let libSeq = 0;
|
||||
async function loadLibrary(reset) {
|
||||
const seq = ++libSeq; // a newer search makes an older, slower answer irrelevant
|
||||
if (reset) libPage = 1;
|
||||
const grid = $('grid');
|
||||
if (reset) grid.replaceChildren();
|
||||
$('libstatus').textContent = 'Loading…';
|
||||
let r;
|
||||
try { r = await api('/api/library?' + filterQuery() + '&page=' + libPage); } catch (e) { r = {}; }
|
||||
if (seq !== libSeq) return;
|
||||
if (!r.books) { $('libstatus').textContent = r.message || 'Koha is not answering yet - try again in a minute.'; return; }
|
||||
if (r.collections) libCollections = r.collections;
|
||||
if (r.tags) fillTagFilter(r.tags);
|
||||
if (!filtersReady && r.shelves) {
|
||||
filtersReady = true;
|
||||
libShelves = r.shelves;
|
||||
$('f-coll').replaceChildren(el('option', { value: '' }, 'Any collection'), el('option', { value: '-' }, '(no collection)'),
|
||||
...(r.collections || []).map(l => el('option', { value: l.value }, l.label)));
|
||||
$('f-shelf').replaceChildren(el('option', { value: '' }, 'Any shelf'), el('option', { value: '-' }, '(no shelf set)'),
|
||||
...r.shelves.map(l => el('option', { value: l.value }, l.label)));
|
||||
$('f-type').replaceChildren(el('option', { value: '' }, 'Any format'),
|
||||
...Object.entries(r.types || {}).map(([k, v]) => el('option', { value: k }, v)));
|
||||
}
|
||||
grid.append(...r.books.map(tile));
|
||||
$('more').hidden = !r.more;
|
||||
const shown = grid.children.length;
|
||||
const f = filters();
|
||||
$('libstatus').textContent = shown ? shown + (r.more ? '+' : '') + (shown === 1 ? ' book' : ' books') + (f.q || f.shelf || f.collection || f.tag || f.type || f.status ? ' match' : '') :
|
||||
(f.q || f.shelf || f.collection || f.tag || f.type || f.status ? 'No books match.' : (viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.'));
|
||||
}
|
||||
$('arimport').onclick = async () => {
|
||||
const f = $('arfile').files[0]; if (!f) { $('arresult').textContent = 'Choose a CSV file first.'; return; }
|
||||
const r = await api('/api/ar/import', { csv: await f.text() });
|
||||
$('arresult').textContent = r.status === 'imported' ? 'Imported ' + r.count + ' reading level' + (r.count === 1 ? '' : 's') + (r.skipped ? ' (' + r.skipped + ' rows skipped: no valid ISBN or level)' : '') + '.' : (r.message || 'Import failed.');
|
||||
};
|
||||
$('more').onclick = () => { libPage += 1; loadLibrary(false); };
|
||||
$('findform').onsubmit = ev => { ev.preventDefault(); loadLibrary(true); };
|
||||
for (const id of ['f-shelf', 'f-coll', 'f-tag', 'f-type', 'f-status', 'f-sort']) $(id).onchange = () => loadLibrary(true);
|
||||
|
||||
// ---- one book
|
||||
async function loadBook(id) {
|
||||
const box = $('bookbox');
|
||||
box.replaceChildren(el('div', { className: 'small' }, 'Loading…'));
|
||||
let b;
|
||||
try { b = await api('/api/book/' + id); } catch (e) { b = {}; }
|
||||
if (!b.copies) { box.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, b.message || 'Book not found.'))); return; }
|
||||
document.title = b.title;
|
||||
if (b.shelves && b.shelves.length) libShelves = b.shelves;
|
||||
if (b.collections) libCollections = b.collections;
|
||||
const facts = [b.author, b.year, b.publisher, b.pages, b.edition, b.series].filter(Boolean);
|
||||
const head = el('div', { className: 'bookhead' }, art(b),
|
||||
el('div', { className: 'body' }, el('h2', {}, b.title), b.subtitle ? el('div', { className: 'meta' }, b.subtitle) : '',
|
||||
el('div', { className: 'meta' }, facts.join(' · ')), b.isbn ? el('div', { className: 'small' }, 'ISBN ' + b.isbn) : '',
|
||||
b.summary ? el('p', {}, b.summary) : ''));
|
||||
box.replaceChildren(head);
|
||||
const refresh = () => loadBook(id);
|
||||
box.append(tagBox(b));
|
||||
const moreBox = el('div', { className: 'copybox' }, el('div', { className: 'small' }, 'Looking for more about this book…'));
|
||||
box.append(moreBox);
|
||||
api('/api/more/' + id).then(m => fillMore(moreBox, b, m)).catch(() => moreBox.remove());
|
||||
for (const c of b.copies) box.append(copyBox(b, c, refresh));
|
||||
if (!b.copies.length) box.append(el('div', { className: 'small' }, 'No copies recorded.'));
|
||||
if (!viewer) {
|
||||
const more = el('button', { type: 'button' }, 'Add another copy');
|
||||
more.onclick = async () => { more.disabled = true; const r = await api('/api/item/copy', { biblio_id: b.id }); if (r.status === 'added') refresh(); else more.disabled = false; };
|
||||
const del = el('button', { type: 'button', className: 'danger' }, 'Delete this book');
|
||||
del.onclick = async () => {
|
||||
if (!confirm('Delete "' + b.title + '" and all its copies from the library?')) return;
|
||||
const r = await api('/api/book/delete', { biblio_id: b.id });
|
||||
if (r.status === 'deleted') location.hash = '#library'; else alert(r.message || 'Could not delete it.');
|
||||
};
|
||||
const full = el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.id, target: '_blank', rel: 'noopener' }, 'Open the full record in Koha');
|
||||
box.append(el('div', { className: 'actions' }, more, del), el('p', { className: 'small' }, full, ' (edit title, author and other details there)'));
|
||||
}
|
||||
}
|
||||
|
||||
// A shelf/collection chooser whose last entry adds a new one (Koha's own screens are not needed).
|
||||
function valueSelect(kind, label, none, list, current) {
|
||||
const sel = el('select', { 'aria-label': label });
|
||||
const fill = (items, cur) => { sel.replaceChildren(el('option', { value: '' }, none), ...items.map(l => el('option', { value: l.value }, l.label)),
|
||||
el('option', { value: '__new' }, '+ New ' + kind + '…')); sel.value = cur || ''; };
|
||||
fill(list, current);
|
||||
let last = current || '';
|
||||
sel.onchange = async () => {
|
||||
if (sel.value !== '__new') { last = sel.value; return; }
|
||||
const name = prompt('Name of the new ' + kind + ':');
|
||||
if (!name || !name.trim()) { sel.value = last; return; }
|
||||
const r = await api('/api/value/add', { kind, name });
|
||||
if (r.status !== 'added') { alert(r.message || 'Could not add that.'); sel.value = last; return; }
|
||||
const items = (kind === 'shelf' ? libShelves : libCollections); items.push({ value: r.value, label: r.label });
|
||||
fill(items, r.value); last = r.value;
|
||||
};
|
||||
return sel;
|
||||
}
|
||||
|
||||
// Reading level (AR): typed in here or imported from a CSV. AR BookFind itself is only a link: it has no
|
||||
// public feed and its terms forbid automated look-ups.
|
||||
function arBox(b, m) {
|
||||
const ar = m.ar || {};
|
||||
const f = (ph, v, w) => el('input', { type: 'text', placeholder: ph, value: v || '', 'aria-label': ph, inputMode: 'decimal', style: 'width:' + w + 'em;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
|
||||
const lvl = f('Level', ar.level, 5), pts = f('Points', ar.points, 5), quiz = f('Quiz no.', ar.quiz, 7), inter = f('Interest (LG/MG/UG)', ar.interest, 9);
|
||||
const wrap = el('div', { className: 'small', style: 'margin-top:10px;border-top:1px solid var(--line);padding-top:8px' });
|
||||
const copy = el('button', { type: 'button' }, 'Copy title');
|
||||
copy.onclick = async () => { try { await navigator.clipboard.writeText(b.title); copy.textContent = 'Copied ✓'; } catch (e) { copy.textContent = 'Copy failed'; } setTimeout(() => copy.textContent = 'Copy title', 1500); };
|
||||
const open = el('a', { href: 'https://www.arbookfind.com/', target: '_blank', rel: 'noopener noreferrer' }, 'Open AR BookFind');
|
||||
const copyTitle = async () => { try { await navigator.clipboard.writeText(b.title); } catch (e) {} };
|
||||
const arPop = el('button', { type: 'button' }, 'Open beside this page');
|
||||
arPop.onclick = async () => { await copyTitle();
|
||||
const w = Math.min(560, Math.floor(screen.availWidth / 2)), h = screen.availHeight - 80;
|
||||
window.open('https://www.arbookfind.com/', 'arbookfind', 'popup=yes,width=' + w + ',height=' + h + ',left=' + (screen.availWidth - w) + ',top=40'); };
|
||||
const arHolder = el('div', {});
|
||||
const arFrame = el('button', { type: 'button' }, 'Show it here');
|
||||
arFrame.onclick = async () => { await copyTitle();
|
||||
if (arHolder.firstChild) { arHolder.replaceChildren(); arFrame.textContent = 'Show it here'; return; }
|
||||
arFrame.textContent = 'Hide it';
|
||||
arHolder.replaceChildren(el('iframe', { src: 'https://www.arbookfind.com/', title: 'AR BookFind', loading: 'lazy', referrerPolicy: 'no-referrer',
|
||||
style: 'width:100%;height:520px;border:1px solid var(--line);border-radius:8px;margin-top:8px;background:#fff' }),
|
||||
el('div', { className: 'small' }, 'If it keeps asking who you are, your browser is blocking its cookies inside another page: use "Open beside this page" instead.')); };
|
||||
if (viewer) { wrap.append(el('strong', {}, 'Reading level: '), ar.level ? 'AR ' + ar.level + (ar.points ? ', ' + ar.points + ' points' : '') + (ar.interest ? ', interest ' + ar.interest : '') : 'not recorded'); return wrap; }
|
||||
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
|
||||
const msg = el('span', {});
|
||||
save.onclick = async () => {
|
||||
const r = await api('/api/ar/set', { isbn: m.isbn, level: lvl.value, points: pts.value, quiz: quiz.value, interest: inter.value });
|
||||
msg.textContent = r.status === 'saved' ? ' Saved ✓' : ' ' + (r.message || 'Could not save.');
|
||||
};
|
||||
// Easiest path: on AR BookFind's book page select everything (Ctrl/Cmd+A), copy, and paste it here.
|
||||
const paste = el('textarea', { rows: 2, placeholder: 'Paste the AR BookFind book page here (select all, copy, paste)', 'aria-label': 'Pasted AR BookFind text',
|
||||
style: 'width:100%;margin-top:6px;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
|
||||
paste.oninput = () => {
|
||||
const t = paste.value, g = re => (t.match(re) || [])[1] || '';
|
||||
const found = { level: g(/Book\s*Level:?\s*([0-9]{1,2}(?:\.[0-9]{1,2})?)/i), points: g(/(?:AR\s*)?Points:?\s*([0-9]{1,3}(?:\.[0-9]{1,2})?)/i),
|
||||
quiz: g(/Quiz\s*(?:No\.?|Number|#)\s*:?\s*([0-9]{3,6})/i), interest: g(/Interest\s*Level:?\s*(LG|MG\+?|UG)/i) };
|
||||
if (found.level) lvl.value = found.level; if (found.points) pts.value = found.points; if (found.quiz) quiz.value = found.quiz; if (found.interest) inter.value = found.interest;
|
||||
msg.textContent = (found.level || found.points || found.quiz) ? ' Filled in from what you pasted - check it, then Save.' : (t.trim() ? " Couldn't find a level or points in that text." : '');
|
||||
};
|
||||
wrap.append(el('strong', {}, 'Reading level (AR): '), lvl, ' ', pts, ' ', quiz, ' ', inter, ' ', save, msg, paste,
|
||||
el('div', { style: 'margin-top:6px' }, open, ' ', arPop, ' ', arFrame, ' ', copy),
|
||||
el('div', { className: 'small' }, 'Search for the title there, select the whole book page, copy, and paste it below. (The title is copied for you when you open it.)'), arHolder);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Tags: any number per book, free words ("to read", "kids", "signed"...). Click one to see every book with it.
|
||||
function tagBox(b) {
|
||||
const box = el('div', { className: 'copybox' });
|
||||
let tags = [...(b.tags || [])];
|
||||
const all = (b.all_tags || []).map(t => t.tag);
|
||||
const draw = () => {
|
||||
const chips = tags.map(t => {
|
||||
const c = el('span', { className: 'chip', style: 'margin:0 6px 6px 0;display:inline-block' });
|
||||
const a = el('a', { href: '#library', style: 'text-decoration:none' }, t);
|
||||
a.onclick = () => { setTimeout(() => { $('f-tag').value = t; loadLibrary(true); }, 0); };
|
||||
c.append(a);
|
||||
if (!viewer) { const x = el('button', { type: 'button', 'aria-label': 'Remove tag ' + t, style: 'border:0;background:none;padding:0 0 0 6px;cursor:pointer;color:inherit' }, '×');
|
||||
x.onclick = () => save(tags.filter(y => y !== t)); c.append(x); }
|
||||
return c;
|
||||
});
|
||||
const parts = [el('strong', {}, 'Tags: '), ...(chips.length ? chips : [el('span', { className: 'small' }, viewer ? 'none' : 'none yet ')])];
|
||||
if (!viewer) {
|
||||
const list = el('datalist', { id: 'taglist' }, ...all.filter(t => !tags.includes(t)).map(t => el('option', { value: t })));
|
||||
const inp = el('input', { type: 'text', placeholder: 'Add a tag, press Enter', 'aria-label': 'Add a tag', maxLength: 30,
|
||||
style: 'font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink);min-width:12em' });
|
||||
inp.setAttribute('list', 'taglist');
|
||||
inp.onkeydown = ev => { if (ev.key === 'Enter') { ev.preventDefault(); const v = inp.value.trim(); if (v) save([...tags, v]); } };
|
||||
parts.push(list, inp);
|
||||
}
|
||||
box.replaceChildren(...parts);
|
||||
};
|
||||
const save = async next => {
|
||||
const r = await api('/api/tags/set', { biblio_id: b.id, tags: next });
|
||||
if (r.status === 'saved') { tags = r.tags; draw(); fillTagFilter(r.all || []); }
|
||||
};
|
||||
draw();
|
||||
return box;
|
||||
}
|
||||
|
||||
function fillMore(box, b, m) {
|
||||
const parts = [];
|
||||
if (m.description && !b.summary) parts.push(el('p', {}, m.description), el('div', { className: 'small' }, 'Description from Open Library.'));
|
||||
if (m.subjects && m.subjects.length) parts.push(el('div', { style: 'margin:8px 0' }, ...m.subjects.map(t => el('span', { className: 'chip', style: 'margin:0 6px 6px 0;display:inline-block' }, t))));
|
||||
if (m.first_published) parts.push(el('div', { className: 'small' }, 'First published: ' + m.first_published));
|
||||
const row = el('div', { className: 'small', style: 'margin-top:8px' }, 'Look it up: ');
|
||||
(m.links || []).forEach((l, i) => { if (i) row.append(' · '); row.append(el('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer' }, l.label)); });
|
||||
parts.push(row);
|
||||
if ((m.links || []).some(l => l.note)) parts.push(arBox(b, m));
|
||||
box.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
function copyBox(b, c, refresh) {
|
||||
const box = el('div', { className: 'copybox' });
|
||||
const out = c.status === 'Checked out';
|
||||
const status = el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status +
|
||||
(out && c.out ? (c.out.who ? ' to ' + c.out.who : '') + (c.out.due ? ', due ' + c.out.due : '') : ''));
|
||||
box.append(el('div', {}, c.barcode ? el('span', { className: 'badge' }, c.barcode) : el('span', { className: 'small' }, 'no barcode yet'), ' ', status));
|
||||
if (viewer) {
|
||||
box.append(el('div', { className: 'meta' }, [c.shelf_label || 'no shelf set', c.callnumber && 'Call no. ' + c.callnumber].filter(Boolean).join(' · ')));
|
||||
return box;
|
||||
}
|
||||
const sel = valueSelect('shelf', 'Shelf', '(no shelf set)', libShelves, c.shelf);
|
||||
const csel = valueSelect('collection', 'Collection', '(no collection)', libCollections, c.collection);
|
||||
const cn = el('input', { type: 'text', value: c.callnumber, placeholder: 'Call number', 'aria-label': 'Call number', maxLength: 80 });
|
||||
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
save.disabled = true;
|
||||
const r = await api('/api/item/update', { item_id: c.item_id, shelf: sel.value, collection: csel.value, callnumber: cn.value });
|
||||
save.textContent = r.status === 'saved' ? 'Saved ✓' : 'Failed'; setTimeout(() => { save.textContent = 'Save'; save.disabled = false; }, 1500);
|
||||
};
|
||||
const rm = el('button', { type: 'button', className: 'danger' }, 'Remove copy');
|
||||
rm.disabled = out;
|
||||
rm.onclick = async () => {
|
||||
if (!confirm('Remove this copy' + (c.barcode ? ' (barcode ' + c.barcode + ')' : '') + '?')) return;
|
||||
const r = await api('/api/item/delete', { item_id: c.item_id, biblio_id: b.id });
|
||||
if (r.status === 'undone') { if (b.copies.length <= 1) location.hash = '#library'; else refresh(); }
|
||||
};
|
||||
const lab = el('a', { href: printLink(c.barcode, c.barcode), target: '_blank', rel: 'noopener' }, el('button', { type: 'button' }, 'Print label'));
|
||||
box.append(el('div', { className: 'row' }, sel, csel, cn, save, c.barcode ? lab : '', rm));
|
||||
return box;
|
||||
}
|
||||
|
||||
// ---- lend / return
|
||||
const fmtDue = d => d ? new Date(d + 'T12:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
|
||||
let curPatron = null, lendBusy = false;
|
||||
|
||||
function say(box, kind, text) { box.replaceChildren(el('div', { className: 'card ' + kind }, el('div', { className: 'body' }, text))); }
|
||||
|
||||
async function doReturn(code, box) {
|
||||
box.textContent = 'Returning…';
|
||||
let r; try { r = await api('/api/return', { barcode: code }); } catch (e) { r = { status: 'error', message: 'Could not reach the service.' }; }
|
||||
if (r.status === 'returned') { beep(SOUND.added); say(box, 'ok', 'Returned: ' + (r.title || code) + (r.patron ? ' (from ' + r.patron + ')' : '')); }
|
||||
else if (r.status === 'not_out') { beep(SOUND.duplicate); say(box, 'warn', (r.title || code) + ' was not checked out.'); }
|
||||
else if (r.status === 'unknown_barcode') { beep(SOUND.notfound); say(box, 'warn', 'No book has the barcode ' + code + '.'); }
|
||||
else { beep(SOUND.error); say(box, 'bad', r.message || 'Could not return that.'); }
|
||||
loadOut(); if (curPatron) openPatron(curPatron.id, true);
|
||||
return r;
|
||||
}
|
||||
$('retform').onsubmit = async ev => { ev.preventDefault(); const c = $('ret').value.trim(); $('ret').value = ''; if (c) { await doReturn(c, $('retresult')); $('ret').focus(); } };
|
||||
|
||||
$('retlive').hidden = $('wholive').hidden = !liveOK;
|
||||
$('retlive').onclick = () => liveScan('sticker', 'Scan each returned book', async code => {
|
||||
const r = await doReturn(code, $('retresult')); return $('retresult').innerText; }, true);
|
||||
async function pickByCode(code) {
|
||||
const r = await api('/api/patrons?q=' + encodeURIComponent(code));
|
||||
const hit = (r.patrons || []).filter(p => p.card === code);
|
||||
if (hit.length === 1) { location.hash = '#lend/' + hit[0].id; return true; }
|
||||
return false;
|
||||
}
|
||||
$('wholive').onclick = () => liveScan('sticker', 'Hold the library card or the phone QR in the frame', async code => {
|
||||
if (await pickByCode(code)) return; beep(SOUND.notfound); $('whostatus').textContent = 'No library card matches ' + code + '.'; }, false);
|
||||
$('retcam').onclick = async () => {
|
||||
const codes = await takePhoto('sticker', $('retresult'));
|
||||
if (codes === null) { $('retresult').textContent = ''; return; }
|
||||
if (!codes.length) { $('retresult').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
|
||||
doReturn(codes[0], $('retresult'));
|
||||
};
|
||||
$('whocam').onclick = async () => {
|
||||
const codes = await takePhoto('sticker', $('whostatus'));
|
||||
if (codes === null) { $('whostatus').textContent = ''; return; }
|
||||
$('whostatus').textContent = '';
|
||||
if (!codes.length) { $('whostatus').textContent = "Couldn't read a code from that photo - move closer and try again."; return; }
|
||||
const r = await api('/api/patrons?q=' + encodeURIComponent(codes[0]));
|
||||
const hit = (r.patrons || []).filter(p => p.card === codes[0]);
|
||||
if (hit.length === 1) { beep(SOUND.added); location.hash = '#lend/' + hit[0].id; }
|
||||
else { beep(SOUND.notfound); $('whostatus').textContent = 'No library card matches ' + codes[0] + '.'; }
|
||||
};
|
||||
|
||||
async function loadOut() {
|
||||
let r; try { r = await api('/api/out'); } catch (e) { return; }
|
||||
if (!r.loans) return;
|
||||
$('outcount').textContent = r.loans.length ? '(' + r.loans.length + ')' : '';
|
||||
const late = r.loans.filter(l => l.overdue).length;
|
||||
if (!r.loans.length) { $('outlist').replaceChildren(el('div', { className: 'small' }, 'Nothing is checked out.')); return; }
|
||||
$('outlist').replaceChildren(...(late ? [el('div', { className: 'small' }, late + ' overdue')] : []), ...r.loans.map(l => loanRow(l, true)));
|
||||
}
|
||||
|
||||
function loanRow(l, showWho) {
|
||||
const ret = el('button', { type: 'button' }, 'Return');
|
||||
ret.onclick = async () => { ret.disabled = true; await doReturn(l.barcode, $('retresult')); };
|
||||
const ren = el('button', { type: 'button' }, 'Renew');
|
||||
ren.onclick = async () => {
|
||||
ren.disabled = true; const r = await api('/api/renew', { checkout_id: l.checkout_id });
|
||||
if (r.status === 'renewed') { loadOut(); if (curPatron) openPatron(curPatron.id, true); } else { ren.disabled = false; alert(r.message); }
|
||||
};
|
||||
const a = el('a', { href: '#book/' + l.biblio_id }, l.title);
|
||||
return el('div', { className: 'loan' }, el('span', { className: 'badge' }, l.barcode),
|
||||
el('span', { className: 't' }, a, showWho ? el('div', { className: 'small' }, l.who) : ''),
|
||||
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, (l.overdue ? 'Overdue, was due ' : 'Due ') + fmtDue(l.due)), ret, ren);
|
||||
}
|
||||
|
||||
async function openPatron(id, quiet) {
|
||||
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
|
||||
if (!p.name) { $('patronbox').textContent = 'Could not load that person.'; return; }
|
||||
curPatron = p;
|
||||
$('whoresults').replaceChildren();
|
||||
const scan = el('input', { placeholder: 'Scan a book to check out', inputMode: 'numeric', autocomplete: 'off', 'aria-label': 'Barcode to check out' });
|
||||
const form = el('form', { className: 'scan' }, scan, el('button', { className: 'primary', type: 'submit' }, 'Check out'));
|
||||
const out = (quiet && document.getElementById('coresult')) || el('div', { id: 'coresult', role: 'status', className: 'small', style: 'margin-top:8px' });
|
||||
form.onsubmit = async ev => {
|
||||
ev.preventDefault(); const code = scan.value.trim(); scan.value = ''; if (!code || lendBusy) return; lendBusy = true;
|
||||
try {
|
||||
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
|
||||
if (r.status === 'checked_out') { beep(SOUND.added); say(out, 'ok', (r.title || code) + ' - due ' + fmtDue(r.due)); }
|
||||
else if (r.status === 'already_out') {
|
||||
beep(SOUND.duplicate);
|
||||
const rb = el('button', { type: 'button', className: 'primary' }, 'Return it, then check it out');
|
||||
rb.onclick = async () => { rb.disabled = true; await doReturn(code, out); const r2 = await api('/api/checkout', { patron_id: p.id, barcode: code });
|
||||
if (r2.status === 'checked_out') say(out, 'ok', (r2.title || code) + ' - due ' + fmtDue(r2.due)); await openPatron(p.id, true); loadOut(); };
|
||||
out.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, (r.title || code) + ' is already checked out. ', rb)));
|
||||
} else { beep(SOUND.notfound); say(out, 'bad', r.message || 'That did not work.'); }
|
||||
} catch (e) { say(out, 'bad', 'Could not reach the service.'); }
|
||||
lendBusy = false; await openPatron(p.id, true); loadOut(); document.querySelector('#patronbox form input').focus();
|
||||
};
|
||||
const bookCam = el('button', { type: 'button' }, 'Scan book with camera');
|
||||
bookCam.onclick = async () => {
|
||||
const codes = await takePhoto('sticker', out);
|
||||
if (codes === null) { out.textContent = ''; return; }
|
||||
if (!codes.length) { out.textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
|
||||
scan.value = codes[0]; form.requestSubmit();
|
||||
};
|
||||
const bookLive = el('button', { type: 'button', hidden: !liveOK }, 'Live scan books');
|
||||
bookLive.onclick = () => liveScan('sticker', 'Scan each book to check out to ' + p.name, async code => {
|
||||
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
|
||||
openPatron(p.id, true); loadOut();
|
||||
if (r.status === 'checked_out') return (r.title || code) + ' - due ' + fmtDue(r.due);
|
||||
beep(SOUND.notfound); return (r.title ? r.title + ': ' : '') + (r.message || 'That did not work.');
|
||||
}, true);
|
||||
const loans = p.loans.length ? p.loans.map(l => loanRow(l, false)) : [el('div', { className: 'small' }, 'Nothing checked out to ' + p.name + ' yet.')];
|
||||
$('patronbox').replaceChildren(el('div', { className: 'title', style: 'margin-top:10px' }, p.name, ' ', el('span', { className: 'badge' }, p.card || 'no card')),
|
||||
form, el('div', { className: 'cam-row' }, bookCam, ' ', bookLive), out, el('h2', {}, 'Has out'), ...loans,
|
||||
el('div', { className: 'small', style: 'margin-top:8px' }, el('a', { href: '#card/' + p.id }, 'Print library card'), ' · ',
|
||||
el('a', { href: '#lend' }, 'Choose someone else')));
|
||||
if (!quiet) scan.focus();
|
||||
}
|
||||
|
||||
async function findPeople(term, into, withActions) {
|
||||
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(term)); } catch (e) { r = {}; }
|
||||
if (!r.patrons) { into.textContent = (r && r.message) || 'Search failed.'; return []; }
|
||||
if (!r.patrons.length) { into.replaceChildren(el('div', { className: 'small' }, term ? 'Nobody matches "' + term + '".' : 'No people yet.')); return []; }
|
||||
into.replaceChildren(...r.patrons.map(p => el('div', { className: 'person' },
|
||||
el('span', { className: 't' }, el('strong', {}, p.name), el('div', { className: 'small' }, [p.card, p.phone, p.email].filter(Boolean).join(' · '))),
|
||||
...withActions(p))));
|
||||
return r.patrons;
|
||||
}
|
||||
$('whoform').onsubmit = async ev => {
|
||||
ev.preventDefault(); const term = $('who').value.trim(); if (!term) return;
|
||||
const list = await findPeople(term, $('whoresults'), p => { const b = el('button', { type: 'button', className: 'primary' }, 'Choose');
|
||||
b.onclick = () => { location.hash = '#lend/' + p.id; }; return [b]; });
|
||||
const exact = list.filter(p => p.card === term);
|
||||
if (exact.length === 1) { $('who').value = ''; location.hash = '#lend/' + exact[0].id; } // a scanned card goes straight through
|
||||
};
|
||||
|
||||
// ---- people
|
||||
async function loadPeople(term) {
|
||||
await findPeople(term, $('peoplelist'), p => {
|
||||
const lend = el('a', { href: '#lend/' + p.id }, el('button', { type: 'button' }, 'Lend / returns'));
|
||||
const card = el('a', { href: '#card/' + p.id }, el('button', { type: 'button' }, 'Print card'));
|
||||
const reset = el('button', { type: 'button' }, 'New password');
|
||||
reset.onclick = async () => { if (!confirm('Give ' + p.name + ' a new sign-in password?')) return; const r = await api('/api/patron/reset-login', { patron_id: p.id });
|
||||
$('loginresult').replaceChildren(el('div', { className: 'result-note' }, r.status === 'saved' ? r.name + ': sign in with card number ' + r.card + ' and password ' : 'Could not do that.', r.password ? el('strong', {}, r.password) : '')); };
|
||||
return [lend, card, reset];
|
||||
});
|
||||
}
|
||||
$('alllogins').onclick = async () => {
|
||||
if (!confirm('Create a sign-in for everyone who does not have one yet? You will see each password once.')) return;
|
||||
const r = await api('/api/logins/all', {});
|
||||
if (r.status !== 'done') { $('loginresult').textContent = r.message || 'Could not do that.'; return; }
|
||||
$('loginresult').replaceChildren(r.made.length ? el('div', { className: 'result-note' }, el('strong', {}, r.made.length + ' sign-ins created - write these down or print this page:'),
|
||||
...r.made.map(x => el('div', {}, x.name + ' - card ' + x.card + ' - password ', el('strong', {}, x.password)))) : el('div', { className: 'small' }, 'Everyone already has a sign-in.'));
|
||||
};
|
||||
let pwModeLoaded = false;
|
||||
async function loadPwSettings() {
|
||||
if (pwModeLoaded) return; const v = await api('/api/settings'); const st = v.settings || {}; pwModeLoaded = true;
|
||||
document.querySelectorAll('input[name=pwmode]').forEach(i => { i.checked = i.value === (st.patron_password_mode || 'generated'); });
|
||||
$('pwsame').value = st.patron_password || '';
|
||||
}
|
||||
$('pwsave').onclick = async () => {
|
||||
const mode = document.querySelector('input[name=pwmode]:checked').value;
|
||||
const r = await api('/api/settings', { patron_password_mode: mode, patron_password: $('pwsame').value });
|
||||
$('pwsaved').textContent = r.settings && r.settings.patron_password_mode === mode ? 'Saved ✓' + (mode === 'same' && !r.settings.patron_password ? ' (enter a password of 4+ characters)' : '') : 'Could not save.';
|
||||
};
|
||||
$('peopleform').onsubmit = ev => { ev.preventDefault(); loadPeople($('pq').value.trim()); };
|
||||
$('personform').onsubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
const f = ev.target, confirm = f.dataset.confirm === '1'; delete f.dataset.confirm;
|
||||
const r = await api('/api/patron/add', Object.assign(Object.fromEntries(new FormData(f)), confirm ? { confirm: true } : {}));
|
||||
if (r.status === 'added') {
|
||||
f.reset();
|
||||
$('personresult').replaceChildren(el('div', { className: 'result-note' }, r.name + ' added with library card number ', el('span', { className: 'badge' }, r.card), r.password ? ' (sign-in: card number + password ' : '', r.password ? el('strong', {}, r.password) : '', r.password ? ')' : '', '. ',
|
||||
el('a', { href: '#card/' + r.id }, 'Print the card'), ' or ', el('a', { href: '#lend/' + r.id }, 'check something out')));
|
||||
loadPeople('');
|
||||
} else if (r.status === 'duplicate') {
|
||||
const again = el('button', { type: 'button', className: 'primary' }, 'Add another person with this name');
|
||||
again.onclick = () => { f.requestSubmit && (f.dataset.confirm = '1'); f.requestSubmit(); };
|
||||
$('personresult').replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, r.message + ' ', again)));
|
||||
} else $('personresult').replaceChildren(el('div', { className: 'card bad' }, el('div', { className: 'body' }, r.message || 'Could not add that person.')));
|
||||
};
|
||||
|
||||
// ---- printable library card (Code 39 barcode drawn as SVG)
|
||||
const C39 = { '0': 'nnnwwnwnn', '1': 'wnnwnnnnw', '2': 'nnwwnnnnw', '3': 'wnwwnnnnn', '4': 'nnnwwnnnw', '5': 'wnnwwnnnn',
|
||||
'6': 'nnwwwnnnn', '7': 'nnnwnnwnw', '8': 'wnnwnnwnn', '9': 'nnwwnnwnn', '*': 'nwnnwnwnn' };
|
||||
function code39(text) {
|
||||
const W = 3; let x = 10, bars = '';
|
||||
for (const ch of '*' + text + '*') {
|
||||
const pat = C39[ch]; if (!pat) continue;
|
||||
for (let i = 0; i < 9; i++) { const w = pat[i] === 'w' ? W : 1; if (i % 2 === 0) bars += '<rect x="' + x + '" y="0" width="' + w + '" height="50"/>'; x += w; }
|
||||
x += 1; // narrow gap between characters
|
||||
}
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + (x + 9) + ' 50" preserveAspectRatio="none" fill="#000" role="img" aria-label="Barcode ' + text + '">' + bars + '</svg>';
|
||||
}
|
||||
function cardEl(p) {
|
||||
const svg = el('div', { className: 'bc' });
|
||||
svg.innerHTML = code39(p.card);
|
||||
const qr = el('img', { className: 'qr', alt: 'QR code ' + p.card, src: 'api/qr?t=' + encodeURIComponent(p.card) });
|
||||
return el('div', { className: 'libcard' }, el('div', { className: 'lib' }, libraryDisplay || 'Library card'), el('div', { className: 'nm' }, p.name),
|
||||
el('div', {}, el('div', { className: 'codes' }, svg, qr), el('div', { className: 'no' }, p.card)));
|
||||
}
|
||||
async function loadCard(id) {
|
||||
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
|
||||
if (!p.name) { $('cardbox').textContent = 'Could not load that person.'; return; }
|
||||
curCard = p;
|
||||
$('cardbox').replaceChildren(cardEl(p));
|
||||
}
|
||||
let curCard = null;
|
||||
async function loadCards() {
|
||||
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(($('pq').value || '').trim())); } catch (e) { r = {}; }
|
||||
const list = (r.patrons || []).filter(p => p.card);
|
||||
$('cardsheet').replaceChildren(...list.map(cardEl));
|
||||
$('cardsnote').textContent = list.length ? list.length + ' card' + (list.length === 1 ? '' : 's') + (($('pq').value || '').trim() ? ' (matching your search)' : '') : 'Nobody to print yet.';
|
||||
}
|
||||
$('printcards').onclick = () => window.print();
|
||||
// A picture of the QR (name + number under it) the person can keep on their phone and hold up to be scanned.
|
||||
$('savecard').onclick = async () => {
|
||||
if (!curCard) return;
|
||||
const svgText = await (await fetch('api/qr?t=' + encodeURIComponent(curCard.card), { headers: { 'X-Quickadd': '1' } })).text();
|
||||
const img = new Image(); await new Promise((ok, no) => { img.onload = ok; img.onerror = no; img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText); });
|
||||
const c = document.createElement('canvas'); c.width = 640; c.height = 780; const g = c.getContext('2d');
|
||||
g.fillStyle = '#fff'; g.fillRect(0, 0, c.width, c.height); g.fillStyle = '#000'; g.textAlign = 'center';
|
||||
g.font = 'bold 34px sans-serif'; g.fillText(libraryDisplay || 'Library card', 320, 60);
|
||||
g.imageSmoothingEnabled = false; g.drawImage(img, 70, 90, 500, 500);
|
||||
g.font = 'bold 40px sans-serif'; g.fillText(curCard.name, 320, 650); g.font = '34px monospace'; g.fillText(curCard.card, 320, 705);
|
||||
const a = document.createElement('a'); a.href = c.toDataURL('image/png'); a.download = 'library-card-' + curCard.card + '.png'; a.click();
|
||||
};
|
||||
$('printcard').onclick = () => window.print();
|
||||
|
||||
async function loadMine() {
|
||||
let r; try { r = await api('/api/me'); } catch (e) { r = {}; }
|
||||
if (!r.loans) { $('meloans').textContent = r.message || 'Could not load your books.'; return; }
|
||||
$('mename').textContent = r.name; $('mecard').textContent = r.card;
|
||||
const today = new Date(); today.setHours(12, 0, 0, 0);
|
||||
const rows = r.loans.map(l => {
|
||||
const days = Math.round((new Date(l.due + 'T12:00:00') - today) / 86400000);
|
||||
const when = l.overdue ? 'Overdue by ' + (-days) + (days === -1 ? ' day' : ' days') : days === 0 ? 'Due today' : 'Due ' + fmtDue(l.due) + ' (in ' + days + (days === 1 ? ' day)' : ' days)');
|
||||
const ren = el('button', { type: 'button' }, 'Renew');
|
||||
ren.onclick = async () => { ren.disabled = true; const x = await api('/api/me/renew', { checkout_id: l.checkout_id });
|
||||
if (x.status === 'renewed') loadMine(); else { ren.disabled = false; alert(x.message || 'That one cannot be renewed.'); } };
|
||||
return el('div', { className: 'loan' }, el('span', { className: 't' }, el('a', { href: '#book/' + l.biblio_id }, l.title), el('div', { className: 'small' }, l.author)),
|
||||
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, when), ren);
|
||||
});
|
||||
$('meloans').replaceChildren(...(rows.length ? rows : [el('div', { className: 'card ok' }, el('div', { className: 'body' }, 'You have nothing checked out right now.'))]));
|
||||
}
|
||||
$('mepwform').onsubmit = async ev => {
|
||||
ev.preventDefault(); const f = ev.target; const r = await api('/api/me/password', Object.fromEntries(new FormData(f)));
|
||||
$('mepwresult').textContent = r.status === 'saved' ? 'Password changed. Next time your browser asks, use the new one.' : (r.message || 'Could not change it.');
|
||||
if (r.status === 'saved') f.reset();
|
||||
};
|
||||
|
||||
function route() {
|
||||
const h = location.hash.replace(/^#/, '');
|
||||
let m, view = 'library';
|
||||
if ((m = h.match(/^book\/(\d+)$/))) view = 'book';
|
||||
else if (h === 'mine' && isPatron) view = 'mine';
|
||||
else if (viewer) view = 'library';
|
||||
else if (h === 'add') view = 'add';
|
||||
else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
|
||||
else if (h === 'people') view = 'people';
|
||||
else if ((m = h.match(/^card\/(\d+)$/))) view = 'card';
|
||||
else if (h === 'cards') view = 'cards';
|
||||
for (const v of ['library', 'book', 'add', 'lend', 'people', 'card', 'cards', 'mine']) $('view-' + v).hidden = v !== view;
|
||||
const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people', cards: 'people', mine: 'mine' }[view];
|
||||
for (const t of ['library', 'add', 'lend', 'people', 'mine']) $('tab-' + t).classList.toggle('on', t === tab);
|
||||
$('tab-mine').hidden = !isPatron;
|
||||
for (const t of ['add', 'lend', 'people']) $('tab-' + t).hidden = viewer;
|
||||
$('csvtools').hidden = viewer;
|
||||
const names = { library: 'The library', book: 'The library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards', mine: 'My books' };
|
||||
document.title = names[view] + ' - Room for Books';
|
||||
if (view === 'book') loadBook(m[1]);
|
||||
else if (view === 'mine') loadMine();
|
||||
else if (view === 'add') $('code').focus();
|
||||
else if (view === 'lend') { loadOut(); curPatron = null; $('patronbox').replaceChildren();
|
||||
if (m && m[1]) openPatron(m[1]); else { $('whoresults').replaceChildren(); $('who').focus(); } }
|
||||
else if (view === 'people') { loadPeople($('pq').value.trim()); loadPwSettings(); }
|
||||
else if (view === 'card') loadCard(m[1]);
|
||||
else if (view === 'cards') loadCards();
|
||||
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
|
||||
}
|
||||
window.addEventListener('hashchange', route);
|
||||
let libraryDisplay = '';
|
||||
|
||||
(async () => {
|
||||
const s = await api('/api/state');
|
||||
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
|
||||
viewer = s.role !== 'admin'; isPatron = s.role === 'patron';
|
||||
types = s.types || {}; if (!types[type]) type = 'BOOK'; if (!viewer) typeButtons();
|
||||
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
|
||||
staffBase = s.staff_url || '';
|
||||
$('lnk-opac').href = s.opac_url ? s.opac_url + '/' : 'http://' + location.hostname + ':' + s.opac_port + '/';
|
||||
// The public catalogue is off by default: only link to it when a public address was configured.
|
||||
$('lnk-opac').hidden = !s.opac_url;
|
||||
if (s.opac_url) $('lnk-opac').href = s.opac_url + '/';
|
||||
$('lnk-staff').href = staffRoot() + '/';
|
||||
$('lnk-staff').hidden = viewer;
|
||||
$('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl';
|
||||
if (s.locations && s.locations.length) {
|
||||
const sel = $('shelf');
|
||||
@@ -441,9 +1232,10 @@
|
||||
sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); };
|
||||
$('shelfbox').hidden = false;
|
||||
}
|
||||
api('/api/settings').then(applySettings);
|
||||
if (!viewer) api('/api/settings').then(applySettings);
|
||||
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||||
$('code').focus();
|
||||
libraryDisplay = s.library_name || '';
|
||||
route();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
+37
-27
@@ -1,29 +1,30 @@
|
||||
#!/bin/bash
|
||||
################################################################################
|
||||
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
|
||||
# catalogue (Koha) running in Docker, shown on the kiosk as a public
|
||||
# search terminal.
|
||||
# catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk.
|
||||
#
|
||||
# What gets installed (all from this checkout's library/ directory, deployed to
|
||||
# $LIBRARY_DIR - see library/README.md for the stack itself):
|
||||
# - Koha: the catalogue ("OPAC", public search, no login) and the staff
|
||||
# interface (cataloguing, Z39.50 lookup, label printing).
|
||||
# - Quick Add: a one-box "scan an ISBN, the book is added" page.
|
||||
# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending.
|
||||
# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab.
|
||||
# - MariaDB.
|
||||
# Docker itself is installed on demand (lib/docker.sh).
|
||||
#
|
||||
# On the kiosk the catalogue is added as one of the Sites (tabs) with
|
||||
# duration 0 (stay put - it's a search terminal, not a rotating display).
|
||||
# Staff and Quick Add are meant for the librarian's own device; whether they
|
||||
# are reachable from the LAN or only from this kiosk is the "Network access"
|
||||
# choice below. NOTE: Docker publishes ports straight through iptables and
|
||||
# bypasses ufw, so "this kiosk only" is enforced by binding the published
|
||||
# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules.
|
||||
# Everything needs a login. The kiosk shows the "Find a book" page signed in as a
|
||||
# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at
|
||||
# all, because Koha's own search permission also allows editing label layouts); the page
|
||||
# list stores that guest login like any other Basic Auth site. The admin login never
|
||||
# goes into the kiosk's config. The public catalogue (OPAC) is off by default.
|
||||
#
|
||||
# Whether other devices on the LAN can reach the library site is the "Network access"
|
||||
# choice below. NOTE: Docker publishes ports straight through iptables and bypasses
|
||||
# ufw, so "this kiosk only" is enforced by binding the published ports to 127.0.0.1
|
||||
# (BIND_ADDR in library/.env), not by firewall rules.
|
||||
#
|
||||
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
|
||||
################################################################################
|
||||
|
||||
LIBRARY_CATALOGUE_TAB_NAME="Library catalogue"
|
||||
LIBRARY_CATALOGUE_TAB_NAME="Find a book"
|
||||
|
||||
################################################################################
|
||||
# Detection / state
|
||||
@@ -42,7 +43,13 @@ library_is_running() {
|
||||
[[ "$(docker_container_state library-koha)" == "running" ]]
|
||||
}
|
||||
|
||||
# The page the kiosk shows: Quick Add's Find a book view on the library site.
|
||||
library_catalogue_url() {
|
||||
echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find"
|
||||
}
|
||||
|
||||
# Pages from earlier versions of this add-on pointed at the public catalogue.
|
||||
library_old_catalogue_url() {
|
||||
echo "http://localhost:$(library_env_get OPAC_PORT)/"
|
||||
}
|
||||
|
||||
@@ -66,19 +73,19 @@ addon_library_status() {
|
||||
health="$(docker_container_health library-koha)"
|
||||
ip="$(get_ip_address)"
|
||||
echo "Library Catalog: ${state}${health:+ ($health)}"
|
||||
echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)"
|
||||
echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)"
|
||||
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||
echo " Network: this kiosk only"
|
||||
else
|
||||
echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/"
|
||||
echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||
fi
|
||||
}
|
||||
|
||||
addon_library_menu_builder() {
|
||||
if library_is_installed; then
|
||||
load_existing_config
|
||||
local tab_label="Show the catalogue on the kiosk"
|
||||
library_tab_exists && tab_label="Stop showing the catalogue on the kiosk"
|
||||
local tab_label="Show Find a book on the kiosk"
|
||||
library_tab_exists && tab_label="Stop showing Find a book on the kiosk"
|
||||
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
|
||||
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
|
||||
else
|
||||
@@ -111,13 +118,13 @@ library_sync_files() {
|
||||
library_add_tab() {
|
||||
load_existing_config
|
||||
if library_tab_exists; then
|
||||
log_info "The catalogue is already one of the kiosk's pages."
|
||||
log_info "Find a book is already one of the kiosk's pages."
|
||||
return 0
|
||||
fi
|
||||
URLS+=("$(library_catalogue_url)")
|
||||
DURS+=("0")
|
||||
USERS+=("")
|
||||
PASSES+=("")
|
||||
USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login
|
||||
PASSES+=("$(library_env_get VIEWER_PASSWORD)")
|
||||
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
|
||||
save_config
|
||||
}
|
||||
@@ -125,11 +132,12 @@ library_add_tab() {
|
||||
# Drop every page that points at the catalogue's local address.
|
||||
library_remove_tab() {
|
||||
load_existing_config
|
||||
local url idx
|
||||
local url old idx
|
||||
url="$(library_catalogue_url)"
|
||||
old="$(library_old_catalogue_url)"
|
||||
local -a nu=() nd=() nus=() np=() nn=()
|
||||
for idx in "${!URLS[@]}"; do
|
||||
[[ "${URLS[$idx]}" == "$url" ]] && continue
|
||||
[[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue
|
||||
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
|
||||
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
|
||||
done
|
||||
@@ -199,7 +207,7 @@ action_library_install() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then
|
||||
if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then
|
||||
library_add_tab
|
||||
fi
|
||||
echo
|
||||
@@ -217,10 +225,12 @@ action_library_show_access() {
|
||||
ip="localhost"
|
||||
echo "(Network access is limited to this kiosk.)"
|
||||
fi
|
||||
echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/"
|
||||
echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||
echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/"
|
||||
echo " Login (staff and add books): ${user} / ${pass}"
|
||||
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||
echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
|
||||
echo " Admin login: ${user} / ${pass}"
|
||||
echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)"
|
||||
echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only,"
|
||||
echo " it cannot add or change anything (and it has no login on the staff site itself)."
|
||||
echo
|
||||
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
|
||||
pause
|
||||
|
||||
+28
-14
@@ -74,6 +74,8 @@ STAFF_PORT=8081
|
||||
QUICKADD_PORT=8082
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=s3cretPASS
|
||||
VIEWER_USER=guest
|
||||
VIEWER_PASSWORD=guestPASS
|
||||
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||
ENV
|
||||
echo running > "$STUB_STATE"
|
||||
@@ -98,7 +100,7 @@ source "$REPO/menus/addon_library.sh"
|
||||
export KIOSK_UNATTENDED=0
|
||||
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
|
||||
|
||||
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; }
|
||||
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; }
|
||||
|
||||
# ============================================================================
|
||||
echo "== lib/docker.sh"
|
||||
@@ -133,8 +135,10 @@ printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.ou
|
||||
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
|
||||
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
|
||||
check "is_installed is now true" library_is_installed
|
||||
check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs)
|
||||
check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs)
|
||||
check "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs)
|
||||
check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS
|
||||
check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'"
|
||||
check "existing tab preserved" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
|
||||
addon_library_status > "$T/status.out"
|
||||
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
|
||||
@@ -142,21 +146,29 @@ check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/sta
|
||||
# ============================================================================
|
||||
echo "== menu while installed"
|
||||
addon_library_menu_builder
|
||||
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}"
|
||||
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}"
|
||||
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
|
||||
|
||||
echo "== tab toggling is idempotent"
|
||||
library_add_tab >/dev/null 2>&1
|
||||
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1
|
||||
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
|
||||
check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0
|
||||
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs)
|
||||
check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||
addon_library_menu_builder
|
||||
check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}"
|
||||
check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}"
|
||||
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
|
||||
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1
|
||||
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||
|
||||
# ============================================================================
|
||||
echo "== an earlier version's tab (public catalogue address) is cleaned up"
|
||||
jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH"
|
||||
check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1
|
||||
library_remove_tab >/dev/null 2>&1
|
||||
check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0
|
||||
check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||
library_add_tab >/dev/null 2>&1
|
||||
|
||||
echo "== network access"
|
||||
: > "$STUB_LOG"
|
||||
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
|
||||
@@ -168,8 +180,10 @@ check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR
|
||||
# ============================================================================
|
||||
echo "== access screen"
|
||||
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
|
||||
check "shows the staff login" grep -q 'admin / s3cretPASS' "$T/access.out"
|
||||
check "shows quick-add address" grep -q 'Add books.*:8082' "$T/access.out"
|
||||
check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out"
|
||||
check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out"
|
||||
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
|
||||
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
|
||||
|
||||
# ============================================================================
|
||||
echo "== update"
|
||||
@@ -185,7 +199,7 @@ printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
|
||||
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
|
||||
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
|
||||
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0
|
||||
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||
|
||||
echo "== purge needs the typed confirmation"
|
||||
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||
@@ -195,8 +209,8 @@ library_add_tab >/dev/null 2>&1
|
||||
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
|
||||
check "install dir removed" test ! -e "$LIBRARY_DIR"
|
||||
check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0
|
||||
check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs)
|
||||
check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||
check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||
|
||||
echo
|
||||
echo "passed: $PASS failed: $FAIL"
|
||||
|
||||
Reference in New Issue
Block a user