Design of the sapupra project
v0.50.0.98
Table of Contents
- 1. Introduction
- 2. Scan devices
- 3. Programs
- 4. Messages
- 5. The gateway
- 6. The worker
- 7. The entry module
- 8. Several users at once
- 9. Inserting texts into messages
- 10. When a part is absent
- 11. The language of the pages
- 12. Location labels
- 13. Labels of our own
- 14. Removing
- 15. The database
- 16. The web front
- 17. The printed lists
- 18. A catalogue to try the programs on
- 19. Configuration
- 20. Code layout
List of Listings
List of Tables
- Table 1: The programs of the prototype
- Table 2: The topics of the broker
- Table 3: The fields of an input
- Table 4: The kinds of input
- Table 5: The replies of the gateway
- Table 6: Classification of a code
- Table 7: Default order of the book resolvers, by registration group
- Table 8: The statuses of an input
- Table 9: The pages of the entry module
- Table 10: What happens when a part is absent
- Table 11: The options of
spplabel - Table 12: What a QR code holds: characters of the alphanumeric mode at level M, bytes at the four levels
- Table 13: The unit tests
1. Introduction
This document designs the programs of the first prototype: the gateway, the worker and the entry module, the messages they exchange through the broker, and the database. Concept.org gives the architecture and its reasons, and defines the terms used here (Concept.org, Glossary); Manual.org will say how to install and use what is designed here, as each program is written.
The design is written before the code, and is a proposal until the code exists; written so far: the common library and the gateway. It says what was decided; the options considered for each decision, and why one was chosen, are in the research journal (ResearchJournal.org), and the decisions still to take in the Decisions section of FutureResearch.org. The web front is the pages of the entry module that read the catalogue (0.18.0), with Datasette as an optional extra outside the build; the contract of both with the database is in A view for the web front, and the pages are in The web front.
The implementation is C++17 with Qt 6 (the modules Core, Network and Sql), libmosquitto, YAZ, and libzint for the labels; Qt’s event loop drives every program, and no program needs a thread of its own but the worker, for the Z39.50 queries.
2. Scan devices
A scan device is a device that reads a barcode and transmits what it read to the broker, directly or through the gateway. Today, a phone with a scanning application that forwards each scan by HTTP to the gateway: an Android phone with Binary Eye, and later, untried, an iPhone or an iPad with AllCodeRelay. A device that could publish to the broker itself (a scanner of our own making, an application that speaks MQTT) would need no gateway. Each scan device has a scan device identifier, which it sends with every scan, and which names its topic on the broker.
3. Programs
Table 1 lists the programs, the account each uses on the
broker, and what each listens on. The names follow the rule of the
development standard for executables (lowercase, no extension) with
the prefix spp. The ports are those of the
configuration (Configuration); 8080 and 8081 are the defaults.
| Program | Role | Broker account | Listens on |
|---|---|---|---|
sppgate |
receives the scan devices’ scans | gateway |
HTTP, port 8080 |
sppwork |
records, resolves, writes the DB | worker |
(nothing) |
sppentry |
completes failed scans, by hand | entry |
HTTP, port 8081 |
sppconsult |
consults the catalogue only | (none) | HTTP, port 8082 |
spplabel |
prints labels: locations, codes | (none) | (nothing) |
spplist |
writes the printed lists (Org) | (none) | (nothing) |
The programs share a library, built with them, for what they have in
common: the configuration, the logging, the connection to the
broker, the messages, the classification of codes, and a small HTTP
server (Code layout). The gateway is the program sppgate; the
HTTP server is a part of the library, which sppgate uses to
receive the scans and sppentry to serve its pages. spplabel and
spplist are commands run by hand (Location labels, The printed lists);
the others are services (sppconsult is an option of the installation:
Access from outside the network). A seventh, sppgen, which makes a
catalogue of invented books to try the others on (A catalogue to try the programs on), is
built and not installed.
3.1. Running as services
The gateway, the worker and the entry module run unattended, from
the start of the machine, under the system account sapupra. Each
program runs in the foreground; the service manager puts it in the
background, restarts it if it stops, and stops it by SIGTERM, on
which the program closes its connections and exits:
- on Debian, a systemd unit for each, as
sppgate.service(both are made from one template,sapupra.service.in; so are the rc scripts, fromsapupra.rc.in); the program logs to its standard error, which the journal keeps (journalctl -u sppgate); - on FreeBSD, an rc script for each, as
sppgate, which starts the program throughdaemon(8):daemon(8)writes its own process number in the pid file (-P), runs the program under the accountsapupra(-u), restarts it 5 seconds after it stops (-R), and passes the stop signal on; the program logs to syslog (--syslog).
Started by hand, a program stays attached to its terminal, which is
what trying it needs; SIGINT (Ctrl-C) stops it. The files are
built with the programs, and installed by hand (Manual.org).
4. Messages
4.1. Topics
Every topic starts with sapupra/v1/, so that a later, incompatible
version of the messages can live beside this one. Table 2
lists them; DEVICE is the scan device identifier,
ID the identifier of an input.
Topic (after sapupra/v1/) |
Published by | Read by | Retained |
|---|---|---|---|
input/DEVICE |
gateway, entry | worker | no |
receipt/ID |
worker | gateway, entry | no |
verdict/ID |
worker | gateway, entry | no |
status/worker |
worker, its will | gateway, entry | yes |
One topic carries every input of a scan device, whatever it is (a scan, a location, a typed ISBN, a label): MQTT keeps the order of the messages that one client publishes on one topic, and the order matters, since a location applies to the scans that follow it. All messages are published with QoS 1.
4.2. Input
An input is a JSON object, encoded in UTF-8; Table 3 lists its fields. The gateway and the entry module make it; the worker does not need to know which application of which scan device sent the scan, only what was read. Table 4 gives the meaning of each kind.
| Field | Value |
|---|---|
v |
1, the version of the message |
id |
a UUID, made by the publisher; the identity of the input |
kind |
scan, isbn, location, label, place, device, remove, restore, correct, search, choice, record, copy, dismiss |
device |
the scan device identifier |
origin |
binaryeye, allcoderelay, entry or history |
received |
when the publisher received it: ISO 8601, with offset |
code |
what was read or typed, as it was |
symbology |
the barcode type, as the device named it (EAN_13) |
addOn |
the EAN add-on, if the device reported one |
deviceTime |
the scan device’s own time of the scan, as it wrote it |
for |
the id of the scan that this input completes |
location |
for a location: its text; for a place order: the place it is about |
place |
for an item: the place of that item only; for a place order: the target |
action |
for a place order: make, rename, merge or delete |
reason |
for a remove: donated, sold, lost, mistake or other |
query |
for a search: author, title |
record |
for a choice or a record: the bibliographic record |
raw |
the request of the scan device, unchanged |
| Kind | Made by | Means | Fields |
|---|---|---|---|
scan |
gateway | a code read by a scan device | code |
location |
gateway, entry | the device’s current location changes (to a place that is made) | location |
label |
gateway | a label of an item was scanned | code |
place |
entry | make, rename, merge or delete a place | location, place, action |
remove |
entry | a copy leaves the catalogue, with a reason | code, reason |
restore |
entry | a removed copy comes back | code |
correct |
entry | the description of an item’s edition is corrected | code (the item), record |
isbn |
entry | an ISBN typed for an item, or a failed scan | code, for |
search |
entry | find records by author and title | query |
choice |
entry | this record, among those found, is the item | record, for |
record |
entry | a description typed by hand | record, for |
A location from the gateway is a scanned label; from the entry
module, a place chosen for a scan device. A label carries the
number of the item in code. A place is an order of the entry
module to the worker, which alone writes the database: make a place
(with its parents), rename it (the paths of the places below it
follow) or merge it into another (its items and devices move).
On an isbn, record or choice, place is the place of that item
and never changes the location of the device. A search creates no
item: its verdict carries the records found, and the choice among
them is the next input. Without for, an isbn, choice or
record catalogues a new item, with no scan behind it (an item
without barcode).
The time of the scan device is kept in raw only: Binary Eye sends it
without time zone and with a colon before the milliseconds
(2026-10-23 14:59:12:237); received, the time of the gateway, is
the one the catalogue uses.
4.3. Receipt and verdict
The worker answers each input twice, on topics named after its
id:
- receipt
- as soon as the input is recorded in the database;
carries
vandidonly. - verdict
- when the input is settled, or settled for now; carries
v,id,status(one of the statuses of Statuses),item(the number of the item, if one was created),text, a line for the scan device: the title found, or the reason, and, for a search,records, the records found. A book found by a source ends with the source, “ — from openlibrary” (an em dash;Catalogue::sourcesNote), in the verdict kept in the database, in the one published and in the log, so that one sees who answered; a second copy of a book already catalogued has no such note, no source being asked.
4.4. Presence of the worker
The worker connects with a will, offline on status/worker,
retained, which the broker publishes if the worker disappears; once
connected, it publishes online there, retained. The gateway and the
entry module read it, and do not wait for a verdict that cannot come.
4.5. Delivery
- Publishing
- the gateway (and the entry module) count an input as
handed over when the broker has acknowledged it (the
PUBACKof QoS 1, which libmosquitto reports by its publish callback), never before. - Waiting
- the worker connects with a fixed client identifier,
the name of its account,
worker, and a persistent session; the broker keeps on disk the inputs that arrive while it is away (the persistence of Manual.org). - Taking over
- libmosquitto acknowledges an incoming QoS 1 message
to the broker before it hands the message to the program (read in
its source,
handle_publish.c, version 2.0.18 and the development branch:send__pubackcomes before the message callback), and has no manual acknowledgement. A worker that stops between the two would lose the input. Hence the receipt: the publisher keeps each input until its receipt arrives, and publishes it again, with the sameid, if none came within a delay (30 seconds) while the worker is online. - Twice
- an input may then arrive twice; the worker records an
idonce only (it is the key of the table of inputs) and answers the second time with the same receipt and verdict.
5. The gateway
5.1. Requests
The gateway accepts POST requests on two paths, the root of the
server and /scan (Binary Eye posts to the URL as it is given, with or without a
path), with a JSON body:
- from Binary Eye
- an object with
content,format,timestampanddeviceId(as captured in Manual.org), and sometimesaddOnand others, sent with the request type “POST application/json” of its settings; another request type is answered 415, with the setting to change; - from AllCodeRelay
- an object with one field,
code; - the test request of Binary Eye
contenttest,formatNone; answered at once, with the state of the broker and of the worker, and published nowhere.
The scan device identifier is taken, in this order, from the
parameter of the path device (/scan?device=NAME), from the
header X-Sapupra-Device (which AllCodeRelay’s custom headers can
set), from Binary Eye’s deviceId; without any, it is unknown. It
is 1 to 32 characters among letters, digits, - and _, since it
is a level of a topic; an invalid one is refused (400).
An input made by the gateway keeps the request as it came in raw,
and Binary Eye’s timestamp in deviceTime: Binary Eye writes the
same text in its history, which lets an import of the history
recognize the scans already recorded
(Importing Binary Eye’s history).
A GET of the root of the server answers with the state of the gateway: the broker
connected or not, the worker online or not, the number of inputs
waiting for their receipt; a browser of the local network is enough
to check it.
5.2. Locations
A shelf label and an item’s barcode reach the gateway the same way,
as a scanned code; the location prefix is how the gateway tells them
apart. A code that starts with it, LOC: (configurable), is a
location, not an item: the gateway publishes it as an input of kind
location, whose text is the rest of the code. The answer is the worker’s:
the place is set as the device’s location (located, 200), or it is
not made and nothing changes (unknown, 422): a place is made on
purpose, in the entry module (Where the locations are). A code that is the prefix
alone is refused. A label is printed by spplabel
(Location labels), holding, for example, LOC:SALON B3
or, for a shelf in a room, LOC:salon/bookcase 2/shelf 4: a
location is a path (Where the locations are), and the text after the
prefix is brought to its normal form (Code::normalizeLocation)
before anything else is done with it.
A label of an item is recognised the same way, by its own prefix
([items] prefix=, ITM:): the gateway checks the number and its
check digit (Code::checkItemLabel), answers 422 “Label misread,
scan again” for a bad one without publishing it, and publishes a
good one as an input of kind label (What a scan of a label does). The
two prefixes must not begin alike, and each program that reads them
refuses a configuration where they do.
The prefix exists because a label must never be taken for an item, nor an item
for a label. The codes of items are digits only (EAN-13, UPC-A), or
an ISBN; a prefix with a letter and a colon cannot begin any of
them. Without one, a label holding “Salon B3” would be an
unrecognized code, and a label holding only digits could be looked
up as a product. LOC: is short, and its characters (capitals,
digits, space and colon) are those of the alphanumeric mode of QR
codes, the densest mode for text, which keeps the printed label
small when the location is written in capitals.
5.3. Replies
The reply is short (Binary Eye shows its first 240 characters) and its status tells the scan device whether to sound the error; Table 5 lists the cases.
| Case | HTTP | Text |
|---|---|---|
| resolved, found | 200 | the title, from the verdict |
| location set | 200 | “location: ” and the location |
| location not made | 422 | the text of the verdict (unknown) |
| label of an item | 200 | the item and where it is put (labelled) |
| label misread | 422 | “Label misread, scan again” |
| repeated scan | 200 | the title, “(again)” |
| no verdict within the wait | 202 | “queued” |
| worker offline | 202 | “queued, worker offline” |
| a source did not answer | 202 | the text of the verdict |
| set aside by the worker | 422 | the text of the verdict |
| not handed to the broker | 503 | “Not recorded, scan again” |
| invalid request | 400 | the reason |
| not JSON | 415 | the setting to change |
An input is set aside when it is not found, not an ISBN, ambiguous or not a code (the statuses of Statuses); it is not handed to the broker when the broker is out of reach, or does not acknowledge it in time.
Every status outside 200 to 299 makes Binary Eye vibrate and sound
its error; “not recorded” is the case where the scan is lost unless
it is repeated, and says so. AllCodeRelay keeps a scan whose reply
is not 200 for a later retry: to it the gateway answers 200 in every
case but “not recorded”, with a JSON object (code, the kind of
answer, and codevalue, the text), as its documentation asks.
5.4. Waiting for the verdict
The gateway first waits for the broker’s acknowledgement of the
input, at most 3 seconds (wait, configurable): without it, it
answers “not recorded”. Then, unless the worker is offline, it waits
for the verdict, at most as long, and replies with it, or with
“queued”. A verdict that comes later is not lost: the scan is in the
database with its status, and a failure is in the entry module’s
list.
An acknowledgement that comes after the wait is still possible: libmosquitto keeps the message, and may deliver it. A scan answered “not recorded” can then be recorded after all; scanned again within the repetition window, it is taken for a repetition, and creates no second item.
5.5. Receipts
The gateway keeps each input it has handed over until its receipt
(or its verdict) arrives. Without a receipt within 30 seconds
(receipt_timeout, configurable) while the worker is online, it
publishes the input again, with the same identifier; the delay
starts again when the worker comes back online. The inputs are kept
in memory only: stopping the gateway forgets them, while the broker
keeps those that the worker has not taken.
Since 0.39.0 this is not the gateway’s own code: it is
InputPublisher, the class that the entry module uses, which
publishes, follows the broker’s acknowledgement, the receipt, the
verdict and the worker’s presence, and publishes again. It tells the
gateway by signals (handedOver with the broker’s acknowledgement
of the publication, receiptReceived, verdictReceived,
workerStatusReceived). The gateway keeps what is its own, per
input: the HTTP exchange, the client and the deadline of the reply.
When the deadline passes before the broker’s acknowledgement, the
gateway answers “not recorded” and tells the publisher to
abandon the input, which is then not published again: the scan is
to be repeated by the person, who has been told so. An input that
was acknowledged is kept and republished as above even when the
reply was “queued”.
5.6. HTTP
The HTTP server is the library’s own, HttpServer, on Qt’s
QTcpServer: one request per connection, answered with
Connection: close; a request line, headers, and a body of a
declared length (Content-Length); GET, HEAD and POST only (a
HEAD is routed as a GET, with the flag mHead set on the request, and
the exchange leaves the body out of the reply, Content-Length kept: a
program writes nothing for it, and cannot treat it as a POST); no
chunked transfer, no persistent connection, no TLS. Everything
received is bounded: 8 KiB of headers, 16 KiB of body, 10 seconds to
receive a request, 64 connections at once; a request beyond a limit
is answered with its status (431, 413, 408, 503). A reply is sent
whenever it is ready, which lets the gateway wait for a verdict. The
entry module uses the same server, with a larger body for the import
of a history (Importing Binary Eye’s history).
6. The worker
6.1. Recording first
For each input, in this order: record it (the table of inputs, with
the location then current for its device and its order of arrival),
commit, publish the receipt; then classify, resolve, record the
result, commit, publish the verdict. Recording never waits for the
Internet. The program sppwork does it as Worker (the
messages), Catalogue (the database) and one Resolver per source;
the sources are queried one request at a time per source, and the
answers wait in the queue of the source, not in the event loop.
An input already recorded (the same identifier: the gateway publishes again an input without receipt) is not recorded twice; it is given its receipt again, and, if it is settled, its verdict again. At start, the inputs recorded and not settled (the worker stopped between the receipt and the verdict) are taken up again, and the inputs published while the worker was away are handed over by the broker, which keeps the session under the account’s name.
Only one worker may run on a database. Two of them, with the same
identifier, would take the session from each other at the broker, one
reconnecting every few seconds, and with two identifiers each would
take up the input that the other has in hand. So the worker takes a
lock on a file beside the database (catalogue.db.worker.lock,
Qt’s QLockFile: the process that holds it is named in the file, and a
lock whose process is gone is taken over), and a second worker stops
at its start, saying which process holds it.
6.2. Classification
Table 6 gives the class of a code, from its digits once the spaces and hyphens are removed; a typed ISBN-10 is checked and turned into its ISBN-13.
| Code | Class | Asked |
|---|---|---|
| 13 digits, 978 or 979, not 9790 | ISBN-13 | book resolvers |
| 13 digits, 9790 | ISMN | BnF (printed music) |
| 13 digits, 977 | ISSN | BnF (periodicals) |
| 13 digits, other | GTIN | record and film resolvers |
| 12 digits (UPC-A) | GTIN, 0 prefixed | record and film resolvers |
| 8 digits (EAN-8) | GTIN, 00000 prefixed | record and film resolvers |
| 10 characters, valid ISBN-10 | ISBN-13 | book resolvers |
| anything else | unrecognized | nothing |
A check digit that does not match makes the code unrecognized: a misread barcode is not looked up. Stored with the input, its class and its normalized form (the ISBN-13, the 13-digit GTIN) are what the repetitions and the copies are found by.
6.3. Resolvers and their order
A resolver asks one source about one code and returns nothing, one record or several. The worker asks the resolvers of the class in order and stops at the first that knows the code. For an ISBN the order depends on its registration group, and is configuration, not code; Table 7 is the proposed default.
| Group | Resolvers, in order |
|---|---|
| 2 (French) | BnF SRU, Open Library |
| 0, 1 (English) | Open Library, BnF SRU, Library of Congress |
| 3 (German) | DNB SRU, Open Library, BnF SRU |
| 963, 615 | OSZK Z39.50, Open Library, BnF SRU |
| 88, 979-12 | SBN Z39.50, Open Library, BnF SRU |
| any other | Open Library, BnF SRU |
The Hungarian books (groups 963 and 615) are asked of the
National Széchényi Library (OSZK), whose catalogue answers Z39.50
queries (ResearchJournal.org); a source of their own is
needed, since Open Library knows few of them. It was tried live:
it finds the Kertész by its ISBN-10 and not by its
ISBN-13, so the ISBN-13 of a 978 book is turned into its ISBN-10 for
it. The OSZK and SBN sources exist since 0.19.0 (The Z39.50 resolver),
built when YAZ is there; the table is their default order (one line
of [sources] for the same prefix replaces it), and the default for
any other ISBN is Open Library then the BnF. French books (group 2)
are left to the configuration file, as before.
Since 0.32.0 two more sources exist. The German national library
(DNB) answers SRU at services.dnb.de. The Library of Congress
(LC) answers Z39.50; its address is given in
The Z39.50 resolver, and it is built when YAZ is there. They are in the default orders of the table for German
books (978-3: the DNB first, built in every case) and for English
ones (978-0, 978-1 and 979-8: the LC third, under YAZ), unless the
configuration already has a line for the same prefix. The DNB
resolver is described in The DNB resolver, the LC one is a target of
the Z39.50 resolver (The Z39.50 resolver). Google Books is not added
(the author’s decision: a key for every user for
little gain, ResearchJournal.org).
For a GTIN: MusicBrainz, then the BnF by EAN (Discogs, when a token is configured, is not written). Google Books is not used: it refuses a request without a key (HTTP 429, a quota of zero), and a key is a registration for every user; it was tried with a key and left out (ResearchJournal.org). UPCitemdb’s free trial answers a title, a brand and a category for a DVD, nothing to catalogue it by, and its terms are unread: it is not used (the author’s decision, ResearchJournal.org), and a DVD is entered by hand until a library catalogue answers by UPC (FutureResearch.org; the Library of Congress’s SRU has no index for it). The Romanian national library states a Z39.50 service in UNIMARC and does not give its address (FutureResearch.org).
The sources as they answer (real answers, kept as
test fixtures in FakeSources.h):
- Open Library
- the edition (after one redirection, which the client follows) is JSON as documented; an unknown ISBN is a 404 with an HTML body, whatever the extension asked for; so every 404 is “not found” and its body is never parsed.
- The BnF
- SRU,
recordSchema=dublincore, the querybib.isbn adj "ISBN"orbib.ean adj "EAN". The BnF indexes the ISBN as it was printed: the Simondon of the sample is found by its 13 digits and not by its 10 (0 records), so an ISBN-13 is asked first and, if the BnF has no record for it and it begins with 978, its ISBN-10 is asked in a second request (a record of an older book may hold the 10 digits only). The EAN index covers books as well: the same book is found bybib.eanof its ISBN-13. One record per edition, but a Dublin Core one: the title ends with the author’s name; a creator is “Family, Given (dates). Role” with the role in French; the publisher is “Name (Place)”; the series is a description that begins “Collection :”; the ISBN is the identifier “ISBN 9782081231412”; the barcode a description “Code à barres commercial : EAN …”.BnfResolverturns them into the normalized record; a diagnostic of the service, a page that is not an SRU answer, or an HTTP error make the source unavailable, zero records “not found”. The BnF does not hold the Mozart CD. - MusicBrainz
/ws/2/release/?query=barcode:EAN&fmt=json: the releases that carry the barcode. A disc entered more than once comes back more than once, with other credits or labels: records with the same lower-cased title, year and medium are one. The medium issound, orvideofor a DVD, a Blu-ray or the like. A UPC-A is asked with and without its leading zero.
Records that two sources would make ambiguous are not compared: the first source that knows the code decides, as for every class.
Each source has its own queue, so that its rate limit holds whatever
the number of scans waiting: one request a second for Open Library
and MusicBrainz, which ask for it, and the same, until known, for
the others. Every request carries a User-Agent naming sapupra,
its version, and the contact address set in the configuration. A
Z39.50 query, which YAZ makes by a blocking call, runs in a thread
of its own and reports to the event loop.
A record found is normalized (title, subtitle, contributors and
their roles, publisher, place, year, languages, extent,
identifiers, series, subjects); the answer of the source is kept as
received, as a source record. The record is also the form of an
edition written by hand (kind record) or chosen among several
(kind choice): a JSON object with title required, and medium
(book, sound, video or other), subtitle, contributors
(objects with name and role, or names), publisher, place,
year, languages, extent, series, subjects, isbn13,
gtin and issn.
Open Library, the first source, is asked for /isbn/ISBN-13.json
(it redirects to the edition), then for each author of the edition
(/authors/OL...A.json, at most 10; an author it does not know is
skipped, any other failure makes the source unavailable), one
request a second. It answers 404 for an ISBN it does not know. Its
/api/books is not used. The edition and the author as it answers
are test fixtures: an author is an object with name
(composed, Imre Kertész) and personal_name (which may not be),
and the name is read from name. Some editions have no authors
at all and only works (the Rushdie of a trial,
9780805053081): the author is then a property of the work, so the
resolver asks for /works/OL...W.json (one more request) and reads
authors[].author.key there; a work that is not found leaves the
edition without an author, any other failure makes the source
unavailable. The edition keeps both answers as source records.
Every request of the sources reached by HTTP ends after a timeout
(60 seconds, timeout), asks for no HTTP/2 (http2) and says
Connection: close (keepalive): Qt warned, once, of a connection
that the server had closed. These are settings, for Open Library
timed out three times out of three in the trial of 0.13.0 and the
cause is not known (ResearchJournal.org). With --debug each
request is logged with its status, its duration and the protocol
used, and a failure says how long the source was waited for.
The order is written in the configuration ([sources]): isbn.default
for an ISBN, isbn.978-2 and the like for one registration group (the
first digits of the ISBN-13, hyphens or not; the longest prefix that
fits the ISBN decides), gtin, ismn and issn for the others,
with no source by default. A source that is not one of this version
is an error at start. The same section sets the way every source is
asked: timeout, http2, keepalive. When several records come from one source, or
a source answers after another, the first source that knows the code
decides: several records make the input ambiguous. The records (five
at most) are in the verdict and, since 0.20.0, kept in the database
(the table candidate, written in the same step as the status), so
that the entry module can offer them at any later time, a restart
of its own included. The page of the scan lists them with their
year, publisher, place and extent, each with a button; the choice
is sent as a choice input that completes the scan (never as an
ISBN to look up, which would be ambiguous again), and the worker
catalogues that record. A typed ISBN that turns out ambiguous
shows the same list on its page of result, and the choice completes
the scan that it completed. The ISBN of the scan is given to the
edition when the record has none, so that the next copy is found.
The source’s answers as received are not kept for such an item,
only the record chosen.
6.4. The Z39.50 resolver
Z39.50 is a binary protocol of the libraries, older than the web:
the client connects, searches an index with an attribute (the ISBN
is the Bib-1 use attribute 7) and asks for records in a syntax
(MARC 21 or UNIMARC). The national libraries of Hungary and Italy
speak nothing else of what they publish, and they know the books
that Open Library does not. Z3950Resolver (a Resolver, so the
worker asks it as the others) wraps YAZ’s ZOOM interface:
- the address is
host:port/database: the OSZK attagetes2.oszk.hu:1616/B1(MARC 21), SBN atopac.sbn.it:2100/nopac(UNIMARC), both changeable in[sources](oszk.address,sbn.address); - a request is a connection, one search for each form of the number, and the first five records. The forms are the ISBN-13 and the ISBN-10 of the same book (978 only), in the order the target likes: the OSZK finds the ISBN-10 and not the ISBN-13, SBN the ISBN-13 (both as seen); the second form is asked only if the first found nothing;
- ZOOM blocks, so the request runs in a thread of its own, which
hands the result to the event loop (a queued call to the
resolver); the resolver waits for its threads when it is
destroyed, and a server that is silent is given up at the timeout
of the sources (60 seconds at most). The resolver ignores
SIGPIPE, which YAZ may raise on a closed connection; - the records are fetched as MARCXML, which YAZ makes from either
syntax after converting the character set, so that the
reading of a record is one code for both;
Marcreads MARCXML and, for the tests, ISO 2709 (the records as the servers send them), and maps a record to aRecord; - the mapping differs by flavour. MARC 21: the title in 245
$a($bthe subtitle), the ISBN in 020, the authors in 100 and 700 (the OSZK writes the forename in$j, a local subfield, where others write “Family, Given” in$a), the publication in 260 or 264, the extent in 300, the languages in 041 (a text of several three-letter codes written one after the other, “hunhun”) or the 008, the series in 490, the subjects in 6xx. UNIMARC: the title in 200$a($e), the ISBN in 010, the authors in 700 to 712 (the family name in$a, the forename in$bafter a comma, the role as a code in$4: 070 author, 730 translator), the publication in 210, the extent in 215, the languages in 101, the series in 225 or 410, the subjects in 606. The medium comes from position 6 of the leader (printed text a, sound i and j, video g). The punctuation of ISBD at the end of a value (“ /”, “ :”, “,”, a full stop that does not end an abbreviation) and the non-sorting markers (C1 controls) are removed; - a server may hold several records for one number: SBN has three editions of the Eco’s Il pendolo di Foucault under one ISBN-13, of different years. The first is the book (it is the one SBN lists first, and the one with the fullest description in the sample), the others are kept with it as source records. Offering all of them as an ambiguous verdict would ask the author to choose at each scan of a book he owns in one edition only (since 0.20.0 the page for such a choice exists; the author decided to keep the first record, the choice being provisioned for an explicit demand, FutureResearch.org);
- a failure of the connection or a diagnostic of the server (the Bib-1 diagnostics, such as 114, unsupported use attribute) is “unavailable”, with the reason: the worker goes on to the next source and tries the code again later if none knows it. A server that answers with no record is “not found” for that source.
The tests (MarcTest, Z3950ResolverTest) use the records that the
two servers sent, kept in FakeMarc.h, and, for the
resolver, a Z39.50 server of the tests, Z3950Backend, built with
YAZ’s server library: it serves them over the loopback with the
protocol, two databases, and the refusals, the silence and the
non-MARC record that the tests need. Where YAZ has no server
library, those tests skip. What was never seen is a library
answering the worker: the first scan of a Hungarian or an Italian
book is the trial.
6.5. The DNB resolver
The Deutsche Nationalbibliothek answers SRU at
https://services.dnb.de/sru/dnb without a key.
DnbResolver is a Resolver on HttpResolver, like the BnF’s, and
asks for searchRetrieve in the schema MARC21-xml:
- a code:
isbn=ISBN-13, at most 10 records. It handles ISBNs only; - a search:
tit="..." and atr="..."(title and author), at most 50 records read and the printed ones kept, at most 10; - the envelope is checked: a diagnostic of the service or an answer that is not SRU is “unavailable” with its text, a count of zero “not found”;
- the records are MARC 21, read by
Marcas the OSZK’s and the LC’s. The DNB’s own habits, seen on its answers (dnb-kafka.xml,dnb-none.xml): non-sorting marks (C1 controls) in 245$a, removed; several 264 (publication, production, copyright), of which the one with a place is kept; the publisher also written as a 710 with the relatorpbl, which is not a contributor and is skipped; 653 holding noise (the publisher again), which is not a subject, so only the genre (655) is kept; - a search drops the online editions (338
$bcr, or 007 beginningcr); a query by ISBN keeps what the ISBN is of. Records with the same title, year and publisher are one.
The answer is kept as marc21-xml. What was not seen is the
service answering a German ISBN of the user’s library, and the
search by author and title live; its terms of use are unread
(FutureResearch.org).
The LC target of the Z39.50 resolver is lx2.loc.gov:210/LCDB,
syntax usmarc (MARC 21), the ISBN as attribute 7 without
hyphens, the ISBN-13 first. The test record (loc-rushdie.mrc) is
the LC’s answer for 9780805053081: the ISBD
punctuation is removed, the subtitle (“a novel”) is read from
$b, the subjects from 650 and 651.
6.6. The source of a description
Every answer of a source is kept in source_record, in the order of
the lookup, so the last row of an edition is the source that knew
the code. Catalogue::description returns the origin of the
edition (scan, by hand, …) and the source and the time of
that last row. The page of a copy shows them in a row “Source of the
description”: the name of the source (Vocabulary::source, in the
language of the page) and the date, or, for an edition written by
hand, its origin.
6.7. Statuses
Table 8 lists the statuses of an input, and what follows each.
| Status | Meaning | Next |
|---|---|---|
received |
recorded, not yet settled | classification |
resolved |
an item is catalogued | none |
repeated |
same code, same device, just before | none |
not-found |
every source answered: unknown | the entry module |
not-isbn |
a book’s barcode is not its ISBN | the entry module |
ambiguous |
several records | a choice, entry module |
unavailable |
a source did not answer | retried automatically |
unrecognized |
not a code that can be looked up | the entry module |
located |
the location of the device is set | none |
labelled |
the label of an item was scanned | none |
placed |
a place was made, renamed, merged or deleted | none |
removed |
a copy was removed from the catalogue | none |
restored |
a removed copy was put back | none |
corrected |
the description of an edition was corrected | none |
unknown |
a place or an item that is not made | make the place, or check the label |
found |
a search answered, with its records | a choice, entry module |
another-copy |
a known edition scanned again, held (copies=ask) |
confirm or dismiss, entry module |
dismissed |
set aside by hand | none |
An input in unavailable is retried after 1 minute, 10 minutes,
1 hour, then once a day; after a week it becomes not-found, with
its history; a source that did not answer is told to the scan
device once (“queued”, status unavailable), the retries are
silent, and the verdict that settles the input is published when it
comes. not-isbn is given to a GTIN that no record or film
source knows: it is often a book whose barcode is a product code
(the Romanian book of the sample), and the entry module asks first
for its ISBN.
6.8. Repeated scans
Two copies of one edition are two items. But a continuous scanner
can read one barcode twice; the same code from the same device
within 10 seconds (configurable) is taken for a repetition, and
creates no item. A second copy is scanned after another item, or
after the delay. A code whose earlier input is waiting in the to-do
list, scanned again, is answered “set aside, already waiting”, and
adds nothing to the list: it is recorded as repeated, with the
waiting input in its detail. The verdict of that second scan has
the status of the waiting input (not-found, not-isbn, ambiguous,
unavailable: the scan device sounds its error, the item is not
there) and the text; in the database its status is repeated. A
code that the history of Binary Eye holds, imported again, is found
by the scan device’s own time and answered “already recorded”.
Typing the ISBN of a book whose barcode was set aside (kind isbn
with for) catalogues the edition under the ISBN, records the
barcode as its GTIN, resolves the barcode’s input to the same item,
at the location that was current when the barcode was scanned; the
next copy, scanned by its barcode, is a copy of the edition without
any request.
With [worker] copies ask (the default), the scan of a code whose
edition has an item already, outside that window, is not catalogued:
it is held with the status another-copy and the edition (input.edition),
and the entry module asks. An input of kind copy (with for) then
catalogues it as another copy, at the location the scan had; an
input of kind dismiss (with for, valid for any scan that has no
item) sets the scan and itself dismissed, and deletes nothing.
With yes every such scan is a copy at once; with no it is
answered “already catalogued” (repeated). Only a scan is
concerned. A scan of a code whose lookup is running, outside the
window, is parked until that lookup ends, and then treated as above:
without it the second scan would find no item yet and make a second
one (the race of the trial of 0.15.0).
6.9. Search by author and title
An input of kind search carries a query (title, author, one at
least). The worker asks every source of [sources] search that
canSearch (Open Library by /search.json, the BnF by SRU with
bib.title all and bib.author all joined by and), one after the other,
gathers their records (ten at most, one ISBN-13 once) and answers
found with them, or not-found. A source that fails is named only
when no other found anything. Nothing is catalogued: the entry
module shows the books and the chosen one comes back as an input of
kind choice, with its record and, if a scan was being settled,
for. A work of Open Library lists the ISBNs of all its editions;
the record carries the first valid one, which may not be the edition
in hand.
The second try. When no source gave a record, the title has more
than two words and the query has no card number, the worker asks
the sources of search once more with the two longest words of the
title (the first of the equals, in their order; Worker::relaxedTitle)
and the author as typed, and the verdict says (“with fewer words”, or
“nor with fewer words”). The input keeps the query as typed. Not a
third try: a wrong surname is not guessed.
The card number. A query with lccn (the number as typed, alone: the
entry module refuses it with a title or an author, and the worker
would ignore them) is asked of the sources of [sources] lccn
(loc by default) that canSearchLccn, that is the Z39.50 target with
mLccn: the Library of Congress, by the use attribute 9 with
Code::normalizeLccn of the number (letters of the prefix, the year,
the serial number padded to six digits: 73-1830 is 73001830). A
number that is not one is unrecognized. A worker without YAZ has no
such source and answers not-found with that reason. The fake
server of the tests knows one number (2004431148, the card number that
Open Library gives for the Kertész). The real server was asked on
2026-11-04 for 73-1830 and gave one record.
Both queries were written after the sources’ documentation
and seen answered in 0.18.0 (a search for Simondon, the answers
kept as test fixtures): the sources overlap, so the records are
deduplicated by ISBN-13 (by title and year when neither has one),
and the BnF titles end with the statement of responsibility
(“ / Gilbert Simondon”), which is cut. The entry module, for a
chosen book that has an ISBN-13, sends an input isbn (with for)
instead of choice: the sources then describe the edition, not the
work; a book with no ISBN comes back as choice, with its record.
6.10. Locations
The worker keeps the current location of each device: set by an
input of kind location, from a label or from the entry module, it
applies to every input of that device that follows, until the next.
A location that names a place not made is refused with unknown,
and the current location stays. An input records the location that
was current when it arrived; the item it makes is at that location,
unless the input carries a place of its own (an item typed by
hand, a search, a choice): then the item is there and the location
of the device is not touched. Without a current location an item is
catalogued with none.
The orders of kind place are done by the worker, in one
transaction each (makePlace and movePlace of Catalogue): a place
made makes its parents; a rename changes the path of the place and of
every place below it, and is refused if the new path exists, or lies
inside the place; a merge moves the devices, items and history of
the place to another that exists, and deletes it; a delete
(deletePlace) removes the row of a place that nothing depends on: no
place below it, no device there, no copy there (a removed copy keeps
its place), no scan made there (the orders of kinds place,
remove, restore, search and dismiss do not count; their
reference to the place is dropped). A place with a history is merged
into another, not deleted: the history is of use, and deleting it
would make the scans of the past point nowhere. A refusal is
unrecognized, with the reason; an unknown place is unknown.
The order of kind device (0.44.0) corrects the identifier of a scan
device that wrote its name wrongly: its code is the identifier to
change, its to the identifier it takes. It is done by the worker in
one transaction (renameDevice of Catalogue): the row of the device
takes the new identifier, and so does the column device of every
input it sent, so that its history, its duplicate detection (same
device, same device time, same code) and the items it made, which
point to those inputs, follow. If the new identifier is a scan device
already, the two become one and the location of the one that exists
stays (the location of the other is kept only if the first has none).
The message stored with each input is not rewritten: it is what was
received. The manual entry of the entry module is neither renamed nor
merged into. A device that does not exist is unknown; an
identifier that is not valid or is the same is unrecognized. The
phone goes on sending its old identifier until it is corrected in its
settings: that is a new scan device, to be merged again.
7. The entry module
7.1. Pages
The entry module serves HTML pages to any browser of the local network, with forms and no script, so that the phone’s browser is enough. Table 9 lists them.
| Path | Method | What it does |
|---|---|---|
/input/ID |
GET | a scan, the scans before and after it, the forms |
/input/ID/isbn |
POST | the ISBN typed for that scan: an input isbn with for |
/input/ID/record |
POST | the description by hand: an input record with for |
/new |
GET | an item with no code: an ISBN or a description |
/new/isbn, /new/record |
POST | the same inputs, without for |
/input/ID/copy |
POST | the confirmation of another copy: an input copy with for |
/input/ID/dismiss |
POST | the scan set aside: an input dismiss with for |
/search |
GET, POST | “Look up” by author and title, or by card number (?for=ID for a scan): an input search |
/todo/discard-copies |
POST | the scans held as another-copy discarded at once: one input dismiss for each (500 at most) |
/catalogue |
GET | the catalogue: simple search, counts, latest, results by 50 (The web front) |
/catalogue/advanced |
GET | the same, with a field for every attribute |
/catalogue/item/ID |
GET | a copy: its edition and every copy of it; ?result=ID of an input says that this input made it |
/catalogue.csv |
GET | the copies of a search, or all, as CSV |
/result/ID/choose |
POST | the book chosen among those of a search: an input choice (the number n of the list) |
/location |
GET, POST | GET goes to /places/devices; POST sets the location of a device among the places made (input location) |
/places |
GET, POST | the places with their counts and a link to each; POST makes, renames, merges or deletes a place (inputs place) |
/places/manage |
GET | the forms that make, rename, merge and delete a place |
/places/devices |
GET | the scan devices with their location, and the form that sets one |
/places/labels |
GET | for each place its code and the spplabel command that prints its label |
/removed |
GET | the copies removed, the latest first, with their reason |
…/ID/remove |
POST | under /catalogue/item: the copy removed, with a reason: an input remove |
…/ID/restore |
POST | under /catalogue/item: the copy put back: an input restore |
…/ID/correct |
GET, POST | under /catalogue/item: the form of the description, filled; the order correct with the record |
…/ID/move |
POST | under /catalogue/item: the copy put at a place made: an input label with a place |
/result/ID |
GET | the verdict of an input sent; it loads again until it comes, and leads to the page of the item when the input catalogued one (303) |
/import |
GET, POST | the history of Binary Eye: the form; the preview |
/import/go |
POST | the confirmation: the inputs are published |
/import/TOKEN |
GET | the progress of an import |
- to do
- the scans that need attention (
not-found,not-isbn,ambiguous,unrecognized), oldest first, each with its location, its time, and, on its page, the scans of the same device just before and after it.unavailableis not in the list: the worker retries it by itself and makes itnot-foundafter a week, so it is shown apart, as waiting for a source. A code typed in the search field finds the waiting scan, written as it was scanned or as an ISBN with hyphens; - an input
- what was read, and three ways to settle it: type an
ISBN; search by author and title, with a choice among the records
found (the list is on the result page, kept in memory with the
latest verdicts: after a restart the search is made again); enter
the record by hand. And one to set it aside (dismiss). A scan held
as
another-copyis not settled but decided, by a page whose two buttons say what each does (“Add it as copy N”; “Discard this scan, add nothing”); when several are held, the to-do page offers to discard them all (the history of Binary Eye imported again); - a new item
- the same two ways, for an item with no code, for the
device named (a list, or another name typed), and, if wanted, a
place for that item (a list of the places made, the empty choice
being the device’s current location). The place is the item’s
only: it is sent as the
placeof the input and the location of the device does not change. The same choice is on the forms of an input and of a search; - search
- the search by author and title without a scan, for the device named;
- places
- four pages, one idea (0.33.0; the author found the single
page of 0.31.0 dense). The places: the places with the number of
items at and below each, each a link to what is there (the address
is built in one
arg()call: see Inserting texts into messages for the fault that a chain of them once made of it). Make, rename, merge, delete: the four forms; make a place (its parents with it), rename one, merge one into another, delete one that nothing depends on; each is an input of kindplace, sent to the worker. Scan devices: each device with its current location, and the form that sets one among the places made (with none made, the page says to make one first: it is the condition of the first scans; an input of kindlocation). Labels: for each place its code (the prefix of the locations and the path) and the command that prints its label, and one command for all of them. The same command is offered, folded, on the page of the result of a search by location. A line of links leads from one page to the others. Before 0.31.0 the location of a device had a page of its own, beside the places; they were joined, and are now four pages of one section, and/locationleads to the devices; - the manual entry
- the pages of the module send their inputs as
from a pseudo scan device,
entry(the keydefault_device, which the pages call manual entry and not by the identifier, the identifier being what the database stores: the same rule as for the keywords). Its forms (new item, look up, set a location) choose it unless the person names another device: until 0.33.0 the list began with the device first in alphabetical order and the module’s own was added at the end, so that a person who had set the location of the manual entry created an item that took the location of another device, or none. A device other than the default one is shown by its identifier; - look up
- the page does not take an ISBN, and says where to type one (New item, or the page of the scan it is for);
- the page of a copy
- the number of the item (a row of the
description: it is the one of the label and of the address), the
edition, the source that described it, the copies of the edition
(a table of the location and the day each was entered; with several
copies a first column gives the number, a link for the others and
this one for the page’s own; with one copy there is nothing to tell
apart and the column is not there), and the forms that act on this
copy: move it to a place made, remove it with a reason. When the
input that made the item was typed in the module, the page is where
its result leads, and begins with a line that says so. A copy removed shows when and why, and the form to put it
back. The list of the removed copies is
/removed, reached from the home of the catalogue (the link appears once a copy was removed).
The navigation bar of every page has the buttons Catalogue, To do, New item, Look up, Places and Import, and the foot of the to-do list says what each is for: the first version of the module had them as plain links, and the author did not find them. The list of the scans in the database is read, not kept: the page shows what the worker wrote at the time of the request.
7.2. The look of the pages
(0.25.0) The look is a style sheet of about 5 kB, in Html.cpp,
sent inside every page: no script, no image, no font file, nothing
fetched from outside. The model is the calm, flat look of KDE’s
Breeze, which the author prefers; only its colours are taken, as the
variables of a light scheme and of a dark one, the dark one chosen
by the browser’s own setting.
- The frame
- a bar at the top (the name and the six entries, the entry of the page marked, fixed at the top of a long page on a wide screen, not on a phone, where the entries are two rows of three), a card with the notice, the title and the body, and the foot with the version. The page owns an entry by its title: the page of a scan is of the to-do list, a result is of the look-up, a page that no entry owns marks none.
- Compact
- the fields of the search forms are in a grid that wraps (columns on a desktop, one on a phone); the tables have a small head in capitals, a hairline between the rows and aligned figures.
- What a phone leaves out
- below 40 em the columns that another page repeats are hidden (the publisher and the medium in a table of copies, the device in the to-do list), and the to-do list is a list of cards (the code and the link to settle it, the status, the time and the location) where a wide screen has a table.
- Large targets by pointer, not by page
- a touch screen gets taller buttons, links and fields, a mouse gets compact ones; the dense catalogue and the one-handed to-do list need no two designs.
- Checks
HtmlTestchecks the bar, that nothing is fetched and that the sheet stays under 7 kB (past that, a route of its own with a cache header is due);EntryTestchecks the grid, the buttons and the marked columns. The look itself is judged by eye: it was rendered in a headless browser at 390 and 1280 px, in both schemes, against a catalogue of 24 editions.
7.3. What it publishes
Inputs of kind isbn, record, search, choice, copy, dismiss,
location and place, with for when they complete an input; scan and location, with the origin
history, for an import. They are published like the gateway’s: QoS
1, on the topic of the device, kept in memory until the receipt
comes and published again with the same identifier if it does not
while the worker is online (InputPublisher). It reads the database
only to show it.
Its answer to a form is not the verdict, which the worker may take
seconds to give (a lookup of an ISBN at the sources): it is a
redirect, “303 See Other”, to /result/ID, which shows the verdict
if it is known (the module remembers the latest four thousand; the
database, for an older one) and otherwise reloads itself every
second. The reload of the page, or the back button, then resends
nothing.
The identifier of the input is the token that the form carried, a
UUID made when the form was displayed: a form sent twice (a double
click, a reload of the answer to a POST) is the same input, which the
module does not publish twice and the worker would record once.
Typing an ISBN in a form that was completed by someone else is
refused before it is sent: only a scan with no item yet is settled
(and the worker refuses it again: checkCompletes).
7.4. Importing Binary Eye’s history
(Since 0.30.0 the preview lists the places that the file names and
that are not made, with a box, checked, that makes them when the import
goes: the orders place are published before the scans. Unchecked,
the locations in the file that name an unmade place are refused, and the
scans after them stay where the device was.)
When the gateway or the broker was absent, the scans that Binary Eye
could not send are still in its history, which is kept by default
(its setting Save scan history). They are recovered in four steps.
- On the phone, in Binary Eye: History, then Export to file,
then JSON file, and a file name; the file is written in the
phone’s
Downloadfolder. Each scan in it is an object with, among others,_datetime(the time of the scan, as2026-10-26 14:59:12:237),formatandcontent, read in Binary Eye’s source (JsonExport.kt,Scan.kt, version 1.75.5), and confirmed by the export of the trial of 0.13.0. All its values are strings, and the scans come latest first. The format has the name of the export (EAN13), not that of the request (EAN_13): the module writes the second, as the gateway receives it. - In the phone’s browser, the entry module’s page import: the
file, chosen with the form’s file field; the scan device it comes
from, chosen among the known ones or typed (the history does not
say); and, optionally, the time from which to import. The form
is sent as
multipart/form-data; the entry module’s server takes bodies up to 4 MiB for it (max_upload), some ten thousand scans. - The entry module reads the file, sorts the scans by time, and
shows what it would publish: the scans not yet recorded, and the
location labels among them, each with its time, its code and its
class; the scans already recorded are counted, not listed. A scan
is already recorded when an input of the same device has the same
deviceTimeand the same code: the gateway writes indeviceTimethetimestampthat Binary Eye sends, the same text as the_datetimeof its history. A location label is recorded when an input of the same device has itsdeviceTime. The plan is kept in memory (the latest four) until it is confirmed. - Confirmed, it publishes them, in the order of their times, as
inputs of the kind
scanorlocation(a code with the location prefix), with the originhistory, the history’s object inraw, its_datetimeindeviceTime, and asreceivedthe time of the scan, read in the machine’s time zone. So that each scan gets the location that was current when it was taken, it first publishes the last location label of the history before the first scan it publishes, if there is one; and, at the end, the location that was the device’s before the import, so that scanning goes on where it was (only if the import set a location). It publishes fifty inputs at a time, the next ones as the worker answers, so as not to flood the broker with a long history, and shows the verdicts as they come, by status; what fails joins the to-do list as any scan, and what waited for a source is retried by the worker.
The worker keeps its own guard: an input of origin history whose
device, deviceTime and code are those of an input already
recorded is recorded as repeated, and creates no item. Importing
the same file twice is therefore harmless, and so is importing a
file that the gateway had partly received.
7.5. Access
The prototype asks for no password: the entry module is reachable from the local network only, like everything else. A password, or the restriction to some addresses, can come later (Access from outside the network). It runs under the account of the worker, so that it reads the database as the worker’s own readers do (The files of the database); it opens the file read-only and never writes the catalogue.
7.6. Access from outside the network
Everything is meant for the local network, and nothing in the programs defends against anything else. What an outsider who could reach the pages would find:
- no identity
- the entry module and the gateway ask nobody who they are. Any visitor could settle a scan, enter an item, move, remove or restore a copy, make, rename, merge and delete places, import a file of up to 4 MiB, and publish inputs on the broker through the pages. The consultation alone is harmless to the data and still shows the whole catalogue, and All of it as a CSV file takes it in one request;
- no encryption
- the pages are plain HTTP, so that on the internet everything that is typed or shown is read on the way, and a password that a proxy would ask for would be read too;
- the broker
- it must never be reachable from outside: its passwords are the only protection of the topics, and the pages of the entry module and the gateway are its clients;
- the load
- a pattern of the search is a regular expression tried on every copy (some 400 ms at 20000 copies, A catalogue to try the programs on); the module is single-threaded, so that a few visitors who ask for patterns slow every page, and a pattern that backtracks a lot makes a page wait.
The solutions, from the one that asks the least of the programs:
- A virtual private network into the local network (WireGuard on the
router or on the machine): nothing is opened to the internet but
the port of the VPN, authentication and encryption are those of
the VPN, and from outside everything works as at home, the scans
of the phone included. This is the recommendation when it can be
had; no program of
sapuprachanges. - An SSH tunnel (
ssh -L 8081:server:8081 user@home), for a visit from one machine: the same, with the program that is already there. - A program that consults and does nothing else,
sppconsult, behind a proxy that authenticates and encrypts. A proxy alone, with a list of the paths and methods that pass, was the first idea; it protects what the list protects, and a mistake in the list publishes a page that writes, so that the program itself is made to refuse what it must not serve, and the proxy has one thing to do.
The consultation program. It is the class of the entry module in a
mode that has: no connection to the broker, so no password and no
file of the secrets; a route table of four paths (the catalogue, the
advanced search, the page of an item, the CSV file) and the root path
that leads to the catalogue, GET only, and a 404 that repeats
nothing of the request for every other path; no form that changes
anything (the page of an item has none, a removed copy is not
found), no command that prints a label, no devices and times of the
scans of an item, a bar with the catalogue alone and a footer without
the version; a limit on the length of a field (200 characters) and
on the duration of a search that tries patterns (3 seconds: the
copies tried so far are the answer, and the page says it was cut);
smaller limits of the HTTP server (a request of 4 KiB of headers and
1 KiB of body, 32 connections, 10 seconds); the headers
Content-Security-Policy (nothing but its own style), nosniff, no
referrer and no frame on every reply. It listens on 127.0.0.1 and
warns in the log if told to listen elsewhere. It is a program of its
own, sppconsult, not an option of sppentry: its service has its
name, its unit shuts it in (NoNewPrivileges and the like), and it
cannot be started with the broker’s password by a slip of the
command line. Because the database is readable by every account
(The files of the database), it can run under an account that can do
nothing else.
The proxy. Caddy or nginx: a proxy of our own, a part of the
consultation program (TLS with QSslServer, available since Qt 6.4,
a password, a limit of the rate), was weighed and not made.
- for
- one program and one configuration file, in the format of the others; nothing more to install, the same on Debian and FreeBSD; a part that the test suite can test, which a proxy configuration written from a manual cannot;
- against
- the code that defends the door would be ours, young, and written to be light: the TLS and the password are the part of a program that should be written the least, and read by the most people; the HTTP server of the programs is bounded but it is one thread and a small parser written for a local network, which the open internet tests otherwise; the certificate is the real difficulty and no program of ours removes it: a certificate from a certification authority needs the protocol ACME and a renewal every three months, which is a program of its own (certbot, or a proxy that does it), and a certificate of our own makes each browser and phone warn until it is told to trust it; the ports below 1024 (443) need a privilege or a forward of the router; the updates of the library of TLS come from the system, those of our code from us alone.
The proxy is Caddy: a file of a few lines (a name, a user and the hash
of a password, the address of sppconsult) in which the
certificate is obtained, renewed and served, and the visitors of the
port 80 are sent to the port 443, by itself. It adds one program to
the machine and leaves ours without a door to defend. nginx stays in
the manual for whoever runs it already; it does the same with more
lines, a certificate from certbot, and a limit of the rate that
Caddy lacks without a plugin. What the password does not stop is
a visitor who has it, who costs at most the duration of a search at a
time, the program being single-threaded. Both configurations were
run here against the program (Manual.org).
The things to refuse in any case: opening the ports 8080, 8081 or 1883
of the router to the internet, and moving the pages to a public
host with the database on it (a copy of catalogue.db made with
sqlite3 .backup on a host of its own is a safer public catalogue
than a door to the machine at home).
8. Several users at once
This section discusses the concurrency considerations of the design: what several people scanning and acting at the same time share, and what protects them from each other, by the shared thing.
- The database
- one writer, the worker; the entry module, the web front and Datasette read it (WAL: a reader does not block the writer and the writer does not block a reader; the readers wait up to 5 seconds for a lock). Two workers are refused at start by a lock (Recording first; before 0.33.0 nothing prevented it).
- The order
- the worker takes the inputs one at a time from one event loop, records each with the next sequence number and the location of its device at that moment, and settles them with the database in transactions; nothing is shared between two threads but the Z39.50 queries, which report to the loop.
- Two scans of one code
- from one device or two, while the first
lookup is under way, the second waits for it (Repeated scans) and
is then a copy or not as the setting says; a code that is waiting in
the to-do list makes the later scans of it wait with it. One lookup
for one code. Tested (
WorkerTest::severalUsers). - Two scans of two codes
- independent. Each item takes the location of the device that scanned it, not of the last one to scan.
- Two decisions about one scan
- the first is taken, the second is
answered
unrecognized(“not waiting”): another copy and set aside at once make one or the other, never both (tested). A removal and a restoration of one copy are two orders on one row, taken in the order they arrive. - A form sent twice
- the identifier of the input is the token of the form: a double click or a reload is one input.
- The pages
- the module answers from one event loop; each page is what the database held at the time of the request. Nothing is kept for a user between two requests but the latest verdicts, in memory, by the identifier of the input.
What is not protected, and cannot be by the worker:
- The device is the unit of location. Two people who use the same
device identifier share its current location: one sets it to the
salon and the other to the bedroom, and the next item of either goes
where the last one said. Two phones have two identifiers and do
not meet. The pages of the module are one device for everybody,
the manual entry, and its location is everybody’s. What each
browser has of its own, since 0.38.0, is a place: a cookie
(
sapupra_place, the path of the place percent-encoded, kept a year,HttpOnly,SameSite=Lax) that the forms of the items typed by hand (the ISBN, the description, the search) offer as the place of the item, in the list that is otherwise “the device’s current location”; it is set when such a form is sent with a place, and on the page of the devices, where the first line forgets it. The server keeps nothing for the browser: the cookie is a suggestion, used only if it names a place that is made (a cookie that names another, or that is not a path, is ignored), and the person sees what is chosen before sending. It does not change the location of the manual entry, which only a scan of a label or the form of the devices sets, nor what the phones do. The module that consults sets and reads no cookie. - Two people who name the same new place at once: both orders reach the worker, one makes it and the other is told that it was made already; nothing is made twice.
- The pages carry no sign-in (Access): whoever reaches the port acts, and nobody is told who did what but by the device.
9. Inserting texts into messages
A chain of QString::arg() calls is a fault when a text from outside
comes before another call: a text inserted by one call that holds a
%2 is taken for a place to fill by the next call. The link of a
place whose name holds a slash is ...salon%2Fcaisson..., and the
next arg(), which filled the count, turned it into
salon6Fcaisson: the search by that link found nothing, and the page
that said so showed the damaged name. The calls where a text came
before another arg() are one call with all the texts (the places,
the facets of the catalogue, the messages of the worker and of the
sources, which carry scanned codes and the error strings of the
network); EntryTest::deepLocation keeps the case. The rule for new
code: a message with several markers is filled by one arg() call.
10. When a part is absent
Table 10 says what the scan device shows when a part is missing, what is kept, and how the scan is recovered. The rule is that a scan is either recorded, or said not to be, at once and aloud.
| Absent | Shown | Kept | Recovered by |
|---|---|---|---|
| gateway | “failed to connect” | Binary Eye’s history | importing it |
| broker | “Not recorded” | Binary Eye’s history | scan again, or import |
| worker | “queued, worker offline” | the broker’s disk | the worker’s return |
| sources | “queued” | the database | automatic retries |
| entry module | nothing | the database | its to-do list |
“Shown” is what the scan device shows; in the first two cases, it also sounds its error.
- The gateway absent
- the scan device cannot connect. Binary Eye
then sounds and vibrates its error (any failure of the request,
as for a status outside 200 to 299:
CameraActivity.ktof its source) and shows the failure. The scan is not lost if Binary Eye’s Save scan history is on, which is its default: its history is then imported in the entry module (Importing Binary Eye’s history). - The broker absent
- the gateway is reached but cannot hand the scan over: it answers 503, “Not recorded, scan again”, which makes the error heard. The same recovery as above.
- The worker absent
- the broker keeps the inputs for it on disk (Delivery), and the gateway, which knows from the worker’s presence that no verdict will come, answers “queued” at once.
- The sources absent
- the input is recorded and waits as
unavailable, retried by the worker (Statuses). - The entry module absent
- nothing is lost; the failed scans wait in the database for it.
11. The language of the pages
The pages of the entry module and the one sentence that the gateway gives the phone are in English or in a language that has a catalogue; the language is a key of the configuration file, read by the two programs at start. Nothing else is translated: the log is English so that a report can be read by anyone, the manual pages are documents, the data are in their own languages, and the texts that the worker stores in the database (the detail of a verdict) are the worker’s, shown as they are.
11.1. The sentences
The code is written in English, each sentence whole, between
_( ): the sentence is its own key. The values go where %1 to
%9 are, QString::arg fills them, so that a language can put them
where its grammar wants (the French writes “emplacement : %1”, with
a space before the colon). A count is written for its plural,
_n("%n copy", "%n copies", count), and the catalogue gives the forms
that the language has; %n is replaced by the number. Three macros
more: N_( ) marks a text that is kept to be translated later (a
table of labels), T_( ) a title that a function receives (the English
QString, which Html::page translates to show and keeps in English to
find the entry of the navigation that is current), and the empty
message is never translated (a catalogue holds its header under it).
There are 286 sentences; no sentence is built by gluing words
(the old “N cop” and “y” or “ies” are gone). The markup stays in the
sentence, which is long where a form is (“<form …>”): the translator
keeps the tags and the places as they are, and a test checks it.
11.2. The catalogues
make pot writes sapupra.pot, the list of the sentences, by
xgettext; LL.po is the catalogue of a language, text, one entry
for each sentence, read in diffs and edited by hand or by any
gettext editor; msgfmt compiles it to locale/LL/LC_MESSAGES/sapupra.mo
in the build directory, and make install puts it in the locale
directory of the installation. make update-po merges a new
list into the catalogues; make check runs check-translation,
which refuses a catalogue that lacks a sentence of the code, that
keeps one that the code no longer has, or that has an untranslated
or a fuzzy sentence, and TranslationTest, which reads fr.po and
refuses a translation that changes the tags, the places or the line
ends of its sentence. The tools are those of the package gettext,
needed to build and not to run; configure looks for them
(--with-gettext=auto|yes|no), and without them the catalogues are
not made and the pages are in English. The tree does not use
AM_GNU_GETTEXT and the directory po of the convention, which
bring a set of macros and rules (and expect libintl at run time)
to do what some forty lines of Makefile.am do here.
11.3. The reading of a catalogue
The programs do not use libintl. Translation (in the common
library) reads the .mo file itself: the table of the English
sentences and the table of their translations, in either byte order,
the plural message being “singular, a zero byte, plural” and its
translation the forms one after the other; the header gives the
charset, which must be UTF-8, and the rule of the plural forms, a C
expression of n that a small evaluator reads (the ternary operator,
the comparisons, &&, ||, %, the arithmetic): the rules of
French, English, Russian and Arabic are in the tests. A file that is
not a catalogue, is cut, or has a rule that is not understood is
refused with the reason, and the pages stay in English.
The reason is measured, not guessed. With the library of the C
language, the language of the messages is given by the locale of the
process and by LANGUAGE, which counts only when the locale is not
“C”. A service is started without LANG, so its locale is “C”; a
program may set one with setlocale, but only a locale that the
machine has generated: on this workspace (glibc 2.39) C, C.utf8
and POSIX are the only ones, and with LANGUAGE=fr a program that
had set C.UTF-8 was answered in English. The first version of
Translation was built that way (it chose the language’s own
locale, else C.UTF-8, else en_US.UTF-8, and set LANGUAGE) and
failed its own French test here, as it would on a bare server. A program that reads its own catalogue needs
neither the locale, nor the library (on FreeBSD, which has libintl
in a port, nor that), nor LANG. The price is about two hundred
lines and the format of .mo, which is simple and stable; what is
not read is the context of a message (msgctxt, not used) and a
charset other than UTF-8.
11.4. Choosing the language
[interface] language=fr in the configuration file; empty or en
is the English of the code. The code is two or three lowercase
letters, and perhaps a region (fr, pt_BR): it is a name of a
directory, so that nothing else is accepted. A language without
catalogue keeps the English, and the log says which file was looked
for and which languages are installed; the programs start all the
same, since a page in English is better than no page. The lang
attribute of the pages follows the language. The catalogue
directory is that of the installation ($(localedir), defined at
build as SAPUPRA_LOCALEDIR); a test uses the build directory.
The choice is one for the whole service, since there is one author
on a local network. The header Accept-Language of the browser is the
natural next step (a language for each visitor, a catalogue loaded for
each), and is not built.
11.5. The words of the data
A page says two kinds of words: those of the program (a title, a
button, a notice), which the catalogue translates, and those that
the database holds. If the database holds the word “book”, a French
page shows “book” under a heading that says “Support”, and a
catalogue entered with the English pages shows “book” while the one
entered with the French pages would have held “livre”. The rule that
keeps one language on the screen is that the database holds
keywords and never a word of a language, and that the page says
the keyword in the language of the page. The statuses of an input
(received, queued, dismissed) were already so since 0.27.0
(statusWords); 0.28.0 does the same for the rest, in Vocabulary
(the common library): the medium (book, sound, video and
other), how an edition was described (resolved, copied or
by hand), the role of a contributor (author, translator, illustrator,
editor, composer, performer, foreword, preface, photographer,
director) and the language of the text. Each is a table of the
keyword and its English words, marked N_( ) and so in the
catalogue of the language; a keyword that the table does not know is
shown as it is, so that a value that a source brought is never lost
nor wrongly translated.
The languages are the one case where the sources do not use keywords:
they write fre (the code of ISO 639-2, bibliographic or
terminologic: fre and fra), hun, or the name, English. The
table has forty-five of them with their two codes (and the codes for several languages and for an undetermined one); the page says the
name in the language of the page (fre and French both become
“français” on a French page), and the search follows: a language is
asked for by its code, its English name or its name in the language
of the page (“hongrois”), and the catalogue looks for all of them,
so that records written in either way are found together. What is
not in the table is shown as written. The database is not changed
and no record is rewritten; a file made by any earlier version reads
as before.
The same rule holds for what 0.31.0 adds: the kind of an input
(Vocabulary::kind: scan, location, label, place, removal, …) and
the reason of a removal (Vocabulary::reason: donated, sold, lost,
entered by mistake, other). The author asked whether a
catalogue begun with English pages must be searched with book from
French pages. It does not: the medium is a keyword and the search takes
the keyword and the words of the page alike; the places where a
keyword was still shown raw were the kind of an input, in the lists of
inputs and on the page of an input, and they are translated now. What
no table can translate are the words the author typed (the names of
places, a title, a note): they are his, in his language, as a label
on a shelf is.
What stays in the language of its source, on purpose: the titles,
subtitles, subjects, publishers, places, series, notes and the
extent (“159 p.”), the names of persons. They are the descriptions of
the book, not words of the program; a subject translated by the
program would be a heading that no thesaurus holds. The catalogue of
a library in several languages shows them in the languages of its
books, which is also what a reader of the shelf sees. A dynamic
translation of them was considered and set aside: it would need an
outside service (against “everything on the local network”) or a
table of subjects, and would be wrong exactly where a subject is
particular. What may be done later, if the mixture of
fre and French in the stored data is judged a nuisance in the
exports, is to write the code at the time of the record (the
worker), a change of the worker alone.
11.6. The French
The words, for whoever corrects them (in fr.po alone): an item,
alone or counted, is a document (the author’s remark:
exemplaire is the word of a set, “3 exemplaires de ce document”,
and a counter that says “6 exemplaires de 4 éditions” counts
documents); a copy of an edition, where several are meant, is an
exemplaire (the copies of the edition on the page of a copy, the
confirmation of another copy). The English follows: an item is
counted as items and copy is kept for the set (a message
catalogue has no context, so the two senses are two sentences). A
scan is a lecture and a scan device a lecteur
(no program is named in the sentences, but for the history that
only one program writes), the broker is the courtier and the
worker the travailleur, a location an emplacement, the to-do
list la liste des actions, to dismiss écarter, the catalogue le
catalogue, a search that was made recherché and not cherché,
a field un champ and a regular expression un motif (the words
of the page for the reader who is not a computer scientist, on
the author’s word), what is typed entré or saisi and
never tapé. The punctuation follows the French (a space before a
colon), and the quotation marks are the guillemets. The writer is
not French, and the sentences were not read by one: the author’s
reading, which is that of a French speaker, is the test. The header
of fr.po carries no version of its own: Project-Id-Version is
sapupra $Revision: 98 $, which Subversion expands once the property
svn:keywords is set to Revision on fr.po and sapupra.pot
(a command of the author’s, svn propset svn:keywords Revision), and
make pot writes it so.
12. Location labels
spplabel prints labels: for each code given, the code drawn in the
chosen kind, with its text written in clear under it, all on pages
of SVG, A4 by default, ready to print and cut. It is written
(0.29.0). The codes are drawn by libzint, the library of Zint (BSD
licence; libzint-dev on Debian, graphics/zint on FreeBSD), and
the program is built only where the library is found
(./configure --with-zint=auto|yes|no|DIR), as the pages are
translated only where gettext is found. Where the library is in a
place the compiler does not search (FreeBSD’s ports put it in
/usr/local, and Zint’s package ships no .pc file: the report
of a user), configure tries the compiler’s own places, then
/usr/local and /opt/local, with -isystem and -L and an rpath;
--with-zint=DIR names the prefix and tries that alone. Zint 2.15
and 2.16 have the functions and the fields used. It is a command run by hand,
at the printer’s side: it reads no database, starts no service and
needs no broker; of the configuration file it reads the two prefixes,
[locations] prefix= and [items] prefix=, and nothing else. The
label of a location holds the path that the author chooses, and the
database learns the location at its first scan (Where the locations are).
Table 11 gives the options. Those marked “labels” are the
ones that the labels of our own add (Labels of our own); the others are the
location labels, the ones that the author asked for first.
The layout is the program’s own and the encoder is Zint’s: Labels
(in the common library) makes the pages from rectangles, whatever
draws them, and Barcode (with spplabel alone) asks Zint for the
rectangles of a code. So LabelsTest tries the layout with an
encoder of its own, and only BarcodeTest needs the library. A
label is the code, with a quiet zone of four modules (at least two
millimetres) round it, and under it a heading in bold (the last
part of a path; the number of an item; the content of a code given
by -c) and, smaller, up to three lines of detail (the places that
contain the location, separated by a slash; the text of --text).
A text that is too long for its place is shortened with an ellipsis
and the program says so on its standard error; the code, which
holds all of it, never is. With no grid asked for, a label is 45 by
about 36 millimetres for a code of 20 millimetres, so that an A4 page
holds 28 of them, four across and seven down.
| Option | Meaning | Default |
|---|---|---|
-l, --location PATH |
a location label: the code holds the location prefix and PATH in its normal form, as LOC:salon/bookcase 2/shelf 4; the last part is written large, the places that contain it under it; given once for each label |
(none) |
-c, --code CONTENT |
a code holding CONTENT as it is, CONTENT in clear; a location label made so must hold the prefix itself | (none) |
--item N (labels) |
the label of item N: ITM:N-c (c the check digit), the number in clear |
(none) |
--items FROM-TO (labels) |
the labels of a range of items, as 100-140, at most 5000 |
(none) |
--text LINE (labels) |
a line in clear under the label given just before it, as a short author and title, so that a label that has come unstuck is still known; not after --items |
(none) |
-k, --kind KIND |
the kind of code: qr, datamatrix, aztec (two-dimensional), code128 (one-dimensional, for a narrow edge, ASCII and Latin letters only); the four are read by the scan devices and hold letters |
qr |
-e, --ecc LEVEL |
the error correction of a QR or Aztec code, L, M, Q or H (Table 12); Q for a label that will be rubbed |
M |
-s, --size MM |
the side of a two-dimensional code, or the height of a Code 128, in millimetres, 5 to 150 | 20 |
-p, --page SIZE |
A4 or Letter |
A4 |
--columns N, --rows N |
the grid of the sheet, to match a sheet of adhesive labels: the labels are the cells of the page inside the margin | as many as fit |
--skip N |
leave the first N places blank, for a sheet already partly used; less than the places of a page | 0 |
--margin MM |
the margin of the page | 10 |
--no-frame |
no dashed line round each label | a frame |
--plain |
the code alone, black shapes on nothing, one SVG file of exactly the size asked for each label, for a slicer or a laser cutter | off |
-o, --output FILE |
the SVG file; with several pages, FILE-1.svg, FILE-2.svg and so on | standard output |
--config FILE |
the configuration file, for the prefixes [locations] prefix= and [items] prefix=; a file asked for that cannot be read is an error, the usual one that cannot is a notice |
the usual |
--location-prefix P, --item-prefix P |
the prefixes, which win over the file; they must not begin alike | LOC:, ITM: |
-h, --help, --version |
as every program of the package; the version also says that of libzint |
Listing 1 gives examples: two location labels, a
location label as a Code 128 for a narrow edge, a label with the
labels of our own, a range on a sheet already partly used, and the
code of an item alone, for a plate. Two options would read the
database, read-only, and are left for later if they are wanted:
--from-database (the text under an item’s label taken from the
catalogue instead of --text) and --all-locations (a label for each
location that the catalogue knows).
$ spplabel -l 'salon/bookcase 2/shelf 4' -l 'salon/bookcase 2/shelf 5' -o shelves.svg $ spplabel -k code128 -c 'LOC:CAVE 1' $ spplabel --item 1234 --text 'Kabos: Az érdekes Ujság Décameronja' $ spplabel --items 100-140 --skip 6 -o items.svg $ spplabel --item 1234 --plain -s 20 -o plate.svg
The SVG is in millimetres, on a white page, with the text in a sans-serif font that the viewer chooses (the widths that decide where a line is broken are estimated, so a viewer with a wider font may crowd a text; the code is never affected). It was tried by decoding what it draws: a sheet of 28 labels rendered at 300 dots to the inch, and the codes of the four kinds, with accents, with ideographs and at the four levels, read back by two decoders (zbar and zxing-cpp), and codes of 10, 12 and 15 millimetres (Making spplabel).
13. Labels of our own
Designed on the author’s questions. He chose its first question first: the label holds the prefix and the number of the item (option A, the options); he then took the other three as proposed (the journal): a scan of a label shows the item and puts it at the current location of the device; the items that get a label are those that have no barcode; under the code, the number and an optional short author and title. Two stages are built (0.29.0 and 0.30.0); the inventory pages are the third.
13.1. The problem
Most items have a barcode, and the barcode is that of the edition: the scan makes a copy of it. Some items have none, or none that says anything: an old book, a booklet, a copy whose barcode was cut off. Such an item is entered by hand (a record typed, or an ISBN typed) and gets a number and the location of the scan device, and that is all; it has no mark by which a later scan can find it. So it cannot be put somewhere else by scanning, nor counted in an inventory. And two copies of one edition, even with a barcode, cannot be told apart by it. A label of the catalogue’s own, as libraries put on their books, gives the item what the edition’s barcode cannot: a code that is the item’s alone.
13.2. The code
Chosen (option A of the decision): ITM:, the number of the item
in decimal, a hyphen and a check digit, as in ITM:1234-4. The
prefix is the same device as LOC: of the locations: a letter and a
colon cannot begin an ISBN or a product code, so that a label is
never taken for a book, nor a book for a label; and ITM: is not
the start of LOC:, nor the reverse (checked at start). It is
configurable ([items] prefix=, beside [locations] prefix=). The
check digit (Luhn, as in EAN; Verhoeff, which finds every swap of two
neighbouring digits, is the alternative, a detail of the work)
finds a misread or a mistyped number, which the gateway then
answers as it answers an ISBN with a wrong digit, “scan again”, and
which nothing writes. The number is the item’s own, item.id, so
that nothing is added to the database.
The other forms were weighed in the decision: an EAN-13 of the range 20 to
29, which the stores keep for their own use, would be read by any
scanner but says nothing by itself and may be a product; a serial
printed in advance and bound to an item at its first scan lets one
print a sheet at once and stick as one goes, at the price of a
binding step and a column item.label (a change of the schema, not made). The
check digit is Luhn’s, with the rightmost digit of the number
doubled; Code::checkItemLabel says whether a text read is not a
label, a label misread (a hyphen where it should not be, a letter, a
leading zero, a wrong digit) or good, and it was tried by changing
each digit of a number in every other way, which it always finds.
13.3. Two ways to tie a label to an entry
The author put the two ways that there are.
- The key. The item is entered in the database first, by hand, by its ISBN or by a search in the sources; a label is then printed that holds what ties the item to its entry, its number. A scan of the label is a scan of that key.
- The content. A label is printed that holds all that the database needs to create the entry (an author, a title, a year, a publisher); a scan of it carries that content to the worker, directly or through the broker, and the entry is created from it.
The first is the proposal, and the reasons are these. The second
needs the same work as the first (the data must be typed somewhere
to be printed, and the catalogue is the place where they are
checked, completed by a source and found again), and gives it up
again: the label holds a copy of the entry, which a correction in the
catalogue leaves as it was; the code is large (the Kabos book of the
author’s shelves, with the publisher, is about 90 bytes, a code of
41 modules at level M, Table 12, against 21 for a number);
and, above all, a scan of the label of an item already in the
catalogue (to move it, to count it) would create a second entry,
since the label does not say which entry it is. To say it, it would
have to hold the key as well, and it is then the first way with
more on it. Its merits are real: a label can be printed before the
database exists, by another machine, and survives the loss of the
database. The first way has the second merit too if a line of clear
text goes under the code (--text): a label that has come unstuck is
still a book that can be named, and a lost database is rebuilt from
the labels read by eye.
The path of the second way exists, all the same. A scan reaches the
gateway as a code, the gateway publishes it, the worker classifies
it; a code of a kind that carries a description, handed to the
entry module’s own kind record, would create the entry. It is kept
as a possible exchange format (a book passed on with its label, a
description copied from another system), to be built if a use comes,
and is not the way to label one’s own shelves. The decision lists it
as option D.
13.4. What a QR code can hold
A QR code (Denso Wave, 1994; ISO/IEC 18004) is a square of black and
white modules. Its version, from 1 to 40, fixes the side:
17 + 4 × version modules (21 for version 1, 177 for version 40),
with a quiet margin of four white modules around it. Part of the
square is fixed (the three big squares at the corners that tell the
reader where the code is and how it is turned, the timing lines, the
alignment patterns); the rest holds the data and, with them, the
code of Reed and Solomon that lets a reader rebuild what is dirty,
torn or hidden. The level of that correction is chosen: L restores
about 7 % of the code words, M 15 %, Q 25 %, H 30 %; the higher the
level, the less room for data. The data are written in one of four
modes, and the reader chooses, for each part of the text, the
densest one that holds it: numeric (digits only, about 3.3 bits a
digit), alphanumeric (the digits, the capitals A to Z, and the nine
signs space, $ % * + - . / :, 5.5 bits a character), byte (any
byte, in practice the UTF-8 of the text, 8 bits; a letter with an
accent takes two) and kanji. A single lowercase letter takes the
whole text to the byte mode, which is why the prefixes are in
capitals. Table 12 gives the capacities, by Denso’s table.
| Version | Modules | Alphanumeric (M) | Bytes L | Bytes M | Bytes Q | Bytes H |
|---|---|---|---|---|---|---|
| 1 | 21 | 20 | 17 | 14 | 11 | 7 |
| 2 | 25 | 38 | 32 | 26 | 20 | 14 |
| 3 | 29 | 61 | 53 | 42 | 32 | 24 |
| 5 | 37 | 122 | 106 | 84 | 60 | 44 |
| 6 | 41 | 154 | 134 | 106 | 74 | 58 |
| 10 | 57 | 311 | 271 | 213 | 151 | 119 |
| 40 | 177 | 3391 | 2953 | 2331 | 1663 | 1273 |
Numeric digits at level L go from 41 (version 1) to 7089 (version
40); in the alphanumeric mode, level L, from 25 to 4296. For the
labels: ITM:1234-4 is ten characters of the alphanumeric set, so
it is a version 1 code of 21 modules, even at level H (ten
alphanumeric characters are its capacity). LOC:SALON B3 is also
version 1 at levels L to M; with a lowercase letter, LOC:Salon B3
is twelve bytes, version 1 at level M. A location name of thirty
characters is version 3. What limits a label is the size of its
module and not the standard: a phone held at the length of a hand
reads a module of half a millimetre, so a code of 20 mm is
comfortable up to about 40 modules (version 6, about a hundred
bytes at level M) and a code of version 1 has a module of a
millimetre. The standard allows nearly three thousand bytes; no
label of the shelves needs a tenth of that.
13.5. Where the locations are
In the database, not in the configuration file. The table
location (id, name, unique, created) holds the names. The
name is what follows the prefix of the label: the path
salon/bookcase 2/shelf 4 for the label of LOC:salon/bookcase 2/shelf 4. Three
columns refer to it: device.location, the current location of each
scan device, which a location label changes; item.location, where
the item was when it was entered; and input.location, the location
of the device when each input arrived, which keeps the history. The
configuration file holds only the prefix ([locations] prefix=,
LOC: by default), because the gateway and spplabel must agree on
what a location label looks like. So spplabel needs no database
for a location label.
Who creates a location? The author, on purpose (option C of the
author’s decision, the journal). A place exists once a row of
location is made for it, by an order of kind place from the page
Places of the entry module (or by the import of a history); making
a place makes its ancestors, salon/bookcase 2 and salon for
salon/bookcase 2/shelf 4. Nothing makes a place implicitly: a scan
of a label whose place is not made is refused (unknown, and the
phone sounds its error), and so is a location typed for a device, so
that a misspelt label is found when it is stuck. The same page renames
a place (the paths below it change with it, in one transaction) and
merges one into another (the items, the devices and the history
move, the old row goes); these two are what a misspelling needs.
Printing a label creates nothing, and a label cannot be used before
its place is made. This is also the bootstrap condition the author
named: a device has no current location until one is chosen, in
Location or by scanning a label of a made place, and an item
catalogued before that has none.
Three columns refer to the table: device.location is the
current location of the device, which the scans use; item.location
the place of the item; input.location the place in force at the
time of the input, or the item’s own place when it was typed with
one. The entry module offers the places made to choose from
(Catalogue::places) and shows them with their counts.
A location is a path, from the largest place to the smallest, the
parts separated by a slash, as the author put it: a room has several
bookcases, each many shelves, LOC:room/bookcase/shelf. Apart from
the prefix the content is free (the only character that has a
meaning is the slash). The normal form, which Code gives and
everything uses, has no empty part, nothing round a part and one
space where there were several inside it, so that a label written
LOC:salon / bookcase 2/shelf 4 is the place of
LOC:salon/bookcase 2/shelf 4.
Capitals count: Salon and salon are two places, as before.
The hierarchy is read from the names and stored nowhere: the parent
of a path is the path up to its last slash, so that no table and no
column is added for the hierarchy, and a place is said to be
within another when its path is that other or begins with it and a
slash. Items are at the exact place scanned; a search for a place
finds the copies there and below it (salon finds the shelves of
its bookcases, not salonette), and the counts of the catalogue
list every place that contains one, with the number of copies at
or below it. Renaming a room is a change of every name that
begins with it, which Catalogue::movePlace does in one transaction.
What a path costs on a label: LOC:salon/bookcase 2/shelf 4 is 28
bytes (a lowercase letter takes the code to the byte mode), a code of
29 modules at level M, so that a module of 20 mm is 0.7 mm
(Table 12); the same in capitals is alphanumeric and 25 modules.
Three levels of a few words each stay far below the 40 modules that
a phone reads comfortably at 20 mm.
13.6. What a scan of a label does
The gateway recognises the prefix as it recognises the location
prefix, and publishes an input of kind label (Table 4) with
the number; it is not sent to the sources. The worker records it as
every input, and then:
- the item exists: the verdict is
labelledand says the item (title, authors, place), the phone shows it; the item takes the current location of the scan device, if it has one, as an item that is scanned takes it (the verdict says “put at X (was at Y)”, “already at X”, or “no location is current” when the device has none). Scanned again at the same place, it changes nothing; the history of where it was is the inputs, each of which keeps the item and the location of the device, so that no table is added; - the item does not exist:
unknown, and the scan device sounds its error (a label stuck on the wrong thing, a database restored from an older copy). The item page lists the places where its label was scanned (Catalogue::visits: the time, the device, the place), read from the history.
The sentence that the phone shows is one more of the catalogue of the gateway’s, in the language of the pages.
13.7. Making the labels
spplabel draws the codes, with the text in clear (Table 11).
--item, or --items with a range such as 100-140, draws
ITM:N-c for a number N, with the number in clear under it;
--text adds the line that the author wants under the number (a
short author and title). The page of an item shows its number, its
label code and the command to run, with the author and the title
already quoted for the shell. No database is read by spplabel
and no library is added to the entry module (libzint stays with
spplabel alone).
The size: a QR code of 20 mm (the default of spplabel) is read by
a phone at a hand’s length; Code 128 is the choice for a spine or a
narrow edge. For an old book, the place of the label is the author’s
concern and the library’s practice a guide: not on the paper, which
an adhesive harms, but on a slip put in the book, or inside the
cover; spplabel only draws the sheet. A slip of card is the usual
one; the author’s own idea is a thin plate of PETG of 0.6 mm with
the code printed on it in two colours by a multicolour printer
(a Prusa XL, a CoreOne INDX), which --plain serves: the SVG of a
code of exactly the size asked, black shapes without a margin, which
a slicer extrudes, the quiet zone being the plate’s own (at least
four modules of white round the code, so a plate of 28 mm for a code
of 20). What the question of acid comes to is in the research journal
(PETG in a book).
13.8. The stages
Each is a delivery, in this order, after the decision.
- Done in 0.29.0:
spplabelitself, with the options of Table 11 (the location labels,--item,--itemsand--text); the label code inCode(made, read and checked); the page of an item with its number and the command. Nothing changes for the gateway and the worker. - Done in 0.30.0: the gateway and the worker: the kind
label, the verdictslabelledandunknown, the move; the page of an item shows the places where its label was scanned. - The inventory: for a location and a date, the items there that a label scan has seen since and those it has not. An item with a barcode that has no label cannot be seen by an inventory, since its scan would make a copy: so the inventory sees the items with a label, which are those that have no barcode (the decision’s second question); an item with a barcode is counted by the catalogue, not by the inventory.
The tests follow the existing ones: CodeTest for the code and its
check digit (written), LabelsTest and BarcodeTest for the
sheets (written), GatewayTest for the kind and the refused misread,
WorkerTest for the move and unknown, EntryTest for the pages.
14. Removing
Designed and built in 0.31.0, on the author’s question: a book is donated, or was catalogued by mistake, and the catalogue must let go of it; the same for places and perhaps other things.
14.1. Options
- Delete the row. Simple, and wrong here: the inputs that made the
copy name it (
input.item), the history of the scans and of the labels with it, and the label of a donated book may still be stuck on a book that comes back. A deletion cannot be undone, and a mistaken removal (a tap on the wrong copy) is easy. - Mark the copy removed, with the time and the reason, and leave it where it is (chosen). Everything that counts or lists copies leaves it out; nothing is lost; a copy put back is as it was. The reason is kept because it is the one thing that tells later why a book is not on the shelf: donated, sold, lost, entered by mistake, other (keywords, said in the language of the page).
- A second table of withdrawn copies. The same, with a move of the row and of what points to it: more code for no use.
14.2. What a removal is
An input of kind remove with the number of the copy in code and
the reason, made by the form on the page of the copy; the worker alone
writes (Catalogue::removeItem). The copy keeps its row, its edition,
its place and its history; it is out of the view catalogue, of the
counts by place and by medium, of the number of copies of an edition
(so that copies=ask does not ask about a copy that is gone), of the
searches and of the CSV file. Its edition stays: when the last copy
goes, the edition remains, and a scan of its barcode finds it and
catalogues a first copy again without asking a source. A restore
(the form on the page of the copy, reached from /removed) puts it
back. The label of a removed copy, scanned, is answered unknown with
the words “was removed (reason) on date”, and moves nothing. A removal
of a copy that is removed already, or a restore of one that is not, is
unrecognized; a copy that does not exist is unknown.
A correct replaces the description of the edition of an item
(Catalogue::correctEdition, one transaction): medium, title,
subtitle, publisher, place, year, languages, extent and series; the
contributors and subjects are written again (writeParts, shared with
insertEdition, which keeps the sort name of a name that stays and
completes it from the record) and the contributors left with no edition are
deleted; the row of the full-text index is made again. The
ISBN is replaced only if the origin of the description is by hand:
for a description from a source it is the key by which the book was
found, with the product number and the ISSN. The source_record rows
are not touched. The verdict is corrected, with the number of
copies, and its input (the latest of the kind) gives the date shown
on the page of the item (Description::mCorrected). An item that
does not exist is unknown, a record with no title unrecognized.
The form is the form of the description by hand, filled
(Entry::recordForm with a record), so that there is one form for
both.
14.3. Places, editions, devices
A place made by mistake is deleted (Locations); one with a history is
merged. An edition has no removal: it is a description, and it is out
of every list as soon as it has no copy. A scan device cannot be
removed, since every one has the inputs it sent; if the list of
devices in the forms grows with names typed by mistake, the remedy is
to hide the devices with no input for a long time, which is not built
(FutureResearch.org). The inputs themselves are never removed: they
are the history, and an input set aside is dismissed, not deleted.
A purge, the real deletion of a copy that was catalogued by mistake
and that no history should keep, is not offered. The reason mistake
hides the copy from every list; if a real deletion is wanted it can be
added for a copy removed with that reason, the inputs of the copy
being made anonymous rather than deleted.
The moving of a copy by hand is the same label input that a scan of
its label makes, with a place; the worker records the place in
input.location and puts the copy there.
15. The database
15.1. The files of the database
A database in WAL mode is a main file, catalogue.db, and two files
beside it that SQLite makes when a connection opens it: -wal, the log
to which the writer appends what it commits, and -shm, the index of that
log in shared memory, which the connections map. Readers read the main
file and the log through the index. The log is moved into the main
file by a checkpoint: SQLite does it by itself when the log reaches
1000 pages (4 MB), and when the last connection closes, which also
deletes the two files. A catalogue of some hundreds of books
never reaches 1000 pages, so that everything it holds is in the log
until a connection closes: the main file may be of a single page for
weeks, and a copy of it alone is empty.
- The worker closes with a checkpoint
Catalogue::close, for a connection that writes, runsPRAGMA wal_checkpoint(TRUNCATE)before closing it: the whole log goes into the main file and is emptied, even while another program holds the database open. After the stop of the worker the main file alone is the catalogue.- What stays on the disk
- the last connection to close deletes the
two files only if it can checkpoint, which a read-only connection
(the entry module,
spplist) cannot. So if the entry module is stopped after the worker, the two files stay (the log empty, the index of 32 KB); they are harmless and the next opener reuses them. Stopping the entry module before the worker leaves the main file alone. A worker killed leaves a log that is full, and the next opener recovers it. - Who reads
- a connection that is only a reader needs to read the
log and the index, and to map the index; if it may not create them
(the directory is closed to its account), SQLite can still read
them if they are there and can be read, and refuses if they are
not (
attempt to write a readonly database). So a directory that every account may enter (mode 0755), and files that every account may read (0644), let any account runspplistwhile a service has the database open. When no program has it open the two files are not there, and an account that cannot create them cannot read the file:Catalogue::openReadOnlythen opens the main file alone with the SQLite optionimmutable, which ignores the log and the locks. That is sound because no log file exists (or it is empty): the main file is the whole catalogue. If a log with content exists and cannot be read, the error says how to run the program as the account of the worker. A worker that starts whilespplistreads cannot spoil the reading: the main file changes only at a checkpoint, which the worker does at 4 MB of log or when it stops. - A copy by an ordinary account
sqlite3 file .backup copyopens the file for writing, as SQLite does for every connection (the option-readonlychanges nothing for a database in WAL mode), so that it fails withattempt to write a readonly databasewhereopenReadOnlywould use the optionimmutable; the output file is made before the failure, hence an empty copy. The copy of the main file is right only if no log with content exists:cpand the optionimmutableboth ignore the log, and a log with content (the worker running, or killed) means a catalogue that is old, without any error. The Manual gives the cases (Backing up the catalogue); the safe recipe for every case is the command under the account of the worker. A tool that decides by itself, asopenReadOnlydoes, is possible and not made.- The modes
- the database directory is 0755 and the files 0644 (the unit sets the mode of the state directory to 0755 and the umask to 0022; the rc script inherits the umask 022). The catalogue is not a secret from the local network, since the entry module shows it to anyone who can reach it; it is not one from the accounts of the machine either. The directory of the database is open to enter, not to write: the account that cannot write in it cannot change the catalogue.
15.2. Principles
One SQLite file, written by the worker alone, read by the entry
module and the web front. It runs in WAL mode, where readers do not
block the writer; the readers open it read-only. Its version is the
user_version of SQLite, and the worker upgrades an older file at
start. Version 2 (0.17.0) adds the column input.edition, version 3
(0.20.0) the table candidate, version 4 (0.30.0) the ancestors of the
places already stored (a place is made, with its parents), version 5
(0.31.0) the columns item.removed and item.removal and the filter
of the view catalogue, version 6 (0.44.0) the column
contributor.sort_name; a file of an older version is
upgraded by the worker, all its steps in one transaction that is
undone as a whole if one fails, and the entry module, which opens the file
read-only and cannot upgrade it, refuses it and says to start the
worker first.
15.3. Tables
Listing 2 is the schema; an edition is a description shared by the copies of one publication, an item one copy.
CREATE TABLE device (
id TEXT PRIMARY KEY, -- device identifier
location INTEGER REFERENCES location(id),
updated TEXT NOT NULL);
CREATE TABLE location (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- text of the label or entry
created TEXT NOT NULL);
CREATE TABLE input (
id TEXT PRIMARY KEY, -- the UUID of the message
seq INTEGER NOT NULL UNIQUE, -- order of arrival
device TEXT NOT NULL REFERENCES device(id),
kind TEXT NOT NULL,
origin TEXT NOT NULL,
received TEXT NOT NULL,
code TEXT,
symbology TEXT,
addon TEXT,
device_time TEXT, -- the scan device's own time
class TEXT, -- from the classification
normalized TEXT, -- ISBN-13 or GTIN-13
location INTEGER REFERENCES location(id),
completes TEXT, -- the input it completes
repeats TEXT, -- the input it repeats
status TEXT NOT NULL,
detail TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
retry_at TEXT, -- UTC, ISO 8601
first_try TEXT, -- UTC, ISO 8601
item INTEGER,
edition INTEGER REFERENCES edition(id), -- version 2: the edition of a scan held as another copy
message TEXT NOT NULL); -- the input, as received
CREATE TABLE edition (
id INTEGER PRIMARY KEY,
medium TEXT NOT NULL, -- book, sound, video, other
title TEXT NOT NULL,
subtitle TEXT,
publisher TEXT,
place TEXT,
year TEXT,
languages TEXT,
extent TEXT,
series TEXT,
isbn13 TEXT,
gtin TEXT,
issn TEXT,
origin TEXT NOT NULL, -- resolved, copied, by hand
created TEXT NOT NULL,
updated TEXT NOT NULL);
CREATE TABLE contributor (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
sort_name TEXT); -- "Family, Given" where a source said it
CREATE TABLE edition_contributor (
edition INTEGER NOT NULL REFERENCES edition(id),
contributor INTEGER NOT NULL REFERENCES contributor(id),
role TEXT,
position INTEGER NOT NULL,
PRIMARY KEY (edition, position));
CREATE TABLE subject (
edition INTEGER NOT NULL REFERENCES edition(id),
subject TEXT NOT NULL);
CREATE TABLE item (
id INTEGER PRIMARY KEY,
edition INTEGER NOT NULL REFERENCES edition(id),
location INTEGER REFERENCES location(id),
input TEXT REFERENCES input(id), -- the input that made it
created TEXT NOT NULL,
removed TEXT, -- version 5: when it was removed
removal TEXT); -- and why: donated, sold, lost, mistake, other
CREATE TABLE candidate ( -- version 3
input TEXT NOT NULL REFERENCES input(id),
position INTEGER NOT NULL, -- the order the source gave
record TEXT NOT NULL, -- the Record, as JSON
PRIMARY KEY (input, position));
CREATE TABLE source_record (
id INTEGER PRIMARY KEY,
input TEXT NOT NULL REFERENCES input(id),
edition INTEGER REFERENCES edition(id),
source TEXT NOT NULL, -- openlibrary, bnf, ...
request TEXT NOT NULL,
fetched TEXT NOT NULL,
format TEXT NOT NULL, -- json, unimarc, marc21, marc21-xml, dc
body TEXT NOT NULL);
The times are ISO 8601 text (received with its offset; retry_at
and first_try in UTC, compared as text); device_time is the
text of the scan device, as it wrote it, and an index on device
and device_time finds the scans of a history already recorded. The
neighbours of an input are the inputs of the same device just before
and after it in seq. The indexes are on (device, device_time),
(normalized, code), status and, for the retries, retry_at where
the status is unavailable; no foreign key joins completes,
repeats and item, which name rows the worker writes in the order
of the events. The file is opened with foreign keys on, in WAL mode;
a file that is not a catalogue of sapupra, or is of a later version,
is refused and left as it is. The worker needs Qt’s SQLite driver,
with FTS5, and TLS for the sources: configure tries them.
15.4. Full-text index
An FTS5 table, edition_fts, holds for each edition its title,
subtitle, contributors, publisher, series and subjects, as one row
whose rowid is the edition’s. The worker writes it in the same
transaction as the edition; with the contributors and subjects in
other tables, a row written by the program is simpler than triggers.
The pages of the web front do not query it (0.26.0: the field that
searched it, Anywhere, was dropped); it is kept for Datasette,
whose full-text search of a table uses it, and for what may need it
later.
15.5. A view for the web front
A view, catalogue, gives one row per item with its edition, its
contributors as one text, its location and its date of entry; it is
what the pages of the web front query (and what Datasette shows
first, for whoever runs it). It leaves out the copies that were
removed (item.removed is not null), so that nothing that reads it (the
pages, the CSV file, Datasette) counts or lists them. The
web front opens the file read-only, but not as immutable: the worker
keeps writing it.
16. The web front
The catalogue is consulted with pages of the entry module: routes that only read (Table 9), served from the same port as the rest, with no script (a form that sends by GET, links for the facets and the pages), so that a phone’s browser is enough. The choice was the author’s, between those pages and Datasette: no program to install beside, one service fewer, the look of the other pages, the lookups of the database in the language of the rest; against that, a few hundred lines to keep, where Datasette had them for nothing. The comparison is in ResearchJournal.org.
The pages read the database through the class Catalogue, as the
other pages do, and the entry module has no SQL of its own:
items (with the number found, for the page) and itemsFound, for a
Catalogue::Query, by title or the latest first; the facets of
locations, media and origins; copies and editionOf for the page
of a copy. A page shows fifty copies; a page beyond the last is the
last, a page that is not a number the first. The CSV file is not
paged (200000 copies at most): it is the file for a spreadsheet, the
fields quoted, the line ends CR LF. Every field that comes from the
catalogue is escaped for HTML.
A field for each attribute (0.22.0). The search is a
Catalogue::Query: a pattern for each of the
attributes patternAttributes() lists (title, subtitle, author,
translator, other, publisher, place, language, series, subject, isbn,
issn, extent), a location, a medium, an origin, a year from and to,
a least number of copies and the days between which a copy was
entered. The address of both pages carries every field under its
own name (title, author, …, year_from, year_to,
copies, entered_from, entered_to, origin, medium,
location, page), so that a search can be a bookmark and the
links of the pages keep it whole. The simple form has five inputs
(Title, Author, ISBN or barcode, Location, Medium) and the
link to the advanced one; the advanced form, /catalogue/advanced,
has them all in groups. Both are the same handler: a field that only
the advanced form has, in the address of the simple one, shows the
advanced form, so that nothing typed is hidden. The fields are not
corrected or capitalised by the phone (the attributes of the input
element).
What a search shows (0.23.0). The author found that after
Search the page stayed on the fields, that a search with no hit
could show the last result or the whole catalogue, and asked for a
clear button and the version on every page. So: the form sends to
/catalogue#result, and the page of a search puts the fields in a
details element, folded, before the heading Result (no script),
so that the browser lands on the result; a search that finds nothing
says No hit with what was asked and nothing else; a form sent with
every field empty says so, and only the bare address is the home of
the catalogue (the counts and the latest copies). Reset
(in French Réinitialiser, since 0.35.0; Effacer did not say what was erased)
is a link to the bare page of the form being shown, not a reset
button, which would only restore the values the page was sent with.
Every page of the module is sent with Cache-Control: no-store, since
it shows a database that changes and a browser that keeps the page of
the last search would show it for the next. The foot of Html::page
names the program and its version (PACKAGE_VERSION, the one that
sppentry --version prints).
The Anywhere field (0.22.0 to 0.25.0), words searched in the
full-text index, is gone (0.26.0). The author did not see its use
once each attribute had its own field; the argument for it, one box
for someone who does not know where a word is, did not outweigh the
second way to search that it asked the author to learn, with its own
rules (what a word is, the star, the operators that are not). With
it went Catalogue::ftsQuery, Query::mWords and the parameter q
of /catalogue and /catalogue.csv (the q of the to-do list is
another one). The ISBN field kept the rule that was in the field’s
way: a whole number is an edition, digits alone a beginning.
The order of the answer (0.34.0). The answer is ordered by the title
unless the address says sort= and one of Catalogue::sortKeys()
(title, author, publisher, year, medium, location, entered), and
order=desc for the other way. The headings of the columns of a
result are links that carry the search and the order: a click orders
by that column, a second click on the same column the other way, a
small triangle and aria-sort saying which. The order is made by the
program, not by SQL (NOCASE puts Éluard after Zola): the copies
that satisfy the query are read in the order of their number, and
sorted with QCollator in the language of the pages, capitals
ignored, a number read as a number (shelf 2 before shelf 10); a year
is compared as a number; a value that is empty comes after the
others, in either direction; ties are broken by the title, then by
the number of the copy, so that a page is the same each time. Only
the list of the latest copies, which is by number, is still cut by
the database. The CSV file keeps the order of the search. The form
of a search carries sort and order as hidden fields, so that a
new search keeps the order chosen.
Seven, plus or minus two (0.34.0). A person keeps about seven things
in mind at once (Miller, 1956), and the ergonomics of forms draws
the rule that a screen offers no more than five to nine choices at a
time, the others being grouped or folded. The pages follow it where
it can be done without hiding what is asked for. The menu has six
entries. The advanced search, which had some twenty fields in a grid,
has four groups of at most nine controls, each in a fieldset: the
work (title, subtitle, author, translator, other contributors,
subject, medium), the edition (publisher, place, language, series,
extent, years from and to), the numbers (ISBN or barcode, ISSN) and
the copies (entered between two days, least number of copies,
location, how it was described). The form of an item entered by hand
has two, the work and the edition. A table of results has six
columns. The places on the home of the catalogue were a flat list
that grows with the shelves (eighty for a room); they are a folded
tree, <details>, which shows the rooms, and the furniture, the rows
and the shelves on demand, each node being a link with its count.
What is not changed: the list of the places on Places (it is a
working list, not a choice), the menu of a phone (six entries
already) and the lists of the to-do page, which are tables to read
and not controls. EntryTest::groupsOfNoMoreThanNine counts the
controls of each group of the pages and fails above nine.
The patterns. SQLite has no such search in its full-text index, nor
a REGEXP function without a function added to the connection,
which the SQL module of Qt does not offer without its driver’s
handle (and a direct link to SQLite); so the database narrows the
copies by the lists, the numbers (the years, the copies,
the days: one SQL expression each) and the program tries the
patterns on what is left (Catalogue::matching): the values of the
editions, with the roles of the contributors and the subjects, are
read in bulk, each pattern compiled once, and a verdict is kept for
each edition so that its copies cost nothing more. The engine is
Qt’s QRegularExpression (PCRE2): for found or not found it answers
as grep -E does, the leftmost-longest rule of POSIX mattering only
for the extent of a match; a backslash inside brackets is an escape,
not a character. A pattern is tried on the values of its attribute
one by one (each contributor, each subject, each language), so that
^ and $ stand at the ends of a value and nothing crosses from
one to the next; an attribute with no value (an edition with no
year) is never found by a pattern, ^$ included. Capitals do not
matter, and the accents are removed from the text and from the
pattern alike (decomposed, the combining marks dropped). The ISBN field is the one with a rule of its
own: digits, hyphens and spaces that make an ISBN or a barcode
are the edition’s exact number, in either form; digits alone that
do not are the beginning of a number; anything else is a pattern as
typed, on the ISBN-13 and on the product number. The year is two
numbers; a first that is not all digits is a pattern on the year
like the other fields and the second is not used (the page says so).
What cannot be asked is reported by Catalogue::problems, by field:
a pattern that does not compile, a year, a number of copies or a day
that is not one. The page then says, naming each field, that nothing
was searched (an error 400 for the CSV file). There is no fallback to
plain text: a search that quietly meant something else would find
something else, and a field that is not valid is the typing of a
moment. The catastrophic patterns of backtracking engines are
possible; the module is single-threaded and for one person’s local
network, so the risk is that of a page that waits.
Speed. The test CatalogueTest::largeCatalogue writes 30000
editions (40000 copies) through a separate connection in one
transaction and times every kind of search; the figures of the run
of 0.22.0 are in the research journal. The searches by numbers (the
years, the copies) are SQL and take some milliseconds; those by
pattern read and test the copies that are left, about a second for
all 40000, and the other criteria (the lists, the numbers) narrow that. A
page asks for its fifty rows and the count together, so that the
patterns are tried once.
The pages were tried on test catalogues (three copies; a hundred and
twenty for the pages) and on catalogues of invented books made by
sppgen, up to 20000 copies (A catalogue to try the programs on): the
author’s own catalogue is still to come. On 20000 copies a list in a
chosen order takes 110 to 240 ms in the database, a pattern on the
author 400 ms and on the title 470 ms, the copies of a place 30 ms,
the counts by place 13 ms. The sorting by the alphabet of the language
is done once on the whole answer, not on a page. A page of a copy that does not exist, or whose number
is not a number, is a 404; a form sent by POST to a catalogue route
is a 404 too, so that nothing there writes.
Datasette, which was tried before (0.65.5 on the test fixtures, then
on a sample of 33 items, 4 media and 8 locations), remains possible
and is outside the build; its metadata is the sample file
(datasette.yml.sample: the items, the editions with their search
box, a stored question search and one for the scans to do), the Manual says how to start
it (Manual.org). It runs under the account sapupra (the
read-only connection of a database in WAL mode uses the -shm file of
the worker, The files of the database), has no password and offers a select to anyone who can
reach it. Nothing in sapupra depends on it, and it is not a
service.
17. The printed lists
A list on paper, or a file to give, is wanted from time to time: the
copies sorted by author, by title and by location. spplist is a
command like spplabel (Location labels): run by hand, it reads the
database (read-only, through Catalogue::listing, which does not need
the service to stop) and writes an Org document, which Org exports to
PDF or HTML like the other documents of the tree. Three tables,
each of the copies that were not removed, chosen with -t (one,
two or all): by author (author, title, location), by title (title,
author, location), by location (location, author, title).
- The authors
- the contributors whose role is author or is not given; for a work with none (a disc), those who are not translators. In the table by author a copy is under each of its authors, and one with no author comes last; in the others the authors are together.
- The names
- a name is written and sorted as Surname, Forename.
Where a source said how the name is filed (
contributor.sort_name: the MARC records, the BnF and MusicBrainz say it; a body is filed as it is written) that is used: García Márquez, Gabriel, Pink Floyd. Otherwise the ruleListing::inverted: the last word is the surname, with the particles before it (de, van, von, …); a name with a comma is left as it is, and so is one word. That rule is a heuristic, wrong for a name of the Far East or a body that no source filed (Open Library does not).--as-writtenturns both off, and then the sort is by forename. A name that a source filed is not corrected by a later one that did not; one that a source did not file is completed by a later one that does. - The order
QCollatorin the language of the pages, capitals ignored, numbers as numbers; an empty first column comes last. The article that starts a title is set aside for the order only, by the language of the edition (Listing::filing): the, a, an in English; le, la, les, l’, un, une, des in French; der, die, das, ein, eine in German; il, lo, la, i, gli, le, l’, un, uno, una in Italian; el, la, los, las, un, una in Spanish; a, az, egy in Hungarian. The first language of the edition that the program knows decides; where there is none, or it has no table, nothing is set aside. The title is written in full.- The document
- a header that Org understands (title, date,
language, no table of contents, LuaLaTeX, A4, margins of 18 mm),
a heading and a line for each table, a
longtableso that the tables run over the pages, the widths of the columns fixed to fit the page. The cell is escaped: the bar becomes\vert{}, and a marker of emphasis that opens a word is preceded by\zwnj{}, an entity that prints nothing and that Org does not take for the start of an emphasis (a title like*Foo*stays as typed). The headings are in the language of the configuration file or of--language(the sentences are translated like the others,Listing.cppbeing inTRANSLATED_FILES). - The tests
ListingTest(the names, the cells, the three tables on a small catalogue, the order, one or two tables, the French headings);GeneratorTest::listedfeeds the listing with a generated catalogue.
18. A catalogue to try the programs on
The searches, the pages and the lists have been tried on catalogues
of a few hundred copies; the catalogue of the author will have some
thousands. sppgen makes one: n copies of invented editions, in a
tree of places, entered through the calls of the worker (a record
input with its place, Catalogue::catalogue), so that the database is
the one the programs read. It is a tool of the developer: built, not
installed, and it refuses a file that exists, so that it cannot
overwrite a real catalogue. It says where it is every hundredth of the
copies (not more often than every thousand; -p N sets it), with the
rate and the time left, as a million copies take hours.
The debug log of the clients. The transport, not each program, writes
it, so that the gateway, the entry module and the consultation have the
same lines (HttpServer, at the level debug): a line “connection from
ADDRESS” when a client connects, a line “ADDRESS METHOD PATH -> STATUS”
when the answer is sent, refused for what the server turns away (too
many connections, a request not received in time or malformed) and
“the client closed the connection before the answer” when it leaves.
The query of the address and the body are not written: they may hold
what was typed or scanned. An IPv4 address that came in on a
dual-stack socket is written as an IPv4 address.
- The data
- titles in English, French and Hungarian drawn from
word lists, with accents (a sort that puts
Éclairafterzebrais found at once); authors from a pool a quarter the size of the catalogue, some of them with a particle (Jean de Valmont) so that the inversion of the names is exercised; a translator for one book in ten; publishers, places, years from 1850 to 2026, languages, extents, series, subjects; 88 % books, the rest sound, video and other, with performers for the discs. - The numbers
- an ISBN-13 in the range 979-10 with the right check digit, drawn until it is new, so that none is the ISBN of a book; the barcode of a disc is such a number too.
- The places
-rrooms (up to eight: salon, bureau, chambre, …), each with two to four pieces of furniture, each with two to four rows of three to five shelves; a shelf is written assalon/caisson gauche/rayon 1/étagère 1. That makes twelve to eighty shelves for each room, made with their parents; a copy is put on a shelf, some shelves holding more than others, and 3 % are left without a place. 4 % of the copies are another copy of an edition entered before, so that editions with several copies exist.- The seed
- the same seed, the same catalogue (
-s); the dates of entry are those of the run. - The tests
GeneratorTest(the check digit, the catalogue and its numbers, the same seed twice, the listing of it).
The figures of the run on a catalogue of thousands, and what they changed, are in the research journal.
19. Configuration
One file, read by the programs, each its own section and the
common ones, in an INI form that the library reads itself (Config):
a value is taken as it is written, from after the = to the end of
the line, without the spaces around it, so that a password may hold
any character; a comment is a line of its own. (Qt’s QSettings,
first used, changes a value at a semicolon, a backslash, a double
quote or a comma: ResearchJournal.org.) Each program
gives the broker the name of its account as its client identifier,
so that the broker’s log names the account. Listing 3 is
an example. The file is read by every account (owner root, mode
0644), because the commands that people run, spplist and
spplabel, read the database, the language and the prefixes in it.
What is secret, the broker’s passwords and the tokens of the
sources, is not in it but in a second file beside it, with the same
syntax and the same sections, sapupra.secret.ini (owner root, group
sapupra, mode 0640), which only the services read and whose values
win (The secrets). A program is given another file by --config,
and its secrets are those of the file of that name with .secret
before the .ini. Every value of the example can be changed there: the
addresses and ports, the wait for a verdict, the delay before an
input without receipt is published again, the repetition window, the
location prefix and the item prefix (shared by the gateway, the entry module and spplabel), the level of
each program’s log (log_level: quiet, normal or debug, which -q
and -d of the command line override, so that a service is run at
its level and a run by hand asks for the debugging messages), the
order of
the sources. In this version sppwork knows the sources
openlibrary, bnf and musicbrainz, and refuses a name it does not
know, so that a misspelling is found at start. The example below
names oszk and discogs, which are not written yet: sppwork 0.13.0
refuses them.
[broker] host=192.168.1.10 port=1883 [database] file=/var/db/sapupra/catalogue.db [gateway] listen=192.168.1.10:8080 user=gateway wait=3 receipt_timeout=30 [interface] language=fr [locations] prefix=LOC: [items] prefix=ITM: [worker] user=worker repeat_window=10 contact=someone@example.org [entry] listen=192.168.1.10:8081 user=entry receipt_timeout=30 max_upload=4 default_device=entry [sources] isbn.978-2=bnf,openlibrary isbn.978-963=oszk,openlibrary isbn.978-3=dnb,openlibrary,bnf isbn.default=openlibrary,bnf gtin=musicbrainz,discogs,bnf search=openlibrary,bnf,dnb,oszk
[gateway] password=... [worker] password=... [entry] password=...
19.1. The secrets
The services (sppgate, sppwork, sppentry) read the file and
then the file of the secrets if there is one (Listing 4); sppconsult
has no secret and reads the first alone; a
value of the second wins, and a password left in the first still
works, so that an installation of an earlier version goes on
without a change. What a person runs (spplist, spplabel) reads the first
file alone. The reason is the access: the first file must be open to
every account, and a password must not be. Once the second file
exists, a service that finds a password or a token in a file that
every account can read says so in its log
(Config::notices), and so does it for a file of the secrets that is
open to all. The pairing of the names is by the name of the file, not by
a key, so that a file cannot send a service to read another one.
Only passwords and tokens are secrets: a key called password or
whose name ends in token.
20. Code layout
The tree stays flat. The common library, libsppcommon.a, built and
not installed, holds Config, Log (standard error or syslog,
quiet and debug; the debugging lines of the standard error begin
with the time; the normal messages go to syslog at the level
notice, which FreeBSD’s default configuration keeps), MqttClient (libmosquitto driven by Qt’s event
loop through QSocketNotifier on its socket), HttpServer (see
HTTP), Message (the inputs, receipts and verdicts, to and from
JSON), Code (validation and classification, ISBN-10 to ISBN-13),
SignalWatcher (the stop signals of a service, turned into Qt
signals through a pipe), Record (the normalized description of an
edition, to and from JSON), and what the entry module needs that has
no database in it: HttpForm (the fields of a form, urlencoded or
multipart), Html (escaping, the frame of a page), InputPublisher
(publishes inputs and follows their receipts and verdicts; the gateway and the entry module share it) and
HistoryImport (the history of Binary Eye, read and planned) and
Translation (the sentences of the pages in the language of the
configuration, The language of the pages) and Labels (the pages of labels
as SVG, Location labels; Vocabulary too, the words of the data in
the language of the page). Each
program has its classes (Gateway;
Worker, Catalogue for the database, Resolver with a class per
source: HttpResolver, the common part of the sources reached by
HTTP, and under it OpenLibraryResolver, BnfResolver and
MusicBrainzResolver, and Z3950Resolver, with Marc for the records
of the libraries, when YAZ is there; Entry), those of the worker
in a library of their own, libsppwork.a, which the program and its
tests share, and its main (sppgate.cpp for sppgate), which reads
its options with getopt_long(3): those of the standard (--help,
--version, --debug, --quiet), --config, and, for a service,
--syslog. Each has a manual page: section 8 for a service
(sppgate.8), section 1 for a command. The services go to
sbindir; their service files are built, for the two programs, from
the templates sapupra.service.in and sapupra.rc.in (the worker’s
own lines are marked in the unit’s, and the lines that shut in
sppconsult too), and make check verifies that
none is left with a placeholder. Entry is with sppentry.cpp (and
sppconsult.cpp, which sets its mode that only consults) in the
program, not in a library: it reads the database through Catalogue
(opened read-only, openReadOnly), which is in libsppwork.a.
spplabel has Barcode, the one class that includes libzint’s
header, and is installed in bindir, not sbindir; it is the
only program that is built conditionally (on libzint), and its
page, spplabel.1, is shipped in any case. spplist is installed in
bindir too, with its page, spplist.1; its work is done by Listing
(the sorting of the names, the cells of the Org tables and the
document), which is in libsppwork.a beside Catalogue, whose
listing() it reads, so that a test can feed it without a program.
sppgen is in no directory of installation (noinst_PROGRAMS); it
is Generator, in libsppwork.a as well, behind a main that reads
four options.
The unit tests use QtTest, which is in qt6-base-dev: one program
per class, named after it (CodeTest for Code), run by
make check;
Table 13 says what each covers. The tests that need a broker
start a Mosquitto of their own (TestBroker), on a free port of the
loopback interface, never the machine’s broker, and are skipped
where there is no mosquitto program.
| Test | Covers |
|---|---|
CodeTest |
the sample, check digits, misreads, ISBN-10 and ISBN-13; location paths; the labels of items, each digit changed |
LabelsTest |
the labels, the pages (28 to an A4), the skip, a grid, the escaping, the wrapping, the plain mode |
BarcodeTest |
libzint’s drawings: the sizes that the standard gives, the four kinds, the refusals |
ListingTest |
the names turned about, the cells (the bar, the marks of Org), the three tables, the order, one table, the French headings |
GeneratorTest |
the check digits, a catalogue of the size asked, the same seed, the listing fed with it |
LogTest |
the levels; the time on the debugging lines only |
MessageTest |
topics, identifiers, each kind, each rejected field |
ConfigTest |
values, lists, integers, booleans, listening addresses |
HttpServerTest |
the parser; a late answer; the time and connection limits |
MqttClientTest |
acknowledgement, retained status, session, reconnection |
SignalWatcherTest |
signals delivered, one after the other; one watcher only |
GatewayTest |
each reply, both clients, locations (unknown places), item labels, receipts, no broker |
InputPublisherTest |
the signals of an input, the worker’s status, republication, abandon, no broker |
RecordTest |
the record’s JSON, its rejected fields, its summary |
CatalogueTest |
the schema and its upgrades, a foreign file, recording, places (made, renamed, merged), copies, retries |
OpenLibraryResolverTest |
answers, failures, the rate; real editions and authors, the authors of a work |
DnbResolverTest |
the real answers: the marks, the online editions, the large print, the search; failures |
BnfResolverTest |
the real answer: record, roles, duplicates, ISBN-10 |
MusicBrainzResolverTest |
the real answer: a disc entered twice, the UPC-A |
MarcTest |
ISO 2709 and MARCXML read; MARC 21 (the LC’s record included) and UNIMARC mapped |
Z3950ResolverTest |
the protocol and the searches by author and title, against a server of the tests (YAZ’s own) |
WorkerTest |
the whole path, from the broker to the database, places and item labels, several users, two workers (below) |
HttpFormTest |
urlencoded and multipart forms, a file, line breaks, errors |
HtmlTest |
escaping, the frame of a page, the refresh and the notice |
HistoryImportTest |
the history of the trial; the plan, the labels, the repeats |
EntryTest |
each page, each form, the places and their four pages, a place in a hierarchy, what is published, the import, the answer sorted by each column, the groups of controls, the folded tree of places, the pages in French (below) |
TranslationTest |
the languages, the plural rules, a bad catalogue, the French catalogue sound |
The tests of the three sources reached by HTTP play the source: a fake Open Library,
BnF or MusicBrainz, on the loopback interface, that answers with what
the real one answered (FakeSources.h).
GatewayTest plays the worker itself: an MQTT client that answers
each input with the receipt and the verdict that the test chooses.
WorkerTest plays the gateway: it publishes inputs and reads the
receipts and the verdicts, against a Mosquitto of the test, a fake
Open Library (FakeOpenLibrary.h, shared with the resolver’s test) and
a database in a temporary directory. It covers the presence, the
receipt before the verdict, the locations, the copies, the
repetitions, the code set aside and scanned again, the unrecognized
codes, the Romanian book completed by its ISBN, the ISBN-10, the
records by hand and by choice, the duplicated inputs, the source down
then back, giving up, the product numbers, the ambiguous answer, the
order of the sources, a book that the first source does not know and
the second does (the Simondon, by the BnF), a CD (by MusicBrainz), the history, the search, the recovery of the
inputs not settled, the session kept while the worker is away, and
the settings refused. The sample of the collection is that of
ResearchJournal.org.
EntryTest plays the worker, as GatewayTest does, and the database:
it fills a database as the worker would have (a scan not found, a
product number, an unrecognized code, one waiting for a source, a
catalogued item, and a location), starts the entry module on it and
reads the pages over HTTP as a browser does, sends the forms, and
checks what the fake worker receives (the kind, the for, the
device, the order of a location and an item) and what the page of the
result shows once the verdict has come. It covers the broker away,
the worker offline, the database that is not there yet, a form sent
twice, and the import (preview, confirmation, progress, a file
imported twice).
The order of writing: the library and its tests (written); the
gateway (written; tried against the broker alone, mosquitto_sub
showing what it publishes); the worker (written; tried by the author with Open Library, and
with the BnF and MusicBrainz added in 0.13.0, whose trial is waited for); the entry module, with the import of Binary Eye’s history; spplabel (written in 0.29.0, with the labels of items; the recognition of a label by the gateway and the worker is the next stage).