dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxKiCmsService.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxKi;
3
4use dbx\dbxContent\dbxContentLng;
5use dbx\dbxContent\dbxContentLngSync;
6use dbx\dbxContent\dbxContentMediaUsageScope;
7use dbx\dbxContent\dbxContentHome;
8use dbx\dbxContent\dbxContentPageCache;
9use dbx\dbxContent\dbxContentPermalinkIndex;
10use dbx\dbxContent\dbxContentRenderer;
11use dbx\dbxContent\dbxContentTranslate;
12use dbx\dbxContent\dbxContent_permalink;
13
14require_once dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap_sync.php';
15
17
18 private const TOKEN_SCOPE = 'dbxKi.cms.execute';
19 private const API_VERSION = '0.1';
20
21 private $db;
22
23 public function __construct() {
24 $this->db = dbx()->get_system_obj('dbxDB');
25 }
26
27 public function handle(string $route = 'api'): void {
28 try {
29 $request = $this->request();
30 if ($route === 'describe') {
31 $request['action'] = 'system.describe';
32 } elseif ($route === 'preview') {
33 $request['mode'] = 'preview';
34 } elseif ($route === 'execute') {
35 $request['mode'] = 'execute';
36 }
37
38 $action = strtolower(trim((string)($request['action'] ?? 'system.describe')));
39 $mode = strtolower(trim((string)($request['mode'] ?? 'preview')));
40 $params = is_array($request['params'] ?? null) ? $request['params'] : array();
41
42 if ($action === 'system.describe') {
43 $this->respond($this->describe());
44 }
45
46 if ($action === 'bundle.describe') {
47 $bundle = dbx()->get_include_obj('dbxKiBundleService', 'dbxKi');
48 $this->respond($bundle->describeBundle());
49 }
50
51 if ($action === 'system.health') {
52 $this->respond($this->health());
53 }
54
55 $catalog = $this->catalog();
56 if (!isset($catalog[$action])) {
57 $this->fail('unknown_action', 'Unbekannte Aktion.', array(
58 'action' => $action,
59 'available_actions' => array_keys($catalog),
60 ));
61 }
62
63 $write = (bool)($catalog[$action]['write'] ?? false);
64 if (!$write) {
65 $result = $this->read_action($action, $params);
66 $this->respond(array(
67 'ok' => 1,
68 'api_version' => self::API_VERSION,
69 'action' => $action,
70 'mode' => 'read',
71 'result' => $result,
72 ));
73 }
74
75 if (!in_array($mode, array('preview', 'execute'), true)) {
76 $this->fail('invalid_mode', 'Erlaubte Modi sind preview und execute.');
77 }
78
79 $plan = $this->build_plan($action, $params);
80 $planId = $this->plan_id($action, $plan);
81
82 if ($mode === 'preview') {
83 $this->respond(array(
84 'ok' => 1,
85 'api_version' => self::API_VERSION,
86 'action' => $action,
87 'mode' => 'preview',
88 'will_execute' => false,
89 'plan_id' => $planId,
90 'plan' => $plan,
91 'execute_request' => array(
92 'action' => $action,
93 'mode' => 'execute',
94 'token' => dbx()->action_token(self::TOKEN_SCOPE),
95 'expected_plan_id' => $planId,
96 'confirm' => (bool)($catalog[$action]['destructive'] ?? false),
97 'params' => $params,
98 ),
99 ));
100 }
101
102 $this->authorize_execute($request, (bool)($catalog[$action]['destructive'] ?? false));
103 $expected = trim((string)($request['expected_plan_id'] ?? ''));
104 if ($expected !== '' && !hash_equals($planId, $expected)) {
105 $this->fail('plan_changed', 'Der aktuelle Plan stimmt nicht mehr mit expected_plan_id überein.', array(
106 'expected_plan_id' => $expected,
107 'current_plan_id' => $planId,
108 'current_plan' => $plan,
109 ));
110 }
111
112 $result = $this->execute_action($action, $params, $plan);
113 dbx()->sys_msg(
114 'info',
115 'dbxKi',
116 $action,
117 'KI-CMS-Aktion ausgeführt',
118 'uid=' . (int)dbx()->user() . ' plan=' . $planId
119 );
120
121 $this->respond(array(
122 'ok' => 1,
123 'api_version' => self::API_VERSION,
124 'action' => $action,
125 'mode' => 'execute',
126 'executed' => true,
127 'plan_id' => $planId,
128 'result' => $result,
129 ));
130 } catch (\Throwable $e) {
131 dbx()->sys_msg('error', 'dbxKi', 'api', 'Ausnahme', $e->getMessage());
132 $this->fail('exception', $e->getMessage());
133 }
134 }
135
136 private function request(): array {
137 $request = array();
138 $raw = file_get_contents('php://input');
139 if (is_string($raw) && trim($raw) !== '') {
140 $json = json_decode($raw, true);
141 if (is_array($json)) {
142 $request = $json;
143 }
144 }
145
146 foreach (array('action', 'mode', 'token', 'expected_plan_id', 'confirm') as $key) {
147 if (!array_key_exists($key, $request)) {
148 $value = dbx()->get_request_var($key, null);
149 if ($value !== null) {
150 $request[$key] = $value;
151 }
152 }
153 }
154
155 if (!isset($request['params']) || !is_array($request['params'])) {
156 $params = dbx()->get_request_var('params', array(), '*');
157 if (is_string($params) && trim($params) !== '') {
158 $decoded = json_decode($params, true);
159 $params = is_array($decoded) ? $decoded : array();
160 }
161 $request['params'] = is_array($params) ? $params : array();
162 }
163
164 return $request;
165 }
166
167 private function respond(array $data): void {
168 dbx()->json_response($data, true);
169 }
170
171 private function fail(string $code, string $message, array $details = array()): void {
172 $this->respond(array(
173 'ok' => 0,
174 'api_version' => self::API_VERSION,
175 'error' => array(
176 'code' => $code,
177 'message' => $message,
178 'details' => $details,
179 ),
180 ));
181 }
182
183 private function authorize_execute(array $request, bool $destructive): void {
184 if ((int)dbx()->get_config('dbxKi', 'allow_execute', 1) !== 1) {
185 $this->fail('execute_disabled', 'Automatische Ausführung ist in der Modulkonfiguration deaktiviert.');
186 }
187 $token = trim((string)($request['token'] ?? ''));
188 if (!dbx()->check_action_token(self::TOKEN_SCOPE, $token)) {
189 $this->fail('invalid_token', 'Ungültiges oder abgelaufenes Aktions-Token.');
190 }
191 if ($destructive && !$this->bool_value($request['confirm'] ?? false)) {
192 $this->fail('confirmation_required', 'Diese Aktion löscht Daten. Für automatische Ausführung confirm=true senden.');
193 }
194 }
195
196 private function describe(): array {
197 return array(
198 'ok' => 1,
199 'api_version' => self::API_VERSION,
200 'module' => 'dbxKi',
201 'purpose' => 'KI-optimierte Bedienung des dbXapp-CMS ohne direkten SQL-Zugriff.',
202 'endpoint' => '?dbx_modul=dbxKi&dbx_run1=api',
203 'authentication' => array(
204 'read_and_preview' => 'Normale dbXapp-Modulberechtigung.',
205 'execute' => 'Admin-Sitzung plus token.',
206 'token' => dbx()->action_token(self::TOKEN_SCOPE),
207 'token_scope' => self::TOKEN_SCOPE,
208 ),
209 'protocol' => array(
210 'method' => 'GET oder POST; für komplexe Daten POST mit application/json verwenden.',
211 'request' => array(
212 'action' => 'Eine Aktion aus actions.',
213 'mode' => 'preview oder execute; bei Leseaktionen wird mode ignoriert.',
214 'params' => 'Aktionsparameter.',
215 'token' => 'Nur für execute.',
216 'expected_plan_id' => 'Optional. Verhindert Ausführung, falls sich der Plan seit preview geändert hat.',
217 'confirm' => 'Bei Löschaktionen für execute zwingend true.',
218 ),
219 'automation' => array(
220 'safe' => 'Erst preview aufrufen und execute_request aus der Antwort unverändert senden.',
221 'direct' => 'Für vollautomatische Ausführung action, mode=execute, token und params direkt senden.',
222 'rule' => 'Keine SQL-Befehle erzeugen. Ausschließlich diese Aktionen verwenden.',
223 ),
224 ),
225 'page_workflows' => $this->page_workflows(),
226 'languages' => dbxContentLngSync::accessibleLngs(),
227 'actions' => $this->catalog(),
228 'examples' => array(
229 'preview_page_create' => array(
230 'action' => 'page.create',
231 'mode' => 'preview',
232 'params' => array(
233 'lng' => 'de',
234 'folder_id' => 1,
235 'title' => 'Neue KI-Seite',
236 'content' => '<p>Inhalt</p>',
237 ),
238 ),
239 'automatic_page_update' => array(
240 'action' => 'page.update',
241 'mode' => 'execute',
242 'token' => dbx()->action_token(self::TOKEN_SCOPE),
243 'params' => array(
244 'lng' => 'de',
245 'id' => 1,
246 'title' => 'Aktualisierter Titel',
247 ),
248 ),
249 'translation' => array(
250 'action' => 'translation.apply',
251 'mode' => 'execute',
252 'token' => dbx()->action_token(self::TOKEN_SCOPE),
253 'params' => array(
254 'source_lng' => 'de',
255 'target_lng' => 'en',
256 'source_id' => 1,
257 'translation' => array(
258 'title' => 'Translated title',
259 'description' => 'Translated description',
260 'keywords' => 'translated, keywords',
261 'content' => '<p>Translated content</p>',
262 'seo_title' => 'Translated SEO title',
263 ),
264 ),
265 ),
266 ),
267 );
268 }
269
270 private function page_workflows(): array {
271 return array(
272 'contract' => array(
273 'ki_role' => 'Die KI erzeugt nur JSON-Dateien und optionale Assets. dbxKi importiert, prueft und fuehrt alles aus.',
274 'no_external_tools' => 'Keine eigenen PHP-, SQL-, Shell-, Python- oder Node-Tools fuer CMS-Aenderungen erzeugen.',
275 'delivery' => 'antwort.zip mit manifest.json, job.json, optional assets/ und README.md; alternativ job_json im dbxKi-Importformular.',
276 'auto_execute' => 'Wenn dbxKi nach erfolgreicher Pruefung automatisch ausfuehren soll: manifest.auto_execute = true setzen.',
277 ),
278 'page_create' => array(
279 'guide_action' => 'page.create_guide',
280 'sequence' => array(
281 'Arbeitskontext mit cms.snapshot oder page.create_guide lesen.',
282 'Neue Medien zuerst mit media.create_base64 oder media.create_image_variant als Step anlegen.',
283 'page.create mit lng, folder_id, title, template, content, description, keywords, activ anlegen.',
284 'Inline-Bilder im content immer mit $ref:{media_step}.inline_src und data-cms-media-id setzen.',
285 'Verwendete Inline-/Gallery-/Hero-Medien mit media.assign zuordnen.',
286 'dbxKi importiert job.json, validiert jeden Step und fuehrt den Prozess aus.',
287 ),
288 'fixed_rules' => array(
289 'folder_id, lng und title aus dem Auftrag nicht eigenmaechtig aendern.',
290 'template nur aus Auftrag/Guide verwenden; bei Root-Seiten nie parent verwenden.',
291 'Kein SQL, keine direkten Dateipfade files/media/... in img src.',
292 'HTML ist erlaubt; Bootstrap-5-Klassen sind erlaubt; kein eigenes JavaScript.',
293 'Hero-Bilder unter img/hero, Gallery-Bilder unter img/gallery, normale Inline-Bilder unter img/images.',
294 'Ein Seitenkopf mit Bild und ueberlagertem Text ist immer ein CMS-Hero: Bild per media.assign slot=hero und hero_image_id/hero_template setzen; Hero-Text vor den dbx:hero-Marker schreiben.',
295 'Niemals einen Hero als Inline-Bild mit position-relative/position-absolute im Content nachbauen.',
296 ),
297 ),
298 'page_update' => array(
299 'guide_action' => 'page.update_guide',
300 'sequence' => array(
301 'Bestehende Seite mit page.get oder page.update_guide lesen.',
302 'Nur Felder aendern, die im Auftrag/change_fields erlaubt sind.',
303 'Bei content-Aenderung vorhandene data-cms-media-id, dbx_mid-URLs, Links und Modulaufrufe erhalten, ausser der Auftrag fordert Aenderung.',
304 'Bestehendes Hero-Bild ersetzen: page.hero_replace_image. Neues Hero-Bild setzen: page.hero_create_image.',
305 'page.update nur fuer Seitenfelder verwenden; Medien danach bei Bedarf mit media.assign verknuepfen.',
306 'dbxKi importiert job.json, validiert jeden Step und fuehrt den Prozess aus.',
307 ),
308 'fixed_rules' => array(
309 'id, lng und permalink der Zielseite nicht eigenmaechtig aendern.',
310 'Kein page.delete in KI-Auftraegen.',
311 'Keine vorhandenen Medienpfade manuell umschreiben.',
312 'Wenn content nicht in change_fields steht, content unveraendert lassen.',
313 'Hero-Aenderungen nur ueber page.hero_replace_image/page.hero_create_image oder Hero-Felder plus media.assign slot=hero ausfuehren.',
314 'Keinen Inline-Schein-Hero mit absolut positioniertem Text am Seitenanfang erzeugen.',
315 ),
316 ),
317 );
318 }
319
320 private function page_create_guide(array $params): array {
321 $lng = $this->language($params['lng'] ?? '');
322 $folderId = max(0, (int)($params['folder_id'] ?? $params['folder'] ?? 0));
323 $title = $this->clean($params['title'] ?? '___TITEL___', 254);
324 $withHero = $this->bool_value($params['with_hero'] ?? false);
325 $withGallery = $this->bool_value($params['with_gallery'] ?? false);
326 $template = $this->clean($params['template'] ?? ($withHero ? 'c-title-hero_header-gallery-body1-footer' : 'c-body1-footer'), 254);
327 if ($folderId === 0 && strtolower($template) === 'parent') {
328 $template = 'c-body1-footer';
329 }
330
331 $steps = array();
332 if ($withHero) {
333 $steps[] = array(
334 'id' => 'hero',
335 'action' => 'media.create_base64',
336 'params' => array(
337 'asset_ref' => 'hero.jpg',
338 'file_name' => 'hero.jpg',
339 'media_folder' => 'img/hero',
340 'title' => $title . ' Hero',
341 'alt' => $title,
342 ),
343 );
344 }
345 if ($withGallery) {
346 $steps[] = array(
347 'id' => 'gallery_1',
348 'action' => 'media.create_base64',
349 'params' => array(
350 'asset_ref' => 'gallery-1.jpg',
351 'file_name' => 'gallery-1.jpg',
352 'media_folder' => 'img/gallery',
353 'title' => $title . ' Galerie',
354 'alt' => $title,
355 ),
356 );
357 }
358 $steps[] = array(
359 'id' => 'page',
360 'action' => 'page.create',
361 'params' => array(
362 'lng' => $lng,
363 'folder_id' => $folderId,
364 'title' => $title,
365 'template' => $template,
366 'hero_height' => $withHero ? '300px' : 'parent',
367 'description' => '___SEO_BESCHREIBUNG___',
368 'keywords' => '___KEYWORDS___',
369 'activ' => 1,
370 'content' => '___HTML_CONTENT___',
371 ),
372 );
373 if ($withHero) {
374 $steps[] = array(
375 'id' => 'hero_assign',
376 'action' => 'media.assign',
377 'params' => array(
378 'lng' => $lng,
379 'media_id' => '$ref:hero.media_id',
380 'content_id' => '$ref:page.page_id',
381 'slot' => 'hero',
382 ),
383 );
384 }
385 if ($withGallery) {
386 $steps[] = array(
387 'id' => 'gallery_assign_1',
388 'action' => 'media.assign',
389 'params' => array(
390 'lng' => $lng,
391 'media_id' => '$ref:gallery_1.media_id',
392 'content_id' => '$ref:page.page_id',
393 'slot' => 'gallery',
394 ),
395 );
396 }
397
398 return array(
399 'workflow' => $this->page_workflows()['page_create'],
400 'manifest' => array(
401 'title' => $title,
402 'recipe' => 'page.create.v1',
403 'lng' => $lng,
404 'auto_execute' => true,
405 ),
406 'job' => array('steps' => $steps),
407 'content_contract' => $this->content_contract(),
408 );
409 }
410
411 private function page_update_guide(array $params): array {
412 $lng = $this->language($params['lng'] ?? '');
413 $id = max(0, (int)($params['id'] ?? 0));
414 $heroMode = strtolower(trim((string)($params['hero_mode'] ?? 'none')));
415 if (!in_array($heroMode, array('none', 'replace', 'create'), true)) {
416 $heroMode = 'none';
417 }
418 $fields = $params['change_fields'] ?? array('content');
419 if (is_string($fields)) {
420 $fields = array_values(array_filter(array_map('trim', explode(',', $fields))));
421 }
422 if (!is_array($fields) || !$fields) {
423 $fields = array('content');
424 }
425
426 $steps = array();
427 if ($heroMode === 'replace') {
428 $steps[] = array(
429 'id' => 'hero_replace',
430 'action' => 'page.hero_replace_image',
431 'params' => array(
432 'lng' => $lng,
433 'id' => $id,
434 'source_file' => 'assets/hero.jpg',
435 'width' => 1280,
436 'height' => 300,
437 'fit' => 'cover',
438 ),
439 );
440 } elseif ($heroMode === 'create') {
441 $steps[] = array(
442 'id' => 'hero_create',
443 'action' => 'page.hero_create_image',
444 'params' => array(
445 'lng' => $lng,
446 'id' => $id,
447 'source_file' => 'assets/hero.jpg',
448 'file_name' => 'hero.jpg',
449 'width' => 1280,
450 'height' => 300,
451 'fit' => 'cover',
452 ),
453 );
454 }
455
456 $patch = array();
457 foreach ($fields as $field) {
458 $field = trim((string)$field);
459 if ($field === '') continue;
460 $patch[$field] = $field === 'content' ? '___HTML_CONTENT___' : '___' . strtoupper($field) . '___';
461 }
462 if ($patch) {
463 $steps[] = array(
464 'id' => 'page_update',
465 'action' => 'page.update',
466 'params' => array(
467 'lng' => $lng,
468 'id' => $id,
469 'patch' => $patch,
470 ),
471 );
472 }
473
474 $current = array();
475 if ($id > 0) {
476 try {
477 $current = $this->page_get(array('lng' => $lng, 'id' => $id));
478 } catch (\Throwable $e) {
479 $current = array('error' => $e->getMessage());
480 }
481 }
482
483 return array(
484 'workflow' => $this->page_workflows()['page_update'],
485 'target' => array('lng' => $lng, 'id' => $id, 'change_fields' => array_values($fields), 'hero_mode' => $heroMode),
486 'current_page' => $current,
487 'manifest' => array(
488 'title' => 'Seite ' . $id . ' aktualisieren',
489 'recipe' => 'page.update.v1',
490 'lng' => $lng,
491 'auto_execute' => true,
492 ),
493 'job' => array('steps' => $steps),
494 'content_contract' => $this->content_contract(),
495 );
496 }
497
498 private function content_contract(): array {
499 return array(
500 'html_allowed' => true,
501 'bootstrap_allowed' => true,
502 'forbidden' => array('SQL', 'direkte SQLite-Aenderungen', 'eigene PHP-Tools', 'eigene JavaScript-Logik im Content', 'files/media/... als img src', 'Inline-Schein-Hero mit position-relative/position-absolute'),
503 'inline_media' => 'Immer inline_src/index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid={id} plus data-cms-media-id verwenden.',
504 'hero' => array(
505 'image' => 'Das Hero-Bild gehoert in die CMS-Hero-Zuordnung (slot=hero und hero_image_id), niemals in content.',
506 'text' => 'Hero-Text steht vor dem hr-Marker data-dbx-marker="dbx:hero".',
507 'validation' => 'dbxKi lehnt einen Inline-Bildblock mit absolut ueberlagertem Hero-Text am Seitenanfang ab.',
508 ),
509 'openwin' => 'openWin nur ueber class dbx-win und data-dbx="lib=openWin|url=...|title=...|width=...|height=..." verwenden.',
510 'markers' => array(
511 'dbx:hero' => 'Text davor wird Hero-Text.',
512 'dbx:header' => 'Text danach bis zum naechsten Marker wird Header.',
513 'dbx:footer' => 'Text danach wird Footer.',
514 ),
515 );
516 }
517
518 private function catalog(): array {
519 return array(
520 'system.health' => array(
521 'write' => false,
522 'description' => 'Prüft Modul, Benutzer, Sprachen und CMS-Datenzugriff.',
523 'params' => array(),
524 ),
525 'cms.snapshot' => array(
526 'write' => false,
527 'description' => 'Liefert Ordner, Seiten und Medien in einem begrenzten Arbeitskontext.',
528 'params' => array('lng' => 'Sprachcode', 'folder_id' => 'Optionaler Ordner', 'limit' => '1..200'),
529 ),
530 'folder.list' => array(
531 'write' => false,
532 'description' => 'Listet CMS-Ordner.',
533 'params' => array('lng' => 'Sprachcode', 'parent_id' => 'Optionaler Parent', 'limit' => '1..500'),
534 ),
535 'folder.get' => array(
536 'write' => false,
537 'description' => 'Liest einen Ordner.',
538 'params' => array('lng' => 'Sprachcode', 'id' => 'Ordner-ID'),
539 ),
540 'folder.create' => array(
541 'write' => true,
542 'description' => 'Erstellt einen Ordner.',
543 'required' => array('name'),
544 'params' => array(
545 'lng' => 'Sprachcode',
546 'name' => 'Bezeichnung',
547 'parent_id' => 'Parent-ID, Standard 0',
548 'group_read' => 'parent oder kommaseparierte Gruppen',
549 'template' => 'Content-Template',
550 'hero_*' => 'Optionale Hero-Einstellungen',
551 ),
552 ),
553 'folder.update' => array(
554 'write' => true,
555 'description' => 'Ändert oder verschiebt einen Ordner.',
556 'required' => array('id'),
557 'params' => array('lng' => 'Sprachcode', 'id' => 'Ordner-ID', 'patch' => 'Zu ändernde Felder oder Felder direkt in params'),
558 ),
559 'folder.delete' => array(
560 'write' => true,
561 'destructive' => true,
562 'description' => 'Löscht einen leeren Ordner in einer Sprache.',
563 'required' => array('id'),
564 'params' => array('lng' => 'Sprachcode', 'id' => 'Ordner-ID'),
565 ),
566 'page.list' => array(
567 'write' => false,
568 'description' => 'Listet CMS-Seiten.',
569 'params' => array('lng' => 'Sprachcode', 'folder_id' => 'Optionaler Ordner', 'limit' => '1..500'),
570 ),
571 'page.get' => array(
572 'write' => false,
573 'description' => 'Liest eine Seite einschließlich Medienzuordnungen.',
574 'params' => array('lng' => 'Sprachcode', 'id' => 'Seiten-ID'),
575 ),
576 'page.create_guide' => array(
577 'write' => false,
578 'description' => 'Liefert den verbindlichen KI-Ablauf, Regeln und ein job.json-Skelett fuer das Anlegen einer CMS-Seite.',
579 'params' => array('lng' => 'Sprachcode', 'folder_id' => 'Zielordner', 'title' => 'Seitentitel', 'with_hero' => '0/1', 'with_gallery' => '0/1'),
580 ),
581 'page.update_guide' => array(
582 'write' => false,
583 'description' => 'Liefert den verbindlichen KI-Ablauf, Regeln und ein job.json-Skelett fuer das Aendern einer CMS-Seite.',
584 'params' => array('lng' => 'Sprachcode', 'id' => 'Seiten-ID', 'change_fields' => 'Liste erlaubter Felder', 'hero_mode' => 'none, replace oder create'),
585 ),
586 'page.create' => array(
587 'write' => true,
588 'description' => 'Erstellt eine CMS-Seite.',
589 'required' => array('title'),
590 'params' => array(
591 'lng' => 'Sprachcode',
592 'folder_id' => 'Ordner-ID',
593 'title' => 'Titel',
594 'seo_title' => 'Optionaler SEO-Titel; Standard ist der Seitentitel',
595 'content' => 'HTML-Inhalt',
596 'description' => 'Meta-Beschreibung',
597 'keywords' => 'Meta-Keywords',
598 'permalink' => 'Optional; wird sonst erzeugt',
599 'activ' => '0 oder 1',
600 'template' => 'Content-Template',
601 ),
602 ),
603 'page.update' => array(
604 'write' => true,
605 'description' => 'Aktualisiert ausgewählte Seitenfelder. Inline-Bilder in content werden automatisch auf CMS-Medien-URLs (dbx_mid) normalisiert.',
606 'required' => array('id'),
607 'params' => array(
608 'lng' => 'Sprachcode',
609 'id' => 'Seiten-ID',
610 'patch' => 'Zu ändernde Felder oder Felder direkt in params',
611 'package_product_image' => 'Optional 1: Paket-Card auf vorhandenes Produktbild (home-package-*) umstellen',
612 'package_media_id' => 'Optional: Medien-ID statt Permalink-Zuordnung',
613 'package_image_alt' => 'Optional: alt-Text fuer das Produktbild',
614 ),
615 ),
616 'page.hero_replace_image' => array(
617 'write' => true,
618 'description' => 'Ersetzt nur die bestehende Hero-Bilddatei einer Seite. Medienverknüpfung und Seitenfelder bleiben unverändert.',
619 'required' => array('id', 'source_file'),
620 'params' => array(
621 'lng' => 'Sprachcode',
622 'id' => 'Seiten-ID',
623 'source_file' => 'Absolute oder dbxapp-relative neue Bildquelle',
624 'width' => 'Optional; Standard Breite des bestehenden Hero-Mediums',
625 'height' => 'Optional; Standard Höhe des bestehenden Hero-Mediums',
626 'fit' => 'cover oder contain, Standard cover',
627 'quality' => '1..100, Standard 82',
628 ),
629 ),
630 'page.hero_create_image' => array(
631 'write' => true,
632 'description' => 'Erstellt ein neues Hero-Bild in files/media/img/hero und setzt es als Hero der Seite.',
633 'required' => array('id', 'source_file'),
634 'params' => array(
635 'lng' => 'Sprachcode',
636 'id' => 'Seiten-ID',
637 'source_file' => 'Absolute oder dbxapp-relative Bildquelle',
638 'file_name' => 'Optionaler Dateiname, Standard aus Permalink',
639 'width' => 'Zielbreite, Standard 1280',
640 'height' => 'Zielhöhe, Standard 300',
641 'fit' => 'cover oder contain, Standard cover',
642 'quality' => '1..100, Standard 82',
643 ),
644 ),
645 'page.delete' => array(
646 'write' => true,
647 'destructive' => true,
648 'description' => 'Löscht eine Seite in einer Sprache und deaktiviert ihre Medienzuordnungen.',
649 'required' => array('id'),
650 'params' => array('lng' => 'Sprachcode', 'id' => 'Seiten-ID'),
651 ),
652 'media.list' => array(
653 'write' => false,
654 'description' => 'Listet aktive Medien und optional deren Zuordnungen.',
655 'params' => array('media_type' => 'image, video, file oder external_video', 'folder' => 'Medienordner', 'limit' => '1..500'),
656 ),
657 'media.get' => array(
658 'write' => false,
659 'description' => 'Liest ein Medium und seine aktiven Verwendungen.',
660 'params' => array('id' => 'Medien-ID'),
661 ),
662 'module.assets' => array(
663 'write' => false,
664 'description' => 'Listet vorhandene Modulbilder aus dbx/modules/*/tpl/mod und files/mod fuer Content- und Modul-Visualisierungen.',
665 'params' => array('module' => 'Optionaler Modulname', 'limit' => '1..500'),
666 ),
667 'media.create_base64' => array(
668 'write' => true,
669 'description' => 'Speichert eine Base64-Datei über dbXapp und registriert sie als Medium. Liefert inline_src/inline_img fuer die Content-Einbindung.',
670 'required' => array('file_name', 'data_base64'),
671 'params' => array(
672 'file_name' => 'Dateiname mit Endung',
673 'data_base64' => 'Reines Base64 oder Data-URL',
674 'media_folder' => 'Standard img/images, img/video oder file/ki; Hero immer img/hero, Gallery immer img/gallery',
675 'title' => 'Titel',
676 'alt' => 'Alternativtext',
677 'caption' => 'Bildunterschrift',
678 'tags' => 'Tags',
679 ),
680 'returns' => array('id', 'row', 'inline_src', 'inline_img'),
681 'usage' => 'Im Content immer inline_src oder inline_img verwenden. Niemals files/media/... direkt in img src setzen.',
682 ),
683 'media.create_image_variant' => array(
684 'write' => true,
685 'description' => 'Erzeugt aus einer lokalen Bildquelle eine zugeschnittene, skalierte und optional farblich getönte Bildvariante und registriert sie als Medium. Liefert inline_src/inline_img fuer die Content-Einbindung.',
686 'required' => array('source_file', 'file_name'),
687 'params' => array(
688 'source_file' => 'Absolute oder dbxapp-relative Quelldatei',
689 'file_name' => 'Zieldateiname mit .webp, .jpg, .jpeg oder .png',
690 'width' => 'Zielbreite, Standard Originalbreite',
691 'height' => 'Zielhöhe, Standard Originalhöhe',
692 'fit' => 'cover oder contain, Standard cover',
693 'crop_x/crop_y/crop_width/crop_height' => 'Optionaler Quell-Ausschnitt in Pixeln vor dem Skalieren',
694 'tint' => 'Optionale Farbe als #RRGGBB',
695 'tint_strength' => '0..1, Standard 0',
696 'quality' => '1..100, Standard 82',
697 'media_folder' => 'Standard img/images; Hero immer img/hero, Gallery immer img/gallery',
698 'title' => 'Titel',
699 'alt' => 'Alternativtext',
700 'caption' => 'Bildunterschrift',
701 'tags' => 'Tags',
702 ),
703 'returns' => array('id', 'row', 'inline_src', 'inline_img'),
704 'usage' => 'Im Content immer inline_src oder inline_img verwenden. Niemals files/media/... direkt in img src setzen.',
705 ),
706 'media.update' => array(
707 'write' => true,
708 'description' => 'Ändert Metadaten eines Mediums.',
709 'required' => array('id'),
710 'params' => array('id' => 'Medien-ID', 'patch' => 'title, alt, caption, tags, template'),
711 ),
712 'media.assign' => array(
713 'write' => true,
714 'description' => 'Ordnet ein Medium einer Seite oder einem Ordner zu.',
715 'required' => array('media_id'),
716 'params' => array(
717 'media_id' => 'Medien-ID',
718 'content_id' => 'Seiten-ID',
719 'folder_id' => 'Ordner-ID',
720 'slot' => 'hero, gallery, inline, header, teaser oder footer',
721 'template' => 'Darstellungs-Template',
722 'caption' => 'Kontextspezifische Bildunterschrift',
723 'settings' => 'Objekt oder JSON-Text',
724 ),
725 ),
726 'media.unassign' => array(
727 'write' => true,
728 'description' => 'Deaktiviert eine Medienzuordnung.',
729 'required' => array('usage_id'),
730 'params' => array('usage_id' => 'ID aus dbxMediaUsage'),
731 ),
732 'media.delete' => array(
733 'write' => true,
734 'destructive' => true,
735 'description' => 'Löscht ein unbenutztes Medium einschließlich lokaler Datei.',
736 'required' => array('id'),
737 'params' => array('id' => 'Medien-ID'),
738 ),
739 'translation.preview' => array(
740 'write' => false,
741 'description' => 'Liefert Quelltext, vorhandenes Ziel und genaue Übersetzungsanweisung.',
742 'required' => array('source_lng', 'target_lng', 'source_id'),
743 'params' => array('source_lng' => 'Quellsprache', 'target_lng' => 'Zielsprache', 'source_id' => 'Quellseiten-ID'),
744 ),
745 'translation.apply' => array(
746 'write' => true,
747 'description' => 'Speichert eine von der KI gelieferte Übersetzung; kein externer Übersetzungsdienst nötig.',
748 'required' => array('source_lng', 'target_lng', 'source_id', 'translation'),
749 'params' => array(
750 'source_lng' => 'Quellsprache',
751 'target_lng' => 'Zielsprache',
752 'source_id' => 'Quellseiten-ID',
753 'translation' => 'Objekt mit title, description, keywords und content; optional seo_title sowie img_alt_1..3 und img_des_1..3',
754 'copy_media' => '1 kopiert aktive Medienzuordnungen, Standard 1',
755 ),
756 ),
757 'translation.sync_all' => array(
758 'write' => true,
759 'description' => 'Übersetzt eine komplette CMS-Sprachstruktur aus einer Quellsprache in eine oder mehrere Zielsprachen.',
760 'required' => array('source_lng'),
761 'params' => array(
762 'source_lng' => 'Quellsprache',
763 'target_lngs' => 'Optional: Array oder kommaseparierte Zielsprachen; Standard alle aktiven Sprachen außer source_lng',
764 'root_folder_id' => 'Optional: Ordner-Teilbaum; Standard 0 = alle Ordner und Seiten',
765 'update_existing' => '1 aktualisiert vorhandene Zielseiten/-ordner, Standard 1',
766 'skip_manual' => '1 überspringt Ziel-Datensätze mit lng_sync=manual, Standard 0',
767 'copy_media' => '1 kopiert aktive Medienzuordnungen, Standard 1',
768 'replace_media_usage' => '1 ersetzt Medienzuordnungen der Zielseite; Standard 0 = nur fehlende ergänzen',
769 ),
770 ),
771 );
772 }
773
774 private function health(): array {
775 $this->ensure_schema();
776 return array(
777 'ok' => 1,
778 'api_version' => self::API_VERSION,
779 'user_id' => (int)dbx()->user(),
780 'admin' => 1,
781 'execute_enabled' => (int)dbx()->get_config('dbxKi', 'allow_execute', 1),
782 'languages' => dbxContentLngSync::accessibleLngs(),
783 'master_language' => dbxContentLngSync::masterLng(),
784 'content_count' => $this->db->count(dbxContentLng::ddContent($this->language(''))),
785 'folder_count' => $this->db->count(dbxContentLng::ddFolder($this->language(''))),
786 'media_count' => $this->db->count('dbxMedia', 'active = 1'),
787 );
788 }
789
790 private function read_action(string $action, array $params) {
791 $this->ensure_schema();
792 switch ($action) {
793 case 'cms.snapshot':
794 return $this->snapshot($params);
795 case 'folder.list':
796 return $this->folder_list($params);
797 case 'folder.get':
798 return $this->folder_get($params);
799 case 'page.list':
800 return $this->page_list($params);
801 case 'page.get':
802 return $this->page_get($params);
803 case 'page.create_guide':
804 return $this->page_create_guide($params);
805 case 'page.update_guide':
806 return $this->page_update_guide($params);
807 case 'media.list':
808 return $this->media_list($params);
809 case 'media.get':
810 return $this->media_get($params);
811 case 'module.assets':
812 return $this->module_assets($params);
813 case 'translation.preview':
814 return $this->translation_preview($params);
815 }
816 throw new \RuntimeException('Leseaktion nicht implementiert: ' . $action);
817 }
818
819 private function build_plan(string $action, array $params): array {
820 $this->ensure_schema();
821 switch ($action) {
822 case 'folder.create':
823 return $this->plan_folder_create($params);
824 case 'folder.update':
825 return $this->plan_folder_update($params);
826 case 'folder.delete':
827 return $this->plan_folder_delete($params);
828 case 'page.create':
829 return $this->plan_page_create($params);
830 case 'page.update':
831 return $this->plan_page_update($params);
832 case 'page.hero_replace_image':
833 return $this->plan_page_hero_replace_image($params);
834 case 'page.hero_create_image':
835 return $this->plan_page_hero_create_image($params);
836 case 'page.delete':
837 return $this->plan_page_delete($params);
838 case 'media.create_base64':
839 return $this->plan_media_create($params);
840 case 'media.create_image_variant':
841 return $this->plan_media_create_image_variant($params);
842 case 'media.update':
843 return $this->plan_media_update($params);
844 case 'media.assign':
845 return $this->plan_media_assign($params);
846 case 'media.unassign':
847 return $this->plan_media_unassign($params);
848 case 'media.delete':
849 return $this->plan_media_delete($params);
850 case 'translation.apply':
851 return $this->plan_translation_apply($params);
852 case 'translation.sync_all':
853 return $this->plan_translation_sync_all($params);
854 }
855 throw new \RuntimeException('Planung nicht implementiert: ' . $action);
856 }
857
858 private function execute_action(string $action, array $params, array $plan) {
859 switch ($action) {
860 case 'folder.create':
861 return $this->execute_folder_create($plan);
862 case 'folder.update':
863 return $this->execute_folder_update($plan);
864 case 'folder.delete':
865 return $this->execute_folder_delete($plan);
866 case 'page.create':
867 return $this->execute_page_create($plan);
868 case 'page.update':
869 return $this->execute_page_update($plan);
870 case 'page.hero_replace_image':
871 return $this->execute_page_hero_replace_image($plan);
872 case 'page.hero_create_image':
873 return $this->execute_page_hero_create_image($plan);
874 case 'page.delete':
875 return $this->execute_page_delete($plan);
876 case 'media.create_base64':
877 return $this->execute_media_create($params, $plan);
878 case 'media.create_image_variant':
879 return $this->execute_media_create_image_variant($plan);
880 case 'media.update':
881 return $this->execute_media_update($plan);
882 case 'media.assign':
883 return $this->execute_media_assign($plan);
884 case 'media.unassign':
885 return $this->execute_media_unassign($plan);
886 case 'media.delete':
887 return $this->execute_media_delete($plan);
888 case 'translation.apply':
889 return $this->execute_translation_apply($params, $plan);
890 case 'translation.sync_all':
891 return $this->execute_translation_sync_all($plan);
892 }
893 throw new \RuntimeException('Ausführung nicht implementiert: ' . $action);
894 }
895
896 private function ensure_schema(): void {
897 if (!is_object($this->db)) {
898 throw new \RuntimeException('dbxDB ist nicht verfügbar.');
899 }
900 dbxContentLngSync::ensureSchema($this->db);
901 }
902
903 private function language($value): string {
904 $lng = strtolower(trim((string)$value));
905 if ($lng === '') {
906 $lng = dbxContentLng::current();
907 }
908 if (!in_array($lng, dbxContentLngSync::accessibleLngs(), true)) {
909 throw new \InvalidArgumentException('Nicht unterstützte Sprache: ' . $lng);
910 }
911 return $lng;
912 }
913
914 private function id(array $params, string $key = 'id'): int {
915 $id = (int)($params[$key] ?? 0);
916 if ($id <= 0) {
917 throw new \InvalidArgumentException($key . ' muss größer als 0 sein.');
918 }
919 return $id;
920 }
921
922 private function limit(array $params, int $default = 100, int $max = 500): int {
923 return max(1, min($max, (int)($params['limit'] ?? $default)));
924 }
925
926 private function bool_value($value): bool {
927 if (is_bool($value)) return $value;
928 return in_array(strtolower(trim((string)$value)), array('1', 'true', 'yes', 'ja', 'on'), true);
929 }
930
931 private function clean($value, int $max = 0): string {
932 $value = trim((string)$value);
933 if ($max > 0) {
934 $value = mb_substr($value, 0, $max);
935 }
936 return $value;
937 }
938
939 private function plan_id(string $action, array $plan): string {
940 return hash('sha256', $action . '|' . json_encode($plan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
941 }
942
943 private function snapshot(array $params): array {
944 return array(
945 'language' => $this->language($params['lng'] ?? ''),
946 'folders' => $this->folder_list($params),
947 'pages' => $this->page_list($params),
948 'media' => $this->media_list(array('limit' => $this->limit($params, 100, 200))),
949 'module_assets' => $this->module_assets(array('limit' => 200)),
950 );
951 }
952
953 private function folder_list(array $params): array {
954 $lng = $this->language($params['lng'] ?? '');
955 $where = '';
956 if (array_key_exists('parent_id', $params)) {
957 $where = 'parent_id = ' . max(0, (int)$params['parent_id']);
958 }
959 $rows = $this->db->select(dbxContentLng::ddFolder($lng), $where, '*', 'sorter,id', 'ASC', '', $this->limit($params), 0, 1);
960 return array('lng' => $lng, 'rows' => is_array($rows) ? $rows : array());
961 }
962
963 private function folder_get(array $params): array {
964 $lng = $this->language($params['lng'] ?? '');
965 $id = $this->id($params);
966 $row = $this->db->select1(dbxContentLng::ddFolder($lng), $id);
967 if (!is_array($row)) throw new \RuntimeException('Ordner nicht gefunden.');
968 return array('lng' => $lng, 'row' => $row);
969 }
970
971 private function page_list(array $params): array {
972 $lng = $this->language($params['lng'] ?? '');
973 $where = '';
974 if (array_key_exists('folder_id', $params)) {
975 $where = 'folder = ' . max(0, (int)$params['folder_id']);
976 }
977 $rows = $this->db->select(dbxContentLng::ddContent($lng), $where, '*', 'folder,sorter,id', 'ASC', '', $this->limit($params), 0, 1);
978 return array('lng' => $lng, 'rows' => is_array($rows) ? $rows : array());
979 }
980
981 private function page_get(array $params): array {
982 $lng = $this->language($params['lng'] ?? '');
983 $id = $this->id($params);
984 $row = $this->db->select1(dbxContentLng::ddContent($lng), $id);
985 if (!is_array($row)) throw new \RuntimeException('Seite nicht gefunden.');
986 $usage = $this->db->select('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage('content_id = ' . $id . ' AND active = 1', $lng), '*', 'slot,sorter,id', 'ASC');
987 $hint = $this->package_page_hint($row);
988 return array(
989 'lng' => $lng,
990 'row' => $row,
991 'media_usage' => is_array($usage) ? $usage : array(),
992 'package_hint' => $hint,
993 );
994 }
995
996 private function media_list(array $params): array {
997 $rows = $this->db->select('dbxMedia', 'active = 1', '*', 'id', 'DESC', '', $this->limit($params), 0, 1);
998 $type = strtolower(trim((string)($params['media_type'] ?? '')));
999 $folder = trim((string)($params['folder'] ?? ''));
1000 $rows = array_values(array_filter(is_array($rows) ? $rows : array(), static function($row) use ($type, $folder) {
1001 if ($type !== '' && strtolower((string)($row['media_type'] ?? '')) !== $type) return false;
1002 if ($folder !== '' && (string)($row['media_folder'] ?? '') !== $folder) return false;
1003 return true;
1004 }));
1005 return array('rows' => $rows);
1006 }
1007
1008 private function media_get(array $params): array {
1009 $id = $this->id($params);
1010 $row = $this->db->select1('dbxMedia', $id);
1011 if (!is_array($row)) throw new \RuntimeException('Medium nicht gefunden.');
1012 $usage = $this->db->select('dbxMediaUsage', 'media_id = ' . $id . ' AND active = 1', '*', 'id', 'ASC');
1013 return array('row' => $row, 'usage' => is_array($usage) ? $usage : array());
1014 }
1015
1016 private function module_assets(array $params): array {
1017 $base = rtrim(str_replace('\\', '/', dbx()->get_base_dir()), '/') . '/';
1018 $moduleFilter = strtolower(trim((string)($params['module'] ?? '')));
1019 $limit = $this->limit($params, 200, 500);
1020 $rows = array();
1021 $seen = array();
1022
1023 $add = static function(string $file, string $source) use (&$rows, &$seen, $base, $moduleFilter): void {
1024 $path = str_replace('\\', '/', $file);
1025 if (!is_file($file)) {
1026 return;
1027 }
1028 if (!preg_match('/\.(svg|png|jpe?g|webp|gif)$/i', $path)) {
1029 return;
1030 }
1031
1032 $rel = str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
1033 $name = basename($path);
1034 $module = '';
1035 $action = '';
1036 if (preg_match('#dbx/modules/([^/]+)/tpl/mod/([^/]+)\.[^.]+$#', $rel, $m)) {
1037 $module = (string)$m[1];
1038 $stem = (string)$m[2];
1039 } else {
1040 $stem = preg_replace('/\.[^.]+$/', '', $name);
1041 if (preg_match('/^([A-Za-z0-9_]+)__(.+)$/', (string)$stem, $m)) {
1042 $module = (string)$m[1];
1043 $action = (string)$m[2];
1044 }
1045 }
1046 if ($action === '' && $module !== '') {
1047 $prefix = $module . '_';
1048 $stem = preg_replace('/\.[^.]+$/', '', $name);
1049 $action = str_starts_with((string)$stem, $prefix) ? substr((string)$stem, strlen($prefix)) : (string)$stem;
1050 }
1051 if ($moduleFilter !== '' && strtolower($module) !== $moduleFilter) {
1052 return;
1053 }
1054 $key = $rel;
1055 if (isset($seen[$key])) {
1056 return;
1057 }
1058 $seen[$key] = true;
1059 $rows[] = array(
1060 'module' => $module,
1061 'action' => $action,
1062 'name' => $name,
1063 'source' => $source,
1064 'path' => $rel,
1065 'src' => $rel,
1066 'bytes' => filesize($file),
1067 );
1068 };
1069
1070 foreach (glob(dbx()->get_base_dir() . 'dbx/modules/*/tpl/mod/*') ?: array() as $file) {
1071 $add($file, 'module_tpl_mod');
1072 if (count($rows) >= $limit) {
1073 break;
1074 }
1075 }
1076 if (count($rows) < $limit) {
1077 foreach (glob(dbx()->get_base_dir() . 'files/mod/*') ?: array() as $file) {
1078 $add($file, 'files_mod');
1079 if (count($rows) >= $limit) {
1080 break;
1081 }
1082 }
1083 }
1084
1085 return array(
1086 'rows' => $rows,
1087 'usage' => 'Im Content als <img src=\"{src}\" ...> verwenden. Vorhandene Modulbilder bevorzugen, wenn Module visuell dargestellt werden.',
1088 );
1089 }
1090
1091 private function translation_preview(array $params): array {
1092 $sourceLng = $this->language($params['source_lng'] ?? '');
1093 $targetLng = $this->language($params['target_lng'] ?? '');
1094 if ($sourceLng === $targetLng) throw new \InvalidArgumentException('Quell- und Zielsprache müssen verschieden sein.');
1095 $sourceId = $this->id($params, 'source_id');
1096 $sourceDd = dbxContentLng::ddContent($sourceLng);
1097 $source = $this->db->select1($sourceDd, $sourceId);
1098 if (!is_array($source)) throw new \RuntimeException('Quellseite nicht gefunden.');
1099 $uid = trim((string)($source['lng_uid'] ?? ''));
1100 $targetId = $uid !== ''
1101 ? dbxContentLngSync::resolveIdByUid($this->db, dbxContentLng::ddContent($targetLng), $uid, $targetLng)
1102 : 0;
1103 $target = $targetId > 0 ? $this->db->select1(dbxContentLng::ddContent($targetLng), $targetId) : null;
1104 return array(
1105 'source_lng' => $sourceLng,
1106 'target_lng' => $targetLng,
1107 'source' => $source,
1108 'target' => $target,
1109 'source_uid_missing' => $uid === '',
1110 'instruction' => array(
1111 'translate_fields' => array(
1112 'title', 'description', 'keywords', 'content', 'seo_title',
1113 'img_alt_1', 'img_alt_2', 'img_alt_3',
1114 'img_des_1', 'img_des_2', 'img_des_3'
1115 ),
1116 'preserve' => 'HTML-Struktur, Links, data-cms-media-id, Platzhalter, IDs, CSS-Klassen und technische Attribute unverändert lassen.',
1117 'do_not_translate' => 'Dateipfade, URLs, Modulnamen, Template-Namen, Shortcodes und Code.',
1118 'quality' => 'Natürlich, fachlich korrekt und zur Zielsprache passend übersetzen. Keine zusätzlichen Aussagen erfinden.',
1119 'next_action' => 'translation.apply mit translation-Objekt aufrufen.',
1120 ),
1121 );
1122 }
1123
1124 private function plan_folder_create(array $params): array {
1125 $lng = $this->language($params['lng'] ?? '');
1126 $name = $this->clean($params['name'] ?? '', 120);
1127 if ($name === '') throw new \InvalidArgumentException('name ist erforderlich.');
1128 $parent = max(0, (int)($params['parent_id'] ?? 0));
1129 if ($parent > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($lng), $parent))) {
1130 throw new \RuntimeException('Parent-Ordner nicht gefunden.');
1131 }
1132 return array(
1133 'operation' => 'insert',
1134 'entity' => 'folder',
1135 'lng' => $lng,
1136 'data' => $this->folder_data($params, $parent, $name),
1137 );
1138 }
1139
1140 private function plan_folder_update(array $params): array {
1141 $lng = $this->language($params['lng'] ?? '');
1142 $id = $this->id($params);
1143 $dd = dbxContentLng::ddFolder($lng);
1144 $before = $this->db->select1($dd, $id);
1145 if (!is_array($before)) throw new \RuntimeException('Ordner nicht gefunden.');
1146 $patch = $this->patch($params);
1147 $parent = array_key_exists('parent_id', $patch) ? max(0, (int)$patch['parent_id']) : (int)($before['parent_id'] ?? 0);
1148 if ($parent === $id || $this->folder_descendant($dd, $parent, $id)) {
1149 throw new \InvalidArgumentException('Ungültiger Parent: Schleife im Ordnerbaum.');
1150 }
1151 if ($parent > 0 && !is_array($this->db->select1($dd, $parent))) throw new \RuntimeException('Parent-Ordner nicht gefunden.');
1152 $allowed = array('name', 'parent_id', 'group_read', 'template', 'hero_template', 'hero_image_id', 'hero_margin_top', 'hero_height', 'hero_variant', 'hero_sticky', 'hero_scroll_layer', 'sorter');
1153 $data = $this->whitelist($patch, $allowed);
1154 if (isset($data['name'])) $data['name'] = $this->clean($data['name'], 120);
1155 if (!$data) throw new \InvalidArgumentException('Keine änderbaren Felder übergeben.');
1156 return array('operation' => 'update', 'entity' => 'folder', 'lng' => $lng, 'id' => $id, 'before' => $before, 'changes' => $data);
1157 }
1158
1159 private function plan_folder_delete(array $params): array {
1160 $lng = $this->language($params['lng'] ?? '');
1161 $id = $this->id($params);
1162 $row = $this->db->select1(dbxContentLng::ddFolder($lng), $id);
1163 if (!is_array($row)) throw new \RuntimeException('Ordner nicht gefunden.');
1164 $check = dbxContentLngSync::folderDeletable($this->db, $lng, $id);
1165 if ((int)($check['deletable'] ?? 0) !== 1) {
1166 throw new \RuntimeException((string)($check['reason'] ?? 'Ordner ist nicht löschbar.'));
1167 }
1168 return array('operation' => 'delete', 'entity' => 'folder', 'lng' => $lng, 'id' => $id, 'before' => $row);
1169 }
1170
1171 private function plan_page_create(array $params): array {
1172 $lng = $this->language($params['lng'] ?? '');
1173 $title = $this->clean($params['title'] ?? '', 254);
1174 if ($title === '') throw new \InvalidArgumentException('title ist erforderlich.');
1175 $folder = max(0, (int)($params['folder_id'] ?? $params['folder'] ?? 0));
1176 if ($folder > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($lng), $folder))) {
1177 throw new \RuntimeException('Zielordner nicht gefunden.');
1178 }
1179 $data = $this->page_data($params, $lng, $folder, $title);
1180 $this->assert_no_fake_inline_hero((string)($data['content'] ?? ''));
1181 return array(
1182 'operation' => 'insert',
1183 'entity' => 'page',
1184 'lng' => $lng,
1185 'data' => $data,
1186 );
1187 }
1188
1189 private function plan_page_update(array $params): array {
1190 $lng = $this->language($params['lng'] ?? '');
1191 $id = $this->id($params);
1192 $dd = dbxContentLng::ddContent($lng);
1193 $before = $this->db->select1($dd, $id);
1194 if (!is_array($before)) throw new \RuntimeException('Seite nicht gefunden.');
1195 $patch = $this->patch($params);
1196 if (array_key_exists('folder_id', $patch) && !array_key_exists('folder', $patch)) {
1197 $patch['folder'] = $patch['folder_id'];
1198 }
1199 $packageProductImage = $this->bool_value($patch['package_product_image'] ?? false);
1200 $packageMediaId = max(0, (int)($patch['package_media_id'] ?? 0));
1201 $packageImageAlt = $this->clean($patch['package_image_alt'] ?? '', 254);
1202 unset($patch['package_product_image'], $patch['package_media_id'], $patch['package_image_alt']);
1203 $allowed = array(
1204 'activ', 'folder', 'title', 'menu_title', 'seo_title', 'permalink', 'description', 'keywords', 'group_read', 'template', 'content', 'sorter',
1205 'hero_template', 'hero_image_id', 'hero_margin_top', 'hero_height', 'hero_variant', 'hero_sticky',
1206 'hero_scroll_layer', 'gallery_template', 'gallery_visible_count', 'gallery_image_size',
1207 'gallery_lightbox_width', 'gallery_overflow', 'gallery_click_behavior'
1208 );
1209 $data = $this->whitelist($patch, $allowed);
1210 if (isset($data['title'])) $data['title'] = $this->clean($data['title'], 254);
1211 if (isset($data['menu_title'])) $data['menu_title'] = $this->clean($data['menu_title'], 96);
1212 if (isset($data['seo_title'])) $data['seo_title'] = $this->clean($data['seo_title'], 254);
1213 if (array_key_exists('permalink', $data)) {
1214 $data['permalink'] = trim($this->clean($data['permalink'], 254));
1215 if (!dbxContent_permalink::isValid($data['permalink'])) {
1216 throw new \InvalidArgumentException('permalink darf nur Kleinbuchstaben, Zahlen und einzelne Bindestriche enthalten.');
1217 }
1218 if (dbxContent_permalink::exists($this->db, $dd, $data['permalink'], $id)) {
1219 throw new \InvalidArgumentException('permalink wird bereits von einer anderen Seite verwendet.');
1220 }
1221 }
1222 if (isset($data['folder'])) {
1223 $data['folder'] = max(0, (int)$data['folder']);
1224 if ($data['folder'] > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($lng), $data['folder']))) {
1225 throw new \RuntimeException('Zielordner nicht gefunden.');
1226 }
1227 }
1228 if (!$data && !$packageProductImage && $packageMediaId <= 0) {
1229 throw new \InvalidArgumentException('Keine änderbaren Felder übergeben.');
1230 }
1231 if (array_key_exists('content', $data)) {
1232 $data['content'] = $this->normalize_content_inline_media_urls((string)$data['content']);
1233 }
1234 $packageMediaApplied = 0;
1235 if ($packageProductImage || $packageMediaId > 0) {
1236 $mediaId = $packageMediaId > 0
1237 ? $packageMediaId
1238 : $this->package_media_id_for_permalink((string)($before['permalink'] ?? ''));
1239 if ($mediaId <= 0) {
1240 throw new \RuntimeException('Kein Paket-Produktbild fuer diese Seite gefunden. package_media_id angeben oder home-package-* Medium anlegen.');
1241 }
1242 $content = array_key_exists('content', $data)
1243 ? (string)$data['content']
1244 : (string)($before['content'] ?? '');
1245 $data['content'] = $this->normalize_content_inline_media_urls(
1246 $this->apply_package_product_image($content, $mediaId, $packageImageAlt)
1247 );
1248 $packageMediaApplied = $mediaId;
1249 }
1250 if (array_key_exists('content', $data)) {
1251 $this->assert_no_fake_inline_hero((string)$data['content']);
1252 }
1253 $plan = array('operation' => 'update', 'entity' => 'page', 'lng' => $lng, 'id' => $id, 'before' => $before, 'changes' => $data);
1254 if ($packageMediaApplied > 0) {
1255 $plan['package_media_id_applied'] = $packageMediaApplied;
1256 }
1257 return $plan;
1258 }
1259
1260 private function plan_page_hero_replace_image(array $params): array {
1261 $lng = $this->language($params['lng'] ?? '');
1262 $id = $this->id($params);
1263 $hero = $this->hero_media_for_page($lng, $id);
1264 $media = $hero['media'];
1265 $source = $this->source_image_plan($params);
1266 $target = $this->media_local_file($media);
1267 if ($target === '') throw new \RuntimeException('Hero-Medium ist keine lokale Datei.');
1268
1269 $width = max(1, (int)($params['width'] ?? $media['width'] ?? 0));
1270 $height = max(1, (int)($params['height'] ?? $media['height'] ?? 0));
1271 if ($width <= 1 || $height <= 1) {
1272 $width = 1280;
1273 $height = 300;
1274 }
1275 $mime = (string)($media['mime'] ?? '');
1276 if (!in_array($mime, array('image/jpeg', 'image/png', 'image/webp'), true)) {
1277 $mime = $this->mime_from_file_name((string)($media['file_name'] ?? 'hero.webp'));
1278 }
1279
1280 return array(
1281 'operation' => 'replace_page_hero_file',
1282 'entity' => 'page_hero',
1283 'lng' => $lng,
1284 'id' => $id,
1285 'page' => $hero['page'],
1286 'media' => $media,
1287 'usage' => $hero['usage'],
1288 'source' => $source,
1289 'target_file' => $target,
1290 'width' => $width,
1291 'height' => $height,
1292 'fit' => $this->image_fit($params['fit'] ?? 'cover'),
1293 'quality' => $this->image_quality($params['quality'] ?? 82),
1294 'mime' => $mime,
1295 );
1296 }
1297
1298 private function plan_page_hero_create_image(array $params): array {
1299 $lng = $this->language($params['lng'] ?? '');
1300 $id = $this->id($params);
1301 $dd = dbxContentLng::ddContent($lng);
1302 $page = $this->db->select1($dd, $id);
1303 if (!is_array($page)) throw new \RuntimeException('Seite nicht gefunden.');
1304
1305 $permalink = trim((string)($page['permalink'] ?? ''));
1306 $baseName = $permalink !== '' ? $permalink : ('page-' . $id);
1307 $fileName = $this->safe_file_name($params['file_name'] ?? ($baseName . '-hero.webp'));
1308 if ($fileName === '') $fileName = 'page-' . $id . '-hero.webp';
1309
1310 $variant = $this->plan_media_create_image_variant(array_merge($params, array(
1311 'file_name' => $fileName,
1312 'width' => max(1, (int)($params['width'] ?? 1280)),
1313 'height' => max(1, (int)($params['height'] ?? 300)),
1314 'fit' => $params['fit'] ?? 'cover',
1315 'quality' => $params['quality'] ?? 82,
1316 'media_folder' => 'img/hero',
1317 'title' => $params['title'] ?? ('Hero ' . ($page['title'] ?? $fileName)),
1318 'alt' => $params['alt'] ?? (string)($page['title'] ?? ''),
1319 )));
1320
1321 return array(
1322 'operation' => 'create_page_hero_media',
1323 'entity' => 'page_hero',
1324 'lng' => $lng,
1325 'id' => $id,
1326 'page' => $page,
1327 'media_plan' => $variant,
1328 );
1329 }
1330
1331 private function plan_page_delete(array $params): array {
1332 $lng = $this->language($params['lng'] ?? '');
1333 $id = $this->id($params);
1334 $row = $this->db->select1(dbxContentLng::ddContent($lng), $id);
1335 if (!is_array($row)) throw new \RuntimeException('Seite nicht gefunden.');
1336 $usage = $this->db->count('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage('content_id = ' . $id . ' AND active = 1', $lng));
1337 return array('operation' => 'delete', 'entity' => 'page', 'lng' => $lng, 'id' => $id, 'before' => $row, 'media_usage_to_deactivate' => $usage);
1338 }
1339
1340 private function plan_media_create(array $params): array {
1341 $name = $this->safe_file_name($params['file_name'] ?? '');
1342 $raw = (string)($params['data_base64'] ?? '');
1343 if ($name === '' || trim($raw) === '') throw new \InvalidArgumentException('file_name und data_base64 sind erforderlich.');
1344 $decoded = $this->decode_base64($raw);
1345 $max = max(1024, (int)dbx()->get_config('dbxKi', 'max_base64_bytes', 10485760));
1346 if (strlen($decoded) > $max) throw new \InvalidArgumentException('Datei überschreitet das konfigurierte Größenlimit.');
1347 $mime = $this->detect_mime($decoded, $name);
1348 $allowed = array('image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/webm', 'video/quicktime', 'application/pdf', 'text/plain');
1349 if (!in_array($mime, $allowed, true)) throw new \InvalidArgumentException('Nicht unterstützter MIME-Typ: ' . $mime);
1350 $type = strpos($mime, 'image/') === 0 ? 'image' : (strpos($mime, 'video/') === 0 ? 'video' : 'file');
1351 $defaultFolder = $type === 'image' ? 'img/images' : ($type === 'video' ? 'img/video' : 'file/ki');
1352 $folder = $this->media_folder($params['media_folder'] ?? $defaultFolder, $type);
1353 return array(
1354 'operation' => 'create_file_and_insert',
1355 'entity' => 'media',
1356 'file_name' => $name,
1357 'bytes' => strlen($decoded),
1358 'sha256' => hash('sha256', $decoded),
1359 'mime' => $mime,
1360 'media_type' => $type,
1361 'media_folder' => $folder,
1362 'metadata' => array(
1363 'title' => $this->clean($params['title'] ?? pathinfo($name, PATHINFO_FILENAME), 160),
1364 'alt' => $this->clean($params['alt'] ?? '', 254),
1365 'caption' => $this->clean($params['caption'] ?? ''),
1366 'tags' => $this->clean($params['tags'] ?? '', 254),
1367 ),
1368 );
1369 }
1370
1371 private function plan_media_create_image_variant(array $params): array {
1372 if (!extension_loaded('gd')) {
1373 throw new \RuntimeException('GD ist erforderlich, um Bildvarianten zu erzeugen.');
1374 }
1375
1376 $source = $this->resolve_local_file((string)($params['source_file'] ?? ''));
1377 if ($source === '' || !is_file($source) || !is_readable($source)) {
1378 throw new \InvalidArgumentException('source_file ist nicht lesbar.');
1379 }
1380
1381 $name = $this->safe_file_name($params['file_name'] ?? '');
1382 if ($name === '') throw new \InvalidArgumentException('file_name ist erforderlich.');
1383 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
1384 $mimeMap = array('jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp');
1385 if (!isset($mimeMap[$ext])) {
1386 throw new \InvalidArgumentException('file_name muss .webp, .jpg, .jpeg oder .png verwenden.');
1387 }
1388
1389 $info = @getimagesize($source);
1390 if (!is_array($info) || empty($info[0]) || empty($info[1])) {
1391 throw new \InvalidArgumentException('source_file ist kein lesbares Bild.');
1392 }
1393 $sourceMime = (string)($info['mime'] ?? '');
1394 if (!in_array($sourceMime, array('image/jpeg', 'image/png', 'image/webp', 'image/gif'), true)) {
1395 throw new \InvalidArgumentException('Nicht unterstützter Quellbildtyp: ' . $sourceMime);
1396 }
1397
1398 $sourceWidth = (int)$info[0];
1399 $sourceHeight = (int)$info[1];
1400 $crop = $this->image_crop_rect($params, $sourceWidth, $sourceHeight);
1401 $width = max(1, (int)($params['width'] ?? $sourceWidth));
1402 $height = max(1, (int)($params['height'] ?? $sourceHeight));
1403 $fit = strtolower(trim((string)($params['fit'] ?? 'cover')));
1404 if (!in_array($fit, array('cover', 'contain'), true)) $fit = 'cover';
1405 $quality = min(100, max(1, (int)($params['quality'] ?? 82)));
1406 $tint = $this->normalize_hex_color((string)($params['tint'] ?? ''));
1407 $tintStrength = max(0.0, min(1.0, (float)($params['tint_strength'] ?? 0)));
1408 $folder = $this->media_folder($params['media_folder'] ?? 'img/images', 'image');
1409
1410 return array(
1411 'operation' => 'create_image_variant_and_insert',
1412 'entity' => 'media',
1413 'source_file' => $source,
1414 'source_sha256' => hash_file('sha256', $source),
1415 'source_mime' => $sourceMime,
1416 'source_width' => $sourceWidth,
1417 'source_height' => $sourceHeight,
1418 'crop' => $crop,
1419 'file_name' => $name,
1420 'mime' => $mimeMap[$ext],
1421 'media_type' => 'image',
1422 'media_folder' => $folder,
1423 'width' => $width,
1424 'height' => $height,
1425 'fit' => $fit,
1426 'quality' => $quality,
1427 'tint' => $tint,
1428 'tint_strength' => $tintStrength,
1429 'metadata' => array(
1430 'title' => $this->clean($params['title'] ?? pathinfo($name, PATHINFO_FILENAME), 160),
1431 'alt' => $this->clean($params['alt'] ?? '', 254),
1432 'caption' => $this->clean($params['caption'] ?? ''),
1433 'tags' => $this->clean($params['tags'] ?? '', 254),
1434 ),
1435 );
1436 }
1437
1438 private function plan_media_update(array $params): array {
1439 $id = $this->id($params);
1440 $before = $this->db->select1('dbxMedia', $id);
1441 if (!is_array($before) || (int)($before['active'] ?? 0) !== 1) throw new \RuntimeException('Medium nicht gefunden.');
1442 $data = $this->whitelist($this->patch($params), array('title', 'alt', 'caption', 'tags', 'template'));
1443 if (!$data) throw new \InvalidArgumentException('Keine änderbaren Metadaten übergeben.');
1444 return array('operation' => 'update', 'entity' => 'media', 'id' => $id, 'before' => $before, 'changes' => $data);
1445 }
1446
1447 private function plan_media_assign(array $params): array {
1448 $mediaId = $this->id($params, 'media_id');
1449 $media = $this->db->select1('dbxMedia', $mediaId);
1450 if (!is_array($media) || (int)($media['active'] ?? 0) !== 1) throw new \RuntimeException('Medium nicht gefunden.');
1451 $contentId = max(0, (int)($params['content_id'] ?? 0));
1452 $folderId = max(0, (int)($params['folder_id'] ?? 0));
1453 if (($contentId > 0) === ($folderId > 0)) throw new \InvalidArgumentException('Genau content_id oder folder_id muss gesetzt sein.');
1454 $lng = $this->language($params['lng'] ?? '');
1455 if ($contentId > 0 && !is_array($this->db->select1(dbxContentLng::ddContent($lng), $contentId))) throw new \RuntimeException('Seite nicht gefunden.');
1456 if ($folderId > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($lng), $folderId))) throw new \RuntimeException('Ordner nicht gefunden.');
1457 $slot = $this->slot($params['slot'] ?? 'gallery');
1458 return array(
1459 'operation' => 'insert',
1460 'entity' => 'media_usage',
1461 'lng' => $lng,
1462 'media' => $media,
1463 'data' => array(
1464 'active' => 1,
1465 'media_id' => $mediaId,
1466 'content_id' => $contentId,
1467 'folder_id' => $folderId,
1468 'slot' => $slot,
1469 'template' => $this->clean($params['template'] ?? $media['template'] ?? '', 80),
1470 'caption' => $this->clean($params['caption'] ?? ''),
1471 'settings' => is_array($params['settings'] ?? null)
1472 ? json_encode($params['settings'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
1473 : $this->clean($params['settings'] ?? ''),
1474 ),
1475 );
1476 }
1477
1478 private function plan_media_unassign(array $params): array {
1479 $id = $this->id($params, 'usage_id');
1480 $row = $this->db->select1('dbxMediaUsage', $id);
1481 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) throw new \RuntimeException('Aktive Medienzuordnung nicht gefunden.');
1482 return array('operation' => 'update', 'entity' => 'media_usage', 'id' => $id, 'before' => $row, 'changes' => array('active' => 0));
1483 }
1484
1485 private function plan_media_delete(array $params): array {
1486 $id = $this->id($params);
1487 $data = $this->media_get(array('id' => $id));
1488 if (count($data['usage'])) throw new \RuntimeException('Medium wird noch verwendet. Zuerst media.unassign ausführen.');
1489 return array('operation' => 'delete', 'entity' => 'media', 'id' => $id, 'before' => $data['row']);
1490 }
1491
1492 private function plan_translation_apply(array $params): array {
1493 $preview = $this->translation_preview($params);
1494 $translation = is_array($params['translation'] ?? null) ? $params['translation'] : array();
1495 foreach (array('title', 'description', 'keywords', 'content') as $field) {
1496 if (!array_key_exists($field, $translation)) throw new \InvalidArgumentException('translation.' . $field . ' fehlt.');
1497 }
1498 $translation = $this->whitelist($translation, array(
1499 'title', 'description', 'keywords', 'content', 'seo_title',
1500 'img_alt_1', 'img_alt_2', 'img_alt_3',
1501 'img_des_1', 'img_des_2', 'img_des_3'
1502 ));
1503 $translation['title'] = $this->clean($translation['title'], 254);
1504 $translation['description'] = $this->clean($translation['description'], 254);
1505 $translation['keywords'] = $this->clean($translation['keywords'], 254);
1506 foreach (array('seo_title', 'img_alt_1', 'img_alt_2', 'img_alt_3') as $field) {
1507 if (array_key_exists($field, $translation)) {
1508 $translation[$field] = $this->clean($translation[$field], 254);
1509 }
1510 }
1511 foreach (array('img_des_1', 'img_des_2', 'img_des_3') as $field) {
1512 if (array_key_exists($field, $translation)) {
1513 $translation[$field] = $this->clean($translation[$field]);
1514 }
1515 }
1516 if ($translation['title'] === '') throw new \InvalidArgumentException('Übersetzter Titel darf nicht leer sein.');
1517 $translation['content'] = $this->normalize_content_inline_media_urls((string)$translation['content']);
1518 return array(
1519 'operation' => is_array($preview['target']) ? 'update' : 'insert',
1520 'entity' => 'translation',
1521 'source_lng' => $preview['source_lng'],
1522 'target_lng' => $preview['target_lng'],
1523 'source' => $preview['source'],
1524 'target' => $preview['target'],
1525 'translation' => $translation,
1526 'copy_media' => !array_key_exists('copy_media', $params) || $this->bool_value($params['copy_media']),
1527 );
1528 }
1529
1530 private function plan_translation_sync_all(array $params): array {
1531 $sourceLng = $this->language($params['source_lng'] ?? '');
1532 $targetLngs = $this->target_languages($params, $sourceLng);
1533 if (!count($targetLngs)) {
1534 throw new \InvalidArgumentException('Keine Zielsprachen gefunden.');
1535 }
1536
1537 $rootFolderId = max(0, (int)($params['root_folder_id'] ?? $params['folder_id'] ?? 0));
1538 if ($rootFolderId > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($sourceLng), $rootFolderId))) {
1539 throw new \RuntimeException('Quellordner nicht gefunden.');
1540 }
1541
1542 $folderIds = $this->collect_folder_ids_for_lng($sourceLng, $rootFolderId);
1543 $pageIds = $this->collect_page_ids_for_lng($sourceLng, $rootFolderId, $folderIds);
1544
1545 return array(
1546 'operation' => 'translation_sync_all',
1547 'entity' => 'content_language',
1548 'source_lng' => $sourceLng,
1549 'target_lngs' => $targetLngs,
1550 'root_folder_id' => $rootFolderId,
1551 'update_existing' => !array_key_exists('update_existing', $params) || $this->bool_value($params['update_existing']),
1552 'skip_manual' => array_key_exists('skip_manual', $params) && $this->bool_value($params['skip_manual']),
1553 'copy_media' => !array_key_exists('copy_media', $params) || $this->bool_value($params['copy_media']),
1554 'replace_media_usage' => array_key_exists('replace_media_usage', $params) && $this->bool_value($params['replace_media_usage']),
1555 'provider' => dbxContentTranslate::provider(),
1556 'counts' => array(
1557 'folders' => count($folderIds),
1558 'pages' => count($pageIds),
1559 'target_languages' => count($targetLngs),
1560 ),
1561 'source_ids' => array(
1562 'folders' => $folderIds,
1563 'pages' => $pageIds,
1564 ),
1565 );
1566 }
1567
1568 private function execute_folder_create(array $plan): array {
1569 $dd = dbxContentLng::ddFolder($plan['lng']);
1570 $data = $plan['data'];
1571 $data['sorter'] = $this->next_sorter($dd, 'parent_id', (int)$data['parent_id']);
1572 $data += $this->lng_fields('f', $plan['lng']);
1573 if ($this->db->insert($dd, $data) !== 1) throw new \RuntimeException('Ordner konnte nicht erstellt werden.');
1574 $id = $this->db->get_insert_id();
1575 $this->invalidate_folder($id);
1576 return array('id' => $id, 'row' => $this->db->select1($dd, $id));
1577 }
1578
1579 private function execute_folder_update(array $plan): array {
1580 $dd = dbxContentLng::ddFolder($plan['lng']);
1581 $data = $plan['changes'];
1582 $data = $this->advance_revision($dd, $plan['id'], $data, $plan['lng']);
1583 if ($this->db->update($dd, $data, $plan['id']) !== 1) throw new \RuntimeException('Ordner konnte nicht aktualisiert werden.');
1584 $this->invalidate_folder($plan['id']);
1585 return array('id' => $plan['id'], 'row' => $this->db->select1($dd, $plan['id']));
1586 }
1587
1588 private function execute_folder_delete(array $plan): array {
1589 $dd = dbxContentLng::ddFolder($plan['lng']);
1590 if ($this->db->delete($dd, $plan['id']) !== 1) throw new \RuntimeException('Ordner konnte nicht gelöscht werden.');
1591 $this->invalidate_folder($plan['id']);
1592 return array('deleted' => true, 'id' => $plan['id'], 'lng' => $plan['lng']);
1593 }
1594
1595 private function execute_page_create(array $plan): array {
1596 $dd = dbxContentLng::ddContent($plan['lng']);
1597 $data = $plan['data'];
1598 if (trim((string)($data['sorter'] ?? '')) === '') {
1599 $data['sorter'] = $this->next_sorter($dd, 'folder', (int)$data['folder']);
1600 }
1601 $data += $this->lng_fields('p', $plan['lng']);
1602 if ($this->db->insert($dd, $data) !== 1) throw new \RuntimeException('Seite konnte nicht erstellt werden.');
1603 $id = $this->db->get_insert_id();
1604 $this->invalidate_page($id, $plan['lng'], $data);
1605 return array('id' => $id, 'row' => $this->db->select1($dd, $id));
1606 }
1607
1608 private function execute_page_update(array $plan): array {
1609 $dd = dbxContentLng::ddContent($plan['lng']);
1610 $data = $this->advance_revision($dd, $plan['id'], $plan['changes'], $plan['lng']);
1611 if ($this->db->update($dd, $data, $plan['id']) !== 1) throw new \RuntimeException('Seite konnte nicht aktualisiert werden.');
1612 $mediaId = (int)($plan['package_media_id_applied'] ?? 0);
1613 if ($mediaId > 0) {
1614 $this->ensure_inline_media_usage((int)$plan['id'], $mediaId, (string)$plan['lng']);
1615 }
1616 $row = $this->db->select1($dd, $plan['id']);
1617 $this->invalidate_page($plan['id'], $plan['lng'], $row);
1618 $result = array('id' => $plan['id'], 'row' => $row);
1619 if ($mediaId > 0) {
1620 $result['package_media_id'] = $mediaId;
1621 $result = array_merge($result, $this->media_inline_payload($mediaId));
1622 }
1623 return $result;
1624 }
1625
1626 private function execute_page_hero_replace_image(array $plan): array {
1627 $target = (string)$plan['target_file'];
1628 $this->render_image_variant_to_file($plan['source'], $target, (int)$plan['width'], (int)$plan['height'], (string)$plan['fit'], (string)$plan['mime'], (int)$plan['quality']);
1629
1630 $mediaId = (int)($plan['media']['id'] ?? 0);
1631 $data = array(
1632 'size' => (int)@filesize($target),
1633 'width' => (int)$plan['width'],
1634 'height' => (int)$plan['height'],
1635 'mime' => (string)$plan['mime'],
1636 );
1637 if ($mediaId <= 0) throw new \RuntimeException('Hero-Medium konnte nicht aktualisiert werden.');
1638 $this->db->update('dbxMedia', $data, $mediaId);
1639 $this->invalidate_media_references($mediaId);
1640 $this->invalidate_page((int)$plan['id'], (string)$plan['lng'], $plan['page']);
1641 return array(
1642 'id' => (int)$plan['id'],
1643 'media_id' => $mediaId,
1644 'file' => str_replace('\\', '/', $target),
1645 'replaced' => true,
1646 );
1647 }
1648
1649 private function execute_page_hero_create_image(array $plan): array {
1650 $media = $this->execute_media_create_image_variant($plan['media_plan']);
1651 $mediaId = (int)($media['id'] ?? 0);
1652 if ($mediaId <= 0) throw new \RuntimeException('Hero-Medium konnte nicht erstellt werden.');
1653
1654 $data = array(
1655 'active' => 1,
1656 'media_id' => $mediaId,
1657 'content_id' => (int)$plan['id'],
1658 'folder_id' => 0,
1659 'content_lng' => dbxContentMediaUsageScope::language((string)$plan['lng']),
1660 'slot' => 'hero',
1661 'template' => '',
1662 'caption' => '',
1663 'settings' => '',
1664 );
1665 $where = dbxContentMediaUsageScope::withLanguage('content_id = ' . (int)$plan['id'] . " AND slot = 'hero' AND active = 1", (string)$plan['lng']);
1666 $this->db->update('dbxMediaUsage', array('active' => 0), $where, 0, 1, 1, 1);
1667 $data['sorter'] = $this->next_usage_sorter((int)$plan['id'], 0, 'hero', (string)$plan['lng']);
1668 if ($this->db->insert('dbxMediaUsage', $data) !== 1) {
1669 throw new \RuntimeException('Hero-Medienzuordnung konnte nicht erstellt werden.');
1670 }
1671 $usageId = $this->db->get_insert_id();
1672 $this->sync_hero_setting((string)$plan['lng'], $data);
1673 $this->invalidate_usage($data);
1674 $row = $this->db->select1(dbxContentLng::ddContent((string)$plan['lng']), (int)$plan['id']);
1675 return array(
1676 'id' => (int)$plan['id'],
1677 'media_id' => $mediaId,
1678 'usage_id' => $usageId,
1679 'row' => $row,
1680 'media' => $media['row'] ?? array(),
1681 );
1682 }
1683
1684 private function execute_page_delete(array $plan): array {
1685 $dd = dbxContentLng::ddContent($plan['lng']);
1686 if ($this->db->delete($dd, $plan['id']) !== 1) throw new \RuntimeException('Seite konnte nicht gelöscht werden.');
1687 $this->db->update('dbxMediaUsage', array('active' => 0), dbxContentMediaUsageScope::withLanguage('content_id = ' . (int)$plan['id'] . ' AND active = 1', (string)$plan['lng']), 0, 1, 1, 1);
1688 dbxContentPageCache::invalidateContent($plan['id']);
1689 dbxContentPageCache::invalidateAllMenus();
1690 dbxContentPermalinkIndex::removeByCid($plan['id'], $plan['lng']);
1691 return array('deleted' => true, 'id' => $plan['id'], 'lng' => $plan['lng']);
1692 }
1693
1694 private function execute_media_create(array $params, array $plan): array {
1695 $bytes = $this->decode_base64((string)$params['data_base64']);
1696 if (!hash_equals((string)$plan['sha256'], hash('sha256', $bytes))) {
1697 throw new \RuntimeException('Der Medieninhalt stimmt nicht mit dem geprüften Plan überein.');
1698 }
1699 $dir = rtrim(dbx()->get_file_dir(), '/\\') . '/media/' . $plan['media_folder'];
1700 $dir = dbx()->os_path($dir);
1701 if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) throw new \RuntimeException('Medienordner konnte nicht erstellt werden.');
1702 $name = $this->unique_name($dir, $plan['file_name']);
1703 $file = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $name;
1704 if (file_put_contents($file, $bytes) === false) throw new \RuntimeException('Mediendatei konnte nicht geschrieben werden.');
1705 $relative = 'media/' . trim(str_replace('\\', '/', $plan['media_folder']), '/') . '/' . $name;
1706 $width = 0;
1707 $height = 0;
1708 $size = @getimagesize($file);
1709 if (is_array($size)) {
1710 $width = (int)($size[0] ?? 0);
1711 $height = (int)($size[1] ?? 0);
1712 }
1713 $data = array_merge($plan['metadata'], array(
1714 'active' => 1,
1715 'file_name' => $name,
1716 'file_path' => $relative,
1717 'mime' => $plan['mime'],
1718 'size' => strlen($bytes),
1719 'width' => $width,
1720 'height' => $height,
1721 'media_type' => $plan['media_type'],
1722 'storage_type' => 'local',
1723 'media_folder' => $plan['media_folder'],
1724 ));
1725 if ($this->db->insert('dbxMedia', $data) !== 1) {
1726 @unlink($file);
1727 throw new \RuntimeException('Medium konnte nicht registriert werden.');
1728 }
1729 $id = $this->db->get_insert_id();
1730 return array_merge(array('id' => $id, 'row' => $this->db->select1('dbxMedia', $id)), $this->media_inline_payload($id));
1731 }
1732
1733 private function execute_media_create_image_variant(array $plan): array {
1734 $source = (string)($plan['source_file'] ?? '');
1735 if (!is_file($source) || !is_readable($source)) {
1736 throw new \RuntimeException('Quellbild ist nicht lesbar.');
1737 }
1738 if (!hash_equals((string)($plan['source_sha256'] ?? ''), hash_file('sha256', $source))) {
1739 throw new \RuntimeException('Das Quellbild stimmt nicht mehr mit dem geprüften Plan überein.');
1740 }
1741
1742 $src = $this->gd_load_image($source, (string)$plan['source_mime']);
1743 $width = max(1, (int)$plan['width']);
1744 $height = max(1, (int)$plan['height']);
1745 $dst = imagecreatetruecolor($width, $height);
1746 imagealphablending($dst, false);
1747 imagesavealpha($dst, true);
1748 $transparent = imagecolorallocatealpha($dst, 255, 255, 255, 127);
1749 imagefilledrectangle($dst, 0, 0, $width, $height, $transparent);
1750
1751 $sourceWidth = imagesx($src);
1752 $sourceHeight = imagesy($src);
1753 $sourceX = 0;
1754 $sourceY = 0;
1755 $crop = is_array($plan['crop'] ?? null) ? $plan['crop'] : array();
1756 if ($crop) {
1757 $sourceX = max(0, min((int)($crop['x'] ?? 0), $sourceWidth - 1));
1758 $sourceY = max(0, min((int)($crop['y'] ?? 0), $sourceHeight - 1));
1759 $sourceWidth = max(1, min((int)($crop['width'] ?? $sourceWidth), imagesx($src) - $sourceX));
1760 $sourceHeight = max(1, min((int)($crop['height'] ?? $sourceHeight), imagesy($src) - $sourceY));
1761 }
1762 $fit = (string)($plan['fit'] ?? 'cover');
1763 if ($fit === 'contain') {
1764 $scale = min($width / $sourceWidth, $height / $sourceHeight);
1765 $copyWidth = max(1, (int)round($sourceWidth * $scale));
1766 $copyHeight = max(1, (int)round($sourceHeight * $scale));
1767 $dstX = (int)floor(($width - $copyWidth) / 2);
1768 $dstY = (int)floor(($height - $copyHeight) / 2);
1769 imagecopyresampled($dst, $src, $dstX, $dstY, $sourceX, $sourceY, $copyWidth, $copyHeight, $sourceWidth, $sourceHeight);
1770 } else {
1771 $sourceRatio = $sourceWidth / $sourceHeight;
1772 $targetRatio = $width / $height;
1773 if ($sourceRatio > $targetRatio) {
1774 $cropHeight = $sourceHeight;
1775 $cropWidth = (int)round($sourceHeight * $targetRatio);
1776 $srcX = $sourceX + (int)floor(($sourceWidth - $cropWidth) / 2);
1777 $srcY = $sourceY;
1778 } else {
1779 $cropWidth = $sourceWidth;
1780 $cropHeight = (int)round($sourceWidth / $targetRatio);
1781 $srcX = $sourceX;
1782 $srcY = $sourceY + (int)floor(($sourceHeight - $cropHeight) / 2);
1783 }
1784 imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $width, $height, $cropWidth, $cropHeight);
1785 }
1786 imagedestroy($src);
1787
1788 $this->gd_apply_tint($dst, (string)($plan['tint'] ?? ''), (float)($plan['tint_strength'] ?? 0));
1789
1790 $dir = rtrim(dbx()->get_file_dir(), '/\\') . '/media/' . $plan['media_folder'];
1791 $dir = dbx()->os_path($dir);
1792 if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) throw new \RuntimeException('Medienordner konnte nicht erstellt werden.');
1793 $name = $this->unique_name($dir, $plan['file_name']);
1794 $file = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $name;
1795 $this->gd_save_image($dst, $file, (string)$plan['mime'], (int)$plan['quality']);
1796 imagedestroy($dst);
1797
1798 $relative = 'media/' . trim(str_replace('\\', '/', $plan['media_folder']), '/') . '/' . $name;
1799 $data = array_merge($plan['metadata'], array(
1800 'active' => 1,
1801 'file_name' => $name,
1802 'file_path' => $relative,
1803 'mime' => $plan['mime'],
1804 'size' => (int)@filesize($file),
1805 'width' => $width,
1806 'height' => $height,
1807 'media_type' => 'image',
1808 'storage_type' => 'local',
1809 'media_folder' => $plan['media_folder'],
1810 ));
1811 if ($this->db->insert('dbxMedia', $data) !== 1) {
1812 @unlink($file);
1813 throw new \RuntimeException('Medium konnte nicht registriert werden.');
1814 }
1815 $id = $this->db->get_insert_id();
1816 return array_merge(array('id' => $id, 'row' => $this->db->select1('dbxMedia', $id)), $this->media_inline_payload($id));
1817 }
1818
1819 private function execute_media_update(array $plan): array {
1820 if ($this->db->update('dbxMedia', $plan['changes'], $plan['id']) !== 1) throw new \RuntimeException('Medium konnte nicht aktualisiert werden.');
1821 $this->invalidate_media_references((int)$plan['id']);
1822 return array('id' => $plan['id'], 'row' => $this->db->select1('dbxMedia', $plan['id']));
1823 }
1824
1825 private function execute_media_assign(array $plan): array {
1826 $data = $plan['data'];
1827 $data['content_lng'] = dbxContentMediaUsageScope::language((string)($plan['lng'] ?? ''));
1828 if ($data['slot'] === 'hero') {
1829 $where = $data['content_id'] > 0
1830 ? 'content_id = ' . (int)$data['content_id']
1831 : 'folder_id = ' . (int)$data['folder_id'];
1832 $this->db->update('dbxMediaUsage', array('active' => 0), dbxContentMediaUsageScope::withLanguage($where . " AND slot = 'hero' AND active = 1", $data['content_lng']), 0, 1, 1, 1);
1833 }
1834 $data['sorter'] = $this->next_usage_sorter($data['content_id'], $data['folder_id'], $data['slot'], $data['content_lng']);
1835 if ($this->db->insert('dbxMediaUsage', $data) !== 1) throw new \RuntimeException('Medienzuordnung konnte nicht erstellt werden.');
1836 $id = $this->db->get_insert_id();
1837 if ($data['slot'] === 'hero') {
1838 $this->sync_hero_setting((string)($plan['lng'] ?? ''), $data);
1839 }
1840 $this->invalidate_usage($data);
1841 return array('usage_id' => $id, 'row' => $this->db->select1('dbxMediaUsage', $id));
1842 }
1843
1844 private function sync_hero_setting(string $lng, array $usage): void {
1845 $mediaId = (int)($usage['media_id'] ?? 0);
1846 $contentId = (int)($usage['content_id'] ?? 0);
1847 $folderId = (int)($usage['folder_id'] ?? 0);
1848 if ($mediaId <= 0) {
1849 return;
1850 }
1851 if ($lng === '') {
1852 $lng = dbxContentLng::current();
1853 }
1854
1855 if ($contentId > 0) {
1856 $dd = dbxContentLng::ddContent($lng);
1857 $page = $this->db->select1($dd, $contentId);
1858 if (!is_array($page)) {
1859 return;
1860 }
1861 $patch = array('hero_image_id' => (string)$mediaId);
1862 $heroTemplate = trim((string)($page['hero_template'] ?? ''));
1863 if ($heroTemplate === '' || $heroTemplate === 'parent') {
1864 $patch['hero_template'] = 'image-hero';
1865 }
1866 if ($this->db->update($dd, $patch, $contentId) !== 1) {
1867 return;
1868 }
1869 $row = $this->db->select1($dd, $contentId);
1870 if (is_array($row)) {
1871 $this->invalidate_page($contentId, $lng, $row);
1872 }
1873 return;
1874 }
1875
1876 if ($folderId > 0) {
1877 $dd = dbxContentLng::ddFolder($lng);
1878 $folder = $this->db->select1($dd, $folderId);
1879 if (!is_array($folder)) {
1880 return;
1881 }
1882 $patch = array('hero_image_id' => (string)$mediaId);
1883 $heroTemplate = trim((string)($folder['hero_template'] ?? ''));
1884 if ($heroTemplate === '' || $heroTemplate === 'parent') {
1885 $patch['hero_template'] = 'image-hero';
1886 }
1887 if ($this->db->update($dd, $patch, $folderId) === 1) {
1888 $this->invalidate_folder($folderId);
1889 }
1890 }
1891 }
1892
1893 private function execute_media_unassign(array $plan): array {
1894 if ($this->db->update('dbxMediaUsage', array('active' => 0), $plan['id']) !== 1) throw new \RuntimeException('Medienzuordnung konnte nicht entfernt werden.');
1895 $this->invalidate_usage($plan['before']);
1896 return array('unassigned' => true, 'usage_id' => $plan['id']);
1897 }
1898
1899 private function execute_media_delete(array $plan): array {
1900 require_once dirname(__DIR__, 2) . '/dbxContent_admin/include/dbxContent_cms.class.php';
1901 $cms = new \dbx\dbxContent_admin\dbxContent_cms();
1902 $result = $cms->delete_media_record((int)$plan['id']);
1903 if ((int)($result['ok'] ?? 0) !== 1) {
1904 throw new \RuntimeException(implode(' ', is_array($result['errors'] ?? null) ? $result['errors'] : array('Medium konnte nicht gelöscht werden.')));
1905 }
1906 return $result;
1907 }
1908
1909 private function execute_translation_apply(array $params, array $plan): array {
1910 $source = $plan['source'];
1911 $targetLng = $plan['target_lng'];
1912 $targetDd = dbxContentLng::ddContent($targetLng);
1913 $sourceUid = trim((string)($source['lng_uid'] ?? ''));
1914 if ($sourceUid === '') {
1915 $sourceUid = dbxContentLngSync::ensureRecordUid(
1916 $this->db,
1917 dbxContentLng::ddContent($plan['source_lng']),
1918 (int)$source['id'],
1919 'p'
1920 );
1921 }
1922 $targetFolder = dbxContentLngSync::ensureFolderIdInLng($this->db, (int)($source['folder'] ?? 0), $targetLng);
1923 $data = $this->copy_page_structure($source);
1924 $data = array_merge($data, $plan['translation']);
1925 $data['folder'] = $targetFolder;
1926 $data['permalink'] = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder($targetLng), $targetFolder, $data['title']);
1927 $data['lng_uid'] = $sourceUid;
1928 $data['lng_sync'] = 'manual';
1929 $data['lng_rev'] = max(1, (int)($plan['target']['lng_rev'] ?? 0) + 1);
1930 $data['lng_synced_rev'] = (int)($source['lng_rev'] ?? 1);
1931
1932 $targetId = (int)($plan['target']['id'] ?? 0);
1933 if ($targetId > 0) {
1934 if ($this->db->update($targetDd, $data, $targetId) !== 1) throw new \RuntimeException('Übersetzung konnte nicht aktualisiert werden.');
1935 } else {
1936 if ($this->db->insert($targetDd, $data) !== 1) throw new \RuntimeException('Übersetzung konnte nicht erstellt werden.');
1937 $targetId = $this->db->get_insert_id();
1938 }
1939
1940 $mediaCopied = 0;
1941 if ($plan['copy_media']) {
1942 $this->db->update(
1943 'dbxMediaUsage',
1944 array('active' => 0),
1945 dbxContentMediaUsageScope::withLanguage('content_id = ' . $targetId . ' AND active = 1', $targetLng),
1946 0,
1947 1,
1948 1,
1949 1
1950 );
1951 $mediaCopied = $this->copy_media_usage((int)$source['id'], $targetId, $targetFolder, (string)$plan['source_lng'], $targetLng);
1952 }
1953 $row = $this->db->select1($targetDd, $targetId);
1954 $this->invalidate_page($targetId, $targetLng, $row);
1955 return array('id' => $targetId, 'lng' => $targetLng, 'row' => $row, 'media_copied' => $mediaCopied);
1956 }
1957
1958 private function execute_translation_sync_all(array $plan): array {
1959 $sourceLng = (string)($plan['source_lng'] ?? '');
1960 $targetLngs = is_array($plan['target_lngs'] ?? null) ? $plan['target_lngs'] : array();
1961 $updateExisting = (bool)($plan['update_existing'] ?? true);
1962 $skipManual = (bool)($plan['skip_manual'] ?? false);
1963 $copyMedia = (bool)($plan['copy_media'] ?? true);
1964 $replaceMediaUsage = (bool)($plan['replace_media_usage'] ?? false);
1965 $sourceIds = is_array($plan['source_ids'] ?? null) ? $plan['source_ids'] : array();
1966 $folderIds = is_array($sourceIds['folders'] ?? null) ? array_map('intval', $sourceIds['folders']) : array();
1967 $pageIds = is_array($sourceIds['pages'] ?? null) ? array_map('intval', $sourceIds['pages']) : array();
1968
1969 dbxContentTranslate::clearWarnings();
1970
1971 $result = array(
1972 'source_lng' => $sourceLng,
1973 'target_lngs' => $targetLngs,
1974 'provider' => dbxContentTranslate::provider(),
1975 'folders' => array('created' => array(), 'updated' => array(), 'skipped' => array()),
1976 'pages' => array('created' => array(), 'updated' => array(), 'skipped' => array()),
1977 'media_copied' => 0,
1978 'errors' => array(),
1979 'warnings' => array(),
1980 );
1981
1982 foreach ($targetLngs as $targetLng) {
1983 $targetLng = $this->language($targetLng);
1984 foreach ($folderIds as $folderId) {
1985 try {
1986 $item = $this->sync_translate_folder($sourceLng, $targetLng, $folderId, $updateExisting, $skipManual);
1987 $bucket = (string)($item['status'] ?? 'skipped');
1988 $result['folders'][$bucket === 'created' ? 'created' : ($bucket === 'updated' ? 'updated' : 'skipped')][] = $item;
1989 } catch (\Throwable $e) {
1990 $result['errors'][] = 'Ordner #' . $folderId . ' nach ' . strtoupper($targetLng) . ': ' . $e->getMessage();
1991 }
1992 }
1993
1994 foreach ($pageIds as $pageId) {
1995 try {
1996 $item = $this->sync_translate_page($sourceLng, $targetLng, $pageId, $updateExisting, $skipManual, $copyMedia, $replaceMediaUsage);
1997 $bucket = (string)($item['status'] ?? 'skipped');
1998 $result['pages'][$bucket === 'created' ? 'created' : ($bucket === 'updated' ? 'updated' : 'skipped')][] = $item;
1999 $result['media_copied'] += (int)($item['media_copied'] ?? 0);
2000 } catch (\Throwable $e) {
2001 $result['errors'][] = 'Seite #' . $pageId . ' nach ' . strtoupper($targetLng) . ': ' . $e->getMessage();
2002 }
2003 }
2004 }
2005
2006 $result['warnings'] = dbxContentTranslate::warnings();
2007 return $result;
2008 }
2009
2010 private function sync_translate_folder(string $sourceLng, string $targetLng, int $sourceId, bool $updateExisting, bool $skipManual): array {
2011 $sourceDd = dbxContentLng::ddFolder($sourceLng);
2012 $targetDd = dbxContentLng::ddFolder($targetLng);
2013 $source = $this->db->select1($sourceDd, $sourceId);
2014 if (!is_array($source)) {
2015 throw new \RuntimeException('Quellordner nicht gefunden.');
2016 }
2017
2018 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceId, 'f');
2019 if ($uid === '') {
2020 throw new \RuntimeException('Sprach-ID konnte nicht erzeugt werden.');
2021 }
2022
2023 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
2024 $target = $targetId > 0 ? $this->db->select1($targetDd, $targetId) : null;
2025 if (is_array($target) && !$updateExisting) {
2026 return array('status' => 'skipped', 'reason' => 'exists', 'entity' => 'folder', 'source_id' => $sourceId, 'target_lng' => $targetLng, 'target_id' => $targetId);
2027 }
2028 if (is_array($target) && $skipManual && strtolower(trim((string)($target['lng_sync'] ?? ''))) === 'manual') {
2029 return array('status' => 'skipped', 'reason' => 'manual', 'entity' => 'folder', 'source_id' => $sourceId, 'target_lng' => $targetLng, 'target_id' => $targetId);
2030 }
2031
2032 $name = dbxContentTranslate::translate((string)($source['name'] ?? ''), $sourceLng, $targetLng, 'folder_name');
2033 if ($name === '' && trim((string)($source['name'] ?? '')) !== '') {
2034 $name = (string)$source['name'];
2035 }
2036 if ($name === '') {
2037 $name = 'Ordner';
2038 }
2039
2040 $data = $this->copy_folder_structure($source);
2041 $data['name'] = $this->clean($name, 120);
2042 $data['parent_id'] = $this->target_folder_id_from_source_parent($sourceLng, $targetLng, (int)($source['parent_id'] ?? 0));
2043 $data['lng_uid'] = $uid;
2044 $data['lng_sync'] = 'auto';
2045 $data['lng_rev'] = is_array($target) ? max(1, (int)($target['lng_rev'] ?? 0) + 1) : 0;
2046 $data['lng_synced_rev'] = max(1, (int)($source['lng_rev'] ?? 1));
2047
2048 if ($targetId > 0) {
2049 if ($this->db->update($targetDd, $data, $targetId) !== 1) {
2050 throw new \RuntimeException('Zielordner konnte nicht aktualisiert werden.');
2051 }
2052 $status = 'updated';
2053 } else {
2054 if ($this->db->insert($targetDd, $data) !== 1) {
2055 throw new \RuntimeException('Zielordner konnte nicht erstellt werden.');
2056 }
2057 $targetId = (int)$this->db->get_insert_id();
2058 $status = 'created';
2059 }
2060
2061 $this->invalidate_folder($targetId);
2062 return array('status' => $status, 'entity' => 'folder', 'source_id' => $sourceId, 'target_lng' => $targetLng, 'target_id' => $targetId, 'name' => $data['name']);
2063 }
2064
2065 private function sync_translate_page(string $sourceLng, string $targetLng, int $sourceId, bool $updateExisting, bool $skipManual, bool $copyMedia, bool $replaceMediaUsage): array {
2066 $sourceDd = dbxContentLng::ddContent($sourceLng);
2067 $targetDd = dbxContentLng::ddContent($targetLng);
2068 $source = $this->db->select1($sourceDd, $sourceId);
2069 if (!is_array($source)) {
2070 throw new \RuntimeException('Quellseite nicht gefunden.');
2071 }
2072
2073 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceId, 'p');
2074 if ($uid === '') {
2075 throw new \RuntimeException('Sprach-ID konnte nicht erzeugt werden.');
2076 }
2077
2078 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
2079 $target = $targetId > 0 ? $this->db->select1($targetDd, $targetId) : null;
2080 if (is_array($target) && !$updateExisting) {
2081 return array('status' => 'skipped', 'reason' => 'exists', 'entity' => 'page', 'source_id' => $sourceId, 'target_lng' => $targetLng, 'target_id' => $targetId);
2082 }
2083 if (is_array($target) && $skipManual && strtolower(trim((string)($target['lng_sync'] ?? ''))) === 'manual') {
2084 return array('status' => 'skipped', 'reason' => 'manual', 'entity' => 'page', 'source_id' => $sourceId, 'target_lng' => $targetLng, 'target_id' => $targetId);
2085 }
2086
2087 $title = dbxContentTranslate::translate((string)($source['title'] ?? ''), $sourceLng, $targetLng, 'title');
2088 if ($title === '' && trim((string)($source['title'] ?? '')) !== '') {
2089 $title = (string)$source['title'];
2090 }
2091 if ($title === '') {
2092 throw new \RuntimeException('Übersetzter Titel ist leer.');
2093 }
2094
2095 $targetFolder = $this->target_folder_id_from_source_parent($sourceLng, $targetLng, (int)($source['folder'] ?? 0));
2096 if ((int)($source['folder'] ?? 0) > 0 && $targetFolder <= 0) {
2097 throw new \RuntimeException('Zielordner konnte nicht aufgelöst werden.');
2098 }
2099
2100 $data = $this->copy_page_structure($source);
2101 $data['folder'] = $targetFolder;
2102 $data['title'] = $this->clean($title, 254);
2103 $data['description'] = $this->clean(dbxContentTranslate::translate((string)($source['description'] ?? ''), $sourceLng, $targetLng, 'description'), 254);
2104 $data['keywords'] = $this->clean(dbxContentTranslate::translate((string)($source['keywords'] ?? ''), $sourceLng, $targetLng, 'keywords'), 254);
2105 $data['content'] = $this->normalize_content_inline_media_urls(dbxContentTranslate::translate((string)($source['content'] ?? ''), $sourceLng, $targetLng, 'content'));
2106 foreach (array('seo_title', 'img_alt_1', 'img_alt_2', 'img_alt_3', 'img_des_1', 'img_des_2', 'img_des_3') as $field) {
2107 if (array_key_exists($field, $source)) {
2108 $max = $field === 'seo_title' || strpos($field, 'img_alt_') === 0 ? 254 : 0;
2109 $data[$field] = $this->clean(dbxContentTranslate::translate((string)($source[$field] ?? ''), $sourceLng, $targetLng, $field), $max);
2110 }
2111 }
2112 $data['permalink'] = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder($targetLng), $targetFolder, $data['title']);
2113 $data['lng_uid'] = $uid;
2114 $data['lng_sync'] = 'auto';
2115 $data['lng_rev'] = is_array($target) ? max(1, (int)($target['lng_rev'] ?? 0) + 1) : 0;
2116 $data['lng_synced_rev'] = max(1, (int)($source['lng_rev'] ?? 1));
2117
2118 if ($targetId > 0) {
2119 if ($this->db->update($targetDd, $data, $targetId) !== 1) {
2120 throw new \RuntimeException('Zielseite konnte nicht aktualisiert werden.');
2121 }
2122 $status = 'updated';
2123 } else {
2124 if ($this->db->insert($targetDd, $data) !== 1) {
2125 throw new \RuntimeException('Zielseite konnte nicht erstellt werden.');
2126 }
2127 $targetId = (int)$this->db->get_insert_id();
2128 $status = 'created';
2129 }
2130
2131 $mediaCopied = 0;
2132 if ($copyMedia) {
2133 if ($replaceMediaUsage) {
2134 $this->db->update('dbxMediaUsage', array('active' => 0), dbxContentMediaUsageScope::withLanguage('content_id = ' . $targetId . ' AND active = 1', $targetLng), 0, 1, 1, 1);
2135 $mediaCopied = $this->copy_media_usage($sourceId, $targetId, $targetFolder, $sourceLng, $targetLng);
2136 } else {
2137 $mediaCopied = $this->copy_missing_media_usage($sourceId, $targetId, $targetFolder, $sourceLng, $targetLng);
2138 }
2139 }
2140
2141 $row = $this->db->select1($targetDd, $targetId);
2142 $this->invalidate_page($targetId, $targetLng, $row);
2143 return array('status' => $status, 'entity' => 'page', 'source_id' => $sourceId, 'target_lng' => $targetLng, 'target_id' => $targetId, 'title' => $data['title'], 'media_copied' => $mediaCopied);
2144 }
2145
2146 private function target_languages(array $params, string $sourceLng): array {
2147 $raw = $params['target_lngs'] ?? $params['target_lng'] ?? array();
2148 if (is_string($raw)) {
2149 $raw = array_values(array_filter(array_map('trim', explode(',', $raw))));
2150 } elseif (!is_array($raw)) {
2151 $raw = array();
2152 }
2153 if (!count($raw)) {
2154 $raw = dbxContentLngSync::accessibleLngs();
2155 }
2156
2157 $out = array();
2158 foreach ($raw as $lng) {
2159 $lng = $this->language($lng);
2160 if ($lng === $sourceLng || in_array($lng, $out, true)) {
2161 continue;
2162 }
2163 $out[] = $lng;
2164 }
2165 return $out;
2166 }
2167
2168 private function collect_folder_ids_for_lng(string $lng, int $rootFolderId = 0): array {
2169 $dd = dbxContentLng::ddFolder($lng);
2170 if ($rootFolderId <= 0) {
2171 $rows = $this->db->select($dd, '', 'id', 'parent_id,sorter,id', 'ASC', '', 0, 0, 0);
2172 return $this->ids_from_rows($rows);
2173 }
2174
2175 $out = array();
2176 $seen = array();
2177 $queue = array($rootFolderId);
2178 while (count($queue)) {
2179 $id = (int)array_shift($queue);
2180 if ($id <= 0 || isset($seen[$id])) {
2181 continue;
2182 }
2183 $seen[$id] = 1;
2184 $out[] = $id;
2185 $rows = $this->db->select($dd, 'parent_id = ' . $id, 'id', 'sorter,id', 'ASC', '', 0, 0, 0);
2186 foreach ($this->ids_from_rows($rows) as $childId) {
2187 if (!isset($seen[$childId])) {
2188 $queue[] = $childId;
2189 }
2190 }
2191 }
2192 return $out;
2193 }
2194
2195 private function collect_page_ids_for_lng(string $lng, int $rootFolderId, array $folderIds): array {
2196 $dd = dbxContentLng::ddContent($lng);
2197 if ($rootFolderId <= 0) {
2198 $rows = $this->db->select($dd, '', 'id', 'folder,sorter,id', 'ASC', '', 0, 0, 0);
2199 return $this->ids_from_rows($rows);
2200 }
2201
2202 $folderIds = array_values(array_filter(array_map('intval', $folderIds), static function($id) {
2203 return $id > 0;
2204 }));
2205 if (!count($folderIds)) {
2206 return array();
2207 }
2208 $rows = $this->db->select($dd, 'folder IN (' . implode(',', $folderIds) . ')', 'id', 'folder,sorter,id', 'ASC', '', 0, 0, 0);
2209 return $this->ids_from_rows($rows);
2210 }
2211
2212 private function ids_from_rows($rows): array {
2213 $out = array();
2214 foreach (is_array($rows) ? $rows : array() as $row) {
2215 if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
2216 $out[] = (int)$row['id'];
2217 }
2218 }
2219 return $out;
2220 }
2221
2222 private function target_folder_id_from_source_parent(string $sourceLng, string $targetLng, int $sourceFolderId): int {
2223 $sourceFolderId = (int)$sourceFolderId;
2224 if ($sourceFolderId <= 0) {
2225 return 0;
2226 }
2227 $sourceDd = dbxContentLng::ddFolder($sourceLng);
2228 $source = $this->db->select1($sourceDd, $sourceFolderId);
2229 if (!is_array($source)) {
2230 return 0;
2231 }
2232
2233 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceFolderId, 'f');
2234 if ($uid === '') {
2235 return 0;
2236 }
2237 $targetDd = dbxContentLng::ddFolder($targetLng);
2238 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
2239 if ($targetId > 0) {
2240 return $targetId;
2241 }
2242
2243 try {
2244 $created = $this->sync_translate_folder($sourceLng, $targetLng, $sourceFolderId, true, false);
2245 return (int)($created['target_id'] ?? 0);
2246 } catch (\Throwable $e) {
2247 return 0;
2248 }
2249 }
2250
2251 private function copy_page_structure(array $source): array {
2252 $skip = array('id', 'create_date', 'create_uid', 'update_date', 'update_uid', 'owner', 'title', 'permalink', 'description', 'keywords', 'content', 'lng_uid', 'lng_sync', 'lng_rev', 'lng_synced_rev');
2253 $data = array();
2254 foreach ($source as $key => $value) {
2255 if (!in_array($key, $skip, true)) {
2256 $data[$key] = $value;
2257 }
2258 }
2259 return $data;
2260 }
2261
2262 private function copy_folder_structure(array $source): array {
2263 $skip = array('id', 'create_date', 'create_uid', 'update_date', 'update_uid', 'owner', 'name', 'parent_id', 'lng_uid', 'lng_sync', 'lng_rev', 'lng_synced_rev');
2264 $data = array();
2265 foreach ($source as $key => $value) {
2266 if (!in_array($key, $skip, true)) {
2267 $data[$key] = $value;
2268 }
2269 }
2270 return $data;
2271 }
2272
2273 private function copy_missing_media_usage(int $sourceId, int $targetId, int $targetFolder, string $sourceLng, string $targetLng): int {
2274 $rows = $this->db->select('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage("content_id = " . $sourceId . " AND active = 1 AND slot IN ('hero','gallery','inline','header','teaser','footer')", $sourceLng), '*', 'slot,sorter,id', 'ASC', '', 0, 0, 0);
2275 $count = 0;
2276 foreach (is_array($rows) ? $rows : array() as $row) {
2277 if (!is_array($row)) {
2278 continue;
2279 }
2280 $mediaId = (int)($row['media_id'] ?? 0);
2281 $slot = str_replace("'", "''", (string)($row['slot'] ?? ''));
2282 if ($mediaId <= 0 || $slot === '') {
2283 continue;
2284 }
2285 if ($slot === 'hero' && (int)$this->db->count('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage('content_id = ' . $targetId . " AND slot = 'hero' AND active = 1", $targetLng)) > 0) {
2286 continue;
2287 }
2288 $exists = (int)$this->db->count('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage('content_id = ' . $targetId . ' AND media_id = ' . $mediaId . " AND slot = '" . $slot . "' AND active = 1", $targetLng));
2289 if ($exists > 0) {
2290 continue;
2291 }
2292 $data = $this->whitelist($row, array('media_id', 'slot', 'sorter', 'template', 'caption', 'settings'));
2293 $data['active'] = 1;
2294 $data['content_id'] = $targetId;
2295 $data['folder_id'] = $targetFolder;
2296 $data['content_lng'] = dbxContentMediaUsageScope::language($targetLng);
2297 if ($this->db->insert('dbxMediaUsage', $data) === 1) {
2298 $count++;
2299 }
2300 }
2301 return $count;
2302 }
2303
2304 private function folder_data(array $params, int $parent, string $name): array {
2305 return array(
2306 'name' => $name,
2307 'parent_id' => $parent,
2308 'group_read' => $this->clean($params['group_read'] ?? ($parent > 0 ? 'parent' : '*'), 512),
2309 'template' => $this->clean($params['template'] ?? ($parent > 0 ? 'parent' : 'c-content'), 254),
2310 'hero_template' => $this->clean($params['hero_template'] ?? ($parent > 0 ? 'parent' : 'image-hero'), 80),
2311 'hero_image_id' => $this->clean($params['hero_image_id'] ?? 'parent', 32),
2312 'hero_margin_top' => $this->clean($params['hero_margin_top'] ?? 'parent', 32),
2313 'hero_height' => $this->clean($params['hero_height'] ?? ($parent > 0 ? 'parent' : '300px'), 32),
2314 'hero_variant' => $this->clean($params['hero_variant'] ?? 'parent', 32),
2315 'hero_sticky' => $this->clean($params['hero_sticky'] ?? 'parent', 32),
2316 'hero_scroll_layer' => $this->clean($params['hero_scroll_layer'] ?? 'parent', 32),
2317 );
2318 }
2319
2320 private function page_data(array $params, string $lng, int $folder, string $title): array {
2321 $permalink = trim($this->clean($params['permalink'] ?? '', 254));
2322 if ($permalink === '') {
2323 $permalink = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder($lng), $folder, $title);
2324 } else {
2325 if (!dbxContent_permalink::isValid($permalink)) {
2326 throw new \InvalidArgumentException('permalink darf nur Kleinbuchstaben, Zahlen und einzelne Bindestriche enthalten.');
2327 }
2328 if (dbxContent_permalink::exists($this->db, dbxContentLng::ddContent($lng), $permalink)) {
2329 throw new \InvalidArgumentException('permalink wird bereits von einer anderen Seite verwendet.');
2330 }
2331 }
2332 return array(
2333 'activ' => $this->bool_value($params['activ'] ?? true) ? 1 : 0,
2334 'folder' => $folder,
2335 'title' => $title,
2336 'menu_title' => $this->clean($params['menu_title'] ?? '', 96),
2337 'seo_title' => $this->clean($params['seo_title'] ?? $title, 254),
2338 'permalink' => $permalink,
2339 'description' => $this->clean($params['description'] ?? '', 254),
2340 'keywords' => $this->clean($params['keywords'] ?? '', 254),
2341 'group_read' => $this->clean($params['group_read'] ?? 'parent', 512),
2342 'template' => $this->clean($params['template'] ?? 'parent', 254),
2343 'hero_template' => $this->clean($params['hero_template'] ?? 'parent', 80),
2344 'hero_image_id' => $this->clean($params['hero_image_id'] ?? 'parent', 32),
2345 'hero_margin_top' => $this->clean($params['hero_margin_top'] ?? 'parent', 32),
2346 'hero_height' => $this->clean($params['hero_height'] ?? '300px', 32),
2347 'hero_variant' => $this->clean($params['hero_variant'] ?? 'parent', 32),
2348 'hero_sticky' => $this->clean($params['hero_sticky'] ?? 'parent', 32),
2349 'hero_scroll_layer' => $this->clean($params['hero_scroll_layer'] ?? 'parent', 32),
2350 'gallery_template' => $this->clean($params['gallery_template'] ?? 'image-gallery', 80),
2351 'gallery_visible_count' => $this->clean($params['gallery_visible_count'] ?? '3', 32),
2352 'gallery_image_size' => $this->clean($params['gallery_image_size'] ?? 'original', 32),
2353 'gallery_lightbox_width' => $this->clean($params['gallery_lightbox_width'] ?? '100vw', 32),
2354 'gallery_overflow' => $this->clean($params['gallery_overflow'] ?? 'grid', 32),
2355 'gallery_click_behavior' => $this->clean($params['gallery_click_behavior'] ?? 'lightbox', 32),
2356 'sorter' => $this->clean($params['sorter'] ?? '', 32),
2357 'content' => $this->normalize_content_inline_media_urls((string)($params['content'] ?? '')),
2358 );
2359 }
2360
2368 private function assert_no_fake_inline_hero(string $html): void {
2369 if (stripos($html, '<img') === false || stripos($html, 'position') === false) {
2370 return;
2371 }
2372
2373 $doc = new \DOMDocument('1.0', 'UTF-8');
2374 $previous = libxml_use_internal_errors(true);
2375 try {
2376 $loaded = $doc->loadHTML(
2377 '<div data-dbx-ki-content-root="1">' . $html . '</div>',
2378 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
2379 );
2380 } finally {
2381 libxml_clear_errors();
2382 libxml_use_internal_errors($previous);
2383 }
2384 if (!$loaded) {
2385 return;
2386 }
2387
2388 $xpath = new \DOMXPath($doc);
2389 $roots = $xpath->query('//*[@data-dbx-ki-content-root="1"]');
2390 $root = $roots instanceof \DOMNodeList ? $roots->item(0) : null;
2391 if (!$root instanceof \DOMElement) {
2392 return;
2393 }
2394 $first = null;
2395 foreach ($root->childNodes as $child) {
2396 if ($child instanceof \DOMElement) {
2397 $first = $child;
2398 break;
2399 }
2400 }
2401 if (!$first instanceof \DOMElement) {
2402 return;
2403 }
2404
2405 $images = $first->getElementsByTagName('img');
2406 foreach ($images as $image) {
2407 $host = $image->parentNode;
2408 while ($host instanceof \DOMElement && $host !== $root) {
2409 $class = ' ' . strtolower($host->getAttribute('class')) . ' ';
2410 $style = strtolower($host->getAttribute('style'));
2411 $relative = str_contains($class, ' position-relative ')
2412 || preg_match('/position\s*:\s*relative/i', $style) === 1;
2413 if ($relative) {
2414 foreach ($host->getElementsByTagName('*') as $candidate) {
2415 if ($candidate === $image || !$candidate instanceof \DOMElement) {
2416 continue;
2417 }
2418 $candidateClass = ' ' . strtolower($candidate->getAttribute('class')) . ' ';
2419 $candidateStyle = strtolower($candidate->getAttribute('style'));
2420 $absolute = str_contains($candidateClass, ' position-absolute ')
2421 || preg_match('/position\s*:\s*absolute/i', $candidateStyle) === 1;
2422 $text = trim(preg_replace('/\s+/u', ' ', $candidate->textContent ?? '') ?? '');
2423 $structuredText = $candidate->getElementsByTagName('h1')->length
2424 + $candidate->getElementsByTagName('h2')->length
2425 + $candidate->getElementsByTagName('p')->length
2426 + $candidate->getElementsByTagName('a')->length;
2427 if ($absolute && (mb_strlen($text) >= 80 || $structuredText >= 2)) {
2428 throw new \InvalidArgumentException(
2429 'dbxKi: Ein Bild mit ueberlagertem Text am Seitenanfang ist ein CMS-Hero. '
2430 . 'Hero-Bild ueber hero_image_id/media.assign slot=hero setzen und den Hero-Text '
2431 . 'vor den dbx:hero-Marker schreiben; kein Inline-Schein-Hero.'
2432 );
2433 }
2434 }
2435 }
2436 if ($host === $first) {
2437 break;
2438 }
2439 $host = $host->parentNode;
2440 }
2441 }
2442 }
2443
2444 private function patch(array $params): array {
2445 $patch = is_array($params['patch'] ?? null) ? $params['patch'] : $params;
2446 foreach (array('id', 'lng', 'patch', 'folder_id') as $key) {
2447 if ($key !== 'folder_id') unset($patch[$key]);
2448 }
2449 return $patch;
2450 }
2451
2452 private function whitelist(array $data, array $allowed): array {
2453 return array_intersect_key($data, array_flip($allowed));
2454 }
2455
2456 private function lng_fields(string $prefix, string $lng): array {
2457 return array(
2458 'lng_uid' => dbxContentLngSync::newUid($prefix),
2459 'lng_sync' => $lng === dbxContentLngSync::masterLng() ? 'auto' : 'manual',
2460 'lng_rev' => 1,
2461 'lng_synced_rev' => 0,
2462 );
2463 }
2464
2465 private function advance_revision(string $dd, int $id, array $data, string $lng): array {
2466 $row = $this->db->select1($dd, $id, 'lng_uid,lng_rev', 0);
2467 $uid = trim((string)($row['lng_uid'] ?? ''));
2468 if ($uid === '') $uid = dbxContentLngSync::newUid(strpos($dd, 'folder') !== false ? 'f' : 'p');
2469 $data['lng_uid'] = $uid;
2470 $data['lng_rev'] = max(1, (int)($row['lng_rev'] ?? 0)) + 1;
2471 if ($lng !== dbxContentLngSync::masterLng()) $data['lng_sync'] = 'manual';
2472 return $data;
2473 }
2474
2475 private function next_sorter(string $dd, string $field, int $parent): string {
2476 $rows = $this->db->select($dd, $field . ' = ' . $parent, 'sorter,id', 'sorter DESC,id DESC', 'ASC', '', 1, 0, 0);
2477 $max = is_array($rows) && isset($rows[0]) ? (int)($rows[0]['sorter'] ?? 0) : 0;
2478 return sprintf('%04d', $max + 10);
2479 }
2480
2481 private function next_usage_sorter(int $content, int $folder, string $slot, string $lng = ''): string {
2482 $where = "active = 1 AND slot = '" . str_replace("'", "''", $slot) . "'";
2483 if ($content > 0) $where .= ' AND content_id = ' . $content;
2484 if ($folder > 0) $where .= ' AND folder_id = ' . $folder;
2485 $where = dbxContentMediaUsageScope::withLanguage($where, $lng);
2486 $rows = $this->db->select('dbxMediaUsage', $where, 'sorter,id', 'sorter DESC,id DESC', 'ASC', '', 1, 0, 0);
2487 $max = is_array($rows) && isset($rows[0]) ? (int)($rows[0]['sorter'] ?? 0) : 0;
2488 return sprintf('%04d', $max + 10);
2489 }
2490
2491 private function folder_descendant(string $dd, int $candidate, int $ancestor): bool {
2492 $seen = array();
2493 while ($candidate > 0 && !isset($seen[$candidate])) {
2494 if ($candidate === $ancestor) return true;
2495 $seen[$candidate] = 1;
2496 $row = $this->db->select1($dd, $candidate, 'parent_id', 0);
2497 if (!is_array($row)) break;
2498 $candidate = (int)($row['parent_id'] ?? 0);
2499 }
2500 return false;
2501 }
2502
2503 private function slot($value): string {
2504 $slot = strtolower(trim((string)$value));
2505 $allowed = array('hero', 'gallery', 'inline', 'header', 'teaser', 'footer');
2506 if (!in_array($slot, $allowed, true)) throw new \InvalidArgumentException('Ungültiger Medienslot: ' . $slot);
2507 return $slot;
2508 }
2509
2510 private function safe_file_name($value): string {
2511 $name = basename(str_replace('\\', '/', trim((string)$value)));
2512 $name = preg_replace('/[^A-Za-z0-9._-]+/', '-', $name);
2513 return trim((string)$name, '.-');
2514 }
2515
2516 private function decode_base64(string $raw): string {
2517 $raw = trim($raw);
2518 if (preg_match('~^data:[^;]+;base64,(.*)$~s', $raw, $match)) $raw = $match[1];
2519 $decoded = base64_decode(preg_replace('/\s+/', '', $raw), true);
2520 if ($decoded === false) throw new \InvalidArgumentException('data_base64 ist ungültig.');
2521 return $decoded;
2522 }
2523
2524 private function detect_mime(string $bytes, string $name): string {
2525 if (class_exists('\finfo')) {
2526 $finfo = new \finfo(FILEINFO_MIME_TYPE);
2527 $mime = (string)$finfo->buffer($bytes);
2528 if ($mime !== '') return $mime;
2529 }
2530 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
2531 $map = array('jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif', 'pdf' => 'application/pdf', 'txt' => 'text/plain');
2532 return $map[$ext] ?? 'application/octet-stream';
2533 }
2534
2535 private function resolve_local_file(string $path): string {
2536 $path = trim(str_replace('\\', '/', $path));
2537 if ($path === '') return '';
2538 if (!preg_match('~^(?:[A-Za-z]:/|/)~', $path)) {
2539 $path = rtrim(str_replace('\\', '/', dbx()->get_base_dir()), '/') . '/' . ltrim($path, '/');
2540 }
2541 return dbx()->os_path($path);
2542 }
2543
2544 private function media_local_file(array $media): string {
2545 if (($media['storage_type'] ?? 'local') !== 'local') return '';
2546 $filePath = trim((string)($media['file_path'] ?? ''));
2547 if ($filePath === '') return '';
2548 $filePath = preg_replace('~^files/~', '', str_replace('\\', '/', $filePath));
2549 if (strpos($filePath, 'media/') !== 0) return '';
2550 return dbx()->os_path(rtrim(dbx()->get_file_dir(), '/\\') . '/' . $filePath);
2551 }
2552
2553 private function hero_media_for_page(string $lng, int $id): array {
2554 $page = $this->db->select1(dbxContentLng::ddContent($lng), $id);
2555 if (!is_array($page)) throw new \RuntimeException('Seite nicht gefunden.');
2556
2557 $usage = array();
2558 $mediaId = (int)($page['hero_image_id'] ?? 0);
2559 if ($mediaId <= 0) {
2560 $rows = $this->db->select('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage('content_id = ' . $id . " AND slot = 'hero' AND active = 1", $lng), '*', 'sorter,id', 'DESC', '', 1, 0, 0);
2561 if (is_array($rows) && is_array($rows[0] ?? null)) {
2562 $usage = $rows[0];
2563 $mediaId = (int)($usage['media_id'] ?? 0);
2564 }
2565 }
2566 if ($mediaId <= 0) throw new \RuntimeException('Die Seite hat kein bestehendes Hero-Bild.');
2567
2568 $media = $this->db->select1('dbxMedia', $mediaId);
2569 if (!is_array($media) || (int)($media['active'] ?? 0) !== 1) throw new \RuntimeException('Hero-Medium nicht gefunden.');
2570 if (!$usage) {
2571 $rows = $this->db->select('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage('content_id = ' . $id . ' AND media_id = ' . $mediaId . " AND slot = 'hero' AND active = 1", $lng), '*', 'sorter,id', 'DESC', '', 1, 0, 0);
2572 if (is_array($rows) && is_array($rows[0] ?? null)) $usage = $rows[0];
2573 }
2574 return array('page' => $page, 'media' => $media, 'usage' => $usage);
2575 }
2576
2577 private function source_image_plan(array $params): array {
2578 $source = $this->resolve_local_file((string)($params['source_file'] ?? ''));
2579 if ($source === '' || !is_file($source) || !is_readable($source)) {
2580 throw new \InvalidArgumentException('source_file ist nicht lesbar.');
2581 }
2582 $info = @getimagesize($source);
2583 if (!is_array($info) || empty($info[0]) || empty($info[1])) {
2584 throw new \InvalidArgumentException('source_file ist kein lesbares Bild.');
2585 }
2586 $mime = (string)($info['mime'] ?? '');
2587 if (!in_array($mime, array('image/jpeg', 'image/png', 'image/webp', 'image/gif'), true)) {
2588 throw new \InvalidArgumentException('Nicht unterstützter Quellbildtyp: ' . $mime);
2589 }
2590 return array(
2591 'file' => $source,
2592 'sha256' => hash_file('sha256', $source),
2593 'mime' => $mime,
2594 'width' => (int)$info[0],
2595 'height' => (int)$info[1],
2596 'crop' => $this->image_crop_rect($params, (int)$info[0], (int)$info[1]),
2597 'tint' => $this->normalize_hex_color((string)($params['tint'] ?? '')),
2598 'tint_strength' => max(0.0, min(1.0, (float)($params['tint_strength'] ?? 0))),
2599 );
2600 }
2601
2602 private function image_fit($value): string {
2603 $fit = strtolower(trim((string)$value));
2604 return in_array($fit, array('cover', 'contain'), true) ? $fit : 'cover';
2605 }
2606
2607 private function image_quality($value): int {
2608 return min(100, max(1, (int)$value));
2609 }
2610
2611 private function image_crop_rect(array $params, int $sourceWidth, int $sourceHeight): array {
2612 $sourceWidth = max(1, $sourceWidth);
2613 $sourceHeight = max(1, $sourceHeight);
2614 $x = (int)($params['crop_x'] ?? 0);
2615 $y = (int)($params['crop_y'] ?? 0);
2616 $width = (int)($params['crop_width'] ?? $sourceWidth);
2617 $height = (int)($params['crop_height'] ?? $sourceHeight);
2618
2619 $x = max(0, min($x, $sourceWidth - 1));
2620 $y = max(0, min($y, $sourceHeight - 1));
2621 $width = max(1, min($width, $sourceWidth - $x));
2622 $height = max(1, min($height, $sourceHeight - $y));
2623
2624 return array('x' => $x, 'y' => $y, 'width' => $width, 'height' => $height);
2625 }
2626
2627 private function mime_from_file_name(string $name): string {
2628 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
2629 $map = array('jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp');
2630 return $map[$ext] ?? 'image/webp';
2631 }
2632
2633 private function normalize_hex_color(string $value): string {
2634 $value = trim($value);
2635 if ($value === '') return '';
2636 if ($value[0] !== '#') $value = '#' . $value;
2637 return preg_match('/^#[0-9A-Fa-f]{6}$/', $value) ? strtoupper($value) : '';
2638 }
2639
2640 private function gd_load_image(string $file, string $mime) {
2641 switch ($mime) {
2642 case 'image/jpeg':
2643 $image = @imagecreatefromjpeg($file);
2644 break;
2645 case 'image/png':
2646 $image = @imagecreatefrompng($file);
2647 break;
2648 case 'image/webp':
2649 $image = function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($file) : false;
2650 break;
2651 case 'image/gif':
2652 $image = @imagecreatefromgif($file);
2653 break;
2654 default:
2655 $image = false;
2656 }
2657 if (!$image) throw new \RuntimeException('Bild konnte nicht geladen werden.');
2658 imagealphablending($image, true);
2659 imagesavealpha($image, true);
2660 return $image;
2661 }
2662
2663 private function render_image_variant_to_file(array $source, string $file, int $width, int $height, string $fit, string $mime, int $quality): void {
2664 if (!hash_equals((string)($source['sha256'] ?? ''), hash_file('sha256', (string)$source['file']))) {
2665 throw new \RuntimeException('Das Quellbild stimmt nicht mehr mit dem geprüften Plan überein.');
2666 }
2667 $src = $this->gd_load_image((string)$source['file'], (string)$source['mime']);
2668 $dst = imagecreatetruecolor($width, $height);
2669 imagealphablending($dst, false);
2670 imagesavealpha($dst, true);
2671 $transparent = imagecolorallocatealpha($dst, 255, 255, 255, 127);
2672 imagefilledrectangle($dst, 0, 0, $width, $height, $transparent);
2673
2674 $sourceWidth = imagesx($src);
2675 $sourceHeight = imagesy($src);
2676 $sourceX = 0;
2677 $sourceY = 0;
2678 $crop = is_array($source['crop'] ?? null) ? $source['crop'] : array();
2679 if ($crop) {
2680 $sourceX = max(0, min((int)($crop['x'] ?? 0), $sourceWidth - 1));
2681 $sourceY = max(0, min((int)($crop['y'] ?? 0), $sourceHeight - 1));
2682 $sourceWidth = max(1, min((int)($crop['width'] ?? $sourceWidth), imagesx($src) - $sourceX));
2683 $sourceHeight = max(1, min((int)($crop['height'] ?? $sourceHeight), imagesy($src) - $sourceY));
2684 }
2685 if ($fit === 'contain') {
2686 $scale = min($width / $sourceWidth, $height / $sourceHeight);
2687 $copyWidth = max(1, (int)round($sourceWidth * $scale));
2688 $copyHeight = max(1, (int)round($sourceHeight * $scale));
2689 imagecopyresampled($dst, $src, (int)floor(($width - $copyWidth) / 2), (int)floor(($height - $copyHeight) / 2), $sourceX, $sourceY, $copyWidth, $copyHeight, $sourceWidth, $sourceHeight);
2690 } else {
2691 $sourceRatio = $sourceWidth / $sourceHeight;
2692 $targetRatio = $width / $height;
2693 if ($sourceRatio > $targetRatio) {
2694 $cropHeight = $sourceHeight;
2695 $cropWidth = (int)round($sourceHeight * $targetRatio);
2696 $srcX = $sourceX + (int)floor(($sourceWidth - $cropWidth) / 2);
2697 $srcY = $sourceY;
2698 } else {
2699 $cropWidth = $sourceWidth;
2700 $cropHeight = (int)round($sourceWidth / $targetRatio);
2701 $srcX = $sourceX;
2702 $srcY = $sourceY + (int)floor(($sourceHeight - $cropHeight) / 2);
2703 }
2704 imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $width, $height, $cropWidth, $cropHeight);
2705 }
2706 imagedestroy($src);
2707 $this->gd_apply_tint($dst, (string)($source['tint'] ?? ''), (float)($source['tint_strength'] ?? 0));
2708 $this->gd_save_image($dst, $file, $mime, $quality);
2709 imagedestroy($dst);
2710 }
2711
2712 private function gd_apply_tint($image, string $hex, float $strength): void {
2713 if ($hex === '' || $strength <= 0) return;
2714 $r = hexdec(substr($hex, 1, 2));
2715 $g = hexdec(substr($hex, 3, 2));
2716 $b = hexdec(substr($hex, 5, 2));
2717 $overlay = imagecreatetruecolor(imagesx($image), imagesy($image));
2718 imagealphablending($overlay, false);
2719 imagesavealpha($overlay, true);
2720 $color = imagecolorallocate($overlay, $r, $g, $b);
2721 imagefilledrectangle($overlay, 0, 0, imagesx($overlay), imagesy($overlay), $color);
2722 imagecopymerge($image, $overlay, 0, 0, 0, 0, imagesx($image), imagesy($image), (int)round($strength * 100));
2723 imagedestroy($overlay);
2724 }
2725
2726 private function gd_save_image($image, string $file, string $mime, int $quality): void {
2727 $ok = false;
2728 if ($mime === 'image/webp' && function_exists('imagewebp')) {
2729 $ok = @imagewebp($image, $file, $quality);
2730 } elseif ($mime === 'image/png') {
2731 $compression = (int)round((100 - $quality) / 100 * 9);
2732 $ok = @imagepng($image, $file, max(0, min(9, $compression)));
2733 } elseif ($mime === 'image/jpeg') {
2734 $white = imagecreatetruecolor(imagesx($image), imagesy($image));
2735 $bg = imagecolorallocate($white, 255, 255, 255);
2736 imagefilledrectangle($white, 0, 0, imagesx($white), imagesy($white), $bg);
2737 imagecopy($white, $image, 0, 0, 0, 0, imagesx($image), imagesy($image));
2738 $ok = @imagejpeg($white, $file, $quality);
2739 imagedestroy($white);
2740 }
2741 if (!$ok) throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
2742 }
2743
2744 private function media_folder($value, string $type): string {
2745 $folder = trim(str_replace('\\', '/', (string)$value), '/');
2746 $folder = preg_replace('~[^A-Za-z0-9/_-]+~', '-', $folder);
2747 if ($type === 'video') {
2748 return 'img/video';
2749 }
2750 $root = $type === 'image' ? 'img' : 'file';
2751 if ($folder === '' || ($folder !== $root && strpos($folder, $root . '/') !== 0)) $folder = $type === 'image' ? 'img/images' : 'file/ki';
2752 return $folder;
2753 }
2754
2755 private function unique_name(string $dir, string $name): string {
2756 $base = pathinfo($name, PATHINFO_FILENAME);
2757 $ext = pathinfo($name, PATHINFO_EXTENSION);
2758 $candidate = $name;
2759 $i = 1;
2760 while (is_file(rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $candidate)) {
2761 $candidate = $base . '-' . $i++ . ($ext !== '' ? '.' . $ext : '');
2762 }
2763 return $candidate;
2764 }
2765
2766 private function invalidate_page(int $id, string $lng, array $row): void {
2767 dbxContentPageCache::invalidateContent($id);
2768 dbxContentPageCache::invalidateAllMenus();
2769 $previousLng = dbx()->get_system_var('dbx_lng', dbxContentLngSync::masterLng());
2770 dbx()->set_system_var('dbx_lng', $lng);
2771 $renderer = new dbxContentRenderer();
2772 $rights = $renderer->getPublicFolderRights((int)($row['folder'] ?? 0));
2773 if ((int)($row['activ'] ?? 1) === 1 && trim((string)($row['permalink'] ?? '')) !== '') {
2774 dbxContentPermalinkIndex::upsertPage($id, (string)$row['permalink'], $rights, 1, $lng);
2775 } else {
2776 dbxContentPermalinkIndex::removeByCid($id, $lng);
2777 }
2778 dbxContentHome::refreshHomeCache($this->db, $id, $lng);
2779 dbx()->set_system_var('dbx_lng', $previousLng);
2780 }
2781
2782 private function invalidate_folder(int $id): void {
2783 dbxContentPageCache::invalidateFolderTree($this->db, $id);
2784 dbxContentPageCache::invalidateAllMenus();
2785 }
2786
2787 private function invalidate_usage(array $usage): void {
2788 $content = (int)($usage['content_id'] ?? 0);
2789 $folder = (int)($usage['folder_id'] ?? 0);
2790 if ($content > 0) dbxContentPageCache::invalidateContent($content);
2791 if ($folder > 0) dbxContentPageCache::invalidateFolderTree($this->db, $folder);
2792 dbxContentPageCache::invalidateAllMenus();
2793 }
2794
2795 private function invalidate_media_references(int $mediaId): void {
2796 $mediaId = (int)$mediaId;
2797 if ($mediaId <= 0) {
2798 return;
2799 }
2800
2801 $rows = $this->db->select('dbxMediaUsage', 'media_id = ' . $mediaId . ' AND active = 1', '*', 'id', 'ASC', '', 0, 0, 0);
2802 foreach (is_array($rows) ? $rows : array() as $row) {
2803 if (is_array($row)) {
2804 $this->invalidate_usage($row);
2805 }
2806 }
2807 }
2808
2809 private function copy_media_usage(int $sourceId, int $targetId, int $targetFolder, string $sourceLng, string $targetLng): int {
2810 $rows = $this->db->select('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage("content_id = " . $sourceId . " AND active = 1 AND slot IN ('hero','gallery','inline','header','teaser','footer')", $sourceLng), '*', 'slot,sorter,id', 'ASC', '', 0, 0, 0);
2811 $count = 0;
2812 foreach (is_array($rows) ? $rows : array() as $row) {
2813 $data = $this->whitelist($row, array('media_id', 'slot', 'sorter', 'template', 'caption', 'settings'));
2814 $data['active'] = 1;
2815 $data['content_id'] = $targetId;
2816 $data['folder_id'] = $targetFolder;
2817 $data['content_lng'] = dbxContentMediaUsageScope::language($targetLng);
2818 if ($this->db->insert('dbxMediaUsage', $data) === 1) $count++;
2819 }
2820 return $count;
2821 }
2822
2823 private function inline_media_src(int $id): string {
2824 return 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . max(0, (int)$id);
2825 }
2826
2827 private function package_media_file_map(): array {
2828 return array(
2829 'dbxapp-paket-demo' => 'paket-demo-360x480.webp',
2830 'dbxapp-paket-non-profit' => 'paket-nonprofit-360x480.webp',
2831 'dbxapp-paket-business' => 'paket-business-360x480.webp',
2832 'dbxapp-paket-intranet' => 'paket-intranet-360x480.webp',
2833 'dbxapp-paket-enterprise' => 'paket-enterprise-360x480.webp',
2834 );
2835 }
2836
2837 private function package_media_id_for_permalink(string $permalink): int {
2838 $permalink = trim(strtolower($permalink));
2839 $map = $this->package_media_file_map();
2840 $fileName = (string)($map[$permalink] ?? '');
2841 if ($fileName === '') {
2842 return 0;
2843 }
2844 $where = "active = 1 AND file_name = '" . str_replace("'", "''", $fileName) . "'";
2845 $row = $this->db->select1('dbxMedia', $where);
2846 return is_array($row) ? (int)($row['id'] ?? 0) : 0;
2847 }
2848
2849 private function package_page_hint(array $page): ?array {
2850 $permalink = trim((string)($page['permalink'] ?? ''));
2851 $mediaId = $this->package_media_id_for_permalink($permalink);
2852 if ($mediaId <= 0) {
2853 return null;
2854 }
2855 return array(
2856 'permalink' => $permalink,
2857 'media_id' => $mediaId,
2858 'file_name' => (string)($this->package_media_file_map()[strtolower($permalink)] ?? ''),
2859 'inline_src' => $this->inline_media_src($mediaId),
2860 'update_patch' => array('package_product_image' => true),
2861 );
2862 }
2863
2864 private function apply_package_product_image(string $content, int $mediaId, string $alt = ''): string {
2865 if ($mediaId <= 0 || stripos($content, 'col-md-4') === false || stripos($content, 'card') === false) {
2866 return $content;
2867 }
2868 $srcEsc = htmlspecialchars($this->inline_media_src($mediaId), ENT_QUOTES, 'UTF-8');
2869 $altEsc = htmlspecialchars($alt !== '' ? $alt : 'Paket', ENT_QUOTES, 'UTF-8');
2870 $img = '<img class="card-img-top" src="' . $srcEsc . '" data-cms-media-id="' . $mediaId . '" alt="' . $altEsc . '">';
2871
2872 $updated = preg_replace_callback(
2873 '/<div class="col-md-4"><div class="card shadow-sm(?:\s+position-relative)?">(?:<img[^>]*card-img-top[^>]*>)?(?:<span class="position-absolute[^>]*>[\s\S]*?<\/span>)?<div class="card-body text-center">([\s\S]*?)<\/div><\/div><\/div>/i',
2874 function($m) use ($img) {
2875 $body = (string)($m[1] ?? '');
2876 $badge = '';
2877 if (preg_match('/<span class="badge[^>]*bg-success[^>]*>([\s\S]*?)<\/span>/i', $body, $badgeMatch)) {
2878 $label = trim(strip_tags((string)($badgeMatch[1] ?? 'Kostenlos')));
2879 if ($label !== '') {
2880 $badge = '<span class="position-absolute top-0 end-0 badge rounded-pill bg-success m-2">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
2881 }
2882 }
2883 $body = preg_replace('/<span class="badge[^>]*>[\s\S]*?<\/span>\s*(?:<br\s*\/?>)?\s*/i', '', $body, 1);
2884 $body = preg_replace('/<img\b[^>]*>\s*/i', '', $body, 1);
2885 $body = preg_replace('/\bh5 mt-3\b/', 'h5', $body, 1);
2886 return '<div class="col-md-4"><div class="card shadow-sm position-relative">' . $img . $badge . '<div class="card-body text-center">' . $body . '</div></div></div>';
2887 },
2888 $content,
2889 1
2890 );
2891
2892 return is_string($updated) && $updated !== '' ? $updated : $content;
2893 }
2894
2895 private function ensure_inline_media_usage(int $contentId, int $mediaId, string $lng = ''): void {
2896 $contentId = (int)$contentId;
2897 $mediaId = (int)$mediaId;
2898 if ($contentId <= 0 || $mediaId <= 0) {
2899 return;
2900 }
2901 $lng = dbxContentMediaUsageScope::language($lng);
2902 $where = dbxContentMediaUsageScope::withLanguage('content_id = ' . $contentId . ' AND media_id = ' . $mediaId . " AND slot = 'inline' AND active = 1", $lng);
2903 if (is_array($this->db->select1('dbxMediaUsage', $where))) {
2904 return;
2905 }
2906 $data = array(
2907 'active' => 1,
2908 'media_id' => $mediaId,
2909 'content_id' => $contentId,
2910 'folder_id' => 0,
2911 'content_lng' => $lng,
2912 'slot' => 'inline',
2913 'template' => '',
2914 'caption' => '',
2915 'settings' => '',
2916 'sorter' => $this->next_usage_sorter($contentId, 0, 'inline', $lng),
2917 );
2918 $this->db->insert('dbxMediaUsage', $data);
2919 }
2920
2921 private function media_inline_payload(int $id): array {
2922 $id = max(0, (int)$id);
2923 if ($id <= 0) {
2924 return array();
2925 }
2926 $src = $this->inline_media_src($id);
2927 return array(
2928 'inline_src' => $src,
2929 'inline_img' => '<img src="' . htmlspecialchars($src, ENT_QUOTES, 'UTF-8') . '" data-cms-media-id="' . $id . '" alt="">',
2930 );
2931 }
2932
2933 private function normalize_content_inline_media_urls(string $html): string {
2934 $html = (string)$html;
2935 if ($html === '' || stripos($html, '<img') === false) {
2936 return $html;
2937 }
2938
2939 return preg_replace_callback('/<img\b([^>]*?)>/i', function($m) {
2940 $tag = (string)($m[0] ?? '');
2941 $attrs = (string)($m[1] ?? '');
2942 $id = 0;
2943 if (preg_match('/\bdata-cms-media-id=["\']?([0-9]+)/i', $attrs, $id_match)) {
2944 $id = (int)$id_match[1];
2945 } elseif (preg_match('/\bdbx_mid=([0-9]+)/i', $attrs, $id_match)) {
2946 $id = (int)$id_match[1];
2947 } elseif (preg_match('/\bsrc=(["\'])([^"\']+)\1/i', $attrs, $src_match)) {
2948 $id = $this->media_id_by_inline_src((string)$src_match[2]);
2949 }
2950 if ($id <= 0) {
2951 return $tag;
2952 }
2953 return $this->patch_img_tag_for_inline_media($tag, $id);
2954 }, $html);
2955 }
2956
2957 private function media_id_by_inline_src(string $src): int {
2958 $src = html_entity_decode(trim($src), ENT_QUOTES, 'UTF-8');
2959 if ($src === '' || preg_match('#^(?:https?:)?//#i', $src) || stripos($src, 'dbx_mid=') !== false) {
2960 return 0;
2961 }
2962
2963 $path = preg_replace('/[?#].*$/', '', str_replace('\\', '/', $src));
2964 $rel = '';
2965 if (preg_match('#(?:^|/)(?:files/)?media/(.+)$#i', $path, $match)) {
2966 $rel = 'media/' . ltrim((string)$match[1], '/');
2967 } else {
2968 return 0;
2969 }
2970
2971 static $cache = array();
2972 if (isset($cache[$rel])) {
2973 return (int)$cache[$rel];
2974 }
2975
2976 $where = "active = 1 AND file_path = '" . str_replace("'", "''", $rel) . "'";
2977 $row = $this->db->select1('dbxMedia', $where);
2978 if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
2979 return $cache[$rel] = (int)$row['id'];
2980 }
2981
2982 $base = basename($rel);
2983 if ($base === '' || $base === '.' || $base === '..') {
2984 return $cache[$rel] = 0;
2985 }
2986 $rows = $this->db->select(
2987 'dbxMedia',
2988 "active = 1 AND file_name = '" . str_replace("'", "''", $base) . "'",
2989 'id,file_path',
2990 'id',
2991 'DESC',
2992 '',
2993 5,
2994 0,
2995 0
2996 );
2997 if (is_array($rows)) {
2998 foreach ($rows as $candidate) {
2999 $candidatePath = ltrim(str_replace('\\', '/', (string)($candidate['file_path'] ?? '')), '/');
3000 if ($candidatePath === $rel || basename($candidatePath) === $base) {
3001 return $cache[$rel] = (int)($candidate['id'] ?? 0);
3002 }
3003 }
3004 }
3005
3006 return $cache[$rel] = 0;
3007 }
3008
3009 private function patch_img_tag_for_inline_media(string $tag, int $id): string {
3010 $id = max(0, (int)$id);
3011 if ($id <= 0) {
3012 return $tag;
3013 }
3014 $src = $this->inline_media_src($id);
3015 $src_attr = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
3016 $tag = preg_replace('/\s*data-cms-media-id\s*=\s*["\']?[^"\'>\s]*["\']*/i', '', $tag);
3017 if (preg_match('/\bsrc=(["\'])([^"\']*)\1/i', $tag)) {
3018 $tag = preg_replace('/\bsrc=(["\'])([^"\']*)\1/i', 'src="' . $src_attr . '"', $tag, 1);
3019 } else {
3020 $tag = preg_replace('/^<img\b/i', '<img src="' . $src_attr . '"', $tag);
3021 }
3022 $tag = preg_replace('/^<img\b/i', '<img data-cms-media-id="' . $id . '"', $tag);
3023 return $tag;
3024 }
3025
3026 public function bundleActionCatalog(): array {
3027 return $this->catalog();
3028 }
3029
3030 public function bundleIsAllowedInPackage(string $action): bool {
3031 $catalog = $this->catalog();
3032 if (!isset($catalog[$action]) || !($catalog[$action]['write'] ?? false)) {
3033 return false;
3034 }
3035 if (!empty($catalog[$action]['destructive'])) {
3036 return false;
3037 }
3038 return true;
3039 }
3040
3041 public function bundleBuildPlan(string $action, array $params): array {
3042 if (!$this->bundleIsAllowedInPackage($action)) {
3043 throw new \InvalidArgumentException('Aktion im Bundle nicht erlaubt: ' . $action);
3044 }
3045 return $this->build_plan($action, $params);
3046 }
3047
3048 public function bundleExecutePlan(string $action, array $params, array $plan): array {
3049 if (!$this->bundleIsAllowedInPackage($action)) {
3050 throw new \InvalidArgumentException('Aktion im Bundle nicht erlaubt: ' . $action);
3051 }
3052 return $this->execute_action($action, $params, $plan);
3053 }
3054
3055 public function bundleExecuteToken(): string {
3056 return dbx()->action_token(self::TOKEN_SCOPE);
3057 }
3058
3059 public function bundleCheckExecuteToken(string $token): bool {
3060 return dbx()->check_action_token(self::TOKEN_SCOPE, $token);
3061 }
3062
3063 public function bundleSnapshot(array $params = array()): array {
3064 return $this->snapshot($params);
3065 }
3066
3067 public function bundleSystemDescribe(): array {
3068 return $this->describe();
3069 }
3070}
if(!is_array( $before)||(int)( $before[ 'id'] ?? 0) !==$mediaId) if((string)($before['media_type'] ?? '') !=='video') $updated
bundleBuildPlan(string $action, array $params)
bundleExecutePlan(string $action, array $params, array $plan)
bundleSnapshot(array $params=array())
user($key='id')
Liest einen Wert des aktuellen Benutzers.
Definition dbxApi.php:1305
dbx()
Liefert die zentrale dbXapp-API als Singleton.
Definition dbxApi.php:2805
get_base_dir(int $cutData=0)
Liefert das Basisverzeichnis der Installation.
Definition dbxApi.php:1507
get_file_dir()
Liefert das files/-Verzeichnis der Installation.
Definition dbxApi.php:1524
action_token(string $scope='global')
Liefert ein sessiongebundenes Token fuer zustandsaendernde Link-Aktionen.
Definition dbxApi.php:1189
json_response(array $data, bool $withRuntime=false)
Beendet den Request mit JSON-Ausgabe.
Definition dbxApi.php:1647
check_action_token(string $scope='global', string $token='')
Prueft ein sessiongebundenes Aktions-Token.
Definition dbxApi.php:1206
if(!is_object( $db)) if($db->connect_db_server($server) !==1) $created
foreach( $iterator as $file) if(! $groups) $expected
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
if(PHP_SAPI !=='cli') $write
DBX schema administration.
if($cinematic===''||!str_contains($cinematic, 'data-dbx-cinema')) $page
for( $step=0;$step< 1000;$step++) if(($schema['status'] ?? '') !=='finished') $media