diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..6e04140 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "inventory-site", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "8412", "--directory", "_site"], + "port": 8412 + } + ] +} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..76e538c --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,66 @@ +name: deploy site + +on: + push: + branches: [main] + paths: + - "data/**" + - "site/**" + - "scripts/build_site.py" + - "scripts/make_qr_labels.py" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Never let two deploys race; a later push should win. +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install -r requirements.txt + + # Data must be valid before it gets published to a public site. + - name: Validate data + run: python3 scripts/validate.py + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Build search site + run: python3 scripts/build_site.py + + # QR payloads must point at wherever Pages actually serves this repo, so + # take the base URL from configure-pages rather than hardcoding it. + - name: Build QR label sheet + run: | + python3 scripts/make_qr_labels.py \ + --base-url "${{ steps.pages.outputs.base_url }}" + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 2836ae9..1ba23e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store .claude/settings.local.json +_site/ diff --git a/PLAN.md b/PLAN.md index 4b705ab..4dd07b8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -156,12 +156,16 @@ Answer §7. Nothing below can be built without the repo-visibility and photo-pri - `scripts/validate.py` + a CI workflow: schema check, closed-set enforcement, `location_id` referential integrity, ISO date check, duplicate `item_id` check. This is what keeps the data clean once several people are editing. - **Done when:** `items.csv` has ~500 rows, validation is green in CI, and the xlsx is never opened again. **✅ Done 2026-08-03: 499 items, 173 locations, 16 review-queue rows.** -### Phase 2 — Make things findable (~6–8 h) -- `scripts/build_site.py` → `inventory.json` + one page per location; GitHub Action rebuilds on every push to `data/`. -- Single-page search UI: instant fuzzy search across name/category/notes/location, filter by room and status, mobile-first layout. Vendored Fuse.js, no framework, no bundler. -- Location deep links (`/#209-CAB-01`) so a QR code lands on "here is what is in this drawer." -- `scripts/make_qr_labels.py` → printable PDF sheet of QR stickers, one per location. **This is the feature that makes the system feel real** — scan the drawer, see the contents. -- **Done when:** a grad student on a phone can answer "do we have EZ DNA Methylation-Gold?" in under 10 seconds. +### Phase 2 — Make things findable (~6–8 h) — ✅ **Done 2026-08-03** +- `scripts/build_site.py` → a single self-contained `_site/index.html` with the data **inlined**, not fetched as a JSON sidecar. Inlining means the built page works over `file://`, so previewing it needs no local server and there are no CORS surprises. +- Single-page search UI: instant search across name/category/notes/owner/vendor **and location label**, so "fridge door" and "cabinet 3" are queries too. Filters for room, category, and status; match highlighting; mobile-first; dark mode. +- **No search library.** 499 items is small enough that a precomputed lowercase haystack per item plus a scored linear scan beats any index, and there's nothing vendored to keep alive. Scoring: name-prefix > name-substring > any-field, and every query token must match somewhere. +- Location deep links (`/#209-CAB-01`) with **descendant rollup** — scanning the sticker on the -20 freezer shows all 49 items across its shelves and drawers, not the zero items filed directly against the freezer itself. +- `scripts/make_qr_labels.py` → printable sheet of QR stickers, one per location, filterable by room and kind. **HTML, not PDF**, so there's no PDF toolchain to maintain: open and print from the browser. Sticker geometry is CSS variables at the top of the generated file, so it can be nudged to fit label stock without touching the script. +- `.github/workflows/pages.yml` validates, builds, and deploys on every push to `main`. QR payloads take their base URL from `configure-pages` output rather than a hardcoded string, so the codes always point at wherever Pages actually serves the repo. +- **Done when:** a grad student on a phone can answer "do we have EZ DNA Methylation-Gold?" in under 10 seconds. ✅ + + **Requires one manual step:** Settings → Pages → Source = **GitHub Actions**. The workflow can't enable Pages for the repo itself. ### Phase 3 — Make writing easy (~4–6 h) - GitHub Issue Forms: **Add item**, **Mark consumed / low**, **Found something not listed**, **Verified a location**. Dropdowns populated from `locations.csv` (regenerated by the build Action, so they never drift). diff --git a/README.md b/README.md index 5cbcace..796b7da 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Development plan and architecture: **[PLAN.md](PLAN.md)** · Photo rules (public ## Status -**Phase 1 complete** — the spreadsheet has been migrated to canonical CSVs with CI validation. Search UI, add-item forms, and photo intake are still to come; see [PLAN.md §5](PLAN.md#5-phases). +**Phases 1–2 complete** — the spreadsheet is migrated to validated CSVs, and there's a searchable site with printable QR stickers for every location. Add-item forms (Phase 3) and photo intake (Phase 4) are still to come; see [PLAN.md §5](PLAN.md#5-phases). | | | |---|---| @@ -33,6 +33,22 @@ Two fields carry more weight than they look like they do: - **`last_verified`** — every migrated row says `2021-08-29`, because that's what the spreadsheet's own sheet names claim. This makes five years of staleness a queryable fact instead of a caveat. - **`source`** — `legacy-xlsx`, `manual`, or `photo-llm`. Permanent provenance, so an audit can always ask which rows a model wrote. +## The site + +Search UI and QR stickers deploy from `main` to GitHub Pages. Build and preview locally: + +```bash +python3 scripts/build_site.py && python3 scripts/make_qr_labels.py +``` + +That writes `_site/index.html` (self-contained — just open it, no server needed) and `_site/labels.html`, a printable sheet of QR stickers. Each sticker encodes `/#`, so scanning the one on a drawer shows that drawer's contents, including anything nested inside it. + +Stickers for just part of the lab: + +```bash +python3 scripts/make_qr_labels.py --rooms 213 --kinds drawer cabinet +``` + ## Working with it ```bash diff --git a/data/items.csv b/data/items.csv index c80cc1f..4c7fecd 100644 --- a/data/items.csv +++ b/data/items.csv @@ -1,6 +1,6 @@ item_id,name,category,location_id,quantity,unit,vendor,catalog_no,lot,received,expires,status,last_verified,verified_by,owner,source,photo_id,notes itm-00001,Plastic graduated cylinders,glassware,209-CAB-01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00002,Plastic boxes to hold sample tubes,sample,209-CAB-02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00002,Plastic boxes to hold sample tubes,consumable,209-CAB-02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00003,"Qubit RNA and DNA buffer, qubit assay tubes, DNA/RNA dye",equipment,209-DRW-03,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00004,Syringes and needles,consumable,209-DRW-04,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00005,Shaker supplies,equipment,209-DRW-05,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, @@ -31,11 +31,11 @@ itm-00029,labratory scoop,tool,209-DRW-07,,,,,,,,unverified,2021-08-29,,,legacy- itm-00030,long handle micro scoop/spatulas,tool,209-DRW-07,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00031,handheld motorized pestle homogenizer,equipment,209-DRW-07,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00032,dissection plates,consumable,209-DRW-08,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00033,Plastic boxes to hold sample tubes,sample,209-CAB-09,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00033,Plastic boxes to hold sample tubes,consumable,209-CAB-09,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00034,Plastic bottles,glassware,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00035,plastic containers,consumable,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00036,2000mL flask,glassware,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00037,paper and plastic boxes to hold sample tubes,sample,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00037,paper and plastic boxes to hold sample tubes,consumable,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00038,Electrophoresis supplies,other,209-CAB-11,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00039,Electrophoresis supplies,other,209-CAB-12,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00040,Large bottle brushes,glassware,209-CAB-13,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, @@ -85,10 +85,10 @@ itm-00083,Scientific calculator,office,209-DRW-34,,,,,,,,unverified,2021-08-29,, itm-00084,empty google notebook,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00085,graph paper on clipboard,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00086,paper/plastic report covers,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00087,EZNA mollusc dna kit product manual,kit,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00088,tissue FIX container (50mL) manual,consumable,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00087,EZNA mollusc dna kit product manual,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00088,tissue FIX container (50mL) manual,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00089,Mostly empty composition notebook (only first 3 pages used),office,209-DRW-35,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00090,DNeasy blood and tissue handbook,sample,209-DRW-35,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00090,DNeasy blood and tissue handbook,office,209-DRW-35,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00091,800mL commode collection container,consumable,209-DRW-37,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00092,Glass bottles,glassware,209-CAB-38,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00093,Glass bottles,glassware,209-CAB-39,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, @@ -245,7 +245,7 @@ itm-00243,ProFound c-Myc Tag IP/Co-IP Application Set,other,209-FRIDGE-S01,,,,,, itm-00244,DNA C. gigas samples in snap cap tubes (blue tube rack),sample,209-FRIDGE-S01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00245,DNA C. sikamea samples in snap cap tubes (green and red tube rack),sample,209-FRIDGE-S01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00246,P5/P7 TRUSEQ in snap cap tubes (orange tube rack),sample,209-FRIDGE-S01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00247,"Bag labeled ""Shelly"" with sample tubes",sample,209-FRIDGE-S01,,,,,,2021-10-30,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00247,"Bag labeled ""Shelly"" with sample tubes",consumable,209-FRIDGE-S01,,,,,,2021-10-30,,unverified,2021-08-29,,,legacy-xlsx,, itm-00248,Flow Cell compatible with MinION and GridION,other,209-FRIDGE-S01,4,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00249,MethylMiner Methylated DNA enrichment kit (x5 boxes),kit,209-FRIDGE-S01,,,,,,2019-02-14,,unverified,2021-08-29,,,legacy-xlsx,, itm-00250,Rneasy MinElute Spin Columns (in plastic grocery bag),consumable,209-FRIDGE-S01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, @@ -420,7 +420,7 @@ itm-00418,DNA Chips,consumable,213-SHELF-S02,,,,,,2015-10-28,,unverified,2021-08 itm-00419,High Sensitivity DNA Chips,consumable,213-SHELF-S02,,,,,,2020-11-14,,unverified,2021-08-29,,,legacy-xlsx,, itm-00420,RNA Pico chips,consumable,213-SHELF-S02,,,,,,2018-01-04,,unverified,2021-08-29,,,legacy-xlsx,, itm-00421,RNA Pico chips,consumable,213-SHELF-S02,,,,,,2019-11-01,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00422,Kit instructions,kit,213-SHELF-S02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00422,Kit instructions,office,213-SHELF-S02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00423,Mini compostion (partly filled out),other,213-SHELF-S02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00424,Syringes and safe-lock tubes,consumable,213-SHELF-S02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00425,RNeasy Plus Micro kit (50),kit,213-SHELF-S03,,,,,,2019-03-22,,unverified,2021-08-29,,,legacy-xlsx,, @@ -495,6 +495,6 @@ itm-00493,"small jar labeled ""sample collected in May 2000""",sample,213-F20-S0 itm-00494,MethylMiner Methylated DNA enrichment kit *CRAB ONLY* (opened 10/28/2020),kit,213-F20-S04-D06,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00495,CRC 471 gray whale blubber/skin,other,213-F20-S04-D06,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00496,small bag with several small bivalve samples,sample,213-F20-S04-D06,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, -itm-00497,2-Mercaptoethanol (opened,reagent,213-F20-S04-D07,,,,,,2018-08-06,,unverified,2021-08-29,,,legacy-xlsx,, +itm-00497,2-Mercaptoethanol,reagent,213-F20-S04-D07,,,,,,2018-08-06,,unverified,2021-08-29,,,legacy-xlsx,,other dates noted: 2018-08-08; opened itm-00498,"Misc. samples (crab, geoduck, etc.)",sample,213-F20-S05,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, itm-00499,SPUD IPC stock reagants,reagent,213-F20-S05,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,, diff --git a/requirements.txt b/requirements.txt index 289437c..7bcc450 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ openpyxl>=3.1 +segno>=1.6 diff --git a/scripts/build_site.py b/scripts/build_site.py new file mode 100644 index 0000000..1c9b377 --- /dev/null +++ b/scripts/build_site.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Build the static search site into _site/. + +Reads data/*.csv and inlines it into site/index.html, producing a completely +self-contained _site/index.html. Inlining rather than fetching a JSON sidecar +means the built page works over file:// -- no local web server needed to +preview it, and no CORS surprises. + +Usage: + python3 scripts/build_site.py [--data DIR] [--template FILE] [--out DIR] +""" + +from __future__ import annotations + +import argparse +import csv +import datetime as dt +import json +from pathlib import Path + +# Includes the `null` fallback so the substitution replaces the whole +# expression. Matching only the comment would leave `const DATA = {...}null;`, +# which is a syntax error -- and the template stays valid JavaScript this way. +PLACEHOLDER = "/*__INVENTORY_DATA__*/null" + +# Short keys: this JSON is inlined into the page, so it is worth keeping small. +ITEM_KEYS = { + "item_id": "id", "name": "n", "category": "c", "location_id": "l", + "quantity": "q", "unit": "u", "vendor": "v", "catalog_no": "cat", + "received": "r", "expires": "e", "status": "s", "last_verified": "lv", + "owner": "o", "source": "src", "notes": "nt", +} + + +def read(path: Path) -> list[dict]: + with path.open(newline="") as handle: + return list(csv.DictReader(handle)) + + +def breadcrumb(location_id: str, by_id: dict[str, dict]) -> str: + """Human-readable ancestry, e.g. '-20C freezer Rm 213 > shelf 2 > drawer 8'.""" + parts, node, guard = [], by_id.get(location_id), 0 + while node and guard < 12: + parts.append(node["label"] or node["location_id"]) + node = by_id.get(node["parent_id"]) if node["parent_id"] else None + guard += 1 + return " › ".join(reversed(parts)) + + +def build(datadir: Path, template: Path, outdir: Path) -> dict: + rooms = read(datadir / "rooms.csv") + locations = read(datadir / "locations.csv") + items = read(datadir / "items.csv") + + by_id = {loc["location_id"]: loc for loc in locations} + counts: dict[str, int] = {} + for item in items: + counts[item["location_id"]] = counts.get(item["location_id"], 0) + 1 + + payload = { + "generated": dt.date.today().isoformat(), + "rooms": [{"id": r["room"], "label": r["label"], "notes": r["notes"]} + for r in rooms], + "locations": [ + { + "id": loc["location_id"], + "room": loc["room"], + "kind": loc["kind"], + "label": loc["label"] or loc["location_id"], + "parent": loc["parent_id"], + "path": breadcrumb(loc["location_id"], by_id), + "notes": loc["notes"], + "count": counts.get(loc["location_id"], 0), + } + for loc in locations + ], + "items": [ + {short: item[long] for long, short in ITEM_KEYS.items() if item[long]} + for item in items + ], + } + + html = template.read_text() + if PLACEHOLDER not in html: + raise SystemExit(f"{template}: missing {PLACEHOLDER} placeholder") + # separators= drops the whitespace json.dumps adds by default. + data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + html = html.replace(PLACEHOLDER, data) + + outdir.mkdir(parents=True, exist_ok=True) + target = outdir / "index.html" + target.write_text(html) + + # .nojekyll stops GitHub Pages from running Jekyll over the output, which + # would otherwise strip files beginning with an underscore. + (outdir / ".nojekyll").write_text("") + + print(f"wrote {target} ({target.stat().st_size / 1024:.0f} KB)") + print(f" {len(payload['items'])} items, {len(payload['locations'])} locations, " + f"{len(payload['rooms'])} rooms") + return payload + + +def main() -> None: + root = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data", default=root / "data", type=Path) + parser.add_argument("--template", default=root / "site" / "index.html", + type=Path) + parser.add_argument("--out", default=root / "_site", type=Path) + args = parser.parse_args() + build(args.data, args.template, args.out) + + +if __name__ == "__main__": + main() diff --git a/scripts/make_qr_labels.py b/scripts/make_qr_labels.py new file mode 100644 index 0000000..66a813e --- /dev/null +++ b/scripts/make_qr_labels.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Generate a printable sheet of QR stickers, one per location. + +Each sticker encodes /#. Scanning the sticker on a drawer +opens the search site filtered to that drawer's contents -- including anything +nested inside it. + +Output is HTML rather than PDF so there is no PDF toolchain to maintain: open +it and print from the browser. Sticker geometry is CSS variables at the top of +the generated file, so it can be nudged to match whatever label stock you have +without touching this script. + +Usage: + python3 scripts/make_qr_labels.py + python3 scripts/make_qr_labels.py --rooms 213 --out _site/labels-213.html +""" + +from __future__ import annotations + +import argparse +import csv +import datetime as dt +import html +from pathlib import Path + +try: + import segno +except ImportError: + raise SystemExit("segno required: pip install -r requirements.txt") + +DEFAULT_BASE = "https://robertslab.github.io/lab-inventory/" + +PAGE_TEMPLATE = """ + + + +Inventory QR labels{title_suffix} + + + +
+

