- The site icon is no longer a select at the end of the company data. It has its own section: a button opens the media library (upload included), then a square is dragged, resized by its corner, a slider or +/-, and previewed at 180 and 32 px. The crop is stored as fractions (faviconCrop) and the PNGs are cut from it; faviconVersion changes the link so browsers pick up a new cut. Icons without a crop keep their old link. - openMedia() takes needsAlt: false, since the icon never appears as content. The dialog styles moved from editor.css to media-dialog.css so the dialog works outside the editor. - Privacy page and imprint are chosen with the content search, like the 404 page. The picker can store the path instead of the id (valueKey/data-value). - The page and post lists in the administration sort newest first; the subpage indentation is gone there, as it would point at the wrong parent. Tests: 581 PHPUnit, 35 node, test:editor green (the consent step now picks the privacy page by search). test:admin --admin-only fails only at the known nested-interactive menu summaries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
510 lines
27 KiB
PHP
510 lines
27 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidDocument;
|
|
use App\Domain\InvalidPage;
|
|
use App\Domain\StaleWrite;
|
|
use Doctrine\DBAL\Connection;
|
|
|
|
final class PageRepository
|
|
{
|
|
/** Reserved because the application already answers under these paths. */
|
|
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege', 'einwilligung'];
|
|
public const MAX_DEPTH = 4;
|
|
|
|
public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings, private RichText $richText = new RichText())
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Creates a page with its first draft. The draft stays private until it is published.
|
|
*
|
|
* @return string the new page ID
|
|
*/
|
|
public function createPage(string $slug, ?string $parentId, string $type, array $document, string $locale = 'de'): string
|
|
{
|
|
$slug = strtolower(trim($slug));
|
|
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
|
|
throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
|
|
}
|
|
if (!in_array($type, ['page', 'post'], true)) {
|
|
throw new InvalidPage('Unbekannter Inhaltstyp.');
|
|
}
|
|
$document = $this->richText->normaliseDocument($document);
|
|
$this->validator->validate($document, writing: true);
|
|
|
|
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
|
|
$path = $slug;
|
|
if (null !== $parentId) {
|
|
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $locale]);
|
|
if (false === $parent) {
|
|
throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.');
|
|
}
|
|
if ('page' !== $parent['type']) {
|
|
throw new InvalidPage('Beiträge können keine Unterseiten enthalten.');
|
|
}
|
|
if (substr_count($parent['path'], '/') + 2 > self::MAX_DEPTH) {
|
|
throw new InvalidPage(sprintf('Mehr als %d Ebenen sind nicht vorgesehen.', self::MAX_DEPTH));
|
|
}
|
|
$path = $parent['path'].'/'.$slug;
|
|
} elseif (in_array($slug, self::RESERVED_SLUGS, true)) {
|
|
throw new InvalidPage('Diese Adresse ist für das CMS reserviert.');
|
|
}
|
|
if (false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', $locale, $path])) {
|
|
throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $path));
|
|
}
|
|
$pageId = bin2hex(random_bytes(16));
|
|
$revisionId = bin2hex(random_bytes(16));
|
|
$this->db->insert('page', [
|
|
'id' => $pageId, 'website_id' => 'default', 'locale' => $locale,
|
|
'slug' => $slug, 'path' => $path, 'parent_id' => $parentId, 'type' => $type,
|
|
'draft_revision_id' => $revisionId, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s'),
|
|
]);
|
|
$this->db->insert('revision', [
|
|
'id' => $revisionId, 'page_id' => $pageId,
|
|
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
|
'created_at' => gmdate('Y-m-d H:i:s'),
|
|
'seq' => 1,
|
|
]);
|
|
|
|
return $pageId;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Renames or moves a page and carries its whole subtree along.
|
|
*
|
|
* Every affected address gets a 301 from its old form, so a migrated or long lived site keeps
|
|
* every external link working. All of it happens in one transaction: a half moved subtree
|
|
* would leave pages unreachable.
|
|
*
|
|
* @return int the number of pages whose address changed
|
|
*/
|
|
public function movePage(string $pageId, string $slug, ?string $parentId, int $expectedVersion, RedirectRepository $redirects): int
|
|
{
|
|
$slug = strtolower(trim($slug));
|
|
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
|
|
throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
|
|
}
|
|
|
|
return $this->db->transactional(function () use ($pageId, $slug, $parentId, $expectedVersion, $redirects): int {
|
|
$page = $this->db->fetchAssociative('SELECT id, path, slug, parent_id, type, locale, version FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']);
|
|
if (false === $page) {
|
|
throw new InvalidPage('Diese Seite gibt es nicht mehr.');
|
|
}
|
|
if ((int) $page['version'] !== $expectedVersion) {
|
|
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
|
}
|
|
if ($page['path'] === $this->settings->homePath()) {
|
|
// The root answers at "/". Renaming it would move the whole site to a new address,
|
|
// which is a settings decision, not an editing one.
|
|
throw new InvalidPage('Diese Seite ist die Startseite. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.');
|
|
}
|
|
|
|
$newPath = $slug;
|
|
if (null !== $parentId) {
|
|
if ($parentId === $pageId) {
|
|
throw new InvalidPage('Eine Seite kann nicht ihre eigene übergeordnete Seite sein.');
|
|
}
|
|
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $page['locale']]);
|
|
if (false === $parent) {
|
|
throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.');
|
|
}
|
|
if ('page' !== $parent['type']) {
|
|
throw new InvalidPage('Beiträge können keine Unterseiten enthalten.');
|
|
}
|
|
// Moving a page under one of its own descendants would detach the whole subtree.
|
|
if ($parent['path'] === $page['path'] || str_starts_with($parent['path'].'/', $page['path'].'/')) {
|
|
throw new InvalidPage('Eine Seite kann nicht unter eine ihrer eigenen Unterseiten verschoben werden.');
|
|
}
|
|
$newPath = $parent['path'].'/'.$slug;
|
|
}
|
|
if ($newPath === $page['path']) {
|
|
return 0;
|
|
}
|
|
|
|
// Collect the subtree before anything changes, so the old addresses are still known.
|
|
$affected = $this->db->fetchAllAssociative(
|
|
'SELECT id, path FROM page WHERE website_id = ? AND locale = ? AND (path = ? OR path LIKE ?) ORDER BY path',
|
|
['default', $page['locale'], $page['path'], $page['path'].'/%'],
|
|
);
|
|
$depth = substr_count($newPath, '/') + 1;
|
|
foreach ($affected as $row) {
|
|
$moved = $newPath.substr($row['path'], strlen($page['path']));
|
|
if (substr_count($moved, '/') + 1 > self::MAX_DEPTH) {
|
|
throw new InvalidPage(sprintf('Mehr als %d Ebenen sind nicht vorgesehen; die Unterseiten würden zu tief liegen.', self::MAX_DEPTH));
|
|
}
|
|
$taken = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ? AND id <> ?', ['default', $page['locale'], $moved, $row['id']]);
|
|
if (false !== $taken) {
|
|
throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $moved));
|
|
}
|
|
}
|
|
unset($depth);
|
|
|
|
foreach ($affected as $row) {
|
|
$moved = $newPath.substr($row['path'], strlen($page['path']));
|
|
$this->db->executeStatement('UPDATE page SET path = ? WHERE id = ?', [$moved, $row['id']]);
|
|
$redirects->remember($row['path'], $moved);
|
|
}
|
|
$this->db->executeStatement('UPDATE page SET slug = ?, parent_id = ?, version = version + 1 WHERE id = ?', [$slug, $parentId, $pageId]);
|
|
|
|
return count($affected);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Moves a page into the bin. Its revisions stay; publicly it answers 404 and it disappears
|
|
* from menus, archives and the sitemap. Subpages go along, because leaving them behind would
|
|
* make them unreachable without their parent in the address.
|
|
*
|
|
* @return int the number of pages moved into the bin
|
|
*/
|
|
public function trashPage(string $pageId, int $expectedVersion): int
|
|
{
|
|
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
|
|
$page = $this->db->fetchAssociative('SELECT path, locale, version, deleted_at FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']);
|
|
if (false === $page || null !== $page['deleted_at']) {
|
|
throw new InvalidPage('Diese Seite ist nicht mehr vorhanden.');
|
|
}
|
|
if ((int) $page['version'] !== $expectedVersion) {
|
|
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
|
}
|
|
if ($page['path'] === $this->settings->homePath()) {
|
|
throw new InvalidPage('Die Startseite kann nicht in den Papierkorb. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.');
|
|
}
|
|
|
|
return $this->db->executeStatement(
|
|
'UPDATE page SET deleted_at = ?, version = version + 1 WHERE website_id = ? AND locale = ? AND (path = ? OR path LIKE ?) AND deleted_at IS NULL',
|
|
[gmdate('Y-m-d H:i:s'), 'default', $page['locale'], $page['path'], $page['path'].'/%'],
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Takes a page out of the bin. Its ancestors have to be present, or the address would break. */
|
|
public function restorePage(string $pageId): void
|
|
{
|
|
$this->db->transactional(function () use ($pageId): void {
|
|
$page = $this->db->fetchAssociative('SELECT path, parent_id, locale FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NOT NULL', [$pageId, 'default']);
|
|
if (false === $page) {
|
|
throw new InvalidPage('Diese Seite liegt nicht im Papierkorb.');
|
|
}
|
|
if (null !== $page['parent_id']) {
|
|
$parent = $this->db->fetchOne('SELECT id FROM page WHERE id = ? AND deleted_at IS NULL', [$page['parent_id']]);
|
|
if (false === $parent) {
|
|
throw new InvalidPage('Stelle zuerst die übergeordnete Seite wieder her.');
|
|
}
|
|
}
|
|
$this->db->executeStatement('UPDATE page SET deleted_at = NULL, version = version + 1 WHERE id = ?', [$pageId]);
|
|
});
|
|
}
|
|
|
|
/** @return list<array{id: string, path: string, deletedAt: string}> */
|
|
public function trash(string $locale = 'de'): array
|
|
{
|
|
return array_map(static fn (array $row): array => ['id' => $row['id'], 'path' => $row['path'], 'deletedAt' => $row['deleted_at']], $this->db->fetchAllAssociative(
|
|
'SELECT id, path, deleted_at FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC',
|
|
['default', $locale],
|
|
));
|
|
}
|
|
|
|
/** @return list<array{id: string, createdAt: string, title: string, published: bool, draft: bool}> */
|
|
/**
|
|
* One page of the history, newest first.
|
|
*
|
|
* It used to stop at thirty without saying so - which looks like "there are thirty" and is
|
|
* the one thing a history must not do.
|
|
*
|
|
* @return array{items: list<array>, page: int, pages: int, total: int}
|
|
*/
|
|
public function revisions(string $pageId, int $number = 1, int $perPage = 25): array
|
|
{
|
|
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$pageId]), $perPage);
|
|
$page = $this->db->fetchAssociative('SELECT published_revision_id, draft_revision_id FROM page WHERE id = ?', [$pageId]);
|
|
|
|
$items = array_map(static function (array $row) use ($page): array {
|
|
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
|
|
|
return [
|
|
'id' => $row['id'],
|
|
'createdAt' => $row['created_at'],
|
|
'title' => $document['title'] ?? '',
|
|
'published' => $row['id'] === ($page['published_revision_id'] ?? null),
|
|
'draft' => $row['id'] === ($page['draft_revision_id'] ?? null),
|
|
];
|
|
}, $this->db->fetchAllAssociative(
|
|
'SELECT id, created_at, document FROM revision WHERE page_id = ? ORDER BY seq DESC LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
|
|
[$pageId],
|
|
));
|
|
|
|
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
|
|
}
|
|
|
|
/**
|
|
* Makes an earlier revision the current draft by storing its document again. Nothing is
|
|
* overwritten in place: the restored state becomes a new revision, so the history stays whole.
|
|
*/
|
|
public function restoreRevision(string $pageId, string $revisionId, int $expectedVersion): string
|
|
{
|
|
return $this->saveDraft($pageId, $this->revisionDocument($pageId, $revisionId), $expectedVersion);
|
|
}
|
|
|
|
/** Das Dokument einer Fassung dieser Seite - und nur dieser Seite. */
|
|
public function revisionDocument(string $pageId, string $revisionId): array
|
|
{
|
|
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
|
|
if (false === $raw) {
|
|
throw new InvalidPage('Diese Fassung gehört nicht zu dieser Seite.');
|
|
}
|
|
|
|
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/**
|
|
* Publishes every page whose scheduled moment has passed. Run from cron; without it a
|
|
* scheduled page simply stays a draft, which is the safe direction.
|
|
*
|
|
* @return list<string> the paths that went public
|
|
*/
|
|
public function publishDue(): array
|
|
{
|
|
$published = [];
|
|
foreach ($this->db->fetchAllAssociative(
|
|
'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)',
|
|
['default', gmdate('Y-m-d H:i:s')],
|
|
) as $row) {
|
|
try {
|
|
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
|
|
} catch (InvalidPage|InvalidDocument|StaleWrite|\InvalidArgumentException $e) {
|
|
// Eine Seite, deren Fassung heute nicht mehr gilt, bleibt Entwurf - und haelt die
|
|
// anderen nicht auf. Der Grund steht im Protokoll, die Seite behaelt ihren Termin.
|
|
error_log(sprintf('Geplante Veröffentlichung von /%s zurückgestellt: %s', $row['path'], $e->getMessage()));
|
|
|
|
continue;
|
|
}
|
|
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
|
|
$published[] = $row['path'];
|
|
}
|
|
|
|
return $published;
|
|
}
|
|
|
|
public function schedule(string $pageId, string $when, int $expectedVersion): void
|
|
{
|
|
$when = trim($when);
|
|
if ('' !== $when && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/D', $when)) {
|
|
throw new InvalidPage('Der Zeitpunkt braucht die Form JJJJ-MM-TT HH:MM.');
|
|
}
|
|
$changed = $this->db->executeStatement(
|
|
'UPDATE page SET publish_at = ?, version = version + 1 WHERE id = ? AND version = ?',
|
|
['' === $when ? null : str_replace('T', ' ', $when).':00', $pageId, $expectedVersion],
|
|
);
|
|
if (1 !== $changed) {
|
|
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a page and its revisions. Pages with children stay untouched so no content
|
|
* disappears silently; the editor has to deal with the children first.
|
|
*/
|
|
public function deletePage(string $pageId, int $expectedVersion): void
|
|
{
|
|
$this->db->transactional(function () use ($pageId, $expectedVersion): void {
|
|
$page = $this->db->fetchAssociative('SELECT version FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']);
|
|
if (false === $page) {
|
|
throw new InvalidPage('Diese Seite gibt es nicht mehr.');
|
|
}
|
|
if ((int) $page['version'] !== $expectedVersion) {
|
|
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
|
}
|
|
if (0 !== (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE parent_id = ?', [$pageId])) {
|
|
throw new InvalidPage('Diese Seite hat Unterseiten. Verschiebe oder lösche zuerst die Unterseiten.');
|
|
}
|
|
// Detach both pointers first: the revision rows are referenced by the page row.
|
|
$this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]);
|
|
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
|
|
$this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]);
|
|
$this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]);
|
|
});
|
|
}
|
|
|
|
public function saveDraft(string $pageId, array $document, int $expectedVersion): string
|
|
{
|
|
// Sanitising happens before validating and before storing, so the database never holds
|
|
// markup the allowlist would reject and no renderer has to remember to filter.
|
|
$document = $this->richText->normaliseDocument($document);
|
|
$this->validator->validate($document, writing: true);
|
|
|
|
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
|
|
$id = bin2hex(random_bytes(16));
|
|
$changed = $this->db->executeStatement(
|
|
'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?',
|
|
[$id, $pageId, $expectedVersion],
|
|
);
|
|
if (1 !== $changed) {
|
|
throw new StaleWrite('The page changed. Reload before saving.');
|
|
}
|
|
$this->db->insert('revision', [
|
|
'id' => $id, 'page_id' => $pageId,
|
|
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
|
'created_at' => gmdate('Y-m-d H:i:s'),
|
|
// Strictly increasing per page, because several saves share one second.
|
|
'seq' => 1 + (int) $this->db->fetchOne('SELECT COALESCE(MAX(seq), 0) FROM revision WHERE page_id = ?', [$pageId]),
|
|
]);
|
|
|
|
return $id;
|
|
});
|
|
}
|
|
|
|
public function publish(string $pageId, string $revisionId, int $expectedVersion): void
|
|
{
|
|
$this->db->transactional(function () use ($pageId, $revisionId, $expectedVersion): void {
|
|
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
|
|
if (false === $raw) {
|
|
throw new \InvalidArgumentException('Revision does not belong to this page.');
|
|
}
|
|
$this->validator->validate(json_decode($raw, true, 512, JSON_THROW_ON_ERROR));
|
|
$changed = $this->db->executeStatement(
|
|
'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
|
[$revisionId, $pageId, $expectedVersion],
|
|
);
|
|
if (1 !== $changed) {
|
|
throw new StaleWrite('The page changed. Reload before publishing.');
|
|
}
|
|
// A post that goes public without its own date gets one, so the archive can order it.
|
|
$this->db->executeStatement(
|
|
"UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL",
|
|
[gmdate('Y-m-d H:i:s'), $pageId],
|
|
);
|
|
});
|
|
}
|
|
|
|
public function findPublished(string $path, string $locale = 'de'): ?array
|
|
{
|
|
$page = $this->db->fetchAssociative(
|
|
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
|
|
[$path, $locale, 'default'],
|
|
);
|
|
if (false === $page) {
|
|
return null;
|
|
}
|
|
$page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
|
|
$this->validator->validate($page['document']);
|
|
|
|
return $page;
|
|
}
|
|
|
|
/**
|
|
* Which header and footer a page wants: null for the standard one, 'none' for no area at all,
|
|
* or the address of another one.
|
|
*/
|
|
public function saveAreas(string $pageId, ?string $header, ?string $footer, int $expectedVersion): void
|
|
{
|
|
foreach ([$header, $footer] as $choice) {
|
|
if (null !== $choice && 'none' !== $choice && 1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $choice)) {
|
|
throw new InvalidPage('Die Adresse eines Bereichs darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
|
|
}
|
|
}
|
|
$changed = $this->db->executeStatement(
|
|
'UPDATE page SET header_area = ?, footer_area = ?, version = version + 1 WHERE id = ? AND version = ?',
|
|
[$header, $footer, $pageId, $expectedVersion],
|
|
);
|
|
if (1 !== $changed) {
|
|
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Seite neu.');
|
|
}
|
|
}
|
|
|
|
/** A published page by ID, used to serve its own script file. */
|
|
public function findPublishedById(string $id, string $locale = 'de'): ?array
|
|
{
|
|
$row = $this->db->fetchAssociative(
|
|
'SELECT p.id, p.path, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
|
|
[$id, $locale, 'default'],
|
|
);
|
|
if (false === $row) {
|
|
return null;
|
|
}
|
|
$row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
|
|
|
return $row;
|
|
}
|
|
|
|
public function listForEditor(): array
|
|
{
|
|
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
|
|
}
|
|
|
|
/** Only the requested screenful is hydrated; filters and counts run in the database. */
|
|
public function editorPage(string $type, int $page = 1, int $limit = 20, string $query = '', string $status = 'all'): array
|
|
{
|
|
$limit = in_array($limit, [20, 50], true) ? $limit : 20;
|
|
$type = 'post' === $type ? 'post' : 'page';
|
|
$qb = $this->db->createQueryBuilder()->from('page', 'p')
|
|
->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id')
|
|
->where('p.website_id = :website')->andWhere('p.type = :type')
|
|
->setParameter('website', 'default')->setParameter('type', $type);
|
|
$qb->andWhere('trash' === $status ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL');
|
|
if ('draft' === $status) {
|
|
$qb->andWhere('p.published_revision_id IS NULL');
|
|
} elseif ('published' === $status) {
|
|
$qb->andWhere('p.published_revision_id IS NOT NULL');
|
|
} elseif ('changed' === $status) {
|
|
$qb->andWhere('p.published_revision_id IS NOT NULL AND p.published_revision_id <> p.draft_revision_id');
|
|
}
|
|
if ('' !== trim($query)) {
|
|
$qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')')
|
|
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
|
|
}
|
|
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
|
|
$pages = max(1, (int) ceil($total / $limit));
|
|
$page = max(1, min($pages, $page));
|
|
$items = $qb->select('p.*', 'r.document')->orderBy('p.created_at', 'DESC')
|
|
// Neueste zuerst, bei Seiten wie bei Beiträgen (Umut: „absteigend also neueste zuerst“).
|
|
->addOrderBy('p.id', 'DESC')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
|
|
|
|
return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit];
|
|
}
|
|
|
|
/** Bounded suggestions for menus and parent selectors, including a selected ID lookup. */
|
|
public function choices(string $query = '', string $type = '', bool $parentsOnly = false, ?string $id = null): array
|
|
{
|
|
$qb = $this->db->createQueryBuilder()->select('p.id', 'p.path', 'p.type', 'p.published_revision_id', 'r.document')
|
|
->from('page', 'p')->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id')
|
|
->where('p.website_id = :website')->andWhere('p.deleted_at IS NULL')->setParameter('website', 'default');
|
|
if (null !== $id) {
|
|
$qb->andWhere('p.id = :id')->setParameter('id', $id);
|
|
}
|
|
if ($parentsOnly || in_array($type, ['page', 'post'], true)) {
|
|
$qb->andWhere('p.type = :type')->setParameter('type', $parentsOnly ? 'page' : $type);
|
|
}
|
|
if ($parentsOnly) {
|
|
$qb->andWhere("LENGTH(p.path) - LENGTH(REPLACE(p.path, '/', '')) < ".(self::MAX_DEPTH - 1));
|
|
}
|
|
if ('' !== trim($query)) {
|
|
$qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')')
|
|
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
|
|
}
|
|
$rows = $qb->orderBy('p.path')->addOrderBy('p.id')->setMaxResults(20)->executeQuery()->fetchAllAssociative();
|
|
|
|
return array_map(static fn (array $row): array => [
|
|
'id' => $row['id'], 'path' => $row['path'], 'type' => $row['type'],
|
|
'title' => json_decode($row['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $row['path'],
|
|
'published' => null !== $row['published_revision_id'],
|
|
], $rows);
|
|
}
|
|
|
|
public function findForEditor(string $id): ?array
|
|
{
|
|
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$id, 'default']);
|
|
if (false === $row) {
|
|
return null;
|
|
}
|
|
$row['version'] = (int) $row['version'];
|
|
$row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
|
$this->validator->validate($row['document']);
|
|
|
|
return $row;
|
|
}
|
|
}
|