['onFormValidationProcessed', 0], // Priority -100 so this runs AFTER add-page-by-form's onFormProcessed // (priority 0) has created the page and copied the uploaded files — // we reorder those files, then clear the page-tree cache. 'onFormProcessed' => ['onFormProcessed', -100], ]; } /** * Server-authoritative active-trip parent injection. * * The post form no longer hardcodes `pageconfig.parent`; instead the write * target is derived from `site.active_trip` at submit time and injected into * the form data. add-page-by-form reads `$form->value()->toArray()['parent']` * (add-page-by-form.php:521) and honours it over any pageconfig/header parent. * * Fail closed: if `active_trip` is unset/empty we throw a ValidationException * so the `add_page` action never runs. Merely leaving `parent` unset is unsafe — * with `pageconfig.parent` removed, add-page-by-form's `getParentPage('')` * resolves to the /post page itself and the entry would silently land there. */ public function onFormValidationProcessed(Event $event): void { $form = $event['form']; if (!$form || $form->getName() !== 'new-entry') { return; } $activeTrip = $this->grav['config']->get('site.active_trip'); $activeTrip = is_string($activeTrip) ? trim($activeTrip) : ''; if ($activeTrip === '') { throw new ValidationException('No active trip is set — cannot post an entry. Set site.active_trip first.'); } $form->setData('parent', $this->resolveDailiesParent($activeTrip)); } /** * Normalise `active_trip` to its dailies container route. * * Accepts either a full route ("/trips/italy-2026-demo") or a bare slug * ("italy-2026-demo") and returns "/trips//dailies". */ private function resolveDailiesParent(string $activeTrip): string { $trip = trim($activeTrip, '/'); if (strpos($trip, 'trips/') !== 0) { $trip = 'trips/' . $trip; } return '/' . $trip . '/dailies'; } /** * The photo order the user arranged in the form, sent explicitly by * post-form.js as a JSON array of filenames in the dedicated * data[photo_order] input. FilePond does not re-sequence its own submitted * inputs on reorder, so this is the only reliable source of the drag order. * Read straight from $_POST as a top-level key (not under data[]), so Grav's * form never captures it and it never lands in the entry frontmatter. */ private function orderFromPost(): array { $raw = $_POST['photo_order'] ?? null; if (!is_string($raw) || $raw === '') { return []; } $decoded = json_decode($raw, true); if (!is_array($decoded)) { return []; } $names = []; foreach ($decoded as $name) { if (is_string($name) && $name !== '') { $names[] = basename(str_replace('\\', '/', $name)); } } return $names; } public function onFormProcessed(Event $event): void { $form = $event['form']; if (!$form || $form->getName() !== 'new-entry') { 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()); } $this->grav['cache']->deleteAll(); } /** * Rename the uploaded photos to photo-1..N in 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. */ private function reorderPhotos(): void { $names = $this->orderFromPost(); if (count($names) < 1) { return; // nothing uploaded } $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. $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 * dailies directory that contains all of the uploaded files. Matching by the * exact uploaded filenames avoids re-deriving add-page-by-form's slug logic. */ private function findEntryFolder(string $slug, array $names): ?string { $pagesRoot = rtrim(USER_DIR, '/\\') . '/pages'; $dailies = null; foreach (glob($pagesRoot . '/*trips*', GLOB_ONLYDIR) ?: [] as $tripsDir) { foreach (glob($tripsDir . '/*', GLOB_ONLYDIR) ?: [] as $tripDir) { $base = basename($tripDir); if ($base === $slug || preg_match('/(^|\.)' . preg_quote($slug, '/') . '$/', $base)) { $found = glob($tripDir . '/*dailies*', GLOB_ONLYDIR) ?: []; if ($found) { $dailies = $found[0]; break 2; } } } } if ($dailies === null) { return null; } foreach (glob($dailies . '/*', GLOB_ONLYDIR) ?: [] as $child) { $allPresent = true; foreach ($names as $name) { if (!is_file($child . DIRECTORY_SEPARATOR . $name)) { $allPresent = false; break; } } if ($allPresent) { return $child; } } return null; } }