Inventory QR labels{title_suffix}

+

{count} stickers · generated {generated} · each code opens + {base}#<location>. Print, cut on the dashed lines, and tape + one to each drawer, shelf, or bin. Dashed borders are dropped when printing.

+
+
+{stickers} +
+ + +""" + + +def read(path: Path) -> list[dict]: + with path.open(newline="") as handle: + return list(csv.DictReader(handle)) + + +def sticker(location: dict, url: str, item_count: int) -> str: + # error='m' (~15% recovery) survives a scuffed sticker on a lab drawer + # better than the default while staying compact enough to stay legible. + qr = segno.make(url, error="m", micro=False) + # omitsize=True emits a viewBox instead of width/height attributes. Without + # a viewBox, the CSS --qr size only enlarges the SVG viewport while the code + # stays at its intrinsic module size in the corner. + svg = qr.svg_inline(scale=1, border=0, dark="#000", omitsize=True) + plural = "item" if item_count == 1 else "items" + return ( + '
' + f"{svg}" + '
' + f'
{html.escape(location["location_id"])}
' + f'
{html.escape(location["label"] or "")}
' + f'
{item_count} {plural} recorded
' + "
" + ) + + +def main() -> None: + root = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data", default=root / "data", type=Path) + parser.add_argument("--out", default=root / "_site" / "labels.html", type=Path) + parser.add_argument("--base-url", default=DEFAULT_BASE, + help=f"site root the codes point at (default {DEFAULT_BASE})") + parser.add_argument("--rooms", nargs="*", metavar="ROOM", + help="only these rooms (default: all)") + parser.add_argument("--kinds", nargs="*", metavar="KIND", + help="only these location kinds, e.g. drawer cabinet") + args = parser.parse_args() + + locations = read(args.data / "locations.csv") + items = read(args.data / "items.csv") + + counts: dict[str, int] = {} + for item in items: + counts[item["location_id"]] = counts.get(item["location_id"], 0) + 1 + + if args.rooms: + locations = [l for l in locations if l["room"] in set(args.rooms)] + if args.kinds: + locations = [l for l in locations if l["kind"] in set(args.kinds)] + locations.sort(key=lambda l: l["location_id"]) + + if not locations: + raise SystemExit("no locations matched those filters") + + base = args.base_url if args.base_url.endswith("/") else args.base_url + "/" + stickers = [ + sticker(loc, f"{base}#{loc['location_id']}", counts.get(loc["location_id"], 0)) + for loc in locations + ] + + suffix = f" — room {', '.join(args.rooms)}" if args.rooms else "" + page = PAGE_TEMPLATE.format( + title_suffix=html.escape(suffix), + count=len(stickers), + generated=dt.date.today().isoformat(), + base=html.escape(base), + stickers="\n".join(stickers), + ) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(page) + print(f"wrote {args.out} ({args.out.stat().st_size / 1024:.0f} KB)") + print(f" {len(stickers)} stickers pointing at {base}") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_legacy.py b/scripts/migrate_legacy.py index 24674bd..c51d491 100644 --- a/scripts/migrate_legacy.py +++ b/scripts/migrate_legacy.py @@ -100,6 +100,10 @@ # the site's category filter is actually useful; ~1 in 6 items still lands in # `other`, which is honest for a drawer containing a Nintendo. CATEGORY_RULES = [ + # Documentation first: 'DNeasy blood and tissue handbook' is paperwork, not + # a kit and not a blood sample. Content words would otherwise win. + ("office", r"handbooks?|manuals?|instructions?|composition book|" + r"\bnotebooks?\b|\bhandouts?\b"), ("kit", r"\bkits?\b|mini ?prep|midi ?prep|maxi ?prep|micro ?prep|\bduet\b"), ("enzyme", r"polymerase|ligase|transcriptase|nuclease|dnase|rnase|proteinase|" r"restriction enzyme|\btaq\b|exonuclease|phosphatase|lysozyme|" @@ -107,7 +111,10 @@ ("antibody", r"antibod|antisera|\bigg\b|\bighm\b|\bserum\b|immunoglobulin"), ("media", r"\bagar\b|\bbroth\b|\bmedia\b|\bmedium\b|\bfbs\b|\bsera\b|" r"\bfood\b|algae paste"), - ("sample", r"\bsamples?\b|\bgdna\b|extractions?|\baliquots?\b|libraries|" + # 'sample tubes' / 'sample boxes' are containers, not samples -- the + # lookahead keeps empty labware out of the sample category. + ("sample", r"\bsamples?\b(?!\s+(tubes?|boxes|bags?|racks?|containers?|" + r"storage|labels?))|\bgdna\b|extractions?|\baliquots?\b|libraries|" r"snap cap|histology slides|\bcdna\b|\brna\b(?!ase)\s+from|" r"\bblood\b|tissue in|in 100% ethanol|\bbcs plates?\b"), ("glassware", r"\bbottles?\b|\bflasks?\b|\bbeakers?\b|graduated cylinders?|" @@ -339,16 +346,25 @@ def explode(self, contents: str, location_id: str, sheet: str, rownum: int, item_received = received name = part - date_match = PAREN_DATE_RE.search(name) - if date_match: - month, day, year = (int(g) for g in date_match.groups()) + # Cells carry one or more parenthetical dates. The earliest becomes + # `received`; any others are commentary dates ('opened 8/8/2018') + # and are preserved in notes rather than dropped. + found = [] + for match in PAREN_DATE_RE.finditer(name): + month, day, year = (int(g) for g in match.groups()) year += 2000 if year < 100 else 0 try: - item_received = dt.date(year, month, day).isoformat() + found.append(dt.date(year, month, day).isoformat()) except ValueError: self.warnings.append( (sheet, rownum, f"bad embedded date in {part!r}")) - name = PAREN_DATE_RE.sub("", name).strip() + if found: + found.sort() + item_received = found[0] + if len(found) > 1: + extra = f"other dates noted: {', '.join(found[1:])}" + item_notes = f"{item_notes}; {extra}".strip("; ") if item_notes else extra + name = PAREN_DATE_RE.sub("", name) quantity = "" qty_match = XQTY_RE.search(name) @@ -356,6 +372,20 @@ def explode(self, contents: str, location_id: str, sheet: str, rownum: int, quantity = qty_match.group(1) name = XQTY_RE.sub("", name).strip() + # Removing embedded dates can leave unbalanced parentheses, and the + # source has its own ('2-Mercaptoethanol (8/6/2018) (opened + # (8/8/2018)'). Move the orphaned fragment to notes instead of + # shipping a name that ends mid-parenthesis. + name = re.sub(r"\s{2,}", " ", name).strip() + while name.count("(") > name.count(")"): + cut = name.rfind("(") + fragment = name[cut + 1:].strip(" ()") + name = name[:cut].strip(" ,;") + if fragment: + item_notes = (f"{item_notes}; {fragment}".strip("; ") + if item_notes else fragment) + name = name.replace("()", "").strip(" ,;") + owner = "" owner_match = OWNER_RE.search(name) if owner_match: diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..a018282 --- /dev/null +++ b/site/index.html @@ -0,0 +1,344 @@ + + + + + +Roberts Lab Inventory + + + +
+ +
+

Roberts Lab Inventory

+
+ · + source +
+
+ + + + + + +
+
    + + +
    + Every item still says unverified until someone lays eyes on it. + See the plan + for how verification works. · + printable QR labels +
    + +
    + + + +