['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)); // One shared /post form drives both create and edit (KTD1). add-page-by-form // reads overwrite_mode from the /post page header's pageconfig (not form // data), so we toggle it here per submit. $editPath = $this->editPathFromForm($form); if ($editPath === '') { // CREATE — left untouched (any site.login user): overwrite_mode:false // so stock add-page-by-form falls through to slug_field (date,title) // and writes a fresh dated folder. $this->setOverwriteMode('false'); return; } // EDIT — enforce R6 server-side (KTD6) BEFORE allowing an in-place write. // Fail closed (ValidationException) so the add_page action never runs. // The UI only renders Edit for the owner on the active trip, but that gate // is cosmetic; this is the authoritative check. if (!EntryScopeGuard::isOwner($this->grav)) { throw new ValidationException('You are not allowed to edit journal entries.'); } $segment = EntryScopeGuard::segmentFromEditPath($editPath); if (EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment) === null) { // Unsafe/traversal segment, no active trip, missing page, or a target // outside the active trip's dailies — all rejected identically. throw new ValidationException('That entry is not editable here — it is not in the active trip.'); } $this->setOverwriteMode('edit'); } /** * The hidden edit_path form field, trimmed. Empty string when creating a new * entry; the entry's `/entry.md` path when editing (set by post-form.js). */ private function editPathFromForm($form): string { $value = $form->value('edit_path'); return is_string($value) ? trim($value) : ''; } /** * Override add-page-by-form's overwrite_mode by mutating the current (/post) * page header's pageconfig. add-page-by-form reads * `$grav['page']->header()->pageconfig['overwrite_mode']` (add-page-by-form.php * :385) from the same page singleton, and Page::header() returns a cached * instance, so this write is visible when its onFormProcessed runs afterwards. */ private function setOverwriteMode(string $mode): void { $page = $this->grav['page'] ?? null; if (!$page) { return; } $header = $page->header(); $pageconfig = (isset($header->pageconfig) && is_array($header->pageconfig)) ? $header->pageconfig : []; $pageconfig['overwrite_mode'] = $mode; $header->pageconfig = $pageconfig; } /** * 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; } // 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(); } /** * 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/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 reconcilePhotos($form): void { $names = $this->orderFromPost(); if (count($names) < 1) { 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 = 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 } } $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) { $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; } }