Adding a new photo while editing an entry 500s: add-page-by-form's edit branch
reads existing frontmatter via `(array)$page->header()`, but Grav 2.0's
Grav\Common\Page\Header keeps data in a protected `items`, so the cast mangles
keys and `$original_frontmatter['photos']` is never set → array_merge(null,…)
TypeError. Fix: use Header::toArray() (clean keys, stdClass fallback) + guard the
per-field merge. Grav 2.0.7 does not change this — only the plugin fix does.
add-page-by-form is abandoned upstream (last release 2023-09) and its dir is
git-ignored/GPM-managed, so the fix is tracked as deploy/patches/*.patch and
re-applied after any GPM install/update:
- make apply-plugin-patches (local) — chained into install-plugins
- make remote-apply-plugin-patches-{test,prod} — piped over SSH into
`patch -p1 --forward`; chained into remote-install-plugins / remote-update-plugins
Content syncs don't touch user/plugins/, so the patch survives them; only a GPM
op wipes it (now auto-restored). Runbook + README document the step and a
verify check. Remove once the plugin is forked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
43 KiB
artifact_contract, artifact_readiness, execution, product_contract_source, title, date
| artifact_contract | artifact_readiness | execution | product_contract_source | title | date |
|---|---|---|---|---|---|
| ce-unified-plan/v1 | implementation-ready | code | ce-brainstorm | Front-End Journal Entry Edit - Plan | 2026-07-04 |
Front-End Journal Entry Edit - Plan
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.) Held on the branch for review — not merged / not deployed.
Goal Capsule
- Objective: Let the site owner edit, delete, and unpublish/publish journal entries directly from the front-end feed — reusing the existing
/postform and theadd-page-by-formplugin'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.logingate) — 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/postsurface, 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
publishedfrontmatter. 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
dailiescontainer. The controls render only on the active trip's feed cards — past-trip feed pages (which share the sametrip-feed-colpartial) do not show them. Neither theadd-page-by-formsave path nor the Grav API delete path enforces trip-scope on its own (the plugin accepts a client-suppliedparent/edit_path, andPagesController::deletechecks only write-permission), so this guard must be a custom server-side hook on both the save and delete paths, validating the target route againstsite.active_tripbefore 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..Nordering convention, not the removedhero_imagefield.
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. - No bulk operations (multi-select edit/delete/publish).
Success criteria
- 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-formedit mode exists but needs a create-path patch —overwrite_mode: editsaves to the existing folder "respecting any already present uploaded files," targeting it via a hiddenedit_pathfield (the plugin checksedit_pathfirst, thenfile_path, atadd-page-by-form.php:537-545— standardize onedit_path). Note the edit branch does not fall through toslug_fieldwhenedit_pathis 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
/postform'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|firstinpartials/entry-journal.html.twig); thehero_imagefield was removed and is not reintroduced. - Feed collection is
.published()— bothtrip.html.twigandhome.html.twigcollect 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-entryform submit, so it does not fire on an API delete; the Grav API'sPagesController::deleteclears the cache itself, so the delete path inherits cache-clearing from the API, not from cache-on-save. - Auth reuses the existing
site.logingate; 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: trueat 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
publishedtoggle 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: trueat 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-entryform viaoverwrite_mode: edit+ a hiddenedit_path; the plugin's edit branch is patched to preserve create. Setpageconfig.overwrite_mode: editonpost-form.mdunconditionally and add a hiddenedit_pathfield that is empty on create, populated on edit. Code check (feasibility + adversarial, confidence 100): inadd-page-by-form.phptheslug_field: date,titlecomputation lives only in theelse(non-edit) branch (~lines 550-602); underoverwrite_mode === 'edit'the slug is derived solely frombasename(dirname($form_data['edit_path']))(line 541, guarded byisset(), not!empty()). So with an empty/absentedit_paththe create path does not fall through toslug_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 bothedit_pathandfile_pathare empty it falls through to the existingslug_fieldcomputation (restoring create behavior). This patch is a required file of U1, not a deferred contingency. V1 verifies both branches (emptyedit_path→ fresh dated folder; populated → in-place). (Alternative considered and rejected for higher carrying cost: a separate edit-form page with its ownoverwrite_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
publishedtoggle 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 —
publishedbecomes a real form field, replacing the staticpagefrontmatter.published: true. Add apublishedtoggle to the blueprint (default1). Remove the staticpagefrontmatter.published: trueso 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.jsreads the param,GET /api/v1/pages<route>(session-auth,credentials: 'include'— the gpx-manager pattern), and populates each field + the hiddenedit_path+ thepublishedtoggle. 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-actionsplugin. The stockDELETE /api/v1/pages<route>has no trip-scope guard (PagesController::deletechecks only write-permission), which violates R6. A thin new plugin registers one route viaonApiRegisterRoutesthat: (a) requires the authenticated owner —grav.user.username == site.owner_username, not merely any login (the super-admintesteraccount 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 thegrav-api-integrationskill for theAbstractApiController+onApiRegisterRoutescontract. -
KTD6 — The save-path scope guard lives in
cache-on-save's existingonFormValidationProcessed. That handler already runs fornew-entry, resolvessite.active_trip, and injects the parent. Extend it: whenedit_pathis 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.mdpasses a prefix test whilebasename(dirname())targets a different entry (security-lens, confidence 75). Also assert owner identity (KTD8), consistent with the delete route. Throw aValidationException(fail closed) otherwise. Leave create (noedit_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 mapentriesarray 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.authenticatedis true for any login, including the super-admintesteraccount, 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). Addowner_usernametosite.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.twigand the home active-trip branch compute it and pass it intotrip-feed-col.html.twig, which passes it intoentry-journal.html.twig. Past-trip pages computefalse, 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):
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):
sequenceDiagram
participant U as Owner (browser)
participant C as Journal card
participant EA as entry-actions plugin (API route)
U->>C: click Delete
C->>C: swap to Cancel / Confirm delete
U->>C: Confirm delete
C->>EA: DELETE /api/v1/entry/<slug> (credentials: include)
EA->>EA: authenticated? target ∈ active-trip dailies?
alt authorized
EA->>EA: delete folder + clear cache
EA-->>C: 200 → remove card from DOM
else rejected
EA-->>C: 403 → restore Delete control + inline error
end
Implementation Units
U1. Blueprint: published field + enable edit mode
- Goal: Make the post form capable of editing in place and carrying publish state.
- Requirements: R1, R2, R4; KTD1, KTD3.
- Dependencies: none.
- Files:
user/pages/02.post/post-form.md;user/plugins/add-page-by-form/add-page-by-form.php(create-path patch, KTD1);user/config/site.yaml(owner_username, KTD8). - Approach: Set
pageconfig.overwrite_mode: edit. Add a hiddenedit_pathfield (empty default). Add apublishedtoggle field (default1, near the advanced fields). Remove the staticpagefrontmatter.published: trueso the field is authoritative (KTD3). Patch the plugin's edit branch (KTD1): in theif ($overwrite_mode === 'edit')block, when bothedit_pathandfile_pathare empty, fall through to the existingslug_field: date,titlecomputation from theelsebranch (factor it into a shared code path or duplicate the slug build) so create still writes a fresh dated folder. Addowner_usernametosite.yaml. - Patterns to follow: existing
force_connect/featuredtoggle fields in the same blueprint; hidden field viatype: hidden; the existingslug_fieldbuild inadd-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: editand an emptyedit_path→ a new dated folder is written (not the dailies container, not a 'slug empty' error),published: truein frontmatter. Covers V1. - Publish field write: submit with
publishedoff → frontmatter showspublished: 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.
- Create path preserved under edit mode: submit a new entry with
- Verification: posting a brand-new entry still works exactly as before the blueprint flipped to edit mode;
publishedvalue round-trips to frontmatter as a real boolean.
U2. Save-path active-trip scope guard (cache-on-save)
- 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 tonew-entry), whenedit_pathis 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 anullresolution or any../traversal segment (a raw string-prefix check is insufficient — see KTD6). Also assertgrav.user.username == site.owner_username(KTD8). ThrowValidationException(fail closed) otherwise. Leave create (noedit_path) untouched. - Execution note: test-first — add failing tests asserting both an out-of-scope
edit_pathand a traversaledit_path(/trips/<active>/dailies/../other/entry.md) are rejected before writing the guard. - Patterns to follow: the existing fail-closed
ValidationExceptionfor a missingactive_tripin the same method; the KTD5 shared scope-guard helper. - Test scenarios:
- Edit within active trip's dailies → guard passes, save proceeds.
- Edit with
edit_pathpointing outside active dailies (e.g. another trip, or/) →ValidationException, no page write. Covers V3. - Traversal
edit_paththat string-prefix-matches the active dailies but resolves elsewhere →ValidationException, no page write. Covers V3 (traversal branch). - Non-owner authenticated session (e.g.
tester) →ValidationException, no page write. - 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).
- Files:
user/themes/intotheeast/templates/trip.html.twig,user/themes/intotheeast/templates/home.html.twig. - Approach: Swap
.children.published()→grav.user.authenticated ? dailies_page.children : dailies_page.children.published()for the feed list only. Keep the mapentriesarray and stats inputs on a.published()-only list. Computeowner_can_edit(KTD8) and pass it intotrip-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 attrip.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.
U4. Card UI: Draft badge + Edit/Delete controls
- Goal: Render the badge and the owner controls on the journal card.
- Requirements: R1, R3, R5, R6; KTD8.
- Dependencies: U3 (provides
owner_can_editand 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) throughtrip-feed-colintoentry-journal. Inentry-journal.html.twig: whenentry.publishedis false, render a "Draft" badge in the header. Whenowner_can_edit, render an Edit link (/post?edit={{ entry.route }}&return={{ page.url|url_encode }}— thereturnparam 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). Carrydata-entry-routefor 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 viamake build-assets; never hand-editjs/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. ThenGET /api/v1/pages<route>(credentials: 'include'), populate title/date/content/lat/lng/city/country/weather/transport/featured/force_connect/published, set the hiddenedit_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 formh1to "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 thereturnURL 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 hiddenedit_path, thepublishedtoggle, 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 onecurl(session cookie) thatGET /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 ingpx-manager.html.twig; the existinginitValidationand field-setting helpers inpost-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, setsedit_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
returnsurface. 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.
- Loading state: on
- 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.
U6. Delete API route + card delete wiring (entry-actions plugin)
- 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 scriptuser/themes/intotheeast/js/src/feed-actions.js(rebuilt viamake build-assets) — the delete control lives inentry-journal.html.twig(rendered by the feed partial, not the/postpage), so it does not belong inpost-form.js(C3);plugins.txtnote only if GPM-managed (this is custom-in-repo, so not added toplugins.txt). - Approach: Register
DELETE /api/v1/entry/<slug>viaonApiRegisterRoutes. 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 anaria-liveregion — 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" viaaria-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-integrationskill (AbstractApiController,onApiRegisterRoutes, response/exception helpers);api.yamlsession-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).
- Delete targeting a non-active-trip / arbitrary page route → 403, nothing deleted. Covers V3 (delete branch).
- Traversal slug (
../) or slug containing/→ 400, nothing deleted. - Non-owner authenticated session (
tester) → 403, nothing deleted. - Unauthenticated delete request → 401/403, nothing deleted.
- 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.
- Requirements: R7; (M2).
- Dependencies: U5 (edit mode established). Milestone 2.
- Files:
user/themes/intotheeast/js/src/post-form.js; possibly theentry-actionsplugin 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
filesinit pointing at the entry media URLs). Re-enable the photos section for edit. - Patterns to follow: the existing FilePond init +
GravFilePondusage inpost-form.js; entry media URLs as rendered inentry-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.
U8. M2: Persist add / remove / reorder (cover = first)
- Goal: Save photo changes back to the entry.
- Requirements: R8, R9, R10; (M2).
- Dependencies: U7.
- Files:
user/themes/intotheeast/js/src/post-form.js,user/plugins/cache-on-save/cache-on-save.php(reorderPhotos),user/plugins/add-page-by-form/add-page-by-form.php(file-delete path). - Approach: On save, reconcile FilePond state to the
photo-1..Nscheme (drag order = cover order, reusing the existing rename convention). Route removals throughadd-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. VerifyreorderPhotosis reachable from the edit path. - Execution note: characterization-first — capture current
reorderPhotosbehavior before extending it to the edit path. - Patterns to follow: existing
photo-1..Nrename +reorderPhotos()incache-on-save; HEIC→JPEGbeforeAddFilehook inpost-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: editand noedit_path, posting a new entry writes a fresh dated folder identical to prior behavior — verified by the KTD1 plugin patch (emptyedit_path/file_pathfalls through toslug_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
publishedtoggle writes a real booleanpublished: true/falseto 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 plusedit_pathand 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: falseentry'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_secretrisk 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 tojs/post/post-form.js.
Risks & Dependencies
overwrite_mode: editcreate-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 toslug_fieldwhenedit_path/file_pathempty). V1 verifies the patched create path. Contingency if the patch proves unworkable: a dedicated edit-form page.- Empty
jwt_secretinapi.yaml(S1, security-lens).jwt_secret: ''alongsidejwt_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.loginsession 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-entrysubmit, 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'sSameSiteattribute; confirm it isLax/Strict. Document this dependency; revisit if CORS is ever loosened. - Owner account hygiene — the super-admin
testeraccount authenticates and, under a naivegrav.user.authenticatedgate, would gain full edit/delete rights; the owner-username gate (KTD8) closes this. Thetesteraccount should not ship to production.
Sources & Research
- Codebase (grounding for every KTD):
user/plugins/add-page-by-form/add-page-by-form.php(edit mode 537-545, delete path 121/715-718),user/plugins/cache-on-save/cache-on-save.php(parent injection + cache clear),user/pages/02.post/post-form.md(blueprint),user/themes/intotheeast/templates/trip.html.twig&home.html.twig(feed collection),partials/trip-feed-col.html.twig&partials/entry-journal.html.twig(card),user/themes/intotheeast/templates/gpx-manager.html.twig+user/plugins/api/api.yaml(session-auth API delete pattern),user/themes/intotheeast/js/src/main.js(filter bar). - Skills:
grav-api-integration(custom API route contract for theentry-actionsdelete endpoint). - Upstream: this artifact's own Product Contract (ce-brainstorm) and the ce-doc-review pass of 2026-07-04.