['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], // Fired by the api plugin (PagesController) on every Admin2-driven // create/update/translate, right before $page->save(). // onFormValidationProcessed (above) does not fire for Admin2 saves, // so BOTH hooks are needed to cover every path lat/lng can arrive // through: this one for Admin2/API, that one for the public /post // form. (Until the location-override panel shipped, the public // form's lat/lng inputs were CSS-hidden and GPS-filled, making // Admin2 the only realistic hand-entry route. They are now visible // and directly editable, so the /post path is the primary one.) 'onAdminSave' => ['onAdminSave', 0], ]; } /** * 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)); $this->sanitizeCoordinates($form); // 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'; } /** * Strip invisible Unicode formatting characters (bidi marks, zero-width * joiners, etc. — Unicode category "Cf") from a pasted lat/lng value, then * validate the result is a real coordinate. * * Root cause this guards against: coordinates copied from a maps app can * carry an invisible mark (e.g. U+200E LEFT-TO-RIGHT MARK) that neither * JS `.trim()` nor PHP's numeric-string coercion strip. Twig's * `number_format` filter then silently float-casts the corrupted string * to 0.0 (PHP does not raise a warning), placing the entry at Null Island * instead of failing loudly. * * Coordinates are optional (some entries intentionally have none), so a * blank value cleans to '' with no error — only a non-blank value that * still fails to parse as an in-range decimal after cleaning is rejected. * * @return string the cleaned value to write back * @throws ValidationException if non-blank but still invalid after cleaning */ private function cleanCoordinate(string $field, string $raw, float $bound): string { $clean = trim(preg_replace('/\p{Cf}/u', '', $raw) ?? $raw); if ($clean === '') { return $clean; } if (!is_numeric($clean) || abs((float) $clean) > $bound) { throw new ValidationException(sprintf( '%s "%s" is not a valid coordinate — check for stray characters from pasting.', $field === 'lat' ? 'Latitude' : 'Longitude', $raw )); } return $clean; } /** Post-form entry point (see cleanCoordinate) — 'lat'/'lng' as top-level form fields. */ private function sanitizeCoordinates($form): void { foreach (['lat' => 90.0, 'lng' => 180.0] as $field => $bound) { $raw = $form->value($field); if (!is_string($raw)) { continue; } $form->setData($field, $this->cleanCoordinate($field, $raw, $bound)); } } /** * Admin2/API entry point (see cleanCoordinate) — 'header.lat'/'header.lng' * on the Page object the api plugin is about to save. Fires on every * create/update/translate (PagesController::create/update/translatePage), * so this also re-validates already-clean values on every subsequent edit * — harmless, since a clean value round-trips unchanged. */ public function onAdminSave(Event $event): void { $page = $event['page'] ?? $event['object'] ?? null; if (!$page instanceof PageInterface) { return; } $header = $page->header(); if (!$header) { return; } foreach (['lat' => 90.0, 'lng' => 180.0] as $field => $bound) { $raw = $header->{$field} ?? null; if (!is_string($raw)) { continue; // unset, null, or already a native number — nothing to clean } $header->{$field} = $this->cleanCoordinate($field, $raw, $bound); } } /** * 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()); } } // Two-part invalidation, latched to run ONCE per submit (see // $cacheInvalidated) — the 4 process actions would otherwise repeat it. // deleteAll() drops the Doctrine store (the tracker feed page cache etc.), // but the page-tree INDEX is keyed on // md5(dirs + folderHash + config->checksum() + lang) (Pages::buildRegularPages). // With cache.check.method:folder that index can survive a create — a fresh // entry then stays invisible to the API (GET /api/v1/pages{route} 404s), so // opening the just-posted entry for editing shows "this entry no longer // exists". invalidateCache() touches system.yaml, bumping config->checksum() // so the index key changes and the tree rebuilds on the next request. if (!$this->cacheInvalidated) { $this->cacheInvalidated = true; $this->grav['cache']->deleteAll(); Cache::invalidateCache(); $this->grav['log']->info('cache-on-save: cleared page cache + invalidated page-tree index after new-entry submit'); } } /** * 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 } } PhotoRenumberer::renumber($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'); } } } /** * 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; } }