diff --git a/bin/promote-legacy-images.php b/bin/promote-legacy-images.php new file mode 100644 index 0000000..261a1ed --- /dev/null +++ b/bin/promote-legacy-images.php @@ -0,0 +1,208 @@ +` thumbnail (matching the editor + * preview convention from UploadEndpoint::ensureThumbnail). + * 5. Strip the entry from `Item.data.images` and save the item. + * + * Idempotency: skips items where a file row with the same + * (itemId, fieldId, name) already exists. + * + * Usage: + * php bin/promote-legacy-images.php + * php bin/promote-legacy-images.php --dry-run + * php bin/promote-legacy-images.php --db=/path/to/other.db + */ + +require __DIR__ . '/../vendor/autoload.php'; + +use Imanager\Domain\File; +use Imanager\Domain\Item; +use Imanager\Files\FileStorage; +use Imanager\Files\ImageProcessor; +use Imanager\Storage\CategoryRepository; +use Imanager\Storage\FieldRepository; +use Imanager\Storage\FileRepository; +use Imanager\Storage\ItemRepository; +use Scriptor\Boot\ImanagerBootstrap; + +$dryRun = false; +$dbPath = null; +foreach ($argv as $arg) { + if ($arg === '--dry-run') { + $dryRun = true; + } elseif (str_starts_with($arg, '--db=')) { + $dbPath = substr($arg, 5); + } +} + +$paths = $dbPath !== null ? ['databasePath' => $dbPath] : []; +$container = ImanagerBootstrap::create(__DIR__ . '/..', $paths); + +$categories = $container->get(CategoryRepository::class); +$fields = $container->get(FieldRepository::class); +$items = $container->get(ItemRepository::class); +$files = $container->get(FileRepository::class); +$storage = $container->get(FileStorage::class); +$processor = $container->get(ImageProcessor::class); + +$pages = $categories->findBySlug('pages'); +if ($pages === null || $pages->id === null) { + fwrite(STDERR, "No 'pages' category\n"); + exit(1); +} +$field = $fields->findByName($pages->id, 'images'); +if ($field === null || $field->id === null) { + fwrite(STDERR, "No 'images' field on Pages category\n"); + exit(1); +} +$fieldId = (int) $field->id; + +$total = $items->countByCategory($pages->id); +$promoted = 0; +$missing = 0; +$dupes = 0; +$itemsTouched = 0; + +echo "Scanning {$total} pages" . ($dryRun ? ' (dry-run)' : '') . "\n"; +echo str_repeat('-', 70) . "\n"; + +$batch = 100; +for ($offset = 0; $offset < $total; $offset += $batch) { + foreach ($items->findByCategory($pages->id, $offset, $batch) as $item) { + $rawImages = $item->data->get('images'); + if (! is_array($rawImages) || $rawImages === []) { + continue; + } + + $existingNames = []; + foreach ($files->findByItemAndField($item->id, $fieldId) as $f) { + $existingNames[$f->name] = true; + } + + $promotedHere = 0; + $remaining = []; + + foreach ($rawImages as $img) { + if (! is_array($img)) { + $remaining[] = $img; + continue; + } + $name = (string) ($img['name'] ?? ''); + $dir = ltrim((string) ($img['path'] ?? ''), '/'); + $dir = preg_replace('#^data/uploads(-2\.0)?/#', '', $dir) ?? $dir; + $dir = trim($dir, '/'); + if ($name === '') { + $remaining[] = $img; + continue; + } + $relPath = ($dir !== '' ? $dir . '/' : '') . $name; + + if (isset($existingNames[$name])) { + $dupes++; + printf(" item #%-3d skip dupe: %s\n", $item->id, $name); + continue; + } + if (! $storage->exists($relPath)) { + $missing++; + printf(" item #%-3d MISSING on disk: %s\n", $item->id, $relPath); + $remaining[] = $img; + continue; + } + + $absPath = $storage->absolutePath($relPath); + $size = (int) (filesize($absPath) ?: 0); + $mime = (string) (mime_content_type($absPath) ?: 'application/octet-stream'); + $width = 0; + $height = 0; + if (str_starts_with($mime, 'image/')) { + $dims = $processor->dimensions($absPath); + $width = $dims['width']; + $height = $dims['height']; + } + + $title = (string) ($img['title'] ?? ''); + $position = (int) ($img['position'] ?? 0); + + $file = new File( + id: null, + itemId: (int) $item->id, + fieldId: $fieldId, + name: $name, + path: $relPath, + mime: $mime, + size: $size, + width: $width, + height: $height, + position: $position, + created: time(), + title: $title, + ); + + printf(" item #%-3d promote: %-50s %dx%d %d bytes\n", + $item->id, + mb_strimwidth($name, 0, 50, '…'), + $width, $height, $size, + ); + + if (! $dryRun) { + $files->save($file); + + $thumbRel = ($dir !== '' ? $dir . '/' : '') . 'thumbnail/300x300_' . $name; + if (! $storage->exists($thumbRel)) { + try { + $bytes = $processor->thumbnail($absPath, 300, 300); + $storage->write($thumbRel, $bytes); + } catch (\Throwable $e) { + fprintf(STDERR, " thumbnail failed for %s: %s\n", $relPath, $e->getMessage()); + } + } + } + $promoted++; + $promotedHere++; + } + + if ($promotedHere === 0) { + continue; + } + + $itemsTouched++; + + if (! $dryRun) { + $newData = $item->data->with('images', $remaining); + $items->save(new Item( + id: $item->id, + categoryId: $item->categoryId, + name: $item->name, + label: $item->label, + position: $item->position, + active: $item->active, + data: $newData, + created: $item->created, + updated: time(), + )); + } + } +} + +echo str_repeat('-', 70) . "\n"; +echo $dryRun + ? "Dry-run: {$promoted} would be promoted across {$itemsTouched} items, {$dupes} skipped (dupe), {$missing} missing.\n" + : "Done: {$promoted} promoted across {$itemsTouched} items, {$dupes} skipped (dupe), {$missing} missing.\n"; diff --git a/boot/Editor/Api/UploadEndpoint.php b/boot/Editor/Api/UploadEndpoint.php index 8c6febf..f939d70 100644 --- a/boot/Editor/Api/UploadEndpoint.php +++ b/boot/Editor/Api/UploadEndpoint.php @@ -14,6 +14,7 @@ use Imanager\Storage\FileRepository; use Imanager\Validation\Sanitizer as ImanagerSanitizer; use Scriptor\Boot\Editor\Editor; +use Scriptor\Boot\Files\DirectoryCleanup; /** * Phase 14d-1 upload endpoint — JSON API mounted at `/editor/api/upload`, @@ -38,6 +39,9 @@ * tokenName, tokenValue * Response 200: {"status": "ok"} * + * Captions / titles do not have an endpoint here: they live on the + * page form and travel with the page-save POST as `image_titles[]`. + * * Auth gate sits one level up in EditorRouter (anonymous requests get * 302'd to /editor/auth/ before reaching here). CSRF is enforced * locally for both verbs because FilePond posts JSON-friendly form @@ -57,7 +61,6 @@ public function handle(string $method): never { match (strtoupper($method)) { 'POST' => $this->handlePost(), - 'PATCH' => $this->handlePatch(), 'DELETE' => $this->handleDelete(), default => $this->error(405, 'Method not allowed'), }; @@ -137,40 +140,6 @@ private function ensureThumbnail(string $sourceRel, int $width, int $height): ?s return $thumbRel; } - /** - * Update the title (caption / alt text) of an existing file row. - * Body fields (form-urlencoded; PHP doesn't populate `$_POST` for - * PATCH so the body is parsed via {@see parseBody()}): - * - * fileId file id to update - * title new title; empty string clears the caption - * tokenName, tokenValue - * - * Returns 200 `{"status":"ok","title":"..."}` on success, - * 404 when the file id is unknown, 400 on bad input. - */ - private function handlePatch(): never - { - $body = self::parseBody(); - $token = (string) ($body['tokenName'] ?? $this->editor->input->getString('tokenName')); - $tokenV = (string) ($body['tokenValue'] ?? $this->editor->input->getString('tokenValue')); - $this->assertCsrf($token, $tokenV); - - $fileId = isset($body['fileId']) ? (int) $body['fileId'] : 0; - if ($fileId < 1) { - $this->error(400, 'fileId is required'); - } - $title = isset($body['title']) ? (string) $body['title'] : ''; - - $file = $this->files->find($fileId); - if ($file === null) { - $this->error(404, 'File not found'); - } - - $updated = $this->files->save($file->withTitle($title)); - $this->json(200, ['status' => 'ok', 'title' => $updated->title]); - } - private function handleDelete(): never { // PHP doesn't populate $_POST for DELETE bodies, so parse the @@ -198,18 +167,7 @@ private function handleDelete(): never $this->json(200, ['status' => 'gone']); } - // Best-effort cleanup of the matching thumbnail next door. - $thumbDir = \dirname($file->path) . '/thumbnail'; - if ($this->storage->exists($thumbDir)) { - // No bulk-delete on FileStorage; tolerate missing matches. - foreach (['300x300', '600x600', '1200x0', '800x350'] as $size) { - $thumb = $thumbDir . '/' . $size . '_' . $file->name; - if ($this->storage->exists($thumb)) { - $this->storage->delete($thumb); - } - } - } - $this->storage->delete($file->path); + DirectoryCleanup::purge($this->storage, $file->path); $this->files->delete($fileId); $this->json(200, ['status' => 'ok']); } diff --git a/boot/Editor/Pages/PagesModule.php b/boot/Editor/Pages/PagesModule.php index 458e3c9..69dd465 100644 --- a/boot/Editor/Pages/PagesModule.php +++ b/boot/Editor/Pages/PagesModule.php @@ -80,14 +80,20 @@ private function dispatchAction(string $action): void private function saveAction(): void { + // The new-page flow JS sets X-Requested-With when it has FilePond + // files staged: it needs the new page id back as JSON so it can + // upload each staged file against /editor/api/upload before + // navigating to the redirect URL. + $isXhr = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest'; + if (! $this->csrfPasses($this->editor->input->postString('tokenName'), $this->editor->input->postString('tokenValue'))) { - $this->editor->addMsg('error', $this->t('error_csrf_token_mismatch')); + $this->saveError($isXhr, $this->t('error_csrf_token_mismatch')); return; } $name = $this->editor->sanitizer->text(str_replace('"', '', $this->editor->input->postString('name'))); if ($name === '') { - $this->editor->addMsg('error', $this->t('error_page_title') ?: 'A page name is required.'); + $this->saveError($isXhr, $this->t('error_page_title') ?: 'A page name is required.'); return; } @@ -95,11 +101,11 @@ private function saveAction(): void $slugSource = $rawSlug !== '' ? $rawSlug : $name; $slug = preg_replace('/(-)\1+/', '$1', $this->editor->sanitizer->slug($slugSource)) ?? ''; if ($slug === '') { - $this->editor->addMsg('error', $this->t('error_page_name') ?: 'Page slug could not be derived.'); + $this->saveError($isXhr, $this->t('error_page_name') ?: 'Page slug could not be derived.'); return; } if (\in_array($slug, $this->reservedSlugs, true)) { - $this->editor->addMsg('error', $this->t('error_slug_reserved') ?: 'Slug is reserved.'); + $this->saveError($isXhr, $this->t('error_slug_reserved') ?: 'Slug is reserved.'); return; } @@ -114,7 +120,7 @@ private function saveAction(): void } if ($this->pages->slugTaken($slug, $parentId, $existing?->id())) { - $this->editor->addMsg('error', $this->t('error_page_title_exists') ?: 'A page with that slug already exists under this parent.'); + $this->saveError($isXhr, $this->t('error_page_title_exists') ?: 'A page with that slug already exists under this parent.'); return; } @@ -125,15 +131,13 @@ private function saveAction(): void $content = $this->editor->input->postString('content'); if ($content === '') { - $this->editor->addMsg('error', $this->t('error_page_content') ?: 'Page content is required.'); + $this->saveError($isXhr, $this->t('error_page_content') ?: 'Page content is required.'); return; } $template = $this->editor->sanitizer->templateName($this->editor->input->postString('template')); $active = $this->editor->input->postString('published') !== ''; - // Rebuild the data bag from the existing page so we don't drop - // image entries the legacy widget previously stored. $data = $existing !== null ? $this->existingDataMap($existing) : []; $data['slug'] = $slug; $data['parent'] = $parentId; @@ -142,24 +146,6 @@ private function saveAction(): void $data['template'] = $template; $data['pagetype'] = $data['pagetype'] ?? '1'; - // Legacy image titles: PagesModule::renderLegacyImageRow emits an - // input named `legacy_image_titles[]` per migrated image - // entry. Splice each posted value back into the matching slot in - // data.images so the page-save persists caption edits without - // touching anything else on the entry. - $rawTitles = $this->editor->input->post('legacy_image_titles'); - if (\is_array($rawTitles) && isset($data['images']) && \is_array($data['images'])) { - $images = $data['images']; - foreach ($rawTitles as $index => $title) { - $idx = (int) $index; - if (! isset($images[$idx]) || ! \is_array($images[$idx])) { - continue; - } - $images[$idx]['title'] = (string) $title; - } - $data['images'] = $images; - } - $now = time(); $item = new Item( id: $existing?->id(), @@ -176,12 +162,99 @@ private function saveAction(): void try { $saved = $this->pages->save($item); } catch (\Throwable $e) { - $this->editor->addMsg('error', $this->t('error_saving_page') ?: 'Saving failed: ' . $e->getMessage()); + $this->saveError($isXhr, $this->t('error_saving_page') ?: 'Saving failed: ' . $e->getMessage()); return; } + // Image titles travel with the page form (one input per file row, + // name="image_titles[]"). Apply each title onto the + // matching FileRepository row, scoped to this page's files so a + // tampered POST can't relabel files of another item. + $this->applyImageMetadata((int) $saved->id()); + + $redirect = $this->editor->siteUrl . '/pages/edit/?page=' . $saved->id(); + + if ($isXhr) { + // Skip flash so the JSON consumer doesn't strand a leftover + // success message on the next non-XHR navigation. + $this->jsonResponse([ + 'status' => 'ok', + 'pageId' => (int) $saved->id(), + 'redirect' => $redirect, + ]); + } + $this->editor->flashMsg('success', $this->t('successful_saved_page') ?: 'Page saved.'); - $this->redirect($this->editor->siteUrl . '/pages/edit/?page=' . $saved->id()); + $this->redirect($redirect); + } + + /** + * Persist `image_titles[]` and `image_positions[]` + * posted alongside the page form. Only files owned by `$pageId` + * are eligible — defensive scoping against forged ids. + */ + private function applyImageMetadata(int $pageId): void + { + if ($pageId < 1) { + return; + } + $titles = $this->editor->input->post('image_titles'); + $positions = $this->editor->input->post('image_positions'); + $hasTitles = \is_array($titles) && $titles !== []; + $hasPositions = \is_array($positions) && $positions !== []; + if (! $hasTitles && ! $hasPositions) { + return; + } + + $field = $this->fields->findByName($this->pages->categoryId, 'images'); + if ($field === null || $field->id === null) { + return; + } + + $owned = []; + foreach ($this->files->findByItemAndField($pageId, (int) $field->id) as $file) { + if ($file->id !== null) { + $owned[$file->id] = $file; + } + } + + if ($hasTitles) { + foreach ($titles as $rawId => $rawTitle) { + $fileId = (int) $rawId; + $file = $owned[$fileId] ?? null; + if ($file === null) { + continue; + } + $title = (string) $rawTitle; + if ($file->title === $title) { + continue; + } + $owned[$fileId] = $this->files->save($file->withTitle($title)); + } + } + + if ($hasPositions) { + foreach ($positions as $rawId => $rawPos) { + $fileId = (int) $rawId; + $file = $owned[$fileId] ?? null; + if ($file === null) { + continue; + } + $position = (int) $rawPos; + if ($file->position === $position) { + continue; + } + $owned[$fileId] = $this->files->save($file->withPosition($position)); + } + } + } + + private function saveError(bool $isXhr, string $message): void + { + if ($isXhr) { + $this->jsonResponse(['status' => 'error', 'error' => $message], 400); + } + $this->editor->addMsg('error', $message); } private function deleteAction(): void @@ -339,15 +412,7 @@ private function renderImagesSection(?Page $page): string ? '

' . $info . '

' : ''; - // Edit existing page → wire FilePond against the upload endpoint. - // New page → can't wire an upload (UploadHandler requires itemId>=1). - if ($page === null || $page->id() === null) { - return '
' . $infoBlock - . '

Save the page first to enable image upload.

'; - } - - $itemId = (int) $page->id(); - $field = $this->fields->findByName($this->pages->categoryId, 'images'); + $field = $this->fields->findByName($this->pages->categoryId, 'images'); if ($field === null || $field->id === null) { return '
' . $infoBlock . '

The Pages category has no images field — nothing to render.

'; @@ -355,36 +420,22 @@ private function renderImagesSection(?Page $page): string $fieldId = (int) $field->id; $token = $this->editor->csrf->token('pages'); - $files = $this->files->findByItemAndField($itemId, $fieldId); - $legacy = $page->images; - + // itemId 0 = new page; the JS picks deferred mode and uploads + // each staged file only after the page-save XHR returns the + // fresh page id. + $itemId = $page?->id() ?? 0; $existingRows = ''; - foreach ($files as $file) { - $existingRows .= $this->renderUploadedFileRow($file); - } - $legacyRows = ''; - foreach ($legacy as $index => $img) { - if (! \is_array($img)) { - continue; + if ($itemId > 0) { + $rowIndex = 0; + foreach ($this->files->findByItemAndField($itemId, $fieldId) as $file) { + $existingRows .= $this->renderUploadedFileRow($file, $rowIndex); + $rowIndex++; } - $legacyRows .= $this->renderLegacyImageRow($img, (int) $index); } - $existingBlock = $existingRows !== '' ? '
    ' . $existingRows . '
' : ''; - $legacyCount = substr_count($legacyRows, '
  • Migrated 1.x images on this page (' . $legacyCount . ')' - . ' — uploads above replace them on the frontend' - . '
      ' . $legacyRows . '
    ' - : ''; - // FilePond container + the metadata bag the init script needs. - // The script reads `data-*` attributes off this element, posts - // multipart uploads to the API, and stuffs the new fileId into a - // hidden input so the page form can render the up-to-date set - // after a redirect. $pondId = 'filepond-images-' . $itemId; $widget = sprintf( '', @@ -400,65 +451,10 @@ private function renderImagesSection(?Page $page): string . $infoBlock . $existingBlock . $widget - . $legacyBlock . ''; } - /** - * Renders a row for a migrated 1.x image entry (lives inside the - * item's `data.images` JSON array, not in the files table). The - * legacy `path` field points at `data/uploads//`; the - * migrator copied the assets to `data/uploads-2.0//`, - * so we rewrite the prefix here so the inline preview resolves. - * - * Display-only: editing/deleting these entries belongs to a - * follow-up sub-phase. Uploads above replace the legacy entry on - * the public site (BasicTheme::headlineImage). - * - * @param array $img - */ - private function renderLegacyImageRow(array $img, int $index): string - { - $name = (string) ($img['name'] ?? ''); - $title = (string) ($img['title'] ?? ''); - $path = (string) ($img['path'] ?? ''); - if ($name === '' || $path === '') { - return ''; - } - - // Map legacy `data/uploads/...` prefix onto the post-migration - // root; foreign / already-modern paths pass through untouched. - $clean = '/' . ltrim($path, '/'); - $clean = preg_replace('#^/data/uploads/#', '/data/uploads-2.0/', $clean) ?? $clean; - if (! str_ends_with($clean, '/')) { - $clean .= '/'; - } - $assetUrl = $clean . $name; - - $i = static fn(string $s): string => htmlspecialchars($s, \ENT_QUOTES); - - // Legacy titles persist back via the form's save-page action: - // the input's name encodes the array index so saveAction() can - // splice the new value into Item.data.images[$index].title. - $inputName = 'legacy_image_titles[' . $index . ']'; - - return '
  • ' - . '' - . '' . $i($name) . '' - . '' - . '
    ' - . '' . $i($name) . '' - . '
    ' - . '' - . 'saves with the page' - . '
    ' - . '
    ' - . '
  • '; - } - - private function renderUploadedFileRow(File $file): string + private function renderUploadedFileRow(File $file, int $rowIndex): string { $i = static fn(string $s): string => htmlspecialchars($s, \ENT_QUOTES); $thumbName = \sprintf('300x300_%s', $file->name); @@ -470,6 +466,8 @@ private function renderUploadedFileRow(File $file): string $token = $this->editor->csrf->token('pages'); $apiUrl = $this->editor->siteUrl . '/api/upload'; $id = (int) $file->id; + $titleField = 'image_titles[' . $id . ']'; + $positionField = 'image_positions[' . $id . ']'; return '
  • ' . '' @@ -480,14 +478,9 @@ private function renderUploadedFileRow(File $file): string . '(' . $file->width . 'x' . $file->height . ', ' . $file->size . ' bytes)' . '
    ' . '' - . '' - . '' + . ' value="' . $i($file->title) . '">' . '
    ' . '' . ' ' + // Order field — JS-sortable updates the value on drag-end so the + // next page-save persists the new order onto the matching File rows. + . '' . '
  • '; } @@ -587,7 +583,7 @@ private function wrapList(string $rows, string $token): string private function existingDataMap(Page $page): array { $out = []; - foreach (['slug', 'parent', 'pagetype', 'menu_title', 'content', 'template', 'images'] as $key) { + foreach (['slug', 'parent', 'pagetype', 'menu_title', 'content', 'template'] as $key) { if ($page->item->data->has($key)) { $out[$key] = $page->item->data->get($key); } @@ -629,8 +625,9 @@ private function t(string $key, array $vars = []): string /** * @param array $payload */ - private function jsonResponse(array $payload): never + private function jsonResponse(array $payload, int $status = 200): never { + http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($payload, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); exit; diff --git a/boot/Events/ItemFileCleanupListener.php b/boot/Events/ItemFileCleanupListener.php index 2a35d17..1f47007 100644 --- a/boot/Events/ItemFileCleanupListener.php +++ b/boot/Events/ItemFileCleanupListener.php @@ -7,6 +7,7 @@ use Imanager\Domain\Event\ItemDeleted; use Imanager\Files\FileStorage; use Imanager\Storage\FileRepository; +use Scriptor\Boot\Files\DirectoryCleanup; /** * Listener for `ItemDeleted` — wipes uploaded files (and their @@ -20,10 +21,10 @@ * disappear automatically with the cascade once delete() returns; * we only need to scrub the on-disk side. * - * Thumbnails are stored under `/thumbnail/x_` (the - * convention shared by Frontend\ImageUrlBuilder and the upload - * endpoint) — we walk the thumbnail directory so this works for any - * thumbnail size, not just the ones we currently generate. + * Asset bytes, every thumbnail variant, and the now-empty + * `//...` directory chain are scrubbed in one pass + * via {@see DirectoryCleanup::purge()} — same helper used by the + * per-file DELETE endpoint so both paths leave an identical state. */ final readonly class ItemFileCleanupListener { @@ -34,50 +35,8 @@ public function __construct( public function __invoke(ItemDeleted $event): void { - $files = $this->files->findByItem($event->itemId); - if ($files === []) { - return; - } - - foreach ($files as $file) { - // Delete the asset itself. - if ($this->storage->exists($file->path)) { - $this->storage->delete($file->path); - } - // Plus every thumbnail size that was ever generated for it. - $this->purgeThumbnails($file->path, $file->name); - } - } - - /** - * Walks `/thumbnail/` and removes every `x_` entry - * for the given filename. Tolerates a missing directory (no - * thumbnails generated yet) and unreadable / non-image siblings. - */ - private function purgeThumbnails(string $assetPath, string $name): void - { - $thumbDirRel = \dirname($assetPath) . '/thumbnail'; - $thumbDirAbs = $this->storage->absolutePath($thumbDirRel); - if (! is_dir($thumbDirAbs)) { - return; - } - $entries = scandir($thumbDirAbs); - if ($entries === false) { - return; - } - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - // Match `x_` exactly so we never delete a sibling - // file uploaded with a different stem. - if (! preg_match('/^\d+x\d+_/', $entry) || ! str_ends_with($entry, '_' . $name)) { - continue; - } - $thumbRel = $thumbDirRel . '/' . $entry; - if ($this->storage->exists($thumbRel)) { - $this->storage->delete($thumbRel); - } + foreach ($this->files->findByItem($event->itemId) as $file) { + DirectoryCleanup::purge($this->storage, $file->path); } } } diff --git a/boot/Files/DirectoryCleanup.php b/boot/Files/DirectoryCleanup.php new file mode 100644 index 0000000..6155be4 --- /dev/null +++ b/boot/Files/DirectoryCleanup.php @@ -0,0 +1,86 @@ +/thumbnail/x_` + * convention), then walks up the directory chain rmdir'ing each + * parent that's now empty. Stops before reaching the storage root — + * the root itself is never touched. + * + * Both the page-delete listener and the per-file DELETE endpoint + * funnel through here so empty `//...` directories + * don't accumulate after a page or its files are removed. + */ +final class DirectoryCleanup +{ + public static function purge(FileStorage $storage, string $assetPath): void + { + if ($storage->exists($assetPath)) { + $storage->delete($assetPath); + } + + $assetDir = \dirname($assetPath); + // Match thumbnails on the path's basename, not the `File.name` + // field — collision-free uploads (e.g. `foo-2.png` from a + // second upload of `foo.png`) keep `name=foo.png` but their + // thumbnails follow the path stem (`300x300_foo-2.png`). + self::purgeThumbnails($storage, $assetDir, \basename($assetPath)); + + // Walk up: rmdir each parent that became empty. Bails on the + // first non-empty dir, missing dir, or when we run out of + // path segments (dirname() returns "." once the relative + // path can't climb further). + $dir = $assetDir; + while ($dir !== '' && $dir !== '.' && $dir !== '/') { + $abs = $storage->absolutePath($dir); + if (! is_dir($abs)) { + break; + } + if (! @rmdir($abs)) { + break; + } + $dir = \dirname($dir); + } + } + + /** + * Walks `/thumbnail/` and removes every `x_` + * entry. Tolerates a missing directory (no thumbnails generated + * yet) and unrelated siblings. + */ + private static function purgeThumbnails(FileStorage $storage, string $assetDir, string $basename): void + { + $thumbDirRel = $assetDir . '/thumbnail'; + $thumbDirAbs = $storage->absolutePath($thumbDirRel); + if (! is_dir($thumbDirAbs)) { + return; + } + + $entries = scandir($thumbDirAbs); + if ($entries === false) { + return; + } + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + // Match `x_` exactly so we never delete a + // sibling file uploaded with a different stem. + if (! preg_match('/^\d+x\d+_/', $entry) || ! str_ends_with($entry, '_' . $basename)) { + continue; + } + $thumbRel = $thumbDirRel . '/' . $entry; + if ($storage->exists($thumbRel)) { + $storage->delete($thumbRel); + } + } + + @rmdir($thumbDirAbs); + } +} diff --git a/boot/Frontend/Page.php b/boot/Frontend/Page.php index 6ab2857..46a4792 100644 --- a/boot/Frontend/Page.php +++ b/boot/Frontend/Page.php @@ -26,8 +26,6 @@ public string $menu_title; public string $content; public int $parent; - /** @var list> */ - public array $images; public function __construct( public Item $item, @@ -40,9 +38,6 @@ public function __construct( $this->menu_title = self::str($data->get('menu_title')); $this->content = self::str($data->get('content')); $this->parent = (int) ($data->get('parent') ?? 0); - - $rawImages = $data->get('images'); - $this->images = \is_array($rawImages) ? array_values($rawImages) : []; } public function id(): ?int diff --git a/editor/theme/scripts/filepond-init.js b/editor/theme/scripts/filepond-init.js index 4ab67d5..79ce515 100644 --- a/editor/theme/scripts/filepond-init.js +++ b/editor/theme/scripts/filepond-init.js @@ -5,16 +5,25 @@ * Phase 14d-1 upload endpoint. Configuration comes from data-* attributes * the server emits next to the input: * - * data-itemid owning item id + * data-itemid owning item id (0 = new page; deferred mode) * data-fieldid owning field id * data-csrf-name CSRF token name (`pages` for the pages form) * data-csrf-value CSRF token value * data-upload-url POST/DELETE URL (typically /editor/api/upload) * - * The image-section already lists existing uploads server-side; FilePond - * only handles the new-upload flow plus the `revert` (cancel pending) - * action. The "remove" button on existing rows posts a separate DELETE - * to the same endpoint. + * Two operating modes: + * - itemId > 0 (existing page): files upload immediately when added. + * - itemId = 0 (new page): files stage in memory until form submit. + * The submit handler POSTs the page form via fetch with + * `X-Requested-With: XMLHttpRequest`, reads the new pageId from the + * JSON response, then runs `processFiles()` so each staged file + * uploads against the freshly created page. Once every upload + * resolves, the browser navigates to the redirect URL. + * + * Image titles (captions) live on the page form as + * `image_titles[]` inputs and save with the page — there is no + * per-image XHR. The "remove" button on existing rows posts a + * dedicated DELETE to the upload endpoint. */ (function () { 'use strict'; @@ -40,35 +49,54 @@ } document.querySelectorAll('input.filepond').forEach(function (input) { - var itemId = input.dataset.itemid; - var fieldId = input.dataset.fieldid; - var csrfName = input.dataset.csrfName; - var csrfValue = input.dataset.csrfValue; - var url = input.dataset.uploadUrl; + var widget = { + itemId: input.dataset.itemid || '0', + fieldId: input.dataset.fieldid, + csrfName: input.dataset.csrfName, + csrfValue: input.dataset.csrfValue, + url: input.dataset.uploadUrl + }; - if (!itemId || !fieldId || !csrfName || !csrfValue || !url) { + if (!widget.fieldId || !widget.csrfName || !widget.csrfValue || !widget.url) { return; } - FilePond.create(input, { + // Capture the parent
    *before* FilePond.create() runs. + // FilePond replaces the original with its own root element + // and detaches the input node, which would make a later + // `input.closest('form')` return null. + var form = input.closest('form'); + + var deferred = (widget.itemId === '0'); + + var pond = FilePond.create(input, { // Form field name used for the uploaded blob in the multipart // request. Default is "filepond"; we use "file" so the upload // endpoint stays aligned with the cURL smoke fixtures. name: 'file', allowMultiple: true, + instantUpload: !deferred, + // In deferred mode the per-file process button would post the + // upload against itemId=0 and the server would 400 — kill it. + // The X (remove-from-staging) button stays so users can take a + // file back out before submitting the form. + allowProcess: !deferred, acceptedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], maxFileSize: '8MB', labelIdle: 'Drop images here or Browse', server: { process: { - url: url, + url: widget.url, method: 'POST', withCredentials: true, ondata: function (formData) { - formData.append('itemId', itemId); - formData.append('fieldId', fieldId); - formData.append('tokenName', csrfName); - formData.append('tokenValue', csrfValue); + // widget.itemId is read live so the deferred-mode handler + // below can swap in the freshly created page id before + // triggering processFiles(). + formData.append('itemId', widget.itemId); + formData.append('fieldId', widget.fieldId); + formData.append('tokenName', widget.csrfName); + formData.append('tokenValue', widget.csrfValue); return formData; }, onload: function (response) { @@ -88,18 +116,38 @@ } }, revert: { - url: '', // FilePond appends the file id; we override with full url + url: '', method: 'DELETE', withCredentials: true, - // Use a custom function so we can send the CSRF + fileId in the - // form-encoded body the endpoint expects. onload: function () { return true; }, onerror: function () { return 'Revert failed'; } } } }); + + if (deferred && form) { + attachDeferredSubmit(form, pond, widget); + } }); + // jQuery-UI sortable on the existing-image list. On drag-end we + // renumber every `.image-list__position` hidden input to the row's + // new index — the next page-save persists the order onto the + // matching File.position fields. jQuery-UI is already loaded by + // editor.js for the page-list reorder, we just bind it here. + if (typeof window.jQuery !== 'undefined' && typeof window.jQuery.fn.sortable === 'function') { + var $ = window.jQuery; + $('.image-list--uploaded').sortable({ + items: '.image-list__item', + cursor: 'move', + update: function () { + $(this).children('.image-list__item').each(function (index) { + $(this).find('.image-list__position').val(index); + }); + } + }).disableSelection(); + } + // Existing-file remove buttons: server-rendered. POST a DELETE to the // upload endpoint with CSRF, then drop the row from the DOM. document.querySelectorAll('.image-list__remove').forEach(function (btn) { @@ -131,54 +179,50 @@ }); }); }); + }); - // Title-save buttons next to each modern file row: PATCHes the - // upload endpoint with the new caption. Status text appears - // briefly next to the input on success / failure. - document.querySelectorAll('.image-list__title-save').forEach(function (btn) { - btn.addEventListener('click', function (event) { - event.preventDefault(); - var fileId = btn.dataset.fileId; - if (!fileId) { return; } - - var item = btn.closest('.image-list__item'); - if (!item) { return; } - var input = item.querySelector('.image-list__title-input'); - var status = item.querySelector('.image-list__title-status'); - if (!input) { return; } + /** + * Hook the parent 's submit so a new page can be saved first + * (XHR returns the new pageId), then each staged file uploads against + * that id, then we navigate to the redirect URL the server returned. + * + * If FilePond has no files staged we fall through to the normal + * synchronous form submit — there is nothing to coordinate. + */ + function attachDeferredSubmit(form, pond, widget) { + form.addEventListener('submit', function (event) { + if (pond.getFiles().length === 0) { return; } + event.preventDefault(); - var url = input.dataset.patchUrl; - var csrfName = input.dataset.csrfName; - var csrfVal = input.dataset.csrfValue; - if (!url) { return; } + // FilePond's internal `` + // is inside the form, so a naive `new FormData(form)` would attach + // the staged file under `file` and resend it with every retry. We + // upload via processFiles() afterwards, so strip the field here. + var formData = new FormData(form); + formData.delete('file'); - var body = new URLSearchParams({ - fileId: fileId, - title: input.value, - tokenName: csrfName || '', - tokenValue: csrfVal || '' - }); - if (status) { status.textContent = 'saving…'; } + var redirect = null; - fetch(url, { - method: 'PATCH', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - credentials: 'same-origin', - body: body.toString() - }).then(function (res) { - if (res.ok) { - if (status) { - status.textContent = 'saved'; - setTimeout(function () { status.textContent = ''; }, 1500); - } - } else { - res.text().then(function (text) { - if (status) { status.textContent = 'failed'; } - console.error('Title save failed:', text); - }); - } - }); + fetch(form.action || window.location.href, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, + credentials: 'same-origin', + body: formData + }).then(function (res) { + return res.json().then(function (data) { return { ok: res.ok, data: data }; }); + }).then(function (result) { + if (!result.ok || !result.data || !result.data.pageId) { + throw new Error((result.data && result.data.error) || 'Save failed'); + } + widget.itemId = String(result.data.pageId); + redirect = result.data.redirect; + return pond.processFiles(); + }).then(function () { + window.location = redirect || (form.action || '/editor/pages/'); + }).catch(function (err) { + console.error(err); + alert('Save failed: ' + (err && err.message ? err.message : err)); }); }); - }); + } })(); diff --git a/site/themes/basic/lib/Basic.php b/site/themes/basic/lib/Basic.php index 1ee9584..9dc16f4 100644 --- a/site/themes/basic/lib/Basic.php +++ b/site/themes/basic/lib/Basic.php @@ -397,35 +397,26 @@ private function renderHero(): string private function headlineImage(Page $page): ?array { $itemId = $page->id(); - if ($itemId !== null) { - $field = $this->resolveImagesField(); - if ($field !== null) { - $files = $this->files->findByItemAndField($itemId, $field); - if ($files !== []) { - $first = $files[0]; - return [ - 'name' => $first->name, - // FileStorage paths are //; the - // ImageUrlBuilder rewrites `data/uploads/` legacy - // prefixes only, so prepend the 2.0 root explicitly. - 'path' => 'data/uploads-2.0/' . \dirname($first->path) . '/', - 'title' => $first->title, - 'position' => $first->position, - ]; - } - } + if ($itemId === null) { + return null; } - - // Fallback: migrated 1.x image entries embedded in item.data. - $legacy = $page->images[0] ?? null; - if (! \is_array($legacy) || ! isset($legacy['name'], $legacy['path'])) { + $field = $this->resolveImagesField(); + if ($field === null) { + return null; + } + $files = $this->files->findByItemAndField($itemId, $field); + if ($files === []) { return null; } + $first = $files[0]; return [ - 'name' => (string) $legacy['name'], - 'path' => (string) $legacy['path'], - 'title' => (string) ($legacy['title'] ?? ''), - 'position' => (int) ($legacy['position'] ?? 0), + 'name' => $first->name, + // FileStorage paths are storage-root-relative; the + // ImageUrlBuilder rewrites `data/uploads/` legacy prefixes + // only, so prepend the 2.0 root explicitly. + 'path' => 'data/uploads-2.0/' . \dirname($first->path) . '/', + 'title' => $first->title, + 'position' => $first->position, ]; }