Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
86.94% |
466 / 536 |
|
59.46% |
22 / 37 |
CRAP | |
0.00% |
0 / 1 |
| Editor | |
86.94% |
466 / 536 |
|
59.46% |
22 / 37 |
213.61 | |
0.00% |
0 / 1 |
| edit | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
2 | |||
| create | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| save | |
97.96% |
48 / 49 |
|
0.00% |
0 / 1 |
10 | |||
| discard | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
3.00 | |||
| savesAsDraft | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| draftFacts | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| nodeStore | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| store | |
61.76% |
21 / 34 |
|
0.00% |
0 / 1 |
13.53 | |||
| delete | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
5 | |||
| paths | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
12 | |||
| createPaths | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
3.03 | |||
| blocks | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
| createBlocks | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| blocksPreview | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
6 | |||
| complete | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| refuseIncomplete | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| issues | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| applyForm | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| applySettings | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
11 | |||
| previewPath | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| blueprint | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
7 | |||
| submittedUid | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| origin | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
8 | |||
| embed | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
| embedFields | |
65.00% |
13 / 20 |
|
0.00% |
0 / 1 |
10.74 | |||
| embedContent | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
| types | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| queryState | |
90.91% |
30 / 33 |
|
0.00% |
0 / 1 |
13.13 | |||
| generatedPaths | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| pathSourceFields | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| editorContext | |
82.76% |
24 / 29 |
|
0.00% |
0 / 1 |
4.08 | |||
| nodeMeta | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| displayDate | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
5.25 | |||
| userLabel | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
7.48 | |||
| intParam | |
72.73% |
8 / 11 |
|
0.00% |
0 / 1 |
7.99 | |||
| openParam | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| stringParam | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Controller\Panel; |
| 6 | |
| 7 | use Celema\Core\Exception\HttpBadRequest; |
| 8 | use Celema\Core\Exception\HttpConflict; |
| 9 | use Celema\Core\Exception\HttpNotFound; |
| 10 | use Celema\Core\Factory\Factory; |
| 11 | use Celema\Core\Response; |
| 12 | use Celema\Sire\Issue; |
| 13 | use Cosray\Api\PreviewToken; |
| 14 | use Cosray\Bootstrap; |
| 15 | use Cosray\Cms; |
| 16 | use Cosray\Collection\Listing; |
| 17 | use Cosray\Context; |
| 18 | use Cosray\Exception\NoSuchField; |
| 19 | use Cosray\Node\Factory as NodeFactory; |
| 20 | use Cosray\Node\PathManager; |
| 21 | use Cosray\Node\ReservedPaths; |
| 22 | use Cosray\Node\RoutePathGenerator; |
| 23 | use Cosray\Node\Serializer; |
| 24 | use Cosray\Node\Store; |
| 25 | use Cosray\Node\Types; |
| 26 | use Cosray\Node\Wrapper; |
| 27 | use Cosray\Panel\CollectionQuery; |
| 28 | use Cosray\Panel\CollectionUrls; |
| 29 | use Cosray\Panel\FormPatch; |
| 30 | use Cosray\Panel\NodeUrls; |
| 31 | use Cosray\Panel\System; |
| 32 | use Cosray\Richtext\Normalizer; |
| 33 | use Cosray\Value\Blocks as BlocksValue; |
| 34 | use DateTimeImmutable; |
| 35 | use IntlDateFormatter; |
| 36 | use Throwable; |
| 37 | |
| 38 | final class Editor extends Panel |
| 39 | { |
| 40 | private const int LIMIT_DEFAULT = 50; |
| 41 | private const int LIMIT_MAX = 250; |
| 42 | |
| 43 | /** @return array<string, mixed> */ |
| 44 | public function edit(Context $context, Cms $cms, string $node): array |
| 45 | { |
| 46 | $result = $cms->node->working($node); |
| 47 | |
| 48 | if (!$result) { |
| 49 | throw new HttpNotFound($this->request); |
| 50 | } |
| 51 | |
| 52 | $nodeObj = Wrapper::unwrap($result); |
| 53 | $serializer = new Serializer( |
| 54 | $this->types(), |
| 55 | $cms->nodeFactory()->uid(), |
| 56 | $context->assets(), |
| 57 | $context->paths(), |
| 58 | ); |
| 59 | $data = $serializer->read( |
| 60 | $nodeObj, |
| 61 | NodeFactory::dataFor($nodeObj), |
| 62 | NodeFactory::fieldNamesFor($nodeObj), |
| 63 | ); |
| 64 | |
| 65 | return $this->editorContext( |
| 66 | mode: 'edit', |
| 67 | node: $this->embedFields($data, $this->origin()['embed']), |
| 68 | context: $context, |
| 69 | generatedPaths: $this->generatedPaths($context, $nodeObj, $data), |
| 70 | pathSourceFields: $this->pathSourceFields($context, $nodeObj), |
| 71 | meta: $this->nodeMeta($data), |
| 72 | ); |
| 73 | } |
| 74 | |
| 75 | /** @return array<string, mixed> */ |
| 76 | public function create(Context $context, Cms $cms, string $type): array |
| 77 | { |
| 78 | [$nodeObj, $data] = $this->blueprint($cms, $context, $type); |
| 79 | $embed = $this->origin()['embed']; |
| 80 | |
| 81 | if ($embed !== null) { |
| 82 | $data = $this->embedFields($this->embedContent($data), $embed); |
| 83 | } |
| 84 | |
| 85 | return $this->editorContext( |
| 86 | mode: 'create', |
| 87 | node: $data, |
| 88 | context: $context, |
| 89 | generatedPaths: $this->generatedPaths($context, $nodeObj, $data), |
| 90 | pathSourceFields: $this->pathSourceFields($context, $nodeObj), |
| 91 | ); |
| 92 | } |
| 93 | |
| 94 | /** @return Response|array<string, mixed> */ |
| 95 | public function save( |
| 96 | Context $context, |
| 97 | Cms $cms, |
| 98 | Factory $factory, |
| 99 | string $node, |
| 100 | ): Response|array { |
| 101 | $result = $cms->node->working($node); |
| 102 | |
| 103 | if (!$result) { |
| 104 | throw new HttpNotFound($this->request); |
| 105 | } |
| 106 | |
| 107 | $nodeObj = Wrapper::unwrap($result); |
| 108 | $serializer = new Serializer( |
| 109 | $this->types(), |
| 110 | $cms->nodeFactory()->uid(), |
| 111 | $context->assets(), |
| 112 | $context->paths(), |
| 113 | ); |
| 114 | $data = $serializer->read( |
| 115 | $nodeObj, |
| 116 | NodeFactory::dataFor($nodeObj), |
| 117 | NodeFactory::fieldNamesFor($nodeObj), |
| 118 | ); |
| 119 | |
| 120 | $form = $this->formData(); |
| 121 | |
| 122 | if (!$this->complete($form)) { |
| 123 | return $this->refuseIncomplete($data); |
| 124 | } |
| 125 | |
| 126 | $data = $this->applyForm($data, $form); |
| 127 | $store = $this->nodeStore($context, $cms); |
| 128 | $links = $this->origin()['links']; |
| 129 | $htmx = $this->request->hasHeader('HX-Request'); |
| 130 | |
| 131 | try { |
| 132 | if ($this->savesAsDraft($nodeObj, $data, $form)) { |
| 133 | $store->draft($nodeObj, $data, $context->locales(), $this->actor()); |
| 134 | } else { |
| 135 | $store->publish($nodeObj, $data, $context->locales(), $this->actor()); |
| 136 | } |
| 137 | } catch (HttpBadRequest $e) { |
| 138 | if (!$htmx) { |
| 139 | // Non-htmx fallback follows the PRG pattern; errors are |
| 140 | // reported through the htmx path the panel always uses. |
| 141 | return Response::create($factory)->redirect($links->edit($node), 303); |
| 142 | } |
| 143 | |
| 144 | $payload = is_array($e->payload()) ? $e->payload() : []; |
| 145 | |
| 146 | return [ |
| 147 | 'saved' => false, |
| 148 | 'message' => (string) ($payload['message'] ?? __('node:invalid-data')), |
| 149 | 'errors' => $this->issues($payload), |
| 150 | 'published' => (bool) ($data['published'] ?? false), |
| 151 | 'renderable' => (bool) ($data['type']['renderable'] ?? false), |
| 152 | 'preview' => null, |
| 153 | ]; |
| 154 | } |
| 155 | |
| 156 | if (!$htmx) { |
| 157 | return Response::create($factory)->redirect($links->edit($node), 303); |
| 158 | } |
| 159 | |
| 160 | $draft = $this->draftFacts($cms->node->working($node)?->meta->get('draft')); |
| 161 | |
| 162 | return [ |
| 163 | 'saved' => true, |
| 164 | 'message' => $draft === null ? __('editor:saved') : __('editor:changes-saved'), |
| 165 | 'errors' => [], |
| 166 | 'published' => (bool) ($data['published'] ?? false), |
| 167 | 'renderable' => (bool) ($data['type']['renderable'] ?? false), |
| 168 | 'preview' => ($form['preview'] ?? null) === '1' ? $this->previewPath($node) : null, |
| 169 | 'draft' => $draft, |
| 170 | ]; |
| 171 | } |
| 172 | |
| 173 | public function discard( |
| 174 | Context $context, |
| 175 | Cms $cms, |
| 176 | Factory $factory, |
| 177 | string $node, |
| 178 | ): Response { |
| 179 | $result = $cms->node->working($node); |
| 180 | |
| 181 | if (!$result) { |
| 182 | throw new HttpNotFound($this->request); |
| 183 | } |
| 184 | |
| 185 | $links = $this->origin()['links']; |
| 186 | $this->nodeStore($context, $cms)->discard(Wrapper::unwrap($result)); |
| 187 | $response = Response::create($factory); |
| 188 | |
| 189 | if (!$this->request->hasHeader('HX-Request')) { |
| 190 | return $response->redirect($links->edit($node), 303); |
| 191 | } |
| 192 | |
| 193 | // A followed redirect would land on the discard form's hx-swap="none" |
| 194 | // and render nothing. HX-Location has htmx fetch the editor from the |
| 195 | // live row into the main region itself; sourced from the discard |
| 196 | // form, so the unsaved-changes guard stands aside for that request, |
| 197 | // and without the form's confirmation, which the POST already asked. |
| 198 | return $response->header('HX-Location', json_encode([ |
| 199 | 'path' => $links->edit($node), |
| 200 | 'target' => '#main', |
| 201 | 'swap' => 'innerHTML show:top', |
| 202 | 'source' => '#node-editor-discard', |
| 203 | 'confirm' => false, |
| 204 | 'replace' => 'true', |
| 205 | ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * A published node that stays published takes a plain save as its |
| 210 | * working copy. Everything else — an unpublished node, the switch |
| 211 | * turned off, or an explicit publish — writes the live row. |
| 212 | * |
| 213 | * @param array<string, mixed> $data |
| 214 | * @param array<array-key, mixed> $form |
| 215 | */ |
| 216 | private function savesAsDraft(object $node, array $data, array $form): bool |
| 217 | { |
| 218 | return ( |
| 219 | (bool) ($data['type']['renderable'] ?? false) |
| 220 | && (bool) NodeFactory::meta($node, 'published') |
| 221 | && (bool) ($data['published'] ?? false) |
| 222 | && ($form['publish'] ?? null) !== '1' |
| 223 | ); |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * The working copy's facts for the editor: when it diverged, and who |
| 228 | * last saved it. |
| 229 | * |
| 230 | * @return ?array{since: ?string, editor: ?string} |
| 231 | */ |
| 232 | private function draftFacts(mixed $draft): ?array |
| 233 | { |
| 234 | if (!is_array($draft)) { |
| 235 | return null; |
| 236 | } |
| 237 | |
| 238 | $editor = $draft['editorName'] ?? null; |
| 239 | |
| 240 | return [ |
| 241 | 'since' => $this->displayDate($draft['created'] ?? null), |
| 242 | 'editor' => is_string($editor) && trim($editor) !== '' ? $editor : null, |
| 243 | ]; |
| 244 | } |
| 245 | |
| 246 | private function nodeStore(Context $context, Cms $cms): Store |
| 247 | { |
| 248 | return new Store( |
| 249 | $context->db, |
| 250 | new PathManager(ReservedPaths::fromConfig($context->config)), |
| 251 | $this->types(), |
| 252 | $cms->nodeFactory()->uid(), |
| 253 | factory: $cms->nodeFactory(), |
| 254 | cms: $cms, |
| 255 | context: $context, |
| 256 | ); |
| 257 | } |
| 258 | |
| 259 | /** @return Response|array<string, mixed> */ |
| 260 | public function store( |
| 261 | Context $context, |
| 262 | Cms $cms, |
| 263 | Factory $factory, |
| 264 | string $type, |
| 265 | ): Response|array { |
| 266 | [$nodeObj, $data] = $this->blueprint($cms, $context, $type); |
| 267 | |
| 268 | $form = $this->formData(); |
| 269 | |
| 270 | if (!$this->complete($form)) { |
| 271 | return $this->refuseIncomplete($data); |
| 272 | } |
| 273 | |
| 274 | $patch = new FormPatch($data['fields']); |
| 275 | $submitted = $form['content'] ?? []; |
| 276 | $data['content'] = $patch->content( |
| 277 | $data['content'], |
| 278 | is_array($submitted) ? $submitted : [], |
| 279 | ); |
| 280 | $data = $this->applySettings($data, $form); |
| 281 | |
| 282 | $uid = $this->submittedUid($form); |
| 283 | |
| 284 | if ($uid !== null) { |
| 285 | // Honour the uid the create form pre-generated so media uploaded |
| 286 | // to node/<uid>/ before the first save resolves to the node. |
| 287 | $data['uid'] = $uid; |
| 288 | } |
| 289 | |
| 290 | $store = $this->nodeStore($context, $cms); |
| 291 | $links = $this->origin()['links']; |
| 292 | |
| 293 | try { |
| 294 | $result = $store->create($nodeObj, $data, $context->locales(), $this->actor()); |
| 295 | } catch (HttpBadRequest $e) { |
| 296 | if (!$this->request->hasHeader('HX-Request')) { |
| 297 | return Response::create($factory)->redirect($links->create($type, $data['parent'] ?? null), 303); |
| 298 | } |
| 299 | |
| 300 | $payload = is_array($e->payload()) ? $e->payload() : []; |
| 301 | |
| 302 | return [ |
| 303 | 'saved' => false, |
| 304 | 'message' => (string) ($payload['message'] ?? __('node:invalid-data')), |
| 305 | 'errors' => $this->issues($payload), |
| 306 | 'published' => (bool) ($data['published'] ?? false), |
| 307 | 'renderable' => (bool) ($data['type']['renderable'] ?? false), |
| 308 | 'preview' => null, |
| 309 | ]; |
| 310 | } |
| 311 | |
| 312 | $edit = $links->edit((string) ($result['uid'] ?? $data['uid'])); |
| 313 | |
| 314 | // The redirect swaps the fresh edit form in; htmx follows it. An |
| 315 | // embedded editor tells its frame that the node now exists. |
| 316 | return Response::create($factory)->redirect( |
| 317 | $this->origin()['embed'] === null ? $edit : $edit . (str_contains($edit, '?') ? '&' : '?') . 'created=1', |
| 318 | 303, |
| 319 | ); |
| 320 | } |
| 321 | |
| 322 | /** @return Response|array<string, mixed> */ |
| 323 | public function delete( |
| 324 | Context $context, |
| 325 | Cms $cms, |
| 326 | Factory $factory, |
| 327 | string $node, |
| 328 | ): Response|array { |
| 329 | $origin = $this->origin(); |
| 330 | $result = $cms->node->working($node); |
| 331 | |
| 332 | if (!$result) { |
| 333 | throw new HttpNotFound($this->request); |
| 334 | } |
| 335 | |
| 336 | $nodeObj = Wrapper::unwrap($result); |
| 337 | $store = $this->nodeStore($context, $cms); |
| 338 | |
| 339 | try { |
| 340 | $store->delete($nodeObj, $this->actor()); |
| 341 | } catch (HttpConflict $e) { |
| 342 | // Rendered through the editor-save view: the refusal lands in |
| 343 | // the status chip, the form stays as it is. |
| 344 | $payload = is_array($e->payload()) ? $e->payload() : []; |
| 345 | |
| 346 | return [ |
| 347 | 'saved' => false, |
| 348 | 'message' => (string) ($payload['message'] ?? __('node:has-children')), |
| 349 | 'errors' => [], |
| 350 | 'published' => (bool) $result->meta->get('published'), |
| 351 | 'renderable' => (bool) $this->types()->get($nodeObj::class, 'renderable', false), |
| 352 | 'preview' => null, |
| 353 | ]; |
| 354 | } |
| 355 | |
| 356 | $response = Response::create($factory); |
| 357 | |
| 358 | if (!$this->request->hasHeader('HX-Request')) { |
| 359 | return $response->redirect($origin['backUrl'], 303); |
| 360 | } |
| 361 | |
| 362 | return $response->header('HX-Location', json_encode([ |
| 363 | 'path' => $origin['backUrl'], |
| 364 | 'target' => '#frame', |
| 365 | 'swap' => 'innerHTML show:top', |
| 366 | 'source' => '#node-editor-delete', |
| 367 | 'confirm' => false, |
| 368 | ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); |
| 369 | } |
| 370 | |
| 371 | /** @return array<string, mixed> */ |
| 372 | public function paths(Context $context, Cms $cms, string $node): array |
| 373 | { |
| 374 | $result = $cms->node->working($node); |
| 375 | |
| 376 | if (!$result) { |
| 377 | throw new HttpNotFound($this->request); |
| 378 | } |
| 379 | |
| 380 | $nodeObj = Wrapper::unwrap($result); |
| 381 | $pathsUrl = new NodeUrls($this->panelPath())->paths($node); |
| 382 | |
| 383 | if (!(bool) $this->types()->get($nodeObj::class, 'routable', false)) { |
| 384 | return ['paths' => [], 'pathsUrl' => $pathsUrl]; |
| 385 | } |
| 386 | |
| 387 | $serializer = new Serializer( |
| 388 | $this->types(), |
| 389 | $cms->nodeFactory()->uid(), |
| 390 | $context->assets(), |
| 391 | $context->paths(), |
| 392 | ); |
| 393 | $data = $serializer->read( |
| 394 | $nodeObj, |
| 395 | NodeFactory::dataFor($nodeObj), |
| 396 | NodeFactory::fieldNamesFor($nodeObj), |
| 397 | ); |
| 398 | $form = $this->formData(); |
| 399 | $data = $this->applyForm($data, $form); |
| 400 | |
| 401 | $generator = new RoutePathGenerator($context->db, $this->types()); |
| 402 | |
| 403 | return [ |
| 404 | 'paths' => $generator->preview($nodeObj::class, $data, $context->locales()), |
| 405 | 'pathsUrl' => $pathsUrl, |
| 406 | ]; |
| 407 | } |
| 408 | |
| 409 | /** |
| 410 | * Route-path preview for a not-yet-saved node, built from its blueprint. |
| 411 | * |
| 412 | * @return array<string, mixed> |
| 413 | */ |
| 414 | public function createPaths(Context $context, Cms $cms, string $type): array |
| 415 | { |
| 416 | [$nodeObj, $data] = $this->blueprint($cms, $context, $type); |
| 417 | $pathsUrl = new NodeUrls($this->panelPath())->createPaths($type, $data['parent'] ?? null); |
| 418 | |
| 419 | if (!(bool) $this->types()->get($nodeObj::class, 'routable', false)) { |
| 420 | return ['paths' => [], 'pathsUrl' => $pathsUrl]; |
| 421 | } |
| 422 | |
| 423 | $form = $this->formData(); |
| 424 | $data = $this->applyForm($data, $form); |
| 425 | $uid = $this->submittedUid($form); |
| 426 | |
| 427 | if ($uid !== null) { |
| 428 | $data['uid'] = $uid; |
| 429 | } |
| 430 | |
| 431 | $generator = new RoutePathGenerator($context->db, $this->types()); |
| 432 | |
| 433 | return [ |
| 434 | 'paths' => $generator->preview($nodeObj::class, $data, $context->locales()), |
| 435 | 'pathsUrl' => $pathsUrl, |
| 436 | ]; |
| 437 | } |
| 438 | |
| 439 | /** |
| 440 | * The layout preview of a blocks field: the field as the site renders |
| 441 | * it, built from the submitted form on top of the working copy. Nothing |
| 442 | * is validated or saved — readers clamp what they load and an empty |
| 443 | * block renders nothing, so half-finished content previews as well. |
| 444 | * |
| 445 | * @return array<string, mixed> |
| 446 | */ |
| 447 | public function blocks( |
| 448 | Context $context, |
| 449 | Cms $cms, |
| 450 | string $node, |
| 451 | string $field, |
| 452 | ): array { |
| 453 | $result = $cms->node->working($node); |
| 454 | |
| 455 | if (!$result) { |
| 456 | throw new HttpNotFound($this->request); |
| 457 | } |
| 458 | |
| 459 | $nodeObj = Wrapper::unwrap($result); |
| 460 | $serializer = new Serializer( |
| 461 | $this->types(), |
| 462 | $cms->nodeFactory()->uid(), |
| 463 | $context->assets(), |
| 464 | $context->paths(), |
| 465 | ); |
| 466 | $data = $serializer->read( |
| 467 | $nodeObj, |
| 468 | NodeFactory::dataFor($nodeObj), |
| 469 | NodeFactory::fieldNamesFor($nodeObj), |
| 470 | ); |
| 471 | |
| 472 | return $this->blocksPreview($context, $cms, $nodeObj, $data, $field); |
| 473 | } |
| 474 | |
| 475 | /** |
| 476 | * The layout preview for a not-yet-saved node, built from its blueprint. |
| 477 | * |
| 478 | * @return array<string, mixed> |
| 479 | */ |
| 480 | public function createBlocks( |
| 481 | Context $context, |
| 482 | Cms $cms, |
| 483 | string $type, |
| 484 | string $field, |
| 485 | ): array { |
| 486 | [$nodeObj, $data] = $this->blueprint($cms, $context, $type); |
| 487 | |
| 488 | return $this->blocksPreview($context, $cms, $nodeObj, $data, $field); |
| 489 | } |
| 490 | |
| 491 | /** |
| 492 | * @param array<string, mixed> $data |
| 493 | * @return array<string, mixed> |
| 494 | */ |
| 495 | private function blocksPreview( |
| 496 | Context $context, |
| 497 | Cms $cms, |
| 498 | object $node, |
| 499 | array $data, |
| 500 | string $field, |
| 501 | ): array { |
| 502 | $data = $this->applyForm($data, $this->formData()); |
| 503 | |
| 504 | if (is_array($data['content'] ?? null)) { |
| 505 | // The renderer reads the canonical richtext document the save |
| 506 | // would store, not the envelope as the editor submits it. |
| 507 | $data['content'] = new Normalizer()->content($data['content']); |
| 508 | } |
| 509 | |
| 510 | $hydrated = $cms->nodeFactory()->create($node::class, $context, $cms, $data); |
| 511 | |
| 512 | try { |
| 513 | $value = NodeFactory::fieldFor($hydrated, $field)->value(); |
| 514 | } catch (NoSuchField) { |
| 515 | throw new HttpNotFound($this->request); |
| 516 | } |
| 517 | |
| 518 | if (!$value instanceof BlocksValue) { |
| 519 | throw new HttpNotFound($this->request); |
| 520 | } |
| 521 | |
| 522 | $locales = $context->locales(); |
| 523 | $requested = $this->request->param('locale', ''); |
| 524 | $locale = is_string($requested) && $locales->exists($requested) |
| 525 | ? $locales->get($requested) |
| 526 | : $locales->getDefault(); |
| 527 | |
| 528 | return [ |
| 529 | 'html' => $context->withLocale($locale, $value->render(...)), |
| 530 | 'stylesheet' => (string) file_get_contents(dirname(__DIR__, 3) . '/resources/blocks.css'), |
| 531 | 'locale' => $locale->id, |
| 532 | ]; |
| 533 | } |
| 534 | |
| 535 | /** |
| 536 | * The editor form renders a sentinel input as its LAST control; a |
| 537 | * submission without it lost its tail — typically a form-encoded POST |
| 538 | * silently truncated by PHP's max_input_vars, or a mangled JSON body. |
| 539 | * |
| 540 | * @param array<array-key, mixed> $form |
| 541 | */ |
| 542 | private function complete(array $form): bool |
| 543 | { |
| 544 | return ($form['_complete'] ?? null) === '1'; |
| 545 | } |
| 546 | |
| 547 | /** |
| 548 | * Saving an incomplete submission would silently delete the missing |
| 549 | * fields (entries rows are replaced wholesale), so this is a hard |
| 550 | * stop, not a validation issue. |
| 551 | * |
| 552 | * @param array<string, mixed> $data |
| 553 | * @return array<string, mixed> |
| 554 | */ |
| 555 | private function refuseIncomplete(array $data): array |
| 556 | { |
| 557 | if (!$this->request->hasHeader('HX-Request')) { |
| 558 | throw new HttpBadRequest( |
| 559 | $this->request, |
| 560 | payload: ['message' => __('editor:incomplete-form')], |
| 561 | ); |
| 562 | } |
| 563 | |
| 564 | return [ |
| 565 | 'saved' => false, |
| 566 | 'message' => __('editor:incomplete-form'), |
| 567 | 'errors' => [], |
| 568 | 'published' => (bool) ($data['published'] ?? false), |
| 569 | 'renderable' => (bool) ($data['type']['renderable'] ?? false), |
| 570 | 'preview' => null, |
| 571 | ]; |
| 572 | } |
| 573 | |
| 574 | /** |
| 575 | * The issues behind a rejected save, reduced to what the error box |
| 576 | * renders: the message plus the data path locating the field — the |
| 577 | * client resolves the path to the matching form control. |
| 578 | * |
| 579 | * @param array<string, mixed> $payload |
| 580 | * @return list<array{message: string, path: list<string|int>}> |
| 581 | */ |
| 582 | private function issues(array $payload): array |
| 583 | { |
| 584 | $issues = $payload['errors'] ?? null; |
| 585 | |
| 586 | if (!is_array($issues)) { |
| 587 | return []; |
| 588 | } |
| 589 | |
| 590 | return array_values(array_map( |
| 591 | static fn(Issue $issue): array => [ |
| 592 | 'message' => $issue->message, |
| 593 | 'path' => $issue->path, |
| 594 | ], |
| 595 | array_filter($issues, static fn(mixed $issue): bool => $issue instanceof Issue), |
| 596 | )); |
| 597 | } |
| 598 | |
| 599 | /** |
| 600 | * Apply the submitted editor form (content patch + settings). |
| 601 | * |
| 602 | * @param array<string, mixed> $data |
| 603 | * @param array<array-key, mixed> $form |
| 604 | * @return array<string, mixed> |
| 605 | */ |
| 606 | private function applyForm(array $data, array $form): array |
| 607 | { |
| 608 | $patch = new FormPatch($data['fields']); |
| 609 | $submitted = $form['content'] ?? []; |
| 610 | $data['content'] = $patch->content( |
| 611 | $data['content'], |
| 612 | is_array($submitted) ? $submitted : [], |
| 613 | ); |
| 614 | |
| 615 | return $this->applySettings($data, $form); |
| 616 | } |
| 617 | |
| 618 | /** |
| 619 | * @param array<string, mixed> $data |
| 620 | * @param array<array-key, mixed> $form |
| 621 | * @return array<string, mixed> |
| 622 | */ |
| 623 | private function applySettings(array $data, array $form): array |
| 624 | { |
| 625 | if (array_key_exists('handle', $form)) { |
| 626 | $data['handle'] = is_string($form['handle']) ? $form['handle'] : null; |
| 627 | } |
| 628 | |
| 629 | if (is_array($form['paths'] ?? null)) { |
| 630 | $paths = is_array($data['paths'] ?? null) ? $data['paths'] : []; |
| 631 | |
| 632 | foreach ($form['paths'] as $locale => $path) { |
| 633 | if (is_string($locale) && is_string($path)) { |
| 634 | $paths[$locale] = $path; |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | $data['paths'] = $paths; |
| 639 | } |
| 640 | |
| 641 | foreach (['published', 'hidden'] as $flag) { |
| 642 | if (array_key_exists($flag, $form)) { |
| 643 | $data[$flag] = in_array($form[$flag], ['1', 'on', true], true); |
| 644 | } |
| 645 | } |
| 646 | |
| 647 | if (($form['publish'] ?? null) === '1') { |
| 648 | $data['published'] = true; |
| 649 | } |
| 650 | |
| 651 | return $data; |
| 652 | } |
| 653 | |
| 654 | /** The path the preview overlay loads after a save: the node's working copy, by uid. */ |
| 655 | /** |
| 656 | * The site's own preview, or the headless frontend's when `api.preview` |
| 657 | * names one, with a token that lets it read the working copy. |
| 658 | */ |
| 659 | private function previewPath(string $uid): string |
| 660 | { |
| 661 | $template = $this->config->api->preview; |
| 662 | $tokens = PreviewToken::fromConfig($this->config); |
| 663 | |
| 664 | if ($template === null || $tokens === null) { |
| 665 | return '/preview/' . rawurlencode($uid); |
| 666 | } |
| 667 | |
| 668 | return strtr($template, [ |
| 669 | '{uid}' => rawurlencode($uid), |
| 670 | '{token}' => rawurlencode($tokens->issue($uid)), |
| 671 | '{locale}' => rawurlencode($this->localeId()), |
| 672 | ]); |
| 673 | } |
| 674 | |
| 675 | /** |
| 676 | * Build a blueprint node object and its serialized create payload. |
| 677 | * |
| 678 | * @return array{0: object, 1: array<string, mixed>} |
| 679 | */ |
| 680 | private function blueprint(Cms $cms, Context $context, string $type): array |
| 681 | { |
| 682 | $registered = $this->container->tag(Bootstrap::NODE_TAG); |
| 683 | |
| 684 | if (!$registered->has($type)) { |
| 685 | throw new HttpNotFound($this->request); |
| 686 | } |
| 687 | |
| 688 | $class = $registered->entry($type)->definition(); |
| 689 | $parent = $this->stringParam('parent', $this->request->params()); |
| 690 | |
| 691 | if ($parent !== '') { |
| 692 | $parentNode = $cms->node->byUid($parent, published: null); |
| 693 | $children = $parentNode?->meta->type->get('children', []); |
| 694 | |
| 695 | if (!$parentNode || !is_array($children) || !in_array($class, $children, true)) { |
| 696 | throw new HttpNotFound($this->request); |
| 697 | } |
| 698 | } |
| 699 | |
| 700 | $factory = $cms->nodeFactory(); |
| 701 | $node = $factory->blueprint($class, $context, $cms); |
| 702 | $serializer = new Serializer($this->types(), $factory->uid()); |
| 703 | $data = $serializer->blueprint($node, NodeFactory::fieldNamesFor($node), $context->locales()); |
| 704 | $data['parent'] = $parent === '' ? null : $parent; |
| 705 | |
| 706 | return [$node, $data]; |
| 707 | } |
| 708 | |
| 709 | /** |
| 710 | * The uid the create form pre-generated, if it is a well-formed handle; |
| 711 | * used only in create mode so uploads and the saved node share one uid. |
| 712 | * |
| 713 | * @param array<array-key, mixed> $form |
| 714 | */ |
| 715 | private function submittedUid(array $form): ?string |
| 716 | { |
| 717 | $uid = $form['uid'] ?? null; |
| 718 | |
| 719 | if (is_string($uid) && preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/', $uid)) { |
| 720 | return $uid; |
| 721 | } |
| 722 | |
| 723 | return null; |
| 724 | } |
| 725 | |
| 726 | /** |
| 727 | * @return array{ |
| 728 | * backUrl: string, |
| 729 | * backLabel: string, |
| 730 | * activeCollection: ?string, |
| 731 | * links: NodeUrls, |
| 732 | * embed: ?array{origins: list<string>, fields: ?list<string>, lang: ?list<string>, created: bool}, |
| 733 | * } |
| 734 | */ |
| 735 | private function origin(): array |
| 736 | { |
| 737 | $origin = [ |
| 738 | 'backUrl' => $this->homeUrl(), |
| 739 | 'backLabel' => __('nav:home'), |
| 740 | 'activeCollection' => null, |
| 741 | 'links' => new NodeUrls($this->panelPath()), |
| 742 | 'embed' => null, |
| 743 | ]; |
| 744 | $from = $this->request->param('from', ''); |
| 745 | |
| 746 | if ($from === 'embed' && $this->config->panel->embed !== []) { |
| 747 | $embed = $this->embed(); |
| 748 | |
| 749 | return array_replace($origin, [ |
| 750 | 'links' => new NodeUrls($this->panelPath(), 'embed', extra: array_filter([ |
| 751 | 'fields' => implode(',', $embed['fields'] ?? []), |
| 752 | 'lang' => implode(',', $embed['lang'] ?? []), |
| 753 | ])), |
| 754 | 'embed' => $embed, |
| 755 | ]); |
| 756 | } |
| 757 | |
| 758 | if ($from === 'dashboard' && $this->config->panel->dashboard) { |
| 759 | return array_replace($origin, [ |
| 760 | 'backLabel' => __('nav:dashboard'), |
| 761 | 'links' => new NodeUrls($this->panelPath(), 'dashboard'), |
| 762 | ]); |
| 763 | } |
| 764 | |
| 765 | if (!is_string($from) || !str_starts_with($from, 'collection:')) { |
| 766 | return $origin; |
| 767 | } |
| 768 | |
| 769 | $slug = substr($from, strlen('collection:')); |
| 770 | $ref = $this->navigation()->refs()[$slug] ?? null; |
| 771 | |
| 772 | if ($ref === null) { |
| 773 | return $origin; |
| 774 | } |
| 775 | |
| 776 | $query = $this->queryState($this->listing($ref)); |
| 777 | |
| 778 | return [ |
| 779 | 'backUrl' => new CollectionUrls($this->panelPath(), $slug, $query)->back(), |
| 780 | 'backLabel' => __($ref->meta->label), |
| 781 | 'activeCollection' => $slug, |
| 782 | 'links' => new NodeUrls($this->panelPath(), $from, $query), |
| 783 | 'embed' => null, |
| 784 | ]; |
| 785 | } |
| 786 | |
| 787 | /** |
| 788 | * The embedded editor's request: the origins that may frame it, the |
| 789 | * fields and locales it is limited to, and whether a node was just |
| 790 | * created. |
| 791 | * |
| 792 | * @return array{origins: list<string>, fields: ?list<string>, lang: ?list<string>, created: bool} |
| 793 | */ |
| 794 | private function embed(): array |
| 795 | { |
| 796 | $list = function (string $name): ?array { |
| 797 | $value = $this->request->param($name, ''); |
| 798 | |
| 799 | if (!is_string($value) || trim($value) === '') { |
| 800 | return null; |
| 801 | } |
| 802 | |
| 803 | return array_values(array_filter( |
| 804 | array_map(trim(...), explode(',', $value)), |
| 805 | static fn(string $item): bool => preg_match('/^[A-Za-z0-9_-]{1,64}$/', $item) === 1, |
| 806 | )); |
| 807 | }; |
| 808 | |
| 809 | return [ |
| 810 | 'origins' => $this->config->panel->embed, |
| 811 | 'fields' => $list('fields'), |
| 812 | 'lang' => $list('lang'), |
| 813 | 'created' => $this->request->param('created', '') === '1', |
| 814 | ]; |
| 815 | } |
| 816 | |
| 817 | /** |
| 818 | * Narrows an embedded editor to the fields it asked for. Fields left |
| 819 | * out are not submitted, and the save leaves them as they are. |
| 820 | * |
| 821 | * @param array<string, mixed> $data |
| 822 | * @param ?array{fields: ?list<string>} $embed |
| 823 | * @return array<string, mixed> |
| 824 | */ |
| 825 | private function embedFields(array $data, ?array $embed): array |
| 826 | { |
| 827 | $names = $embed['fields'] ?? null; |
| 828 | |
| 829 | if ($names === null) { |
| 830 | return $data; |
| 831 | } |
| 832 | |
| 833 | $data['fields'] = array_values(array_filter( |
| 834 | is_array($data['fields'] ?? null) ? $data['fields'] : [], |
| 835 | static fn(mixed $field): bool => is_array($field) && in_array($field['name'] ?? null, $names, true), |
| 836 | )); |
| 837 | $data['fieldsets'] = array_values(array_filter(array_map( |
| 838 | static function (mixed $fieldset) use ($names): ?array { |
| 839 | if (!is_array($fieldset)) { |
| 840 | return null; |
| 841 | } |
| 842 | |
| 843 | $fieldset['fields'] = array_values(array_intersect( |
| 844 | is_array($fieldset['fields'] ?? null) ? $fieldset['fields'] : [], |
| 845 | $names, |
| 846 | )); |
| 847 | |
| 848 | return $fieldset['fields'] === [] ? null : $fieldset; |
| 849 | }, |
| 850 | is_array($data['fieldsets'] ?? null) ? $data['fieldsets'] : [], |
| 851 | ))); |
| 852 | |
| 853 | return $data; |
| 854 | } |
| 855 | |
| 856 | /** |
| 857 | * Values an embedding application fills in before the editor opens: a |
| 858 | * JSON object of fields in the API's source shape. |
| 859 | * |
| 860 | * @param array<string, mixed> $data |
| 861 | * @return array<string, mixed> |
| 862 | */ |
| 863 | private function embedContent(array $data): array |
| 864 | { |
| 865 | $given = json_decode((string) $this->request->param('content', ''), true); |
| 866 | |
| 867 | if (!is_array($given)) { |
| 868 | return $data; |
| 869 | } |
| 870 | |
| 871 | foreach ($given as $name => $value) { |
| 872 | if (is_array($value) && is_array($data['content'][$name] ?? null)) { |
| 873 | $data['content'][$name] = array_replace_recursive($data['content'][$name], $value); |
| 874 | } |
| 875 | } |
| 876 | |
| 877 | return $data; |
| 878 | } |
| 879 | |
| 880 | private function types(): Types |
| 881 | { |
| 882 | return $this->container->instance(Types::class); |
| 883 | } |
| 884 | |
| 885 | private function queryState(Listing $lister): CollectionQuery |
| 886 | { |
| 887 | $params = $this->request->param('list', []); |
| 888 | |
| 889 | if (!is_array($params)) { |
| 890 | throw new HttpBadRequest($this->request); |
| 891 | } |
| 892 | |
| 893 | $offset = $this->intParam('offset', $params, 0, min: 0); |
| 894 | $limit = $this->intParam('limit', $params, self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX); |
| 895 | $dir = strtolower($this->stringParam('dir', $params)); |
| 896 | |
| 897 | if ($dir !== '' && !in_array($dir, ['asc', 'desc'], true)) { |
| 898 | throw new HttpBadRequest($this->request); |
| 899 | } |
| 900 | |
| 901 | $parent = $this->stringParam('parent', $params); |
| 902 | $parent = $lister->meta->showChildren && $parent !== '' ? $parent : null; |
| 903 | $view = $this->stringParam('view', $params); |
| 904 | $open = $this->openParam($params); |
| 905 | $sort = $this->stringParam('sort', $params); |
| 906 | |
| 907 | if ($sort !== '' && !array_key_exists($sort, $lister->sorts)) { |
| 908 | throw new HttpBadRequest($this->request); |
| 909 | } |
| 910 | $defaultView = $lister->meta->showChildren && $parent === null ? 'tree' : 'list'; |
| 911 | |
| 912 | if ($view === '') { |
| 913 | $view = $defaultView; |
| 914 | } |
| 915 | |
| 916 | if (!in_array($view, ['tree', 'list'], true)) { |
| 917 | throw new HttpBadRequest($this->request); |
| 918 | } |
| 919 | |
| 920 | if (!$lister->meta->showChildren) { |
| 921 | $open = []; |
| 922 | } |
| 923 | |
| 924 | return new CollectionQuery( |
| 925 | q: $this->stringParam('q', $params), |
| 926 | sort: $sort, |
| 927 | dir: $dir, |
| 928 | offset: $offset, |
| 929 | limit: $limit, |
| 930 | parent: $parent, |
| 931 | view: $view, |
| 932 | open: $open, |
| 933 | defaultView: $defaultView, |
| 934 | ); |
| 935 | } |
| 936 | |
| 937 | /** |
| 938 | * The route paths the current node would generate, previewed in the |
| 939 | * settings pane. Empty for non-routable types and create mode. |
| 940 | * |
| 941 | * @param array<string, mixed> $data |
| 942 | * @return array<string, string> |
| 943 | */ |
| 944 | private function generatedPaths(Context $context, object $node, array $data): array |
| 945 | { |
| 946 | if (!(bool) $this->types()->get($node::class, 'routable', false)) { |
| 947 | return []; |
| 948 | } |
| 949 | |
| 950 | return new RoutePathGenerator($context->db, $this->types()) |
| 951 | ->preview($node::class, $data, $context->locales()); |
| 952 | } |
| 953 | |
| 954 | /** |
| 955 | * The node's own content-field names its route template references, so |
| 956 | * the editor marks exactly those inputs to live-refresh the path preview. |
| 957 | * |
| 958 | * @return list<string> |
| 959 | */ |
| 960 | private function pathSourceFields(Context $context, object $node): array |
| 961 | { |
| 962 | return new RoutePathGenerator($context->db, $this->types()) |
| 963 | ->referencedFields($this->types()->get($node::class, 'route')); |
| 964 | } |
| 965 | |
| 966 | /** |
| 967 | * @param array<string, mixed> $node |
| 968 | * @param array<string, string> $generatedPaths |
| 969 | * @param list<string> $pathSourceFields |
| 970 | * @param array<string, mixed> $meta |
| 971 | * @return array<string, mixed> |
| 972 | */ |
| 973 | private function editorContext( |
| 974 | string $mode, |
| 975 | array $node, |
| 976 | Context $context, |
| 977 | array $generatedPaths = [], |
| 978 | array $pathSourceFields = [], |
| 979 | array $meta = [], |
| 980 | ): array { |
| 981 | $locales = array_map( |
| 982 | static fn($locale) => [ |
| 983 | 'id' => $locale->id, |
| 984 | 'title' => $locale->title, |
| 985 | 'fallback' => $locale->fallback, |
| 986 | ], |
| 987 | iterator_to_array($context->locales(), false), |
| 988 | ); |
| 989 | $languages = $this->origin()['embed']['lang'] ?? null; |
| 990 | |
| 991 | if ($languages !== null) { |
| 992 | $narrowed = array_values(array_filter( |
| 993 | $locales, |
| 994 | static fn(array $locale): bool => in_array($locale['id'], $languages, true), |
| 995 | )); |
| 996 | $locales = $narrowed === [] ? $locales : $narrowed; |
| 997 | } |
| 998 | |
| 999 | return $this->context([ |
| 1000 | ...$this->origin(), |
| 1001 | 'mode' => $mode, |
| 1002 | 'node' => $node, |
| 1003 | 'locales' => $locales, |
| 1004 | 'defaultLocale' => in_array($context->locales()->getDefault()->id, array_column($locales, 'id'), true) |
| 1005 | ? $context->locales()->getDefault()->id |
| 1006 | : $locales[0]['id'], |
| 1007 | 'system' => new System($this->config, $context->locales(), $this->csrfToken())->payload(), |
| 1008 | 'generatedPaths' => $generatedPaths, |
| 1009 | 'pathSourceFields' => $pathSourceFields, |
| 1010 | 'meta' => $meta, |
| 1011 | 'inspectorCollapsed' => $this->request->cookie('cosray_inspector', '') === 'collapsed', |
| 1012 | ]); |
| 1013 | } |
| 1014 | |
| 1015 | /** |
| 1016 | * The inspector's fact rows: creation date in the panel locale and the |
| 1017 | * last editor's display name. Only an existing node has either. |
| 1018 | * |
| 1019 | * @param array<string, mixed> $data |
| 1020 | * @return array{created: ?string, editor: ?string, draft: ?array{since: ?string, editor: ?string}} |
| 1021 | */ |
| 1022 | private function nodeMeta(array $data): array |
| 1023 | { |
| 1024 | return [ |
| 1025 | 'created' => $this->displayDate($data['created'] ?? null), |
| 1026 | 'editor' => $this->userLabel($data['editor'] ?? null), |
| 1027 | 'draft' => $this->draftFacts($data['draft'] ?? null), |
| 1028 | ]; |
| 1029 | } |
| 1030 | |
| 1031 | private function displayDate(mixed $value): ?string |
| 1032 | { |
| 1033 | if (!is_string($value) || trim($value) === '') { |
| 1034 | return null; |
| 1035 | } |
| 1036 | |
| 1037 | $timezone = $this->config->app->timezone; |
| 1038 | |
| 1039 | try { |
| 1040 | $date = new DateTimeImmutable($value, $timezone); |
| 1041 | } catch (Throwable) { |
| 1042 | return null; |
| 1043 | } |
| 1044 | |
| 1045 | $formatter = new IntlDateFormatter( |
| 1046 | $this->localeId(), |
| 1047 | IntlDateFormatter::MEDIUM, |
| 1048 | IntlDateFormatter::NONE, |
| 1049 | $timezone, |
| 1050 | ); |
| 1051 | $formatted = $formatter->format($date->getTimestamp()); |
| 1052 | |
| 1053 | return $formatted === false ? null : $formatted; |
| 1054 | } |
| 1055 | |
| 1056 | private function userLabel(mixed $user): ?string |
| 1057 | { |
| 1058 | if (!is_array($user)) { |
| 1059 | return null; |
| 1060 | } |
| 1061 | |
| 1062 | $data = $user['data'] ?? null; |
| 1063 | |
| 1064 | if (is_string($data)) { |
| 1065 | $data = json_decode($data, true); |
| 1066 | } |
| 1067 | |
| 1068 | $candidates = [ |
| 1069 | is_array($data) ? $data['name'] ?? null : null, |
| 1070 | $user['username'] ?? null, |
| 1071 | $user['email'] ?? null, |
| 1072 | ]; |
| 1073 | |
| 1074 | foreach ($candidates as $candidate) { |
| 1075 | if (is_string($candidate) && trim($candidate) !== '') { |
| 1076 | return $candidate; |
| 1077 | } |
| 1078 | } |
| 1079 | |
| 1080 | return null; |
| 1081 | } |
| 1082 | |
| 1083 | /** @param array<array-key, mixed> $params */ |
| 1084 | private function intParam( |
| 1085 | string $key, |
| 1086 | array $params, |
| 1087 | int $default, |
| 1088 | int $min, |
| 1089 | ?int $max = null, |
| 1090 | ): int { |
| 1091 | $value = $params[$key] ?? $default; |
| 1092 | |
| 1093 | if (is_int($value)) { |
| 1094 | $int = $value; |
| 1095 | } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) { |
| 1096 | $int = (int) $value; |
| 1097 | } else { |
| 1098 | throw new HttpBadRequest($this->request); |
| 1099 | } |
| 1100 | |
| 1101 | if ($int < $min) { |
| 1102 | throw new HttpBadRequest($this->request); |
| 1103 | } |
| 1104 | |
| 1105 | if ($max !== null && $int > $max) { |
| 1106 | throw new HttpBadRequest($this->request); |
| 1107 | } |
| 1108 | |
| 1109 | return $int; |
| 1110 | } |
| 1111 | |
| 1112 | /** |
| 1113 | * @param array<array-key, mixed> $params |
| 1114 | * @return list<string> |
| 1115 | */ |
| 1116 | private function openParam(array $params): array |
| 1117 | { |
| 1118 | $value = $params['open'] ?? ''; |
| 1119 | |
| 1120 | if (!is_string($value)) { |
| 1121 | throw new HttpBadRequest($this->request); |
| 1122 | } |
| 1123 | |
| 1124 | $open = []; |
| 1125 | |
| 1126 | foreach (explode(',', $value) as $uid) { |
| 1127 | $uid = trim($uid); |
| 1128 | |
| 1129 | if ($uid !== '' && !in_array($uid, $open, true)) { |
| 1130 | $open[] = $uid; |
| 1131 | } |
| 1132 | } |
| 1133 | |
| 1134 | return $open; |
| 1135 | } |
| 1136 | |
| 1137 | /** @param array<array-key, mixed> $params */ |
| 1138 | private function stringParam(string $key, array $params): string |
| 1139 | { |
| 1140 | $value = $params[$key] ?? ''; |
| 1141 | |
| 1142 | if (!is_string($value)) { |
| 1143 | throw new HttpBadRequest($this->request); |
| 1144 | } |
| 1145 | |
| 1146 | return trim($value); |
| 1147 | } |
| 1148 | } |