@@ -15,6 +15,23 @@ Ideas and improvements not yet planned or scheduled.
---
## Hero-image cleanup (journal)
The `hero_image` field was removed from the post form (journal heroes now come
from the first uploaded photo). Follow-up: purge the now-unused field from the
journal entity end-to-end.
- [ ]**Remove hero from the journal entity** — drop `hero_image` from the entry blueprint/template so journal entries no longer carry or reference it (journal rendering already uses `entry.media.images|first`)
- [ ]**Remove hero from posts + demo content** — strip `hero_image` frontmatter from existing journal entries and the `italy-2026-demo` seed content (`user/docs/demo/`), then re-run `make demo-load`
---
## Journal entry detail page
- [ ]**Retire the journal-entry detail page** — the trip/home feed already renders each entry's full body inline (`entry.content|raw` in `partials/entry-journal.html.twig`), so the standalone `entry.html.twig` route per journal entry is largely redundant. Consider removing the route/permalink for journal entries. **Journal only** — stories are full standalone pages and keep their detail view. (Surfaced during the front-end edit brainstorm; unrelated to edit/delete itself.)
- [ ]**Re-import pixelfed photos at full resolution** — the current import pulled pixelfed's optimised web renditions, so imported images cap at ~1440px on the long edge (portraits are 700–1200px wide). This is fine for the feed and 1x banners, but the retina cover 2x only kicks in for genuinely wide (≥1440px) sources, so auto-picked trip banners are currently 1x-only. Find the original high-quality versions in the local filesystem and re-import them (or point the pipeline at the originals rather than the pixelfed web renditions). Purely a quality upgrade — no functional gap; future content shot/stored at full res won't have this ceiling.
After submitting a new post via `/post`, the entry page file is created correctly on disk but does not appear in the `/trips/<active_trip>/dailies` feed or in the Grav Admin panel until the cache is manually flushed.
**Next session goal:** Add Playwright coverage for the edit-mode photo editor add / delete / reorder paths — **especially the failure paths** just implemented, which currently have zero automated coverage.
---
## TL;DR — where things stand
The photo-editor media-API feature is **code-complete and committed** but **not smoke-tested**. Three review follow-ups landed this session (commit `7ffd75e`) on the edit-mode add/delete/reorder **failure** paths. Those paths are exercised by **no** existing test, so nothing proves the behavioral changes work end-to-end. That's the whole reason for the next session.
**Do not** push, **do not** bump the submodule pin, and **do not** touch the other-session WIP (see Constraints) until the new tests pass and Mischa says go.
- Status: only `M user` — the submodule pin is **intentionally stale** (not bumped mid-feature; per project convention bump once at feature end). **Leave it.**
-`361a6b4 fix(review): harden photo reorder against data loss + failure-path drift`
-`a4432d8 feat(post-form): live photo editor on entry edit (media API + SortableJS)`
- **Dirty (DO NOT COMMIT — belongs to a different session):**
-`config/plugins/api.yaml`
-`config/site.yaml`
-`themes/intotheeast/js/src/post-form.css` (a trailing FilePond CSS block)
- Nothing pushed on either repo.
---
## What commit `7ffd75e` changed (the code under test)
All in the edit-mode photo editor (the `initPhotoEditor` IIFE in
`user/themes/intotheeast/js/src/post-form.js`, bundled to
`user/themes/intotheeast/js/post/post-form.js`):
1.**Surfaced auth-expiry.** Replaced the boolean `apiOk` with `apiSend(url, opts, okStatuses)`, which rejects with an `Error` carrying `.status`. A lapsed owner login mid-edit (**401/403**) now shows *"Your login session expired — sign in again, then retry."* instead of a generic "try again". Applies to reorder, delete, and add paths (`editErrorMsg(err, fallback)` picks the copy).
2.**Hardened the add-batch rollback (review item #6).** When a post-upload reorder fails, the cleanup DELETEs no longer swallow individual failures. Each rollback DELETE resolves true/false (204/404 = truly gone); any `false` sets `rollbackIncomplete`, producing *"Couldn't finish adding photos and cleanup was incomplete — reload the page and check your photos."* instead of a false "rolled back cleanly". This closes the window where a surviving stock-named file steals the lexicographic cover slot (`media.images|first`).
3.**Audit log** on the two owner-only destructive routes in
(`deleteEntry`, `reorderPhotos`) — behaviorally inert, logs owner + slug. Not worth a Playwright test.
**User-facing strings to assert against** (stable; survive minification):
-`login session expired` / `sign in again`
-`cleanup was incomplete`
- The N-photos-couldn't-be-added count message
---
## The API surface the editor talks to
- **Add photo:** `POST /api/v1/pages{route}/media` (stock media API, multipart)
- **Delete photo:** `DELETE /api/v1/pages{route}/media/{filename}` — editor treats **204 and 404** as success
- **Reorder:** `POST /api/v1/entry/{slug}/photos/order`, body `{ "order": ["photo-01.jpg", …] }` — custom scope-guarded route in the `entry-actions` plugin; returns **204**
- All requests use `credentials: 'include'` (session-cookie auth).
Server-side numbering invariant lives in `PhotoRenumberer` (shared by cache-on-save + entry-actions): every on-disk image is renamed `photo-01..NN` zero-padded; the manifest only supplies order, and any unlisted image is appended (never lost).
---
## Test harness facts (read before writing specs)
- **Runner:** Playwright, config at `playwright.config.js`. `testDir: ./tests/ui`. Specs are `*.spec.js`.
- **Auth is already solved.** The `setup` project (`tests/ui/auth/auth.setup.js`) logs in with `GRAV_TEST_USER` / `GRAV_TEST_PASS` (from `.env`) and saves `storageState` to `tests/.auth/user.json`; the `chromium` project loads it. **So every test already runs as the authenticated owner** — edit mode is reachable without extra login steps.
- **⚠️ Port:** `baseURL` defaults to `http://localhost:8081`, but **this worktree's dev container serves on `:8091`** (`itte_journal_grav`, mapped `8091->80`). Run with `GRAV_BASE_URL=http://localhost:8091` or the specs will hit the wrong container.
- **Helpers** (`tests/ui/helpers.js`, exported): `fillEditor`, `waitForPhotoUpload`, `postEntry`, `cleanupEntry`, `findEntry`, `readEntryMd`, `TRACKER_DIR`, `ACTIVE_TRIP_URL`. `findEntry(tag)`/`cleanupEntry(tag)` locate/remove an entry folder on disk — use them to build a fixture entry and to clean up.
- **Existing post specs** live in `tests/ui/post/` (`post-form-ux.spec.js`, `post.spec.js`, `validation.spec.js`). They cover the **create** form only — none open `/post?edit=…` or the photo editor. Mirror their style (fixtures at `tests/fixtures/test-photo*.jpg`).
Edit mode is `GET /post?edit=<slug>` (verify the exact param against the template). Failure paths need **`page.route()` interception** to force API errors — that's the core technique here.
1.**Fixture:** post one entry via the create form (or drop a folder), capture its slug, open it in edit mode. Clean up with `cleanupEntry` in `afterAll`.
2.**Happy paths** (no interception): add a photo → persists (appears on disk / in grid); delete a photo → gone; drag-reorder → files renamed `photo-01..NN` in new order.
3.**Auth-expiry (item #1):**`page.route('**/api/v1/**', r => r.fulfill({ status: 401 }))` on a reorder/delete/add → assert the *"login session expired … sign in again"* copy appears.
4.**Incomplete rollback (#6):** let the uploads succeed but force the reorder to fail **and** at least one cleanup DELETE to fail (route-match `DELETE **/media/**` → 500). Assert the *"cleanup was incomplete — reload"* message. This is the highest-value, never-before-tested branch.
5.**Delete failure:** force a `DELETE` to 500 → assert *"Couldn't delete that photo. Try again."* and the photo stays in the grid.
Keep assertions on the **user-facing strings** above, not on minified identifiers.
### Also pending: manual smoke test
Independent of automation, the behavioral changes still want one **manual owner-session pass on `:8091`**: log in, open an entry in edit mode, add/delete/reorder and confirm each persists; then simulate a lapsed session and confirm the "sign in again" copy. If Playwright covers 2–5 above, this becomes a quick confidence check rather than the only verification.
---
## Constraints (carried from this session — still in force)
- **Other-session WIP is off-limits.** Do not stage/commit `config/plugins/api.yaml`, `config/site.yaml`, or the FilePond block in `themes/intotheeast/js/src/post-form.css`. If `make build-assets` recompiles `css-compiled/post-form.css` from that dirty source, **revert it**: `git checkout -- themes/intotheeast/css-compiled/post-form.css`.
- **Never** read `.env`, `.env.prod`, `.env.test` (pass them to `make`/`compose` only). `GRAV_TEST_USER`/`PASS` live there.
- **Only** write inside `travel-blog-intotheeast/` or subfolders.
- **Do not** bump the submodule pin or push until the feature is done and Mischa approves.
- **Do not** hand-edit the bundle (`js/post/post-form.js`) or `css-compiled/*` — edit `js/src/*` and rebuild with `make build-assets`.
- No dev/prod mode switching; fix issues at the app level.
- New test files go in the **outer repo** (`tests/` is outer-repo, not the `user/` submodule).
---
## Fast start for the next session
```
# worktree root
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.worktrees/journal-post-form
**State:** Implementation + code-review complete. **Remaining: owner UI QA (Part B) → then landing (Part A §Landing).**
This doc has two audiences:
- **Part A — Handover (Claude → future Claude):** exact branch state, what's committed where, the dual-session/worktree situation, and the landing procedure. Read this first in a fresh session before touching anything.
- **Part B — QA checklist (Mischa):** the owner-session UI pass the test harness cannot do (it can't obtain your login). Run on http://localhost:8091.
### Commits made in the 2026-07-07 review session (code-review F1–F8)
All **local to this worktree's branch** — nothing pushed, no pin bump, no `content-push`.
**Submodule `user/`** (on `feat/journal-post-form`):
-`8db3ffe` — F1/F7: latch cache invalidation (`$cacheInvalidated`) to once-per-submit + info log — `plugins/cache-on-save/cache-on-save.php`
-`7f6bf9e` — F4: `initDisclosure` reads each toggle's default from the rendered `[checked]` attribute instead of a `/\[published\]$/` field-name regex; rebuilt bundle — `themes/intotheeast/js/src/post-form.js` + `js/post/post-form.js`
### DO NOT commit — off-limits WIP left dirty on purpose
- Submodule: `config/site.yaml` (owner's local `travelling:false` / `active_trip` testing config — `m` dirty is normal), `config/plugins/api.yaml`, `themes/intotheeast/js/src/post-form.css`, `themes/intotheeast/css-compiled/post-form.css` (the two CSS files get touched by `make build-assets` rebuilding from the in-progress `post-form.css` source — not part of this work).
- Outer: the `user` gitlink (`M user` — pin **intentionally not bumped**).
Two Claude sessions run in parallel. **Local isolation is real and proven:**
- This worktree's `user/` git dir: `.git/worktrees/journal-post-form/modules/user`, branch `feat/journal-post-form` — its **own object store**. The other session's branch (`feat/trip-description-hero`) is not visible here and its HEAD commit does not exist in this object store.
- Other checkouts: `content-fixes` worktree → `user/` on `feat/trip-description-hero`; main checkout → `user/` on `main`.
**The only shared resource is Gitea `origin`** (the `intotheeast-com-content.git` content repo) + the single outer pin + outer `main`. Collisions can *only* happen at push / merge-to-main / pin-bump. **Therefore: never push, never `content-push`, never bump the pin from a worktree mid-flight. Landing is a single deliberate step the owner triggers.**
### Landing procedure (owner-triggered, once QA passes) — do NOT run unprompted
1.**Owner UI QA** (Part B) passes.
2.**Submodule first.** Reconcile `user/``feat/journal-post-form` → `user/``main` (merge; prefer the merge commit, not the branch tip). Push `user/` to Gitea → this triggers the production content pull via webhook.
3.**Bump the pin.** In the outer repo, stage the `user` gitlink pointing at that `user/``main` merge commit (must already be pushed). Commit.
5.**Plugin patch.**`add-page-by-form` is GPM-managed/git-ignored; the Grav-2.0 header fix lives at `deploy/patches/add-page-by-form-grav2-header.patch`. Re-apply with `make apply-plugin-patches` after any plugin (re)install on the server — R9 (add photos on edit) breaks without it.
6.**Env override.** Re-run `make remote-apply-env-prod` after any fresh install (prod Twig cache settings live only in `user/env/<host>/`, not synced by content).
7.**Pre-launch smoke** (CLAUDE.md): submit one post via `/post` on prod, confirm it appears in the trip feed immediately (verifies cache-on-save under `twig.cache:true`).
### Running the tests
- Full post suite: `GRAV_BASE_URL=http://localhost:8091 npx playwright test post/ --reporter=line` (20 pass as of 2026-07-07).
- After any `js/src/*` edit: `make build-assets` (never hand-edit `js/post/*` or `css-compiled/*`).
-`setup` project logs in → `tests/.auth/user.json`; specs run as the authenticated owner (anon-view clears storageState).
### Verified vs NOT verified
- **Verified (harness):** 20 post specs on :8091 incl. ES1 (create→edit round-trip, the cache fix), AE3b (disclosure deviation), delete flow, anon/draft visibility, HEIC convert, photo renumber; `PhotoRenumberer` unit tests.
- **NOT verifiable by harness (needs owner login / real device):** interactive photo add/delete/**drag** reorder in edit mode, on-device **touch**-drag, combined add+delete+reorder in one save. → **This is Part B.**
---
## Part B — Owner QA checklist (Mischa)
Run logged in as the owner on **http://localhost:8091** (worktree dev server). Check each box; if any fails, stop and note it — do not land.
### Create
- [ ] Post an entry with **1 photo** → success toast; entry appears in the active-trip feed **immediately**; that photo is the cover.
- [ ] Post an entry with **multiple photos including a HEIC** → HEIC converts to JPEG, all attach, first image is the cover.
- [ ] Post with **Published = No** (under "More options") → entry shows a **Draft badge** to you; open the same trip page in a **private/incognito window** → the draft is **absent**.
### Edit (open an entry's Edit link from the feed)
- [ ] Change **title + body**, Save → feed reflects the new title/body.
- [ ] Open the entry you *just* created for editing → **no "this entry no longer exists"** banner (the create→edit cache fix).
- [ ]**Add** a new photo on edit → attaches and renumbers; regressions don't drop existing photos.
- [ ]**Delete** a photo via the inline confirm → removed from disk; if you removed the first, the **cover updates** to the new first.
- [ ]**Reorder** photos by **mouse drag** → order persists after Save; first = cover on the feed.
- [ ]**Combined** in one save: add + delete + reorder → all three land correctly (cover=first, existing preserved, dropped removed).
### On-device
- [ ] On a **phone or tablet**, edit an entry and **touch-drag** to reorder photos → works and persists.
### Delete
- [ ] Delete an entry from the feed (Delete → Confirm) → card disappears and the folder leaves disk.
- [ ] Delete → **Cancel** → nothing removed.
When every box is checked, hand back to a fresh Claude session and point it at **Part A §Landing procedure**.
**Status:** 🔄 In progress — M1 (U1–U6) complete & verified (V1–V7). M2 **partially delivered** (2026-07-05): **U7 (load existing photos into FilePond) + remove + reorder** are implemented and verified end-to-end on the :8091 container — V9 (photos load, cover-ordered) and V10 (remove a photo, reorder so a different image is the cover; on-disk `photo-1..N` renumber) both pass; reconcile helpers also covered by a reflection unit test (4 cases). One real bug found & fixed en route: `onFormProcessed` fires once per `process:` action (4×), so photo reconciliation is now latched to run **once** (a 2nd pass deleted the just-renamed `photo-N` files). Changes are in `cache-on-save.php` (edit-aware reconcile) + `post-form.js` (U7 load, D1 disable-sweep excludes the FilePond field). **R9 (add NEW photos on edit) now WORKS (2026-07-05)** via a local patch to add-page-by-form. Root cause: its edit-mode merge read existing frontmatter with `(array)$page->header()`, but Grav 2.0's `Grav\Common\Page\Header` keeps data in a protected `items`, so the cast mangled keys (`\0*\0items`) and `$original_frontmatter['photos']` was never set → `array_merge(null,…)` TypeError on any edit that uploads a file. Fix: use `Header::toArray()` (clean keys) + guard the per-field merge. add-page-by-form is abandoned upstream (last release Sept 2023) and its dir is **git-ignored/GPM-managed**, so the patch is tracked as `deploy/patches/add-page-by-form-grav2-header.patch` and re-applied via `make apply-plugin-patches` after any plugin reinstall — until the plugin is forked. Verified end-to-end on :8091: add a photo, remove one, reorder, and all three combined in one save (cover=first, existing preserved, dropped removed); create-with-photos and edit remove/reorder regressions still pass. (Grav 2.0.7 does **not** fix this on its own — the Header object is unchanged across the patch; only the plugin fix does.) **Code-review complete (2026-07-07)** — the multi-agent review of the branch ran and all findings (F1–F8) were applied & verified (20/20 post specs on :8091); the review's own PERF finding confirmed and hardened the once-per-submit cache latch noted above. **Implementation + review are done; the only remaining items are (1) owner-session UI QA and (2) the deliberate landing step (merge `user/`→main, pin bump, `content-push`, deploy).** Both are captured in `docs/working/handovers/2026-07-07-journal-post-form-review-handover-and-qa.md`. Still **not merged / not deployed** — held for owner QA.
## Goal Capsule
- **Objective:** Let the site owner edit, delete, and unpublish/publish journal entries directly from the front-end feed — reusing the existing `/post` form and the `add-page-by-form` plugin's native edit mode — without touching the Admin2 backend.
- **Product authority:** Mischa (site owner, sole author).
- **Open blockers:** None blocking. Two planning-time details flagged under Outstanding Questions.
---
## Product Contract
### Actors
- **Owner** (authenticated via the existing `site.login` gate) — the only actor who can edit, delete, or change publish state. Everything below is gated to this actor.
- **Public visitor** (unauthenticated) — sees only published entries; never sees edit/delete controls or drafts.
### Problem
Correcting a typo, fixing metadata, reordering photos, or shelving a half-written entry currently means logging into Admin2 and navigating the page tree. The owner wants to do all of it inline, from the same feed where the entries already live, on the same phone-friendly form used to post them.
### What we're building
Edit/delete/publish controls that live on the **journal feed cards of the active trip** (its trip page and the home active-trip feed, both rendered by the shared `partials/trip-feed-col.html.twig`). There is **no detail-page route** involved — the feed already renders each entry's full body inline, so the card is the surface. Delivered in two milestones.
---
### Milestone 1 — Edit, delete & publish-state from the feed cards
**Photos are untouched in M1** (the entry keeps its existing images exactly as-is).
- **R1 — Edit control.** Each journal card shows an **Edit** control when the owner is logged in. The Edit control **navigates to the post form** at `/post?edit=<entry-path>` (a query param carrying the entry's path) — a plain redirect to the existing full-page `/post` surface, not a modal or inline card expansion. The form loads prefilled with the entry's current values: title, date, content, lat, lng, location_city, location_country, weather_desc, weather_temp_c, transport_mode, featured, force_connect, published.
- **R2 — Save in place.** Saving writes back to the entry's **existing folder** (via the plugin's `overwrite_mode: edit` + a hidden path field). Editing the title or date does **not** rename the folder or change the URL — identity is stable by design. After saving, the form does a **full page reload** back to the feed (matching the existing post flow — no in-place card update).
- **R3 — Delete control.** Each journal card shows a **Delete** control (owner only). Deleting requires an explicit **confirmation step** — an inline button swap on the card (Delete → **Cancel** / **Confirm delete**), no browser dialog or modal — then removes the entry via the Grav API (session-auth `DELETE`, the pattern already used by `/gpx-manager`), clears the page-tree cache, and the card disappears from the feed.
- **R4 — Publish/unpublish toggle.** The form carries a publish-state toggle. The owner can unpublish an entry (to shelve it for later rewriting) or re-publish it. This sets the entry's `published` frontmatter. Publishing/unpublishing happens **only through the edit form** — there is no separate card-level publish control.
- **R5 — Drafts stay owner-visible.** An unpublished (draft) entry remains visible **to the logged-in owner** in the feed, marked with a **"Draft"** badge, and stays **editable** from its card (opening the edit form, where it can be re-published). It is **hidden from the public** feed entirely. Draft cards appear under **both** the "All content" and "Journal" filter tabs. Drafts are **excluded from the trip map and stats counts** — they render as a feed card only (no map marker, no stat contribution).
- **R6 — Server-side guard.** Edit, delete, and publish actions are enforced server-side, not just hidden in the UI: authenticated owner only, and only for entries inside the **active trip's**`dailies` container. The controls render **only on the active trip's** feed cards — past-trip feed pages (which share the same `trip-feed-col` partial) do **not** show them. Neither the `add-page-by-form` save path nor the Grav API delete path enforces trip-scope on its own (the plugin accepts a client-supplied `parent`/`edit_path`, and `PagesController::delete` checks only write-permission), so this guard must be a **custom server-side hook** on both the save and delete paths, validating the target route against `site.active_trip` before proceeding.
### Milestone 2 — Editable photos in the edit form (FilePond)
- **R7 — Load existing photos.** Opening an entry for edit loads its current photos into the FilePond field so they can be managed.
- **R8 — Remove photos.** The owner can delete any existing photo from the entry.
- **R9 — Add photos.** The owner can upload new photos, appended to the set, with the same HEIC→JPEG conversion used when posting.
- **R10 — Reorder.** Existing + new photos can be dragged into any order. The **first photo is the cover** — this reuses the live `photo-1..N` ordering convention, *not* the removed `hero_image` field.
---
### Scope Boundaries (non-goals)
- **Stories are untouched** by all of this — no edit/delete/publish changes to stories; they keep their standalone detail pages and `hero_image`.
- **No detail-page edit route** — edit is invoked from feed cards only (the Edit control redirects to `/post?edit=<path>`).
- **No editing of past-trip entries** — controls appear only on the active trip's cards; past trips are read-only through this UI (edit them via Admin2 if ever needed).
- **Retiring the journal detail page** is out of scope (tracked in `docs/working/backlog.md` → "Journal entry detail page"). It is cleanup unrelated to edit/delete.
- The owner can fix a typo or metadata on an existing entry from the feed and see it update, with the entry's URL unchanged.
- The owner can delete an entry from the feed (after confirming) and it disappears.
- The owner can unpublish an entry, still see it (badged "Draft") and re-open it later to finish and publish — while the public never sees it.
- (M2) The owner can remove, add, and reorder an entry's photos and see the cover change to match the new first photo.
### Dependencies / Assumptions
- **`add-page-by-form` edit mode exists but needs a create-path patch** — `overwrite_mode: edit` saves to the existing folder "respecting any already present uploaded files," targeting it via a hidden **`edit_path`** field (the plugin checks `edit_path` first, then `file_path`, at `add-page-by-form.php:537-545` — standardize on `edit_path`). Note the edit branch does **not** fall through to `slug_field` when `edit_path` is empty, so the shared-form create path requires the plugin patch in KTD1/U1. This is the backbone of M1/M2 save-in-place.
- **Post-form field parity** — the `/post` form's fields already map 1:1 to entry frontmatter, so prefill is a matter of loading values, not redesigning the form.
- **Cover = first image** is an existing convention (`entry.media.images|first` in `partials/entry-journal.html.twig`); the `hero_image` field was removed and is not reintroduced.
- **Feed collection is `.published()`** — both `trip.html.twig` and `home.html.twig` collect dailies via `.children.published()`, which drops unpublished pages unconditionally. R5 (owner-visible drafts) requires replacing this with an **auth-aware collection** in both templates: include unpublished entries only when the owner is authenticated, then gate the Draft badge/controls by auth.
- **Delete + cache** — deleting an entry must clear the page-tree cache. Note cache-on-save only clears on the `new-entry` form submit, so it does **not** fire on an API delete; the Grav API's `PagesController::delete` clears the cache itself, so the delete path inherits cache-clearing from the API, not from cache-on-save.
- **Auth** reuses the existing `site.login` gate; no new auth system.
### Outstanding Questions (resolve in planning)
- **"Save as draft" on create?** The publish toggle is a shared form field, so it will also appear on the *new-entry* path — confirm whether the create form should let the owner save a brand-new entry directly as a draft (likely yes, near-zero extra cost) or always publish new entries.
- **Draft direct-URL access?** Confirm Grav returns a **404 at a draft's direct URL** for anonymous visitors (not merely hiding it from the feed collection) under the current Login plugin config — otherwise draft content is reachable by anyone who guesses the date-slug URL.
- **Auth-varying feed vs. output caching?** Once `twig.cache: true` at launch, the feed renders differently for the owner (drafts shown) vs. the public (drafts hidden). Confirm the draft branch is evaluated **per-request** (or the feed bypasses output cache for authenticated sessions) so a cached render can't leak drafts to the public or hide them from the owner. Add a launch smoke test: load the feed as owner, then anonymous, and confirm drafts don't leak.
**Planning resolutions (2026-07-04):**
- *Save as draft on create* → **Yes.** The `published` toggle is a shared field defaulting to Published; flipping it off on the create path saves a brand-new entry as a draft. Near-zero cost, falls out of the shared field (see KTD3).
- The *draft direct-URL* and *auth-vs-cache* questions are not planning blockers — they are **launch-time verifications** carried into the Verification Contract (V7, V8). Both are low-risk for a solo-owner blog but must be confirmed before `twig.cache: true` at launch.
---
## Product Contract preservation
Product Contract unchanged. Planning enriches this artifact in place (requirements-only → implementation-ready); all R1–R10 IDs, scope boundaries, and success criteria are preserved verbatim. The only additions are the resolutions above and the Planning Contract below.
---
## Key Technical Decisions
- **KTD1 — Edit reuses the `new-entry` form via `overwrite_mode: edit` + a hidden `edit_path`; the plugin's edit branch is patched to preserve create.** Set `pageconfig.overwrite_mode: edit` on `post-form.md` unconditionally and add a hidden `edit_path` field that is **empty on create, populated on edit**. **Code check (feasibility + adversarial, confidence 100):** in `add-page-by-form.php` the `slug_field: date,title` computation lives *only* in the `else` (non-edit) branch (~lines 550-602); under `overwrite_mode === 'edit'` the slug is derived solely from `basename(dirname($form_data['edit_path']))` (line 541, guarded by `isset()`, not `!empty()`). So with an empty/absent `edit_path` the create path does **not** fall through to `slug_field` — it either writes into the dailies container itself (`basename(dirname(''))` → `.`) or aborts with a 'slug empty' error. The "one form for both" reuse is therefore **not implementable as written**. **Decision:** patch the plugin's edit branch so that when both `edit_path` and `file_path` are empty it falls through to the existing `slug_field` computation (restoring create behavior). This patch is a **required file of U1**, not a deferred contingency. V1 verifies both branches (empty `edit_path` → fresh dated folder; populated → in-place). *(Alternative considered and rejected for higher carrying cost: a separate edit-form page with its own `overwrite_mode: edit`.)*
- **KTD2 — Publish is folded into the edit save; no separate publish endpoint.** R4 specifies publish/unpublish happens only through the edit form, so the `published` toggle is a normal form field written to page frontmatter on save. This removes an entire endpoint from the surface — the only new server API is delete (KTD5).
- **KTD3 — `published` becomes a real form field, replacing the static `pagefrontmatter.published: true`.** Add a `published` toggle to the blueprint (default `1`). Remove the static `pagefrontmatter.published: true` so the field value is authoritative on every submit (create and edit). *Verification:* confirm the field value lands in frontmatter and the static default no longer overrides it (V2).
- **KTD4 — Prefill is client-side via the Grav API.** The Edit link opens `/post?edit=<entry-route>`; `post-form.js` reads the param, `GET /api/v1/pages<route>` (session-auth, `credentials: 'include'` — the gpx-manager pattern), and populates each field + the hidden `edit_path` + the `published` toggle. Reuses the JS layer we own and the already-configured session API. No server-side Twig form-default plumbing.
- **KTD5 — Delete is a purpose-built, active-trip-scoped API route in a new `entry-actions` plugin.** The stock `DELETE /api/v1/pages<route>` has no trip-scope guard (`PagesController::delete` checks only write-permission), which violates R6. A thin new plugin registers one route via `onApiRegisterRoutes` that: (a) requires the authenticated **owner** — `grav.user.username == site.owner_username`, **not** merely any login (the super-admin `tester` account also authenticates — see KTD8); (b) resolves the delete target **through the page tree** via `$grav['pages']->find($dailiesRoute . '/' . $slug)` (never raw filesystem-path concatenation) and asserts the resolved page is non-null and `->parent()->route()` equals the active trip's dailies route — rejecting any slug containing `/` or `..` at the handler entry with 400; (c) deletes the page folder; (d) clears the page-tree cache. Rejects with 403 otherwise. **Shared guard (FYI A2):** the plugin exports the active-trip→dailies-parent resolution + "is direct child of active dailies" assertion as one helper; `cache-on-save` (KTD6) calls the *same* helper so the two R6 enforcement points cannot diverge. See the `grav-api-integration` skill for the `AbstractApiController` + `onApiRegisterRoutes` contract.
- **KTD6 — The save-path scope guard lives in `cache-on-save`'s existing `onFormValidationProcessed`.** That handler already runs for `new-entry`, resolves `site.active_trip`, and injects the parent. Extend it: when `edit_path` is present, **normalize it first** — resolve via `$grav['pages']->find($edit_path)` and assert the returned page is non-null and its `->parent()->route()` equals the active dailies route (using the KTD5 shared helper). A raw string-prefix check is insufficient: a value like `/trips/<active>/dailies/../other-slug/entry.md` passes a prefix test while `basename(dirname())` targets a *different* entry (security-lens, confidence 75). Also assert owner identity (KTD8), consistent with the delete route. Throw a `ValidationException` (fail closed) otherwise. Leave create (no `edit_path`) untouched. This is R6's enforcement point for edit/publish — no new plugin needed for the save side.
- **KTD7 — Auth-aware feed collection; map/stats stay published-only.** Replace `.children.published()` with an owner-aware collection: `grav.user.authenticated ? dailies_page.children : dailies_page.children.published()`. The feed (`all_items`) uses the owner-aware list so drafts show to the owner; the **map `entries` array and stats inputs continue to use `.published()` only**, so drafts never get a marker or a stat contribution (R5). The between-trips home grid stays `.published()` (past trips are public-only).
- **KTD8 — Controls are gated by `owner_can_edit`, computed once per surface and threaded through the feed-col partial.** **Owner identity, not just authentication (security-lens, confidence 100):**`grav.user.authenticated` is true for *any* login, including the super-admin `tester` account, so gating on it alone would grant edit/delete/draft-visibility to every account. Gate on the specific owner: `owner_can_edit = grav.user.authenticated and grav.user.username == site.owner_username and (trip.slug == site.active_trip)`. Add `owner_username` to `site.yaml` (single source of truth) so the same identity check backs the UI gate here **and** the server guards (KTD5/KTD6) — the UI gate is cosmetic; the server is authoritative. `trip.html.twig` and the home active-trip branch compute it and pass it into `trip-feed-col.html.twig`, which passes it into `entry-journal.html.twig`. Past-trip pages compute `false`, so no controls render there — satisfying R6's "active trip only" at the UI layer, matching the server guard.
- **KTD9 — M1 hides the photos field and relaxes the ≥1-photo rule in edit mode.** Photos are untouched in M1, and the create flow requires ≥1 photo (`post-form.js initValidation`). In edit mode (`?edit=` present) the photos section is hidden and the ≥1-photo check is skipped, so an edit submit with an empty FilePond leaves existing images intact (`overwrite_mode: edit` "respects already present uploaded files"). M2 replaces this by loading the real photos into FilePond.
---
## High-Level Technical Design
**Edit round-trip (M1):**
```mermaid
sequenceDiagram
participant U as Owner (browser)
participant C as Journal card
participant P as /post?edit=route
participant JS as post-form.js
participant API as Grav API (session auth)
participant APBF as add-page-by-form
participant COS as cache-on-save guard
U->>C: click Edit (owner + active trip only)
C->>P: navigate /post?edit=<entry-route>
P->>JS: page load, ?edit present
JS->>API: GET /api/v1/pages<route>
API-->>JS: frontmatter + content
JS->>P: fill fields, set hidden edit_path,<br/>set published toggle, hide photos, relax photo rule
U->>P: edit + Save
P->>COS: form submit (new-entry)
COS->>COS: assert edit_path ∈ active dailies (else ValidationException/fail closed)
COS->>APBF: proceed
APBF->>APBF: overwrite_mode:edit → write to existing folder
COS->>COS: clear page-tree cache
P-->>U: full reload → feed shows updated entry (URL unchanged)
```
**Delete flow (M1):**
```mermaid
sequenceDiagram
participant U as Owner (browser)
participant C as Journal card
participant EA as entry-actions plugin (API route)
- **Approach:** Set `pageconfig.overwrite_mode: edit`. Add a hidden `edit_path` field (empty default). Add a `published` toggle field (default `1`, near the advanced fields). Remove the static `pagefrontmatter.published: true` so the field is authoritative (KTD3). **Patch the plugin's edit branch (KTD1):** in the `if ($overwrite_mode === 'edit')` block, when both `edit_path` and `file_path` are empty, fall through to the existing `slug_field: date,title` computation from the `else` branch (factor it into a shared code path or duplicate the slug build) so create still writes a fresh dated folder. Add `owner_username` to `site.yaml`.
- **Patterns to follow:** existing `force_connect`/`featured` toggle fields in the same blueprint; hidden field via `type: hidden`; the existing `slug_field` build in `add-page-by-form.php`'s non-edit branch.
- **Execution note:** characterization-first on the plugin patch — capture the current create-path slug output before changing the edit branch, so the patch is proven not to alter create.
- **Test scenarios:**
- Create path preserved under edit mode: submit a new entry with `overwrite_mode: edit` and an empty `edit_path` → a new dated folder is written (not the dailies container, not a 'slug empty' error), `published: true` in frontmatter. *Covers V1.*
- Publish field write: submit with `published` off → frontmatter shows `published: false` (assert the on-disk type is a real boolean/int, not the quoted string `'0'`). *Covers V2.*
-`Test expectation:` blueprint + plugin patch are behavior-bearing — covered by the two scenarios above plus U2/U5 integration.
- **Verification:** posting a brand-new entry still works exactly as before the blueprint flipped to edit mode; `published` value round-trips to frontmatter as a real boolean.
- **Goal:** Enforce R6 on the edit/publish save path.
- **Requirements:** R6; KTD6.
- **Dependencies:** U1.
- **Files:** `user/plugins/cache-on-save/cache-on-save.php`, `tests/` (PHP or UI integration).
- **Approach:** In `onFormValidationProcessed` (already gated to `new-entry`), when `edit_path` is present **normalize it via `$grav['pages']->find($edit_path)`** and assert the resolved page is non-null and its `->parent()->route()` equals the active dailies route — using the KTD5 shared helper so save and delete share one scope check. Reject a `null` resolution or any `..`/traversal segment (a raw string-prefix check is insufficient — see KTD6). Also assert `grav.user.username == site.owner_username` (KTD8). Throw `ValidationException` (fail closed) otherwise. Leave create (no `edit_path`) untouched.
- **Execution note:** test-first — add failing tests asserting both an out-of-scope `edit_path`**and** a traversal `edit_path` (`/trips/<active>/dailies/../other/entry.md`) are rejected before writing the guard.
- **Patterns to follow:** the existing fail-closed `ValidationException` for a missing `active_trip` in the same method; the KTD5 shared scope-guard helper.
- **Test scenarios:**
- Edit within active trip's dailies → guard passes, save proceeds.
- Edit with `edit_path` pointing outside active dailies (e.g. another trip, or `/`) → `ValidationException`, no page write. *Covers V3.*
- Traversal `edit_path` that string-prefix-matches the active dailies but resolves elsewhere → `ValidationException`, no page write. *Covers V3 (traversal branch).*
- Create (no `edit_path`) → guard is a no-op, entry posts normally.
- **Verification:** a forged out-of-scope or traversal `edit_path`, and a non-owner session, cannot write; in-scope owner edits and normal creates are unaffected.
### U3. Auth-aware feed collection; drafts excluded from map/stats
- **Goal:** Owner sees drafts in the feed; public and map/stats do not.
- **Requirements:** R5; KTD7, KTD8.
- **Dependencies:** none (parallel-safe with U1/U2).
- **Approach:** Swap `.children.published()` → `grav.user.authenticated ? dailies_page.children : dailies_page.children.published()` for the **feed** list only. Keep the map `entries` array and stats inputs on a `.published()`-only list. Compute `owner_can_edit` (KTD8) and pass it into `trip-feed-col`. Home active-trip branch: `owner_can_edit = grav.user.authenticated`. Between-trips grid stays `.published()`.
- **Patterns to follow:** existing `{% set journal_entries = ... %}` blocks at `trip.html.twig:12`, `home.html.twig:17`; the existing `{% include 'partials/trip-feed-col.html.twig' with { ... } only %}` param list.
- **Test scenarios:**
- Anonymous visitor: draft entry absent from feed, map, and stats. *Covers V4.*
- Authenticated owner: draft entry present in feed; still absent from map markers and stat counts.
- Published entries: unchanged for both audiences.
- **Verification:** draft visibility differs by auth in the feed only; map/stats identical for both.
- **Goal:** Render the badge and the owner controls on the journal card.
- **Requirements:** R1, R3, R5, R6; KTD8.
- **Dependencies:** U3 (provides `owner_can_edit` and draft flag).
- **Files:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`, `user/themes/intotheeast/templates/partials/entry-journal.html.twig`, theme CSS (`user/themes/intotheeast/css/…` or the relevant partial styles).
- **Approach:** Thread `owner_can_edit` (owner-username gated per KTD8, not merely authenticated) through `trip-feed-col` into `entry-journal`. In `entry-journal.html.twig`: when `entry.published` is false, render a "Draft" badge in the header. When `owner_can_edit`, render an **Edit** link (`/post?edit={{ entry.route }}&return={{ page.url|url_encode }}` — the `return` param lets a save from the home feed reload back to home, not always the trip page; see U5/D5) and a **Delete** control with the inline Cancel/Confirm button-swap markup (no browser dialog). Carry `data-entry-route` for the delete JS. **Touch targets (D8):** Edit/Delete/Cancel/Confirm controls get a min 44×44px tap area (phone-first, field use) — add the sizing to the card-control CSS.
- **Patterns to follow:** the card header structure at `entry-journal.html.twig:3-22`; the filter/`data-*` attribute convention already on the `<article>`.
- **Test scenarios:**
- Anonymous: no Edit/Delete controls, no Draft badge visible (drafts absent anyway).
- Owner on active trip: Edit + Delete present on every journal card; Draft badge on unpublished ones. *Covers V5.*
- Non-owner authenticated (e.g. `tester`) on active trip: no controls (owner_can_edit false).
- Owner on a past-trip page: no controls (owner_can_edit false).
-`Test expectation:` markup/gating — covered by the above UI assertions.
- **Verification:** controls appear only for owner+active-trip; badge tracks publish state; controls meet the 44px tap-target minimum.
### U5. Edit prefill + edit-mode form behavior (post-form.js)
- **Goal:** Fill the form from the entry and adapt the form for editing.
- **Requirements:** R1, R2; KTD1, KTD4, KTD9.
- **Dependencies:** U1 (fields exist).
- **Files:** `user/themes/intotheeast/js/src/post-form.js` (rebuild via `make build-assets`; never hand-edit `js/post/post-form.js`).
- **Approach:** On `?edit=<route>` detection, **before the fetch fires, disable all form fields and swap the submit button to a "Loading entry…" state (D1)** — this prevents the owner typing into empty fields on a slow mobile connection and having that input silently overwritten when the prefill resolves. Then `GET /api/v1/pages<route>` (`credentials: 'include'`), populate title/date/content/lat/lng/city/country/weather/transport/featured/force_connect/published, set the hidden `edit_path`, hide the photos section, and skip the ≥1-photo validation (KTD9); re-enable fields + restore the submit button on success. **Edit-mode chrome (D6):** set the form `h1` to "Edit entry" and the submit button to "Save changes". **On fetch failure (D7):** show an inline error banner between the form heading and the first field, restore empty defaults, keep fields disabled (don't leave a half-filled form). **Save behavior:** keep the existing full-reload-on-success, redirecting to the `return` URL param when present, else the active trip page (D5). **Save-failure state (D3):** if the server guard rejects the submit, the error re-render must preserve the hidden `edit_path`, the `published` toggle, and the prefilled fields so the owner doesn't lose edit context (relevant given the Form 9.1.10 re-render path — see Risks/A3). **Pre-U5 check (A4):** confirm with one `curl` (session cookie) that `GET /api/v1/pages<route>` returns the required frontmatter keys + content and record the exact JSON path (`header.*` vs flat) before wiring field mapping — the gpx-manager reference only covers `/media`.
- **Patterns to follow:** the existing API-fetch + `credentials: 'include'` usage in `gpx-manager.html.twig`; the existing `initValidation` and field-setting helpers in `post-form.js`.
- **Test scenarios:**
- Loading state: on `?edit=`, fields are disabled and the button reads "Loading entry…" until the fetch resolves; typing is impossible before prefill lands. *Covers D1.*
- Edit load: `/post?edit=<route>` fills every field with the entry's values, sets `edit_path`, and shows the "Edit entry" heading. *Covers V6.*
- Edit save: change the title, submit → same folder/URL, title updated, photos intact; reload lands on the `return` surface. *Covers V1 (edit branch), D5.*
- Photos hidden + ≥1-photo rule relaxed in edit mode: submitting with empty FilePond succeeds and keeps existing images.
- API fetch failure: inline error banner shown between heading and first field; form not silently broken.
- **Verification:** editing round-trips values with a stable URL; the form is never editable before prefill lands; photos survive an M1 edit; a failed save preserves edit context.
- **Goal:** Actually delete an entry, scoped to the active trip.
- **Requirements:** R3, R6; KTD5.
- **Dependencies:** U4 (delete control markup).
- **Files:** new plugin `user/plugins/entry-actions/` (`entry-actions.php`, `entry-actions.yaml`, `blueprints.yaml`); **delete JS in a small feed-scoped script**`user/themes/intotheeast/js/src/feed-actions.js` (rebuilt via `make build-assets`) — the delete control lives in `entry-journal.html.twig` (rendered by the feed partial, not the `/post` page), so it does **not** belong in `post-form.js` (C3); `plugins.txt` note only if GPM-managed (this is custom-in-repo, so **not** added to `plugins.txt`).
- **Approach:** Register `DELETE /api/v1/entry/<slug>` via `onApiRegisterRoutes`. Handler: require the authenticated **owner** (`grav.user.username == site.owner_username`, KTD8); reject any slug with `/` or `..` at entry (400); resolve the target through the page tree via `$grav['pages']->find($dailiesRoute . '/' . $slug)` (never raw filesystem-path concatenation); assert the resolved page is non-null and a direct child of the active dailies (KTD5 shared helper); delete the page folder; `cache->deleteAll()`. Return 200/400/403/404 as appropriate. **Frontend (feed-actions.js):** Delete → inline swap to Cancel/Confirm. **On Confirm-click (D2): immediately disable both buttons and set Confirm to "Deleting…", and announce via an `aria-live` region** — prevents a mobile double-tap firing two DELETEs (the second 500s on an already-removed folder). On 200: **capture the next-sibling journal card, remove the deleted card, then move focus to that sibling (or the feed heading if it was the last card) and announce "Entry deleted" via `aria-live` (D4)**. On 403/error: re-enable both buttons, restore labels, show a one-line inline message directly below the control, constrained to card width (D7).
- **Execution note:** test-first on the scope guard — out-of-scope, traversal, and non-owner deletes must be refused before the happy path is wired.
- **Patterns to follow:** `grav-api-integration` skill (`AbstractApiController`, `onApiRegisterRoutes`, response/exception helpers); `api.yaml` session-auth config; the gpx-manager delete fetch shape.
- **Test scenarios:**
- Owner deletes an active-trip entry → folder gone, cache cleared, card removed, focus moves to the next card. *Covers V5 (delete).*
- In-flight guard: double-tapping Confirm fires exactly one DELETE (buttons disabled after first click).
- Confirmation UX: Delete → Cancel restores original control; Delete → Confirm triggers the request.
- **Verification:** scoped delete works for the owner only; out-of-scope/traversal/non-owner/unauth requests are refused; no double-submit; focus is preserved after removal.
### U7. M2: Load existing photos into FilePond on edit
- **Goal:** Show the entry's current photos in the edit form so they can be managed.
- **Files:** `user/themes/intotheeast/js/src/post-form.js`; possibly the `entry-actions` plugin or Grav media API for per-photo metadata.
- **Approach:** In edit mode, instead of hiding the photos section (KTD9's M1 behavior), pre-populate FilePond with the entry's existing images as remote/local items (FilePond `files` init pointing at the entry media URLs). Re-enable the photos section for edit.
- **Patterns to follow:** the existing FilePond init + `GravFilePond` usage in `post-form.js`; entry media URLs as rendered in `entry-journal.html.twig`.
- **Test scenarios:**
- Edit load: existing photos appear as FilePond items in current order. *Covers V9.*
- Entry with a single photo / many photos both render correctly.
- **Verification:** the edit form shows the real photos ready to manage.
- **Approach:** On save, reconcile FilePond state to the `photo-1..N` scheme (drag order = cover order, reusing the existing rename convention). Route removals through `add-page-by-form`'s existing deleted-files mechanism (`add-page-by-form.php:121, 715-718`) so dropped images are unlinked. New uploads get the same HEIC→JPEG conversion as create. Verify `reorderPhotos` is reachable from the edit path.
- **Execution note:** characterization-first — capture current `reorderPhotos` behavior before extending it to the edit path.
- **Patterns to follow:** existing `photo-1..N` rename + `reorderPhotos()` in `cache-on-save`; HEIC→JPEG `beforeAddFile` hook in `post-form.js`.
- **Test scenarios:**
- Remove a photo → file unlinked on disk; remaining renumbered; feed cover updates. *Covers V10.*
- Add a photo (incl. HEIC) → appended, converted, renamed into sequence.
- Reorder so a different image is first → that image becomes the feed cover.
- Mixed add+remove+reorder in one save → final on-disk set matches the FilePond order exactly.
- **Verification:** the on-disk photo set and cover match the FilePond state after save.
---
## Verification Contract
- **V1 — Create not regressed by edit mode.** With `overwrite_mode: edit` and no `edit_path`, posting a new entry writes a fresh dated folder identical to prior behavior — verified by the KTD1 plugin patch (empty `edit_path`/`file_path` falls through to `slug_field`). Assert on the **on-disk folder + feed**, not the re-rendered form (the Form 9.1.10 re-render may 500 — see Risks/A3).
- **V2 — Publish field round-trips.** The `published` toggle writes a real boolean `published: true/false` to frontmatter (not the quoted string `'0'`) and the removed static default no longer overrides it.
- **V3 — Scope guard rejects out-of-scope, traversal, and non-owner writes/deletes.** A forged out-of-scope `edit_path`/delete route, a traversal path that string-prefix-matches active dailies but resolves elsewhere, and a non-owner authenticated session (e.g. `tester`) are each refused server-side (edit → `ValidationException`; delete → 403/400), with no disk change. Both guards call one shared helper (KTD5).
- **V4 — Draft visibility is auth-scoped.** Anonymous: draft absent from feed/map/stats. Owner: draft present in feed only (still absent from map markers and stat counts).
- **V5 — Owner (only) can edit and delete from the card.** Active-trip cards expose working Edit and Delete (with confirm) to the owner username only; the edited entry keeps its URL; the deleted entry disappears and focus moves to the next card. Assert on disk/feed, not the re-render (A3).
- **V6 — Prefill loads all fields.** `/post?edit=<route>` populates every listed field plus `edit_path` and the publish toggle, and the form is not editable until prefill lands (D1).
- **V7 — (interim + launch) Draft direct-URL returns 404 to anonymous.** Confirm a `published: false` entry's URL 404s for anonymous visitors under the current Login config — not merely feed-hidden. **Run this in the dev container during M1** (added to DoD), not only as a launch gate — entry URLs follow a guessable date-slug pattern.
- **V8 — (launch) No draft leak under `twig.cache: true`.** With caching on, load the feed as owner then anonymous; drafts never leak to the public nor vanish for the owner.
- **V9 — (M2) Existing photos load into FilePond on edit.**
- **V10 — (M2) Add/remove/reorder persists; cover = first photo.**
Existing UI suite to extend: `tests/ui/post/post-form-ux.spec.js` and helpers in `tests/ui/helpers`. Standalone Playwright scripts run against the container per the session norm. **Given the Form 9.1.10 filepond regression (Risks/A3), M1 UI assertions target the on-disk entry and the re-rendered feed, not the post-submit form re-render.**
---
## Definition of Done
- All M1 units (U1–U6) implemented; V1–V6 pass, plus **V7 run in the dev container** as an M1 check (draft direct-URL 404s for anonymous). V8 recorded as a launch-gate check (not blocking M1 merge but tracked).
- Owner (owner-username, not merely any authenticated account) can edit, delete (with confirm), and unpublish/publish a journal entry entirely from the active-trip feed, with the entry URL stable and the public never seeing drafts.
- Server-side scope guard proven on both save and delete paths via the shared helper (V3), including traversal and non-owner rejection.
- Empty-`jwt_secret` risk resolved: confirmed the API does not accept empty-signed tokens on the new routes (see Risks/S1).
- M2 units (U7–U8) implemented; V9–V10 pass — may land as a separate follow-up PR after M1.
- No regression to the create flow (V1) or to stories.
- Assets rebuilt via `make build-assets`; no hand-edits to `js/post/post-form.js`.
---
## Risks & Dependencies
- **`overwrite_mode: edit` create-path behavior (KTD1)** was the load-bearing assumption and it **fails as originally written** (feasibility + adversarial, confidence 100) — the plan now resolves it with a required plugin patch in U1 (fall through to `slug_field` when `edit_path`/`file_path` empty). V1 verifies the patched create path. Contingency if the patch proves unworkable: a dedicated edit-form page.
- **Empty `jwt_secret` in `api.yaml` (S1, security-lens).** `jwt_secret: ''` alongside `jwt_enabled: true` — if the API plugin accepts tokens signed with the empty string, the "authenticated owner" guard on both new routes (delete, prefill GET) is forgeable by an unauthenticated attacker. **Pre-M1 check:** verify against the api plugin source (or empirically) that an empty secret means "JWT disabled" and does not accept empty-signed tokens; if it does, set a real secret before shipping. The plugin's own owner-identity assertion (KTD5/KTD8) is the primary control regardless.
- **Grav API session permission for the custom delete route** — confirm the `site.login` session carries sufficient permission for the plugin's delete action (page removal may need an elevated check); the plugin owns its own auth assertion regardless (KTD5).
- **Form 9.1.10 filepond regression** (flagged in project instructions: the post-submit re-render 500s on the filepond field) affects **M2** photo editing **and also M1's edit-save reload (A3, adversarial)** — every `new-entry` submit, including an M1 edit save, goes through the same re-render. It also already breaks the 6 post UI specs. **Mitigation for M1:** assert V1/V5/V6 on the on-disk entry + re-rendered feed rather than the post-submit form re-render (see Verification). M2 photo editing should land only once the regression is resolved in the form-to-page/image-upload rework; do not work around it here.
- **CSRF posture** — the delete route relies on the existing `cors.credentials: false` (blocks cross-origin credentialed fetch). The edit-save POST additionally depends on the PHP session cookie's `SameSite` attribute; confirm it is `Lax`/`Strict`. Document this dependency; revisit if CORS is ever loosened.
- **Owner account hygiene** — the super-admin `tester` account authenticates and, under a naive `grav.user.authenticated` gate, would gain full edit/delete rights; the owner-username gate (KTD8) closes this. The `tester` account should not ship to production.
**Status:**✅ Complete (2026-07-04) — implemented on `feat/journal-post-form` (U1–U7). U4 changed course during execution: the "plain input + custom uploader" fallback uploaded to Grav's flash but couldn't attach photos to the entry without replicating FilePond's undocumented submit contract, so photos now stay on `type:filepond` with a `beforeAddFile` hook that converts HEIC→JPEG then re-adds via `pond.addFile()` (FilePond owns upload+attach). Verified end-to-end in a browser (HEIC→JPEG attach, corrupt-HEIC fail-closed, disclosure, weather gating, draft restore) and via curl (active-trip parent injection + empty-`active_trip` fail-closed). Not yet pushed / submodule pin not yet bumped — awaiting owner go-ahead (content-push triggers production).
> Plan type: `feat` · Depth: Deep — feature · Origin: `/ce-brainstorm` "improve the current php plugin that allows me to add a new journal page to the current active trip" (2026-07-04)
title:Photo Editor for Journal Entries (media-API) — Plan
date:2026-07-05
---
# Photo Editor for Journal Entries (media-API) — Plan
**Status:** 🔄 In progress — implemented & server-logic verified (2026-07-05). Server (shared `PhotoRenumberer`, reorder route, guards) + client (own grid, SortableJS, FilePond decommission) landed; `PhotoRenumberer` unit-verified (pad/normalise/swap/gap/crafted-name-safety/10+/idempotent/ext), PHP lints clean, JS/CSS build clean, `/post` + assets serve on :8091. Server-side SVG block deferred to the R6 add/delete fast-follow (see Deferred). **Pending owner-session UI verification** (add incl. HEIC, inline-confirm delete, mouse reorder, combined; feed cover=first; regressions a/b/c) and **on-device touch-drag** — both need the owner login the harness can't obtain.
## Why this exists (the honest reason)
M2 tried to edit an entry's photos by reusing the `/post`**create** form + FilePond + the abandoned `add-page-by-form` plugin. Two distinct failure classes came out of that, and it matters not to blur them into one root cause:
- **FilePond-widget bugs** — `text/html` previews and broken touch-drag. FilePond is built to upload new files to a fresh entry, not to load/preview/reorder existing server files; these are the widget used against its grain.
- **PHP-side bugs** — the header-cast fatal and the rename-reconcile gymnastics live in `add-page-by-form` / `cache-on-save`, **not** in FilePond. This plan **reuses that same rename-reconcile logic** (see the reorder route below), so it must be validated on its own merits — a "different foundation" does not make the carried-forward reconcile code automatically safe.
This plan replaces the **photo UI** with the **proven `gpx-manager` pattern**: our own UI talking straight to the Grav media API.
## Foundation status (what's proven vs still assumed)
**Proven on 2026-07-05 (not assumed):**
-`POST /api/v1/pages<entry-route>/media` (FormData `file`, owner session) → **201**, file on disk ✓
**Still assumed (novel, load-bearing, NOT yet proven — this is where the 4-day risk lives):**
- The custom reorder route (rename to `photo-01..NN`) — no stock endpoint exists.
- Live reorder-rename behaviour under real add/delete ops.
- Three independent live mutations interacting cleanly with the edit session's text-field Save.
- HEIC→JPEG conversion at real photo sizes/counts on a phone.
## Design decisions
1.**Live, not on-submit.** Add / delete / reorder each persist **immediately** via the API — decoupled from the `/post` form's text-field Save. No flash, no submit-time reconcile. This sidesteps `add-page-by-form` for the photo path entirely (the text-field save still uses it + our committed patch). *(Edit-then-leave / no-undo behaviour for the destructive delete path is unresolved — see Open Questions.)*
2.**Inline on `/post?edit`.** In edit mode, hide the FilePond section and render the photo-editor component from the media list. **Create mode keeps FilePond, untouched** (out of scope). Hiding the section alone is **not** enough — see the FilePond decommission step in the Client section.
3.**Own thumbnail grid, SortableJS for drag.** Square `<img>` thumbnails in a grid. Reorder via **SortableJS** — exactly what FilePond couldn't do reliably here. SortableJS is **not yet a theme dependency**: install `sortablejs` and import it into `js/src/post-form.js` so esbuild bundles it into `js/post`. This is a task, not existing foundation.
4.**Cover = first.** After any add/delete/reorder, files are renumbered **`photo-01..NN`** (zero-padded, wide enough for the expected max) in display order; the client sorts thumbnails **numerically**, and the feed renders `media.images|first` as cover. Zero-padding is required so lexicographic media order equals numeric order past 10 photos (otherwise photo-1, photo-10, photo-2…). The shared renumber helper must also normalise any pre-existing un-padded `photo-N` files on first reorder. **This helper also runs on create-mode reconcile**, so create-mode entries will now emit `photo-01..NN` too — an intentional, accepted change (see Scope boundaries). Existing published entries keep their un-padded names harmlessly (they have <10 photos and the client sorts numerically).
5.**Add/delete via stock media API; reorder via one custom scope-guarded route.** Stock `POST`/`DELETE …/media` are already proven on entry routes, so add + delete use the **stock media API** (client-side, session-auth). Only the missing **reorder** (rename to `photo-01..NN`) is a custom route in the **`entry-actions`** plugin using `EntryScopeGuard` (owner-username + direct-child-of-active-dailies, the R6 guard). **Accepted tradeoff:** server-side scope enforcement on photo **add/delete** is a **known R6 gap** — any account with `api.media.write` can reach the un-scoped stock endpoint directly, and the client UI gate is **not** an access-control boundary. For a solo-owner blog this is accepted for launch and tracked as a **documented fast-follow** (promote add/delete onto scope-guarded custom routes later). HEIC→JPEG happens client-side before upload (reuse the existing converter).
## Server — `entry-actions` plugin, 1 custom route (+ stock media API for add/delete)
**Add / delete — stock media API (client-side, session-auth):**
-`POST /api/v1/pages<entry-route>/media` — upload (stock endpoint). **No SVG support for now:** add `svg` to `security.uploads_dangerous_extensions` (or reject `.svg` in the upload path) so SVGs are **blocked, not sanitized** — this removes the stored-XSS-via-SVG vector without depending on `security.sanitize_svg` staying enabled. Other executable types (html/js/php) are already blocked by Grav's default dangerous-extension denylist, which is the **actual** control on this stock path — there is no positive MIME allowlist or on-disk extension rewrite here. Allowed image types: **jpg/jpeg/png/webp**; the client file input accepts those **plus HEIC** (converted client-side to JPEG before upload) and excludes SVG. If stronger positive-MIME validation is ever wanted, it moves add onto the scope-guarded custom route (the same place the R6 add/delete fast-follow lands).
- **After every stock add and every stock delete, immediately call the reorder route (below) to re-establish `photo-01..NN`.** Stock upload keeps the file's original (slugified) name — not the next `photo-N` — and stock delete leaves a numbering gap without renumbering; without a follow-up renumber, `cover = first` breaks until the next manual drag. The reorder route is the single owner of the `photo-N` invariant.
**Reorder — one custom route (owner + scope guarded):**
-`POST /api/v1/entry/<slug>/photos/order` — body: ordered filenames → two-phase rename to `photo-01..NN` (reuse the proven cache-on-save rename logic; factor it into a shared helper — and validate that helper on its own, per "Why this exists").
Handler: `EntryScopeGuard::isOwnerUser` + `resolveActiveDailyChild` (reject 403/400 otherwise), then filesystem op, then `cache->deleteAll()`. Reject filenames containing `/` or `..`. **Operate only on filenames that already exist as image media** in the entry folder — any name in the ordered list that isn't a current image file is ignored, so the entry `.md`, a `.gpx`, or a `.meta.yaml` can never be renamed or clobbered by a crafted order body.
**Deploy note:** the new `/entry/<slug>/photos/order` route only registers after the API route-map cache is rebuilt, so a cache clear must run on deploy. The existing `DELETE /entry/<slug>` route confirms the nested-static-after-param pattern registers fine.
## Client — new `photo-editor.js` (bundled into the post-form entry)
In edit mode only:
- **Decommission the FilePond photo path (hiding it is not enough).** Skip `editLoadPhotos()` and the FilePond `photo_order` submit-handler wiring entirely — do not initialise/populate FilePond. Otherwise the stale `photo_order` manifest posted on text Save drives `cache-on-save.reconcilePhotos()` → `deleteUnlistedImages()`, which **silently deletes any photo added live after page-open**. With an empty manifest the reconcile leaves the live-managed folder untouched.
- Hide the FilePond `.photos-collapse`; render `.photo-editor` from `GET …/media` (image files, numeric-sorted). Show a **loading placeholder** during the fetch and an **empty state** for zero-photo entries that keeps the "Add photos" button visible ("No photos yet — add some").
- Each cell: `<img>` thumbnail + ✕ delete. **Inline confirm:** ✕ swaps the cell to "Delete? [Confirm] [Cancel]" (Confirm disabled while the DELETE is in flight) → `DELETE …/media/<file>` → renumber → re-render; Cancel reverts.
- "Add photos" button → hidden file input → HEIC→JPEG → `POST …/media` (one per file) → renumber → re-render. **Upload progress:** disable the button while a batch is in flight and show "Uploading N of M…", clearing per file.
- **Add is a two-write op (stock upload, then reorder).** On a multi-file add, upload each file (stock `POST …/media`) and call the reorder route **once after the whole batch** — not per file — so there is one renumber pass and only the final numbering matters. The client passes the stock-uploaded basenames into that reorder manifest. If an upload succeeds (201) but the follow-up reorder fails, auto-retry the reorder — it is idempotent, since `renumberPhotos` skips files not on disk — or roll back by `DELETE`-ing the just-uploaded file(s), and surface a single inline error. Never leave an orphan stock-named file in the folder: it is a real image, so it would break `cover = first` and the numeric sort until the next successful drag.
-`Sortable` on the grid → on drop, `POST …/photos/order` with the new filename order → re-render. First cell = cover.
- **Failure path (every op).** On non-2xx / network error: show an inline error near the affected control (reuse gpx-manager's `.gpx-status.error`), keep the item in place — for reorder, **revert the SortableJS move to the last-known-good order** — re-enable the control for retry, and do **not** silently re-render. Displayed order/cover must never disagree with disk without an error shown.
- All live; independent of the form's Save button (which continues to handle title/date/content/etc.).
## Scope boundaries (non-goals)
- **Create flow (new-entry FilePond) untouched** — *except* that the shared renumber helper is now zero-padded, so create-mode entries also emit `photo-01..NN`. That is the only create-path side effect; the FilePond UI itself is unchanged. Two photo UIs for now (FilePond on create, this on edit); unifying them is a follow-up.
- **I verify in-harness:** add (incl. HEIC), delete, reorder-by-**mouse**, and combined — each persists to disk + shows in the feed immediately; cover = first after reorder; the reorder route's owner/scope-guard rejects non-owner + out-of-scope.
- **Regression checks:** (a) a text-field Save *after* a live photo add does **not** delete the added photo (FilePond decommission); (b) an entry with **10+ photos** keeps arranged order and the correct cover (zero-padding); (c) each op's failure path shows an inline error and leaves UI and disk consistent.
- **You verify on-device (the one thing I can't simulate):** touch-drag reorder on a phone.
## Estimate
One focused implementation push — 1 custom reorder handler + stock add/delete reuse + one JS component + CSS + the SortableJS dependency (install + import). Not another multi-day cycle. Residual risk concentrated in the "still assumed" list above.
## Deferred / Open Questions
### From 2026-07-05 review
- **No undo / cancel model for destructive live edits (P1).** Add/delete/reorder persist immediately and delete is a destructive `unlink`; the Save button trains the user that leaving without saving discards changes, but live deletes are already gone with no undo and no "permanent" signal. Decide between: (a) accept live-is-permanent + add a "saves immediately" affordance and a real delete confirm (cheapest for the deadline); (b) soft-delete to a trash subfolder purged on Save/leave; (c) stage deletes client-side and commit on Save. Resolve before implementing the delete path.
### Deferred during implementation (2026-07-05)
- **Server-side SVG block deferred to the R6 add/delete fast-follow.** The plan
called for adding `svg` to `security.uploads_dangerous_extensions`, but
`user/config/security.yaml` is **gitignored** (a Grav 1.7-era rule from when the
HMAC `salt` lived there; obsolete in 2.0.7 where the secret moved to the
still-ignored `security-private.php`). Tracking it would mean un-ignoring a
security-namespace file from another work session's era — out of scope for this
push. Instead: **SVG is excluded client-side** in the photo-editor file input
`accept` (jpg/jpeg/png/webp + HEIC only). The **server-side** block is a
documented fast-follow that lands together with promoting photo add/delete onto
the scope-guarded custom route (the same R6 gap already accepted above) — both
concern the un-scoped stock media endpoint, which only the solo owner can reach.
### Resolved at review close (2026-07-05) — recorded for the implementer
- **Reorder-route filename safety** — *resolved:* the handler operates only on filenames already present as image media in the folder, so a crafted order body can't rename/clobber the entry `.md`, a `.gpx`, or a `.meta.yaml`. (Now in the Server reorder-route spec.)
- **Multi-photo add — reorder cadence** — *resolved:* call the reorder route **once after the whole batch** of uploads, not once per file. (Now in the Client "Add is a two-write op" spec.)
- **New route 404 until cache rebuild** — *resolved:* deploy must clear the API route-map cache so `/entry/<slug>/photos/order` registers; the existing `DELETE /entry/<slug>` proves the nested-route pattern works. (Now a deploy note in the Server section.)
- **`.meta.yaml` sidecars not renamed by `renumberPhotos`** — *deferred (genuine future work):* no effect today because per-image captions are deferred. When captions ship, the shared renumber helper must rename each image's `.meta.yaml` sidecar alongside it (and clean up orphans), or per-image metadata will drift on reorder/delete.
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.