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
|
||||
{
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return [
|
||||
@@ -161,50 +172,117 @@ class CacheOnSavePlugin extends Plugin
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder the just-copied photos to match the order the user arranged in
|
||||
// the form (FilePond drag). Best-effort: any failure logs and is skipped
|
||||
// so a post is never lost over cosmetics.
|
||||
try {
|
||||
$this->reorderPhotos();
|
||||
} catch (\Throwable $e) {
|
||||
$this->grav['log']->warning('cache-on-save: photo reorder skipped — ' . $e->getMessage());
|
||||
// Reconcile the entry's photos to the order the owner arranged in the form
|
||||
// (FilePond drag). On create this only renumbers the just-copied uploads;
|
||||
// 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 {
|
||||
$this->reconcilePhotos($form);
|
||||
} catch (\Throwable $e) {
|
||||
$this->grav['log']->warning('cache-on-save: photo reconcile skipped — ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$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 hero (see partials/entry-journal + entry-story), so a deterministic
|
||||
* photo-N naming is what makes the arranged order stick. copyFiles() writes
|
||||
* each file under its unsanitised client filename, and post-form.js sends the
|
||||
* drag order via the top-level `photo_order` POST key (orderFromPost) — so we
|
||||
* can map each on-disk file to its final photo-N slot.
|
||||
* the hero/cover (see partials/entry-journal + entry-story), so a deterministic
|
||||
* photo-N naming is what makes the arranged order stick. post-form.js sends the
|
||||
* final ordered set via the top-level `photo_order` POST key (orderFromPost):
|
||||
* on create these are the just-uploaded client filenames; on edit (M2) the mix
|
||||
* 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();
|
||||
if (count($names) < 1) {
|
||||
return; // nothing uploaded
|
||||
return; // nothing submitted — leave the entry's photos untouched
|
||||
}
|
||||
|
||||
$activeTrip = $this->grav['config']->get('site.active_trip');
|
||||
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
||||
if ($activeTrip === '') {
|
||||
return;
|
||||
}
|
||||
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
|
||||
$slug = preg_replace('#/.*$#', '', $slug);
|
||||
|
||||
$dir = $this->findEntryFolder($slug, $names);
|
||||
if ($dir === null) {
|
||||
return; // couldn't confidently locate the new entry folder
|
||||
$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 = is_string($activeTrip) ? trim($activeTrip) : '';
|
||||
if ($activeTrip === '') {
|
||||
return;
|
||||
}
|
||||
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
|
||||
$slug = preg_replace('#/.*$#', '', $slug);
|
||||
$dir = $this->findEntryFolder($slug, $names);
|
||||
if ($dir === null) {
|
||||
return; // couldn't confidently locate the new entry folder
|
||||
}
|
||||
}
|
||||
|
||||
// Two-phase rename via temp names so a target (photo-2.jpg) can't clobber
|
||||
// a not-yet-moved source of the same name.
|
||||
$this->renumberPhotos($dir, $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = [];
|
||||
$i = 1;
|
||||
foreach ($names as $name) {
|
||||
|
||||
Reference in New Issue
Block a user