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