refactor(photos): extract shared zero-padded PhotoRenumberer helper
Factor cache-on-save's renumberPhotos into a shared PhotoRenumberer class (Grav\Plugin\Shared), the single owner of the photo-NN naming invariant used by both the create/edit reconcile and the upcoming live reorder route, so their numbering can't diverge. Changes vs the old private method: - Zero-pads to photo-01..NN (pad width grows with the set) so lexicographic media order equals numeric order past 9 photos — cover = images|first stays correct for 10+ photos. Normalises pre-existing un-padded photo-N on first pass. - Image-extension guard moved into the helper: only real image files on disk are renamed, so a crafted manifest naming the entry .md, a .gpx or a .meta.yaml is skipped by every caller, not just cache-on-save. Create-mode entries now also emit photo-01..NN — an intentional, accepted side effect of sharing one helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,15 +6,17 @@ use Grav\Common\Plugin;
|
|||||||
use RocketTheme\Toolbox\Event\Event;
|
use RocketTheme\Toolbox\Event\Event;
|
||||||
|
|
||||||
require_once __DIR__ . '/classes/EntryScopeGuard.php';
|
require_once __DIR__ . '/classes/EntryScopeGuard.php';
|
||||||
|
require_once __DIR__ . '/classes/PhotoRenumberer.php';
|
||||||
|
|
||||||
use Grav\Plugin\Shared\EntryScopeGuard;
|
use Grav\Plugin\Shared\EntryScopeGuard;
|
||||||
|
use Grav\Plugin\Shared\PhotoRenumberer;
|
||||||
|
|
||||||
class CacheOnSavePlugin extends Plugin
|
class CacheOnSavePlugin extends Plugin
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* onFormProcessed fires once per `process:` action (add_page, upload, message,
|
* onFormProcessed fires once per `process:` action (add_page, upload, message,
|
||||||
* reset — 4x for the post form). Photo reconciliation must run exactly once:
|
* 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 first pass renames the kept photos to photo-01..NN, so a second pass with
|
||||||
* the same manifest would see those renamed files as "unlisted" and delete
|
* 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
|
* 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
|
* request); the first fire is the `add_page` action, after add-page-by-form
|
||||||
@@ -244,7 +246,7 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->renumberPhotos($dir, $names);
|
PhotoRenumberer::renumber($dir, $names);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,38 +278,6 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
$src = $dir . DIRECTORY_SEPARATOR . $name;
|
|
||||||
if (!is_file($src)) {
|
|
||||||
continue; // skip anything not actually on disk
|
|
||||||
}
|
|
||||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)) ?: 'jpg';
|
|
||||||
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $i . '.' . $ext;
|
|
||||||
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $i . '.' . $ext;
|
|
||||||
if ($src === $final) {
|
|
||||||
$i++;
|
|
||||||
continue; // already correctly named
|
|
||||||
}
|
|
||||||
@rename($src, $tmp);
|
|
||||||
$planned[] = [$tmp, $final];
|
|
||||||
$i++;
|
|
||||||
}
|
|
||||||
foreach ($planned as [$tmp, $final]) {
|
|
||||||
if (is_file($tmp)) {
|
|
||||||
@rename($tmp, $final);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locate the freshly-created entry folder: the child of the active trip's
|
* Locate the freshly-created entry folder: the child of the active trip's
|
||||||
* dailies directory that contains all of the uploaded files. Matching by the
|
* dailies directory that contains all of the uploaded files. Matching by the
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
namespace Grav\Plugin\Shared;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the `photo-NN` naming invariant, shared by BOTH
|
||||||
|
* paths that establish it so their numbering can never diverge:
|
||||||
|
* - cache-on-save's create/edit reconcile (onFormProcessed), and
|
||||||
|
* - entry-actions' live reorder route (POST /entry/{slug}/photos/order).
|
||||||
|
*
|
||||||
|
* Files named in the ordered manifest are renamed to `photo-01..NN` (ZERO-PADDED)
|
||||||
|
* in that order. Zero-padding is load-bearing: the published feed lists media in
|
||||||
|
* filename order and treats the first as the cover (entry.media.images|first), and
|
||||||
|
* lexicographic order only equals numeric order past 9 photos when the index is
|
||||||
|
* padded (otherwise photo-1, photo-10, photo-2…). The pad width grows with the
|
||||||
|
* set so it stays correct for 100+ photos, while normal entries get `photo-01`.
|
||||||
|
*
|
||||||
|
* Safety: only files that are ON DISK and carry a known IMAGE extension are ever
|
||||||
|
* renamed. A crafted manifest entry naming the entry `.md`, a `.gpx`, or a
|
||||||
|
* `.meta.yaml` sidecar is silently skipped — it can never be renamed or clobbered.
|
||||||
|
* This guard lives here (not only in the callers) so every caller inherits it.
|
||||||
|
*/
|
||||||
|
class PhotoRenumberer
|
||||||
|
{
|
||||||
|
/** Image extensions eligible for renumbering. Broad on purpose: existing
|
||||||
|
* entries may hold .heic even though new uploads are jpg/jpeg/png/webp. */
|
||||||
|
private const IMAGE_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename the image files named in $names to photo-01..NN in that order, in $dir.
|
||||||
|
*
|
||||||
|
* Two-phase via temp names so a target (photo-02.jpg) can't clobber a
|
||||||
|
* not-yet-moved source of the same name (e.g. a straight swap or the
|
||||||
|
* un-padded photo-N → photo-0N normalisation pass). Non-image and missing
|
||||||
|
* files in $names are skipped and do not consume an index, so the surviving
|
||||||
|
* images are always numbered contiguously from 01.
|
||||||
|
*
|
||||||
|
* Idempotent: a file already at its correct padded name is left untouched,
|
||||||
|
* so re-running with the same manifest (e.g. an auto-retried reorder) is a
|
||||||
|
* no-op.
|
||||||
|
*/
|
||||||
|
public static function renumber(string $dir, array $names): void
|
||||||
|
{
|
||||||
|
// Keep only real image files, in the requested order — this is both the
|
||||||
|
// security filter and what determines the pad width.
|
||||||
|
$targets = [];
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if (!is_string($name) || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$base = basename(str_replace('\\', '/', $name));
|
||||||
|
$src = $dir . DIRECTORY_SEPARATOR . $base;
|
||||||
|
if (!is_file($src)) {
|
||||||
|
continue; // not on disk — skip (idempotent for auto-retry)
|
||||||
|
}
|
||||||
|
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, self::IMAGE_EXTS, true)) {
|
||||||
|
continue; // never rename the entry .md, a .gpx, or a sidecar
|
||||||
|
}
|
||||||
|
$targets[] = [$src, $ext ?: 'jpg'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = max(2, strlen((string) count($targets)));
|
||||||
|
|
||||||
|
$planned = [];
|
||||||
|
$i = 1;
|
||||||
|
foreach ($targets as [$src, $ext]) {
|
||||||
|
$index = str_pad((string) $i, $width, '0', STR_PAD_LEFT);
|
||||||
|
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $index . '.' . $ext;
|
||||||
|
if ($src === $final) {
|
||||||
|
$i++;
|
||||||
|
continue; // already correctly named — leave it
|
||||||
|
}
|
||||||
|
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $i . '.' . $ext;
|
||||||
|
@rename($src, $tmp);
|
||||||
|
$planned[] = [$tmp, $final];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
foreach ($planned as [$tmp, $final]) {
|
||||||
|
if (is_file($tmp)) {
|
||||||
|
@rename($tmp, $final);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user