2386 lines
91 KiB
PHP
2386 lines
91 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Controllers;
|
|
|
|
use Grav\Common\Filesystem\Folder;
|
|
use Grav\Common\Grav;
|
|
use Grav\Common\Config\Config;
|
|
use Grav\Common\Language\Language;
|
|
use Grav\Common\Language\LanguageCodes;
|
|
use Grav\Common\Page\Interfaces\PageInterface;
|
|
use Grav\Common\Page\Page;
|
|
use Grav\Common\Page\PageOrdering;
|
|
use Grav\Framework\Flex\FlexDirectory;
|
|
use Grav\Common\User\Interfaces\UserInterface;
|
|
use Grav\Plugin\Api\Exceptions\ApiException;
|
|
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
|
use Grav\Plugin\Api\Exceptions\TwigContentForbiddenException;
|
|
use Grav\Plugin\Api\Exceptions\ValidationException;
|
|
use Grav\Plugin\Api\FlexBackend;
|
|
use Grav\Plugin\Api\Response\ApiResponse;
|
|
use Grav\Plugin\Api\Serializers\MediaSerializer;
|
|
use Grav\Plugin\Api\Serializers\PageSerializer;
|
|
use Grav\Plugin\Api\Services\ThumbnailService;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
class PagesController extends AbstractApiController
|
|
{
|
|
use FlexBackend;
|
|
private const PERMISSION_READ = 'api.pages.read';
|
|
private const PERMISSION_WRITE = 'api.pages.write';
|
|
|
|
private const ALLOWED_FILTERS = ['published', 'template', 'routable', 'visible', 'parent', 'children_of', 'root'];
|
|
private const ALLOWED_SORT_FIELDS = ['date', 'title', 'slug', 'modified', 'order', 'default'];
|
|
|
|
private readonly PageSerializer $serializer;
|
|
|
|
public function __construct(Grav $grav, Config $config)
|
|
{
|
|
parent::__construct($grav, $config);
|
|
$cacheDir = $grav['locator']->findResource('cache://') . '/api/thumbnails';
|
|
$thumbnailService = new ThumbnailService($cacheDir);
|
|
$baseUrl = '/' . trim($config->get('plugins.api.route', '/api'), '/') . '/' . $config->get('plugins.api.version_prefix', 'v1');
|
|
$mediaSerializer = new MediaSerializer($thumbnailService, $baseUrl);
|
|
$this->serializer = new PageSerializer($mediaSerializer);
|
|
}
|
|
|
|
/**
|
|
* GET /pages - List pages with filtering, sorting, and pagination.
|
|
*/
|
|
public function index(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_READ);
|
|
$previousLang = $this->applyLanguage($request);
|
|
|
|
try {
|
|
$directory = $this->getFlexDirectory('pages');
|
|
if ($directory) {
|
|
return $this->indexViaFlex($request, $directory);
|
|
}
|
|
return $this->indexViaPages($request);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List pages using the Flex-Objects backend (indexed, cached).
|
|
*/
|
|
private function indexViaFlex(ServerRequestInterface $request, FlexDirectory $directory): ResponseInterface
|
|
{
|
|
$filters = $this->getFilters($request, self::ALLOWED_FILTERS);
|
|
$sorting = $this->getSorting($request, self::ALLOWED_SORT_FIELDS);
|
|
$pagination = $this->getPagination($request);
|
|
$query = $request->getQueryParams();
|
|
$search = $query['search'] ?? null;
|
|
|
|
$sortField = $sorting['sort'] ?? 'date';
|
|
$sortOrder = $sorting['sort'] ? $sorting['order'] : 'desc';
|
|
|
|
// 'default' sort with children_of: use native page ordering
|
|
if ($sortField === 'default' && isset($filters['children_of'])) {
|
|
return $this->indexViaDefaultSort($request, $filters['children_of'], $filters, $pagination);
|
|
}
|
|
if ($sortField === 'default') {
|
|
$sortField = 'order';
|
|
$sortOrder = 'asc';
|
|
}
|
|
|
|
// Start with full collection
|
|
$collection = $directory->getCollection();
|
|
|
|
// Apply search
|
|
if ($search && $search !== '') {
|
|
$collection = $collection->search($search);
|
|
}
|
|
|
|
// Apply filters using flex methods where available
|
|
if (isset($filters['published'])) {
|
|
$bool = filter_var($filters['published'], FILTER_VALIDATE_BOOLEAN);
|
|
if (method_exists($collection, 'withPublished')) {
|
|
$collection = $collection->withPublished($bool);
|
|
}
|
|
}
|
|
if (isset($filters['visible'])) {
|
|
$bool = filter_var($filters['visible'], FILTER_VALIDATE_BOOLEAN);
|
|
if (method_exists($collection, 'withVisible')) {
|
|
$collection = $collection->withVisible($bool);
|
|
}
|
|
}
|
|
if (isset($filters['routable'])) {
|
|
$bool = filter_var($filters['routable'], FILTER_VALIDATE_BOOLEAN);
|
|
if (method_exists($collection, 'withRoutable')) {
|
|
$collection = $collection->withRoutable($bool);
|
|
}
|
|
}
|
|
|
|
// Template, parent, children_of, root — filter manually on the collection
|
|
if (isset($filters['template']) || isset($filters['parent']) || isset($filters['children_of']) || isset($filters['root'])) {
|
|
$filtered = [];
|
|
foreach ($collection as $page) {
|
|
if ($page instanceof PageInterface && $this->matchesFilters($page, $filters)) {
|
|
$filtered[$page->getKey()] = $page;
|
|
}
|
|
}
|
|
// Re-select from the collection to maintain the flex type
|
|
$collection = $collection->select(array_keys($filtered));
|
|
}
|
|
|
|
// Map sort fields to flex-compatible field names
|
|
$flexSortField = match ($sortField) {
|
|
'date' => 'date',
|
|
'modified' => 'timestamp',
|
|
'title' => 'title',
|
|
'slug' => 'slug',
|
|
'order' => 'order',
|
|
default => 'date',
|
|
};
|
|
$collection = $collection->sort([$flexSortField => $sortOrder]);
|
|
|
|
// Skip the virtual pages-root container (no file on disk). The home
|
|
// page IS a real file-backed page even though its route is '/'.
|
|
$items = [];
|
|
foreach ($collection as $page) {
|
|
if ($page instanceof PageInterface && $page->route() && $page->exists()) {
|
|
$items[] = $page;
|
|
}
|
|
}
|
|
|
|
$total = count($items);
|
|
$locatedAt = $this->applyLocate($items, $pagination, $query['locate'] ?? null);
|
|
$slice = array_slice($items, $pagination['offset'], $pagination['limit']);
|
|
|
|
$includeTranslations = filter_var(
|
|
$request->getQueryParams()['translations'] ?? false,
|
|
FILTER_VALIDATE_BOOLEAN
|
|
);
|
|
|
|
$listOptions = [
|
|
'include_content' => false,
|
|
'render_content' => false,
|
|
'include_children' => false,
|
|
'include_media' => false,
|
|
'include_translations' => $includeTranslations,
|
|
];
|
|
|
|
$data = $this->serializer->serializeCollection($slice, $listOptions);
|
|
|
|
return ApiResponse::paginated(
|
|
data: $data,
|
|
total: $total,
|
|
page: $pagination['page'],
|
|
perPage: $pagination['per_page'],
|
|
baseUrl: $this->getApiBaseUrl() . '/pages',
|
|
locatedAtIndex: $locatedAt,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* List pages using the regular Grav Pages service (fallback).
|
|
*/
|
|
private function indexViaPages(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->enablePages();
|
|
|
|
$filters = $this->getFilters($request, self::ALLOWED_FILTERS);
|
|
$sorting = $this->getSorting($request, self::ALLOWED_SORT_FIELDS);
|
|
$pagination = $this->getPagination($request);
|
|
|
|
$sortField = $sorting['sort'] ?? 'date';
|
|
$sortOrder = $sorting['sort'] ? $sorting['order'] : 'desc';
|
|
|
|
if ($sortField === 'default' && isset($filters['children_of'])) {
|
|
return $this->indexViaDefaultSort($request, $filters['children_of'], $filters, $pagination);
|
|
}
|
|
if ($sortField === 'default') {
|
|
$sortField = 'order';
|
|
$sortOrder = 'asc';
|
|
}
|
|
|
|
$pages = $this->grav['pages'];
|
|
$allPages = $this->collectAndFilterPages($pages->instances(), $filters);
|
|
$allPages = $this->sortPages($allPages, $sortField, $sortOrder);
|
|
|
|
$total = count($allPages);
|
|
$locatedAt = $this->applyLocate($allPages, $pagination, $request->getQueryParams()['locate'] ?? null);
|
|
$slice = array_slice($allPages, $pagination['offset'], $pagination['limit']);
|
|
|
|
$includeTranslations = filter_var(
|
|
$request->getQueryParams()['translations'] ?? false,
|
|
FILTER_VALIDATE_BOOLEAN
|
|
);
|
|
|
|
$listOptions = [
|
|
'include_content' => false,
|
|
'render_content' => false,
|
|
'include_children' => false,
|
|
'include_media' => false,
|
|
'include_translations' => $includeTranslations,
|
|
];
|
|
|
|
$data = $this->serializer->serializeCollection($slice, $listOptions);
|
|
|
|
return ApiResponse::paginated(
|
|
data: $data,
|
|
total: $total,
|
|
page: $pagination['page'],
|
|
perPage: $pagination['per_page'],
|
|
baseUrl: $this->getApiBaseUrl() . '/pages',
|
|
locatedAtIndex: $locatedAt,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* GET /pages/{route} - Get a single page by route.
|
|
*/
|
|
public function show(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_READ);
|
|
$previousLang = $this->applyLanguage($request);
|
|
|
|
try {
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$page = $this->findPageOrFail('/' . $route);
|
|
|
|
// If the page already has process.twig:true, the same gate that
|
|
// governs writes also governs reading the full record. Returning
|
|
// the editor view to a user who can't save it is misleading; let
|
|
// Admin Next show the toast on the show() failure instead.
|
|
$this->guardTwigContent($page, [], $this->getUser($request));
|
|
|
|
$query = $request->getQueryParams();
|
|
$summary = filter_var($query['summary'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
|
$options = [
|
|
'include_content' => !$summary,
|
|
'render_content' => filter_var($query['render'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
|
'include_summary' => $summary,
|
|
'summary_size' => isset($query['summary_size']) ? (int) $query['summary_size'] : null,
|
|
'include_children' => filter_var($query['children'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
|
'children_depth' => max(1, (int) ($query['children_depth'] ?? 1)),
|
|
'include_media' => true,
|
|
'include_translations' => filter_var($query['translations'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
|
];
|
|
|
|
$data = $this->serializer->serialize($page, $options);
|
|
|
|
return $this->respondWithEtag($data);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /pages - Create a new page.
|
|
*/
|
|
public function create(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['route', 'title']);
|
|
|
|
// Language can come from body or query param
|
|
$lang = $body['lang'] ?? null;
|
|
$previousLang = $this->applyLanguage($request, $lang);
|
|
|
|
try {
|
|
$this->enablePages();
|
|
|
|
$route = '/' . trim($body['route'], '/');
|
|
$template = $body['template'] ?? 'default';
|
|
$title = $body['title'];
|
|
$content = $body['content'] ?? '';
|
|
$header = $body['header'] ?? [];
|
|
$order = $body['order'] ?? null;
|
|
|
|
// `kind` mirrors classic admin's three-way split:
|
|
// - 'page' (default): folder + <template>.md inside (current behaviour)
|
|
// - 'folder': folder only, no .md file written — useful as a
|
|
// routing/grouping container
|
|
// - 'module': folder for a modular sub-page; the slug is prefixed
|
|
// with `_` (per Grav's modular convention) unless the
|
|
// caller already supplied that prefix
|
|
$kind = strtolower((string) ($body['kind'] ?? 'page'));
|
|
if (!in_array($kind, ['page', 'folder', 'module'], true)) {
|
|
throw new ValidationException("Invalid 'kind' value: must be one of page, folder, module.");
|
|
}
|
|
|
|
// Ensure parent exists
|
|
$parentRoute = dirname($route);
|
|
$slug = basename($route);
|
|
|
|
// Modular sub-page convention: folder name starts with `_`.
|
|
if ($kind === 'module' && !str_starts_with($slug, '_')) {
|
|
$slug = '_' . $slug;
|
|
$route = ($parentRoute === '/' ? '' : $parentRoute) . '/' . $slug;
|
|
}
|
|
|
|
if ($parentRoute !== '/') {
|
|
$parent = $this->grav['pages']->find($parentRoute);
|
|
if (!$parent) {
|
|
throw new ValidationException("Parent page not found at route: {$parentRoute}");
|
|
}
|
|
$parentPath = $parent->path();
|
|
} else {
|
|
$parentPath = $this->grav['locator']->findResource('page://', true);
|
|
}
|
|
|
|
// Resolve `order: "auto"` against existing siblings: if any sibling
|
|
// carries a numeric prefix, assign the next number; otherwise leave
|
|
// the new page unprefixed. Mirrors admin-classic's add-page flow.
|
|
if (is_string($order) && strtolower($order) === 'auto') {
|
|
$order = $this->nextOrderInParent($parentPath);
|
|
}
|
|
|
|
// Build directory name with optional ordering prefix. Width follows
|
|
// the parent's existing children when present, so adding a page
|
|
// under a 3-digit collection stays 3-digit.
|
|
$dirName = $order !== null ? PageOrdering::key($order, $slug, $this->siblingDigits($parentPath)) : $slug;
|
|
$pagePath = $parentPath . '/' . $dirName;
|
|
|
|
if (is_dir($pagePath)) {
|
|
throw new ValidationException("A page already exists at route: {$route}");
|
|
}
|
|
|
|
// Build header with title
|
|
$header = array_merge(['title' => $title], $header);
|
|
|
|
// Enforce security.twig_content.* gate before any plugin event can
|
|
// mutate the header — reject the create up-front if the request
|
|
// wants process.twig:true and the user isn't allowed.
|
|
$this->guardTwigContent(null, $header, $this->getUser($request));
|
|
|
|
// Fire before event — plugins can modify $header/$content or throw to cancel
|
|
$this->fireEvent('onApiBeforePageCreate', [
|
|
'route' => $route,
|
|
'header' => &$header,
|
|
'content' => &$content,
|
|
'template' => &$template,
|
|
'lang' => $lang,
|
|
]);
|
|
|
|
// Allow plugins to inject frontmatter fields (e.g. auto-date plugin)
|
|
$this->fireAdminEvent('onAdminCreatePageFrontmatter', [
|
|
'header' => &$header,
|
|
'data' => $body,
|
|
]);
|
|
|
|
if ($kind === 'folder') {
|
|
// Folder-only page: create the directory with no .md inside.
|
|
// Grav treats such folders as routing/grouping containers.
|
|
if (!is_dir($pagePath)) {
|
|
if (!@mkdir($pagePath, 0775, true) && !is_dir($pagePath)) {
|
|
throw new \RuntimeException("Failed to create folder at: {$pagePath}");
|
|
}
|
|
}
|
|
$page = null;
|
|
} else {
|
|
// Build filename with language extension if applicable
|
|
$filename = $this->buildPageFilename($template, $lang);
|
|
|
|
$page = new Page();
|
|
$page->filePath($pagePath . '/' . $filename);
|
|
$page->header((object) $header);
|
|
$page->rawMarkdown($content);
|
|
|
|
// Allow plugins to modify the page before save (e.g. SEO Magic, mega-frontmatter)
|
|
$this->fireAdminEvent('onAdminSave', ['object' => &$page, 'page' => &$page]);
|
|
|
|
// Validate the submitted page fields against the blueprint (admin2#30).
|
|
$this->validatePageChanges($page, ['header' => $header, 'content' => $content]);
|
|
|
|
$page->save();
|
|
}
|
|
|
|
$this->clearPagesCache();
|
|
|
|
// Re-init pages and fetch the newly created page for serialization
|
|
$this->enablePages(true);
|
|
$newPage = $this->grav['pages']->find($route);
|
|
|
|
$resolved = $newPage ?? $page;
|
|
if ($resolved !== null) {
|
|
$this->fireAdminEvent('onAdminAfterSave', ['object' => $resolved, 'page' => $resolved]);
|
|
$this->fireEvent('onApiPageCreated', ['page' => $resolved, 'route' => $route, 'lang' => $lang]);
|
|
}
|
|
|
|
// Folder-only pages (no .md) may not surface as a Page object after
|
|
// re-init in every theme/setup. Return a minimal payload in that
|
|
// case rather than 500-ing on the serializer.
|
|
$data = $resolved !== null
|
|
? $this->serializer->serialize($resolved)
|
|
: ['route' => $route, 'kind' => $kind];
|
|
$location = $this->getApiBaseUrl() . '/pages' . $route;
|
|
|
|
return ApiResponse::created(
|
|
$data,
|
|
$location,
|
|
$this->invalidationHeaders(['pages:create:' . $route, 'pages:list']),
|
|
);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /pages/{route} - Partial update of a page.
|
|
*/
|
|
public function update(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$previousLang = $this->applyLanguage($request);
|
|
|
|
try {
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$page = $this->findPageOrFail('/' . $route);
|
|
|
|
// Guard against writing to a non-existent translation file. When
|
|
// ?lang=X is specified but no X translation exists, Grav's fallback
|
|
// would silently resolve to the source language and clobber it.
|
|
// Force callers to create the translation via POST /translate first.
|
|
$query = $request->getQueryParams();
|
|
$requestedLang = $query['lang'] ?? null;
|
|
if ($requestedLang && $this->isMultiLangEnabled()) {
|
|
$pageLang = $page->language() ?: null;
|
|
// A default.md file (no language suffix) is treated as the default language
|
|
$defaultLang = $this->grav['language']->getDefault() ?: 'en';
|
|
if (!$pageLang && $requestedLang === $defaultLang) {
|
|
$pageLang = $defaultLang;
|
|
}
|
|
if ($pageLang !== $requestedLang) {
|
|
throw new ValidationException(
|
|
"No '{$requestedLang}' translation exists for this page. "
|
|
. "Use POST /pages/{$route}/translate to create it first."
|
|
);
|
|
}
|
|
}
|
|
|
|
// ETag validation for conflict detection
|
|
$currentData = $this->serializer->serialize($page);
|
|
$this->validateEtag($request, $this->generateEtag($currentData));
|
|
|
|
$body = $this->getRequestBody($request);
|
|
|
|
// Enforce security.twig_content.* gate against the incoming header
|
|
// and the existing page state, before any plugin event can mutate
|
|
// either. Covers two cases: user tries to flip process.twig:true,
|
|
// or user tries to edit a page that already has it on.
|
|
$this->guardTwigContent($page, (array) ($body['header'] ?? []), $this->getUser($request));
|
|
|
|
// Fire before event — plugins can modify $body or throw to cancel
|
|
$this->fireEvent('onApiBeforePageUpdate', ['page' => $page, 'data' => &$body]);
|
|
|
|
if (array_key_exists('content', $body)) {
|
|
$page->rawMarkdown($body['content']);
|
|
}
|
|
|
|
if (array_key_exists('title', $body)) {
|
|
$header = $this->headerToArray($page->header());
|
|
$header['title'] = $body['title'];
|
|
$page->header((object) $header);
|
|
}
|
|
|
|
if (array_key_exists('header', $body)) {
|
|
$existing = $this->headerToArray($page->header());
|
|
$merged = $this->mergePatch($existing, $body['header']);
|
|
// Strip null values — toggleable fields send null to signal removal
|
|
$merged = $this->stripNullValues($merged);
|
|
$page->header((object) $merged);
|
|
// Sync properties that legacy Page caches separately from the
|
|
// header dict (otherwise they stay stale until reload).
|
|
if (array_key_exists('published', $body['header'])) {
|
|
$page->published((bool) $body['header']['published']);
|
|
}
|
|
if (array_key_exists('visible', $body['header'])) {
|
|
$page->visible((bool) $body['header']['visible']);
|
|
}
|
|
}
|
|
|
|
// Template change requires renaming the page file (e.g. default.md → post.md)
|
|
$templateChanged = false;
|
|
$oldFilePath = null;
|
|
if (array_key_exists('template', $body) && $body['template'] !== $page->template()) {
|
|
$oldFilePath = $page->path() . '/' . $page->template() . ($page->language() ? '.' . $page->language() : '') . '.md';
|
|
$page->template($body['template']);
|
|
$page->name($body['template'] . ($page->language() ? '.' . $page->language() : '') . '.md');
|
|
$templateChanged = true;
|
|
}
|
|
|
|
if (array_key_exists('published', $body)) {
|
|
$header = $this->headerToArray($page->header());
|
|
$header['published'] = (bool) $body['published'];
|
|
$page->header((object) $header);
|
|
// Legacy Page caches $this->published at init and doesn't
|
|
// re-read from the header. Sync the setter so the post-save
|
|
// serializer reflects the new value (avoids a stale "No" in
|
|
// the Page Info sidebar until a reload).
|
|
$page->published((bool) $body['published']);
|
|
}
|
|
|
|
if (array_key_exists('visible', $body)) {
|
|
$header = $this->headerToArray($page->header());
|
|
$header['visible'] = (bool) $body['visible'];
|
|
$page->header((object) $header);
|
|
$page->visible((bool) $body['visible']);
|
|
}
|
|
|
|
// Allow plugins to modify the page before save
|
|
$this->fireAdminEvent('onAdminSave', ['object' => &$page, 'page' => &$page]);
|
|
|
|
// Validate the submitted page fields against the blueprint before
|
|
// writing to disk (admin2#30) — a required field sent empty now
|
|
// returns 422 instead of saving silently.
|
|
$this->validatePageChanges($page, $body);
|
|
|
|
$page->save();
|
|
|
|
// Remove old template file after successful save
|
|
if ($templateChanged && $oldFilePath && file_exists($oldFilePath)) {
|
|
unlink($oldFilePath);
|
|
}
|
|
|
|
$this->clearPagesCache();
|
|
|
|
$this->fireAdminEvent('onAdminAfterSave', ['object' => $page, 'page' => $page]);
|
|
$this->fireEvent('onApiPageUpdated', ['page' => $page]);
|
|
|
|
$data = $this->serializer->serialize($page);
|
|
|
|
return $this->respondWithEtag($data, 200, ['pages:update:/' . $route, 'pages:list']);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE /pages/{route} - Delete a page.
|
|
*/
|
|
public function delete(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$previousLang = $this->applyLanguage($request);
|
|
|
|
try {
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$page = $this->findPageOrFail('/' . $route);
|
|
|
|
$query = $request->getQueryParams();
|
|
$lang = $query['lang'] ?? null;
|
|
$includeChildren = filter_var($query['children'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
|
|
|
// If a specific language is requested, delete only that language file
|
|
if ($lang && $this->isMultiLangEnabled()) {
|
|
$this->fireEvent('onApiBeforePageDelete', ['page' => $page, 'lang' => $lang]);
|
|
|
|
$this->deleteLanguageFile($page, $lang);
|
|
$this->clearPagesCache();
|
|
|
|
$this->fireAdminEvent('onAdminAfterDelete', ['object' => $page, 'page' => $page]);
|
|
$this->fireEvent('onApiPageDeleted', ['route' => '/' . $route, 'lang' => $lang]);
|
|
|
|
return ApiResponse::noContent(
|
|
$this->invalidationHeaders(['pages:delete:/' . $route, 'pages:list']),
|
|
);
|
|
}
|
|
|
|
if (!$includeChildren && $page->children()->count() > 0) {
|
|
throw new ValidationException(
|
|
'This page has children. Use ?children=true to confirm deletion of the page and all its children.'
|
|
);
|
|
}
|
|
|
|
$this->fireEvent('onApiBeforePageDelete', ['page' => $page]);
|
|
|
|
$pagePath = $page->path();
|
|
Folder::delete($pagePath);
|
|
|
|
$this->clearPagesCache();
|
|
|
|
$this->fireAdminEvent('onAdminAfterDelete', ['object' => $page, 'page' => $page]);
|
|
$this->fireEvent('onApiPageDeleted', ['route' => '/' . $route]);
|
|
|
|
return ApiResponse::noContent(
|
|
$this->invalidationHeaders(['pages:delete:/' . $route, 'pages:list']),
|
|
);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /pages/{route}/move - Move a page to a new location.
|
|
*/
|
|
public function move(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$page = $this->findPageOrFail('/' . $route);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['parent']);
|
|
|
|
$newParentRoute = '/' . trim($body['parent'], '/');
|
|
$newSlug = ltrim($body['slug'] ?? $page->slug(), '.');
|
|
// $page->order() returns the matched prefix INCLUDING the trailing
|
|
// dot (e.g. '04.'), not a plain number. Concatenating that with the
|
|
// dot in $dirName produces double-dot folder names like
|
|
// '04..slug' — which then makes Grav read the slug as '.slug'.
|
|
// Normalize to an int (or null when no prefix exists) so the
|
|
// body['order'] contract and the fallback agree on shape.
|
|
if (array_key_exists('order', $body)) {
|
|
$newOrder = $body['order'];
|
|
} else {
|
|
$currentOrder = $page->order();
|
|
$newOrder = ($currentOrder === false || $currentOrder === '' || $currentOrder === null)
|
|
? null
|
|
: (int) rtrim((string) $currentOrder, '.');
|
|
}
|
|
|
|
// Resolve new parent path
|
|
if ($newParentRoute === '/') {
|
|
$newParentPath = $this->grav['locator']->findResource('page://', true);
|
|
} else {
|
|
$newParent = $this->grav['pages']->find($newParentRoute);
|
|
if (!$newParent) {
|
|
throw new ValidationException("Destination parent not found at route: {$newParentRoute}");
|
|
}
|
|
$newParentPath = $newParent->path();
|
|
}
|
|
|
|
// Build new directory name
|
|
$dirName = $newOrder !== null
|
|
? str_pad((string) $newOrder, 2, '0', STR_PAD_LEFT) . '.' . $newSlug
|
|
: $newSlug;
|
|
|
|
$oldPath = $page->path();
|
|
$newPath = $newParentPath . '/' . $dirName;
|
|
|
|
if ($oldPath === $newPath) {
|
|
throw new ValidationException('Source and destination paths are identical.');
|
|
}
|
|
|
|
if (is_dir($newPath)) {
|
|
throw new ValidationException("A page already exists at the destination path.");
|
|
}
|
|
|
|
Folder::move($oldPath, $newPath);
|
|
$this->clearPagesCache();
|
|
|
|
$this->fireAdminEvent('onAdminAfterSaveAs', ['path' => $newPath]);
|
|
|
|
// Re-init and find the moved page
|
|
$this->enablePages(true);
|
|
$newRoute = $newParentRoute === '/' ? '/' . $newSlug : $newParentRoute . '/' . $newSlug;
|
|
$movedPage = $this->grav['pages']->find($newRoute);
|
|
|
|
$this->fireEvent('onApiPageMoved', [
|
|
'page' => $movedPage,
|
|
'old_route' => '/' . $route,
|
|
'new_route' => $newRoute,
|
|
]);
|
|
|
|
$moveTags = ['pages:move:/' . $route, 'pages:update:' . $newRoute, 'pages:list'];
|
|
|
|
if (!$movedPage) {
|
|
// Fallback: return minimal data if page can't be found at expected route
|
|
return ApiResponse::create(
|
|
['route' => $newRoute, 'slug' => $newSlug],
|
|
200,
|
|
$this->invalidationHeaders($moveTags),
|
|
);
|
|
}
|
|
|
|
$data = $this->serializer->serialize($movedPage);
|
|
|
|
return $this->respondWithEtag($data, 200, $moveTags);
|
|
}
|
|
|
|
/**
|
|
* POST /pages/{route}/copy - Copy a page to a new location.
|
|
*/
|
|
public function copy(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$page = $this->findPageOrFail('/' . $route);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['route']);
|
|
|
|
$destRoute = '/' . trim($body['route'], '/');
|
|
$destSlug = basename($destRoute);
|
|
$destParentRoute = dirname($destRoute);
|
|
|
|
// Resolve destination parent path
|
|
if ($destParentRoute === '/') {
|
|
$destParentPath = $this->grav['locator']->findResource('page://', true);
|
|
} else {
|
|
$destParent = $this->grav['pages']->find($destParentRoute);
|
|
if (!$destParent) {
|
|
throw new ValidationException("Destination parent not found at route: {$destParentRoute}");
|
|
}
|
|
$destParentPath = $destParent->path();
|
|
}
|
|
|
|
$destPath = $destParentPath . '/' . $destSlug;
|
|
|
|
if (is_dir($destPath)) {
|
|
throw new ValidationException("A page already exists at route: {$destRoute}");
|
|
}
|
|
|
|
$sourcePath = $page->path();
|
|
Folder::copy($sourcePath, $destPath);
|
|
$this->clearPagesCache();
|
|
|
|
// Re-init and find the copied page
|
|
$this->enablePages(true);
|
|
$copiedPage = $this->grav['pages']->find($destRoute);
|
|
|
|
$copyTags = ['pages:create:' . $destRoute, 'pages:list'];
|
|
|
|
if (!$copiedPage) {
|
|
return ApiResponse::created(
|
|
['route' => $destRoute, 'slug' => $destSlug],
|
|
$this->getApiBaseUrl() . '/pages' . $destRoute,
|
|
$this->invalidationHeaders($copyTags),
|
|
);
|
|
}
|
|
|
|
$data = $this->serializer->serialize($copiedPage);
|
|
$location = $this->getApiBaseUrl() . '/pages' . $destRoute;
|
|
|
|
return ApiResponse::created($data, $location, $this->invalidationHeaders($copyTags));
|
|
}
|
|
|
|
/**
|
|
* GET /pages/{route}/languages - List available and missing translations for a page.
|
|
*/
|
|
public function languages(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_READ);
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$page = $this->findPageOrFail('/' . $route);
|
|
|
|
$translated = $page->translatedLanguages();
|
|
$untranslated = $page->untranslatedLanguages();
|
|
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
|
|
$data = [
|
|
'route' => $page->route(),
|
|
'default_language' => $language->getDefault() ?: null,
|
|
'translated' => $translated,
|
|
'untranslated' => $untranslated,
|
|
];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* POST /pages/{route}/translate - Create a new translation for an existing page.
|
|
*/
|
|
public function translate(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$page = $this->findPageOrFail('/' . $route);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['lang']);
|
|
|
|
$lang = $body['lang'];
|
|
$this->validateLanguageCode($lang);
|
|
|
|
// Check if translation already exists
|
|
$translated = $page->translatedLanguages();
|
|
if (isset($translated[$lang])) {
|
|
throw new ValidationException("A translation already exists for language '{$lang}'. Use PATCH to update it.");
|
|
}
|
|
|
|
$title = $body['title'] ?? $page->title();
|
|
$content = $body['content'] ?? $page->rawMarkdown();
|
|
$header = $body['header'] ?? $this->headerToArray($page->header());
|
|
|
|
// Ensure title is set
|
|
$header = array_merge(['title' => $title], is_array($header) ? $header : []);
|
|
|
|
$this->fireEvent('onApiBeforePageTranslate', [
|
|
'page' => $page,
|
|
'lang' => $lang,
|
|
'header' => &$header,
|
|
'content' => &$content,
|
|
]);
|
|
|
|
// Build the language-specific file path
|
|
$template = $page->template();
|
|
$filename = $this->buildPageFilename($template, $lang);
|
|
$filePath = $page->path() . '/' . $filename;
|
|
|
|
$translatedPage = new Page();
|
|
$translatedPage->filePath($filePath);
|
|
$translatedPage->header((object) $header);
|
|
$translatedPage->rawMarkdown($content);
|
|
|
|
// Allow plugins to modify the page before save
|
|
$this->fireAdminEvent('onAdminSave', ['object' => &$translatedPage, 'page' => &$translatedPage]);
|
|
|
|
$translatedPage->save();
|
|
|
|
$this->clearPagesCache();
|
|
|
|
// Re-init and fetch the translated page
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
$previousLang = $language->getActive() ?? false;
|
|
$language->setActive($lang);
|
|
|
|
try {
|
|
$this->enablePages(true);
|
|
$newPage = $this->grav['pages']->find('/' . $route);
|
|
|
|
$this->fireAdminEvent('onAdminAfterSave', ['object' => $newPage ?? $translatedPage, 'page' => $newPage ?? $translatedPage]);
|
|
$this->fireEvent('onApiPageTranslated', [
|
|
'page' => $newPage ?? $translatedPage,
|
|
'route' => '/' . $route,
|
|
'lang' => $lang,
|
|
]);
|
|
|
|
$data = $this->serializer->serialize($newPage ?? $translatedPage);
|
|
$location = $this->getApiBaseUrl() . '/pages/' . $route;
|
|
|
|
return ApiResponse::created(
|
|
$data,
|
|
$location,
|
|
$this->invalidationHeaders(['pages:update:/' . $route, 'pages:list']),
|
|
);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /pages/{route}/adopt-language — Claim an untyped default page file
|
|
* (e.g., "default.md") as belonging to a specific language by renaming it
|
|
* in-place to "{template}.{lang}.md". Does not modify contents; pure
|
|
* filesystem rename + cache bust.
|
|
*
|
|
* Useful for sites that started single-language (bare default.md) and later
|
|
* enabled multilang — lets the operator declare "this existing content is
|
|
* the English version" without editing YAML or re-saving the page.
|
|
*
|
|
* Fails if the page already has an explicit file for that language, or if
|
|
* no untyped base file exists.
|
|
*/
|
|
public function adoptLanguage(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$page = $this->findPageOrFail('/' . $route);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['lang']);
|
|
|
|
$lang = (string) $body['lang'];
|
|
$this->validateLanguageCode($lang);
|
|
|
|
if (!$this->isMultiLangEnabled()) {
|
|
throw new ValidationException('Multi-language is not enabled for this site.');
|
|
}
|
|
|
|
$template = $page->template();
|
|
$pageDir = $page->path();
|
|
if (!$pageDir || !is_dir($pageDir)) {
|
|
throw new NotFoundException("Page directory not found for route: /{$route}");
|
|
}
|
|
|
|
$baseFile = $pageDir . '/' . $template . '.md';
|
|
if (!is_file($baseFile)) {
|
|
throw new ValidationException(
|
|
"No untyped base file ({$template}.md) found for route /{$route}. "
|
|
. 'Page already uses language-suffixed files — use POST /pages/{route}/translate for new languages.'
|
|
);
|
|
}
|
|
|
|
// Block only if an EXPLICIT language file already exists. We can't use
|
|
// $page->translatedLanguages() because Grav reports the default lang
|
|
// as "translated" whenever default.md exists (the fallback). The
|
|
// question we actually need to answer is: does default.<lang>.md
|
|
// exist on disk?
|
|
$targetFilename = $this->buildPageFilename($template, $lang);
|
|
$targetFile = $pageDir . '/' . $targetFilename;
|
|
if (is_file($targetFile)) {
|
|
throw new ValidationException("A translation file already exists for language '{$lang}'.");
|
|
}
|
|
|
|
// If the config resolves to the same filename (unlikely, but guard),
|
|
// there's nothing to do — fail cleanly rather than nop-renaming.
|
|
if (realpath($baseFile) === realpath($targetFile)) {
|
|
throw new ValidationException(
|
|
'Target filename resolves to the same path as the base file — '
|
|
. 'check system.languages.include_default_lang_file_extension.'
|
|
);
|
|
}
|
|
|
|
$this->fireEvent('onApiBeforePageAdoptLanguage', [
|
|
'page' => $page,
|
|
'route' => '/' . $route,
|
|
'lang' => $lang,
|
|
'from_file' => $baseFile,
|
|
'to_file' => $targetFile,
|
|
]);
|
|
|
|
if (!@rename($baseFile, $targetFile)) {
|
|
throw new ApiException(500, 'Rename Failed', "Failed to rename '{$baseFile}' to '{$targetFile}'.");
|
|
}
|
|
|
|
$this->clearPagesCache();
|
|
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
$previousLang = $language->getActive() ?? false;
|
|
$language->setActive($lang);
|
|
|
|
try {
|
|
$this->enablePages(true);
|
|
$newPage = $this->grav['pages']->find('/' . $route);
|
|
|
|
$this->fireEvent('onApiPageLanguageAdopted', [
|
|
'page' => $newPage ?? $page,
|
|
'route' => '/' . $route,
|
|
'lang' => $lang,
|
|
]);
|
|
|
|
$data = $this->serializer->serialize($newPage ?? $page);
|
|
|
|
return ApiResponse::create(
|
|
$data,
|
|
200,
|
|
$this->invalidationHeaders(['pages:update:/' . $route, 'pages:list']),
|
|
);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /languages - List all configured site languages.
|
|
*/
|
|
public function siteLanguages(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_READ);
|
|
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
|
|
if (!$language->enabled()) {
|
|
return ApiResponse::create([
|
|
'enabled' => false,
|
|
'languages' => [],
|
|
'default' => null,
|
|
'active' => null,
|
|
]);
|
|
}
|
|
|
|
$langs = $language->getLanguages();
|
|
$default = $language->getDefault() ?: null;
|
|
|
|
$languageDetails = [];
|
|
foreach ($langs as $code) {
|
|
$languageDetails[] = [
|
|
'code' => $code,
|
|
'name' => LanguageCodes::getName($code) ?: $code,
|
|
'native_name' => LanguageCodes::getNativeName($code) ?: $code,
|
|
'rtl' => LanguageCodes::isRtl($code),
|
|
'is_default' => $code === $default,
|
|
];
|
|
}
|
|
|
|
$data = [
|
|
'enabled' => true,
|
|
'languages' => $languageDetails,
|
|
'default' => $default,
|
|
'active' => $language->getActive() ?: $default,
|
|
];
|
|
|
|
return ApiResponse::create($data);
|
|
}
|
|
|
|
/**
|
|
* POST /pages/{route}/sync - Sync/reset a translation from another language.
|
|
* Copies content and header from source language to target language.
|
|
*/
|
|
public function sync(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['source_lang', 'target_lang']);
|
|
|
|
$sourceLang = $body['source_lang'];
|
|
$targetLang = $body['target_lang'];
|
|
$this->validateLanguageCode($sourceLang);
|
|
$this->validateLanguageCode($targetLang);
|
|
|
|
if ($sourceLang === $targetLang) {
|
|
throw new ValidationException('Source and target languages must be different.');
|
|
}
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
$previousLang = $language->getActive() ?? false;
|
|
|
|
try {
|
|
// Load the source page
|
|
$language->setActive($sourceLang);
|
|
$this->enablePages(true);
|
|
$sourcePage = $this->grav['pages']->find('/' . $route);
|
|
|
|
if (!$sourcePage) {
|
|
throw new NotFoundException("Page not found at route '/{$route}' for source language '{$sourceLang}'.");
|
|
}
|
|
|
|
$sourceContent = $sourcePage->rawMarkdown();
|
|
$sourceHeader = $this->headerToArray($sourcePage->header());
|
|
|
|
// Load the target page
|
|
$language->setActive($targetLang);
|
|
$this->enablePages(true);
|
|
$targetPage = $this->grav['pages']->find('/' . $route);
|
|
|
|
if (!$targetPage) {
|
|
throw new NotFoundException("Page not found at route '/{$route}' for target language '{$targetLang}'.");
|
|
}
|
|
|
|
// Verify the target translation file actually exists
|
|
$translated = $targetPage->translatedLanguages();
|
|
if (!isset($translated[$targetLang])) {
|
|
throw new ValidationException(
|
|
"No translation file exists for language '{$targetLang}'. Use POST /pages/{route}/translate to create one first."
|
|
);
|
|
}
|
|
|
|
$this->fireEvent('onApiBeforePageSync', [
|
|
'page' => $targetPage,
|
|
'source_lang' => $sourceLang,
|
|
'target_lang' => $targetLang,
|
|
'header' => &$sourceHeader,
|
|
'content' => &$sourceContent,
|
|
]);
|
|
|
|
// Overwrite the target with source data
|
|
$targetPage->header((object) $sourceHeader);
|
|
$targetPage->rawMarkdown($sourceContent);
|
|
|
|
$this->fireAdminEvent('onAdminSave', ['object' => &$targetPage, 'page' => &$targetPage]);
|
|
$targetPage->save();
|
|
$this->clearPagesCache();
|
|
|
|
// Re-fetch the updated page
|
|
$this->enablePages(true);
|
|
$updatedPage = $this->grav['pages']->find('/' . $route);
|
|
|
|
$this->fireAdminEvent('onAdminAfterSave', ['object' => $updatedPage ?? $targetPage, 'page' => $updatedPage ?? $targetPage]);
|
|
$this->fireEvent('onApiPageSynced', [
|
|
'page' => $updatedPage ?? $targetPage,
|
|
'route' => '/' . $route,
|
|
'source_lang' => $sourceLang,
|
|
'target_lang' => $targetLang,
|
|
]);
|
|
|
|
$data = $this->serializer->serialize($updatedPage ?? $targetPage);
|
|
return ApiResponse::create(
|
|
$data,
|
|
200,
|
|
$this->invalidationHeaders(['pages:update:/' . $route, 'pages:list']),
|
|
);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /pages/{route}/compare - Compare two language versions of a page side-by-side.
|
|
*/
|
|
public function compare(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_READ);
|
|
|
|
$params = $request->getQueryParams();
|
|
$sourceLang = $params['source'] ?? null;
|
|
$targetLang = $params['target'] ?? null;
|
|
|
|
if (!$sourceLang || !$targetLang) {
|
|
throw new ValidationException("Both 'source' and 'target' query parameters are required.");
|
|
}
|
|
|
|
$this->validateLanguageCode($sourceLang);
|
|
$this->validateLanguageCode($targetLang);
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
$previousLang = $language->getActive() ?? false;
|
|
|
|
try {
|
|
// Load source page
|
|
$language->setActive($sourceLang);
|
|
$this->enablePages(true);
|
|
$sourcePage = $this->grav['pages']->find('/' . $route);
|
|
|
|
$sourceData = null;
|
|
if ($sourcePage) {
|
|
$translated = $sourcePage->translatedLanguages();
|
|
$sourceData = [
|
|
'lang' => $sourceLang,
|
|
'exists' => isset($translated[$sourceLang]),
|
|
'title' => $sourcePage->title(),
|
|
'content' => $sourcePage->rawMarkdown(),
|
|
'header' => $this->headerToArray($sourcePage->header()),
|
|
'modified' => $sourcePage->modified() ? date('c', $sourcePage->modified()) : null,
|
|
];
|
|
}
|
|
|
|
// Load target page
|
|
$language->setActive($targetLang);
|
|
$this->enablePages(true);
|
|
$targetPage = $this->grav['pages']->find('/' . $route);
|
|
|
|
$targetData = null;
|
|
if ($targetPage) {
|
|
$translated = $targetPage->translatedLanguages();
|
|
$targetData = [
|
|
'lang' => $targetLang,
|
|
'exists' => isset($translated[$targetLang]),
|
|
'title' => $targetPage->title(),
|
|
'content' => $targetPage->rawMarkdown(),
|
|
'header' => $this->headerToArray($targetPage->header()),
|
|
'modified' => $targetPage->modified() ? date('c', $targetPage->modified()) : null,
|
|
];
|
|
}
|
|
|
|
$data = [
|
|
'route' => '/' . $route,
|
|
'source' => $sourceData,
|
|
'target' => $targetData,
|
|
];
|
|
|
|
return ApiResponse::create($data);
|
|
} finally {
|
|
$this->restoreLanguage($previousLang);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /pages/{route}/reorder - Reorder child pages under a parent.
|
|
*/
|
|
public function reorder(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$this->enablePages();
|
|
|
|
$route = $this->getRouteParam($request, 'route');
|
|
$parent = $this->findPageOrFail('/' . $route);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['order']);
|
|
|
|
$order = $body['order'];
|
|
if (!is_array($order)) {
|
|
throw new ValidationException("The 'order' field must be an array of child slugs.");
|
|
}
|
|
|
|
$this->fireEvent('onApiBeforePagesReorder', ['parent' => $parent, 'order' => $order]);
|
|
|
|
$parentPath = $parent->path();
|
|
$children = $parent->children();
|
|
|
|
// Build a map of slug -> current directory name
|
|
$childMap = [];
|
|
foreach ($children as $child) {
|
|
$childMap[$child->slug()] = basename($child->path());
|
|
}
|
|
|
|
// Validate all slugs exist
|
|
foreach ($order as $slug) {
|
|
if (!isset($childMap[$slug])) {
|
|
throw new ValidationException("Child page with slug '{$slug}' not found under '{$parent->route()}'.");
|
|
}
|
|
}
|
|
|
|
// Rename directories with new ordering prefixes. Use the widest existing
|
|
// sibling prefix as the target width so a parent of all-3-digit children
|
|
// stays 3-digit through reorder; otherwise fall back to system default.
|
|
$digits = 0;
|
|
foreach ($childMap as $existingDir) {
|
|
$w = PageOrdering::digitsFromFolder($existingDir);
|
|
if ($w !== null && $w > $digits) {
|
|
$digits = $w;
|
|
}
|
|
}
|
|
$reorderDigits = $digits ?: null;
|
|
|
|
$tempRenames = [];
|
|
$position = 1;
|
|
|
|
foreach ($order as $slug) {
|
|
$currentDir = $childMap[$slug];
|
|
$newDir = PageOrdering::key($position, $slug, $reorderDigits);
|
|
|
|
if ($currentDir !== $newDir) {
|
|
$oldPath = $parentPath . '/' . $currentDir;
|
|
// Use temp name to avoid conflicts during rename
|
|
$tempPath = $parentPath . '/_temp_' . $position . '_' . $slug;
|
|
$tempRenames[] = [
|
|
'temp' => $tempPath,
|
|
'final' => $parentPath . '/' . $newDir,
|
|
'old' => $oldPath,
|
|
];
|
|
if (is_dir($oldPath)) {
|
|
rename($oldPath, $tempPath);
|
|
}
|
|
}
|
|
|
|
$position++;
|
|
}
|
|
|
|
// Now rename from temp to final names
|
|
foreach ($tempRenames as $rename) {
|
|
if (is_dir($rename['temp'])) {
|
|
rename($rename['temp'], $rename['final']);
|
|
}
|
|
}
|
|
|
|
$this->clearPagesCache();
|
|
|
|
$this->fireEvent('onApiPagesReordered', ['parent' => $parent, 'order' => $order]);
|
|
|
|
// Re-init and return updated children
|
|
$this->enablePages(true);
|
|
$updatedParent = $this->grav['pages']->find('/' . $route);
|
|
$childData = [];
|
|
if ($updatedParent) {
|
|
foreach ($updatedParent->children() as $child) {
|
|
$childData[] = [
|
|
'route' => $child->route(),
|
|
'slug' => $child->slug(),
|
|
'title' => $child->title(),
|
|
'order' => $child->order(),
|
|
];
|
|
}
|
|
}
|
|
|
|
return ApiResponse::create(
|
|
$childData,
|
|
200,
|
|
$this->invalidationHeaders(['pages:reorder:/' . $route, 'pages:list']),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /pages/batch - Batch operations on multiple pages.
|
|
*/
|
|
public function batch(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$this->enablePages();
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['operation', 'routes']);
|
|
|
|
$operation = $body['operation'];
|
|
$routes = $body['routes'];
|
|
$options = $body['options'] ?? [];
|
|
|
|
$allowedOps = ['publish', 'unpublish', 'delete', 'copy'];
|
|
if (!in_array($operation, $allowedOps, true)) {
|
|
throw new ValidationException(
|
|
"Invalid operation '{$operation}'. Allowed: " . implode(', ', $allowedOps)
|
|
);
|
|
}
|
|
|
|
if (!is_array($routes) || empty($routes)) {
|
|
throw new ValidationException("The 'routes' field must be a non-empty array.");
|
|
}
|
|
|
|
$maxBatch = $this->config->get('plugins.api.batch.max_items', 50);
|
|
if (count($routes) > $maxBatch) {
|
|
throw new ValidationException("Batch operations are limited to {$maxBatch} items.");
|
|
}
|
|
|
|
// Validate all routes exist first
|
|
$pages = [];
|
|
foreach ($routes as $route) {
|
|
$normalizedRoute = '/' . trim($route, '/');
|
|
$page = $this->grav['pages']->find($normalizedRoute);
|
|
if (!$page) {
|
|
throw new ValidationException("Page not found at route: {$normalizedRoute}");
|
|
}
|
|
$pages[$normalizedRoute] = $page;
|
|
}
|
|
|
|
$results = [];
|
|
|
|
foreach ($pages as $route => $page) {
|
|
try {
|
|
match ($operation) {
|
|
'publish' => $this->batchPublish($page, true),
|
|
'unpublish' => $this->batchPublish($page, false),
|
|
'delete' => $this->batchDelete($page),
|
|
'copy' => $this->batchCopy($page, $options),
|
|
};
|
|
$results[] = ['route' => $route, 'status' => 'success'];
|
|
} catch (\Throwable $e) {
|
|
$results[] = ['route' => $route, 'status' => 'error', 'message' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
$this->clearPagesCache();
|
|
|
|
// Build per-route invalidations so listeners on specific pages react too.
|
|
$tags = ['pages:list'];
|
|
foreach ($results as $r) {
|
|
if ($r['status'] !== 'success') continue;
|
|
$tags[] = match ($operation) {
|
|
'delete' => 'pages:delete:' . $r['route'],
|
|
'copy' => 'pages:create:' . $r['route'],
|
|
default => 'pages:update:' . $r['route'],
|
|
};
|
|
}
|
|
|
|
return ApiResponse::create(
|
|
[
|
|
'operation' => $operation,
|
|
'results' => $results,
|
|
'total' => count($results),
|
|
'successful' => count(array_filter($results, fn($r) => $r['status'] === 'success')),
|
|
'failed' => count(array_filter($results, fn($r) => $r['status'] === 'error')),
|
|
],
|
|
200,
|
|
$this->invalidationHeaders($tags),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /pages/reorganize - Reorganize multiple pages (move and/or reorder) atomically.
|
|
*
|
|
* Accepts an array of operations, each specifying a page route and optionally
|
|
* a new parent and/or position. All operations are validated before any
|
|
* filesystem changes are applied. Uses a two-phase temp-rename strategy
|
|
* to avoid conflicts.
|
|
*/
|
|
public function reorganize(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_WRITE);
|
|
$this->enablePages();
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['operations']);
|
|
|
|
$operations = $body['operations'];
|
|
if (!is_array($operations) || empty($operations)) {
|
|
throw new ValidationException("The 'operations' field must be a non-empty array.");
|
|
}
|
|
|
|
$maxBatch = $this->config->get('plugins.api.batch.max_items', 50);
|
|
if (count($operations) > $maxBatch) {
|
|
throw new ValidationException("Reorganize operations are limited to {$maxBatch} items.");
|
|
}
|
|
|
|
// --- Phase 1: Validate all operations ---
|
|
$resolved = [];
|
|
$seenRoutes = [];
|
|
$affectedParentRoutes = [];
|
|
|
|
foreach ($operations as $index => $op) {
|
|
if (!is_array($op) || !isset($op['route'])) {
|
|
throw new ValidationException("Operation at index {$index} must have a 'route' field.");
|
|
}
|
|
|
|
$route = '/' . trim($op['route'], '/');
|
|
|
|
if (isset($seenRoutes[$route])) {
|
|
throw new ValidationException("Duplicate route '{$route}' in operations.");
|
|
}
|
|
$seenRoutes[$route] = true;
|
|
|
|
$page = $this->grav['pages']->find($route);
|
|
if (!$page) {
|
|
throw new ValidationException("Page not found at route: {$route}");
|
|
}
|
|
|
|
$currentParentRoute = dirname($page->route()) ?: '/';
|
|
if ($currentParentRoute === '.') {
|
|
$currentParentRoute = '/';
|
|
}
|
|
$affectedParentRoutes[$currentParentRoute] = true;
|
|
|
|
// Resolve destination parent
|
|
$newParentRoute = null;
|
|
$newParentPath = null;
|
|
if (isset($op['parent'])) {
|
|
$newParentRoute = '/' . trim($op['parent'], '/');
|
|
if ($newParentRoute === '/') {
|
|
$newParentPath = $this->grav['locator']->findResource('page://', true);
|
|
} else {
|
|
$newParent = $this->grav['pages']->find($newParentRoute);
|
|
if (!$newParent) {
|
|
throw new ValidationException("Destination parent not found at route: {$newParentRoute} (operation index {$index}).");
|
|
}
|
|
$newParentPath = $newParent->path();
|
|
}
|
|
$affectedParentRoutes[$newParentRoute] = true;
|
|
|
|
// Prevent moving a page into its own subtree
|
|
if (str_starts_with($newParentRoute . '/', $route . '/')) {
|
|
throw new ValidationException("Cannot move '{$route}' into its own subtree '{$newParentRoute}'.");
|
|
}
|
|
} else {
|
|
// Stays under current parent
|
|
$newParentRoute = $currentParentRoute;
|
|
if ($currentParentRoute === '/') {
|
|
$newParentPath = $this->grav['locator']->findResource('page://', true);
|
|
} else {
|
|
$currentParent = $this->grav['pages']->find($currentParentRoute);
|
|
$newParentPath = $currentParent ? $currentParent->path() : null;
|
|
}
|
|
}
|
|
|
|
$position = isset($op['position']) ? (int) $op['position'] : null;
|
|
|
|
// Validate position conflicts: no two ops targeting same parent with same position
|
|
if ($position !== null) {
|
|
$posKey = $newParentRoute . ':' . $position;
|
|
foreach ($resolved as $prev) {
|
|
if ($prev['newParentRoute'] === $newParentRoute && $prev['position'] === $position) {
|
|
throw new ValidationException(
|
|
"Position conflict: both '{$prev['route']}' and '{$route}' target position {$position} under '{$newParentRoute}'."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Whether this op actually causes a path rename on disk. Position-
|
|
// unchanged + parent-unchanged ops are no-ops at the filesystem
|
|
// level — clients (e.g. the tree-view drag handler) emit them
|
|
// when renumbering all siblings of a drop target, even for
|
|
// siblings whose position didn't actually shift. Tracking these
|
|
// as "moved" below would falsely flag conflicts when one of those
|
|
// no-op siblings happens to be the source parent of another op.
|
|
$currentOrder = (int) $page->order();
|
|
$parentChanged = $newParentRoute !== $currentParentRoute;
|
|
$positionChanged = $position !== null && $position !== $currentOrder;
|
|
$actuallyMoves = $parentChanged || $positionChanged;
|
|
|
|
$resolved[] = [
|
|
'route' => $route,
|
|
'page' => $page,
|
|
// Strip leading dots so pages whose slug somehow starts with
|
|
// '.' don't get rebuilt into '04..slug' style folders.
|
|
// Matches the sanitization the single-page /move endpoint
|
|
// already applies.
|
|
'slug' => ltrim($page->slug(), '.'),
|
|
'oldPath' => $page->path(),
|
|
'currentParentRoute' => $currentParentRoute,
|
|
'newParentRoute' => $newParentRoute,
|
|
'newParentPath' => $newParentPath,
|
|
'position' => $position,
|
|
'actuallyMoves' => $actuallyMoves,
|
|
];
|
|
}
|
|
|
|
// Reject any op whose destination parent is itself being moved in
|
|
// this batch. Otherwise Phase 2 would rename the parent mid-batch,
|
|
// making Phase 3's rename($tempPath, $finalPath) fail because the
|
|
// captured newParentPath no longer exists on disk. Asking the
|
|
// client to drop these ops produces a clear error instead of the
|
|
// confusing "No such file or directory" surface.
|
|
//
|
|
// Only routes that actually rename on disk participate — a no-op
|
|
// renumber (position unchanged, parent unchanged) leaves the folder
|
|
// path intact, so it cannot invalidate a sibling op's newParentPath.
|
|
$movedRoutes = [];
|
|
foreach ($resolved as $op) {
|
|
if ($op['actuallyMoves']) {
|
|
$movedRoutes[$op['route']] = true;
|
|
}
|
|
}
|
|
foreach ($resolved as $index => $op) {
|
|
$parentRoute = $op['newParentRoute'];
|
|
// The parent itself, or any ancestor of it, being moved is
|
|
// unsafe — its on-disk path won't match newParentPath after
|
|
// Phase 2 renames.
|
|
$check = $parentRoute;
|
|
while ($check !== '/' && $check !== '') {
|
|
if (isset($movedRoutes[$check])) {
|
|
throw new ValidationException(
|
|
"Operation index {$index} targets parent '{$parentRoute}', but '{$check}' is also being moved in the same batch. Reorganize the parent first, or drop one of the ops."
|
|
);
|
|
}
|
|
$check = dirname($check);
|
|
if ($check === '.') {
|
|
$check = '/';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->fireEvent('onApiBeforePagesReorganize', ['operations' => $resolved]);
|
|
|
|
// --- Phase 2: Move to temp names ---
|
|
$completedRenames = [];
|
|
|
|
try {
|
|
foreach ($resolved as $index => &$op) {
|
|
$slug = $op['slug'];
|
|
$destParentPath = $op['newParentPath'];
|
|
$tempName = '_reorg_temp_' . $index . '_' . $slug;
|
|
$tempPath = $destParentPath . '/' . $tempName;
|
|
|
|
if (is_dir($op['oldPath'])) {
|
|
Folder::move($op['oldPath'], $tempPath);
|
|
$completedRenames[] = ['from' => $op['oldPath'], 'to' => $tempPath];
|
|
$op['tempPath'] = $tempPath;
|
|
} else {
|
|
$op['tempPath'] = null;
|
|
}
|
|
}
|
|
unset($op);
|
|
|
|
// --- Phase 3: Rename from temp to final names ---
|
|
foreach ($resolved as &$op) {
|
|
if (!$op['tempPath']) {
|
|
continue;
|
|
}
|
|
|
|
$slug = $op['slug'];
|
|
$position = $op['position'];
|
|
$destParentPath = $op['newParentPath'];
|
|
|
|
$dirName = $position !== null
|
|
? str_pad((string) $position, 2, '0', STR_PAD_LEFT) . '.' . $slug
|
|
: $slug;
|
|
|
|
$finalPath = $destParentPath . '/' . $dirName;
|
|
|
|
rename($op['tempPath'], $finalPath);
|
|
$completedRenames[] = ['from' => $op['tempPath'], 'to' => $finalPath];
|
|
$op['finalPath'] = $finalPath;
|
|
}
|
|
unset($op);
|
|
} catch (\Throwable $e) {
|
|
// Best-effort rollback: reverse completed renames
|
|
foreach (array_reverse($completedRenames) as $rename) {
|
|
if (is_dir($rename['to'])) {
|
|
try {
|
|
Folder::move($rename['to'], $rename['from']);
|
|
} catch (\Throwable) {
|
|
// Can't recover further
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new ValidationException("Reorganize failed during filesystem operations: {$e->getMessage()}");
|
|
}
|
|
|
|
$this->clearPagesCache();
|
|
$this->enablePages(true);
|
|
|
|
$this->fireEvent('onApiPagesReorganized', ['operations' => $resolved]);
|
|
|
|
// --- Phase 4: Build response with all affected pages ---
|
|
$affectedData = [];
|
|
foreach (array_keys($affectedParentRoutes) as $parentRoute) {
|
|
$parent = $parentRoute === '/'
|
|
? $this->grav['pages']->find('/')
|
|
: $this->grav['pages']->find($parentRoute);
|
|
|
|
if (!$parent) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($parent->children() as $child) {
|
|
$affectedData[] = [
|
|
'route' => $child->route(),
|
|
'slug' => $child->slug(),
|
|
'title' => $child->title(),
|
|
'order' => $child->order(),
|
|
'parent' => $parentRoute,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Emit one update/move tag per reorganized page plus list invalidation
|
|
$tags = ['pages:list'];
|
|
foreach ($resolved as $op) {
|
|
$tags[] = 'pages:move:' . $op['route'];
|
|
}
|
|
|
|
return ApiResponse::create(
|
|
$affectedData,
|
|
200,
|
|
$this->invalidationHeaders($tags),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* GET /taxonomy - List all taxonomy types and their values.
|
|
*/
|
|
public function taxonomy(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, self::PERMISSION_READ);
|
|
$this->enablePages();
|
|
|
|
$raw = $this->grav['taxonomy']->taxonomy();
|
|
|
|
// Simplify: return just taxonomy type => [values] without internal file paths
|
|
$taxonomy = [];
|
|
foreach ($raw as $type => $values) {
|
|
$taxonomy[$type] = array_keys($values);
|
|
}
|
|
|
|
return ApiResponse::create($taxonomy);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Private helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Enable the Pages subsystem. API disables pages on init for performance,
|
|
* so we re-enable when page endpoints are actually called.
|
|
*/
|
|
private function enablePages(bool $forceReinit = false): void
|
|
{
|
|
$pages = $this->grav['pages'];
|
|
|
|
if ($forceReinit) {
|
|
$pages->reset();
|
|
}
|
|
|
|
// Pages::enablePages() flips the flag and calls init()
|
|
$pages->enablePages();
|
|
}
|
|
|
|
/**
|
|
* Find a page by route or throw NotFoundException.
|
|
*/
|
|
private function findPageOrFail(string $route): PageInterface
|
|
{
|
|
$page = $this->grav['pages']->find($route);
|
|
|
|
if (!$page) {
|
|
throw new NotFoundException("Page not found at route: {$route}");
|
|
}
|
|
|
|
return $page;
|
|
}
|
|
|
|
/**
|
|
* Collect all page instances and apply filters.
|
|
*
|
|
* @param iterable<string, PageInterface> $instances
|
|
* @return list<PageInterface>
|
|
*/
|
|
private function collectAndFilterPages(iterable $instances, array $filters): array
|
|
{
|
|
$pages = [];
|
|
|
|
foreach ($instances as $page) {
|
|
// Skip the virtual pages-root container (no file on disk).
|
|
// The home page is a real file-backed page with route '/'.
|
|
if (!$page->route() || !$page->exists()) {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->matchesFilters($page, $filters)) {
|
|
continue;
|
|
}
|
|
|
|
$pages[] = $page;
|
|
}
|
|
|
|
return $pages;
|
|
}
|
|
|
|
/**
|
|
* Check if a page matches all active filters.
|
|
*/
|
|
private function matchesFilters(PageInterface $page, array $filters): bool
|
|
{
|
|
foreach ($filters as $filter => $value) {
|
|
$matches = match ($filter) {
|
|
'published' => $page->published() === filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
|
'template' => $page->template() === $value,
|
|
'routable' => $page->routable() === filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
|
'visible' => $page->visible() === filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
|
'parent' => str_starts_with($page->route(), '/' . trim($value, '/')),
|
|
'children_of' => $this->isDirectChildOf($page, $value),
|
|
// Root-level = direct child of the pages-root, resolved from the
|
|
// real hierarchy (see isDirectChildOf) so home-page children
|
|
// aren't mistaken for top-level pages.
|
|
'root' => filter_var($value, FILTER_VALIDATE_BOOLEAN) && $this->isDirectChildOf($page, '/'),
|
|
default => true,
|
|
};
|
|
|
|
if (!$matches) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if a page is a direct child of the given parent route.
|
|
*
|
|
* Resolves the relationship from Grav's real page hierarchy
|
|
* ($page->parent()) rather than by string-matching the public route. The
|
|
* home page's children have the home segment stripped from their public
|
|
* route (e.g. '/child' instead of '/home/child' when home.hide_in_urls is
|
|
* on), so a route-string comparison wrongly lists them as children of root
|
|
* — the cause of the tree/columns hierarchy bug
|
|
* (getgrav/grav-plugin-admin2#32). Comparing against the actual parent
|
|
* page, like admin-classic's tree does, keeps the hierarchy correct.
|
|
*/
|
|
private function isDirectChildOf(PageInterface $page, string $parentValue): bool
|
|
{
|
|
$parent = $page->parent();
|
|
if ($parent === null) {
|
|
// The virtual pages-root itself has no parent; it's nobody's child.
|
|
return false;
|
|
}
|
|
|
|
$parentRoute = '/' . trim($parentValue, '/');
|
|
|
|
if ($parentRoute === '/') {
|
|
// Direct child of root: the page's parent IS the pages-root. This
|
|
// correctly excludes children of the home page (whose parent is the
|
|
// home page, a real page), which would otherwise leak into root.
|
|
return $parent->root();
|
|
}
|
|
|
|
// Match the real parent by its structural route first (the home page's
|
|
// public route is '/', but its rawRoute is e.g. '/home'), then fall
|
|
// back to the public route for everything else.
|
|
return $parentRoute === $parent->rawRoute()
|
|
|| $parentRoute === $parent->route();
|
|
}
|
|
|
|
private function indexViaDefaultSort(ServerRequestInterface $request, string $parentRoute, array $filters, array $pagination): ResponseInterface
|
|
{
|
|
// Collect direct children and find parent using Flex or Pages service
|
|
$directory = $this->getFlexDirectory('pages');
|
|
$parent = null;
|
|
$childRoute = '/' . trim($filters['children_of'], '/');
|
|
|
|
$items = [];
|
|
if ($directory) {
|
|
foreach ($directory->getCollection() as $page) {
|
|
if (!$page instanceof PageInterface) {
|
|
continue;
|
|
}
|
|
// Match by rawRoute too: the home page's public route is '/'
|
|
// while the frontend asks for its structural route (e.g. '/home').
|
|
if ($page->route() === $childRoute || $page->rawRoute() === $childRoute) {
|
|
$parent = $page;
|
|
}
|
|
if ($this->isDirectChildOf($page, $filters['children_of'])) {
|
|
$items[] = $page;
|
|
}
|
|
}
|
|
} else {
|
|
$this->enablePages();
|
|
$parent = $this->grav['pages']->find($childRoute);
|
|
$allPages = $this->collectAndFilterPages($this->grav['pages']->instances(), $filters);
|
|
$items = $allPages;
|
|
}
|
|
|
|
// Check parent's collection ordering (e.g. blog ordered by date desc)
|
|
$collectionSort = null;
|
|
$collectionDir = 'asc';
|
|
if ($parent) {
|
|
$header = $parent->header();
|
|
|
|
// Use ->get() for nested dot-notation access (works for both stdClass and Header objects)
|
|
$getVal = function (string $key, $default = null) use ($header) {
|
|
if (method_exists($header, 'get')) {
|
|
return $header->get($key, $default);
|
|
}
|
|
// Fallback for stdClass: walk dot-path
|
|
$parts = explode('.', $key);
|
|
$current = $header;
|
|
foreach ($parts as $part) {
|
|
if (is_object($current) && isset($current->$part)) {
|
|
$current = $current->$part;
|
|
} elseif (is_array($current) && isset($current[$part])) {
|
|
$current = $current[$part];
|
|
} else {
|
|
return $default;
|
|
}
|
|
}
|
|
return $current;
|
|
};
|
|
|
|
$displayOrder = $getVal('admin.children_display_order', 'collection');
|
|
|
|
if ($displayOrder === 'collection') {
|
|
$collectionSort = $getVal('content.order.by');
|
|
$collectionDir = $getVal('content.order.dir', 'asc');
|
|
}
|
|
}
|
|
|
|
if ($collectionSort) {
|
|
// Use collection ordering from parent header
|
|
$sortField = match ($collectionSort) {
|
|
'title' => 'title',
|
|
'date' => 'date',
|
|
'modified', 'timestamp' => 'modified',
|
|
'slug', 'basename' => 'slug',
|
|
default => 'order',
|
|
};
|
|
$items = $this->sortPages($items, $sortField, $collectionDir);
|
|
} else {
|
|
// Filesystem order: ordered pages first (ascending), then unordered (alpha by slug)
|
|
$ordered = [];
|
|
$unordered = [];
|
|
foreach ($items as $page) {
|
|
// Flex pages return false for unordered folders and an int (incl. 0 for "00.")
|
|
// for ordered ones — so test for false explicitly, not truthiness.
|
|
if ($page->order() !== false) {
|
|
$ordered[] = $page;
|
|
} else {
|
|
$unordered[] = $page;
|
|
}
|
|
}
|
|
usort($ordered, function ($a, $b) {
|
|
return (int) $a->order() <=> (int) $b->order();
|
|
});
|
|
usort($unordered, function ($a, $b) {
|
|
return strcasecmp($a->slug() ?? '', $b->slug() ?? '');
|
|
});
|
|
$items = array_merge($ordered, $unordered);
|
|
}
|
|
|
|
$total = count($items);
|
|
$locatedAt = $this->applyLocate($items, $pagination, $request->getQueryParams()['locate'] ?? null);
|
|
$slice = array_slice($items, $pagination['offset'], $pagination['limit']);
|
|
|
|
$includeTranslations = filter_var(
|
|
$request->getQueryParams()['translations'] ?? false,
|
|
FILTER_VALIDATE_BOOLEAN
|
|
);
|
|
|
|
$data = $this->serializer->serializeCollection($slice, [
|
|
'include_content' => false,
|
|
'render_content' => false,
|
|
'include_children' => false,
|
|
'include_media' => false,
|
|
'include_translations' => $includeTranslations,
|
|
]);
|
|
|
|
return ApiResponse::paginated(
|
|
data: $data,
|
|
total: $total,
|
|
page: $pagination['page'],
|
|
perPage: $pagination['per_page'],
|
|
baseUrl: $this->getApiBaseUrl() . '/pages',
|
|
locatedAtIndex: $locatedAt,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* If `$locateRoute` is set, find that page's index in the (already-sorted)
|
|
* $items list and rewrite $pagination to point at the chunk containing it.
|
|
* Returns the absolute index of the located page, or null if not found /
|
|
* not requested. Locate takes precedence over any explicit `page` param —
|
|
* the contract is "give me the chunk holding this route".
|
|
*
|
|
* @param list<PageInterface> $items
|
|
* @param array{page:int,per_page:int,offset:int,limit:int} $pagination
|
|
*/
|
|
private function applyLocate(array $items, array &$pagination, ?string $locateRoute): ?int
|
|
{
|
|
if ($locateRoute === null || $locateRoute === '') {
|
|
return null;
|
|
}
|
|
$needle = '/' . trim($locateRoute, '/');
|
|
foreach ($items as $idx => $page) {
|
|
if (!$page instanceof PageInterface) {
|
|
continue;
|
|
}
|
|
if ($page->route() === $needle || $page->rawRoute() === $needle) {
|
|
$perPage = $pagination['per_page'];
|
|
$newPage = $perPage > 0 ? ((int) floor($idx / $perPage)) + 1 : 1;
|
|
$pagination['page'] = $newPage;
|
|
$pagination['offset'] = ($newPage - 1) * $perPage;
|
|
return $idx;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Sort pages by the given field and direction.
|
|
*
|
|
* @param list<PageInterface> $pages
|
|
* @return list<PageInterface>
|
|
*/
|
|
private function sortPages(array $pages, string $field, string $order): array
|
|
{
|
|
usort($pages, function (PageInterface $a, PageInterface $b) use ($field, $order): int {
|
|
$result = match ($field) {
|
|
'date' => ($a->date() ?? 0) <=> ($b->date() ?? 0),
|
|
'modified' => ($a->modified() ?? 0) <=> ($b->modified() ?? 0),
|
|
'title' => strcasecmp($a->title() ?? '', $b->title() ?? ''),
|
|
'slug' => strcmp($a->slug() ?? '', $b->slug() ?? ''),
|
|
'order' => ($a->order() ?? PHP_INT_MAX) <=> ($b->order() ?? PHP_INT_MAX),
|
|
default => 0,
|
|
};
|
|
|
|
return $order === 'desc' ? -$result : $result;
|
|
});
|
|
|
|
return $pages;
|
|
}
|
|
|
|
/**
|
|
* Batch helper: set published state on a page.
|
|
*/
|
|
private function batchPublish(PageInterface $page, bool $published): void
|
|
{
|
|
$header = $this->headerToArray($page->header());
|
|
$header['published'] = $published;
|
|
$page->header((object) $header);
|
|
$page->save();
|
|
}
|
|
|
|
/**
|
|
* Batch helper: delete a page.
|
|
*/
|
|
private function batchDelete(PageInterface $page): void
|
|
{
|
|
Folder::delete($page->path());
|
|
}
|
|
|
|
/**
|
|
* Batch helper: copy a page.
|
|
*/
|
|
private function batchCopy(PageInterface $page, array $options): void
|
|
{
|
|
$destParent = $options['destination'] ?? dirname($page->route());
|
|
$suffix = $options['suffix'] ?? '-copy';
|
|
$destSlug = $page->slug() . $suffix;
|
|
|
|
if ($destParent === '/') {
|
|
$destParentPath = $this->grav['locator']->findResource('page://', true);
|
|
} else {
|
|
$parent = $this->grav['pages']->find($destParent);
|
|
if (!$parent) {
|
|
throw new ValidationException("Destination parent not found: {$destParent}");
|
|
}
|
|
$destParentPath = $parent->path();
|
|
}
|
|
|
|
$destPath = $destParentPath . '/' . $destSlug;
|
|
if (is_dir($destPath)) {
|
|
throw new ValidationException("A page already exists at the copy destination for: {$page->route()}");
|
|
}
|
|
|
|
Folder::copy($page->path(), $destPath);
|
|
}
|
|
|
|
/**
|
|
* Clear the pages cache after a mutation.
|
|
*/
|
|
private function clearPagesCache(): void
|
|
{
|
|
$this->grav['cache']->clearCache('standard');
|
|
}
|
|
|
|
/**
|
|
* Resolve `order: "auto"` for a new page. Returns highest existing numeric
|
|
* prefix among direct children + 1, or null when no sibling carries a
|
|
* numeric prefix (so the new page stays unprefixed).
|
|
*/
|
|
private function nextOrderInParent(string $parentPath): ?int
|
|
{
|
|
if (!is_dir($parentPath)) {
|
|
return null;
|
|
}
|
|
|
|
$highest = 0;
|
|
$hasNumeric = false;
|
|
$dh = @opendir($parentPath);
|
|
if ($dh === false) {
|
|
return null;
|
|
}
|
|
try {
|
|
while (($entry = readdir($dh)) !== false) {
|
|
if ($entry === '.' || $entry === '..' || $entry[0] === '.') {
|
|
continue;
|
|
}
|
|
[$o] = PageOrdering::parse($entry);
|
|
if ($o !== null) {
|
|
$hasNumeric = true;
|
|
if ($o > $highest) {
|
|
$highest = $o;
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
closedir($dh);
|
|
}
|
|
|
|
return $hasNumeric ? $highest + 1 : null;
|
|
}
|
|
|
|
/**
|
|
* Widest existing order-prefix digit width across direct child folders of
|
|
* $parentPath. Returns null when no children carry a numeric prefix, so
|
|
* callers fall back to PageOrdering's configured default.
|
|
*
|
|
* Single readdir; no Page object instantiation. Safe for hot paths.
|
|
*/
|
|
private function siblingDigits(string $parentPath): ?int
|
|
{
|
|
if (!is_dir($parentPath)) {
|
|
return null;
|
|
}
|
|
|
|
$max = 0;
|
|
$dh = @opendir($parentPath);
|
|
if ($dh === false) {
|
|
return null;
|
|
}
|
|
try {
|
|
while (($entry = readdir($dh)) !== false) {
|
|
if ($entry === '.' || $entry === '..' || $entry[0] === '.') {
|
|
continue;
|
|
}
|
|
$w = PageOrdering::digitsFromFolder($entry);
|
|
if ($w !== null && $w > $max) {
|
|
$max = $w;
|
|
}
|
|
}
|
|
} finally {
|
|
closedir($dh);
|
|
}
|
|
|
|
return $max ?: null;
|
|
}
|
|
|
|
/**
|
|
* Apply language from ?lang= query parameter or an explicit language code.
|
|
* Returns the previous active language so it can be restored.
|
|
*/
|
|
private function applyLanguage(ServerRequestInterface $request, ?string $explicitLang = null): string|false
|
|
{
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
$previousLang = $language->getActive() ?? false;
|
|
|
|
$lang = $explicitLang ?? ($request->getQueryParams()['lang'] ?? null);
|
|
|
|
if ($lang !== null && $language->enabled()) {
|
|
$this->validateLanguageCode($lang);
|
|
|
|
$changed = $language->getActive() !== $lang;
|
|
$language->setActive($lang);
|
|
|
|
// Grav builds (and caches) the pages index for whichever language
|
|
// is active at init time; enablePages()/init() are then no-ops. If
|
|
// the index was already built for another language — or gets built
|
|
// later at the default — find()/save() resolve to the wrong
|
|
// translation file, so a PATCH/DELETE silently clobbers the default
|
|
// language and a GET returns the wrong content. Force a rebuild
|
|
// against the now-active language so route lookups target the
|
|
// requested translation. See getgrav/grav-plugin-api#6.
|
|
if ($changed) {
|
|
$pages = $this->grav['pages'];
|
|
$pages->enablePages();
|
|
$pages->reset();
|
|
}
|
|
}
|
|
|
|
return $previousLang;
|
|
}
|
|
|
|
/**
|
|
* Restore the previously active language.
|
|
*/
|
|
private function restoreLanguage(string|false $previousLang): void
|
|
{
|
|
if ($previousLang === false) {
|
|
// No language was active before — nothing to restore
|
|
return;
|
|
}
|
|
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
$language->setActive($previousLang);
|
|
}
|
|
|
|
/**
|
|
* Validate that a language code is configured in the site.
|
|
*/
|
|
private function validateLanguageCode(string $lang): void
|
|
{
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
|
|
if (!$language->enabled()) {
|
|
throw new ValidationException('Multi-language is not enabled on this site.');
|
|
}
|
|
|
|
if (!$language->validate($lang)) {
|
|
$supported = implode(', ', $language->getLanguages());
|
|
throw new ValidationException("Invalid language code '{$lang}'. Supported languages: {$supported}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if multi-language is enabled.
|
|
*/
|
|
private function isMultiLangEnabled(): bool
|
|
{
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
return $language->enabled();
|
|
}
|
|
|
|
/**
|
|
* Build the page filename with optional language extension.
|
|
* e.g., "default.md" or "default.fr.md".
|
|
*
|
|
* Templates may be namespaced with a directory prefix (e.g. modular
|
|
* templates come back from Grav core as `modular/hero`). Only the basename
|
|
* is used for the on-disk filename — the directory prefix is purely for
|
|
* template lookup. Without this, a modular sub-page would write a file at
|
|
* `<folder>/modular/hero.md` rather than the expected `<folder>/hero.md`.
|
|
*/
|
|
private function buildPageFilename(string $template, ?string $lang): string
|
|
{
|
|
$base = basename($template);
|
|
|
|
if ($lang === null || !$this->isMultiLangEnabled()) {
|
|
return $base . '.md';
|
|
}
|
|
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
|
|
// For the default language, use plain .md (Grav convention)
|
|
// unless include_default_lang is configured
|
|
$default = $language->getDefault();
|
|
$includeDefault = $this->grav['config']->get('system.languages.include_default_lang_file_extension', true);
|
|
|
|
if ($lang === $default && !$includeDefault) {
|
|
return $base . '.md';
|
|
}
|
|
|
|
return $base . '.' . $lang . '.md';
|
|
}
|
|
|
|
/**
|
|
* Delete only a specific language file for a page, preserving other translations.
|
|
*/
|
|
private function deleteLanguageFile(PageInterface $page, string $lang): void
|
|
{
|
|
$this->validateLanguageCode($lang);
|
|
|
|
$translated = $page->translatedLanguages();
|
|
if (!isset($translated[$lang])) {
|
|
throw new NotFoundException("No translation found for language '{$lang}' at route: {$page->route()}");
|
|
}
|
|
|
|
// If this is the only translation, delete the entire page directory
|
|
if (count($translated) <= 1) {
|
|
Folder::delete($page->path());
|
|
return;
|
|
}
|
|
|
|
// Find and delete the specific language file
|
|
$template = $page->template();
|
|
$pagePath = $page->path();
|
|
|
|
/** @var Language $language */
|
|
$language = $this->grav['language'];
|
|
$default = $language->getDefault();
|
|
|
|
// Try language-specific filename first, then plain .md for default lang
|
|
$candidates = [
|
|
$pagePath . '/' . $template . '.' . $lang . '.md',
|
|
];
|
|
|
|
if ($lang === $default) {
|
|
$candidates[] = $pagePath . '/' . $template . '.md';
|
|
}
|
|
|
|
foreach ($candidates as $filePath) {
|
|
if (file_exists($filePath)) {
|
|
unlink($filePath);
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new NotFoundException("Could not locate the language file for '{$lang}' at route: {$page->route()}");
|
|
}
|
|
|
|
/**
|
|
* Convert a page header into a plain array.
|
|
*
|
|
* Flex pages (Grav's default since 1.7) return a Header/Data object from
|
|
* header(), not a stdClass. Casting that object with (array) leaks its
|
|
* protected properties as NUL-prefixed keys ("\0*\0items",
|
|
* "\0*\0nestedSeparator"), which then get merged back in and persisted into
|
|
* the frontmatter — corrupting the file a little more on every save (see
|
|
* grav-plugin-admin2#31, triggered by Expert-mode frontmatter edits).
|
|
*
|
|
* Going through JSON invokes the object's jsonSerialize() and yields the
|
|
* clean field keys, matching how PageSerializer reads headers. Legacy pages
|
|
* (stdClass header) and already-plain arrays round-trip cleanly too.
|
|
*
|
|
* @param object|array|null $header
|
|
* @return array
|
|
*/
|
|
private function headerToArray($header): array
|
|
{
|
|
if ($header === null) {
|
|
return [];
|
|
}
|
|
if (is_array($header)) {
|
|
return $header;
|
|
}
|
|
return json_decode(json_encode($header), true) ?: [];
|
|
}
|
|
|
|
/**
|
|
* Validate the submitted page fields against the page blueprint.
|
|
*
|
|
* Page blueprints name their fields `header.*` (plus `content`, `slug`,
|
|
* `folder`), so we re-key the incoming body into that shape and let
|
|
* validateChangedFields() flatten + check only what was submitted. A flat
|
|
* `title` in the body maps to `header.title`.
|
|
*
|
|
* @param object $page The page being saved (legacy Page or Flex PageObject).
|
|
* @param array $body The request body / built create payload.
|
|
*/
|
|
private function validatePageChanges(object $page, array $body): void
|
|
{
|
|
if (!method_exists($page, 'getBlueprint')) {
|
|
return;
|
|
}
|
|
|
|
$changes = [];
|
|
if (array_key_exists('header', $body) && is_array($body['header'])) {
|
|
$changes['header'] = $body['header'];
|
|
}
|
|
if (array_key_exists('title', $body)) {
|
|
$changes['header']['title'] = $body['title'];
|
|
}
|
|
if (array_key_exists('content', $body)) {
|
|
$changes['content'] = $body['content'];
|
|
}
|
|
|
|
$this->validateChangedFields($changes, $page->getBlueprint());
|
|
}
|
|
|
|
/**
|
|
* Recursively strip null values from an array.
|
|
* Used to remove header fields that were toggled off (sent as null).
|
|
*/
|
|
private function stripNullValues(array $data): array
|
|
{
|
|
foreach ($data as $key => $value) {
|
|
if ($value === null) {
|
|
unset($data[$key]);
|
|
} elseif (is_array($value)) {
|
|
$data[$key] = $this->stripNullValues($value);
|
|
// Remove empty arrays left after stripping
|
|
if (empty($data[$key])) {
|
|
unset($data[$key]);
|
|
}
|
|
}
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Enforce the `security.twig_content.*` gate when a request touches a page
|
|
* with `process: { twig: true }` (either incoming, existing, or both).
|
|
*
|
|
* Three rejection cases:
|
|
* - REASON_DISABLED — site-wide gate is off; nobody can save twig:true.
|
|
* - REASON_PAGE_FORBIDDEN — page already has twig:true and the user can't edit it.
|
|
* - REASON_FORBIDDEN — user is trying to enable twig but lacks permission.
|
|
*
|
|
* The `admin.pages_twig` permission is deliberately named outside the
|
|
* `admin.pages` hierarchy so granting `admin.pages` does NOT implicitly
|
|
* grant twig-toggle (the Flex ACL walks parent prefixes).
|
|
*/
|
|
private function guardTwigContent(?PageInterface $existingPage, array $incomingHeader, UserInterface $user): void
|
|
{
|
|
$existingTwig = false;
|
|
if ($existingPage !== null) {
|
|
$existingHeader = $this->headerToArray($existingPage->header());
|
|
$existingTwig = (bool) (($existingHeader['process']['twig'] ?? false));
|
|
}
|
|
|
|
$incomingTwig = null;
|
|
if (array_key_exists('process', $incomingHeader) && is_array($incomingHeader['process'])
|
|
&& array_key_exists('twig', $incomingHeader['process'])) {
|
|
$incomingTwig = (bool) $incomingHeader['process']['twig'];
|
|
}
|
|
|
|
$touchesTwig = $existingTwig || $incomingTwig === true;
|
|
if (!$touchesTwig) {
|
|
return;
|
|
}
|
|
|
|
$config = $this->grav['config'];
|
|
|
|
if ((bool) $config->get('security.twig_content.process_enabled', false) === false) {
|
|
throw new TwigContentForbiddenException(TwigContentForbiddenException::REASON_DISABLED);
|
|
}
|
|
|
|
$editorEnabled = (bool) $config->get('security.twig_content.editor_enabled', false);
|
|
if ($editorEnabled) {
|
|
return;
|
|
}
|
|
|
|
if ($this->isSuperAdmin($user) || $this->hasPermission($user, 'admin.pages_twig')) {
|
|
return;
|
|
}
|
|
|
|
// Distinguish between "you can't edit this twig page" and "you can't
|
|
// enable twig" so the UI can render the right toast.
|
|
$reason = $existingTwig
|
|
? TwigContentForbiddenException::REASON_PAGE_FORBIDDEN
|
|
: TwigContentForbiddenException::REASON_FORBIDDEN;
|
|
throw new TwigContentForbiddenException($reason);
|
|
}
|
|
}
|