feat(post-form): M2 photo edit — load, remove & reorder from the edit form
Editing an entry now loads its existing photos into FilePond so the owner can remove and reorder them; the first photo is the cover. Adding NEW photos on edit is intentionally suppressed (see below). post-form.js (U7): - On ?edit=, load the entry's current images into FilePond as LOCAL items (via the session media API, gpx-manager pattern). They display for remove/reorder and ride the existing photo_order manifest on submit, but are never re-uploaded. - Exclude the FilePond field from the D1 prefill disable-sweep — FilePond reads its input's disabled state at init and never re-enables, which had removed its controls in edit mode. - Suppress the add affordance in edit mode (allowBrowse/allowDrop off): a new upload on edit hits add-page-by-form's Grav-2.0 edit-merge fatal ((array)$page->header() yields mangled protected keys → array_merge(null,…)). That plugin is stock/GPM/git-ignored (no fork), so adding photos on edit is deferred to the form-to-page/image-upload rework. cache-on-save.php (U8): - reconcilePhotos(): on edit, resolve the entry folder via the shared scope guard (not the fuzzy create-path finder), delete any image dropped from the manifest, then renumber survivors photo-1..N in the submitted order (cover = first). - Run reconciliation ONCE per submit: onFormProcessed fires per process action (4×); a 2nd pass deleted the just-renamed photo-N files as "unlisted". - Empty manifest reconciles nothing (fail-safe: never wipes photos on a missing photo_order). Verified on the container: existing photos load (V9); remove + reorder persist to disk with cover=first (V10); reconcile helpers covered by a reflection unit test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,17 @@ use Grav\Plugin\Shared\EntryScopeGuard;
|
|||||||
|
|
||||||
class CacheOnSavePlugin extends Plugin
|
class CacheOnSavePlugin extends Plugin
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* onFormProcessed fires once per `process:` action (add_page, upload, message,
|
||||||
|
* reset — 4x for the post form). Photo reconciliation must run exactly once:
|
||||||
|
* the first pass renames the kept photos to photo-1..N, so a second pass with
|
||||||
|
* the same manifest would see those renamed files as "unlisted" and delete
|
||||||
|
* them. This latches after the first run (the plugin instance persists for the
|
||||||
|
* request); the first fire is the `add_page` action, after add-page-by-form
|
||||||
|
* (priority 0) has created the page and copied files, so files are present.
|
||||||
|
*/
|
||||||
|
private bool $photosReconciled = false;
|
||||||
|
|
||||||
public static function getSubscribedEvents(): array
|
public static function getSubscribedEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -161,35 +172,65 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reorder the just-copied photos to match the order the user arranged in
|
// Reconcile the entry's photos to the order the owner arranged in the form
|
||||||
// the form (FilePond drag). Best-effort: any failure logs and is skipped
|
// (FilePond drag). On create this only renumbers the just-copied uploads;
|
||||||
// so a post is never lost over cosmetics.
|
// on edit (M2) it also removes any photo the owner dropped and renumbers
|
||||||
|
// the surviving set so the first file is the cover. Runs ONCE per submit
|
||||||
|
// (see $photosReconciled) — a second pass would delete the just-renamed
|
||||||
|
// photo-N files as "unlisted". Best-effort: any failure logs and is
|
||||||
|
// skipped so a post is never lost over cosmetics.
|
||||||
|
if (!$this->photosReconciled) {
|
||||||
|
$this->photosReconciled = true;
|
||||||
try {
|
try {
|
||||||
$this->reorderPhotos();
|
$this->reconcilePhotos($form);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->grav['log']->warning('cache-on-save: photo reorder skipped — ' . $e->getMessage());
|
$this->grav['log']->warning('cache-on-save: photo reconcile skipped — ' . $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->grav['cache']->deleteAll();
|
$this->grav['cache']->deleteAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename the uploaded photos to photo-1..N in the submitted (drag) order.
|
* Reconcile the entry's photo files to the submitted (drag) order.
|
||||||
*
|
*
|
||||||
* The published entry lists media in filename order and treats the first as
|
* The published entry lists media in filename order and treats the first as
|
||||||
* the hero (see partials/entry-journal + entry-story), so a deterministic
|
* the hero/cover (see partials/entry-journal + entry-story), so a deterministic
|
||||||
* photo-N naming is what makes the arranged order stick. copyFiles() writes
|
* photo-N naming is what makes the arranged order stick. post-form.js sends the
|
||||||
* each file under its unsanitised client filename, and post-form.js sends the
|
* final ordered set via the top-level `photo_order` POST key (orderFromPost):
|
||||||
* drag order via the top-level `photo_order` POST key (orderFromPost) — so we
|
* on create these are the just-uploaded client filenames; on edit (M2) the mix
|
||||||
* can map each on-disk file to its final photo-N slot.
|
* of surviving existing photos (loaded into FilePond as local items) plus any
|
||||||
|
* new uploads, in the arranged order.
|
||||||
|
*
|
||||||
|
* Create: fuzzily locate the fresh folder by its uploaded filenames, then
|
||||||
|
* renumber. Edit: locate the folder authoritatively through the page tree via
|
||||||
|
* the shared scope guard (findEntryFolder is unsafe once files are the generic
|
||||||
|
* photo-N.jpg — many entries share those names), delete any image the owner
|
||||||
|
* dropped (not in the manifest), then renumber the survivors.
|
||||||
|
*
|
||||||
|
* Fail-safe: an empty manifest reconciles nothing (photos are left untouched),
|
||||||
|
* so a missing/failed `photo_order` on edit never wipes an entry's images.
|
||||||
*/
|
*/
|
||||||
private function reorderPhotos(): void
|
private function reconcilePhotos($form): void
|
||||||
{
|
{
|
||||||
$names = $this->orderFromPost();
|
$names = $this->orderFromPost();
|
||||||
if (count($names) < 1) {
|
if (count($names) < 1) {
|
||||||
return; // nothing uploaded
|
return; // nothing submitted — leave the entry's photos untouched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$editPath = $this->editPathFromForm($form);
|
||||||
|
if ($editPath !== '') {
|
||||||
|
// EDIT — resolve the target folder through the page tree (shared guard),
|
||||||
|
// then prune dropped photos before renumbering the survivors.
|
||||||
|
$segment = EntryScopeGuard::segmentFromEditPath($editPath);
|
||||||
|
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment);
|
||||||
|
if ($page === null) {
|
||||||
|
return; // out of scope / unresolvable — the save guard already ran
|
||||||
|
}
|
||||||
|
$dir = $page->path();
|
||||||
|
$this->deleteUnlistedImages($dir, $names);
|
||||||
|
} else {
|
||||||
|
// CREATE — locate the fresh folder by the set of uploaded filenames.
|
||||||
$activeTrip = $this->grav['config']->get('site.active_trip');
|
$activeTrip = $this->grav['config']->get('site.active_trip');
|
||||||
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
||||||
if ($activeTrip === '') {
|
if ($activeTrip === '') {
|
||||||
@@ -197,14 +238,51 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
}
|
}
|
||||||
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
|
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
|
||||||
$slug = preg_replace('#/.*$#', '', $slug);
|
$slug = preg_replace('#/.*$#', '', $slug);
|
||||||
|
|
||||||
$dir = $this->findEntryFolder($slug, $names);
|
$dir = $this->findEntryFolder($slug, $names);
|
||||||
if ($dir === null) {
|
if ($dir === null) {
|
||||||
return; // couldn't confidently locate the new entry folder
|
return; // couldn't confidently locate the new entry folder
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Two-phase rename via temp names so a target (photo-2.jpg) can't clobber
|
$this->renumberPhotos($dir, $names);
|
||||||
// a not-yet-moved source of the same name.
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every image file in $dir whose basename is not in $keep (the manifest
|
||||||
|
* of photos the owner kept). Only touches known image extensions — never the
|
||||||
|
* entry .md or any other file — and clears any Grav media sidecar so a stale
|
||||||
|
* `.meta.yaml` can't resurrect a removed image.
|
||||||
|
*/
|
||||||
|
private function deleteUnlistedImages(string $dir, array $keep): void
|
||||||
|
{
|
||||||
|
$keepSet = array_flip($keep);
|
||||||
|
$imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
|
||||||
|
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
|
||||||
|
if (!is_file($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$base = basename($path);
|
||||||
|
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, $imageExts, true)) {
|
||||||
|
continue; // never touch .md or non-image files
|
||||||
|
}
|
||||||
|
if (isset($keepSet[$base])) {
|
||||||
|
continue; // still in the arranged set — keep it
|
||||||
|
}
|
||||||
|
@unlink($path);
|
||||||
|
if (is_file($path . '.meta.yaml')) {
|
||||||
|
@unlink($path . '.meta.yaml');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename the files named in $names to photo-1..N in that order, in $dir.
|
||||||
|
* Two-phase via temp names so a target (photo-2.jpg) can't clobber a
|
||||||
|
* not-yet-moved source of the same name.
|
||||||
|
*/
|
||||||
|
private function renumberPhotos(string $dir, array $names): void
|
||||||
|
{
|
||||||
$planned = [];
|
$planned = [];
|
||||||
$i = 1;
|
$i = 1;
|
||||||
foreach ($names as $name) {
|
foreach ($names as $name) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -527,8 +527,9 @@ function initValidation() {
|
|||||||
var firstInvalid = null;
|
var firstInvalid = null;
|
||||||
|
|
||||||
// ≥1 photo (photos are the first field). Count any present FilePond item.
|
// ≥1 photo (photos are the first field). Count any present FilePond item.
|
||||||
// Skipped in edit mode (KTD9): photos are untouched in M1, so an empty
|
// Skipped in edit mode (U7): the edit form loads the entry's existing
|
||||||
// FilePond on an edit submit keeps the entry's existing images.
|
// photos, and the owner may deliberately remove all of them to leave a
|
||||||
|
// text-only entry — so an empty FilePond on an edit submit is allowed.
|
||||||
if (!EDIT_MODE && document.querySelectorAll('.filepond--item').length < 1) {
|
if (!EDIT_MODE && document.querySelectorAll('.filepond--item').length < 1) {
|
||||||
firstInvalid = showPhotoError('Add at least one photo.');
|
firstInvalid = showPhotoError('Add at least one photo.');
|
||||||
}
|
}
|
||||||
@@ -687,14 +688,21 @@ function initSuccessState() {
|
|||||||
notice.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
notice.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Edit mode (U5, KTD4/KTD9): prefill from the API, adapt the form ──────────
|
/* ── Edit mode (U5, KTD4; U7 photos): prefill from the API, adapt the form ─────
|
||||||
* The Edit control on a journal card links to /post?edit=<entry-route>. We detect
|
* The Edit control on a journal card links to /post?edit=<entry-route>. We detect
|
||||||
* that param, disable the form (D1), fetch the entry via the session-auth Grav
|
* that param, disable the form (D1), fetch the entry via the session-auth Grav
|
||||||
* API (credentials:include — the gpx-manager pattern), populate every field, set
|
* API (credentials:include — the gpx-manager pattern), populate every field, set
|
||||||
* the hidden edit_path so the save writes back in place (cache-on-save toggles
|
* the hidden edit_path so the save writes back in place (cache-on-save toggles
|
||||||
* overwrite_mode:edit server-side), hide the photos section and relax the
|
* overwrite_mode:edit server-side), and switch the chrome to "Edit entry" /
|
||||||
* >=1-photo rule (photos are untouched in M1 — an empty FilePond leaves existing
|
* "Save changes" (D6).
|
||||||
* images intact), and switch the chrome to "Edit entry" / "Save changes" (D6).
|
*
|
||||||
|
* Photos (M2/U7): the entry's existing images are loaded into FilePond as LOCAL
|
||||||
|
* items (already on the server — not re-uploaded on save) so the owner can drop,
|
||||||
|
* add and reorder them. On submit their filenames ride the same `photo_order`
|
||||||
|
* manifest as new uploads; cache-on-save reconciles the entry folder to match
|
||||||
|
* (delete dropped, renumber survivors photo-1..N, first = cover — U8). The
|
||||||
|
* >=1-photo rule stays relaxed in edit mode so an owner may leave a text-only
|
||||||
|
* entry after removing every photo.
|
||||||
*/
|
*/
|
||||||
var EDIT_MODE = false;
|
var EDIT_MODE = false;
|
||||||
|
|
||||||
@@ -726,9 +734,19 @@ function editSetContent(value) {
|
|||||||
|
|
||||||
// Disable/enable the form's own fields + submit (get-location/weather live
|
// Disable/enable the form's own fields + submit (get-location/weather live
|
||||||
// OUTSIDE the form, so they're untouched). Also gates the EasyMDE editor.
|
// OUTSIDE the form, so they're untouched). Also gates the EasyMDE editor.
|
||||||
|
//
|
||||||
|
// The photos/FilePond control is deliberately EXCLUDED: FilePond reads the
|
||||||
|
// disabled state of its underlying input when the form plugin creates it and
|
||||||
|
// never re-enables (removing its browse button, so the owner can't add photos on
|
||||||
|
// edit — U7). Photos are also safe to leave live during the D1 prefill window:
|
||||||
|
// they load additively (editLoadPhotos), not by overwrite, so early interaction
|
||||||
|
// can't be clobbered the way an empty text field could.
|
||||||
function editFormDisabled(form, disabled) {
|
function editFormDisabled(form, disabled) {
|
||||||
var els = form.querySelectorAll('input, textarea, select, button');
|
var els = form.querySelectorAll('input, textarea, select, button');
|
||||||
Array.prototype.forEach.call(els, function (el) { el.disabled = disabled; });
|
Array.prototype.forEach.call(els, function (el) {
|
||||||
|
if (el.type === 'file' || (el.name && el.name.indexOf('data[photos') === 0)) return;
|
||||||
|
el.disabled = disabled;
|
||||||
|
});
|
||||||
if (window.postFormEditor && window.postFormEditor.codemirror) {
|
if (window.postFormEditor && window.postFormEditor.codemirror) {
|
||||||
window.postFormEditor.codemirror.setOption('readOnly', disabled ? 'nocursor' : false);
|
window.postFormEditor.codemirror.setOption('readOnly', disabled ? 'nocursor' : false);
|
||||||
}
|
}
|
||||||
@@ -750,6 +768,53 @@ function editShowError(wrap, msg) {
|
|||||||
if (h1) h1.insertAdjacentElement('afterend', banner); else wrap.insertBefore(banner, wrap.firstChild);
|
if (h1) h1.insertAdjacentElement('afterend', banner); else wrap.insertBefore(banner, wrap.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poll for the managed FilePond instance (created by the form plugin's handler on
|
||||||
|
// DOMContentLoaded) — the same instance initPhotoConversion hooks — then run cb.
|
||||||
|
function editWaitForPond(cb, tries) {
|
||||||
|
tries = tries || 0;
|
||||||
|
var ponds = (window.GravFilePond && window.GravFilePond.getInstances)
|
||||||
|
? window.GravFilePond.getInstances() : [];
|
||||||
|
if (ponds.length && ponds[0]) { cb(ponds[0]); return; }
|
||||||
|
if (tries < 120) setTimeout(function () { editWaitForPond(cb, tries + 1); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// U7: load the entry's current photos into FilePond as LOCAL items. Local files
|
||||||
|
// already live on the server, so FilePond shows them for reorder/removal but does
|
||||||
|
// NOT re-upload them on save. Their filenames sort to folder order (first = cover)
|
||||||
|
// and ride the `photo_order` manifest on submit; cache-on-save reconciles the
|
||||||
|
// folder to match (U8). A broken media URL is skipped, never aborting the rest.
|
||||||
|
//
|
||||||
|
// Adding NEW photos on edit (R9) is intentionally suppressed here (allowBrowse /
|
||||||
|
// allowDrop off) — a new upload on the edit save goes through add-page-by-form's
|
||||||
|
// edit-mode file merge, which fatals on Grav 2.0 (`(array)$page->header()` yields
|
||||||
|
// mangled protected-property keys, so `$original_frontmatter['photos']` is never
|
||||||
|
// set → array_merge(null,…) TypeError). That's a stock, GPM-managed plugin we
|
||||||
|
// must not fork, so add-photo-on-edit is deferred to the form-to-page/image-upload
|
||||||
|
// rework. Remove + reorder (which never upload) work and are what M2 ships.
|
||||||
|
function editLoadPhotos(route) {
|
||||||
|
fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : { data: [] }; })
|
||||||
|
.then(function (json) {
|
||||||
|
var media = (json && json.data) || [];
|
||||||
|
var images = media.filter(function (m) {
|
||||||
|
return m && typeof m.filename === 'string' && /\.(jpe?g|png|gif|webp|heic|heif)$/i.test(m.filename);
|
||||||
|
}).sort(function (a, b) {
|
||||||
|
return a.filename < b.filename ? -1 : (a.filename > b.filename ? 1 : 0);
|
||||||
|
});
|
||||||
|
editWaitForPond(function (pond) {
|
||||||
|
// Keep remove + reorder; drop the add affordance (see note above).
|
||||||
|
try { pond.setOptions({ allowBrowse: false, allowDrop: false, allowReorder: true }); } catch (e) { /* older API */ }
|
||||||
|
images.forEach(function (m) {
|
||||||
|
try {
|
||||||
|
var p = pond.addFile(route + '/' + m.filename, { type: 'local' });
|
||||||
|
if (p && typeof p.catch === 'function') p.catch(function () { /* skip a broken URL */ });
|
||||||
|
} catch (e) { /* older FilePond API — skip */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { /* no existing photos loaded — owner can still save */ });
|
||||||
|
}
|
||||||
|
|
||||||
function initEditMode() {
|
function initEditMode() {
|
||||||
var form = document.querySelector('form[name="new-entry"]');
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
var wrap = document.querySelector('.post-form-wrap');
|
var wrap = document.querySelector('.post-form-wrap');
|
||||||
@@ -764,11 +829,9 @@ function initEditMode() {
|
|||||||
var submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
|
var submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
|
||||||
var origLabel = submitBtn ? (submitBtn.tagName === 'INPUT' ? submitBtn.value : submitBtn.textContent) : 'Save changes';
|
var origLabel = submitBtn ? (submitBtn.tagName === 'INPUT' ? submitBtn.value : submitBtn.textContent) : 'Save changes';
|
||||||
|
|
||||||
// KTD9: hide the photos section (photos untouched in M1). The >=1-photo rule
|
// U7: the photos section stays visible in edit mode — existing photos are
|
||||||
// is skipped while EDIT_MODE (see initValidation).
|
// loaded into FilePond below once the prefill resolves. The >=1-photo rule is
|
||||||
var photoField = form.querySelector('.photos-collapse') || form.querySelector('.filepond-root, .form-input-file');
|
// skipped while EDIT_MODE (see initValidation) so removing every photo is OK.
|
||||||
var photoWrapper = photoField ? (photoField.closest('.form-field') || photoField) : null;
|
|
||||||
if (photoWrapper) photoWrapper.style.display = 'none';
|
|
||||||
|
|
||||||
// D1: no typing before prefill lands — disable + loading label.
|
// D1: no typing before prefill lands — disable + loading label.
|
||||||
editFormDisabled(form, true);
|
editFormDisabled(form, true);
|
||||||
@@ -807,6 +870,8 @@ function initEditMode() {
|
|||||||
|
|
||||||
var more = form.querySelector('.more-options');
|
var more = form.querySelector('.more-options');
|
||||||
if (more) more.open = true; // reveal Published/Featured/Connector
|
if (more) more.open = true; // reveal Published/Featured/Connector
|
||||||
|
|
||||||
|
editLoadPhotos(route); // U7: pull the entry's existing photos into FilePond
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
// D7: inline error between heading and first field; keep the form
|
// D7: inline error between heading and first field; keep the form
|
||||||
|
|||||||
Reference in New Issue
Block a user