Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e0bf16a2a | ||
|
|
79df0df53f | ||
|
|
99798db8f6 | ||
|
|
11e8e1a544 | ||
|
|
7297138630 | ||
|
|
97c8a36bac | ||
|
|
90af03d597 | ||
|
|
8aad2278a0 | ||
|
|
e9df1f05d8 | ||
|
|
2d25b7e28e | ||
|
|
0ab90a01cb | ||
|
|
9ec626302b | ||
|
|
8a27df9e9c | ||
|
|
79f03acea8 | ||
|
|
1b11b2bd62 | ||
|
|
d8003b11fb | ||
|
|
9f434a801a | ||
|
|
14af9502d4 | ||
|
|
283010dccb | ||
|
|
3685308d10 | ||
|
|
afa9288b4b | ||
|
|
c80cf15bf1 | ||
|
|
6a4fd40bc3 | ||
|
|
7c1faa1515 | ||
|
|
1e2b763566 | ||
|
|
757ac7394d | ||
|
|
156b3fd14c | ||
|
|
e3d8e01e5b | ||
|
|
4b348dcbc0 | ||
|
|
d82db48e4b | ||
|
|
20bf7ee03d | ||
|
|
cb594f34d8 | ||
|
|
bfc5d9a871 | ||
|
|
74f6b9a876 | ||
|
|
84f249a774 | ||
|
|
a13acedd90 | ||
|
|
15734c9195 | ||
|
|
48057fe1f3 | ||
|
|
9ab454373b | ||
|
|
33e048cea9 | ||
|
|
4c4abe23c6 | ||
|
|
d26db3f6e3 | ||
|
|
5bdaf21828 | ||
|
|
7a6f6963f9 | ||
|
|
41c28592a2 | ||
|
|
3cfa72cb25 | ||
|
|
8552f1d152 |
@@ -129,21 +129,19 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
|
|||||||
A push to `main` builds the image and deploys to Komodo; the live instance at
|
A push to `main` builds the image and deploys to Komodo; the live instance at
|
||||||
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
|
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
|
||||||
|
|
||||||
**Gadfly reviews the PR as opened, not as merged.** The workflow triggers on
|
**One sweep, not a loop.** Take Gadfly's *initial* review, fix what's real, and
|
||||||
`opened`/`reopened`/`ready_for_review` — deliberately *not* `synchronize` — so
|
merge once the build is green. Do NOT re-trigger Gadfly on the fix commits and
|
||||||
every commit you push afterwards, including the ones you push in response to
|
wait for it again — a re-review-per-fix loop burns ~10 min a pass and drags a
|
||||||
Gadfly itself, is unreviewed unless you ask. Once you've stopped pushing and
|
small PR out for an hour (Steve's explicit call, 2026-07-22). The initial review
|
||||||
before you merge, comment **`@gadfly review`** on the PR to re-trigger it. The
|
is the check; your own build/test/judgment covers the fixes. Gadfly is advisory
|
||||||
phrase is required, and this is not hypothetical: on #76 the follow-up commit
|
and never blocks merge, so green build + fixes applied is enough.
|
||||||
was the one that contained a real bug.
|
|
||||||
|
|
||||||
**A skipped Gadfly run reports success.** A comment without the trigger phrase
|
(Mechanics, if you ever *do* need a manual re-run: the workflow triggers on
|
||||||
still starts the workflow, which logs `comment does not contain trigger phrase`
|
`opened`/`reopened`/`ready_for_review`, not `synchronize`, so pushes don't
|
||||||
and exits green in ~2 seconds. So "the pipeline is green" does NOT mean "this
|
re-review; a `@gadfly review` comment does. A comment without that exact phrase
|
||||||
was reviewed". Confirm a re-review actually ran by its **duration** — a real
|
still runs and exits green in ~2s — a skip that looks like a pass, so judge a
|
||||||
pass takes ~10 minutes, a skip takes 2 seconds. Don't look for a new consensus
|
real review by its ~10-min duration, not its status. Gadfly edits its consensus
|
||||||
comment: Gadfly EDITS its existing status-board and consensus comments in place,
|
comment in place, so `updated_at` moves but `created_at` doesn't.)
|
||||||
so their `created_at` stays at the first review and only `updated_at` moves.
|
|
||||||
|
|
||||||
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
|
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
|
||||||
without the PR dance.
|
without the PR dance.
|
||||||
@@ -163,6 +161,25 @@ Agent config: `OLLAMA_CLOUD_API_KEY`, `PANSY_AGENT_MODEL` (default
|
|||||||
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
|
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
|
||||||
failover for free — don't parse that grammar in pansy.
|
failover for free — don't parse that grammar in pansy.
|
||||||
|
|
||||||
|
The model and enabled flag are also **admin-editable at runtime** in Settings
|
||||||
|
(#79); the env vars are just defaults (precedence: DB setting → env → default).
|
||||||
|
Two things this makes load-bearing:
|
||||||
|
|
||||||
|
- **The live Runner is hot-swapped, not built once.** It sits behind an
|
||||||
|
`atomic.Pointer` in `internal/api` (`agentHolder`), and its routes are
|
||||||
|
registered *unconditionally* with a nil-check on `agent.get()`. Do NOT go back
|
||||||
|
to registering the chat routes only when a key is present — a settings change
|
||||||
|
has to be able to turn the assistant on without a restart, which a missing
|
||||||
|
route can't. `/capabilities` reads the pointer, so it reflects the live state.
|
||||||
|
- **`OLLAMA_CLOUD_API_KEY` stays in the environment, never the DB.** Model
|
||||||
|
selection is a setting; the key is not. A secret in `instance_settings` lands
|
||||||
|
in every backup and in the undo history's blast radius. The Settings API
|
||||||
|
reports whether a key is *present*, never its value.
|
||||||
|
- The "how to turn a spec into a model" knowledge lives once in
|
||||||
|
`internal/agentmodel`, imported by both `agent` (to run) and `service` (to
|
||||||
|
validate a spec before storing it). It can't live in `agent` — `agent` imports
|
||||||
|
`service`, so a `service`→`agent` import would cycle.
|
||||||
|
|
||||||
`majordomo` is a **real dependency** now, resolved from the Gitea instance as a
|
`majordomo` is a **real dependency** now, resolved from the Gitea instance as a
|
||||||
pseudo-version. There is no `replace` directive and there must not be one: a
|
pseudo-version. There is no `replace` directive and there must not be one: a
|
||||||
`replace` pointing at `../majordomo` builds on your laptop and breaks the Docker
|
`replace` pointing at `../majordomo` builds on your laptop and breaks the Docker
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
|
|||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
|
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
|
||||||
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and the failure mode it prevents are written out once in `hexCenters`; #75 is what getting it wrong looked like.
|
- **A fill is one of two operations (#77).** A plop is a *clump*, not a plant, which is the right primitive for SKETCHING ("a few plops of garlic in a corner") but can't draw a real planting — a filled bed comes out as ~15 blobs, not 8 rows of garlic. So `FillRegion`/`FillNamedRegion` take a `FillLayout`: `clump` (default; plop radius 1.5×spacing, ~7 plants each — quick coverage) or `grid` (radius spacing/2, pitch = spacing, ONE plant per plop — a layout you could plant from). Surfaced on `POST /objects/:id/fill` (`layout`) and the agent's `fill_region` (`mode`). Same centered `hexCenters` lattice for both, but BOTH the plop radius (`plopRadiusFor`) and the edge inset (`edgeInset`) differ by layout: a grid plant sits at the plop's centre, so it insets a half-spacing; a clump's plants reach its rim, so it insets radius-less-a-half and overhangs the edge by that half — reusing the clump formula for grid would inset by zero and plant flush on the edge. A grid-filled bed approaches the low-hundreds-of-plops the SVG budget was sized for, which the semantic-zoom tiers already anticipate.
|
||||||
|
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and how it differs by layout are written out once in `edgeInset` (which `hexCenters` then honours); #75 is what getting it wrong looked like.
|
||||||
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
|
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
|
||||||
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes.
|
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
|
||||||
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
||||||
|
- **Instance settings (#79):** admin-editable, instance-wide config in a single-row `instance_settings` table — pansy's first DB-stored *instance* state (everything else hangs off a garden/object). Today it holds the agent model + on/off; **secrets never move here** — `OLLAMA_CLOUD_API_KEY` stays in the env so it doesn't land in backups or the undo history. Precedence: Settings value → env → default. The live agent Runner sits behind an `atomic.Pointer` in the API layer (`agentHolder`) with its routes always registered, so a settings change swaps it with no restart and no race against in-flight requests; `/capabilities` reads that pointer, so it reports what's live rather than what was configured at boot. The model/registry knowledge lives in one leaf package (`internal/agentmodel`) that both the runner and the settings validator import — agent imports service, so it can live in neither.
|
||||||
|
- **Seed-packet capture (#81):** photograph a packet → a *vision* model (separate `vision_model` setting) reads it into structured fields via one-shot `majordomo.Generate[SeedPacket]` — NOT an agent loop, so the extraction can't touch the garden; it only reads a picture and returns data. The image is normalized to JPEG at the upload boundary (`internal/imagenorm`: decodes HEIC/webp/png/jpeg, since majordomo's stdlib media path can't do HEIC — the iPhone default; it also bakes in the JPEG EXIF orientation, since the re-encode strips EXIF and a phone photo tagged "rotate 90°" would otherwise reach the model sideways). The hard part is **catalog matching, not OCR**: a wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates — it surfaces ranked candidates (`matchPlants`) and the user confirms, then `CreateFromPacket` makes the plant (new or existing) + the lot. Plants/lots aren't in the undo history (they're catalog/inventory), so there's no change set to wrap. The extractor is injectable on the service (`WithPacketExtractor`) so the whole path tests hermetically against majordomo's `fake` provider.
|
||||||
- **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
|
- **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
|
||||||
|
|
||||||
## Domain model
|
## Domain model
|
||||||
@@ -64,14 +67,21 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
|
|||||||
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
||||||
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
||||||
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
||||||
|
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect
|
||||||
|
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
|
||||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||||
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
||||||
|
POST /seed-lots/scan ← multipart image → a seed-packet proposal (reads only, no writes)
|
||||||
|
POST /seed-lots/from-packet ← confirmed proposal → a plant (new or existing) + a lot
|
||||||
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
||||||
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
||||||
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
||||||
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
||||||
GET /capabilities ← what this instance can do, so the UI offers only what works
|
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
|
||||||
|
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model
|
||||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||||
|
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
|
||||||
|
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
|
||||||
```
|
```
|
||||||
|
|
||||||
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
|
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
|
||||||
@@ -118,7 +128,8 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
|
|||||||
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
|
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
|
||||||
|
|
||||||
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
||||||
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
|
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag, and the mobile `mode`.
|
||||||
|
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (shown once a bed is focused; focusing a bed puts you in this mode — a "Recent" strip of what you've most recently planted *in this garden* (#100, derived from plantings, not the manual tray) for one-tap re-arming, the seed tray, the picker, and a clump/rows **fill** control that runs the region fill (#77) the UI couldn't reach before), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
|
||||||
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
||||||
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
||||||
|
|
||||||
|
|||||||
@@ -67,13 +67,16 @@ The garden assistant reads three more. Setting none of them leaves the assistant
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
|
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
|
||||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is disabled, not broken — the chat routes simply aren't registered. |
|
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is off, not broken. This is the one agent value that stays in the environment — it is **never** stored in the database or editable in Settings. |
|
||||||
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. |
|
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Default model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. An admin can override this per-instance in **Settings** without a redeploy; a blank Settings value inherits this. |
|
||||||
| `PANSY_AGENT_ENABLED` | on when a key is present | Turns the assistant off without removing the key. |
|
| `PANSY_AGENT_ENABLED` | on when a key is present | Default on/off for the assistant. Also overridable in Settings (which can inherit this default). |
|
||||||
|
| `PANSY_VISION_MODEL` | *(empty)* | Default model for **seed-packet capture** (photograph a packet → it fills in the plant + purchase). A *vision-capable* model (the chat model may not be). Empty = the feature isn't offered. Runs against the same `OLLAMA_CLOUD_API_KEY`, and is overridable in Settings. |
|
||||||
|
|
||||||
|
The agent model + enabled flag, and the vision model, can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — an agent change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence is **Settings value, if set → env var → built-in default**.
|
||||||
|
|
||||||
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
||||||
|
|
||||||
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs `garden assistant disabled` at startup with which of the three conditions failed, so the answer is in the first few lines of the log.
|
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs why the assistant is off at startup, and Settings shows the same status (a key present, the resolved model, and whether it's actually running).
|
||||||
|
|
||||||
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ go 1.26.2
|
|||||||
require (
|
require (
|
||||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
|
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0
|
github.com/coreos/go-oidc/v3 v3.20.0
|
||||||
|
github.com/gen2brain/heic v0.7.1
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
github.com/samber/slog-gin v1.15.0
|
github.com/samber/slog-gin v1.15.0
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/crypto v0.36.0
|
||||||
|
golang.org/x/image v0.44.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
modernc.org/sqlite v1.34.4
|
modernc.org/sqlite v1.34.4
|
||||||
)
|
)
|
||||||
@@ -33,6 +35,7 @@ require (
|
|||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/ebitengine/purego v0.10.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
@@ -51,14 +54,15 @@ require (
|
|||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/tetratelabs/wazero v1.12.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
|
|||||||
@@ -26,12 +26,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||||
|
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||||
|
github.com/gen2brain/heic v0.7.1 h1:Aha1sZdKEeZeWl5o0xkSg7NBRhhkrlokGVCRri+2Qcc=
|
||||||
|
github.com/gen2brain/heic v0.7.1/go.mod h1:ja42wMJc4fpnKsfdUJxeZa2YqqRnes1wS0xqs5+8o5w=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
@@ -126,6 +130,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
|||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||||
|
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
@@ -144,11 +150,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
|||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||||
|
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -163,27 +171,27 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7
|
|||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
|||||||
+15
-23
@@ -10,12 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
)
|
)
|
||||||
@@ -41,29 +39,23 @@ type Runner struct {
|
|||||||
model llm.Model
|
model llm.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner resolves the configured model and returns a Runner, or an error if
|
// NewRunner resolves modelSpec against pansy's registry and returns a Runner, or
|
||||||
// the assistant can't be offered. Callers should treat an error as "no
|
// an error if the assistant can't be offered. Callers should treat an error as
|
||||||
// assistant" rather than a startup failure — an instance with no key must still
|
// "no assistant" rather than a startup failure — an instance with no key must
|
||||||
// serve the app.
|
// still serve the app.
|
||||||
func NewRunner(svc *service.Service, cfg *config.Config) (*Runner, error) {
|
//
|
||||||
if !cfg.Agent.Ready() {
|
// It takes the key and spec explicitly rather than a *config.Config so the same
|
||||||
|
// constructor serves both boot (from env) and a runtime settings change (from
|
||||||
|
// the DB) — the Runner has no idea which one configured it.
|
||||||
|
func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) {
|
||||||
|
if apiKey == "" {
|
||||||
return nil, errors.New("agent: not configured")
|
return nil, errors.New("agent: not configured")
|
||||||
}
|
}
|
||||||
|
// agentmodel.Resolve already rejects an empty/blank spec, so don't duplicate
|
||||||
// A private registry, not the package-level default: pansy passes the key it
|
// that guard here — one place decides what a valid spec is.
|
||||||
// was configured with rather than depending on ambient environment, and
|
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||||
// majordomo's own ollama-cloud preset reads OLLAMA_API_KEY while pansy (like
|
|
||||||
// gadfly) is configured with OLLAMA_CLOUD_API_KEY. Registering the provider
|
|
||||||
// explicitly makes that bridge visible instead of a mysterious empty token.
|
|
||||||
reg := majordomo.New()
|
|
||||||
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(cfg.Agent.OllamaCloudAPIKey)))
|
|
||||||
|
|
||||||
// The spec goes to Parse VERBATIM. The grammar — including comma-separated
|
|
||||||
// failover chains — is majordomo's, and re-implementing any of it here would
|
|
||||||
// only mean two places to update when it grows.
|
|
||||||
model, err := reg.Parse(cfg.Agent.Model)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("agent: resolve model %q: %w", cfg.Agent.Model, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Runner{svc: svc, model: model}, nil
|
return &Runner{svc: svc, model: model}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
)
|
)
|
||||||
@@ -61,7 +60,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("bed: %v", err)
|
t.Fatalf("bed: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
|
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
|
||||||
t.Fatalf("seed garlic: %v", err)
|
t.Fatalf("seed garlic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,24 +231,24 @@ func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewRunnerNeedsConfiguration — an instance with no key must not get a
|
// TestNewRunnerNeedsConfiguration — an instance with no key or no model must not
|
||||||
// half-built runner; the caller treats the error as "no assistant" and carries on.
|
// get a half-built runner; the caller treats the error as "no assistant" and
|
||||||
|
// carries on. (Whether the assistant is ENABLED is resolved before NewRunner is
|
||||||
|
// reached, so it isn't NewRunner's concern any more.)
|
||||||
func TestNewRunnerNeedsConfiguration(t *testing.T) {
|
func TestNewRunnerNeedsConfiguration(t *testing.T) {
|
||||||
svc, _ := newAgentTestService(t)
|
svc, _ := newAgentTestService(t)
|
||||||
for _, cfg := range []*config.Config{
|
for _, tc := range []struct{ key, model string }{
|
||||||
{Agent: config.AgentConfig{Enabled: false, OllamaCloudAPIKey: "k", Model: "ollama-cloud/x"}},
|
{"", "ollama-cloud/x"},
|
||||||
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "", Model: "ollama-cloud/x"}},
|
{"k", ""},
|
||||||
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "k", Model: ""}},
|
{"k", " "},
|
||||||
} {
|
} {
|
||||||
if _, err := NewRunner(svc, cfg); err == nil {
|
if _, err := NewRunner(svc, tc.key, tc.model); err == nil {
|
||||||
t.Errorf("NewRunner accepted %+v", cfg.Agent)
|
t.Errorf("NewRunner accepted key=%q model=%q", tc.key, tc.model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A model spec naming a provider that doesn't exist is a configuration
|
// A model spec naming a provider that doesn't exist is a configuration
|
||||||
// error, not a panic at first use.
|
// error, not a panic at first use.
|
||||||
if _, err := NewRunner(svc, &config.Config{Agent: config.AgentConfig{
|
if _, err := NewRunner(svc, "k", "nonesuch/model"); err == nil {
|
||||||
Enabled: true, OllamaCloudAPIKey: "k", Model: "nonesuch/model",
|
|
||||||
}}); err == nil {
|
|
||||||
t.Error("NewRunner accepted an unresolvable model spec")
|
t.Error("NewRunner accepted an unresolvable model spec")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,8 +127,9 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
|
|||||||
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"`
|
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"`
|
||||||
PlantID int64 `json:"plantId" description:"plant to fill with"`
|
PlantID int64 `json:"plantId" description:"plant to fill with"`
|
||||||
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
|
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
|
||||||
|
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
|
||||||
}) (any, error) {
|
}) (any, error) {
|
||||||
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride)
|
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adapter) findPlant(ctx context.Context, args struct {
|
func (a *adapter) findPlant(ctx context.Context, args struct {
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("bed: %v", err)
|
t.Fatalf("bed: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
|
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
|
||||||
t.Fatalf("seed the garlic: %v", err)
|
t.Fatalf("seed the garlic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Package agentmodel holds the ONE place that knows how pansy turns a model spec
|
||||||
|
// into a majordomo model: which provider to register and under which token.
|
||||||
|
//
|
||||||
|
// It exists as a leaf so both internal/agent (which builds the run loop) and
|
||||||
|
// internal/service (which validates a spec before storing it as a setting) can
|
||||||
|
// share that knowledge without an import cycle — agent imports service, so the
|
||||||
|
// shared bit can live in neither of them.
|
||||||
|
package agentmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registry builds the private majordomo registry pansy uses.
|
||||||
|
//
|
||||||
|
// Private, not the package-level default: pansy passes the key it was configured
|
||||||
|
// with rather than depending on ambient environment, and majordomo's own
|
||||||
|
// ollama-cloud preset reads OLLAMA_API_KEY while pansy (like gadfly) is
|
||||||
|
// configured with OLLAMA_CLOUD_API_KEY. Registering the provider explicitly
|
||||||
|
// makes that bridge visible instead of a mysterious empty token.
|
||||||
|
func registry(apiKey string) *majordomo.Registry {
|
||||||
|
reg := majordomo.New()
|
||||||
|
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(apiKey)))
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve parses a model spec against pansy's registry into a live model. The
|
||||||
|
// spec goes to Parse VERBATIM — the grammar, including comma-separated failover
|
||||||
|
// chains, is majordomo's, and re-implementing any of it here would only mean two
|
||||||
|
// places to update when it grows.
|
||||||
|
func Resolve(apiKey, spec string) (llm.Model, error) {
|
||||||
|
if strings.TrimSpace(spec) == "" {
|
||||||
|
return nil, errors.New("agentmodel: empty model spec")
|
||||||
|
}
|
||||||
|
m, err := registry(apiKey).Parse(spec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("agentmodel: resolve %q: %w", spec, err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports whether a spec resolves, without building anything the caller
|
||||||
|
// keeps — the cheap, deterministic check a settings save runs to reject a typo.
|
||||||
|
//
|
||||||
|
// It does NOT make a live call, so it needs no working key and won't catch a
|
||||||
|
// model that is merely absent upstream; that surfaces on first use. Parse
|
||||||
|
// resolving (known provider, well-formed spec) is the half worth doing eagerly.
|
||||||
|
func Validate(apiKey, spec string) error {
|
||||||
|
_, err := Resolve(apiKey, spec)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package agentmodel
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestValidate is what the settings PATCH relies on to reject a typo at save
|
||||||
|
// time rather than on the next chat turn.
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
if err := Validate("k", "ollama-cloud/glm-5.2:cloud"); err != nil {
|
||||||
|
t.Errorf("valid spec rejected: %v", err)
|
||||||
|
}
|
||||||
|
// No key needed to validate — Parse resolves the provider, it doesn't call it.
|
||||||
|
if err := Validate("", "ollama-cloud/glm-5.2:cloud"); err != nil {
|
||||||
|
t.Errorf("valid spec rejected without a key: %v", err)
|
||||||
|
}
|
||||||
|
// A comma-separated failover chain is majordomo grammar and must resolve.
|
||||||
|
if err := Validate("k", "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud"); err != nil {
|
||||||
|
t.Errorf("failover chain rejected: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", " ", "nonesuch/model"} {
|
||||||
|
if err := Validate("k", bad); err == nil {
|
||||||
|
t.Errorf("Validate accepted %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveReturnsAModel confirms a good spec yields a usable model handle.
|
||||||
|
func TestResolveReturnsAModel(t *testing.T) {
|
||||||
|
m, err := Resolve("k", "ollama-cloud/glm-5.2:cloud")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("resolve returned a nil model with no error")
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -56,6 +56,16 @@ type stepEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handlers) agentChat(c *gin.Context) {
|
func (h *handlers) agentChat(c *gin.Context) {
|
||||||
|
// The route is always registered, so the assistant being off is a runtime
|
||||||
|
// state, not a missing route: answer it plainly rather than 404ing a path
|
||||||
|
// that exists. Loaded once here so a settings-driven swap mid-request can't
|
||||||
|
// make it flip between the guard and the Run call.
|
||||||
|
runner := h.agent.get()
|
||||||
|
if runner == nil {
|
||||||
|
writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req chatRequest
|
var req chatRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
|
||||||
@@ -78,7 +88,7 @@ func (h *handlers) agentChat(c *gin.Context) {
|
|||||||
stopBeat := stream.keepAlive(keepAliveInterval)
|
stopBeat := stream.keepAlive(keepAliveInterval)
|
||||||
defer stopBeat()
|
defer stopBeat()
|
||||||
|
|
||||||
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||||
replayHistory(history),
|
replayHistory(history),
|
||||||
func(s mdagent.Step) {
|
func(s mdagent.Step) {
|
||||||
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentHolder owns the live assistant Runner and lets an admin swap it at
|
||||||
|
// runtime when the model settings change (#79).
|
||||||
|
//
|
||||||
|
// The Runner used to be a plain handlers field set once at boot, with the chat
|
||||||
|
// routes registered only when it existed. That made the assistant permanently
|
||||||
|
// whatever the environment said at startup. Now the routes are always
|
||||||
|
// registered and the Runner lives behind an atomic pointer, so a settings change
|
||||||
|
// can turn the assistant on, off, or onto a different model without a restart —
|
||||||
|
// and without a data race against in-flight requests reading the pointer.
|
||||||
|
//
|
||||||
|
// A nil pointer means "no assistant right now"; handlers nil-check get() rather
|
||||||
|
// than assuming a Runner is present.
|
||||||
|
type agentHolder struct {
|
||||||
|
svc *service.Service
|
||||||
|
|
||||||
|
ptr atomic.Pointer[agent.Runner]
|
||||||
|
// rebuildMu serializes rebuilds so two concurrent settings saves can't
|
||||||
|
// interleave into a torn "resolve A, resolve B, store A, store B" swap. The
|
||||||
|
// read path (get) stays lock-free on the atomic pointer.
|
||||||
|
rebuildMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAgentHolder builds the holder and resolves the initial Runner from whatever
|
||||||
|
// the settings + environment currently say. A resolution failure is logged and
|
||||||
|
// left as "no assistant", never fatal: a garden planner must still boot.
|
||||||
|
func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
|
||||||
|
h := &agentHolder{svc: svc}
|
||||||
|
h.rebuild(ctx)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the current Runner, or nil if the assistant is off.
|
||||||
|
func (h *agentHolder) get() *agent.Runner { return h.ptr.Load() }
|
||||||
|
|
||||||
|
// rebuild resolves the effective agent configuration and swaps the Runner to
|
||||||
|
// match: a new one when the assistant should be on, nil when it shouldn't. It is
|
||||||
|
// safe to call at boot and from a settings save; concurrent calls serialize.
|
||||||
|
//
|
||||||
|
// It logs what it did rather than returning an error, because every caller wants
|
||||||
|
// the same thing — best-effort apply, keep serving either way — and a settings
|
||||||
|
// save must not fail just because the new model won't resolve. The save already
|
||||||
|
// validated the spec; a rebuild failure here means the environment changed under
|
||||||
|
// it, and "assistant off, with a reason in the log" is the right outcome.
|
||||||
|
func (h *agentHolder) rebuild(ctx context.Context) {
|
||||||
|
h.rebuildMu.Lock()
|
||||||
|
defer h.rebuildMu.Unlock()
|
||||||
|
|
||||||
|
eff, err := h.svc.EffectiveAgent(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// EffectiveAgent errors only if the settings row can't be read — a DB fault,
|
||||||
|
// and a very transient one when it happens right after a settings write. We
|
||||||
|
// keep the current Runner rather than tear down a working assistant on a
|
||||||
|
// blip: the state is already persisted, so the next rebuild (any later save,
|
||||||
|
// or a restart) reconciles it. Loud, because a persistent failure here means
|
||||||
|
// the live assistant no longer matches stored settings.
|
||||||
|
slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !eff.Ready() {
|
||||||
|
if h.ptr.Swap(nil) != nil {
|
||||||
|
slog.Info("api: garden assistant turned off",
|
||||||
|
"enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
|
||||||
|
if err != nil {
|
||||||
|
// Configured but unusable. Turn the assistant off rather than leaving a
|
||||||
|
// stale Runner on the old model — an admin who just pointed it at a broken
|
||||||
|
// spec should see it stop, not silently keep answering on the previous one.
|
||||||
|
slog.Error("api: garden assistant disabled (model won't resolve)", "error", err, "model", eff.Model)
|
||||||
|
h.ptr.Store(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ptr.Store(runner)
|
||||||
|
slog.Info("api: garden assistant ready", "model", eff.Model)
|
||||||
|
}
|
||||||
@@ -6,24 +6,40 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAgentRoutesAbsentWithoutAKey — an instance with no API key must start,
|
// TestAgentDisabledWithoutAKey — an instance with no API key must start, serve
|
||||||
// serve the app, and simply not offer the assistant. The routes aren't
|
// the app, and not offer the assistant.
|
||||||
// registered at all, so this is a 404 rather than a handler that apologizes:
|
//
|
||||||
// the same shape as OIDC when unconfigured.
|
// The contract CHANGED with #79: the chat route is now always registered (so a
|
||||||
func TestAgentRoutesAbsentWithoutAKey(t *testing.T) {
|
// settings change can turn the assistant on without a restart), so "off" is a
|
||||||
|
// runtime 503 rather than a missing route. capabilities reports agent:false, and
|
||||||
|
// the frontend keys the chat tab off that — so a user never reaches the 503.
|
||||||
|
func TestAgentDisabledWithoutAKey(t *testing.T) {
|
||||||
r := authEngine(t, localCfg()) // localCfg has no agent configuration
|
r := authEngine(t, localCfg()) // localCfg has no agent configuration
|
||||||
cookie := registerAndCookie(t, r, "[email protected]")
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
gid := createGardenAPI(t, r, cookie, "G")
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
|
||||||
|
// Chat is refused, plainly, because there is no Runner to run.
|
||||||
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||||
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
|
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
|
||||||
if w.Code != http.StatusNotFound {
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
t.Errorf("chat without a key: status %d, want 404", w.Code)
|
t.Errorf("chat without a key: status %d, want 503", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capabilities advertises the assistant as unavailable, which is what the UI
|
||||||
|
// actually consults.
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("capabilities: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if agent, _ := decodeMap(t, w.Body.Bytes())["agent"].(bool); agent {
|
||||||
|
t.Error("capabilities reported agent:true with no key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// History is just stored data behind the ordinary garden-role check, so it
|
||||||
|
// reads fine (empty) whether or not a Runner exists — it isn't gated on one.
|
||||||
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
|
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
|
||||||
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusNotFound {
|
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusOK {
|
||||||
t.Errorf("history without a key: status %d, want 404", w.Code)
|
t.Errorf("history without a key: status %d, want 200 (it's data, not the model)", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// And the rest of the app is entirely unaffected.
|
// And the rest of the app is entirely unaffected.
|
||||||
|
|||||||
+52
-34
@@ -6,13 +6,13 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
sloggin "github.com/samber/slog-gin"
|
sloggin "github.com/samber/slog-gin"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
)
|
)
|
||||||
@@ -23,9 +23,12 @@ type handlers struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
svc *service.Service
|
svc *service.Service
|
||||||
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
|
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
|
||||||
// agent is nil unless the assistant is configured; the chat routes are only
|
// agent holds the live Runner behind an atomic pointer. Unlike oidc it is
|
||||||
// registered when it isn't, so a handler never has to check.
|
// never nil — the holder is always present and its Runner may be nil when the
|
||||||
agent *agent.Runner
|
// assistant is off. The chat routes are registered unconditionally and
|
||||||
|
// nil-check agent.get(), so a settings change can turn the assistant on or off
|
||||||
|
// at runtime (#79) rather than only at boot.
|
||||||
|
agent *agentHolder
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the gin engine with the standard middleware stack and registers the
|
// New builds the gin engine with the standard middleware stack and registers the
|
||||||
@@ -119,6 +122,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
objects.PATCH("/:id", h.updateObject)
|
objects.PATCH("/:id", h.updateObject)
|
||||||
objects.DELETE("/:id", h.deleteObject)
|
objects.DELETE("/:id", h.deleteObject)
|
||||||
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
|
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
|
||||||
|
// Bulk ops. These wrap the same service methods the agent tools call, so an
|
||||||
|
// instance with no model configured still gets the most valuable operation in
|
||||||
|
// the app — and so "clear bed" is ONE change set rather than one per plop.
|
||||||
|
objects.POST("/:id/fill", h.fillObject)
|
||||||
|
objects.POST("/:id/clear", h.clearObject)
|
||||||
|
|
||||||
// Plantings ("plops") are addressed by their own id; the service resolves the
|
// Plantings ("plops") are addressed by their own id; the service resolves the
|
||||||
// owning object/garden for the permission check.
|
// owning object/garden for the permission check.
|
||||||
@@ -126,37 +134,30 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
plantings.PATCH("/:id", h.updatePlanting)
|
plantings.PATCH("/:id", h.updatePlanting)
|
||||||
plantings.DELETE("/:id", h.deletePlanting)
|
plantings.DELETE("/:id", h.deletePlanting)
|
||||||
|
|
||||||
// The garden assistant, registered only when it can actually be offered —
|
// The garden assistant. Its routes are registered UNCONDITIONALLY and the live
|
||||||
// the same shape as OIDC. An instance with no API key serves the app
|
// Runner sits behind an atomic pointer in the holder, so a settings change can
|
||||||
// normally and simply doesn't have these routes.
|
// turn the assistant on or off at runtime (#79). Each handler nil-checks
|
||||||
if !cfg.Agent.Ready() {
|
// agent.get(); a chat request while the assistant is off gets a clean 503
|
||||||
// Say WHY, at startup, in the logs an operator is already looking at.
|
// (AGENT_DISABLED), not a panic and not a missing route.
|
||||||
// Someone who set the key and sees no assistant otherwise has nothing to
|
//
|
||||||
// check — and "is the variable reaching the container?" is exactly the
|
// The holder resolves its initial Runner from settings + environment at
|
||||||
// question they need answered.
|
// construction. If the key never reaches the container, the assistant is off
|
||||||
slog.Info("api: garden assistant disabled",
|
// and the reason is logged below — the same operability need #72 added.
|
||||||
"enabled", cfg.Agent.Enabled,
|
h.agent = newAgentHolder(context.Background(), svc)
|
||||||
"hasApiKey", cfg.Agent.OllamaCloudAPIKey != "",
|
if cfg.Agent.OllamaCloudAPIKey == "" {
|
||||||
"model", cfg.Agent.Model,
|
slog.Info("api: garden assistant has no API key",
|
||||||
"hint", "needs OLLAMA_CLOUD_API_KEY set in the container's environment (not just the stack's)")
|
"hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings")
|
||||||
}
|
}
|
||||||
if cfg.Agent.Ready() {
|
|
||||||
runner, err := agent.NewRunner(svc, cfg)
|
|
||||||
if err != nil {
|
|
||||||
// Configured but unusable (an unresolvable model spec, say). Log it and
|
|
||||||
// carry on without the assistant rather than refusing to start: a
|
|
||||||
// garden planner that won't boot because of a chat feature is worse
|
|
||||||
// than one without chat.
|
|
||||||
slog.Error("api: garden assistant disabled", "error", err)
|
|
||||||
} else {
|
|
||||||
h.agent = runner
|
|
||||||
agentGroup := v1.Group("/agent", h.requireAuth())
|
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||||
agentGroup.POST("/chat", h.agentChat)
|
agentGroup.POST("/chat", h.agentChat)
|
||||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||||
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
|
|
||||||
}
|
// Instance settings: admin-only, and the first thing to enforce is_admin.
|
||||||
}
|
// requireAdmin runs after requireAuth (it reads the actor requireAuth stored).
|
||||||
|
settings := v1.Group("/settings", h.requireAuth(), h.requireAdmin())
|
||||||
|
settings.GET("", h.getSettings)
|
||||||
|
settings.PATCH("", h.updateSettings)
|
||||||
|
|
||||||
// Undo. A change set is addressed by its own id; the service resolves the
|
// Undo. A change set is addressed by its own id; the service resolves the
|
||||||
// owning garden for the permission check, same as objects and plantings.
|
// owning garden for the permission check, same as objects and plantings.
|
||||||
@@ -184,6 +185,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
seedLots.GET("/:id", h.getSeedLot)
|
seedLots.GET("/:id", h.getSeedLot)
|
||||||
seedLots.PATCH("/:id", h.updateSeedLot)
|
seedLots.PATCH("/:id", h.updateSeedLot)
|
||||||
seedLots.DELETE("/:id", h.deleteSeedLot)
|
seedLots.DELETE("/:id", h.deleteSeedLot)
|
||||||
|
// Seed-packet capture (#81): scan a photo into a proposal, then create the
|
||||||
|
// plant + lot from the confirmed proposal. scan reads only.
|
||||||
|
seedLots.POST("/scan", h.scanSeedPacket)
|
||||||
|
seedLots.POST("/from-packet", h.createFromPacket)
|
||||||
|
|
||||||
// Public, unauthenticated read of a garden by its share token. Deliberately
|
// Public, unauthenticated read of a garden by its share token. Deliberately
|
||||||
// NOT behind requireAuth: the token is the capability, so a logged-out visitor
|
// NOT behind requireAuth: the token is the capability, so a logged-out visitor
|
||||||
@@ -198,11 +203,24 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
// capabilities reports what this instance can actually do, so the UI offers only
|
// capabilities reports what this instance can actually do, so the UI offers only
|
||||||
// what works.
|
// what works.
|
||||||
//
|
//
|
||||||
// It reports whether the runner BUILT, not whether it was configured: a
|
// It reports whether the assistant is live RIGHT NOW, not merely configured:
|
||||||
// configured-but-unresolvable model leaves the routes unregistered, and saying
|
// the chat routes always exist, but a request while the Runner is nil is refused,
|
||||||
// "yes" there would offer a chat tab whose first message 404s.
|
// so offering the tab must track the live Runner. Reading agent.get() (an atomic
|
||||||
|
// load) means this reflects a settings-driven swap on the very next poll.
|
||||||
func (h *handlers) capabilities(c *gin.Context) {
|
func (h *handlers) capabilities(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent != nil})
|
// vision advertises whether seed-packet scanning (#81) can be offered — a
|
||||||
|
// configured, resolvable vision model + a key. Read per-request so a settings
|
||||||
|
// change is reflected on the next poll, same as agent.
|
||||||
|
vision := false
|
||||||
|
if vis, err := h.svc.EffectiveVision(c.Request.Context()); err != nil {
|
||||||
|
// A read fault here means the DB is unhappy; report vision off (safe: the
|
||||||
|
// UI just hides a button) but don't do it silently — the same best-effort
|
||||||
|
// settings reads elsewhere log rather than swallow.
|
||||||
|
slog.Error("api: could not resolve vision settings for capabilities", "error", err)
|
||||||
|
} else {
|
||||||
|
vision = vis.Ready()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil, "vision": vision})
|
||||||
}
|
}
|
||||||
|
|
||||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bulk operations on a plantable object (#82): fill a region with one plant, and
|
||||||
|
// clear everything out of it.
|
||||||
|
//
|
||||||
|
// These were reachable only through the agent toolbox until now, which meant the
|
||||||
|
// most valuable bulk operation in a garden planner — and the one carrying the
|
||||||
|
// most carefully reasoned geometry in the codebase — did not exist at all on an
|
||||||
|
// instance with no model configured. They are thin adapters over the same
|
||||||
|
// service methods `internal/agent/tools.go` calls, so the permission checks and
|
||||||
|
// the one-change-set-per-operation guarantee come along unchanged.
|
||||||
|
|
||||||
|
// fillRect is an explicit rectangle in the object's local frame, the alternative
|
||||||
|
// to a compass name. A named type (not an inline anonymous struct) to match the
|
||||||
|
// rest of internal/api and so it can carry its own validity check.
|
||||||
|
type fillRect struct {
|
||||||
|
MinX float64 `json:"minXCm"`
|
||||||
|
MinY float64 `json:"minYCm"`
|
||||||
|
MaxX float64 `json:"maxXCm"`
|
||||||
|
MaxY float64 `json:"maxYCm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// degenerate reports whether the rect encloses no area. Such a rect (including
|
||||||
|
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
|
||||||
|
// and plant a single plop at the object's centre — a surprising result for what
|
||||||
|
// is really malformed input.
|
||||||
|
func (r fillRect) degenerate() bool {
|
||||||
|
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectFillRequest is the body for POST /objects/:id/fill.
|
||||||
|
//
|
||||||
|
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
|
||||||
|
// explicit rect in the object's local frame. The named form is what a person
|
||||||
|
// means and what the agent uses; the rect is for a future drag-a-box affordance.
|
||||||
|
// Exactly one must be supplied — accepting both and silently preferring one
|
||||||
|
// would make a client bug look like a geometry bug.
|
||||||
|
type objectFillRequest struct {
|
||||||
|
PlantID int64 `json:"plantId" binding:"required"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Rect *fillRect `json:"rect"`
|
||||||
|
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
|
||||||
|
// without editing the catalog entry.
|
||||||
|
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
|
||||||
|
// Layout is "clump" (default; fat clumps for a quick sketch) or "grid"
|
||||||
|
// (individual plants in rows at true spacing). Empty = clump. An unknown value
|
||||||
|
// is refused by the service (#77).
|
||||||
|
Layout string `json:"layout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) fillObject(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req objectFillRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
named, hasRect := req.Region != "", req.Rect != nil
|
||||||
|
if named == hasRect {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
|
||||||
|
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := mustActor(c).ID
|
||||||
|
var (
|
||||||
|
created []domain.Planting
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if rect := req.Rect; rect != nil {
|
||||||
|
// Reject a zero-area rect here rather than let it plant one stray plop.
|
||||||
|
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
|
||||||
|
// instead of reading req.Rect.MinX under an invariant from a line above.)
|
||||||
|
if rect.degenerate() {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
||||||
|
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
|
||||||
|
} else {
|
||||||
|
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 200, not 201: a fill can legitimately create nothing (the region is already
|
||||||
|
// planted), and there is no single resource to point a Location at.
|
||||||
|
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearObject soft-removes every active plop in an object.
|
||||||
|
//
|
||||||
|
// Distinct from deleting the object, and — unlike the client-side loop this
|
||||||
|
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
|
||||||
|
// rather than one per plop.
|
||||||
|
func (h *handlers) clearObject(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"cleared": n})
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fillPath(id int64) string { return objectPath(id) + "/fill" }
|
||||||
|
func clearPath(id int64) string { return objectPath(id) + "/clear" }
|
||||||
|
|
||||||
|
// makeFillPlant creates a custom plant and returns its id. (A near-identical
|
||||||
|
// createPlantAPI landed alongside the seed-lot tests; consolidating the two into
|
||||||
|
// one shared helper is a fine follow-up, kept separate here only to avoid a
|
||||||
|
// merge collision on the shared symbol.)
|
||||||
|
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||||
|
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedFillableBed makes a garden with one plantable bed and a custom plant,
|
||||||
|
// returning (gardenID, objectID, plantID).
|
||||||
|
func seedFillableBed(t *testing.T, r *gin.Engine, cookie *http.Cookie, w, h, spacing float64) (int64, int64, int64) {
|
||||||
|
t.Helper()
|
||||||
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
rec := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||||
|
"kind": "bed", "widthCm": w, "heightCm": h, "plantable": true,
|
||||||
|
}, cookie)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create bed: status %d, body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
objID := int64(decodeMap(t, rec.Body.Bytes())["id"].(float64))
|
||||||
|
plantID := makeFillPlant(t, r, cookie, "Fillable", spacing)
|
||||||
|
return gid, objID, plantID
|
||||||
|
}
|
||||||
|
|
||||||
|
// countChangeSets reads the history page and reports how many change sets exist.
|
||||||
|
func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID int64) int {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodGet, historyPath(gardenID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
sets, _ := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
|
||||||
|
return len(sets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillAndClearAPI covers the two routes end to end through the router.
|
||||||
|
//
|
||||||
|
// These exist because both operations were previously reachable ONLY through the
|
||||||
|
// agent toolbox, so on an instance with no model configured the most valuable
|
||||||
|
// bulk operation in the app did not exist at all.
|
||||||
|
func TestFillAndClearAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
||||||
|
|
||||||
|
// Fill by compass name.
|
||||||
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "all",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := decodeMap(t, w.Body.Bytes())
|
||||||
|
created := int(body["created"].(float64))
|
||||||
|
if created == 0 {
|
||||||
|
t.Fatalf("fill created nothing: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if plops, _ := body["plantings"].([]any); len(plops) != created {
|
||||||
|
t.Errorf("created=%d but returned %d plantings", created, len(plops))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear it: one call, and it reports what it removed.
|
||||||
|
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clear: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != created {
|
||||||
|
t.Errorf("cleared %d, want %d (everything the fill made)", n, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing an already-empty bed is a no-op, not an error.
|
||||||
|
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second clear: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
|
||||||
|
t.Errorf("second clear removed %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillLayoutAPI covers the layout selector (#77): grid packs denser than the
|
||||||
|
// clump default, and an unknown layout is a 400 rather than a silent clump fill.
|
||||||
|
func TestFillLayoutAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
||||||
|
|
||||||
|
// Clump (default) for the baseline count.
|
||||||
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "all",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clump fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
clumpN := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clear between fills: %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid packs individual plants at true spacing — many more, small plops.
|
||||||
|
w = doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "all", "layout": "grid",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("grid fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if gridN := int(decodeMap(t, w.Body.Bytes())["created"].(float64)); gridN <= clumpN {
|
||||||
|
t.Errorf("grid fill created %d, want more than the clump fill's %d", gridN, clumpN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown layout is a 400, not a silent clump fill.
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "all", "layout": "spiral",
|
||||||
|
}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("unknown layout = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
|
||||||
|
// its own corner of the bed.
|
||||||
|
func TestFillRegionSelectionAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
_, objID, plantID := seedFillableBed(t, r, cookie, 400, 400, 20)
|
||||||
|
|
||||||
|
// Neither → 400. Both → 400. Accepting both and silently preferring one
|
||||||
|
// would make a client bug look like a geometry bug.
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{"plantId": plantID}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("no region = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
both := map[string]any{
|
||||||
|
"plantId": plantID, "region": "all",
|
||||||
|
"rect": map[string]any{"minXCm": -50, "minYCm": -50, "maxXCm": 50, "maxYCm": 50},
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), both, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("both region and rect = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// An unknown compass name is rejected rather than silently filling nothing.
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "middle-ish",
|
||||||
|
}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("bad region name = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero-area rect is malformed input, not "plant one at the centre". An empty
|
||||||
|
// `"rect": {}` decodes to all-zeros and must be caught the same way.
|
||||||
|
for _, rect := range []map[string]any{
|
||||||
|
{}, // {} → 0,0,0,0
|
||||||
|
{"minXCm": 10, "minYCm": 10, "maxXCm": 10, "maxYCm": 50}, // zero width
|
||||||
|
{"minXCm": 10, "minYCm": 50, "maxXCm": 50, "maxYCm": 50}, // zero height
|
||||||
|
{"minXCm": 50, "minYCm": 50, "maxXCm": 10, "maxYCm": 10}, // inverted
|
||||||
|
} {
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID),
|
||||||
|
map[string]any{"plantId": plantID, "rect": rect}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("degenerate rect %v = %d, want 400", rect, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rect confined to the NE corner produces plops only there. Local frame:
|
||||||
|
// +x east, -y north.
|
||||||
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID,
|
||||||
|
"rect": map[string]any{"minXCm": 0, "minYCm": -200, "maxXCm": 200, "maxYCm": 0},
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("rect fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
plops, _ := decodeMap(t, w.Body.Bytes())["plantings"].([]any)
|
||||||
|
if len(plops) == 0 {
|
||||||
|
t.Fatal("rect fill created nothing")
|
||||||
|
}
|
||||||
|
for _, raw := range plops {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
if x, y := p["xCm"].(float64), p["yCm"].(float64); x < 0 || y > 0 {
|
||||||
|
t.Errorf("plop at (%v,%v) outside the NE rect", x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClearObjectIsOneChangeSetAPI is the regression test for the behaviour this
|
||||||
|
// endpoint exists to restore.
|
||||||
|
//
|
||||||
|
// The UI used to clear a bed with a loop of PATCHes, and since every service
|
||||||
|
// mutation auto-scopes its own change set, clearing a 40-plop bed wrote 40 of
|
||||||
|
// them — 40 presses of Undo to put the bed back. CLAUDE.md states the rule
|
||||||
|
// directly: multi-row operations record together so they undo as one unit.
|
||||||
|
func TestClearObjectIsOneChangeSetAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
gid, objID, plantID := seedFillableBed(t, r, cookie, 300, 300, 20)
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "all",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
created := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
||||||
|
if created < 4 {
|
||||||
|
t.Fatalf("need several plops to make this meaningful, got %d", created)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := countChangeSets(t, r, cookie, gid)
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clear: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if after := countChangeSets(t, r, cookie, gid); after != before+1 {
|
||||||
|
t.Errorf("clearing %d plops added %d change sets, want exactly 1", created, after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillClearPermissionsAPI: a viewer may look but not fill or clear, and a
|
||||||
|
// stranger gets 404 because existence is masked.
|
||||||
|
func TestFillClearPermissionsAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
owner := registerAndCookie(t, r, "[email protected]")
|
||||||
|
viewer := registerAndCookie(t, r, "[email protected]")
|
||||||
|
stranger := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
gid, objID, plantID := seedFillableBed(t, r, owner, 200, 200, 20)
|
||||||
|
if w := doJSON(t, r, http.MethodPost, sharesPath(gid),
|
||||||
|
map[string]any{"email": "[email protected]", "role": "viewer"}, owner); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("share as viewer: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fillBody := map[string]any{"plantId": plantID, "region": "all"}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, viewer); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("viewer fill = %d, want 403 (they can see it but may not do that)", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, viewer); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("viewer clear = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, stranger); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("stranger fill = %d, want 404 (existence masked)", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, stranger); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("stranger clear = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous fill = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous clear = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,12 @@ import (
|
|||||||
// the buyer — a lot is never shared along with a garden — so every handler here
|
// the buyer — a lot is never shared along with a garden — so every handler here
|
||||||
// scopes to the session actor with no garden in the picture.
|
// scopes to the session actor with no garden in the picture.
|
||||||
|
|
||||||
// seedLotCreateRequest is the body for POST /seed-lots.
|
// seedLotFields is the lot half of a create body — every field EXCEPT which plant
|
||||||
type seedLotCreateRequest struct {
|
// it attaches to. seedLotCreateRequest adds a required plantId; the seed-packet
|
||||||
PlantID int64 `json:"plantId" binding:"required"`
|
// confirm supplies none (the plant comes from its plantId/newPlant choice), so it
|
||||||
|
// embeds these fields directly. Sharing one struct keeps the two request shapes —
|
||||||
|
// and their validation — from drifting apart.
|
||||||
|
type seedLotFields struct {
|
||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
SourceURL string `json:"sourceUrl"`
|
SourceURL string `json:"sourceUrl"`
|
||||||
SKU string `json:"sku"`
|
SKU string `json:"sku"`
|
||||||
@@ -32,15 +35,28 @@ type seedLotCreateRequest struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
|
// toInput builds the service input with no plant attribution; callers that know
|
||||||
|
// the plant (the create handler; the packet confirm) set PlantID afterwards.
|
||||||
|
func (f seedLotFields) toInput() service.SeedLotInput {
|
||||||
return service.SeedLotInput{
|
return service.SeedLotInput{
|
||||||
PlantID: r.PlantID, Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU,
|
Vendor: f.Vendor, SourceURL: f.SourceURL, SKU: f.SKU, LotCode: f.LotCode,
|
||||||
LotCode: r.LotCode, PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear,
|
PurchasedAt: f.PurchasedAt, PackedForYear: f.PackedForYear, Quantity: f.Quantity,
|
||||||
Quantity: r.Quantity, Unit: r.Unit, CostCents: r.CostCents,
|
Unit: f.Unit, CostCents: f.CostCents, GerminationPct: f.GerminationPct, Notes: f.Notes,
|
||||||
GerminationPct: r.GerminationPct, Notes: r.Notes,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedLotCreateRequest is the body for POST /seed-lots.
|
||||||
|
type seedLotCreateRequest struct {
|
||||||
|
PlantID int64 `json:"plantId" binding:"required"`
|
||||||
|
seedLotFields
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
|
||||||
|
in := r.seedLotFields.toInput()
|
||||||
|
in.PlantID = r.PlantID
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
// seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
|
// seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
|
||||||
// optional, plus the required current version. The nullable columns are
|
// optional, plus the required current version. The nullable columns are
|
||||||
// json.RawMessage so an explicit null (clear it) is distinguishable from an
|
// json.RawMessage so an explicit null (clear it) is distinguishable from an
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedLotPath(id int64) string {
|
||||||
|
return "/api/v1/seed-lots/" + strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeList decodes a bare JSON array body. Seed lot listing returns the array
|
||||||
|
// directly rather than wrapping it (unlike /journal's {"entries": …}), so a
|
||||||
|
// helper that assumed an object would quietly read nothing.
|
||||||
|
func decodeList(t *testing.T, body []byte) []any {
|
||||||
|
t.Helper()
|
||||||
|
var out []any
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
t.Fatalf("decode list: %v (%s)", err, body)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPlantAPI makes a custom plant and returns its id.
|
||||||
|
func createPlantAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||||
|
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotCrudAPI walks the whole seed lot lifecycle over HTTP.
|
||||||
|
//
|
||||||
|
// It exists for the reason CLAUDE.md gives: service tests cannot see a route
|
||||||
|
// that was never registered, or one registered with the wrong :param name. Every
|
||||||
|
// other handler file had a sibling API test; this group did not, which is the
|
||||||
|
// state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested, and
|
||||||
|
// completely unreachable.
|
||||||
|
func TestSeedLotCrudAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
plantID := createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||||
|
|
||||||
|
// Create.
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "vendor": "Johnny's", "sku": "2761",
|
||||||
|
"quantity": 100, "unit": "seeds", "packedForYear": 2026, "costCents": 495,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lot := decodeMap(t, w.Body.Bytes())
|
||||||
|
id := int64(lot["id"].(float64))
|
||||||
|
if lot["vendor"] != "Johnny's" || lot["unit"] != "seeds" {
|
||||||
|
t.Errorf("unexpected lot: %+v", lot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET by id — the route most likely to be missing or mis-registered.
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := decodeMap(t, w.Body.Bytes()); int64(got["id"].(float64)) != id {
|
||||||
|
t.Errorf("get returned id %v, want %d", got["id"], id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List, and the ?plantId= filter.
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 1 {
|
||||||
|
t.Fatalf("list returned %d lots, want 1: %s", n, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
other := createPlantAPI(t, r, cookie, "Cherokee Purple", 45)
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+strconv.FormatInt(other, 10), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("filtered list: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||||
|
t.Errorf("filter by a plant with no lots returned %d, want 0", n)
|
||||||
|
}
|
||||||
|
// A bad plantId filter is 400, whether non-numeric or out of range — the
|
||||||
|
// handler rejects id < 1, not just unparseable strings.
|
||||||
|
for _, bad := range []string{"nope", "0", "-1"} {
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+bad, nil, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("plantId=%q filter = %d, want 400", bad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH with the current version.
|
||||||
|
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||||
|
"vendor": "Fedco", "version": lot["version"],
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
updated := decodeMap(t, w.Body.Bytes())
|
||||||
|
if updated["vendor"] != "Fedco" {
|
||||||
|
t.Errorf("vendor = %v, want Fedco", updated["vendor"])
|
||||||
|
}
|
||||||
|
if updated["version"].(float64) != lot["version"].(float64)+1 {
|
||||||
|
t.Errorf("patch didn't bump version: %v -> %v", lot["version"], updated["version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale version conflicts and carries the current row back, so the client
|
||||||
|
// can rebase without a second request.
|
||||||
|
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||||
|
"vendor": "stale", "version": lot["version"],
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("stale patch: status %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["vendor"] != "Fedco" {
|
||||||
|
t.Errorf("409 body missing the current row: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE.
|
||||||
|
if w := doJSON(t, r, http.MethodDelete, seedLotPath(id), nil, cookie); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("get after delete = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotRemainingIsDerivedAPI checks that `remaining` reflects plantings
|
||||||
|
// through the HTTP surface, not just in the service.
|
||||||
|
//
|
||||||
|
// DESIGN.md makes derivation load-bearing — "a decremented column drifts the
|
||||||
|
// moment a planting is edited behind its back" — so a route that returned a
|
||||||
|
// stored or stale figure would break the invariant silently, and the number is
|
||||||
|
// the whole reason anyone opens the seed shelf.
|
||||||
|
func TestSeedLotRemainingIsDerivedAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
plantID := createPlantAPI(t, r, cookie, "Beans", 10)
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "quantity": 50, "unit": "seeds",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create lot: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||||
|
"kind": "bed", "widthCm": 100, "heightCm": 100, "plantable": true,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
// Plant 12 of them against the lot.
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||||
|
"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20, "count": 12, "seedLotId": lotID,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get lot: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got := decodeMap(t, w.Body.Bytes())
|
||||||
|
if rem, ok := got["remaining"].(float64); !ok || rem != 38 {
|
||||||
|
t.Errorf("remaining = %v, want 38 (50 bought - 12 planted): %s", got["remaining"], w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over-plant it: 45 more (57 total against 50 bought) drives remaining
|
||||||
|
// NEGATIVE. That's deliberate — the number is a derived truth about what
|
||||||
|
// you've committed, not a floor clamped at zero, and "you've planted more than
|
||||||
|
// you bought" is exactly the signal a gardener wants rather than a hidden -7.
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||||
|
"plantId": plantID, "xCm": 40, "yCm": 40, "radiusCm": 20, "count": 45, "seedLotId": lotID,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("over-plant: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||||
|
if rem, ok := decodeMap(t, w.Body.Bytes())["remaining"].(float64); !ok || rem != -7 {
|
||||||
|
t.Errorf("remaining after over-planting = %v, want -7 (50 - 57)", rem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotsArePrivateAPI checks the ACL through the router.
|
||||||
|
//
|
||||||
|
// Lots are private to the buyer and deliberately never travel with a shared
|
||||||
|
// garden, so another user must not be able to read or edit one. Per the
|
||||||
|
// project's convention, no-access is ErrNotFound rather than ErrForbidden —
|
||||||
|
// existence is masked — so every one of these is a 404, not a 403.
|
||||||
|
func TestSeedLotsArePrivateAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
alice := registerAndCookie(t, r, "[email protected]")
|
||||||
|
bob := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
plantID := createPlantAPI(t, r, alice, "Alice's garlic", 15)
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "vendor": "Secret Vendor", "quantity": 10, "unit": "bulbs",
|
||||||
|
}, alice)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("alice create: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"get", http.MethodGet, nil},
|
||||||
|
{"patch", http.MethodPatch, map[string]any{"vendor": "hijacked", "version": 1}},
|
||||||
|
{"delete", http.MethodDelete, nil},
|
||||||
|
} {
|
||||||
|
if w := doJSON(t, r, tc.method, seedLotPath(lotID), tc.body, bob); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("bob %s = %d, want 404 (existence is masked)", tc.name, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob's own listing must not include it either.
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, bob)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bob list: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||||
|
t.Errorf("bob sees %d of alice's lots, want 0: %s", n, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it's still intact for alice.
|
||||||
|
if w := doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, alice); w.Code != http.StatusOK {
|
||||||
|
t.Errorf("alice lost access to her own lot: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotsRequireAuthAPI: the group is behind requireAuth, and an
|
||||||
|
// unauthenticated caller gets 401 rather than an empty list.
|
||||||
|
func TestSeedLotsRequireAuthAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/api/v1/seed-lots"},
|
||||||
|
{http.MethodPost, "/api/v1/seed-lots"},
|
||||||
|
{http.MethodGet, seedLotPath(1)},
|
||||||
|
{http.MethodPatch, seedLotPath(1)},
|
||||||
|
{http.MethodDelete, seedLotPath(1)},
|
||||||
|
} {
|
||||||
|
if w := doJSON(t, r, tc.method, tc.path, nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s %s = %d, want 401", tc.method, tc.path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seed-packet capture (#81). Two steps, deliberately separate:
|
||||||
|
// POST /seed-lots/scan multipart image → a proposal (reads only)
|
||||||
|
// POST /seed-lots/from-packet confirmed proposal → a plant + lot
|
||||||
|
// The scan never writes; creation happens only from an explicit confirm, so a
|
||||||
|
// misread can't add anything to the catalog on its own.
|
||||||
|
|
||||||
|
// scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at
|
||||||
|
// 25 MiB; this is a little over that for the multipart envelope. A phone photo is
|
||||||
|
// a few MB, so this is generous.
|
||||||
|
const scanUploadLimit = 30 << 20
|
||||||
|
|
||||||
|
// scanReadTimeout is how long we allow the image upload to take. The server's
|
||||||
|
// default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo
|
||||||
|
// on a slow phone connection, so this endpoint extends it — the same
|
||||||
|
// ResponseController mechanism the SSE path uses for writes (#78).
|
||||||
|
const scanReadTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// scanWriteTimeout extends the write deadline for the same reason. The server's
|
||||||
|
// absolute WriteTimeout (30s) is measured from the start of the request, but this
|
||||||
|
// handler's response can't be written until AFTER a slow upload AND a live vision
|
||||||
|
// call — together easily past 30s. Without this, a successful extraction's
|
||||||
|
// response is silently dropped: the exact failure mode #78 fixed for SSE.
|
||||||
|
const scanWriteTimeout = 120 * time.Second
|
||||||
|
|
||||||
|
// scanSeedPacket reads an uploaded packet photo and returns a proposal.
|
||||||
|
func (h *handlers) scanSeedPacket(c *gin.Context) {
|
||||||
|
// Extend both deadlines for the (potentially large, potentially slow) upload
|
||||||
|
// and the live vision call that follows. Best-effort: if the writer doesn't
|
||||||
|
// support it, the server defaults apply.
|
||||||
|
rc := http.NewResponseController(c.Writer)
|
||||||
|
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout))
|
||||||
|
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
|
||||||
|
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
|
||||||
|
file, err := c.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
// A body over scanUploadLimit trips MaxBytesReader — that's 413, not a
|
||||||
|
// malformed request. Everything else here is a genuinely missing/garbled
|
||||||
|
// multipart field.
|
||||||
|
var tooBig *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooBig) {
|
||||||
|
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
// Opening the parsed upload failed on our side, not the client's.
|
||||||
|
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not read the uploaded image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so
|
||||||
|
// everything downstream — including the vision model — only sees a format it
|
||||||
|
// can read. This is where an iPhone HEIC becomes usable.
|
||||||
|
jpeg, _, err := imagenorm.Normalize(f, imagenorm.Options{})
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, imagenorm.ErrTooLarge):
|
||||||
|
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||||
|
case errors.Is(err, imagenorm.ErrUnsupported):
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
|
||||||
|
default:
|
||||||
|
// A read or re-encode fault is ours, not bad input.
|
||||||
|
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not process the uploaded image")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
|
||||||
|
if err != nil {
|
||||||
|
// A missing vision model surfaces as ErrInvalidInput from the service; give
|
||||||
|
// it a clearer message than the generic 400, since the UI shouldn't have
|
||||||
|
// offered the button at all in that case.
|
||||||
|
if errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, prop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
|
||||||
|
// existing plant) or newPlant (create a variety), plus the lot to record. The lot
|
||||||
|
// is seedLotFields — the create body's lot half WITHOUT plantId, since the plant
|
||||||
|
// comes from the plantId/newPlant choice, not the lot body.
|
||||||
|
type fromPacketRequest struct {
|
||||||
|
PlantID *int64 `json:"plantId"`
|
||||||
|
NewPlant *plantCreateRequest `json:"newPlant"`
|
||||||
|
Lot seedLotFields `json:"lot"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFromPacket turns a confirmed proposal into a plant + lot.
|
||||||
|
func (h *handlers) createFromPacket(c *gin.Context) {
|
||||||
|
var req fromPacketRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm := service.PacketConfirm{
|
||||||
|
PlantID: req.PlantID,
|
||||||
|
Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it
|
||||||
|
}
|
||||||
|
if req.NewPlant != nil {
|
||||||
|
in := req.NewPlant.toInput()
|
||||||
|
confirm.NewPlant = &in
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, res)
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||||
|
)
|
||||||
|
|
||||||
|
// packetEngine builds an engine whose service reads seed packets via the given
|
||||||
|
// canned extractor, so the scan endpoint can be tested without a live model.
|
||||||
|
func packetEngine(t *testing.T, cfg *config.Config, extract func() (vision.SeedPacket, error)) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := store.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
if err := db.Migrate(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
svc := service.New(db, cfg, service.WithPacketExtractor(
|
||||||
|
func(context.Context, string, string, []byte) (vision.SeedPacket, error) { return extract() },
|
||||||
|
))
|
||||||
|
return New(cfg, svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// visionCfg is a config with a vision model + key configured, so packet scanning
|
||||||
|
// is available.
|
||||||
|
func visionCfg() *config.Config {
|
||||||
|
c := localCfg()
|
||||||
|
c.Agent = config.AgentConfig{OllamaCloudAPIKey: "k", VisionModel: "ollama-cloud/vision:cloud"}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// pngUpload builds a multipart body with a real PNG under the "image" field.
|
||||||
|
func pngUpload(t *testing.T) (body *bytes.Buffer, contentType string) {
|
||||||
|
t.Helper()
|
||||||
|
var img bytes.Buffer
|
||||||
|
if err := png.Encode(&img, image.NewRGBA(image.Rect(0, 0, 32, 24))); err != nil {
|
||||||
|
t.Fatalf("encode png: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
part, err := w.CreateFormFile("image", "packet.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("form file: %v", err)
|
||||||
|
}
|
||||||
|
part.Write(img.Bytes())
|
||||||
|
w.Close()
|
||||||
|
return &buf, w.FormDataContentType()
|
||||||
|
}
|
||||||
|
|
||||||
|
func doMultipart(t *testing.T, r *gin.Engine, path, contentType string, body *bytes.Buffer, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, body)
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanSeedPacketAPI: a multipart image → a proposal, end to end through the
|
||||||
|
// router. The extractor is canned; imagenorm runs for real on the uploaded PNG.
|
||||||
|
func TestScanSeedPacketAPI(t *testing.T) {
|
||||||
|
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
||||||
|
return vision.SeedPacket{Species: "garlic", Variety: "Music", Category: "vegetable"}, nil
|
||||||
|
})
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
// A matching plant so the proposal has a candidate.
|
||||||
|
createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||||
|
|
||||||
|
body, ct := pngUpload(t)
|
||||||
|
w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct, body, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("scan: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
res := decodeMap(t, w.Body.Bytes())
|
||||||
|
pkt, _ := res["packet"].(map[string]any)
|
||||||
|
if pkt["variety"] != "Music" {
|
||||||
|
t.Errorf("packet variety = %v", pkt["variety"])
|
||||||
|
}
|
||||||
|
if cands, _ := res["candidates"].([]any); len(cands) == 0 {
|
||||||
|
t.Error("expected a candidate match for Music Garlic")
|
||||||
|
}
|
||||||
|
if res["suggestedName"] != "Music" {
|
||||||
|
t.Errorf("suggestedName = %v", res["suggestedName"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanSeedPacketErrorsAPI: no image, unreadable bytes, and vision-not-
|
||||||
|
// configured each get their own clear status.
|
||||||
|
func TestScanSeedPacketErrorsAPI(t *testing.T) {
|
||||||
|
// Vision configured, so we reach imagenorm / the extractor.
|
||||||
|
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
||||||
|
return vision.SeedPacket{Variety: "X"}, nil
|
||||||
|
})
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
// No file field → 400.
|
||||||
|
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", "multipart/form-data; boundary=x", bytes.NewBufferString(""), cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("no image = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// A file that isn't an image → 400 (imagenorm rejects it).
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
part, _ := mw.CreateFormFile("image", "notes.txt")
|
||||||
|
part.Write([]byte("this is not an image"))
|
||||||
|
mw.Close()
|
||||||
|
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("non-image = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vision NOT configured → 503, even with a valid image.
|
||||||
|
r2 := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||||
|
cookie2 := registerAndCookie(t, r2, "[email protected]")
|
||||||
|
body, ct := pngUpload(t)
|
||||||
|
if w := doMultipart(t, r2, "/api/v1/seed-lots/scan", ct, body, cookie2); w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("no vision model = %d, want 503", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthenticated → 401.
|
||||||
|
b3, ct3 := pngUpload(t)
|
||||||
|
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct3, b3, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous scan = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanSeedPacketTooLargeAPI: a body over the multipart cap trips
|
||||||
|
// MaxBytesReader, which must surface as 413 (too large), not 400 (malformed).
|
||||||
|
func TestScanSeedPacketTooLargeAPI(t *testing.T) {
|
||||||
|
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
part, _ := mw.CreateFormFile("image", "big.png")
|
||||||
|
// A hair over scanUploadLimit (30 MiB) so MaxBytesReader trips during parsing.
|
||||||
|
part.Write(bytes.Repeat([]byte{0}, (30<<20)+1024))
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("oversized upload = %d, want 413", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateFromPacketAPI: confirm → plant + lot. No model involved, so the full
|
||||||
|
// path runs through the router.
|
||||||
|
func TestCreateFromPacketAPI(t *testing.T) {
|
||||||
|
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
// New plant + lot.
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||||
|
"newPlant": map[string]any{"name": "Music Garlic", "category": "vegetable", "color": "#4a7c3f", "icon": "🧄", "spacingCm": 15},
|
||||||
|
"lot": map[string]any{"vendor": "Johnny's", "quantity": 8, "unit": "bulbs"},
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("new plant confirm: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
res := decodeMap(t, w.Body.Bytes())
|
||||||
|
if res["plantIsNew"] != true {
|
||||||
|
t.Errorf("plantIsNew = %v, want true", res["plantIsNew"])
|
||||||
|
}
|
||||||
|
plantObj, _ := res["plant"].(map[string]any)
|
||||||
|
plantID := int64(plantObj["id"].(float64))
|
||||||
|
lotObj, _ := res["lot"].(map[string]any)
|
||||||
|
if int64(lotObj["plantId"].(float64)) != plantID {
|
||||||
|
t.Errorf("lot not attributed to the new plant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing plant + lot.
|
||||||
|
w = doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||||
|
"plantId": plantID,
|
||||||
|
"lot": map[string]any{"vendor": "Fedco", "quantity": 10, "unit": "bulbs"},
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("existing plant confirm: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if decodeMap(t, w.Body.Bytes())["plantIsNew"] != false {
|
||||||
|
t.Error("plantIsNew should be false for an existing plant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both plantId and newPlant → 400.
|
||||||
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||||
|
"plantId": plantID,
|
||||||
|
"newPlant": map[string]any{"name": "X", "category": "vegetable", "color": "#4a7c3f", "icon": "🌱"},
|
||||||
|
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
||||||
|
}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("both plant choices = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither → 400.
|
||||||
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||||
|
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
||||||
|
}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("neither plant choice = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthenticated → 401.
|
||||||
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous confirm = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCapabilitiesReportsVision: /capabilities advertises vision only when a
|
||||||
|
// vision model is configured.
|
||||||
|
func TestCapabilitiesReportsVision(t *testing.T) {
|
||||||
|
on := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||||
|
cookie := registerAndCookie(t, on, "[email protected]")
|
||||||
|
w := doJSON(t, on, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
||||||
|
if decodeMap(t, w.Body.Bytes())["vision"] != true {
|
||||||
|
t.Errorf("vision should be true with a model configured: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
off := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||||
|
cookie2 := registerAndCookie(t, off, "[email protected]")
|
||||||
|
w = doJSON(t, off, http.MethodGet, "/api/v1/capabilities", nil, cookie2)
|
||||||
|
if decodeMap(t, w.Body.Bytes())["vision"] != false {
|
||||||
|
t.Errorf("vision should be false with no model: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Instance settings (#79): admin-only, instance-wide. The authoritative admin
|
||||||
|
// check is in the service; requireAdmin here is a cheap early 403 that also
|
||||||
|
// keeps the route group readable.
|
||||||
|
|
||||||
|
// requireAdmin rejects a non-admin actor. It runs after requireAuth, so the
|
||||||
|
// actor is already resolved and carries IsAdmin — no extra query. Returns 403
|
||||||
|
// (not 404): a logged-in user knows settings exist, they just may not touch them.
|
||||||
|
func (h *handlers) requireAdmin() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !mustActor(c).IsAdmin {
|
||||||
|
writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "admin access required")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsResponse is what GET/PATCH /settings return. It carries the stored
|
||||||
|
// settings plus a read-only view of what's resolved and live, so the UI can show
|
||||||
|
// "inheriting ollama-cloud/glm-5.2:cloud from the environment" and whether a key
|
||||||
|
// is present — without ever exposing the key itself.
|
||||||
|
type settingsResponse struct {
|
||||||
|
Settings *domain.InstanceSettings `json:"settings"`
|
||||||
|
// Effective is the configuration actually in force after layering settings
|
||||||
|
// over the environment.
|
||||||
|
Effective effectiveView `json:"effective"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type effectiveView struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// HasApiKey reports whether OLLAMA_CLOUD_API_KEY is set. The key itself is
|
||||||
|
// never serialized — an admin may know one exists, not what it is.
|
||||||
|
HasApiKey bool `json:"hasApiKey"`
|
||||||
|
// AgentLive is whether the assistant Runner is actually built right now. It
|
||||||
|
// can be false even when Enabled+HasApiKey are true (an unresolvable model),
|
||||||
|
// which is exactly the case the UI needs to surface.
|
||||||
|
AgentLive bool `json:"agentLive"`
|
||||||
|
// VisionModel is the resolved seed-packet model (DB-over-env). VisionReady is
|
||||||
|
// whether capture can actually be offered (a key and a model).
|
||||||
|
VisionModel string `json:"visionModel"`
|
||||||
|
VisionReady bool `json:"visionReady"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsPayload builds the response, or an error. It does NOT swallow an
|
||||||
|
// EffectiveConfig failure into a misleading empty "effective" view — an empty
|
||||||
|
// view would report no model and no key, which reads as "nothing configured"
|
||||||
|
// rather than "we couldn't read it". Since EffectiveConfig re-reads the same row
|
||||||
|
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
|
||||||
|
// surfacing as a 500, not papering over. It also resolves the agent and vision
|
||||||
|
// views from ONE row read rather than fetching the single-row table twice.
|
||||||
|
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
|
||||||
|
eff, vis, err := h.svc.EffectiveConfig(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
return settingsResponse{}, err
|
||||||
|
}
|
||||||
|
return settingsResponse{
|
||||||
|
Settings: st,
|
||||||
|
Effective: effectiveView{
|
||||||
|
Model: eff.Model,
|
||||||
|
Enabled: eff.Enabled,
|
||||||
|
HasApiKey: eff.APIKey != "",
|
||||||
|
AgentLive: h.agent.get() != nil,
|
||||||
|
VisionModel: vis.Model,
|
||||||
|
VisionReady: vis.Ready(),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) getSettings(c *gin.Context) {
|
||||||
|
st, err := h.svc.GetInstanceSettings(c.Request.Context(), mustActor(c).ID)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := h.settingsPayload(c, st)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsUpdateRequest is the PATCH body. agentModel "" means inherit the env
|
||||||
|
// var. agentEnabled is json.RawMessage so an explicit null (inherit) is
|
||||||
|
// distinguishable from an absent field and from true/false.
|
||||||
|
type settingsUpdateRequest struct {
|
||||||
|
AgentModel string `json:"agentModel"`
|
||||||
|
AgentEnabled json.RawMessage `json:"agentEnabled"`
|
||||||
|
VisionModel string `json:"visionModel"`
|
||||||
|
Version int64 `json:"version" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) updateSettings(c *gin.Context) {
|
||||||
|
var req settingsUpdateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a current version is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentEnabled: absent or null → inherit (nil); true/false → explicit override.
|
||||||
|
// The shared parseNullable does exactly this three-way decode; present is
|
||||||
|
// irrelevant here because absent and null both mean "inherit".
|
||||||
|
enabled, _, err := parseNullable[bool](req.AgentEnabled)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
|
||||||
|
AgentModel: req.AgentModel,
|
||||||
|
AgentEnabled: enabled,
|
||||||
|
VisionModel: req.VisionModel,
|
||||||
|
Version: req.Version,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrVersionConflict) {
|
||||||
|
writeVersionConflict(c, st)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the change to the LIVE assistant. Detached from the request context:
|
||||||
|
// the write is committed and the rebuild describes it, so a client that hangs
|
||||||
|
// up now must not leave the running Runner out of step with the stored
|
||||||
|
// settings. Mirrors the same reasoning as the history-write detachment.
|
||||||
|
h.agent.rebuild(context.WithoutCancel(c.Request.Context()))
|
||||||
|
|
||||||
|
payload, err := h.settingsPayload(c, st)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentCfg is a config with the assistant configured — a (fake) key, on, and a
|
||||||
|
// resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only
|
||||||
|
// PARSE the spec (no live call), so a Runner still builds and capabilities
|
||||||
|
// reports it live. That's enough to exercise the runtime on/off swap.
|
||||||
|
func agentCfg() *config.Config {
|
||||||
|
c := localCfg()
|
||||||
|
c.Agent = config.AgentConfig{
|
||||||
|
Model: "ollama-cloud/glm-5.2:cloud",
|
||||||
|
OllamaCloudAPIKey: "test-key",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any)
|
||||||
|
return int64(st["version"].(float64))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsAdminOnly: the first registered user is admin and can read/write
|
||||||
|
// settings; a second user is not and gets 403 (not 404 — settings aren't a
|
||||||
|
// masked resource).
|
||||||
|
func TestSettingsAdminOnly(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]") // first user → admin
|
||||||
|
member := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("member GET settings: status %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("member PATCH settings: status %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
// Unauthenticated is 401, before the admin check.
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous GET settings: status %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsInheritFromEnv: an untouched instance reports the env model as
|
||||||
|
// effective, and an empty stored model keeps inheriting it.
|
||||||
|
func TestSettingsInheritFromEnv(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := decodeMap(t, w.Body.Bytes())
|
||||||
|
st := body["settings"].(map[string]any)
|
||||||
|
eff := body["effective"].(map[string]any)
|
||||||
|
|
||||||
|
if st["agentModel"] != "" {
|
||||||
|
t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"])
|
||||||
|
}
|
||||||
|
if eff["model"] != "ollama-cloud/glm-5.2:cloud" {
|
||||||
|
t.Errorf("effective model = %v, want the env value", eff["model"])
|
||||||
|
}
|
||||||
|
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
||||||
|
t.Errorf("effective = %+v, want a key present and the agent live", eff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
|
||||||
|
// effect on the LIVE assistant, with no restart. Driven entirely through HTTP.
|
||||||
|
func TestSettingsUpdateSwapsTheRunner(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
capsAgent := func() bool {
|
||||||
|
w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
||||||
|
return decodeMap(t, w.Body.Bytes())["agent"] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured and on out of the box.
|
||||||
|
if !capsAgent() {
|
||||||
|
t.Fatal("assistant should be live at boot with a key + enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn it OFF via settings → capabilities flips immediately.
|
||||||
|
v := settingsVersion(t, r, admin)
|
||||||
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if capsAgent() {
|
||||||
|
t.Error("assistant still live after being disabled — the runner wasn't swapped")
|
||||||
|
}
|
||||||
|
// Chat now refuses, at runtime, on a route that still exists.
|
||||||
|
gid := createGardenAPI(t, r, admin, "G")
|
||||||
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||||
|
map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("chat while disabled: status %d, want 503", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn it back ON, with an explicit model, and confirm it's live again and the
|
||||||
|
// effective model reflects the change.
|
||||||
|
v = settingsVersion(t, r, admin)
|
||||||
|
w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{
|
||||||
|
"agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v,
|
||||||
|
}, admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" {
|
||||||
|
t.Errorf("effective model = %v after change, want the new one", eff["model"])
|
||||||
|
}
|
||||||
|
if !capsAgent() {
|
||||||
|
t.Error("assistant not live after being re-enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time,
|
||||||
|
// not a broken assistant on the next turn.
|
||||||
|
func TestSettingsRejectsBadModel(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
v := settingsVersion(t, r, admin)
|
||||||
|
|
||||||
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("bad model: status %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// agentEnabled must be a bool or null, not a string.
|
||||||
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("string agentEnabled: status %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsVersionConflict: a stale version 409s and carries the current row.
|
||||||
|
func TestSettingsVersionConflict(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
v := settingsVersion(t, r, admin)
|
||||||
|
|
||||||
|
// First write succeeds and bumps the version.
|
||||||
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Reusing the old version conflicts.
|
||||||
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("stale update: status %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) {
|
||||||
|
t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsSwapUnderRace runs settings saves concurrently with chat requests,
|
||||||
|
// so `go test -race` proves the atomic swap of the live Runner is safe against
|
||||||
|
// in-flight readers. The whole point of the atomic.Pointer is this: without it,
|
||||||
|
// toggling the assistant while a request reads it is a data race.
|
||||||
|
func TestSettingsSwapUnderRace(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
// Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the
|
||||||
|
// chat handler does — so this races the pointer read against the writer's swap
|
||||||
|
// without ever invoking the model (a real Run would hit the network on a fake
|
||||||
|
// key). If get() is race-clean here it is race-clean in chat.
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Writer: flip the assistant on and off, swapping the pointer each time.
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
v := settingsVersion(t, r, admin)
|
||||||
|
doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin)
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
@@ -74,13 +74,11 @@ type AgentConfig struct {
|
|||||||
// a key is present, so an instance with no key starts cleanly and simply
|
// a key is present, so an instance with no key starts cleanly and simply
|
||||||
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
|
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
|
||||||
Enabled bool
|
Enabled bool
|
||||||
}
|
// VisionModel is the model that reads a photographed seed packet
|
||||||
|
// (PANSY_VISION_MODEL). Empty by default: the seed-packet capture feature is
|
||||||
// Ready reports whether the assistant can actually be offered. Both the route
|
// only offered when a vision-capable model is configured (in env or Settings)
|
||||||
// registration and whatever advertises capabilities gate on this, so what's
|
// and a key is present. Passed verbatim to majordomo.Parse, like Model.
|
||||||
// advertised always matches what's live.
|
VisionModel string
|
||||||
func (a AgentConfig) Ready() bool {
|
|
||||||
return a.Enabled && a.OllamaCloudAPIKey != "" && a.Model != ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
||||||
@@ -127,6 +125,9 @@ func Load() *Config {
|
|||||||
// opt-in, and making people set a second flag to use what they just
|
// opt-in, and making people set a second flag to use what they just
|
||||||
// configured is a papercut with no upside.
|
// configured is a papercut with no upside.
|
||||||
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
|
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
|
||||||
|
// No default vision model: unlike chat there's no obvious safe default,
|
||||||
|
// and the feature stays off until an admin names one that can see.
|
||||||
|
VisionModel: envStr("PANSY_VISION_MODEL", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
||||||
|
|||||||
@@ -242,6 +242,26 @@ const (
|
|||||||
ConflictUnsupported = "unsupported"
|
ConflictUnsupported = "unsupported"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// InstanceSettings is the single row of instance-wide, admin-editable
|
||||||
|
// configuration (#79). Secrets are deliberately absent — see migration 0010.
|
||||||
|
//
|
||||||
|
// Both agent fields express "inherit from the environment unless set":
|
||||||
|
// AgentModel == "" falls back to PANSY_AGENT_MODEL; AgentEnabled == nil inherits
|
||||||
|
// the env default. EffectiveAgent resolves them against the environment.
|
||||||
|
type InstanceSettings struct {
|
||||||
|
// AgentModel overrides PANSY_AGENT_MODEL when non-empty. Passed verbatim to
|
||||||
|
// majordomo.Parse, exactly like the env var it shadows.
|
||||||
|
AgentModel string `json:"agentModel"`
|
||||||
|
// AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit.
|
||||||
|
AgentEnabled *bool `json:"agentEnabled"`
|
||||||
|
// VisionModel overrides PANSY_VISION_MODEL when non-empty; the model that
|
||||||
|
// reads a photographed seed packet (#81). Empty = feature off unless the env
|
||||||
|
// var names one.
|
||||||
|
VisionModel string `json:"visionModel"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy
|
||||||
|
// (and majordomo's vision path) can rely on, decoding the formats a phone
|
||||||
|
// actually produces.
|
||||||
|
//
|
||||||
|
// # Why this exists
|
||||||
|
//
|
||||||
|
// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or
|
||||||
|
// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very
|
||||||
|
// first input is "a photo from my phone", so without this the feature fails on
|
||||||
|
// the exact device that motivates it. Normalizing at the upload boundary means
|
||||||
|
// everything downstream only ever sees JPEG.
|
||||||
|
//
|
||||||
|
// # The CGO constraint
|
||||||
|
//
|
||||||
|
// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was
|
||||||
|
// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic
|
||||||
|
// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with
|
||||||
|
// image.Decode like any other format. golang.org/x/image/webp is pure Go too.
|
||||||
|
//
|
||||||
|
// # Import-driven registry
|
||||||
|
//
|
||||||
|
// Go's image decoders register via blank imports, and the failure mode is
|
||||||
|
// backwards from intuition: forget "image/png" and PNG uploads fail with
|
||||||
|
// "unknown format" while the exotic HEIC still works. So the blank imports below
|
||||||
|
// are load-bearing, and TestNormalizeAllFormats exercises all four formats to
|
||||||
|
// keep them so.
|
||||||
|
package imagenorm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
|
||||||
|
// Decoders, registered with image.Decode by side effect. All four matter:
|
||||||
|
// jpeg/png are the common cases, heic is the iPhone default, webp is common
|
||||||
|
// on the web. Dropping any one silently breaks that format's uploads.
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
|
||||||
|
_ "github.com/gen2brain/heic"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults chosen for the seed-packet path against ollama-cloud's limits (8
|
||||||
|
// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them.
|
||||||
|
const (
|
||||||
|
// DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's
|
||||||
|
// MaxDim; anything larger is downscaled. A packet photo has plenty of detail
|
||||||
|
// left at 2048.
|
||||||
|
DefaultMaxDim = 2048
|
||||||
|
// DefaultMaxBytes caps the *input* we will read. A phone photo is 3–8 MB; 25
|
||||||
|
// MiB leaves headroom for a large HEIC without inviting a decompression bomb
|
||||||
|
// as an unbounded read. The re-encoded output is far smaller.
|
||||||
|
DefaultMaxBytes = 25 << 20
|
||||||
|
// maxDecodePixels bounds the DECODED bitmap regardless of input byte size, so
|
||||||
|
// a small file claiming enormous dimensions (a decompression bomb) is refused
|
||||||
|
// before its ~4-bytes/px bitmap is allocated. 50 MP ≈ 200 MB peak — above any
|
||||||
|
// current phone camera (a 48 MP sensor is 48 MP) while capping the
|
||||||
|
// amplification a hostile header can force. maxDecodePixels and maxDimension
|
||||||
|
// are internal safety floors, not knobs — unlike MaxDim/MaxBytes there's no
|
||||||
|
// reason for a caller to raise them.
|
||||||
|
maxDecodePixels = 50_000_000
|
||||||
|
// maxDimension caps EACH side independently. It exists to make the pixel-count
|
||||||
|
// check overflow-safe: without it, a header claiming ~2^32 on a side could
|
||||||
|
// wrap int64(w)*int64(h) negative and slip past maxDecodePixels. No real image
|
||||||
|
// is 50k px on a side.
|
||||||
|
maxDimension = 50_000
|
||||||
|
// jpegQuality for the normalized output. 85 is visually clean and keeps the
|
||||||
|
// file small; the model reads text off it, not fine gradients.
|
||||||
|
jpegQuality = 85
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options tunes Normalize. The zero value uses the Default* constants.
|
||||||
|
type Options struct {
|
||||||
|
MaxDim int // longest edge; 0 → DefaultMaxDim
|
||||||
|
MaxBytes int // input read cap; 0 → DefaultMaxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) maxDim() int {
|
||||||
|
if o.MaxDim > 0 {
|
||||||
|
return o.MaxDim
|
||||||
|
}
|
||||||
|
return DefaultMaxDim
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) maxBytes() int {
|
||||||
|
if o.MaxBytes > 0 {
|
||||||
|
return o.MaxBytes
|
||||||
|
}
|
||||||
|
return DefaultMaxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTooLarge means the input exceeded the byte cap or decoded to an absurd
|
||||||
|
// pixel count. ErrUnsupported means the bytes weren't a decodable image format —
|
||||||
|
// or a decoder panicked on them (see the recover in Normalize).
|
||||||
|
var (
|
||||||
|
ErrTooLarge = errors.New("imagenorm: image too large")
|
||||||
|
ErrUnsupported = errors.New("imagenorm: unsupported or corrupt image")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP),
|
||||||
|
// downscales it to fit opts.MaxDim on its longest edge, applies the JPEG EXIF
|
||||||
|
// orientation so the pixels come out upright, and returns it re-encoded as JPEG,
|
||||||
|
// plus the decoded format name (e.g. "heic") — handy for logging what a phone
|
||||||
|
// actually sent. On any error the returned bytes are nil and format is "".
|
||||||
|
//
|
||||||
|
// Errors, by cause:
|
||||||
|
// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels /
|
||||||
|
// maxDimension → ErrTooLarge, refused before the bitmap is allocated;
|
||||||
|
// - bytes that aren't a decodable image, or a decoder that panics on them →
|
||||||
|
// ErrUnsupported;
|
||||||
|
// - a genuine read or JPEG-encode I/O failure → a wrapped error (not a
|
||||||
|
// sentinel), since those are the caller's stream/environment, not the image.
|
||||||
|
//
|
||||||
|
// It bounds work against a hostile upload three ways: the byte cap, the
|
||||||
|
// pre-decode pixel/dimension check, and a recover around the third-party decoders
|
||||||
|
// (a malformed HEIC/WebP shouldn't take the process down).
|
||||||
|
//
|
||||||
|
// EXIF orientation: phone cameras store the sensor pixels in one orientation and
|
||||||
|
// set an EXIF tag to rotate on display, so a JPEG "portrait" photo is really a
|
||||||
|
// landscape bitmap tagged "rotate 90°" — and the re-encode below strips EXIF,
|
||||||
|
// which is exactly why the rotation must be BAKED IN here. applyOrientation does
|
||||||
|
// that for the JPEG path (the format phone uploads overwhelmingly arrive in);
|
||||||
|
// other formats carry no JPEG EXIF and their decoders own orientation, so they're
|
||||||
|
// left as decoded.
|
||||||
|
//
|
||||||
|
// One known gap, deferred to the upload handler (#81): there is no context —
|
||||||
|
// image.Decode is CPU-bound and not cancellable mid-decode, so a caller that
|
||||||
|
// needs a hard deadline should run Normalize under its own timeout. The size
|
||||||
|
// guards keep the work finite regardless.
|
||||||
|
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
|
||||||
|
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
|
||||||
|
// maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway.
|
||||||
|
byteCap := opts.maxBytes()
|
||||||
|
limit := int64(byteCap) + 1
|
||||||
|
if limit < 1 {
|
||||||
|
limit = int64(DefaultMaxBytes) + 1
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r, limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("imagenorm: read: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) > byteCap {
|
||||||
|
return nil, "", ErrTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check dimensions BEFORE a full decode, so a decompression bomb is refused
|
||||||
|
// before it allocates its bitmap. The per-side maxDimension check runs first
|
||||||
|
// so the pixel-count multiply below can't overflow.
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", ErrUnsupported
|
||||||
|
}
|
||||||
|
if cfg.Width <= 0 || cfg.Height <= 0 ||
|
||||||
|
cfg.Width > maxDimension || cfg.Height > maxDimension ||
|
||||||
|
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
|
||||||
|
return nil, "", ErrTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
img, format, err := decodeSafely(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downscale first (cheaper to rotate the small image), then bake in the EXIF
|
||||||
|
// orientation so the JPEG we emit is upright. A 90° rotation swaps the sides
|
||||||
|
// but not the longest edge, so the downscale bound still holds after it.
|
||||||
|
img = downscale(img, opts.maxDim())
|
||||||
|
img = applyOrientation(img, exifOrientation(raw))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("imagenorm: encode jpeg: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSafely decodes raw, converting both a decode error and a decoder PANIC
|
||||||
|
// into ErrUnsupported. The recover matters because the image comes from an
|
||||||
|
// untrusted upload and the HEIC/WebP decoders are third-party (libheif via WASM,
|
||||||
|
// x/image/webp): a malformed file that panics one of them must fail this one
|
||||||
|
// request, not crash the process.
|
||||||
|
func decodeSafely(raw []byte) (img image.Image, format string, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
img, format, err = nil, "", ErrUnsupported
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
img, format, err = image.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", ErrUnsupported
|
||||||
|
}
|
||||||
|
return img, format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOrientation returns img with the EXIF orientation (1..8) baked in, so the
|
||||||
|
// pixels are upright and no display-time rotation is needed. Orientation 1 (and
|
||||||
|
// anything out of range) is a no-op. Values 5..8 are 90° rotations, which swap
|
||||||
|
// the output's width and height. Copies raw RGBA pixels by byte offset (after a
|
||||||
|
// one-time conversion if the source isn't already RGBA), so a full-resolution
|
||||||
|
// rotation doesn't box a color.Color per pixel.
|
||||||
|
func applyOrientation(img image.Image, o int) image.Image {
|
||||||
|
if o <= 1 || o > 8 {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
// Work on a concrete RGBA so the transform is a 4-byte copy per pixel rather
|
||||||
|
// than millions of boxed color.Color values through At/Set. downscale usually
|
||||||
|
// hands us an *image.RGBA already; convert once if not (e.g. a small JPEG that
|
||||||
|
// skipped downscale decodes to YCbCr).
|
||||||
|
src, ok := img.(*image.RGBA)
|
||||||
|
if !ok {
|
||||||
|
b := img.Bounds()
|
||||||
|
conv := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||||
|
draw.Draw(conv, conv.Bounds(), img, b.Min, draw.Src)
|
||||||
|
src = conv
|
||||||
|
}
|
||||||
|
sb := src.Bounds()
|
||||||
|
w, h := sb.Dx(), sb.Dy()
|
||||||
|
// A quarter-turn (5..8) transposes the output; size the one buffer accordingly.
|
||||||
|
dw, dh := w, h
|
||||||
|
if o >= 5 {
|
||||||
|
dw, dh = h, w
|
||||||
|
}
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
||||||
|
for y := range h {
|
||||||
|
for x := range w {
|
||||||
|
var dx, dy int
|
||||||
|
switch o {
|
||||||
|
case 2: // mirror horizontal
|
||||||
|
dx, dy = w-1-x, y
|
||||||
|
case 3: // rotate 180
|
||||||
|
dx, dy = w-1-x, h-1-y
|
||||||
|
case 4: // mirror vertical
|
||||||
|
dx, dy = x, h-1-y
|
||||||
|
case 5: // transpose (mirror across the main diagonal)
|
||||||
|
dx, dy = y, x
|
||||||
|
case 6: // rotate 90° clockwise
|
||||||
|
dx, dy = h-1-y, x
|
||||||
|
case 7: // transverse (mirror across the anti-diagonal)
|
||||||
|
dx, dy = h-1-y, w-1-x
|
||||||
|
case 8: // rotate 90° counter-clockwise
|
||||||
|
dx, dy = y, w-1-x
|
||||||
|
}
|
||||||
|
si := src.PixOffset(sb.Min.X+x, sb.Min.Y+y)
|
||||||
|
di := dst.PixOffset(dx, dy)
|
||||||
|
copy(dst.Pix[di:di+4], src.Pix[si:si+4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// exifOrientation extracts the EXIF Orientation tag (1..8) from raw image bytes,
|
||||||
|
// returning 1 (normal) when it's absent or unparseable — the safe default, since
|
||||||
|
// a wrong guess rotates a correct image. Only the JPEG APP1/Exif path is parsed:
|
||||||
|
// that's the format uploaded phone photos overwhelmingly arrive in, and the other
|
||||||
|
// decoders own their own orientation.
|
||||||
|
func exifOrientation(raw []byte) int {
|
||||||
|
// A JPEG is a run of FFxx marker segments after the SOI (FFD8). Walk them
|
||||||
|
// looking for APP1 (FFE1) carrying "Exif\0\0"; stop at the scan data (SOS).
|
||||||
|
if len(raw) < 4 || raw[0] != 0xFF || raw[1] != 0xD8 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
for i := 2; i+1 < len(raw); {
|
||||||
|
if raw[i] != 0xFF {
|
||||||
|
return 1 // not aligned on a marker; give up rather than misread
|
||||||
|
}
|
||||||
|
// A marker may be preceded by any number of 0xFF fill bytes (JPEG spec);
|
||||||
|
// skip them so a padded APP1 isn't misread as a marker of value 0xFF.
|
||||||
|
for i+1 < len(raw) && raw[i+1] == 0xFF {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i+1 >= len(raw) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
marker := raw[i+1]
|
||||||
|
if marker == 0xD9 || marker == 0xDA {
|
||||||
|
return 1 // EOI / start-of-scan: no more headers to read
|
||||||
|
}
|
||||||
|
if i+4 > len(raw) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
segLen := int(raw[i+2])<<8 | int(raw[i+3])
|
||||||
|
if segLen < 2 || i+2+segLen > len(raw) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if marker == 0xE1 {
|
||||||
|
if o, ok := orientationFromApp1(raw[i+4 : i+2+segLen]); ok {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2 + segLen
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// orientationFromApp1 reads the Orientation tag from a JPEG APP1 segment body
|
||||||
|
// (everything after the 2-byte length): "Exif\0\0" then a TIFF block holding
|
||||||
|
// IFD0. Returns (0, false) if the segment isn't Exif or the tag is missing.
|
||||||
|
func orientationFromApp1(seg []byte) (int, bool) {
|
||||||
|
const prefix = "Exif\x00\x00"
|
||||||
|
if len(seg) < len(prefix)+8 || string(seg[:len(prefix)]) != prefix {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
tiff := seg[len(prefix):]
|
||||||
|
var bo binary.ByteOrder
|
||||||
|
switch string(tiff[0:2]) {
|
||||||
|
case "II":
|
||||||
|
bo = binary.LittleEndian
|
||||||
|
case "MM":
|
||||||
|
bo = binary.BigEndian
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if bo.Uint16(tiff[2:4]) != 0x2A { // TIFF magic (42); byte order must agree
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
ifd := int(bo.Uint32(tiff[4:8])) // offset to IFD0 from the TIFF start
|
||||||
|
if ifd < 8 || ifd+2 > len(tiff) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n := int(bo.Uint16(tiff[ifd : ifd+2]))
|
||||||
|
for k := range n {
|
||||||
|
off := ifd + 2 + k*12 // each IFD entry is 12 bytes
|
||||||
|
if off+12 > len(tiff) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if bo.Uint16(tiff[off:off+2]) != 0x0112 { // Orientation tag
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Orientation is defined as a single SHORT, whose value sits inline in the
|
||||||
|
// first 2 bytes of the value field. Reject anything else rather than read a
|
||||||
|
// mistyped entry (a LONG/offset there would be a different number entirely).
|
||||||
|
if bo.Uint16(tiff[off+2:off+4]) != 3 || bo.Uint32(tiff[off+4:off+8]) != 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v := int(bo.Uint16(tiff[off+8 : off+10]))
|
||||||
|
if v >= 1 && v <= 8 {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
|
||||||
|
// aspect ratio. An image already within bounds is returned unchanged (no
|
||||||
|
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
|
||||||
|
// a sharp result on text, which is what a packet photo is mostly made of.
|
||||||
|
func downscale(img image.Image, maxDim int) image.Image {
|
||||||
|
b := img.Bounds()
|
||||||
|
w, h := b.Dx(), b.Dy()
|
||||||
|
longest := max(w, h)
|
||||||
|
if longest <= maxDim || longest == 0 {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
scale := float64(maxDim) / float64(longest)
|
||||||
|
nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1)
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||||
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package imagenorm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"hash/crc32"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
|
||||||
|
// encode; heic/webp come from testdata (Go has no encoder for them).
|
||||||
|
func pngBytes(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := range h {
|
||||||
|
for x := range w {
|
||||||
|
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
|
||||||
|
m.Pix[m.PixOffset(x, y)+3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := png.Encode(&b, m); err != nil {
|
||||||
|
t.Fatalf("encode png: %v", err)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func jpegBytes(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&b, m, nil); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTestdata(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile("testdata/" + name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
|
||||||
|
// accept must round-trip to a valid JPEG. It exists specifically to catch a
|
||||||
|
// dropped blank import — the failure mode where the common format (PNG) breaks
|
||||||
|
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
|
||||||
|
func TestNormalizeAllFormats(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
wantFormat string
|
||||||
|
}{
|
||||||
|
{"png", pngBytes(t, 120, 90), "png"},
|
||||||
|
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
|
||||||
|
{"heic", readTestdata(t, "sample.heic"), "heic"},
|
||||||
|
{"webp", readTestdata(t, "sample.webp"), "webp"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize(%s): %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if format != tc.wantFormat {
|
||||||
|
t.Errorf("format = %q, want %q", format, tc.wantFormat)
|
||||||
|
}
|
||||||
|
// The output must itself be a decodable JPEG.
|
||||||
|
_, outFormat, err := image.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("output isn't a valid image: %v", err)
|
||||||
|
}
|
||||||
|
if outFormat != "jpeg" {
|
||||||
|
t.Errorf("output format = %q, want jpeg", outFormat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
|
||||||
|
// longest edge with aspect ratio preserved, and a small one is left alone.
|
||||||
|
func TestNormalizeDownscales(t *testing.T) {
|
||||||
|
// A 2:1 image twice as wide as DefaultMaxDim → clamped to DefaultMaxDim on the
|
||||||
|
// long edge with aspect preserved. Derived from the constant, not hard-coded,
|
||||||
|
// so the test tracks the default rather than silently asserting a magic number.
|
||||||
|
longEdge := DefaultMaxDim * 2
|
||||||
|
big := pngBytes(t, longEdge, longEdge/2)
|
||||||
|
out, _, err := Normalize(bytes.NewReader(big), Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize: %v", err)
|
||||||
|
}
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode out: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Width != DefaultMaxDim {
|
||||||
|
t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim)
|
||||||
|
}
|
||||||
|
if cfg.Height != DefaultMaxDim/2 {
|
||||||
|
t.Errorf("height = %d, want %d (aspect preserved)", cfg.Height, DefaultMaxDim/2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A small image within bounds keeps its dimensions.
|
||||||
|
small := pngBytes(t, 100, 80)
|
||||||
|
out, _, err = Normalize(bytes.NewReader(small), Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize small: %v", err)
|
||||||
|
}
|
||||||
|
cfg, _, err = image.DecodeConfig(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode small out: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Width != 100 || cfg.Height != 80 {
|
||||||
|
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
|
||||||
|
// refused without a full decode.
|
||||||
|
func TestNormalizeRejectsOversizeInput(t *testing.T) {
|
||||||
|
big := pngBytes(t, 500, 500)
|
||||||
|
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
|
||||||
|
if err != ErrTooLarge {
|
||||||
|
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRejectsGarbage: unreadable-as-image bytes and a truncated image
|
||||||
|
// both fail cleanly with ErrUnsupported, not a panic.
|
||||||
|
func TestNormalizeRejectsGarbage(t *testing.T) {
|
||||||
|
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
|
||||||
|
if err != ErrUnsupported {
|
||||||
|
t.Errorf("garbage err = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
|
||||||
|
png := pngBytes(t, 100, 100)
|
||||||
|
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
|
||||||
|
if err != ErrUnsupported {
|
||||||
|
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pngHeader builds a valid PNG signature + IHDR chunk (with a correct CRC, which
|
||||||
|
// DecodeConfig verifies) for the given dimensions, and nothing else. It's enough
|
||||||
|
// for image.DecodeConfig to report width/height without a real bitmap — exactly
|
||||||
|
// what's needed to exercise the pre-decode size guard with a tiny input.
|
||||||
|
func pngHeader(w, h uint32) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
|
||||||
|
ihdr := make([]byte, 13)
|
||||||
|
binary.BigEndian.PutUint32(ihdr[0:], w)
|
||||||
|
binary.BigEndian.PutUint32(ihdr[4:], h)
|
||||||
|
ihdr[8] = 8 // bit depth
|
||||||
|
ihdr[9] = 6 // colour type: RGBA
|
||||||
|
// compression/filter/interlace already 0.
|
||||||
|
binary.Write(&buf, binary.BigEndian, uint32(len(ihdr)))
|
||||||
|
chunk := append([]byte("IHDR"), ihdr...)
|
||||||
|
buf.Write(chunk)
|
||||||
|
binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRejectsPixelBomb is the guard the review found untested: a small
|
||||||
|
// input (a bare ~40-byte PNG header) claiming an enormous canvas is refused with
|
||||||
|
// ErrTooLarge from DecodeConfig alone, before image.Decode allocates anything.
|
||||||
|
// Covers both the per-side maxDimension trip and the pixel-count trip — and, via
|
||||||
|
// the near-2^16-per-side case, that the count math doesn't overflow.
|
||||||
|
func TestNormalizeRejectsPixelBomb(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
w, h uint32
|
||||||
|
}{
|
||||||
|
{"huge single side", 60000, 10}, // > maxDimension on width
|
||||||
|
{"huge area within side cap", 40000, 40000}, // sides < cap, area 1.6 GP > maxDecodePixels
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
hdr := pngHeader(tc.w, tc.h)
|
||||||
|
if len(hdr) > 100 {
|
||||||
|
t.Fatalf("header unexpectedly large (%d bytes) — not a bomb test", len(hdr))
|
||||||
|
}
|
||||||
|
// Sanity: the header really does decode to those dimensions.
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(hdr))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("crafted PNG header didn't parse: %v", err)
|
||||||
|
}
|
||||||
|
if uint32(cfg.Width) != tc.w || uint32(cfg.Height) != tc.h {
|
||||||
|
t.Fatalf("header reports %dx%d, want %dx%d", cfg.Width, cfg.Height, tc.w, tc.h)
|
||||||
|
}
|
||||||
|
if _, _, err := Normalize(bytes.NewReader(hdr), Options{}); err != ErrTooLarge {
|
||||||
|
t.Errorf("pixel bomb %dx%d err = %v, want ErrTooLarge", tc.w, tc.h, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// orientedJPEG builds a JPEG whose top-left quadrant is white and the rest black
|
||||||
|
// — a marker to track through a rotation — tagged with the given EXIF orientation
|
||||||
|
// (1..8). The marker lets a test assert the pixels actually moved to where that
|
||||||
|
// orientation says they should.
|
||||||
|
func orientedJPEG(t *testing.T, orient int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
const w, h = 40, 24
|
||||||
|
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := range h {
|
||||||
|
for x := range w {
|
||||||
|
c := color.RGBA{0, 0, 0, 255}
|
||||||
|
if x < w/2 && y < h/2 {
|
||||||
|
c = color.RGBA{255, 255, 255, 255}
|
||||||
|
}
|
||||||
|
m.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var jb bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&jb, m, &jpeg.Options{Quality: 95}); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
if orient == 0 {
|
||||||
|
return jb.Bytes() // caller wants a plain JPEG with no EXIF
|
||||||
|
}
|
||||||
|
return spliceExifOrientation(t, jb.Bytes(), orient)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spliceExifOrientation inserts a minimal little-endian Exif APP1 segment
|
||||||
|
// carrying just the Orientation tag right after the JPEG SOI marker.
|
||||||
|
func spliceExifOrientation(t *testing.T, jpg []byte, orient int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var tiff bytes.Buffer
|
||||||
|
tiff.WriteString("II") // little-endian
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x2A))
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint32(8)) // IFD0 offset
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint16(1)) // one entry
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x0112))
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint16(3)) // SHORT
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint32(1)) // count
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint16(orient)) // value
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0)) // value pad
|
||||||
|
_ = binary.Write(&tiff, binary.LittleEndian, uint32(0)) // next IFD
|
||||||
|
payload := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
|
||||||
|
|
||||||
|
segLen := len(payload) + 2
|
||||||
|
seg := []byte{0xFF, 0xE1, byte(segLen >> 8), byte(segLen)}
|
||||||
|
seg = append(seg, payload...)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(jpg)+len(seg))
|
||||||
|
out = append(out, jpg[:2]...) // SOI
|
||||||
|
out = append(out, seg...)
|
||||||
|
return append(out, jpg[2:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bright(c color.Color) bool {
|
||||||
|
r, g, b, _ := c.RGBA() // 16-bit
|
||||||
|
return (r+g+b)/3 > 0x8000
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeAppliesExifOrientation is the #103 regression: a phone photo tagged
|
||||||
|
// "rotate 90°" must come out of Normalize with the pixels upright, not sideways —
|
||||||
|
// the re-encode strips EXIF, so the rotation has to be baked into the bitmap.
|
||||||
|
func TestNormalizeAppliesExifOrientation(t *testing.T) {
|
||||||
|
// The white marker starts centred at (10,6) in the 40x24 source. For each
|
||||||
|
// orientation, wantW/H is the corrected canvas and (mx,my) is where that
|
||||||
|
// marker must land — derived from the same transform Normalize applies.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
orient int
|
||||||
|
wantW, wantH int
|
||||||
|
mx, my int
|
||||||
|
}{
|
||||||
|
{"none", 0, 40, 24, 10, 6}, // no EXIF → unchanged, marker top-left
|
||||||
|
{"normal", 1, 40, 24, 10, 6}, // normal → unchanged
|
||||||
|
{"mirror-h", 2, 40, 24, 29, 6}, // flip horizontal → top-right
|
||||||
|
{"rotate-180", 3, 40, 24, 29, 17}, // → bottom-right
|
||||||
|
{"mirror-v", 4, 40, 24, 10, 17}, // flip vertical → bottom-left
|
||||||
|
{"transpose", 5, 24, 40, 6, 10}, // main diagonal (dims swap)
|
||||||
|
{"rotate-90-cw", 6, 24, 40, 17, 10}, // → top-right (dims swap)
|
||||||
|
{"transverse", 7, 24, 40, 17, 29}, // anti-diagonal (dims swap)
|
||||||
|
{"rotate-90-ccw", 8, 24, 40, 6, 29}, // → bottom-left (dims swap)
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, format, err := Normalize(bytes.NewReader(orientedJPEG(t, tc.orient)), Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize err = %v", err)
|
||||||
|
}
|
||||||
|
if format != "jpeg" {
|
||||||
|
t.Errorf("format = %q, want jpeg", format)
|
||||||
|
}
|
||||||
|
m, _, err := image.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode output: %v", err)
|
||||||
|
}
|
||||||
|
if m.Bounds().Dx() != tc.wantW || m.Bounds().Dy() != tc.wantH {
|
||||||
|
t.Errorf("output %dx%d, want %dx%d",
|
||||||
|
m.Bounds().Dx(), m.Bounds().Dy(), tc.wantW, tc.wantH)
|
||||||
|
}
|
||||||
|
if !bright(m.At(m.Bounds().Min.X+tc.mx, m.Bounds().Min.Y+tc.my)) {
|
||||||
|
t.Errorf("white marker not at (%d,%d) — orientation not applied", tc.mx, tc.my)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExifOrientationParsing pins the parser against non-JPEG and no-EXIF inputs,
|
||||||
|
// which must default to 1 (never guess a rotation onto a correct image).
|
||||||
|
func TestExifOrientationParsing(t *testing.T) {
|
||||||
|
if o := exifOrientation(pngBytes(t, 8, 8)); o != 1 {
|
||||||
|
t.Errorf("PNG orientation = %d, want 1 (no JPEG EXIF path)", o)
|
||||||
|
}
|
||||||
|
if o := exifOrientation(orientedJPEG(t, 0)); o != 1 {
|
||||||
|
t.Errorf("JPEG without EXIF orientation = %d, want 1", o)
|
||||||
|
}
|
||||||
|
if o := exifOrientation(orientedJPEG(t, 6)); o != 6 {
|
||||||
|
t.Errorf("JPEG tagged 6 → %d, want 6", o)
|
||||||
|
}
|
||||||
|
if o := exifOrientation([]byte("not an image")); o != 1 {
|
||||||
|
t.Errorf("garbage bytes → %d, want 1", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# imagenorm test fixtures
|
||||||
|
|
||||||
|
Go has no encoder for HEIC or WebP, so these small samples are committed rather
|
||||||
|
than generated at test time. They are used by `TestNormalizeAllFormats` to prove
|
||||||
|
every accepted format round-trips to JPEG (and, mainly, to catch a dropped blank
|
||||||
|
import — see the package doc).
|
||||||
|
|
||||||
|
- `sample.heic` — a 240×160 gradient, created from a Go-generated PNG with macOS
|
||||||
|
`sips -s format heic`. HEVC still-image profile.
|
||||||
|
- `sample.webp` — a 16×16 image copied from CPython's stdlib test corpus
|
||||||
|
(`Lib/test/test_email/data/python.webp`), verified to decode with
|
||||||
|
`golang.org/x/image/webp` before committing. Used only as a decode fixture.
|
||||||
|
|
||||||
|
Neither contains anything meaningful; they exist purely to be decoded.
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1,168 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Instance settings (#79): admin-editable, instance-wide configuration. The
|
||||||
|
// admin gate lives HERE, in the seam, not in the handler — the same rule every
|
||||||
|
// other permission follows. The API's requireAdmin middleware is a cheap early
|
||||||
|
// 403, not the authority.
|
||||||
|
|
||||||
|
// requireAdmin returns nil iff the actor is an admin, else ErrForbidden.
|
||||||
|
//
|
||||||
|
// ErrForbidden, not ErrNotFound: settings are not a resource whose existence is
|
||||||
|
// masked. A logged-in non-admin knows the instance has settings; they simply may
|
||||||
|
// not touch them. (Contrast objects/lots, where no-access masks existence.)
|
||||||
|
func (s *Service) requireAdmin(ctx context.Context, actorID int64) error {
|
||||||
|
u, err := s.store.GetUserByID(ctx, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !u.IsAdmin {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstanceSettings returns the instance settings for an admin.
|
||||||
|
func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*domain.InstanceSettings, error) {
|
||||||
|
if err := s.requireAdmin(ctx, actorID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.store.GetInstanceSettings(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceSettingsPatch is a full replacement of the editable fields plus the
|
||||||
|
// current version. Both agent fields carry their "inherit" sentinel: an empty
|
||||||
|
// AgentModel means fall back to env, a nil AgentEnabled means inherit.
|
||||||
|
type InstanceSettingsPatch struct {
|
||||||
|
AgentModel string
|
||||||
|
AgentEnabled *bool
|
||||||
|
VisionModel string
|
||||||
|
Version int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstanceSettings applies an admin's change, version-guarded. It returns
|
||||||
|
// (current row, ErrVersionConflict) on a stale version, like every mutable
|
||||||
|
// resource. The model spec is validated before it is stored, so a typo is a 400
|
||||||
|
// now rather than a broken assistant on the next turn.
|
||||||
|
//
|
||||||
|
// It does NOT rebuild the running agent — that is the API layer's job, because
|
||||||
|
// the live Runner lives there. The caller rebuilds on success.
|
||||||
|
func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, patch InstanceSettingsPatch) (*domain.InstanceSettings, error) {
|
||||||
|
if err := s.requireAdmin(ctx, actorID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
model := strings.TrimSpace(patch.AgentModel)
|
||||||
|
vision := strings.TrimSpace(patch.VisionModel)
|
||||||
|
// Validate non-empty specs up front. An empty one is the "inherit env"
|
||||||
|
// sentinel and needs no check — the env value was validated at boot.
|
||||||
|
for _, spec := range []string{model, vision} {
|
||||||
|
if spec != "" {
|
||||||
|
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
||||||
|
AgentModel: model,
|
||||||
|
AgentEnabled: patch.AgentEnabled,
|
||||||
|
VisionModel: vision,
|
||||||
|
Version: patch.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveAgent resolves the agent configuration actually in force: DB settings
|
||||||
|
// override the environment, and the API key always comes from the environment.
|
||||||
|
//
|
||||||
|
// This is internal plumbing (the API layer calls it to build the Runner), NOT an
|
||||||
|
// admin-gated operation — resolving what's configured is not the same as editing
|
||||||
|
// it, and the agent bootstrap must work before any user is even authenticated.
|
||||||
|
type EffectiveAgent struct {
|
||||||
|
Model string
|
||||||
|
Enabled bool
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready mirrors config.AgentConfig.Ready: enabled, with a key and a model.
|
||||||
|
func (e EffectiveAgent) Ready() bool {
|
||||||
|
return e.Enabled && e.APIKey != "" && e.Model != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveAgent reads the settings row and layers it over the environment.
|
||||||
|
func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
||||||
|
st, err := s.store.GetInstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return EffectiveAgent{}, err
|
||||||
|
}
|
||||||
|
return s.agentOver(st), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentOver layers a settings row over the env-derived agent defaults. Split out
|
||||||
|
// so EffectiveConfig can resolve agent AND vision from a single row read instead
|
||||||
|
// of fetching the same one-row table twice.
|
||||||
|
func (s *Service) agentOver(st *domain.InstanceSettings) EffectiveAgent {
|
||||||
|
eff := EffectiveAgent{
|
||||||
|
Model: s.cfg.Agent.Model,
|
||||||
|
Enabled: s.cfg.Agent.Enabled,
|
||||||
|
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
||||||
|
}
|
||||||
|
if st.AgentModel != "" {
|
||||||
|
eff.Model = st.AgentModel
|
||||||
|
}
|
||||||
|
if st.AgentEnabled != nil {
|
||||||
|
eff.Enabled = *st.AgentEnabled
|
||||||
|
}
|
||||||
|
return eff
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveVision resolves the vision configuration in force for seed-packet
|
||||||
|
// capture (#81): the model from DB-over-env, the key always from env.
|
||||||
|
type EffectiveVision struct {
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready reports whether packet capture can be offered: a key and a vision model.
|
||||||
|
// There's no separate enabled flag — configuring a vision model IS enabling it.
|
||||||
|
func (e EffectiveVision) Ready() bool {
|
||||||
|
return e.APIKey != "" && e.Model != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveVision reads the settings row and layers it over the environment.
|
||||||
|
func (s *Service) EffectiveVision(ctx context.Context) (EffectiveVision, error) {
|
||||||
|
st, err := s.store.GetInstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return EffectiveVision{}, err
|
||||||
|
}
|
||||||
|
return s.visionOver(st), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// visionOver layers a settings row over the env-derived vision defaults. See
|
||||||
|
// agentOver for why this is split from EffectiveVision.
|
||||||
|
func (s *Service) visionOver(st *domain.InstanceSettings) EffectiveVision {
|
||||||
|
eff := EffectiveVision{
|
||||||
|
Model: s.cfg.Agent.VisionModel,
|
||||||
|
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
||||||
|
}
|
||||||
|
if st.VisionModel != "" {
|
||||||
|
eff.Model = st.VisionModel
|
||||||
|
}
|
||||||
|
return eff
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveConfig resolves the agent AND vision configuration from ONE settings
|
||||||
|
// read, for callers (the settings view) that need both — the single-row table
|
||||||
|
// would otherwise be fetched twice for one response.
|
||||||
|
func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, EffectiveVision, error) {
|
||||||
|
st, err := s.store.GetInstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return EffectiveAgent{}, EffectiveVision{}, err
|
||||||
|
}
|
||||||
|
return s.agentOver(st), s.visionOver(st), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsTestService builds a service whose env config carries the given agent
|
||||||
|
// model/enabled/key, so EffectiveAgent's env fallback can be exercised.
|
||||||
|
func settingsTestService(t *testing.T, envModel string, envEnabled bool, key string) (*Service, int64) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := openConfig()
|
||||||
|
cfg.Agent = config.AgentConfig{Model: envModel, Enabled: envEnabled, OllamaCloudAPIKey: key}
|
||||||
|
s := newTestService(t, cfg)
|
||||||
|
admin := seedUser(t, s, "[email protected]") // first user is admin
|
||||||
|
return s, admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAdmin: the first user is admin; a second is not and gets
|
||||||
|
// ErrForbidden (not ErrNotFound — settings existence isn't masked).
|
||||||
|
func TestRequireAdmin(t *testing.T) {
|
||||||
|
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||||
|
member := seedUser(t, s, "[email protected]")
|
||||||
|
|
||||||
|
if err := s.requireAdmin(context.Background(), admin); err != nil {
|
||||||
|
t.Errorf("admin rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.requireAdmin(context.Background(), member); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Errorf("member requireAdmin = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEffectiveAgentLayering: DB settings override env; the API key always comes
|
||||||
|
// from env; the "inherit" sentinels fall back.
|
||||||
|
func TestEffectiveAgentLayering(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, admin := settingsTestService(t, "ollama-cloud/env-model", true, "envkey")
|
||||||
|
|
||||||
|
// Untouched: everything inherits env.
|
||||||
|
eff, err := s.EffectiveAgent(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("effective: %v", err)
|
||||||
|
}
|
||||||
|
if eff.Model != "ollama-cloud/env-model" || !eff.Enabled || eff.APIKey != "envkey" {
|
||||||
|
t.Errorf("inherited effective = %+v, want the env values", eff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the model only; enabled still inherits env (true).
|
||||||
|
cur, _ := s.GetInstanceSettings(ctx, admin)
|
||||||
|
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||||
|
AgentModel: "ollama-cloud/glm-5.2:cloud", Version: cur.Version,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update model: %v", err)
|
||||||
|
}
|
||||||
|
eff, _ = s.EffectiveAgent(ctx)
|
||||||
|
if eff.Model != "ollama-cloud/glm-5.2:cloud" {
|
||||||
|
t.Errorf("model = %q, want the DB override", eff.Model)
|
||||||
|
}
|
||||||
|
if !eff.Enabled {
|
||||||
|
t.Error("enabled should still inherit env (true) when unset")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now override enabled to false explicitly.
|
||||||
|
cur, _ = s.GetInstanceSettings(ctx, admin)
|
||||||
|
no := false
|
||||||
|
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||||
|
AgentModel: "ollama-cloud/glm-5.2:cloud", AgentEnabled: &no, Version: cur.Version,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update enabled: %v", err)
|
||||||
|
}
|
||||||
|
eff, _ = s.EffectiveAgent(ctx)
|
||||||
|
if eff.Enabled {
|
||||||
|
t.Error("enabled should be the explicit false override now")
|
||||||
|
}
|
||||||
|
if eff.Ready() {
|
||||||
|
t.Error("Ready() should be false when disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateInstanceSettingsRejectsBadModel: a spec that won't resolve is
|
||||||
|
// ErrInvalidInput, before it is stored.
|
||||||
|
func TestUpdateInstanceSettingsRejectsBadModel(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||||
|
cur, _ := s.GetInstanceSettings(ctx, admin)
|
||||||
|
|
||||||
|
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||||
|
AgentModel: "nonesuch/model", Version: cur.Version,
|
||||||
|
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("bad model = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rejected write didn't touch the row.
|
||||||
|
after, _ := s.GetInstanceSettings(ctx, admin)
|
||||||
|
if after.Version != cur.Version || after.AgentModel != "" {
|
||||||
|
t.Errorf("a rejected update changed the row: %+v", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstanceSettingsAdminGate: the read/write operations are admin-gated at the
|
||||||
|
// service seam, not just in the handler.
|
||||||
|
func TestInstanceSettingsAdminGate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, _ := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||||
|
member := seedUser(t, s, "[email protected]")
|
||||||
|
|
||||||
|
if _, err := s.GetInstanceSettings(ctx, member); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Errorf("member GetInstanceSettings = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpdateInstanceSettings(ctx, member, InstanceSettingsPatch{Version: 1}); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Errorf("member UpdateInstanceSettings = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-44
@@ -97,30 +97,106 @@ func defaultPlopRadius(spacingCM float64) float64 {
|
|||||||
return math.Max(1.5*spacingCM, 15)
|
return math.Max(1.5*spacingCM, 15)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
// FillLayout selects what a fill packs (#77).
|
||||||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
//
|
||||||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
// A plop is a CLUMP, not a plant, and that abstraction is the right primitive for
|
||||||
// radius pitch, centered in the region, and set in from each edge by the plop's
|
// SKETCHING — "a few plops of garlic in one corner" — but it can't draw a real
|
||||||
// radius less half a spacing — see hexCenters for why that half-spacing is what
|
// planting: a filled 4×8ft bed comes out as ~15 blobs, not 8 rows of garlic. So
|
||||||
// the edge is owed. A candidate is skipped when its plop would sit entirely
|
// filling is now two operations. FillClump (the default, unchanged) drops fat
|
||||||
// inside an existing active plop (so re-filling doesn't stack duplicates).
|
// clumps for quick coverage; FillGrid lays out individual plants at true spacing,
|
||||||
// Returns the plops it created.
|
// producing a layout you could actually plant from.
|
||||||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
type FillLayout string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FillClump packs fat clumps (radius 1.5×spacing). Each plop is ~7 plants.
|
||||||
|
FillClump FillLayout = "clump"
|
||||||
|
// FillGrid packs one plant per plop at true spacing (radius spacing/2, pitch
|
||||||
|
// = spacing). A bed becomes rows of individual plants.
|
||||||
|
FillGrid FillLayout = "grid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// plopRadiusFor is the plop radius a fill uses, given the plant's spacing and the
|
||||||
|
// layout. Grid mode is a plain spacing/2 (so the pitch is one spacing and each
|
||||||
|
// plop's derived count is 1); clump mode keeps the 15cm floor that stops a
|
||||||
|
// tiny-spacing plant from making invisibly small clumps — a floor grid mode
|
||||||
|
// doesn't want, since its whole point is true spacing.
|
||||||
|
func plopRadiusFor(spacingCM float64, layout FillLayout) float64 {
|
||||||
|
if layout == FillGrid {
|
||||||
|
return spacingCM / 2
|
||||||
|
}
|
||||||
|
return defaultPlopRadius(spacingCM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// edgeInset is how far a plop's CENTRE must stay inside the region edge. It
|
||||||
|
// differs by layout because the half-spacing rule is about where the PLANT lands,
|
||||||
|
// and the plant sits in a different place within the plop.
|
||||||
|
//
|
||||||
|
// Spacing is a constraint between neighbouring plants competing for the same soil,
|
||||||
|
// light and water; a bed edge is nobody's neighbour, so the outer plant owes it
|
||||||
|
// only HALF the spacing — the half it would otherwise share. That is the
|
||||||
|
// square-foot-chart arithmetic: garlic at 9-per-square sits 2" from the frame, not
|
||||||
|
// 6".
|
||||||
|
//
|
||||||
|
// - Grid: one plant, at the plop's centre. Put that centre a half-spacing in and
|
||||||
|
// the outer row lands exactly where the rule wants it — inset = spacing/2.
|
||||||
|
// - Clump: a fat plop (radius 1.5×spacing) whose plants fill out to its RIM.
|
||||||
|
// Insetting the whole circle would push the outer row a full 1.5 spacings in,
|
||||||
|
// three times the rule. Instead the clump may hang over by a half-spacing (rim
|
||||||
|
// at spacing/2 past the edge), landing its outermost plants that same
|
||||||
|
// half-spacing in — inset = radius − spacing/2. A grid plop reusing THAT
|
||||||
|
// formula would inset by radius − spacing/2 = 0 and plant flush on the edge,
|
||||||
|
// which is the bug this split fixes.
|
||||||
|
func edgeInset(radius, spacing float64, layout FillLayout) float64 {
|
||||||
|
half := math.Max(0, spacing) / 2
|
||||||
|
if layout == FillGrid {
|
||||||
|
return half
|
||||||
|
}
|
||||||
|
return math.Max(0, radius-half)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validFillLayout normalizes a layout: empty defaults to clump (so existing
|
||||||
|
// callers are unchanged), a known value passes, anything else is rejected.
|
||||||
|
func validFillLayout(l FillLayout) (FillLayout, bool) {
|
||||||
|
switch l {
|
||||||
|
case "", FillClump:
|
||||||
|
return FillClump, true
|
||||||
|
case FillGrid:
|
||||||
|
return FillGrid, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillRegion lays a field of plops of one plant across a region of a plantable
|
||||||
|
// object the actor can edit. The layout picks the primitive: FillClump drops fat
|
||||||
|
// clumps for quick sketching, FillGrid lays out individual plants at true spacing
|
||||||
|
// (see FillLayout). Plop radius comes from the plant's spacing (or spacingOverride)
|
||||||
|
// via plopRadiusFor; centers sit on a centered hex lattice at 2×radius pitch, set
|
||||||
|
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
|
||||||
|
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its
|
||||||
|
// plop would sit entirely inside an existing active plop (so re-filling doesn't
|
||||||
|
// stack duplicates). Returns the plops it created.
|
||||||
|
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
|
||||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||||||
// already loaded and authorized (roleEditor). It rejects a non-finite region,
|
// already loaded and authorized (roleEditor). It validates the layout, rejects a
|
||||||
// clamps the region to the object's bounds, refuses fills over maxFillPlops, and
|
// non-finite region, clamps the region to the object's bounds, refuses fills over
|
||||||
// inserts the whole batch in one transaction rather than one round-trip per plop.
|
// maxFillPlops, and inserts the whole batch in one transaction rather than one
|
||||||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
// round-trip per plop.
|
||||||
|
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
|
||||||
if !o.Plantable {
|
if !o.Plantable {
|
||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
layout, ok := validFillLayout(layout)
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
plant, err := s.visiblePlant(ctx, actorID, plantID)
|
plant, err := s.visiblePlant(ctx, actorID, plantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -132,7 +208,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
|||||||
}
|
}
|
||||||
spacing = *spacingOverride
|
spacing = *spacingOverride
|
||||||
}
|
}
|
||||||
radius := defaultPlopRadius(spacing)
|
radius := plopRadiusFor(spacing, layout)
|
||||||
if !isFinite(radius) || radius <= 0 {
|
if !isFinite(radius) || radius <= 0 {
|
||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
}
|
}
|
||||||
@@ -150,7 +226,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
|||||||
}
|
}
|
||||||
|
|
||||||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||||||
centers, total := hexCenters(region, radius, spacing, maxFillPlops)
|
centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops)
|
||||||
if total > maxFillPlops {
|
if total > maxFillPlops {
|
||||||
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
||||||
}
|
}
|
||||||
@@ -161,13 +237,17 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
|||||||
}
|
}
|
||||||
today := s.now().UTC().Format(dateLayout)
|
today := s.now().UTC().Format(dateLayout)
|
||||||
batch := make([]*domain.Planting, 0, len(centers))
|
batch := make([]*domain.Planting, 0, len(centers))
|
||||||
|
// Only the plops that were ALREADY here can cover a candidate: every plop this
|
||||||
|
// fill makes shares one radius and sits on a distinct lattice point, and a plop
|
||||||
|
// is "covered" only when it lies entirely inside another — impossible between
|
||||||
|
// two equal-radius circles at different centres. So skip against `existing` as
|
||||||
|
// loaded and don't grow it per plop, which made an empty-bed grid fill's check
|
||||||
|
// needlessly quadratic.
|
||||||
for _, c := range centers {
|
for _, c := range centers {
|
||||||
if coveredByExisting(c.x, c.y, radius, existing) {
|
if coveredByExisting(c.x, c.y, radius, existing) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
|
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today})
|
||||||
batch = append(batch, p)
|
|
||||||
existing = append(existing, *p) // so later candidates in THIS fill don't stack on it
|
|
||||||
}
|
}
|
||||||
created, err := s.store.CreatePlantings(ctx, batch)
|
created, err := s.store.CreatePlantings(ctx, batch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,24 +276,14 @@ type localPoint struct{ x, y float64 }
|
|||||||
//
|
//
|
||||||
// # How close to the edge the outer row goes
|
// # How close to the edge the outer row goes
|
||||||
//
|
//
|
||||||
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
|
// The caller passes `inset`: the margin the outer row keeps from every edge. It
|
||||||
// soil, light and water. A bed edge is not a competitor, so the outer row only
|
// encodes the half-spacing rule (spacing is owed between neighbouring plants, and
|
||||||
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
|
// a bed edge is nobody's neighbour) and differs by layout — see edgeInset, which
|
||||||
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
|
// derives it. hexCenters just honours it on all four sides.
|
||||||
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
|
|
||||||
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
|
|
||||||
//
|
//
|
||||||
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so
|
// Do not "simplify" the centering back to anchoring at the region's min corner.
|
||||||
// three spacings across — and its plants sit out to its rim. So keeping the whole
|
// That is what #75 was: staggered rows start a full pitch in, and the leftover all
|
||||||
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
|
// lands on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||||||
// times what the rule allows. Instead the clump may hang over the edge by up to
|
|
||||||
// half a spacing, which puts its outermost plants exactly the half-spacing from
|
|
||||||
// the edge that the rule asks for. Overhang is capped there and nowhere near the
|
|
||||||
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
|
|
||||||
//
|
|
||||||
// Do not "simplify" this back to anchoring at the region's min corner. That is
|
|
||||||
// what #75 was: staggered rows start a full pitch in, and the leftover all lands
|
|
||||||
// on the far edge, where clumps hang outside a bed that nothing clips them to.
|
|
||||||
//
|
//
|
||||||
// # Counting before building
|
// # Counting before building
|
||||||
//
|
//
|
||||||
@@ -221,7 +291,7 @@ type localPoint struct{ x, y float64 }
|
|||||||
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
||||||
// its whole lattice first just to be counted and thrown away. Over `limit` it
|
// its whole lattice first just to be counted and thrown away. Over `limit` it
|
||||||
// returns (nil, total), so the caller can still refuse with the real number.
|
// returns (nil, total), so the caller can still refuse with the real number.
|
||||||
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
|
func hexCenters(r Region, radius, inset float64, limit int) ([]localPoint, int) {
|
||||||
if radius <= 0 {
|
if radius <= 0 {
|
||||||
return nil, 0
|
return nil, 0
|
||||||
}
|
}
|
||||||
@@ -234,11 +304,6 @@ func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int
|
|||||||
pitch := 2 * radius
|
pitch := 2 * radius
|
||||||
rowH := pitch * math.Sqrt(3) / 2
|
rowH := pitch * math.Sqrt(3) / 2
|
||||||
|
|
||||||
// How far a clump's centre must stay inside the edge: its own radius, less the
|
|
||||||
// half-spacing of overhang the rule allows. Never negative, and never past the
|
|
||||||
// centre of the clump.
|
|
||||||
inset := math.Max(0, radius-math.Max(0, spacing)/2)
|
|
||||||
|
|
||||||
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
|
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
|
||||||
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
|
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
|
||||||
|
|
||||||
@@ -311,7 +376,7 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
|
|||||||
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
||||||
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
||||||
// hold the object's geometry. It resolves the name against the object, then fills.
|
// hold the object's geometry. It resolves the name against the object, then fills.
|
||||||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
|
||||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -320,7 +385,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestFillRegionCappedForHugeArea(t *testing.T) {
|
|||||||
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
|
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
|
||||||
plant := seedOwnPlant(t, s, owner, 10)
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrInvalidInput) {
|
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
|
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ func TestHexCentersEdgeInset(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
|
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
|
||||||
pts, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops)
|
pts, total := hexCenters(r, tc.radius, edgeInset(tc.radius, tc.spacing, FillClump), maxFillPlops)
|
||||||
if len(pts) == 0 {
|
if len(pts) == 0 {
|
||||||
t.Fatal("no centers")
|
t.Fatal("no centers")
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func TestHexCentersTinyRegion(t *testing.T) {
|
|||||||
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
|
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
pts, _ := hexCenters(tc.r, 15, 10, maxFillPlops)
|
pts, _ := hexCenters(tc.r, 15, edgeInset(15, 10, FillClump), maxFillPlops)
|
||||||
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
|
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
|
||||||
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
|
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
|
|||||||
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
|
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
|
||||||
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
|
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
|
||||||
} {
|
} {
|
||||||
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil)
|
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump)
|
||||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
|
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 10)
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
|
|
||||||
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
|
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
|
||||||
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil)
|
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillRegion: %v", err)
|
t.Fatalf("FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
|
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
|
||||||
|
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
|
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillRegion: %v", err)
|
t.Fatalf("FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
|||||||
|
|
||||||
// Re-filling the same region skips everything (each candidate sits exactly on
|
// Re-filling the same region skips everything (each candidate sits exactly on
|
||||||
// an existing plop → entirely inside it).
|
// an existing plop → entirely inside it).
|
||||||
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
|
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second FillRegion: %v", err)
|
t.Fatalf("second FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -292,6 +292,70 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFillGridLaysOutIndividualPlants is the #77 grid mode: a grid fill packs one
|
||||||
|
// plant per plop at true spacing, so a bed becomes rows of plants rather than a
|
||||||
|
// few fat clumps. On the same bed it produces many more, smaller plops, each a
|
||||||
|
// single plant.
|
||||||
|
func TestFillGridLaysOutIndividualPlants(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
bed := seedFillBed(t, s, owner, g.ID, 60, 60)
|
||||||
|
plant := seedOwnPlant(t, s, owner, 10) // spacing 10
|
||||||
|
|
||||||
|
region, _ := NamedRegion(bed, "all")
|
||||||
|
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clump: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
|
||||||
|
t.Fatalf("clear: %v", err)
|
||||||
|
}
|
||||||
|
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grid: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid packs at spacing 10 (radius 5, pitch 10); clump at radius 15 (pitch 30).
|
||||||
|
// Grid must produce many more plops.
|
||||||
|
if len(grid) <= len(clump) {
|
||||||
|
t.Errorf("grid produced %d plops, clump %d — grid should be denser", len(grid), len(clump))
|
||||||
|
}
|
||||||
|
// Each grid plop is one plant at radius spacing/2 = 5.
|
||||||
|
maxAbs := 0.0
|
||||||
|
for _, p := range grid {
|
||||||
|
if p.RadiusCM != 5 {
|
||||||
|
t.Errorf("grid plop radius = %v, want 5 (spacing/2)", p.RadiusCM)
|
||||||
|
}
|
||||||
|
if p.DerivedCount != 1 {
|
||||||
|
t.Errorf("grid plop derived count = %d, want 1 (one plant per plop)", p.DerivedCount)
|
||||||
|
}
|
||||||
|
maxAbs = math.Max(maxAbs, math.Max(math.Abs(p.XCM), math.Abs(p.YCM)))
|
||||||
|
}
|
||||||
|
// The half-spacing edge rule: a grid plant sits AT the plop centre, so the
|
||||||
|
// outer row is inset a half-spacing (spacing/2 = 5) — at ±25 on this 60cm bed,
|
||||||
|
// not flush on ±30. Regression guard: the clump inset formula (radius − half)
|
||||||
|
// collapses to 0 for grid and would plant one on the very edge.
|
||||||
|
if edge := 30.0; maxAbs > edge-5+1e-6 {
|
||||||
|
t.Errorf("outermost grid plop at |coord|=%.2f — only %.2fcm from the edge; want a half-spacing (5cm) in", maxAbs, edge-maxAbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillRejectsUnknownLayout: a layout that isn't clump/grid is ErrInvalidInput.
|
||||||
|
func TestFillRejectsUnknownLayout(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
bed := seedFillBed(t, s, owner, g.ID, 60, 60)
|
||||||
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
|
region, _ := NamedRegion(bed, "all")
|
||||||
|
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral")); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
|
func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
s := newTestService(t, openConfig())
|
s := newTestService(t, openConfig())
|
||||||
@@ -304,7 +368,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 20)
|
plant := seedOwnPlant(t, s, owner, 20)
|
||||||
|
|
||||||
region, _ := NamedRegion(bed, "ne")
|
region, _ := NamedRegion(bed, "ne")
|
||||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
|
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillRegion: %v", err)
|
t.Fatalf("FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -327,7 +391,7 @@ func TestClearObject(t *testing.T) {
|
|||||||
bed := seedBed(t, s, owner, g.ID)
|
bed := seedBed(t, s, owner, g.ID)
|
||||||
plant := seedOwnPlant(t, s, owner, 10)
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); err != nil {
|
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); err != nil {
|
||||||
t.Fatalf("fill: %v", err)
|
t.Fatalf("fill: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +425,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
region, _ := NamedRegion(bed, "all")
|
region, _ := NamedRegion(bed, "all")
|
||||||
|
|
||||||
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrForbidden) {
|
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrForbidden) {
|
||||||
t.Errorf("viewer fill = %v, want ErrForbidden", err)
|
t.Errorf("viewer fill = %v, want ErrForbidden", err)
|
||||||
}
|
}
|
||||||
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
|
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
|
||||||
@@ -391,7 +455,7 @@ func TestFillScenario(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("region %q: %v", name, err)
|
t.Fatalf("region %q: %v", name, err)
|
||||||
}
|
}
|
||||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil); err != nil {
|
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump); err != nil {
|
||||||
t.Fatalf("fill %q: %v", name, err)
|
t.Fatalf("fill %q: %v", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 15)
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil)
|
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillNamedRegion: %v", err)
|
t.Fatalf("FillNamedRegion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 15)
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
||||||
t.Fatalf("fill: %v", err)
|
t.Fatalf("fill: %v", err)
|
||||||
}
|
}
|
||||||
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||||
@@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 15)
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
||||||
t.Fatalf("fill: %v", err)
|
t.Fatalf("fill: %v", err)
|
||||||
}
|
}
|
||||||
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||||
@@ -725,7 +725,7 @@ func TestRevertResultCarriesItsCounts(t *testing.T) {
|
|||||||
plant := seedOwnPlant(t, s, owner, 15)
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
||||||
t.Fatalf("fill: %v", err)
|
t.Fatalf("fill: %v", err)
|
||||||
}
|
}
|
||||||
// A second, different kind of change, so the breakdown has more than one row
|
// A second, different kind of change, so the breakdown has more than one row
|
||||||
@@ -830,7 +830,7 @@ func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
|||||||
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
||||||
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
||||||
}, func(ctx context.Context) error {
|
}, func(ctx context.Context) error {
|
||||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cancel() // the client disconnects, mid-turn, after the work landed
|
cancel() // the client disconnects, mid-turn, after the work landed
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seed-packet capture (#81): read a photographed packet, propose a plant + lot,
|
||||||
|
// let the user confirm. The two halves are deliberately separate operations:
|
||||||
|
// extraction only READS (a picture in, a proposal out — it can't touch the
|
||||||
|
// garden), and creation happens later, from the confirmed proposal, so a
|
||||||
|
// misread never writes anything on its own.
|
||||||
|
|
||||||
|
// PacketPlantMatch is a candidate existing plant the packet might be, with why it
|
||||||
|
// matched, so the UI can pre-select the likely one and let the user override.
|
||||||
|
type PacketPlantMatch struct {
|
||||||
|
Plant domain.Plant `json:"plant"`
|
||||||
|
// Reason is a short human tag: "exact name", "variety in name", "same species".
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PacketProposal is what a scan returns: the fields read off the packet, plus
|
||||||
|
// candidate existing plants it might already be. Nothing is created yet.
|
||||||
|
type PacketProposal struct {
|
||||||
|
Packet vision.SeedPacket `json:"packet"`
|
||||||
|
// Candidates are existing plants the packet may match, best first. Empty means
|
||||||
|
// "probably a new variety" — the UI then offers to create one.
|
||||||
|
Candidates []PacketPlantMatch `json:"candidates"`
|
||||||
|
// SuggestedName is the variety (or species) to prefill a new-plant name with.
|
||||||
|
SuggestedName string `json:"suggestedName"`
|
||||||
|
// SuggestedCategory is the packet's category if it's a valid one, for prefill.
|
||||||
|
SuggestedCategory string `json:"suggestedCategory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractSeedPacket reads a (JPEG) packet photo and proposes a plant + lot for
|
||||||
|
// the actor to confirm. It needs a configured vision model; with none it returns
|
||||||
|
// ErrInvalidInput (the API layer turns "not configured" into a clear message and
|
||||||
|
// never offers the feature in the first place).
|
||||||
|
//
|
||||||
|
// The extraction runs as the actor only in the sense that the catalog match is
|
||||||
|
// scoped to what they can see; the model call itself has no ACL — it just reads
|
||||||
|
// a picture the actor uploaded.
|
||||||
|
func (s *Service) ExtractSeedPacket(ctx context.Context, actorID int64, jpeg []byte) (*PacketProposal, error) {
|
||||||
|
if len(jpeg) == 0 {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
vis, err := s.EffectiveVision(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !vis.Ready() {
|
||||||
|
// No vision model configured — the feature isn't available.
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
packet, err := s.extractPacket(ctx, vis.APIKey, vis.Model, jpeg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
plants, err := s.store.ListPlantsForActor(ctx, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PacketProposal{
|
||||||
|
Packet: packet,
|
||||||
|
Candidates: matchPlants(packet, plants),
|
||||||
|
SuggestedName: suggestedName(packet),
|
||||||
|
SuggestedCategory: validCategory(packet.Category),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestedName is the variety if the packet named one, else the species — what
|
||||||
|
// to prefill a new plant's name with. "Music" beats "garlic" when both are read.
|
||||||
|
func suggestedName(p vision.SeedPacket) string {
|
||||||
|
if v := strings.TrimSpace(p.Variety); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(p.Species)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validCategory returns the packet's category if it's one pansy knows, else "".
|
||||||
|
// It reuses plantCategories — the same set CreatePlant validates against — so a
|
||||||
|
// new category can't be accepted by one path and rejected by the other.
|
||||||
|
func validCategory(c string) string {
|
||||||
|
if _, ok := plantCategories[c]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPlants ranks existing plants the packet might already be, best first.
|
||||||
|
//
|
||||||
|
// This is the crux of "create both, linked" (#81): getting it wrong makes a
|
||||||
|
// duplicate catalog entry that then splits a variety's seed-lot history across
|
||||||
|
// two rows. So it NEVER decides — it only surfaces candidates for the user to
|
||||||
|
// confirm. Matching is deliberately conservative and name-based (no fuzzy
|
||||||
|
// scoring that could confidently mis-rank): exact variety name, variety appearing
|
||||||
|
// within a plant's name, then same species word. Case-insensitive.
|
||||||
|
func matchPlants(p vision.SeedPacket, plants []domain.Plant) []PacketPlantMatch {
|
||||||
|
variety := strings.ToLower(strings.TrimSpace(p.Variety))
|
||||||
|
species := strings.ToLower(strings.TrimSpace(p.Species))
|
||||||
|
|
||||||
|
// rank: lower is better; keep only matched plants.
|
||||||
|
type scored struct {
|
||||||
|
match PacketPlantMatch
|
||||||
|
rank int
|
||||||
|
}
|
||||||
|
var out []scored
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
add := func(pl domain.Plant, rank int, reason string) {
|
||||||
|
if seen[pl.ID] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[pl.ID] = true
|
||||||
|
out = append(out, scored{PacketPlantMatch{Plant: pl, Reason: reason}, rank})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pl := range plants {
|
||||||
|
name := strings.ToLower(pl.Name)
|
||||||
|
switch {
|
||||||
|
case variety != "" && name == variety:
|
||||||
|
add(pl, 0, "exact name")
|
||||||
|
case variety != "" && strings.Contains(name, variety):
|
||||||
|
add(pl, 1, "variety in name")
|
||||||
|
case species != "" && wordIn(name, species):
|
||||||
|
add(pl, 2, "same species")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].rank < out[j].rank })
|
||||||
|
|
||||||
|
matches := make([]PacketPlantMatch, len(out))
|
||||||
|
for i, s := range out {
|
||||||
|
matches[i] = s.match
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordIn reports whether word appears as a whole space-delimited token in name,
|
||||||
|
// so "garlic" matches "German Garlic" but not "garlicky-thing".
|
||||||
|
func wordIn(name, word string) bool {
|
||||||
|
for _, tok := range strings.Fields(name) {
|
||||||
|
if tok == word {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PacketConfirm is a user-confirmed proposal to turn into rows.
|
||||||
|
//
|
||||||
|
// Plant selection is explicit: either PlantID names an existing plant to attach
|
||||||
|
// the lot to, or NewPlant carries the fields to create one. Exactly one — the
|
||||||
|
// service refuses both or neither, so an ambiguous confirm can't silently pick.
|
||||||
|
type PacketConfirm struct {
|
||||||
|
// PlantID attaches the lot to an existing plant. Set this XOR NewPlant.
|
||||||
|
PlantID *int64
|
||||||
|
// NewPlant creates a variety. Set this XOR PlantID.
|
||||||
|
NewPlant *PlantInput
|
||||||
|
// Lot is the purchase to record against whichever plant results.
|
||||||
|
Lot SeedLotInput
|
||||||
|
}
|
||||||
|
|
||||||
|
// PacketResult is what a confirm produced.
|
||||||
|
type PacketResult struct {
|
||||||
|
Plant *domain.Plant `json:"plant"`
|
||||||
|
Lot *domain.SeedLot `json:"lot"`
|
||||||
|
PlantIsNew bool `json:"plantIsNew"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFromPacket turns a confirmed proposal into a plant (new or existing) plus
|
||||||
|
// a seed lot attributed to it. Unlike garden edits these rows aren't in the undo
|
||||||
|
// history — plants and lots are catalog/inventory, created directly — so there is
|
||||||
|
// no change set to wrap; the two creations just happen in sequence.
|
||||||
|
//
|
||||||
|
// If a new plant is created but the lot then fails, the plant is rolled back so
|
||||||
|
// the confirm is all-or-nothing. Otherwise a bad lot (say, an invalid unit) would
|
||||||
|
// strand a half-made catalog entry the user never asked for on its own, and the
|
||||||
|
// error return gives the HTTP caller no handle to it. A just-created plant has no
|
||||||
|
// plantings or lots yet, so the delete is safe; if it somehow can't be undone we
|
||||||
|
// log and still surface the original lot error, not the cleanup one.
|
||||||
|
func (s *Service) CreateFromPacket(ctx context.Context, actorID int64, in PacketConfirm) (*PacketResult, error) {
|
||||||
|
hasID, hasNew := in.PlantID != nil, in.NewPlant != nil
|
||||||
|
if hasID == hasNew {
|
||||||
|
return nil, domain.ErrInvalidInput // exactly one of existing / new
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &PacketResult{}
|
||||||
|
if hasNew {
|
||||||
|
plant, err := s.CreatePlant(ctx, actorID, *in.NewPlant)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.Plant = plant
|
||||||
|
res.PlantIsNew = true
|
||||||
|
} else {
|
||||||
|
// Attach to an existing plant the actor can see. visiblePlant enforces the
|
||||||
|
// ACL (built-ins + their own); a plant they can't see is ErrNotFound.
|
||||||
|
plant, err := s.visiblePlant(ctx, actorID, *in.PlantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.Plant = plant
|
||||||
|
}
|
||||||
|
|
||||||
|
lotIn := in.Lot
|
||||||
|
lotIn.PlantID = res.Plant.ID
|
||||||
|
lot, err := s.CreateSeedLot(ctx, actorID, lotIn)
|
||||||
|
if err != nil {
|
||||||
|
if res.PlantIsNew {
|
||||||
|
// Roll back the plant we just made for this confirm; keep the lot error.
|
||||||
|
if delErr := s.DeletePlant(ctx, actorID, res.Plant.ID); delErr != nil {
|
||||||
|
slog.Error("service: could not roll back plant after packet lot failed",
|
||||||
|
"error", delErr, "plant", res.Plant.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.Lot = lot
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||||
|
)
|
||||||
|
|
||||||
|
func packet(species, variety, category string) vision.SeedPacket {
|
||||||
|
return vision.SeedPacket{Species: species, Variety: variety, Category: category}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plant(id int64, name string) domain.Plant {
|
||||||
|
return domain.Plant{ID: id, Name: name, Category: domain.CategoryVegetable}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchPlants pins the catalog-matching heuristic: it surfaces candidates,
|
||||||
|
// best first, and never invents a match — the whole point, since a wrong auto-
|
||||||
|
// match would fragment a variety's seed-lot history across duplicate rows.
|
||||||
|
func TestMatchPlants(t *testing.T) {
|
||||||
|
catalog := []domain.Plant{
|
||||||
|
plant(1, "Garlic"),
|
||||||
|
plant(2, "Music Garlic"),
|
||||||
|
plant(3, "Cherokee Purple"),
|
||||||
|
plant(4, "Basil"),
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("exact variety wins, ranked above looser matches", func(t *testing.T) {
|
||||||
|
got := matchPlants(packet("tomato", "Cherokee Purple", "vegetable"), catalog)
|
||||||
|
if len(got) == 0 || got[0].Plant.ID != 3 || got[0].Reason != "exact name" {
|
||||||
|
t.Fatalf("want Cherokee Purple exact first, got %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("variety within a name, plus same-species, ordered", func(t *testing.T) {
|
||||||
|
// "Music" garlic: "Music Garlic" contains the variety (rank 1); "Garlic"
|
||||||
|
// shares the species word (rank 2).
|
||||||
|
got := matchPlants(packet("garlic", "Music", "vegetable"), catalog)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Plant.ID != 2 || got[0].Reason != "variety in name" {
|
||||||
|
t.Errorf("first = %+v, want Music Garlic / variety in name", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Plant.ID != 1 || got[1].Reason != "same species" {
|
||||||
|
t.Errorf("second = %+v, want Garlic / same species", got[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("case-insensitive", func(t *testing.T) {
|
||||||
|
got := matchPlants(packet("", "cherokee purple", ""), catalog)
|
||||||
|
if len(got) == 0 || got[0].Plant.ID != 3 {
|
||||||
|
t.Errorf("case-insensitive exact match failed: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no match → empty (a new variety)", func(t *testing.T) {
|
||||||
|
if got := matchPlants(packet("okra", "Clemson Spineless", "vegetable"), catalog); len(got) != 0 {
|
||||||
|
t.Errorf("want no candidates, got %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("species word boundary, not substring", func(t *testing.T) {
|
||||||
|
// "garlic" should not match a hypothetical "garlicky" — wordIn is token-based.
|
||||||
|
got := matchPlants(packet("garlic", "", ""), []domain.Plant{plant(9, "Garlicky Mustard")})
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("substring shouldn't match on species: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// visionTestService builds a service with a configured vision model and a canned
|
||||||
|
// extractor, so ExtractSeedPacket can run with no live model.
|
||||||
|
func visionTestService(t *testing.T, out vision.SeedPacket, extractErr error) (*Service, int64) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := openConfig()
|
||||||
|
cfg.Agent.OllamaCloudAPIKey = "k"
|
||||||
|
cfg.Agent.VisionModel = "ollama-cloud/vision:cloud"
|
||||||
|
s := newTestService(t, cfg)
|
||||||
|
s.extractPacket = func(ctx context.Context, apiKey, model string, jpeg []byte) (vision.SeedPacket, error) {
|
||||||
|
return out, extractErr
|
||||||
|
}
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
return s, owner
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractSeedPacket exercises the orchestration: canned packet → proposal
|
||||||
|
// with catalog candidates + prefill suggestions.
|
||||||
|
func TestExtractSeedPacket(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, owner := visionTestService(t, packet("garlic", "Music", "vegetable"), nil)
|
||||||
|
// Seed a matching plant.
|
||||||
|
if _, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||||
|
Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed plant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prop, err := s.ExtractSeedPacket(ctx, owner, []byte("jpeg-bytes"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extract: %v", err)
|
||||||
|
}
|
||||||
|
if prop.Packet.Variety != "Music" {
|
||||||
|
t.Errorf("packet variety = %q", prop.Packet.Variety)
|
||||||
|
}
|
||||||
|
if len(prop.Candidates) == 0 || prop.Candidates[0].Plant.Name != "Music Garlic" {
|
||||||
|
t.Errorf("expected Music Garlic candidate, got %+v", prop.Candidates)
|
||||||
|
}
|
||||||
|
if prop.SuggestedName != "Music" || prop.SuggestedCategory != domain.CategoryVegetable {
|
||||||
|
t.Errorf("suggestions = %q/%q", prop.SuggestedName, prop.SuggestedCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractSeedPacketNeedsVisionModel: with no vision model configured, the
|
||||||
|
// feature is unavailable (ErrInvalidInput), and the extractor is never called.
|
||||||
|
func TestExtractSeedPacketNeedsVisionModel(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig()) // no vision model, no key
|
||||||
|
called := false
|
||||||
|
s.extractPacket = func(ctx context.Context, _, _ string, _ []byte) (vision.SeedPacket, error) {
|
||||||
|
called = true
|
||||||
|
return vision.SeedPacket{}, nil
|
||||||
|
}
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
if _, err := s.ExtractSeedPacket(context.Background(), owner, []byte("x")); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("extractor was called despite no configured vision model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateFromPacketNewPlant: a confirm with NewPlant creates the plant and a
|
||||||
|
// lot attributed to it, in one call.
|
||||||
|
func TestCreateFromPacketNewPlant(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||||
|
|
||||||
|
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||||
|
NewPlant: &PlantInput{Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
||||||
|
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: domain.UnitBulbs},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if !res.PlantIsNew || res.Plant.Name != "Music Garlic" {
|
||||||
|
t.Errorf("plant = %+v, isNew=%v", res.Plant, res.PlantIsNew)
|
||||||
|
}
|
||||||
|
if res.Lot == nil || res.Lot.PlantID != res.Plant.ID {
|
||||||
|
t.Errorf("lot not attributed to the new plant: %+v", res.Lot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateFromPacketExistingPlant: a confirm with PlantID attaches the lot to
|
||||||
|
// the existing plant and creates nothing new.
|
||||||
|
func TestCreateFromPacketExistingPlant(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||||
|
existing, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||||
|
Name: "Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed plant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||||
|
PlantID: &existing.ID,
|
||||||
|
Lot: SeedLotInput{Vendor: "Fedco", Quantity: 10, Unit: domain.UnitBulbs},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if res.PlantIsNew || res.Plant.ID != existing.ID {
|
||||||
|
t.Errorf("should attach to existing plant, got %+v isNew=%v", res.Plant, res.PlantIsNew)
|
||||||
|
}
|
||||||
|
if res.Lot.PlantID != existing.ID {
|
||||||
|
t.Errorf("lot plantId = %d, want %d", res.Lot.PlantID, existing.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateFromPacketRollsBackNewPlantOnLotFailure: if the lot fails after a new
|
||||||
|
// plant was created for the confirm, the plant is rolled back so a bad lot can't
|
||||||
|
// strand a half-made catalog entry the user never asked for on its own.
|
||||||
|
func TestCreateFromPacketRollsBackNewPlantOnLotFailure(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||||
|
|
||||||
|
before, err := s.ListPlants(ctx, owner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list before: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bogus unit makes CreateSeedLot fail AFTER the plant is created.
|
||||||
|
_, err = s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||||
|
NewPlant: &PlantInput{Name: "Rollback Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
||||||
|
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: "furlongs"},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Fatalf("err = %v, want ErrInvalidInput from the bad unit", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := s.ListPlants(ctx, owner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list after: %v", err)
|
||||||
|
}
|
||||||
|
if len(after) != len(before) {
|
||||||
|
t.Errorf("plant count %d → %d: the rolled-back plant was left behind", len(before), len(after))
|
||||||
|
}
|
||||||
|
for _, p := range after {
|
||||||
|
if p.Name == "Rollback Garlic" {
|
||||||
|
t.Errorf("plant %q survived a failed lot; rollback didn't fire", p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateFromPacketExactlyOne: both or neither of PlantID/NewPlant is refused,
|
||||||
|
// so an ambiguous confirm can't silently pick.
|
||||||
|
func TestCreateFromPacketExactlyOne(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||||
|
id := int64(1)
|
||||||
|
for _, in := range []PacketConfirm{
|
||||||
|
{}, // neither
|
||||||
|
{PlantID: &id, NewPlant: &PlantInput{Name: "X"}}, // both
|
||||||
|
} {
|
||||||
|
if _, err := s.CreateFromPacket(ctx, owner, in); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("CreateFromPacket(%+v) err = %v, want ErrInvalidInput", in, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||||
)
|
)
|
||||||
|
|
||||||
// timeLayout is the ISO-8601 UTC format used for every full timestamp pansy
|
// timeLayout is the ISO-8601 UTC format used for every full timestamp pansy
|
||||||
@@ -41,16 +43,35 @@ type Service struct {
|
|||||||
// produced by timingHash (fixed salt, no RNG) so it is always present — an
|
// produced by timingHash (fixed salt, no RNG) so it is always present — an
|
||||||
// empty one would silently re-open account enumeration.
|
// empty one would silently re-open account enumeration.
|
||||||
dummyHash string
|
dummyHash string
|
||||||
|
// extractPacket reads a photographed seed packet (#81). Injectable so tests
|
||||||
|
// can supply a canned packet instead of calling a live vision model — the
|
||||||
|
// same reason `now` is injectable. Defaults to vision.Extract.
|
||||||
|
extractPacket func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option customizes a Service at construction. The only current use is injecting
|
||||||
|
// a seed-packet extractor in tests so they don't call a live vision model.
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
// WithPacketExtractor overrides how a photographed seed packet is read (#81).
|
||||||
|
// Production uses vision.Extract; a test supplies a canned reader.
|
||||||
|
func WithPacketExtractor(fn func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)) Option {
|
||||||
|
return func(s *Service) { s.extractPacket = fn }
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Service.
|
// New constructs a Service.
|
||||||
func New(st *store.DB, cfg *config.Config) *Service {
|
func New(st *store.DB, cfg *config.Config, opts ...Option) *Service {
|
||||||
return &Service{
|
s := &Service{
|
||||||
store: st,
|
store: st,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
dummyHash: timingHash(),
|
dummyHash: timingHash(),
|
||||||
|
extractPacket: vision.Extract,
|
||||||
}
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatTime renders a time as pansy's canonical UTC string.
|
// formatTime renders a time as pansy's canonical UTC string.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The instance_settings row is seeded by migration 0010 and there is exactly one
|
||||||
|
// (CHECK id = 1), so reads never branch on existence and writes never insert.
|
||||||
|
|
||||||
|
const instanceSettingsColumns = `agent_model, agent_enabled, vision_model, version, updated_at`
|
||||||
|
|
||||||
|
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable
|
||||||
|
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
|
||||||
|
func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
|
||||||
|
var (
|
||||||
|
out domain.InstanceSettings
|
||||||
|
enabled sql.NullInt64
|
||||||
|
)
|
||||||
|
if err := s.Scan(&out.AgentModel, &enabled, &out.VisionModel, &out.Version, &out.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if enabled.Valid {
|
||||||
|
b := enabled.Int64 != 0
|
||||||
|
out.AgentEnabled = &b
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstanceSettings returns the single settings row.
|
||||||
|
func (d *DB) GetInstanceSettings(ctx context.Context) (*domain.InstanceSettings, error) {
|
||||||
|
s, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
||||||
|
`SELECT `+instanceSettingsColumns+` FROM instance_settings WHERE id = 1`))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// The migration seeds this row, so its absence is a broken database, not a
|
||||||
|
// normal "not found" the caller should paper over.
|
||||||
|
return nil, fmt.Errorf("store: instance_settings row missing (migration 0010 not applied?)")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: get instance settings: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstanceSettings applies a version-guarded update to the single row,
|
||||||
|
// following the same optimistic-concurrency contract as every mutable resource:
|
||||||
|
// the updated row on success, (current row, ErrVersionConflict) on a version
|
||||||
|
// mismatch. There is no ErrNotFound path — the row always exists.
|
||||||
|
//
|
||||||
|
// agentEnabled is nil to store SQL NULL (inherit env), or a pointer to store an
|
||||||
|
// explicit 0/1.
|
||||||
|
func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSettings) (*domain.InstanceSettings, error) {
|
||||||
|
var enabled any
|
||||||
|
if s.AgentEnabled != nil {
|
||||||
|
enabled = boolToInt(*s.AgentEnabled)
|
||||||
|
}
|
||||||
|
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
||||||
|
`UPDATE instance_settings
|
||||||
|
SET agent_model = ?, agent_enabled = ?, vision_model = ?,
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
WHERE id = 1 AND version = ?
|
||||||
|
RETURNING `+instanceSettingsColumns,
|
||||||
|
s.AgentModel, enabled, s.VisionModel, s.Version,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
current, gerr := d.GetInstanceSettings(ctx)
|
||||||
|
if gerr != nil {
|
||||||
|
return nil, gerr
|
||||||
|
}
|
||||||
|
return current, domain.ErrVersionConflict
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: update instance settings: %w", err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Instance settings (#79): the first configuration that lives in the database
|
||||||
|
-- rather than the environment.
|
||||||
|
--
|
||||||
|
-- Until now every preference hung off a garden or object row; this is pansy's
|
||||||
|
-- first INSTANCE-level state. A single-row table (CHECK id = 1) is the least
|
||||||
|
-- surprising shape for "there is exactly one of these" — a key/value table would
|
||||||
|
-- invite typo'd keys and lose the column types.
|
||||||
|
--
|
||||||
|
-- Only NON-SECRET agent settings live here. OLLAMA_CLOUD_API_KEY stays in the
|
||||||
|
-- environment on purpose: a copy in SQLite would land in every backup and in the
|
||||||
|
-- blast radius of the undo history. An admin can change WHICH model runs, not
|
||||||
|
-- WHOSE account pays for it.
|
||||||
|
--
|
||||||
|
-- Both agent columns are "inherit from env unless set":
|
||||||
|
-- * agent_model = '' means fall back to PANSY_AGENT_MODEL, then the built-in
|
||||||
|
-- default. So an instance that never opens Settings behaves exactly as it
|
||||||
|
-- did before this migration, and the documented env var keeps working.
|
||||||
|
-- * agent_enabled is NULLABLE: NULL means inherit PANSY_AGENT_ENABLED's
|
||||||
|
-- behaviour (on when a key is present), 0/1 is an explicit override. A plain
|
||||||
|
-- boolean couldn't tell "admin hasn't touched this" from "admin turned it
|
||||||
|
-- off", and those must deploy differently.
|
||||||
|
--
|
||||||
|
-- version drives the same optimistic-concurrency 409 every other mutable row
|
||||||
|
-- uses, so two admins editing at once conflict rather than clobber.
|
||||||
|
CREATE TABLE instance_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
agent_model TEXT NOT NULL DEFAULT '',
|
||||||
|
agent_enabled INTEGER CHECK (agent_enabled IN (0, 1)),
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the single row so every read is a plain SELECT with no "does it exist
|
||||||
|
-- yet" branch. Inherits everything from the environment out of the box.
|
||||||
|
INSERT INTO instance_settings (id, agent_model, agent_enabled) VALUES (1, '', NULL);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Vision model setting (#81): the model that reads a photographed seed packet.
|
||||||
|
--
|
||||||
|
-- Separate from agent_model because it's a different capability — extracting
|
||||||
|
-- structured fields from an image needs a vision-capable model, which the chat
|
||||||
|
-- model may not be. Same "inherit from env unless set" contract as agent_model:
|
||||||
|
-- '' falls back to PANSY_VISION_MODEL, then the feature is simply not offered.
|
||||||
|
--
|
||||||
|
-- Like agent_model, the API KEY is NOT stored here — the vision model runs
|
||||||
|
-- against the same OLLAMA_CLOUD_API_KEY from the environment.
|
||||||
|
ALTER TABLE instance_settings ADD COLUMN vision_model TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Package vision reads a photographed seed packet into structured fields (#81).
|
||||||
|
//
|
||||||
|
// It is one-shot structured extraction, NOT an agent loop: majordomo.Generate[T]
|
||||||
|
// derives a JSON schema from the SeedPacket struct, hands the image to a vision
|
||||||
|
// model, and unmarshals the reply into SeedPacket. Because it can't call a tool,
|
||||||
|
// it can't touch the garden — it only reads a picture and returns data, which the
|
||||||
|
// service then turns into a plant + lot after the user confirms.
|
||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeedPacket is what a vision model reads off a packet. The json/description/enum
|
||||||
|
// tags drive the schema majordomo.Generate derives; pointer fields are nullable,
|
||||||
|
// so a field the packet doesn't print comes back nil rather than a made-up zero.
|
||||||
|
//
|
||||||
|
// These are the packet's PRINTED facts. Mapping them onto a pansy Plant + SeedLot
|
||||||
|
// (and deciding whether the variety is one already in the catalog) is the
|
||||||
|
// service's job, not the model's.
|
||||||
|
type SeedPacket struct {
|
||||||
|
Species string `json:"species" description:"the plant species in plain words, e.g. tomato, garlic, basil"`
|
||||||
|
Variety string `json:"variety" description:"the cultivar or variety name, e.g. Cherokee Purple; empty if the packet only names a species"`
|
||||||
|
Category string `json:"category" enum:"vegetable,herb,flower,fruit,tree_shrub,cover" description:"the single best-fit category"`
|
||||||
|
Vendor string `json:"vendor" description:"the seed company, e.g. Johnny's Selected Seeds"`
|
||||||
|
SKU string `json:"sku" description:"the vendor's item/product number, if printed"`
|
||||||
|
LotCode string `json:"lotCode" description:"the lot or batch code, if printed"`
|
||||||
|
PackedForYear *int `json:"packedForYear" description:"the 'packed for' or 'sell by' year, if printed"`
|
||||||
|
DaysToMaturity *int `json:"daysToMaturity" description:"days to maturity/harvest, if printed"`
|
||||||
|
SpacingCM *float64 `json:"spacingCm" description:"recommended in-row spacing in CENTIMETERS; convert if the packet uses inches"`
|
||||||
|
SeedCount *int `json:"seedCount" description:"approximate seed count in the packet, if printed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPrompt tells the model the conventions it can't guess: centimeters, and
|
||||||
|
// that a missing field must be left empty rather than invented.
|
||||||
|
const extractPrompt = `You are reading a photograph of a seed packet. Extract only what is actually printed on it.
|
||||||
|
Rules:
|
||||||
|
- Spacing must be in CENTIMETERS. If the packet gives inches, convert (1 in = 2.54 cm).
|
||||||
|
- If a field is not printed on the packet, leave it empty or null. Do not guess or fill from general knowledge.
|
||||||
|
- "variety" is the cultivar name (e.g. "Cherokee Purple"); "species" is the plain plant name (e.g. "tomato").`
|
||||||
|
|
||||||
|
// Extract runs one vision extraction: it resolves the model spec against pansy's
|
||||||
|
// registry, sends the JPEG with the prompt, and returns the parsed SeedPacket.
|
||||||
|
// The image bytes should already be normalized to JPEG (see internal/imagenorm).
|
||||||
|
//
|
||||||
|
// It makes a live model call, so callers give it a bounded context.
|
||||||
|
func Extract(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (SeedPacket, error) {
|
||||||
|
if len(jpeg) == 0 {
|
||||||
|
return SeedPacket{}, fmt.Errorf("vision: empty image")
|
||||||
|
}
|
||||||
|
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||||
|
if err != nil {
|
||||||
|
return SeedPacket{}, err
|
||||||
|
}
|
||||||
|
return generate(ctx, model, jpeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate makes the actual one-shot call against an already-resolved model. It
|
||||||
|
// is split from Extract on purpose: the hermetic test drives THIS function with a
|
||||||
|
// fake model, so the prompt, the derived schema and the image part it builds are
|
||||||
|
// the real ones the live path uses — not a hand-copied double that could drift.
|
||||||
|
func generate(ctx context.Context, model llm.Model, jpeg []byte) (SeedPacket, error) {
|
||||||
|
return majordomo.Generate[SeedPacket](ctx, model, majordomo.Request{
|
||||||
|
Messages: []majordomo.Message{
|
||||||
|
majordomo.UserParts(
|
||||||
|
majordomo.Text(extractPrompt),
|
||||||
|
majordomo.Image("image/jpeg", jpeg),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tinyJPEG returns a real, sniffable JPEG. The chain runs media.Normalize before
|
||||||
|
// the provider, which checks the image's magic bytes, so a string literal won't
|
||||||
|
// do — the bytes must actually be a JPEG.
|
||||||
|
func tinyJPEG(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractParsesModelJSON is the hermetic proof that the extraction path works
|
||||||
|
// end to end without a live model: a fake vision model returns canned packet JSON
|
||||||
|
// and Generate[SeedPacket] unmarshals it into the struct, image and schema
|
||||||
|
// included.
|
||||||
|
func TestExtractParsesModelJSON(t *testing.T) {
|
||||||
|
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||||
|
fp := fake.New("fp") // default caps advertise structured output + images
|
||||||
|
reg.RegisterProvider(fp)
|
||||||
|
fp.Enqueue("vision", fake.Reply(`{
|
||||||
|
"species": "garlic",
|
||||||
|
"variety": "Music",
|
||||||
|
"category": "vegetable",
|
||||||
|
"vendor": "Johnny's",
|
||||||
|
"sku": "2761",
|
||||||
|
"lotCode": "L-42",
|
||||||
|
"packedForYear": 2026,
|
||||||
|
"daysToMaturity": 240,
|
||||||
|
"spacingCm": 15,
|
||||||
|
"seedCount": 8
|
||||||
|
}`))
|
||||||
|
|
||||||
|
m, err := reg.Parse("fp/vision")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := generate(context.Background(), m, tinyJPEG(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extract: %v", err)
|
||||||
|
}
|
||||||
|
if got.Variety != "Music" || got.Species != "garlic" || got.Category != "vegetable" {
|
||||||
|
t.Errorf("unexpected packet: %+v", got)
|
||||||
|
}
|
||||||
|
if got.SpacingCM == nil || *got.SpacingCM != 15 {
|
||||||
|
t.Errorf("spacingCm = %v, want 15", got.SpacingCM)
|
||||||
|
}
|
||||||
|
if got.PackedForYear == nil || *got.PackedForYear != 2026 {
|
||||||
|
t.Errorf("packedForYear = %v, want 2026", got.PackedForYear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image and the derived schema really reached the model.
|
||||||
|
call := fp.Calls()[0]
|
||||||
|
if call.Request.SchemaName != "seedpacket" {
|
||||||
|
t.Errorf("schema name = %q, want seedpacket", call.Request.SchemaName)
|
||||||
|
}
|
||||||
|
var sawImage bool
|
||||||
|
for _, p := range call.Request.Messages[0].Parts {
|
||||||
|
if _, ok := p.(llm.ImagePart); ok {
|
||||||
|
sawImage = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawImage {
|
||||||
|
t.Error("the image part didn't reach the model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractLeavesMissingFieldsNil: a packet that only prints a species comes
|
||||||
|
// back with nil pointers for the numbers, not invented zeros — the whole reason
|
||||||
|
// the numeric fields are pointers.
|
||||||
|
func TestExtractLeavesMissingFieldsNil(t *testing.T) {
|
||||||
|
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||||
|
fp := fake.New("fp")
|
||||||
|
reg.RegisterProvider(fp)
|
||||||
|
fp.Enqueue("vision", fake.Reply(`{"species":"basil","category":"herb"}`))
|
||||||
|
m, _ := reg.Parse("fp/vision")
|
||||||
|
|
||||||
|
got, err := generate(context.Background(), m, tinyJPEG(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extract: %v", err)
|
||||||
|
}
|
||||||
|
if got.SpacingCM != nil || got.DaysToMaturity != nil || got.PackedForYear != nil || got.SeedCount != nil {
|
||||||
|
t.Errorf("missing numeric fields should be nil, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractRejectsEmptyImage: no bytes, no call.
|
||||||
|
func TestExtractRejectsEmptyImage(t *testing.T) {
|
||||||
|
if _, err := Extract(context.Background(), "k", "fp/vision", nil); err == nil {
|
||||||
|
t.Error("Extract accepted an empty image")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
|
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||||
import { Toaster } from '@/components/ui/toast'
|
import { Toaster } from '@/components/ui/toast'
|
||||||
import { useLogout, useMe } from '@/lib/auth'
|
import { useLogout, useMe } from '@/lib/auth'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
const navLinks = [
|
// Top-level sections. `icon` is only used by the mobile bottom bar; the desktop
|
||||||
{ to: '/gardens', label: 'Gardens' },
|
// top bar shows labels alone. Settings is filtered to admins at render.
|
||||||
{ to: '/plants', label: 'Plants' },
|
const sections = [
|
||||||
|
{ to: '/gardens', label: 'Gardens', icon: '🏡', adminOnly: false },
|
||||||
|
{ to: '/plants', label: 'Plants', icon: '🌿', adminOnly: false },
|
||||||
|
{ to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
type Section = (typeof sections)[number]
|
||||||
|
|
||||||
// TanStack Router concatenates the base className with activeProps/inactiveProps,
|
// TanStack Router concatenates the base className with activeProps/inactiveProps,
|
||||||
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
|
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
|
||||||
// state props — never in the base — to avoid ambiguous overrides.
|
// state props — never in the base — to avoid ambiguous overrides.
|
||||||
@@ -14,60 +21,59 @@ const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-color
|
|||||||
const navLinkActive = 'bg-border/60 text-fg'
|
const navLinkActive = 'bg-border/60 text-fg'
|
||||||
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
|
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
|
||||||
|
|
||||||
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
|
/**
|
||||||
|
* Top-level chrome. Mobile-first: a slim top bar (brand + account) with the
|
||||||
|
* section nav moved to a thumb-reachable bottom tab bar; the desktop breakpoint
|
||||||
|
* (`md:`) restores the inline top nav. The editor is a full-screen context, so it
|
||||||
|
* owns the bottom of the screen — the app bottom bar hides there (the brand link
|
||||||
|
* is the way back to the gardens list), leaving exactly one bottom bar per route.
|
||||||
|
*/
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const logout = useLogout()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const user = me.data
|
const user = me.data
|
||||||
|
const matchRoute = useMatchRoute()
|
||||||
|
|
||||||
async function onLogout() {
|
// Full-screen canvas contexts own the bottom of the screen: the garden editor
|
||||||
try {
|
// and the public shared-garden view both render a 100dvh-8rem canvas, so a
|
||||||
await logout.mutateAsync()
|
// signed-in viewer must not get the app bottom bar overlapping it. Fuzzy:false
|
||||||
await navigate({ to: '/login' })
|
// so the '/gardens' list itself still shows the bar.
|
||||||
} catch {
|
const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false })
|
||||||
// The logout request failed, so the session is still valid server-side:
|
const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false })
|
||||||
// leave the user where they are (the button re-enables for a retry) rather
|
const showBottomNav = !!user && !inEditor && !inPublicGarden
|
||||||
// than pretending they're signed out. logout.isError drives the title below.
|
|
||||||
}
|
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col">
|
<div className="flex min-h-full flex-col">
|
||||||
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
|
<header className="sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur">
|
||||||
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
|
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
|
||||||
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
||||||
🌱 pansy
|
🌱 pansy
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center gap-1">
|
{/* Desktop inline section links. Hidden on mobile, where the bottom bar
|
||||||
|
carries them. */}
|
||||||
|
<div className="hidden flex-1 items-center gap-1 md:flex">
|
||||||
{user &&
|
{user &&
|
||||||
navLinks.map((l) => (
|
visibleSections.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={l.to}
|
key={s.to}
|
||||||
to={l.to}
|
to={s.to}
|
||||||
className={navLinkBase}
|
className={navLinkBase}
|
||||||
activeProps={{ className: navLinkActive }}
|
activeProps={{ className: navLinkActive }}
|
||||||
inactiveProps={{ className: navLinkInactive }}
|
inactiveProps={{ className: navLinkInactive }}
|
||||||
>
|
>
|
||||||
{l.label}
|
{s.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer so account/sign-out sits right on mobile (the desktop links
|
||||||
|
own flex-1 above). */}
|
||||||
|
<div className="flex-1 md:hidden" />
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center gap-2">
|
<AccountMenu displayName={user.displayName} />
|
||||||
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onLogout}
|
|
||||||
disabled={logout.isPending}
|
|
||||||
title={logout.isError ? 'Sign out failed — try again' : undefined}
|
|
||||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
@@ -79,11 +85,132 @@ export function AppShell() {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
|
<main
|
||||||
|
className={cn(
|
||||||
|
'mx-auto w-full max-w-5xl flex-1 px-4 py-6',
|
||||||
|
// Clear the fixed bottom bar on mobile so content isn't hidden behind
|
||||||
|
// it. The 3.5rem must match BottomNav's h-14 (kept adjacent below).
|
||||||
|
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Boundary for the lazily-loaded routes (see router.tsx). */}
|
||||||
|
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading…</p>}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{showBottomNav && <BottomNav sections={visibleSections} />}
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Account control: a compact button that toggles a small sign-out popover. On
|
||||||
|
* desktop the display name shows inline; on mobile it lives inside the popover. */
|
||||||
|
function AccountMenu({ displayName }: { displayName: string }) {
|
||||||
|
const logout = useLogout()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
|
||||||
|
// Close on any route change, so navigating (bottom nav, browser back) can't
|
||||||
|
// leave the popover — and its full-screen backdrop — stuck open over the page.
|
||||||
|
useEffect(() => setOpen(false), [pathname])
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
try {
|
||||||
|
await logout.mutateAsync()
|
||||||
|
await navigate({ to: '/login' })
|
||||||
|
} catch {
|
||||||
|
// The logout request failed, so the session is still valid server-side.
|
||||||
|
// Keep the popover OPEN so the button (now "Retry sign out", driven by
|
||||||
|
// logout.isError) stays on screen — closing it would hide the only retry
|
||||||
|
// affordance and pretend nothing went wrong.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = displayName.trim().charAt(0).toUpperCase() || '·'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:text-fg"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">{displayName}</span>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex size-8 items-center justify-center rounded-full bg-border/60 text-sm font-semibold text-fg"
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{/* Full-screen click-away backdrop; reliably closes on an outside tap
|
||||||
|
without a document listener. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close menu"
|
||||||
|
className="fixed inset-0 z-30 cursor-default"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 z-40 mt-2 w-48 rounded-lg border border-border bg-surface p-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<p className="truncate px-3 py-2 text-xs text-muted">
|
||||||
|
Signed in as <span className="text-fg">{displayName}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onLogout}
|
||||||
|
disabled={logout.isPending}
|
||||||
|
title={logout.isError ? 'Sign out failed — try again' : undefined}
|
||||||
|
className="w-full rounded-md px-3 py-2 text-left text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */
|
||||||
|
function BottomNav({ sections }: { sections: ReadonlyArray<Section> }) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Sections"
|
||||||
|
className="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-surface/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||||
|
>
|
||||||
|
<ul className="mx-auto flex max-w-5xl items-stretch justify-around">
|
||||||
|
{sections.map((s) => (
|
||||||
|
<li key={s.to} className="flex-1">
|
||||||
|
{/* h-14 matches the clearance reserved on <main> above. Color lives
|
||||||
|
only in the state props (per the top-bar convention), never the
|
||||||
|
base, so active/inactive don't fight. */}
|
||||||
|
<Link
|
||||||
|
to={s.to}
|
||||||
|
className="flex h-14 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors"
|
||||||
|
activeProps={{ className: 'text-accent-strong' }}
|
||||||
|
inactiveProps={{ className: 'text-muted hover:text-fg' }}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="text-lg leading-none">
|
||||||
|
{s.icon}
|
||||||
|
</span>
|
||||||
|
{s.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small tap-to-arm plant chip (icon + name), highlighted when it's the armed
|
||||||
|
* plant. Shared by the Seed Tray (which wraps it with a remove button) and the
|
||||||
|
* Recent-plants strip, so the two quick-pick surfaces stay visually identical.
|
||||||
|
* `rounded` is false when a caller (the tray) attaches a trailing control and
|
||||||
|
* needs a flat right edge.
|
||||||
|
*/
|
||||||
|
export function PlantChip({
|
||||||
|
plant,
|
||||||
|
active,
|
||||||
|
onArm,
|
||||||
|
rounded = true,
|
||||||
|
}: {
|
||||||
|
plant: Plant
|
||||||
|
active: boolean
|
||||||
|
onArm: (plant: Plant) => void
|
||||||
|
rounded?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onArm(plant)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={active ? `Placing ${plant.name}` : `Place ${plant.name}`}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||||
|
rounded ? 'rounded-full pr-2.5' : 'rounded-l-full pr-1',
|
||||||
|
active
|
||||||
|
? 'border-accent bg-accent/10 text-accent-strong'
|
||||||
|
: 'border-border bg-surface text-fg hover:border-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PlantIcon color={plant.color} icon={plant.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
|
||||||
|
<span className="max-w-[6rem] truncate">{plant.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from 'react'
|
import { useEffect, useRef, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
|
||||||
|
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
|
||||||
|
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
|
||||||
|
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
|
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
|
||||||
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
||||||
@@ -27,12 +34,57 @@ export function Modal({
|
|||||||
busyRef.current = busy
|
busyRef.current = busy
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cardRef.current?.focus()
|
const card = cardRef.current
|
||||||
|
// Remember who opened the dialog so focus can return there on close —
|
||||||
|
// otherwise it lands on <body> and a keyboard user loses their place.
|
||||||
|
const opener = document.activeElement as HTMLElement | null
|
||||||
|
card?.focus()
|
||||||
|
|
||||||
|
const focusable = () =>
|
||||||
|
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && !busyRef.current) onCloseRef.current()
|
if (e.key === 'Escape' && !busyRef.current) {
|
||||||
|
onCloseRef.current()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
const items = focusable()
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
card?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const first = items[0]
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
const active = document.activeElement
|
||||||
|
// If focus is NOT inside the dialog, pull it back in rather than let Tab
|
||||||
|
// escape. This is the robust case that covers focus having fallen to
|
||||||
|
// <body> — a control that was removed (ShareGardenModal's remove-share
|
||||||
|
// button) or disabled while busy — as well as any externally-stolen focus.
|
||||||
|
if (!card || !card.contains(active)) {
|
||||||
|
e.preventDefault()
|
||||||
|
;(e.shiftKey ? last : first).focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.shiftKey && (active === first || active === card)) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
// Restore focus to the opener only if it's still in the document — the
|
||||||
|
// delete/clear flows this trap targets often remove the element that
|
||||||
|
// opened the dialog (a garden card, a plop row). A disconnected node's
|
||||||
|
// focus() silently no-ops and leaves focus on <body>, so fall through to
|
||||||
|
// that case explicitly rather than pretend it worked.
|
||||||
|
if (opener && opener.isConnected) opener.focus()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ interface ToastState {
|
|||||||
|
|
||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
|
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
|
||||||
|
// the stack without bound and push older ones off-screen. Cap it: keep the most
|
||||||
|
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
|
||||||
|
// happened — is always visible.
|
||||||
|
const MAX_TOASTS = 4
|
||||||
|
|
||||||
export const useToastStore = create<ToastState>((set) => ({
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
toasts: [],
|
toasts: [],
|
||||||
push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })),
|
push: (message, tone = 'info') =>
|
||||||
|
set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }].slice(-MAX_TOASTS) })),
|
||||||
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -32,21 +39,34 @@ export const toast = {
|
|||||||
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
||||||
function ToastItem({ item }: { item: Toast }) {
|
function ToastItem({ item }: { item: Toast }) {
|
||||||
const dismiss = useToastStore((s) => s.dismiss)
|
const dismiss = useToastStore((s) => s.dismiss)
|
||||||
|
const isError = item.tone === 'error'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Error toasts are the primary report that a mutation failed, so they do NOT
|
||||||
|
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
||||||
|
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
||||||
|
if (isError) return
|
||||||
const t = setTimeout(() => dismiss(item.id), 4000)
|
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [item.id, dismiss])
|
}, [item.id, dismiss, isError])
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={item.tone === 'error' ? 'alert' : 'status'}
|
role={isError ? 'alert' : 'status'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
|
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
||||||
item.tone === 'error'
|
isError
|
||||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
: 'border-border bg-surface text-fg',
|
: 'border-border bg-surface text-fg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.message}
|
<span className="flex-1">{item.message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(item.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,25 @@ import { useClearObject } from '@/lib/objects'
|
|||||||
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
||||||
* are kept with removed_at, so history survives). */
|
* are kept with removed_at, so history survives). */
|
||||||
export function ClearBedModal({
|
export function ClearBedModal({
|
||||||
|
objectId,
|
||||||
objectName,
|
objectName,
|
||||||
plops,
|
plopCount,
|
||||||
gardenId,
|
gardenId,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
|
objectId: number
|
||||||
objectName: string
|
objectName: string
|
||||||
plops: { id: number; version: number }[]
|
plopCount: number
|
||||||
gardenId: number
|
gardenId: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const clear = useClearObject(gardenId)
|
const clear = useClearObject(gardenId)
|
||||||
const n = plops.length
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '}
|
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||||
|
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -31,8 +33,8 @@ export function ClearBedModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={clear.isPending || n === 0}
|
disabled={clear.isPending || plopCount === 0}
|
||||||
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
|
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
|
||||||
>
|
>
|
||||||
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -235,7 +235,13 @@ export function GardenCanvas({
|
|||||||
className="h-full w-full select-none"
|
className="h-full w-full select-none"
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: 'none' }}
|
||||||
onPointerDown={onCanvasPointerDown}
|
onPointerDown={onCanvasPointerDown}
|
||||||
|
// role="application" tells a screen reader this is an interactive canvas
|
||||||
|
// to operate, not a document to read linearly. The <title> names it, and
|
||||||
|
// objects inside are individually focusable buttons (see ObjectShape).
|
||||||
|
role="application"
|
||||||
|
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
|
||||||
>
|
>
|
||||||
|
<title>{garden.name} garden layout</title>
|
||||||
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
||||||
{drawnGridCm != null && (
|
{drawnGridCm != null && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
import type { EditorObject } from './types'
|
import type { EditorObject } from './types'
|
||||||
import { objectDisplayName } from './kinds'
|
import { objectDisplayName } from './kinds'
|
||||||
|
|
||||||
|
// Shared styling for the small From/To date inputs, so the two stay in step and
|
||||||
|
// don't drift from each other.
|
||||||
|
const dateInputClass =
|
||||||
|
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The garden's journal: write an entry, read the season back.
|
* The garden's journal: write an entry, read the season back.
|
||||||
*
|
*
|
||||||
@@ -42,7 +47,15 @@ export function JournalPanel({
|
|||||||
scopeObjectId: number | null
|
scopeObjectId: number | null
|
||||||
onScopeChange: (id: number | null) => void
|
onScopeChange: (id: number | null) => void
|
||||||
}) {
|
}) {
|
||||||
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
|
// Date-range narrowing (#85): the backend and JournalFilter already supported
|
||||||
|
// from/to; they just had no UI. Empty inputs don't filter.
|
||||||
|
const [from, setFrom] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const filter = {
|
||||||
|
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
|
||||||
|
...(from ? { from } : {}),
|
||||||
|
...(to ? { to } : {}),
|
||||||
|
}
|
||||||
const journal = useJournal(gardenId, filter)
|
const journal = useJournal(gardenId, filter)
|
||||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||||
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||||
@@ -70,6 +83,41 @@ export function JournalPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span>From</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={from}
|
||||||
|
max={to || undefined}
|
||||||
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
|
className={dateInputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span>To</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={to}
|
||||||
|
min={from || undefined}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
className={dateInputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(from || to) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFrom('')
|
||||||
|
setTo('')
|
||||||
|
}}
|
||||||
|
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Composer
|
<Composer
|
||||||
gardenId={gardenId}
|
gardenId={gardenId}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, type PointerEvent } from 'react'
|
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
|
||||||
import { objectTransform } from './shared'
|
import { objectTransform } from './shared'
|
||||||
|
import { kindDef, objectDisplayName } from './kinds'
|
||||||
import type { EditorObject } from './types'
|
import type { EditorObject } from './types'
|
||||||
|
|
||||||
const DEFAULT_FILL = '#8a8a8a'
|
const DEFAULT_FILL = '#8a8a8a'
|
||||||
@@ -57,11 +58,50 @@ export const ObjectShape = memo(function ObjectShape({
|
|||||||
onSelect(object.id)
|
onSelect(object.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard path into selection (#84): the arrow-key nudge handler already
|
||||||
|
// exists but only ever acted on a pointer selection, so it was unreachable
|
||||||
|
// without a mouse. Enter/Space on a focused object selects it, which is the
|
||||||
|
// step that was missing.
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onSelect(object.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stroke = selected ? '#2f7a3e' : '#00000033'
|
const stroke = selected ? '#2f7a3e' : '#00000033'
|
||||||
const strokeWidth = selected ? 2 : 1
|
const strokeWidth = selected ? 2 : 1
|
||||||
|
|
||||||
|
// A concise accessible name: the object's label plus its kind's canonical
|
||||||
|
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
|
||||||
|
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
|
||||||
|
// dimensions aren't included; they need the garden's unit context this
|
||||||
|
// component doesn't hold, so they're a follow-up.
|
||||||
|
const kindLabel = kindDef(object.kind)?.label ?? object.kind
|
||||||
|
const label = `${objectDisplayName(object)}, ${kindLabel}`
|
||||||
|
|
||||||
|
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
|
||||||
|
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
|
||||||
|
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
|
||||||
|
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
|
||||||
|
// rather than React state because onFocus on an SVG <g> is unreliable and a
|
||||||
|
// presentation attribute is overridden by any CSS rule.
|
||||||
return (
|
return (
|
||||||
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
|
<g
|
||||||
|
className="object-shape"
|
||||||
|
transform={objectTransform(object)}
|
||||||
|
onPointerDown={handleDown}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={label}
|
||||||
|
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
|
||||||
|
// toggle is what aria-pressed means). aria-current marks it as the active
|
||||||
|
// item among the objects. Omitted, not "false", when unselected.
|
||||||
|
aria-current={selected || undefined}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
{object.shape === 'circle' ? (
|
{object.shape === 'circle' ? (
|
||||||
<ellipse
|
<ellipse
|
||||||
cx={0}
|
cx={0}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { PlantChip } from '@/components/plants/PlantChip'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
|
||||||
|
* so re-placing "more of the same" is one tap instead of a trip through the
|
||||||
|
* catalog or the manual tray. Derived from actual plantings (see
|
||||||
|
* recentlyPlantedIds), newest first; renders nothing until something's planted.
|
||||||
|
* Tap a chip to arm it for placement (the armed one is highlighted).
|
||||||
|
*/
|
||||||
|
export function RecentPlants({
|
||||||
|
plants,
|
||||||
|
armedPlantId,
|
||||||
|
onArm,
|
||||||
|
}: {
|
||||||
|
plants: Plant[]
|
||||||
|
armedPlantId: number | null
|
||||||
|
onArm: (plant: Plant) => void
|
||||||
|
}) {
|
||||||
|
if (plants.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||||
|
<span className="shrink-0 text-[0.7rem] font-medium uppercase tracking-wide text-muted">Recent</span>
|
||||||
|
{plants.map((p) => (
|
||||||
|
<PlantChip key={p.id} plant={p} active={p.id === armedPlantId} onArm={onArm} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+10
-19
@@ -1,5 +1,5 @@
|
|||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
import { PlantChip } from '@/components/plants/PlantChip'
|
||||||
import type { Plant } from '@/lib/plants'
|
import type { Plant } from '@/lib/plants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,28 +26,19 @@ export function SeedTray({
|
|||||||
{trayPlants.map((p) => {
|
{trayPlants.map((p) => {
|
||||||
const active = p.id === armedPlantId
|
const active = p.id === armedPlantId
|
||||||
return (
|
return (
|
||||||
<span
|
<span key={p.id} className="inline-flex items-center">
|
||||||
key={p.id}
|
{/* Flat right edge so the remove button below seams into one pill. */}
|
||||||
className={cn(
|
<PlantChip plant={p} active={active} onArm={onArm} rounded={false} />
|
||||||
'inline-flex items-center rounded-full border text-xs transition-colors',
|
|
||||||
active ? 'border-accent bg-accent/10 text-accent-strong' : 'border-border bg-surface text-fg',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onArm(p)}
|
|
||||||
aria-pressed={active}
|
|
||||||
title={active ? `Placing ${p.name}` : `Place ${p.name}`}
|
|
||||||
className="flex items-center gap-1.5 rounded-full py-1 pl-1.5 pr-1 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
||||||
>
|
|
||||||
<PlantIcon color={p.color} icon={p.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
|
|
||||||
<span className="max-w-[6rem] truncate font-medium">{p.name}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(p.id)}
|
onClick={() => onRemove(p.id)}
|
||||||
aria-label={`Remove ${p.name} from tray`}
|
aria-label={`Remove ${p.name} from tray`}
|
||||||
className="rounded-full px-1.5 py-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
className={cn(
|
||||||
|
'rounded-r-full border border-l-0 px-1.5 py-1 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||||
|
active
|
||||||
|
? 'border-accent bg-accent/10 text-accent-strong hover:text-fg'
|
||||||
|
: 'border-border bg-surface text-muted hover:text-fg',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,10 +11,25 @@ import type { EditorObject } from './types'
|
|||||||
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
||||||
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
||||||
|
|
||||||
|
// The editor's one primary activity (#99). On mobile this is the segmented
|
||||||
|
// control at the bottom of the screen; it decides which tools dock there —
|
||||||
|
// placing fixtures, placing plants, the journal, or the assistant — so the four
|
||||||
|
// activities stop competing for the same cramped strip. Desktop keeps its
|
||||||
|
// side-column layout and treats this as a lighter hint.
|
||||||
|
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
|
||||||
|
|
||||||
|
// Where the editor starts, and where it returns on a garden switch.
|
||||||
|
export const DEFAULT_MODE: EditorMode = 'fixtures'
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
viewport: Viewport
|
viewport: Viewport
|
||||||
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
||||||
|
|
||||||
|
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
|
||||||
|
// last using is not a property of the garden.
|
||||||
|
mode: EditorMode
|
||||||
|
setMode: (mode: EditorMode) => void
|
||||||
|
|
||||||
// The selected object OR plop (mutually exclusive; selecting one clears the
|
// The selected object OR plop (mutually exclusive; selecting one clears the
|
||||||
// other). selectedId is a garden object; selectedPlantingId is a plop.
|
// other). selectedId is a garden object; selectedPlantingId is a plop.
|
||||||
selectedId: number | null
|
selectedId: number | null
|
||||||
@@ -82,6 +97,9 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
viewport: { tx: 0, ty: 0, scale: 1 },
|
viewport: { tx: 0, ty: 0, scale: 1 },
|
||||||
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
||||||
|
|
||||||
|
mode: DEFAULT_MODE,
|
||||||
|
setMode: (mode) => set({ mode }),
|
||||||
|
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
|
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
|
||||||
|
|
||||||
@@ -129,5 +147,6 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
railTab: null,
|
railTab: null,
|
||||||
seasonYear: null,
|
seasonYear: null,
|
||||||
journalObjectId: null,
|
journalObjectId: null,
|
||||||
|
mode: DEFAULT_MODE,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
+27
-5
@@ -13,13 +13,21 @@ import { historyKey } from './history'
|
|||||||
|
|
||||||
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
||||||
|
|
||||||
/** Whether this instance has the assistant configured. Without it the panel
|
export const capabilitiesKey = ['capabilities'] as const
|
||||||
* isn't rendered at all — a dead button is worse than no button. */
|
|
||||||
|
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
|
||||||
|
* at all — a dead button is worse than no button.
|
||||||
|
*
|
||||||
|
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
|
||||||
|
* in Settings (#79), so this must be able to change under a running page. The
|
||||||
|
* settings save invalidates this key directly; the finite staleTime just means
|
||||||
|
* another admin's change is picked up on the next focus/remount rather than
|
||||||
|
* never. */
|
||||||
export function useCapabilities() {
|
export function useCapabilities() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['capabilities'] as const,
|
queryKey: capabilitiesKey,
|
||||||
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
|
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
|
||||||
staleTime: Infinity, // server config; it doesn't change under a running page
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +160,23 @@ export async function streamChat(
|
|||||||
handlers.onError('Could not reach the server.')
|
handlers.onError('Could not reach the server.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Session expired mid-conversation (#85). Reporting this as "the assistant is
|
||||||
|
// unavailable" would send the user chasing a config problem that isn't there.
|
||||||
|
// Send them to sign in again, preserving where they were.
|
||||||
|
handlers.onError('Your session has expired — please sign in again.')
|
||||||
|
const back = encodeURIComponent(location.pathname + location.search)
|
||||||
|
window.location.assign(`/login?redirect=${back}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
|
// 503 is the assistant being turned off at runtime (#79) — the route exists,
|
||||||
|
// there's just no model behind it. Distinct from a 404, which would mean the
|
||||||
|
// whole endpoint is absent.
|
||||||
handlers.onError(
|
handlers.onError(
|
||||||
res.status === 404
|
res.status === 503
|
||||||
|
? "The assistant isn't enabled on this instance."
|
||||||
|
: res.status === 404
|
||||||
? "This instance doesn't have the assistant configured."
|
? "This instance doesn't have the assistant configured."
|
||||||
: 'The assistant is not available right now.',
|
: 'The assistant is not available right now.',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export class ApiError extends Error {
|
|||||||
get isUnauthorized(): boolean {
|
get isUnauthorized(): boolean {
|
||||||
return this.status === 401
|
return this.status === 401
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resource is gone or masked (pansy returns 404 for no-access, not 403). */
|
||||||
|
get isNotFound(): boolean {
|
||||||
|
return this.status === 404
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParamValue = string | number | boolean | undefined | null
|
type ParamValue = string | number | boolean | undefined | null
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { getLastGardenId, rememberLastGarden, forgetLastGarden } from './lastGarden'
|
||||||
|
|
||||||
|
// The tests run in the node environment (no DOM), so stand up a minimal
|
||||||
|
// in-memory localStorage rather than pull in jsdom for one thin module.
|
||||||
|
function installStorage(impl?: Partial<Storage>) {
|
||||||
|
const store = new Map<string, string>()
|
||||||
|
const base: Storage = {
|
||||||
|
getItem: (k) => store.get(k) ?? null,
|
||||||
|
setItem: (k, v) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
key: (i) => [...store.keys()][i] ?? null,
|
||||||
|
get length() {
|
||||||
|
return store.size
|
||||||
|
},
|
||||||
|
}
|
||||||
|
;(globalThis as { localStorage?: Storage }).localStorage = { ...base, ...impl }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => installStorage())
|
||||||
|
afterEach(() => {
|
||||||
|
delete (globalThis as { localStorage?: Storage }).localStorage
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lastGarden', () => {
|
||||||
|
it('round-trips a remembered garden id', () => {
|
||||||
|
expect(getLastGardenId()).toBeNull()
|
||||||
|
rememberLastGarden(42)
|
||||||
|
expect(getLastGardenId()).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-positive or unparseable stored value', () => {
|
||||||
|
localStorage.setItem('pansy:last-garden', 'not-a-number')
|
||||||
|
expect(getLastGardenId()).toBeNull()
|
||||||
|
localStorage.setItem('pansy:last-garden', '0')
|
||||||
|
expect(getLastGardenId()).toBeNull()
|
||||||
|
localStorage.setItem('pansy:last-garden', '-3')
|
||||||
|
expect(getLastGardenId()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forgets unconditionally with no argument', () => {
|
||||||
|
rememberLastGarden(7)
|
||||||
|
forgetLastGarden()
|
||||||
|
expect(getLastGardenId()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forgets only when the stored id matches onlyIfEquals', () => {
|
||||||
|
rememberLastGarden(5)
|
||||||
|
// A 404 on a different (directly-linked) garden must not wipe the good resume.
|
||||||
|
forgetLastGarden(9)
|
||||||
|
expect(getLastGardenId()).toBe(5)
|
||||||
|
// A 404 on the stored garden itself does clear it.
|
||||||
|
forgetLastGarden(5)
|
||||||
|
expect(getLastGardenId()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('swallows storage failures instead of throwing', () => {
|
||||||
|
installStorage({
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('quota')
|
||||||
|
},
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error('blocked')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(() => rememberLastGarden(1)).not.toThrow()
|
||||||
|
expect(getLastGardenId()).toBeNull() // getItem throwing → null, not a crash
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// The last garden opened on THIS device, so a returning user resumes where they
|
||||||
|
// were instead of always landing on the gardens list. Per-device, in
|
||||||
|
// localStorage (same rationale as the seed tray and PlantPicker recents): a
|
||||||
|
// convenience, not authoritative state — a quota/availability failure is
|
||||||
|
// swallowed, and a stored id that no longer loads clears itself (see the editor).
|
||||||
|
//
|
||||||
|
// Only the id is stored. The garden is resolved by the editor on load; if it's
|
||||||
|
// gone (deleted, or access revoked), forgetLastGarden() drops it so `/` stops
|
||||||
|
// resuming a garden that can't open.
|
||||||
|
|
||||||
|
const KEY = 'pansy:last-garden'
|
||||||
|
|
||||||
|
/** The last-opened garden id on this device, or null if none/unparseable. */
|
||||||
|
export function getLastGardenId(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (raw == null) return null
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the garden the device is now in, so `/` resumes here next time. */
|
||||||
|
export function rememberLastGarden(id: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(id))
|
||||||
|
} catch {
|
||||||
|
// Resume is a convenience; ignore quota/availability failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget the stored last garden. With `onlyIfEquals`, clears only when the stored
|
||||||
|
* id matches — so a 404 on a directly-linked garden can't wipe a different, still
|
||||||
|
* good resume target the user had.
|
||||||
|
*/
|
||||||
|
export function forgetLastGarden(onlyIfEquals?: number): void {
|
||||||
|
try {
|
||||||
|
if (onlyIfEquals != null && getLastGardenId() !== onlyIfEquals) return
|
||||||
|
localStorage.removeItem(KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-17
@@ -328,28 +328,60 @@ export function useUpdatePlanting(gardenId: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes;
|
const clearResultSchema = z.object({ cleared: z.number() })
|
||||||
* a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates
|
const fillResultSchema = z.object({ created: z.number() })
|
||||||
* once at the end. Pass the object's active plops (id + current version). */
|
|
||||||
|
/** Fill mode (#77/#100): the layout a region fill packs — fat clumps for quick
|
||||||
|
* coverage, or a grid of individual plants at true spacing you could plant from.
|
||||||
|
* Passed straight through to the server's `layout` field. */
|
||||||
|
export type FillLayout = 'clump' | 'grid'
|
||||||
|
|
||||||
|
/** Fill a whole plantable object with one plant at the chosen layout, via the
|
||||||
|
* same `POST /objects/:id/fill` the agent uses (region "all"). The response is
|
||||||
|
* just a count; invalidate rather than optimistically splice a hex lattice we'd
|
||||||
|
* have to recompute client-side. Returns how many plops it created. */
|
||||||
|
export function useFillObject(gardenId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
objectId,
|
||||||
|
plantId,
|
||||||
|
layout,
|
||||||
|
}: {
|
||||||
|
objectId: number
|
||||||
|
plantId: number
|
||||||
|
layout: FillLayout
|
||||||
|
}): Promise<number> => {
|
||||||
|
const res = fillResultSchema.parse(
|
||||||
|
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }),
|
||||||
|
)
|
||||||
|
return res.created
|
||||||
|
},
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
||||||
|
onError: (err) => toast.error(objectErrorMessage(err, 'Could not fill the bed.')),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear a bed: soft-remove every active plop in an object (#82).
|
||||||
|
*
|
||||||
|
* ONE request, and so ONE change set. This used to be a loop of PATCHes, which
|
||||||
|
* meant clearing a 40-plop bed wrote 40 change sets and took 40 presses of Undo
|
||||||
|
* to put back — while the agent's clear_object, for the identical user-facing
|
||||||
|
* action, undid in a single click. The rule it violated is stated in CLAUDE.md:
|
||||||
|
* multi-row operations record all their changes together so they undo as one
|
||||||
|
* unit. Doing it server-side also removes the partial-failure case the old loop
|
||||||
|
* had to reconcile. */
|
||||||
export function useClearObject(gardenId: number) {
|
export function useClearObject(gardenId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (plops: { id: number; version: number }[]) => {
|
mutationFn: async (objectId: number): Promise<number> => {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
// No body — clear takes none; passing undefined sends none rather than an
|
||||||
// allSettled, not all: a partial failure still soft-removed some rows
|
// empty {}. The response is just a count; validate it rather than cast.
|
||||||
// server-side, so we must reconcile the cache rather than roll everything
|
const res = clearResultSchema.parse(await api.post(`/objects/${objectId}/clear`))
|
||||||
// back. Report how many failed.
|
return res.cleared
|
||||||
const results = await Promise.allSettled(
|
|
||||||
plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })),
|
|
||||||
)
|
|
||||||
const failed = results.filter((r) => r.status === 'rejected').length
|
|
||||||
if (failed > 0) {
|
|
||||||
throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// Reconcile on success OR partial failure, so the cache matches the server.
|
|
||||||
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
||||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'),
|
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { computeDerivedCount, effectiveCount } from './plantings'
|
import { computeDerivedCount, effectiveCount, recentlyPlantedIds, type EditorPlanting } from './plantings'
|
||||||
|
|
||||||
|
function plop(over: Partial<EditorPlanting>): EditorPlanting {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
objectId: 1,
|
||||||
|
plantId: 1,
|
||||||
|
xCm: 0,
|
||||||
|
yCm: 0,
|
||||||
|
radiusCm: 5,
|
||||||
|
count: null,
|
||||||
|
derivedCount: 1,
|
||||||
|
label: null,
|
||||||
|
plantedAt: null,
|
||||||
|
version: 1,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('recentlyPlantedIds', () => {
|
||||||
|
it('returns unique plant ids, newest planted first', () => {
|
||||||
|
const got = recentlyPlantedIds([
|
||||||
|
plop({ id: 1, plantId: 10, plantedAt: '2026-05-01' }),
|
||||||
|
plop({ id: 2, plantId: 20, plantedAt: '2026-07-01' }),
|
||||||
|
plop({ id: 3, plantId: 10, plantedAt: '2026-06-01' }), // dup plant, later
|
||||||
|
])
|
||||||
|
// 20 (Jul) before 10 (its most recent plop is Jun); 10 appears once.
|
||||||
|
expect(got).toEqual([20, 10])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('breaks a same-day tie by newer plop id, and sorts undated last', () => {
|
||||||
|
const got = recentlyPlantedIds([
|
||||||
|
plop({ id: 5, plantId: 30, plantedAt: null }),
|
||||||
|
plop({ id: 6, plantId: 40, plantedAt: '2026-07-01' }),
|
||||||
|
plop({ id: 7, plantId: 50, plantedAt: '2026-07-01' }),
|
||||||
|
])
|
||||||
|
expect(got).toEqual([50, 40, 30]) // id 7 > 6 on the same day; undated 30 last
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is empty for no plantings', () => {
|
||||||
|
expect(recentlyPlantedIds([])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('computeDerivedCount', () => {
|
describe('computeDerivedCount', () => {
|
||||||
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
|
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ export function effectiveCount(p: { count: number | null; derivedCount: number }
|
|||||||
return p.count ?? p.derivedCount
|
return p.count ?? p.derivedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plant ids a garden has been planted with, most recent first and de-duplicated —
|
||||||
|
* the "what have I actually been planting here" quick list (#100). Ordered by
|
||||||
|
* plantedAt (a plop with no date sorts last), then by id so newer plops win a
|
||||||
|
* same-day tie. The caller resolves the ids to plants against the catalog.
|
||||||
|
*/
|
||||||
|
export function recentlyPlantedIds(plantings: EditorPlanting[]): number[] {
|
||||||
|
const sorted = [...plantings].sort(
|
||||||
|
(a, b) => (b.plantedAt ?? '').localeCompare(a.plantedAt ?? '') || b.id - a.id,
|
||||||
|
)
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const ids: number[] = []
|
||||||
|
for (const p of sorted) {
|
||||||
|
if (!seen.has(p.plantId)) {
|
||||||
|
seen.add(p.plantId)
|
||||||
|
ids.push(p.plantId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
/** Client-side mirror of the server's derived-count formula, for live display
|
/** Client-side mirror of the server's derived-count formula, for live display
|
||||||
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
|
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
|
||||||
* spacing²)), capped like the server. */
|
* spacing²)), capped like the server. */
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Instance settings data layer (#79): admin-only, instance-wide configuration.
|
||||||
|
//
|
||||||
|
// The GET/PATCH return both the stored settings and a read-only "effective" view
|
||||||
|
// — what's actually in force after layering the DB over the environment — so the
|
||||||
|
// form can say "inheriting ollama-cloud/glm-5.2:cloud from the environment" and
|
||||||
|
// whether the API key is present, without the key ever crossing the wire.
|
||||||
|
|
||||||
|
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { ApiError, api } from './api'
|
||||||
|
import { capabilitiesKey } from './agent'
|
||||||
|
|
||||||
|
export const instanceSettingsSchema = z.object({
|
||||||
|
// '' means "inherit the PANSY_AGENT_MODEL env var".
|
||||||
|
agentModel: z.string(),
|
||||||
|
// null means "inherit PANSY_AGENT_ENABLED"; true/false is an explicit override.
|
||||||
|
agentEnabled: z.boolean().nullable(),
|
||||||
|
version: z.number(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
})
|
||||||
|
export type InstanceSettings = z.infer<typeof instanceSettingsSchema>
|
||||||
|
|
||||||
|
export const effectiveAgentSchema = z.object({
|
||||||
|
model: z.string(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
hasApiKey: z.boolean(),
|
||||||
|
agentLive: z.boolean(),
|
||||||
|
})
|
||||||
|
export type EffectiveAgent = z.infer<typeof effectiveAgentSchema>
|
||||||
|
|
||||||
|
export const settingsResponseSchema = z.object({
|
||||||
|
settings: instanceSettingsSchema,
|
||||||
|
effective: effectiveAgentSchema,
|
||||||
|
})
|
||||||
|
export type SettingsResponse = z.infer<typeof settingsResponseSchema>
|
||||||
|
|
||||||
|
export const settingsKey = ['settings'] as const
|
||||||
|
|
||||||
|
export const settingsQueryOptions = queryOptions({
|
||||||
|
queryKey: settingsKey,
|
||||||
|
queryFn: async (): Promise<SettingsResponse> =>
|
||||||
|
settingsResponseSchema.parse(await api.get('/settings')),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return useQuery(settingsQueryOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsUpdate {
|
||||||
|
agentModel: string
|
||||||
|
agentEnabled: boolean | null
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSettings() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: SettingsUpdate): Promise<SettingsResponse> =>
|
||||||
|
settingsResponseSchema.parse(await api.patch('/settings', input)),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.setQueryData(settingsKey, res)
|
||||||
|
// The save may have turned the assistant on or off; the editor keys its
|
||||||
|
// chat tab off /capabilities, so make it re-read rather than trust its
|
||||||
|
// cached answer.
|
||||||
|
qc.invalidateQueries({ queryKey: capabilitiesKey })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If err is a 409 version conflict, return the fresh settings it carries so a
|
||||||
|
* form can rebase; otherwise null. */
|
||||||
|
export function conflictSettings(err: unknown): InstanceSettings | null {
|
||||||
|
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
|
||||||
|
const current = (err.body as { current?: unknown }).current
|
||||||
|
const parsed = instanceSettingsSchema.safeParse(current)
|
||||||
|
if (parsed.success) return parsed.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -12,33 +12,43 @@ import { PlopInspector } from '@/editor/PlopInspector'
|
|||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
import { Palette } from '@/editor/Palette'
|
import { Palette } from '@/editor/Palette'
|
||||||
import { SeedTray } from '@/editor/SeedTray'
|
import { SeedTray } from '@/editor/SeedTray'
|
||||||
|
import { RecentPlants } from '@/editor/RecentPlants'
|
||||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||||
import { EditorHint } from '@/editor/EditorHint'
|
import { EditorHint } from '@/editor/EditorHint'
|
||||||
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||||||
import { objectDisplayName } from '@/editor/kinds'
|
import { objectDisplayName } from '@/editor/kinds'
|
||||||
import { useEditorStore } from '@/editor/store'
|
import { useEditorStore, type EditorMode } from '@/editor/store'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
import type { EditorGarden } from '@/editor/types'
|
import type { EditorGarden } from '@/editor/types'
|
||||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||||
import { useMe } from '@/lib/auth'
|
import { useMe } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
toEditorObject,
|
toEditorObject,
|
||||||
useEnsurePlantInFull,
|
useEnsurePlantInFull,
|
||||||
|
useFillObject,
|
||||||
useGardenFull,
|
useGardenFull,
|
||||||
useGardenSeason,
|
useGardenSeason,
|
||||||
useGardenYears,
|
useGardenYears,
|
||||||
useUpdateObject,
|
useUpdateObject,
|
||||||
useUpdatePlanting,
|
useUpdatePlanting,
|
||||||
|
type FillLayout,
|
||||||
} from '@/lib/objects'
|
} from '@/lib/objects'
|
||||||
import { toEditorPlanting } from '@/lib/plantings'
|
import { recentlyPlantedIds, toEditorPlanting } from '@/lib/plantings'
|
||||||
import type { Plant } from '@/lib/plants'
|
import type { Plant } from '@/lib/plants'
|
||||||
import { useCapabilities } from '@/lib/agent'
|
import { useCapabilities } from '@/lib/agent'
|
||||||
import { useJournalCounts } from '@/lib/journal'
|
import { useJournalCounts } from '@/lib/journal'
|
||||||
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import { useSeedTray } from '@/lib/seedTray'
|
import { useSeedTray } from '@/lib/seedTray'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
import { rememberLastGarden, forgetLastGarden } from '@/lib/lastGarden'
|
||||||
|
import { ApiError } from '@/lib/api'
|
||||||
|
|
||||||
const routeApi = getRouteApi('/gardens/$gardenId')
|
const routeApi = getRouteApi('/gardens/$gardenId')
|
||||||
|
|
||||||
|
// How many recently-planted chips the Plants-mode quick strip shows — a working
|
||||||
|
// set, not the whole history; the picker covers the long tail.
|
||||||
|
const RECENT_PLANTS_MAX = 8
|
||||||
|
|
||||||
export function GardenEditorPage() {
|
export function GardenEditorPage() {
|
||||||
const { gardenId } = routeApi.useParams()
|
const { gardenId } = routeApi.useParams()
|
||||||
const gid = Number(gardenId)
|
const gid = Number(gardenId)
|
||||||
@@ -55,6 +65,14 @@ export function GardenEditorPage() {
|
|||||||
const me = useMe()
|
const me = useMe()
|
||||||
usePageTitle(full.data?.garden.name ?? 'Garden')
|
usePageTitle(full.data?.garden.name ?? 'Garden')
|
||||||
|
|
||||||
|
// The garden itself is gone (deleted, or access revoked) — keyed on the LIVE
|
||||||
|
// query, since whether the garden EXISTS doesn't depend on the season being
|
||||||
|
// viewed. Both the bounce effect and the "gone" render message read this one
|
||||||
|
// value, so the promise ("taking you to your gardens…") and the redirect can't
|
||||||
|
// disagree — e.g. a season-view error while the live garden is fine must not
|
||||||
|
// claim a redirect that never fires.
|
||||||
|
const gardenGone = live.error instanceof ApiError && live.error.isNotFound
|
||||||
|
|
||||||
const selectedId = useEditorStore((s) => s.selectedId)
|
const selectedId = useEditorStore((s) => s.selectedId)
|
||||||
const select = useEditorStore((s) => s.select)
|
const select = useEditorStore((s) => s.select)
|
||||||
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
|
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
|
||||||
@@ -73,8 +91,11 @@ export function GardenEditorPage() {
|
|||||||
)
|
)
|
||||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||||
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
||||||
|
const mode = useEditorStore((s) => s.mode)
|
||||||
|
const setMode = useEditorStore((s) => s.setMode)
|
||||||
|
|
||||||
const updatePlanting = useUpdatePlanting(gid)
|
const updatePlanting = useUpdatePlanting(gid)
|
||||||
|
const fillObject = useFillObject(gid)
|
||||||
const updateObject = useUpdateObject(gid)
|
const updateObject = useUpdateObject(gid)
|
||||||
const ensurePlant = useEnsurePlantInFull(gid)
|
const ensurePlant = useEnsurePlantInFull(gid)
|
||||||
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
||||||
@@ -96,6 +117,17 @@ export function GardenEditorPage() {
|
|||||||
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
||||||
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
|
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
|
||||||
|
|
||||||
|
// Plants recently placed in THIS garden, newest first (#100) — the quick strip
|
||||||
|
// in Plants mode, so re-planting "more of the same" doesn't need the picker.
|
||||||
|
const recentPlants = useMemo(
|
||||||
|
() =>
|
||||||
|
recentlyPlantedIds(plantings)
|
||||||
|
.slice(0, RECENT_PLANTS_MAX)
|
||||||
|
.map((id) => plantsById.get(id))
|
||||||
|
.filter((p): p is Plant => !!p),
|
||||||
|
[plantings, plantsById],
|
||||||
|
)
|
||||||
|
|
||||||
// Role gating, computed before the effects/returns so the nudge handler can use
|
// Role gating, computed before the effects/returns so the nudge handler can use
|
||||||
// it. Ownership is the authoritative ownerId==me check.
|
// it. Ownership is the authoritative ownerId==me check.
|
||||||
const gd = full.data?.garden
|
const gd = full.data?.garden
|
||||||
@@ -133,6 +165,24 @@ export function GardenEditorPage() {
|
|||||||
navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true })
|
navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true })
|
||||||
}, [focusedObjectId, navigate])
|
}, [focusedObjectId, navigate])
|
||||||
|
|
||||||
|
// Remember this garden as the device's last-opened, so `/` resumes here next
|
||||||
|
// visit. Keyed on the live query: which garden EXISTS doesn't depend on the
|
||||||
|
// season being viewed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (live.isSuccess) rememberLastGarden(gid)
|
||||||
|
}, [live.isSuccess, gid])
|
||||||
|
|
||||||
|
// A last garden that 404s (deleted, or access revoked) must not strand the user
|
||||||
|
// on an error screen the `/` resume keeps returning to: forget it (only if it's
|
||||||
|
// this one, so a bad direct link can't wipe a good resume target) and bounce to
|
||||||
|
// the list. Transient errors (500/network) fall through to the retryable screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (gardenGone) {
|
||||||
|
forgetLastGarden(gid)
|
||||||
|
navigate({ to: '/gardens' })
|
||||||
|
}
|
||||||
|
}, [gardenGone, gid, navigate])
|
||||||
|
|
||||||
// If the focused object vanished — deleted, or a stale ?focus id on load — leave
|
// If the focused object vanished — deleted, or a stale ?focus id on load — leave
|
||||||
// focus mode so the canvas isn't stuck dimmed with no way out.
|
// focus mode so the canvas isn't stuck dimmed with no way out.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,16 +191,39 @@ export function GardenEditorPage() {
|
|||||||
}
|
}
|
||||||
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
||||||
|
|
||||||
|
// The canvas mode follows focus: inside a bed you're placing Plants, out of it
|
||||||
|
// you're arranging Fixtures — so the bottom strip can't show the object palette
|
||||||
|
// while you're in a bed, or the seed tray while you're not. A panel mode
|
||||||
|
// (journal/assistant) is set explicitly and left alone here. (Inert on desktop,
|
||||||
|
// where the mode bar isn't shown.)
|
||||||
|
useEffect(() => {
|
||||||
|
const cur = useEditorStore.getState().mode
|
||||||
|
if (focusedObjectId != null) {
|
||||||
|
if (cur !== 'journal' && cur !== 'assistant') setMode('plants')
|
||||||
|
} else if (cur === 'plants') {
|
||||||
|
setMode('fixtures')
|
||||||
|
}
|
||||||
|
}, [focusedObjectId, setMode])
|
||||||
|
|
||||||
// Selecting anything lands you in the inspector without a click — the rail
|
// Selecting anything lands you in the inspector without a click — the rail
|
||||||
// must never be something you operate before you can edit. Keyed off the
|
// must never be something you operate before you can edit. Keyed off the
|
||||||
// selected ids rather than a "something is selected" boolean, so selecting a
|
// selected ids rather than a "something is selected" boolean, so selecting a
|
||||||
// DIFFERENT object while History is open still brings the inspector forward.
|
// DIFFERENT object while History is open still brings the inspector forward.
|
||||||
// Deselecting drops back out of the inspector but leaves History/Chat open if
|
// Deselecting drops back out of the inspector but leaves History/Chat open if
|
||||||
// that's where you were, since those aren't about the selection.
|
// that's where you were, since those aren't about the selection. Selecting is a
|
||||||
|
// canvas interaction, so it also leaves a panel mode — otherwise closing the
|
||||||
|
// inspector would strand the mode bar on Journal/Assistant with nothing open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId != null || selectedPlantingId != null) setRailTab('inspector')
|
if (selectedId != null || selectedPlantingId != null) {
|
||||||
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null)
|
setRailTab('inspector')
|
||||||
}, [selectedId, selectedPlantingId, setRailTab])
|
const s = useEditorStore.getState()
|
||||||
|
if (s.mode === 'journal' || s.mode === 'assistant') {
|
||||||
|
setMode(s.focusedObjectId != null ? 'plants' : 'fixtures')
|
||||||
|
}
|
||||||
|
} else if (useEditorStore.getState().railTab === 'inspector') {
|
||||||
|
setRailTab(null)
|
||||||
|
}
|
||||||
|
}, [selectedId, selectedPlantingId, setRailTab, setMode])
|
||||||
|
|
||||||
const exitFocus = () => {
|
const exitFocus = () => {
|
||||||
setFocusedObject(null)
|
setFocusedObject(null)
|
||||||
@@ -159,6 +232,33 @@ export function GardenEditorPage() {
|
|||||||
selectPlanting(null)
|
selectPlanting(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The mobile mode bar. Journal/Assistant are panel modes, so they open the
|
||||||
|
// rail sheet; Fixtures/Plants are canvas modes, so they close a panel rail (but
|
||||||
|
// leave an inspector, which is about the selection, alone). Tapping Fixtures
|
||||||
|
// means going back to arranging objects, so it steps out of a focused bed.
|
||||||
|
const selectMode = (m: EditorMode) => {
|
||||||
|
setMode(m)
|
||||||
|
if (m === 'journal') {
|
||||||
|
setJournalObjectId(null)
|
||||||
|
setRailTab('journal')
|
||||||
|
} else if (m === 'assistant') {
|
||||||
|
setRailTab('chat')
|
||||||
|
} else {
|
||||||
|
if (railTab === 'journal' || railTab === 'chat') setRailTab(null)
|
||||||
|
if (m === 'fixtures' && focusedObjectId != null) exitFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety for a live capability flip: if the admin turns the assistant off while
|
||||||
|
// it's the active mode, drop back to a canvas mode so the bar isn't stuck on a
|
||||||
|
// tab that no longer exists (and close the now-orphaned chat rail).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capabilities.data?.agent && useEditorStore.getState().mode === 'assistant') {
|
||||||
|
setMode('fixtures')
|
||||||
|
if (useEditorStore.getState().railTab === 'chat') setRailTab(null)
|
||||||
|
}
|
||||||
|
}, [capabilities.data?.agent, setMode, setRailTab])
|
||||||
|
|
||||||
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
|
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
@@ -256,12 +356,20 @@ export function GardenEditorPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden…</p>
|
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden…</p>
|
||||||
if (full.isError)
|
if (full.isError) {
|
||||||
|
// Only promise the bounce when the GARDEN is gone (the effect above keys on
|
||||||
|
// the same gardenGone). A season-view error while the live garden is fine is a
|
||||||
|
// different, non-redirecting failure and gets the generic message.
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Alert>Could not load this garden.</Alert>
|
<Alert>
|
||||||
|
{gardenGone
|
||||||
|
? 'That garden is no longer available — taking you to your gardens…'
|
||||||
|
: 'Could not load this garden.'}
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const g = full.data.garden
|
const g = full.data.garden
|
||||||
const garden: EditorGarden = {
|
const garden: EditorGarden = {
|
||||||
@@ -304,6 +412,14 @@ export function GardenEditorPage() {
|
|||||||
if (armedPlant?.id === id) setArmedPlant(null)
|
if (armedPlant?.id === id) setArmedPlant(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fill the whole focused bed with the armed plant at the chosen layout (#100 /
|
||||||
|
// #77) — the UI's way to run the region fill that was agent-only before. Guards
|
||||||
|
// are belt-and-braces: the control only shows with a plant armed in a bed.
|
||||||
|
function fillBed(layout: FillLayout) {
|
||||||
|
if (!canEdit || focusedObject == null || armedPlant == null) return
|
||||||
|
fillObject.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, layout })
|
||||||
|
}
|
||||||
|
|
||||||
// The picker hands back the full Plant (from the whole catalog), so use it
|
// The picker hands back the full Plant (from the whole catalog), so use it
|
||||||
// directly rather than re-resolving against the garden's referenced-plant map —
|
// directly rather than re-resolving against the garden's referenced-plant map —
|
||||||
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
||||||
@@ -398,9 +514,15 @@ export function GardenEditorPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
|
||||||
|
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
|
||||||
|
// the canvas bottom + Fit button under the browser chrome (#85).
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
||||||
<div className="shrink-0 md:w-40">
|
{/* Desktop-only control column. On mobile these move to the bottom mode bar
|
||||||
|
+ a slim top strip so the canvas — the point of the screen — isn't shoved
|
||||||
|
into a corner by a stack of controls (#99). */}
|
||||||
|
<div className="hidden shrink-0 md:block md:w-40">
|
||||||
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||||
{garden.name}
|
{garden.name}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -447,38 +569,48 @@ export function GardenEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
{/* Mobile top strip: the garden identity / season / share that live in the
|
||||||
|
desktop left column. md:hidden. */}
|
||||||
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
|
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}>
|
||||||
|
{garden.name}
|
||||||
|
</h1>
|
||||||
|
{!canEdit && seasonYear === null && (
|
||||||
|
<span className="shrink-0 rounded-md bg-border/40 px-2 py-0.5 text-xs text-muted">👁 View only</span>
|
||||||
|
)}
|
||||||
|
{years.data && years.data.length > 0 && (
|
||||||
|
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
|
||||||
|
)}
|
||||||
|
{isOwner && (
|
||||||
|
<Button variant="ghost" className="shrink-0 px-2 py-1 text-xs" onClick={() => setSharing(true)}>
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
|
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
|
||||||
|
{/* Focus toolbar is desktop-only; on mobile its plant tools move to the
|
||||||
|
bottom Plants-mode strip so they don't wrap over the canvas. */}
|
||||||
{focusedObject && (
|
{focusedObject && (
|
||||||
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
|
<div className="absolute left-2 top-2 z-20 hidden flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur md:flex">
|
||||||
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
||||||
← {garden.name}
|
← {garden.name}
|
||||||
</button>
|
</button>
|
||||||
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
|
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
|
||||||
{canEdit &&
|
{canEdit &&
|
||||||
(focusedObject.plantable ? (
|
(focusedObject.plantable ? (
|
||||||
<>
|
<PlantPlacementTools
|
||||||
<SeedTray
|
recentPlants={recentPlants}
|
||||||
trayPlants={trayPlants}
|
trayPlants={trayPlants}
|
||||||
armedPlantId={armedPlant?.id ?? null}
|
armedPlant={armedPlant}
|
||||||
onArm={armPlant}
|
onArm={armPlant}
|
||||||
onRemove={removeFromTrayAndDisarm}
|
onRemove={removeFromTrayAndDisarm}
|
||||||
onOpenPicker={() => setPicker('place')}
|
onOpenPicker={() => setPicker('place')}
|
||||||
|
onDisarm={() => setArmedPlant(null)}
|
||||||
|
focusedPlopCount={focusedPlops.length}
|
||||||
|
onClear={() => setClearing(true)}
|
||||||
|
onFill={fillBed}
|
||||||
|
filling={fillObject.isPending}
|
||||||
/>
|
/>
|
||||||
{armedPlant && (
|
|
||||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{focusedPlops.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
|
||||||
onClick={() => setClearing(true)}
|
|
||||||
>
|
|
||||||
Clear ({focusedPlops.length})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted">Not plantable</span>
|
<span className="text-xs text-muted">Not plantable</span>
|
||||||
))}
|
))}
|
||||||
@@ -497,6 +629,54 @@ export function GardenEditorPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile bottom: contextual tools for the current mode + the mode switch
|
||||||
|
bar (#99). md:hidden — desktop uses the left column. A panel-mode rail
|
||||||
|
(journal/assistant) or the inspector overlays this while open. */}
|
||||||
|
<div className="shrink-0 md:hidden">
|
||||||
|
{canEdit && (mode === 'fixtures' || mode === 'plants') && (
|
||||||
|
<div className="mb-2 min-h-[2.25rem]">
|
||||||
|
{mode === 'fixtures' && <Palette />}
|
||||||
|
{mode === 'plants' &&
|
||||||
|
(focusedObject ? (
|
||||||
|
focusedObject.plantable ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<PlantPlacementTools
|
||||||
|
recentPlants={recentPlants}
|
||||||
|
trayPlants={trayPlants}
|
||||||
|
armedPlant={armedPlant}
|
||||||
|
onArm={armPlant}
|
||||||
|
onRemove={removeFromTrayAndDisarm}
|
||||||
|
onOpenPicker={() => setPicker('place')}
|
||||||
|
onDisarm={() => setArmedPlant(null)}
|
||||||
|
focusedPlopCount={focusedPlops.length}
|
||||||
|
onClear={() => setClearing(true)}
|
||||||
|
onFill={fillBed}
|
||||||
|
filling={fillObject.isPending}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||||
|
Done planting
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Reachable via a ?focus= deep link onto a non-plantable object:
|
||||||
|
// say so, and give a way back out (there's no focus toolbar here).
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-1 text-xs text-muted">
|
||||||
|
{objectDisplayName(focusedObject)} isn’t plantable.
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="px-1 text-xs text-muted">Tap a bed, then “🌱 Plant here” to start planting.</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{railTab && (
|
{railTab && (
|
||||||
<EditorRail
|
<EditorRail
|
||||||
tabs={railTabs}
|
tabs={railTabs}
|
||||||
@@ -504,10 +684,17 @@ export function GardenEditorPage() {
|
|||||||
onActivate={setRailTab}
|
onActivate={setRailTab}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
// Only the inspector is *about* the selection, so only closing it
|
// Only the inspector is *about* the selection, so only closing it
|
||||||
// deselects; dismissing History leaves the canvas as you had it.
|
// deselects; dismissing a panel leaves the canvas as you had it. Any
|
||||||
|
// panel rail (journal/history/chat) drops back to a canvas mode so the
|
||||||
|
// mobile mode bar reappears.
|
||||||
if (railTab === 'inspector') {
|
if (railTab === 'inspector') {
|
||||||
select(null)
|
select(null)
|
||||||
selectPlanting(null)
|
selectPlanting(null)
|
||||||
|
} else {
|
||||||
|
// Back to a canvas mode so the mode bar reappears — Plants if you're
|
||||||
|
// still inside a bed, else Fixtures. (Hardcoding Fixtures here docked
|
||||||
|
// the object palette inside a focused bed.)
|
||||||
|
setMode(focusedObjectId != null ? 'plants' : 'fixtures')
|
||||||
}
|
}
|
||||||
setRailTab(null)
|
setRailTab(null)
|
||||||
}}
|
}}
|
||||||
@@ -526,8 +713,9 @@ export function GardenEditorPage() {
|
|||||||
|
|
||||||
{clearing && focusedObject && (
|
{clearing && focusedObject && (
|
||||||
<ClearBedModal
|
<ClearBedModal
|
||||||
|
objectId={focusedObject.id}
|
||||||
objectName={objectDisplayName(focusedObject)}
|
objectName={objectDisplayName(focusedObject)}
|
||||||
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
|
plopCount={focusedPlops.length}
|
||||||
gardenId={gid}
|
gardenId={gid}
|
||||||
onClose={() => setClearing(false)}
|
onClose={() => setClearing(false)}
|
||||||
/>
|
/>
|
||||||
@@ -535,3 +723,150 @@ export function GardenEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
|
||||||
|
// focus toolbar and the mobile Plants strip so the two can't drift apart.
|
||||||
|
function PlantPlacementTools({
|
||||||
|
recentPlants,
|
||||||
|
trayPlants,
|
||||||
|
armedPlant,
|
||||||
|
onArm,
|
||||||
|
onRemove,
|
||||||
|
onOpenPicker,
|
||||||
|
onDisarm,
|
||||||
|
focusedPlopCount,
|
||||||
|
onClear,
|
||||||
|
onFill,
|
||||||
|
filling,
|
||||||
|
}: {
|
||||||
|
recentPlants: Plant[]
|
||||||
|
trayPlants: Plant[]
|
||||||
|
armedPlant: Plant | null
|
||||||
|
onArm: (p: Plant, lot?: SeedLot) => void
|
||||||
|
onRemove: (id: number) => void
|
||||||
|
onOpenPicker: () => void
|
||||||
|
onDisarm: () => void
|
||||||
|
focusedPlopCount: number
|
||||||
|
onClear: () => void
|
||||||
|
onFill: (layout: FillLayout) => void
|
||||||
|
filling: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RecentPlants plants={recentPlants} armedPlantId={armedPlant?.id ?? null} onArm={onArm} />
|
||||||
|
<SeedTray
|
||||||
|
trayPlants={trayPlants}
|
||||||
|
armedPlantId={armedPlant?.id ?? null}
|
||||||
|
onArm={onArm}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onOpenPicker={onOpenPicker}
|
||||||
|
/>
|
||||||
|
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
|
||||||
|
{armedPlant && (
|
||||||
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{focusedPlopCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
Clear ({focusedPlopCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fill control (#100 / #77): choose a layout, then fill the whole bed with
|
||||||
|
// the armed plant. Clump packs fat blobs for a quick sketch; grid lays out
|
||||||
|
// individual plants in rows you could actually plant from. Defaults to grid,
|
||||||
|
// since "fill this bed" usually means a real planting.
|
||||||
|
function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; busy: boolean }) {
|
||||||
|
const [layout, setLayout] = useState<FillLayout>('grid')
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-0.5 text-xs">
|
||||||
|
<span className="inline-flex overflow-hidden rounded-full">
|
||||||
|
{(['clump', 'grid'] as const).map((l) => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={layout === l}
|
||||||
|
onClick={() => setLayout(l)}
|
||||||
|
title={l === 'clump' ? 'Fat clumps — a quick sketch' : 'Rows of plants you could plant from'}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 font-medium capitalize transition-colors',
|
||||||
|
layout === l ? 'bg-border/70 text-accent-strong' : 'text-muted hover:text-fg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{l === 'grid' ? 'rows' : l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" className="px-2 py-0.5 text-xs" disabled={busy} onClick={() => onFill(layout)}>
|
||||||
|
{busy ? 'Filling…' : 'Fill bed'}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
||||||
|
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
||||||
|
// strip. Assistant is dropped when the instance has no model configured.
|
||||||
|
const MODES: { id: EditorMode; label: string; icon: string }[] = [
|
||||||
|
{ id: 'fixtures', label: 'Fixtures', icon: '🛠️' },
|
||||||
|
{ id: 'plants', label: 'Plants', icon: '🌱' },
|
||||||
|
{ id: 'journal', label: 'Journal', icon: '📓' },
|
||||||
|
{ id: 'assistant', label: 'Assistant', icon: '💬' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function ModeBar({
|
||||||
|
mode,
|
||||||
|
onSelect,
|
||||||
|
hasAssistant,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
mode: EditorMode
|
||||||
|
onSelect: (m: EditorMode) => void
|
||||||
|
hasAssistant: boolean
|
||||||
|
canEdit: boolean
|
||||||
|
}) {
|
||||||
|
const modes = MODES.filter((m) => {
|
||||||
|
if (m.id === 'assistant') return hasAssistant
|
||||||
|
// Fixtures/Plants are edit actions — a viewer can't place anything, so the
|
||||||
|
// bar offers only what they can do (read the journal, ask the assistant).
|
||||||
|
if (m.id === 'fixtures' || m.id === 'plants') return canEdit
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Editor mode"
|
||||||
|
className="flex items-stretch justify-around overflow-hidden rounded-xl border border-border bg-surface"
|
||||||
|
>
|
||||||
|
{modes.map((m) => {
|
||||||
|
const active = mode === m.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => onSelect(m.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors',
|
||||||
|
active ? 'bg-border/60 text-accent-strong' : 'text-muted hover:text-fg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="text-lg leading-none">
|
||||||
|
{m.icon}
|
||||||
|
</span>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function PublicGardenPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
|
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||||
{garden.name}
|
{garden.name}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { TextField } from '@/components/ui/TextField'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
conflictSettings,
|
||||||
|
useSettings,
|
||||||
|
useUpdateSettings,
|
||||||
|
type EffectiveAgent,
|
||||||
|
type InstanceSettings,
|
||||||
|
} from '@/lib/settings'
|
||||||
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
|
// agentEnabled is a tri-state on the wire (null = inherit env, true, false); the
|
||||||
|
// form models it as three named choices so "inherit" is a deliberate pick, not
|
||||||
|
// an empty control.
|
||||||
|
type EnabledChoice = 'inherit' | 'on' | 'off'
|
||||||
|
const toChoice = (v: boolean | null): EnabledChoice => (v === null ? 'inherit' : v ? 'on' : 'off')
|
||||||
|
const fromChoice = (c: EnabledChoice): boolean | null => (c === 'inherit' ? null : c === 'on')
|
||||||
|
|
||||||
|
/** Admin-only instance settings (#79). The one that matters today is the agent
|
||||||
|
* model; the API key stays in the environment and is only ever reported as
|
||||||
|
* present/absent, never shown or edited. */
|
||||||
|
export function SettingsPage() {
|
||||||
|
usePageTitle('Settings')
|
||||||
|
const settings = useSettings()
|
||||||
|
const update = useUpdateSettings()
|
||||||
|
|
||||||
|
if (settings.isPending) {
|
||||||
|
return <p className="text-sm text-muted">Loading settings…</p>
|
||||||
|
}
|
||||||
|
if (settings.isError) {
|
||||||
|
return <Alert>{errorMessage(settings.error, "Couldn't load settings.")}</Alert>
|
||||||
|
}
|
||||||
|
return <SettingsForm data={settings.data} update={update} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsForm({
|
||||||
|
data,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
data: { settings: InstanceSettings; effective: EffectiveAgent }
|
||||||
|
update: ReturnType<typeof useUpdateSettings>
|
||||||
|
}) {
|
||||||
|
const [model, setModel] = useState(data.settings.agentModel)
|
||||||
|
const [enabled, setEnabled] = useState<EnabledChoice>(toChoice(data.settings.agentEnabled))
|
||||||
|
const [version, setVersion] = useState(data.settings.version)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Rebase the form when the query cache updates (e.g. a successful save writes
|
||||||
|
// the new row back), so the version we submit is never stale.
|
||||||
|
useEffect(() => {
|
||||||
|
setVersion(data.settings.version)
|
||||||
|
}, [data.settings.version])
|
||||||
|
|
||||||
|
const eff = data.effective
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setError(null)
|
||||||
|
update.mutate(
|
||||||
|
{ agentModel: model.trim(), agentEnabled: fromChoice(enabled), version },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.info('Settings saved.'),
|
||||||
|
onError: (err) => {
|
||||||
|
const current = conflictSettings(err)
|
||||||
|
if (current) {
|
||||||
|
// Someone else saved first. Adopt their row so the next attempt is
|
||||||
|
// clean, and say so rather than silently discarding this edit.
|
||||||
|
setModel(current.agentModel)
|
||||||
|
setEnabled(toChoice(current.agentEnabled))
|
||||||
|
setVersion(current.version)
|
||||||
|
setError('Someone else changed these settings just now — reloaded their version. Re-apply your change if you still want it.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(errorMessage(err, "Couldn't save settings."))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-2xl flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-fg">Settings</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
Instance-wide, admin-only. Changes take effect immediately — no restart.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-fg">Garden assistant</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
The model runs against your Ollama Cloud key, which is set in the environment and never
|
||||||
|
shown here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AgentStatus eff={eff} />
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Model"
|
||||||
|
name="agentModel"
|
||||||
|
placeholder={eff.model || 'ollama-cloud/glm-5.2:cloud'}
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
hint={
|
||||||
|
model.trim() === ''
|
||||||
|
? `Empty — inheriting ${eff.model || 'the built-in default'} from the environment.`
|
||||||
|
: 'A majordomo model spec. A comma-separated list is a failover chain.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Enabled"
|
||||||
|
name="agentEnabled"
|
||||||
|
value={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.value as EnabledChoice)}
|
||||||
|
options={[
|
||||||
|
{ value: 'inherit', label: 'Inherit from environment' },
|
||||||
|
{ value: 'on', label: 'On' },
|
||||||
|
{ value: 'off', label: 'Off' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={save} disabled={update.isPending}>
|
||||||
|
{update.isPending ? 'Saving…' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentStatus surfaces the gap the capabilities endpoint exists for: enabled +
|
||||||
|
// a key does not guarantee the assistant is actually running (an unresolvable
|
||||||
|
// model leaves it down), and that's precisely what an admin needs to see.
|
||||||
|
function AgentStatus({ eff }: { eff: EffectiveAgent }) {
|
||||||
|
const [tone, text] = eff.agentLive
|
||||||
|
? (['ok', `Live on ${eff.model}`] as const)
|
||||||
|
: !eff.hasApiKey
|
||||||
|
? (['warn', 'No API key set in the environment — the assistant is off.'] as const)
|
||||||
|
: !eff.enabled
|
||||||
|
? (['warn', 'Turned off.'] as const)
|
||||||
|
: (['warn', `Configured but not running — check the model (${eff.model}).`] as const)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'inline-block h-2 w-2 rounded-full ' + (tone === 'ok' ? 'bg-emerald-500' : 'bg-amber-500')
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className={tone === 'ok' ? 'text-fg' : 'text-muted'}>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+75
-4
@@ -1,3 +1,4 @@
|
|||||||
|
import { lazy, type ComponentType } from 'react'
|
||||||
import {
|
import {
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
createRoute,
|
createRoute,
|
||||||
@@ -9,14 +10,54 @@ import { AppShell } from '@/components/layout/AppShell'
|
|||||||
import { NotFound } from '@/components/NotFound'
|
import { NotFound } from '@/components/NotFound'
|
||||||
import { RouteError } from '@/components/RouteError'
|
import { RouteError } from '@/components/RouteError'
|
||||||
import { LoginPage } from '@/pages/LoginPage'
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
import { RegisterPage } from '@/pages/RegisterPage'
|
|
||||||
import { GardensPage } from '@/pages/GardensPage'
|
import { GardensPage } from '@/pages/GardensPage'
|
||||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
|
||||||
import { PublicGardenPage } from '@/pages/PublicGardenPage'
|
|
||||||
import { PlantsPage } from '@/pages/PlantsPage'
|
|
||||||
import { meQueryOptions } from '@/lib/auth'
|
import { meQueryOptions } from '@/lib/auth'
|
||||||
import { queryClient } from '@/lib/queryClient'
|
import { queryClient } from '@/lib/queryClient'
|
||||||
import { safeRedirectPath } from '@/lib/redirect'
|
import { safeRedirectPath } from '@/lib/redirect'
|
||||||
|
import { getLastGardenId } from '@/lib/lastGarden'
|
||||||
|
|
||||||
|
// Lazily load a page by its named export, with recovery for the stale-chunk
|
||||||
|
// problem. A push to main redeploys, so a still-open app references chunk hashes
|
||||||
|
// the server has just replaced; that import 404s and React.lazy MEMOIZES the
|
||||||
|
// rejection, so RouteError's "Try again" (router.invalidate) can never recover —
|
||||||
|
// the user is stuck until a manual hard reload. On the first such failure we
|
||||||
|
// reload once (fetching the fresh index + hashes); a session flag stops a reload
|
||||||
|
// loop, and a success clears it so a later genuine failure can reload again.
|
||||||
|
function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||||
|
return lazy(async () => {
|
||||||
|
try {
|
||||||
|
const mod = await load()
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem('pansy:chunk-reload')
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — fine */
|
||||||
|
}
|
||||||
|
return { default: mod[name] as ComponentType }
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
if (!sessionStorage.getItem('pansy:chunk-reload')) {
|
||||||
|
sessionStorage.setItem('pansy:chunk-reload', '1')
|
||||||
|
window.location.reload()
|
||||||
|
return await new Promise<{ default: ComponentType }>(() => {}) // hold for the reload
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — fall through to surface the error */
|
||||||
|
}
|
||||||
|
throw err // already reloaded once (or can't); let the error boundary show it
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code-split the heavier / deeper routes so a phone on cell data doesn't download
|
||||||
|
// the whole app (notably the canvas editor with its gesture + geometry deps)
|
||||||
|
// before the first screen paints. Login and the gardens list — the entry points —
|
||||||
|
// stay eager to avoid a fallback flash on landing; AppShell wraps the Outlet in a
|
||||||
|
// Suspense boundary for the rest.
|
||||||
|
const GardenEditorPage = lazyPage(() => import('@/pages/GardenEditorPage'), 'GardenEditorPage')
|
||||||
|
const PublicGardenPage = lazyPage(() => import('@/pages/PublicGardenPage'), 'PublicGardenPage')
|
||||||
|
const PlantsPage = lazyPage(() => import('@/pages/PlantsPage'), 'PlantsPage')
|
||||||
|
const SettingsPage = lazyPage(() => import('@/pages/SettingsPage'), 'SettingsPage')
|
||||||
|
const RegisterPage = lazyPage(() => import('@/pages/RegisterPage'), 'RegisterPage')
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
@@ -48,10 +89,32 @@ async function requireGuest(context: RouterContext, redirectTo: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireAdmin: authenticated AND admin. A non-admin who navigates to /settings
|
||||||
|
// is sent to /gardens rather than shown a page whose API calls would 403. This
|
||||||
|
// is convenience routing, not the security boundary — the server's requireAdmin
|
||||||
|
// is authoritative.
|
||||||
|
async function requireAdmin(context: RouterContext, path: string) {
|
||||||
|
const me = await context.queryClient.ensureQueryData(meQueryOptions)
|
||||||
|
if (!me) {
|
||||||
|
throw redirect({ to: '/login', search: { redirect: path } })
|
||||||
|
}
|
||||||
|
if (!me.isAdmin) {
|
||||||
|
throw redirect({ to: '/gardens' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
|
// Resume the garden this device was last in, so a returning user lands back in
|
||||||
|
// their plot rather than on the list every time. A stored id that no longer
|
||||||
|
// loads is cleared by the editor and bounces here to /gardens on the next
|
||||||
|
// visit, so this can't trap anyone on a dead garden.
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
|
const last = getLastGardenId()
|
||||||
|
if (last != null) {
|
||||||
|
throw redirect({ to: '/gardens/$gardenId', params: { gardenId: String(last) } })
|
||||||
|
}
|
||||||
throw redirect({ to: '/gardens' })
|
throw redirect({ to: '/gardens' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -109,6 +172,13 @@ const plantsRoute = createRoute({
|
|||||||
component: PlantsPage,
|
component: PlantsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const settingsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: 'settings',
|
||||||
|
beforeLoad: ({ context, location }) => requireAdmin(context, location.href),
|
||||||
|
component: SettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
|
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
|
||||||
// guard, so a logged-out visitor viewing a shared link is never redirected to
|
// guard, so a logged-out visitor viewing a shared link is never redirected to
|
||||||
// /login or OIDC.
|
// /login or OIDC.
|
||||||
@@ -125,6 +195,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
gardensRoute,
|
gardensRoute,
|
||||||
gardenEditorRoute,
|
gardenEditorRoute,
|
||||||
plantsRoute,
|
plantsRoute,
|
||||||
|
settingsRoute,
|
||||||
publicGardenRoute,
|
publicGardenRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus on a canvas object (#84). :focus-visible shows the ring for
|
||||||
|
keyboard focus but not a mouse click; the dashed accent ring distinguishes
|
||||||
|
"focused" from the solid ring that marks "selected". A CSS rule overrides
|
||||||
|
the shape's inline stroke presentation attributes. */
|
||||||
|
.object-shape {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.object-shape:focus-visible :is(rect, ellipse) {
|
||||||
|
stroke: var(--color-accent-strong);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
||||||
|
|||||||
@@ -29,6 +29,27 @@ export default defineConfig(({ mode }) => {
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// One vendor chunk for ALL node_modules: it's rarely-changing, so it
|
||||||
|
// caches across app deploys while the tiny app chunk churns. Kept as a
|
||||||
|
// SINGLE chunk on purpose — splitting react-dom/scheduler into their own
|
||||||
|
// chunk reorders their module init across chunk boundaries and breaks
|
||||||
|
// React 19 at load ("Cannot set 'Activity' of undefined"). The heavy
|
||||||
|
// routes are code-split separately via React.lazy (router.tsx), which is
|
||||||
|
// where the real first-paint win is.
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes('node_modules')) return undefined
|
||||||
|
// Editor-only libs (the gesture engine) ride with the lazy editor
|
||||||
|
// chunk instead of the eager vendor one, so the first paint doesn't
|
||||||
|
// pay for code only the canvas needs. Everything else — react, tanstack
|
||||||
|
// and the rest — stays in ONE vendor chunk; splitting react-dom out
|
||||||
|
// reorders its init across chunks and breaks React 19 at load.
|
||||||
|
if (id.includes('@use-gesture')) return undefined
|
||||||
|
return 'vendor'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user