diff --git a/library/README.md b/library/README.md index b002d3f..c94bded 100644 --- a/library/README.md +++ b/library/README.md @@ -208,7 +208,18 @@ quickadd/ the scan-to-add service (Python standard library + yaz) Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before. -## Everyday use (the Library site at `/add/`) +## The home page (the Room for Books site at `/add/`) + +One simple page: a **Look up** box (books by title, author or ISBN, and people by name or card number), four big buttons +(**Check out**, **Return**, **Add books**, **Add a person**) and **Recently added** books with their covers. The **menu** +(top right) holds the rest: browse all books, lend / return, people and library cards, barcodes and settings, export / import, +and a link to Koha's own staff screens. Borrowers and the read-only guest see just the lookup, recent books and (borrowers) My books. + +**Adding by title:** on Add books, scan or type an ISBN to add it straight away; type a title or author instead and you get a list +of matches (cover, author, year, publisher) from Open Library and the Library of Congress to choose from. Books you already have +are marked, with an *Add another copy* button. + +## Everyday use (details) One page for the day-to-day, so you rarely need Koha's own screens: diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc index 3d80282..687801f 100644 Binary files a/library/quickadd/__pycache__/app.cpython-313.pyc and b/library/quickadd/__pycache__/app.cpython-313.pyc differ diff --git a/library/quickadd/app.py b/library/quickadd/app.py index 7e3cfcc..8694ab7 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -1527,6 +1527,101 @@ def op_logins_all(payload): return 200, {"status": "done", "made": made} +# ------------------------------------------------------------ title / author search +def z3950_candidates(spec, term, limit=8): + """Title search (falling back to any field) on a Z39.50 server; returns summaries with an ISBN.""" + out = [] + for attr in ("@attr 1=4", "@attr 1=1016"): + with tempfile.TemporaryDirectory() as tmp: + dump = os.path.join(tmp, "rec.mrc") + q = term.replace('"', " ") + script = 'format usmarc\nfind %s "%s"\nshow 1+%d\nquit\n' % (attr, q, limit) + try: + res = subprocess.run(["yaz-client", "-m", dump, spec], input=script, capture_output=True, text=True, + timeout=Z3950_TIMEOUT).stdout + except (subprocess.TimeoutExpired, OSError): + return out + m = re.search(r"Number of hits: (\d+)", res) + if not m or int(m.group(1)) == 0 or not os.path.exists(dump): + continue + raw = open(dump, "rb").read() + args = ["yaz-marcdump", "-o", "marcxml"] + if len(raw) > 9 and raw[9:10] == b" ": + args += ["-f", "MARC-8", "-t", "UTF-8", "-l", "9=97"] + conv = subprocess.run(args + [dump], capture_output=True, text=True, timeout=15) + if conv.returncode != 0: + continue + try: + root = ET.fromstring(conv.stdout if conv.stdout.lstrip().startswith("%s" % (NS["m"], conv.stdout)) + except ET.ParseError: + continue + for rec in root.iter("{%s}record" % NS["m"]): + info = describe(rec) + isbn = "" + for df in rec.findall("m:datafield[@tag='020']", NS): + for sf in df.findall("m:subfield[@code='a']", NS): + c = clean_code((sf.text or "").split(" ")[0]) + if c and len(c) in (10, 13) and not isbn: + isbn = isbn10_to_13(c) if len(c) == 10 else c + if isbn: + out.append(dict(info, isbn=isbn, source="Library of Congress")) + if out: + return out + return out + + +def openlibrary_candidates(term, limit=10): + url = "https://openlibrary.org/search.json?" + urllib.parse.urlencode( + {"q": term, "limit": str(limit), "fields": "title,author_name,first_publish_year,isbn,publisher,cover_i"}) + try: + with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "library-quickadd/1.0"}), timeout=10) as r: + docs = json.load(r).get("docs") or [] + except (urllib.error.URLError, OSError, ValueError): + return [] + out = [] + for d in docs: + isbns = [clean_code(i) for i in (d.get("isbn") or [])] + isbns = [i for i in isbns if i and len(i) == 13 and i[:3] in ("978", "979")] or [isbn10_to_13(i) for i in isbns if i and len(i) == 10] + if not isbns or not d.get("title"): + continue + out.append({"title": d["title"], "author": ", ".join((d.get("author_name") or [])[:2]), "year": str(d.get("first_publish_year") or ""), + "publisher": ((d.get("publisher") or [""])[0])[:60], "isbn": isbns[0], "source": "Open Library"}) + return out + + +def op_lookup(term): + """Books matching a title/author, to choose from; each has an ISBN so choosing one is a normal add.""" + term = re.sub(r"\s+", " ", term or "").strip()[:100] + if len(term) < 3: + return {"candidates": []} + found, seen = [], set() + for src in SOURCES: + got = [] + if src == "openlibrary": + got = openlibrary_candidates(term) + elif src.startswith("z3950:"): + got = z3950_candidates(src[len("z3950:"):], term) + for c in got: + key = (c["isbn"], (c["title"] + c["author"]).lower()[:40]) + if c["isbn"] in {k[0] for k in seen} or key in seen: + continue + seen.add(key) + found.append(c) + found = found[:12] + if found: # which of these do we already own? one query for all + conds = [{"isbn": {"-like": "%" + c["isbn"] + "%"}} for c in found] + [{"ean": {"-like": "%" + c["isbn"] + "%"}} for c in found] + try: + owned = " ".join((b.get("isbn") or "") + " " + (b.get("ean") or "") for b in + (koha("GET", "/biblios", query={"q": json.dumps({"-or": conds}), "_per_page": "50"}) or [])) + except KohaError: + owned = "" + for c in found: + alt = isbn13_to_10(c["isbn"]) or "" + c["have"] = c["isbn"] in owned or bool(alt and alt in owned) + have = found + return {"candidates": have} + + # ------------------------------------------------------------ photo scanning MAX_IMAGE = 12 * 1024 * 1024 ZBAR_TIMEOUT = 20 @@ -1726,6 +1821,12 @@ class Handler(BaseHTTPRequestHandler): except KohaError as e: return self._send(404 if e.status == 404 else 502, {"status": "error", "message": "Koha is not answering yet - try again in a minute." if e.status == 503 else "Failed: %s" % e.body[:120]}) + if path == "/api/lookup": + q = (urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("q") or [""])[0] + try: + return self._send(200, op_lookup(q)) + except KohaError: + return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."}) if path == "/api/me": if self.role != "patron": return self._send(403, {"status": "error", "message": "Sign in with a library card to see your books."}) diff --git a/library/quickadd/index.html b/library/quickadd/index.html index bfc4ea2..2b68bc7 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -44,6 +44,34 @@ padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; } button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } + header.top { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 0 8px; border-bottom: 1px solid var(--line); margin-bottom: 12px; } + .brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--ink); } + .brand .logo { font-size: 1.5rem; } .brand h1 { margin: 0; } + .menubtn { font-size: 1.5rem; line-height: 1; padding: 6px 14px; } + .menu { position: absolute; right: 0; top: 100%; z-index: 20; background: var(--card); border: 1px solid var(--line); border-radius: 12px; + box-shadow: 0 8px 24px rgba(0,0,0,.18); padding: 8px; min-width: 250px; display: flex; flex-direction: column; gap: 2px; } + .menu[hidden] { display: none; } + .menu a { color: var(--ink); text-decoration: none; padding: 9px 12px; border-radius: 8px; font-weight: 600; } + .menu a:hover, .menu a:focus-visible { background: var(--bg); } + .menu a.on { background: var(--accent); color: var(--accent-ink); } + .menu a.ext::after { content: ' \2197'; font-size: .85em; } + .menu a[hidden] { display: none; } + .menugroup { border-top: 1px solid var(--line); margin-top: 6px; padding-top: 6px; display: flex; flex-direction: column; gap: 2px; } + .menugroup[hidden] { display: none; } + .menulabel { font-size: .8rem; color: var(--muted); padding: 2px 12px; text-transform: uppercase; letter-spacing: .05em; } + .find-form.big input { font-size: 1.5rem; padding: 16px 18px; } + .bigacts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: 16px 0; } + .bigacts[hidden] { display: none; } + .bigact { display: flex; flex-direction: column; gap: 4px; padding: 18px 14px; border-radius: 14px; background: var(--accent); color: var(--accent-ink); + text-decoration: none; min-height: 96px; justify-content: center; } + .bigact strong { font-size: 1.25rem; } .bigact span { font-size: .88rem; opacity: .9; } + .bigact:hover, .bigact:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } + .foot { max-width: 760px; margin: 8px auto 28px; padding: 14px 16px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: .9rem; text-align: center; } + .foot a { color: var(--accent); } + .cand { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid var(--line); } + .cand .cv { width: 54px; height: 78px; border-radius: 4px; background: var(--line); flex: none; object-fit: cover; } + .cand .t { flex: 1; min-width: 0; overflow-wrap: anywhere; } + @media print { .foot, .menubtn, .menu { display: none !important; } } nav.tabs { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; } nav.tabs a { color: var(--accent); text-decoration: none; font-weight: 600; padding: 6px 12px; border-radius: 999px; } nav.tabs a.on { background: var(--accent); color: var(--accent-ink); } @@ -134,7 +162,7 @@ .libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; } .cardsheet { display: grid; grid-template-columns: repeat(2, 85.6mm); gap: 6mm; justify-content: start; } .cardsheet .libcard { break-inside: avoid; } - @media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } } + @media print { header, .noprint, nav, .foot { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } } #live { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.92); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 12px; } #live[hidden] { display: none; } #live video { width: min(100%, 640px); max-height: 60vh; border-radius: 12px; background: #000; object-fit: cover; } @@ -146,19 +174,48 @@
-
-

Room for Books

-
+