From 2c358618f8e803dcfe9c6f508fa9107fdc74ac9c Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sun, 13 Sep 2026 20:57:01 -0700 Subject: [PATCH 1/4] feat: photos, a public flag + blurb, and an operator-only Publish to the site checkout (0.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos: sniffed by magic bytes (JPEG/PNG/WebP; HEIC -> JPEG via macOS sips), capped at 20 MB, and stripped of metadata AT UPLOAD by allowlist (pure Python, no Pillow): a JPEG keeps only decoder segments + JFIF + ICC + Adobe, and gets back a minimal EXIF carrying ONLY the Orientation when it was rotated; everything after EOI (MPF extras) is dropped; PNG keeps image/colour chunks; WebP drops EXIF/XMP and clears their VP8X flags. Stored as photos//. next to the DB, written temp-then-rename inside the row's transaction; deleted with the item/lot after commit. Public site: items.public (opt-in, every existing item stays private) + items.blurb (additive migrations). publish.py builds src/data/catalog.json key by key from an allowlist (id, name, system, category, condition, price_cents, quantity, status, blurb, photos, links, updated) for public + available/listed + priced + in-stock items; mirrors their photos into src/assets/catalog/ (containment-checked, never follows a link); the preview's hash must match at publish (409 otherwise); commits the two paths with `git commit --only` and pushes (a push failure is reported, never raised). API: items/{id}/photos (raw-body upload, list, bytes, PATCH alt/position, DELETE), publish/preview, publish. Tools: inventory_add_photo, inventory_publish_preview — and deliberately NO publish tool (tested). View: public toggle, blurb, photo manager (file picker only; no drag-and-drop in the Tauri webview), row chips, Publish dialog with the diff. The copied chat post is now pinned byte for byte. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011UQ33xXfygwEkzbbJx3YwZ --- README.md | 32 ++- api.py | 90 ++++++- csvio.py | 4 + photos.py | 343 ++++++++++++++++++++++++++ protoagent.plugin.yaml | 23 +- publish.py | 375 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- skills/inventory-ops/SKILL.md | 13 + store.py | 270 +++++++++++++++++++- tests/test_photos.py | 334 +++++++++++++++++++++++++ tests/test_publish.py | 449 ++++++++++++++++++++++++++++++++++ tests/test_tools.py | 2 +- tools.py | 40 ++- view.py | 138 ++++++++++- 14 files changed, 2095 insertions(+), 20 deletions(-) create mode 100644 photos.py create mode 100644 publish.py create mode 100644 tests/test_photos.py create mode 100644 tests/test_publish.py diff --git a/README.md b/README.md index 1cad3d3..9997685 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ inventory_summary() | | | |---|---| -| **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_reprice_plan` · `inventory_stale` | -| **View** | a rail panel: the item grid (double-click to edit name/category/condition/qty; status select; click a price for the Price dialog), Price / Sold / Listing / Edit / Delete per row, an optional **game system** per item (filter, ordering, inline edit with suggestions), multi-select with **Copy list** (also ⌘C) — a for-sale post per game system: the system as a header, then `Name NoS — $20` per line (condition inline, whole-dollar prices), lots with their P&L, sales, an activity log, CSV import (file or paste, with the mapping report) and export | -| **API** | bearer-gated JSON under `/api/plugins/inventory` — `summary`, `lots`, `items`, `items/{id}/price`, `items/{id}/sold`, `items/{id}/listings`, `listings/{id}/end`, `sales`, `stale`, `audit`, `import`, `export` | +| **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_reprice_plan` · `inventory_add_photo` · `inventory_publish_preview` · `inventory_stale` — there is deliberately **no publish tool** | +| **View** | a rail panel: the item grid (double-click to edit name/category/condition/qty; status select; click a price for the Price dialog), Price / Sold / Listing / Edit / Delete per row, an optional **game system** per item (filter, ordering, inline edit with suggestions), multi-select with **Copy list** (also ⌘C) — a for-sale post per game system: the system as a header, then `Name NoS — $20` per line (condition inline, whole-dollar prices), lots with their P&L, sales, an activity log, CSV import (file or paste, with the mapping report) and export; per item a **Show on the public site** flag, a public **blurb** and **photos** (upload from the Edit dialog, alt text, make cover, delete); a **Publish** button that previews the site catalog diff and publishes it | +| **API** | bearer-gated JSON under `/api/plugins/inventory` — `summary`, `lots`, `items`, `items/{id}/price`, `items/{id}/sold`, `items/{id}/listings`, `listings/{id}/end`, `sales`, `stale`, `audit`, `import`, `export`, `items/{id}/photos` (raw-body upload, list, bytes, PATCH alt/position, DELETE), `publish/preview`, `publish` | | **Automations** | `weekly_review: true` arms a plugin-owned recurring turn (`weekly_review_cron`, default Monday 09:00 in `review_timezone`) that re-prices stale evidence from eBay sold comps through the same tools, reports stale listings with a recommendation (it never changes a listing itself), and posts the per-lot P&L. Cancelled when the plugin is disabled. | -| **Events** | `inventory.item.changed`, `inventory.lot.changed`, `inventory.sale.recorded`, `inventory.imported` | +| **Events** | `inventory.item.changed`, `inventory.lot.changed`, `inventory.sale.recorded`, `inventory.imported`, `inventory.published` | | **Skill** | `inventory-ops` — the rules (a target needs a basis; never set sold by hand; sold ≠ active ≠ retail) and the re-price / weekly-review routines | ## The model @@ -33,6 +33,30 @@ inventory_summary() Money is stored as integer cents and exposed as dollars. +## Photos and the public site + +- **Photos** are sniffed by their bytes (JPEG, PNG, WebP; HEIC/HEIF is converted to JPEG with + macOS `sips`), capped at 20 MB, and **stripped of metadata on upload** — EXIF (GPS, camera, + serial), XMP, IPTC, comments, and anything after a JPEG's end-of-image marker. Only the + EXIF Orientation survives, so phone photos still display upright. Pure Python, no Pillow. + Stored next to the database as `photos//.`; position 0 is the cover. +- **Public** is opt-in per item. **Publish** (the view's button — the agent can only preview) + builds `src/data/catalog.json` in the `site_dir` checkout from an allowlist of fields — + `id, name, system, category, condition, price_cents, quantity, status, blurb, photos, links, + updated` — for items that are public, available or listed, priced, and in stock. Cost, + lot, notes, the low/high band, retail, price basis, sales and the audit log never leave. +- The preview carries a hash of exactly what it showed; Publish refuses (409) if the + inventory moved since. It mirrors the photos into `src/assets/catalog/` (only inside that + folder), commits the two paths (`git commit --only`, so nothing else you staged rides + along) and pushes. A failed push is reported; the files and the commit stay. + +```yaml +inventory: + site_dir: /path/to/nerdsville-site # the site checkout; blank = preview only + publish_git: true # commit catalog + photos after a publish + publish_push: true # push so the site's CI deploys +``` + ## Setup ```bash diff --git a/api.py b/api.py index 242fa0d..39fb92a 100644 --- a/api.py +++ b/api.py @@ -6,6 +6,8 @@ import contextlib import logging +from fastapi import Request # module level: FastAPI resolves the (postponed) annotation from these globals + from .store import InventoryError, InventoryStore, normalize_status #: Target fields never travel through the generic item write — they need a basis (POST /price). @@ -18,7 +20,10 @@ def build_data_router(store: InventoryStore, cfg: dict, *, emit=lambda topic, data: None): from fastapi import APIRouter, HTTPException, Query - from fastapi.responses import PlainTextResponse + from fastapi.responses import FileResponse, PlainTextResponse + from starlette.concurrency import run_in_threadpool + + from .photos import MAX_BYTES r = APIRouter() @@ -141,6 +146,89 @@ async def _delete_item(item_id: str) -> dict: emit("item.changed", {"id": item_id, "action": "delete"}) return {"ok": True} + # ── photos ── the page uploads the File itself as the body (kit.apiFetch passes a Blob + # through untouched), with its type in Content-Type and the alt text in ?alt=. + def _need_item(item_id: str) -> None: + if store.get_item_row(item_id) is None: + raise HTTPException(status_code=404, detail=f"no item {item_id!r}") + + @r.get("/items/{item_id}/photos") + async def _photos(item_id: str) -> dict: + _need_item(item_id) + return {"photos": store.list_photos(item_id)} + + @r.post("/items/{item_id}/photos") + async def _add_photo(item_id: str, request: Request, alt: str = "") -> dict: + _need_item(item_id) + too_big = f"photos are capped at {MAX_BYTES // (1024 * 1024)} MB" + declared = request.headers.get("content-length") or "" + if declared.isdigit() and int(declared) > MAX_BYTES: + raise HTTPException(status_code=413, detail=too_big) + buf = bytearray() + async for chunk in request.stream(): + buf += chunk + if len(buf) > MAX_BYTES: + raise HTTPException(status_code=413, detail=too_big) + try: # sniff + sanitize (+ a HEIC conversion) off the event loop + photo = await run_in_threadpool(store.add_photo, item_id, bytes(buf), alt=alt, actor=ACTOR) + except InventoryError as exc: + _raise(exc) + emit("item.changed", {"id": item_id, "action": "photo_added"}) + return {"photo": photo} + + @r.get("/items/{item_id}/photos/{photo_id}") + async def _photo_bytes(item_id: str, photo_id: str): + found = store.photo_file(item_id, photo_id) + if found is None: + raise HTTPException(status_code=404, detail=f"no photo {photo_id!r} on item {item_id!r}") + path, content_type = found + return FileResponse( + path, + media_type=content_type, + headers={"Cache-Control": "private, max-age=86400", "X-Content-Type-Options": "nosniff"}, + ) + + @r.patch("/items/{item_id}/photos/{photo_id}") + async def _patch_photo(item_id: str, photo_id: str, body: dict) -> dict: + try: + photo = store.update_photo( + item_id, photo_id, alt=body.get("alt"), position=body.get("position"), actor=ACTOR + ) + except InventoryError as exc: + if str(exc).startswith("no photo"): + raise HTTPException(status_code=404, detail=str(exc)) from exc + _raise(exc) + emit("item.changed", {"id": item_id, "action": "photo_updated"}) + return {"photo": photo} + + @r.delete("/items/{item_id}/photos/{photo_id}") + async def _delete_photo(item_id: str, photo_id: str) -> dict: + if not store.delete_photo(item_id, photo_id, actor=ACTOR): + raise HTTPException(status_code=404, detail=f"no photo {photo_id!r} on item {item_id!r}") + emit("item.changed", {"id": item_id, "action": "photo_deleted"}) + return {"ok": True} + + # ── the public site ── sync handlers: they read and write files (and run git), so + # FastAPI runs them in its threadpool instead of on the event loop. + @r.get("/publish/preview") + def _publish_preview() -> dict: + from .publish import preview + + return preview(store, cfg) + + @r.post("/publish") + def _publish(body: dict) -> dict: + from .publish import PublishConflict, publish + + try: + out = publish(store, cfg, str(body.get("hash") or ""), actor=ACTOR) + except PublishConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except InventoryError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + emit("published", {"count": out["count"], "commit": out["commit"], "pushed": out["pushed"]}) + return out + @r.post("/items/{item_id}/price") async def _price(item_id: str, body: dict) -> dict: obs = body.get("observation") or None diff --git a/csvio.py b/csvio.py index 8024a22..d3fea77 100644 --- a/csvio.py +++ b/csvio.py @@ -26,6 +26,8 @@ "system": ("system", "game_system", "game"), "name": ("item", "name", "title", "item_name"), "condition": ("condition", "cond"), + "public": ("public", "on_site", "show_on_site", "published"), + "blurb": ("blurb", "public_blurb", "site_blurb"), "quantity": ("unit_quantity", "quantity", "qty"), "model_count": ("model_count", "models"), "notes": ("notes", "note", "description"), @@ -98,6 +100,7 @@ "quantity", "model_count", "status", + "public", "target_low", "target", "target_high", @@ -105,6 +108,7 @@ "cost_basis", "price_basis", "price_updated_on", + "blurb", "notes", "updated_at", ) diff --git a/photos.py b/photos.py new file mode 100644 index 0000000..341a9b9 --- /dev/null +++ b/photos.py @@ -0,0 +1,343 @@ +"""Photo intake — sniff, convert and sanitize an upload so that nothing but pixels (and the +one EXIF field needed to show them upright) is ever stored, let alone published. + +A phone photo carries GPS coordinates, the camera serial and the time it was taken; the +catalog is public, so metadata is stripped at the door rather than at publish time. +Allowlists, not blocklists: a JPEG keeps only the segments a decoder needs (quantisation, +Huffman, frame, scan) plus a JFIF header, an ICC colour profile and the Adobe colour +marker; a PNG keeps only its image chunks; a WebP only its image and colour chunks. +Everything after a JPEG's end-of-image marker (the extra images an iPhone appends) goes. + +Pure Python on purpose: Pillow is not guaranteed in the frozen desktop runtime. HEIC/HEIF +is converted with macOS ``sips`` when it is on PATH, else refused with a clear message. +""" + +from __future__ import annotations + +import os +import shutil +import struct +import subprocess +import tempfile + +#: Upload cap. A full-resolution phone JPEG is 3–8 MB; a HEIC converts to about that. +MAX_BYTES = 20 * 1024 * 1024 + +CONTENT_TYPES = {"jpg": "image/jpeg", "png": "image/png", "webp": "image/webp"} + + +class PhotoError(ValueError): + """An upload we will not store, phrased for the operator.""" + + +# ── sniffing ─────────────────────────────────────────────────────────────────── +_PNG_SIG = b"\x89PNG\r\n\x1a\n" +_HEIF_BRANDS = {b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1"} + + +def sniff(data: bytes) -> str | None: + """``"jpeg" | "png" | "webp" | "heic"`` from the magic bytes, else ``None`` — the + filename and the declared content type are never trusted.""" + if data[:3] == b"\xff\xd8\xff": + return "jpeg" + if data[:8] == _PNG_SIG: + return "png" + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + if len(data) >= 16 and data[4:8] == b"ftyp": + size = struct.unpack(">I", data[:4])[0] + brands = {data[8:12]} + compat = data[16 : max(16, min(size, len(data), 128))] + brands |= {compat[i : i + 4] for i in range(0, len(compat) - 3, 4)} + if brands & _HEIF_BRANDS: + return "heic" + return None + + +# ── JPEG ─────────────────────────────────────────────────────────────────────── +_SOI, _EOI, _SOS = 0xD8, 0xD9, 0xDA +#: Markers with no length field: TEM and the restart markers. +_STANDALONE = {0x01, *range(0xD0, 0xD8)} +_ORIENTATION_TAG = 0x0112 + + +def _keep_app(marker: int, payload: bytes) -> bool: + """Which APPn segments survive: the JFIF header, an ICC profile and the Adobe colour + marker. EXIF (APP1), XMP (APP1), Photoshop/IPTC (APP13), MPF, thumbnails, maker notes — + all dropped.""" + if marker == 0xE0: + return payload.startswith(b"JFIF\x00") + if marker == 0xE2: + return payload.startswith(b"ICC_PROFILE\x00") + if marker == 0xEE: + return payload.startswith(b"Adobe") + return False + + +def header_segments(data: bytes) -> list[tuple[int, bytes]]: + """``(marker, payload)`` for every length-carrying segment before the first scan — for + reading the orientation and for tests. Stops quietly at anything malformed.""" + out: list[tuple[int, bytes]] = [] + if data[:2] != b"\xff\xd8": + return out + i, n = 2, len(data) + while i + 4 <= n and data[i] == 0xFF: + marker = data[i + 1] + if marker == 0xFF: + i += 1 + continue + if marker in _STANDALONE: + i += 2 + continue + if marker in (_SOS, _EOI): + break + length = struct.unpack(">H", data[i + 2 : i + 4])[0] + if length < 2 or i + 2 + length > n: + break + out.append((marker, data[i + 4 : i + 2 + length])) + i += 2 + length + return out + + +def _tiff_ifd0(tiff: bytes) -> tuple[str, list[tuple[int, int, int, bytes]]] | None: + """Parse IFD0 of a TIFF block: ``(endian, [(tag, type, count, value4)])`` or None.""" + if len(tiff) < 8 or tiff[:2] not in (b"II", b"MM"): + return None + e = "<" if tiff[:2] == b"II" else ">" + if struct.unpack(e + "H", tiff[2:4])[0] != 42: + return None + off = struct.unpack(e + "I", tiff[4:8])[0] + if off + 2 > len(tiff): + return None + count = struct.unpack(e + "H", tiff[off : off + 2])[0] + entries = [] + for k in range(count): + p = off + 2 + 12 * k + if p + 12 > len(tiff): + break + tag, typ, cnt = struct.unpack(e + "HHI", tiff[p : p + 8]) + entries.append((tag, typ, cnt, tiff[p + 8 : p + 12])) + return e, entries + + +def exif_ifd0_tags(data: bytes) -> list[int]: + """The IFD0 tag numbers of every EXIF block in a JPEG's header (0x8825 = GPS pointer).""" + tags: list[int] = [] + for marker, payload in header_segments(data): + if marker == 0xE1 and payload.startswith(b"Exif\x00\x00"): + parsed = _tiff_ifd0(payload[6:]) + if parsed: + tags.extend(t for t, *_ in parsed[1]) + return tags + + +def read_orientation(data: bytes) -> int | None: + """The EXIF Orientation (1–8) of a JPEG, or None when absent or unreadable.""" + for marker, payload in header_segments(data): + if marker != 0xE1 or not payload.startswith(b"Exif\x00\x00"): + continue + parsed = _tiff_ifd0(payload[6:]) + if not parsed: + return None + e, entries = parsed + for tag, typ, cnt, value in entries: + if tag == _ORIENTATION_TAG and typ == 3 and cnt == 1: + v = struct.unpack(e + "H", value[:2])[0] + return v if 1 <= v <= 8 else None + return None + return None + + +def _orientation_segment(orientation: int) -> bytes: + """A minimal EXIF APP1 carrying ONLY the Orientation tag (big-endian TIFF, one IFD0 entry).""" + ifd = struct.pack(">H", 1) + struct.pack(">HHIH2x", _ORIENTATION_TAG, 3, 1, orientation) + struct.pack(">I", 0) + payload = b"Exif\x00\x00" + b"MM\x00\x2a" + struct.pack(">I", 8) + ifd + return b"\xff\xe1" + struct.pack(">H", len(payload) + 2) + payload + + +def sanitize_jpeg(data: bytes) -> bytes: + """Rebuild a JPEG from the segments a decoder needs; see the module docstring. When the + original was rotated (Orientation ≠ 1), a minimal EXIF block carrying only that is + written back so the photo still displays upright.""" + if data[:2] != b"\xff\xd8": + raise PhotoError("not a JPEG") + orientation = read_orientation(data) + pending = _orientation_segment(orientation) if orientation and orientation != 1 else b"" + out = bytearray(b"\xff\xd8") + i, n = 2, len(data) + while i < n: + if data[i] != 0xFF: + raise PhotoError(f"corrupt JPEG (expected a marker at byte {i})") + if i + 1 < n and data[i + 1] == 0xFF: # fill byte + i += 1 + continue + if i + 1 >= n: + break + marker = data[i + 1] + if marker == _EOI: + if pending: + raise PhotoError("corrupt JPEG (no image data)") + out += b"\xff\xd9" + return bytes(out) # anything after the end-of-image marker is dropped + if marker in _STANDALONE: + out += data[i : i + 2] + i += 2 + continue + if i + 4 > n: + break + length = struct.unpack(">H", data[i + 2 : i + 4])[0] + if length < 2 or i + 2 + length > n: + raise PhotoError("corrupt JPEG (a segment runs past the end of the file)") + segment, payload = data[i : i + 2 + length], data[i + 4 : i + 2 + length] + i += 2 + length + if 0xE0 <= marker <= 0xEF: + if not _keep_app(marker, payload): + continue + elif marker == 0xFE: # COM + continue + if pending and marker != 0xE0: # after SOI and any JFIF header, before everything else + out += pending + pending = b"" + out += segment + if marker == _SOS: + j = i # entropy-coded data runs to the next real marker + while j < n: + if data[j] != 0xFF: + j += 1 + continue + if j + 1 >= n: + j = n + break + nxt = data[j + 1] + if nxt == 0x00 or 0xD0 <= nxt <= 0xD7: + j += 2 + elif nxt == 0xFF: + j += 1 + else: + break + out += data[i:j] + i = j + raise PhotoError("truncated JPEG (no end-of-image marker) — the upload may have been cut off") + + +# ── PNG ──────────────────────────────────────────────────────────────────────── +#: Image and colour chunks only; text, EXIF, timestamps and private chunks are dropped. +_PNG_KEEP = { + b"IHDR", + b"PLTE", + b"IDAT", + b"IEND", + b"tRNS", + b"gAMA", + b"cHRM", + b"sRGB", + b"iCCP", + b"sBIT", + b"bKGD", + b"pHYs", + b"hIST", + b"sPLT", + b"cICP", + b"mDCv", + b"cLLi", + b"acTL", + b"fcTL", + b"fdAT", +} + + +def sanitize_png(data: bytes) -> bytes: + if data[:8] != _PNG_SIG: + raise PhotoError("not a PNG") + out = bytearray(_PNG_SIG) + i, n = 8, len(data) + while i + 12 <= n: + length = struct.unpack(">I", data[i : i + 4])[0] + ctype = data[i + 4 : i + 8] + end = i + 12 + length + if end > n: + break + if ctype in _PNG_KEEP: + out += data[i:end] + i = end + if ctype == b"IEND": + return bytes(out) # trailing bytes after IEND are dropped + raise PhotoError("truncated PNG (no IEND chunk) — the upload may have been cut off") + + +# ── WebP ─────────────────────────────────────────────────────────────────────── +_WEBP_KEEP = {b"VP8 ", b"VP8L", b"VP8X", b"ALPH", b"ANIM", b"ANMF", b"ICCP"} +_VP8X_EXIF, _VP8X_XMP = 0x08, 0x04 + + +def sanitize_webp(data: bytes) -> bytes: + if not (len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP"): + raise PhotoError("not a WebP") + end = min(len(data), 8 + struct.unpack(" end: + raise PhotoError("truncated WebP — the upload may have been cut off") + chunk = bytearray(data[i:body_end]) + if size & 1: + chunk += b"\x00" # RIFF pads odd chunks to an even length + i = body_end + (size & 1) + if fourcc not in _WEBP_KEEP: + continue + if fourcc == b"VP8X" and size >= 1: + chunk[8] &= ~(_VP8X_EXIF | _VP8X_XMP) & 0xFF + chunks.append(bytes(chunk)) + if not any(c[:4] in (b"VP8 ", b"VP8L", b"ANMF") for c in chunks): + raise PhotoError("WebP has no image data") + body = b"WEBP" + b"".join(chunks) + return b"RIFF" + struct.pack(" bytes: + """Convert HEIC/HEIF (the iPhone default) to JPEG with macOS ``sips``.""" + sips = shutil.which("sips") + if not sips: + raise PhotoError( + "HEIC/HEIF photos can't be converted on this machine — export the photo as JPEG and upload that" + ) + with tempfile.TemporaryDirectory(prefix="inventory-heic-") as d: + src, dst = os.path.join(d, "in.heic"), os.path.join(d, "out.jpg") + with open(src, "wb") as f: + f.write(data) + try: + proc = subprocess.run([sips, "-s", "format", "jpeg", src, "--out", dst], capture_output=True, timeout=60) + except (OSError, subprocess.TimeoutExpired) as exc: + raise PhotoError(f"could not convert the HEIC photo to JPEG: {exc}") from exc + if proc.returncode != 0 or not os.path.exists(dst): + detail = (proc.stderr or b"").decode(errors="replace").strip()[-200:] or f"sips exited {proc.returncode}" + raise PhotoError(f"could not convert the HEIC photo to JPEG: {detail}") + with open(dst, "rb") as f: + return f.read() + + +def prepare_photo(data: bytes) -> tuple[bytes, str]: + """An upload → ``(sanitized bytes, ext)`` with ext in jpg/png/webp, or PhotoError.""" + if not data: + raise PhotoError("the upload is empty") + if len(data) > MAX_BYTES: + raise PhotoError( + f"photos are capped at {MAX_BYTES // (1024 * 1024)} MB; this one is {len(data) / 1048576:.1f} MB" + ) + kind = sniff(data) + if kind == "heic": + data = heic_to_jpeg(data) + kind = sniff(data) + if kind != "jpeg": + raise PhotoError("the HEIC conversion did not produce a JPEG") + if kind == "jpeg": + return sanitize_jpeg(data), "jpg" + if kind == "png": + return sanitize_png(data), "png" + if kind == "webp": + return sanitize_webp(data), "webp" + raise PhotoError("unsupported image — upload a JPEG, PNG, WebP or HEIC photo") diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 3ac8bff..0067469 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: inventory name: Inventory (resale source of truth) -version: 0.4.2 +version: 0.5.0 description: >- The resale inventory as a source of truth: lots (what you bought and for how much), items (what each lot split into, its condition and status), listings (where each item is @@ -12,8 +12,13 @@ description: >- set price with evidence, mark sold, listings, CSV import/export, stale review, re-price plan), an optional weekly-review job on the host scheduler, a console rail view (a grid with inline edits, price / sold / listing dialogs, CSV import/export, lots, sales and an activity log) and a bearer-gated JSON API under - /api/plugins/inventory. Money is stored as integer cents and exposed as dollars. Ships - DISABLED; enable with `plugins: { enabled: [inventory] }`. + /api/plugins/inventory. Money is stored as integer cents and exposed as dollars. + + Photos (stripped of location and camera metadata on upload) and a public catalog: items + marked public are written — through a field allowlist, only when the operator presses + Publish in the view — into a static site checkout as src/data/catalog.json plus their + photos, then committed and pushed. The agent can preview a publish but never publish. + Ships DISABLED; enable with `plugins: { enabled: [inventory] }`. enabled: false repository: https://github.com/protoLabsAI/inventory-plugin min_protoagent_version: "0.148.0" # sdk.plugin_store (the instance-scoped store) first shipped in 0.148 @@ -37,6 +42,12 @@ config: review_max_items: 15 # re-price at most this many items per run (eBay pacing) stale_listing_days: 14 # a live listing older than this is reported stale_price_days: 30 # price evidence older than this is re-checked + # ── Public site ── Publish (the operator's button in the view; never the agent) writes + # src/data/catalog.json and src/assets/catalog/ in this site checkout — nothing else — + # then commits those two paths and pushes, so the site's CI deploys. Blank = preview only. + site_dir: "" + publish_git: true # commit the catalog + photos after a publish + publish_push: true # and push that commit (a failed push is reported, never fatal) # Console view (ADR 0026): a left-rail icon whose panel iframes the page this plugin # serves at `path` (public prefix); its data comes through /api/plugins/inventory (gated). @@ -52,6 +63,9 @@ settings: - { key: review_max_items, label: "Items re-priced per run", type: number, description: "Caps eBay searches per review run; the rest wait for the next one." } - { key: stale_listing_days, label: "Stale listing (days)", type: number, description: "A live listing older than this is reported in the review." } - { key: stale_price_days, label: "Stale price (days)", type: number, description: "Price evidence older than this is re-checked in the review." } + - { key: site_dir, label: "Site directory", type: path, path_kind: dir, description: "The checkout of the public site. Publish writes src/data/catalog.json and src/assets/catalog/ there, nothing else." } + - { key: publish_git, label: "Commit on publish", type: bool, description: "Commit the catalog and photos in the site checkout after a publish." } + - { key: publish_push, label: "Push on publish", type: bool, description: "Push that commit so the site's CI deploys it. A failed push is reported; the files and the commit stay." } # Event bus (ADR 0039): the topics this plugin publishes. emits: @@ -59,8 +73,9 @@ emits: - { topic: inventory.lot.changed, summary: "a lot was created, updated or deleted ({id})" } - { topic: inventory.sale.recorded, summary: "a sale was recorded ({item_id, sale_id, net, channel})" } - { topic: inventory.imported, summary: "a CSV was imported ({kind, created, updated})" } + - { topic: inventory.published, summary: "the operator published the public catalog ({count, commit, pushed})" } subscribes: [] capabilities: network: [] - filesystem: scoped # its own SQLite file + CSVs the operator names + filesystem: scoped # its own SQLite file + photo store, CSVs the operator names, and on Publish the site checkout's catalog paths diff --git a/publish.py b/publish.py new file mode 100644 index 0000000..5327233 --- /dev/null +++ b/publish.py @@ -0,0 +1,375 @@ +"""The public catalog — what the Nerdsville site shows, built from the inventory through an +allowlist and written into the site checkout ONLY when the operator presses Publish. + +The contract with the site (``src/data/catalog.json``):: + + {"version": 1, "generated_at": "", "items": [ + {"id", "name", "system", "category", "condition", "price_cents", "quantity", "status", + "blurb", "photos": [{"file", "alt"}], "links": [{"channel", "url"}], "updated"}]} + +An item is in it iff it is marked public, is available or listed, has a target price and +a quantity above zero. Nothing else ever leaves: not the cost, the lot, the notes, the +low/high band, the retail anchor, the price basis, the sales or the audit trail — the item +dict is BUILT key by key from :data:`ITEM_KEYS`, never filtered down from a row. + +Photos are copied to ``src/assets/catalog//.`` and that directory is +made an exact mirror (only inside it: every path is checked to resolve under it). The +preview hashes the items; Publish recomputes and refuses (409) when the hash moved, so what +is published is exactly what the operator reviewed. When the site is a git checkout the +two paths are committed (``--only``, so nothing else the operator staged rides along) and +pushed; a push failure is reported, never raised — the files and the commit stay. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import logging +import os +import re +import shutil +import subprocess +import threading +from pathlib import Path + +from .store import InventoryError, InventoryStore, now_iso + +log = logging.getLogger("protoagent.plugins.inventory") + +CATALOG_REL = "src/data/catalog.json" +ASSETS_REL = "src/assets/catalog" +CATALOG_VERSION = 1 +#: The ONLY keys an item carries on the site, in this order. +ITEM_KEYS = ( + "id", + "name", + "system", + "category", + "condition", + "price_cents", + "quantity", + "status", + "blurb", + "photos", + "links", + "updated", +) +FOR_SALE = ("available", "listed") +GIT_TIMEOUT = 30 +PUSH_TIMEOUT = 90 +_URL_RE = re.compile(r"^https?://[^\s]+$", re.I) +_PUBLISH_LOCK = threading.Lock() + + +class PublishConflict(InventoryError): + """The inventory moved between the preview and the publish.""" + + +def _text(v) -> str: + return str(v or "").strip() + + +def build_catalog(store: InventoryStore) -> tuple[list[dict], list[dict], list[str]]: + """``(items, skipped, warnings)``: the catalog items in contract order and shape; the + public items left out and why; photos that are missing on disk (left out, not fatal).""" + items: list[dict] = [] + skipped: list[dict] = [] + warnings: list[str] = [] + for it in store.publish_source(): + if it["status"] not in FOR_SALE: + reason = f"not for sale (status {it['status']})" + elif it["target_cents"] is None: + reason = "no asking price — set a target" + elif int(it["quantity"] or 0) <= 0: + reason = "quantity is 0" + else: + reason = "" + if reason: + skipped.append({"id": it["id"], "name": it["name"], "reason": reason}) + continue + photos = [] + for p in it["photos"]: + rel = f"{it['id']}/{p['id']}.{p['ext']}" + if not (store.photos_dir / rel).is_file(): + warnings.append(f"{it['id']}: photo {p['id']} is missing on disk — left out") + continue + photos.append({"file": rel, "alt": _text(p["alt"])}) + links = [ + {"channel": _text(li["channel"]), "url": _text(li["url"])} + for li in it["listings"] + if _URL_RE.match(_text(li["url"])) + ] + items.append( + { + "id": it["id"], + "name": _text(it["name"]), + "system": _text(it["system"]), + "category": _text(it["category"]), + "condition": _text(it["condition"]), + "price_cents": int(it["target_cents"]), + "quantity": int(it["quantity"]), + "status": it["status"], + "blurb": _text(it["blurb"]), + "photos": photos, + "links": links, + "updated": _text(it["updated_at"])[:10], + } + ) + items.sort(key=lambda e: (e["system"] == "", e["system"].casefold(), e["name"].casefold(), e["id"])) + return items, skipped, warnings + + +def canonical_hash(items: list[dict]) -> str: + return hashlib.sha256(json.dumps(items, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() + + +def site_dir_of(cfg: dict) -> Path | None: + raw = _text(cfg.get("site_dir")) + return Path(raw).expanduser() if raw else None + + +def check_site_dir(site: Path | None) -> str: + """ "" when the site checkout is usable, else what is wrong with it (for the operator).""" + if site is None: + return "no site directory set — point Settings ▸ Plugins ▸ Inventory ▸ Site directory at the site checkout" + if not site.is_dir(): + return f"the site directory {site} does not exist" + if not (site / "src").is_dir(): + return f"{site} has no src/ folder — is it the site checkout?" + return "" + + +def read_published(site: Path | None) -> list[dict] | None: + """The items of the catalog currently in the site checkout; None when there is none (or + it can't be read — then everything counts as added).""" + if site is None: + return None + path = site / CATALOG_REL + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + items = data.get("items") if isinstance(data, dict) else None + return items if isinstance(items, list) else None + + +def diff(old: list[dict] | None, new: list[dict]) -> dict: + old_by = {e.get("id"): e for e in (old or []) if isinstance(e, dict)} + new_by = {e["id"]: e for e in new} + return { + "added": [{"id": i, "name": e["name"]} for i, e in new_by.items() if i not in old_by], + "removed": [{"id": i, "name": e.get("name", "")} for i, e in old_by.items() if i not in new_by], + "changed": [ + {"id": i, "name": e["name"], "fields": [k for k in ITEM_KEYS if old_by[i].get(k) != e.get(k)]} + for i, e in new_by.items() + if i in old_by and old_by[i] != e + ], + } + + +def preview(store: InventoryStore, cfg: dict) -> dict: + """What Publish would do right now — read-only; safe for the agent to call.""" + items, skipped, warnings = build_catalog(store) + site = site_dir_of(cfg) + problem = check_site_dir(site) + old = read_published(site) if not problem else None + return { + "ok": True, + "site_dir": str(site) if site else "", + "site_dir_ok": not problem, + "site_dir_problem": problem, + "hash": canonical_hash(items), + "count": len(items), + "published_count": len(old) if old is not None else 0, + **diff(old, items), + "skipped": skipped, + "warnings": warnings, + "items_preview": items, + } + + +def _write_catalog(site: Path, items: list[dict], old: list[dict] | None) -> bool: + """Write catalog.json — unless the items are unchanged (a fresh generated_at alone must not + make a diff). Temp file + rename, so the site never sees half a file.""" + if old is not None and old == items: + return False + path = site / CATALOG_REL + path.parent.mkdir(parents=True, exist_ok=True) + doc = {"version": CATALOG_VERSION, "generated_at": now_iso(), "items": items} + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + os.replace(tmp, path) + return True + + +def _inside(child: Path, root: Path) -> bool: + return child == root or root in child.parents + + +def check_targets(site: Path) -> None: + """Refuse BEFORE writing anything when either destination resolves outside the site + checkout (a symlinked src/data or src/assets would otherwise carry a write elsewhere). + ``resolve()`` follows every existing link in the chain, even when the leaf is missing.""" + site_real = site.resolve() + for rel in (CATALOG_REL, ASSETS_REL): + real = (site / rel).resolve() + if not _inside(real, site_real) or real == site_real: + raise InventoryError(f"{rel} resolves outside the site directory ({real}) — refusing to write there") + + +def _mirror_photos(store: InventoryStore, site: Path, items: list[dict]) -> tuple[int, int]: + """Make ``src/assets/catalog`` hold exactly the catalog's photos. Touches nothing outside + it: the directory itself must resolve inside the site, every write target inside it, and + the sweep never follows a symlink (a stray link is removed, its target left alone).""" + root = site / ASSETS_REL + root.mkdir(parents=True, exist_ok=True) + site_real, root_real = site.resolve(), root.resolve() + if not _inside(root_real, site_real) or root_real == site_real: + raise InventoryError(f"{ASSETS_REL} resolves outside the site directory — refusing to write there") + expected = {p["file"] for e in items for p in e["photos"]} + copied = 0 + for rel in sorted(expected): + src, dst = store.photos_dir / rel, root / rel + if dst.is_symlink(): + dst.unlink() + if dst.is_file() and dst.stat().st_size == src.stat().st_size: + continue # photo files are immutable per id; same size = same file + if dst.parent.is_symlink(): + dst.parent.unlink() + dst.parent.mkdir(parents=True, exist_ok=True) + if not _inside(dst.parent.resolve(), root_real): + raise InventoryError(f"{rel} resolves outside {ASSETS_REL} — refusing to write there") + tmp = dst.with_name(f".{dst.name}.tmp") + shutil.copyfile(src, tmp) + os.replace(tmp, dst) + copied += 1 + deleted = 0 + for dirpath, dirnames, filenames in os.walk(root_real, topdown=False, followlinks=False): + here = Path(dirpath) + for name in filenames: + path = here / name + if path.relative_to(root_real).as_posix() not in expected: + path.unlink() + deleted += 1 + for name in dirnames: + sub = here / name + if sub.is_symlink(): + sub.unlink() + deleted += 1 + continue + with contextlib.suppress(OSError): + sub.rmdir() # only when empty + return copied, deleted + + +def _git(site: Path, *args: str, timeout: int = GIT_TIMEOUT) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "-C", str(site), *args], + capture_output=True, + text=True, + timeout=timeout, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, # never hang on a credentials prompt + ) + + +def _tail(proc: subprocess.CompletedProcess) -> str: + return ((proc.stderr or "") + (proc.stdout or "")).strip()[-400:] or f"git exited {proc.returncode}" + + +def _is_git_repo(site: Path) -> bool: + try: + r = _git(site, "rev-parse", "--is-inside-work-tree") + except (OSError, subprocess.SubprocessError): + return False + return r.returncode == 0 and r.stdout.strip() == "true" + + +def _commit_and_push(site: Path, message: str, *, push: bool) -> dict: + out = {"commit": None, "pushed": False, "push_error": None, "git_error": None} + paths = [CATALOG_REL, ASSETS_REL] + try: + r = _git(site, "add", "-A", "--", *paths) + if r.returncode: + out["git_error"] = _tail(r) + return out + staged = _git(site, "diff", "--cached", "--quiet", "--", *paths).returncode == 1 + if staged: + r = _git(site, "commit", "--only", "-m", message, "--", *paths) + if r.returncode: + out["git_error"] = _tail(r) + return out + out["commit"] = _git(site, "rev-parse", "--short", "HEAD").stdout.strip() or None + if not push: + return out + ahead = _git(site, "rev-list", "--count", "@{u}..HEAD") + if not staged and ahead.returncode == 0 and ahead.stdout.strip() == "0": + return out # nothing new, nothing unpushed + try: + r = _git(site, "push", timeout=PUSH_TIMEOUT) + except subprocess.TimeoutExpired: + out["push_error"] = f"git push timed out after {PUSH_TIMEOUT}s" + return out + out["pushed"] = r.returncode == 0 + if r.returncode: + out["push_error"] = _tail(r) + except (OSError, subprocess.SubprocessError) as exc: + out["git_error"] = f"git failed: {exc}" + return out + + +def publish(store: InventoryStore, cfg: dict, expected_hash: str, *, actor: str = "console") -> dict: + """Write the reviewed catalog into the site checkout. Operator-only: the agent has a + preview tool and no publish tool. Raises PublishConflict when the inventory moved since + the preview (nothing is written), InventoryError when the site directory is unusable.""" + from .tools import as_bool + + site = site_dir_of(cfg) + problem = check_site_dir(site) + if problem: + raise InventoryError(problem) + with _PUBLISH_LOCK: + items, _skipped, warnings = build_catalog(store) + digest = canonical_hash(items) + if not expected_hash or digest != str(expected_hash): + raise PublishConflict("inventory changed since the preview — review it again") + check_targets(site) + old = read_published(site) + changes = diff(old, items) + wrote = _write_catalog(site, items, old) + copied, deleted = _mirror_photos(store, site, items) + git = {"commit": None, "pushed": False, "push_error": None, "git_error": None} + if as_bool(cfg.get("publish_git"), True) and _is_git_repo(site): + a, r, c = len(changes["added"]), len(changes["removed"]), len(changes["changed"]) + message = f"catalog: publish {len(items)} items (+{a} −{r} ~{c})" + git = _commit_and_push(site, message, push=as_bool(cfg.get("publish_push"), True)) + store.record_audit( + "publish", + digest[:12], + "publish", + actor, + { + "count": len(items), + "added": [x["id"] for x in changes["added"]], + "removed": [x["id"] for x in changes["removed"]], + "changed": [x["id"] for x in changes["changed"]], + "site_dir": str(site), + "wrote_catalog": wrote, + "photos_copied": copied, + "photos_deleted": deleted, + **git, + }, + ) + if git["push_error"] or git["git_error"]: + log.warning("[inventory] publish wrote the files but git reported: %s", git["git_error"] or git["push_error"]) + return { + "ok": True, + "hash": digest, + "count": len(items), + **changes, + "wrote_catalog": wrote, + "photos_copied": copied, + "photos_deleted": deleted, + "warnings": warnings, + **git, + } diff --git a/pyproject.toml b/pyproject.toml index a444e0f..e20362d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "inventory-plugin" -version = "0.4.2" +version = "0.5.0" description = "Resale inventory as a source of truth for protoAgent — lots, items, listings, sales, price evidence, audit" requires-python = ">=3.11" diff --git a/skills/inventory-ops/SKILL.md b/skills/inventory-ops/SKILL.md index bae5ba6..d449b94 100644 --- a/skills/inventory-ops/SKILL.md +++ b/skills/inventory-ops/SKILL.md @@ -64,6 +64,19 @@ When `weekly_review: true` is set in the plugin config, the host scheduler runs this routine as an automated turn on `weekly_review_cron` (default Monday 09:00). It uses the same tools and the same rules; the only thing it never does is touch a listing. +## Photos and the public site + +- `inventory_add_photo(item_id, path, alt)` attaches a photo from the workspace. Metadata + (location, camera) is stripped on the way in. The first photo is the cover. Write alt + text that says what is in the picture ("Griff Oberwald miniature, front view, unpainted"). +- `inventory_upsert_item(..., public=True, blurb="…")` marks an item for the site catalog. + The blurb is what a buyer reads: one or two plain sentences about the piece and its + condition. Never put cost, lot, where it came from or anything from `notes` in a blurb. +- `inventory_publish_preview()` shows what the catalog would contain, what changed since the + last publish, and which public items are left out and why (no price, not for sale). +- **You never publish.** There is no publish tool. Tell the operator what is ready and that + the Publish button is in the Inventory view; they review the diff and press it. + ## Importing a spreadsheet `inventory_import_csv(path=…)` maps common headers itself and reports `mapped_columns`, diff --git a/store.py b/store.py index f8fe086..20deafb 100644 --- a/store.py +++ b/store.py @@ -20,7 +20,9 @@ import json import logging import math +import os import re +import shutil import sqlite3 import threading import uuid @@ -61,6 +63,8 @@ lot_id TEXT NOT NULL DEFAULT '', category TEXT NOT NULL DEFAULT '', system TEXT NOT NULL DEFAULT '', + public INTEGER NOT NULL DEFAULT 0, + blurb TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, condition TEXT NOT NULL DEFAULT '', quantity INTEGER NOT NULL DEFAULT 1, @@ -119,6 +123,16 @@ notes TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS ix_obs_item ON price_observations(item_id); +CREATE TABLE IF NOT EXISTS photos ( + id TEXT PRIMARY KEY, + item_id TEXT NOT NULL, + ext TEXT NOT NULL, + alt TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL, + bytes INTEGER NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_photos_item ON photos(item_id, position); CREATE TABLE IF NOT EXISTS audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, @@ -134,6 +148,10 @@ _MIGRATIONS: list[tuple[str, str, str]] = [ # 0.4.0 — the game system an item belongs to (Warhammer 40K, Blood Bowl, …), optional. ("items", "system", "ALTER TABLE items ADD COLUMN system TEXT NOT NULL DEFAULT ''"), + # 0.5.0 — shown on the public site (opt-in, so every existing item stays private), and + # the short public description that goes with it. + ("items", "public", "ALTER TABLE items ADD COLUMN public INTEGER NOT NULL DEFAULT 0"), + ("items", "blurb", "ALTER TABLE items ADD COLUMN blurb TEXT NOT NULL DEFAULT ''"), ] @@ -248,8 +266,44 @@ def check_id(kind: str, value: str) -> str: _ITEM_MONEY = ("cost_basis", "target_low", "target", "target_high", "retail") -_ITEM_TEXT = ("lot_id", "category", "system", "name", "condition", "notes", "price_basis", "price_updated_on") +_ITEM_TEXT = ( + "lot_id", + "category", + "system", + "name", + "condition", + "notes", + "blurb", + "price_basis", + "price_updated_on", +) _ITEM_INT = ("quantity", "model_count") +_ITEM_BOOL = ("public",) +_TRUE_WORDS = {"true", "yes", "y", "1", "on"} +_FALSE_WORDS = {"false", "no", "n", "0", "off"} +#: Photo ids are uuid4 hex — checked before one ever becomes part of a path. +_PHOTO_ID_RE = re.compile(r"^[0-9a-f]{32}$") + + +def _bool_field(name: str, value) -> int | None: + """A yes/no flag from a form, a tool call or a sheet: ``None`` or blank → unset; real + bools, 0/1 and the usual words pass; anything else is an error, never a silent no.""" + if value is None: + return None + if isinstance(value, bool): + return int(value) + if isinstance(value, int | float) and value in (0, 1): + return int(value) + text = str(value).strip().lower() + if not text: + return None + if text in _TRUE_WORDS: + return 1 + if text in _FALSE_WORDS: + return 0 + raise InventoryError(f"{name} must be yes or no, got {value!r}") + + _LOT_TEXT = ("name", "description", "acquired_on", "source", "notes") @@ -262,6 +316,8 @@ def _item_row_to_dict(r: sqlite3.Row) -> dict: "system": d["system"], "name": d["name"], "condition": d["condition"], + "public": bool(d.get("public") or 0), + "blurb": d.get("blurb") or "", "quantity": d["quantity"], "model_count": d["model_count"], "notes": d["notes"], @@ -275,9 +331,17 @@ def _item_row_to_dict(r: sqlite3.Row) -> dict: "price_updated_on": d["price_updated_on"], "created_at": d["created_at"], "updated_at": d["updated_at"], + **({"photo_count": d["photo_count"]} if "photo_count" in d else {}), } +def _photo_row_to_dict(r: sqlite3.Row) -> dict: + d = dict(r) + d["file"] = f"{d['item_id']}/{d['id']}.{d['ext']}" + d["content_type"] = {"jpg": "image/jpeg", "png": "image/png", "webp": "image/webp"}.get(d["ext"], "") + return d + + def _lot_row_to_dict(r: sqlite3.Row) -> dict: d = dict(r) d["acquisition_cost"] = dollars(d.pop("acquisition_cost_cents")) @@ -318,6 +382,8 @@ class InventoryStore: def __init__(self, path: str | Path): self.path = Path(path) self.path.parent.mkdir(parents=True, exist_ok=True) + #: Uploaded photos live next to the database: ``photos//.``. + self.photos_dir = self.path.parent / "photos" with self._read() as con: con.executescript(SCHEMA) for table, column, ddl in _MIGRATIONS: @@ -364,6 +430,11 @@ def _audit(con, entity: str, entity_id, action: str, actor: str, changes: dict | (now_iso(), entity, str(entity_id), action, actor or "", json.dumps(changes or {}, default=str)), ) + def record_audit(self, entity: str, entity_id, action: str, actor: str, changes: dict | None = None) -> None: + """An audit row for an event that is not a row change (a publish).""" + with self._tx() as con: + self._audit(con, entity, entity_id, action, actor, changes) + def audit_log(self, limit: int = 100, entity_id: str = "") -> list[dict]: with self._read() as con: if entity_id: @@ -427,6 +498,7 @@ def list_lots(self) -> list[dict]: def delete_lot(self, lot_id: str, *, actor: str = "", cascade: bool = False) -> int: """Remove a lot. Refuses while items reference it unless ``cascade`` (which removes them too — every removed row is snapshotted into the audit trail).""" + ids: list[str] = [] with self._tx() as con: n = con.execute("SELECT COUNT(*) FROM items WHERE lot_id=?", (lot_id,)).fetchone()[0] if n and not cascade: @@ -441,7 +513,10 @@ def delete_lot(self, lot_id: str, *, actor: str = "", cascade: bool = False) -> self._audit( con, "lot", lot_id, "delete", actor, {"cascade": cascade, "items": n, "snapshot": dict(row)} ) - return cur.rowcount + deleted = cur.rowcount + for iid in ids: # files only after the rows are gone for good + self._remove_photo_dir(iid) + return deleted # ── items ───────────────────────────────────────────────────────────────── @staticmethod @@ -455,6 +530,11 @@ def _item_fields(data: dict) -> dict: for k in _ITEM_INT: if k in data and data[k] is not None and not (isinstance(data[k], str) and not data[k].strip()): fields[k] = _int_field(k, data[k]) + for k in _ITEM_BOOL: + if k in data: + flag = _bool_field(k, data[k]) + if flag is not None: + fields[k] = flag for k in _ITEM_MONEY: if k in data: cents = money_field(k, data[k]) @@ -526,6 +606,13 @@ def get_item(self, item_id: str) -> dict | None: (item_id,), ) ] + item["photos"] = [ + _photo_row_to_dict(r) + for r in con.execute( + "SELECT * FROM photos WHERE item_id=? ORDER BY position, created_at, id", (item_id,) + ) + ] + item["photo_count"] = len(item["photos"]) return item def systems(self) -> list[str]: @@ -584,7 +671,7 @@ def list_items( "(lower(name) LIKE ? OR lower(notes) LIKE ? OR lower(id) LIKE ? OR lower(category) LIKE ? OR lower(system) LIKE ?)" ) args.extend([like, like, like, like, like]) - sql = "SELECT * FROM items" + sql = "SELECT items.*, (SELECT COUNT(*) FROM photos p WHERE p.item_id=items.id) AS photo_count FROM items" if where: sql += " WHERE " + " AND ".join(where) # Game system first, so the grid and a copied list read system → lot → category. @@ -606,17 +693,192 @@ def _delete_item_rows(con, item_id: str, actor: str) -> int: "observations": [ dict(r) for r in con.execute("SELECT * FROM price_observations WHERE item_id=?", (item_id,)) ], + "photos": [dict(r) for r in con.execute("SELECT * FROM photos WHERE item_id=?", (item_id,))], } con.execute("DELETE FROM items WHERE id=?", (item_id,)) con.execute("DELETE FROM listings WHERE item_id=?", (item_id,)) con.execute("DELETE FROM sales WHERE item_id=?", (item_id,)) con.execute("DELETE FROM price_observations WHERE item_id=?", (item_id,)) + con.execute("DELETE FROM photos WHERE item_id=?", (item_id,)) InventoryStore._audit(con, "item", item_id, "delete", actor, {"snapshot": snapshot}) return 1 def delete_item(self, item_id: str, *, actor: str = "") -> int: with self._tx() as con: - return self._delete_item_rows(con, item_id, actor) + n = self._delete_item_rows(con, item_id, actor) + if n: # the photo files go only once the rows are committed away + self._remove_photo_dir(item_id) + return n + + # ── photos ──────────────────────────────────────────────────────────────── + def _photo_dir(self, item_id: str) -> Path: + """``photos/`` — the id is re-checked and the result must sit directly under + the photos directory, so no id can steer a write or a delete anywhere else.""" + folder = self.photos_dir / check_id("item", item_id) + if folder.resolve().parent != self.photos_dir.resolve(): + raise InventoryError(f"item id {item_id!r} does not map to a photo folder") + return folder + + def _remove_photo_dir(self, item_id: str) -> None: + with contextlib.suppress(InventoryError, OSError): + folder = self._photo_dir(item_id) + if folder.is_dir() and not folder.is_symlink(): + shutil.rmtree(folder, ignore_errors=True) + + def add_photo(self, item_id: str, data: bytes, *, alt: str = "", actor: str = "") -> dict: + """Store an uploaded photo for an item: sniffed, converted (HEIC → JPEG) and stripped + of metadata first (see photos.py), then written to a temp name and renamed into place + inside the same transaction as its row and audit entry — a failed write leaves + neither a row nor a file behind. New photos go last; position 0 is the cover.""" + from .photos import PhotoError, prepare_photo + + folder = self._photo_dir(item_id) + if self.get_item_row(item_id) is None: + raise InventoryError(f"no item {item_id!r}") + try: + clean, ext = prepare_photo(bytes(data or b"")) + except PhotoError as exc: + raise InventoryError(str(exc)) from exc + photo_id = uuid.uuid4().hex + folder.mkdir(parents=True, exist_ok=True) + final = folder / f"{photo_id}.{ext}" + tmp = folder / f".{photo_id}.{ext}.tmp" + tmp.write_bytes(clean) + try: + with self._tx() as con: + if con.execute("SELECT 1 FROM items WHERE id=?", (item_id,)).fetchone() is None: + raise InventoryError(f"no item {item_id!r}") + pos = con.execute( + "SELECT COALESCE(MAX(position) + 1, 0) FROM photos WHERE item_id=?", (item_id,) + ).fetchone()[0] + ts = now_iso() + con.execute( + "INSERT INTO photos(id, item_id, ext, alt, position, bytes, created_at) VALUES (?,?,?,?,?,?,?)", + (photo_id, item_id, ext, str(alt or ""), pos, len(clean), ts), + ) + con.execute("UPDATE items SET updated_at=? WHERE id=?", (ts, item_id)) + self._audit( + con, + "photo", + photo_id, + "create", + actor, + {"item_id": item_id, "ext": ext, "bytes": len(clean), "alt": str(alt or ""), "position": pos}, + ) + os.replace(tmp, final) + row = con.execute("SELECT * FROM photos WHERE id=?", (photo_id,)).fetchone() + except BaseException: + tmp.unlink(missing_ok=True) + final.unlink(missing_ok=True) + with contextlib.suppress(OSError): + folder.rmdir() # only if this left it empty + raise + return _photo_row_to_dict(row) + + def get_item_row(self, item_id: str) -> dict | None: + with self._read() as con: + row = con.execute("SELECT * FROM items WHERE id=?", (item_id,)).fetchone() + return dict(row) if row else None + + def list_photos(self, item_id: str) -> list[dict]: + with self._read() as con: + rows = con.execute( + "SELECT * FROM photos WHERE item_id=? ORDER BY position, created_at, id", (item_id,) + ).fetchall() + return [_photo_row_to_dict(r) for r in rows] + + def photo_file(self, item_id: str, photo_id: str) -> tuple[Path, str] | None: + """``(path, content type)`` of a stored photo, or None.""" + if not _PHOTO_ID_RE.match(str(photo_id or "")): + return None + with self._read() as con: + row = con.execute("SELECT * FROM photos WHERE id=? AND item_id=?", (photo_id, item_id)).fetchone() + if row is None: + return None + d = _photo_row_to_dict(row) + try: + path = self._photo_dir(item_id) / f"{photo_id}.{d['ext']}" + except InventoryError: + return None + return (path, d["content_type"]) if path.is_file() else None + + @staticmethod + def _renumber_photos(con, item_id: str, order: list[str]) -> None: + for k, pid in enumerate(order): + con.execute("UPDATE photos SET position=? WHERE id=?", (k, pid)) + + def update_photo(self, item_id: str, photo_id: str, *, alt=None, position=None, actor: str = "") -> dict: + """Change a photo's alt text and/or move it (0 = cover); the rest renumber around it.""" + if not _PHOTO_ID_RE.match(str(photo_id or "")): + raise InventoryError(f"no photo {photo_id!r} on item {item_id!r}") + with self._tx() as con: + row = con.execute("SELECT * FROM photos WHERE id=? AND item_id=?", (photo_id, item_id)).fetchone() + if row is None: + raise InventoryError(f"no photo {photo_id!r} on item {item_id!r}") + changes: dict = {} + if alt is not None: + con.execute("UPDATE photos SET alt=? WHERE id=?", (str(alt), photo_id)) + changes["alt"] = str(alt) + if position is not None and not (isinstance(position, str) and not position.strip()): + order = [ + r[0] + for r in con.execute( + "SELECT id FROM photos WHERE item_id=? ORDER BY position, created_at, id", (item_id,) + ) + ] + order.remove(photo_id) + pos = max(0, min(_int_field("position", position), len(order))) + order.insert(pos, photo_id) + self._renumber_photos(con, item_id, order) + changes["position"] = pos + if changes: + con.execute("UPDATE items SET updated_at=? WHERE id=?", (now_iso(), item_id)) + self._audit(con, "photo", photo_id, "update", actor, {"item_id": item_id, **changes}) + row = con.execute("SELECT * FROM photos WHERE id=?", (photo_id,)).fetchone() + return _photo_row_to_dict(row) + + def delete_photo(self, item_id: str, photo_id: str, *, actor: str = "") -> int: + """Remove a photo: the row (and a renumber) in one transaction, the file after commit.""" + if not _PHOTO_ID_RE.match(str(photo_id or "")): + return 0 + with self._tx() as con: + row = con.execute("SELECT * FROM photos WHERE id=? AND item_id=?", (photo_id, item_id)).fetchone() + if row is None: + return 0 + con.execute("DELETE FROM photos WHERE id=?", (photo_id,)) + order = [ + r[0] + for r in con.execute( + "SELECT id FROM photos WHERE item_id=? ORDER BY position, created_at, id", (item_id,) + ) + ] + self._renumber_photos(con, item_id, order) + con.execute("UPDATE items SET updated_at=? WHERE id=?", (now_iso(), item_id)) + self._audit(con, "photo", photo_id, "delete", actor, {"item_id": item_id, "snapshot": dict(row)}) + with contextlib.suppress(InventoryError, OSError): + (self._photo_dir(item_id) / f"{photo_id}.{row['ext']}").unlink(missing_ok=True) + return 1 + + def publish_source(self) -> list[dict]: + """Every item marked public, each with its photos (in order) and live listings — read + in ONE transaction so the snapshot is consistent. Raw rows: publish.py decides what + (little) of this leaves the building.""" + with self._read() as con: + con.execute("BEGIN") + try: + items = [dict(r) for r in con.execute("SELECT * FROM items WHERE public=1")] + photos: dict[str, list[dict]] = {} + for r in con.execute("SELECT * FROM photos ORDER BY item_id, position, created_at, id"): + photos.setdefault(r["item_id"], []).append(dict(r)) + listings: dict[str, list[dict]] = {} + for r in con.execute("SELECT * FROM listings WHERE state='active' ORDER BY id"): + listings.setdefault(r["item_id"], []).append(dict(r)) + finally: + con.rollback() + for it in items: + it["photos"] = photos.get(it["id"], []) + it["listings"] = listings.get(it["id"], []) + return items # ── pricing ─────────────────────────────────────────────────────────────── @staticmethod diff --git a/tests/test_photos.py b/tests/test_photos.py new file mode 100644 index 0000000..8ae297d --- /dev/null +++ b/tests/test_photos.py @@ -0,0 +1,334 @@ +"""Photo intake: sniffing, the metadata strippers (hand-built fixtures — no Pillow in the +runtime, none in the suite), and the store's photo rows + files.""" + +from __future__ import annotations + +import sqlite3 +import struct +import zlib + +import pytest +from inventory_plugin import photos as ph +from inventory_plugin.store import InventoryError, InventoryStore + + +# ── fixture builders ─────────────────────────────────────────────────────────── +def seg(marker: int, payload: bytes) -> bytes: + return b"\xff" + bytes([marker]) + struct.pack(">H", len(payload) + 2) + payload + + +def exif_app1(orientation: int | None = 6, gps: bool = True, le: bool = False) -> bytes: + """An EXIF APP1 with Make, Orientation and a GPS IFD (GPSLatitudeRef 'N').""" + e = "<" if le else ">" + entries = [(0x010F, 2, 4, b"Cam\x00")] + if orientation is not None: + entries.append((0x0112, 3, 1, struct.pack(e + "H", orientation) + b"\x00\x00")) + n = len(entries) + (1 if gps else 0) + gps_off = 8 + 2 + 12 * n + 4 + if gps: + entries.append((0x8825, 4, 1, struct.pack(e + "I", gps_off))) + ifd = ( + struct.pack(e + "H", len(entries)) + + b"".join(struct.pack(e + "HHI", t, ty, c) + v for t, ty, c, v in entries) + + struct.pack(e + "I", 0) + ) + gps_ifd = ( + (struct.pack(e + "H", 1) + struct.pack(e + "HHI", 0x0001, 2, 2) + b"N\x00\x00\x00" + struct.pack(e + "I", 0)) + if gps + else b"" + ) + tiff = (b"II" if le else b"MM") + struct.pack(e + "H", 42) + struct.pack(e + "I", 8) + ifd + gps_ifd + return seg(0xE1, b"Exif\x00\x00" + tiff) + + +SCAN = b"\x12\x34\xff\x00\x56\xff\xd0\x78\x9a" # a stuffed byte and a restart marker inside the scan +FRAME = [ + seg(0xDB, b"\x00" + bytes(64)), + seg(0xC0, b"\x08\x00\x01\x00\x01\x01\x01\x11\x00"), + seg(0xC4, b"\x00" + bytes(16) + b"\x00"), +] + + +def jpeg(orientation: int | None = 6, gps: bool = True, extras: bool = True, trailer: bool = True, le=False) -> bytes: + parts = [b"\xff\xd8", seg(0xE0, b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"), exif_app1(orientation, gps, le)] + if extras: + parts += [ + seg(0xE1, b"http://ns.adobe.com/xap/1.0/\x0045,31N"), + seg(0xED, b"Photoshop 3.0\x008BIM iptc-city-Portland"), + seg(0xE2, b"ICC_PROFILE\x00\x01\x01profile-bytes"), + seg(0xE2, b"MPF\x00multi-picture"), + seg(0xFE, b"shot at 45.5N 122.6W"), + ] + parts += [*FRAME, seg(0xDA, b"\x01\x01\x00\x00\x3f\x00"), SCAN, b"\xff\xd9"] + if trailer: # the second image an iPhone appends after the first EOI, with its own EXIF + parts.append(b"\xff\xd8" + exif_app1(1, True) + b"\xff\xd9") + return b"".join(parts) + + +def png_chunk(ctype: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + ctype + data + struct.pack(">I", zlib.crc32(ctype + data) & 0xFFFFFFFF) + + +def png_file() -> bytes: + return ( + b"\x89PNG\r\n\x1a\n" + + png_chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + + png_chunk(b"tEXt", b"Comment\x00at home") + + png_chunk(b"eXIf", b"MM\x00*GPS") + + png_chunk(b"iTXt", b"XML:com.adobe.xmp\x00\x00\x00\x00\x00GPS") + + png_chunk(b"zTXt", b"k\x00\x00x") + + png_chunk(b"tIME", b"\x07\xea\x09\x0d\x0c\x00\x00") + + png_chunk(b"prVt", b"private") + + png_chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00")) + + png_chunk(b"IEND", b"") + + b"trailing junk GPS" + ) + + +def riff_chunk(fourcc: bytes, data: bytes) -> bytes: + return fourcc + struct.pack(" bytes: + vp8x = bytes([0x20 | 0x08 | 0x04, 0, 0, 0]) + b"\x00\x00\x00\x00\x00\x00" + body = ( + b"WEBP" + + riff_chunk(b"VP8X", vp8x) + + riff_chunk(b"ICCP", b"icc") + + riff_chunk(b"VP8 ", b"abc") # odd length → padded + + riff_chunk(b"EXIF", b"MM\x00*GPS-here") + + riff_chunk(b"XMP ", b"GPS") + ) + return b"RIFF" + struct.pack(" list[tuple[bytes, bytes]]: + out, i = [], 12 + while i + 8 <= len(data): + size = struct.unpack(" list[bytes]: + out, i = [], 8 + while i + 12 <= len(data): + length = struct.unpack(">I", data[i : i + 4])[0] + ctype, body, crc = data[i + 4 : i + 8], data[i + 8 : i + 8 + length], data[i + 8 + length : i + 12 + length] + assert struct.unpack(">I", crc)[0] == zlib.crc32(ctype + body) & 0xFFFFFFFF + out.append(ctype) + i += 12 + length + return out + + +# ── sniffing ─────────────────────────────────────────────────────────────────── +def test_sniff_reads_the_bytes_not_the_name(): + assert ph.sniff(jpeg()) == "jpeg" + assert ph.sniff(png_file()) == "png" + assert ph.sniff(webp_file()) == "webp" + assert ph.sniff(b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic") == "heic" + assert ph.sniff(b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00isommp42") is None # a video is not a photo + assert ph.sniff(b"GIF89a....") is None + assert ph.sniff(b"") is None + + +# ── JPEG ─────────────────────────────────────────────────────────────────────── +@pytest.mark.parametrize("le", [False, True]) +def test_jpeg_loses_gps_and_every_other_tag_but_keeps_orientation_6(le): + src = jpeg(orientation=6, le=le) + assert ph.read_orientation(src) == 6 and 0x8825 in ph.exif_ifd0_tags(src) + out = ph.sanitize_jpeg(src) + assert ph.read_orientation(out) == 6 + assert ph.exif_ifd0_tags(out) == [0x0112] # ONLY orientation: no GPS pointer, no Make + assert out.count(b"Exif\x00\x00") == 1 + for needle in (b"45,31N", b"Portland", b"shot at", b"xmpmeta", b"MPF\x00", b"Cam\x00", b"8BIM"): + assert needle not in out, needle + assert b"ICC_PROFILE" in out and out[2:4] == b"\xff\xe0" # colour kept; JFIF stays first + assert [m for m, _ in ph.header_segments(out)][:2] == [0xE0, 0xE1] # orientation right after JFIF + assert SCAN in out # stuffed bytes and restart markers inside the scan survive verbatim + assert out.endswith(b"\xff\xd9") and out.count(b"\xff\xd8") == 1 # the appended second image is gone + + +def test_jpeg_without_rotation_keeps_no_exif_at_all(): + for src in (jpeg(orientation=1), jpeg(orientation=None), jpeg(orientation=None, gps=False, extras=False)): + out = ph.sanitize_jpeg(src) + assert b"Exif" not in out and ph.read_orientation(out) is None + + +def test_jpeg_sanitize_is_idempotent_and_keeps_multi_scan_images(): + once = ph.sanitize_jpeg(jpeg()) + assert ph.sanitize_jpeg(once) == once + progressive = b"".join( + [ + b"\xff\xd8", + *FRAME, + seg(0xDA, b"\x01\x01\x00\x00\x3f\x00"), + b"\x01\x02\xff\x00", + seg(0xC4, b"\x10" + bytes(16) + b"\x00"), # a table between scans + seg(0xDA, b"\x01\x01\x00\x00\x3f\x00"), + b"\x03\x04", + b"\xff\xd9", + ] + ) + out = ph.sanitize_jpeg(progressive) + assert out == progressive # nothing to strip, nothing lost + + +def test_a_truncated_jpeg_is_refused(): + src = jpeg(trailer=False) + with pytest.raises(ph.PhotoError, match="truncated|corrupt"): + ph.sanitize_jpeg(src[:-2]) + with pytest.raises(ph.PhotoError): + ph.sanitize_jpeg(src[:40]) + + +# ── PNG / WebP ───────────────────────────────────────────────────────────────── +def test_png_keeps_only_image_chunks_with_valid_crcs(): + out = ph.sanitize_png(png_file()) + assert png_types(out) == [b"IHDR", b"IDAT", b"IEND"] + assert b"GPS" not in out and b"at home" not in out and not out.endswith(b"junk GPS") + + +def test_webp_drops_exif_and_xmp_and_clears_their_flags(): + out = ph.sanitize_webp(webp_file()) + chunks = webp_chunks(out) + assert [c for c, _ in chunks] == [b"VP8X", b"ICCP", b"VP8 "] + assert chunks[0][1][0] == 0x20 # ICC flag kept, EXIF + XMP flags cleared + assert struct.unpack("(.*?)', PAGE, re.S).group(1) + harness = ( + "globalThis.BASE = ''; globalThis.location = { pathname: '/plugins/inventory/view' };\n" + "const el = () => new Proxy({}, { get: (t, k) => (k === 'addEventListener' || k === 'removeEventListener' || k === 'appendChild' || k === 'remove' || k === 'focus' ? () => {} : k === 'querySelectorAll' ? () => [] : k === 'querySelector' || k === 'closest' ? () => el() : k === 'classList' ? { toggle() {}, add() {}, remove() {} } : k === 'dataset' ? {} : k === 'hidden' ? true : ''), set: () => true });\n" + "globalThis.document = { querySelector: () => el(), querySelectorAll: () => [], getElementById: () => el(), createElement: () => el(), addEventListener() {}, removeEventListener() {}, body: el() };\n" + "globalThis.setTimeout = () => 0; globalThis.clearTimeout = () => 0; globalThis.fetch = async () => ({ ok: false, status: 0, statusText: 'stub', text: async () => '' });\n" + ) + probe = ( + "\nconsole.log(JSON.stringify({" + " chips: itemChips({ public: true, photo_count: 2 }), one: itemChips({ public: false, photo_count: 1 }), none: itemChips({ public: false, photo_count: 0 })," + " card: photoCard({ id: 'x\">', PAGE, re.S).group(1) + harness = ( + "globalThis.BASE = ''; globalThis.location = { pathname: '/plugins/inventory/view' };\n" + "const el = () => new Proxy({}, { get: (t, k) => (k === 'addEventListener' || k === 'removeEventListener' || k === 'appendChild' || k === 'remove' || k === 'focus' ? () => {} : k === 'querySelectorAll' ? () => [] : k === 'querySelector' || k === 'closest' ? () => el() : k === 'classList' ? { toggle() {}, add() {}, remove() {} } : k === 'dataset' ? {} : k === 'hidden' ? true : ''), set: () => true });\n" + "globalThis.document = { querySelector: () => el(), querySelectorAll: () => [], getElementById: () => el(), createElement: () => el(), addEventListener() {}, removeEventListener() {}, body: el() };\n" + "globalThis.setTimeout = () => 0; globalThis.clearTimeout = () => 0; globalThis.fetch = async () => ({ ok: false, status: 0, statusText: 'stub', text: async () => '' });\n" + ) + hexes = [f"{k:032x}" for k in range(8)] + entry = { + "id": "A", + "name": "Griff", + "price_cents": 2650, + "condition": "NoS", + "quantity": 2, + "blurb": "Sealed\nline two", + "photos": [{"file": f"A/{h}.jpg", "alt": '">Sealed" not in pv # the blurb is shown, escaped + assert pv.count("data-thumb-photo=") == 6 and "+2 more" in pv # thumbnails capped, the rest counted + assert f'data-thumb-item="A" data-thumb-photo="{hexes[0]}"' in pv + assert "IIBBBBB", 1, 1, 8, 2, 0, 0, 0) + src = ( + b"\x89PNG\r\n\x1a\n" + + png_chunk(b"IHDR", body) + + png_chunk(b"iCCP", b"Josh's MacBook Pro\x00\x00" + zlib.compress(b"icc")) + + png_chunk(b"sPLT", b"HOME-sentinel\x00\x08") + + png_chunk(b"cICP", b"\x01\x0d\x00\x01") + + png_chunk(b"IDAT", zlib.compress(b"\x00\x00\x00\x00")) + + png_chunk(b"IEND", b"") + ) + out = ph.sanitize_png(src) + assert png_types(out) == [b"IHDR", b"iCCP", b"IDAT", b"IEND"] # CRCs checked by png_types + assert b"MacBook" not in out and b"HOME-sentinel" not in out and b"ICC profile\x00" in out + + +@pytest.mark.parametrize("kind", ["chunk", "flag"]) +def test_animated_webp_is_refused(kind): + if kind == "chunk": + body = ( + b"WEBP" + + riff_chunk(b"VP8X", bytes([0x02]) + bytes(9)) + + riff_chunk(b"ANIM", bytes(6)) + + riff_chunk(b"ANMF", bytes(16) + riff_chunk(b"EXIF", b"GPS")) + ) + else: + body = b"WEBP" + riff_chunk(b"VP8X", bytes([0x02]) + bytes(9)) + riff_chunk(b"VP8 ", b"abc") + with pytest.raises(ph.PhotoError, match="animated WebP"): + ph.sanitize_webp(b"RIFF" + struct.pack(".test/", False), + ("ftp://ebay.com/x", False), + ("javascript:alert(1)", False), + ("", False), + ], +) +def test_public_url(url, ok): + assert bool(pub.public_url(url)) is ok + + +def test_links_with_a_blank_channel_or_a_bad_url_are_dropped(store): + _item(store, "A") + store.add_listing("A", channel=" ", url="https://www.ebay.com/itm/1", actor="t") + store.add_listing("A", channel="eBay", url="https://user:pw@www.ebay.com/itm/2?token=x", actor="t") + store.add_listing("A", channel="Etsy", url="https://www.etsy.com/listing/3", actor="t") + (entry,), _, _ = pub.build_catalog(store) + assert entry["links"] == [{"channel": "Etsy", "url": "https://www.etsy.com/listing/3"}] + + +def test_a_published_column_in_a_sheet_is_not_the_public_flag(store): + from inventory_plugin.csvio import import_csv + + out = import_csv(store, "inventory_id,item,published\nA,Team A,yes\n", actor="t") + assert out["ok"] and store.get_item("A")["public"] is False + + +# ── 8. private edits aren't site changes ─────────────────────────────────────── +def test_editing_only_private_fields_is_not_a_change_on_the_site(store, tmp_path, monkeypatch): + site = tmp_path / "site" + (site / "src").mkdir(parents=True) + (site / "package.json").write_text("{}") + cfg = {"site_dir": str(site), "publish_git": False} + _item(store, "A", photo=True) + _publish(store, cfg) + published = json.loads((site / "src/data/catalog.json").read_text())["items"][0]["updated"] + with sqlite3.connect(store.path) as con: # make "today" visibly different from the published date + con.execute("UPDATE items SET updated_at='2030-01-01T00:00:00Z'") + store.upsert_item({"id": "A", "notes": "new private note", "cost_basis": 3}, actor="t") + p = pub.preview(store, cfg) + assert (p["added"], p["removed"], p["changed"]) == ([], [], []) + assert p["items_preview"][0]["updated"] == published + out = pub.publish(store, cfg, p["hash"]) + assert out["wrote_catalog"] is False + store.upsert_item({"id": "A", "blurb": "Now with a blurb"}, actor="t") # a shown field: a real change + (changed,) = pub.preview(store, cfg)["changed"] + assert "blurb" in changed["fields"] # (updated moves too unless it is still the published day) + + +# ── 9 + 10. the site dir must look like the site; git must be there ───────────── +def test_a_site_dir_must_have_src_and_a_site_config(store, tmp_path): + (tmp_path / "home" / "src").mkdir(parents=True) + p = pub.preview(store, {"site_dir": str(tmp_path / "home")}) + assert not p["site_dir_ok"] and "missing an astro.config.mjs or package.json" in p["site_dir_problem"] + (tmp_path / "home" / "astro.config.mjs").write_text("") + assert pub.preview(store, {"site_dir": str(tmp_path / "home")})["site_dir_ok"] is True + + +def test_no_git_on_path_is_reported(store, tmp_path, monkeypatch): + site = tmp_path / "site" + (site / "src").mkdir(parents=True) + (site / "package.json").write_text("{}") + _item(store, "A") + monkeypatch.setattr(pub.shutil, "which", lambda name: None) + out = _publish(store, {"site_dir": str(site)}) + assert out["git_error"] == "git is not installed or not on PATH — files were written but not committed" + assert (site / "src/data/catalog.json").exists() + + +def test_a_site_dir_that_is_not_a_git_checkout_says_so(store, tmp_path): + site = tmp_path / "site" + (site / "src").mkdir(parents=True) + (site / "package.json").write_text("{}") + _item(store, "A") + out = _publish(store, {"site_dir": str(site)}) + assert "isn't a git checkout" in out["git_error"] and out["commit"] is None + + +def test_store_path_is_the_db(tmp_path): + s = InventoryStore(tmp_path / "x" / "inventory.db") + assert s.photos_dir == tmp_path / "x" / "photos" diff --git a/tools.py b/tools.py index d99e33e..5da441c 100644 --- a/tools.py +++ b/tools.py @@ -329,6 +329,13 @@ def inventory_reprice_plan(lot_id: str = "", max_items: int = 15, price_days: in def inventory_add_photo(item_id: str, path: str, alt: str = "") -> str: """Attach a photo file (JPEG, PNG, WebP, or HEIC on a Mac) from the agent workspace to an item. Location, camera and other metadata are stripped on the way in. The first photo is the item's cover on the public site. `alt` describes the photo for people using screen readers, e.g. "Griff Oberwald miniature, front view, unpainted".""" try: + if not str(cfg.get("workspace_dir") or "").strip(): + return _err( + InventoryError( + "no agent workspace is configured, so photos can't be read from disk — " + "set inventory.workspace_dir, or add photos in the Inventory view" + ) + ) p = resolve_workspace_path(cfg, path) if not p.is_file(): return _err(InventoryError(f"no file at {path!r}")) diff --git a/view.py b/view.py index 06df3f1..95ecb54 100644 --- a/view.py +++ b/view.py @@ -60,7 +60,11 @@ .photo-actions{display:flex;gap:4px;flex-wrap:wrap;align-items:center} .photo-status{font-size:12px;color:var(--pl-color-fg-muted);min-height:1em} .pv-h{font-weight:600;margin-top:var(--pl-space-3)} - .pv-list{margin:4px 0 0;padding-left:18px;max-height:180px;overflow:auto} + .pv-list{margin:4px 0 0;padding-left:18px;max-height:320px;overflow:auto} + .pv-entry{margin:4px 0 10px}.pv-price{font-weight:600}.pv-blurb{white-space:pre-line;margin-top:2px} + .pv-thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px;align-items:center} + .pv-thumb{width:64px;height:64px;border-radius:4px;overflow:hidden;background:rgba(127,127,127,.15)} + .pv-thumb img{width:100%;height:100%;object-fit:cover;display:block} @media (max-width:640px){.form{grid-template-columns:1fr}.bar .pl-input,.bar .pl-select{min-width:100px}}